// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 //! Build container images for sandbox runtimes. //! //! This module wraps bollard's `build_image()` API to build a container image //! from a Dockerfile and build context. Package-managed local gateways use the //! host Docker daemon, so the resulting tag is passed to the gateway directly. use std::collections::HashMap; use std::path::Path; use std::time::Duration; use bollard::Docker; use bollard::query_parameters::BuildImageOptionsBuilder; use futures::StreamExt; use miette::{IntoDiagnostic, Result, WrapErr}; use tokio::time::timeout; /// Maximum gap between Docker build stream events before a build is treated /// as stuck. /// /// Total silence longer than this on under-provisioned container runtimes /// (e.g. default Colima 2 vCPU / 2 GiB on macOS) reliably indicates a /// deadlocked builder that will never recover. The default leaves headroom /// for legitimately quiet steps (a single long `RUN` that produces no output) /// — override with `OPENSHELL_BUILD_NO_PROGRESS_TIMEOUT_SECS` if a specific /// build needs more time, or shorter for CI tightening. const DEFAULT_BUILD_NO_PROGRESS_TIMEOUT_SECS: u64 = 1800; async fn docker_daemon_platform(docker: &Docker) -> Result { let info = docker .info() .await .into_diagnostic() .wrap_err("failed to query local Docker daemon info")?; let os = info .os_type .as_deref() .filter(|value| !value.is_empty()) .ok_or_else(|| miette::miette!("Docker daemon did not report an operating system"))?; let arch = info .architecture .as_deref() .filter(|value| !value.is_empty()) .ok_or_else(|| miette::miette!("Docker daemon did not report an architecture"))?; normalize_docker_platform(os, arch) } fn normalize_docker_platform(os: &str, arch: &str) -> Result { // Remote or non-standard daemons may return mixed-case arch strings. let arch_lower = arch.to_lowercase(); let normalized_arch = match arch_lower.as_str() { "amd64" | "x86_64" => "amd64", "arm64" | "aarch64" => "arm64", "arm" | "armv7" | "armv7l" | "armhf" => "arm/v7", "armv6" | "armv6l" => "arm/v6", "386" | "i386" | "i686" => "386", "ppc64le" => "ppc64le", "s390x" => "s390x", _ => { return Err(miette::miette!( "unsupported Docker daemon architecture for local image build: {arch}" )); } }; Ok(format!("{os}/{normalized_arch}")) } /// Parse `os/arch[/variant]` and reject malformed empty segments. fn parse_platform(platform: &str) -> Result<(&str, &str, &str)> { let mut parts = platform.split('/'); let os = parts .next() .filter(|v| !v.is_empty()) .ok_or_else(|| miette::miette!("platform is missing an operating system"))?; let arch = parts .next() .filter(|v| !v.is_empty()) .ok_or_else(|| miette::miette!("platform is missing an architecture"))?; // A trailing slash creates a malformed empty variant segment. let variant = match parts.next() { Some("") => { return Err(miette::miette!( "platform variant must not be empty (trailing slash?): '{platform}'" )); } Some(v) => v, None => "", }; if parts.next().is_some() { return Err(miette::miette!( "platform must be os/arch[/variant], got '{platform}'" )); } Ok((os, arch, variant)) } /// Seed Docker's implicit `BuildKit` platform args without replacing caller values. /// /// `target_platform` identifies the output image. `build_platform` identifies /// the Docker daemon doing the build. Variant args are only injected when the /// platform string has an explicit variant, which preserves Dockerfile ARG /// defaults for no-variant platforms. fn insert_implicit_platform_build_args( build_args: &mut HashMap, target_platform: &str, build_platform: &str, ) -> Result<()> { let (target_os, target_arch, target_variant) = parse_platform(target_platform) .wrap_err_with(|| format!("invalid target platform '{target_platform}'"))?; let (build_os, build_arch, build_variant) = parse_platform(build_platform) .wrap_err_with(|| format!("invalid build platform '{build_platform}'"))?; for (key, val) in [ ("TARGETPLATFORM", target_platform), ("TARGETOS", target_os), ("TARGETARCH", target_arch), ("BUILDPLATFORM", build_platform), ("BUILDOS", build_os), ("BUILDARCH", build_arch), ] { build_args .entry(key.to_string()) .or_insert_with(|| val.to_string()); } if !target_variant.is_empty() { build_args .entry("TARGETVARIANT".to_string()) .or_insert_with(|| target_variant.to_string()); } if !build_variant.is_empty() { build_args .entry("BUILDVARIANT".to_string()) .or_insert_with(|| build_variant.to_string()); } Ok(()) } /// Build a container image from a Dockerfile using the local Docker daemon. /// /// This is used by `openshell sandbox create --from `. The image /// remains available in the local Docker daemon so the gateway's active local /// compute driver can resolve the tag. #[allow(clippy::implicit_hasher)] pub async fn build_local_image( dockerfile_path: &Path, tag: &str, context_dir: &Path, build_args: &HashMap, on_log: &mut impl FnMut(String), ) -> Result<()> { on_log(format!( "Building image {tag} from {}", dockerfile_path.display() )); build_image(dockerfile_path, tag, context_dir, build_args, on_log).await?; on_log(format!("Built image {tag}")); Ok(()) } /// Build a container image using the local Docker daemon. /// /// Creates a tar archive of `context_dir`, sends it to Docker with the /// specified Dockerfile path and tag, and streams build output to `on_log`. async fn build_image( dockerfile_path: &Path, tag: &str, context_dir: &Path, build_args: &HashMap, on_log: &mut impl FnMut(String), ) -> Result<()> { let docker = Docker::connect_with_local_defaults() .into_diagnostic() .wrap_err("failed to connect to local Docker daemon")?; // BUILD* always comes from daemon info. A TARGETPLATFORM override only // changes the output image platform. let build_platform = docker_daemon_platform(&docker).await?; let target_platform = build_args .get("TARGETPLATFORM") .map_or_else(|| build_platform.clone(), Clone::clone); let mut effective_build_args = build_args.clone(); insert_implicit_platform_build_args( &mut effective_build_args, &target_platform, &build_platform, )?; // Compute the relative path of the Dockerfile within the context. let dockerfile_relative = dockerfile_path .strip_prefix(context_dir) .unwrap_or(dockerfile_path); let dockerfile_str = dockerfile_relative .to_str() .ok_or_else(|| miette::miette!("Dockerfile path is not valid UTF-8"))?; // Create a tar archive of the build context, respecting .dockerignore. let context_tar = create_build_context_tar(context_dir)?; let mut builder = BuildImageOptionsBuilder::default() .dockerfile(dockerfile_str) .t(tag) .rm(true) .platform(&target_platform); // Pass build args to Docker. if !effective_build_args.is_empty() { builder = builder.buildargs(&effective_build_args); } let options = builder.build(); let body = bollard::body_full(bytes::Bytes::from(context_tar)); let mut stream = docker.build_image(options, None, Some(body)); let no_progress_secs: u64 = std::env::var("OPENSHELL_BUILD_NO_PROGRESS_TIMEOUT_SECS") .ok() .and_then(|s| s.parse().ok()) .filter(|&n| n > 0) .unwrap_or(DEFAULT_BUILD_NO_PROGRESS_TIMEOUT_SECS); let no_progress_timeout = Duration::from_secs(no_progress_secs); loop { let next = match timeout(no_progress_timeout, stream.next()).await { Ok(Some(result)) => result, Ok(None) => break, Err(_) => { return Err(miette::miette!( "Docker build produced no output for {}s. This usually means the container \ runtime is under-provisioned (CPU/memory) and the builder has deadlocked; \ check `docker info` (NCPU, MemTotal) and increase Colima/Docker Desktop \ resources before retrying. If a legitimate build step is just quiet, raise \ the threshold with OPENSHELL_BUILD_NO_PROGRESS_TIMEOUT_SECS=.", no_progress_timeout.as_secs() )); } }; let info = next .into_diagnostic() .wrap_err("Docker build stream error")?; // Forward build output lines. if let Some(stream_line) = &info.stream { let trimmed = stream_line.trim_end(); if !trimmed.is_empty() { on_log(trimmed.to_string()); } } // Check for build errors. if let Some(error_detail) = &info.error_detail { let msg = error_detail .message .as_deref() .unwrap_or("unknown build error"); return Err(miette::miette!("Docker build failed: {msg}")); } } Ok(()) } /// Create a tar archive of a directory for use as a Docker build context. /// /// Walks `context_dir` recursively, respects a `.dockerignore` file if present, /// and adds matching files with paths relative to the context root. fn create_build_context_tar(context_dir: &Path) -> Result> { let ignore_patterns = load_dockerignore(context_dir); let mut builder = tar::Builder::new(Vec::new()); // Walk the directory tree and add entries, skipping ignored paths. walk_and_add(context_dir, context_dir, &ignore_patterns, &mut builder)?; builder .into_inner() .into_diagnostic() .wrap_err("failed to finalize build context tar") } /// Recursively walk a directory and add entries to a tar archive, /// skipping paths that match `.dockerignore` patterns. fn walk_and_add( root: &Path, current: &Path, ignore_patterns: &[IgnorePattern], builder: &mut tar::Builder>, ) -> Result<()> { let entries = std::fs::read_dir(current) .into_diagnostic() .wrap_err_with(|| format!("failed to read directory: {}", current.display()))?; for entry in entries { let entry = entry .into_diagnostic() .wrap_err("failed to read directory entry")?; let path = entry.path(); let relative = path .strip_prefix(root) .unwrap_or(&path) .to_string_lossy() .to_string(); // Normalize to forward slashes for pattern matching. let relative_normalized = relative.replace('\\', "/"); if is_ignored(&relative_normalized, path.is_dir(), ignore_patterns) { continue; } if path.is_dir() { walk_and_add(root, &path, ignore_patterns, builder)?; } else { // Use append_path_with_name which handles GNU LongName extensions // for paths exceeding 100 bytes (the POSIX tar name field limit). builder .append_path_with_name(&path, &relative_normalized) .into_diagnostic() .wrap_err_with(|| format!("failed to add file to tar: {relative_normalized}"))?; } } Ok(()) } /// A parsed `.dockerignore` pattern. #[derive(Debug, Clone)] struct IgnorePattern { /// The glob pattern (may contain `*`, `**`, `?`). pattern: String, /// Whether this is a negation pattern (starts with `!`). negated: bool, } /// Load and parse a `.dockerignore` file from the context directory. /// /// Returns an empty list if no `.dockerignore` exists. fn load_dockerignore(context_dir: &Path) -> Vec { let dockerignore_path = context_dir.join(".dockerignore"); let Ok(contents) = std::fs::read_to_string(&dockerignore_path) else { return Vec::new(); }; contents .lines() .map(str::trim) .filter(|line| !line.is_empty() && !line.starts_with('#')) .map(|line| { line.strip_prefix('!').map_or_else( || IgnorePattern { pattern: line.to_string(), negated: false, }, |rest| IgnorePattern { pattern: rest.trim().to_string(), negated: true, }, ) }) .collect() } /// Check whether a relative path should be ignored based on `.dockerignore` patterns. /// /// Uses a simple glob-matching approach: patterns are matched against the /// full relative path. A leading `/` anchors to the context root. The last /// matching pattern wins (negation patterns re-include files). fn is_ignored(relative_path: &str, is_dir: bool, patterns: &[IgnorePattern]) -> bool { let mut ignored = false; for pat in patterns { let pattern = pat.pattern.trim_start_matches('/'); // Check if the pattern matches. let matches = glob_match(pattern, relative_path, is_dir); if matches { ignored = !pat.negated; } } ignored } /// Simple glob matching supporting `*`, `**`, and `?`. /// /// This is intentionally simple — it covers the common `.dockerignore` cases /// without pulling in a full glob crate. For complex patterns, Docker's own /// builder handles them during the build step anyway; this is just for /// reducing the context tar size. fn glob_match(pattern: &str, path: &str, is_dir: bool) -> bool { // Handle ** prefix (match any number of directories) if let Some(rest) = pattern.strip_prefix("**/") { // Match against the path itself and any suffix after a / if glob_match(rest, path, is_dir) { return true; } for (idx, _) in path.match_indices('/') { if glob_match(rest, &path[idx + 1..], is_dir) { return true; } } return false; } // Handle pattern that is just a name (no slashes) — match against any // path component or as a prefix directory match. if !pattern.contains('/') { // Match the final component of the path. let basename = path.rsplit('/').next().unwrap_or(path); if simple_glob_match(pattern, basename) { return true; } // Also match as a directory prefix: pattern "node_modules" should // match "node_modules/foo/bar". if let Some(first) = path.split('/').next() && simple_glob_match(pattern, first) { return true; } return false; } // Pattern contains slashes — match against the full path. simple_glob_match(pattern, path) || (is_dir && path.starts_with(pattern.trim_end_matches('/'))) } /// Match a simple glob pattern (with `*` and `?` but not `**`) against a string. fn simple_glob_match(pattern: &str, text: &str) -> bool { let mut p_star: Option = None; let mut t_star: Option = None; let p_bytes: Vec = pattern.chars().collect(); let t_bytes: Vec = text.chars().collect(); let mut pi = 0; let mut ti = 0; while ti < t_bytes.len() { if pi < p_bytes.len() && (p_bytes[pi] == '?' || p_bytes[pi] == t_bytes[ti]) { pi += 1; ti += 1; } else if pi < p_bytes.len() && p_bytes[pi] == '*' { p_star = Some(pi); t_star = Some(ti); pi += 1; } else if let Some(ps) = p_star { pi = ps + 1; t_star = Some(t_star.unwrap() + 1); ti = t_star.unwrap(); } else { return false; } } while pi < p_bytes.len() && p_bytes[pi] == '*' { pi += 1; } pi == p_bytes.len() } #[cfg(test)] mod tests { use super::*; use std::fs; #[test] fn test_create_build_context_tar() { let dir = tempfile::tempdir().unwrap(); let dir_path = dir.path(); // Create a simple Dockerfile and a file. fs::write(dir_path.join("Dockerfile"), "FROM ubuntu:24.04\n").unwrap(); fs::write(dir_path.join("hello.txt"), "hello world\n").unwrap(); fs::create_dir(dir_path.join("subdir")).unwrap(); fs::write(dir_path.join("subdir/nested.txt"), "nested\n").unwrap(); let tar_bytes = create_build_context_tar(dir_path).unwrap(); assert!(!tar_bytes.is_empty()); // Verify the tar contains the expected entries. let mut archive = tar::Archive::new(tar_bytes.as_slice()); let entries: Vec = archive .entries() .unwrap() .filter_map(std::result::Result::ok) .map(|e| e.path().unwrap().to_string_lossy().to_string()) .collect(); assert!(entries.iter().any(|e| e.contains("Dockerfile"))); assert!(entries.iter().any(|e| e.contains("hello.txt"))); assert!(entries.iter().any(|e| e.contains("subdir/nested.txt"))); } #[test] fn test_dockerignore_excludes_files() { let dir = tempfile::tempdir().unwrap(); let dir_path = dir.path(); fs::write(dir_path.join("Dockerfile"), "FROM ubuntu:24.04\n").unwrap(); fs::write(dir_path.join("hello.txt"), "hello\n").unwrap(); fs::write(dir_path.join("secret.env"), "SECRET=foo\n").unwrap(); fs::create_dir(dir_path.join("node_modules")).unwrap(); fs::write(dir_path.join("node_modules/pkg.js"), "module\n").unwrap(); fs::write(dir_path.join(".dockerignore"), "*.env\nnode_modules\n").unwrap(); let tar_bytes = create_build_context_tar(dir_path).unwrap(); let mut archive = tar::Archive::new(tar_bytes.as_slice()); let entries: Vec = archive .entries() .unwrap() .filter_map(std::result::Result::ok) .map(|e| e.path().unwrap().to_string_lossy().to_string()) .collect(); assert!(entries.iter().any(|e| e.contains("Dockerfile"))); assert!(entries.iter().any(|e| e.contains("hello.txt"))); assert!(!entries.iter().any(|e| e.contains("secret.env"))); assert!(!entries.iter().any(|e| e.contains("node_modules"))); } #[test] fn test_dockerignore_negation() { let dir = tempfile::tempdir().unwrap(); let dir_path = dir.path(); fs::write(dir_path.join("Dockerfile"), "FROM ubuntu:24.04\n").unwrap(); fs::write(dir_path.join("a.log"), "log\n").unwrap(); fs::write(dir_path.join("important.log"), "keep\n").unwrap(); fs::write(dir_path.join(".dockerignore"), "*.log\n!important.log\n").unwrap(); let tar_bytes = create_build_context_tar(dir_path).unwrap(); let mut archive = tar::Archive::new(tar_bytes.as_slice()); let entries: Vec = archive .entries() .unwrap() .filter_map(std::result::Result::ok) .map(|e| e.path().unwrap().to_string_lossy().to_string()) .collect(); assert!(!entries.iter().any(|e| e.contains("a.log"))); assert!(entries.iter().any(|e| e.contains("important.log"))); } #[test] fn test_long_path_exceeding_100_bytes() { let dir = tempfile::tempdir().unwrap(); let dir_path = dir.path(); // Build a nested path that exceeds 100 bytes when relative to root. let deep_dir = dir_path.join( "a/deeply/nested/directory/path/that/exceeds/one/hundred/bytes/total/from/the/build/context/root", ); fs::create_dir_all(&deep_dir).unwrap(); fs::write(deep_dir.join("file.txt"), "deep content\n").unwrap(); fs::write(dir_path.join("Dockerfile"), "FROM ubuntu:24.04\n").unwrap(); let tar_bytes = create_build_context_tar(dir_path).unwrap(); let mut archive = tar::Archive::new(tar_bytes.as_slice()); let entries: Vec = archive .entries() .unwrap() .filter_map(std::result::Result::ok) .map(|e| e.path().unwrap().to_string_lossy().to_string()) .collect(); let long_entry = entries.iter().find(|e| e.contains("file.txt")); assert!( long_entry.is_some(), "tar should contain deeply nested file; entries: {entries:?}" ); assert!( long_entry.unwrap().len() > 100, "path should exceed 100 bytes to exercise GNU LongName handling" ); } #[test] fn test_simple_glob_match() { assert!(simple_glob_match("*.txt", "hello.txt")); assert!(!simple_glob_match("*.txt", "hello.rs")); assert!(simple_glob_match("test?", "test1")); assert!(!simple_glob_match("test?", "test12")); assert!(simple_glob_match("*", "anything")); assert!(simple_glob_match("foo*bar", "fooXYZbar")); } #[test] fn test_glob_match_double_star() { assert!(glob_match("**/*.log", "a/b/c.log", false)); assert!(glob_match("**/*.log", "c.log", false)); assert!(!glob_match("**/*.log", "c.txt", false)); } #[test] fn test_is_ignored_directory_prefix() { let patterns = vec![IgnorePattern { pattern: "node_modules".to_string(), negated: false, }]; assert!(is_ignored("node_modules", true, &patterns)); assert!(is_ignored("node_modules/foo.js", false, &patterns)); } #[test] fn test_normalize_docker_platform_maps_docker_desktop_arch() { assert_eq!( normalize_docker_platform("linux", "aarch64").unwrap(), "linux/arm64" ); assert_eq!( normalize_docker_platform("linux", "x86_64").unwrap(), "linux/amd64" ); } #[test] fn test_normalize_docker_platform_case_insensitive_arch() { // Some remote or non-standard Docker daemons return mixed-case arch strings. assert_eq!( normalize_docker_platform("linux", "ARM64").unwrap(), "linux/arm64" ); assert_eq!( normalize_docker_platform("linux", "X86_64").unwrap(), "linux/amd64" ); } #[test] fn test_insert_implicit_platform_build_args_native() { // Native builds use one platform for both target and build args. let mut build_args = HashMap::new(); insert_implicit_platform_build_args(&mut build_args, "linux/arm/v7", "linux/arm/v7") .unwrap(); assert_eq!(build_args.get("TARGETPLATFORM").unwrap(), "linux/arm/v7"); assert_eq!(build_args.get("TARGETOS").unwrap(), "linux"); assert_eq!(build_args.get("TARGETARCH").unwrap(), "arm"); assert_eq!(build_args.get("TARGETVARIANT").unwrap(), "v7"); assert_eq!(build_args.get("BUILDPLATFORM").unwrap(), "linux/arm/v7"); assert_eq!(build_args.get("BUILDOS").unwrap(), "linux"); assert_eq!(build_args.get("BUILDARCH").unwrap(), "arm"); assert_eq!(build_args.get("BUILDVARIANT").unwrap(), "v7"); } #[test] fn test_insert_implicit_platform_build_args_cross_compile() { // Cross-builds keep daemon BUILD* args separate from target args. let mut build_args = HashMap::new(); insert_implicit_platform_build_args(&mut build_args, "linux/arm64", "linux/amd64").unwrap(); assert_eq!(build_args.get("TARGETPLATFORM").unwrap(), "linux/arm64"); assert_eq!(build_args.get("TARGETARCH").unwrap(), "arm64"); assert_eq!(build_args.get("BUILDPLATFORM").unwrap(), "linux/amd64"); assert_eq!(build_args.get("BUILDARCH").unwrap(), "amd64"); // Leaving variant args absent preserves Dockerfile ARG defaults. assert!(!build_args.contains_key("TARGETVARIANT")); assert!(!build_args.contains_key("BUILDVARIANT")); } #[test] fn test_insert_implicit_platform_build_args_trailing_slash_rejected() { let mut build_args = HashMap::new(); let result = insert_implicit_platform_build_args(&mut build_args, "linux/arm64/", "linux/amd64"); assert!( result.is_err(), "trailing slash should be rejected as a malformed variant segment" ); } }