mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-15 05:09:44 +08:00
Docker BuildKit exposes TARGET* for the requested image platform. It exposes BUILD* for the daemon running build stages. The bootstrap path did not pass an explicit platform or seed those args, so Dockerfile behavior could depend on daemon defaults. Set the build request platform from TARGETPLATFORM when supplied. Use the daemon platform as the fallback. Query the daemon for BUILD*. Seed implicit args without overwriting caller values. Do not inject empty variant args. Tests - mise run pre-commit Signed-off-by: Shiju <shiju@nvidia.com>
675 lines
24 KiB
Rust
675 lines
24 KiB
Rust
// 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<String> {
|
|
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<String> {
|
|
// 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<String, String>,
|
|
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 <Dockerfile>`. 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<String, String>,
|
|
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<String, String>,
|
|
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=<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<Vec<u8>> {
|
|
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<Vec<u8>>,
|
|
) -> 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<IgnorePattern> {
|
|
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<usize> = None;
|
|
let mut t_star: Option<usize> = None;
|
|
|
|
let p_bytes: Vec<char> = pattern.chars().collect();
|
|
let t_bytes: Vec<char> = 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<String> = 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<String> = 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<String> = 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<String> = 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"
|
|
);
|
|
}
|
|
}
|