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 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 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 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 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 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 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#[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 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 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 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 format!("{RESOURCE_AWS_IAM_INSTANCE_PROFILE}.{existing}")
397 } else {
398 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 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 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 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 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#[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 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 format!("{RESOURCE_AWS_CLOUDWATCH_LOG_GROUP}.{existing}")
534 } else {
535 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 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 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: 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 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 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 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 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}}"), "log_stream_name": "{{instance_id}}"
830 }
831 ]
832 }
833 }
834 },
835 "metrics": {
836 "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 "append_dimensions": {
855 "InstanceId": "${aws:InstanceId}"
856 }
857 }
858 })
859 .to_string()
860 });
861
862 let user_data_script = cloudwatch_agent_config.map(|cwa_config| {
864 let cwa_config_esc = cwa_config
865 .replace("\\", r"\\") .replace("\"", r#"\""#) .replace("\n", r"\n") .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 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, "user_data": user_data_script, "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}