Skip to main content

hydro_deploy/
aws.rs

1use std::any::Any;
2use std::fmt::Debug;
3use std::sync::{Arc, Mutex, OnceLock};
4
5use anyhow::Result;
6use nanoid::nanoid;
7use serde_json::json;
8
9use super::terraform::{TERRAFORM_ALPHABET, TerraformOutput, TerraformProvider};
10use super::{ClientStrategy, Host, HostTargetType, LaunchedHost, ResourceBatch, ResourceResult};
11use crate::ssh::LaunchedSshHost;
12use crate::{BaseServerStrategy, HostStrategyGetter, PortNetworkHint};
13
14pub struct LaunchedEc2Instance {
15    resource_result: Arc<ResourceResult>,
16    user: String,
17    pub internal_ip: String,
18    pub external_ip: Option<String>,
19}
20
21impl LaunchedSshHost for LaunchedEc2Instance {
22    fn get_external_ip(&self) -> Option<&str> {
23        self.external_ip.as_deref()
24    }
25
26    fn get_internal_ip(&self) -> &str {
27        &self.internal_ip
28    }
29
30    fn get_cloud_provider(&self) -> &'static str {
31        "AWS"
32    }
33
34    fn resource_result(&self) -> &Arc<ResourceResult> {
35        &self.resource_result
36    }
37
38    fn ssh_user(&self) -> &str {
39        self.user.as_str()
40    }
41}
42
43#[derive(Debug, Clone)]
44pub struct NetworkResources {
45    vpc: String,
46    subnet: String,
47    security_group: String,
48}
49
50#[derive(Debug)]
51pub struct AwsNetwork {
52    pub region: String,
53    pub existing_network_key: OnceLock<NetworkResources>,
54    pub existing_network_id: OnceLock<NetworkResources>,
55    id: String,
56}
57
58impl AwsNetwork {
59    pub fn new(region: impl Into<String>, existing_vpc: Option<NetworkResources>) -> Arc<Self> {
60        Arc::new(Self {
61            region: region.into(),
62            existing_network_key: OnceLock::new(),
63            existing_network_id: existing_vpc.map(From::from).unwrap_or_default(),
64            id: nanoid!(8, &TERRAFORM_ALPHABET),
65        })
66    }
67
68    fn collect_resources(&self, resource_batch: &mut ResourceBatch) -> NetworkResources {
69        resource_batch
70            .terraform
71            .terraform
72            .required_providers
73            .insert(
74                "aws".to_owned(),
75                TerraformProvider {
76                    source: "hashicorp/aws".to_owned(),
77                    version: "5.0.0".to_owned(),
78                },
79            );
80
81        resource_batch.terraform.provider.insert(
82            "aws".to_owned(),
83            json!({
84                "region": self.region
85            }),
86        );
87
88        let vpc_network = format!("hydro-vpc-network-{}", self.id);
89        let subnet_key = format!("{vpc_network}-subnet");
90        let sg_key = format!("{vpc_network}-default-sg");
91
92        if let Some(existing) = self.existing_network_id.get() {
93            let mut resolve = |resource_type: &str, existing_id: &str, data_key: String| {
94                resource_batch
95                    .terraform
96                    .data
97                    .entry(resource_type.to_owned())
98                    .or_default()
99                    .insert(data_key.clone(), json!({ "id": existing_id }));
100                format!("data.{resource_type}.{data_key}")
101            };
102
103            NetworkResources {
104                vpc: resolve("aws_vpc", &existing.vpc, vpc_network),
105                subnet: resolve("aws_subnet", &existing.subnet, subnet_key),
106                security_group: resolve("aws_security_group", &existing.security_group, sg_key),
107            }
108        } else if let Some(existing) = self.existing_network_key.get() {
109            NetworkResources {
110                vpc: format!("aws_vpc.{}", existing.vpc),
111                subnet: format!("aws_subnet.{}", existing.subnet),
112                security_group: format!("aws_security_group.{}", existing.security_group),
113            }
114        } else {
115            resource_batch
116                .terraform
117                .resource
118                .entry("aws_vpc".to_owned())
119                .or_default()
120                .insert(
121                    vpc_network.clone(),
122                    json!({
123                        "cidr_block": "10.0.0.0/16",
124                        "enable_dns_hostnames": true,
125                        "enable_dns_support": true,
126                        "tags": {
127                            "Name": vpc_network
128                        }
129                    }),
130                );
131
132            // Create internet gateway
133            let igw_key = format!("{vpc_network}-igw");
134            resource_batch
135                .terraform
136                .resource
137                .entry("aws_internet_gateway".to_owned())
138                .or_default()
139                .insert(
140                    igw_key.clone(),
141                    json!({
142                        "vpc_id": format!("${{aws_vpc.{}.id}}", vpc_network),
143                        "tags": {
144                            "Name": igw_key
145                        }
146                    }),
147                );
148
149            // Create subnet
150            resource_batch
151                .terraform
152                .resource
153                .entry("aws_subnet".to_owned())
154                .or_default()
155                .insert(
156                    subnet_key.clone(),
157                    json!({
158                        "vpc_id": format!("${{aws_vpc.{}.id}}", vpc_network),
159                        "cidr_block": "10.0.1.0/24",
160                        "availability_zone": format!("{}a", self.region),
161                        "map_public_ip_on_launch": true,
162                        "tags": {
163                            "Name": subnet_key
164                        }
165                    }),
166                );
167
168            // Create route table
169            let rt_key = format!("{vpc_network}-rt");
170            resource_batch
171                .terraform
172                .resource
173                .entry("aws_route_table".to_owned())
174                .or_default()
175                .insert(
176                    rt_key.clone(),
177                    json!({
178                        "vpc_id": format!("${{aws_vpc.{}.id}}", vpc_network),
179                        "tags": {
180                            "Name": rt_key
181                        }
182                    }),
183                );
184
185            // Create route
186            resource_batch
187                .terraform
188                .resource
189                .entry("aws_route".to_owned())
190                .or_default()
191                .insert(
192                    format!("{vpc_network}-route"),
193                    json!({
194                        "route_table_id": format!("${{aws_route_table.{}.id}}", rt_key),
195                        "destination_cidr_block": "0.0.0.0/0",
196                        "gateway_id": format!("${{aws_internet_gateway.{}.id}}", igw_key)
197                    }),
198                );
199
200            resource_batch
201                .terraform
202                .resource
203                .entry("aws_route_table_association".to_owned())
204                .or_default()
205                .insert(
206                    format!("{vpc_network}-rta"),
207                    json!({
208                        "subnet_id": format!("${{aws_subnet.{}.id}}", subnet_key),
209                        "route_table_id": format!("${{aws_route_table.{}.id}}", rt_key)
210                    }),
211                );
212
213            // Create security group that allows internal communication
214            resource_batch
215                .terraform
216                .resource
217                .entry("aws_security_group".to_owned())
218                .or_default()
219                .insert(
220                    sg_key.clone(),
221                    json!({
222                        "name": format!("{vpc_network}-default-allow-internal"),
223                        "description": "Allow internal communication between instances",
224                        "vpc_id": format!("${{aws_vpc.{}.id}}", vpc_network),
225                        "ingress": [
226                            {
227                                "from_port": 0,
228                                "to_port": 65535,
229                                "protocol": "tcp",
230                                "cidr_blocks": ["10.0.0.0/16"],
231                                "description": "Allow all TCP traffic within VPC",
232                                "ipv6_cidr_blocks": [],
233                                "prefix_list_ids": [],
234                                "security_groups": [],
235                                "self": false
236                            },
237                            {
238                                "from_port": 0,
239                                "to_port": 65535,
240                                "protocol": "udp",
241                                "cidr_blocks": ["10.0.0.0/16"],
242                                "description": "Allow all UDP traffic within VPC",
243                                "ipv6_cidr_blocks": [],
244                                "prefix_list_ids": [],
245                                "security_groups": [],
246                                "self": false
247                            },
248                            {
249                                "from_port": -1,
250                                "to_port": -1,
251                                "protocol": "icmp",
252                                "cidr_blocks": ["10.0.0.0/16"],
253                                "description": "Allow ICMP within VPC",
254                                "ipv6_cidr_blocks": [],
255                                "prefix_list_ids": [],
256                                "security_groups": [],
257                                "self": false
258                            }
259                        ],
260                        "egress": [
261                            {
262                                "from_port": 0,
263                                "to_port": 0,
264                                "protocol": "-1",
265                                "cidr_blocks": ["0.0.0.0/0"],
266                                "description": "Allow all outbound traffic",
267                                "ipv6_cidr_blocks": [],
268                                "prefix_list_ids": [],
269                                "security_groups": [],
270                                "self": false
271                            }
272                        ]
273                    }),
274                );
275
276            let resources = NetworkResources {
277                vpc: format!("aws_vpc.{vpc_network}"),
278                subnet: format!("aws_subnet.{subnet_key}"),
279                security_group: format!("aws_security_group.{sg_key}"),
280            };
281
282            // Add outputs so we can retrieve actual AWS IDs after apply
283            resource_batch.terraform.output.insert(
284                format!("hydro-network-{}-vpc-id", self.id),
285                TerraformOutput {
286                    value: format!("${{aws_vpc.{vpc_network}.id}}"),
287                },
288            );
289            resource_batch.terraform.output.insert(
290                format!("hydro-network-{}-subnet-id", self.id),
291                TerraformOutput {
292                    value: format!("${{aws_subnet.{subnet_key}.id}}"),
293                },
294            );
295            resource_batch.terraform.output.insert(
296                format!("hydro-network-{}-sg-id", self.id),
297                TerraformOutput {
298                    value: format!("${{aws_security_group.{sg_key}.id}}"),
299                },
300            );
301
302            let _ = self.existing_network_key.set(NetworkResources {
303                vpc: vpc_network,
304                subnet: subnet_key,
305                security_group: sg_key,
306            });
307            resources
308        }
309    }
310
311    pub fn update_from_outputs(&self, resource_result: &ResourceResult) {
312        let outputs = &resource_result.terraform.outputs;
313        if let (Some(vpc), Some(subnet), Some(sg)) = (
314            outputs.get(&format!("hydro-network-{}-vpc-id", self.id)),
315            outputs.get(&format!("hydro-network-{}-subnet-id", self.id)),
316            outputs.get(&format!("hydro-network-{}-sg-id", self.id)),
317        ) {
318            let _ = self.existing_network_id.set(NetworkResources {
319                vpc: vpc.value.clone(),
320                subnet: subnet.value.clone(),
321                security_group: sg.value.clone(),
322            });
323        }
324    }
325}
326
327/// Represents a IAM role, IAM policy attachments, and instance profile for one or multiple EC2 instances.
328#[derive(Debug)]
329pub struct AwsEc2IamInstanceProfile {
330    pub region: String,
331    pub existing_instance_profile_key_or_name: Option<String>,
332    pub policy_arns: Vec<String>,
333    id: String,
334}
335
336impl AwsEc2IamInstanceProfile {
337    /// Creates a new instance. If `existing_instance_profile_name` is `Some`, that will be used as the instance
338    /// profile name which must already exist in the AWS account.
339    pub fn new(region: impl Into<String>, existing_instance_profile_name: Option<String>) -> Self {
340        Self {
341            region: region.into(),
342            existing_instance_profile_key_or_name: existing_instance_profile_name,
343            policy_arns: Default::default(),
344            id: nanoid!(8, &TERRAFORM_ALPHABET),
345        }
346    }
347
348    /// Permits the given ARN.
349    pub fn add_policy_arn(mut self, policy_arn: impl Into<String>) -> Self {
350        if self.existing_instance_profile_key_or_name.is_some() {
351            panic!("Adding an ARN to an existing instance profile is not supported.");
352        }
353        self.policy_arns.push(policy_arn.into());
354        self
355    }
356
357    /// Enables running and emitting telemetry via the CloudWatch agent.
358    pub fn add_cloudwatch_agent_server_policy_arn(self) -> Self {
359        self.add_policy_arn("arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy")
360    }
361
362    fn collect_resources(&mut self, resource_batch: &mut ResourceBatch) -> String {
363        const RESOURCE_AWS_IAM_INSTANCE_PROFILE: &str = "aws_iam_instance_profile";
364        const RESOURCE_AWS_IAM_ROLE_POLICY_ATTACHMENT: &str = "aws_iam_role_policy_attachment";
365        const RESOURCE_AWS_IAM_ROLE: &str = "aws_iam_role";
366
367        resource_batch
368            .terraform
369            .terraform
370            .required_providers
371            .insert(
372                "aws".to_owned(),
373                TerraformProvider {
374                    source: "hashicorp/aws".to_owned(),
375                    version: "5.0.0".to_owned(),
376                },
377            );
378
379        resource_batch.terraform.provider.insert(
380            "aws".to_owned(),
381            json!({
382                "region": self.region
383            }),
384        );
385
386        let instance_profile_key = format!("hydro-instance-profile-{}", self.id);
387
388        if let Some(existing) = self.existing_instance_profile_key_or_name.as_ref() {
389            if resource_batch
390                .terraform
391                .resource
392                .get(RESOURCE_AWS_IAM_INSTANCE_PROFILE)
393                .is_some_and(|map| map.contains_key(existing))
394            {
395                // `existing` is a key.
396                format!("{RESOURCE_AWS_IAM_INSTANCE_PROFILE}.{existing}")
397            } else {
398                // `existing` is a name of an existing resource, supplied when constructed.
399                resource_batch
400                    .terraform
401                    .data
402                    .entry(RESOURCE_AWS_IAM_INSTANCE_PROFILE.to_owned())
403                    .or_default()
404                    .insert(
405                        instance_profile_key.clone(),
406                        json!({
407                            "id": existing,
408                        }),
409                    );
410
411                format!("data.{RESOURCE_AWS_IAM_INSTANCE_PROFILE}.{instance_profile_key}")
412            }
413        } else {
414            // Create the role (permissions set after).
415            let iam_role_key = format!("{instance_profile_key}-iam-role");
416            resource_batch
417                .terraform
418                .resource
419                .entry(RESOURCE_AWS_IAM_ROLE.to_owned())
420                .or_default()
421                .insert(
422                    iam_role_key.clone(),
423                    json!({
424                        "name": format!("hydro-iam-role-{}", self.id),
425                        "assume_role_policy": json!({
426                            "Version": "2012-10-17",
427                            "Statement": [
428                                {
429                                    "Action": "sts:AssumeRole",
430                                    "Effect": "Allow",
431                                    "Principal": {
432                                        "Service": "ec2.amazonaws.com"
433                                    }
434                                }
435                            ]
436                        }).to_string(),
437                    }),
438                );
439
440            // Attach permissions
441            for (i, policy_arn) in self.policy_arns.iter().enumerate() {
442                let policy_attachment_key = format!("{iam_role_key}-policy-attachment-{i}");
443                resource_batch
444                    .terraform
445                    .resource
446                    .entry(RESOURCE_AWS_IAM_ROLE_POLICY_ATTACHMENT.to_owned())
447                    .or_default()
448                    .insert(
449                        policy_attachment_key,
450                        json!({
451                            "policy_arn": policy_arn,
452                            "role": format!("${{{RESOURCE_AWS_IAM_ROLE}.{iam_role_key}.name}}"),
453                        }),
454                    );
455            }
456
457            // Create instance profile. This is what attaches to EC2 instances.
458            resource_batch
459                .terraform
460                .resource
461                .entry(RESOURCE_AWS_IAM_INSTANCE_PROFILE.to_owned())
462                .or_default()
463                .insert(
464                    instance_profile_key.clone(),
465                    json!({
466                        "name": format!("hydro-instance-profile-{}", self.id),
467                        "role": format!("${{{RESOURCE_AWS_IAM_ROLE}.{iam_role_key}.name}}"),
468                    }),
469                );
470
471            // Set key
472            self.existing_instance_profile_key_or_name = Some(instance_profile_key.clone());
473
474            format!("{RESOURCE_AWS_IAM_INSTANCE_PROFILE}.{instance_profile_key}")
475        }
476    }
477}
478
479/// Represents a CloudWatch log group.
480#[derive(Debug)]
481pub struct AwsCloudwatchLogGroup {
482    pub region: String,
483    pub existing_cloudwatch_log_group_key_or_name: Option<String>,
484    id: String,
485}
486
487impl AwsCloudwatchLogGroup {
488    /// Creates a new instance. If `existing_cloudwatch_log_group_name` is `Some`, that will be used as the CloudWatch
489    /// log group name which must already exist in the AWS account and region.
490    pub fn new(
491        region: impl Into<String>,
492        existing_cloudwatch_log_group_name: Option<String>,
493    ) -> Self {
494        Self {
495            region: region.into(),
496            existing_cloudwatch_log_group_key_or_name: existing_cloudwatch_log_group_name,
497            id: nanoid!(8, &TERRAFORM_ALPHABET),
498        }
499    }
500
501    fn collect_resources(&mut self, resource_batch: &mut ResourceBatch) -> String {
502        const RESOURCE_AWS_CLOUDWATCH_LOG_GROUP: &str = "aws_cloudwatch_log_group";
503
504        resource_batch
505            .terraform
506            .terraform
507            .required_providers
508            .insert(
509                "aws".to_owned(),
510                TerraformProvider {
511                    source: "hashicorp/aws".to_owned(),
512                    version: "5.0.0".to_owned(),
513                },
514            );
515
516        resource_batch.terraform.provider.insert(
517            "aws".to_owned(),
518            json!({
519                "region": self.region
520            }),
521        );
522
523        let cloudwatch_log_group_key = format!("hydro-cloudwatch-log-group-{}", self.id);
524
525        if let Some(existing) = self.existing_cloudwatch_log_group_key_or_name.as_ref() {
526            if resource_batch
527                .terraform
528                .resource
529                .get(RESOURCE_AWS_CLOUDWATCH_LOG_GROUP)
530                .is_some_and(|map| map.contains_key(existing))
531            {
532                // `existing` is a key.
533                format!("{RESOURCE_AWS_CLOUDWATCH_LOG_GROUP}.{existing}")
534            } else {
535                // `existing` is a name of an existing resource, supplied when constructed.
536                resource_batch
537                    .terraform
538                    .data
539                    .entry(RESOURCE_AWS_CLOUDWATCH_LOG_GROUP.to_owned())
540                    .or_default()
541                    .insert(
542                        cloudwatch_log_group_key.clone(),
543                        json!({
544                            "id": existing,
545                        }),
546                    );
547
548                format!("data.{RESOURCE_AWS_CLOUDWATCH_LOG_GROUP}.{cloudwatch_log_group_key}")
549            }
550        } else {
551            // Create the log group.
552            resource_batch
553                .terraform
554                .resource
555                .entry(RESOURCE_AWS_CLOUDWATCH_LOG_GROUP.to_owned())
556                .or_default()
557                .insert(
558                    cloudwatch_log_group_key.clone(),
559                    json!({
560                        "name": format!("hydro-cloudwatch-log-group-{}", self.id),
561                        "retention_in_days": 1,
562                    }),
563                );
564
565            // Set key
566            self.existing_cloudwatch_log_group_key_or_name = Some(cloudwatch_log_group_key.clone());
567
568            format!("{RESOURCE_AWS_CLOUDWATCH_LOG_GROUP}.{cloudwatch_log_group_key}")
569        }
570    }
571}
572
573pub struct AwsEc2Host {
574    /// ID from [`crate::Deployment::add_host`].
575    id: usize,
576
577    region: String,
578    instance_type: String,
579    target_type: HostTargetType,
580    ami: String,
581    network: Arc<AwsNetwork>,
582    iam_instance_profile: Option<Arc<Mutex<AwsEc2IamInstanceProfile>>>,
583    cloudwatch_log_group: Option<Arc<Mutex<AwsCloudwatchLogGroup>>>,
584    cwa_metrics_collected: Option<serde_json::Value>,
585    user: Option<String>,
586    display_name: Option<String>,
587    pub launched: OnceLock<Arc<LaunchedEc2Instance>>,
588    external_ports: Mutex<Vec<u16>>,
589}
590
591impl Debug for AwsEc2Host {
592    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
593        f.write_fmt(format_args!(
594            "AwsEc2Host({} ({:?}))",
595            self.id, &self.display_name
596        ))
597    }
598}
599
600impl AwsEc2Host {
601    #[expect(clippy::too_many_arguments, reason = "used via builder pattern")]
602    pub fn new(
603        id: usize,
604        region: impl Into<String>,
605        instance_type: impl Into<String>,
606        target_type: HostTargetType,
607        ami: impl Into<String>,
608        network: Arc<AwsNetwork>,
609        iam_instance_profile: Option<Arc<Mutex<AwsEc2IamInstanceProfile>>>,
610        cloudwatch_log_group: Option<Arc<Mutex<AwsCloudwatchLogGroup>>>,
611        cwa_metrics_collected: Option<serde_json::Value>,
612        user: Option<String>,
613        display_name: Option<String>,
614    ) -> Self {
615        Self {
616            id,
617            region: region.into(),
618            instance_type: instance_type.into(),
619            target_type,
620            ami: ami.into(),
621            network,
622            iam_instance_profile,
623            cloudwatch_log_group,
624            cwa_metrics_collected,
625            user,
626            display_name,
627            launched: OnceLock::new(),
628            external_ports: Mutex::new(Vec::new()),
629        }
630    }
631}
632
633impl Host for AwsEc2Host {
634    fn target_type(&self) -> HostTargetType {
635        self.target_type
636    }
637
638    fn request_port_base(&self, bind_type: &BaseServerStrategy) {
639        match bind_type {
640            BaseServerStrategy::UnixSocket => {}
641            BaseServerStrategy::InternalTcpPort(_) => {}
642            BaseServerStrategy::ExternalTcpPort(port) => {
643                let mut external_ports = self.external_ports.lock().unwrap();
644                if !external_ports.contains(port) {
645                    if self.launched.get().is_some() {
646                        todo!("Cannot adjust security group after host has been launched");
647                    }
648                    external_ports.push(*port);
649                }
650            }
651        }
652    }
653
654    fn request_custom_binary(&self) {
655        self.request_port_base(&BaseServerStrategy::ExternalTcpPort(22));
656    }
657
658    fn id(&self) -> usize {
659        self.id
660    }
661
662    fn collect_resources(&self, resource_batch: &mut ResourceBatch) {
663        if self.launched.get().is_some() {
664            return;
665        }
666
667        let network_resources = self.network.collect_resources(resource_batch);
668
669        let iam_instance_profile = self
670            .iam_instance_profile
671            .as_deref()
672            .map(|irip| irip.lock().unwrap().collect_resources(resource_batch));
673
674        let cloudwatch_log_group = self
675            .cloudwatch_log_group
676            .as_deref()
677            .map(|cwlg| cwlg.lock().unwrap().collect_resources(resource_batch));
678
679        // Add additional providers
680        resource_batch
681            .terraform
682            .terraform
683            .required_providers
684            .insert(
685                "local".to_owned(),
686                TerraformProvider {
687                    source: "hashicorp/local".to_owned(),
688                    version: "2.3.0".to_owned(),
689                },
690            );
691
692        resource_batch
693            .terraform
694            .terraform
695            .required_providers
696            .insert(
697                "tls".to_owned(),
698                TerraformProvider {
699                    source: "hashicorp/tls".to_owned(),
700                    version: "4.0.4".to_owned(),
701                },
702            );
703
704        // Generate SSH key pair
705        resource_batch
706            .terraform
707            .resource
708            .entry("tls_private_key".to_owned())
709            .or_default()
710            .insert(
711                "vm_instance_ssh_key".to_owned(),
712                json!({
713                    "algorithm": "RSA",
714                    "rsa_bits": 4096
715                }),
716            );
717
718        resource_batch
719            .terraform
720            .resource
721            .entry("local_file".to_owned())
722            .or_default()
723            .insert(
724                "vm_instance_ssh_key_pem".to_owned(),
725                json!({
726                    "content": "${tls_private_key.vm_instance_ssh_key.private_key_pem}",
727                    "filename": ".ssh/vm_instance_ssh_key_pem",
728                    "file_permission": "0600",
729                    "directory_permission": "0700"
730                }),
731            );
732
733        resource_batch
734            .terraform
735            .resource
736            .entry("aws_key_pair".to_owned())
737            .or_default()
738            .insert(
739                "ec2_key_pair".to_owned(),
740                json!({
741                    "key_name": format!("hydro-key-{}", nanoid!(8, &TERRAFORM_ALPHABET)),
742                    "public_key": "${tls_private_key.vm_instance_ssh_key.public_key_openssh}"
743                }),
744            );
745
746        let instance_key = format!("ec2-instance-{}", self.id);
747        let mut instance_name = format!("hydro-ec2-instance-{}", nanoid!(8, &TERRAFORM_ALPHABET));
748
749        if let Some(mut display_name) = self.display_name.clone() {
750            instance_name.push('-');
751            display_name = display_name.replace("_", "-").to_lowercase();
752
753            let num_chars_to_cut = instance_name.len() + display_name.len() - 63;
754            if num_chars_to_cut > 0 {
755                display_name.drain(0..num_chars_to_cut);
756            }
757            instance_name.push_str(&display_name);
758        }
759
760        let vpc_ref = format!("${{{}.id}}", network_resources.vpc);
761        let default_sg_ref = format!("${{{}.id}}", network_resources.security_group);
762
763        // Create additional security group for external ports if needed
764        let mut security_groups = vec![default_sg_ref];
765        let external_ports = self.external_ports.lock().unwrap();
766
767        if !external_ports.is_empty() {
768            let sg_key = format!("sg-{}", self.id);
769            let mut sg_rules = vec![];
770
771            for port in external_ports.iter() {
772                sg_rules.push(json!({
773                    "from_port": port,
774                    "to_port": port,
775                    "protocol": "tcp",
776                    "cidr_blocks": ["0.0.0.0/0"],
777                    "description": format!("External port {}", port),
778                    "ipv6_cidr_blocks": [],
779                    "prefix_list_ids": [],
780                    "security_groups": [],
781                    "self": false
782                }));
783            }
784
785            resource_batch
786                .terraform
787                .resource
788                .entry("aws_security_group".to_owned())
789                .or_default()
790                .insert(
791                    sg_key.clone(),
792                    json!({
793                        "name": format!("hydro-sg-{}", nanoid!(8, &TERRAFORM_ALPHABET)),
794                        "description": "Hydro external ports security group",
795                        "vpc_id": vpc_ref,
796                        "ingress": sg_rules,
797                        "egress": [{
798                            "from_port": 0,
799                            "to_port": 0,
800                            "protocol": "-1",
801                            "cidr_blocks": ["0.0.0.0/0"],
802                            "description": "All outbound traffic",
803                            "ipv6_cidr_blocks": [],
804                            "prefix_list_ids": [],
805                            "security_groups": [],
806                            "self": false
807                        }]
808                    }),
809                );
810
811            security_groups.push(format!("${{aws_security_group.{}.id}}", sg_key));
812        }
813        drop(external_ports);
814
815        let subnet_ref = format!("${{{}.id}}", network_resources.subnet);
816        let iam_instance_profile_ref = iam_instance_profile.map(|key| format!("${{{key}.name}}"));
817
818        // Write the CloudWatch Agent config file.
819        // https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Agent-Configuration-File-Details.html
820        let cloudwatch_agent_config = cloudwatch_log_group.map(|cwlg| {
821            json!({
822                "logs": {
823                    "logs_collected": {
824                        "files": {
825                            "collect_list": [
826                                {
827                                    "file_path": "/var/log/hydro/metrics.log",
828                                    "log_group_name": format!("${{{cwlg}.name}}"), // This `$` is interpreted by terraform
829                                    "log_stream_name": "{{instance_id}}"
830                                }
831                            ]
832                        }
833                    }
834                },
835                "metrics": {
836                    // "namespace": todo!(), // TODO(mingwei): use flow_name here somehow
837                    "metrics_collected": self.cwa_metrics_collected.as_ref().unwrap_or(&json!({
838                        "cpu": {
839                            "resources": [
840                                "*"
841                            ],
842                            "measurement": [
843                                "usage_active"
844                            ],
845                            "totalcpu": true
846                        },
847                        "mem": {
848                            "measurement": [
849                                "used_percent"
850                            ]
851                        }
852                    })),
853                    // See special escape handling below.
854                    "append_dimensions": {
855                        "InstanceId": "${aws:InstanceId}"
856                    }
857                }
858            })
859            .to_string()
860        });
861
862        // TODO(mingwei): Run this in SSH instead of `user_data` to avoid racing and capture errors.
863        let user_data_script = cloudwatch_agent_config.map(|cwa_config| {
864            let cwa_config_esc = cwa_config
865                .replace("\\", r"\\") // escape backslashes
866                .replace("\"", r#"\""#) // escape quotes
867                .replace("\n", r"\n") // escape newlines
868                // Special handling of AWS `append_dimensions` fields:
869                // `$$` to escape for terraform, becomes `\$` in bash, becomes `$` in echo output.
870                .replace("${aws:", r"\$${aws:");
871            format!(
872                r##"
873#!/bin/bash
874set -euxo pipefail
875
876mkdir -p /var/log/hydro/
877chmod +777 /var/log/hydro
878touch /var/log/hydro/metrics.log
879chmod +666 /var/log/hydro/metrics.log
880
881# Install the CloudWatch Agent
882yum install -y amazon-cloudwatch-agent
883
884mkdir -p /opt/aws/amazon-cloudwatch-agent/etc
885echo -e "{cwa_config_esc}" > /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json
886
887# Start or restart the agent
888/opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl \
889    -a fetch-config -m ec2 \
890    -c file:/opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json \
891    -s
892"##
893            )
894        });
895
896        // Create EC2 instance
897        resource_batch
898            .terraform
899            .resource
900            .entry("aws_instance".to_owned())
901            .or_default()
902            .insert(
903                instance_key.clone(),
904                json!({
905                    "ami": self.ami,
906                    "instance_type": self.instance_type,
907                    "key_name": "${aws_key_pair.ec2_key_pair.key_name}",
908                    "vpc_security_group_ids": security_groups,
909                    "subnet_id": subnet_ref,
910                    "associate_public_ip_address": true,
911                    "iam_instance_profile": iam_instance_profile_ref, // May be `None`.
912                    "user_data": user_data_script, // May be `None`.
913                    "tags": {
914                        "Name": instance_name
915                    }
916                }),
917            );
918
919        resource_batch.terraform.output.insert(
920            format!("{}-private-ip", instance_key),
921            TerraformOutput {
922                value: format!("${{aws_instance.{}.private_ip}}", instance_key),
923            },
924        );
925
926        resource_batch.terraform.output.insert(
927            format!("{}-public-ip", instance_key),
928            TerraformOutput {
929                value: format!("${{aws_instance.{}.public_ip}}", instance_key),
930            },
931        );
932    }
933
934    fn launched(&self) -> Option<Arc<dyn LaunchedHost>> {
935        self.launched
936            .get()
937            .map(|a| a.clone() as Arc<dyn LaunchedHost>)
938    }
939
940    fn provision(&self, resource_result: &Arc<ResourceResult>) -> Arc<dyn LaunchedHost> {
941        self.launched
942            .get_or_init(|| {
943                let id = self.id;
944
945                self.network.update_from_outputs(resource_result);
946                let internal_ip = resource_result
947                    .terraform
948                    .outputs
949                    .get(&format!("ec2-instance-{id}-private-ip"))
950                    .unwrap()
951                    .value
952                    .clone();
953
954                let external_ip = resource_result
955                    .terraform
956                    .outputs
957                    .get(&format!("ec2-instance-{id}-public-ip"))
958                    .map(|v| v.value.clone());
959
960                Arc::new(LaunchedEc2Instance {
961                    resource_result: resource_result.clone(),
962                    user: self.user.clone().unwrap_or_else(|| "ec2-user".to_owned()),
963                    internal_ip,
964                    external_ip,
965                })
966            })
967            .clone()
968    }
969
970    fn strategy_as_server<'a>(
971        &'a self,
972        client_host: &dyn Host,
973        network_hint: PortNetworkHint,
974    ) -> Result<(ClientStrategy<'a>, HostStrategyGetter)> {
975        if matches!(network_hint, PortNetworkHint::Auto)
976            && client_host.can_connect_to(ClientStrategy::UnixSocket(self.id))
977        {
978            Ok((
979                ClientStrategy::UnixSocket(self.id),
980                Box::new(|_| BaseServerStrategy::UnixSocket),
981            ))
982        } else if matches!(
983            network_hint,
984            PortNetworkHint::Auto | PortNetworkHint::TcpPort(_)
985        ) && client_host.can_connect_to(ClientStrategy::InternalTcpPort(self))
986        {
987            Ok((
988                ClientStrategy::InternalTcpPort(self),
989                Box::new(move |_| {
990                    BaseServerStrategy::InternalTcpPort(match network_hint {
991                        PortNetworkHint::Auto => None,
992                        PortNetworkHint::TcpPort(port) => port,
993                    })
994                }),
995            ))
996        } else if matches!(network_hint, PortNetworkHint::Auto)
997            && client_host.can_connect_to(ClientStrategy::ForwardedTcpPort(self))
998        {
999            Ok((
1000                ClientStrategy::ForwardedTcpPort(self),
1001                Box::new(|me| {
1002                    me.downcast_ref::<AwsEc2Host>()
1003                        .unwrap()
1004                        .request_port_base(&BaseServerStrategy::ExternalTcpPort(22));
1005                    BaseServerStrategy::InternalTcpPort(None)
1006                }),
1007            ))
1008        } else {
1009            anyhow::bail!("Could not find a strategy to connect to AWS EC2 instance")
1010        }
1011    }
1012
1013    fn can_connect_to(&self, typ: ClientStrategy) -> bool {
1014        match typ {
1015            ClientStrategy::UnixSocket(id) => {
1016                #[cfg(unix)]
1017                {
1018                    self.id == id
1019                }
1020
1021                #[cfg(not(unix))]
1022                {
1023                    let _ = id;
1024                    false
1025                }
1026            }
1027            ClientStrategy::InternalTcpPort(target_host) => {
1028                if let Some(aws_target) = <dyn Any>::downcast_ref::<AwsEc2Host>(target_host) {
1029                    self.region == aws_target.region
1030                        && Arc::ptr_eq(&self.network, &aws_target.network)
1031                } else {
1032                    false
1033                }
1034            }
1035            ClientStrategy::ForwardedTcpPort(_) => false,
1036        }
1037    }
1038}