Skip to main content

hydro_deploy/
aws.rs

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