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    CacheMiss { docker_build_duration_ms: u128, nodes_cached: usize },
87    /// `docker build` (or the cache-hit `docker pull`) exited non-zero.
88    BuildFailed { exit_code: Option<i32>, stderr_tail: String },
89}
90
91/// The typed, `serde`-serializable "JSON receipt out" half of the
92/// keyway contract.
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
94pub struct WrapperReceipt {
95    pub outcome: WrapperOutcome,
96    pub nodes: Vec<NodeCacheStatus>,
97    pub total_wall_clock_ms: u128,
98    pub docker_ran: bool,
99}
100
101impl WrapperReceipt {
102    /// Render this receipt as pretty JSON — the canonical keyway output
103    /// surface (never `format!()` of ad-hoc fields).
104    ///
105    /// # Errors
106    ///
107    /// Propagates any `serde_json` serialization failure (never expected
108    /// for this fully-owned type, but kept fallible per the typed-emission
109    /// contract).
110    pub fn to_json(&self) -> Result<String, serde_json::Error> {
111        serde_json::to_string_pretty(self)
112    }
113}
114
115/// Errors reading/parsing the Dockerfile graph — surfaced as a typed
116/// error, never a panic.
117#[derive(Debug, thiserror::Error)]
118pub enum WrapperError {
119    #[error("computing the dockerfile graph: {0}")]
120    Graph(#[from] sui_spec::SpecError),
121    #[error("cache backend error: {0}")]
122    Cache(#[from] sui_cache::CacheError),
123    #[error("failed to spawn docker: {0}")]
124    Command(#[from] CommandRunError),
125}
126
127/// A [`DockerfileEnvironment`] that reads the Dockerfile straight off
128/// disk — the one production side effect this crate performs beyond the
129/// command runner and the cache backend.
130pub struct FilesystemDockerfileEnvironment {
131    pub build_args: BTreeMap<String, String>,
132}
133
134impl DockerfileEnvironment for FilesystemDockerfileEnvironment {
135    fn read_dockerfile(&self, path: &str) -> Result<String, String> {
136        std::fs::read_to_string(path).map_err(|e| e.to_string())
137    }
138
139    fn resolve_build_arg(&self, name: &str) -> Option<String> {
140        self.build_args.get(name).cloned()
141    }
142}
143
144/// Run the wrapper: compute the graph, check the cache, and either
145/// materialize a hit or fall through to a real build.
146///
147/// # Errors
148///
149/// Returns [`WrapperError`] if the Dockerfile can't be parsed into a
150/// graph, or if the cache backend itself errors (a cache *miss* is not
151/// an error — only a backend I/O failure is). A failing `docker build`
152/// subprocess is *not* a `WrapperError` — it is the typed
153/// [`WrapperOutcome::BuildFailed`] inside an `Ok` receipt.
154pub async fn run_wrapper<E, R>(
155    config: &WrapperConfig,
156    env: &E,
157    cache: &Arc<dyn StorageBackend>,
158    runner: &R,
159) -> Result<WrapperReceipt, WrapperError>
160where
161    E: DockerfileEnvironment,
162    R: CommandRunner,
163{
164    let start = Instant::now();
165
166    let graph: DockerfileGraph = dockerfile::apply(
167        &DockerfileArgs { path: config.dockerfile_path.display().to_string() },
168        env,
169    )?;
170
171    let mut nodes = Vec::with_capacity(graph.nodes.len());
172    let mut all_cached = !graph.nodes.is_empty();
173    let mut cached_image_ref: Option<String> = None;
174    for node in &graph.nodes {
175        let hit = cache.get_narinfo(&node.content_hash).await?;
176        if let Some(image_ref) = &hit {
177            cached_image_ref = Some(image_ref.clone());
178        } else {
179            all_cached = false;
180        }
181        nodes.push(NodeCacheStatus { content_hash: node.content_hash.clone(), cached: hit.is_some() });
182    }
183
184    if all_cached {
185        // Full hit: materialize the already-built image rather than
186        // rebuilding. The image reference is whatever the last node's
187        // cache entry recorded on the miss path that produced it.
188        let image_ref = cached_image_ref.unwrap_or_else(|| config.image_tag.clone());
189        let invocation = DockerBuildInvocation::pull(&image_ref);
190        let outcome = runner.run(&invocation)?;
191        let total_wall_clock_ms = start.elapsed().as_millis();
192        if outcome.success {
193            return Ok(WrapperReceipt {
194                outcome: WrapperOutcome::CacheHit { image_ref, node_count: nodes.len() },
195                nodes,
196                total_wall_clock_ms,
197                docker_ran: false,
198            });
199        }
200        return Ok(WrapperReceipt {
201            outcome: WrapperOutcome::BuildFailed {
202                exit_code: outcome.exit_code,
203                stderr_tail: outcome.stderr_tail(4096),
204            },
205            nodes,
206            total_wall_clock_ms,
207            docker_ran: false,
208        });
209    }
210
211    // Partial or full miss: never splice, always a full real build.
212    let build_started = Instant::now();
213    let invocation = DockerBuildInvocation::build(
214        &config.dockerfile_path,
215        &config.context_dir,
216        &config.image_tag,
217        &config.build_args,
218    );
219    let outcome = runner.run(&invocation)?;
220    let docker_build_duration_ms = build_started.elapsed().as_millis();
221    let total_wall_clock_ms = start.elapsed().as_millis();
222
223    if !outcome.success {
224        return Ok(WrapperReceipt {
225            outcome: WrapperOutcome::BuildFailed {
226                exit_code: outcome.exit_code,
227                stderr_tail: outcome.stderr_tail(4096),
228            },
229            nodes,
230            total_wall_clock_ms,
231            docker_ran: true,
232        });
233    }
234
235    // Back-fill the cache: every node's hash now maps to the freshly
236    // built image tag, so a future run over the same graph hits.
237    let mut nodes_cached = 0usize;
238    for node in &graph.nodes {
239        cache.put_narinfo(&node.content_hash, &config.image_tag).await?;
240        nodes_cached += 1;
241    }
242    for status in &mut nodes {
243        status.cached = true;
244    }
245
246    Ok(WrapperReceipt {
247        outcome: WrapperOutcome::CacheMiss { docker_build_duration_ms, nodes_cached },
248        nodes,
249        total_wall_clock_ms,
250        docker_ran: true,
251    })
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use command::CommandOutcome;
258    use sui_spec::dockerfile::MockDockerfileEnvironment;
259
260    const DOCKERFILE_PATH: &str = "Dockerfile";
261
262    fn simple_env() -> MockDockerfileEnvironment {
263        MockDockerfileEnvironment::default().with_dockerfile(
264            DOCKERFILE_PATH,
265            "FROM debian:bookworm-slim\nRUN apt-get update\nCMD [\"true\"]\n",
266        )
267    }
268
269    fn config() -> WrapperConfig {
270        WrapperConfig {
271            dockerfile_path: PathBuf::from(DOCKERFILE_PATH),
272            context_dir: PathBuf::from("."),
273            build_args: BTreeMap::new(),
274            image_tag: "example/image:test".to_string(),
275            daemon_socket_path: None,
276        }
277    }
278
279    fn graph_for(env: &MockDockerfileEnvironment) -> DockerfileGraph {
280        dockerfile::apply(&DockerfileArgs { path: DOCKERFILE_PATH.to_string() }, env).unwrap()
281    }
282
283    #[tokio::test]
284    async fn full_cache_hit_never_invokes_docker_build() {
285        let env = simple_env();
286        let graph = graph_for(&env);
287        let mut mock_cache = MockCacheBackend::new();
288        for node in &graph.nodes {
289            mock_cache = mock_cache.with_entry(&node.content_hash, "example/image:cached");
290        }
291        let cache: Arc<dyn StorageBackend> = Arc::new(mock_cache);
292        let runner = MockCommandRunner::new();
293
294        let receipt = run_wrapper(&config(), &env, &cache, &runner).await.unwrap();
295
296        assert!(!receipt.docker_ran);
297        match receipt.outcome {
298            WrapperOutcome::CacheHit { image_ref, node_count } => {
299                assert_eq!(image_ref, "example/image:cached");
300                assert_eq!(node_count, graph.nodes.len());
301            }
302            other => panic!("expected CacheHit, got {other:?}"),
303        }
304        // Exactly one invocation — a `docker pull`, never a `docker build`.
305        let recorded = runner.recorded();
306        assert_eq!(recorded.len(), 1);
307        assert_eq!(recorded[0].args[0], "pull");
308    }
309
310    #[tokio::test]
311    async fn full_cache_miss_falls_through_to_docker_build() {
312        let env = simple_env();
313        let cache: Arc<dyn StorageBackend> = Arc::new(MockCacheBackend::new());
314        let runner = MockCommandRunner::new();
315
316        let receipt = run_wrapper(&config(), &env, &cache, &runner).await.unwrap();
317
318        assert!(receipt.docker_ran);
319        match receipt.outcome {
320            WrapperOutcome::CacheMiss { nodes_cached, .. } => {
321                assert_eq!(nodes_cached, 3, "FROM + RUN + CMD");
322            }
323            other => panic!("expected CacheMiss, got {other:?}"),
324        }
325        let recorded = runner.recorded();
326        assert_eq!(recorded.len(), 1);
327        let invocation = &recorded[0];
328        assert_eq!(invocation.program, "docker");
329        assert_eq!(invocation.args[0], "build");
330        assert!(invocation.args.contains(&"-f".to_string()));
331        assert!(invocation.args.contains(&"-t".to_string()));
332        assert!(invocation.args.contains(&"example/image:test".to_string()));
333
334        // The cache was back-filled — a second run over the same graph
335        // is a full hit.
336        let graph = graph_for(&env);
337        for node in &graph.nodes {
338            let hit = cache.get_narinfo(&node.content_hash).await.unwrap();
339            assert_eq!(hit.as_deref(), Some("example/image:test"));
340        }
341    }
342
343    #[tokio::test]
344    async fn partial_cache_hit_still_falls_through_to_a_full_build() {
345        let env = simple_env();
346        let graph = graph_for(&env);
347        assert!(graph.nodes.len() >= 2, "fixture must have >=2 nodes to test partial hit");
348
349        // Cache only the FIRST node — a partial hit.
350        let mock_cache = MockCacheBackend::new().with_entry(&graph.nodes[0].content_hash, "example/image:partial");
351        let cache: Arc<dyn StorageBackend> = Arc::new(mock_cache);
352        let runner = MockCommandRunner::new();
353
354        let receipt = run_wrapper(&config(), &env, &cache, &runner).await.unwrap();
355
356        // No clever splice — falls straight through to a full real build.
357        assert!(receipt.docker_ran);
358        assert!(matches!(receipt.outcome, WrapperOutcome::CacheMiss { .. }));
359        let recorded = runner.recorded();
360        assert_eq!(recorded.len(), 1, "exactly one full docker build, no partial splice attempt");
361        assert_eq!(recorded[0].args[0], "build");
362
363        // The per-node receipt is still honest about which nodes were
364        // cached before the fallback ran.
365        assert!(receipt.nodes[0].cached, "first node was pre-cached in this fixture");
366    }
367
368    #[tokio::test]
369    async fn failing_docker_build_returns_build_failed_not_a_panic() {
370        let env = simple_env();
371        let cache: Arc<dyn StorageBackend> = Arc::new(MockCacheBackend::new());
372        let runner = MockCommandRunner::with_outcome(CommandOutcome {
373            success: false,
374            exit_code: Some(1),
375            stdout: Vec::new(),
376            stderr: b"error: failed to solve: process did not complete successfully".to_vec(),
377        });
378
379        let receipt = run_wrapper(&config(), &env, &cache, &runner).await.unwrap();
380
381        assert!(receipt.docker_ran);
382        match receipt.outcome {
383            WrapperOutcome::BuildFailed { exit_code, stderr_tail } => {
384                assert_eq!(exit_code, Some(1));
385                assert!(stderr_tail.contains("failed to solve"));
386            }
387            other => panic!("expected BuildFailed, got {other:?}"),
388        }
389
390        // The cache was NOT back-filled on a failed build.
391        let graph = graph_for(&env);
392        for node in &graph.nodes {
393            let hit = cache.get_narinfo(&node.content_hash).await.unwrap();
394            assert!(hit.is_none(), "a failed build must not poison the cache");
395        }
396    }
397
398    #[test]
399    fn receipt_json_roundtrip() {
400        let receipt = WrapperReceipt {
401            outcome: WrapperOutcome::CacheHit { image_ref: "example/image:cached".to_string(), node_count: 3 },
402            nodes: vec![
403                NodeCacheStatus { content_hash: "aaa".to_string(), cached: true },
404                NodeCacheStatus { content_hash: "bbb".to_string(), cached: true },
405            ],
406            total_wall_clock_ms: 42,
407            docker_ran: false,
408        };
409        let json = receipt.to_json().unwrap();
410        let parsed: WrapperReceipt = serde_json::from_str(&json).unwrap();
411        assert_eq!(parsed, receipt);
412    }
413
414    #[test]
415    fn receipt_yaml_config_roundtrip() {
416        // The keyway "YAML in" half — a WrapperConfig round-trips through
417        // serde_yaml_ng exactly as it would through a `--config wrapper.yaml`
418        // CLI flag.
419        let cfg = config();
420        let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
421        let parsed: WrapperConfig = serde_yaml_ng::from_str(&yaml).unwrap();
422        assert_eq!(parsed, cfg);
423    }
424
425    #[test]
426    fn docker_build_invocation_is_typed_not_string_concatenated() {
427        let mut build_args = BTreeMap::new();
428        build_args.insert("TARGETARCH".to_string(), "amd64".to_string());
429        let invocation = DockerBuildInvocation::build(
430            &PathBuf::from("Dockerfile"),
431            &PathBuf::from("."),
432            "example/image:test",
433            &build_args,
434        );
435        assert_eq!(invocation.program, "docker");
436        assert_eq!(
437            invocation.args,
438            vec![
439                "build".to_string(),
440                "-f".to_string(),
441                "Dockerfile".to_string(),
442                "-t".to_string(),
443                "example/image:test".to_string(),
444                "--build-arg".to_string(),
445                "TARGETARCH=amd64".to_string(),
446                ".".to_string(),
447            ]
448        );
449    }
450
451    /// Best-effort integration test against a REAL `docker` binary, in
452    /// cache-miss mode, proving the real subprocess path works. Skips
453    /// cleanly (never fakes a result) when `docker` is not on PATH — this
454    /// environment has no docker daemon reachable, so this test is
455    /// expected to skip in CI/sandboxes without one.
456    #[tokio::test]
457    async fn real_docker_build_end_to_end_when_docker_is_available() {
458        let docker_available = std::process::Command::new("docker")
459            .arg("--version")
460            .output()
461            .map(|o| o.status.success())
462            .unwrap_or(false);
463        if !docker_available {
464            eprintln!("skipping real_docker_build_end_to_end_when_docker_is_available: no docker on PATH");
465            return;
466        }
467
468        let dir = tempfile::tempdir().unwrap();
469        let dockerfile_path = dir.path().join("Dockerfile");
470        std::fs::write(&dockerfile_path, "FROM scratch\nCOPY Dockerfile /Dockerfile\n").unwrap();
471
472        let env = FilesystemDockerfileEnvironment { build_args: BTreeMap::new() };
473        let cache: Arc<dyn StorageBackend> = Arc::new(MockCacheBackend::new());
474        let runner = RealCommandRunner;
475        let cfg = WrapperConfig {
476            dockerfile_path,
477            context_dir: dir.path().to_path_buf(),
478            build_args: BTreeMap::new(),
479            image_tag: "sui-dockerfile-wrapper-test:latest".to_string(),
480            daemon_socket_path: None,
481        };
482
483        let receipt = run_wrapper(&cfg, &env, &cache, &runner).await.unwrap();
484        assert!(receipt.docker_ran);
485        assert!(
486            matches!(receipt.outcome, WrapperOutcome::CacheMiss { .. }),
487            "expected a real cache-miss docker build, got {:?}",
488            receipt.outcome
489        );
490    }
491}