Skip to main content

sui_dockerfile_wrapper/
command.rs

1//! Typed `docker build` invocation + the mockable command-runner seam.
2//!
3//! Per the fleet's TYPED EMISSION rule, the subprocess argv is built via
4//! typed `.arg()` calls on [`DockerBuildInvocation`] — never string
5//! concatenation — and every consumer of the invocation (real subprocess,
6//! or a test's recording mock) goes through the [`CommandRunner`] trait so
7//! the fallback path is provable without ever shelling out in a unit test.
8
9use std::path::Path;
10use std::process::Command;
11
12/// One `docker build` (or `docker buildx build`) invocation, built up via
13/// typed setters rather than any string template.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct DockerBuildInvocation {
16    /// The program to exec — `"docker"` by default.
17    pub program: String,
18    /// Positional + flag arguments, in the order they will be passed to
19    /// [`Command::arg`].
20    pub args: Vec<String>,
21}
22
23impl DockerBuildInvocation {
24    /// Build the canonical `docker build -f <dockerfile> -t <tag> --build-arg
25    /// K=V ... <context>` invocation.
26    #[must_use]
27    pub fn build(
28        dockerfile_path: &Path,
29        context_dir: &Path,
30        image_tag: &str,
31        build_args: &std::collections::BTreeMap<String, String>,
32    ) -> Self {
33        let mut args = vec![
34            "build".to_string(),
35            "-f".to_string(),
36            dockerfile_path.display().to_string(),
37            "-t".to_string(),
38            image_tag.to_string(),
39        ];
40        for (k, v) in build_args {
41            args.push("--build-arg".to_string());
42            let mut kv = k.clone();
43            kv.push('=');
44            kv.push_str(v);
45            args.push(kv);
46        }
47        args.push(context_dir.display().to_string());
48        Self { program: "docker".to_string(), args }
49    }
50
51    /// Build a `docker pull <image_ref>` invocation — used to materialize a
52    /// full cache hit's already-built image rather than re-running `docker
53    /// build`.
54    #[must_use]
55    pub fn pull(image_ref: &str) -> Self {
56        Self {
57            program: "docker".to_string(),
58            args: vec!["pull".to_string(), image_ref.to_string()],
59        }
60    }
61
62    /// Build a `docker history --no-trunc --format '{{.CreatedBy}}'
63    /// <image>` invocation — one `CreatedBy` line per layer, newest
64    /// first, used by the equivalence checker to compare layer
65    /// count/order without the fragile fixed-column table format.
66    #[must_use]
67    pub fn history(image_ref: &str) -> Self {
68        Self {
69            program: "docker".to_string(),
70            args: vec![
71                "history".to_string(),
72                "--no-trunc".to_string(),
73                "--format".to_string(),
74                "{{.CreatedBy}}".to_string(),
75                image_ref.to_string(),
76            ],
77        }
78    }
79
80    /// Build a `docker save -o <output_path> <image_ref>` invocation —
81    /// used by the equivalence checker to obtain a tarball it can
82    /// inspect layer-by-layer without needing a registry.
83    #[must_use]
84    pub fn save(image_ref: &str, output_path: &Path) -> Self {
85        Self {
86            program: "docker".to_string(),
87            args: vec![
88                "save".to_string(),
89                "-o".to_string(),
90                output_path.display().to_string(),
91                image_ref.to_string(),
92            ],
93        }
94    }
95
96    /// Build a `docker push <image_ref>` invocation — used by the
97    /// warmth benchmark to publish a real image to a local registry so
98    /// the cache-hit path's `docker pull` is a genuine network op, not
99    /// a simulated one.
100    #[must_use]
101    pub fn push(image_ref: &str) -> Self {
102        Self { program: "docker".to_string(), args: vec!["push".to_string(), image_ref.to_string()] }
103    }
104
105    /// Build a `docker tag <source_ref> <target_ref>` invocation.
106    #[must_use]
107    pub fn tag(source_ref: &str, target_ref: &str) -> Self {
108        Self {
109            program: "docker".to_string(),
110            args: vec!["tag".to_string(), source_ref.to_string(), target_ref.to_string()],
111        }
112    }
113
114    /// Convert to a real [`Command`], ready to `.output()`.
115    #[must_use]
116    pub fn to_std_command(&self) -> Command {
117        let mut cmd = Command::new(&self.program);
118        cmd.args(&self.args);
119        cmd
120    }
121}
122
123/// The outcome of running a [`DockerBuildInvocation`] — never a panic, a
124/// typed exit status + captured output either way.
125#[derive(Debug, Clone)]
126pub struct CommandOutcome {
127    pub success: bool,
128    pub exit_code: Option<i32>,
129    pub stdout: Vec<u8>,
130    pub stderr: Vec<u8>,
131}
132
133impl CommandOutcome {
134    /// The last `n` bytes of stderr, decoded lossily — used for the typed
135    /// `BuildFailed` receipt so a failing build's tail is captured without
136    /// unbounded log growth.
137    #[must_use]
138    pub fn stderr_tail(&self, n: usize) -> String {
139        let start = self.stderr.len().saturating_sub(n);
140        String::from_utf8_lossy(&self.stderr[start..]).into_owned()
141    }
142}
143
144/// Errors launching the subprocess itself (not the subprocess's own exit
145/// status — that is captured in [`CommandOutcome`]).
146#[derive(Debug, thiserror::Error)]
147pub enum CommandRunError {
148    #[error("failed to spawn `{program}`: {source}")]
149    Spawn {
150        program: String,
151        #[source]
152        source: std::io::Error,
153    },
154}
155
156/// The injectable side-effect seam: real runs shell out, tests record the
157/// invocation and return a canned outcome.
158pub trait CommandRunner {
159    /// Run the invocation to completion, returning its typed outcome.
160    ///
161    /// # Errors
162    ///
163    /// Returns [`CommandRunError`] only if the process could not be
164    /// spawned at all (e.g. the binary is missing) — a non-zero exit
165    /// status is a normal (`success: false`) [`CommandOutcome`], not an
166    /// error.
167    fn run(&self, invocation: &DockerBuildInvocation) -> Result<CommandOutcome, CommandRunError>;
168}
169
170/// The production runner — shells out to the real `docker` binary via
171/// [`std::process::Command`].
172#[derive(Debug, Default, Clone, Copy)]
173pub struct RealCommandRunner;
174
175impl CommandRunner for RealCommandRunner {
176    fn run(&self, invocation: &DockerBuildInvocation) -> Result<CommandOutcome, CommandRunError> {
177        let output = invocation
178            .to_std_command()
179            .output()
180            .map_err(|source| CommandRunError::Spawn { program: invocation.program.clone(), source })?;
181        Ok(CommandOutcome {
182            success: output.status.success(),
183            exit_code: output.status.code(),
184            stdout: output.stdout,
185            stderr: output.stderr,
186        })
187    }
188}
189
190/// A recording mock runner for tests — never spawns a real process. Records
191/// every invocation it was asked to run and returns a pre-scripted outcome
192/// (defaulting to success with empty output).
193#[derive(Debug, Default)]
194pub struct MockCommandRunner {
195    pub invocations: std::sync::Mutex<Vec<DockerBuildInvocation>>,
196    pub scripted_outcome: Option<CommandOutcome>,
197}
198
199impl MockCommandRunner {
200    #[must_use]
201    pub fn new() -> Self {
202        Self::default()
203    }
204
205    #[must_use]
206    pub fn with_outcome(outcome: CommandOutcome) -> Self {
207        Self { invocations: std::sync::Mutex::new(Vec::new()), scripted_outcome: Some(outcome) }
208    }
209
210    /// Snapshot of every invocation recorded so far.
211    ///
212    /// # Panics
213    ///
214    /// Panics only if the internal mutex is poisoned (a prior panic
215    /// while holding the lock) — never in normal test use.
216    #[must_use]
217    pub fn recorded(&self) -> Vec<DockerBuildInvocation> {
218        self.invocations.lock().expect("mock mutex poisoned").clone()
219    }
220}
221
222impl CommandRunner for MockCommandRunner {
223    fn run(&self, invocation: &DockerBuildInvocation) -> Result<CommandOutcome, CommandRunError> {
224        self.invocations
225            .lock()
226            .expect("mock mutex poisoned")
227            .push(invocation.clone());
228        Ok(self.scripted_outcome.clone().unwrap_or(CommandOutcome {
229            success: true,
230            exit_code: Some(0),
231            stdout: Vec::new(),
232            stderr: Vec::new(),
233        }))
234    }
235}
236