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: u64, nodes_cached: usize },
95 BuildFailed { exit_code: Option<i32>, stderr_tail: String },
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
102pub struct WrapperReceipt {
103 pub outcome: WrapperOutcome,
104 pub nodes: Vec<NodeCacheStatus>,
105 pub total_wall_clock_ms: u64,
108 pub docker_ran: bool,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub fell_through_reason: Option<String>,
120}
121
122impl WrapperReceipt {
123 pub fn to_json(&self) -> Result<String, serde_json::Error> {
132 serde_json::to_string_pretty(self)
133 }
134}
135
136#[derive(Debug, thiserror::Error)]
144pub enum WrapperError {
145 #[error("failed to spawn docker: {0}")]
146 Command(#[from] CommandRunError),
147}
148
149pub 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
166fn elapsed_ms(since: Instant) -> u64 {
172 u64::try_from(since.elapsed().as_millis()).unwrap_or(u64::MAX)
173}
174
175struct CachePlan {
179 graph: DockerfileGraph,
180 nodes: Vec<NodeCacheStatus>,
181 full_hit_image_ref: Option<String>,
183}
184
185async 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
238pub 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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}