use std::collections::BTreeMap;
use std::sync::Arc;
use chrono::{DateTime, Utc};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub type SharedEcsState = Arc<RwLock<fakecloud_core::multi_account::MultiAccountState<EcsState>>>;
impl fakecloud_core::multi_account::AccountState for EcsState {
fn new_for_account(account_id: &str, region: &str, _endpoint: &str) -> Self {
Self::new(account_id, region)
}
}
pub const ECS_SNAPSHOT_SCHEMA_VERSION: u32 = 4;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct EcsSnapshot {
pub schema_version: u32,
pub accounts: Option<fakecloud_core::multi_account::MultiAccountState<EcsState>>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct EcsState {
pub account_id: String,
pub region: String,
pub clusters: BTreeMap<String, Cluster>,
pub task_definitions: BTreeMap<String, BTreeMap<i32, TaskDefinition>>,
pub next_revision: BTreeMap<String, i32>,
pub account_setting_defaults: BTreeMap<String, String>,
pub principal_account_settings: BTreeMap<String, BTreeMap<String, String>>,
#[serde(default)]
pub tasks: BTreeMap<String, Task>,
#[serde(default)]
pub events: Vec<LifecycleEvent>,
#[serde(default)]
pub services: BTreeMap<String, Service>,
#[serde(default)]
pub container_instances: BTreeMap<String, ContainerInstance>,
#[serde(default)]
pub attributes: BTreeMap<String, Attribute>,
#[serde(default)]
pub capacity_providers: BTreeMap<String, CapacityProvider>,
#[serde(default)]
pub task_sets: BTreeMap<String, TaskSet>,
}
impl EcsState {
pub fn new(account_id: &str, region: &str) -> Self {
Self {
account_id: account_id.to_string(),
region: region.to_string(),
clusters: BTreeMap::new(),
task_definitions: BTreeMap::new(),
next_revision: BTreeMap::new(),
account_setting_defaults: BTreeMap::new(),
principal_account_settings: BTreeMap::new(),
tasks: BTreeMap::new(),
events: Vec::new(),
services: BTreeMap::new(),
container_instances: BTreeMap::new(),
attributes: BTreeMap::new(),
capacity_providers: BTreeMap::new(),
task_sets: BTreeMap::new(),
}
}
pub fn reset(&mut self) {
self.clusters.clear();
self.task_definitions.clear();
self.next_revision.clear();
self.account_setting_defaults.clear();
self.principal_account_settings.clear();
self.tasks.clear();
self.events.clear();
self.services.clear();
self.container_instances.clear();
self.attributes.clear();
self.capacity_providers.clear();
self.task_sets.clear();
}
pub fn service_key(cluster_name: &str, service_name: &str) -> String {
format!("{}/{}", cluster_name, service_name)
}
pub fn service_arn(&self, cluster_name: &str, service_name: &str) -> String {
if self.arn_format_disabled("serviceLongArnFormat") {
format!(
"arn:aws:ecs:{}:{}:service/{}",
self.region, self.account_id, service_name
)
} else {
format!(
"arn:aws:ecs:{}:{}:service/{}/{}",
self.region, self.account_id, cluster_name, service_name
)
}
}
pub fn task_arn(&self, cluster_name: &str, task_id: &str) -> String {
if self.arn_format_disabled("taskLongArnFormat") {
format!(
"arn:aws:ecs:{}:{}:task/{}",
self.region, self.account_id, task_id
)
} else {
format!(
"arn:aws:ecs:{}:{}:task/{}/{}",
self.region, self.account_id, cluster_name, task_id
)
}
}
pub fn container_instance_arn(&self, cluster_name: &str, instance_id: &str) -> String {
if self.arn_format_disabled("containerInstanceLongArnFormat") {
format!(
"arn:aws:ecs:{}:{}:container-instance/{}",
self.region, self.account_id, instance_id
)
} else {
format!(
"arn:aws:ecs:{}:{}:container-instance/{}/{}",
self.region, self.account_id, cluster_name, instance_id
)
}
}
pub fn effective_account_setting(
&self,
name: &str,
principal_arn: Option<&str>,
) -> Option<String> {
if let Some(arn) = principal_arn {
if let Some(p) = self.principal_account_settings.get(arn) {
if let Some(v) = p.get(name) {
return Some(v.clone());
}
}
}
self.account_setting_defaults.get(name).cloned()
}
fn arn_format_disabled(&self, setting_name: &str) -> bool {
matches!(
self.effective_account_setting(setting_name, None)
.as_deref(),
Some("disabled")
)
}
pub fn push_event(&mut self, event: LifecycleEvent) {
const MAX_EVENTS: usize = 1024;
if self.events.len() >= MAX_EVENTS {
self.events.drain(0..self.events.len() - MAX_EVENTS + 1);
}
self.events.push(event);
}
pub fn cluster_arn(&self, cluster_name: &str) -> String {
format!(
"arn:aws:ecs:{}:{}:cluster/{}",
self.region, self.account_id, cluster_name
)
}
pub fn task_definition_arn(&self, family: &str, revision: i32) -> String {
format!(
"arn:aws:ecs:{}:{}:task-definition/{}:{}",
self.region, self.account_id, family, revision
)
}
pub fn resolve_cluster_name(input: Option<&str>) -> String {
let raw = input.unwrap_or("").trim();
if raw.is_empty() {
return "default".to_string();
}
if let Some(name) = raw.rsplit_once('/').map(|(_, n)| n) {
return name.to_string();
}
raw.to_string()
}
pub fn allocate_revision(&mut self, family: &str) -> i32 {
let next = self.next_revision.entry(family.to_string()).or_insert(0);
*next += 1;
*next
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Cluster {
pub cluster_name: String,
pub cluster_arn: String,
pub status: String,
pub registered_container_instances_count: i32,
pub running_tasks_count: i32,
pub pending_tasks_count: i32,
pub active_services_count: i32,
#[serde(default)]
pub statistics: Vec<Value>,
#[serde(default)]
pub tags: Vec<TagEntry>,
#[serde(default)]
pub settings: Vec<Value>,
pub configuration: Option<Value>,
#[serde(default)]
pub capacity_providers: Vec<String>,
#[serde(default)]
pub default_capacity_provider_strategy: Vec<Value>,
#[serde(default)]
pub attachments: Vec<Value>,
pub attachments_status: Option<String>,
pub service_connect_defaults: Option<Value>,
pub created_at: DateTime<Utc>,
}
impl Cluster {
pub fn new(cluster_name: &str, cluster_arn: String) -> Self {
Self {
cluster_name: cluster_name.to_string(),
cluster_arn,
status: "ACTIVE".to_string(),
registered_container_instances_count: 0,
running_tasks_count: 0,
pending_tasks_count: 0,
active_services_count: 0,
statistics: Vec::new(),
tags: Vec::new(),
settings: Vec::new(),
configuration: None,
capacity_providers: Vec::new(),
default_capacity_provider_strategy: Vec::new(),
attachments: Vec::new(),
attachments_status: None,
service_connect_defaults: None,
created_at: Utc::now(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TagEntry {
pub key: String,
pub value: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TaskDefinition {
pub family: String,
pub revision: i32,
pub task_definition_arn: String,
#[serde(default)]
pub container_definitions: Vec<Value>,
pub status: String,
pub task_role_arn: Option<String>,
pub execution_role_arn: Option<String>,
pub network_mode: Option<String>,
#[serde(default)]
pub requires_compatibilities: Vec<String>,
#[serde(default)]
pub compatibilities: Vec<String>,
pub cpu: Option<String>,
pub memory: Option<String>,
pub pid_mode: Option<String>,
pub ipc_mode: Option<String>,
#[serde(default)]
pub volumes: Vec<Value>,
#[serde(default)]
pub placement_constraints: Vec<Value>,
pub proxy_configuration: Option<Value>,
#[serde(default)]
pub inference_accelerators: Vec<Value>,
pub ephemeral_storage: Option<Value>,
pub runtime_platform: Option<Value>,
#[serde(default)]
pub requires_attributes: Vec<Value>,
pub registered_at: DateTime<Utc>,
pub registered_by: Option<String>,
pub deregistered_at: Option<DateTime<Utc>>,
#[serde(default)]
pub tags: Vec<TagEntry>,
pub enable_fault_injection: Option<bool>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Task {
pub task_arn: String,
pub task_id: String,
pub cluster_arn: String,
pub cluster_name: String,
pub task_definition_arn: String,
pub family: String,
pub revision: i32,
pub last_status: String,
pub desired_status: String,
pub launch_type: String,
pub platform_version: Option<String>,
pub cpu: Option<String>,
pub memory: Option<String>,
#[serde(default)]
pub containers: Vec<Container>,
#[serde(default)]
pub overrides: Value,
pub started_by: Option<String>,
pub group: Option<String>,
pub connectivity: String,
pub stop_code: Option<String>,
pub stopped_reason: Option<String>,
pub created_at: DateTime<Utc>,
pub started_at: Option<DateTime<Utc>>,
pub stopping_at: Option<DateTime<Utc>>,
pub stopped_at: Option<DateTime<Utc>>,
pub pull_started_at: Option<DateTime<Utc>>,
pub pull_stopped_at: Option<DateTime<Utc>>,
pub connectivity_at: Option<DateTime<Utc>>,
pub started_by_ref_id: Option<String>,
pub execution_role_arn: Option<String>,
pub task_role_arn: Option<String>,
#[serde(default)]
pub tags: Vec<TagEntry>,
pub awslogs: Option<AwsLogsConfig>,
#[serde(default)]
pub captured_logs: String,
pub protection: Option<TaskProtection>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TaskProtection {
pub enabled: bool,
pub expiration: Option<DateTime<Utc>>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Container {
pub container_arn: String,
pub name: String,
pub image: String,
pub task_arn: String,
pub last_status: String,
pub exit_code: Option<i64>,
pub reason: Option<String>,
pub runtime_id: Option<String>,
pub essential: bool,
pub cpu: Option<String>,
pub memory: Option<String>,
pub memory_reservation: Option<String>,
#[serde(default)]
pub network_bindings: Vec<Value>,
#[serde(default)]
pub network_interfaces: Vec<Value>,
pub health_status: Option<String>,
pub managed_agents: Option<Value>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AwsLogsConfig {
pub group: String,
pub stream_prefix: Option<String>,
pub region: String,
pub container_name: String,
}
impl AwsLogsConfig {
pub fn stream_name(&self, task_id: &str) -> String {
match &self.stream_prefix {
Some(p) => format!("{}/{}/{}", p, self.container_name, task_id),
None => format!("{}/{}", self.container_name, task_id),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LifecycleEvent {
pub at: DateTime<Utc>,
pub event_type: String,
pub task_arn: Option<String>,
pub cluster_arn: Option<String>,
pub last_status: Option<String>,
pub detail: Value,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Service {
pub service_name: String,
pub service_arn: String,
pub cluster_name: String,
pub cluster_arn: String,
pub task_definition_arn: String,
pub family: String,
pub revision: i32,
pub desired_count: i32,
pub running_count: i32,
pub pending_count: i32,
pub launch_type: String,
pub status: String,
pub scheduling_strategy: String,
pub deployment_controller: String,
pub minimum_healthy_percent: Option<i32>,
pub maximum_percent: Option<i32>,
pub circuit_breaker: Option<CircuitBreakerConfig>,
#[serde(default)]
pub deployments: Vec<Deployment>,
#[serde(default)]
pub load_balancers: Vec<Value>,
#[serde(default)]
pub service_registries: Vec<Value>,
#[serde(default)]
pub placement_constraints: Vec<Value>,
#[serde(default)]
pub placement_strategy: Vec<Value>,
#[serde(default)]
pub network_configuration: Option<Value>,
#[serde(default)]
pub tags: Vec<TagEntry>,
pub created_at: DateTime<Utc>,
pub created_by: Option<String>,
pub role_arn: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CircuitBreakerConfig {
pub enable: bool,
pub rollback: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Deployment {
pub deployment_id: String,
pub status: String,
pub task_definition_arn: String,
pub desired_count: i32,
pub pending_count: i32,
pub running_count: i32,
pub failed_tasks: i32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub launch_type: String,
pub rollout_state: String,
pub rollout_state_reason: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ContainerInstance {
pub container_instance_arn: String,
pub ec2_instance_id: Option<String>,
pub cluster_name: String,
pub cluster_arn: String,
pub status: String,
pub version: i64,
pub version_info: Option<Value>,
pub agent_connected: bool,
pub agent_update_status: Option<String>,
pub remaining_resources: Vec<Value>,
pub registered_resources: Vec<Value>,
pub running_tasks_count: i32,
pub pending_tasks_count: i32,
pub registered_at: DateTime<Utc>,
#[serde(default)]
pub attributes: Vec<AttributeRef>,
#[serde(default)]
pub tags: Vec<TagEntry>,
pub capacity_provider_name: Option<String>,
pub health_status: Option<Value>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AttributeRef {
pub name: String,
pub value: Option<String>,
pub target_type: Option<String>,
pub target_id: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Attribute {
pub cluster_name: String,
pub target_type: String,
pub target_id: String,
pub name: String,
pub value: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CapacityProvider {
pub name: String,
pub arn: String,
pub status: String,
pub auto_scaling_group_provider: Option<Value>,
pub update_status: Option<String>,
pub update_status_reason: Option<String>,
pub created_at: DateTime<Utc>,
#[serde(default)]
pub tags: Vec<TagEntry>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TaskSet {
pub task_set_id: String,
pub task_set_arn: String,
pub service_arn: String,
pub cluster_arn: String,
pub service_name: String,
pub cluster_name: String,
pub external_id: Option<String>,
pub status: String,
pub task_definition: String,
pub computed_desired_count: i32,
pub pending_count: i32,
pub running_count: i32,
pub launch_type: Option<String>,
pub platform_version: Option<String>,
pub scale: Option<Value>,
pub stability_status: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(default)]
pub load_balancers: Vec<Value>,
#[serde(default)]
pub service_registries: Vec<Value>,
#[serde(default)]
pub capacity_provider_strategy: Vec<Value>,
#[serde(default)]
pub tags: Vec<TagEntry>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_cluster_name_defaults_to_default() {
assert_eq!(EcsState::resolve_cluster_name(None), "default");
assert_eq!(EcsState::resolve_cluster_name(Some("")), "default");
assert_eq!(EcsState::resolve_cluster_name(Some(" ")), "default");
}
#[test]
fn resolve_cluster_name_strips_arn_prefix() {
assert_eq!(
EcsState::resolve_cluster_name(Some("arn:aws:ecs:us-east-1:111122223333:cluster/prod")),
"prod"
);
}
#[test]
fn resolve_cluster_name_passes_through_name() {
assert_eq!(EcsState::resolve_cluster_name(Some("prod")), "prod");
}
#[test]
fn allocate_revision_monotonic() {
let mut s = EcsState::new("111122223333", "us-east-1");
assert_eq!(s.allocate_revision("web"), 1);
assert_eq!(s.allocate_revision("web"), 2);
assert_eq!(s.allocate_revision("worker"), 1);
assert_eq!(s.allocate_revision("web"), 3);
}
#[test]
fn cluster_arn_format() {
let s = EcsState::new("111122223333", "us-east-1");
assert_eq!(
s.cluster_arn("prod"),
"arn:aws:ecs:us-east-1:111122223333:cluster/prod"
);
}
#[test]
fn task_definition_arn_format() {
let s = EcsState::new("111122223333", "us-east-1");
assert_eq!(
s.task_definition_arn("web", 3),
"arn:aws:ecs:us-east-1:111122223333:task-definition/web:3"
);
}
#[test]
fn task_arn_long_format_default() {
let s = EcsState::new("111122223333", "us-east-1");
assert_eq!(
s.task_arn("prod", "abc123"),
"arn:aws:ecs:us-east-1:111122223333:task/prod/abc123"
);
}
#[test]
fn task_arn_short_when_disabled() {
let mut s = EcsState::new("111122223333", "us-east-1");
s.account_setting_defaults
.insert("taskLongArnFormat".into(), "disabled".into());
assert_eq!(
s.task_arn("prod", "abc123"),
"arn:aws:ecs:us-east-1:111122223333:task/abc123"
);
}
#[test]
fn service_arn_short_when_disabled() {
let mut s = EcsState::new("111122223333", "us-east-1");
s.account_setting_defaults
.insert("serviceLongArnFormat".into(), "disabled".into());
assert_eq!(
s.service_arn("prod", "web"),
"arn:aws:ecs:us-east-1:111122223333:service/web"
);
}
#[test]
fn container_instance_arn_short_when_disabled() {
let mut s = EcsState::new("111122223333", "us-east-1");
s.account_setting_defaults
.insert("containerInstanceLongArnFormat".into(), "disabled".into());
assert_eq!(
s.container_instance_arn("prod", "i-abc"),
"arn:aws:ecs:us-east-1:111122223333:container-instance/i-abc"
);
}
#[test]
fn principal_setting_overrides_default() {
let mut s = EcsState::new("111122223333", "us-east-1");
s.account_setting_defaults
.insert("taskLongArnFormat".into(), "disabled".into());
let principal = "arn:aws:iam::111122223333:user/alice".to_string();
let mut p = BTreeMap::new();
p.insert("taskLongArnFormat".into(), "enabled".into());
s.principal_account_settings.insert(principal.clone(), p);
assert_eq!(
s.effective_account_setting("taskLongArnFormat", Some(&principal))
.as_deref(),
Some("enabled")
);
assert_eq!(
s.effective_account_setting("taskLongArnFormat", None)
.as_deref(),
Some("disabled")
);
}
#[test]
fn reset_clears_all() {
let mut s = EcsState::new("111122223333", "us-east-1");
s.clusters.insert(
"prod".to_string(),
Cluster::new("prod", s.cluster_arn("prod")),
);
s.allocate_revision("web");
s.account_setting_defaults
.insert("serviceLongArnFormat".into(), "enabled".into());
s.reset();
assert!(s.clusters.is_empty());
assert!(s.next_revision.is_empty());
assert!(s.account_setting_defaults.is_empty());
}
}