Skip to main content

gha_container_proof/
probe.rs

1//! Docker CLI probe.
2//!
3//! Offline by default — `docker image inspect <image>` to check local
4//! availability and `docker run --rm <image> ...` for tool/command probes.
5//! No pulls unless `--allow-pull` is set. No daemon socket use beyond what the
6//! `docker` CLI itself does. Skips cleanly when the CLI is absent.
7
8use std::process::{Command, Output};
9use std::time::Instant;
10
11use camino::Utf8PathBuf;
12
13use crate::model::{
14    Check, Compatibility, ProbeReport, ProbeStep, ProbeStepKind, RunnerOs, Subject, SubjectKind,
15};
16
17pub const EXCERPT_LIMIT: usize = 4 * 1024;
18
19const DOCKER_BIN_ENV: &str = "GHA_CONTAINER_PROOF_DOCKER";
20
21#[derive(Debug, Clone)]
22pub struct ProbeInput {
23    pub image: String,
24    pub runner_os: RunnerOs,
25    pub tools: Vec<String>,
26    pub commands: Vec<String>,
27    pub allow_pull: bool,
28    pub docker_bin: Option<Utf8PathBuf>,
29}
30
31pub fn probe(input: &ProbeInput) -> Subject {
32    let mut subject = Subject::new(SubjectKind::DockerProbe);
33    subject.image = Some(input.image.clone());
34    subject.runner_os = Some(input.runner_os);
35    subject.requires_docker = true;
36    subject.requires_pull = input.allow_pull;
37
38    let mut report = ProbeReport::new();
39
40    let cli = resolve_docker_bin(input.docker_bin.as_deref());
41    let cli_path = match cli {
42        Some(path) => path,
43        None => {
44            subject.classification = Compatibility::Simulated;
45            subject.push(Check::skip(
46                "probe.docker_cli_not_found",
47                "docker CLI not found on PATH and no --docker-bin override; skipping every probe",
48            ));
49            for tool in &input.tools {
50                subject.push(Check::skip(
51                    "probe.tool_skipped",
52                    format!("tool `{tool}` skipped: docker CLI unavailable"),
53                ));
54            }
55            for command in &input.commands {
56                subject.push(Check::skip(
57                    "probe.command_skipped",
58                    format!("command `{command}` skipped: docker CLI unavailable"),
59                ));
60            }
61            subject.probe = Some(report);
62            return subject;
63        }
64    };
65    report.docker_cli_available = true;
66    report.docker_bin = Some(cli_path.to_string());
67
68    // 1) Inspect image locally.
69    let inspect_step = run_docker(
70        &cli_path,
71        &["image", "inspect", &input.image],
72        ProbeStepKind::Inspect,
73    );
74    let mut image_available = false;
75
76    if let Some(spawn_err) = &inspect_step.spawn_error {
77        subject.classification = Compatibility::Unsupported;
78        subject.push(Check::fail(
79            "probe.docker_daemon_unreachable",
80            format!(
81                "could not invoke docker for image `{}`: {spawn_err}",
82                input.image
83            ),
84        ));
85    } else if inspect_step.success {
86        image_available = true;
87        subject.push(Check::pass(
88            "probe.image_inspect_ok",
89            format!("`docker image inspect {}` succeeded", input.image),
90        ));
91    } else {
92        let daemon = inspect_step
93            .stderr
94            .as_deref()
95            .map(|stderr| {
96                stderr
97                    .to_ascii_lowercase()
98                    .contains("cannot connect to the docker daemon")
99            })
100            .unwrap_or(false);
101        if daemon {
102            subject.classification = Compatibility::Unsupported;
103            subject.push(Check::fail(
104                "probe.docker_daemon_unreachable",
105                format!(
106                    "`docker image inspect {}` could not reach the daemon",
107                    input.image
108                ),
109            ));
110        } else {
111            subject.requires_pull = input.allow_pull;
112            if input.allow_pull {
113                subject.push(Check::warn(
114                    "probe.image_pull_required",
115                    format!(
116                        "image `{}` not present locally; `--allow-pull` is set",
117                        input.image
118                    ),
119                ));
120            } else {
121                subject.classification = Compatibility::Unsupported;
122                subject.push(Check::fail(
123                    "probe.image_inspect_missing",
124                    format!(
125                        "image `{}` not present locally and `--allow-pull` was not set",
126                        input.image
127                    ),
128                ));
129            }
130        }
131    }
132    report.inspect = Some(inspect_step);
133
134    // 2) Optional pull when allowed and inspect failed.
135    if !image_available && input.allow_pull {
136        let pull_step = run_docker(&cli_path, &["pull", &input.image], ProbeStepKind::Pull);
137        if pull_step.success {
138            image_available = true;
139            subject.push(Check::pass(
140                "probe.image_pull_attempted",
141                format!("`docker pull {}` succeeded", input.image),
142            ));
143        } else if let Some(err) = &pull_step.spawn_error {
144            subject.push(Check::fail(
145                "probe.image_pull_attempted",
146                format!("`docker pull {}` could not spawn: {err}", input.image),
147            ));
148        } else {
149            subject.push(Check::fail(
150                "probe.image_pull_attempted",
151                format!(
152                    "`docker pull {}` exited {}",
153                    input.image,
154                    pull_step.exit_code.unwrap_or(-1)
155                ),
156            ));
157        }
158        report.commands.push(pull_step);
159    }
160
161    let can_run = image_available
162        && subject
163            .checks
164            .iter()
165            .all(|check| check.id != "probe.docker_daemon_unreachable");
166
167    // 3) Tools.
168    for tool in &input.tools {
169        if !can_run {
170            subject.push(Check::skip(
171                "probe.tool_skipped",
172                format!("tool `{tool}` skipped: image not available"),
173            ));
174            continue;
175        }
176        let step = run_docker(
177            &cli_path,
178            &["run", "--rm", &input.image, tool, "--version"],
179            ProbeStepKind::Tool,
180        );
181        if let Some(err) = &step.spawn_error {
182            subject.push(Check::fail(
183                "probe.tool_spawn_failed",
184                format!(
185                    "`docker run --rm {} {} --version` could not spawn: {err}",
186                    input.image, tool
187                ),
188            ));
189        } else if step.success {
190            subject.push(Check::pass(
191                "probe.tool_ok",
192                format!("`{tool} --version` succeeded in `{}`", input.image),
193            ));
194        } else {
195            subject.push(Check::fail(
196                "probe.tool_exit_nonzero",
197                format!(
198                    "`{tool} --version` exited {} in `{}`",
199                    step.exit_code.unwrap_or(-1),
200                    input.image
201                ),
202            ));
203        }
204        report.tools.push(step);
205    }
206
207    // 4) Commands.
208    for command in &input.commands {
209        if !can_run {
210            subject.push(Check::skip(
211                "probe.command_skipped",
212                format!("command `{command}` skipped: image not available"),
213            ));
214            continue;
215        }
216        let step = run_docker(
217            &cli_path,
218            &["run", "--rm", &input.image, "sh", "-c", command],
219            ProbeStepKind::Command,
220        );
221        if let Some(err) = &step.spawn_error {
222            subject.push(Check::fail(
223                "probe.command_spawn_failed",
224                format!(
225                    "`docker run --rm {} sh -c {command}` could not spawn: {err}",
226                    input.image
227                ),
228            ));
229        } else if step.success {
230            subject.push(Check::pass(
231                "probe.command_ok",
232                format!("command `{command}` succeeded in `{}`", input.image),
233            ));
234        } else {
235            subject.push(Check::fail(
236                "probe.command_exit_nonzero",
237                format!(
238                    "command `{command}` exited {} in `{}`",
239                    step.exit_code.unwrap_or(-1),
240                    input.image
241                ),
242            ));
243        }
244        report.commands.push(step);
245    }
246
247    subject.probe = Some(report);
248    subject
249}
250
251fn resolve_docker_bin(override_path: Option<&camino::Utf8Path>) -> Option<Utf8PathBuf> {
252    if let Some(path) = override_path {
253        return Some(path.to_owned());
254    }
255    if let Ok(value) = std::env::var(DOCKER_BIN_ENV) {
256        if !value.trim().is_empty() {
257            return Some(Utf8PathBuf::from(value));
258        }
259    }
260    let found = which::which("docker").ok()?;
261    Utf8PathBuf::from_path_buf(found).ok()
262}
263
264fn run_docker(bin: &camino::Utf8Path, argv: &[&str], kind: ProbeStepKind) -> ProbeStep {
265    let display = format!("{} {}", bin, argv.join(" "));
266    let start = Instant::now();
267    let result = Command::new(bin.as_str()).args(argv).output();
268    let elapsed_ms = start.elapsed().as_millis();
269    match result {
270        Ok(output) => probe_step_from_output(kind, display, output, elapsed_ms),
271        Err(err) => ProbeStep {
272            kind,
273            command: display,
274            success: false,
275            exit_code: None,
276            elapsed_ms,
277            stdout: None,
278            stderr: None,
279            spawn_error: Some(err.to_string()),
280        },
281    }
282}
283
284fn probe_step_from_output(
285    kind: ProbeStepKind,
286    command: String,
287    output: Output,
288    elapsed_ms: u128,
289) -> ProbeStep {
290    let stdout = excerpt(&String::from_utf8_lossy(&output.stdout));
291    let stderr = excerpt(&String::from_utf8_lossy(&output.stderr));
292    ProbeStep {
293        kind,
294        command,
295        success: output.status.success(),
296        exit_code: output.status.code(),
297        elapsed_ms,
298        stdout: if stdout.is_empty() {
299            None
300        } else {
301            Some(stdout)
302        },
303        stderr: if stderr.is_empty() {
304            None
305        } else {
306            Some(stderr)
307        },
308        spawn_error: None,
309    }
310}
311
312pub fn excerpt(text: &str) -> String {
313    if text.len() <= EXCERPT_LIMIT {
314        return text.to_owned();
315    }
316    let mut out = text[..EXCERPT_LIMIT].to_owned();
317    out.push_str("…<truncated>");
318    out
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn missing_docker_skips_probes() {
327        let input = ProbeInput {
328            image: "alpine:3".to_owned(),
329            runner_os: RunnerOs::Linux,
330            tools: vec!["sh".to_owned()],
331            commands: vec!["echo hi".to_owned()],
332            allow_pull: false,
333            docker_bin: Some(Utf8PathBuf::from("/nonexistent/docker")),
334        };
335        let mut subject = probe(&input);
336        subject.finalize();
337        // spawn failure path produces docker_daemon_unreachable; that is OK.
338        // The contract we care about is: probes are not silently green.
339        assert!(
340            subject
341                .checks
342                .iter()
343                .any(|c| c.id == "probe.docker_daemon_unreachable"
344                    || c.id == "probe.docker_cli_not_found")
345        );
346    }
347
348    #[test]
349    fn excerpt_truncates_long_text() {
350        let big = "a".repeat(EXCERPT_LIMIT + 100);
351        let trimmed = excerpt(&big);
352        assert!(trimmed.ends_with("…<truncated>"));
353        assert!(trimmed.len() < big.len());
354    }
355}