From 7ac9e30a68bd54ac8087c230ccd1d28b48be2289 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Mon, 20 Jul 2026 08:32:26 +0000 Subject: [PATCH 1/3] feat(cli): support Docker archive (.tar/.tar.gz/.tgz) as --from source Add support for loading pre-built Docker archives into the local daemon when creating sandboxes, enabling air-gapped and macOS VM-based build pipelines where a Docker archive is the natural hand-off artifact. Closes #2175 Signed-off-by: Philippe Martin Signed-off-by: Philippe Martin --- crates/openshell-bootstrap/src/build.rs | 81 ++++++++++- crates/openshell-cli/src/main.rs | 8 +- crates/openshell-cli/src/run.rs | 174 ++++++++++++++++++++++-- docs/sandboxes/manage-sandboxes.mdx | 9 +- 4 files changed, 252 insertions(+), 20 deletions(-) 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 df4e983e5b..44e73c1974 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1296,15 +1296,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 7b68487ea1..0ba1e84cc2 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -2022,6 +2022,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, @@ -2533,13 +2537,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. @@ -2564,9 +2571,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() )); } @@ -2605,7 +2620,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() )); } @@ -2626,6 +2641,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) } @@ -2705,6 +2733,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. @@ -9541,8 +9606,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:?}"); } } } @@ -9567,12 +9632,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 05418f8f7d..57ec4f48ff 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 From 1f9735a869ed2ae5b0a4c3f7e493fe7c83f3ed68 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Mon, 20 Jul 2026 08:44:30 +0000 Subject: [PATCH 2/3] test(e2e): add Docker archive sandbox create test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verify the full round-trip: docker build → docker save → openshell sandbox create --from archive.tar → marker file present in output. Signed-off-by: Philippe Martin Signed-off-by: Philippe Martin --- e2e/rust/Cargo.toml | 5 ++ e2e/rust/tests/docker_archive.rs | 100 +++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 e2e/rust/tests/docker_archive.rs diff --git a/e2e/rust/Cargo.toml b/e2e/rust/Cargo.toml index e4d99dc45c..5a4c048606 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; +} From 6d40ff26b1ea0e6cdd92b74a5e1664067d24fcb3 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Wed, 22 Jul 2026 10:03:29 +0000 Subject: [PATCH 3/3] refactor(cli): replace Docker archive loading with direct rootfs tar passthrough to VM driver Instead of loading Docker archives into the local daemon via `docker load`, tar archives passed via `--from` are now treated as flat rootfs tars and forwarded to the VM compute driver through `driver_config.rootfs_tar_path`. This removes the bollard `import_image` dependency and aligns the archive flow with the VM driver's native rootfs overlay mechanism. Signed-off-by: Philippe Martin Signed-off-by: Philippe Martin --- crates/openshell-bootstrap/src/build.rs | 81 +------- crates/openshell-cli/src/main.rs | 6 +- crates/openshell-cli/src/run.rs | 189 +++++++++++------- crates/openshell-driver-vm/src/driver.rs | 161 +++++++++++++-- docs/sandboxes/manage-sandboxes.mdx | 12 +- e2e/rust/Cargo.toml | 4 +- .../{docker_archive.rs => rootfs_tar.rs} | 66 +++--- 7 files changed, 321 insertions(+), 198 deletions(-) rename e2e/rust/tests/{docker_archive.rs => rootfs_tar.rs} (52%) diff --git a/crates/openshell-bootstrap/src/build.rs b/crates/openshell-bootstrap/src/build.rs index 3c5fbbe7e0..efa040c34a 100644 --- a/crates/openshell-bootstrap/src/build.rs +++ b/crates/openshell-bootstrap/src/build.rs @@ -1,19 +1,18 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Build and load container images for sandbox runtimes. +//! Build container images for sandbox runtimes. //! -//! 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. +//! 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, ImportImageOptionsBuilder}; +use bollard::query_parameters::BuildImageOptionsBuilder; use futures::StreamExt; use miette::{IntoDiagnostic, Result, WrapErr}; use tokio::time::timeout; @@ -162,76 +161,6 @@ 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 44e73c1974..399e7712e3 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1296,7 +1296,7 @@ enum SandboxCommands { name: Option, /// Sandbox source: a community sandbox name (e.g., `ollama`), a path - /// to a Dockerfile or directory containing one, a Docker archive + /// to a Dockerfile or directory containing one, a rootfs tar archive /// (`.tar`, `.tar.gz`, or `.tgz`), or a full container image reference /// (e.g., `myregistry.com/img:tag`). /// @@ -1305,8 +1305,8 @@ enum SandboxCommands { /// (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. When given a Docker - /// archive, the image is loaded into the local Docker daemon. + /// local Docker daemon before creating the sandbox. When given a + /// rootfs tar, it is passed directly to the VM compute driver. #[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 0ba1e84cc2..c8dc4bddc2 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -2009,26 +2009,26 @@ pub async fn sandbox_create( let effective_tls = tls.clone(); // Resolve the --from flag into a container image reference, building from - // a Dockerfile first if necessary. - let image: Option = match from { + // a Dockerfile first if necessary, or a rootfs tar path for the VM driver. + let (image, rootfs_tar_path): (Option, Option) = match from { Some(val) => { let resolved = resolve_from(val)?; match resolved { - ResolvedSource::Image(img) => Some(img), + ResolvedSource::Image(img) => (Some(img), None), ResolvedSource::Dockerfile { dockerfile, context, } => { let tag = build_from_dockerfile(&dockerfile, &context, gateway_name).await?; - Some(tag) + (Some(tag), None) } - ResolvedSource::DockerArchive { path } => { - let tag = load_from_docker_archive(&path, gateway_name).await?; - Some(tag) + ResolvedSource::RootfsTar { path } => { + validate_rootfs_tar_source(gateway_name, &mut client, &path).await?; + (None, Some(path)) } } } - None => None, + None => (None, None), }; let providers_v2_enabled = gateway_providers_v2_enabled(&mut client).await?; let inferred_types: Vec = if providers_v2_enabled { @@ -2047,11 +2047,20 @@ pub async fn sandbox_create( let policy = load_sandbox_policy(policy)?; let resource_limits = build_sandbox_resource_limits(cpu, memory)?; - let driver_config = driver_config_json + let mut driver_config = driver_config_json .map(parse_driver_config_json) .transpose()?; - let template = if image.is_some() || resource_limits.is_some() || driver_config.is_some() { + if let Some(tar_path) = &rootfs_tar_path { + let rootfs_config = rootfs_tar_driver_config(tar_path)?; + driver_config = Some(merge_driver_config(driver_config, rootfs_config)); + } + + let template = if image.is_some() + || resource_limits.is_some() + || driver_config.is_some() + || rootfs_tar_path.is_some() + { Some(SandboxTemplate { image: image.unwrap_or_default(), resources: resource_limits, @@ -2537,20 +2546,23 @@ enum ResolvedSource { dockerfile: PathBuf, context: PathBuf, }, - /// A Docker archive (`.tar` or `.tar.gz`) to load into the local daemon. - DockerArchive { path: PathBuf }, + /// A flat rootfs tar archive (`.tar`, `.tar.gz`, `.tgz`) to pass directly + /// to the VM compute driver. + RootfsTar { path: PathBuf }, } /// Classify the `--from` value into an image reference, a Dockerfile that -/// needs building, or a Docker archive that needs loading. +/// needs building, or a rootfs tar to pass to the VM driver. /// /// Resolution order: -/// 1. Existing file whose name contains "Dockerfile" → build from file. -/// 1b. Existing file with `.tar`/`.tar.gz`/`.tgz` extension → load archive. +/// 1. Existing file whose name contains "dockerfile" → build from Dockerfile. /// 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. -/// 5. Otherwise → community sandbox name, expanded via the registry prefix. +/// 3. Existing file with `.tar`, `.tar.gz`, or `.tgz` extension → rootfs tar archive. +/// 4. Other existing local paths → error. +/// 5. Non-existent path-like values (`./…`, `../…`, `/…`, `~/…`) → local +/// error, so they don't reach the gateway as broken image-pull requests. +/// 6. Value contains `/`, `:`, or `.` → treat as a full image reference. +/// 7. Otherwise → community sandbox name, expanded via the registry prefix. fn resolve_from(value: &str) -> Result { let path = Path::new(value); @@ -2571,17 +2583,17 @@ fn resolve_from(value: &str) -> Result { }); } - if filename_looks_like_docker_archive(path) { - let archive_path = path + if filename_looks_like_rootfs_tar(path) { + let tar_path = path .canonicalize() .into_diagnostic() .wrap_err_with(|| format!("failed to resolve path: {}", path.display()))?; - return Ok(ResolvedSource::DockerArchive { path: archive_path }); + return Ok(ResolvedSource::RootfsTar { path: tar_path }); } if value_looks_like_local_source(value) { return Err(miette::miette!( - "local --from file is not a Dockerfile or Docker archive (.tar/.tar.gz/.tgz): {}", + "local --from file is not a Dockerfile or rootfs tar (.tar/.tar.gz/.tgz): {}", path.display() )); } @@ -2620,7 +2632,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, directory containing Dockerfile, Docker archive (.tar/.tar.gz/.tgz), or a container image reference.", + Use an existing Dockerfile, directory containing Dockerfile, rootfs tar (.tar/.tar.gz/.tgz), or a container image reference.", path.display() )); } @@ -2638,20 +2650,17 @@ fn filename_looks_like_dockerfile(path: &Path) -> bool { .map(|n| n.to_string_lossy()) .unwrap_or_default(); let lower = name.to_lowercase(); - lower.contains("dockerfile") || lower.ends_with(".dockerfile") + lower.contains("dockerfile") } -fn filename_looks_like_docker_archive(path: &Path) -> bool { +#[allow(clippy::case_sensitive_file_extension_comparisons)] // already lowercased +fn filename_looks_like_rootfs_tar(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") + lower.ends_with(".tar.gz") || lower.ends_with(".tar") || lower.ends_with(".tgz") } fn value_looks_like_local_source(value: &str) -> bool { @@ -2733,41 +2742,75 @@ 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 { +/// Validate that a rootfs tar source is usable with the current gateway. +async fn validate_rootfs_tar_source( + gateway_name: &str, + client: &mut crate::tls::GrpcClient, + tar_path: &Path, +) -> 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", + "local rootfs tar 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 info = client + .get_gateway_info(GetGatewayInfoRequest {}) + .await + .into_diagnostic() + .wrap_err("failed to query gateway compute driver")? + .into_inner(); - let mut on_log = |msg: String| { - eprintln!(" {msg}"); - }; + let driver_name = info + .compute_drivers + .first() + .map(|d| d.name.as_str()) + .unwrap_or(""); - let tag = openshell_bootstrap::build::load_docker_archive(archive_path, &mut on_log).await?; + if driver_name != "vm" { + return Err(miette!( + "rootfs tar sources are only supported by the VM compute driver, \ + but gateway '{}' uses the '{}' driver", + gateway_name, + driver_name + )); + } - eprintln!(); eprintln!( - "{} Image {} is available in the local Docker daemon for gateway '{}'.", - "✓".green().bold(), - tag.cyan(), + "Using rootfs tar {} for gateway '{}'", + tar_path.display().to_string().cyan(), gateway_name, ); eprintln!(); - Ok(tag) + Ok(()) +} + +/// Build a `driver_config` struct carrying the rootfs tar path for the VM driver. +fn rootfs_tar_driver_config(tar_path: &Path) -> Result { + let fields = serde_json::Map::from_iter([( + "rootfs_tar_path".to_string(), + serde_json::Value::String(tar_path.to_string_lossy().into_owned()), + )]); + openshell_core::proto_struct::json_object_to_struct(fields) + .into_diagnostic() + .wrap_err("failed to encode rootfs_tar_path in driver_config") +} + +/// Merge a rootfs tar config into an existing `driver_config`, if any. +fn merge_driver_config( + base: Option, + overlay: prost_types::Struct, +) -> prost_types::Struct { + match base { + Some(mut base) => { + base.fields.extend(overlay.fields); + base + } + None => overlay, + } } /// Load sandbox policy YAML. @@ -9641,13 +9684,13 @@ mod tests { #[test] fn resolve_from_classifies_tar_archive() { let temp = tempfile::tempdir().expect("failed to create tempdir"); - let archive = temp.path().join("image.tar"); + let archive = temp.path().join("rootfs.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") + .expect("expected RootfsTar source") { - super::ResolvedSource::DockerArchive { path } => { + super::ResolvedSource::RootfsTar { path } => { assert_eq!( path, archive @@ -9655,20 +9698,20 @@ mod tests { .expect("failed to canonicalize archive") ); } - other => panic!("expected DockerArchive source, got {other:?}"), + other => panic!("expected RootfsTar 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"); + let archive = temp.path().join("rootfs.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") + .expect("expected RootfsTar source") { - super::ResolvedSource::DockerArchive { path } => { + super::ResolvedSource::RootfsTar { path } => { assert_eq!( path, archive @@ -9676,20 +9719,20 @@ mod tests { .expect("failed to canonicalize archive") ); } - other => panic!("expected DockerArchive source, got {other:?}"), + other => panic!("expected RootfsTar 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"); + let archive = temp.path().join("rootfs.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") + .expect("expected RootfsTar source") { - super::ResolvedSource::DockerArchive { path } => { + super::ResolvedSource::RootfsTar { path } => { assert_eq!( path, archive @@ -9697,7 +9740,7 @@ mod tests { .expect("failed to canonicalize archive") ); } - other => panic!("expected DockerArchive source, got {other:?}"), + other => panic!("expected RootfsTar source, got {other:?}"), } } @@ -9716,19 +9759,15 @@ mod tests { } #[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"))); + fn filename_looks_like_rootfs_tar_detects_extensions() { + use super::filename_looks_like_rootfs_tar; + assert!(filename_looks_like_rootfs_tar(Path::new("rootfs.tar"))); + assert!(filename_looks_like_rootfs_tar(Path::new("rootfs.tar.gz"))); + assert!(filename_looks_like_rootfs_tar(Path::new("rootfs.tgz"))); + assert!(filename_looks_like_rootfs_tar(Path::new("IMAGE.TAR"))); + assert!(filename_looks_like_rootfs_tar(Path::new("my-image.TAR.GZ"))); + assert!(!filename_looks_like_rootfs_tar(Path::new("Dockerfile"))); + assert!(!filename_looks_like_rootfs_tar(Path::new("image.zip"))); } #[test] diff --git a/crates/openshell-driver-vm/src/driver.rs b/crates/openshell-driver-vm/src/driver.rs index 6c203f7a9d..364fc0f291 100644 --- a/crates/openshell-driver-vm/src/driver.rs +++ b/crates/openshell-driver-vm/src/driver.rs @@ -90,6 +90,7 @@ struct VmSandboxDriverConfig { deserialize_with = "deserialize_optional_non_empty_string_list" )] gpu_device_ids: Option>, + rootfs_tar_path: Option, } impl VmSandboxDriverConfig { @@ -500,9 +501,11 @@ impl VmDriver { #[allow(clippy::result_large_err)] pub fn validate_sandbox(&self, sandbox: &Sandbox) -> Result<(), Status> { validate_vm_sandbox(sandbox, self.config.gpu_enabled)?; - if self.resolved_sandbox_image(sandbox).is_none() { + let has_rootfs_tar = + VmSandboxDriverConfig::from_sandbox(sandbox).is_ok_and(|c| c.rootfs_tar_path.is_some()); + if self.resolved_sandbox_image(sandbox).is_none() && !has_rootfs_tar { return Err(Status::failed_precondition( - "vm sandboxes require template.image or a configured default sandbox image", + "vm sandboxes require template.image, rootfs_tar_path in driver_config, or a configured default sandbox image", )); } Ok(()) @@ -520,11 +523,20 @@ impl VmDriver { validate_vm_sandbox(sandbox, self.config.gpu_enabled)?; let state_dir = sandbox_state_dir(&self.config.state_dir, &sandbox.id)?; - let image_ref = self.resolved_sandbox_image(sandbox).ok_or_else(|| { - Status::failed_precondition( - "vm sandboxes require template.image or a configured default sandbox image", - ) - })?; + let has_rootfs_tar = + VmSandboxDriverConfig::from_sandbox(sandbox).is_ok_and(|c| c.rootfs_tar_path.is_some()); + let image_ref = self + .resolved_sandbox_image(sandbox) + .or_else(|| { + has_rootfs_tar + .then(|| self.bootstrap_image_ref_default()) + .flatten() + }) + .ok_or_else(|| { + Status::failed_precondition( + "vm sandboxes require template.image, rootfs_tar_path in driver_config, or a configured default sandbox image", + ) + })?; info!( sandbox_id = %sandbox.id, image_ref = %image_ref, @@ -683,6 +695,10 @@ impl VmDriver { .and_then(|spec| spec.resource_requirements.as_ref()) .and_then(|requirements| driver_gpu_requirements(Some(requirements))) .is_some(); + let driver_config = + VmSandboxDriverConfig::from_sandbox(&sandbox).map_err(Status::invalid_argument)?; + let rootfs_tar_path = driver_config.rootfs_tar_path.map(PathBuf::from); + self.publish_platform_event( sandbox.id.clone(), platform_event( @@ -693,7 +709,9 @@ impl VmDriver { ), ); - let image_plan = self.prepare_runtime_images(&sandbox.id, &image_ref).await?; + let image_plan = self + .prepare_runtime_images(&sandbox.id, &image_ref, rootfs_tar_path.as_deref()) + .await?; let image_identity = image_plan.image_identity.clone(); self.ensure_provisioning_active(&sandbox.id).await?; info!( @@ -1220,7 +1238,14 @@ impl VmDriver { } async fn restore_persisted_sandbox(&self, sandbox: Sandbox, state_dir: PathBuf) { - let Some(image_ref) = self.resolved_sandbox_image(&sandbox) else { + let has_rootfs_tar = VmSandboxDriverConfig::from_sandbox(&sandbox) + .is_ok_and(|c| c.rootfs_tar_path.is_some()); + + let Some(image_ref) = self.resolved_sandbox_image(&sandbox).or_else(|| { + has_rootfs_tar + .then(|| self.bootstrap_image_ref_default()) + .flatten() + }) else { warn!( sandbox_id = %sandbox.id, sandbox_name = %sandbox.name, @@ -1689,6 +1714,7 @@ impl VmDriver { &self, sandbox_id: &str, image_ref: &str, + rootfs_tar_path: Option<&Path>, ) -> Result { let bootstrap_image_ref = self.bootstrap_image_ref(image_ref); let bootstrap_image_identity = self @@ -1696,6 +1722,18 @@ impl VmDriver { .await?; let root_disk = image_cache_rootfs_image(&self.config.state_dir, &bootstrap_image_identity); + if let Some(tar_path) = rootfs_tar_path { + let prepared = self + .ensure_prepared_rootfs_tar_disk(sandbox_id, tar_path, &root_disk) + .await?; + return Ok(RuntimeImagePlan { + root_disk, + image_disk: Some(prepared.disk_path), + image_identity: prepared.image_identity, + bootstrap_image_identity, + }); + } + if image_ref.trim() == bootstrap_image_ref.trim() { return Ok(RuntimeImagePlan { root_disk, @@ -1717,15 +1755,20 @@ impl VmDriver { } fn bootstrap_image_ref(&self, sandbox_image_ref: &str) -> String { + self.bootstrap_image_ref_default() + .unwrap_or_else(|| sandbox_image_ref.to_string()) + } + + fn bootstrap_image_ref_default(&self) -> Option { let configured = self.config.bootstrap_image.trim(); if !configured.is_empty() { - return configured.to_string(); + return Some(configured.to_string()); } let default = self.config.default_image.trim(); if !default.is_empty() { - return default.to_string(); + return Some(default.to_string()); } - sandbox_image_ref.to_string() + None } async fn prepare_runtime_overlay( @@ -2201,6 +2244,100 @@ impl VmDriver { }) } + async fn ensure_prepared_rootfs_tar_disk( + &self, + sandbox_id: &str, + tar_path: &Path, + bootstrap_root_disk: &Path, + ) -> Result { + let metadata = tokio::fs::metadata(tar_path).await.map_err(|err| { + Status::failed_precondition(format!( + "rootfs tar not accessible at {}: {err}", + tar_path.display() + )) + })?; + let mtime = metadata + .modified() + .unwrap_or(std::time::SystemTime::UNIX_EPOCH) + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let tar_identity = format!("rootfs-tar:{}:{mtime}", tar_path.display()); + let cache_identity = prepared_image_cache_identity(&tar_identity); + let image_path = image_cache_rootfs_image(&self.config.state_dir, &cache_identity); + let tar_display = tar_path.display().to_string(); + + if tokio::fs::metadata(&image_path).await.is_ok() { + self.publish_prepared_cache_hit( + sandbox_id, + &tar_display, + "rootfs_tar", + &cache_identity, + ); + return Ok(PreparedImageDisk { + image_identity: cache_identity, + disk_path: image_path, + }); + } + + self.publish_prepared_cache_miss(sandbox_id, &tar_display, "rootfs_tar", &cache_identity); + let _cache_guard = self.image_cache_lock.lock().await; + if tokio::fs::metadata(&image_path).await.is_ok() { + self.publish_prepared_cache_hit( + sandbox_id, + &tar_display, + "rootfs_tar", + &cache_identity, + ); + return Ok(PreparedImageDisk { + image_identity: cache_identity, + disk_path: image_path, + }); + } + + let staging_dir = image_cache_staging_dir(&self.config.state_dir, &cache_identity); + let rootfs_archive = staging_dir.join(IMAGE_EXPORT_ROOTFS_ARCHIVE); + self.reset_image_staging_dir(&staging_dir).await?; + + self.publish_vm_progress( + sandbox_id, + "CopyingRootfsTar", + format!("Copying rootfs tar \"{tar_display}\""), + HashMap::from([ + ("rootfs_tar_path".to_string(), tar_display.clone()), + ("image_source".to_string(), "rootfs_tar".to_string()), + ("image_identity".to_string(), cache_identity.clone()), + ]), + ); + if let Err(err) = tokio::fs::copy(tar_path, &rootfs_archive).await { + let _ = tokio::fs::remove_dir_all(&staging_dir).await; + return Err(Status::internal(format!( + "failed to copy rootfs tar to staging: {err}" + ))); + } + + let payload = GuestImagePayload { + image_ref: tar_display.clone(), + image_identity: cache_identity.clone(), + source: GuestImagePayloadSource::LocalDocker { rootfs_archive }, + }; + self.build_prepared_image_disk( + sandbox_id, + &tar_display, + "rootfs_tar", + &cache_identity, + bootstrap_root_disk, + &staging_dir, + &payload, + ) + .await?; + + Ok(PreparedImageDisk { + image_identity: cache_identity, + disk_path: image_path, + }) + } + async fn ensure_prepared_registry_image_disk( &self, sandbox_id: &str, diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index 57ec4f48ff..ad05a5b13b 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -107,21 +107,23 @@ openshell sandbox create \ ### Custom Containers -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: +Use `--from` to create a sandbox from the base image, another pre-built sandbox name, a local directory, a rootfs tar 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 ./rootfs.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, 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. +Local directories and Dockerfiles require a local gateway because the CLI +builds images through the local Docker daemon. Rootfs tar archives +(`.tar`, `.tar.gz`, `.tgz`) also require a local gateway and are passed +directly to the VM compute driver. Use a registry image reference for remote +gateways. ## Base Sandbox Container diff --git a/e2e/rust/Cargo.toml b/e2e/rust/Cargo.toml index 5a4c048606..d0bbf6f5de 100644 --- a/e2e/rust/Cargo.toml +++ b/e2e/rust/Cargo.toml @@ -38,8 +38,8 @@ path = "tests/custom_image.rs" required-features = ["e2e-docker"] [[test]] -name = "docker_archive" -path = "tests/docker_archive.rs" +name = "rootfs_tar" +path = "tests/rootfs_tar.rs" required-features = ["e2e-docker"] [[test]] diff --git a/e2e/rust/tests/docker_archive.rs b/e2e/rust/tests/rootfs_tar.rs similarity index 52% rename from e2e/rust/tests/docker_archive.rs rename to e2e/rust/tests/rootfs_tar.rs index fe2b60ef13..e3d303654c 100644 --- a/e2e/rust/tests/docker_archive.rs +++ b/e2e/rust/tests/rootfs_tar.rs @@ -3,11 +3,11 @@ #![cfg(feature = "e2e")] -//! E2E test: load a Docker archive (.tar) and run a sandbox with it. +//! E2E test: create a sandbox from a flat rootfs tar archive. //! //! Prerequisites: -//! - A running Docker-backed openshell gateway (`mise run gateway:docker`) -//! - Docker daemon running (for image build + save) +//! - A running VM-backed openshell gateway with a default sandbox image configured +//! - Docker daemon running (for image build + container export) //! - The `openshell` binary (built automatically from the workspace) use openshell_e2e::harness::container::ContainerEngine; @@ -24,17 +24,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends iproute2 \ RUN groupadd -g 1000660000 sandbox && \ useradd -m -u 1000660000 -g sandbox sandbox -RUN echo "docker-archive-e2e-marker" > /etc/marker.txt +RUN echo "rootfs-tar-e2e-marker" > /etc/marker.txt CMD ["sleep", "infinity"] "#; -const MARKER: &str = "docker-archive-e2e-marker"; +const MARKER: &str = "rootfs-tar-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. +/// Build a Docker image, export its filesystem as a flat rootfs tar, then +/// create a sandbox from that tar and verify it contains the expected marker. #[tokio::test] -async fn sandbox_from_docker_archive() { +async fn sandbox_from_rootfs_tar() { let engine = ContainerEngine::from_env().expect("container engine available"); let tmpdir = tempfile::tempdir().expect("create tmpdir"); @@ -43,7 +43,7 @@ async fn sandbox_from_docker_archive() { std::fs::write(&dockerfile_path, DOCKERFILE_CONTENT).expect("write Dockerfile"); let tag = format!( - "openshell/e2e-archive-test:{}", + "openshell/e2e-rootfs-tar-test:{}", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() @@ -64,32 +64,48 @@ async fn sandbox_from_docker_archive() { 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 + // Step 2: Create a temporary container and export its filesystem as a + // flat rootfs tar (equivalent to `docker export`). + let container_name = format!("openshell-e2e-rootfs-export-{}", std::process::id()); + + let create_output = engine + .command() + .args(["create", "--name", &container_name, &tag]) + .output() + .expect("spawn docker create"); + + assert!( + create_output.status.success(), + "docker create failed:\n{}", + String::from_utf8_lossy(&create_output.stderr) + ); + + let rootfs_tar_path = tmpdir.path().join("rootfs.tar"); + let export_output = engine .command() - .args(["save", "-o"]) - .arg(&archive_path) - .arg(&tag) + .args(["export", "-o"]) + .arg(&rootfs_tar_path) + .arg(&container_name) .output() - .expect("spawn docker save"); + .expect("spawn docker export"); assert!( - save_output.status.success(), - "docker save failed:\n{}", - String::from_utf8_lossy(&save_output.stderr) + export_output.status.success(), + "docker export failed:\n{}", + String::from_utf8_lossy(&export_output.stderr) ); - // Step 3: Remove the local image so the sandbox must load from the archive. + // Clean up the temporary container and image. + let _ = engine.command().args(["rm", &container_name]).output(); 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"]) + // Step 3: Create a sandbox from the rootfs tar. + let tar_str = rootfs_tar_path.to_str().expect("tar path is UTF-8"); + let mut guard = SandboxGuard::create(&["--from", tar_str, "--", "cat", "/etc/marker.txt"]) .await - .expect("sandbox create from Docker archive"); + .expect("sandbox create from rootfs tar"); - // Step 5: Verify the marker file content appears in the output. + // Step 4: Verify the marker file content appears in the output. let clean_output = strip_ansi(&guard.create_output); assert!( clean_output.contains(MARKER),