pub mod cache;
pub mod command;
pub mod daemon_client;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Instant;
use serde::{Deserialize, Serialize};
use sui_cache::storage::StorageBackend;
use sui_spec::dockerfile::{self, DockerfileArgs, DockerfileEnvironment, DockerfileGraph};
pub use command::{CommandOutcome, CommandRunError, CommandRunner, DockerBuildInvocation, MockCommandRunner, RealCommandRunner};
pub use cache::MockCacheBackend;
pub use daemon_client::DaemonAwareCacheClient;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WrapperConfig {
pub dockerfile_path: PathBuf,
pub context_dir: PathBuf,
#[serde(default)]
pub build_args: BTreeMap<String, String>,
pub image_tag: String,
#[serde(default)]
pub daemon_socket_path: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NodeCacheStatus {
pub content_hash: String,
pub cached: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind")]
pub enum WrapperOutcome {
CacheHit { image_ref: String, node_count: usize },
CacheMiss { docker_build_duration_ms: u64, nodes_cached: usize },
BuildFailed { exit_code: Option<i32>, stderr_tail: String },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WrapperReceipt {
pub outcome: WrapperOutcome,
pub nodes: Vec<NodeCacheStatus>,
pub total_wall_clock_ms: u64,
pub docker_ran: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fell_through_reason: Option<String>,
}
impl WrapperReceipt {
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
}
#[derive(Debug, thiserror::Error)]
pub enum WrapperError {
#[error("failed to spawn docker: {0}")]
Command(#[from] CommandRunError),
}
pub struct FilesystemDockerfileEnvironment {
pub build_args: BTreeMap<String, String>,
}
impl DockerfileEnvironment for FilesystemDockerfileEnvironment {
fn read_dockerfile(&self, path: &str) -> Result<String, String> {
std::fs::read_to_string(path).map_err(|e| e.to_string())
}
fn resolve_build_arg(&self, name: &str) -> Option<String> {
self.build_args.get(name).cloned()
}
}
fn elapsed_ms(since: Instant) -> u64 {
u64::try_from(since.elapsed().as_millis()).unwrap_or(u64::MAX)
}
struct CachePlan {
graph: DockerfileGraph,
nodes: Vec<NodeCacheStatus>,
full_hit_image_ref: Option<String>,
}
async fn consult_cache<E>(
config: &WrapperConfig,
env: &E,
cache: &Arc<dyn StorageBackend>,
) -> Result<CachePlan, String>
where
E: DockerfileEnvironment,
{
let graph: DockerfileGraph = dockerfile::apply(
&DockerfileArgs { path: config.dockerfile_path.display().to_string() },
env,
)
.map_err(|e| {
let mut msg = String::from("graph hasher rejected the Dockerfile (scoped parser narrower than docker): ");
msg.push_str(&e.to_string());
msg
})?;
let mut nodes = Vec::with_capacity(graph.nodes.len());
let mut all_cached = !graph.nodes.is_empty();
let mut cached_image_ref: Option<String> = None;
for node in &graph.nodes {
let hit = cache.get_narinfo(&node.content_hash).await.map_err(|e| {
let mut msg = String::from("cache backend error while checking a node: ");
msg.push_str(&e.to_string());
msg
})?;
if let Some(image_ref) = &hit {
cached_image_ref = Some(image_ref.clone());
} else {
all_cached = false;
}
nodes.push(NodeCacheStatus { content_hash: node.content_hash.clone(), cached: hit.is_some() });
}
let full_hit_image_ref = if all_cached {
Some(cached_image_ref.unwrap_or_else(|| config.image_tag.clone()))
} else {
None
};
Ok(CachePlan { graph, nodes, full_hit_image_ref })
}
pub async fn run_wrapper<E, R>(
config: &WrapperConfig,
env: &E,
cache: &Arc<dyn StorageBackend>,
runner: &R,
) -> Result<WrapperReceipt, WrapperError>
where
E: DockerfileEnvironment,
R: CommandRunner,
{
let start = Instant::now();
let (plan, fell_through_reason): (Option<CachePlan>, Option<String>) =
match consult_cache(config, env, cache).await {
Ok(plan) => (Some(plan), None),
Err(reason) => {
tracing::warn!(reason = %reason, "cache accelerator unavailable — falling through to a plain docker build");
(None, Some(reason))
}
};
if let Some(plan) = &plan {
if let Some(image_ref) = &plan.full_hit_image_ref {
let invocation = DockerBuildInvocation::pull(image_ref);
let outcome = runner.run(&invocation)?;
let total_wall_clock_ms = elapsed_ms(start);
if outcome.success {
return Ok(WrapperReceipt {
outcome: WrapperOutcome::CacheHit {
image_ref: image_ref.clone(),
node_count: plan.nodes.len(),
},
nodes: plan.nodes.clone(),
total_wall_clock_ms,
docker_ran: false,
fell_through_reason: None,
});
}
return Ok(WrapperReceipt {
outcome: WrapperOutcome::BuildFailed {
exit_code: outcome.exit_code,
stderr_tail: outcome.stderr_tail(4096),
},
nodes: plan.nodes.clone(),
total_wall_clock_ms,
docker_ran: false,
fell_through_reason: None,
});
}
}
let mut nodes = plan.as_ref().map(|p| p.nodes.clone()).unwrap_or_default();
let build_started = Instant::now();
let invocation = DockerBuildInvocation::build(
&config.dockerfile_path,
&config.context_dir,
&config.image_tag,
&config.build_args,
);
let outcome = runner.run(&invocation)?;
let docker_build_duration_ms = elapsed_ms(build_started);
let total_wall_clock_ms = elapsed_ms(start);
if !outcome.success {
return Ok(WrapperReceipt {
outcome: WrapperOutcome::BuildFailed {
exit_code: outcome.exit_code,
stderr_tail: outcome.stderr_tail(4096),
},
nodes,
total_wall_clock_ms,
docker_ran: true,
fell_through_reason,
});
}
let mut nodes_cached = 0usize;
if let Some(plan) = &plan {
for node in &plan.graph.nodes {
match cache.put_narinfo(&node.content_hash, &config.image_tag).await {
Ok(()) => nodes_cached += 1,
Err(e) => {
tracing::warn!(hash = %node.content_hash, error = %e, "cache back-fill write failed — build still succeeded");
}
}
}
for status in &mut nodes {
status.cached = nodes_cached == plan.graph.nodes.len();
}
}
Ok(WrapperReceipt {
outcome: WrapperOutcome::CacheMiss { docker_build_duration_ms, nodes_cached },
nodes,
total_wall_clock_ms,
docker_ran: true,
fell_through_reason,
})
}
#[cfg(test)]
mod tests {
use super::*;
use command::CommandOutcome;
use sui_spec::dockerfile::MockDockerfileEnvironment;
const DOCKERFILE_PATH: &str = "Dockerfile";
fn simple_env() -> MockDockerfileEnvironment {
MockDockerfileEnvironment::default().with_dockerfile(
DOCKERFILE_PATH,
"FROM debian:bookworm-slim\nRUN apt-get update\nCMD [\"true\"]\n",
)
}
fn config() -> WrapperConfig {
WrapperConfig {
dockerfile_path: PathBuf::from(DOCKERFILE_PATH),
context_dir: PathBuf::from("."),
build_args: BTreeMap::new(),
image_tag: "example/image:test".to_string(),
daemon_socket_path: None,
}
}
fn graph_for(env: &MockDockerfileEnvironment) -> DockerfileGraph {
dockerfile::apply(&DockerfileArgs { path: DOCKERFILE_PATH.to_string() }, env).unwrap()
}
#[tokio::test]
async fn full_cache_hit_never_invokes_docker_build() {
let env = simple_env();
let graph = graph_for(&env);
let mut mock_cache = MockCacheBackend::new();
for node in &graph.nodes {
mock_cache = mock_cache.with_entry(&node.content_hash, "example/image:cached");
}
let cache: Arc<dyn StorageBackend> = Arc::new(mock_cache);
let runner = MockCommandRunner::new();
let receipt = run_wrapper(&config(), &env, &cache, &runner).await.unwrap();
assert!(!receipt.docker_ran);
match receipt.outcome {
WrapperOutcome::CacheHit { image_ref, node_count } => {
assert_eq!(image_ref, "example/image:cached");
assert_eq!(node_count, graph.nodes.len());
}
other => panic!("expected CacheHit, got {other:?}"),
}
let recorded = runner.recorded();
assert_eq!(recorded.len(), 1);
assert_eq!(recorded[0].args[0], "pull");
}
#[tokio::test]
async fn full_cache_miss_falls_through_to_docker_build() {
let env = simple_env();
let cache: Arc<dyn StorageBackend> = Arc::new(MockCacheBackend::new());
let runner = MockCommandRunner::new();
let receipt = run_wrapper(&config(), &env, &cache, &runner).await.unwrap();
assert!(receipt.docker_ran);
match receipt.outcome {
WrapperOutcome::CacheMiss { nodes_cached, .. } => {
assert_eq!(nodes_cached, 3, "FROM + RUN + CMD");
}
other => panic!("expected CacheMiss, got {other:?}"),
}
let recorded = runner.recorded();
assert_eq!(recorded.len(), 1);
let invocation = &recorded[0];
assert_eq!(invocation.program, "docker");
assert_eq!(invocation.args[0], "build");
assert!(invocation.args.contains(&"-f".to_string()));
assert!(invocation.args.contains(&"-t".to_string()));
assert!(invocation.args.contains(&"example/image:test".to_string()));
let graph = graph_for(&env);
for node in &graph.nodes {
let hit = cache.get_narinfo(&node.content_hash).await.unwrap();
assert_eq!(hit.as_deref(), Some("example/image:test"));
}
}
#[tokio::test]
async fn partial_cache_hit_still_falls_through_to_a_full_build() {
let env = simple_env();
let graph = graph_for(&env);
assert!(graph.nodes.len() >= 2, "fixture must have >=2 nodes to test partial hit");
let mock_cache = MockCacheBackend::new().with_entry(&graph.nodes[0].content_hash, "example/image:partial");
let cache: Arc<dyn StorageBackend> = Arc::new(mock_cache);
let runner = MockCommandRunner::new();
let receipt = run_wrapper(&config(), &env, &cache, &runner).await.unwrap();
assert!(receipt.docker_ran);
assert!(matches!(receipt.outcome, WrapperOutcome::CacheMiss { .. }));
let recorded = runner.recorded();
assert_eq!(recorded.len(), 1, "exactly one full docker build, no partial splice attempt");
assert_eq!(recorded[0].args[0], "build");
assert!(receipt.nodes[0].cached, "first node was pre-cached in this fixture");
}
#[tokio::test]
async fn failing_docker_build_returns_build_failed_not_a_panic() {
let env = simple_env();
let cache: Arc<dyn StorageBackend> = Arc::new(MockCacheBackend::new());
let runner = MockCommandRunner::with_outcome(CommandOutcome {
success: false,
exit_code: Some(1),
stdout: Vec::new(),
stderr: b"error: failed to solve: process did not complete successfully".to_vec(),
});
let receipt = run_wrapper(&config(), &env, &cache, &runner).await.unwrap();
assert!(receipt.docker_ran);
match receipt.outcome {
WrapperOutcome::BuildFailed { exit_code, stderr_tail } => {
assert_eq!(exit_code, Some(1));
assert!(stderr_tail.contains("failed to solve"));
}
other => panic!("expected BuildFailed, got {other:?}"),
}
let graph = graph_for(&env);
for node in &graph.nodes {
let hit = cache.get_narinfo(&node.content_hash).await.unwrap();
assert!(hit.is_none(), "a failed build must not poison the cache");
}
}
#[test]
fn receipt_json_roundtrip() {
let receipt = WrapperReceipt {
outcome: WrapperOutcome::CacheHit { image_ref: "example/image:cached".to_string(), node_count: 3 },
nodes: vec![
NodeCacheStatus { content_hash: "aaa".to_string(), cached: true },
NodeCacheStatus { content_hash: "bbb".to_string(), cached: true },
],
total_wall_clock_ms: 42,
docker_ran: false,
fell_through_reason: None,
};
let json = receipt.to_json().unwrap();
let parsed: WrapperReceipt = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, receipt);
let degraded = WrapperReceipt {
outcome: WrapperOutcome::CacheMiss { docker_build_duration_ms: 10, nodes_cached: 0 },
nodes: Vec::new(),
total_wall_clock_ms: 12,
docker_ran: true,
fell_through_reason: Some("cache backend error while checking a node: io error".to_string()),
};
let dj = degraded.to_json().unwrap();
let dparsed: WrapperReceipt = serde_json::from_str(&dj).unwrap();
assert_eq!(dparsed, degraded);
}
#[test]
fn receipt_yaml_config_roundtrip() {
let cfg = config();
let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
let parsed: WrapperConfig = serde_yaml_ng::from_str(&yaml).unwrap();
assert_eq!(parsed, cfg);
}
#[test]
fn docker_build_invocation_is_typed_not_string_concatenated() {
let mut build_args = BTreeMap::new();
build_args.insert("TARGETARCH".to_string(), "amd64".to_string());
let invocation = DockerBuildInvocation::build(
&PathBuf::from("Dockerfile"),
&PathBuf::from("."),
"example/image:test",
&build_args,
);
assert_eq!(invocation.program, "docker");
assert_eq!(
invocation.args,
vec![
"build".to_string(),
"-f".to_string(),
"Dockerfile".to_string(),
"-t".to_string(),
"example/image:test".to_string(),
"--build-arg".to_string(),
"TARGETARCH=amd64".to_string(),
".".to_string(),
]
);
}
#[tokio::test]
async fn real_docker_build_end_to_end_when_docker_is_available() {
let docker_available = std::process::Command::new("docker")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !docker_available {
eprintln!("skipping real_docker_build_end_to_end_when_docker_is_available: no docker on PATH");
return;
}
let dir = tempfile::tempdir().unwrap();
let dockerfile_path = dir.path().join("Dockerfile");
std::fs::write(&dockerfile_path, "FROM scratch\nCOPY Dockerfile /Dockerfile\n").unwrap();
let env = FilesystemDockerfileEnvironment { build_args: BTreeMap::new() };
let cache: Arc<dyn StorageBackend> = Arc::new(MockCacheBackend::new());
let runner = RealCommandRunner;
let cfg = WrapperConfig {
dockerfile_path,
context_dir: dir.path().to_path_buf(),
build_args: BTreeMap::new(),
image_tag: "sui-dockerfile-wrapper-test:latest".to_string(),
daemon_socket_path: None,
};
let receipt = run_wrapper(&cfg, &env, &cache, &runner).await.unwrap();
assert!(receipt.docker_ran);
assert!(
matches!(receipt.outcome, WrapperOutcome::CacheMiss { .. }),
"expected a real cache-miss docker build, got {:?}",
receipt.outcome
);
}
}