use async_trait::async_trait;
use greentic_deploy_spec::{DeploymentId, RevisionId};
pub type TaskDefArn = String;
pub type TaskSetId = String;
#[derive(Debug, thiserror::Error)]
pub enum EcsTargetError {
#[error("ECS API error: {0}")]
Api(String),
#[error(
"target-group pool already owned by deployment service `{owner}` (bound to `{shared_tg}`); \
one pool serves a single deployment's blue/green pair. Per-deployment pools are not \
supported yet — roll this revision out under the existing deployment, or give this \
deployment its own environment binding with a distinct `target_group_arns` pool"
)]
PoolConflict { owner: String, shared_tg: String },
#[error(
"listener rule `{rule_arn}` already serves {condition} but is owned by another deployment \
or is operator-managed; refusing to rewrite a rule this deployment did not create. Give \
this deployment a distinct `alb_routing_host` / `alb_routing_path`, or remove the \
conflicting rule"
)]
ListenerRuleConflict { rule_arn: String, condition: String },
#[error("no ECS API client configured for this handler")]
Unconfigured,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServiceSpec {
pub deployment_id: DeploymentId,
pub cluster: String,
pub region: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TaskSetSpec {
pub deployment_id: DeploymentId,
pub revision_id: RevisionId,
pub cluster: String,
pub region: String,
pub image: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TaskSetRef {
pub deployment_id: DeploymentId,
pub revision_id: RevisionId,
pub cluster: String,
pub region: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TaskSetHandle {
pub task_set_id: TaskSetId,
pub task_def_arn: TaskDefArn,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TaskSetStability {
pub stabilized: bool,
pub running: u32,
pub desired: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListenerRouting {
pub host: Option<String>,
pub path: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListenerRef {
pub deployment_id: DeploymentId,
pub listener_arn: String,
pub cluster: String,
pub routing: Option<ListenerRouting>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TargetGroupWeight {
pub revision_id: RevisionId,
pub weight_bps: u32,
}
#[async_trait]
pub trait EcsDeployTarget: std::fmt::Debug + Send + Sync {
async fn ensure_service(&self, spec: &ServiceSpec) -> Result<(), EcsTargetError>;
async fn create_task_set(&self, spec: &TaskSetSpec) -> Result<TaskSetHandle, EcsTargetError>;
async fn task_set_stability(
&self,
task_set: &TaskSetRef,
) -> Result<TaskSetStability, EcsTargetError>;
async fn delete_task_set(&self, task_set: &TaskSetRef) -> Result<(), EcsTargetError>;
async fn apply_listener_weights(
&self,
listener: &ListenerRef,
weights: &[TargetGroupWeight],
) -> Result<(), EcsTargetError>;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct UnconfiguredEcsTarget;
#[async_trait]
impl EcsDeployTarget for UnconfiguredEcsTarget {
async fn ensure_service(&self, _spec: &ServiceSpec) -> Result<(), EcsTargetError> {
Err(EcsTargetError::Unconfigured)
}
async fn create_task_set(&self, _spec: &TaskSetSpec) -> Result<TaskSetHandle, EcsTargetError> {
Err(EcsTargetError::Unconfigured)
}
async fn task_set_stability(
&self,
_task_set: &TaskSetRef,
) -> Result<TaskSetStability, EcsTargetError> {
Err(EcsTargetError::Unconfigured)
}
async fn delete_task_set(&self, _task_set: &TaskSetRef) -> Result<(), EcsTargetError> {
Err(EcsTargetError::Unconfigured)
}
async fn apply_listener_weights(
&self,
_listener: &ListenerRef,
_weights: &[TargetGroupWeight],
) -> Result<(), EcsTargetError> {
Err(EcsTargetError::Unconfigured)
}
}
#[derive(Debug, Default)]
pub struct InMemoryEcs {
services: std::sync::Mutex<std::collections::BTreeSet<DeploymentId>>,
task_sets:
std::sync::Mutex<std::collections::BTreeMap<(DeploymentId, RevisionId), TaskSetHandle>>,
weights: std::sync::Mutex<std::collections::BTreeMap<DeploymentId, Vec<TargetGroupWeight>>>,
routing: std::sync::Mutex<std::collections::BTreeMap<DeploymentId, Option<ListenerRouting>>>,
}
impl InMemoryEcs {
pub fn services(&self) -> std::collections::BTreeSet<DeploymentId> {
self.services.lock().expect("mutex not poisoned").clone()
}
pub fn task_sets(
&self,
) -> std::collections::BTreeMap<(DeploymentId, RevisionId), TaskSetHandle> {
self.task_sets.lock().expect("mutex not poisoned").clone()
}
pub fn weights_for(&self, deployment_id: DeploymentId) -> Option<Vec<TargetGroupWeight>> {
self.weights
.lock()
.expect("mutex not poisoned")
.get(&deployment_id)
.cloned()
}
pub fn routing_for(&self, deployment_id: DeploymentId) -> Option<Option<ListenerRouting>> {
self.routing
.lock()
.expect("mutex not poisoned")
.get(&deployment_id)
.cloned()
}
}
#[async_trait]
impl EcsDeployTarget for InMemoryEcs {
async fn ensure_service(&self, spec: &ServiceSpec) -> Result<(), EcsTargetError> {
self.services
.lock()
.expect("mutex not poisoned")
.insert(spec.deployment_id);
Ok(())
}
async fn create_task_set(&self, spec: &TaskSetSpec) -> Result<TaskSetHandle, EcsTargetError> {
let key = (spec.deployment_id, spec.revision_id);
let mut sets = self.task_sets.lock().expect("mutex not poisoned");
if let Some(existing) = sets.get(&key) {
return Ok(existing.clone());
}
let handle = TaskSetHandle {
task_set_id: format!("ts-{}-{}", spec.deployment_id.0, spec.revision_id.0),
task_def_arn: format!("td-{}-{}", spec.deployment_id.0, spec.revision_id.0),
};
sets.insert(key, handle.clone());
Ok(handle)
}
async fn task_set_stability(
&self,
_task_set: &TaskSetRef,
) -> Result<TaskSetStability, EcsTargetError> {
Ok(TaskSetStability {
stabilized: true,
running: 1,
desired: 1,
})
}
async fn delete_task_set(&self, task_set: &TaskSetRef) -> Result<(), EcsTargetError> {
self.task_sets
.lock()
.expect("mutex not poisoned")
.remove(&(task_set.deployment_id, task_set.revision_id));
Ok(())
}
async fn apply_listener_weights(
&self,
listener: &ListenerRef,
weights: &[TargetGroupWeight],
) -> Result<(), EcsTargetError> {
self.weights
.lock()
.expect("mutex not poisoned")
.insert(listener.deployment_id, weights.to_vec());
self.routing
.lock()
.expect("mutex not poisoned")
.insert(listener.deployment_id, listener.routing.clone());
Ok(())
}
}