Skip to main content

fakecloud_ecs/
state.rs

1use std::collections::BTreeMap;
2use std::sync::Arc;
3
4use chrono::{DateTime, Utc};
5use parking_lot::RwLock;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9pub type SharedEcsState = Arc<RwLock<fakecloud_core::multi_account::MultiAccountState<EcsState>>>;
10
11impl fakecloud_core::multi_account::AccountState for EcsState {
12    fn new_for_account(account_id: &str, region: &str, _endpoint: &str) -> Self {
13        Self::new(account_id, region)
14    }
15}
16
17pub const ECS_SNAPSHOT_SCHEMA_VERSION: u32 = 4;
18
19/// Top-level persisted ECS snapshot. Mirrors the multi-account snapshot
20/// convention used by Kinesis/ECR/ElastiCache so `main.rs` can share the
21/// load/save pattern.
22#[derive(Clone, Debug, Serialize, Deserialize)]
23pub struct EcsSnapshot {
24    pub schema_version: u32,
25    pub accounts: Option<fakecloud_core::multi_account::MultiAccountState<EcsState>>,
26}
27
28#[derive(Clone, Debug, Default, Serialize, Deserialize)]
29pub struct EcsState {
30    pub account_id: String,
31    pub region: String,
32    /// Cluster state keyed by cluster name.
33    pub clusters: BTreeMap<String, Cluster>,
34    /// Task definitions keyed by `family` -> `revision` -> definition.
35    /// ECS revisions monotonically increase per-family regardless of
36    /// deregistration, so we track the running counter separately.
37    pub task_definitions: BTreeMap<String, BTreeMap<i32, TaskDefinition>>,
38    /// Running revision counter per family. Grows monotonically even
39    /// after task definitions are deregistered or deleted.
40    pub next_revision: BTreeMap<String, i32>,
41    /// Account-default settings (PutAccountSettingDefault). Keyed by
42    /// setting name (e.g. `serviceLongArnFormat`).
43    pub account_setting_defaults: BTreeMap<String, String>,
44    /// Per-principal account settings (PutAccountSetting). Keyed by
45    /// principal ARN, then setting name.
46    pub principal_account_settings: BTreeMap<String, BTreeMap<String, String>>,
47    /// Tasks keyed by task ID (the trailing segment of the task ARN).
48    #[serde(default)]
49    pub tasks: BTreeMap<String, Task>,
50    /// Lifecycle event log for introspection. Bounded at 1024 entries
51    /// (oldest dropped) so long-running servers don't grow unboundedly.
52    #[serde(default)]
53    pub events: Vec<LifecycleEvent>,
54    /// Services keyed by service name within an account. ECS requires
55    /// unique service names per cluster, and since service names are
56    /// already unique per-cluster globally we scope keys by
57    /// `cluster_name:service_name` in [`EcsState::service_key`].
58    #[serde(default)]
59    pub services: BTreeMap<String, Service>,
60    /// Container instances keyed by `cluster/arn-suffix`. Users register
61    /// EC2 hosts here; fakecloud still runs tasks via Docker regardless,
62    /// but the control-plane records remain so `DescribeContainerInstances`
63    /// round-trips.
64    #[serde(default)]
65    pub container_instances: BTreeMap<String, ContainerInstance>,
66    /// Custom attributes keyed by `cluster/target-arn-or-id/name`.
67    #[serde(default)]
68    pub attributes: BTreeMap<String, Attribute>,
69    /// Capacity providers keyed by name.
70    #[serde(default)]
71    pub capacity_providers: BTreeMap<String, CapacityProvider>,
72    /// Task sets keyed by `cluster/service/task-set-id`.
73    #[serde(default)]
74    pub task_sets: BTreeMap<String, TaskSet>,
75    /// Daemon task definitions keyed by `family` -> `revision` -> definition.
76    /// Same shape as `task_definitions` but isolated since daemon defs use
77    /// the dedicated `RegisterDaemonTaskDefinition` op and have their own
78    /// revision counter.
79    #[serde(default)]
80    pub daemon_task_definitions: BTreeMap<String, BTreeMap<i32, DaemonTaskDefinition>>,
81    /// Per-family monotonic revision counter for daemon task defs.
82    #[serde(default)]
83    pub next_daemon_revision: BTreeMap<String, i32>,
84    /// Daemons keyed by `cluster/daemon-name`. Daemons are cluster-scoped
85    /// and run one task per matching capacity provider per AWS spec.
86    #[serde(default)]
87    pub daemons: BTreeMap<String, Daemon>,
88    /// Daemon deployment history keyed by deployment ARN. Each
89    /// CreateDaemon / UpdateDaemon mints a new deployment record.
90    #[serde(default)]
91    pub daemon_deployments: BTreeMap<String, DaemonDeployment>,
92    /// Express Gateway services keyed by `cluster/service-name`. The
93    /// 2026 Express Gateway feature is a serverless container service
94    /// with built-in load balancing and autoscaling.
95    #[serde(default)]
96    pub express_gateway_services: BTreeMap<String, ExpressGatewayService>,
97    /// Service revisions keyed by `serviceRevisionArn` (`{service_arn}:{n}`).
98    /// A revision is minted on CreateService and on each UpdateService that
99    /// changes the task definition, so DescribeServiceRevisions can return the
100    /// real configuration snapshot instead of an empty stub.
101    #[serde(default)]
102    pub service_revisions: BTreeMap<String, ServiceRevision>,
103}
104
105/// A point-in-time snapshot of a service's configuration, addressable by
106/// `serviceRevisionArn`. Mirrors the subset of AWS's ServiceRevision that
107/// callers key on when auditing a deployment.
108#[derive(Clone, Debug, Serialize, Deserialize)]
109pub struct ServiceRevision {
110    pub service_revision_arn: String,
111    pub service_arn: String,
112    pub cluster_arn: String,
113    pub task_definition_arn: String,
114    pub launch_type: String,
115    pub created_at: DateTime<Utc>,
116}
117
118impl EcsState {
119    pub fn new(account_id: &str, region: &str) -> Self {
120        Self {
121            account_id: account_id.to_string(),
122            region: region.to_string(),
123            clusters: BTreeMap::new(),
124            task_definitions: BTreeMap::new(),
125            next_revision: BTreeMap::new(),
126            account_setting_defaults: BTreeMap::new(),
127            principal_account_settings: BTreeMap::new(),
128            tasks: BTreeMap::new(),
129            events: Vec::new(),
130            services: BTreeMap::new(),
131            container_instances: BTreeMap::new(),
132            attributes: BTreeMap::new(),
133            capacity_providers: BTreeMap::new(),
134            task_sets: BTreeMap::new(),
135            daemon_task_definitions: BTreeMap::new(),
136            next_daemon_revision: BTreeMap::new(),
137            daemons: BTreeMap::new(),
138            daemon_deployments: BTreeMap::new(),
139            express_gateway_services: BTreeMap::new(),
140            service_revisions: BTreeMap::new(),
141        }
142    }
143
144    pub fn reset(&mut self) {
145        self.clusters.clear();
146        self.task_definitions.clear();
147        self.next_revision.clear();
148        self.account_setting_defaults.clear();
149        self.principal_account_settings.clear();
150        self.tasks.clear();
151        self.events.clear();
152        self.services.clear();
153        self.container_instances.clear();
154        self.attributes.clear();
155        self.capacity_providers.clear();
156        self.task_sets.clear();
157        self.daemon_task_definitions.clear();
158        self.next_daemon_revision.clear();
159        self.daemons.clear();
160        self.daemon_deployments.clear();
161        self.express_gateway_services.clear();
162        self.service_revisions.clear();
163    }
164
165    /// Mint and store a new service revision for `service`, numbered by how
166    /// many revisions already exist for that service (`{service_arn}:{n}`).
167    /// Returns the new serviceRevisionArn.
168    pub fn record_service_revision(&mut self, service: &Service) -> String {
169        let n = self
170            .service_revisions
171            .values()
172            .filter(|r| r.service_arn == service.service_arn)
173            .count() as i32
174            + 1;
175        let arn = format!("{}:{}", service.service_arn, n);
176        self.service_revisions.insert(
177            arn.clone(),
178            ServiceRevision {
179                service_revision_arn: arn.clone(),
180                service_arn: service.service_arn.clone(),
181                cluster_arn: service.cluster_arn.clone(),
182                task_definition_arn: service.task_definition_arn.clone(),
183                launch_type: service.launch_type.clone(),
184                created_at: Utc::now(),
185            },
186        );
187        arn
188    }
189
190    /// Services are uniquely identified by `(cluster, name)` within an
191    /// account; this helper composes the storage key used in
192    /// `self.services`.
193    pub fn service_key(cluster_name: &str, service_name: &str) -> String {
194        format!("{}/{}", cluster_name, service_name)
195    }
196
197    pub fn service_arn(&self, cluster_name: &str, service_name: &str) -> String {
198        if self.arn_format_disabled("serviceLongArnFormat") {
199            // Pre-Nov-2018 short form: no cluster segment.
200            format!(
201                "arn:aws:ecs:{}:{}:service/{}",
202                self.region, self.account_id, service_name
203            )
204        } else {
205            format!(
206                "arn:aws:ecs:{}:{}:service/{}/{}",
207                self.region, self.account_id, cluster_name, service_name
208            )
209        }
210    }
211
212    pub fn task_arn(&self, cluster_name: &str, task_id: &str) -> String {
213        if self.arn_format_disabled("taskLongArnFormat") {
214            format!(
215                "arn:aws:ecs:{}:{}:task/{}",
216                self.region, self.account_id, task_id
217            )
218        } else {
219            format!(
220                "arn:aws:ecs:{}:{}:task/{}/{}",
221                self.region, self.account_id, cluster_name, task_id
222            )
223        }
224    }
225
226    pub fn container_instance_arn(&self, cluster_name: &str, instance_id: &str) -> String {
227        if self.arn_format_disabled("containerInstanceLongArnFormat") {
228            format!(
229                "arn:aws:ecs:{}:{}:container-instance/{}",
230                self.region, self.account_id, instance_id
231            )
232        } else {
233            format!(
234                "arn:aws:ecs:{}:{}:container-instance/{}/{}",
235                self.region, self.account_id, cluster_name, instance_id
236            )
237        }
238    }
239
240    /// Resolve the effective value of an account setting. Principal
241    /// overrides win over account-level defaults, matching AWS's
242    /// PutAccountSetting / PutAccountSettingDefault layering. With no
243    /// `principal_arn` argument the caller gets the account default.
244    pub fn effective_account_setting(
245        &self,
246        name: &str,
247        principal_arn: Option<&str>,
248    ) -> Option<String> {
249        if let Some(arn) = principal_arn {
250            if let Some(p) = self.principal_account_settings.get(arn) {
251                if let Some(v) = p.get(name) {
252                    return Some(v.clone());
253                }
254            }
255        }
256        self.account_setting_defaults.get(name).cloned()
257    }
258
259    /// `true` when the given `*LongArnFormat` setting has been set to
260    /// `disabled`. The default (including unset) is long format —
261    /// matches AWS's current behaviour where long ARNs are mandatory
262    /// since Jan 2020 but the settings still flip for backward-compat.
263    fn arn_format_disabled(&self, setting_name: &str) -> bool {
264        matches!(
265            self.effective_account_setting(setting_name, None)
266                .as_deref(),
267            Some("disabled")
268        )
269    }
270
271    /// Append a lifecycle event, trimming the oldest when the cap is hit.
272    pub fn push_event(&mut self, event: LifecycleEvent) {
273        const MAX_EVENTS: usize = 1024;
274        if self.events.len() >= MAX_EVENTS {
275            self.events.drain(0..self.events.len() - MAX_EVENTS + 1);
276        }
277        self.events.push(event);
278    }
279
280    pub fn cluster_arn(&self, cluster_name: &str) -> String {
281        format!(
282            "arn:aws:ecs:{}:{}:cluster/{}",
283            self.region, self.account_id, cluster_name
284        )
285    }
286
287    pub fn task_definition_arn(&self, family: &str, revision: i32) -> String {
288        format!(
289            "arn:aws:ecs:{}:{}:task-definition/{}:{}",
290            self.region, self.account_id, family, revision
291        )
292    }
293
294    /// Given a user-supplied cluster reference (name or ARN), return the
295    /// cluster name. Defaults to `"default"` when `None`/empty, matching
296    /// the AWS CLI behaviour.
297    pub fn resolve_cluster_name(input: Option<&str>) -> String {
298        let raw = input.unwrap_or("").trim();
299        if raw.is_empty() {
300            return "default".to_string();
301        }
302        if let Some(name) = raw.rsplit_once('/').map(|(_, n)| n) {
303            return name.to_string();
304        }
305        raw.to_string()
306    }
307
308    /// Bump and return the next revision number for a family. Never
309    /// reused: monotonically increases even across deregistration.
310    pub fn allocate_revision(&mut self, family: &str) -> i32 {
311        let next = self.next_revision.entry(family.to_string()).or_insert(0);
312        *next += 1;
313        *next
314    }
315}
316
317#[derive(Clone, Debug, Serialize, Deserialize)]
318pub struct Cluster {
319    pub cluster_name: String,
320    pub cluster_arn: String,
321    pub status: String,
322    pub registered_container_instances_count: i32,
323    pub running_tasks_count: i32,
324    pub pending_tasks_count: i32,
325    pub active_services_count: i32,
326    #[serde(default)]
327    pub statistics: Vec<Value>,
328    #[serde(default)]
329    pub tags: Vec<TagEntry>,
330    #[serde(default)]
331    pub settings: Vec<Value>,
332    pub configuration: Option<Value>,
333    #[serde(default)]
334    pub capacity_providers: Vec<String>,
335    #[serde(default)]
336    pub default_capacity_provider_strategy: Vec<Value>,
337    #[serde(default)]
338    pub attachments: Vec<Value>,
339    pub attachments_status: Option<String>,
340    pub service_connect_defaults: Option<Value>,
341    pub created_at: DateTime<Utc>,
342}
343
344impl Cluster {
345    pub fn new(cluster_name: &str, cluster_arn: String) -> Self {
346        Self {
347            cluster_name: cluster_name.to_string(),
348            cluster_arn,
349            status: "ACTIVE".to_string(),
350            registered_container_instances_count: 0,
351            running_tasks_count: 0,
352            pending_tasks_count: 0,
353            active_services_count: 0,
354            statistics: Vec::new(),
355            tags: Vec::new(),
356            settings: Vec::new(),
357            configuration: None,
358            capacity_providers: Vec::new(),
359            default_capacity_provider_strategy: Vec::new(),
360            attachments: Vec::new(),
361            attachments_status: None,
362            service_connect_defaults: None,
363            created_at: Utc::now(),
364        }
365    }
366}
367
368#[derive(Clone, Debug, Serialize, Deserialize)]
369pub struct TagEntry {
370    pub key: String,
371    pub value: String,
372}
373
374#[derive(Clone, Debug, Serialize, Deserialize)]
375pub struct TaskDefinition {
376    pub family: String,
377    pub revision: i32,
378    pub task_definition_arn: String,
379    /// Free-form container definitions preserved as the JSON the caller
380    /// supplied. ECS accepts so many optional fields that round-tripping
381    /// the raw JSON is simpler and more faithful than modeling a struct
382    /// with hundreds of members per container.
383    #[serde(default)]
384    pub container_definitions: Vec<Value>,
385    pub status: String,
386    pub task_role_arn: Option<String>,
387    pub execution_role_arn: Option<String>,
388    pub network_mode: Option<String>,
389    #[serde(default)]
390    pub requires_compatibilities: Vec<String>,
391    #[serde(default)]
392    pub compatibilities: Vec<String>,
393    pub cpu: Option<String>,
394    pub memory: Option<String>,
395    pub pid_mode: Option<String>,
396    pub ipc_mode: Option<String>,
397    #[serde(default)]
398    pub volumes: Vec<Value>,
399    #[serde(default)]
400    pub placement_constraints: Vec<Value>,
401    pub proxy_configuration: Option<Value>,
402    #[serde(default)]
403    pub inference_accelerators: Vec<Value>,
404    pub ephemeral_storage: Option<Value>,
405    pub runtime_platform: Option<Value>,
406    #[serde(default)]
407    pub requires_attributes: Vec<Value>,
408    pub registered_at: DateTime<Utc>,
409    pub registered_by: Option<String>,
410    pub deregistered_at: Option<DateTime<Utc>>,
411    #[serde(default)]
412    pub tags: Vec<TagEntry>,
413    pub enable_fault_injection: Option<bool>,
414}
415
416#[derive(Clone, Debug, Serialize, Deserialize)]
417pub struct Task {
418    pub task_arn: String,
419    pub task_id: String,
420    pub cluster_arn: String,
421    pub cluster_name: String,
422    pub task_definition_arn: String,
423    pub family: String,
424    pub revision: i32,
425    /// Container instance this task was placed on. Populated for EC2 /
426    /// EXTERNAL launch types after placement evaluation.
427    #[serde(default)]
428    pub container_instance_arn: Option<String>,
429    /// Capacity provider this task was placed on. Set when the launch
430    /// went through a `capacityProviderStrategy`; absent for direct
431    /// `launchType=EC2/FARGATE` calls. AWS's Task model emits this at
432    /// the top level next to `launchType`.
433    #[serde(default)]
434    pub capacity_provider_name: Option<String>,
435    /// Current lifecycle state: PROVISIONING, PENDING, RUNNING,
436    /// DEPROVISIONING, STOPPED.
437    pub last_status: String,
438    /// What the caller asked for: usually RUNNING, or STOPPED once
439    /// `StopTask` / `StopService` hits.
440    pub desired_status: String,
441    pub launch_type: String,
442    pub platform_version: Option<String>,
443    pub cpu: Option<String>,
444    pub memory: Option<String>,
445    #[serde(default)]
446    pub containers: Vec<Container>,
447    #[serde(default)]
448    pub overrides: Value,
449    pub started_by: Option<String>,
450    pub group: Option<String>,
451    pub connectivity: String,
452    pub stop_code: Option<String>,
453    pub stopped_reason: Option<String>,
454    pub created_at: DateTime<Utc>,
455    pub started_at: Option<DateTime<Utc>>,
456    pub stopping_at: Option<DateTime<Utc>>,
457    pub stopped_at: Option<DateTime<Utc>>,
458    pub pull_started_at: Option<DateTime<Utc>>,
459    pub pull_stopped_at: Option<DateTime<Utc>>,
460    pub connectivity_at: Option<DateTime<Utc>>,
461    pub started_by_ref_id: Option<String>,
462    pub execution_role_arn: Option<String>,
463    pub task_role_arn: Option<String>,
464    #[serde(default)]
465    pub tags: Vec<TagEntry>,
466    /// Log destination derived from the first container's awslogs driver.
467    /// `None` when no awslogs driver is configured — captured stdout/stderr
468    /// is still stored on the task for introspection.
469    pub awslogs: Option<AwsLogsConfig>,
470    /// Captured stdout/stderr from the container. Populated after the
471    /// container exits. Kept here so the introspection endpoint can serve
472    /// logs even when no awslogs driver is configured.
473    #[serde(default)]
474    pub captured_logs: String,
475    /// Task protection state (UpdateTaskProtection). When set, scale-in
476    /// and update-service deployments skip this task until the expiry.
477    pub protection: Option<TaskProtection>,
478    /// Whether ECS Exec is enabled on this task. Inherited from the
479    /// owning service's `enableExecuteCommand` flag (or supplied
480    /// directly on RunTask). Gated when `ExecuteCommand` is invoked.
481    #[serde(default)]
482    pub enable_execute_command: bool,
483    /// Network attachments (ENI, elastic-inference, etc.) populated when
484    /// the task uses `awsvpc` network mode. Synthetic for fakecloud.
485    #[serde(default)]
486    pub attachments: Vec<TaskAttachment>,
487    /// Per-task volume configurations (EBS / FSx) supplied at RunTask or
488    /// inherited from the service. Stored as raw JSON.
489    #[serde(default)]
490    pub volume_configurations: Vec<Value>,
491    /// Task set this task belongs to. Populated when the task is spawned
492    /// by a service using the CODE_DEPLOY deployment controller.
493    #[serde(default)]
494    pub task_set_arn: Option<String>,
495}
496
497#[derive(Clone, Debug, Serialize, Deserialize)]
498pub struct TaskAttachment {
499    pub id: String,
500    #[serde(rename = "type")]
501    pub attachment_type: String,
502    pub status: String,
503    #[serde(default)]
504    pub details: Vec<AttachmentDetail>,
505}
506
507#[derive(Clone, Debug, Serialize, Deserialize)]
508pub struct AttachmentDetail {
509    pub name: String,
510    pub value: String,
511}
512
513#[derive(Clone, Debug, Serialize, Deserialize)]
514pub struct TaskProtection {
515    pub enabled: bool,
516    pub expiration: Option<DateTime<Utc>>,
517}
518
519#[derive(Clone, Debug, Serialize, Deserialize)]
520pub struct Container {
521    pub container_arn: String,
522    pub name: String,
523    pub image: String,
524    pub task_arn: String,
525    pub last_status: String,
526    pub exit_code: Option<i64>,
527    pub reason: Option<String>,
528    pub runtime_id: Option<String>,
529    pub essential: bool,
530    pub cpu: Option<String>,
531    pub memory: Option<String>,
532    pub memory_reservation: Option<String>,
533    #[serde(default)]
534    pub network_bindings: Vec<Value>,
535    #[serde(default)]
536    pub network_interfaces: Vec<Value>,
537    pub health_status: Option<String>,
538    pub managed_agents: Option<Value>,
539    /// Resolved image digest captured at pull time. AWS surfaces this on
540    /// DescribeTasks so callers can pin which exact image revision a task
541    /// is running. `None` until the runtime resolves it post-pull.
542    #[serde(default)]
543    pub image_digest: Option<String>,
544}
545
546#[derive(Clone, Debug, Serialize, Deserialize)]
547pub struct AwsLogsConfig {
548    pub group: String,
549    pub stream_prefix: Option<String>,
550    pub region: String,
551    pub container_name: String,
552}
553
554impl AwsLogsConfig {
555    pub fn stream_name(&self, task_id: &str) -> String {
556        match &self.stream_prefix {
557            Some(p) => format!("{}/{}/{}", p, self.container_name, task_id),
558            None => format!("{}/{}", self.container_name, task_id),
559        }
560    }
561}
562
563#[derive(Clone, Debug, Serialize, Deserialize)]
564pub struct LifecycleEvent {
565    pub at: DateTime<Utc>,
566    pub event_type: String,
567    pub task_arn: Option<String>,
568    pub cluster_arn: Option<String>,
569    pub last_status: Option<String>,
570    pub detail: Value,
571}
572
573#[derive(Clone, Debug, Serialize, Deserialize)]
574pub struct Service {
575    pub service_name: String,
576    pub service_arn: String,
577    pub cluster_name: String,
578    pub cluster_arn: String,
579    pub task_definition_arn: String,
580    pub family: String,
581    pub revision: i32,
582    pub desired_count: i32,
583    pub running_count: i32,
584    pub pending_count: i32,
585    pub launch_type: String,
586    pub status: String,
587    pub scheduling_strategy: String,
588    pub deployment_controller: String,
589    pub minimum_healthy_percent: Option<i32>,
590    pub maximum_percent: Option<i32>,
591    /// Deployment circuit breaker config (opt-in via deploymentConfiguration).
592    pub circuit_breaker: Option<CircuitBreakerConfig>,
593    #[serde(default)]
594    pub deployments: Vec<Deployment>,
595    #[serde(default)]
596    pub load_balancers: Vec<Value>,
597    #[serde(default)]
598    pub service_registries: Vec<Value>,
599    #[serde(default)]
600    pub placement_constraints: Vec<Value>,
601    #[serde(default)]
602    pub placement_strategy: Vec<Value>,
603    #[serde(default)]
604    pub network_configuration: Option<Value>,
605    #[serde(default)]
606    pub tags: Vec<TagEntry>,
607    pub created_at: DateTime<Utc>,
608    pub created_by: Option<String>,
609    pub role_arn: Option<String>,
610    /// Fargate platform version label ("LATEST", "1.4.0", etc). Echoed
611    /// back on DescribeServices.
612    #[serde(default)]
613    pub platform_version: Option<String>,
614    /// Seconds an ECS service waits before failing a task on health
615    /// check failures while a load balancer is still warming up.
616    #[serde(default)]
617    pub health_check_grace_period_seconds: Option<i32>,
618    /// Whether ECS Exec is enabled for tasks launched by this service.
619    #[serde(default)]
620    pub enable_execute_command: bool,
621    /// When true, ECS automatically tags tasks/ENIs with cluster + service
622    /// metadata. Off by default to match AWS.
623    #[serde(default)]
624    pub enable_ecs_managed_tags: bool,
625    /// Tag-propagation source: "TASK_DEFINITION", "SERVICE", or "NONE".
626    /// We model the AWS shape — None here means "NONE" was the effective
627    /// value when the service was created.
628    #[serde(default)]
629    pub propagate_tags: Option<String>,
630    /// Mixed capacity-provider weights that the service uses instead of
631    /// (or alongside) `launch_type`. Stored as raw JSON since the field
632    /// is a list of `{ capacityProvider, weight, base }` records.
633    #[serde(default)]
634    pub capacity_provider_strategy: Vec<Value>,
635    /// AZ-rebalancing toggle for ALB-attached services. ENABLED |
636    /// DISABLED (default). Surface field for AvailabilityZoneRebalancing.
637    #[serde(default)]
638    pub availability_zone_rebalancing: Option<String>,
639    /// Per-service volume configurations (EBS / FSx) inherited by tasks
640    /// launched under this service. Stored as raw JSON.
641    #[serde(default)]
642    pub volume_configurations: Vec<Value>,
643    /// Service Connect configuration (namespace + service mappings) supplied on
644    /// Create/UpdateService. Preserved as raw JSON so every optional field
645    /// round-trips on DescribeServices instead of silently dropping.
646    #[serde(default)]
647    pub service_connect_configuration: Option<Value>,
648}
649
650#[derive(Clone, Debug, Serialize, Deserialize)]
651pub struct CircuitBreakerConfig {
652    pub enable: bool,
653    pub rollback: bool,
654}
655
656#[derive(Clone, Debug, Serialize, Deserialize)]
657pub struct Deployment {
658    pub deployment_id: String,
659    pub status: String,
660    pub task_definition_arn: String,
661    pub desired_count: i32,
662    pub pending_count: i32,
663    pub running_count: i32,
664    pub failed_tasks: i32,
665    pub created_at: DateTime<Utc>,
666    pub updated_at: DateTime<Utc>,
667    pub launch_type: String,
668    pub rollout_state: String,
669    pub rollout_state_reason: Option<String>,
670    /// Lifecycle hooks attached via `deploymentConfiguration.lifecycleHooks`,
671    /// preserved as the raw JSON the caller supplied so every optional field
672    /// round-trips faithfully.
673    #[serde(default)]
674    pub lifecycle_hooks: Vec<Value>,
675    /// ID of the PAUSE lifecycle hook the deployment is currently waiting on.
676    /// `Some` while the deployment is paused; cleared by
677    /// `ContinueServiceDeployment`.
678    #[serde(default)]
679    pub pending_hook_id: Option<String>,
680    /// Lifecycle stage the deployment paused at (e.g.
681    /// `POST_PRODUCTION_TRAFFIC_SHIFT`). `None` when the deployment is not
682    /// paused on a hook.
683    #[serde(default)]
684    pub lifecycle_stage: Option<String>,
685}
686
687#[derive(Clone, Debug, Serialize, Deserialize)]
688pub struct ContainerInstance {
689    pub container_instance_arn: String,
690    pub ec2_instance_id: Option<String>,
691    pub cluster_name: String,
692    pub cluster_arn: String,
693    pub status: String,
694    pub version: i64,
695    pub version_info: Option<Value>,
696    pub agent_connected: bool,
697    pub agent_update_status: Option<String>,
698    pub remaining_resources: Vec<Value>,
699    pub registered_resources: Vec<Value>,
700    pub running_tasks_count: i32,
701    pub pending_tasks_count: i32,
702    pub registered_at: DateTime<Utc>,
703    #[serde(default)]
704    pub attributes: Vec<AttributeRef>,
705    #[serde(default)]
706    pub tags: Vec<TagEntry>,
707    pub capacity_provider_name: Option<String>,
708    pub health_status: Option<Value>,
709}
710
711#[derive(Clone, Debug, Serialize, Deserialize)]
712pub struct AttributeRef {
713    pub name: String,
714    pub value: Option<String>,
715    pub target_type: Option<String>,
716    pub target_id: Option<String>,
717}
718
719#[derive(Clone, Debug, Serialize, Deserialize)]
720pub struct Attribute {
721    pub cluster_name: String,
722    pub target_type: String,
723    pub target_id: String,
724    pub name: String,
725    pub value: Option<String>,
726}
727
728#[derive(Clone, Debug, Serialize, Deserialize)]
729pub struct CapacityProvider {
730    pub name: String,
731    pub arn: String,
732    pub status: String,
733    pub auto_scaling_group_provider: Option<Value>,
734    pub update_status: Option<String>,
735    pub update_status_reason: Option<String>,
736    pub created_at: DateTime<Utc>,
737    #[serde(default)]
738    pub tags: Vec<TagEntry>,
739}
740
741#[derive(Clone, Debug, Serialize, Deserialize)]
742pub struct TaskSet {
743    pub task_set_id: String,
744    pub task_set_arn: String,
745    pub service_arn: String,
746    pub cluster_arn: String,
747    pub service_name: String,
748    pub cluster_name: String,
749    pub external_id: Option<String>,
750    pub status: String,
751    pub task_definition: String,
752    pub computed_desired_count: i32,
753    pub pending_count: i32,
754    pub running_count: i32,
755    pub launch_type: Option<String>,
756    pub platform_version: Option<String>,
757    pub scale: Option<Value>,
758    pub stability_status: String,
759    pub created_at: DateTime<Utc>,
760    pub updated_at: DateTime<Utc>,
761    #[serde(default)]
762    pub load_balancers: Vec<Value>,
763    #[serde(default)]
764    pub service_registries: Vec<Value>,
765    #[serde(default)]
766    pub capacity_provider_strategy: Vec<Value>,
767    #[serde(default)]
768    pub tags: Vec<TagEntry>,
769}
770
771/// Daemon task definition. Same structural shape as a regular
772/// TaskDefinition but registered via `RegisterDaemonTaskDefinition` and
773/// kept in a separate per-family revision counter.
774#[derive(Clone, Debug, Serialize, Deserialize)]
775pub struct DaemonTaskDefinition {
776    pub family: String,
777    pub revision: i32,
778    pub task_definition_arn: String,
779    pub status: String,
780    pub container_definitions: Vec<Value>,
781    pub task_role_arn: Option<String>,
782    pub execution_role_arn: Option<String>,
783    pub cpu: Option<String>,
784    pub memory: Option<String>,
785    #[serde(default)]
786    pub volumes: Vec<Value>,
787    pub registered_at: DateTime<Utc>,
788    pub deregistered_at: Option<DateTime<Utc>>,
789    #[serde(default)]
790    pub tags: Vec<TagEntry>,
791}
792
793/// Daemon resource. Daemons run one task per matching capacity
794/// provider in the cluster. Modeled after the ECS Service struct
795/// since the lifecycle / status / deployment story is parallel.
796#[derive(Clone, Debug, Serialize, Deserialize)]
797pub struct Daemon {
798    pub daemon_name: String,
799    pub daemon_arn: String,
800    pub cluster_arn: String,
801    pub cluster_name: String,
802    pub daemon_task_definition_arn: String,
803    pub status: String,
804    pub deployment_arn: String,
805    pub created_at: DateTime<Utc>,
806    pub updated_at: DateTime<Utc>,
807    #[serde(default)]
808    pub capacity_provider_arns: Vec<String>,
809    pub deployment_configuration: Option<Value>,
810    pub propagate_tags: Option<String>,
811    pub enable_ecs_managed_tags: bool,
812    pub enable_execute_command: bool,
813    pub client_token: Option<String>,
814    #[serde(default)]
815    pub tags: Vec<TagEntry>,
816    /// Revision history of deployment ARNs in chronological order.
817    #[serde(default)]
818    pub deployment_history: Vec<String>,
819    /// Active task IDs spawned by this daemon.
820    #[serde(default)]
821    pub task_arns: Vec<String>,
822}
823
824/// Single deployment record. Created on every CreateDaemon /
825/// UpdateDaemon and retained so DescribeDaemonDeployments and
826/// DescribeDaemonRevisions have something to return.
827#[derive(Clone, Debug, Serialize, Deserialize)]
828pub struct DaemonDeployment {
829    pub deployment_arn: String,
830    pub daemon_arn: String,
831    pub daemon_name: String,
832    pub cluster_arn: String,
833    pub task_definition_arn: String,
834    pub status: String,
835    pub revision: i64,
836    pub created_at: DateTime<Utc>,
837    pub updated_at: DateTime<Utc>,
838}
839
840/// 2026 Express Gateway service — serverless container service with
841/// integrated load balancing, health checks, and autoscaling.
842#[derive(Clone, Debug, Serialize, Deserialize)]
843pub struct ExpressGatewayService {
844    pub service_name: String,
845    pub service_arn: String,
846    pub cluster_arn: String,
847    pub cluster_name: String,
848    pub status: String,
849    pub execution_role_arn: String,
850    pub infrastructure_role_arn: String,
851    pub task_role_arn: Option<String>,
852    pub primary_container: Value,
853    pub network_configuration: Option<Value>,
854    pub health_check_path: Option<String>,
855    pub cpu: Option<String>,
856    pub memory: Option<String>,
857    pub scaling_target: Option<Value>,
858    pub created_at: DateTime<Utc>,
859    pub updated_at: DateTime<Utc>,
860    #[serde(default)]
861    pub tags: Vec<TagEntry>,
862}
863
864impl EcsState {
865    /// Composite key for daemon storage (`cluster_name/daemon_name`).
866    pub fn daemon_key(cluster: &str, name: &str) -> String {
867        format!("{}/{}", cluster, name)
868    }
869
870    /// Composite key for express-gateway storage (`cluster_name/service_name`).
871    pub fn express_gateway_key(cluster: &str, name: &str) -> String {
872        format!("{}/{}", cluster, name)
873    }
874
875    /// Allocate the next monotonic revision for a daemon task family.
876    pub fn allocate_daemon_revision(&mut self, family: &str) -> i32 {
877        let entry = self
878            .next_daemon_revision
879            .entry(family.to_string())
880            .or_insert(0);
881        *entry += 1;
882        *entry
883    }
884
885    /// Build a daemon ARN for a (cluster, name) pair under this account/region.
886    pub fn daemon_arn(&self, cluster: &str, name: &str) -> String {
887        fakecloud_aws::arn::Arn::new(
888            "ecs",
889            &self.region,
890            &self.account_id,
891            &format!("daemon/{}/{}", cluster, name),
892        )
893        .to_string()
894    }
895
896    /// Build an express-gateway service ARN.
897    pub fn express_gateway_arn(&self, cluster: &str, name: &str) -> String {
898        fakecloud_aws::arn::Arn::new(
899            "ecs",
900            &self.region,
901            &self.account_id,
902            &format!("express-gateway-service/{}/{}", cluster, name),
903        )
904        .to_string()
905    }
906
907    /// Build a daemon task definition ARN for a `family:revision` pair.
908    pub fn daemon_task_definition_arn(&self, family: &str, revision: i32) -> String {
909        fakecloud_aws::arn::Arn::new(
910            "ecs",
911            &self.region,
912            &self.account_id,
913            &format!("daemon-task-definition/{}:{}", family, revision),
914        )
915        .to_string()
916    }
917
918    /// Build a daemon deployment ARN.
919    pub fn daemon_deployment_arn(&self, daemon_name: &str, deployment_id: &str) -> String {
920        fakecloud_aws::arn::Arn::new(
921            "ecs",
922            &self.region,
923            &self.account_id,
924            &format!("daemon-deployment/{}/{}", daemon_name, deployment_id),
925        )
926        .to_string()
927    }
928}
929
930#[cfg(test)]
931mod tests {
932    use super::*;
933
934    #[test]
935    fn resolve_cluster_name_defaults_to_default() {
936        assert_eq!(EcsState::resolve_cluster_name(None), "default");
937        assert_eq!(EcsState::resolve_cluster_name(Some("")), "default");
938        assert_eq!(EcsState::resolve_cluster_name(Some("   ")), "default");
939    }
940
941    #[test]
942    fn resolve_cluster_name_strips_arn_prefix() {
943        assert_eq!(
944            EcsState::resolve_cluster_name(Some("arn:aws:ecs:us-east-1:111122223333:cluster/prod")),
945            "prod"
946        );
947    }
948
949    #[test]
950    fn resolve_cluster_name_passes_through_name() {
951        assert_eq!(EcsState::resolve_cluster_name(Some("prod")), "prod");
952    }
953
954    #[test]
955    fn allocate_revision_monotonic() {
956        let mut s = EcsState::new("111122223333", "us-east-1");
957        assert_eq!(s.allocate_revision("web"), 1);
958        assert_eq!(s.allocate_revision("web"), 2);
959        assert_eq!(s.allocate_revision("worker"), 1);
960        assert_eq!(s.allocate_revision("web"), 3);
961    }
962
963    #[test]
964    fn cluster_arn_format() {
965        let s = EcsState::new("111122223333", "us-east-1");
966        assert_eq!(
967            s.cluster_arn("prod"),
968            "arn:aws:ecs:us-east-1:111122223333:cluster/prod"
969        );
970    }
971
972    #[test]
973    fn task_definition_arn_format() {
974        let s = EcsState::new("111122223333", "us-east-1");
975        assert_eq!(
976            s.task_definition_arn("web", 3),
977            "arn:aws:ecs:us-east-1:111122223333:task-definition/web:3"
978        );
979    }
980
981    #[test]
982    fn task_arn_long_format_default() {
983        let s = EcsState::new("111122223333", "us-east-1");
984        assert_eq!(
985            s.task_arn("prod", "abc123"),
986            "arn:aws:ecs:us-east-1:111122223333:task/prod/abc123"
987        );
988    }
989
990    #[test]
991    fn task_arn_short_when_disabled() {
992        let mut s = EcsState::new("111122223333", "us-east-1");
993        s.account_setting_defaults
994            .insert("taskLongArnFormat".into(), "disabled".into());
995        assert_eq!(
996            s.task_arn("prod", "abc123"),
997            "arn:aws:ecs:us-east-1:111122223333:task/abc123"
998        );
999    }
1000
1001    #[test]
1002    fn service_arn_short_when_disabled() {
1003        let mut s = EcsState::new("111122223333", "us-east-1");
1004        s.account_setting_defaults
1005            .insert("serviceLongArnFormat".into(), "disabled".into());
1006        assert_eq!(
1007            s.service_arn("prod", "web"),
1008            "arn:aws:ecs:us-east-1:111122223333:service/web"
1009        );
1010    }
1011
1012    #[test]
1013    fn container_instance_arn_short_when_disabled() {
1014        let mut s = EcsState::new("111122223333", "us-east-1");
1015        s.account_setting_defaults
1016            .insert("containerInstanceLongArnFormat".into(), "disabled".into());
1017        assert_eq!(
1018            s.container_instance_arn("prod", "i-abc"),
1019            "arn:aws:ecs:us-east-1:111122223333:container-instance/i-abc"
1020        );
1021    }
1022
1023    #[test]
1024    fn principal_setting_overrides_default() {
1025        let mut s = EcsState::new("111122223333", "us-east-1");
1026        s.account_setting_defaults
1027            .insert("taskLongArnFormat".into(), "disabled".into());
1028        let principal = "arn:aws:iam::111122223333:user/alice".to_string();
1029        let mut p = BTreeMap::new();
1030        p.insert("taskLongArnFormat".into(), "enabled".into());
1031        s.principal_account_settings.insert(principal.clone(), p);
1032        assert_eq!(
1033            s.effective_account_setting("taskLongArnFormat", Some(&principal))
1034                .as_deref(),
1035            Some("enabled")
1036        );
1037        // Without principal, default wins.
1038        assert_eq!(
1039            s.effective_account_setting("taskLongArnFormat", None)
1040                .as_deref(),
1041            Some("disabled")
1042        );
1043    }
1044
1045    #[test]
1046    fn reset_clears_all() {
1047        let mut s = EcsState::new("111122223333", "us-east-1");
1048        s.clusters.insert(
1049            "prod".to_string(),
1050            Cluster::new("prod", s.cluster_arn("prod")),
1051        );
1052        s.allocate_revision("web");
1053        s.account_setting_defaults
1054            .insert("serviceLongArnFormat".into(), "enabled".into());
1055        s.reset();
1056        assert!(s.clusters.is_empty());
1057        assert!(s.next_revision.is_empty());
1058        assert!(s.account_setting_defaults.is_empty());
1059    }
1060}