1pub 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47pub struct WrapperConfig {
48 pub dockerfile_path: PathBuf,
50 pub context_dir: PathBuf,
52 #[serde(default)]
54 pub build_args: BTreeMap<String, String>,
55 pub image_tag: String,
57 #[serde(default)]
66 pub daemon_socket_path: Option<PathBuf>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
72pub struct NodeCacheStatus {
73 pub content_hash: String,
74 pub cached: bool,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
79#[serde(tag = "kind")]
80pub enum WrapperOutcome {
81 CacheHit { image_ref: String, node_count: usize },
84 CacheMiss { docker_build_duration_ms: u128, nodes_cached: usize },
87 BuildFailed { exit_code: Option<i32>, stderr_tail: String },
89}
90
91#[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 pub fn to_json(&self) -> Result<String, serde_json::Error> {
111 serde_json::to_string_pretty(self)
112 }
113}
114
115#[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
127pub 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
144pub 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 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 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 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 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 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 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 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 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 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 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 #[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}