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,
};
use super::credentials::AssumedSession;
pub use super::deployer::FargateLaunchConfig;
#[derive(Debug, Clone)]
pub struct RealEcsTarget {
ecs: aws_sdk_ecs::Client,
elb: aws_sdk_elasticloadbalancingv2::Client,
launch: FargateLaunchConfig,
target_group_pool: Vec<String>,
}
pub const REAL_ECS_TARGET_IAM_ACTIONS: &[&str] = &[
"ecs:DescribeServices", "ecs:ListServices", "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,
target_group_pool: Vec<String>,
session: Option<AssumedSession>,
) -> Result<Self, EcsTargetError> {
let mut loader = aws_config::defaults(aws_config::BehaviorVersion::latest())
.region(aws_config::Region::new(region.to_string()));
if let Some(session) = session.as_ref() {
loader = loader.credentials_provider(session_credentials(session));
}
let config = loader.load().await;
if config.credentials_provider().is_none() {
return Err(EcsTargetError::Api(
"no AWS credentials provider in the resolved SDK config — bind the deployer \
identity (`op env bootstrap --bind` / `op credentials rotate`) or set AWS_PROFILE \
/ 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,
target_group_pool,
})
}
async fn resolve_pool_arns(&self) -> Result<Vec<String>, EcsTargetError> {
let names: Vec<String> = self
.target_group_pool
.iter()
.filter(|e| !e.starts_with("arn:"))
.cloned()
.collect();
let resolved = if names.is_empty() {
HashMap::new()
} else {
let out = self
.elb
.describe_target_groups()
.set_names(Some(names))
.send()
.await
.map_err(|e| api("describe_target_groups", e))?;
target_group_arns_from(&out)
};
pool_arns_from(&self.target_group_pool, &resolved)
}
async fn sibling_pool_bindings(
&self,
cluster: &str,
me: &str,
) -> Result<Vec<(String, Vec<String>)>, EcsTargetError> {
let mut bindings = Vec::new();
let mut next_token = None;
loop {
let page = self
.ecs
.list_services()
.cluster(cluster)
.set_next_token(next_token)
.send()
.await
.map_err(|e| api("list_services", e))?;
for arn in page.service_arns() {
let name = service_name_from_arn(arn);
if name == me || !name.starts_with(SERVICE_NAME_PREFIX) {
continue;
}
let task_sets = self
.ecs
.describe_task_sets()
.cluster(cluster)
.service(name)
.send()
.await
.map_err(|e| api("describe_task_sets", e))?;
let bound: Vec<String> = assigned_pool_members(&task_sets).into_iter().collect();
if !bound.is_empty() {
bindings.push((name.to_string(), bound));
}
}
match page.next_token() {
Some(token) => next_token = Some(token.to_string()),
None => break,
}
}
Ok(bindings)
}
}
fn session_credentials(session: &AssumedSession) -> aws_sdk_ecs::config::Credentials {
aws_sdk_ecs::config::Credentials::new(
session.access_key_id.clone(),
session.secret_access_key.clone(),
Some(session.session_token.clone()),
Some(std::time::SystemTime::from(session.expiration)),
"greentic-bound-deployer-session",
)
}
#[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 pool = self.resolve_pool_arns().await?;
let siblings = self.sibling_pool_bindings(&spec.cluster, &service).await?;
if let Some((owner, shared_tg)) = conflicting_pool_owner(&siblings, &pool) {
return Err(EcsTargetError::PoolConflict { owner, shared_tg });
}
let taken = assigned_pool_members(&existing);
let target_group_arn = pick_free_pool_member(&pool, &taken).ok_or_else(|| {
EcsTargetError::Api(if pool.is_empty() {
"the AWS-ECS binding configures no ALB target groups — set \
`target_group_arns` (≥2 for blue/green) so warm can place the revision"
.to_string()
} else {
format!(
"target-group pool exhausted: all {} configured target group(s) are bound \
to live task sets; add more to `target_group_arns` to warm another revision",
pool.len()
)
})
})?;
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 service = service_name(&listener.deployment_id);
let out = self
.ecs
.describe_task_sets()
.cluster(&listener.cluster)
.service(&service)
.send()
.await
.map_err(|e| api("describe_task_sets", e))?;
let tuples = weighted_target_groups(weights, &out)?;
let action = forward_action(&tuples)?;
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}"))
}
const SERVICE_NAME_PREFIX: &str = "gtc-svc-";
pub(crate) fn service_name(deployment_id: &DeploymentId) -> String {
format!("{SERVICE_NAME_PREFIX}{}", 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 bound_target_group(out: &DescribeTaskSetsOutput, external_id: &str) -> Option<String> {
find_task_set(out, external_id)?
.load_balancers()
.iter()
.find_map(|lb| lb.target_group_arn().map(str::to_string))
}
fn assigned_pool_members(out: &DescribeTaskSetsOutput) -> std::collections::HashSet<String> {
out.task_sets()
.iter()
.flat_map(|ts| ts.load_balancers())
.filter_map(|lb| lb.target_group_arn().map(str::to_string))
.collect()
}
fn pick_free_pool_member(
pool_arns: &[String],
taken: &std::collections::HashSet<String>,
) -> Option<String> {
pool_arns.iter().find(|arn| !taken.contains(*arn)).cloned()
}
fn service_name_from_arn(arn: &str) -> &str {
arn.rsplit('/').next().unwrap_or(arn)
}
fn conflicting_pool_owner(
siblings: &[(String, Vec<String>)],
pool: &[String],
) -> Option<(String, String)> {
let pool_set: std::collections::HashSet<&str> = pool.iter().map(String::as_str).collect();
siblings.iter().find_map(|(service, bound)| {
bound
.iter()
.find(|tg| pool_set.contains(tg.as_str()))
.map(|tg| (service.clone(), tg.clone()))
})
}
fn pool_arns_from(
pool: &[String],
resolved: &HashMap<String, String>,
) -> Result<Vec<String>, EcsTargetError> {
let arns: Vec<String> = pool
.iter()
.map(|entry| {
if entry.starts_with("arn:") {
Ok(entry.clone())
} else {
resolved.get(entry).cloned().ok_or_else(|| {
EcsTargetError::Api(format!(
"target group pool member `{entry}` not found in this account/region"
))
})
}
})
.collect::<Result<_, _>>()?;
if let Some(dup) = first_duplicate(&arns) {
return Err(EcsTargetError::Api(format!(
"target group `{dup}` appears more than once in the pool; each pool member \
must be a distinct target group"
)));
}
Ok(arns)
}
fn first_duplicate(arns: &[String]) -> Option<&str> {
let mut seen = std::collections::HashSet::new();
arns.iter()
.find(|a| !seen.insert(a.as_str()))
.map(String::as_str)
}
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 weighted_target_groups(
weights: &[TargetGroupWeight],
task_sets: &DescribeTaskSetsOutput,
) -> Result<Vec<(u32, String)>, EcsTargetError> {
weights
.iter()
.map(|w| {
let external_id = task_set_external_id(&w.revision_id);
let arn = bound_target_group(task_sets, &external_id).ok_or_else(|| {
EcsTargetError::Api(format!(
"revision `{}` has no live task set with a target-group binding to route \
traffic to — warm it before shifting traffic",
w.revision_id.0
))
})?;
Ok((w.weight_bps, arn))
})
.collect()
}
fn forward_action(tuples: &[(u32, String)]) -> Result<Action, EcsTargetError> {
let mut forward = ForwardActionConfig::builder();
for (weight_bps, arn) in tuples {
forward = forward.target_groups(
TargetGroupTuple::builder()
.target_group_arn(arn)
.weight(elb_weight(*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_weight_arn_tuples() {
let tuples = vec![
(7000u32, "arn-a".to_string()),
(3000u32, "arn-b".to_string()),
];
let action = forward_action(&tuples).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 bound_target_group_reads_the_load_balancer_binding() {
let out = DescribeTaskSetsOutput::builder()
.task_sets(
TaskSet::builder()
.external_id("gtc-rev-blue")
.load_balancers(LoadBalancer::builder().target_group_arn("arn-blue").build())
.build(),
)
.task_sets(
TaskSet::builder().external_id("gtc-rev-bare").build(),
)
.build();
assert_eq!(
bound_target_group(&out, "gtc-rev-blue").as_deref(),
Some("arn-blue")
);
assert_eq!(bound_target_group(&out, "gtc-rev-bare"), None);
assert_eq!(bound_target_group(&out, "gtc-rev-absent"), None);
}
#[test]
fn assigned_pool_members_collects_every_bound_target_group() {
let out = DescribeTaskSetsOutput::builder()
.task_sets(
TaskSet::builder()
.external_id("gtc-rev-blue")
.load_balancers(LoadBalancer::builder().target_group_arn("arn-blue").build())
.build(),
)
.task_sets(
TaskSet::builder()
.external_id("gtc-rev-green")
.load_balancers(
LoadBalancer::builder()
.target_group_arn("arn-green")
.build(),
)
.build(),
)
.build();
let taken = assigned_pool_members(&out);
assert!(taken.contains("arn-blue") && taken.contains("arn-green"));
assert_eq!(taken.len(), 2);
assert!(assigned_pool_members(&DescribeTaskSetsOutput::builder().build()).is_empty());
}
#[test]
fn pick_free_pool_member_picks_the_first_free_in_order() {
let pool = vec![
"arn-blue".to_string(),
"arn-green".to_string(),
"arn-amber".to_string(),
];
let taken = std::collections::HashSet::from(["arn-blue".to_string()]);
assert_eq!(
pick_free_pool_member(&pool, &taken).as_deref(),
Some("arn-green"),
"skips the bound member, picks the next in configured order"
);
let all_taken = pool.iter().cloned().collect();
assert_eq!(
pick_free_pool_member(&pool, &all_taken),
None,
"pool exhausted → None (caller errors)"
);
assert_eq!(
pick_free_pool_member(&[], &std::collections::HashSet::new()),
None,
"empty pool → None"
);
}
#[test]
fn service_name_from_arn_takes_the_segment_after_the_final_slash() {
assert_eq!(
service_name_from_arn(
"arn:aws:ecs:eu-west-1:123456789012:service/greentic-prod/gtc-svc-01ABC"
),
"gtc-svc-01ABC"
);
assert_eq!(service_name_from_arn("gtc-svc-01ABC"), "gtc-svc-01ABC");
}
#[test]
fn conflicting_pool_owner_flags_only_a_sibling_holding_a_pool_member() {
let pool = vec!["tg-blue".to_string(), "tg-green".to_string()];
let owner = vec![("gtc-svc-other".to_string(), vec!["tg-blue".to_string()])];
assert_eq!(
conflicting_pool_owner(&owner, &pool),
Some(("gtc-svc-other".to_string(), "tg-blue".to_string())),
"a sibling holding a pool member fails closed and names the owner",
);
let unrelated = vec![(
"gtc-svc-other".to_string(),
vec!["tg-unrelated".to_string()],
)];
assert_eq!(
conflicting_pool_owner(&unrelated, &pool),
None,
"an out-of-pool sibling binding does not contend",
);
assert_eq!(conflicting_pool_owner(&[], &pool), None);
}
#[test]
fn pool_arns_from_resolves_names_passes_arns_and_errors_on_unknown() {
const BLUE: &str = "arn:aws:elasticloadbalancing:us-east-1:111122223333:targetgroup/blue/1";
const GREEN: &str =
"arn:aws:elasticloadbalancing:us-east-1:111122223333:targetgroup/green/2";
let resolved = HashMap::from([("green-tg".to_string(), GREEN.to_string())]);
assert_eq!(
pool_arns_from(&[BLUE.to_string(), "green-tg".to_string()], &resolved).unwrap(),
vec![BLUE.to_string(), GREEN.to_string()],
);
let missing = vec!["typo-tg".to_string()];
assert!(
pool_arns_from(&missing, &resolved).is_err(),
"an unresolved name must error, not silently shrink the pool"
);
let dup = vec![GREEN.to_string(), "green-tg".to_string()];
assert!(
pool_arns_from(&dup, &resolved).is_err(),
"a target group repeated in the pool must be rejected"
);
assert!(
pool_arns_from(&[BLUE.to_string(), GREEN.to_string()], &resolved).is_ok(),
"a pool of distinct ARNs is accepted"
);
}
#[test]
fn first_duplicate_finds_the_first_repeat_in_order() {
assert_eq!(
first_duplicate(&["a".to_string(), "b".to_string(), "a".to_string()]),
Some("a")
);
assert_eq!(
first_duplicate(&["a".to_string(), "b".to_string(), "c".to_string()]),
None
);
assert_eq!(first_duplicate(&[]), None);
}
#[test]
fn weighted_target_groups_maps_revisions_to_bound_arns() {
let blue = RevisionId(Ulid::from(0xb1u128));
let out = DescribeTaskSetsOutput::builder()
.task_sets(
TaskSet::builder()
.external_id(task_set_external_id(&blue))
.load_balancers(LoadBalancer::builder().target_group_arn("arn-blue").build())
.build(),
)
.build();
let tuples = weighted_target_groups(
&[TargetGroupWeight {
revision_id: blue,
weight_bps: 10000,
}],
&out,
)
.unwrap();
assert_eq!(tuples, vec![(10000u32, "arn-blue".to_string())]);
let unwarmed = RevisionId(Ulid::from(0xddu128));
assert!(
weighted_target_groups(
&[TargetGroupWeight {
revision_id: unwarmed,
weight_bps: 10000,
}],
&out,
)
.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"
);
}
}
#[test]
fn session_credentials_carry_all_three_parts_and_the_expiry() {
let expiration = chrono::DateTime::from_timestamp(1_900_000_000, 0).unwrap();
let session = AssumedSession {
access_key_id: "AKIAEXAMPLE".to_string(),
secret_access_key: "shh-the-key".to_string(),
session_token: "the-session-blob".to_string(),
expiration,
issued_at: chrono::DateTime::from_timestamp(1_899_000_000, 0).unwrap(),
};
let creds = session_credentials(&session);
assert_eq!(creds.access_key_id(), "AKIAEXAMPLE");
assert_eq!(creds.secret_access_key(), "shh-the-key");
assert_eq!(creds.session_token(), Some("the-session-blob"));
assert_eq!(
creds.expiry(),
Some(std::time::SystemTime::from(expiration))
);
}
}