Skip to main content

greentic_deployer/env_packs/aws/
deploy_target.rs

1//! The [`EcsDeployTarget`] seam: the side-effect surface the AWS-ECS
2//! [`Deployer`](crate::env_packs::deployer::Deployer) verbs mutate through.
3//!
4//! This is the AWS twin of [`K8sCluster`](crate::env_packs::k8s::cluster::K8sCluster):
5//! an object-safe async trait the verbs call, with three implementations —
6//!
7//! - [`InMemoryEcs`] — the unit-test + conformance fake. Records services,
8//!   task sets, and listener weights in maps so per-verb tests can assert what
9//!   landed. `task_set_stability` reports stable immediately so the warm wait
10//!   resolves on the first poll.
11//! - [`UnconfiguredEcsTarget`] — the [`AwsEcsDeployerHandler`](super::AwsEcsDeployerHandler)
12//!   default. Every method fails with [`EcsTargetError::Unconfigured`] so a
13//!   handler built without a real client fails verbs **honestly** rather than
14//!   pretending the AWS calls happened (mirrors `UnconfiguredCluster`).
15//! - `RealEcsTarget` (the aws-sdk-backed impl) lands in a follow-up PR behind
16//!   the `deploy-aws-ecs` feature; it implements this same trait unchanged.
17//!
18//! ## Why task sets (ECS EXTERNAL deployment)
19//!
20//! The deployer drives ECS services configured with the `EXTERNAL` deployment
21//! controller: one service per `deployment_id`, one **task set** per revision.
22//! `warm` registers a task set; `apply_traffic_split` writes weighted forward
23//! rules across the revisions' target groups; `archive` deletes the task set.
24//! This is the AWS-native blue/green shape (`ecs:CreateTaskSet` is already in
25//! the validated IAM verb list) and keeps each revision independently
26//! addressable on the ALB.
27
28use async_trait::async_trait;
29use greentic_deploy_spec::{DeploymentId, RevisionId};
30
31/// Opaque ECS identifiers. Strings on the wire; newtyped only where the
32/// domain benefits — these two are pass-through handles the real SDK fills.
33pub type TaskDefArn = String;
34pub type TaskSetId = String;
35
36/// Failures the seam surfaces to the verbs. The verb layer maps every variant
37/// into [`DeployerError::Provider`](crate::env_packs::deployer::DeployerError::Provider)
38/// — preconditions have already passed by the time the seam is touched, so any
39/// seam failure is provider-side.
40#[derive(Debug, thiserror::Error)]
41pub enum EcsTargetError {
42    /// An AWS API call failed. Carries an actionable message (the operation +
43    /// the response detail) so operators can act.
44    #[error("ECS API error: {0}")]
45    Api(String),
46    /// A deployer *policy* refusal from the single-owner pool guard: the env's
47    /// one target-group pool is already owned by another deployment. Distinct
48    /// from [`Api`](Self::Api) so the operator message isn't mislabeled as an AWS
49    /// API failure — this is a configuration boundary, not a broken call.
50    #[error(
51        "target-group pool already owned by deployment service `{owner}` (bound to `{shared_tg}`); \
52         one pool serves a single deployment's blue/green pair. Per-deployment pools are not \
53         supported yet — roll this revision out under the existing deployment, or give this \
54         deployment its own environment binding with a distinct `target_group_arns` pool"
55    )]
56    PoolConflict { owner: String, shared_tg: String },
57    /// A deployer *policy* refusal from the per-deployment listener-rule write:
58    /// a non-default rule already carries this deployment's routing condition but
59    /// is NOT owned by this deployment (no matching owner tag) — a sibling
60    /// deployment's rule or an operator-managed auth/redirect rule sharing the
61    /// host/path. The deployer refuses to rewrite a rule it did not create rather
62    /// than silently strip its actions. Distinct from [`Api`](Self::Api): a
63    /// configuration boundary, not a broken call.
64    #[error(
65        "listener rule `{rule_arn}` already serves {condition} but is owned by another deployment \
66         or is operator-managed; refusing to rewrite a rule this deployment did not create. Give \
67         this deployment a distinct `alb_routing_host` / `alb_routing_path`, or remove the \
68         conflicting rule"
69    )]
70    ListenerRuleConflict { rule_arn: String, condition: String },
71    /// The handler has no real ECS client wired (the default
72    /// [`UnconfiguredEcsTarget`]). The operator must bind AWS credentials and
73    /// rebuild the handler with a connected client.
74    #[error("no ECS API client configured for this handler")]
75    Unconfigured,
76}
77
78/// Desired state of the per-deployment ECS service (one per `deployment_id`).
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct ServiceSpec {
81    pub deployment_id: DeploymentId,
82    pub cluster: String,
83    pub region: String,
84}
85
86/// Desired state of one revision's task set.
87///
88/// The ALB target group this revision binds to is **not** on the spec: the
89/// real target assigns it statelessly from its operator-supplied pool (reading
90/// which pool members are already bound to live task sets), so the deployer
91/// never computes or carries a target-group name.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct TaskSetSpec {
94    pub deployment_id: DeploymentId,
95    pub revision_id: RevisionId,
96    pub cluster: String,
97    pub region: String,
98    /// Container image the Fargate task runs.
99    pub image: String,
100}
101
102/// Identity of a task set for describe / delete.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct TaskSetRef {
105    pub deployment_id: DeploymentId,
106    pub revision_id: RevisionId,
107    pub cluster: String,
108    /// AWS region the task set lives in. ECS is regional, so describe /
109    /// delete must carry it — a fresh process cannot reconstruct the region
110    /// from the other identifiers. Populated from `AwsEcsParams::region`.
111    pub region: String,
112}
113
114/// What [`EcsDeployTarget::create_task_set`] returns: the created (or existing,
115/// on an idempotent re-create) task set plus its task-definition ARN, so
116/// [`EcsDeployTarget::delete_task_set`] can deregister the definition.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct TaskSetHandle {
119    pub task_set_id: TaskSetId,
120    pub task_def_arn: TaskDefArn,
121}
122
123/// Rollout status of a task set, polled by the warm readiness wait.
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub struct TaskSetStability {
126    /// True once the task set has reached steady state (running == desired AND
127    /// its target group is healthy).
128    pub stabilized: bool,
129    pub running: u32,
130    pub desired: u32,
131}
132
133/// Per-deployment ALB routing condition: the host-header and/or path-pattern
134/// that scopes this deployment's weighted forward to its *own* listener rule,
135/// so several deployments coexist behind one `alb_listener_arn` instead of
136/// fighting over the listener's default action. At least one of `host` / `path`
137/// is set — an all-blank wizard answer parses to `None` routing, which keeps
138/// the legacy default-action write (one deployment per listener). Built from
139/// the wizard's `alb_routing_host` / `alb_routing_path`.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct ListenerRouting {
142    pub host: Option<String>,
143    pub path: Option<String>,
144}
145
146/// Identity of the ALB listener whose weighted forward action mirrors a
147/// deployment's `TrafficSplit`. Carries the operator-supplied listener ARN
148/// (the wizard's `alb_listener_arn`); the verbs build this only when an ALB
149/// mirror is configured.
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct ListenerRef {
152    pub deployment_id: DeploymentId,
153    pub listener_arn: String,
154    /// ECS cluster the deployment's task sets live in — the target reads their
155    /// load-balancer bindings to map each weighted revision to its target
156    /// group, and `describe_task_sets` is cluster-scoped.
157    pub cluster: String,
158    /// Per-deployment routing condition. `Some` scopes the weighted forward to
159    /// this deployment's own listener rule (host/path), preserving the default
160    /// action + sibling rules; `None` writes the listener's default action
161    /// (legacy single-deployment-per-listener behaviour).
162    pub routing: Option<ListenerRouting>,
163}
164
165/// One weighted revision in a listener rule's forward action. The revision's
166/// target group is **not** carried here: the real target maps each revision to
167/// the TG its task set is bound to (read from the live task sets), so the
168/// deployer passes only the routing weight.
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct TargetGroupWeight {
171    pub revision_id: RevisionId,
172    pub weight_bps: u32,
173}
174
175/// The side-effect seam the AWS-ECS deployer verbs drive.
176///
177/// Object-safe (`async_trait`) so the handler holds `Arc<dyn EcsDeployTarget>`
178/// and the real aws-sdk client is one implementation among the fakes. Every
179/// method MUST be idempotent: `ensure_service` / `create_task_set` upsert,
180/// `delete_task_set` of an absent set is `Ok`. The conformance bench leans on
181/// this.
182#[async_trait]
183pub trait EcsDeployTarget: std::fmt::Debug + Send + Sync {
184    /// Create the per-deployment `EXTERNAL`-controller service if absent.
185    /// Idempotent: a second call against an existing service is `Ok`.
186    async fn ensure_service(&self, spec: &ServiceSpec) -> Result<(), EcsTargetError>;
187
188    /// Register the revision's task definition + create its task set.
189    /// Idempotent: re-creating a task set for an existing `(deployment,
190    /// revision)` returns the existing [`TaskSetHandle`] without registering a
191    /// second definition.
192    async fn create_task_set(&self, spec: &TaskSetSpec) -> Result<TaskSetHandle, EcsTargetError>;
193
194    /// Poll a task set's rollout status (steady state + target-group health).
195    async fn task_set_stability(
196        &self,
197        task_set: &TaskSetRef,
198    ) -> Result<TaskSetStability, EcsTargetError>;
199
200    /// Delete a task set and deregister its task definition. Idempotent
201    /// against an already-absent set.
202    async fn delete_task_set(&self, task_set: &TaskSetRef) -> Result<(), EcsTargetError>;
203
204    /// Set the listener's weighted forward action across the deployment's
205    /// task-set target groups (the ALB mirror of the env's `TrafficSplit`).
206    async fn apply_listener_weights(
207        &self,
208        listener: &ListenerRef,
209        weights: &[TargetGroupWeight],
210    ) -> Result<(), EcsTargetError>;
211}
212
213/// The default target for a handler with no real client wired. Every provider
214/// method fails honestly so an unconfigured deployer cannot silently "succeed".
215#[derive(Debug, Default, Clone, Copy)]
216pub struct UnconfiguredEcsTarget;
217
218#[async_trait]
219impl EcsDeployTarget for UnconfiguredEcsTarget {
220    async fn ensure_service(&self, _spec: &ServiceSpec) -> Result<(), EcsTargetError> {
221        Err(EcsTargetError::Unconfigured)
222    }
223    async fn create_task_set(&self, _spec: &TaskSetSpec) -> Result<TaskSetHandle, EcsTargetError> {
224        Err(EcsTargetError::Unconfigured)
225    }
226    async fn task_set_stability(
227        &self,
228        _task_set: &TaskSetRef,
229    ) -> Result<TaskSetStability, EcsTargetError> {
230        Err(EcsTargetError::Unconfigured)
231    }
232    async fn delete_task_set(&self, _task_set: &TaskSetRef) -> Result<(), EcsTargetError> {
233        Err(EcsTargetError::Unconfigured)
234    }
235    async fn apply_listener_weights(
236        &self,
237        _listener: &ListenerRef,
238        _weights: &[TargetGroupWeight],
239    ) -> Result<(), EcsTargetError> {
240        Err(EcsTargetError::Unconfigured)
241    }
242}
243
244/// In-memory fake target for unit tests + the conformance bench.
245///
246/// Records the side effects so tests can assert what landed. `task_set_stability`
247/// reports stable immediately, so the warm readiness wait resolves on the first
248/// poll (the timeout path is exercised by a scripted fake in `deployer.rs`).
249#[derive(Debug, Default)]
250pub struct InMemoryEcs {
251    /// Deployments whose service has been ensured.
252    services: std::sync::Mutex<std::collections::BTreeSet<DeploymentId>>,
253    /// Task sets keyed by `(deployment, revision)`.
254    task_sets:
255        std::sync::Mutex<std::collections::BTreeMap<(DeploymentId, RevisionId), TaskSetHandle>>,
256    /// Last-applied listener weights per deployment.
257    weights: std::sync::Mutex<std::collections::BTreeMap<DeploymentId, Vec<TargetGroupWeight>>>,
258    /// Last-applied listener routing per deployment — the `ListenerRef::routing`
259    /// the verb carried (`None` inner = a default-action write, `Some` = a
260    /// per-deployment rule). Records the deployer's contract decision, which the
261    /// real target turns into a `ModifyListener` vs `CreateRule`/`ModifyRule`.
262    routing: std::sync::Mutex<std::collections::BTreeMap<DeploymentId, Option<ListenerRouting>>>,
263}
264
265impl InMemoryEcs {
266    /// Snapshot of the ensured services.
267    pub fn services(&self) -> std::collections::BTreeSet<DeploymentId> {
268        self.services.lock().expect("mutex not poisoned").clone()
269    }
270
271    /// Snapshot of the live task sets.
272    pub fn task_sets(
273        &self,
274    ) -> std::collections::BTreeMap<(DeploymentId, RevisionId), TaskSetHandle> {
275        self.task_sets.lock().expect("mutex not poisoned").clone()
276    }
277
278    /// Snapshot of the last-applied weights for a deployment.
279    pub fn weights_for(&self, deployment_id: DeploymentId) -> Option<Vec<TargetGroupWeight>> {
280        self.weights
281            .lock()
282            .expect("mutex not poisoned")
283            .get(&deployment_id)
284            .cloned()
285    }
286
287    /// Snapshot of the routing the last `apply_listener_weights` carried for a
288    /// deployment. Outer `None` = no listener write recorded; inner `None` = a
289    /// default-action write; inner `Some` = a per-deployment rule.
290    pub fn routing_for(&self, deployment_id: DeploymentId) -> Option<Option<ListenerRouting>> {
291        self.routing
292            .lock()
293            .expect("mutex not poisoned")
294            .get(&deployment_id)
295            .cloned()
296    }
297}
298
299#[async_trait]
300impl EcsDeployTarget for InMemoryEcs {
301    async fn ensure_service(&self, spec: &ServiceSpec) -> Result<(), EcsTargetError> {
302        self.services
303            .lock()
304            .expect("mutex not poisoned")
305            .insert(spec.deployment_id);
306        Ok(())
307    }
308
309    async fn create_task_set(&self, spec: &TaskSetSpec) -> Result<TaskSetHandle, EcsTargetError> {
310        let key = (spec.deployment_id, spec.revision_id);
311        let mut sets = self.task_sets.lock().expect("mutex not poisoned");
312        // Idempotent: re-creating an existing task set returns the existing
313        // handle without minting a new id.
314        if let Some(existing) = sets.get(&key) {
315            return Ok(existing.clone());
316        }
317        let handle = TaskSetHandle {
318            task_set_id: format!("ts-{}-{}", spec.deployment_id.0, spec.revision_id.0),
319            task_def_arn: format!("td-{}-{}", spec.deployment_id.0, spec.revision_id.0),
320        };
321        sets.insert(key, handle.clone());
322        Ok(handle)
323    }
324
325    async fn task_set_stability(
326        &self,
327        _task_set: &TaskSetRef,
328    ) -> Result<TaskSetStability, EcsTargetError> {
329        // In-memory tasks are instantly stable.
330        Ok(TaskSetStability {
331            stabilized: true,
332            running: 1,
333            desired: 1,
334        })
335    }
336
337    async fn delete_task_set(&self, task_set: &TaskSetRef) -> Result<(), EcsTargetError> {
338        // Idempotent: removing an absent set is Ok.
339        self.task_sets
340            .lock()
341            .expect("mutex not poisoned")
342            .remove(&(task_set.deployment_id, task_set.revision_id));
343        Ok(())
344    }
345
346    async fn apply_listener_weights(
347        &self,
348        listener: &ListenerRef,
349        weights: &[TargetGroupWeight],
350    ) -> Result<(), EcsTargetError> {
351        self.weights
352            .lock()
353            .expect("mutex not poisoned")
354            .insert(listener.deployment_id, weights.to_vec());
355        self.routing
356            .lock()
357            .expect("mutex not poisoned")
358            .insert(listener.deployment_id, listener.routing.clone());
359        Ok(())
360    }
361}