Skip to main content

sui_dockerfile_wrapper/
lib.rs

1//! Phase 2 of `supa-charge-akeyless-ci`: the intercept/resolve/fall-through
2//! wrapper around plain `docker build`.
3//!
4//! Given a Dockerfile path + build context + build-arg map, this crate:
5//!
6//! 1. computes the [`sui_spec::dockerfile`] content-addressed
7//!    [`DockerfileGraph`](sui_spec::dockerfile::DockerfileGraph);
8//! 2. checks every node's `content_hash` against a
9//!    [`sui_cache::storage::StorageBackend`] (the same trait `sui cache
10//!    serve` runs — see [`cache`]);
11//! 3. on a **full** cache hit, materializes the already-built image via
12//!    `docker pull` instead of rebuilding — [`WrapperOutcome::CacheHit`];
13//! 4. on **any** miss (partial or full), shells out to a real `docker
14//!    build` for the *entire* Dockerfile — never a partial cache splice,
15//!    an explicit non-goal per the `supa-charge-akeyless-ci` plan — then
16//!    back-fills the cache with every node's hash → image reference for
17//!    next time — [`WrapperOutcome::CacheMiss`];
18//! 5. on a failing `docker build`, returns
19//!    [`WrapperOutcome::BuildFailed`] — never a panic.
20//!
21//! I/O is behind two injectable seams: [`command::CommandRunner`] (the
22//! `docker` subprocess) and [`sui_cache::storage::StorageBackend`] (the
23//! cache). Both are mocked in this crate's tests; production wires the
24//! real [`command::RealCommandRunner`] and a real backend built via
25//! [`sui_cache::storage::build_backend`].
26
27pub mod cache;
28pub mod command;
29pub mod daemon_client;
30
31use std::collections::BTreeMap;
32use std::path::PathBuf;
33use std::sync::Arc;
34use std::time::Instant;
35
36use serde::{Deserialize, Serialize};
37use sui_cache::storage::StorageBackend;
38use sui_spec::dockerfile::{self, DockerfileArgs, DockerfileEnvironment, DockerfileGraph};
39
40pub use command::{CommandOutcome, CommandRunError, CommandRunner, DockerBuildInvocation, MockCommandRunner, RealCommandRunner};
41pub use cache::MockCacheBackend;
42pub use daemon_client::DaemonAwareCacheClient;
43
44/// Typed, `serde`-deserializable input to a wrapper run — the keyway-shaped
45/// "YAML/JSON in" half of the contract.
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47pub struct WrapperConfig {
48    /// Path to the Dockerfile to build.
49    pub dockerfile_path: PathBuf,
50    /// Build context directory (mirrors `docker build <context>`).
51    pub context_dir: PathBuf,
52    /// Build-arg values (mirrors repeated `--build-arg K=V`).
53    #[serde(default)]
54    pub build_args: BTreeMap<String, String>,
55    /// The image tag to build/pull (mirrors `docker build -t <tag>`).
56    pub image_tag: String,
57    /// Optional path to a node-local `sui-dockerfile-node-cache-daemon`
58    /// Unix domain socket (Phase 3b). Absent by default, which keeps
59    /// this config byte-for-byte identical to Phase 2's original
60    /// shape. This field is read by whoever *constructs* the
61    /// `Arc<dyn StorageBackend>` passed to [`run_wrapper`] (e.g. the
62    /// GHA entrypoint) to decide whether to wrap the remote backend in
63    /// a [`crate::DaemonAwareCacheClient`] — `run_wrapper` itself never
64    /// reads this field, so its behavior is unaffected either way.
65    #[serde(default)]
66    pub daemon_socket_path: Option<PathBuf>,
67}
68
69/// Per-node cache status in the receipt — one row per
70/// [`DockerfileGraph`] node.
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
72pub struct NodeCacheStatus {
73    pub content_hash: String,
74    pub cached: bool,
75}
76
77/// The typed outcome of one wrapper run.
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
79#[serde(tag = "kind")]
80pub enum WrapperOutcome {
81    /// Every graph node was already cached — the image was pulled from
82    /// the cached reference, `docker build` never ran.
83    CacheHit { image_ref: String, node_count: usize },
84    /// At least one node was missing (or the cache had zero nodes) —
85    /// `docker build` ran end to end and the cache was back-filled.
86    ///
87    /// The duration is `u64` milliseconds, not `u128`: this enum is
88    /// `#[serde(tag = "kind")]` (internally tagged), and serde_json
89    /// cannot deserialize a `u128` through the intermediate buffer an
90    /// internally-tagged enum requires — a `u128` here made every
91    /// `CacheMiss` receipt un-round-trippable through the keyway "JSON
92    /// receipt out" contract. `u64` ms is ~584 million years of range,
93    /// far beyond any build wall-clock.
94    CacheMiss { docker_build_duration_ms: u64, nodes_cached: usize },
95    /// `docker build` (or the cache-hit `docker pull`) exited non-zero.
96    BuildFailed { exit_code: Option<i32>, stderr_tail: String },
97}
98
99/// The typed, `serde`-serializable "JSON receipt out" half of the
100/// keyway contract.
101#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
102pub struct WrapperReceipt {
103    pub outcome: WrapperOutcome,
104    pub nodes: Vec<NodeCacheStatus>,
105    /// `u64` milliseconds — see [`WrapperOutcome::CacheMiss`] for why not
106    /// `u128` (serde_json + internally-tagged-enum round-trip).
107    pub total_wall_clock_ms: u64,
108    pub docker_ran: bool,
109    /// Set when the cache *accelerator* could not be consulted and the
110    /// wrapper degraded to a plain real `docker build` — e.g. the graph
111    /// hasher rejected a Dockerfile that real docker builds fine (our
112    /// scoped parser is deliberately narrower than BuildKit), or the
113    /// cache backend itself errored (a transient Redis/Postgres hiccup).
114    /// The cache is an optimization: any cache-side trouble degrades to
115    /// a correct build, it never *breaks* one — this field makes that
116    /// degrade observable rather than silent. `None` on the normal
117    /// (cache consulted successfully) path.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub fell_through_reason: Option<String>,
120}
121
122impl WrapperReceipt {
123    /// Render this receipt as pretty JSON — the canonical keyway output
124    /// surface (never `format!()` of ad-hoc fields).
125    ///
126    /// # Errors
127    ///
128    /// Propagates any `serde_json` serialization failure (never expected
129    /// for this fully-owned type, but kept fallible per the typed-emission
130    /// contract).
131    pub fn to_json(&self) -> Result<String, serde_json::Error> {
132        serde_json::to_string_pretty(self)
133    }
134}
135
136/// The one thing that can genuinely fail a wrapper run: the `docker`
137/// subprocess could not be *spawned* at all (e.g. the binary is not on
138/// PATH). Everything cache-side degrades to a plain real build (D6) and
139/// so is *not* a `WrapperError` — see [`run_wrapper`]'s fall-through
140/// contract. A failing `docker build` is likewise not a `WrapperError`;
141/// it is the typed [`WrapperOutcome::BuildFailed`] inside an `Ok`
142/// receipt. Surfaced as a typed error, never a panic.
143#[derive(Debug, thiserror::Error)]
144pub enum WrapperError {
145    #[error("failed to spawn docker: {0}")]
146    Command(#[from] CommandRunError),
147}
148
149/// A [`DockerfileEnvironment`] that reads the Dockerfile straight off
150/// disk — the one production side effect this crate performs beyond the
151/// command runner and the cache backend.
152pub struct FilesystemDockerfileEnvironment {
153    pub build_args: BTreeMap<String, String>,
154}
155
156impl DockerfileEnvironment for FilesystemDockerfileEnvironment {
157    fn read_dockerfile(&self, path: &str) -> Result<String, String> {
158        std::fs::read_to_string(path).map_err(|e| e.to_string())
159    }
160
161    fn resolve_build_arg(&self, name: &str) -> Option<String> {
162        self.build_args.get(name).cloned()
163    }
164}
165
166/// Elapsed milliseconds since `since`, saturated into a `u64` (the
167/// receipt's serde-safe width). `Instant::elapsed().as_millis()` is a
168/// `u128`; a `u64` of ms is ~584 million years of range, so the
169/// saturation is unreachable in practice — it just keeps the cast
170/// total and explicit rather than a bare `as` truncation.
171fn elapsed_ms(since: Instant) -> u64 {
172    u64::try_from(since.elapsed().as_millis()).unwrap_or(u64::MAX)
173}
174
175/// The successfully-consulted cache plan for one Dockerfile: the parsed
176/// graph, per-node cache status, and — when *every* node was a hit — the
177/// image reference to materialize instead of rebuilding.
178struct CachePlan {
179    graph: DockerfileGraph,
180    nodes: Vec<NodeCacheStatus>,
181    /// `Some(image_ref)` iff the whole graph was cached (a full hit).
182    full_hit_image_ref: Option<String>,
183}
184
185/// Try to consult the cache accelerator: parse the Dockerfile into a
186/// content-addressed graph and check every node against the backend.
187///
188/// Returns `Err(reason)` — a human-readable degrade reason — for **any**
189/// cache-side trouble: a Dockerfile the scoped graph hasher rejects
190/// (our parser is deliberately narrower than BuildKit — a `HEALTHCHECK`
191/// line real docker builds fine lands here), or a backend I/O error
192/// (a transient Redis/Postgres hiccup). The caller degrades to a plain
193/// real `docker build` on `Err` — the cache is an optimization, never a
194/// gate on correctness.
195async fn consult_cache<E>(
196    config: &WrapperConfig,
197    env: &E,
198    cache: &Arc<dyn StorageBackend>,
199) -> Result<CachePlan, String>
200where
201    E: DockerfileEnvironment,
202{
203    let graph: DockerfileGraph = dockerfile::apply(
204        &DockerfileArgs { path: config.dockerfile_path.display().to_string() },
205        env,
206    )
207    .map_err(|e| {
208        let mut msg = String::from("graph hasher rejected the Dockerfile (scoped parser narrower than docker): ");
209        msg.push_str(&e.to_string());
210        msg
211    })?;
212
213    let mut nodes = Vec::with_capacity(graph.nodes.len());
214    let mut all_cached = !graph.nodes.is_empty();
215    let mut cached_image_ref: Option<String> = None;
216    for node in &graph.nodes {
217        let hit = cache.get_narinfo(&node.content_hash).await.map_err(|e| {
218            let mut msg = String::from("cache backend error while checking a node: ");
219            msg.push_str(&e.to_string());
220            msg
221        })?;
222        if let Some(image_ref) = &hit {
223            cached_image_ref = Some(image_ref.clone());
224        } else {
225            all_cached = false;
226        }
227        nodes.push(NodeCacheStatus { content_hash: node.content_hash.clone(), cached: hit.is_some() });
228    }
229
230    let full_hit_image_ref = if all_cached {
231        Some(cached_image_ref.unwrap_or_else(|| config.image_tag.clone()))
232    } else {
233        None
234    };
235    Ok(CachePlan { graph, nodes, full_hit_image_ref })
236}
237
238/// Run the wrapper: consult the cache accelerator and either materialize
239/// a full hit or fall through to a real `docker build`.
240///
241/// # The fall-through safety contract (D6)
242///
243/// The cache is an *accelerator*, never a gate. For **every** cache-side
244/// failure mode — a Dockerfile the scoped graph hasher rejects, a cache
245/// backend I/O error, a partial cache hit, a missing node-cache daemon —
246/// the wrapper degrades to a plain real `docker build` of the *entire*
247/// Dockerfile and **never** returns a broken or partially-spliced
248/// result. The degrade is recorded in
249/// [`WrapperReceipt::fell_through_reason`] so it is observable, never
250/// silent. A partial hit is likewise never spliced — it is a full real
251/// build (with `fell_through_reason == None`, since the cache *was*
252/// consulted successfully, it simply wasn't a full hit).
253///
254/// # Errors
255///
256/// Returns [`WrapperError::Command`] only if the `docker` subprocess
257/// could not be *spawned* at all (e.g. the binary is missing) — a
258/// genuine environment failure, not a cache concern. Graph-computation
259/// and cache-backend errors are **not** propagated: they degrade to a
260/// real build. A failing `docker build` subprocess is not a
261/// `WrapperError` either — it is the typed [`WrapperOutcome::BuildFailed`]
262/// inside an `Ok` receipt.
263pub async fn run_wrapper<E, R>(
264    config: &WrapperConfig,
265    env: &E,
266    cache: &Arc<dyn StorageBackend>,
267    runner: &R,
268) -> Result<WrapperReceipt, WrapperError>
269where
270    E: DockerfileEnvironment,
271    R: CommandRunner,
272{
273    let start = Instant::now();
274
275    // Consult the cache accelerator. On ANY cache-side error, degrade to
276    // a plain real build rather than propagating — the cache never gates
277    // correctness (D6).
278    let (plan, fell_through_reason): (Option<CachePlan>, Option<String>) =
279        match consult_cache(config, env, cache).await {
280            Ok(plan) => (Some(plan), None),
281            Err(reason) => {
282                tracing::warn!(reason = %reason, "cache accelerator unavailable — falling through to a plain docker build");
283                (None, Some(reason))
284            }
285        };
286
287    // Full-hit fast path: materialize the already-built image via
288    // `docker pull` instead of rebuilding. Only reachable when the cache
289    // was consulted successfully AND every node was a hit.
290    if let Some(plan) = &plan {
291        if let Some(image_ref) = &plan.full_hit_image_ref {
292            let invocation = DockerBuildInvocation::pull(image_ref);
293            let outcome = runner.run(&invocation)?;
294            let total_wall_clock_ms = elapsed_ms(start);
295            if outcome.success {
296                return Ok(WrapperReceipt {
297                    outcome: WrapperOutcome::CacheHit {
298                        image_ref: image_ref.clone(),
299                        node_count: plan.nodes.len(),
300                    },
301                    nodes: plan.nodes.clone(),
302                    total_wall_clock_ms,
303                    docker_ran: false,
304                    fell_through_reason: None,
305                });
306            }
307            return Ok(WrapperReceipt {
308                outcome: WrapperOutcome::BuildFailed {
309                    exit_code: outcome.exit_code,
310                    stderr_tail: outcome.stderr_tail(4096),
311                },
312                nodes: plan.nodes.clone(),
313                total_wall_clock_ms,
314                docker_ran: false,
315                fell_through_reason: None,
316            });
317        }
318    }
319
320    // Fall-through: a partial/full cache miss, OR the cache was
321    // unavailable entirely. Either way — never splice, always a full
322    // real build. When the cache was consulted we carry its per-node
323    // status; when it was unavailable we carry an empty node list (we
324    // never computed the graph).
325    let mut nodes = plan.as_ref().map(|p| p.nodes.clone()).unwrap_or_default();
326
327    let build_started = Instant::now();
328    let invocation = DockerBuildInvocation::build(
329        &config.dockerfile_path,
330        &config.context_dir,
331        &config.image_tag,
332        &config.build_args,
333    );
334    let outcome = runner.run(&invocation)?;
335    let docker_build_duration_ms = elapsed_ms(build_started);
336    let total_wall_clock_ms = elapsed_ms(start);
337
338    if !outcome.success {
339        return Ok(WrapperReceipt {
340            outcome: WrapperOutcome::BuildFailed {
341                exit_code: outcome.exit_code,
342                stderr_tail: outcome.stderr_tail(4096),
343            },
344            nodes,
345            total_wall_clock_ms,
346            docker_ran: true,
347            fell_through_reason,
348        });
349    }
350
351    // Back-fill the cache: every node's hash now maps to the freshly
352    // built image tag, so a future run over the same graph hits. This is
353    // best-effort — a back-fill write failure must not fail an
354    // already-successful build (the cache is an accelerator), so a
355    // put error only marks that node uncached and continues.
356    let mut nodes_cached = 0usize;
357    if let Some(plan) = &plan {
358        for node in &plan.graph.nodes {
359            match cache.put_narinfo(&node.content_hash, &config.image_tag).await {
360                Ok(()) => nodes_cached += 1,
361                Err(e) => {
362                    tracing::warn!(hash = %node.content_hash, error = %e, "cache back-fill write failed — build still succeeded");
363                }
364            }
365        }
366        for status in &mut nodes {
367            status.cached = nodes_cached == plan.graph.nodes.len();
368        }
369    }
370
371    Ok(WrapperReceipt {
372        outcome: WrapperOutcome::CacheMiss { docker_build_duration_ms, nodes_cached },
373        nodes,
374        total_wall_clock_ms,
375        docker_ran: true,
376        fell_through_reason,
377    })
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use command::CommandOutcome;
384    use sui_spec::dockerfile::MockDockerfileEnvironment;
385
386    const DOCKERFILE_PATH: &str = "Dockerfile";
387
388    fn simple_env() -> MockDockerfileEnvironment {
389        MockDockerfileEnvironment::default().with_dockerfile(
390            DOCKERFILE_PATH,
391            "FROM debian:bookworm-slim\nRUN apt-get update\nCMD [\"true\"]\n",
392        )
393    }
394
395    fn config() -> WrapperConfig {
396        WrapperConfig {
397            dockerfile_path: PathBuf::from(DOCKERFILE_PATH),
398            context_dir: PathBuf::from("."),
399            build_args: BTreeMap::new(),
400            image_tag: "example/image:test".to_string(),
401            daemon_socket_path: None,
402        }
403    }
404
405    fn graph_for(env: &MockDockerfileEnvironment) -> DockerfileGraph {
406        dockerfile::apply(&DockerfileArgs { path: DOCKERFILE_PATH.to_string() }, env).unwrap()
407    }
408
409    #[tokio::test]
410    async fn full_cache_hit_never_invokes_docker_build() {
411        let env = simple_env();
412        let graph = graph_for(&env);
413        let mut mock_cache = MockCacheBackend::new();
414        for node in &graph.nodes {
415            mock_cache = mock_cache.with_entry(&node.content_hash, "example/image:cached");
416        }
417        let cache: Arc<dyn StorageBackend> = Arc::new(mock_cache);
418        let runner = MockCommandRunner::new();
419
420        let receipt = run_wrapper(&config(), &env, &cache, &runner).await.unwrap();
421
422        assert!(!receipt.docker_ran);
423        match receipt.outcome {
424            WrapperOutcome::CacheHit { image_ref, node_count } => {
425                assert_eq!(image_ref, "example/image:cached");
426                assert_eq!(node_count, graph.nodes.len());
427            }
428            other => panic!("expected CacheHit, got {other:?}"),
429        }
430        // Exactly one invocation — a `docker pull`, never a `docker build`.
431        let recorded = runner.recorded();
432        assert_eq!(recorded.len(), 1);
433        assert_eq!(recorded[0].args[0], "pull");
434    }
435
436    #[tokio::test]
437    async fn full_cache_miss_falls_through_to_docker_build() {
438        let env = simple_env();
439        let cache: Arc<dyn StorageBackend> = Arc::new(MockCacheBackend::new());
440        let runner = MockCommandRunner::new();
441
442        let receipt = run_wrapper(&config(), &env, &cache, &runner).await.unwrap();
443
444        assert!(receipt.docker_ran);
445        match receipt.outcome {
446            WrapperOutcome::CacheMiss { nodes_cached, .. } => {
447                assert_eq!(nodes_cached, 3, "FROM + RUN + CMD");
448            }
449            other => panic!("expected CacheMiss, got {other:?}"),
450        }
451        let recorded = runner.recorded();
452        assert_eq!(recorded.len(), 1);
453        let invocation = &recorded[0];
454        assert_eq!(invocation.program, "docker");
455        assert_eq!(invocation.args[0], "build");
456        assert!(invocation.args.contains(&"-f".to_string()));
457        assert!(invocation.args.contains(&"-t".to_string()));
458        assert!(invocation.args.contains(&"example/image:test".to_string()));
459
460        // The cache was back-filled — a second run over the same graph
461        // is a full hit.
462        let graph = graph_for(&env);
463        for node in &graph.nodes {
464            let hit = cache.get_narinfo(&node.content_hash).await.unwrap();
465            assert_eq!(hit.as_deref(), Some("example/image:test"));
466        }
467    }
468
469    #[tokio::test]
470    async fn partial_cache_hit_still_falls_through_to_a_full_build() {
471        let env = simple_env();
472        let graph = graph_for(&env);
473        assert!(graph.nodes.len() >= 2, "fixture must have >=2 nodes to test partial hit");
474
475        // Cache only the FIRST node — a partial hit.
476        let mock_cache = MockCacheBackend::new().with_entry(&graph.nodes[0].content_hash, "example/image:partial");
477        let cache: Arc<dyn StorageBackend> = Arc::new(mock_cache);
478        let runner = MockCommandRunner::new();
479
480        let receipt = run_wrapper(&config(), &env, &cache, &runner).await.unwrap();
481
482        // No clever splice — falls straight through to a full real build.
483        assert!(receipt.docker_ran);
484        assert!(matches!(receipt.outcome, WrapperOutcome::CacheMiss { .. }));
485        let recorded = runner.recorded();
486        assert_eq!(recorded.len(), 1, "exactly one full docker build, no partial splice attempt");
487        assert_eq!(recorded[0].args[0], "build");
488
489        // The per-node receipt is still honest about which nodes were
490        // cached before the fallback ran.
491        assert!(receipt.nodes[0].cached, "first node was pre-cached in this fixture");
492    }
493
494    #[tokio::test]
495    async fn failing_docker_build_returns_build_failed_not_a_panic() {
496        let env = simple_env();
497        let cache: Arc<dyn StorageBackend> = Arc::new(MockCacheBackend::new());
498        let runner = MockCommandRunner::with_outcome(CommandOutcome {
499            success: false,
500            exit_code: Some(1),
501            stdout: Vec::new(),
502            stderr: b"error: failed to solve: process did not complete successfully".to_vec(),
503        });
504
505        let receipt = run_wrapper(&config(), &env, &cache, &runner).await.unwrap();
506
507        assert!(receipt.docker_ran);
508        match receipt.outcome {
509            WrapperOutcome::BuildFailed { exit_code, stderr_tail } => {
510                assert_eq!(exit_code, Some(1));
511                assert!(stderr_tail.contains("failed to solve"));
512            }
513            other => panic!("expected BuildFailed, got {other:?}"),
514        }
515
516        // The cache was NOT back-filled on a failed build.
517        let graph = graph_for(&env);
518        for node in &graph.nodes {
519            let hit = cache.get_narinfo(&node.content_hash).await.unwrap();
520            assert!(hit.is_none(), "a failed build must not poison the cache");
521        }
522    }
523
524    #[test]
525    fn receipt_json_roundtrip() {
526        let receipt = WrapperReceipt {
527            outcome: WrapperOutcome::CacheHit { image_ref: "example/image:cached".to_string(), node_count: 3 },
528            nodes: vec![
529                NodeCacheStatus { content_hash: "aaa".to_string(), cached: true },
530                NodeCacheStatus { content_hash: "bbb".to_string(), cached: true },
531            ],
532            total_wall_clock_ms: 42,
533            docker_ran: false,
534            fell_through_reason: None,
535        };
536        let json = receipt.to_json().unwrap();
537        let parsed: WrapperReceipt = serde_json::from_str(&json).unwrap();
538        assert_eq!(parsed, receipt);
539
540        // A degraded receipt round-trips too, and its reason survives.
541        let degraded = WrapperReceipt {
542            outcome: WrapperOutcome::CacheMiss { docker_build_duration_ms: 10, nodes_cached: 0 },
543            nodes: Vec::new(),
544            total_wall_clock_ms: 12,
545            docker_ran: true,
546            fell_through_reason: Some("cache backend error while checking a node: io error".to_string()),
547        };
548        let dj = degraded.to_json().unwrap();
549        let dparsed: WrapperReceipt = serde_json::from_str(&dj).unwrap();
550        assert_eq!(dparsed, degraded);
551    }
552
553    #[test]
554    fn receipt_yaml_config_roundtrip() {
555        // The keyway "YAML in" half — a WrapperConfig round-trips through
556        // serde_yaml_ng exactly as it would through a `--config wrapper.yaml`
557        // CLI flag.
558        let cfg = config();
559        let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
560        let parsed: WrapperConfig = serde_yaml_ng::from_str(&yaml).unwrap();
561        assert_eq!(parsed, cfg);
562    }
563
564    #[test]
565    fn docker_build_invocation_is_typed_not_string_concatenated() {
566        let mut build_args = BTreeMap::new();
567        build_args.insert("TARGETARCH".to_string(), "amd64".to_string());
568        let invocation = DockerBuildInvocation::build(
569            &PathBuf::from("Dockerfile"),
570            &PathBuf::from("."),
571            "example/image:test",
572            &build_args,
573        );
574        assert_eq!(invocation.program, "docker");
575        assert_eq!(
576            invocation.args,
577            vec![
578                "build".to_string(),
579                "-f".to_string(),
580                "Dockerfile".to_string(),
581                "-t".to_string(),
582                "example/image:test".to_string(),
583                "--build-arg".to_string(),
584                "TARGETARCH=amd64".to_string(),
585                ".".to_string(),
586            ]
587        );
588    }
589
590    /// Best-effort integration test against a REAL `docker` binary, in
591    /// cache-miss mode, proving the real subprocess path works. Skips
592    /// cleanly (never fakes a result) when `docker` is not on PATH — this
593    /// environment has no docker daemon reachable, so this test is
594    /// expected to skip in CI/sandboxes without one.
595    #[tokio::test]
596    async fn real_docker_build_end_to_end_when_docker_is_available() {
597        let docker_available = std::process::Command::new("docker")
598            .arg("--version")
599            .output()
600            .map(|o| o.status.success())
601            .unwrap_or(false);
602        if !docker_available {
603            eprintln!("skipping real_docker_build_end_to_end_when_docker_is_available: no docker on PATH");
604            return;
605        }
606
607        let dir = tempfile::tempdir().unwrap();
608        let dockerfile_path = dir.path().join("Dockerfile");
609        std::fs::write(&dockerfile_path, "FROM scratch\nCOPY Dockerfile /Dockerfile\n").unwrap();
610
611        let env = FilesystemDockerfileEnvironment { build_args: BTreeMap::new() };
612        let cache: Arc<dyn StorageBackend> = Arc::new(MockCacheBackend::new());
613        let runner = RealCommandRunner;
614        let cfg = WrapperConfig {
615            dockerfile_path,
616            context_dir: dir.path().to_path_buf(),
617            build_args: BTreeMap::new(),
618            image_tag: "sui-dockerfile-wrapper-test:latest".to_string(),
619            daemon_socket_path: None,
620        };
621
622        let receipt = run_wrapper(&cfg, &env, &cache, &runner).await.unwrap();
623        assert!(receipt.docker_ran);
624        assert!(
625            matches!(receipt.outcome, WrapperOutcome::CacheMiss { .. }),
626            "expected a real cache-miss docker build, got {:?}",
627            receipt.outcome
628        );
629    }
630}