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