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    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        // Add additional providers
368        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        // Generate SSH key pair
393        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        // Create additional security group for external ports if needed
457        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        // Create EC2 instance
509        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}