diff --git a/Cargo.lock b/Cargo.lock index d892789..d19a7c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -187,6 +187,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "aws-sdk-ssm" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1424a6fe018235a4adcc234fedb3199874099b98cdc92571e4a4e8e5ea7cd6" +dependencies = [ + "aws-credential-types", + "aws-http", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http", + "once_cell", + "regex-lite", + "tracing", +] + [[package]] name = "aws-sdk-sso" version = "1.7.0" @@ -536,6 +560,7 @@ dependencies = [ "async-trait", "aws-config", "aws-sdk-ec2", + "aws-sdk-ssm", "base64 0.21.5", "color-eyre", "digitalocean-rs", diff --git a/Cargo.toml b/Cargo.toml index 9706a28..6b0015e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,7 @@ base64 = "0.21.5" trait_enum = "0.5.0" aws-config = { version = "1.1.1", features = ["behavior-version-latest"] } aws-sdk-ec2 = "1.9.0" +aws-sdk-ssm = "1.7.0" # opentelemetry = { version = "0.18.0", features = ["trace", "rt-tokio"] } # opentelemetry-otlp = { version = "0.11.0", features = ["tokio"] } # tonic = { version = "0.8.3" } diff --git a/src/cloud/aws.rs b/src/cloud/aws.rs index 6f604a7..3c49625 100644 --- a/src/cloud/aws.rs +++ b/src/cloud/aws.rs @@ -4,14 +4,14 @@ use crate::{ ops::{ExitNode, ExitNodeStatus, EXIT_NODE_PROVISIONER_LABEL}, }; use async_trait::async_trait; -use aws_config::BehaviorVersion; +use aws_config::{BehaviorVersion, Region}; +use aws_sdk_ec2::types::{Tag, TagSpecification}; use base64::Engine; use color_eyre::eyre::{anyhow, Error}; use k8s_openapi::api::core::v1::Secret; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use tracing::{debug, info, warn}; -const AMI_ID: &str = "ami-0df4b2961410d4cff"; #[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)] pub struct AWSProvisioner { @@ -43,7 +43,7 @@ impl AWSIdentity { std::env::set_var("AWS_SECRET_ACCESS_KEY", &self.secret_access_key); let region: String = self.region.clone(); Ok(aws_config::defaults(BehaviorVersion::latest()) - .region(&*region.leak()) + .region(Region::new(region)) .load() .await) } @@ -53,7 +53,11 @@ impl AWSIdentity { let aws_access_key_id: String = secret .data .as_ref() - .and_then(|f| f.get("AWS_ACCESS_KEY_ID")) + .and_then( + |f: &std::collections::BTreeMap| { + f.get("AWS_ACCESS_KEY_ID") + }, + ) .ok_or_else(|| anyhow!("AWS_ACCESS_KEY_ID not found in secret")) // into String .and_then(|key| String::from_utf8(key.clone().0.to_vec()).map_err(|e| e.into()))?; @@ -81,37 +85,98 @@ impl Provisioner for AWSProvisioner { exit_node: ExitNode, ) -> color_eyre::Result { let provisioner = exit_node - .metadata - .annotations - .as_ref() - .and_then(|annotations| annotations.get(EXIT_NODE_PROVISIONER_LABEL)) - .ok_or_else(|| { - anyhow!( - "No provisioner found in annotations for exit node {}", - exit_node.metadata.name.as_ref().unwrap() - ) - })?; + .metadata + .annotations + .as_ref() + .and_then(|annotations| annotations.get(EXIT_NODE_PROVISIONER_LABEL)) + .ok_or_else(|| { + anyhow!( + "No provisioner found in annotations for exit node {}", + exit_node.metadata.name.as_ref().unwrap() + ) + })?; let password = generate_password(32); let cloud_init_config = generate_cloud_init_config(&password, CHISEL_PORT); let user_data = base64::engine::general_purpose::STANDARD.encode(cloud_init_config); - let aws_api = AWSIdentity::from_secret(&auth, self.region.clone())? + let aws_api: aws_config::SdkConfig = AWSIdentity::from_secret(&auth, self.region.clone())? .generate_aws_config() .await?; - let ec2_client = aws_sdk_ec2::Client::new(&aws_api); + let ssm_client = aws_sdk_ssm::Client::new(&aws_api); + let parameter_response = ssm_client + .get_parameter() + .name("/aws/service/canonical/ubuntu/server/22.04/stable/current/amd64/hvm/ebs-gp2/ami-id") + .send() + .await?; + let ami = parameter_response.parameter.unwrap().value.unwrap(); - let tag = format!("chisel-operator-provisioner:{}", provisioner); + let ec2_client = aws_sdk_ec2::Client::new(&aws_api); - ec2_client.run_instances() - .image_id(AMI_ID) + let name = format!( + "{}-{}", + provisioner, + exit_node.metadata.name.as_ref().unwrap() + ); + + let tag_specification = TagSpecification::builder() + .resource_type("instance".into()) + .tags(Tag::builder().key("Name").value(name.clone()).build()) + .build(); + + let instance_response = ec2_client + .run_instances() + .tag_specifications(tag_specification) + .image_id(ami) .instance_type("t2.micro".into()) + .min_count(1) + .max_count(1) .user_data(&user_data) - .send(); + .send() + .await?; - unimplemented!() + let instance = instance_response + .instances + .unwrap() + .into_iter() + .next() + .unwrap(); + + // TODO: Refactor this to run on a reconcile update instead + let public_ip = loop { + let describe_response = ec2_client + .describe_instances() + .instance_ids(instance.instance_id.clone().unwrap()) + .send() + .await?; + let reservation = describe_response + .reservations + .unwrap() + .into_iter() + .next() + .unwrap(); + let instance = reservation.instances.unwrap().into_iter().next().unwrap(); + + debug!(?instance, "Getting instance data"); + + if let Some(ip) = instance.public_ip_address { + break ip; + } else { + warn!("Waiting for instance to get IP address"); + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + } + }; + + let exit_node = ExitNodeStatus { + name: name.clone(), + ip: public_ip, + id: Some(instance.instance_id.unwrap()), + provider: provisioner.clone(), + }; + + Ok(exit_node) } async fn update_exit_node( @@ -119,10 +184,67 @@ impl Provisioner for AWSProvisioner { auth: Secret, exit_node: ExitNode, ) -> color_eyre::Result { - unimplemented!() + let aws_api: aws_config::SdkConfig = AWSIdentity::from_secret(&auth, self.region.clone())? + .generate_aws_config() + .await?; + let ec2_client = aws_sdk_ec2::Client::new(&aws_api); + + let node = exit_node.clone(); + + if let Some(ref status) = &exit_node.status { + let instance_id = status.id.as_ref().ok_or_else(|| { + anyhow!( + "No instance ID found in status for exit node {}", + node.metadata.name.as_ref().unwrap() + ) + })?; + + let describe_response = ec2_client + .describe_instances() + .instance_ids(instance_id) + .send() + .await?; + let reservation = describe_response + .reservations + .unwrap() + .into_iter() + .next() + .unwrap(); + let instance = reservation.instances.unwrap().into_iter().next().unwrap(); + + let mut status = status.clone(); + + if let Some(ip) = instance.public_ip_address { + status.ip = ip; + } + + Ok(status) + } else { + warn!("No status found for exit node, creating new instance"); + // TODO: this should be handled by the controller logic + return self.create_exit_node(auth, exit_node).await; + } } async fn delete_exit_node(&self, auth: Secret, exit_node: ExitNode) -> color_eyre::Result<()> { - unimplemented!() + let aws_api: aws_config::SdkConfig = AWSIdentity::from_secret(&auth, self.region.clone())? + .generate_aws_config() + .await?; + let ec2_client = aws_sdk_ec2::Client::new(&aws_api); + + let instance_id = exit_node + .status + .as_ref() + .and_then(|status| status.id.as_ref()); + + if let Some(instance_id) = instance_id { + ec2_client + .terminate_instances() + .instance_ids(instance_id) + .send() + .await?; + } + + Ok(()) } } diff --git a/src/cloud/digitalocean.rs b/src/cloud/digitalocean.rs index d262db8..1c6d025 100644 --- a/src/cloud/digitalocean.rs +++ b/src/cloud/digitalocean.rs @@ -153,6 +153,7 @@ impl Provisioner for DigitalOceanProvisioner { Ok(status) } else { warn!("No status found for exit node, creating new droplet"); + // TODO: this should be handled by the controller logic return self.create_exit_node(auth, exit_node).await; } }