diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index df4e983e5..399e7712e 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 rootfs tar 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 + /// 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 7b68487ea..c8dc4bddc 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -2009,22 +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::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 { @@ -2043,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, @@ -2533,17 +2546,23 @@ enum ResolvedSource { dockerfile: PathBuf, context: 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 or a Dockerfile that -/// needs building. +/// Classify the `--from` value into an image reference, a Dockerfile that +/// needs building, or a rootfs tar to pass to the VM driver. /// /// Resolution order: -/// 1. Existing file whose name contains "Dockerfile" → build from file. +/// 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); @@ -2564,9 +2583,17 @@ fn resolve_from(value: &str) -> Result { }); } + 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::RootfsTar { path: tar_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 rootfs tar (.tar/.tar.gz/.tgz): {}", path.display() )); } @@ -2605,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, a directory containing Dockerfile, 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() )); } @@ -2623,7 +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") +} + +#[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(); + lower.ends_with(".tar.gz") || lower.ends_with(".tar") || lower.ends_with(".tgz") } fn value_looks_like_local_source(value: &str) -> bool { @@ -2705,6 +2742,77 @@ async fn build_from_dockerfile( Ok(tag) } +/// 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 rootfs tar sources are only supported for local gateways; gateway '{}' is remote", + gateway_name + )); + } + + let info = client + .get_gateway_info(GetGatewayInfoRequest {}) + .await + .into_diagnostic() + .wrap_err("failed to query gateway compute driver")? + .into_inner(); + + let driver_name = info + .compute_drivers + .first() + .map(|d| d.name.as_str()) + .unwrap_or(""); + + 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!( + "Using rootfs tar {} for gateway '{}'", + tar_path.display().to_string().cyan(), + gateway_name, + ); + eprintln!(); + + 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. /// /// Resolution order: `--policy` flag > `OPENSHELL_SANDBOX_POLICY` env var. @@ -9541,8 +9649,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 +9675,101 @@ 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("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 RootfsTar source") + { + super::ResolvedSource::RootfsTar { path } => { + assert_eq!( + path, + archive + .canonicalize() + .expect("failed to canonicalize archive") + ); + } + 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("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 RootfsTar source") + { + super::ResolvedSource::RootfsTar { path } => { + assert_eq!( + path, + archive + .canonicalize() + .expect("failed to canonicalize archive") + ); } + 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("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 RootfsTar source") + { + super::ResolvedSource::RootfsTar { path } => { + assert_eq!( + path, + archive + .canonicalize() + .expect("failed to canonicalize archive") + ); + } + other => panic!("expected RootfsTar 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_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] fn dockerfile_sources_are_rejected_for_remote_gateways() { let metadata = GatewayMetadata { diff --git a/crates/openshell-driver-vm/src/driver.rs b/crates/openshell-driver-vm/src/driver.rs index 6c203f7a9..364fc0f29 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 05418f8f7..ad05a5b13 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -107,19 +107,22 @@ 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 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 ./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 and Dockerfiles require a local gateway because the CLI builds -through the local Docker daemon. Use a registry image reference for remote +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 e4d99dc45..d0bbf6f5d 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 = "rootfs_tar" +path = "tests/rootfs_tar.rs" +required-features = ["e2e-docker"] + [[test]] name = "docker_preflight" path = "tests/docker_preflight.rs" diff --git a/e2e/rust/tests/rootfs_tar.rs b/e2e/rust/tests/rootfs_tar.rs new file mode 100644 index 000000000..e3d303654 --- /dev/null +++ b/e2e/rust/tests/rootfs_tar.rs @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "e2e")] + +//! E2E test: create a sandbox from a flat rootfs tar archive. +//! +//! Prerequisites: +//! - 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; +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 "rootfs-tar-e2e-marker" > /etc/marker.txt + +CMD ["sleep", "infinity"] +"#; + +const MARKER: &str = "rootfs-tar-e2e-marker"; + +/// 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_rootfs_tar() { + 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-rootfs-tar-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: 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(["export", "-o"]) + .arg(&rootfs_tar_path) + .arg(&container_name) + .output() + .expect("spawn docker export"); + + assert!( + export_output.status.success(), + "docker export failed:\n{}", + String::from_utf8_lossy(&export_output.stderr) + ); + + // Clean up the temporary container and image. + let _ = engine.command().args(["rm", &container_name]).output(); + let _ = engine.command().args(["rmi", &tag]).output(); + + // 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 rootfs tar"); + + // Step 4: 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; +}