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    if let Some(answer) = crate::docker_inspect_batch::prefetched_inspect("{{.Id}}", target) {
43        return is_docker_not_found(answer.success, &answer.stderr);
44    }
45    Command::new(docker_command())
46        .args(["inspect", "-f", "{{.Id}}", target])
47        .output()
48        .is_ok_and(|output| {
49            is_docker_not_found(
50                output.status.success(),
51                &String::from_utf8_lossy(&output.stderr),
52            )
53        })
54}
55
56/// Set `containerState: removed` on a record, keeping an earlier observation
57/// time when there is one.
58pub fn mark_container_removed(record: &mut ExecutionRecord, observed_at: Option<&str>) {
59    record.container_state = Some(CONTAINER_STATE_REMOVED.to_string());
60    if record.container_removed_observed_at.is_none() {
61        record.container_removed_observed_at = Some(
62            observed_at
63                .map(str::to_string)
64                .unwrap_or_else(|| Utc::now().to_rfc3339()),
65        );
66    }
67}
68
69fn option_str<'a>(record: &'a ExecutionRecord, key: &str) -> Option<&'a str> {
70    record
71        .options
72        .get(key)
73        .and_then(|value| value.as_str())
74        .filter(|value| !value.is_empty())
75}
76
77/// Whether a finished detached docker record should be checked for removal.
78/// Executing records are never touched: a container that is not visible yet
79/// on a slow Docker-in-Docker host is not a removed one (issue #136).
80fn should_probe_removal(record: &ExecutionRecord) -> bool {
81    record.status == ExecutionStatus::Executed
82        && option_str(record, "isolated") == Some("docker")
83        && option_str(record, "isolationMode") == Some("detached")
84        && (option_str(record, "sessionName").is_some()
85            || option_str(record, "containerId").is_some())
86        && record.container_state.as_deref() != Some(CONTAINER_STATE_REMOVED)
87}
88
89/// Attach `containerState: removed` to a finished detached docker record whose
90/// container no longer exists. The container id is preferred over the session
91/// name, so a later container that reuses the name is not mistaken for it.
92pub fn attach_container_removal(mut record: ExecutionRecord) -> ExecutionRecord {
93    if !should_probe_removal(&record) {
94        return record;
95    }
96    let target = option_str(&record, "containerId")
97        .or_else(|| option_str(&record, "sessionName"))
98        .map(str::to_string);
99    if target.is_some_and(|target| docker_object_missing(&target)) {
100        mark_container_removed(&mut record, None);
101    }
102    record
103}
104
105/// Persist a removal `--status` observed, best effort: a read-only query must
106/// never fail because the store could not be written.
107pub fn persist_container_removal(
108    store: &ExecutionStore,
109    stored: &ExecutionRecord,
110    enriched: &ExecutionRecord,
111) {
112    if enriched.container_state.as_deref() != Some(CONTAINER_STATE_REMOVED)
113        || stored.container_state.as_deref() == Some(CONTAINER_STATE_REMOVED)
114    {
115        return;
116    }
117    if let Some(observed_at) = enriched.container_removed_observed_at.as_deref() {
118        // The removal is still reported; it is simply observed again next time.
119        let _ = store.mark_container_removed(&stored.uuid, observed_at);
120    }
121}