Skip to main content

fakecloud_ecs/runtime/
mod.rs

1//! Docker/Podman-based ECS task execution.
2//!
3//! Mirrors the Lambda `ContainerRuntime` approach (auto-detect CLI, forward
4//! localhost → host.docker.internal) but scoped for ECS's different
5//! lifecycle: tasks are ephemeral, so there is no warm-container pool. Each
6//! `run_task` spawns a background tokio task that pulls the image, starts
7//! the container, waits for exit, captures logs, and updates shared ECS
8//! state in place.
9
10use std::path::PathBuf;
11use std::sync::Arc;
12use std::time::Duration;
13
14use base64::Engine;
15use chrono::Utc;
16use fakecloud_core::delivery::DeliveryBus;
17use fakecloud_logs::ingest::{append_events, IngestEvent};
18use fakecloud_logs::SharedLogsState;
19use fakecloud_secretsmanager::SharedSecretsManagerState;
20use fakecloud_ssm::SharedSsmState;
21use parking_lot::RwLock;
22use tempfile::TempDir;
23use tokio::process::Command;
24
25use crate::state::{LifecycleEvent, SharedEcsState};
26
27#[derive(Debug, thiserror::Error)]
28pub enum RuntimeError {
29    #[error("container CLI not found (tried docker, podman)")]
30    NoCli,
31    #[error("image pull failed: {0}")]
32    ImagePull(String),
33    #[error("container start failed: {0}")]
34    ContainerStart(String),
35    #[error("docker wait failed: {0}")]
36    Wait(String),
37}
38
39/// Docker/Podman executor for ECS tasks.
40pub struct EcsRuntime {
41    cli: String,
42    /// Container-to-host networking resolution (host alias, `--add-host`
43    /// arg, sibling-container address) shared with the other runtimes via
44    /// [`fakecloud_core::container_net`]. Carries the issue #1539 podman +
45    /// in-container fixes.
46    net: fakecloud_core::container_net::HostNetworking,
47    /// Port the main fakecloud server bound to. Used to translate AWS
48    /// ECR URIs (`<acct>.dkr.ecr.<region>.amazonaws.com/<repo>:<tag>`) to
49    /// the local OCI v2 endpoint (`127.0.0.1:<port>/<repo>:<tag>`) so
50    /// tasks can pull images pushed to fakecloud's own ECR.
51    server_port: u16,
52    /// Isolated DOCKER_CONFIG dir pre-populated with Basic auth for
53    /// `127.0.0.1:<port>`; keeps the host user's `~/.docker/config.json`
54    /// untouched and lets `docker pull` succeed against fakecloud ECR
55    /// without a prior `aws ecr get-login-password | docker login`.
56    docker_config: Option<Arc<TempDir>>,
57    /// Tracks per-task lists of `(container_name, docker_container_id)` so
58    /// `stop_task` can kill every container backing a task — multi-container
59    /// task definitions launch one docker container per `containerDefinitions`
60    /// entry, all of which must be torn down on stop.
61    containers: RwLock<std::collections::HashMap<String, Vec<(String, String)>>>,
62    /// Cross-service delivery bus — emits `aws.ecs` EventBridge events
63    /// on task state transitions when wired. `None` if the server started
64    /// without EventBridge configured (or for unit tests).
65    delivery_bus: Option<Arc<DeliveryBus>>,
66    /// CloudWatch Logs state — when set, tasks whose container definition
67    /// declares the `awslogs` log driver get their captured stdout/stderr
68    /// forwarded to a log group/stream under this shared state.
69    logs_state: Option<SharedLogsState>,
70    /// SecretsManager state for resolving `containerDefinition.secrets[]`
71    /// entries whose `valueFrom` is a SecretsManager ARN.
72    secretsmanager_state: Option<SharedSecretsManagerState>,
73    /// SSM Parameter Store state for resolving `secrets[]` entries whose
74    /// `valueFrom` is an SSM parameter ARN.
75    ssm_state: Option<SharedSsmState>,
76    /// `Some` when running on the Kubernetes backend; `run_task` then maps
77    /// each task to a Pod instead of `docker run`. `None` is the default
78    /// Docker/Podman backend, and the fields above drive it.
79    k8s: Option<k8s::K8sTaskBackend>,
80    /// Persist hook wired after the owning `EcsService` is built (the store is
81    /// only known then). Runtime state transitions — PENDING->RUNNING via
82    /// `mark_running_multi`, and the stop/exit finalizers — mutate the shared
83    /// state in the background but did not previously flush to disk, so the
84    /// snapshot lagged live task status until the next control-plane write.
85    /// `reconcile_persisted_tasks` compensated on restart, but a task that ran
86    /// and stopped between restarts could still be mis-reported. Set via
87    /// [`EcsRuntime::set_snapshot_hook`] and invoked by
88    /// [`EcsRuntime::persist_snapshot`]. A `OnceLock` so the `Arc<EcsRuntime>`
89    /// can receive the hook through a shared reference after construction.
90    snapshot_hook: std::sync::OnceLock<fakecloud_persistence::SnapshotHook>,
91}
92
93mod config;
94mod k8s;
95mod lb;
96mod monitoring;
97mod secrets;
98mod task_lifecycle;
99
100impl EcsRuntime {
101    /// Auto-detect Docker or Podman. Returns `None` if neither is
102    /// available. Honours `FAKECLOUD_CONTAINER_CLI` for explicit override.
103    /// `server_port` is the port the main fakecloud server bound to;
104    /// needed to resolve AWS ECR URIs against the local OCI v2 registry.
105    pub fn new(server_port: u16) -> Option<Self> {
106        let cli = fakecloud_core::container_net::detect_container_cli()?;
107        let net = fakecloud_core::container_net::HostNetworking::detect(&cli);
108        let docker_config = build_local_registry_docker_config(server_port).map(Arc::new);
109        Some(Self {
110            cli,
111            net,
112            server_port,
113            docker_config,
114            containers: RwLock::new(std::collections::HashMap::new()),
115            delivery_bus: None,
116            logs_state: None,
117            secretsmanager_state: None,
118            ssm_state: None,
119            k8s: None,
120            snapshot_hook: std::sync::OnceLock::new(),
121        })
122    }
123
124    /// Construct the Kubernetes backend. `server_port` is fakecloud's
125    /// bound port (used when `FAKECLOUD_K8S_SELF_URL` omits one). Fails
126    /// fast on misconfiguration — never silently degrades to Docker.
127    pub async fn new_k8s(server_port: u16) -> Result<Self, k8s::BackendInitError> {
128        let backend = k8s::K8sTaskBackend::from_env(server_port).await?;
129        // Docker fields are inert on the k8s backend; populate the cheap
130        // ones and leave docker_config unset.
131        let net = fakecloud_core::container_net::HostNetworking {
132            host_alias: String::new(),
133            add_host_arg: None,
134            sibling_host: String::new(),
135        };
136        Ok(Self {
137            cli: String::new(),
138            net,
139            server_port,
140            docker_config: None,
141            containers: RwLock::new(std::collections::HashMap::new()),
142            delivery_bus: None,
143            logs_state: None,
144            secretsmanager_state: None,
145            ssm_state: None,
146            k8s: Some(backend),
147            snapshot_hook: std::sync::OnceLock::new(),
148        })
149    }
150
151    /// Backend name for logging.
152    pub fn cli_name(&self) -> &str {
153        if self.k8s.is_some() {
154            "kubernetes"
155        } else {
156            &self.cli
157        }
158    }
159
160    /// Sweep task Pods orphaned by a previous process (k8s only; no-op on
161    /// the Docker backend, handled by the shared container reaper).
162    pub async fn reap_stale(&self) {
163        if let Some(k) = &self.k8s {
164            k.reap_stale().await;
165        }
166    }
167
168    /// Wire EventBridge delivery so task state transitions emit
169    /// `aws.ecs` / `ECS Task State Change` events.
170    pub fn with_delivery_bus(mut self, bus: Arc<DeliveryBus>) -> Self {
171        self.delivery_bus = Some(bus);
172        self
173    }
174
175    /// Wire CloudWatch Logs state so tasks using the `awslogs` driver
176    /// get their captured stdout/stderr forwarded.
177    pub fn with_logs(mut self, logs: SharedLogsState) -> Self {
178        self.logs_state = Some(logs);
179        self
180    }
181
182    /// Wire the persistence hook that flushes the ECS snapshot to disk. Called
183    /// once, after the owning `EcsService` (which owns the snapshot store) is
184    /// constructed — hence a `&self` setter over a `OnceLock` rather than a
185    /// consuming builder, since the runtime is already behind an `Arc` by then.
186    /// A no-op in memory mode (the service hands back `None`, so this is never
187    /// called). Subsequent calls are ignored.
188    pub fn set_snapshot_hook(&self, hook: fakecloud_persistence::SnapshotHook) {
189        let _ = self.snapshot_hook.set(hook);
190    }
191
192    /// Persist the current ECS state through the wired snapshot hook, if any.
193    /// Invoked after each background task state transition so a restart sees
194    /// the latest task status without relying solely on restart reconciliation.
195    pub(crate) async fn persist_snapshot(&self) {
196        if let Some(hook) = self.snapshot_hook.get() {
197            hook().await;
198        }
199    }
200}
201
202/// Per-container launch plan derived from a task definition.
203#[derive(Clone, Debug)]
204pub(crate) struct ContainerPlan {
205    pub(crate) container_name: String,
206    pub(crate) image: String,
207    pub(crate) env: Vec<(String, String)>,
208    pub(crate) entry_point: Vec<String>,
209    pub(crate) command: Vec<String>,
210    pub(crate) secrets_refs: Vec<(String, String)>,
211    pub(crate) essential: bool,
212    pub(crate) has_task_role: bool,
213    /// Port mappings parsed from the task definition. Each entry becomes
214    /// a `--publish containerPort:hostPort/protocol` flag on the docker
215    /// run command (except for `awsvpc`, where ports are exposed via the
216    /// per-task ENI rather than the docker host's port table).
217    pub(crate) port_mappings: Vec<PortMapping>,
218    /// Task-level network mode propagated to every container plan so the
219    /// argv builder can decide whether to emit `--publish` flags. Real
220    /// ECS treats `awsvpc` as "container is on its own ENI"; the
221    /// equivalent in fakecloud is "don't publish to the host".
222    pub(crate) network_mode: Option<String>,
223    /// Container dependencies parsed from `dependsOn[]`. Each entry pairs
224    /// the target container name with the condition that must be observed
225    /// before this container is launched: `START` (target exists/running),
226    /// `COMPLETE` (target exited, any code), `SUCCESS` (target exited with
227    /// code 0), or `HEALTHY` (target's docker `Health.Status` is `healthy`).
228    /// Used both to topologically order the launch loop and to gate each
229    /// `docker run` on the upstream condition.
230    pub(crate) depends_on: Vec<DependsOn>,
231    /// Parsed `healthCheck` from the task definition. Translated into
232    /// docker `--health-*` flags on `docker run` so the container's
233    /// health is observable via `docker inspect .State.Health.Status`.
234    /// `None` when the task definition doesn't declare a healthCheck;
235    /// the container's `healthStatus` then stays `UNKNOWN` (matching ECS
236    /// behaviour for tasks without a health probe).
237    pub(crate) health_check: Option<HealthCheckSpec>,
238    /// Volume mounts resolved by joining the container definition's
239    /// `mountPoints[]` with the task definition's `volumes[]`. Each entry
240    /// renders as one `-v` flag on the `docker run` invocation. Empty when
241    /// the container has no mount points or no matching volume entries.
242    pub(crate) volume_mounts: Vec<VolumeMount>,
243    /// Parsed `ulimits` from the container definition. Each entry becomes
244    /// `--ulimit <name>=<soft>:<hard>` on `docker run`.
245    pub(crate) ulimits: Vec<Ulimit>,
246    /// Parsed `linuxParameters` from the container definition. Emits
247    /// `--cap-add`, `--cap-drop`, `--device`, `--init`, `--shm-size`,
248    /// `--sysctl`, `--tmpfs`, `--privileged`, and `--read-only` flags.
249    pub(crate) linux_parameters: Option<LinuxParameters>,
250    /// `stopTimeout` in seconds. Becomes `--stop-timeout <N>` on `docker run`.
251    pub(crate) stop_timeout: Option<u32>,
252    /// `user` from the container definition. Becomes `--user <value>`.
253    pub(crate) user: Option<String>,
254    /// `workingDirectory` from the container definition. Becomes `--workdir`.
255    pub(crate) working_directory: Option<String>,
256    /// `tty` from the container definition. Emits `--tty` when true.
257    pub(crate) tty: bool,
258    /// `interactive` from the container definition. Emits `--interactive` when true.
259    pub(crate) interactive: bool,
260    /// `readonlyRootFilesystem` from the container definition. Emits `--read-only` when true.
261    pub(crate) readonly_rootfs: bool,
262}
263
264/// One parsed `dependsOn[]` entry on a container. Pairs the upstream
265/// container name with the condition that must hold before the dependent
266/// container is launched. AWS spells the conditions `START`, `COMPLETE`,
267/// `SUCCESS`, `HEALTHY` and treats anything else as an error at register
268/// time — we mirror that in [`parse_depends_on`].
269#[derive(Clone, Debug, PartialEq, Eq)]
270pub(crate) struct DependsOn {
271    pub container_name: String,
272    pub condition: DependsOnCondition,
273}
274
275/// `dependsOn[].condition` from the task definition. The variants map
276/// 1:1 to AWS's documented values; the launch loop polls docker for the
277/// matching predicate before starting the dependent container.
278#[derive(Clone, Copy, Debug, PartialEq, Eq)]
279pub(crate) enum DependsOnCondition {
280    /// Upstream container has been started (docker container exists and
281    /// is either running or has exited).
282    Start,
283    /// Upstream container has exited (any exit code).
284    Complete,
285    /// Upstream container has exited with code 0.
286    Success,
287    /// Upstream container's `Health.Status` is `healthy`. When the
288    /// upstream has no healthCheck configured, AWS treats this as
289    /// immediately satisfied — we do the same.
290    Healthy,
291}
292
293impl DependsOnCondition {
294    /// Parse the AWS-spelled condition string. Returns `None` for
295    /// unrecognised values so callers can surface a `ClientException`
296    /// at register time.
297    pub fn parse(raw: &str) -> Option<Self> {
298        match raw {
299            "START" => Some(Self::Start),
300            "COMPLETE" => Some(Self::Complete),
301            "SUCCESS" => Some(Self::Success),
302            "HEALTHY" => Some(Self::Healthy),
303            _ => None,
304        }
305    }
306
307    /// AWS-spelled string for this condition. Used in user-facing error
308    /// messages so timeout/dependency-failed reasons echo back the same
309    /// value the user wrote in their task definition.
310    pub fn as_aws_str(self) -> &'static str {
311        match self {
312            Self::Start => "START",
313            Self::Complete => "COMPLETE",
314            Self::Success => "SUCCESS",
315            Self::Healthy => "HEALTHY",
316        }
317    }
318}
319
320/// Container health check parsed from the ECS task definition. Each
321/// field maps 1:1 to a docker `--health-*` flag on `docker run`. AWS
322/// defaults: interval=30s, timeout=5s, retries=3, startPeriod=0s — we
323/// preserve those defaults at parse time so the argv builder always
324/// has concrete values to emit.
325#[derive(Clone, Debug, PartialEq, Eq)]
326pub(crate) struct HealthCheckSpec {
327    /// `command[]` from the task definition. The first element selects
328    /// the docker syntax: `CMD-SHELL` => `--health-cmd <rest joined by space>`,
329    /// `CMD` => `--health-cmd <rest joined by space>` (still routed to
330    /// `--health-cmd` because docker doesn't accept argv-form here),
331    /// `NONE` => no flag emitted (caller skips emitting healthcheck).
332    pub command: Vec<String>,
333    pub interval_seconds: u32,
334    pub timeout_seconds: u32,
335    pub retries: u32,
336    pub start_period_seconds: u32,
337}
338
339/// One entry in a container's `portMappings`. Mirrors the AWS shape so
340/// [`build_run_argv`] and the `networkBindings` response can share the
341/// same parsed representation.
342#[derive(Clone, Debug, PartialEq, Eq)]
343pub(crate) struct PortMapping {
344    pub container_port: u16,
345    /// `0` (or unset in the source JSON) means "use the same value as
346    /// containerPort" — host-mode default per AWS docs.
347    pub host_port: u16,
348    /// Lower-case `tcp` / `udp`. Defaults to `tcp` when omitted.
349    pub protocol: String,
350}
351
352/// One resolved `mountPoints` entry on a container plan. Computed at
353/// launch by joining the container definition's `mountPoints` against the
354/// task definition's `volumes` array. Each entry becomes a single
355/// `-v <source>:<containerPath>[:ro]` flag on `docker run`.
356///
357/// Source resolution by volume kind:
358/// - **host bind** (`volume.host.sourcePath` set): bind the host path
359///   into the container at `containerPath`.
360/// - **EFS** (`efsVolumeConfiguration` set): bind a host-side stub
361///   directory at `/tmp/fakecloud/efs/<filesystemId>[/<rootDirectory>]`
362///   so multiple tasks targeting the same filesystem id can share state
363///   the way real EFS would. The stub directory is created with
364///   `mkdir -p` ahead of `docker run`.
365/// - **FSx for Windows** (`fsxWindowsFileServerVolumeConfiguration` set):
366///   stub directory at `/tmp/fakecloud/fsx/<filesystemId>/<rootDirectory>`
367///   created the same way as EFS.
368/// - **Docker named volume** (`dockerVolumeConfiguration` set): pass the
369///   volume name through to docker as a named volume reference.
370/// - **Bare volume** (only `name` set, no host config): treated as an
371///   anonymous docker volume for that task — matches AWS's "Docker
372///   volumes" default scope.
373#[derive(Clone, Debug, PartialEq, Eq)]
374pub(crate) struct VolumeMount {
375    /// Left side of `-v`: a host path, a docker named volume, or a stub
376    /// directory under `/tmp/fakecloud/{efs,fsx}/...` for shared FS
377    /// emulation.
378    pub source: String,
379    /// Container-side path, taken verbatim from the container
380    /// definition's `mountPoints[].containerPath`.
381    pub container_path: String,
382    /// `mountPoints[].readOnly` honoured: when true, append `:ro` to the
383    /// `-v` flag so the bind/named volume is read-only inside the
384    /// container. Defaults to false (read-write) when omitted.
385    pub read_only: bool,
386    /// Whether `source` is a task-scoped docker named volume that should be
387    /// removed when the task stops, matching AWS: anonymous "Docker volumes"
388    /// and `dockerVolumeConfiguration` with `scope=task` are deleted on task
389    /// teardown. `false` for host bind mounts, EFS/FSx shared stubs, and
390    /// `scope=shared` docker volumes, which persist independently of the task.
391    pub cleanup_on_stop: bool,
392}
393
394/// One `ulimits` entry. Becomes `--ulimit <name>=<soft>:<hard>`.
395#[derive(Clone, Debug, PartialEq, Eq)]
396pub(crate) struct Ulimit {
397    pub name: String,
398    pub soft_limit: i32,
399    pub hard_limit: i32,
400}
401
402/// One `linuxParameters.devices` entry. Becomes `--device <hostPath>:<containerPath><permissions>`.
403#[derive(Clone, Debug, PartialEq, Eq)]
404pub(crate) struct Device {
405    pub host_path: String,
406    pub container_path: String,
407    pub permissions: String,
408}
409
410/// One `linuxParameters.sysctl` entry. Becomes `--sysctl <name>=<value>`.
411#[derive(Clone, Debug, PartialEq, Eq)]
412pub(crate) struct Sysctl {
413    pub name: String,
414    pub value: String,
415}
416
417/// Parsed `linuxParameters` from the container definition.
418#[derive(Clone, Debug, PartialEq, Eq, Default)]
419pub(crate) struct LinuxParameters {
420    pub capabilities_add: Vec<String>,
421    pub capabilities_drop: Vec<String>,
422    pub devices: Vec<Device>,
423    pub init_process_enabled: bool,
424    pub shared_memory_size: Option<i32>,
425    pub sysctls: Vec<Sysctl>,
426    pub tmpfs: Vec<Tmpfs>,
427    pub privileged: bool,
428}
429
430/// One `linuxParameters.tmpfs` entry. Becomes `--tmpfs <containerPath>:size=<size>M<,options>*`.
431#[derive(Clone, Debug, PartialEq, Eq)]
432pub(crate) struct Tmpfs {
433    pub container_path: String,
434    pub size: i32,
435    pub mount_options: Vec<String>,
436}
437
438#[derive(Clone, Debug)]
439struct ResolvedContainerPlan {
440    plan: ContainerPlan,
441    env: Vec<(String, String)>,
442}
443
444/// Result of waiting for a task's lifetime-determining container.
445#[derive(Clone, Debug)]
446struct TaskExitOutcome {
447    /// Index into the started-containers list of the container whose exit
448    /// closed out the task. `None` only in degenerate cases — kept as
449    /// `Option` so `final_containers` indexing stays explicit.
450    exited_index: Option<usize>,
451    exit_code: i64,
452    stop_code: &'static str,
453}
454
455/// Per-container record persisted on the task. Mirrors the AWS Container
456/// shape but tracks the docker-side container id alongside ECS metadata.
457#[derive(Clone, Debug)]
458pub(crate) struct RunningContainer {
459    pub(crate) name: String,
460    pub(crate) container_id: String,
461    pub(crate) essential: bool,
462    pub(crate) exit_code: Option<i64>,
463    /// Resolved `networkBindings` for DescribeTasks. Computed from the
464    /// task definition's `portMappings` at launch and surfaced verbatim
465    /// in the per-container response.
466    pub(crate) network_bindings: Vec<serde_json::Value>,
467    /// Image digest captured from `docker inspect` after pull. AWS
468    /// surfaces this on the Container response so callers can pin which
469    /// exact image revision a task is running. `None` when the inspect
470    /// failed or the CLI didn't expose `RepoDigests`.
471    pub(crate) image_digest: Option<String>,
472}
473
474/// Pure decision: does the current set of containers warrant stopping
475/// the task? Returns true when any essential container has exited, or
476/// when every container has exited (regardless of essential). Mirrors
477/// AWS ECS task lifetime semantics.
478pub(crate) fn task_should_stop(containers: &[RunningContainer]) -> bool {
479    if containers.is_empty() {
480        return true;
481    }
482    let any_essential_exited = containers
483        .iter()
484        .any(|c| c.essential && c.exit_code.is_some());
485    if any_essential_exited {
486        return true;
487    }
488    containers.iter().all(|c| c.exit_code.is_some())
489}
490
491/// True if the task's `desired_status` in state is `STOPPED` — i.e. a
492/// StopTask / scale-down / DeleteService raced the launch and asked for this
493/// task to be killed. A missing task (deleted from state mid-launch) also
494/// counts as "stop": there's nothing left to keep running for.
495pub(crate) fn task_desired_stopped(
496    state: &SharedEcsState,
497    account_id: &str,
498    task_id: &str,
499) -> bool {
500    let accounts = state.read();
501    match accounts.get(account_id).and_then(|s| s.tasks.get(task_id)) {
502        Some(task) => task.desired_status == "STOPPED",
503        None => true,
504    }
505}
506
507fn build_container_plans(
508    state: &SharedEcsState,
509    account_id: &str,
510    task_id: &str,
511    _server_port: u16,
512) -> Result<Vec<ContainerPlan>, RuntimeError> {
513    let accounts = state.read();
514    let s = accounts
515        .get(account_id)
516        .ok_or_else(|| RuntimeError::ContainerStart("account missing".into()))?;
517    let task = s
518        .tasks
519        .get(task_id)
520        .ok_or_else(|| RuntimeError::ContainerStart("task missing".into()))?;
521    if task.containers.is_empty() {
522        return Err(RuntimeError::ContainerStart(
523            "task has no containers".into(),
524        ));
525    }
526    let has_task_role = task.task_role_arn.is_some();
527    let task_def = s
528        .task_definitions
529        .get(&task.family)
530        .and_then(|revs| revs.get(&task.revision));
531    let network_mode = task_def.and_then(|td| td.network_mode.clone());
532    // Index `volumes[]` by name so each container's `mountPoints[]` can
533    // resolve its volume in O(1). Real ECS rejects mountPoints that
534    // reference an undeclared volume at register time; we don't yet, so
535    // unresolved names just produce zero mounts at launch.
536    let volumes_by_name: std::collections::HashMap<String, &serde_json::Value> = task_def
537        .map(|td| {
538            td.volumes
539                .iter()
540                .filter_map(|v| {
541                    let name = v.get("name").and_then(|n| n.as_str())?;
542                    Some((name.to_string(), v))
543                })
544                .collect()
545        })
546        .unwrap_or_default();
547    let mut plans = Vec::with_capacity(task.containers.len());
548    for container in &task.containers {
549        let def = find_container_definition(s, &task.family, task.revision, &container.name);
550        let secrets_refs = def
551            .as_ref()
552            .and_then(|d| d.get("secrets").and_then(|v| v.as_array()).cloned())
553            .map(|arr| {
554                arr.iter()
555                    .filter_map(|e| {
556                        let name = e.get("name").and_then(|v| v.as_str())?.to_string();
557                        let value_from = e.get("valueFrom").and_then(|v| v.as_str())?.to_string();
558                        Some((name, value_from))
559                    })
560                    .collect::<Vec<_>>()
561            })
562            .unwrap_or_default();
563        let str_array = |key: &str| -> Vec<String> {
564            def.as_ref()
565                .and_then(|d| d.get(key).and_then(|v| v.as_array()).cloned())
566                .map(|arr| {
567                    arr.iter()
568                        .filter_map(|v| v.as_str().map(String::from))
569                        .collect::<Vec<_>>()
570                })
571                .unwrap_or_default()
572        };
573        let env = def
574            .as_ref()
575            .and_then(|d| d.get("environment").and_then(|v| v.as_array()).cloned())
576            .map(|arr| {
577                arr.iter()
578                    .filter_map(|e| {
579                        let k = e.get("name").and_then(|v| v.as_str())?;
580                        let v = e.get("value").and_then(|v| v.as_str()).unwrap_or("");
581                        Some((k.to_string(), v.to_string()))
582                    })
583                    .collect::<Vec<_>>()
584            })
585            .unwrap_or_default();
586        let port_mappings = def
587            .as_ref()
588            .and_then(|d| d.get("portMappings").and_then(|v| v.as_array()).cloned())
589            .map(|arr| {
590                arr.iter()
591                    .filter_map(parse_port_mapping)
592                    .collect::<Vec<_>>()
593            })
594            .unwrap_or_default();
595        let depends_on = def
596            .as_ref()
597            .and_then(|d| d.get("dependsOn").and_then(|v| v.as_array()).cloned())
598            .map(|arr| {
599                arr.iter()
600                    .filter_map(parse_depends_on_entry)
601                    .collect::<Vec<_>>()
602            })
603            .unwrap_or_default();
604        let health_check = def
605            .as_ref()
606            .and_then(|d| d.get("healthCheck"))
607            .and_then(parse_health_check);
608        let volume_mounts = def
609            .as_ref()
610            .and_then(|d| d.get("mountPoints").and_then(|v| v.as_array()).cloned())
611            .map(|arr| {
612                arr.iter()
613                    .filter_map(|mp| resolve_mount_point(mp, &volumes_by_name))
614                    .collect::<Vec<_>>()
615            })
616            .unwrap_or_default();
617        let ulimits = def
618            .as_ref()
619            .and_then(|d| d.get("ulimits").and_then(|v| v.as_array()).cloned())
620            .map(|arr| arr.iter().filter_map(parse_ulimit).collect::<Vec<_>>())
621            .unwrap_or_default();
622        let linux_parameters = def
623            .as_ref()
624            .and_then(|d| d.get("linuxParameters"))
625            .and_then(parse_linux_parameters);
626        let stop_timeout = def.as_ref().and_then(|d| {
627            d.get("stopTimeout")
628                .and_then(|v| v.as_u64())
629                .map(|n| n as u32)
630        });
631        let user = def
632            .as_ref()
633            .and_then(|d| d.get("user").and_then(|v| v.as_str()).map(String::from));
634        let working_directory = def.as_ref().and_then(|d| {
635            d.get("workingDirectory")
636                .and_then(|v| v.as_str())
637                .map(String::from)
638        });
639        let tty = def
640            .as_ref()
641            .and_then(|d| d.get("tty").and_then(|v| v.as_bool()))
642            .unwrap_or(false);
643        let interactive = def
644            .as_ref()
645            .and_then(|d| d.get("interactive").and_then(|v| v.as_bool()))
646            .unwrap_or(false);
647        let readonly_rootfs = def
648            .as_ref()
649            .and_then(|d| d.get("readonlyRootFilesystem").and_then(|v| v.as_bool()))
650            .unwrap_or(false);
651        plans.push(ContainerPlan {
652            container_name: container.name.clone(),
653            image: container.image.clone(),
654            env,
655            entry_point: str_array("entryPoint"),
656            command: str_array("command"),
657            secrets_refs,
658            essential: container.essential,
659            has_task_role,
660            port_mappings,
661            network_mode: network_mode.clone(),
662            depends_on,
663            health_check,
664            volume_mounts,
665            ulimits,
666            linux_parameters,
667            stop_timeout,
668            user,
669            working_directory,
670            tty,
671            interactive,
672            readonly_rootfs,
673        });
674    }
675    let plans = topo_sort_plans(plans);
676    Ok(plans)
677}
678
679/// Resolve one `mountPoints[]` entry against the indexed task-definition
680/// volumes. Returns `None` when:
681/// - the entry has no `containerPath` or `sourceVolume`,
682/// - the named volume isn't declared on the task definition.
683///
684/// Returns `Some(VolumeMount)` for every supported volume kind:
685/// host bind, EFS, FSx, named docker volume, anonymous docker volume.
686fn resolve_mount_point(
687    mount_point: &serde_json::Value,
688    volumes_by_name: &std::collections::HashMap<String, &serde_json::Value>,
689) -> Option<VolumeMount> {
690    let container_path = mount_point
691        .get("containerPath")
692        .and_then(|v| v.as_str())?
693        .to_string();
694    let source_volume = mount_point.get("sourceVolume").and_then(|v| v.as_str())?;
695    let read_only = mount_point
696        .get("readOnly")
697        .and_then(|v| v.as_bool())
698        .unwrap_or(false);
699    let volume = volumes_by_name.get(source_volume)?;
700    let source = resolve_volume_source(source_volume, volume)?;
701    let cleanup_on_stop = volume_is_task_scoped(volume);
702    Some(VolumeMount {
703        source,
704        container_path,
705        read_only,
706        cleanup_on_stop,
707    })
708}
709
710/// Whether a task-definition `volumes[]` entry is a task-scoped docker named
711/// volume that AWS deletes when the task stops. Mirrors the kind matching in
712/// [`resolve_volume_source`]:
713/// - host bind with a non-empty `sourcePath` -> persists on the host: `false`.
714/// - EFS / FSx -> shared filesystem, persists: `false`.
715/// - `dockerVolumeConfiguration` -> task-scoped unless `scope=shared`.
716/// - bare entry (or host with empty `sourcePath`) -> anonymous task-scoped
717///   docker volume: `true`.
718fn volume_is_task_scoped(volume: &serde_json::Value) -> bool {
719    if let Some(host) = volume.get("host") {
720        if let Some(path) = host.get("sourcePath").and_then(|v| v.as_str()) {
721            if !path.is_empty() {
722                return false;
723            }
724        }
725    }
726    if volume.get("efsVolumeConfiguration").is_some()
727        || volume
728            .get("fsxWindowsFileServerVolumeConfiguration")
729            .is_some()
730    {
731        return false;
732    }
733    if let Some(docker) = volume.get("dockerVolumeConfiguration") {
734        // Default scope for an ECS docker volume is `task`; only `shared`
735        // volumes outlive the task.
736        let scope = docker
737            .get("scope")
738            .and_then(|v| v.as_str())
739            .unwrap_or("task");
740        return scope != "shared";
741    }
742    // Bare volume entry (or host with empty sourcePath): anonymous task volume.
743    true
744}
745
746/// Map a single task-definition `volumes[]` entry to the source side of a
747/// `docker run -v` flag. The matching here mirrors the AWS volume kinds:
748///
749/// 1. `host.sourcePath` -> use that path directly (bind mount).
750/// 2. `efsVolumeConfiguration.fileSystemId` -> stub directory under
751///    `/tmp/fakecloud/efs/<filesystemId>[/<rootDirectory>]`. Created with
752///    `mkdir -p` so different tasks targeting the same filesystem id
753///    share the same host directory, matching real EFS's "many tasks,
754///    one filesystem" semantics.
755/// 3. `fsxWindowsFileServerVolumeConfiguration.fileSystemId` -> stub
756///    directory under `/tmp/fakecloud/fsx/<filesystemId>/<rootDirectory>`.
757/// 4. `dockerVolumeConfiguration` -> the volume `name` itself (named
758///    docker volume; docker creates it on first reference).
759/// 5. Bare entry (only `name`) -> the volume `name` as an anonymous
760///    docker volume reference, matching AWS's "Docker volumes" default.
761///
762/// Returns `None` when the configuration is malformed (e.g. EFS without
763/// a fileSystemId).
764fn resolve_volume_source(name: &str, volume: &serde_json::Value) -> Option<String> {
765    if let Some(host) = volume.get("host") {
766        if let Some(path) = host.get("sourcePath").and_then(|v| v.as_str()) {
767            // Empty sourcePath means "anonymous host volume" — fall
768            // through to the named-volume default below.
769            if !path.is_empty() {
770                ensure_dir_exists(path);
771                return Some(path.to_string());
772            }
773        }
774    }
775    if let Some(efs) = volume.get("efsVolumeConfiguration") {
776        let fs_id = efs.get("fileSystemId").and_then(|v| v.as_str())?;
777        let root = efs
778            .get("rootDirectory")
779            .and_then(|v| v.as_str())
780            .unwrap_or("/");
781        return Some(shared_volume_name("efs", fs_id, root));
782    }
783    if let Some(fsx) = volume.get("fsxWindowsFileServerVolumeConfiguration") {
784        let fs_id = fsx.get("fileSystemId").and_then(|v| v.as_str())?;
785        let root = fsx
786            .get("rootDirectory")
787            .and_then(|v| v.as_str())
788            .unwrap_or("/");
789        return Some(shared_volume_name("fsx", fs_id, root));
790    }
791    if volume.get("dockerVolumeConfiguration").is_some() {
792        // Named docker volume — docker auto-creates it on first
793        // reference. Pass the volume name through verbatim.
794        return Some(name.to_string());
795    }
796    // Bare volume entry: anonymous docker volume keyed by name.
797    Some(name.to_string())
798}
799
800/// Compose the docker **named-volume** name for an EFS/FSx volume. A
801/// single shared volume per filesystem id when `rootDirectory` is unset
802/// or `/` (the EFS default mount target); otherwise the rootDirectory is
803/// folded into the name so distinct mount targets within one filesystem
804/// stay isolated. A docker named volume lives on the daemon rather than a
805/// host path, so tasks share state correctly *and* it works when
806/// fakecloud itself runs in a container (`FAKECLOUD_IN_CONTAINER=1`),
807/// where a host-path stub created inside fakecloud's own filesystem would
808/// resolve to an empty dir against the host daemon (issue #1539, bug 0.6).
809/// The segments are sanitized to docker's volume-name charset.
810fn shared_volume_name(kind: &str, fs_id: &str, root: &str) -> String {
811    let trimmed = root.trim_start_matches('/').trim_end_matches('/');
812    let fs_id = sanitize_volume_segment(fs_id);
813    if trimmed.is_empty() {
814        format!("fakecloud-{kind}-{fs_id}")
815    } else {
816        format!(
817            "fakecloud-{kind}-{fs_id}-{}",
818            sanitize_volume_segment(trimmed)
819        )
820    }
821}
822
823/// Map an arbitrary string to docker's volume-name charset by replacing
824/// every character outside `[A-Za-z0-9_.-]` with `-`.
825fn sanitize_volume_segment(s: &str) -> String {
826    s.chars()
827        .map(|c| {
828            if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') {
829                c
830            } else {
831                '-'
832            }
833        })
834        .collect()
835}
836
837/// Best-effort `mkdir -p` so the EFS/FSx stub path exists before the
838/// first task tries to bind-mount it. Failures are ignored — docker
839/// will surface a clear error on the run, and unit tests don't have a
840/// writable `/tmp/fakecloud` in every sandbox.
841fn ensure_dir_exists(path: &str) {
842    let _ = std::fs::create_dir_all(path);
843}
844
845/// Parse one `dependsOn[]` entry. Returns `None` for malformed entries
846/// (missing `containerName`, unrecognised `condition`) so the caller
847/// can drop them silently from the launch plan — register-time
848/// validation already rejects bad values; this is a defensive fallback.
849fn parse_depends_on_entry(value: &serde_json::Value) -> Option<DependsOn> {
850    let container_name = value
851        .get("containerName")
852        .and_then(|v| v.as_str())?
853        .to_string();
854    let raw_condition = value.get("condition").and_then(|v| v.as_str())?;
855    let condition = DependsOnCondition::parse(raw_condition)?;
856    Some(DependsOn {
857        container_name,
858        condition,
859    })
860}
861
862/// Topologically sort container plans so `dependsOn` dependencies start
863/// before their dependants. Implements Kahn's algorithm with stable order:
864/// when multiple plans are ready, we keep their original declaration
865/// index, so a task without any dependsOn launches in the same order the
866/// user wrote in the task definition. Cycles fall through with the
867/// remaining plans appended in original order — the runtime will still
868/// launch every container; it just can't guarantee dependency ordering
869/// in that degenerate case. Cycles are rejected at register time
870/// (RegisterTaskDefinition -> validate_depends_on_acyclic), so reaching
871/// that branch from a real launch path means a bug elsewhere.
872fn topo_sort_plans(plans: Vec<ContainerPlan>) -> Vec<ContainerPlan> {
873    use std::collections::{HashMap, HashSet};
874    let names: HashSet<String> = plans.iter().map(|p| p.container_name.clone()).collect();
875    let index: HashMap<String, usize> = plans
876        .iter()
877        .enumerate()
878        .map(|(i, p)| (p.container_name.clone(), i))
879        .collect();
880    // in_degree[i] = number of unresolved dependencies for plan i. We
881    // ignore depends_on entries that name a container not in the task
882    // (real ECS rejects those at register time; our register path doesn't
883    // yet, so be defensive here).
884    let mut in_degree: Vec<usize> = plans
885        .iter()
886        .map(|p| {
887            p.depends_on
888                .iter()
889                .filter(|d| names.contains(&d.container_name))
890                .count()
891        })
892        .collect();
893    // dependants[i] = indices of plans that depend on plan i.
894    let mut dependants: Vec<Vec<usize>> = vec![Vec::new(); plans.len()];
895    for (i, p) in plans.iter().enumerate() {
896        for d in &p.depends_on {
897            if let Some(&di) = index.get(&d.container_name) {
898                dependants[di].push(i);
899            }
900        }
901    }
902    let mut ordered: Vec<ContainerPlan> = Vec::with_capacity(plans.len());
903    let mut emitted: Vec<bool> = vec![false; plans.len()];
904    loop {
905        // Pick the lowest-index plan whose in_degree is 0 to keep stable
906        // order across runs.
907        let next = (0..plans.len()).find(|&i| !emitted[i] && in_degree[i] == 0);
908        match next {
909            Some(i) => {
910                emitted[i] = true;
911                ordered.push(plans[i].clone());
912                for &di in &dependants[i] {
913                    if in_degree[di] > 0 {
914                        in_degree[di] -= 1;
915                    }
916                }
917            }
918            None => break,
919        }
920    }
921    // Cycle: append anything left in original order so we don't drop plans.
922    for (i, p) in plans.into_iter().enumerate() {
923        if !emitted[i] {
924            ordered.push(p);
925        }
926    }
927    ordered
928}
929
930/// Validate that `containerDefinitions[].dependsOn[]` graph is acyclic.
931/// Real ECS rejects cyclic dependencies at RegisterTaskDefinition time
932/// with a `ClientException`; we mirror that. Returns the offending pair
933/// of container names so the caller can produce a useful error.
934///
935/// Operates directly on the raw JSON definitions (rather than parsed
936/// `ContainerPlan`s) so register-time validation doesn't have to first
937/// build a full plan from a not-yet-stored task definition.
938pub(crate) fn find_depends_on_cycle(
939    container_definitions: &[serde_json::Value],
940) -> Option<(String, String)> {
941    use std::collections::HashMap;
942
943    let names: Vec<String> = container_definitions
944        .iter()
945        .filter_map(|c| c.get("name").and_then(|n| n.as_str()).map(String::from))
946        .collect();
947    let index: HashMap<&str, usize> = names
948        .iter()
949        .enumerate()
950        .map(|(i, n)| (n.as_str(), i))
951        .collect();
952
953    let mut adj: Vec<Vec<usize>> = vec![Vec::new(); names.len()];
954    for (i, cd) in container_definitions.iter().enumerate() {
955        if i >= names.len() {
956            continue;
957        }
958        let Some(deps) = cd.get("dependsOn").and_then(|v| v.as_array()) else {
959            continue;
960        };
961        for d in deps {
962            let Some(target) = d.get("containerName").and_then(|v| v.as_str()) else {
963                continue;
964            };
965            if let Some(&j) = index.get(target) {
966                // Edge: i depends on j -> for cycle DFS we walk from i to j.
967                adj[i].push(j);
968            }
969        }
970    }
971
972    // DFS with three-colour marking (white=0, gray=1, black=2). When we
973    // hit a gray neighbour we've closed a cycle; report the back-edge as
974    // the offending pair.
975    let mut state = vec![0u8; names.len()];
976    let mut stack: Vec<(usize, usize)> = Vec::new();
977    for start in 0..names.len() {
978        if state[start] != 0 {
979            continue;
980        }
981        stack.clear();
982        stack.push((start, 0));
983        state[start] = 1;
984        while let Some(&(node, next_edge)) = stack.last() {
985            if next_edge < adj[node].len() {
986                let nb = adj[node][next_edge];
987                stack.last_mut().unwrap().1 += 1;
988                match state[nb] {
989                    0 => {
990                        state[nb] = 1;
991                        stack.push((nb, 0));
992                    }
993                    1 => {
994                        return Some((names[node].clone(), names[nb].clone()));
995                    }
996                    _ => {}
997                }
998            } else {
999                state[node] = 2;
1000                stack.pop();
1001            }
1002        }
1003    }
1004    None
1005}
1006
1007/// Snapshot of the docker container state we care about for `dependsOn`
1008/// gating: whether the container exists/started, whether it's exited,
1009/// its exit code, and (when configured) its health status.
1010#[derive(Debug, Clone)]
1011struct InspectedState {
1012    started: bool,
1013    exited: bool,
1014    exit_code: i64,
1015    health: Option<String>,
1016}
1017
1018/// One `docker inspect` call returning every field needed by
1019/// [`condition_is_met`]. Returns `None` when the container doesn't exist
1020/// yet or inspect fails — the caller will simply retry on the next poll.
1021async fn inspect_container_state(cli: &str, container_id: &str) -> Option<InspectedState> {
1022    // Compose all four fields into a single inspect format so the gate
1023    // costs one process spawn per poll rather than four.
1024    let format =
1025        "{{.State.Status}}|{{.State.Running}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}<none>{{end}}";
1026    let out = Command::new(cli)
1027        .args(["inspect", "-f", format, container_id])
1028        .output()
1029        .await
1030        .ok()?;
1031    if !out.status.success() {
1032        return None;
1033    }
1034    let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
1035    let parts: Vec<&str> = raw.split('|').collect();
1036    if parts.len() < 4 {
1037        return None;
1038    }
1039    let status = parts[0];
1040    let running = parts[1] == "true";
1041    let exit_code: i64 = parts[2].parse().unwrap_or(-1);
1042    let health = match parts[3] {
1043        "<none>" | "" => None,
1044        other => Some(other.to_string()),
1045    };
1046    // `created` is the brief moment between docker creating the
1047    // container and the entrypoint running. Treat anything past
1048    // `created` as "started" for the START condition.
1049    let started = running || status == "exited" || status == "running" || status == "dead";
1050    let exited = status == "exited" || status == "dead";
1051    Some(InspectedState {
1052        started,
1053        exited,
1054        exit_code,
1055        health,
1056    })
1057}
1058
1059/// Decide whether the polled `state` satisfies a `dependsOn[].condition`.
1060/// Encapsulates the AWS semantics so the polling loop is purely
1061/// mechanical.
1062fn condition_is_met(condition: DependsOnCondition, state: &InspectedState) -> bool {
1063    match condition {
1064        DependsOnCondition::Start => state.started,
1065        DependsOnCondition::Complete => state.exited,
1066        DependsOnCondition::Success => state.exited && state.exit_code == 0,
1067        DependsOnCondition::Healthy => state.health.as_deref() == Some("healthy"),
1068    }
1069}
1070
1071/// Test-only re-export of [`parse_port_mapping`] so sibling test modules
1072/// can lock in the default-port / default-protocol behaviour without us
1073/// widening the visibility of the parser itself.
1074#[cfg(test)]
1075pub(crate) fn __test_parse_port_mapping(value: &serde_json::Value) -> Option<PortMapping> {
1076    parse_port_mapping(value)
1077}
1078
1079/// Parse a `healthCheck` block from a task definition's container
1080/// definition. Returns `None` for missing `command` or for a command
1081/// whose first token is `NONE` (the AWS-documented "disable healthcheck
1082/// inherited from image" sentinel — emit no flags rather than a `none`
1083/// healthcheck). Defaults follow AWS: 30s/5s/3/0s.
1084fn parse_health_check(value: &serde_json::Value) -> Option<HealthCheckSpec> {
1085    let cmd_arr = value.get("command")?.as_array()?;
1086    let command: Vec<String> = cmd_arr
1087        .iter()
1088        .filter_map(|v| v.as_str().map(String::from))
1089        .collect();
1090    if command.is_empty() {
1091        return None;
1092    }
1093    if command.first().map(|s| s.as_str()) == Some("NONE") {
1094        return None;
1095    }
1096    let read_u32 = |key: &str, default: u32| -> u32 {
1097        value
1098            .get(key)
1099            .and_then(|v| v.as_i64())
1100            .filter(|n| (0..=u32::MAX as i64).contains(n))
1101            .map(|n| n as u32)
1102            .unwrap_or(default)
1103    };
1104    Some(HealthCheckSpec {
1105        command,
1106        interval_seconds: read_u32("interval", 30),
1107        timeout_seconds: read_u32("timeout", 5),
1108        retries: read_u32("retries", 3),
1109        start_period_seconds: read_u32("startPeriod", 0),
1110    })
1111}
1112
1113/// Parse one `ulimits` entry from the container definition JSON.
1114fn parse_ulimit(value: &serde_json::Value) -> Option<Ulimit> {
1115    let name = value.get("name").and_then(|v| v.as_str())?;
1116    let soft = value
1117        .get("softLimit")
1118        .and_then(|v| v.as_i64())
1119        .filter(|n| *n >= 0)? as i32;
1120    let hard = value
1121        .get("hardLimit")
1122        .and_then(|v| v.as_i64())
1123        .filter(|n| *n >= 0)? as i32;
1124    Some(Ulimit {
1125        name: name.to_string(),
1126        soft_limit: soft,
1127        hard_limit: hard,
1128    })
1129}
1130
1131/// Parse `linuxParameters` from the container definition JSON.
1132fn parse_linux_parameters(value: &serde_json::Value) -> Option<LinuxParameters> {
1133    let mut lp = LinuxParameters::default();
1134    if let Some(arr) = value
1135        .get("capabilities")
1136        .and_then(|v| v.get("add"))
1137        .and_then(|v| v.as_array())
1138    {
1139        lp.capabilities_add = arr
1140            .iter()
1141            .filter_map(|v| v.as_str().map(String::from))
1142            .collect();
1143    }
1144    if let Some(arr) = value
1145        .get("capabilities")
1146        .and_then(|v| v.get("drop"))
1147        .and_then(|v| v.as_array())
1148    {
1149        lp.capabilities_drop = arr
1150            .iter()
1151            .filter_map(|v| v.as_str().map(String::from))
1152            .collect();
1153    }
1154    if let Some(arr) = value.get("devices").and_then(|v| v.as_array()) {
1155        lp.devices = arr.iter().filter_map(parse_device).collect();
1156    }
1157    lp.init_process_enabled = value
1158        .get("initProcessEnabled")
1159        .and_then(|v| v.as_bool())
1160        .unwrap_or(false);
1161    lp.shared_memory_size = value
1162        .get("sharedMemorySize")
1163        .and_then(|v| v.as_i64())
1164        .map(|n| n as i32);
1165    if let Some(arr) = value.get("sysctl").and_then(|v| v.as_array()) {
1166        lp.sysctls = arr.iter().filter_map(parse_sysctl).collect();
1167    }
1168    if let Some(arr) = value.get("tmpfs").and_then(|v| v.as_array()) {
1169        lp.tmpfs = arr.iter().filter_map(parse_tmpfs).collect();
1170    }
1171    lp.privileged = value
1172        .get("privileged")
1173        .and_then(|v| v.as_bool())
1174        .unwrap_or(false);
1175    Some(lp)
1176}
1177
1178fn parse_device(value: &serde_json::Value) -> Option<Device> {
1179    let host_path = value.get("hostPath").and_then(|v| v.as_str())?.to_string();
1180    let container_path = value
1181        .get("containerPath")
1182        .and_then(|v| v.as_str())?
1183        .to_string();
1184    let permissions = value
1185        .get("permissions")
1186        .and_then(|v| v.as_str())
1187        .unwrap_or("rwm")
1188        .to_string();
1189    Some(Device {
1190        host_path,
1191        container_path,
1192        permissions,
1193    })
1194}
1195
1196fn parse_sysctl(value: &serde_json::Value) -> Option<Sysctl> {
1197    let name = value.get("name").and_then(|v| v.as_str())?.to_string();
1198    let value_str = value.get("value").and_then(|v| v.as_str())?.to_string();
1199    Some(Sysctl {
1200        name,
1201        value: value_str,
1202    })
1203}
1204
1205fn parse_tmpfs(value: &serde_json::Value) -> Option<Tmpfs> {
1206    let container_path = value
1207        .get("containerPath")
1208        .and_then(|v| v.as_str())?
1209        .to_string();
1210    let size = value
1211        .get("size")
1212        .and_then(|v| v.as_i64())
1213        .filter(|n| *n > 0)? as i32;
1214    let mount_options = value
1215        .get("mountOptions")
1216        .and_then(|v| v.as_array())
1217        .map(|arr| {
1218            arr.iter()
1219                .filter_map(|v| v.as_str().map(String::from))
1220                .collect()
1221        })
1222        .unwrap_or_default();
1223    Some(Tmpfs {
1224        container_path,
1225        size,
1226        mount_options,
1227    })
1228}
1229
1230/// Render a [`HealthCheckSpec`] into the docker run flags that emulate
1231/// the equivalent ECS healthCheck. AWS's `command[0]` is a sentinel
1232/// (`CMD-SHELL`/`CMD`/`NONE`); docker's `--health-cmd` always takes a
1233/// single shell-string, so we collapse the remaining tokens with spaces
1234/// for either sentinel — matching how docker itself stringifies HEALTHCHECK
1235/// CMD ["a","b"] back to a shell string at inspect time.
1236pub(crate) fn render_health_flags(hc: &HealthCheckSpec) -> Vec<String> {
1237    if hc.command.len() < 2 {
1238        return Vec::new();
1239    }
1240    let cmd_kind = hc.command[0].as_str();
1241    if cmd_kind != "CMD" && cmd_kind != "CMD-SHELL" {
1242        return Vec::new();
1243    }
1244    let cmd_string = hc.command[1..].join(" ");
1245    vec![
1246        "--health-cmd".into(),
1247        cmd_string,
1248        format!("--health-interval={}s", hc.interval_seconds),
1249        format!("--health-timeout={}s", hc.timeout_seconds),
1250        format!("--health-retries={}", hc.retries),
1251        format!("--health-start-period={}s", hc.start_period_seconds),
1252    ]
1253}
1254
1255/// Test-only re-export of [`parse_health_check`] so unit tests in
1256/// sibling modules can lock in the AWS default-fill behaviour without
1257/// us widening the parser's visibility.
1258#[cfg(test)]
1259pub(crate) fn __test_parse_health_check(value: &serde_json::Value) -> Option<HealthCheckSpec> {
1260    parse_health_check(value)
1261}
1262
1263/// Map a docker `.State.Health.Status` value to the ECS `healthStatus`
1264/// shape. Docker emits `starting|healthy|unhealthy|none|""` (empty when
1265/// the image has no HEALTHCHECK and we didn't add one). ECS only knows
1266/// `HEALTHY|UNHEALTHY|UNKNOWN`, so anything that isn't a clean healthy/
1267/// unhealthy lands in `UNKNOWN`.
1268pub(crate) fn docker_health_to_ecs(raw: &str) -> &'static str {
1269    match raw.trim().to_ascii_lowercase().as_str() {
1270        "healthy" => "HEALTHY",
1271        "unhealthy" => "UNHEALTHY",
1272        _ => "UNKNOWN",
1273    }
1274}
1275
1276/// Parse a single `portMappings[]` entry. Returns `None` for entries
1277/// that are missing `containerPort` or have a value out of `u16` range.
1278/// Defaults: `hostPort` -> `containerPort`, `protocol` -> `tcp`.
1279fn parse_port_mapping(value: &serde_json::Value) -> Option<PortMapping> {
1280    let container_port = value
1281        .get("containerPort")
1282        .and_then(|v| v.as_i64())
1283        .filter(|n| (0..=u16::MAX as i64).contains(n))? as u16;
1284    let host_port_raw = value
1285        .get("hostPort")
1286        .and_then(|v| v.as_i64())
1287        .filter(|n| (0..=u16::MAX as i64).contains(n))
1288        .map(|n| n as u16)
1289        .unwrap_or(0);
1290    let host_port = if host_port_raw == 0 {
1291        container_port
1292    } else {
1293        host_port_raw
1294    };
1295    let protocol = value
1296        .get("protocol")
1297        .and_then(|v| v.as_str())
1298        .map(|s| s.to_ascii_lowercase())
1299        .unwrap_or_else(|| "tcp".to_string());
1300    Some(PortMapping {
1301        container_port,
1302        host_port,
1303        protocol,
1304    })
1305}
1306
1307/// The `fakecloud-instance=fakecloud-<pid>` ownership label value, matching
1308/// exactly how RDS/ElastiCache/Lambda/EC2(Docker) construct it. The shared
1309/// startup reaper lists containers carrying this label, parses the owning
1310/// PID, and removes any whose owner is no longer alive. Returns the full
1311/// `key=value` string ready to follow a `--label` flag.
1312pub(crate) fn fakecloud_instance_label() -> String {
1313    format!("fakecloud-instance=fakecloud-{}", std::process::id())
1314}
1315
1316/// Build the docker `run` argv for a single container plan. Pure so unit
1317/// tests can assert on flag ordering / `--publish` translation without
1318/// shelling out. The returned vector is everything *after* the binary
1319/// name (i.e. starts with `run`, ends with the user-supplied command
1320/// args).
1321pub(crate) fn build_run_argv(
1322    plan: &ContainerPlan,
1323    env: &[(String, String)],
1324    task_id: &str,
1325    host_alias: &str,
1326    add_host_arg: Option<&str>,
1327    run_image: &str,
1328    awsvpc_network_ready: bool,
1329) -> Vec<String> {
1330    let mut argv: Vec<String> = Vec::new();
1331    argv.push("run".into());
1332    argv.push("-d".into());
1333    argv.push("--name".into());
1334    argv.push(format!("{}-{}", task_id, plan.container_name));
1335    argv.push("--label".into());
1336    argv.push(format!("fakecloud-ecs-task={}", task_id));
1337    argv.push("--label".into());
1338    argv.push(format!("fakecloud-ecs-container={}", plan.container_name));
1339    // Ownership label shared with RDS/ElastiCache/Lambda/EC2(Docker). The
1340    // startup reaper (`fakecloud-server::reaper`) filters strictly on
1341    // `label=fakecloud-instance` and parses the owning PID out of the value
1342    // (`fakecloud-<pid>`). Without this, ECS task containers (and their
1343    // host-port publishes / awsvpc networks) leak unreapably after an
1344    // ungraceful restart. See fakecloud_instance_label().
1345    argv.push("--label".into());
1346    argv.push(fakecloud_instance_label());
1347    // Inject `--add-host host.docker.internal:<ip>` only for docker;
1348    // podman provides `host.containers.internal` natively and rejects
1349    // the host-gateway mapping (issue #1539).
1350    if let Some(arg) = add_host_arg {
1351        argv.push("--add-host".into());
1352        argv.push(arg.to_string());
1353    }
1354    let use_awsvpc_network = plan.network_mode.as_deref() == Some("awsvpc") && awsvpc_network_ready;
1355    if use_awsvpc_network {
1356        argv.push("--network".into());
1357        argv.push(format!("fakecloud-ecs-{}", task_id));
1358    }
1359    // `awsvpc` puts the container on a per-task ENI; emulating that on a
1360    // local docker host means *not* publishing to the host port table.
1361    // Bridge / host / default network modes still get `--publish`. If
1362    // the awsvpc per-task network creation failed and we fell back to
1363    // bridge, we DO want to publish so the container is reachable.
1364    let publish_ports = !use_awsvpc_network;
1365    if publish_ports {
1366        for pm in &plan.port_mappings {
1367            argv.push("--publish".into());
1368            argv.push(format!(
1369                "{}:{}/{}",
1370                pm.container_port, pm.host_port, pm.protocol
1371            ));
1372        }
1373    }
1374    if let Some(ref hc) = plan.health_check {
1375        argv.extend(render_health_flags(hc));
1376    }
1377    let http_alias_prefix = format!("http://{host_alias}:");
1378    let https_alias_prefix = format!("https://{host_alias}:");
1379    for (k, v) in env {
1380        let transformed = v
1381            .replace("http://127.0.0.1:", http_alias_prefix.as_str())
1382            .replace("https://127.0.0.1:", https_alias_prefix.as_str())
1383            .replace("http://localhost:", http_alias_prefix.as_str())
1384            .replace("https://localhost:", https_alias_prefix.as_str());
1385        argv.push("-e".into());
1386        argv.push(format!("{}={}", k, transformed));
1387    }
1388    // Volume mounts: one `-v` flag per mountPoints entry, with the
1389    // source resolved from the task definition's `volumes[]`. EFS and
1390    // FSx stubs were materialised on the host (mkdir -p) before this
1391    // function returns, so docker can bind them straight in.
1392    for vm in &plan.volume_mounts {
1393        argv.push("-v".into());
1394        let suffix = if vm.read_only { ":ro" } else { "" };
1395        argv.push(format!("{}:{}{}", vm.source, vm.container_path, suffix));
1396    }
1397    for ul in &plan.ulimits {
1398        argv.push("--ulimit".into());
1399        argv.push(format!("{}={}:{}", ul.name, ul.soft_limit, ul.hard_limit));
1400    }
1401    if let Some(ref lp) = plan.linux_parameters {
1402        for cap in &lp.capabilities_add {
1403            argv.push("--cap-add".into());
1404            argv.push(cap.clone());
1405        }
1406        for cap in &lp.capabilities_drop {
1407            argv.push("--cap-drop".into());
1408            argv.push(cap.clone());
1409        }
1410        for dev in &lp.devices {
1411            argv.push("--device".into());
1412            argv.push(format!(
1413                "{}:{}{}",
1414                dev.host_path, dev.container_path, dev.permissions
1415            ));
1416        }
1417        if lp.init_process_enabled {
1418            argv.push("--init".into());
1419        }
1420        if let Some(size) = lp.shared_memory_size {
1421            argv.push("--shm-size".into());
1422            argv.push(format!("{}m", size));
1423        }
1424        for sys in &lp.sysctls {
1425            argv.push("--sysctl".into());
1426            argv.push(format!("{}={}", sys.name, sys.value));
1427        }
1428        for tmp in &lp.tmpfs {
1429            let mut opts = tmp.mount_options.join(",");
1430            if !opts.is_empty() {
1431                opts = format!(",{}", opts);
1432            }
1433            argv.push("--tmpfs".into());
1434            argv.push(format!("{}:size={}M{}", tmp.container_path, tmp.size, opts));
1435        }
1436        if lp.privileged {
1437            argv.push("--privileged".into());
1438        }
1439    }
1440    if let Some(timeout) = plan.stop_timeout {
1441        argv.push("--stop-timeout".into());
1442        argv.push(format!("{}", timeout));
1443    }
1444    if let Some(ref user) = plan.user {
1445        argv.push("--user".into());
1446        argv.push(user.clone());
1447    }
1448    if let Some(ref wd) = plan.working_directory {
1449        argv.push("--workdir".into());
1450        argv.push(wd.clone());
1451    }
1452    if plan.tty {
1453        argv.push("--tty".into());
1454    }
1455    if plan.interactive {
1456        argv.push("--interactive".into());
1457    }
1458    if plan.readonly_rootfs {
1459        argv.push("--read-only".into());
1460    }
1461    if let Some(first) = plan.entry_point.first() {
1462        argv.push("--entrypoint".into());
1463        argv.push(first.clone());
1464    }
1465    argv.push(run_image.to_string());
1466    for arg in plan.entry_point.iter().skip(1) {
1467        argv.push(arg.clone());
1468    }
1469    for arg in &plan.command {
1470        argv.push(arg.clone());
1471    }
1472    argv
1473}
1474
1475/// Render `networkBindings` JSON for a launched container. Empty under
1476/// `awsvpc` (the equivalent info goes on the task's ENI attachments) and
1477/// for containers without `portMappings`.
1478pub(crate) fn network_bindings_for(plan: &ContainerPlan) -> Vec<serde_json::Value> {
1479    if plan.network_mode.as_deref() == Some("awsvpc") {
1480        return Vec::new();
1481    }
1482    plan.port_mappings
1483        .iter()
1484        .map(|pm| {
1485            serde_json::json!({
1486                "bindIP": "0.0.0.0",
1487                "containerPort": pm.container_port,
1488                "hostPort": pm.host_port,
1489                "protocol": pm.protocol,
1490            })
1491        })
1492        .collect()
1493}
1494
1495/// Compute ELBv2 target registrations for a task based on its service's
1496/// loadBalancers configuration. Returns (target_group_arn, [(target_id, port)])
1497/// for each target group that should receive this task.
1498#[allow(clippy::type_complexity)]
1499pub(crate) fn compute_elbv2_targets(
1500    ecs_state: &crate::state::EcsState,
1501    task: &crate::state::Task,
1502) -> Vec<(String, Vec<(String, Option<i64>)>)> {
1503    let mut result = Vec::new();
1504    let Some(group) = task.group.as_deref() else {
1505        return result;
1506    };
1507    let service_name = group.strip_prefix("service:").unwrap_or(group);
1508    let key = crate::state::EcsState::service_key(&task.cluster_name, service_name);
1509    let Some(service) = ecs_state.services.get(&key) else {
1510        return result;
1511    };
1512
1513    let network_mode = ecs_state
1514        .task_definitions
1515        .get(&task.family)
1516        .and_then(|revs| revs.get(&task.revision))
1517        .and_then(|td| td.network_mode.as_deref());
1518
1519    for lb in &service.load_balancers {
1520        let tg_arn = lb.get("targetGroupArn").and_then(|v| v.as_str());
1521        let container_name = lb.get("containerName").and_then(|v| v.as_str());
1522        let container_port = lb.get("containerPort").and_then(|v| v.as_i64());
1523        let Some(tg_arn) = tg_arn else { continue };
1524        let Some(container_name) = container_name else {
1525            continue;
1526        };
1527
1528        let target_id = if network_mode == Some("awsvpc") {
1529            task.attachments
1530                .iter()
1531                .find(|a| a.attachment_type == "eni")
1532                .and_then(|eni| {
1533                    eni.details
1534                        .iter()
1535                        .find(|d| d.name == "privateIPv4Address")
1536                        .map(|d| d.value.clone())
1537                })
1538        } else {
1539            Some("127.0.0.1".to_string())
1540        };
1541
1542        let port = if network_mode == Some("awsvpc") {
1543            container_port
1544        } else {
1545            task.containers
1546                .iter()
1547                .find(|c| c.name == container_name)
1548                .and_then(|c| {
1549                    c.network_bindings
1550                        .iter()
1551                        .find(|nb| {
1552                            nb.get("containerPort").and_then(|v| v.as_i64()) == container_port
1553                        })
1554                        .and_then(|nb| nb.get("hostPort").and_then(|v| v.as_i64()))
1555                })
1556        };
1557
1558        if let Some(id) = target_id {
1559            if let Some(entry) = result.iter_mut().find(|(arn, _)| arn == tg_arn) {
1560                entry.1.push((id, port));
1561            } else {
1562                result.push((tg_arn.to_string(), vec![(id, port)]));
1563            }
1564        }
1565    }
1566    result
1567}
1568
1569struct TaskSnapshot {
1570    task_arn: String,
1571    cluster_arn: String,
1572    launch_type: String,
1573    group: Option<String>,
1574    task_definition_arn: String,
1575    containers: serde_json::Value,
1576}
1577
1578fn snapshot_task(state: &SharedEcsState, account_id: &str, task_id: &str) -> Option<TaskSnapshot> {
1579    let accounts = state.read();
1580    let s = accounts.get(account_id)?;
1581    let task = s.tasks.get(task_id)?;
1582    Some(TaskSnapshot {
1583        task_arn: task.task_arn.clone(),
1584        cluster_arn: task.cluster_arn.clone(),
1585        launch_type: task.launch_type.clone(),
1586        group: task.group.clone(),
1587        task_definition_arn: task.task_definition_arn.clone(),
1588        containers: serde_json::Value::Array(
1589            task.containers
1590                .iter()
1591                .map(|c| {
1592                    serde_json::json!({
1593                        "containerArn": c.container_arn,
1594                        "name": c.name,
1595                        "image": c.image,
1596                        "lastStatus": c.last_status,
1597                        "exitCode": c.exit_code,
1598                        "reason": c.reason,
1599                    })
1600                })
1601                .collect(),
1602        ),
1603    })
1604}
1605
1606/// Build an isolated docker config directory with Basic auth for
1607/// fakecloud ECR. Lets `docker pull/push/tag` work against the local OCI
1608/// v2 registry without requiring the user to run
1609/// `aws ecr get-login-password | docker login` first. Authorizes every host
1610/// fakecloud's ECR can be addressed by -- `127.0.0.1` (fakecloud on the host),
1611/// `host.docker.internal` (Docker) and `host.containers.internal` (podman) when
1612/// fakecloud runs in a container and pull URIs are rewritten to the sibling
1613/// host. Centralized in container_net so Lambda and ECS can't drift (bug-audit
1614/// 2026-06-20, 0.B2).
1615fn build_local_registry_docker_config(server_port: u16) -> Option<TempDir> {
1616    let dir = TempDir::new().ok()?;
1617    let auth = base64::engine::general_purpose::STANDARD.encode("AWS:fakecloud-ecs-runtime");
1618    let auths: serde_json::Map<String, serde_json::Value> =
1619        fakecloud_core::container_net::registry_auth_hosts(server_port)
1620            .into_iter()
1621            .map(|host| (host, serde_json::json!({ "auth": auth })))
1622            .collect();
1623    let config = serde_json::json!({ "auths": auths });
1624    std::fs::write(dir.path().join("config.json"), config.to_string()).ok()?;
1625    Some(dir)
1626}
1627
1628fn find_container_definition(
1629    state: &crate::state::EcsState,
1630    family: &str,
1631    revision: i32,
1632    name: &str,
1633) -> Option<serde_json::Value> {
1634    state
1635        .task_definitions
1636        .get(family)?
1637        .get(&revision)?
1638        .container_definitions
1639        .iter()
1640        .find(|c| c.get("name").and_then(|v| v.as_str()) == Some(name))
1641        .cloned()
1642}
1643
1644fn mark_pull_started(state: &SharedEcsState, account_id: &str, task_id: &str) {
1645    let mut accounts = state.write();
1646    let Some(s) = accounts.get_mut(account_id) else {
1647        return;
1648    };
1649    let task_arn_cluster = s
1650        .tasks
1651        .get(task_id)
1652        .map(|t| (t.task_arn.clone(), t.cluster_arn.clone()));
1653    if let Some(task) = s.tasks.get_mut(task_id) {
1654        task.pull_started_at = Some(Utc::now());
1655    }
1656    if let Some((arn, cluster_arn)) = task_arn_cluster {
1657        s.push_event(LifecycleEvent {
1658            at: Utc::now(),
1659            event_type: "PullStarted".into(),
1660            task_arn: Some(arn),
1661            cluster_arn: Some(cluster_arn),
1662            last_status: Some("PENDING".into()),
1663            detail: serde_json::json!({}),
1664        });
1665    }
1666}
1667
1668fn mark_pull_stopped(state: &SharedEcsState, account_id: &str, task_id: &str) {
1669    let mut accounts = state.write();
1670    let Some(s) = accounts.get_mut(account_id) else {
1671        return;
1672    };
1673    if let Some(task) = s.tasks.get_mut(task_id) {
1674        task.pull_stopped_at = Some(Utc::now());
1675    }
1676}
1677
1678pub(crate) fn mark_running_multi(
1679    state: &SharedEcsState,
1680    account_id: &str,
1681    task_id: &str,
1682    started: &[RunningContainer],
1683) {
1684    let mut accounts = state.write();
1685    let Some(s) = accounts.get_mut(account_id) else {
1686        return;
1687    };
1688    let (arn, cluster_arn) = {
1689        let Some(task) = s.tasks.get_mut(task_id) else {
1690            return;
1691        };
1692        task.last_status = "RUNNING".into();
1693        task.connectivity = "CONNECTED".into();
1694        task.connectivity_at = Some(Utc::now());
1695        task.started_at = Some(Utc::now());
1696        for rc in started {
1697            if let Some(c) = task.containers.iter_mut().find(|c| c.name == rc.name) {
1698                c.runtime_id = Some(rc.container_id.clone());
1699                c.last_status = "RUNNING".into();
1700                c.network_bindings = rc.network_bindings.clone();
1701                if rc.image_digest.is_some() {
1702                    c.image_digest = rc.image_digest.clone();
1703                }
1704            }
1705        }
1706        if let Some(cluster) = s.clusters.get_mut(&task.cluster_name) {
1707            cluster.running_tasks_count += 1;
1708            if cluster.pending_tasks_count > 0 {
1709                cluster.pending_tasks_count -= 1;
1710            }
1711        }
1712        if let Some(ref ci_arn) = task.container_instance_arn {
1713            if let Some(ci) = s
1714                .container_instances
1715                .values_mut()
1716                .find(|ci| ci.container_instance_arn == *ci_arn)
1717            {
1718                ci.running_tasks_count += 1;
1719                if ci.pending_tasks_count > 0 {
1720                    ci.pending_tasks_count -= 1;
1721                }
1722            }
1723        }
1724        (task.task_arn.clone(), task.cluster_arn.clone())
1725    };
1726    s.push_event(LifecycleEvent {
1727        at: Utc::now(),
1728        event_type: "TaskStateChange".into(),
1729        task_arn: Some(arn),
1730        cluster_arn: Some(cluster_arn),
1731        last_status: Some("RUNNING".into()),
1732        detail: serde_json::json!({}),
1733    });
1734}
1735
1736#[allow(clippy::too_many_arguments)]
1737fn finalize_stopped_multi(
1738    state: &SharedEcsState,
1739    account_id: &str,
1740    task_id: &str,
1741    final_containers: &[RunningContainer],
1742    primary_exit_code: i64,
1743    captured: &str,
1744    stop_code: &str,
1745    stopped_reason: Option<String>,
1746) {
1747    let mut accounts = state.write();
1748    let Some(s) = accounts.get_mut(account_id) else {
1749        return;
1750    };
1751    let (arn, cluster_arn) = {
1752        let Some(task) = s.tasks.get_mut(task_id) else {
1753            return;
1754        };
1755        task.last_status = "STOPPED".into();
1756        task.desired_status = "STOPPED".into();
1757        task.stopping_at = task.stopping_at.or(Some(Utc::now()));
1758        task.stopped_at = Some(Utc::now());
1759        task.stop_code = Some(stop_code.into());
1760        task.stopped_reason = stopped_reason.or(Some(format!("Exit code {}", primary_exit_code)));
1761        task.captured_logs = captured.to_string();
1762        for c in task.containers.iter_mut() {
1763            c.last_status = "STOPPED".into();
1764            if c.exit_code.is_none() {
1765                let mapped = final_containers
1766                    .iter()
1767                    .find(|r| r.name == c.name)
1768                    .and_then(|r| r.exit_code);
1769                c.exit_code = mapped.or(Some(primary_exit_code));
1770            }
1771        }
1772        if let Some(cluster) = s.clusters.get_mut(&task.cluster_name) {
1773            if cluster.running_tasks_count > 0 {
1774                cluster.running_tasks_count -= 1;
1775            }
1776        }
1777        if let Some(ref ci_arn) = task.container_instance_arn {
1778            if let Some(ci) = s
1779                .container_instances
1780                .values_mut()
1781                .find(|ci| ci.container_instance_arn == *ci_arn)
1782            {
1783                if ci.running_tasks_count > 0 {
1784                    ci.running_tasks_count -= 1;
1785                }
1786            }
1787        }
1788        (task.task_arn.clone(), task.cluster_arn.clone())
1789    };
1790    s.push_event(LifecycleEvent {
1791        at: Utc::now(),
1792        event_type: "TaskStateChange".into(),
1793        task_arn: Some(arn),
1794        cluster_arn: Some(cluster_arn),
1795        last_status: Some("STOPPED".into()),
1796        detail: serde_json::json!({
1797            "exitCode": primary_exit_code,
1798            "stopCode": stop_code,
1799        }),
1800    });
1801}
1802
1803fn finalize_failure(state: &SharedEcsState, account_id: &str, task_id: &str, reason: &str) {
1804    let mut accounts = state.write();
1805    let Some(s) = accounts.get_mut(account_id) else {
1806        return;
1807    };
1808    let (arn, cluster_arn) = {
1809        let Some(task) = s.tasks.get_mut(task_id) else {
1810            return;
1811        };
1812        // Capture the prior status before we clobber it: if the task had
1813        // already reached RUNNING when execution failed (e.g. `docker wait`
1814        // blew up after the container started), we owe the cluster a
1815        // running-tasks decrement. Tasks that died before RUNNING only
1816        // ever incremented pendingTasksCount.
1817        let was_running = task.last_status == "RUNNING";
1818        task.last_status = "STOPPED".into();
1819        task.desired_status = "STOPPED".into();
1820        task.stopped_at = Some(Utc::now());
1821        task.stop_code = Some("TaskFailedToStart".into());
1822        task.stopped_reason = Some(reason.to_string());
1823        // Surface the failure reason on the /logs endpoint — without this,
1824        // a task that never reached RUNNING returns an empty log string,
1825        // leaving E2E assertions with no diagnostic.
1826        task.captured_logs = format!("[task failed to start]: {reason}");
1827        for c in task.containers.iter_mut() {
1828            c.last_status = "STOPPED".into();
1829            c.reason = Some(reason.to_string());
1830        }
1831        if let Some(cluster) = s.clusters.get_mut(&task.cluster_name) {
1832            if was_running {
1833                if cluster.running_tasks_count > 0 {
1834                    cluster.running_tasks_count -= 1;
1835                }
1836            } else if cluster.pending_tasks_count > 0 {
1837                cluster.pending_tasks_count -= 1;
1838            }
1839        }
1840        if let Some(ref ci_arn) = task.container_instance_arn {
1841            if let Some(ci) = s
1842                .container_instances
1843                .values_mut()
1844                .find(|ci| ci.container_instance_arn == *ci_arn)
1845            {
1846                if was_running {
1847                    if ci.running_tasks_count > 0 {
1848                        ci.running_tasks_count -= 1;
1849                    }
1850                } else if ci.pending_tasks_count > 0 {
1851                    ci.pending_tasks_count -= 1;
1852                }
1853            }
1854        }
1855        (task.task_arn.clone(), task.cluster_arn.clone())
1856    };
1857    s.push_event(LifecycleEvent {
1858        at: Utc::now(),
1859        event_type: "TaskFailedToStart".into(),
1860        task_arn: Some(arn),
1861        cluster_arn: Some(cluster_arn),
1862        last_status: Some("STOPPED".into()),
1863        detail: serde_json::json!({ "reason": reason }),
1864    });
1865}
1866
1867/// Short helper for tests + snapshot code to sleep between state
1868/// transitions. Exposed on the crate boundary to keep test timing
1869/// centralized.
1870pub async fn sleep(duration: Duration) {
1871    tokio::time::sleep(duration).await;
1872}
1873
1874#[cfg(test)]
1875mod tests {
1876    use super::*;
1877    use crate::state::{EcsState, Task};
1878    use fakecloud_aws::arn::Arn;
1879    use fakecloud_core::multi_account::MultiAccountState;
1880    use parking_lot::RwLock;
1881    use std::sync::Arc;
1882
1883    #[test]
1884    fn cli_available_for_known_missing_binary_is_false() {
1885        assert!(!fakecloud_core::container_net::cli_available(
1886            "definitely-not-a-real-cli-binary-xyz"
1887        ));
1888    }
1889
1890    #[test]
1891    fn aws_ecr_uris_translate_for_local_pull() {
1892        assert_eq!(
1893            fakecloud_core::ecr_uri::translate_to_local(
1894                "123456789012.dkr.ecr.us-east-1.amazonaws.com/app:latest",
1895                4566
1896            )
1897            .as_deref(),
1898            Some("127.0.0.1:4566/app:latest")
1899        );
1900    }
1901
1902    fn make_task(task_id: &str) -> Task {
1903        Task {
1904            task_arn: Arn::new(
1905                "ecs",
1906                "us-east-1",
1907                "000000000000",
1908                &format!("task/default/{task_id}"),
1909            )
1910            .to_string(),
1911            task_id: task_id.into(),
1912            cluster_arn: "arn:aws:ecs:us-east-1:000000000000:cluster/default".into(),
1913            cluster_name: "default".into(),
1914            task_definition_arn: "arn:aws:ecs:us-east-1:000000000000:task-definition/app:1".into(),
1915            family: "app".into(),
1916            revision: 1,
1917            container_instance_arn: None,
1918            capacity_provider_name: None,
1919            last_status: "PENDING".into(),
1920            desired_status: "RUNNING".into(),
1921            launch_type: "FARGATE".into(),
1922            platform_version: None,
1923            cpu: None,
1924            memory: None,
1925            containers: Vec::new(),
1926            overrides: serde_json::json!({}),
1927            started_by: None,
1928            group: None,
1929            connectivity: "CONNECTING".into(),
1930            stop_code: None,
1931            stopped_reason: None,
1932            created_at: Utc::now(),
1933            started_at: None,
1934            stopping_at: None,
1935            stopped_at: None,
1936            pull_started_at: None,
1937            pull_stopped_at: None,
1938            connectivity_at: None,
1939            started_by_ref_id: None,
1940            execution_role_arn: None,
1941            task_role_arn: None,
1942            tags: Vec::new(),
1943            awslogs: None,
1944            captured_logs: String::new(),
1945            protection: None,
1946            enable_execute_command: false,
1947            attachments: Vec::new(),
1948            volume_configurations: Vec::new(),
1949            task_set_arn: None,
1950        }
1951    }
1952
1953    #[test]
1954    fn finalize_failure_writes_reason_into_captured_logs() {
1955        let mut accounts: MultiAccountState<EcsState> =
1956            MultiAccountState::new("000000000000", "us-east-1", "http://localhost:4566");
1957        let acct = accounts.get_or_create("000000000000");
1958        acct.tasks.insert("t1".into(), make_task("t1"));
1959        let state: SharedEcsState = Arc::new(RwLock::new(accounts));
1960
1961        finalize_failure(
1962            &state,
1963            "000000000000",
1964            "t1",
1965            "failed to resolve secret DB_PASSWORD",
1966        );
1967
1968        let accounts = state.read();
1969        let task = accounts
1970            .get("000000000000")
1971            .unwrap()
1972            .tasks
1973            .get("t1")
1974            .unwrap();
1975        assert_eq!(task.last_status, "STOPPED");
1976        assert_eq!(task.stop_code.as_deref(), Some("TaskFailedToStart"));
1977        assert!(
1978            task.captured_logs
1979                .contains("failed to resolve secret DB_PASSWORD"),
1980            "captured_logs missing reason: {:?}",
1981            task.captured_logs
1982        );
1983        assert!(
1984            task.captured_logs.starts_with("[task failed to start]:"),
1985            "captured_logs missing prefix: {:?}",
1986            task.captured_logs
1987        );
1988    }
1989
1990    /// Records the last serialized snapshot so tests can assert what a runtime
1991    /// state transition flushed to disk.
1992    struct RecordingStore(Arc<std::sync::Mutex<Option<Vec<u8>>>>);
1993    impl fakecloud_persistence::SnapshotStore for RecordingStore {
1994        fn load(&self) -> std::io::Result<Option<Vec<u8>>> {
1995            Ok(None)
1996        }
1997        fn save(&self, bytes: &[u8]) -> std::io::Result<()> {
1998            *self.0.lock().unwrap() = Some(bytes.to_vec());
1999            Ok(())
2000        }
2001    }
2002
2003    fn bare_runtime() -> EcsRuntime {
2004        EcsRuntime {
2005            cli: String::new(),
2006            net: fakecloud_core::container_net::HostNetworking {
2007                host_alias: String::new(),
2008                add_host_arg: None,
2009                sibling_host: String::new(),
2010            },
2011            server_port: 0,
2012            docker_config: None,
2013            containers: RwLock::new(std::collections::HashMap::new()),
2014            delivery_bus: None,
2015            logs_state: None,
2016            secretsmanager_state: None,
2017            ssm_state: None,
2018            k8s: None,
2019            snapshot_hook: std::sync::OnceLock::new(),
2020        }
2021    }
2022
2023    /// The PENDING->RUNNING transition (`mark_running_multi`) must be flushed
2024    /// through the runtime's persistence hook so a restart sees RUNNING without
2025    /// relying solely on restart reconciliation. Regression for the Tier-4
2026    /// bug-hunt finding that runtime transitions never called the snapshot.
2027    #[tokio::test]
2028    async fn running_transition_persists_snapshot() {
2029        let mut accounts: MultiAccountState<EcsState> =
2030            MultiAccountState::new("000000000000", "us-east-1", "http://localhost:4566");
2031        let acct = accounts.get_or_create("000000000000");
2032        acct.tasks.insert("t1".into(), make_task("t1"));
2033        let state: SharedEcsState = Arc::new(RwLock::new(accounts));
2034
2035        // Build the production persistence hook backed by a recording store.
2036        let recorded = Arc::new(std::sync::Mutex::new(None));
2037        let store: Arc<dyn fakecloud_persistence::SnapshotStore> =
2038            Arc::new(RecordingStore(recorded.clone()));
2039        let hook = crate::service::EcsService::new(state.clone())
2040            .with_snapshot_store(store)
2041            .snapshot_hook()
2042            .expect("hook present when a store is wired");
2043
2044        let rt = bare_runtime();
2045        rt.set_snapshot_hook(hook);
2046
2047        // Nothing persisted before the transition.
2048        assert!(recorded.lock().unwrap().is_none());
2049
2050        // Drive the PENDING->RUNNING transition + persist, exactly as
2051        // run_task_inner does after launching containers.
2052        mark_running_multi(&state, "000000000000", "t1", &[]);
2053        rt.persist_snapshot().await;
2054
2055        let bytes = recorded
2056            .lock()
2057            .unwrap()
2058            .clone()
2059            .expect("running transition should have flushed a snapshot");
2060        let snap: crate::EcsSnapshot = serde_json::from_slice(&bytes).unwrap();
2061        let persisted = snap.accounts.expect("accounts present");
2062        let task = persisted
2063            .get("000000000000")
2064            .unwrap()
2065            .tasks
2066            .get("t1")
2067            .unwrap();
2068        assert_eq!(
2069            task.last_status, "RUNNING",
2070            "persisted snapshot must reflect the RUNNING transition"
2071        );
2072    }
2073
2074    /// 4.2 — `task_desired_stopped` is the post-launch gate `run_task_inner`
2075    /// uses to detect a StopTask / scale-down / DeleteService that raced the
2076    /// launch. RUNNING desired_status -> keep running; STOPPED -> self-stop;
2077    /// task removed from state -> treat as stop (nothing to keep alive).
2078    #[test]
2079    fn task_desired_stopped_detects_stop_during_launch() {
2080        let mut accounts: MultiAccountState<EcsState> =
2081            MultiAccountState::new("000000000000", "us-east-1", "http://localhost:4566");
2082        let acct = accounts.get_or_create("000000000000");
2083        acct.tasks.insert("running".into(), make_task("running"));
2084        let mut stopping = make_task("stopping");
2085        stopping.desired_status = "STOPPED".into();
2086        acct.tasks.insert("stopping".into(), stopping);
2087        let state: SharedEcsState = Arc::new(RwLock::new(accounts));
2088
2089        assert!(
2090            !task_desired_stopped(&state, "000000000000", "running"),
2091            "a RUNNING task must not be treated as stopped",
2092        );
2093        assert!(
2094            task_desired_stopped(&state, "000000000000", "stopping"),
2095            "a task whose desired_status is STOPPED must be treated as stopped",
2096        );
2097        assert!(
2098            task_desired_stopped(&state, "000000000000", "deleted-mid-launch"),
2099            "a task removed from state mid-launch must be treated as stopped",
2100        );
2101    }
2102
2103    fn make_container(name: &str, essential: bool) -> crate::state::Container {
2104        crate::state::Container {
2105            container_arn: format!(
2106                "arn:aws:ecs:us-east-1:000000000000:container/default/abc/{name}"
2107            ),
2108            name: name.into(),
2109            image: "alpine".into(),
2110            task_arn: "arn:aws:ecs:us-east-1:000000000000:task/default/abc".into(),
2111            last_status: "RUNNING".into(),
2112            exit_code: None,
2113            reason: None,
2114            runtime_id: Some(format!("dockerid-{name}")),
2115            essential,
2116            cpu: None,
2117            memory: None,
2118            memory_reservation: None,
2119            network_bindings: Vec::new(),
2120            network_interfaces: Vec::new(),
2121            health_status: None,
2122            managed_agents: None,
2123            image_digest: None,
2124        }
2125    }
2126
2127    #[test]
2128    fn task_should_stop_when_essential_exits() {
2129        let containers = vec![
2130            RunningContainer {
2131                name: "app".into(),
2132                container_id: "id-app".into(),
2133                essential: true,
2134                exit_code: Some(0),
2135                network_bindings: Vec::new(),
2136                image_digest: None,
2137            },
2138            RunningContainer {
2139                name: "sidecar".into(),
2140                container_id: "id-sc".into(),
2141                essential: false,
2142                exit_code: None,
2143                network_bindings: Vec::new(),
2144                image_digest: None,
2145            },
2146        ];
2147        assert!(task_should_stop(&containers));
2148    }
2149
2150    #[test]
2151    fn task_keeps_running_when_only_non_essential_exits() {
2152        let containers = vec![
2153            RunningContainer {
2154                name: "app".into(),
2155                container_id: "id-app".into(),
2156                essential: true,
2157                exit_code: None,
2158                network_bindings: Vec::new(),
2159                image_digest: None,
2160            },
2161            RunningContainer {
2162                name: "sidecar".into(),
2163                container_id: "id-sc".into(),
2164                essential: false,
2165                exit_code: Some(0),
2166                network_bindings: Vec::new(),
2167                image_digest: None,
2168            },
2169        ];
2170        assert!(!task_should_stop(&containers));
2171    }
2172
2173    #[test]
2174    fn task_stops_when_all_non_essentials_exit() {
2175        let containers = vec![
2176            RunningContainer {
2177                name: "a".into(),
2178                container_id: "id-a".into(),
2179                essential: false,
2180                exit_code: Some(0),
2181                network_bindings: Vec::new(),
2182                image_digest: None,
2183            },
2184            RunningContainer {
2185                name: "b".into(),
2186                container_id: "id-b".into(),
2187                essential: false,
2188                exit_code: Some(1),
2189                network_bindings: Vec::new(),
2190                image_digest: None,
2191            },
2192        ];
2193        assert!(task_should_stop(&containers));
2194    }
2195
2196    #[test]
2197    fn finalize_stopped_multi_assigns_per_container_exit_codes() {
2198        let mut accounts: MultiAccountState<EcsState> =
2199            MultiAccountState::new("000000000000", "us-east-1", "http://localhost:4566");
2200        let acct = accounts.get_or_create("000000000000");
2201        let mut t = make_task("t1");
2202        t.containers = vec![
2203            make_container("app", true),
2204            make_container("sidecar", false),
2205        ];
2206        acct.tasks.insert("t1".into(), t);
2207        let state: SharedEcsState = Arc::new(RwLock::new(accounts));
2208
2209        let final_containers = vec![
2210            RunningContainer {
2211                name: "app".into(),
2212                container_id: "id-app".into(),
2213                essential: true,
2214                exit_code: Some(0),
2215                network_bindings: Vec::new(),
2216                image_digest: None,
2217            },
2218            RunningContainer {
2219                name: "sidecar".into(),
2220                container_id: "id-sc".into(),
2221                essential: false,
2222                exit_code: Some(137),
2223                network_bindings: Vec::new(),
2224                image_digest: None,
2225            },
2226        ];
2227        finalize_stopped_multi(
2228            &state,
2229            "000000000000",
2230            "t1",
2231            &final_containers,
2232            0,
2233            "captured",
2234            "EssentialContainerExited",
2235            None,
2236        );
2237
2238        let accounts = state.read();
2239        let task = accounts
2240            .get("000000000000")
2241            .unwrap()
2242            .tasks
2243            .get("t1")
2244            .unwrap();
2245        assert_eq!(task.last_status, "STOPPED");
2246        assert_eq!(task.stop_code.as_deref(), Some("EssentialContainerExited"));
2247        let app = task.containers.iter().find(|c| c.name == "app").unwrap();
2248        let sc = task
2249            .containers
2250            .iter()
2251            .find(|c| c.name == "sidecar")
2252            .unwrap();
2253        assert_eq!(app.exit_code, Some(0));
2254        assert_eq!(sc.exit_code, Some(137));
2255        assert_eq!(app.last_status, "STOPPED");
2256        assert_eq!(sc.last_status, "STOPPED");
2257    }
2258
2259    fn plan(name: &str, deps: &[&str]) -> ContainerPlan {
2260        ContainerPlan {
2261            container_name: name.into(),
2262            image: "alpine".into(),
2263            env: Vec::new(),
2264            entry_point: Vec::new(),
2265            command: Vec::new(),
2266            secrets_refs: Vec::new(),
2267            essential: true,
2268            has_task_role: false,
2269            port_mappings: Vec::new(),
2270            network_mode: None,
2271            depends_on: deps
2272                .iter()
2273                .map(|s| DependsOn {
2274                    container_name: (*s).to_string(),
2275                    condition: DependsOnCondition::Start,
2276                })
2277                .collect(),
2278            health_check: None,
2279            volume_mounts: Vec::new(),
2280            ulimits: Vec::new(),
2281            linux_parameters: None,
2282            stop_timeout: None,
2283            user: None,
2284            working_directory: None,
2285            tty: false,
2286            interactive: false,
2287            readonly_rootfs: false,
2288        }
2289    }
2290
2291    #[test]
2292    fn topo_sort_orders_by_depends_on() {
2293        // sidecar depends on app, so app must come first regardless of
2294        // declaration order.
2295        let plans = vec![plan("sidecar", &["app"]), plan("app", &[])];
2296        let ordered = topo_sort_plans(plans);
2297        assert_eq!(ordered[0].container_name, "app");
2298        assert_eq!(ordered[1].container_name, "sidecar");
2299    }
2300
2301    #[test]
2302    fn topo_sort_preserves_declaration_order_when_no_deps() {
2303        let plans = vec![plan("first", &[]), plan("second", &[]), plan("third", &[])];
2304        let ordered = topo_sort_plans(plans);
2305        let names: Vec<&str> = ordered.iter().map(|p| p.container_name.as_str()).collect();
2306        assert_eq!(names, vec!["first", "second", "third"]);
2307    }
2308
2309    #[test]
2310    fn topo_sort_handles_chain() {
2311        // c -> b -> a, declared in reverse so the topological sort must
2312        // bubble dependencies up.
2313        let plans = vec![plan("c", &["b"]), plan("b", &["a"]), plan("a", &[])];
2314        let ordered = topo_sort_plans(plans);
2315        let names: Vec<&str> = ordered.iter().map(|p| p.container_name.as_str()).collect();
2316        assert_eq!(names, vec!["a", "b", "c"]);
2317    }
2318
2319    #[test]
2320    fn topo_sort_ignores_unknown_dependency() {
2321        // depends_on names a container not in this task definition. Real
2322        // ECS would reject this at register time; we don't (yet), so the
2323        // unknown dep should just be skipped instead of stalling the sort.
2324        let plans = vec![plan("only", &["does-not-exist"])];
2325        let ordered = topo_sort_plans(plans);
2326        assert_eq!(ordered.len(), 1);
2327        assert_eq!(ordered[0].container_name, "only");
2328    }
2329
2330    #[test]
2331    fn topo_sort_recovers_from_cycle() {
2332        // Cyclic dependsOn: both plans should still appear in the output
2333        // so the runtime doesn't silently drop them.
2334        let plans = vec![plan("a", &["b"]), plan("b", &["a"])];
2335        let ordered = topo_sort_plans(plans);
2336        assert_eq!(ordered.len(), 2);
2337    }
2338
2339    #[test]
2340    fn parse_health_check_fills_aws_defaults() {
2341        let v = serde_json::json!({
2342            "command": ["CMD-SHELL", "curl -f http://localhost/ || exit 1"],
2343        });
2344        let hc = __test_parse_health_check(&v).expect("parsed");
2345        assert_eq!(hc.command[0], "CMD-SHELL");
2346        assert_eq!(hc.interval_seconds, 30);
2347        assert_eq!(hc.timeout_seconds, 5);
2348        assert_eq!(hc.retries, 3);
2349        assert_eq!(hc.start_period_seconds, 0);
2350    }
2351
2352    #[test]
2353    fn parse_health_check_overrides_explicit_values() {
2354        let v = serde_json::json!({
2355            "command": ["CMD", "/probe"],
2356            "interval": 7,
2357            "timeout": 2,
2358            "retries": 9,
2359            "startPeriod": 12,
2360        });
2361        let hc = __test_parse_health_check(&v).expect("parsed");
2362        assert_eq!(hc.interval_seconds, 7);
2363        assert_eq!(hc.timeout_seconds, 2);
2364        assert_eq!(hc.retries, 9);
2365        assert_eq!(hc.start_period_seconds, 12);
2366    }
2367
2368    #[test]
2369    fn parse_health_check_returns_none_for_none_sentinel() {
2370        // ECS uses ["NONE"] to disable an inherited HEALTHCHECK; we
2371        // skip emission rather than passing a literal `none` to docker.
2372        let v = serde_json::json!({ "command": ["NONE"] });
2373        assert!(__test_parse_health_check(&v).is_none());
2374    }
2375
2376    #[test]
2377    fn parse_health_check_returns_none_for_missing_command() {
2378        let v = serde_json::json!({ "interval": 30 });
2379        assert!(__test_parse_health_check(&v).is_none());
2380    }
2381
2382    #[test]
2383    fn render_health_flags_emits_full_set_for_cmd_shell() {
2384        let hc = HealthCheckSpec {
2385            command: vec!["CMD-SHELL".into(), "curl -f http://localhost/".into()],
2386            interval_seconds: 15,
2387            timeout_seconds: 3,
2388            retries: 4,
2389            start_period_seconds: 10,
2390        };
2391        let flags = render_health_flags(&hc);
2392        assert_eq!(flags[0], "--health-cmd");
2393        assert_eq!(flags[1], "curl -f http://localhost/");
2394        assert!(flags.contains(&"--health-interval=15s".to_string()));
2395        assert!(flags.contains(&"--health-timeout=3s".to_string()));
2396        assert!(flags.contains(&"--health-retries=4".to_string()));
2397        assert!(flags.contains(&"--health-start-period=10s".to_string()));
2398    }
2399
2400    #[test]
2401    fn render_health_flags_joins_cmd_argv_with_spaces() {
2402        // CMD form in ECS is argv-style; docker `--health-cmd` only
2403        // accepts a single shell string, so we collapse with spaces.
2404        let hc = HealthCheckSpec {
2405            command: vec![
2406                "CMD".into(),
2407                "/bin/probe".into(),
2408                "--port".into(),
2409                "8080".into(),
2410            ],
2411            interval_seconds: 30,
2412            timeout_seconds: 5,
2413            retries: 3,
2414            start_period_seconds: 0,
2415        };
2416        let flags = render_health_flags(&hc);
2417        assert_eq!(flags[1], "/bin/probe --port 8080");
2418    }
2419
2420    #[test]
2421    fn build_run_argv_emits_health_flags_when_present() {
2422        let plan = ContainerPlan {
2423            container_name: "app".into(),
2424            image: "alpine".into(),
2425            env: Vec::new(),
2426            entry_point: Vec::new(),
2427            command: Vec::new(),
2428            secrets_refs: Vec::new(),
2429            essential: true,
2430            has_task_role: false,
2431            port_mappings: Vec::new(),
2432            network_mode: None,
2433            depends_on: Vec::new(),
2434            health_check: Some(HealthCheckSpec {
2435                command: vec!["CMD-SHELL".into(), "true".into()],
2436                interval_seconds: 5,
2437                timeout_seconds: 2,
2438                retries: 1,
2439                start_period_seconds: 1,
2440            }),
2441            volume_mounts: Vec::new(),
2442            ulimits: Vec::new(),
2443            linux_parameters: None,
2444            stop_timeout: None,
2445            user: None,
2446            working_directory: None,
2447            tty: false,
2448            interactive: false,
2449            readonly_rootfs: false,
2450        };
2451        let argv = build_run_argv(
2452            &plan,
2453            &[],
2454            "task-1",
2455            "host.docker.internal",
2456            None,
2457            "alpine",
2458            true,
2459        );
2460        let joined = argv.join(" ");
2461        assert!(joined.contains("--health-cmd true"), "argv: {joined}");
2462        assert!(joined.contains("--health-interval=5s"), "argv: {joined}");
2463        assert!(joined.contains("--health-timeout=2s"), "argv: {joined}");
2464        assert!(joined.contains("--health-retries=1"), "argv: {joined}");
2465        assert!(
2466            joined.contains("--health-start-period=1s"),
2467            "argv: {joined}"
2468        );
2469    }
2470
2471    #[test]
2472    fn build_run_argv_emits_no_health_flags_when_absent() {
2473        let plan = ContainerPlan {
2474            container_name: "app".into(),
2475            image: "alpine".into(),
2476            env: Vec::new(),
2477            entry_point: Vec::new(),
2478            command: Vec::new(),
2479            secrets_refs: Vec::new(),
2480            essential: true,
2481            has_task_role: false,
2482            port_mappings: Vec::new(),
2483            network_mode: None,
2484            depends_on: Vec::new(),
2485            health_check: None,
2486            volume_mounts: Vec::new(),
2487            ulimits: Vec::new(),
2488            linux_parameters: None,
2489            stop_timeout: None,
2490            user: None,
2491            working_directory: None,
2492            tty: false,
2493            interactive: false,
2494            readonly_rootfs: false,
2495        };
2496        let argv = build_run_argv(
2497            &plan,
2498            &[],
2499            "task-1",
2500            "host.docker.internal",
2501            None,
2502            "alpine",
2503            true,
2504        );
2505        assert!(!argv.iter().any(|s| s.starts_with("--health")));
2506    }
2507
2508    #[test]
2509    fn docker_health_to_ecs_maps_known_states() {
2510        assert_eq!(docker_health_to_ecs("healthy"), "HEALTHY");
2511        assert_eq!(docker_health_to_ecs("HEALTHY"), "HEALTHY");
2512        assert_eq!(docker_health_to_ecs("unhealthy"), "UNHEALTHY");
2513        assert_eq!(docker_health_to_ecs("starting"), "UNKNOWN");
2514        assert_eq!(docker_health_to_ecs("none"), "UNKNOWN");
2515        assert_eq!(docker_health_to_ecs(""), "UNKNOWN");
2516    }
2517
2518    /// `host.sourcePath` becomes a host bind mount with the path
2519    /// passed straight through to docker.
2520    #[test]
2521    fn resolve_host_bind_volume_uses_source_path() {
2522        let mut volumes = std::collections::HashMap::new();
2523        let v = serde_json::json!({
2524            "name": "data",
2525            "host": { "sourcePath": "/var/lib/myapp" }
2526        });
2527        volumes.insert("data".to_string(), &v);
2528        let mp = serde_json::json!({
2529            "sourceVolume": "data",
2530            "containerPath": "/app/data",
2531            "readOnly": false
2532        });
2533        let resolved = resolve_mount_point(&mp, &volumes).expect("resolved");
2534        assert_eq!(resolved.source, "/var/lib/myapp");
2535        assert_eq!(resolved.container_path, "/app/data");
2536        assert!(!resolved.read_only);
2537    }
2538
2539    /// `readOnly: true` on the mount point appends `:ro` to the
2540    /// rendered docker `-v` flag.
2541    #[test]
2542    fn read_only_mount_renders_ro_suffix() {
2543        let plan = ContainerPlan {
2544            container_name: "app".into(),
2545            image: "alpine".into(),
2546            env: Vec::new(),
2547            entry_point: Vec::new(),
2548            command: Vec::new(),
2549            secrets_refs: Vec::new(),
2550            essential: true,
2551            has_task_role: false,
2552            port_mappings: Vec::new(),
2553            network_mode: None,
2554            depends_on: Vec::new(),
2555            health_check: None,
2556            volume_mounts: vec![VolumeMount {
2557                source: "/host/path".into(),
2558                container_path: "/in/container".into(),
2559                read_only: true,
2560                cleanup_on_stop: false,
2561            }],
2562            ulimits: Vec::new(),
2563            linux_parameters: None,
2564            stop_timeout: None,
2565            user: None,
2566            working_directory: None,
2567            tty: false,
2568            interactive: false,
2569            readonly_rootfs: false,
2570        };
2571        let argv = build_run_argv(
2572            &plan,
2573            &[],
2574            "task-1",
2575            "host.docker.internal",
2576            None,
2577            "alpine",
2578            true,
2579        );
2580        let pair = argv
2581            .windows(2)
2582            .find(|w| w[0] == "-v")
2583            .expect("expected -v flag");
2584        assert_eq!(pair[1], "/host/path:/in/container:ro");
2585    }
2586
2587    /// EFS volumes resolve to a stub directory under `/tmp/fakecloud/efs`
2588    /// keyed by `fileSystemId`. `rootDirectory` (when set and not `/`)
2589    /// is appended so different mount targets within the same
2590    /// filesystem stay isolated.
2591    #[test]
2592    fn resolve_efs_volume_uses_stub_dir() {
2593        let mut volumes = std::collections::HashMap::new();
2594        let v = serde_json::json!({
2595            "name": "efs-vol",
2596            "efsVolumeConfiguration": {
2597                "fileSystemId": "fs-12345678",
2598                "rootDirectory": "/exports/app"
2599            }
2600        });
2601        volumes.insert("efs-vol".to_string(), &v);
2602        let mp = serde_json::json!({
2603            "sourceVolume": "efs-vol",
2604            "containerPath": "/mnt/efs"
2605        });
2606        let resolved = resolve_mount_point(&mp, &volumes).expect("resolved");
2607        // EFS resolves to a docker named volume (container-safe), with the
2608        // rootDirectory folded into the name (bug-audit 2026-05-28, 0.6).
2609        assert_eq!(resolved.source, "fakecloud-efs-fs-12345678-exports-app");
2610        assert_eq!(resolved.container_path, "/mnt/efs");
2611    }
2612
2613    /// EFS without `rootDirectory` (or with `/`) maps to the root of
2614    /// the filesystem stub so multiple tasks targeting the same id
2615    /// share state.
2616    #[test]
2617    fn efs_without_root_directory_uses_filesystem_root() {
2618        // No rootDirectory (or "/") -> a single shared named volume per
2619        // filesystem id.
2620        assert_eq!(
2621            shared_volume_name("efs", "fs-abc", "/"),
2622            "fakecloud-efs-fs-abc"
2623        );
2624        assert_eq!(
2625            shared_volume_name("efs", "fs-abc", ""),
2626            "fakecloud-efs-fs-abc"
2627        );
2628    }
2629
2630    /// `dockerVolumeConfiguration` resolves to the volume name itself,
2631    /// which docker treats as a named volume reference. No host path
2632    /// is materialised — docker creates the volume on first reference.
2633    #[test]
2634    fn resolve_docker_named_volume_uses_volume_name() {
2635        let mut volumes = std::collections::HashMap::new();
2636        let v = serde_json::json!({
2637            "name": "named-vol",
2638            "dockerVolumeConfiguration": {
2639                "scope": "task",
2640                "driver": "local"
2641            }
2642        });
2643        volumes.insert("named-vol".to_string(), &v);
2644        let mp = serde_json::json!({
2645            "sourceVolume": "named-vol",
2646            "containerPath": "/data"
2647        });
2648        let resolved = resolve_mount_point(&mp, &volumes).expect("resolved");
2649        assert_eq!(resolved.source, "named-vol");
2650        assert_eq!(resolved.container_path, "/data");
2651    }
2652
2653    /// Only task-scoped docker volumes are flagged for removal on task stop;
2654    /// host binds, EFS/FSx shared stubs and scope=shared volumes persist.
2655    #[test]
2656    fn cleanup_on_stop_matches_aws_volume_scope() {
2657        let cases = [
2658            // (volume json, expected cleanup_on_stop)
2659            (
2660                serde_json::json!({ "name": "v", "host": { "sourcePath": "/data" } }),
2661                false,
2662            ),
2663            (
2664                serde_json::json!({ "name": "v", "efsVolumeConfiguration": { "fileSystemId": "fs-a" } }),
2665                false,
2666            ),
2667            (
2668                serde_json::json!({ "name": "v", "fsxWindowsFileServerVolumeConfiguration": { "fileSystemId": "fs-b" } }),
2669                false,
2670            ),
2671            (
2672                serde_json::json!({ "name": "v", "dockerVolumeConfiguration": { "scope": "shared" } }),
2673                false,
2674            ),
2675            (
2676                serde_json::json!({ "name": "v", "dockerVolumeConfiguration": { "scope": "task" } }),
2677                true,
2678            ),
2679            // scope omitted defaults to task.
2680            (
2681                serde_json::json!({ "name": "v", "dockerVolumeConfiguration": {} }),
2682                true,
2683            ),
2684            // bare entry and empty host sourcePath -> anonymous task volume.
2685            (serde_json::json!({ "name": "v" }), true),
2686            (
2687                serde_json::json!({ "name": "v", "host": { "sourcePath": "" } }),
2688                true,
2689            ),
2690        ];
2691        for (vol, expected) in cases {
2692            assert_eq!(
2693                volume_is_task_scoped(&vol),
2694                expected,
2695                "unexpected cleanup_on_stop for {vol}"
2696            );
2697        }
2698    }
2699
2700    /// FSx for Windows uses the same stub-directory pattern as EFS but
2701    /// scoped under `/tmp/fakecloud/fsx/<filesystemId>/`.
2702    #[test]
2703    fn resolve_fsx_volume_uses_stub_dir() {
2704        let mut volumes = std::collections::HashMap::new();
2705        let v = serde_json::json!({
2706            "name": "fsx-vol",
2707            "fsxWindowsFileServerVolumeConfiguration": {
2708                "fileSystemId": "fs-xyz",
2709                "rootDirectory": "share"
2710            }
2711        });
2712        volumes.insert("fsx-vol".to_string(), &v);
2713        let mp = serde_json::json!({
2714            "sourceVolume": "fsx-vol",
2715            "containerPath": "C:\\data"
2716        });
2717        let resolved = resolve_mount_point(&mp, &volumes).expect("resolved");
2718        // FSx resolves to a docker named volume (bug-audit 2026-05-28, 0.6).
2719        assert_eq!(resolved.source, "fakecloud-fsx-fs-xyz-share");
2720    }
2721
2722    /// Mount points that reference an undeclared `sourceVolume` resolve
2723    /// to `None` so `build_container_plans` skips them rather than
2724    /// emitting a broken `-v` flag.
2725    #[test]
2726    fn unknown_source_volume_returns_none() {
2727        let volumes = std::collections::HashMap::new();
2728        let mp = serde_json::json!({
2729            "sourceVolume": "missing",
2730            "containerPath": "/x"
2731        });
2732        assert!(resolve_mount_point(&mp, &volumes).is_none());
2733    }
2734
2735    /// `find_depends_on_cycle` returns the back-edge endpoints when a
2736    /// trivial 2-cycle exists. Real ECS would reject this at register
2737    /// time; our service-level handler relies on this helper.
2738    #[test]
2739    fn find_depends_on_cycle_detects_two_node_cycle() {
2740        let cds = vec![
2741            serde_json::json!({
2742                "name": "a",
2743                "image": "alpine",
2744                "dependsOn": [{"containerName": "b", "condition": "START"}],
2745            }),
2746            serde_json::json!({
2747                "name": "b",
2748                "image": "alpine",
2749                "dependsOn": [{"containerName": "a", "condition": "START"}],
2750            }),
2751        ];
2752        let cycle = find_depends_on_cycle(&cds);
2753        assert!(cycle.is_some(), "expected cycle to be detected");
2754    }
2755
2756    /// A three-node chain (a -> b -> c) is acyclic and must not be
2757    /// flagged. Guards against an over-eager DFS reporting back-edges
2758    /// from already-finished nodes.
2759    #[test]
2760    fn find_depends_on_cycle_accepts_chain() {
2761        let cds = vec![
2762            serde_json::json!({
2763                "name": "a",
2764                "image": "alpine",
2765                "dependsOn": [{"containerName": "b", "condition": "START"}],
2766            }),
2767            serde_json::json!({
2768                "name": "b",
2769                "image": "alpine",
2770                "dependsOn": [{"containerName": "c", "condition": "START"}],
2771            }),
2772            serde_json::json!({
2773                "name": "c",
2774                "image": "alpine",
2775            }),
2776        ];
2777        assert!(find_depends_on_cycle(&cds).is_none());
2778    }
2779
2780    /// `dependsOn[]` entries that name a container outside the task
2781    /// definition are ignored by the cycle check (they can't form a
2782    /// cycle by definition; runtime also drops them).
2783    #[test]
2784    fn find_depends_on_cycle_ignores_unknown_target() {
2785        let cds = vec![serde_json::json!({
2786            "name": "only",
2787            "image": "alpine",
2788            "dependsOn": [{"containerName": "ghost", "condition": "START"}],
2789        })];
2790        assert!(find_depends_on_cycle(&cds).is_none());
2791    }
2792
2793    /// `condition_is_met` covers each AWS condition value against a
2794    /// simulated docker inspect snapshot. Pinning these mappings here
2795    /// catches accidental re-orderings of the match arms.
2796    #[test]
2797    fn condition_is_met_matches_aws_semantics() {
2798        let running = InspectedState {
2799            started: true,
2800            exited: false,
2801            exit_code: 0,
2802            health: None,
2803        };
2804        let exited_ok = InspectedState {
2805            started: true,
2806            exited: true,
2807            exit_code: 0,
2808            health: None,
2809        };
2810        let exited_fail = InspectedState {
2811            started: true,
2812            exited: true,
2813            exit_code: 1,
2814            health: None,
2815        };
2816        let healthy = InspectedState {
2817            started: true,
2818            exited: false,
2819            exit_code: 0,
2820            health: Some("healthy".into()),
2821        };
2822
2823        // START is satisfied as soon as the container has started, even
2824        // if it later exited.
2825        assert!(condition_is_met(DependsOnCondition::Start, &running));
2826        assert!(condition_is_met(DependsOnCondition::Start, &exited_ok));
2827
2828        // COMPLETE requires an exit, regardless of code.
2829        assert!(!condition_is_met(DependsOnCondition::Complete, &running));
2830        assert!(condition_is_met(DependsOnCondition::Complete, &exited_ok));
2831        assert!(condition_is_met(DependsOnCondition::Complete, &exited_fail));
2832
2833        // SUCCESS requires an exit AND code 0.
2834        assert!(!condition_is_met(DependsOnCondition::Success, &running));
2835        assert!(condition_is_met(DependsOnCondition::Success, &exited_ok));
2836        assert!(!condition_is_met(DependsOnCondition::Success, &exited_fail));
2837
2838        // HEALTHY requires Health.Status == "healthy".
2839        assert!(!condition_is_met(DependsOnCondition::Healthy, &running));
2840        assert!(condition_is_met(DependsOnCondition::Healthy, &healthy));
2841    }
2842
2843    /// `DependsOnCondition::parse` accepts the four AWS-spelled values
2844    /// and rejects everything else — register-time validation depends on
2845    /// this returning `None` for unknowns.
2846    #[test]
2847    fn depends_on_condition_parse_round_trips() {
2848        assert_eq!(
2849            DependsOnCondition::parse("START"),
2850            Some(DependsOnCondition::Start)
2851        );
2852        assert_eq!(
2853            DependsOnCondition::parse("COMPLETE"),
2854            Some(DependsOnCondition::Complete)
2855        );
2856        assert_eq!(
2857            DependsOnCondition::parse("SUCCESS"),
2858            Some(DependsOnCondition::Success)
2859        );
2860        assert_eq!(
2861            DependsOnCondition::parse("HEALTHY"),
2862            Some(DependsOnCondition::Healthy)
2863        );
2864        assert_eq!(DependsOnCondition::parse("start"), None);
2865        assert_eq!(DependsOnCondition::parse("ANY"), None);
2866    }
2867
2868    // ── ulimits + linuxParameters + misc docker flags (O6) ──
2869
2870    #[test]
2871    fn build_run_argv_emits_ulimits() {
2872        let plan = ContainerPlan {
2873            container_name: "app".into(),
2874            image: "alpine".into(),
2875            env: Vec::new(),
2876            entry_point: Vec::new(),
2877            command: Vec::new(),
2878            secrets_refs: Vec::new(),
2879            essential: true,
2880            has_task_role: false,
2881            port_mappings: Vec::new(),
2882            network_mode: None,
2883            depends_on: Vec::new(),
2884            health_check: None,
2885            volume_mounts: Vec::new(),
2886            ulimits: vec![Ulimit {
2887                name: "nofile".into(),
2888                soft_limit: 1024,
2889                hard_limit: 2048,
2890            }],
2891            linux_parameters: None,
2892            stop_timeout: None,
2893            user: None,
2894            working_directory: None,
2895            tty: false,
2896            interactive: false,
2897            readonly_rootfs: false,
2898        };
2899        let argv = build_run_argv(&plan, &[], "t", "host.docker.internal", None, "img", true);
2900        assert!(argv.contains(&"--ulimit".to_string()));
2901        assert!(argv.contains(&"nofile=1024:2048".to_string()));
2902    }
2903
2904    #[test]
2905    fn build_run_argv_emits_linux_parameters() {
2906        let plan = ContainerPlan {
2907            container_name: "app".into(),
2908            image: "alpine".into(),
2909            env: Vec::new(),
2910            entry_point: Vec::new(),
2911            command: Vec::new(),
2912            secrets_refs: Vec::new(),
2913            essential: true,
2914            has_task_role: false,
2915            port_mappings: Vec::new(),
2916            network_mode: None,
2917            depends_on: Vec::new(),
2918            health_check: None,
2919            volume_mounts: Vec::new(),
2920            ulimits: Vec::new(),
2921            linux_parameters: Some(LinuxParameters {
2922                capabilities_add: vec!["NET_ADMIN".into()],
2923                capabilities_drop: vec!["ALL".into()],
2924                devices: vec![Device {
2925                    host_path: "/dev/zero".into(),
2926                    container_path: "/dev/zero".into(),
2927                    permissions: "rwm".into(),
2928                }],
2929                init_process_enabled: true,
2930                shared_memory_size: Some(256),
2931                sysctls: vec![Sysctl {
2932                    name: "net.ipv4.ip_forward".into(),
2933                    value: "1".into(),
2934                }],
2935                tmpfs: vec![Tmpfs {
2936                    container_path: "/tmp".into(),
2937                    size: 128,
2938                    mount_options: vec!["noexec".into()],
2939                }],
2940                privileged: true,
2941            }),
2942            stop_timeout: Some(30),
2943            user: Some("1000:1000".into()),
2944            working_directory: Some("/app".into()),
2945            tty: true,
2946            interactive: true,
2947            readonly_rootfs: true,
2948        };
2949        let argv = build_run_argv(&plan, &[], "t", "host.docker.internal", None, "img", true);
2950        assert!(argv.contains(&"--cap-add".to_string()));
2951        assert!(argv.contains(&"NET_ADMIN".to_string()));
2952        assert!(argv.contains(&"--cap-drop".to_string()));
2953        assert!(argv.contains(&"ALL".to_string()));
2954        assert!(argv.contains(&"--device".to_string()));
2955        assert!(argv.contains(&"/dev/zero:/dev/zerorwm".to_string()));
2956        assert!(argv.contains(&"--init".to_string()));
2957        assert!(argv.contains(&"--shm-size".to_string()));
2958        assert!(argv.contains(&"256m".to_string()));
2959        assert!(argv.contains(&"--sysctl".to_string()));
2960        assert!(argv.contains(&"net.ipv4.ip_forward=1".to_string()));
2961        assert!(argv.contains(&"--tmpfs".to_string()));
2962        assert!(argv.contains(&"--privileged".to_string()));
2963        assert!(argv.contains(&"--stop-timeout".to_string()));
2964        assert!(argv.contains(&"30".to_string()));
2965        assert!(argv.contains(&"--user".to_string()));
2966        assert!(argv.contains(&"1000:1000".to_string()));
2967        assert!(argv.contains(&"--workdir".to_string()));
2968        assert!(argv.contains(&"/app".to_string()));
2969        assert!(argv.contains(&"--tty".to_string()));
2970        assert!(argv.contains(&"--interactive".to_string()));
2971        assert!(argv.contains(&"--read-only".to_string()));
2972    }
2973
2974    #[test]
2975    fn parse_linux_parameters_fills_defaults() {
2976        let raw = serde_json::json!({"initProcessEnabled": true});
2977        let lp = parse_linux_parameters(&raw).expect("parses");
2978        assert!(lp.init_process_enabled);
2979        assert!(!lp.privileged);
2980        assert!(lp.capabilities_add.is_empty());
2981    }
2982
2983    #[test]
2984    fn parse_device_uses_default_permissions() {
2985        let raw = serde_json::json!({"hostPath": "/dev/null", "containerPath": "/dev/null"});
2986        let dev = parse_device(&raw).expect("parses");
2987        assert_eq!(dev.permissions, "rwm");
2988    }
2989
2990    #[test]
2991    fn compute_elbv2_targets_empty_when_no_group() {
2992        let mut accounts: MultiAccountState<EcsState> =
2993            MultiAccountState::new("000000000000", "us-east-1", "http://localhost:4566");
2994        let acct = accounts.get_or_create("000000000000");
2995        let mut task = make_task("t1");
2996        task.group = None;
2997        acct.tasks.insert("t1".into(), task);
2998        let state = acct.clone();
2999        let targets = compute_elbv2_targets(&state, state.tasks.get("t1").unwrap());
3000        assert!(targets.is_empty());
3001    }
3002
3003    #[test]
3004    fn compute_elbv2_targets_bridge_mode_uses_localhost_and_host_port() {
3005        let mut accounts: MultiAccountState<EcsState> =
3006            MultiAccountState::new("000000000000", "us-east-1", "http://localhost:4566");
3007        let acct = accounts.get_or_create("000000000000");
3008
3009        let td = crate::state::TaskDefinition {
3010            family: "app".into(),
3011            revision: 1,
3012            task_definition_arn: "arn:aws:ecs:us-east-1:000000000000:task-definition/app:1".into(),
3013            container_definitions: Vec::new(),
3014            network_mode: Some("bridge".into()),
3015            status: "ACTIVE".into(),
3016            task_role_arn: None,
3017            execution_role_arn: None,
3018            requires_compatibilities: Vec::new(),
3019            compatibilities: Vec::new(),
3020            cpu: None,
3021            memory: None,
3022            pid_mode: None,
3023            ipc_mode: None,
3024            volumes: Vec::new(),
3025            placement_constraints: Vec::new(),
3026            proxy_configuration: None,
3027            inference_accelerators: Vec::new(),
3028            ephemeral_storage: None,
3029            runtime_platform: None,
3030            requires_attributes: Vec::new(),
3031            registered_at: Utc::now(),
3032            registered_by: None,
3033            deregistered_at: None,
3034            tags: Vec::new(),
3035            enable_fault_injection: None,
3036        };
3037        acct.task_definitions.insert("app".into(), {
3038            let mut m = std::collections::BTreeMap::new();
3039            m.insert(1, td);
3040            m
3041        });
3042
3043        let service = crate::state::Service {
3044            service_name: "svc".into(),
3045            service_arn: "arn:aws:ecs:us-east-1:000000000000:service/default/svc".into(),
3046            cluster_name: "default".into(),
3047            cluster_arn: "arn:aws:ecs:us-east-1:000000000000:cluster/default".into(),
3048            task_definition_arn: "arn:aws:ecs:us-east-1:000000000000:task-definition/app:1".into(),
3049            family: "app".into(),
3050            revision: 1,
3051            desired_count: 1,
3052            running_count: 0,
3053            pending_count: 0,
3054            launch_type: "FARGATE".into(),
3055            status: "ACTIVE".into(),
3056            scheduling_strategy: "REPLICA".into(),
3057            deployment_controller: "ECS".into(),
3058            minimum_healthy_percent: Some(0),
3059            maximum_percent: Some(200),
3060            circuit_breaker: None,
3061            deployments: Vec::new(),
3062            load_balancers: vec![serde_json::json!({
3063                "targetGroupArn": "arn:aws:elasticloadbalancing:us-east-1:000000000000:targetgroup/tg/abc",
3064                "containerName": "app",
3065                "containerPort": 80,
3066            })],
3067            service_registries: Vec::new(),
3068            placement_constraints: Vec::new(),
3069            placement_strategy: Vec::new(),
3070            network_configuration: None,
3071            volume_configurations: vec![],
3072            service_connect_configuration: None,
3073            tags: Vec::new(),
3074            created_at: Utc::now(),
3075            created_by: None,
3076            role_arn: None,
3077            platform_version: None,
3078            health_check_grace_period_seconds: None,
3079            enable_execute_command: false,
3080            enable_ecs_managed_tags: false,
3081            propagate_tags: None,
3082            capacity_provider_strategy: Vec::new(),
3083            availability_zone_rebalancing: None,
3084        };
3085        acct.services.insert(
3086            crate::state::EcsState::service_key("default", "svc"),
3087            service,
3088        );
3089
3090        let mut task = make_task("t1");
3091        task.group = Some("service:svc".into());
3092        task.containers = vec![crate::state::Container {
3093            container_arn: "arn:aws:ecs:us-east-1:000000000000:container/default/abc/app".into(),
3094            name: "app".into(),
3095            image: "alpine".into(),
3096            task_arn: task.task_arn.clone(),
3097            last_status: "RUNNING".into(),
3098            exit_code: None,
3099            reason: None,
3100            runtime_id: Some("dockerid-app".into()),
3101            essential: true,
3102            cpu: None,
3103            memory: None,
3104            memory_reservation: None,
3105            network_bindings: vec![serde_json::json!({
3106                "bindIP": "0.0.0.0",
3107                "containerPort": 80,
3108                "hostPort": 32768,
3109                "protocol": "tcp",
3110            })],
3111            network_interfaces: Vec::new(),
3112            health_status: None,
3113            managed_agents: None,
3114            image_digest: None,
3115        }];
3116        acct.tasks.insert("t1".into(), task);
3117
3118        let state = acct.clone();
3119        let targets = compute_elbv2_targets(&state, state.tasks.get("t1").unwrap());
3120        assert_eq!(targets.len(), 1);
3121        let (arn, tg_targets) = &targets[0];
3122        assert_eq!(
3123            arn,
3124            "arn:aws:elasticloadbalancing:us-east-1:000000000000:targetgroup/tg/abc"
3125        );
3126        assert_eq!(tg_targets.len(), 1);
3127        assert_eq!(tg_targets[0].0, "127.0.0.1");
3128        assert_eq!(tg_targets[0].1, Some(32768));
3129    }
3130
3131    #[test]
3132    fn compute_elbv2_targets_awsvpc_uses_eni_ip() {
3133        let mut accounts: MultiAccountState<EcsState> =
3134            MultiAccountState::new("000000000000", "us-east-1", "http://localhost:4566");
3135        let acct = accounts.get_or_create("000000000000");
3136
3137        let td = crate::state::TaskDefinition {
3138            family: "app".into(),
3139            revision: 1,
3140            task_definition_arn: "arn:aws:ecs:us-east-1:000000000000:task-definition/app:1".into(),
3141            container_definitions: Vec::new(),
3142            network_mode: Some("awsvpc".into()),
3143            status: "ACTIVE".into(),
3144            task_role_arn: None,
3145            execution_role_arn: None,
3146            requires_compatibilities: Vec::new(),
3147            compatibilities: Vec::new(),
3148            cpu: None,
3149            memory: None,
3150            pid_mode: None,
3151            ipc_mode: None,
3152            volumes: Vec::new(),
3153            placement_constraints: Vec::new(),
3154            proxy_configuration: None,
3155            inference_accelerators: Vec::new(),
3156            ephemeral_storage: None,
3157            runtime_platform: None,
3158            requires_attributes: Vec::new(),
3159            registered_at: Utc::now(),
3160            registered_by: None,
3161            deregistered_at: None,
3162            tags: Vec::new(),
3163            enable_fault_injection: None,
3164        };
3165        acct.task_definitions.insert("app".into(), {
3166            let mut m = std::collections::BTreeMap::new();
3167            m.insert(1, td);
3168            m
3169        });
3170
3171        let service = crate::state::Service {
3172            service_name: "svc".into(),
3173            service_arn: "arn:aws:ecs:us-east-1:000000000000:service/default/svc".into(),
3174            cluster_name: "default".into(),
3175            cluster_arn: "arn:aws:ecs:us-east-1:000000000000:cluster/default".into(),
3176            task_definition_arn: "arn:aws:ecs:us-east-1:000000000000:task-definition/app:1".into(),
3177            family: "app".into(),
3178            revision: 1,
3179            desired_count: 1,
3180            running_count: 0,
3181            pending_count: 0,
3182            launch_type: "FARGATE".into(),
3183            status: "ACTIVE".into(),
3184            scheduling_strategy: "REPLICA".into(),
3185            deployment_controller: "ECS".into(),
3186            minimum_healthy_percent: Some(0),
3187            maximum_percent: Some(200),
3188            circuit_breaker: None,
3189            deployments: Vec::new(),
3190            load_balancers: vec![serde_json::json!({
3191                "targetGroupArn": "arn:aws:elasticloadbalancing:us-east-1:000000000000:targetgroup/tg/abc",
3192                "containerName": "app",
3193                "containerPort": 80,
3194            })],
3195            service_registries: Vec::new(),
3196            placement_constraints: Vec::new(),
3197            placement_strategy: Vec::new(),
3198            network_configuration: None,
3199            volume_configurations: vec![],
3200            service_connect_configuration: None,
3201            tags: Vec::new(),
3202            created_at: Utc::now(),
3203            created_by: None,
3204            role_arn: None,
3205            platform_version: None,
3206            health_check_grace_period_seconds: None,
3207            enable_execute_command: false,
3208            enable_ecs_managed_tags: false,
3209            propagate_tags: None,
3210            capacity_provider_strategy: Vec::new(),
3211            availability_zone_rebalancing: None,
3212        };
3213        acct.services.insert(
3214            crate::state::EcsState::service_key("default", "svc"),
3215            service,
3216        );
3217
3218        let mut task = make_task("t1");
3219        task.group = Some("service:svc".into());
3220        task.attachments = vec![crate::state::TaskAttachment {
3221            id: "eni-123".into(),
3222            attachment_type: "eni".into(),
3223            status: "ATTACHED".into(),
3224            details: vec![
3225                crate::state::AttachmentDetail {
3226                    name: "privateIPv4Address".into(),
3227                    value: "172.18.0.2".into(),
3228                },
3229                crate::state::AttachmentDetail {
3230                    name: "macAddress".into(),
3231                    value: "02:42:ac:12:00:02".into(),
3232                },
3233            ],
3234        }];
3235        acct.tasks.insert("t1".into(), task);
3236
3237        let state = acct.clone();
3238        let targets = compute_elbv2_targets(&state, state.tasks.get("t1").unwrap());
3239        assert_eq!(targets.len(), 1);
3240        let (arn, tg_targets) = &targets[0];
3241        assert_eq!(
3242            arn,
3243            "arn:aws:elasticloadbalancing:us-east-1:000000000000:targetgroup/tg/abc"
3244        );
3245        assert_eq!(tg_targets.len(), 1);
3246        assert_eq!(tg_targets[0].0, "172.18.0.2");
3247        assert_eq!(tg_targets[0].1, Some(80));
3248    }
3249
3250    fn minimal_plan() -> ContainerPlan {
3251        ContainerPlan {
3252            container_name: "app".into(),
3253            image: "alpine".into(),
3254            env: Vec::new(),
3255            entry_point: Vec::new(),
3256            command: Vec::new(),
3257            secrets_refs: Vec::new(),
3258            essential: true,
3259            has_task_role: false,
3260            port_mappings: Vec::new(),
3261            network_mode: None,
3262            depends_on: Vec::new(),
3263            health_check: None,
3264            volume_mounts: Vec::new(),
3265            ulimits: Vec::new(),
3266            linux_parameters: None,
3267            stop_timeout: None,
3268            user: None,
3269            working_directory: None,
3270            tty: false,
3271            interactive: false,
3272            readonly_rootfs: false,
3273        }
3274    }
3275
3276    /// 4.1 — every ECS task container must carry the shared
3277    /// `fakecloud-instance` ownership label so the startup reaper picks it
3278    /// up after an ungraceful restart (it filters strictly on that label).
3279    #[test]
3280    fn build_run_argv_emits_fakecloud_instance_label() {
3281        let plan = minimal_plan();
3282        let argv = build_run_argv(
3283            &plan,
3284            &[],
3285            "task-1",
3286            "host.docker.internal",
3287            None,
3288            "alpine",
3289            true,
3290        );
3291        let expected = fakecloud_instance_label();
3292        assert!(
3293            argv.windows(2)
3294                .any(|w| w[0] == "--label" && w[1] == expected),
3295            "argv must contain `--label {expected}`: {argv:?}",
3296        );
3297    }
3298
3299    /// 4.1 — the label value must be exactly the shape the reaper parses:
3300    /// `fakecloud-instance=fakecloud-<pid>`. The reaper strips the
3301    /// `fakecloud-` prefix off the value and `parse::<u32>()`s the rest, so a
3302    /// non-numeric tail (e.g. a task id) would silently never reap.
3303    #[test]
3304    fn fakecloud_instance_label_matches_reaper_format() {
3305        let label = fakecloud_instance_label();
3306        let (key, value) = label.split_once('=').expect("label is key=value");
3307        assert_eq!(key, "fakecloud-instance");
3308        let pid_str = value
3309            .strip_prefix("fakecloud-")
3310            .expect("value starts with fakecloud-");
3311        assert_eq!(
3312            pid_str.parse::<u32>().ok(),
3313            Some(std::process::id()),
3314            "reaper must be able to parse the owning pid out of {label}",
3315        );
3316    }
3317}