1use 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)";
12const DAEMON_RESTART_WINDOW_MS: i64 = 15_000;
15const CONTAINER_STATE_FORMAT: &str =
16 "{{.Id}} {{.State.FinishedAt}} {{.State.StartedAt}} {{.State.ExitCode}} {{.State.OOMKilled}}";
17const 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
57pub 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
66pub 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
76pub 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
126pub 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 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 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
294pub 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
333pub(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}