Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 76 additions & 5 deletions crates/openshell-bootstrap/src/build.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 <archive.tar>`. 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<String> {
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<String> = 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: <tag>" on success.
if let Some(tag) = trimmed.strip_prefix("Loaded image: ") {
loaded_tag = Some(tag.to_string());
}
// Docker responds with "Loaded image ID: sha256:<id>" 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
Expand Down
8 changes: 5 additions & 3 deletions crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1226,15 +1226,17 @@ enum SandboxCommands {
name: Option<String>,

/// 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/<name>: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<String>,

Expand Down
174 changes: 166 additions & 8 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Comment on lines 2532 to +2533

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Split into: A single heading like "Existing file" followed by two points indicating the different sources.

/// 2. Existing directory that contains a `Dockerfile` → build from directory.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Why are two "Dockerfile" cases now separated by a archive case? Should the explicit Dockerfile and Dockerfile parent folder cases not be treated the same way?

/// 3. Missing explicit local paths → local error, not image pull.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out-of-scope: What are "Missing explicit local paths"?

/// 4. Value contains `/`, `:`, or `.` → treat as a full image reference.
Expand All @@ -2548,9 +2555,17 @@ fn resolve_from(value: &str) -> Result<ResolvedSource> {
});
}

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()
));
}
Expand Down Expand Up @@ -2589,7 +2604,7 @@ fn resolve_from(value: &str) -> Result<ResolvedSource> {
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()
));
}
Expand All @@ -2610,6 +2625,19 @@ fn filename_looks_like_dockerfile(path: &Path) -> bool {
lower.contains("dockerfile") || lower.ends_with(".dockerfile")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of scope, but the || lower.ends_with(".dockerfile") is redundant since lower.contains("dockerfile")` will ALWAYS be true in that case.

}

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")
Comment on lines +2635 to +2638

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Why use ends_with in one case and `.extension for the other? Can we use the same logic for all cases?

Also we already have a path? Can't we call .extension() directly on that and then map the resultant string through to_lowercase()?

}

fn value_looks_like_local_source(value: &str) -> bool {
value_is_explicit_local_path(value) || value_looks_like_bare_dockerfile_name(value)
}
Expand Down Expand Up @@ -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<String> {
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.
Expand Down Expand Up @@ -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:?}");
}
}
}
Expand All @@ -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 {
Expand Down
9 changes: 5 additions & 4 deletions docs/sandboxes/manage-sandboxes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading