hydro_deploy/
aws.rs

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            // Create internet gateway
127            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            // Create subnet
144            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            // Create route table
164            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            // Create route
181            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            // Create security group that allows internal communication
209            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 from [`crate::Deployment::add_host`].
281    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        // Add additional providers
372        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        // Generate SSH key pair
397        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        // Create additional security group for external ports if needed
461        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        // Create EC2 instance
513        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}