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}