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