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 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 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 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 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 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 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#[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 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 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 #[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 format!("{RESOURCE_AWS_IAM_INSTANCE_PROFILE}.{existing}")
400 } else {
401 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 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 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 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 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#[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 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 format!("{RESOURCE_AWS_CLOUDWATCH_LOG_GROUP}.{existing}")
537 } else {
538 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 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 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: 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 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 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 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 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}}"), "log_stream_name": "{{instance_id}}"
833 }
834 ]
835 }
836 }
837 },
838 "metrics": {
839 "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 "append_dimensions": {
858 "InstanceId": "${aws:InstanceId}"
859 }
860 }
861 })
862 .to_string()
863 });
864
865 let user_data_script = cloudwatch_agent_config.map(|cwa_config| {
867 let cwa_config_esc = cwa_config
868 .replace('\\', r"\\") .replace('"', r#"\""#) .replace('\n', r"\n") .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 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, "user_data": user_data_script, "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}