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}
76
77impl EcsState {
78    pub fn new(account_id: &str, region: &str) -> Self {
79        Self {
80            account_id: account_id.to_string(),
81            region: region.to_string(),
82            clusters: BTreeMap::new(),
83            task_definitions: BTreeMap::new(),
84            next_revision: BTreeMap::new(),
85            account_setting_defaults: BTreeMap::new(),
86            principal_account_settings: BTreeMap::new(),
87            tasks: BTreeMap::new(),
88            events: Vec::new(),
89            services: BTreeMap::new(),
90            container_instances: BTreeMap::new(),
91            attributes: BTreeMap::new(),
92            capacity_providers: BTreeMap::new(),
93            task_sets: BTreeMap::new(),
94        }
95    }
96
97    pub fn reset(&mut self) {
98        self.clusters.clear();
99        self.task_definitions.clear();
100        self.next_revision.clear();
101        self.account_setting_defaults.clear();
102        self.principal_account_settings.clear();
103        self.tasks.clear();
104        self.events.clear();
105        self.services.clear();
106        self.container_instances.clear();
107        self.attributes.clear();
108        self.capacity_providers.clear();
109        self.task_sets.clear();
110    }
111
112    /// Services are uniquely identified by `(cluster, name)` within an
113    /// account; this helper composes the storage key used in
114    /// `self.services`.
115    pub fn service_key(cluster_name: &str, service_name: &str) -> String {
116        format!("{}/{}", cluster_name, service_name)
117    }
118
119    pub fn service_arn(&self, cluster_name: &str, service_name: &str) -> String {
120        if self.arn_format_disabled("serviceLongArnFormat") {
121            // Pre-Nov-2018 short form: no cluster segment.
122            format!(
123                "arn:aws:ecs:{}:{}:service/{}",
124                self.region, self.account_id, service_name
125            )
126        } else {
127            format!(
128                "arn:aws:ecs:{}:{}:service/{}/{}",
129                self.region, self.account_id, cluster_name, service_name
130            )
131        }
132    }
133
134    pub fn task_arn(&self, cluster_name: &str, task_id: &str) -> String {
135        if self.arn_format_disabled("taskLongArnFormat") {
136            format!(
137                "arn:aws:ecs:{}:{}:task/{}",
138                self.region, self.account_id, task_id
139            )
140        } else {
141            format!(
142                "arn:aws:ecs:{}:{}:task/{}/{}",
143                self.region, self.account_id, cluster_name, task_id
144            )
145        }
146    }
147
148    pub fn container_instance_arn(&self, cluster_name: &str, instance_id: &str) -> String {
149        if self.arn_format_disabled("containerInstanceLongArnFormat") {
150            format!(
151                "arn:aws:ecs:{}:{}:container-instance/{}",
152                self.region, self.account_id, instance_id
153            )
154        } else {
155            format!(
156                "arn:aws:ecs:{}:{}:container-instance/{}/{}",
157                self.region, self.account_id, cluster_name, instance_id
158            )
159        }
160    }
161
162    /// Resolve the effective value of an account setting. Principal
163    /// overrides win over account-level defaults, matching AWS's
164    /// PutAccountSetting / PutAccountSettingDefault layering. With no
165    /// `principal_arn` argument the caller gets the account default.
166    pub fn effective_account_setting(
167        &self,
168        name: &str,
169        principal_arn: Option<&str>,
170    ) -> Option<String> {
171        if let Some(arn) = principal_arn {
172            if let Some(p) = self.principal_account_settings.get(arn) {
173                if let Some(v) = p.get(name) {
174                    return Some(v.clone());
175                }
176            }
177        }
178        self.account_setting_defaults.get(name).cloned()
179    }
180
181    /// `true` when the given `*LongArnFormat` setting has been set to
182    /// `disabled`. The default (including unset) is long format —
183    /// matches AWS's current behaviour where long ARNs are mandatory
184    /// since Jan 2020 but the settings still flip for backward-compat.
185    fn arn_format_disabled(&self, setting_name: &str) -> bool {
186        matches!(
187            self.effective_account_setting(setting_name, None)
188                .as_deref(),
189            Some("disabled")
190        )
191    }
192
193    /// Append a lifecycle event, trimming the oldest when the cap is hit.
194    pub fn push_event(&mut self, event: LifecycleEvent) {
195        const MAX_EVENTS: usize = 1024;
196        if self.events.len() >= MAX_EVENTS {
197            self.events.drain(0..self.events.len() - MAX_EVENTS + 1);
198        }
199        self.events.push(event);
200    }
201
202    pub fn cluster_arn(&self, cluster_name: &str) -> String {
203        format!(
204            "arn:aws:ecs:{}:{}:cluster/{}",
205            self.region, self.account_id, cluster_name
206        )
207    }
208
209    pub fn task_definition_arn(&self, family: &str, revision: i32) -> String {
210        format!(
211            "arn:aws:ecs:{}:{}:task-definition/{}:{}",
212            self.region, self.account_id, family, revision
213        )
214    }
215
216    /// Given a user-supplied cluster reference (name or ARN), return the
217    /// cluster name. Defaults to `"default"` when `None`/empty, matching
218    /// the AWS CLI behaviour.
219    pub fn resolve_cluster_name(input: Option<&str>) -> String {
220        let raw = input.unwrap_or("").trim();
221        if raw.is_empty() {
222            return "default".to_string();
223        }
224        if let Some(name) = raw.rsplit_once('/').map(|(_, n)| n) {
225            return name.to_string();
226        }
227        raw.to_string()
228    }
229
230    /// Bump and return the next revision number for a family. Never
231    /// reused: monotonically increases even across deregistration.
232    pub fn allocate_revision(&mut self, family: &str) -> i32 {
233        let next = self.next_revision.entry(family.to_string()).or_insert(0);
234        *next += 1;
235        *next
236    }
237}
238
239#[derive(Clone, Debug, Serialize, Deserialize)]
240pub struct Cluster {
241    pub cluster_name: String,
242    pub cluster_arn: String,
243    pub status: String,
244    pub registered_container_instances_count: i32,
245    pub running_tasks_count: i32,
246    pub pending_tasks_count: i32,
247    pub active_services_count: i32,
248    #[serde(default)]
249    pub statistics: Vec<Value>,
250    #[serde(default)]
251    pub tags: Vec<TagEntry>,
252    #[serde(default)]
253    pub settings: Vec<Value>,
254    pub configuration: Option<Value>,
255    #[serde(default)]
256    pub capacity_providers: Vec<String>,
257    #[serde(default)]
258    pub default_capacity_provider_strategy: Vec<Value>,
259    #[serde(default)]
260    pub attachments: Vec<Value>,
261    pub attachments_status: Option<String>,
262    pub service_connect_defaults: Option<Value>,
263    pub created_at: DateTime<Utc>,
264}
265
266impl Cluster {
267    pub fn new(cluster_name: &str, cluster_arn: String) -> Self {
268        Self {
269            cluster_name: cluster_name.to_string(),
270            cluster_arn,
271            status: "ACTIVE".to_string(),
272            registered_container_instances_count: 0,
273            running_tasks_count: 0,
274            pending_tasks_count: 0,
275            active_services_count: 0,
276            statistics: Vec::new(),
277            tags: Vec::new(),
278            settings: Vec::new(),
279            configuration: None,
280            capacity_providers: Vec::new(),
281            default_capacity_provider_strategy: Vec::new(),
282            attachments: Vec::new(),
283            attachments_status: None,
284            service_connect_defaults: None,
285            created_at: Utc::now(),
286        }
287    }
288}
289
290#[derive(Clone, Debug, Serialize, Deserialize)]
291pub struct TagEntry {
292    pub key: String,
293    pub value: String,
294}
295
296#[derive(Clone, Debug, Serialize, Deserialize)]
297pub struct TaskDefinition {
298    pub family: String,
299    pub revision: i32,
300    pub task_definition_arn: String,
301    /// Free-form container definitions preserved as the JSON the caller
302    /// supplied. ECS accepts so many optional fields that round-tripping
303    /// the raw JSON is simpler and more faithful than modeling a struct
304    /// with hundreds of members per container.
305    #[serde(default)]
306    pub container_definitions: Vec<Value>,
307    pub status: String,
308    pub task_role_arn: Option<String>,
309    pub execution_role_arn: Option<String>,
310    pub network_mode: Option<String>,
311    #[serde(default)]
312    pub requires_compatibilities: Vec<String>,
313    #[serde(default)]
314    pub compatibilities: Vec<String>,
315    pub cpu: Option<String>,
316    pub memory: Option<String>,
317    pub pid_mode: Option<String>,
318    pub ipc_mode: Option<String>,
319    #[serde(default)]
320    pub volumes: Vec<Value>,
321    #[serde(default)]
322    pub placement_constraints: Vec<Value>,
323    pub proxy_configuration: Option<Value>,
324    #[serde(default)]
325    pub inference_accelerators: Vec<Value>,
326    pub ephemeral_storage: Option<Value>,
327    pub runtime_platform: Option<Value>,
328    #[serde(default)]
329    pub requires_attributes: Vec<Value>,
330    pub registered_at: DateTime<Utc>,
331    pub registered_by: Option<String>,
332    pub deregistered_at: Option<DateTime<Utc>>,
333    #[serde(default)]
334    pub tags: Vec<TagEntry>,
335    pub enable_fault_injection: Option<bool>,
336}
337
338#[derive(Clone, Debug, Serialize, Deserialize)]
339pub struct Task {
340    pub task_arn: String,
341    pub task_id: String,
342    pub cluster_arn: String,
343    pub cluster_name: String,
344    pub task_definition_arn: String,
345    pub family: String,
346    pub revision: i32,
347    /// Current lifecycle state: PROVISIONING, PENDING, RUNNING,
348    /// DEPROVISIONING, STOPPED.
349    pub last_status: String,
350    /// What the caller asked for: usually RUNNING, or STOPPED once
351    /// `StopTask` / `StopService` hits.
352    pub desired_status: String,
353    pub launch_type: String,
354    pub platform_version: Option<String>,
355    pub cpu: Option<String>,
356    pub memory: Option<String>,
357    #[serde(default)]
358    pub containers: Vec<Container>,
359    #[serde(default)]
360    pub overrides: Value,
361    pub started_by: Option<String>,
362    pub group: Option<String>,
363    pub connectivity: String,
364    pub stop_code: Option<String>,
365    pub stopped_reason: Option<String>,
366    pub created_at: DateTime<Utc>,
367    pub started_at: Option<DateTime<Utc>>,
368    pub stopping_at: Option<DateTime<Utc>>,
369    pub stopped_at: Option<DateTime<Utc>>,
370    pub pull_started_at: Option<DateTime<Utc>>,
371    pub pull_stopped_at: Option<DateTime<Utc>>,
372    pub connectivity_at: Option<DateTime<Utc>>,
373    pub started_by_ref_id: Option<String>,
374    pub execution_role_arn: Option<String>,
375    pub task_role_arn: Option<String>,
376    #[serde(default)]
377    pub tags: Vec<TagEntry>,
378    /// Log destination derived from the first container's awslogs driver.
379    /// `None` when no awslogs driver is configured — captured stdout/stderr
380    /// is still stored on the task for introspection.
381    pub awslogs: Option<AwsLogsConfig>,
382    /// Captured stdout/stderr from the container. Populated after the
383    /// container exits. Kept here so the introspection endpoint can serve
384    /// logs even when no awslogs driver is configured.
385    #[serde(default)]
386    pub captured_logs: String,
387    /// Task protection state (UpdateTaskProtection). When set, scale-in
388    /// and update-service deployments skip this task until the expiry.
389    pub protection: Option<TaskProtection>,
390}
391
392#[derive(Clone, Debug, Serialize, Deserialize)]
393pub struct TaskProtection {
394    pub enabled: bool,
395    pub expiration: Option<DateTime<Utc>>,
396}
397
398#[derive(Clone, Debug, Serialize, Deserialize)]
399pub struct Container {
400    pub container_arn: String,
401    pub name: String,
402    pub image: String,
403    pub task_arn: String,
404    pub last_status: String,
405    pub exit_code: Option<i64>,
406    pub reason: Option<String>,
407    pub runtime_id: Option<String>,
408    pub essential: bool,
409    pub cpu: Option<String>,
410    pub memory: Option<String>,
411    pub memory_reservation: Option<String>,
412    #[serde(default)]
413    pub network_bindings: Vec<Value>,
414    #[serde(default)]
415    pub network_interfaces: Vec<Value>,
416    pub health_status: Option<String>,
417    pub managed_agents: Option<Value>,
418}
419
420#[derive(Clone, Debug, Serialize, Deserialize)]
421pub struct AwsLogsConfig {
422    pub group: String,
423    pub stream_prefix: Option<String>,
424    pub region: String,
425    pub container_name: String,
426}
427
428impl AwsLogsConfig {
429    pub fn stream_name(&self, task_id: &str) -> String {
430        match &self.stream_prefix {
431            Some(p) => format!("{}/{}/{}", p, self.container_name, task_id),
432            None => format!("{}/{}", self.container_name, task_id),
433        }
434    }
435}
436
437#[derive(Clone, Debug, Serialize, Deserialize)]
438pub struct LifecycleEvent {
439    pub at: DateTime<Utc>,
440    pub event_type: String,
441    pub task_arn: Option<String>,
442    pub cluster_arn: Option<String>,
443    pub last_status: Option<String>,
444    pub detail: Value,
445}
446
447#[derive(Clone, Debug, Serialize, Deserialize)]
448pub struct Service {
449    pub service_name: String,
450    pub service_arn: String,
451    pub cluster_name: String,
452    pub cluster_arn: String,
453    pub task_definition_arn: String,
454    pub family: String,
455    pub revision: i32,
456    pub desired_count: i32,
457    pub running_count: i32,
458    pub pending_count: i32,
459    pub launch_type: String,
460    pub status: String,
461    pub scheduling_strategy: String,
462    pub deployment_controller: String,
463    pub minimum_healthy_percent: Option<i32>,
464    pub maximum_percent: Option<i32>,
465    /// Deployment circuit breaker config (opt-in via deploymentConfiguration).
466    pub circuit_breaker: Option<CircuitBreakerConfig>,
467    #[serde(default)]
468    pub deployments: Vec<Deployment>,
469    #[serde(default)]
470    pub load_balancers: Vec<Value>,
471    #[serde(default)]
472    pub service_registries: Vec<Value>,
473    #[serde(default)]
474    pub placement_constraints: Vec<Value>,
475    #[serde(default)]
476    pub placement_strategy: Vec<Value>,
477    #[serde(default)]
478    pub network_configuration: Option<Value>,
479    #[serde(default)]
480    pub tags: Vec<TagEntry>,
481    pub created_at: DateTime<Utc>,
482    pub created_by: Option<String>,
483    pub role_arn: Option<String>,
484}
485
486#[derive(Clone, Debug, Serialize, Deserialize)]
487pub struct CircuitBreakerConfig {
488    pub enable: bool,
489    pub rollback: bool,
490}
491
492#[derive(Clone, Debug, Serialize, Deserialize)]
493pub struct Deployment {
494    pub deployment_id: String,
495    pub status: String,
496    pub task_definition_arn: String,
497    pub desired_count: i32,
498    pub pending_count: i32,
499    pub running_count: i32,
500    pub failed_tasks: i32,
501    pub created_at: DateTime<Utc>,
502    pub updated_at: DateTime<Utc>,
503    pub launch_type: String,
504    pub rollout_state: String,
505    pub rollout_state_reason: Option<String>,
506}
507
508#[derive(Clone, Debug, Serialize, Deserialize)]
509pub struct ContainerInstance {
510    pub container_instance_arn: String,
511    pub ec2_instance_id: Option<String>,
512    pub cluster_name: String,
513    pub cluster_arn: String,
514    pub status: String,
515    pub version: i64,
516    pub version_info: Option<Value>,
517    pub agent_connected: bool,
518    pub agent_update_status: Option<String>,
519    pub remaining_resources: Vec<Value>,
520    pub registered_resources: Vec<Value>,
521    pub running_tasks_count: i32,
522    pub pending_tasks_count: i32,
523    pub registered_at: DateTime<Utc>,
524    #[serde(default)]
525    pub attributes: Vec<AttributeRef>,
526    #[serde(default)]
527    pub tags: Vec<TagEntry>,
528    pub capacity_provider_name: Option<String>,
529    pub health_status: Option<Value>,
530}
531
532#[derive(Clone, Debug, Serialize, Deserialize)]
533pub struct AttributeRef {
534    pub name: String,
535    pub value: Option<String>,
536    pub target_type: Option<String>,
537    pub target_id: Option<String>,
538}
539
540#[derive(Clone, Debug, Serialize, Deserialize)]
541pub struct Attribute {
542    pub cluster_name: String,
543    pub target_type: String,
544    pub target_id: String,
545    pub name: String,
546    pub value: Option<String>,
547}
548
549#[derive(Clone, Debug, Serialize, Deserialize)]
550pub struct CapacityProvider {
551    pub name: String,
552    pub arn: String,
553    pub status: String,
554    pub auto_scaling_group_provider: Option<Value>,
555    pub update_status: Option<String>,
556    pub update_status_reason: Option<String>,
557    pub created_at: DateTime<Utc>,
558    #[serde(default)]
559    pub tags: Vec<TagEntry>,
560}
561
562#[derive(Clone, Debug, Serialize, Deserialize)]
563pub struct TaskSet {
564    pub task_set_id: String,
565    pub task_set_arn: String,
566    pub service_arn: String,
567    pub cluster_arn: String,
568    pub service_name: String,
569    pub cluster_name: String,
570    pub external_id: Option<String>,
571    pub status: String,
572    pub task_definition: String,
573    pub computed_desired_count: i32,
574    pub pending_count: i32,
575    pub running_count: i32,
576    pub launch_type: Option<String>,
577    pub platform_version: Option<String>,
578    pub scale: Option<Value>,
579    pub stability_status: String,
580    pub created_at: DateTime<Utc>,
581    pub updated_at: DateTime<Utc>,
582    #[serde(default)]
583    pub load_balancers: Vec<Value>,
584    #[serde(default)]
585    pub service_registries: Vec<Value>,
586    #[serde(default)]
587    pub capacity_provider_strategy: Vec<Value>,
588    #[serde(default)]
589    pub tags: Vec<TagEntry>,
590}
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595
596    #[test]
597    fn resolve_cluster_name_defaults_to_default() {
598        assert_eq!(EcsState::resolve_cluster_name(None), "default");
599        assert_eq!(EcsState::resolve_cluster_name(Some("")), "default");
600        assert_eq!(EcsState::resolve_cluster_name(Some("   ")), "default");
601    }
602
603    #[test]
604    fn resolve_cluster_name_strips_arn_prefix() {
605        assert_eq!(
606            EcsState::resolve_cluster_name(Some("arn:aws:ecs:us-east-1:111122223333:cluster/prod")),
607            "prod"
608        );
609    }
610
611    #[test]
612    fn resolve_cluster_name_passes_through_name() {
613        assert_eq!(EcsState::resolve_cluster_name(Some("prod")), "prod");
614    }
615
616    #[test]
617    fn allocate_revision_monotonic() {
618        let mut s = EcsState::new("111122223333", "us-east-1");
619        assert_eq!(s.allocate_revision("web"), 1);
620        assert_eq!(s.allocate_revision("web"), 2);
621        assert_eq!(s.allocate_revision("worker"), 1);
622        assert_eq!(s.allocate_revision("web"), 3);
623    }
624
625    #[test]
626    fn cluster_arn_format() {
627        let s = EcsState::new("111122223333", "us-east-1");
628        assert_eq!(
629            s.cluster_arn("prod"),
630            "arn:aws:ecs:us-east-1:111122223333:cluster/prod"
631        );
632    }
633
634    #[test]
635    fn task_definition_arn_format() {
636        let s = EcsState::new("111122223333", "us-east-1");
637        assert_eq!(
638            s.task_definition_arn("web", 3),
639            "arn:aws:ecs:us-east-1:111122223333:task-definition/web:3"
640        );
641    }
642
643    #[test]
644    fn task_arn_long_format_default() {
645        let s = EcsState::new("111122223333", "us-east-1");
646        assert_eq!(
647            s.task_arn("prod", "abc123"),
648            "arn:aws:ecs:us-east-1:111122223333:task/prod/abc123"
649        );
650    }
651
652    #[test]
653    fn task_arn_short_when_disabled() {
654        let mut s = EcsState::new("111122223333", "us-east-1");
655        s.account_setting_defaults
656            .insert("taskLongArnFormat".into(), "disabled".into());
657        assert_eq!(
658            s.task_arn("prod", "abc123"),
659            "arn:aws:ecs:us-east-1:111122223333:task/abc123"
660        );
661    }
662
663    #[test]
664    fn service_arn_short_when_disabled() {
665        let mut s = EcsState::new("111122223333", "us-east-1");
666        s.account_setting_defaults
667            .insert("serviceLongArnFormat".into(), "disabled".into());
668        assert_eq!(
669            s.service_arn("prod", "web"),
670            "arn:aws:ecs:us-east-1:111122223333:service/web"
671        );
672    }
673
674    #[test]
675    fn container_instance_arn_short_when_disabled() {
676        let mut s = EcsState::new("111122223333", "us-east-1");
677        s.account_setting_defaults
678            .insert("containerInstanceLongArnFormat".into(), "disabled".into());
679        assert_eq!(
680            s.container_instance_arn("prod", "i-abc"),
681            "arn:aws:ecs:us-east-1:111122223333:container-instance/i-abc"
682        );
683    }
684
685    #[test]
686    fn principal_setting_overrides_default() {
687        let mut s = EcsState::new("111122223333", "us-east-1");
688        s.account_setting_defaults
689            .insert("taskLongArnFormat".into(), "disabled".into());
690        let principal = "arn:aws:iam::111122223333:user/alice".to_string();
691        let mut p = BTreeMap::new();
692        p.insert("taskLongArnFormat".into(), "enabled".into());
693        s.principal_account_settings.insert(principal.clone(), p);
694        assert_eq!(
695            s.effective_account_setting("taskLongArnFormat", Some(&principal))
696                .as_deref(),
697            Some("enabled")
698        );
699        // Without principal, default wins.
700        assert_eq!(
701            s.effective_account_setting("taskLongArnFormat", None)
702                .as_deref(),
703            Some("disabled")
704        );
705    }
706
707    #[test]
708    fn reset_clears_all() {
709        let mut s = EcsState::new("111122223333", "us-east-1");
710        s.clusters.insert(
711            "prod".to_string(),
712            Cluster::new("prod", s.cluster_arn("prod")),
713        );
714        s.allocate_revision("web");
715        s.account_setting_defaults
716            .insert("serviceLongArnFormat".into(), "enabled".into());
717        s.reset();
718        assert!(s.clusters.is_empty());
719        assert!(s.next_revision.is_empty());
720        assert!(s.account_setting_defaults.is_empty());
721    }
722}