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 target_type: HostTargetType,
286 ami: String,
287 network: Arc<RwLock<AwsNetwork>>,
288 user: Option<String>,
289 display_name: Option<String>,
290 pub launched: OnceLock<Arc<LaunchedEc2Instance>>,
291 external_ports: Mutex<Vec<u16>>,
292}
293
294impl Debug for AwsEc2Host {
295 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
296 f.write_fmt(format_args!(
297 "AwsEc2Host({} ({:?}))",
298 self.id, &self.display_name
299 ))
300 }
301}
302
303impl AwsEc2Host {
304 #[expect(clippy::too_many_arguments, reason = "used via builder pattern")]
305 pub fn new(
306 id: usize,
307 region: impl Into<String>,
308 instance_type: impl Into<String>,
309 target_type: HostTargetType,
310 ami: impl Into<String>,
311 network: Arc<RwLock<AwsNetwork>>,
312 user: Option<String>,
313 display_name: Option<String>,
314 ) -> Self {
315 Self {
316 id,
317 region: region.into(),
318 instance_type: instance_type.into(),
319 target_type,
320 ami: ami.into(),
321 network,
322 user,
323 display_name,
324 launched: OnceLock::new(),
325 external_ports: Mutex::new(Vec::new()),
326 }
327 }
328}
329
330#[async_trait]
331impl Host for AwsEc2Host {
332 fn target_type(&self) -> HostTargetType {
333 self.target_type
334 }
335
336 fn request_port_base(&self, bind_type: &BaseServerStrategy) {
337 match bind_type {
338 BaseServerStrategy::UnixSocket => {}
339 BaseServerStrategy::InternalTcpPort(_) => {}
340 BaseServerStrategy::ExternalTcpPort(port) => {
341 let mut external_ports = self.external_ports.lock().unwrap();
342 if !external_ports.contains(port) {
343 if self.launched.get().is_some() {
344 todo!("Cannot adjust security group after host has been launched");
345 }
346 external_ports.push(*port);
347 }
348 }
349 }
350 }
351
352 fn request_custom_binary(&self) {
353 self.request_port_base(&BaseServerStrategy::ExternalTcpPort(22));
354 }
355
356 fn id(&self) -> usize {
357 self.id
358 }
359
360 fn collect_resources(&self, resource_batch: &mut ResourceBatch) {
361 if self.launched.get().is_some() {
362 return;
363 }
364
365 let vpc_path = self
366 .network
367 .try_write()
368 .unwrap()
369 .collect_resources(resource_batch);
370
371 resource_batch
373 .terraform
374 .terraform
375 .required_providers
376 .insert(
377 "local".to_string(),
378 TerraformProvider {
379 source: "hashicorp/local".to_string(),
380 version: "2.3.0".to_string(),
381 },
382 );
383
384 resource_batch
385 .terraform
386 .terraform
387 .required_providers
388 .insert(
389 "tls".to_string(),
390 TerraformProvider {
391 source: "hashicorp/tls".to_string(),
392 version: "4.0.4".to_string(),
393 },
394 );
395
396 resource_batch
398 .terraform
399 .resource
400 .entry("tls_private_key".to_string())
401 .or_default()
402 .insert(
403 "vm_instance_ssh_key".to_string(),
404 json!({
405 "algorithm": "RSA",
406 "rsa_bits": 4096
407 }),
408 );
409
410 resource_batch
411 .terraform
412 .resource
413 .entry("local_file".to_string())
414 .or_default()
415 .insert(
416 "vm_instance_ssh_key_pem".to_string(),
417 json!({
418 "content": "${tls_private_key.vm_instance_ssh_key.private_key_pem}",
419 "filename": ".ssh/vm_instance_ssh_key_pem",
420 "file_permission": "0600",
421 "directory_permission": "0700"
422 }),
423 );
424
425 resource_batch
426 .terraform
427 .resource
428 .entry("aws_key_pair".to_string())
429 .or_default()
430 .insert(
431 "ec2_key_pair".to_string(),
432 json!({
433 "key_name": format!("hydro-key-{}", nanoid!(8, &TERRAFORM_ALPHABET)),
434 "public_key": "${tls_private_key.vm_instance_ssh_key.public_key_openssh}"
435 }),
436 );
437
438 let instance_key = format!("ec2-instance-{}", self.id);
439 let mut instance_name = format!("hydro-ec2-instance-{}", nanoid!(8, &TERRAFORM_ALPHABET));
440
441 if let Some(mut display_name) = self.display_name.clone() {
442 instance_name.push('-');
443 display_name = display_name.replace("_", "-").to_lowercase();
444
445 let num_chars_to_cut = instance_name.len() + display_name.len() - 63;
446 if num_chars_to_cut > 0 {
447 display_name.drain(0..num_chars_to_cut);
448 }
449 instance_name.push_str(&display_name);
450 }
451
452 let network_id = self.network.try_read().unwrap().id.clone();
453 let vpc_ref = format!("${{{}.id}}", vpc_path);
454 let subnet_ref = format!("${{aws_subnet.hydro-vpc-network-{}-subnet.id}}", network_id);
455 let default_sg_ref = format!(
456 "${{aws_security_group.hydro-vpc-network-{}-default-sg.id}}",
457 network_id
458 );
459
460 let mut security_groups = vec![default_sg_ref.clone()];
462 let external_ports = self.external_ports.lock().unwrap();
463
464 if !external_ports.is_empty() {
465 let sg_key = format!("sg-{}", self.id);
466 let mut sg_rules = vec![];
467
468 for port in external_ports.iter() {
469 sg_rules.push(json!({
470 "from_port": port,
471 "to_port": port,
472 "protocol": "tcp",
473 "cidr_blocks": ["0.0.0.0/0"],
474 "description": format!("External port {}", port),
475 "ipv6_cidr_blocks": [],
476 "prefix_list_ids": [],
477 "security_groups": [],
478 "self": false
479 }));
480 }
481
482 resource_batch
483 .terraform
484 .resource
485 .entry("aws_security_group".to_string())
486 .or_default()
487 .insert(
488 sg_key.clone(),
489 json!({
490 "name": format!("hydro-sg-{}", nanoid!(8, &TERRAFORM_ALPHABET)),
491 "description": "Hydro external ports security group",
492 "vpc_id": vpc_ref,
493 "ingress": sg_rules,
494 "egress": [{
495 "from_port": 0,
496 "to_port": 0,
497 "protocol": "-1",
498 "cidr_blocks": ["0.0.0.0/0"],
499 "description": "All outbound traffic",
500 "ipv6_cidr_blocks": [],
501 "prefix_list_ids": [],
502 "security_groups": [],
503 "self": false
504 }]
505 }),
506 );
507
508 security_groups.push(format!("${{aws_security_group.{}.id}}", sg_key));
509 }
510 drop(external_ports);
511
512 resource_batch
514 .terraform
515 .resource
516 .entry("aws_instance".to_string())
517 .or_default()
518 .insert(
519 instance_key.clone(),
520 json!({
521 "ami": self.ami,
522 "instance_type": self.instance_type,
523 "key_name": "${aws_key_pair.ec2_key_pair.key_name}",
524 "vpc_security_group_ids": security_groups,
525 "subnet_id": subnet_ref,
526 "associate_public_ip_address": true,
527 "tags": {
528 "Name": instance_name
529 }
530 }),
531 );
532
533 resource_batch.terraform.output.insert(
534 format!("{}-private-ip", instance_key),
535 TerraformOutput {
536 value: format!("${{aws_instance.{}.private_ip}}", instance_key),
537 },
538 );
539
540 resource_batch.terraform.output.insert(
541 format!("{}-public-ip", instance_key),
542 TerraformOutput {
543 value: format!("${{aws_instance.{}.public_ip}}", instance_key),
544 },
545 );
546 }
547
548 fn launched(&self) -> Option<Arc<dyn LaunchedHost>> {
549 self.launched
550 .get()
551 .map(|a| a.clone() as Arc<dyn LaunchedHost>)
552 }
553
554 fn provision(&self, resource_result: &Arc<ResourceResult>) -> Arc<dyn LaunchedHost> {
555 self.launched
556 .get_or_init(|| {
557 let id = self.id;
558
559 let internal_ip = resource_result
560 .terraform
561 .outputs
562 .get(&format!("ec2-instance-{id}-private-ip"))
563 .unwrap()
564 .value
565 .clone();
566
567 let external_ip = resource_result
568 .terraform
569 .outputs
570 .get(&format!("ec2-instance-{id}-public-ip"))
571 .map(|v| v.value.clone());
572
573 Arc::new(LaunchedEc2Instance {
574 resource_result: resource_result.clone(),
575 user: self
576 .user
577 .as_ref()
578 .cloned()
579 .unwrap_or("ec2-user".to_string()),
580 internal_ip,
581 external_ip,
582 })
583 })
584 .clone()
585 }
586
587 fn strategy_as_server<'a>(
588 &'a self,
589 client_host: &dyn Host,
590 network_hint: PortNetworkHint,
591 ) -> Result<(ClientStrategy<'a>, HostStrategyGetter)> {
592 if matches!(network_hint, PortNetworkHint::Auto)
593 && client_host.can_connect_to(ClientStrategy::UnixSocket(self.id))
594 {
595 Ok((
596 ClientStrategy::UnixSocket(self.id),
597 Box::new(|_| BaseServerStrategy::UnixSocket),
598 ))
599 } else if matches!(
600 network_hint,
601 PortNetworkHint::Auto | PortNetworkHint::TcpPort(_)
602 ) && client_host.can_connect_to(ClientStrategy::InternalTcpPort(self))
603 {
604 Ok((
605 ClientStrategy::InternalTcpPort(self),
606 Box::new(move |_| {
607 BaseServerStrategy::InternalTcpPort(match network_hint {
608 PortNetworkHint::Auto => None,
609 PortNetworkHint::TcpPort(port) => port,
610 })
611 }),
612 ))
613 } else if matches!(network_hint, PortNetworkHint::Auto)
614 && client_host.can_connect_to(ClientStrategy::ForwardedTcpPort(self))
615 {
616 Ok((
617 ClientStrategy::ForwardedTcpPort(self),
618 Box::new(|me| {
619 me.downcast_ref::<AwsEc2Host>()
620 .unwrap()
621 .request_port_base(&BaseServerStrategy::ExternalTcpPort(22));
622 BaseServerStrategy::InternalTcpPort(None)
623 }),
624 ))
625 } else {
626 anyhow::bail!("Could not find a strategy to connect to AWS EC2 instance")
627 }
628 }
629
630 fn can_connect_to(&self, typ: ClientStrategy) -> bool {
631 match typ {
632 ClientStrategy::UnixSocket(id) => {
633 #[cfg(unix)]
634 {
635 self.id == id
636 }
637
638 #[cfg(not(unix))]
639 {
640 let _ = id;
641 false
642 }
643 }
644 ClientStrategy::InternalTcpPort(target_host) => {
645 if let Some(aws_target) = <dyn Any>::downcast_ref::<AwsEc2Host>(target_host) {
646 self.region == aws_target.region
647 && Arc::ptr_eq(&self.network, &aws_target.network)
648 } else {
649 false
650 }
651 }
652 ClientStrategy::ForwardedTcpPort(_) => false,
653 }
654 }
655}