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}
620
621#[derive(Clone, Debug, Serialize, Deserialize)]
622pub struct ContainerInstance {
623    pub container_instance_arn: String,
624    pub ec2_instance_id: Option<String>,
625    pub cluster_name: String,
626    pub cluster_arn: String,
627    pub status: String,
628    pub version: i64,
629    pub version_info: Option<Value>,
630    pub agent_connected: bool,
631    pub agent_update_status: Option<String>,
632    pub remaining_resources: Vec<Value>,
633    pub registered_resources: Vec<Value>,
634    pub running_tasks_count: i32,
635    pub pending_tasks_count: i32,
636    pub registered_at: DateTime<Utc>,
637    #[serde(default)]
638    pub attributes: Vec<AttributeRef>,
639    #[serde(default)]
640    pub tags: Vec<TagEntry>,
641    pub capacity_provider_name: Option<String>,
642    pub health_status: Option<Value>,
643}
644
645#[derive(Clone, Debug, Serialize, Deserialize)]
646pub struct AttributeRef {
647    pub name: String,
648    pub value: Option<String>,
649    pub target_type: Option<String>,
650    pub target_id: Option<String>,
651}
652
653#[derive(Clone, Debug, Serialize, Deserialize)]
654pub struct Attribute {
655    pub cluster_name: String,
656    pub target_type: String,
657    pub target_id: String,
658    pub name: String,
659    pub value: Option<String>,
660}
661
662#[derive(Clone, Debug, Serialize, Deserialize)]
663pub struct CapacityProvider {
664    pub name: String,
665    pub arn: String,
666    pub status: String,
667    pub auto_scaling_group_provider: Option<Value>,
668    pub update_status: Option<String>,
669    pub update_status_reason: Option<String>,
670    pub created_at: DateTime<Utc>,
671    #[serde(default)]
672    pub tags: Vec<TagEntry>,
673}
674
675#[derive(Clone, Debug, Serialize, Deserialize)]
676pub struct TaskSet {
677    pub task_set_id: String,
678    pub task_set_arn: String,
679    pub service_arn: String,
680    pub cluster_arn: String,
681    pub service_name: String,
682    pub cluster_name: String,
683    pub external_id: Option<String>,
684    pub status: String,
685    pub task_definition: String,
686    pub computed_desired_count: i32,
687    pub pending_count: i32,
688    pub running_count: i32,
689    pub launch_type: Option<String>,
690    pub platform_version: Option<String>,
691    pub scale: Option<Value>,
692    pub stability_status: String,
693    pub created_at: DateTime<Utc>,
694    pub updated_at: DateTime<Utc>,
695    #[serde(default)]
696    pub load_balancers: Vec<Value>,
697    #[serde(default)]
698    pub service_registries: Vec<Value>,
699    #[serde(default)]
700    pub capacity_provider_strategy: Vec<Value>,
701    #[serde(default)]
702    pub tags: Vec<TagEntry>,
703}
704
705/// Daemon task definition. Same structural shape as a regular
706/// TaskDefinition but registered via `RegisterDaemonTaskDefinition` and
707/// kept in a separate per-family revision counter.
708#[derive(Clone, Debug, Serialize, Deserialize)]
709pub struct DaemonTaskDefinition {
710    pub family: String,
711    pub revision: i32,
712    pub task_definition_arn: String,
713    pub status: String,
714    pub container_definitions: Vec<Value>,
715    pub task_role_arn: Option<String>,
716    pub execution_role_arn: Option<String>,
717    pub cpu: Option<String>,
718    pub memory: Option<String>,
719    #[serde(default)]
720    pub volumes: Vec<Value>,
721    pub registered_at: DateTime<Utc>,
722    pub deregistered_at: Option<DateTime<Utc>>,
723    #[serde(default)]
724    pub tags: Vec<TagEntry>,
725}
726
727/// Daemon resource. Daemons run one task per matching capacity
728/// provider in the cluster. Modeled after the ECS Service struct
729/// since the lifecycle / status / deployment story is parallel.
730#[derive(Clone, Debug, Serialize, Deserialize)]
731pub struct Daemon {
732    pub daemon_name: String,
733    pub daemon_arn: String,
734    pub cluster_arn: String,
735    pub cluster_name: String,
736    pub daemon_task_definition_arn: String,
737    pub status: String,
738    pub deployment_arn: String,
739    pub created_at: DateTime<Utc>,
740    pub updated_at: DateTime<Utc>,
741    #[serde(default)]
742    pub capacity_provider_arns: Vec<String>,
743    pub deployment_configuration: Option<Value>,
744    pub propagate_tags: Option<String>,
745    pub enable_ecs_managed_tags: bool,
746    pub enable_execute_command: bool,
747    pub client_token: Option<String>,
748    #[serde(default)]
749    pub tags: Vec<TagEntry>,
750    /// Revision history of deployment ARNs in chronological order.
751    #[serde(default)]
752    pub deployment_history: Vec<String>,
753    /// Active task IDs spawned by this daemon.
754    #[serde(default)]
755    pub task_arns: Vec<String>,
756}
757
758/// Single deployment record. Created on every CreateDaemon /
759/// UpdateDaemon and retained so DescribeDaemonDeployments and
760/// DescribeDaemonRevisions have something to return.
761#[derive(Clone, Debug, Serialize, Deserialize)]
762pub struct DaemonDeployment {
763    pub deployment_arn: String,
764    pub daemon_arn: String,
765    pub daemon_name: String,
766    pub cluster_arn: String,
767    pub task_definition_arn: String,
768    pub status: String,
769    pub revision: i64,
770    pub created_at: DateTime<Utc>,
771    pub updated_at: DateTime<Utc>,
772}
773
774/// 2026 Express Gateway service — serverless container service with
775/// integrated load balancing, health checks, and autoscaling.
776#[derive(Clone, Debug, Serialize, Deserialize)]
777pub struct ExpressGatewayService {
778    pub service_name: String,
779    pub service_arn: String,
780    pub cluster_arn: String,
781    pub cluster_name: String,
782    pub status: String,
783    pub execution_role_arn: String,
784    pub infrastructure_role_arn: String,
785    pub task_role_arn: Option<String>,
786    pub primary_container: Value,
787    pub network_configuration: Option<Value>,
788    pub health_check_path: Option<String>,
789    pub cpu: Option<String>,
790    pub memory: Option<String>,
791    pub scaling_target: Option<Value>,
792    pub created_at: DateTime<Utc>,
793    pub updated_at: DateTime<Utc>,
794    #[serde(default)]
795    pub tags: Vec<TagEntry>,
796}
797
798impl EcsState {
799    /// Composite key for daemon storage (`cluster_name/daemon_name`).
800    pub fn daemon_key(cluster: &str, name: &str) -> String {
801        format!("{}/{}", cluster, name)
802    }
803
804    /// Composite key for express-gateway storage (`cluster_name/service_name`).
805    pub fn express_gateway_key(cluster: &str, name: &str) -> String {
806        format!("{}/{}", cluster, name)
807    }
808
809    /// Allocate the next monotonic revision for a daemon task family.
810    pub fn allocate_daemon_revision(&mut self, family: &str) -> i32 {
811        let entry = self
812            .next_daemon_revision
813            .entry(family.to_string())
814            .or_insert(0);
815        *entry += 1;
816        *entry
817    }
818
819    /// Build a daemon ARN for a (cluster, name) pair under this account/region.
820    pub fn daemon_arn(&self, cluster: &str, name: &str) -> String {
821        fakecloud_aws::arn::Arn::new(
822            "ecs",
823            &self.region,
824            &self.account_id,
825            &format!("daemon/{}/{}", cluster, name),
826        )
827        .to_string()
828    }
829
830    /// Build an express-gateway service ARN.
831    pub fn express_gateway_arn(&self, cluster: &str, name: &str) -> String {
832        fakecloud_aws::arn::Arn::new(
833            "ecs",
834            &self.region,
835            &self.account_id,
836            &format!("express-gateway-service/{}/{}", cluster, name),
837        )
838        .to_string()
839    }
840
841    /// Build a daemon task definition ARN for a `family:revision` pair.
842    pub fn daemon_task_definition_arn(&self, family: &str, revision: i32) -> String {
843        fakecloud_aws::arn::Arn::new(
844            "ecs",
845            &self.region,
846            &self.account_id,
847            &format!("daemon-task-definition/{}:{}", family, revision),
848        )
849        .to_string()
850    }
851
852    /// Build a daemon deployment ARN.
853    pub fn daemon_deployment_arn(&self, daemon_name: &str, deployment_id: &str) -> String {
854        fakecloud_aws::arn::Arn::new(
855            "ecs",
856            &self.region,
857            &self.account_id,
858            &format!("daemon-deployment/{}/{}", daemon_name, deployment_id),
859        )
860        .to_string()
861    }
862}
863
864#[cfg(test)]
865mod tests {
866    use super::*;
867
868    #[test]
869    fn resolve_cluster_name_defaults_to_default() {
870        assert_eq!(EcsState::resolve_cluster_name(None), "default");
871        assert_eq!(EcsState::resolve_cluster_name(Some("")), "default");
872        assert_eq!(EcsState::resolve_cluster_name(Some("   ")), "default");
873    }
874
875    #[test]
876    fn resolve_cluster_name_strips_arn_prefix() {
877        assert_eq!(
878            EcsState::resolve_cluster_name(Some("arn:aws:ecs:us-east-1:111122223333:cluster/prod")),
879            "prod"
880        );
881    }
882
883    #[test]
884    fn resolve_cluster_name_passes_through_name() {
885        assert_eq!(EcsState::resolve_cluster_name(Some("prod")), "prod");
886    }
887
888    #[test]
889    fn allocate_revision_monotonic() {
890        let mut s = EcsState::new("111122223333", "us-east-1");
891        assert_eq!(s.allocate_revision("web"), 1);
892        assert_eq!(s.allocate_revision("web"), 2);
893        assert_eq!(s.allocate_revision("worker"), 1);
894        assert_eq!(s.allocate_revision("web"), 3);
895    }
896
897    #[test]
898    fn cluster_arn_format() {
899        let s = EcsState::new("111122223333", "us-east-1");
900        assert_eq!(
901            s.cluster_arn("prod"),
902            "arn:aws:ecs:us-east-1:111122223333:cluster/prod"
903        );
904    }
905
906    #[test]
907    fn task_definition_arn_format() {
908        let s = EcsState::new("111122223333", "us-east-1");
909        assert_eq!(
910            s.task_definition_arn("web", 3),
911            "arn:aws:ecs:us-east-1:111122223333:task-definition/web:3"
912        );
913    }
914
915    #[test]
916    fn task_arn_long_format_default() {
917        let s = EcsState::new("111122223333", "us-east-1");
918        assert_eq!(
919            s.task_arn("prod", "abc123"),
920            "arn:aws:ecs:us-east-1:111122223333:task/prod/abc123"
921        );
922    }
923
924    #[test]
925    fn task_arn_short_when_disabled() {
926        let mut s = EcsState::new("111122223333", "us-east-1");
927        s.account_setting_defaults
928            .insert("taskLongArnFormat".into(), "disabled".into());
929        assert_eq!(
930            s.task_arn("prod", "abc123"),
931            "arn:aws:ecs:us-east-1:111122223333:task/abc123"
932        );
933    }
934
935    #[test]
936    fn service_arn_short_when_disabled() {
937        let mut s = EcsState::new("111122223333", "us-east-1");
938        s.account_setting_defaults
939            .insert("serviceLongArnFormat".into(), "disabled".into());
940        assert_eq!(
941            s.service_arn("prod", "web"),
942            "arn:aws:ecs:us-east-1:111122223333:service/web"
943        );
944    }
945
946    #[test]
947    fn container_instance_arn_short_when_disabled() {
948        let mut s = EcsState::new("111122223333", "us-east-1");
949        s.account_setting_defaults
950            .insert("containerInstanceLongArnFormat".into(), "disabled".into());
951        assert_eq!(
952            s.container_instance_arn("prod", "i-abc"),
953            "arn:aws:ecs:us-east-1:111122223333:container-instance/i-abc"
954        );
955    }
956
957    #[test]
958    fn principal_setting_overrides_default() {
959        let mut s = EcsState::new("111122223333", "us-east-1");
960        s.account_setting_defaults
961            .insert("taskLongArnFormat".into(), "disabled".into());
962        let principal = "arn:aws:iam::111122223333:user/alice".to_string();
963        let mut p = BTreeMap::new();
964        p.insert("taskLongArnFormat".into(), "enabled".into());
965        s.principal_account_settings.insert(principal.clone(), p);
966        assert_eq!(
967            s.effective_account_setting("taskLongArnFormat", Some(&principal))
968                .as_deref(),
969            Some("enabled")
970        );
971        // Without principal, default wins.
972        assert_eq!(
973            s.effective_account_setting("taskLongArnFormat", None)
974                .as_deref(),
975            Some("disabled")
976        );
977    }
978
979    #[test]
980    fn reset_clears_all() {
981        let mut s = EcsState::new("111122223333", "us-east-1");
982        s.clusters.insert(
983            "prod".to_string(),
984            Cluster::new("prod", s.cluster_arn("prod")),
985        );
986        s.allocate_revision("web");
987        s.account_setting_defaults
988            .insert("serviceLongArnFormat".into(), "enabled".into());
989        s.reset();
990        assert!(s.clusters.is_empty());
991        assert!(s.next_revision.is_empty());
992        assert!(s.account_setting_defaults.is_empty());
993    }
994}