Skip to main content

start_command/
docker_inspect_batch.rs

1//! One `docker inspect` for every container a status query probes (issue #243).
2//!
3//! `--list` used to start one or two `docker inspect` processes per record
4//! (the live state, the process IDs, the removal check), one after another,
5//! and `--status` up to three for its one record. While a query runs, this
6//! module asks Docker once for every field those probes read, and answers the
7//! per-record `docker inspect -f <format> <target>` calls from that answer.
8//!
9//! Docker prints one line per found container, in argument order, and one
10//! `No such container: <target>` line on stderr per missing one. Each line is
11//! matched back to its target by name or ID prefix (Docker's own lookup
12//! order), so the answer does not depend on the line order. A target the
13//! batch cannot account for (an old or stubbed docker, a daemon error) is not
14//! answered, and its per-record probe runs as before.
15
16use crate::docker_cleanup::docker_command;
17use crate::execution_control::CommandRunOutput;
18use crate::execution_store::ExecutionRecord;
19use regex::Regex;
20use std::cell::RefCell;
21use std::collections::HashMap;
22use std::process::Command;
23use std::sync::OnceLock;
24
25/// Fields of the batched inspect, in output order.
26pub const BATCH_FIELDS: [&str; 8] = [
27    ".Name",
28    ".Id",
29    ".State.Pid",
30    ".State.Running",
31    ".State.ExitCode",
32    ".State.OOMKilled",
33    ".State.StartedAt",
34    ".State.FinishedAt",
35];
36
37/// Fields of one found container, keyed by template field.
38pub type BatchRow = HashMap<String, String>;
39
40/// Answers per target: fields, or `None` for a target Docker reported missing.
41pub type BatchAnswers = HashMap<String, Option<BatchRow>>;
42
43thread_local! {
44    /// Answers of the query in progress on this thread.
45    static PREFETCHED: RefCell<Option<BatchAnswers>> = const { RefCell::new(None) };
46}
47
48/// The Go template of the batched inspect.
49pub fn batch_format() -> String {
50    BATCH_FIELDS
51        .iter()
52        .map(|field| format!("{{{{{}}}}}", field))
53        .collect::<Vec<_>>()
54        .join(" ")
55}
56
57fn not_found_line() -> &'static Regex {
58    static PATTERN: OnceLock<Regex> = OnceLock::new();
59    PATTERN.get_or_init(|| Regex::new(r"(?i)no such (?:object|container):\s*(\S+)").unwrap())
60}
61
62fn placeholder() -> &'static Regex {
63    static PATTERN: OnceLock<Regex> = OnceLock::new();
64    PATTERN.get_or_init(|| Regex::new(r"\{\{\s*(\.[A-Za-z.]+)\s*\}\}").unwrap())
65}
66
67/// Containers the status probes of these records would inspect. Records whose
68/// container is already marked removed are skipped: they are never probed.
69pub fn docker_probe_targets(records: &[ExecutionRecord]) -> Vec<String> {
70    let mut targets: Vec<String> = Vec::new();
71    for record in records {
72        let isolated = record.options.get("isolated").and_then(|v| v.as_str());
73        if isolated != Some("docker")
74            || record.container_state.as_deref()
75                == Some(crate::container_removal::CONTAINER_STATE_REMOVED)
76        {
77            continue;
78        }
79        for key in ["sessionName", "containerId"] {
80            if let Some(target) = record.options.get(key).and_then(|v| v.as_str()) {
81                if !target.is_empty() && !targets.iter().any(|t| t == target) {
82                    targets.push(target.to_string());
83                }
84            }
85        }
86    }
87    targets
88}
89
90/// Match a batched inspect answer to its targets; unanswered targets are
91/// absent from the result.
92pub fn parse_batch_inspect(targets: &[String], stdout: &str, stderr: &str) -> BatchAnswers {
93    let rows: Vec<BatchRow> = stdout
94        .lines()
95        .map(|line| line.split_whitespace().collect::<Vec<_>>())
96        .filter(|parts| parts.len() == BATCH_FIELDS.len())
97        .map(|parts| {
98            BATCH_FIELDS
99                .iter()
100                .zip(parts)
101                .map(|(field, value)| (field.to_string(), value.to_string()))
102                .collect()
103        })
104        .collect();
105    let missing: Vec<&str> = not_found_line()
106        .captures_iter(stderr)
107        .filter_map(|caps| caps.get(1).map(|m| m.as_str()))
108        .collect();
109    let mut answers = BatchAnswers::new();
110    for target in targets {
111        // Docker's own "no such container" wins over a prefix of another
112        // target's ID.
113        if missing.contains(&target.as_str()) {
114            answers.insert(target.clone(), None);
115            continue;
116        }
117        let name = format!("/{}", target);
118        let row = rows
119            .iter()
120            .find(|row| row[".Name"] == name)
121            .or_else(|| rows.iter().find(|row| row[".Id"].starts_with(target)));
122        if let Some(row) = row {
123            answers.insert(target.clone(), Some(row.clone()));
124        }
125    }
126    answers
127}
128
129fn run_batch_inspect(targets: &[String]) -> BatchAnswers {
130    if targets.is_empty() {
131        return BatchAnswers::new();
132    }
133    let output = Command::new(docker_command())
134        .args(["inspect", "--type", "container", "-f", &batch_format()])
135        .args(targets)
136        .output();
137    match output {
138        Ok(output) => parse_batch_inspect(
139            targets,
140            &String::from_utf8_lossy(&output.stdout),
141            &String::from_utf8_lossy(&output.stderr),
142        ),
143        Err(_) => BatchAnswers::new(),
144    }
145}
146
147/// Clears the answers when the query ends, even by a panic.
148struct PrefetchGuard;
149
150impl Drop for PrefetchGuard {
151    fn drop(&mut self) {
152        PREFETCHED.with(|cell| cell.borrow_mut().take());
153    }
154}
155
156/// Run `query` with the docker probes of `records` answered by one inspect.
157/// Nested calls reuse the outer answer.
158pub fn with_docker_inspect_batch<T>(records: &[ExecutionRecord], query: impl FnOnce() -> T) -> T {
159    if PREFETCHED.with(|cell| cell.borrow().is_some()) {
160        return query();
161    }
162    let answers = run_batch_inspect(&docker_probe_targets(records));
163    PREFETCHED.with(|cell| *cell.borrow_mut() = Some(answers));
164    let _guard = PrefetchGuard;
165    query()
166}
167
168/// Answer `docker inspect -f <format> <target>` from the batch, or `None`
169/// when the batch has no answer and the probe must run.
170pub fn prefetched_inspect(format: &str, target: &str) -> Option<CommandRunOutput> {
171    let row = PREFETCHED.with(|cell| cell.borrow().as_ref()?.get(target).cloned())?;
172    let Some(row) = row else {
173        return Some(CommandRunOutput {
174            success: false,
175            stdout: String::new(),
176            stderr: format!(
177                "Error response from daemon: No such container: {}\n",
178                target
179            ),
180            status: Some(1),
181            error: None,
182        });
183    };
184    let mut known = true;
185    let stdout =
186        placeholder().replace_all(format, |caps: &regex::Captures| match row.get(&caps[1]) {
187            Some(value) => value.clone(),
188            None => {
189                known = false;
190                String::new()
191            }
192        });
193    if !known || stdout.contains("{{") {
194        return None;
195    }
196    Some(CommandRunOutput {
197        success: true,
198        stdout: format!("{}\n", stdout),
199        stderr: String::new(),
200        status: Some(0),
201        error: None,
202    })
203}