1use std::any::Any;
2use std::collections::HashMap;
3use std::fmt::Debug;
4use std::sync::{Arc, Mutex, OnceLock};
5
6use anyhow::Result;
7use async_trait::async_trait;
8use nanoid::nanoid;
9use serde_json::json;
10use tokio::sync::RwLock;
11
12use super::terraform::{TERRAFORM_ALPHABET, TerraformOutput, TerraformProvider};
13use super::{ClientStrategy, Host, HostTargetType, LaunchedHost, ResourceBatch, ResourceResult};
14use crate::ssh::LaunchedSshHost;
15use crate::{BaseServerStrategy, HostStrategyGetter, PortNetworkHint};
16
17pub struct LaunchedEc2Instance {
18 resource_result: Arc<ResourceResult>,
19 user: String,
20 pub internal_ip: String,
21 pub external_ip: Option<String>,
22}
23
24impl LaunchedSshHost for LaunchedEc2Instance {
25 fn get_external_ip(&self) -> Option<String> {
26 self.external_ip.clone()
27 }
28
29 fn get_internal_ip(&self) -> String {
30 self.internal_ip.clone()
31 }
32
33 fn get_cloud_provider(&self) -> String {
34 "AWS".to_string()
35 }
36
37 fn resource_result(&self) -> &Arc<ResourceResult> {
38 &self.resource_result
39 }
40
41 fn ssh_user(&self) -> &str {
42 self.user.as_str()
43 }
44}
45
46#[derive(Debug)]
47pub struct AwsNetwork {
48 pub region: String,
49 pub existing_vpc: Option<String>,
50 id: String,
51}
52
53impl AwsNetwork {
54 pub fn new(region: impl Into<String>, existing_vpc: Option<String>) -> Self {
55 Self {
56 region: region.into(),
57 existing_vpc,
58 id: nanoid!(8, &TERRAFORM_ALPHABET),
59 }
60 }
61
62 fn collect_resources(&mut self, resource_batch: &mut ResourceBatch) -> String {
63 resource_batch
64 .terraform
65 .terraform
66 .required_providers
67 .insert(
68 "aws".to_string(),
69 TerraformProvider {
70 source: "hashicorp/aws".to_string(),
71 version: "5.0.0".to_string(),
72 },
73 );
74
75 resource_batch.terraform.provider.insert(
76 "aws".to_string(),
77 json!({
78 "region": self.region
79 }),
80 );
81
82 let vpc_network = format!("hydro-vpc-network-{}", self.id);
83
84 if let Some(existing) = self.existing_vpc.as_ref() {
85 if resource_batch
86 .terraform
87 .resource
88 .get("aws_vpc")
89 .unwrap_or(&HashMap::new())
90 .contains_key(existing)
91 {
92 format!("aws_vpc.{existing}")
93 } else {
94 resource_batch
95 .terraform
96 .data
97 .entry("aws_vpc".to_string())
98 .or_default()
99 .insert(
100 vpc_network.clone(),
101 json!({
102 "id": existing,
103 }),
104 );
105
106 format!("data.aws_vpc.{vpc_network}")
107 }
108 } else {
109 resource_batch
110 .terraform
111 .resource
112 .entry("aws_vpc".to_string())
113 .or_default()
114 .insert(
115 vpc_network.clone(),
116 json!({
117 "cidr_block": "10.0.0.0/16",
118 "enable_dns_hostnames": true,
119 "enable_dns_support": true,
120 "tags": {
121 "Name": vpc_network
122 }
123 }),
124 );
125
126 let igw_key = format!("{vpc_network}-igw");
128 resource_batch
129 .terraform
130 .resource
131 .entry("aws_internet_gateway".to_string())
132 .or_default()
133 .insert(
134 igw_key.clone(),
135 json!({
136 "vpc_id": format!("${{aws_vpc.{}.id}}", vpc_network),
137 "tags": {
138 "Name": igw_key
139 }
140 }),
141 );
142
143 let subnet_key = format!("{vpc_network}-subnet");
145 resource_batch
146 .terraform
147 .resource
148 .entry("aws_subnet".to_string())
149 .or_default()
150 .insert(
151 subnet_key.clone(),
152 json!({
153 "vpc_id": format!("${{aws_vpc.{}.id}}", vpc_network),
154 "cidr_block": "10.0.1.0/24",
155 "availability_zone": format!("{}a", self.region),
156 "map_public_ip_on_launch": true,
157 "tags": {
158 "Name": subnet_key
159 }
160 }),
161 );
162
163 let rt_key = format!("{vpc_network}-rt");
165 resource_batch
166 .terraform
167 .resource
168 .entry("aws_route_table".to_string())
169 .or_default()
170 .insert(
171 rt_key.clone(),
172 json!({
173 "vpc_id": format!("${{aws_vpc.{}.id}}", vpc_network),
174 "tags": {
175 "Name": rt_key
176 }
177 }),
178 );
179
180 resource_batch
182 .terraform
183 .resource
184 .entry("aws_route".to_string())
185 .or_default()
186 .insert(
187 format!("{vpc_network}-route"),
188 json!({
189 "route_table_id": format!("${{aws_route_table.{}.id}}", rt_key),
190 "destination_cidr_block": "0.0.0.0/0",
191 "gateway_id": format!("${{aws_internet_gateway.{}.id}}", igw_key)
192 }),
193 );
194
195 resource_batch
196 .terraform
197 .resource
198 .entry("aws_route_table_association".to_string())
199 .or_default()
200 .insert(
201 format!("{vpc_network}-rta"),
202 json!({
203 "subnet_id": format!("${{aws_subnet.{}.id}}", subnet_key),
204 "route_table_id": format!("${{aws_route_table.{}.id}}", rt_key)
205 }),
206 );
207
208 let sg_key = format!("{vpc_network}-default-sg");
210 resource_batch
211 .terraform
212 .resource
213 .entry("aws_security_group".to_string())
214 .or_default()
215 .insert(
216 sg_key.clone(),
217 json!({
218 "name": format!("{vpc_network}-default-allow-internal"),
219 "description": "Allow internal communication between instances",
220 "vpc_id": format!("${{aws_vpc.{}.id}}", vpc_network),
221 "ingress": [
222 {
223 "from_port": 0,
224 "to_port": 65535,
225 "protocol": "tcp",
226 "cidr_blocks": ["10.0.0.0/16"],
227 "description": "Allow all TCP traffic within VPC",
228 "ipv6_cidr_blocks": [],
229 "prefix_list_ids": [],
230 "security_groups": [],
231 "self": false
232 },
233 {
234 "from_port": 0,
235 "to_port": 65535,
236 "protocol": "udp",
237 "cidr_blocks": ["10.0.0.0/16"],
238 "description": "Allow all UDP traffic within VPC",
239 "ipv6_cidr_blocks": [],
240 "prefix_list_ids": [],
241 "security_groups": [],
242 "self": false
243 },
244 {
245 "from_port": -1,
246 "to_port": -1,
247 "protocol": "icmp",
248 "cidr_blocks": ["10.0.0.0/16"],
249 "description": "Allow ICMP within VPC",
250 "ipv6_cidr_blocks": [],
251 "prefix_list_ids": [],
252 "security_groups": [],
253 "self": false
254 }
255 ],
256 "egress": [
257 {
258 "from_port": 0,
259 "to_port": 0,
260 "protocol": "-1",
261 "cidr_blocks": ["0.0.0.0/0"],
262 "description": "Allow all outbound traffic",
263 "ipv6_cidr_blocks": [],
264 "prefix_list_ids": [],
265 "security_groups": [],
266 "self": false
267 }
268 ]
269 }),
270 );
271
272 self.existing_vpc = Some(vpc_network.clone());
273
274 format!("aws_vpc.{vpc_network}")
275 }
276 }
277}
278
279pub struct AwsEc2Host {
280 id: usize,
282
283 region: String,
284 instance_type: String,
285 ami: String,
286 network: Arc<RwLock<AwsNetwork>>,
287 user: Option<String>,
288 display_name: Option<String>,
289 pub launched: OnceLock<Arc<LaunchedEc2Instance>>,
290 external_ports: Mutex<Vec<u16>>,
291}
292
293impl Debug for AwsEc2Host {
294 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
295 f.write_fmt(format_args!(
296 "AwsEc2Host({} ({:?}))",
297 self.id, &self.display_name
298 ))
299 }
300}
301
302impl AwsEc2Host {
303 pub fn new(
304 id: usize,
305 region: impl Into<String>,
306 instance_type: impl Into<String>,
307 ami: impl Into<String>,
308 network: Arc<RwLock<AwsNetwork>>,
309 user: Option<String>,
310 display_name: Option<String>,
311 ) -> Self {
312 Self {
313 id,
314 region: region.into(),
315 instance_type: instance_type.into(),
316 ami: ami.into(),
317 network,
318 user,
319 display_name,
320 launched: OnceLock::new(),
321 external_ports: Mutex::new(Vec::new()),
322 }
323 }
324}
325
326#[async_trait]
327impl Host for AwsEc2Host {
328 fn target_type(&self) -> HostTargetType {
329 HostTargetType::Linux
330 }
331
332 fn request_port_base(&self, bind_type: &BaseServerStrategy) {
333 match bind_type {
334 BaseServerStrategy::UnixSocket => {}
335 BaseServerStrategy::InternalTcpPort(_) => {}
336 BaseServerStrategy::ExternalTcpPort(port) => {
337 let mut external_ports = self.external_ports.lock().unwrap();
338 if !external_ports.contains(port) {
339 if self.launched.get().is_some() {
340 todo!("Cannot adjust security group after host has been launched");
341 }
342 external_ports.push(*port);
343 }
344 }
345 }
346 }
347
348 fn request_custom_binary(&self) {
349 self.request_port_base(&BaseServerStrategy::ExternalTcpPort(22));
350 }
351
352 fn id(&self) -> usize {
353 self.id
354 }
355
356 fn collect_resources(&self, resource_batch: &mut ResourceBatch) {
357 if self.launched.get().is_some() {
358 return;
359 }
360
361 let vpc_path = self
362 .network
363 .try_write()
364 .unwrap()
365 .collect_resources(resource_batch);
366
367 resource_batch
369 .terraform
370 .terraform
371 .required_providers
372 .insert(
373 "local".to_string(),
374 TerraformProvider {
375 source: "hashicorp/local".to_string(),
376 version: "2.3.0".to_string(),
377 },
378 );
379
380 resource_batch
381 .terraform
382 .terraform
383 .required_providers
384 .insert(
385 "tls".to_string(),
386 TerraformProvider {
387 source: "hashicorp/tls".to_string(),
388 version: "4.0.4".to_string(),
389 },
390 );
391
392 resource_batch
394 .terraform
395 .resource
396 .entry("tls_private_key".to_string())
397 .or_default()
398 .insert(
399 "vm_instance_ssh_key".to_string(),
400 json!({
401 "algorithm": "RSA",
402 "rsa_bits": 4096
403 }),
404 );
405
406 resource_batch
407 .terraform
408 .resource
409 .entry("local_file".to_string())
410 .or_default()
411 .insert(
412 "vm_instance_ssh_key_pem".to_string(),
413 json!({
414 "content": "${tls_private_key.vm_instance_ssh_key.private_key_pem}",
415 "filename": ".ssh/vm_instance_ssh_key_pem",
416 "file_permission": "0600",
417 "directory_permission": "0700"
418 }),
419 );
420
421 resource_batch
422 .terraform
423 .resource
424 .entry("aws_key_pair".to_string())
425 .or_default()
426 .insert(
427 "ec2_key_pair".to_string(),
428 json!({
429 "key_name": format!("hydro-key-{}", nanoid!(8, &TERRAFORM_ALPHABET)),
430 "public_key": "${tls_private_key.vm_instance_ssh_key.public_key_openssh}"
431 }),
432 );
433
434 let instance_key = format!("ec2-instance-{}", self.id);
435 let mut instance_name = format!("hydro-ec2-instance-{}", nanoid!(8, &TERRAFORM_ALPHABET));
436
437 if let Some(mut display_name) = self.display_name.clone() {
438 instance_name.push('-');
439 display_name = display_name.replace("_", "-").to_lowercase();
440
441 let num_chars_to_cut = instance_name.len() + display_name.len() - 63;
442 if num_chars_to_cut > 0 {
443 display_name.drain(0..num_chars_to_cut);
444 }
445 instance_name.push_str(&display_name);
446 }
447
448 let network_id = self.network.try_read().unwrap().id.clone();
449 let vpc_ref = format!("${{{}.id}}", vpc_path);
450 let subnet_ref = format!("${{aws_subnet.hydro-vpc-network-{}-subnet.id}}", network_id);
451 let default_sg_ref = format!(
452 "${{aws_security_group.hydro-vpc-network-{}-default-sg.id}}",
453 network_id
454 );
455
456 let mut security_groups = vec![default_sg_ref.clone()];
458 let external_ports = self.external_ports.lock().unwrap();
459
460 if !external_ports.is_empty() {
461 let sg_key = format!("sg-{}", self.id);
462 let mut sg_rules = vec![];
463
464 for port in external_ports.iter() {
465 sg_rules.push(json!({
466 "from_port": port,
467 "to_port": port,
468 "protocol": "tcp",
469 "cidr_blocks": ["0.0.0.0/0"],
470 "description": format!("External port {}", port),
471 "ipv6_cidr_blocks": [],
472 "prefix_list_ids": [],
473 "security_groups": [],
474 "self": false
475 }));
476 }
477
478 resource_batch
479 .terraform
480 .resource
481 .entry("aws_security_group".to_string())
482 .or_default()
483 .insert(
484 sg_key.clone(),
485 json!({
486 "name": format!("hydro-sg-{}", nanoid!(8, &TERRAFORM_ALPHABET)),
487 "description": "Hydro external ports security group",
488 "vpc_id": vpc_ref,
489 "ingress": sg_rules,
490 "egress": [{
491 "from_port": 0,
492 "to_port": 0,
493 "protocol": "-1",
494 "cidr_blocks": ["0.0.0.0/0"],
495 "description": "All outbound traffic",
496 "ipv6_cidr_blocks": [],
497 "prefix_list_ids": [],
498 "security_groups": [],
499 "self": false
500 }]
501 }),
502 );
503
504 security_groups.push(format!("${{aws_security_group.{}.id}}", sg_key));
505 }
506 drop(external_ports);
507
508 resource_batch
510 .terraform
511 .resource
512 .entry("aws_instance".to_string())
513 .or_default()
514 .insert(
515 instance_key.clone(),
516 json!({
517 "ami": self.ami,
518 "instance_type": self.instance_type,
519 "key_name": "${aws_key_pair.ec2_key_pair.key_name}",
520 "vpc_security_group_ids": security_groups,
521 "subnet_id": subnet_ref,
522 "associate_public_ip_address": true,
523 "tags": {
524 "Name": instance_name
525 }
526 }),
527 );
528
529 resource_batch.terraform.output.insert(
530 format!("{}-private-ip", instance_key),
531 TerraformOutput {
532 value: format!("${{aws_instance.{}.private_ip}}", instance_key),
533 },
534 );
535
536 resource_batch.terraform.output.insert(
537 format!("{}-public-ip", instance_key),
538 TerraformOutput {
539 value: format!("${{aws_instance.{}.public_ip}}", instance_key),
540 },
541 );
542 }
543
544 fn launched(&self) -> Option<Arc<dyn LaunchedHost>> {
545 self.launched
546 .get()
547 .map(|a| a.clone() as Arc<dyn LaunchedHost>)
548 }
549
550 fn provision(&self, resource_result: &Arc<ResourceResult>) -> Arc<dyn LaunchedHost> {
551 self.launched
552 .get_or_init(|| {
553 let id = self.id;
554
555 let internal_ip = resource_result
556 .terraform
557 .outputs
558 .get(&format!("ec2-instance-{id}-private-ip"))
559 .unwrap()
560 .value
561 .clone();
562
563 let external_ip = resource_result
564 .terraform
565 .outputs
566 .get(&format!("ec2-instance-{id}-public-ip"))
567 .map(|v| v.value.clone());
568
569 Arc::new(LaunchedEc2Instance {
570 resource_result: resource_result.clone(),
571 user: self
572 .user
573 .as_ref()
574 .cloned()
575 .unwrap_or("ec2-user".to_string()),
576 internal_ip,
577 external_ip,
578 })
579 })
580 .clone()
581 }
582
583 fn strategy_as_server<'a>(
584 &'a self,
585 client_host: &dyn Host,
586 network_hint: PortNetworkHint,
587 ) -> Result<(ClientStrategy<'a>, HostStrategyGetter)> {
588 if matches!(network_hint, PortNetworkHint::Auto)
589 && client_host.can_connect_to(ClientStrategy::UnixSocket(self.id))
590 {
591 Ok((
592 ClientStrategy::UnixSocket(self.id),
593 Box::new(|_| BaseServerStrategy::UnixSocket),
594 ))
595 } else if matches!(
596 network_hint,
597 PortNetworkHint::Auto | PortNetworkHint::TcpPort(_)
598 ) && client_host.can_connect_to(ClientStrategy::InternalTcpPort(self))
599 {
600 Ok((
601 ClientStrategy::InternalTcpPort(self),
602 Box::new(move |_| {
603 BaseServerStrategy::InternalTcpPort(match network_hint {
604 PortNetworkHint::Auto => None,
605 PortNetworkHint::TcpPort(port) => port,
606 })
607 }),
608 ))
609 } else if matches!(network_hint, PortNetworkHint::Auto)
610 && client_host.can_connect_to(ClientStrategy::ForwardedTcpPort(self))
611 {
612 Ok((
613 ClientStrategy::ForwardedTcpPort(self),
614 Box::new(|me| {
615 me.downcast_ref::<AwsEc2Host>()
616 .unwrap()
617 .request_port_base(&BaseServerStrategy::ExternalTcpPort(22));
618 BaseServerStrategy::InternalTcpPort(None)
619 }),
620 ))
621 } else {
622 anyhow::bail!("Could not find a strategy to connect to AWS EC2 instance")
623 }
624 }
625
626 fn can_connect_to(&self, typ: ClientStrategy) -> bool {
627 match typ {
628 ClientStrategy::UnixSocket(id) => {
629 #[cfg(unix)]
630 {
631 self.id == id
632 }
633
634 #[cfg(not(unix))]
635 {
636 let _ = id;
637 false
638 }
639 }
640 ClientStrategy::InternalTcpPort(target_host) => {
641 if let Some(aws_target) = <dyn Any>::downcast_ref::<AwsEc2Host>(target_host) {
642 self.region == aws_target.region
643 && Arc::ptr_eq(&self.network, &aws_target.network)
644 } else {
645 false
646 }
647 }
648 ClientStrategy::ForwardedTcpPort(_) => false,
649 }
650 }
651}