fakecloud_ecs/runtime/task_lifecycle.rs
1//! `EcsRuntime` `task_lifecycle` family — extracted from service.rs by audit-2026-05-19.
2
3use super::*;
4
5impl EcsRuntime {
6 /// Spawn the task asynchronously. Returns immediately after transitioning
7 /// the task to `PENDING`; the background task advances it to `RUNNING`
8 /// once the container is created and to `STOPPED` once the container
9 /// exits.
10 pub fn run_task(self: Arc<Self>, state: SharedEcsState, task_id: String, account_id: String) {
11 let rt = self.clone();
12 tokio::spawn(async move {
13 if let Err(err) = rt.run_task_inner(&state, &task_id, &account_id).await {
14 tracing::warn!(%err, task = %task_id, "ecs task execution failed");
15 // Also surface on stderr so nextest's captured-output for a
16 // failed E2E shows the reason instead of just "empty logs".
17 eprintln!("[ecs] task {task_id} failed: {err}");
18 finalize_failure(&state, &account_id, &task_id, &err.to_string());
19 rt.emit_state_change(
20 &state,
21 &account_id,
22 &task_id,
23 "STOPPED",
24 Some(("TaskFailedToStart", err.to_string())),
25 );
26 // Persist the STOPPED/failed transition so a restart sees the
27 // terminal status instead of a stale PENDING/RUNNING row.
28 rt.persist_snapshot().await;
29 }
30 });
31 }
32
33 pub async fn run_task_inner(
34 &self,
35 state: &SharedEcsState,
36 task_id: &str,
37 account_id: &str,
38 ) -> Result<(), RuntimeError> {
39 if self.k8s.is_some() {
40 return self.k8s_run_task_inner(state, task_id, account_id).await;
41 }
42 // Build a per-container launch plan up-front so we hold the read
43 // lock once. Each entry carries everything needed to compose a
44 // `docker run` invocation for one container in the task.
45 let plans = build_container_plans(state, account_id, task_id, self.server_port)?;
46 if plans.is_empty() {
47 return Err(RuntimeError::ContainerStart(
48 "task has no containers".into(),
49 ));
50 }
51
52 // Resolve secrets for each plan. Failures fail the whole task to
53 // match real ECS's "failed to retrieve secret" behaviour — there's
54 // no point starting a sidecar when the app container will fail.
55 let mut resolved_plans: Vec<ResolvedContainerPlan> = Vec::with_capacity(plans.len());
56 for plan in plans {
57 let mut env = plan.env.clone();
58 for (name, value_from) in &plan.secrets_refs {
59 match self.resolve_secret(account_id, value_from) {
60 Some(v) => env.push((name.clone(), v)),
61 None => {
62 return Err(RuntimeError::ContainerStart(format!(
63 "failed to resolve secret {name} from {value_from}"
64 )));
65 }
66 }
67 }
68 // The agent/metadata endpoints live on fakecloud (the host);
69 // the container reaches them via the platform host alias —
70 // `host.docker.internal` for docker, `host.containers.internal`
71 // for podman (issue #1539).
72 let host_alias = &self.net.host_alias;
73 if plan.has_task_role {
74 env.push((
75 "AWS_CONTAINER_CREDENTIALS_FULL_URI".into(),
76 format!(
77 "http://{host_alias}:{}/_fakecloud/ecs/creds/{}",
78 self.server_port, task_id
79 ),
80 ));
81 }
82 env.push((
83 "ECS_CONTAINER_METADATA_URI".into(),
84 format!(
85 "http://{host_alias}:{}/_fakecloud/ecs/v3/{}",
86 self.server_port, task_id
87 ),
88 ));
89 env.push((
90 "ECS_CONTAINER_METADATA_URI_V4".into(),
91 format!(
92 "http://{host_alias}:{}/_fakecloud/ecs/v4/{}",
93 self.server_port, task_id
94 ),
95 ));
96 resolved_plans.push(ResolvedContainerPlan { plan, env });
97 }
98
99 // Pull every distinct image up-front so a second container's pull
100 // failure surfaces before we leave the first container running.
101 mark_pull_started(state, account_id, task_id);
102 let mut run_images: Vec<String> = Vec::with_capacity(resolved_plans.len());
103 let mut image_digests: Vec<Option<String>> = Vec::with_capacity(resolved_plans.len());
104 for rp in &resolved_plans {
105 // Rewrite ECR URIs to fakecloud's local registry at the sibling
106 // host (`127.0.0.1` on the host, `host.docker.internal` when
107 // fakecloud is containerized) so the daemon/sibling can reach
108 // fakecloud's published registry port (issue #1539, bug 0.8).
109 let local_pull_uri = fakecloud_core::ecr_uri::translate_to_local_at(
110 &rp.plan.image,
111 &self.net.sibling_host,
112 self.server_port,
113 );
114 let pull_uri = local_pull_uri.as_deref().unwrap_or(&rp.plan.image);
115 let pull_out = self
116 .cli_command()
117 .args(["pull", pull_uri])
118 .output()
119 .await
120 .map_err(|e| RuntimeError::ImagePull(e.to_string()))?;
121 if !pull_out.status.success() {
122 let err = String::from_utf8_lossy(&pull_out.stderr).to_string();
123 return Err(RuntimeError::ImagePull(err));
124 }
125 // Retag the local pull URI to the AWS URI so `docker run` finds
126 // the image under the user-facing name. Digest-pinned refs
127 // can't be `docker tag` targets, so we fall through and run
128 // under the local URI in that case.
129 let run_image = if let Some(ref local_uri) = local_pull_uri {
130 if fakecloud_core::ecr_uri::is_digest_ref(&rp.plan.image) {
131 local_uri.clone()
132 } else {
133 let _ = self
134 .cli_command()
135 .args(["tag", local_uri, &rp.plan.image])
136 .output()
137 .await;
138 rp.plan.image.clone()
139 }
140 } else {
141 rp.plan.image.clone()
142 };
143 // Best-effort image digest extraction so DescribeTasks emits
144 // the resolved digest the way real ECS does. Failures here
145 // (e.g. CLI without RepoDigests) are silent — digest stays
146 // `None` rather than failing the task.
147 let digest = self.lookup_image_digest(pull_uri).await;
148 run_images.push(run_image);
149 image_digests.push(digest);
150 }
151 mark_pull_stopped(state, account_id, task_id);
152
153 // For awsvpc network mode, create a per-task docker network so
154 // containers share an isolated bridge. Clean it up when the task
155 // stops. Network creation is best-effort: on failure we fall back
156 // to the default bridge and continue.
157 let awsvpc_network = resolved_plans
158 .iter()
159 .any(|rp| rp.plan.network_mode.as_deref() == Some("awsvpc"));
160 let network_name = format!("fakecloud-ecs-{}", task_id);
161 let network_created = if awsvpc_network {
162 let create = Command::new(&self.cli)
163 .args([
164 "network",
165 "create",
166 "--driver",
167 "bridge",
168 "--label",
169 &format!("fakecloud-ecs-task={}", task_id),
170 // Ownership label so the startup reaper can prune the
171 // per-task awsvpc network after an ungraceful restart,
172 // matching the container label.
173 "--label",
174 &super::fakecloud_instance_label(),
175 &network_name,
176 ])
177 .output()
178 .await;
179 match create {
180 Ok(out) if out.status.success() => {
181 tracing::info!(
182 task = %task_id,
183 network = %network_name,
184 "created awsvpc docker network"
185 );
186 true
187 }
188 Ok(out) => {
189 let err = String::from_utf8_lossy(&out.stderr);
190 tracing::warn!(
191 task = %task_id,
192 network = %network_name,
193 error = %err,
194 "awsvpc network creation failed; falling back to default bridge"
195 );
196 false
197 }
198 Err(e) => {
199 tracing::warn!(
200 task = %task_id,
201 network = %network_name,
202 error = %e,
203 "awsvpc network creation failed; falling back to default bridge"
204 );
205 false
206 }
207 }
208 } else {
209 false
210 };
211
212 if network_created {
213 let eni_id = format!(
214 "eni-{}",
215 uuid::Uuid::new_v4()
216 .to_string()
217 .replace('-', "")
218 .get(..17)
219 .unwrap_or("")
220 );
221 let mac = format!(
222 "02:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
223 rand::random::<u8>(),
224 rand::random::<u8>(),
225 rand::random::<u8>(),
226 rand::random::<u8>(),
227 rand::random::<u8>()
228 );
229 let ip = format!("10.0.{}.{}", rand::random::<u8>(), rand::random::<u8>());
230 let mut accounts = state.write();
231 if let Some(st) = accounts.get_mut(account_id) {
232 if let Some(task) = st.tasks.get_mut(task_id) {
233 task.attachments.push(crate::state::TaskAttachment {
234 id: eni_id.clone(),
235 attachment_type: "eni".into(),
236 status: "ATTACHED".into(),
237 details: vec![
238 crate::state::AttachmentDetail {
239 name: "subnetId".into(),
240 value: "subnet-fakecloud".into(),
241 },
242 crate::state::AttachmentDetail {
243 name: "privateIPv4Address".into(),
244 value: ip.clone(),
245 },
246 crate::state::AttachmentDetail {
247 name: "macAddress".into(),
248 value: mac.clone(),
249 },
250 ],
251 });
252 }
253 }
254 tracing::info!(
255 task = %task_id,
256 eni = %eni_id,
257 ip = %ip,
258 "populated awsvpc ENI attachment"
259 );
260 }
261
262 // Launch every container detached, in topological order. Before
263 // each `docker run` we honour the dependent's `dependsOn[]` by
264 // polling docker until each upstream container reaches the
265 // requested condition (START/COMPLETE/SUCCESS/HEALTHY). If any
266 // fails to start (or an upstream gate times out), kill the
267 // already-started containers and bail — partial-launch state is
268 // harder to reason about than a clean failure.
269 // Register the task in `self.containers` BEFORE the launch loop so a
270 // concurrent StopTask / scale-down / DeleteService that arrives
271 // during the multi-second pull+run window can find the entry and stop
272 // whatever has been launched so far. (`stop_task` is a no-op when the
273 // key is absent — the orphan-on-race bug.) We append each container
274 // id to this entry as it starts, mirroring the k8s backend which
275 // registers the Pod name before `create_pod`.
276 self.containers
277 .write()
278 .entry(task_id.to_string())
279 .or_default();
280 let mut started: Vec<RunningContainer> = Vec::with_capacity(resolved_plans.len());
281 for (idx, (rp, run_image)) in resolved_plans.iter().zip(run_images.iter()).enumerate() {
282 // Wait for every dependsOn[] entry on this container. Upstreams
283 // declared in the same task always show up earlier in the
284 // launch order thanks to topo_sort_plans, so we only ever look
285 // backwards into `started`.
286 for dep in &rp.plan.depends_on {
287 let upstream = match started.iter().find(|c| c.name == dep.container_name) {
288 Some(u) => u,
289 // Upstream not in this task definition (we ignored it
290 // during topo-sort too). Skip the gate — this matches
291 // the existing "ignore unknown dependency" behaviour.
292 None => continue,
293 };
294 // Whether the upstream has a healthCheck configured —
295 // governs the HEALTHY shortcut: AWS treats HEALTHY as
296 // immediately satisfied when the upstream has no probe.
297 let upstream_has_health_check = resolved_plans
298 .iter()
299 .find(|p| p.plan.container_name == dep.container_name)
300 .is_some_and(|p| p.plan.health_check.is_some());
301 if let Err(err) = self
302 .wait_for_depends_on(upstream, dep.condition, upstream_has_health_check)
303 .await
304 {
305 self.cleanup_partial_start(&started, task_id);
306 return Err(err);
307 }
308 }
309 let argv = build_run_argv(
310 &rp.plan,
311 &rp.env,
312 task_id,
313 &self.net.host_alias,
314 self.net.add_host_arg.as_deref(),
315 run_image,
316 network_created,
317 );
318 let mut cmd = Command::new(&self.cli);
319 cmd.args(&argv);
320 let run_out = cmd.output().await.map_err(|e| {
321 // Cleanup already-started containers on launch failure.
322 self.cleanup_partial_start(&started, task_id);
323 RuntimeError::ContainerStart(e.to_string())
324 })?;
325 if !run_out.status.success() {
326 let err = String::from_utf8_lossy(&run_out.stderr).to_string();
327 self.cleanup_partial_start(&started, task_id);
328 return Err(RuntimeError::ContainerStart(err));
329 }
330 let container_id = String::from_utf8_lossy(&run_out.stdout).trim().to_string();
331 // Append to the runtime map immediately so a StopTask landing
332 // between this container and the next one in the launch loop can
333 // reach it.
334 self.containers
335 .write()
336 .entry(task_id.to_string())
337 .or_default()
338 .push((rp.plan.container_name.clone(), container_id.clone()));
339 started.push(RunningContainer {
340 name: rp.plan.container_name.clone(),
341 container_id,
342 essential: rp.plan.essential,
343 exit_code: None,
344 network_bindings: network_bindings_for(&rp.plan),
345 image_digest: image_digests.get(idx).cloned().unwrap_or(None),
346 });
347 }
348
349 // A StopTask / scale-down / DeleteService that raced the launch may
350 // have flipped this task's desired_status to STOPPED while we were
351 // pulling/running (and may have already issued `docker stop` against
352 // the containers it could see in the runtime map). If so, stop every
353 // container we launched and finalize as a clean stop instead of
354 // marking the task RUNNING with a live workload the user asked to
355 // kill. Mirrors the k8s backend, which observes a 404 from the
356 // StopTask-deleted Pod and finalizes the same way.
357 if task_desired_stopped(state, account_id, task_id) {
358 for rc in &started {
359 let _ = Command::new(&self.cli)
360 .args(["stop", "--time", "10", &rc.container_id])
361 .output()
362 .await;
363 let _ = Command::new(&self.cli)
364 .args(["rm", "-f", &rc.container_id])
365 .output()
366 .await;
367 }
368 if network_created {
369 let _ = Command::new(&self.cli)
370 .args(["network", "rm", &network_name])
371 .output()
372 .await;
373 }
374 self.containers.write().remove(task_id);
375 let stopped: Vec<RunningContainer> = started
376 .iter()
377 .map(|rc| RunningContainer {
378 exit_code: Some(rc.exit_code.unwrap_or(137)),
379 ..rc.clone()
380 })
381 .collect();
382 finalize_stopped_multi(
383 state,
384 account_id,
385 task_id,
386 &stopped,
387 137,
388 "",
389 "UserInitiated",
390 Some("Task stopped during launch".into()),
391 );
392 self.deregister_lb_targets(state, account_id, task_id);
393 self.emit_state_change(
394 state,
395 account_id,
396 task_id,
397 "STOPPED",
398 Some(("UserInitiated", "Task stopped during launch".into())),
399 );
400 self.persist_snapshot().await;
401 return Ok(());
402 }
403
404 mark_running_multi(state, account_id, task_id, &started);
405 self.register_lb_targets(state, account_id, task_id);
406 self.emit_state_change(state, account_id, task_id, "RUNNING", None);
407 // Persist the PENDING->RUNNING transition so DescribeTasks reports
408 // RUNNING after a restart without waiting on restart reconciliation.
409 self.persist_snapshot().await;
410
411 // Wait for the first essential container (or, if none are
412 // essential, any container) to exit. ECS task lifetime is
413 // bounded by the first essential exit, after which all remaining
414 // containers are stopped. While polling we also refresh each
415 // container's `healthStatus` from `docker inspect` so
416 // DescribeTasks reflects HEALTHCHECK transitions in near real
417 // time.
418 let wait_outcome = self
419 .wait_for_task_exit_with_health(state, account_id, task_id, &started)
420 .await?;
421
422 // Stop and reap any sidecars still running. Best-effort — failures
423 // here shouldn't keep the task from transitioning to STOPPED.
424 let mut final_containers = started.clone();
425 for (i, rc) in started.iter().enumerate() {
426 if Some(i) == wait_outcome.exited_index {
427 final_containers[i].exit_code = Some(wait_outcome.exit_code);
428 continue;
429 }
430 // Try to grab the exit code if the container already exited
431 // on its own (non-essential exits don't stop the task), then
432 // fall back to `docker stop` for stragglers.
433 let inspect = Command::new(&self.cli)
434 .args(["inspect", "-f", "{{.State.ExitCode}}", &rc.container_id])
435 .output()
436 .await;
437 let still_running = match inspect {
438 Ok(out) if out.status.success() => {
439 let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
440 // `docker inspect` returns 0 for not-yet-exited
441 // containers, so we additionally check `State.Running`.
442 let running = Command::new(&self.cli)
443 .args(["inspect", "-f", "{{.State.Running}}", &rc.container_id])
444 .output()
445 .await
446 .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "true")
447 .unwrap_or(false);
448 if !running {
449 if let Ok(code) = s.parse::<i64>() {
450 final_containers[i].exit_code = Some(code);
451 }
452 }
453 running
454 }
455 _ => false,
456 };
457 if still_running {
458 let _ = Command::new(&self.cli)
459 .args(["stop", "--time", "10", &rc.container_id])
460 .output()
461 .await;
462 let wait_out = Command::new(&self.cli)
463 .args(["wait", &rc.container_id])
464 .output()
465 .await;
466 if let Ok(out) = wait_out {
467 let code: i64 = String::from_utf8_lossy(&out.stdout)
468 .trim()
469 .parse()
470 .unwrap_or(-1);
471 final_containers[i].exit_code = Some(code);
472 }
473 }
474 }
475
476 // Capture combined stdout+stderr from every container so the
477 // introspection endpoint shows logs from sidecars too.
478 let mut captured = String::new();
479 for rc in &started {
480 let logs_out = Command::new(&self.cli)
481 .args(["logs", &rc.container_id])
482 .output()
483 .await
484 .map_err(|e| RuntimeError::Wait(e.to_string()))?;
485 captured.push_str(&format!("[{}] ", rc.name));
486 captured.push_str(&String::from_utf8_lossy(&logs_out.stdout));
487 captured.push_str(&String::from_utf8_lossy(&logs_out.stderr));
488 }
489
490 // Reap every container we own.
491 for rc in &started {
492 let _ = Command::new(&self.cli)
493 .args(["rm", "-f", &rc.container_id])
494 .output()
495 .await;
496 }
497 // Reap task-scoped docker volumes (anonymous "Docker volumes" and
498 // `dockerVolumeConfiguration` with scope=task), matching AWS, which
499 // deletes them when the task stops. Host binds, EFS/FSx stubs and
500 // scope=shared volumes are left in place — they outlive the task.
501 // `volume rm` is best-effort and no-ops on a volume still in use.
502 let mut task_volumes: std::collections::BTreeSet<String> =
503 std::collections::BTreeSet::new();
504 for rp in &resolved_plans {
505 for vm in &rp.plan.volume_mounts {
506 if vm.cleanup_on_stop {
507 task_volumes.insert(vm.source.clone());
508 }
509 }
510 }
511 for vol in &task_volumes {
512 let _ = Command::new(&self.cli)
513 .args(["volume", "rm", "-f", vol])
514 .output()
515 .await;
516 }
517 // Clean up the per-task docker network for awsvpc.
518 if network_created {
519 let _ = Command::new(&self.cli)
520 .args(["network", "rm", &network_name])
521 .output()
522 .await;
523 }
524 self.containers.write().remove(task_id);
525
526 // Forward logs BEFORE flipping the task to STOPPED so a client
527 // that polls DescribeTasks and immediately queries
528 // DescribeLogStreams can't observe the STOPPED transition before
529 // the awslogs group/stream has been materialised.
530 self.forward_awslogs_if_configured(state, account_id, task_id, &captured);
531 let exit_code = wait_outcome.exit_code;
532 finalize_stopped_multi(
533 state,
534 account_id,
535 task_id,
536 &final_containers,
537 exit_code,
538 &captured,
539 wait_outcome.stop_code,
540 None,
541 );
542 self.deregister_lb_targets(state, account_id, task_id);
543 self.emit_state_change(
544 state,
545 account_id,
546 task_id,
547 "STOPPED",
548 Some((wait_outcome.stop_code, format!("Exit code {}", exit_code))),
549 );
550 // Persist the terminal STOPPED transition + captured exit codes so a
551 // restart reflects the completed task instead of a stale RUNNING row.
552 self.persist_snapshot().await;
553 Ok(())
554 }
555
556 /// Wait for the task to reach a stop condition (any essential
557 /// container exits, or every container exits when none are
558 /// essential) while also polling `docker inspect .State.Health.Status`
559 /// on every iteration to push the latest `healthStatus` onto each
560 /// task container — so DescribeTasks shows live HEALTHCHECK
561 /// transitions instead of the boot-time `UNKNOWN`. Returns the
562 /// index into `started` of the container whose exit determined the
563 /// task lifetime, its exit code, and the stopCode.
564 pub(super) async fn wait_for_task_exit_with_health(
565 &self,
566 state: &SharedEcsState,
567 account_id: &str,
568 task_id: &str,
569 started: &[RunningContainer],
570 ) -> Result<TaskExitOutcome, RuntimeError> {
571 let any_essential = started.iter().any(|c| c.essential);
572 let mut working: Vec<RunningContainer> = started.to_vec();
573 let mut first_exited: Option<usize> = None;
574 loop {
575 // Refresh health status before checking exits so a container
576 // that goes UNHEALTHY -> exits in the same iteration leaves
577 // its final health state on the task before we transition to
578 // STOPPED.
579 self.refresh_health_status(state, account_id, task_id, started)
580 .await;
581 for (i, rc) in started.iter().enumerate() {
582 if working[i].exit_code.is_some() {
583 continue;
584 }
585 let inspect = Command::new(&self.cli)
586 .args(["inspect", "-f", "{{.State.Running}}", &rc.container_id])
587 .output()
588 .await;
589 let running = match inspect {
590 Ok(out) if out.status.success() => {
591 String::from_utf8_lossy(&out.stdout).trim() == "true"
592 }
593 _ => false,
594 };
595 if running {
596 continue;
597 }
598 let wait_out = Command::new(&self.cli)
599 .args(["wait", &rc.container_id])
600 .output()
601 .await
602 .map_err(|e| RuntimeError::Wait(e.to_string()))?;
603 if !wait_out.status.success() {
604 let err = String::from_utf8_lossy(&wait_out.stderr).to_string();
605 return Err(RuntimeError::Wait(err));
606 }
607 let exit_code: i64 = String::from_utf8_lossy(&wait_out.stdout)
608 .trim()
609 .parse()
610 .unwrap_or(-1);
611 working[i].exit_code = Some(exit_code);
612 if first_exited.is_none() && (rc.essential || !any_essential) {
613 first_exited = Some(i);
614 }
615 }
616 if task_should_stop(&working) {
617 let idx = first_exited
618 .or_else(|| working.iter().position(|c| c.exit_code.is_some()))
619 .unwrap_or(0);
620 let exit_code = working[idx].exit_code.unwrap_or(-1);
621 return Ok(TaskExitOutcome {
622 exited_index: Some(idx),
623 exit_code,
624 stop_code: if any_essential {
625 "EssentialContainerExited"
626 } else {
627 "TaskCompleted"
628 },
629 });
630 }
631 sleep(Duration::from_millis(200)).await;
632 }
633 }
634
635 /// Block the launch of a dependent container until its upstream
636 /// reaches the requested `dependsOn[].condition`. We poll
637 /// `docker inspect` at a small interval; the wait is bounded by an
638 /// AWS-style timeout (120s by default — long enough for image
639 /// startup but short enough to surface bugs as a clean
640 /// `ContainerStart` failure).
641 ///
642 /// `upstream_has_health_check` is needed for the `HEALTHY` branch:
643 /// when the upstream has no healthCheck, AWS treats `HEALTHY` as
644 /// immediately satisfied (otherwise the dependent would block
645 /// forever, since docker reports `Health.Status` only when the
646 /// container has a HEALTHCHECK directive).
647 pub(super) async fn wait_for_depends_on(
648 &self,
649 upstream: &RunningContainer,
650 condition: DependsOnCondition,
651 upstream_has_health_check: bool,
652 ) -> Result<(), RuntimeError> {
653 // Bounded wait — chosen to comfortably cover slow init scripts
654 // without letting a wedged dependency stall a task indefinitely.
655 const WAIT_TIMEOUT: Duration = Duration::from_secs(120);
656 const POLL_INTERVAL: Duration = Duration::from_millis(200);
657
658 // HEALTHY against an upstream without a healthCheck: AWS treats
659 // this as immediately satisfied because there's no probe to
660 // observe. Skip the polling loop entirely so the dependent isn't
661 // wedged forever waiting for a status that docker will never set.
662 if matches!(condition, DependsOnCondition::Healthy) && !upstream_has_health_check {
663 return Ok(());
664 }
665
666 let deadline = std::time::Instant::now() + WAIT_TIMEOUT;
667 loop {
668 let inspect = inspect_container_state(&self.cli, &upstream.container_id).await;
669 if let Some(state) = inspect {
670 if condition_is_met(condition, &state) {
671 return Ok(());
672 }
673 // SUCCESS specifically: if the container exited with a
674 // non-zero code, the gate can never be satisfied. Bail
675 // immediately rather than waiting for the timeout — this
676 // matches ECS's "stoppedReason: dependency failed" path.
677 if matches!(condition, DependsOnCondition::Success)
678 && state.exited
679 && state.exit_code != 0
680 {
681 return Err(RuntimeError::ContainerStart(format!(
682 "dependency on container {} ({}) failed: upstream exited with code {}",
683 upstream.name,
684 DependsOnCondition::Success.as_aws_str(),
685 state.exit_code,
686 )));
687 }
688 }
689 if std::time::Instant::now() >= deadline {
690 return Err(RuntimeError::ContainerStart(format!(
691 "timed out waiting for container {} to reach condition {}",
692 upstream.name,
693 condition.as_aws_str(),
694 )));
695 }
696 tokio::time::sleep(POLL_INTERVAL).await;
697 }
698 }
699
700 /// Best-effort cleanup of containers we already started when a later
701 /// container in the task failed to launch. Without this, half-launched
702 /// tasks leak docker containers. `task_id` mirrors the value used at
703 /// network creation so `network rm` targets the right name —
704 /// deriving it from a container_id prefix was wrong (container ids
705 /// are docker-assigned, not task-shaped).
706 pub(super) fn cleanup_partial_start(&self, started: &[RunningContainer], task_id: &str) {
707 let cli = self.cli.clone();
708 let ids: Vec<String> = started.iter().map(|c| c.container_id.clone()).collect();
709 let network = format!("fakecloud-ecs-{task_id}");
710 // Drop the runtime map entry we pre-registered before the launch loop
711 // so a failed launch doesn't leave a stale (now-empty) key that
712 // future StopTask calls would treat as a live task.
713 self.containers.write().remove(task_id);
714 tokio::spawn(async move {
715 for id in ids {
716 let _ = Command::new(&cli).args(["rm", "-f", &id]).output().await;
717 }
718 let _ = Command::new(&cli)
719 .args(["network", "rm", &network])
720 .output()
721 .await;
722 });
723 }
724
725 /// Kill every container behind a task with the configured stop
726 /// timeout. Returns true if at least one container was killed. Called
727 /// synchronously from `StopTask`; the wait loop in `run_task_inner`
728 /// observes the exits and transitions the task to `STOPPED`.
729 pub async fn stop_task(&self, task_id: &str, reason: &str) -> bool {
730 if let Some(k) = &self.k8s {
731 tracing::info!(task = %task_id, reason = %reason, "ecs task stop requested (k8s)");
732 return k.stop_task(task_id).await;
733 }
734 let containers = self.containers.read().get(task_id).cloned();
735 let Some(list) = containers else {
736 return false;
737 };
738 if list.is_empty() {
739 return false;
740 }
741 // `docker stop` sends SIGTERM then SIGKILL after a timeout.
742 for (_name, id) in &list {
743 let _ = Command::new(&self.cli)
744 .args(["stop", "--time", "10", id])
745 .output()
746 .await;
747 }
748 tracing::info!(task = %task_id, reason = %reason, "ecs task stop requested");
749 true
750 }
751
752 /// Kill every running container the runtime owns. Called on reset /
753 /// shutdown so docker state matches fakecloud state after a fresh
754 /// boot.
755 pub async fn stop_all(&self) {
756 if let Some(k) = &self.k8s {
757 k.stop_all().await;
758 return;
759 }
760 let ids: Vec<String> = self
761 .containers
762 .read()
763 .values()
764 .flat_map(|list| list.iter().map(|(_, id)| id.clone()))
765 .collect();
766 for id in ids {
767 let _ = Command::new(&self.cli).args(["kill", &id]).output().await;
768 let _ = Command::new(&self.cli).args(["rm", &id]).output().await;
769 }
770 self.containers.write().clear();
771 }
772}