diff --git a/crates/openshell-bootstrap/src/build.rs b/crates/openshell-bootstrap/src/build.rs index efa040c34a..3c5fbbe7e0 100644 --- a/crates/openshell-bootstrap/src/build.rs +++ b/crates/openshell-bootstrap/src/build.rs @@ -1,18 +1,19 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Build container images for sandbox runtimes. +//! Build and load 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. +//! This module wraps bollard's `build_image()` and `import_image()` APIs to +//! build a container image from a Dockerfile or load one from a Docker archive. +//! 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 bollard::query_parameters::{BuildImageOptionsBuilder, ImportImageOptionsBuilder}; use futures::StreamExt; use miette::{IntoDiagnostic, Result, WrapErr}; use tokio::time::timeout; @@ -161,6 +162,76 @@ pub async fn build_local_image( Ok(()) } +/// Load a Docker archive into the local Docker daemon. +/// +/// This is used by `openshell sandbox create --from `. The image +/// is loaded via Docker's `POST /images/load` endpoint and the embedded tag +/// from the archive's manifest is returned. +/// +/// Supports plain `.tar` and gzip-compressed `.tar.gz`/`.tgz` archives. +pub async fn load_docker_archive( + archive_path: &Path, + on_log: &mut impl FnMut(String), +) -> Result { + on_log(format!("Loading Docker archive {}", archive_path.display())); + + let archive_bytes = std::fs::read(archive_path) + .into_diagnostic() + .wrap_err_with(|| format!("failed to read archive: {}", archive_path.display()))?; + + let docker = Docker::connect_with_local_defaults() + .into_diagnostic() + .wrap_err("failed to connect to local Docker daemon")?; + + let body = bollard::body_full(bytes::Bytes::from(archive_bytes)); + let options = ImportImageOptionsBuilder::default().quiet(false).build(); + + let mut stream = docker.import_image(options, body, None); + + let mut loaded_tag: Option = None; + while let Some(result) = stream.next().await { + let info = result + .into_diagnostic() + .wrap_err("Docker image load stream error")?; + + if let Some(stream_line) = &info.stream { + let trimmed = stream_line.trim(); + if !trimmed.is_empty() { + on_log(trimmed.to_string()); + } + // Docker responds with "Loaded image: " on success. + if let Some(tag) = trimmed.strip_prefix("Loaded image: ") { + loaded_tag = Some(tag.to_string()); + } + // Docker responds with "Loaded image ID: sha256:" for + // archives without a RepoTag. + if loaded_tag.is_none() + && let Some(id) = trimmed.strip_prefix("Loaded image ID: ") + { + loaded_tag = Some(id.to_string()); + } + } + + if let Some(status) = &info.status { + let trimmed = status.trim(); + if !trimmed.is_empty() { + on_log(trimmed.to_string()); + } + } + } + + let tag = loaded_tag.ok_or_else(|| { + miette::miette!( + "Docker loaded the archive but did not report an image tag; \ + the archive at {} may be empty or malformed", + archive_path.display() + ) + })?; + + on_log(format!("Loaded image {tag}")); + Ok(tag) +} + /// Build a container image using the local Docker daemon. /// /// Creates a tar archive of `context_dir`, sends it to Docker with the diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 38a4ace162..944a010dd2 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1226,15 +1226,17 @@ enum SandboxCommands { name: Option, /// Sandbox source: a community sandbox name (e.g., `ollama`), a path - /// to a Dockerfile or directory containing one, or a full container - /// image reference (e.g., `myregistry.com/img:tag`). + /// to a Dockerfile or directory containing one, a Docker archive + /// (`.tar`, `.tar.gz`, or `.tgz`), or a full container image reference + /// (e.g., `myregistry.com/img:tag`). /// /// Community names are resolved to /// `ghcr.io/nvidia/openshell-community/sandboxes/:latest` /// (override the prefix with `OPENSHELL_COMMUNITY_REGISTRY`). /// /// When given a Dockerfile or directory, the image is built into the - /// local Docker daemon before creating the sandbox. + /// local Docker daemon before creating the sandbox. When given a Docker + /// archive, the image is loaded into the local Docker daemon. #[arg(long, value_hint = ValueHint::AnyPath)] from: Option, diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index d9b2df10b5..261f6b036d 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -2020,6 +2020,10 @@ pub async fn sandbox_create( let tag = build_from_dockerfile(&dockerfile, &context, gateway_name).await?; Some(tag) } + ResolvedSource::DockerArchive { path } => { + let tag = load_from_docker_archive(&path, gateway_name).await?; + Some(tag) + } } } None => None, @@ -2517,13 +2521,16 @@ enum ResolvedSource { dockerfile: PathBuf, context: PathBuf, }, + /// A Docker archive (`.tar` or `.tar.gz`) to load into the local daemon. + DockerArchive { path: PathBuf }, } -/// Classify the `--from` value into an image reference or a Dockerfile that -/// needs building. +/// Classify the `--from` value into an image reference, a Dockerfile that +/// needs building, or a Docker archive that needs loading. /// /// Resolution order: /// 1. Existing file whose name contains "Dockerfile" → build from file. +/// 1b. Existing file with `.tar`/`.tar.gz`/`.tgz` extension → load archive. /// 2. Existing directory that contains a `Dockerfile` → build from directory. /// 3. Missing explicit local paths → local error, not image pull. /// 4. Value contains `/`, `:`, or `.` → treat as a full image reference. @@ -2548,9 +2555,17 @@ fn resolve_from(value: &str) -> Result { }); } + if filename_looks_like_docker_archive(path) { + let archive_path = path + .canonicalize() + .into_diagnostic() + .wrap_err_with(|| format!("failed to resolve path: {}", path.display()))?; + return Ok(ResolvedSource::DockerArchive { path: archive_path }); + } + if value_looks_like_local_source(value) { return Err(miette::miette!( - "local --from file is not a Dockerfile: {}", + "local --from file is not a Dockerfile or Docker archive (.tar/.tar.gz/.tgz): {}", path.display() )); } @@ -2589,7 +2604,7 @@ fn resolve_from(value: &str) -> Result { if value_looks_like_local_source(value) { return Err(miette::miette!( "local --from path does not exist: {}\n\ - Use an existing Dockerfile, a directory containing Dockerfile, or a container image reference.", + Use an existing Dockerfile, directory containing Dockerfile, Docker archive (.tar/.tar.gz/.tgz), or a container image reference.", path.display() )); } @@ -2610,6 +2625,19 @@ fn filename_looks_like_dockerfile(path: &Path) -> bool { lower.contains("dockerfile") || lower.ends_with(".dockerfile") } +fn filename_looks_like_docker_archive(path: &Path) -> bool { + let name = path + .file_name() + .map(|n| n.to_string_lossy()) + .unwrap_or_default(); + let lower = name.to_lowercase(); + // `.tar.gz` has extension `gz`, so check the compound suffix with ends_with. + lower.ends_with(".tar.gz") + || Path::new(&*lower) + .extension() + .is_some_and(|ext| ext == "tar" || ext == "tgz") +} + fn value_looks_like_local_source(value: &str) -> bool { value_is_explicit_local_path(value) || value_looks_like_bare_dockerfile_name(value) } @@ -2689,6 +2717,43 @@ async fn build_from_dockerfile( Ok(tag) } +/// Load a Docker archive and return the image tag. +/// +/// Local-gateway only — same constraint as Dockerfile sources. +async fn load_from_docker_archive(archive_path: &Path, gateway_name: &str) -> Result { + let metadata = get_gateway_metadata(gateway_name); + if !dockerfile_sources_supported_for_gateway(metadata.as_ref()) { + return Err(miette!( + "local Docker archive sources are only supported for local gateways; gateway '{}' is remote", + gateway_name + )); + } + + eprintln!( + "Loading Docker archive {} for gateway '{}'", + archive_path.display().to_string().cyan(), + gateway_name, + ); + eprintln!(); + + let mut on_log = |msg: String| { + eprintln!(" {msg}"); + }; + + let tag = openshell_bootstrap::build::load_docker_archive(archive_path, &mut on_log).await?; + + eprintln!(); + eprintln!( + "{} Image {} is available in the local Docker daemon for gateway '{}'.", + "✓".green().bold(), + tag.cyan(), + gateway_name, + ); + eprintln!(); + + Ok(tag) +} + /// Load sandbox policy YAML. /// /// Resolution order: `--policy` flag > `OPENSHELL_SANDBOX_POLICY` env var. @@ -8737,8 +8802,8 @@ mod tests { .expect("failed to canonicalize context") ); } - super::ResolvedSource::Image(image) => { - panic!("expected Dockerfile source, got image {image}"); + other => { + panic!("expected Dockerfile source, got {other:?}"); } } } @@ -8763,12 +8828,105 @@ mod tests { match resolve_from(image_ref).expect("expected image source") { super::ResolvedSource::Image(image) => assert_eq!(image, image_ref), - super::ResolvedSource::Dockerfile { .. } => { - panic!("expected image ref, got Dockerfile source"); + other => { + panic!("expected image ref, got {other:?}"); } } } + #[test] + fn resolve_from_classifies_tar_archive() { + let temp = tempfile::tempdir().expect("failed to create tempdir"); + let archive = temp.path().join("image.tar"); + fs::write(&archive, b"fake tar content").expect("failed to write archive"); + + match resolve_from(archive.to_str().expect("temp path is not UTF-8")) + .expect("expected DockerArchive source") + { + super::ResolvedSource::DockerArchive { path } => { + assert_eq!( + path, + archive + .canonicalize() + .expect("failed to canonicalize archive") + ); + } + other => panic!("expected DockerArchive source, got {other:?}"), + } + } + + #[test] + fn resolve_from_classifies_tar_gz_archive() { + let temp = tempfile::tempdir().expect("failed to create tempdir"); + let archive = temp.path().join("image.tar.gz"); + fs::write(&archive, b"fake tar.gz content").expect("failed to write archive"); + + match resolve_from(archive.to_str().expect("temp path is not UTF-8")) + .expect("expected DockerArchive source") + { + super::ResolvedSource::DockerArchive { path } => { + assert_eq!( + path, + archive + .canonicalize() + .expect("failed to canonicalize archive") + ); + } + other => panic!("expected DockerArchive source, got {other:?}"), + } + } + + #[test] + fn resolve_from_classifies_tgz_archive() { + let temp = tempfile::tempdir().expect("failed to create tempdir"); + let archive = temp.path().join("image.tgz"); + fs::write(&archive, b"fake tgz content").expect("failed to write archive"); + + match resolve_from(archive.to_str().expect("temp path is not UTF-8")) + .expect("expected DockerArchive source") + { + super::ResolvedSource::DockerArchive { path } => { + assert_eq!( + path, + archive + .canonicalize() + .expect("failed to canonicalize archive") + ); + } + other => panic!("expected DockerArchive source, got {other:?}"), + } + } + + #[test] + fn resolve_from_rejects_missing_tar_archive() { + let temp = tempfile::tempdir().expect("failed to create tempdir"); + let missing = temp.path().join("missing.tar"); + + let err = resolve_from(missing.to_str().expect("temp path is not UTF-8")) + .expect_err("expected missing archive to be rejected"); + + assert!( + err.to_string().contains("local --from path does not exist"), + "unexpected error: {err}" + ); + } + + #[test] + fn filename_looks_like_docker_archive_detects_extensions() { + use super::filename_looks_like_docker_archive; + assert!(filename_looks_like_docker_archive(Path::new("image.tar"))); + assert!(filename_looks_like_docker_archive(Path::new( + "image.tar.gz" + ))); + assert!(filename_looks_like_docker_archive(Path::new("image.tgz"))); + assert!(filename_looks_like_docker_archive(Path::new("IMAGE.TAR"))); + assert!(filename_looks_like_docker_archive(Path::new( + "my-image.TAR.GZ" + ))); + assert!(!filename_looks_like_docker_archive(Path::new("Dockerfile"))); + assert!(!filename_looks_like_docker_archive(Path::new("image.zip"))); + } + #[test] fn dockerfile_sources_are_rejected_for_remote_gateways() { let metadata = GatewayMetadata { diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index 86d4053978..309e7b468b 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -107,20 +107,21 @@ openshell sandbox create \ ### Custom Containers -Use `--from` to create a sandbox from the base image, another pre-built sandbox name, a local directory, or a container image: +Use `--from` to create a sandbox from the base image, another pre-built sandbox name, a local directory, a Docker archive, or a container image: ```shell openshell sandbox create --from base openshell sandbox create --from ollama openshell sandbox create --from ./my-sandbox-dir +openshell sandbox create --from ./output.tar openshell sandbox create --from my-registry.example.com/my-image:latest ``` Bare names such as `base` and `ollama` resolve to images under `ghcr.io/nvidia/openshell-community/sandboxes`. Set `OPENSHELL_COMMUNITY_REGISTRY` when you need to use an internal mirror. -Local directories and Dockerfiles require a local gateway because the CLI builds -through the local Docker daemon. Use a registry image reference for remote -gateways. +Local directories, Dockerfiles, and Docker archives (`.tar`, `.tar.gz`, `.tgz`) +require a local gateway because the CLI builds or loads images through the local +Docker daemon. Use a registry image reference for remote gateways. ## Base Sandbox Container diff --git a/e2e/rust/Cargo.toml b/e2e/rust/Cargo.toml index 6e8fa70bdf..e96c9dcc1f 100644 --- a/e2e/rust/Cargo.toml +++ b/e2e/rust/Cargo.toml @@ -37,6 +37,11 @@ name = "custom_image" path = "tests/custom_image.rs" required-features = ["e2e-docker"] +[[test]] +name = "docker_archive" +path = "tests/docker_archive.rs" +required-features = ["e2e-docker"] + [[test]] name = "docker_preflight" path = "tests/docker_preflight.rs" diff --git a/e2e/rust/tests/docker_archive.rs b/e2e/rust/tests/docker_archive.rs new file mode 100644 index 0000000000..fe2b60ef13 --- /dev/null +++ b/e2e/rust/tests/docker_archive.rs @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "e2e")] + +//! E2E test: load a Docker archive (.tar) and run a sandbox with it. +//! +//! Prerequisites: +//! - A running Docker-backed openshell gateway (`mise run gateway:docker`) +//! - Docker daemon running (for image build + save) +//! - The `openshell` binary (built automatically from the workspace) + +use openshell_e2e::harness::container::ContainerEngine; +use openshell_e2e::harness::output::strip_ansi; +use openshell_e2e::harness::sandbox::SandboxGuard; + +const DOCKERFILE_CONTENT: &str = r#"FROM public.ecr.aws/docker/library/python:3.13-slim + +# iproute2 is required for sandbox network namespace isolation. +RUN apt-get update && apt-get install -y --no-install-recommends iproute2 \ + && rm -rf /var/lib/apt/lists/* + +# Create the sandbox user/group so the supervisor can switch to it. +RUN groupadd -g 1000660000 sandbox && \ + useradd -m -u 1000660000 -g sandbox sandbox + +RUN echo "docker-archive-e2e-marker" > /etc/marker.txt + +CMD ["sleep", "infinity"] +"#; + +const MARKER: &str = "docker-archive-e2e-marker"; + +/// Build a Docker image, export it as a .tar archive, then create a sandbox +/// from that archive and verify it contains the expected marker file. +#[tokio::test] +async fn sandbox_from_docker_archive() { + let engine = ContainerEngine::from_env().expect("container engine available"); + let tmpdir = tempfile::tempdir().expect("create tmpdir"); + + // Step 1: Write a Dockerfile and build an image. + let dockerfile_path = tmpdir.path().join("Dockerfile"); + std::fs::write(&dockerfile_path, DOCKERFILE_CONTENT).expect("write Dockerfile"); + + let tag = format!( + "openshell/e2e-archive-test:{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + ); + + let build_output = engine + .command() + .args(["build", "-t", &tag, "-f"]) + .arg(&dockerfile_path) + .arg(tmpdir.path()) + .output() + .expect("spawn docker build"); + + assert!( + build_output.status.success(), + "docker build failed:\n{}", + String::from_utf8_lossy(&build_output.stderr) + ); + + // Step 2: Export the image to a .tar archive. + let archive_path = tmpdir.path().join("image.tar"); + let save_output = engine + .command() + .args(["save", "-o"]) + .arg(&archive_path) + .arg(&tag) + .output() + .expect("spawn docker save"); + + assert!( + save_output.status.success(), + "docker save failed:\n{}", + String::from_utf8_lossy(&save_output.stderr) + ); + + // Step 3: Remove the local image so the sandbox must load from the archive. + let _ = engine.command().args(["rmi", &tag]).output(); + + // Step 4: Create a sandbox from the Docker archive. + let archive_str = archive_path.to_str().expect("archive path is UTF-8"); + let mut guard = SandboxGuard::create(&["--from", archive_str, "--", "cat", "/etc/marker.txt"]) + .await + .expect("sandbox create from Docker archive"); + + // Step 5: Verify the marker file content appears in the output. + let clean_output = strip_ansi(&guard.create_output); + assert!( + clean_output.contains(MARKER), + "expected marker '{MARKER}' in sandbox output:\n{clean_output}" + ); + + guard.cleanup().await; +}