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#[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 pub clusters: BTreeMap<String, Cluster>,
34 pub task_definitions: BTreeMap<String, BTreeMap<i32, TaskDefinition>>,
38 pub next_revision: BTreeMap<String, i32>,
41 pub account_setting_defaults: BTreeMap<String, String>,
44 pub principal_account_settings: BTreeMap<String, BTreeMap<String, String>>,
47 #[serde(default)]
49 pub tasks: BTreeMap<String, Task>,
50 #[serde(default)]
53 pub events: Vec<LifecycleEvent>,
54 #[serde(default)]
59 pub services: BTreeMap<String, Service>,
60 #[serde(default)]
65 pub container_instances: BTreeMap<String, ContainerInstance>,
66 #[serde(default)]
68 pub attributes: BTreeMap<String, Attribute>,
69 #[serde(default)]
71 pub capacity_providers: BTreeMap<String, CapacityProvider>,
72 #[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 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 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 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 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 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 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 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 #[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 pub last_status: String,
350 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 pub awslogs: Option<AwsLogsConfig>,
382 #[serde(default)]
386 pub captured_logs: String,
387 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 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 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}