Skip to main content

start_command/
exit_evidence.rs

1//! Exit-time evidence; cumulative cgroup counters and OOMKilled are historical.
2use crate::execution_control::{CommandRunner, SystemCommandRunner};
3use chrono::{DateTime, Duration};
4pub const MAIN_OOM: &str = "Exit evidence: main-oom (recent cgroup oom_kill delta)";
5pub const DOCKER_OOM: &str = "Exit evidence: main-oom (recent Docker oom event)";
6pub const DAEMON_RESTART: &str = "Exit evidence: docker-daemon-restart (Docker service journal)";
7pub const DAEMON_RESTART_API: &str =
8    "Exit evidence: docker-daemon-restart (Docker API: containers finished together)";
9const DAEMON_RESTART_PREFIX: &str = "Exit evidence: docker-daemon-restart";
10const UNAVAILABLE: &str =
11    "Exit evidence: unavailable (no attributed exit-time OOM or daemon restart evidence)";
12// dockerd gives every container its stop timeout and kills the rest within its
13// 15 s shutdown timeout, so the containers of one restart finish this close.
14const DAEMON_RESTART_WINDOW_MS: i64 = 15_000;
15const CONTAINER_STATE_FORMAT: &str =
16    "{{.Id}} {{.State.FinishedAt}} {{.State.StartedAt}} {{.State.ExitCode}} {{.State.OOMKilled}}";
17// Bounds one `docker inspect` call on hosts with many stopped containers.
18const MAX_PEER_CONTAINERS: usize = 256;
19
20fn is_container_id(id: &str) -> bool {
21    id.len() == 64 && id.bytes().all(|c| matches!(c, b'0'..=b'9' | b'a'..=b'f'))
22}
23
24pub fn recent_oom_delta(sample: &str, finished_at: &str) -> bool {
25    let fields = sample.split_whitespace().collect::<Vec<_>>();
26    let changed = fields.get(4).and_then(|s| s.parse::<i64>().ok());
27    let observed = fields.get(5).and_then(|s| s.parse::<i64>().ok());
28    match (
29        changed,
30        observed,
31        DateTime::parse_from_rfc3339(finished_at).ok(),
32    ) {
33        (Some(changed), Some(observed), Some(finished)) => {
34            changed > 0
35                && changed <= observed
36                && finished.timestamp().abs_diff(changed) <= 3
37                && finished.timestamp().abs_diff(observed) <= 3
38        }
39        _ => false,
40    }
41}
42
43pub fn from_log(log: Option<&str>) -> (bool, bool) {
44    let last = last_marker(log);
45    (
46        last == Some(MAIN_OOM) || last == Some(DOCKER_OOM),
47        last.is_some_and(|s| s.starts_with(DAEMON_RESTART_PREFIX)),
48    )
49}
50
51fn last_marker(log: Option<&str>) -> Option<&str> {
52    log.unwrap_or("")
53        .lines()
54        .rfind(|s| s.starts_with("Exit evidence:"))
55}
56
57/// Which source attributed the restart: the host journal or the API (#241).
58pub fn daemon_restart_source(log: Option<&str>) -> Option<&'static str> {
59    match last_marker(log) {
60        Some(DAEMON_RESTART_API) => Some("api"),
61        Some(s) if s.starts_with(DAEMON_RESTART_PREFIX) => Some("journal"),
62        _ => None,
63    }
64}
65
66/// The stored `exitEvidence` object; the source is kept only for a restart.
67pub fn evidence_json(log: Option<&str>, main_oom: bool) -> serde_json::Value {
68    let (_, daemon) = from_log(log);
69    let mut value = serde_json::json!({"mainOom": main_oom, "daemonRestart": daemon});
70    if let Some(source) = daemon_restart_source(log).filter(|_| daemon) {
71        value["daemonRestartSource"] = source.into();
72    }
73    value
74}
75
76/// Socket-only daemon-restart evidence (issue #241): another container that
77/// finished within the same shutdown window, was not OOM-killed, and was either
78/// stopped by a signal (137/143) or started again afterwards (restart policy;
79/// moby resets its ExitCode to 0 but keeps FinishedAt). `docker events` cannot
80/// be used: its buffer does not survive the restart, while State does.
81pub fn daemon_restart_api_evidence(states: &str, container_id: &str, finished_at: &str) -> bool {
82    let Ok(finished) = DateTime::parse_from_rfc3339(finished_at) else {
83        return false;
84    };
85    let finished = finished.timestamp_millis();
86    states.lines().any(|line| {
87        let fields = line.split_whitespace().collect::<Vec<_>>();
88        let [id, peer_finished, peer_started, exit, oom] = fields[..] else {
89            return false;
90        };
91        let Ok(end) = DateTime::parse_from_rfc3339(peer_finished) else {
92            return false;
93        };
94        let end = end.timestamp_millis();
95        if !is_container_id(id)
96            || id == container_id
97            || oom != "false"
98            || end <= 0
99            || (end - finished).abs() > DAEMON_RESTART_WINDOW_MS
100        {
101            return false;
102        }
103        exit == "137"
104            || exit == "143"
105            || DateTime::parse_from_rfc3339(peer_started)
106                .is_ok_and(|started| started.timestamp_millis() > end)
107    })
108}
109
110pub fn recent_docker_oom(events: &str, finished_at: &str) -> bool {
111    let Ok(finished) = DateTime::parse_from_rfc3339(finished_at) else {
112        return false;
113    };
114    let finished = finished.timestamp_millis() as f64 / 1000.0;
115    events.lines().any(|line| {
116        let line = line.trim();
117        !line.is_empty()
118            && line.bytes().all(|c| c.is_ascii_digit())
119            && line.parse::<u64>().is_ok_and(|at| {
120                let gap = finished - at as f64;
121                at > 0 && (-1.0..=3.0).contains(&gap)
122            })
123    })
124}
125
126/// Terminal evidence remains useful if the user moves or truncates the log.
127pub fn record_tail(
128    record: &crate::execution_store::ExecutionRecord,
129    tail: Option<String>,
130) -> Option<String> {
131    let stored = record
132        .options
133        .get("exitEvidence")
134        .filter(|value| value.is_object());
135    let marker = if stored
136        .and_then(|s| s.get("daemonRestart"))
137        .and_then(serde_json::Value::as_bool)
138        == Some(true)
139    {
140        if stored
141            .and_then(|s| s.get("daemonRestartSource"))
142            .and_then(serde_json::Value::as_str)
143            == Some("api")
144        {
145            Some(DAEMON_RESTART_API)
146        } else {
147            Some(DAEMON_RESTART)
148        }
149    } else if stored
150        .and_then(|s| s.get("mainOom"))
151        .and_then(serde_json::Value::as_bool)
152        == Some(true)
153    {
154        Some(MAIN_OOM)
155    } else if stored.is_some() {
156        Some(UNAVAILABLE)
157    } else {
158        None
159    };
160    marker
161        .map(|marker| format!("{}\n{}\n", tail.as_deref().unwrap_or(""), marker))
162        .or(tail)
163}
164
165pub fn daemon_restart_evidence(journal: &str, container_id: &str) -> bool {
166    !container_id.is_empty()
167        && journal.lines().any(|s| {
168            [
169                "Main process exited",
170                "Stopping Docker",
171                "Starting Docker",
172                "Daemon shutdown complete",
173            ]
174            .iter()
175            .any(|m| s.contains(m))
176        })
177        && journal.lines().any(|s| {
178            s.contains(container_id)
179                && ["failed to exit", "using the force", "force kill"]
180                    .iter()
181                    .any(|m| s.contains(m))
182        })
183}
184
185pub fn collect<R: CommandRunner>(
186    name: &str,
187    sample: &str,
188    finished: &str,
189    exit: &str,
190    oom: &str,
191    runner: &R,
192) -> String {
193    let local = std::env::var("DOCKER_HOST")
194        .map(|s| s.starts_with("unix://"))
195        .unwrap_or(true);
196    let sampled_oom = oom == "true"
197        && (exit == "137" || exit.parse::<i32>().is_ok_and(|n| n < 0))
198        && recent_oom_delta(sample, finished);
199    if exit == "137" && !name.is_empty() {
200        if let Ok(end) = DateTime::parse_from_rfc3339(finished) {
201            let id = runner.run(
202                &crate::docker_cleanup::docker_command().to_string_lossy(),
203                &["inspect".into(), "-f".into(), "{{.Id}}".into(), name.into()],
204            );
205            let id_text = id.stdout.trim();
206            if id.success && is_container_id(id_text) {
207                let journal = if local {
208                    runner.run(
209                        "journalctl",
210                        &[
211                            "-u".into(),
212                            "docker.service".into(),
213                            "--since".into(),
214                            (end - Duration::seconds(60)).to_rfc3339(),
215                            "--until".into(),
216                            (end + Duration::seconds(1)).to_rfc3339(),
217                            "--no-pager".into(),
218                            "-o".into(),
219                            "cat".into(),
220                        ],
221                    )
222                } else {
223                    Default::default()
224                };
225                if journal.success && daemon_restart_evidence(&journal.stdout, id_text) {
226                    return DAEMON_RESTART.into();
227                }
228                let events = runner.run(
229                    &crate::docker_cleanup::docker_command().to_string_lossy(),
230                    &[
231                        "events".into(),
232                        "--since".into(),
233                        (end - Duration::seconds(60)).to_rfc3339(),
234                        "--until".into(),
235                        (end + Duration::seconds(1)).to_rfc3339(),
236                        "--filter".into(),
237                        format!("container={}", id_text),
238                        "--filter".into(),
239                        "event=oom".into(),
240                        "--format".into(),
241                        "{{.Time}}".into(),
242                    ],
243                );
244                if events.success && recent_docker_oom(&events.stdout, finished) {
245                    return DOCKER_OOM.into();
246                }
247                // Without a host journal (a mounted docker.sock, a remote
248                // daemon) the only record of the restart is the state of the
249                // other containers.
250                let docker = crate::docker_cleanup::docker_command()
251                    .to_string_lossy()
252                    .into_owned();
253                let peers = if sampled_oom {
254                    Default::default()
255                } else {
256                    runner.run(
257                        &docker,
258                        &["ps".into(), "-a".into(), "-q".into(), "--no-trunc".into()],
259                    )
260                };
261                let ids = peers
262                    .stdout
263                    .split_whitespace()
264                    .filter(|peer| is_container_id(peer) && *peer != id_text)
265                    .take(MAX_PEER_CONTAINERS)
266                    .map(str::to_string)
267                    .collect::<Vec<_>>();
268                // A container removed after `docker ps` fails inspect with
269                // status 1 but the others are still printed, so the output is
270                // used either way.
271                let states = if ids.is_empty() {
272                    Default::default()
273                } else {
274                    let mut args =
275                        vec!["inspect".into(), "-f".into(), CONTAINER_STATE_FORMAT.into()];
276                    args.extend(ids.iter().cloned());
277                    runner.run(&docker, &args)
278                };
279                if daemon_restart_api_evidence(&states.stdout, id_text, finished) {
280                    return DAEMON_RESTART_API.into();
281                }
282                if std::env::var("START_DEBUG").as_deref() == Ok("1") {
283                    eprintln!("[DEBUG] Exit evidence: container={} finishedAt={} eventsAvailable={} oomEvents={:?} peerContainers={} peerStates={:?} sample={:?}", id_text, finished, events.success, events.stdout, ids.len(), states.stdout, sample);
284                }
285            }
286        }
287    }
288    if sampled_oom {
289        return MAIN_OOM.into();
290    }
291    UNAVAILABLE.into()
292}
293
294/// A watcher that shares the daemon with its task (a mounted docker.sock) dies
295/// in the same restart and never logs evidence, so `--status` collects it from
296/// the container state that survived, once per process (issue #241).
297pub fn collect_lost_watcher_evidence<R: CommandRunner>(
298    record: &crate::execution_store::ExecutionRecord,
299    finished_at: &str,
300    oom_killed: Option<bool>,
301    runner: &R,
302) -> Option<serde_json::Value> {
303    use std::collections::HashMap;
304    use std::sync::{Mutex, OnceLock};
305    static CACHE: OnceLock<Mutex<HashMap<String, serde_json::Value>>> = OnceLock::new();
306    let name = record
307        .options
308        .get("sessionName")
309        .and_then(serde_json::Value::as_str)
310        .filter(|name| !name.is_empty())?;
311    if record.options.contains_key("exitEvidence") || finished_at.is_empty() {
312        return None;
313    }
314    let key = format!("{}\0{}", name, finished_at);
315    let cache = CACHE.get_or_init(Default::default);
316    if let Some(value) = cache.lock().ok().and_then(|c| c.get(&key).cloned()) {
317        return Some(value);
318    }
319    let oom = match oom_killed {
320        Some(true) => "true",
321        Some(false) => "false",
322        None => "",
323    };
324    let line = collect(name, "", finished_at, "137", oom, runner);
325    let (main, _) = from_log(Some(&line));
326    let value = evidence_json(Some(&line), main);
327    if let Ok(mut cache) = cache.lock() {
328        cache.insert(key, value.clone());
329    }
330    Some(value)
331}
332
333/// Stores the lost watcher's evidence when the log tail carries none.
334pub(crate) fn add_lost_watcher_evidence(
335    enriched: &mut crate::execution_store::ExecutionRecord,
336    record: &crate::execution_store::ExecutionRecord,
337    state: Option<&crate::status_probe::DockerState>,
338    tail: Option<&str>,
339) {
340    let logged = tail.is_some_and(|tail| tail.lines().any(|l| l.starts_with("Exit evidence:")));
341    if let Some(evidence) = state
342        .filter(|_| !logged)
343        .and_then(|state| state.lost_watcher_evidence(record))
344    {
345        enriched.options.insert("exitEvidence".into(), evidence);
346    }
347}
348
349pub fn main(args: &[String]) {
350    if args.len() >= 5 {
351        println!(
352            "{}",
353            collect(
354                &args[0],
355                &args[1],
356                &args[2],
357                &args[3],
358                &args[4],
359                &SystemCommandRunner
360            )
361        );
362    }
363}
364
365pub fn snippet(name: &str, log: &str) -> String {
366    use crate::isolation::isolation_log::shell_quote;
367    let exe = std::env::current_exe()
368        .ok()
369        .map(|p| p.to_string_lossy().into_owned())
370        .unwrap_or_else(|| "$".into());
371    let stderr = if std::env::var("START_DEBUG").as_deref() == Ok("1") {
372        "2>&1"
373    } else {
374        "2>/dev/null"
375    };
376    format!("{} __start-exit-evidence {} \"$__start_command_cgroup\" \"$__start_command_finished\" \"$__start_command_exit\" \"$__start_command_oom\" >> {} {}", shell_quote(&exe), shell_quote(name), log, stderr)
377}