Skip to main content

start_command/
container_removal.rs

1//! Removed-container bookkeeping for `--status` (issue #225).
2//!
3//! A kept docker container can be removed later (by hand, by an orchestrator,
4//! or by start-command's own cleanup policy). `docker inspect` then exits 1
5//! with `No such object` / `No such container`, which used to be folded into
6//! the same `None` as "docker could not be queried at all", so `--status` kept
7//! reporting the old `containerId` as if the container still held its disk.
8//!
9//! This module tells the two failures apart and records the removal on the
10//! execution record as `containerState: removed` plus
11//! `containerRemovedObservedAt`. `containerId` is kept for history.
12
13use std::process::Command;
14
15use chrono::Utc;
16
17use crate::docker_cleanup::docker_command;
18use crate::execution_store::{ExecutionRecord, ExecutionStatus, ExecutionStore};
19
20/// `containerState` of a record whose container no longer exists.
21pub const CONTAINER_STATE_REMOVED: &str = "removed";
22
23/// Environment variable the detached watcher sets for the finalizer when its
24/// own `docker rm -f` removed the container.
25pub const CONTAINER_REMOVED_ENV: &str = "START_INTERNAL_CONTAINER_REMOVED";
26
27/// Whether a failed `docker inspect` answered that the object is unknown:
28/// `Error: No such object: <name>` (`docker inspect`, Docker 29 prints it
29/// lower-case) or `Error response from daemon: No such container: <name>`. A
30/// daemon that cannot be reached ("Cannot connect", "failed to connect ... no
31/// such file or directory") does not match.
32pub fn is_docker_not_found(success: bool, stderr: &str) -> bool {
33    if success {
34        return false;
35    }
36    let stderr = stderr.to_ascii_lowercase();
37    stderr.contains("no such object") || stderr.contains("no such container")
38}
39
40/// `docker inspect -f {{.Id}} <target>` answered "no such container".
41fn docker_object_missing(target: &str) -> bool {
42    Command::new(docker_command())
43        .args(["inspect", "-f", "{{.Id}}", target])
44        .output()
45        .is_ok_and(|output| {
46            is_docker_not_found(
47                output.status.success(),
48                &String::from_utf8_lossy(&output.stderr),
49            )
50        })
51}
52
53/// Set `containerState: removed` on a record, keeping an earlier observation
54/// time when there is one.
55pub fn mark_container_removed(record: &mut ExecutionRecord, observed_at: Option<&str>) {
56    record.container_state = Some(CONTAINER_STATE_REMOVED.to_string());
57    if record.container_removed_observed_at.is_none() {
58        record.container_removed_observed_at = Some(
59            observed_at
60                .map(str::to_string)
61                .unwrap_or_else(|| Utc::now().to_rfc3339()),
62        );
63    }
64}
65
66fn option_str<'a>(record: &'a ExecutionRecord, key: &str) -> Option<&'a str> {
67    record
68        .options
69        .get(key)
70        .and_then(|value| value.as_str())
71        .filter(|value| !value.is_empty())
72}
73
74/// Whether a finished detached docker record should be checked for removal.
75/// Executing records are never touched: a container that is not visible yet
76/// on a slow Docker-in-Docker host is not a removed one (issue #136).
77fn should_probe_removal(record: &ExecutionRecord) -> bool {
78    record.status == ExecutionStatus::Executed
79        && option_str(record, "isolated") == Some("docker")
80        && option_str(record, "isolationMode") == Some("detached")
81        && (option_str(record, "sessionName").is_some()
82            || option_str(record, "containerId").is_some())
83        && record.container_state.as_deref() != Some(CONTAINER_STATE_REMOVED)
84}
85
86/// Attach `containerState: removed` to a finished detached docker record whose
87/// container no longer exists. The container id is preferred over the session
88/// name, so a later container that reuses the name is not mistaken for it.
89pub fn attach_container_removal(mut record: ExecutionRecord) -> ExecutionRecord {
90    if !should_probe_removal(&record) {
91        return record;
92    }
93    let target = option_str(&record, "containerId")
94        .or_else(|| option_str(&record, "sessionName"))
95        .map(str::to_string);
96    if target.is_some_and(|target| docker_object_missing(&target)) {
97        mark_container_removed(&mut record, None);
98    }
99    record
100}
101
102/// Persist a removal `--status` observed, best effort: a read-only query must
103/// never fail because the store could not be written.
104pub fn persist_container_removal(
105    store: &ExecutionStore,
106    stored: &ExecutionRecord,
107    enriched: &ExecutionRecord,
108) {
109    if enriched.container_state.as_deref() != Some(CONTAINER_STATE_REMOVED)
110        || stored.container_state.as_deref() == Some(CONTAINER_STATE_REMOVED)
111    {
112        return;
113    }
114    if let Some(observed_at) = enriched.container_removed_observed_at.as_deref() {
115        // The removal is still reported; it is simply observed again next time.
116        let _ = store.mark_container_removed(&stored.uuid, observed_at);
117    }
118}