use std::collections::HashMap;
use async_trait::async_trait;
use aws_sdk_ecs::operation::create_task_set::CreateTaskSetOutput;
use aws_sdk_ecs::operation::describe_services::DescribeServicesOutput;
use aws_sdk_ecs::operation::describe_task_sets::DescribeTaskSetsOutput;
use aws_sdk_ecs::operation::register_task_definition::RegisterTaskDefinitionOutput;
use aws_sdk_ecs::types::{
AssignPublicIp, AwsVpcConfiguration, Compatibility, ContainerDefinition, DeploymentController,
DeploymentControllerType, LoadBalancer, NetworkConfiguration, NetworkMode, PortMapping, Scale,
ScaleUnit, StabilityStatus, TaskSet,
};
use aws_sdk_elasticloadbalancingv2::operation::describe_target_groups::DescribeTargetGroupsOutput;
use aws_sdk_elasticloadbalancingv2::types::{
Action, ActionTypeEnum, ForwardActionConfig, TargetGroupTuple,
};
use greentic_deploy_spec::{DeploymentId, RevisionId};
use super::deploy_target::{
EcsDeployTarget, EcsTargetError, ListenerRef, ServiceSpec, TargetGroupWeight, TaskSetHandle,
TaskSetRef, TaskSetSpec, TaskSetStability,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FargateLaunchConfig {
pub execution_role_arn: String,
pub task_role_arn: Option<String>,
pub subnets: Vec<String>,
pub security_groups: Vec<String>,
pub assign_public_ip: bool,
pub cpu: String,
pub memory: String,
pub container_name: String,
pub container_port: i32,
}
#[derive(Debug, Clone)]
pub struct RealEcsTarget {
ecs: aws_sdk_ecs::Client,
elb: aws_sdk_elasticloadbalancingv2::Client,
launch: FargateLaunchConfig,
}
pub const REAL_ECS_TARGET_IAM_ACTIONS: &[&str] = &[
"ecs:DescribeServices", "ecs:CreateService", "ecs:RegisterTaskDefinition", "ecs:CreateTaskSet", "ecs:DescribeTaskSets", "ecs:DeleteTaskSet", "ecs:DeregisterTaskDefinition", "elasticloadbalancing:DescribeTargetGroups", "elasticloadbalancing:ModifyListener", ];
impl RealEcsTarget {
pub async fn resolve(
region: &str,
launch: FargateLaunchConfig,
) -> Result<Self, EcsTargetError> {
let config = aws_config::defaults(aws_config::BehaviorVersion::latest())
.region(aws_config::Region::new(region.to_string()))
.load()
.await;
if config.credentials_provider().is_none() {
return Err(EcsTargetError::Api(
"no AWS credentials provider in the resolved SDK config — set AWS_PROFILE or \
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY"
.to_string(),
));
}
Ok(Self {
ecs: aws_sdk_ecs::Client::new(&config),
elb: aws_sdk_elasticloadbalancingv2::Client::new(&config),
launch,
})
}
async fn target_group_arns(
&self,
names: &[String],
) -> Result<HashMap<String, String>, EcsTargetError> {
if names.is_empty() {
return Ok(HashMap::new());
}
let out = self
.elb
.describe_target_groups()
.set_names(Some(names.to_vec()))
.send()
.await
.map_err(|e| api("describe_target_groups", e))?;
Ok(target_group_arns_from(&out))
}
}
#[async_trait]
impl EcsDeployTarget for RealEcsTarget {
async fn ensure_service(&self, spec: &ServiceSpec) -> Result<(), EcsTargetError> {
let service = service_name(&spec.deployment_id);
let described = self
.ecs
.describe_services()
.cluster(&spec.cluster)
.services(&service)
.send()
.await
.map_err(|e| api("describe_services", e))?;
if active_service_exists(&described, &service) {
return Ok(());
}
self.ecs
.create_service()
.cluster(&spec.cluster)
.service_name(&service)
.client_token(service_client_token(&spec.deployment_id))
.deployment_controller(
DeploymentController::builder()
.r#type(DeploymentControllerType::External)
.build()
.expect("deployment controller type is set"),
)
.send()
.await
.map_err(|e| api("create_service", e))?;
Ok(())
}
async fn create_task_set(&self, spec: &TaskSetSpec) -> Result<TaskSetHandle, EcsTargetError> {
let service = service_name(&spec.deployment_id);
let external_id = task_set_external_id(&spec.revision_id);
let existing = self
.ecs
.describe_task_sets()
.cluster(&spec.cluster)
.service(&service)
.send()
.await
.map_err(|e| api("describe_task_sets", e))?;
if let Some(handle) = existing_task_set_handle(&existing, &external_id) {
return Ok(handle);
}
let target_group_arn = self
.target_group_arns(std::slice::from_ref(&spec.target_group))
.await?
.remove(&spec.target_group)
.ok_or_else(|| {
EcsTargetError::Api(format!(
"target group `{}` not found in this account/region",
spec.target_group
))
})?;
let registered = self
.ecs
.register_task_definition()
.family(task_def_family(&spec.deployment_id, &spec.revision_id))
.requires_compatibilities(Compatibility::Fargate)
.network_mode(NetworkMode::Awsvpc)
.cpu(&self.launch.cpu)
.memory(&self.launch.memory)
.execution_role_arn(&self.launch.execution_role_arn)
.set_task_role_arn(self.launch.task_role_arn.clone())
.container_definitions(container_def(&self.launch, &spec.image))
.send()
.await
.map_err(|e| api("register_task_definition", e))?;
let task_def_arn = task_def_arn_from(®istered)?;
let created = self
.ecs
.create_task_set()
.cluster(&spec.cluster)
.service(&service)
.task_definition(&task_def_arn)
.external_id(&external_id)
.client_token(task_set_client_token(&spec.revision_id))
.network_configuration(network_config(&self.launch))
.load_balancers(load_balancer(&self.launch, &target_group_arn))
.scale(
Scale::builder()
.value(100.0)
.unit(ScaleUnit::Percent)
.build(),
)
.send()
.await
.map_err(|e| api("create_task_set", e))?;
task_set_handle_from(&created, task_def_arn)
}
async fn task_set_stability(
&self,
task_set: &TaskSetRef,
) -> Result<TaskSetStability, EcsTargetError> {
let service = service_name(&task_set.deployment_id);
let external_id = task_set_external_id(&task_set.revision_id);
let out = self
.ecs
.describe_task_sets()
.cluster(&task_set.cluster)
.service(&service)
.send()
.await
.map_err(|e| api("describe_task_sets", e))?;
stability_from(&out, &external_id)
}
async fn delete_task_set(&self, task_set: &TaskSetRef) -> Result<(), EcsTargetError> {
let service = service_name(&task_set.deployment_id);
let external_id = task_set_external_id(&task_set.revision_id);
let out = self
.ecs
.describe_task_sets()
.cluster(&task_set.cluster)
.service(&service)
.send()
.await
.map_err(|e| api("describe_task_sets", e))?;
let Some(found) = task_set_for_delete(&out, &external_id) else {
return Ok(());
};
self.ecs
.delete_task_set()
.cluster(&task_set.cluster)
.service(&service)
.task_set(&found.id)
.force(true)
.send()
.await
.map_err(|e| api("delete_task_set", e))?;
if let Some(arn) = found.task_definition {
self.ecs
.deregister_task_definition()
.task_definition(&arn)
.send()
.await
.map_err(|e| api("deregister_task_definition", e))?;
}
Ok(())
}
async fn apply_listener_weights(
&self,
listener: &ListenerRef,
weights: &[TargetGroupWeight],
) -> Result<(), EcsTargetError> {
let names: Vec<String> = weights.iter().map(|w| w.target_group.clone()).collect();
let arns = self.target_group_arns(&names).await?;
let action = forward_action(weights, &arns)?;
self.elb
.modify_listener()
.listener_arn(&listener.listener_arn)
.default_actions(action)
.send()
.await
.map_err(|e| api("modify_listener", e))?;
Ok(())
}
}
fn api<E: std::fmt::Display>(op: &str, err: E) -> EcsTargetError {
EcsTargetError::Api(format!("ecs {op}: {err}"))
}
fn service_name(deployment_id: &DeploymentId) -> String {
format!("gtc-svc-{}", deployment_id.0)
}
fn uuid_form(bits: u128) -> String {
format!(
"{:08x}-{:04x}-{:04x}-{:04x}-{:012x}",
(bits >> 96) as u32,
(bits >> 80) as u16,
(bits >> 64) as u16,
(bits >> 48) as u16,
(bits & 0xffff_ffff_ffff) as u64,
)
}
fn service_client_token(deployment_id: &DeploymentId) -> String {
uuid_form(deployment_id.0.into())
}
fn task_set_client_token(revision_id: &RevisionId) -> String {
uuid_form(revision_id.0.into())
}
fn task_def_family(deployment_id: &DeploymentId, revision_id: &RevisionId) -> String {
format!("gtc-td-{}-{}", deployment_id.0, revision_id.0)
}
fn task_set_external_id(revision_id: &RevisionId) -> String {
format!("gtc-rev-{}", revision_id.0)
}
fn active_service_exists(out: &DescribeServicesOutput, name: &str) -> bool {
out.services().iter().any(|s| {
s.service_name() == Some(name)
&& s.status()
.is_some_and(|st| st.eq_ignore_ascii_case("ACTIVE"))
})
}
fn container_def(launch: &FargateLaunchConfig, image: &str) -> ContainerDefinition {
ContainerDefinition::builder()
.name(&launch.container_name)
.image(image)
.essential(true)
.port_mappings(
PortMapping::builder()
.container_port(launch.container_port)
.build(),
)
.build()
}
fn network_config(launch: &FargateLaunchConfig) -> NetworkConfiguration {
let assign = if launch.assign_public_ip {
AssignPublicIp::Enabled
} else {
AssignPublicIp::Disabled
};
NetworkConfiguration::builder()
.awsvpc_configuration(
AwsVpcConfiguration::builder()
.set_subnets(Some(launch.subnets.clone()))
.set_security_groups(Some(launch.security_groups.clone()))
.assign_public_ip(assign)
.build()
.expect("awsvpc subnets are set"),
)
.build()
}
fn load_balancer(launch: &FargateLaunchConfig, target_group_arn: &str) -> LoadBalancer {
LoadBalancer::builder()
.target_group_arn(target_group_arn)
.container_name(&launch.container_name)
.container_port(launch.container_port)
.build()
}
fn task_def_arn_from(out: &RegisterTaskDefinitionOutput) -> Result<String, EcsTargetError> {
out.task_definition()
.and_then(|td| td.task_definition_arn())
.map(str::to_string)
.ok_or_else(|| {
EcsTargetError::Api(
"RegisterTaskDefinition returned no task-definition ARN".to_string(),
)
})
}
fn task_set_handle_from(
out: &CreateTaskSetOutput,
task_def_arn: String,
) -> Result<TaskSetHandle, EcsTargetError> {
let id = out
.task_set()
.and_then(|ts| ts.id())
.map(str::to_string)
.ok_or_else(|| EcsTargetError::Api("CreateTaskSet returned no task-set id".to_string()))?;
Ok(TaskSetHandle {
task_set_id: id,
task_def_arn,
})
}
fn existing_task_set_handle(
out: &DescribeTaskSetsOutput,
external_id: &str,
) -> Option<TaskSetHandle> {
find_task_set(out, external_id).and_then(|ts| {
let id = ts.id()?.to_string();
let task_def_arn = ts.task_definition()?.to_string();
Some(TaskSetHandle {
task_set_id: id,
task_def_arn,
})
})
}
fn stability_from(
out: &DescribeTaskSetsOutput,
external_id: &str,
) -> Result<TaskSetStability, EcsTargetError> {
let ts = find_task_set(out, external_id).ok_or_else(|| {
EcsTargetError::Api(format!(
"DescribeTaskSets returned no task set for externalId `{external_id}`"
))
})?;
let stabilized = ts.stability_status() == Some(&StabilityStatus::SteadyState);
Ok(TaskSetStability {
stabilized,
running: ts.running_count().max(0) as u32,
desired: ts.computed_desired_count().max(0) as u32,
})
}
struct TaskSetToDelete {
id: String,
task_definition: Option<String>,
}
fn task_set_for_delete(out: &DescribeTaskSetsOutput, external_id: &str) -> Option<TaskSetToDelete> {
let ts = find_task_set(out, external_id)?;
Some(TaskSetToDelete {
id: ts.id()?.to_string(),
task_definition: ts.task_definition().map(str::to_string),
})
}
fn find_task_set<'a>(out: &'a DescribeTaskSetsOutput, external_id: &str) -> Option<&'a TaskSet> {
out.task_sets()
.iter()
.find(|ts| ts.external_id() == Some(external_id))
}
fn target_group_arns_from(out: &DescribeTargetGroupsOutput) -> HashMap<String, String> {
let mut map = HashMap::new();
for tg in out.target_groups() {
if let (Some(name), Some(arn)) = (tg.target_group_name(), tg.target_group_arn()) {
map.insert(name.to_string(), arn.to_string());
}
}
map
}
fn forward_action(
weights: &[TargetGroupWeight],
arns: &HashMap<String, String>,
) -> Result<Action, EcsTargetError> {
let mut forward = ForwardActionConfig::builder();
for w in weights {
let arn = arns.get(&w.target_group).ok_or_else(|| {
EcsTargetError::Api(format!(
"target group `{}` not found in this account/region",
w.target_group
))
})?;
forward = forward.target_groups(
TargetGroupTuple::builder()
.target_group_arn(arn)
.weight(elb_weight(w.weight_bps))
.build(),
);
}
Ok(Action::builder()
.r#type(ActionTypeEnum::Forward)
.forward_config(forward.build())
.build())
}
fn elb_weight(weight_bps: u32) -> i32 {
(weight_bps / 10).min(999) as i32
}
#[cfg(test)]
mod tests {
use super::*;
use aws_sdk_ecs::types::{Service, TaskDefinition};
use aws_sdk_elasticloadbalancingv2::types::TargetGroup;
use ulid::Ulid;
fn launch() -> FargateLaunchConfig {
FargateLaunchConfig {
execution_role_arn: "arn:aws:iam::111122223333:role/exec".to_string(),
task_role_arn: Some("arn:aws:iam::111122223333:role/task".to_string()),
subnets: vec!["subnet-a".to_string(), "subnet-b".to_string()],
security_groups: vec!["sg-1".to_string()],
assign_public_ip: false,
cpu: "256".to_string(),
memory: "512".to_string(),
container_name: "worker".to_string(),
container_port: 8080,
}
}
fn dep() -> DeploymentId {
DeploymentId(Ulid::from(0x0d_u128))
}
fn rev() -> RevisionId {
RevisionId(Ulid::from(0x1e_u128))
}
#[test]
fn names_are_deterministic_and_revision_id_bridges_identity() {
let (d, r) = (dep(), rev());
assert_eq!(service_name(&d), format!("gtc-svc-{}", d.0));
assert_eq!(task_def_family(&d, &r), format!("gtc-td-{}-{}", d.0, r.0));
assert_eq!(task_set_external_id(&r), format!("gtc-rev-{}", r.0));
}
#[test]
fn active_service_exists_ignores_inactive_and_other_names() {
let out = DescribeServicesOutput::builder()
.services(
Service::builder()
.service_name("gtc-svc-dep1")
.status("ACTIVE")
.build(),
)
.build();
assert!(active_service_exists(&out, "gtc-svc-dep1"));
assert!(!active_service_exists(&out, "gtc-svc-other"));
let inactive = DescribeServicesOutput::builder()
.services(
Service::builder()
.service_name("gtc-svc-dep1")
.status("INACTIVE")
.build(),
)
.build();
assert!(
!active_service_exists(&inactive, "gtc-svc-dep1"),
"an INACTIVE (recently deleted) service must be treated as absent"
);
let empty = DescribeServicesOutput::builder().build();
assert!(!active_service_exists(&empty, "gtc-svc-dep1"));
}
#[test]
fn container_def_carries_image_name_and_port() {
let cd = container_def(&launch(), "registry/img:rev-rev1");
assert_eq!(cd.name(), Some("worker"));
assert_eq!(cd.image(), Some("registry/img:rev-rev1"));
assert_eq!(cd.essential(), Some(true));
assert_eq!(cd.port_mappings().len(), 1);
assert_eq!(cd.port_mappings()[0].container_port(), Some(8080));
}
#[test]
fn network_config_maps_subnets_security_groups_and_public_ip() {
let nc = network_config(&launch());
let vpc = nc.awsvpc_configuration().expect("awsvpc config present");
assert_eq!(vpc.subnets(), ["subnet-a", "subnet-b"]);
assert_eq!(vpc.security_groups(), ["sg-1"]);
assert_eq!(vpc.assign_public_ip(), Some(&AssignPublicIp::Disabled));
let mut public = launch();
public.assign_public_ip = true;
let nc = network_config(&public);
assert_eq!(
nc.awsvpc_configuration().unwrap().assign_public_ip(),
Some(&AssignPublicIp::Enabled)
);
}
#[test]
fn load_balancer_binds_container_to_target_group() {
let lb = load_balancer(&launch(), "arn:aws:elasticloadbalancing:::targetgroup/tg/1");
assert_eq!(
lb.target_group_arn(),
Some("arn:aws:elasticloadbalancing:::targetgroup/tg/1")
);
assert_eq!(lb.container_name(), Some("worker"));
assert_eq!(lb.container_port(), Some(8080));
}
#[test]
fn task_def_arn_from_reads_arn_or_errors() {
let out = RegisterTaskDefinitionOutput::builder()
.task_definition(
TaskDefinition::builder()
.task_definition_arn("arn:aws:ecs:us-east-1:111122223333:task-definition/x:1")
.build(),
)
.build();
assert_eq!(
task_def_arn_from(&out).unwrap(),
"arn:aws:ecs:us-east-1:111122223333:task-definition/x:1"
);
let empty = RegisterTaskDefinitionOutput::builder().build();
assert!(task_def_arn_from(&empty).is_err());
}
#[test]
fn task_set_handle_from_reads_id_with_the_registered_arn() {
let out = CreateTaskSetOutput::builder()
.task_set(TaskSet::builder().id("ecs-ts-abc").build())
.build();
let handle = task_set_handle_from(&out, "td-arn".to_string()).unwrap();
assert_eq!(handle.task_set_id, "ecs-ts-abc");
assert_eq!(handle.task_def_arn, "td-arn");
let empty = CreateTaskSetOutput::builder().build();
assert!(task_set_handle_from(&empty, "td-arn".to_string()).is_err());
}
fn task_set_with(
external_id: &str,
status: StabilityStatus,
running: i32,
desired: i32,
) -> TaskSet {
TaskSet::builder()
.id(format!("ecs-{external_id}"))
.external_id(external_id)
.task_definition(format!("td-{external_id}"))
.stability_status(status)
.running_count(running)
.computed_desired_count(desired)
.build()
}
#[test]
fn find_and_stability_match_by_external_id() {
let out = DescribeTaskSetsOutput::builder()
.task_sets(task_set_with(
"gtc-rev-other",
StabilityStatus::Stabilizing,
0,
1,
))
.task_sets(task_set_with(
"gtc-rev-rev1",
StabilityStatus::SteadyState,
2,
2,
))
.build();
let stab = stability_from(&out, "gtc-rev-rev1").unwrap();
assert!(stab.stabilized);
assert_eq!(stab.running, 2);
assert_eq!(stab.desired, 2);
let other = stability_from(&out, "gtc-rev-other").unwrap();
assert!(!other.stabilized, "Stabilizing status is not steady state");
assert!(
stability_from(&out, "gtc-rev-missing").is_err(),
"a task set that has not appeared is an honest error"
);
}
#[test]
fn existing_handle_and_delete_lookup_project_the_matched_task_set() {
let out = DescribeTaskSetsOutput::builder()
.task_sets(task_set_with(
"gtc-rev-rev1",
StabilityStatus::SteadyState,
1,
1,
))
.build();
let handle = existing_task_set_handle(&out, "gtc-rev-rev1").unwrap();
assert_eq!(handle.task_set_id, "ecs-gtc-rev-rev1");
assert_eq!(handle.task_def_arn, "td-gtc-rev-rev1");
assert!(existing_task_set_handle(&out, "gtc-rev-absent").is_none());
let del = task_set_for_delete(&out, "gtc-rev-rev1").expect("present");
assert_eq!(del.id, "ecs-gtc-rev-rev1");
assert_eq!(del.task_definition.as_deref(), Some("td-gtc-rev-rev1"));
assert!(
task_set_for_delete(&out, "gtc-rev-absent").is_none(),
"absent task set yields None so delete is an idempotent no-op"
);
}
#[test]
fn target_group_arns_from_skips_partial_entries() {
let out = DescribeTargetGroupsOutput::builder()
.target_groups(
TargetGroup::builder()
.target_group_name("tg-a")
.target_group_arn("arn-a")
.build(),
)
.target_groups(
TargetGroup::builder()
.target_group_name("tg-no-arn")
.build(),
)
.build();
let map = target_group_arns_from(&out);
assert_eq!(map.get("tg-a").map(String::as_str), Some("arn-a"));
assert!(!map.contains_key("tg-no-arn"));
}
#[test]
fn forward_action_mirrors_weights_and_resolves_arns() {
let arns = HashMap::from([
("tg-a".to_string(), "arn-a".to_string()),
("tg-b".to_string(), "arn-b".to_string()),
]);
let weights = vec![
TargetGroupWeight {
revision_id: RevisionId(Ulid::from(0xa_u128)),
weight_bps: 7000,
target_group: "tg-a".to_string(),
},
TargetGroupWeight {
revision_id: RevisionId(Ulid::from(0xb_u128)),
weight_bps: 3000,
target_group: "tg-b".to_string(),
},
];
let action = forward_action(&weights, &arns).unwrap();
assert_eq!(action.r#type(), Some(&ActionTypeEnum::Forward));
let tgs = action.forward_config().unwrap().target_groups();
assert_eq!(tgs.len(), 2);
assert_eq!(tgs[0].target_group_arn(), Some("arn-a"));
assert_eq!(tgs[0].weight(), Some(700));
assert_eq!(tgs[1].target_group_arn(), Some("arn-b"));
assert_eq!(tgs[1].weight(), Some(300));
}
#[test]
fn forward_action_errors_on_unresolved_target_group() {
let weights = vec![TargetGroupWeight {
revision_id: RevisionId(Ulid::from(0xa_u128)),
weight_bps: 10000,
target_group: "tg-missing".to_string(),
}];
assert!(forward_action(&weights, &HashMap::new()).is_err());
}
#[test]
fn elb_weight_scales_bps_and_clamps_to_the_api_ceiling() {
assert_eq!(elb_weight(0), 0);
assert_eq!(elb_weight(2500), 250);
assert_eq!(elb_weight(5000), 500);
assert_eq!(elb_weight(10000), 999);
}
fn assert_uuid_form_shape(token: &str) {
assert_eq!(
token.len(),
36,
"ECS clientToken is a 36-char UUID-form string"
);
let dashes: Vec<usize> = token.match_indices('-').map(|(i, _)| i).collect();
assert_eq!(dashes, vec![8, 13, 18, 23], "dash positions: {token}");
assert!(
token.chars().all(|c| c == '-' || c.is_ascii_hexdigit()),
"token is lowercase-hex + dashes only; got {token}"
);
}
#[test]
fn service_client_token_is_deterministic_uuid_shaped_and_per_deployment() {
let d = dep();
let token = service_client_token(&d);
assert_eq!(
token,
service_client_token(&d),
"same deployment must yield the same idempotency token so concurrent \
ensure_service calls dedupe"
);
assert_uuid_form_shape(&token);
assert_ne!(
service_client_token(&DeploymentId(Ulid::from(0xbeef_u128))),
token
);
}
#[test]
fn task_set_client_token_is_deterministic_uuid_shaped_and_per_revision() {
let r = rev();
let token = task_set_client_token(&r);
assert_eq!(
token,
task_set_client_token(&r),
"same revision must yield the same idempotency token so concurrent \
create_task_set calls dedupe"
);
assert_uuid_form_shape(&token);
assert_ne!(
task_set_client_token(&RevisionId(Ulid::from(0xbeef_u128))),
token
);
}
#[test]
fn real_target_iam_actions_are_a_subset_of_validated_verbs() {
use crate::env_packs::aws::credentials::VALIDATED_IAM_VERBS;
for action in REAL_ECS_TARGET_IAM_ACTIONS {
assert!(
VALIDATED_IAM_VERBS.contains(action),
"RealEcsTarget calls `{action}` but the credentials preflight does \
not validate it — add it to VALIDATED_IAM_VERBS so a validated \
role does not fail on the first live deploy"
);
}
}
}