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
6 changes: 3 additions & 3 deletions .agents/skills/openshell-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -409,13 +409,13 @@ Review the proposed scope and prover findings before approval. Treat `rule appro

Build a custom container image and run it as a sandbox.

### Create a sandbox from a Dockerfile
### Create a sandbox from a build file

```bash
openshell sandbox create --from ./Dockerfile --name my-app
```

The `--from` flag accepts a Dockerfile path, a directory containing a Dockerfile, a full image reference such as `myregistry.com/img:tag`, or a community sandbox name such as `ollama`.
The `--from` flag accepts an existing container build file (for example `Dockerfile`, `Containerfile`, or another valid build file), a directory containing a `Dockerfile` or `Containerfile`, a full image reference such as `myregistry.com/image:tag`, or a community sandbox name such as `ollama`.

Local Dockerfile and directory builds require a local gateway because the CLI builds through the local Docker daemon. Use a registry image reference for remote gateways. Bare community names resolve under `ghcr.io/nvidia/openshell-community/sandboxes` unless `OPENSHELL_COMMUNITY_REGISTRY` overrides the prefix.

Expand Down Expand Up @@ -631,7 +631,7 @@ $ openshell sandbox upload --help
| Policy revision history | `openshell policy list <name>` |
| View global policy | `openshell policy get --global --full` |
| Review proposed rules | `openshell rule get <name> --status pending` |
| Create sandbox from Dockerfile | `openshell sandbox create --from ./Dockerfile` |
| Create sandbox from build file | `openshell sandbox create --from ./Dockerfile` |
| Forward a port | `openshell forward start <port> <name> -d` |
| Expose an HTTP service | `openshell service expose <name> <port> [service]` |
| Upload files to sandbox | `openshell sandbox upload <name> <path>` |
Expand Down
8 changes: 4 additions & 4 deletions crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1296,15 +1296,15 @@ 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 container build file (e.g., `Dockerfile`, `Containerfile`), a directory
/// containing one, 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.
/// When given a build file or directory, the image is built into the
/// local docker daemon before creating the sandbox.
#[arg(long, value_hint = ValueHint::AnyPath)]
from: Option<String>,

Expand Down
231 changes: 182 additions & 49 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2008,8 +2008,8 @@ pub async fn sandbox_create(
let effective_server = server.to_string();
let effective_tls = tls.clone();

// Resolve the --from flag into a container image reference, building from
// a Dockerfile first if necessary.
// Resolve the `--from` flag into a container image reference, building from
// a Dockerfile, Containerfile, or custom build file first if necessary.
let image: Option<String> = match from {
Some(val) => {
let resolved = resolve_from(val)?;
Expand Down Expand Up @@ -2535,61 +2535,59 @@ enum ResolvedSource {
},
}

/// Classify the `--from` value into an image reference or a Dockerfile that
/// needs building.
/// Classify the `--from` value into an image reference or a container build file that
/// needs building (Dockerfile, Containerfile, or custom build file).
///
/// Resolution order:
/// 1. Existing file whose name contains "Dockerfile" → build from file.
/// 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.
/// 1. Existing file -> build from file path.
/// 2. Existing directory containing `Dockerfile` or `Containerfile` -> 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.
fn resolve_from(value: &str) -> Result<ResolvedSource> {
let path = Path::new(value);

// 1. Existing file that looks like a Dockerfile.
// 1. Existing file — treat any valid file path as a container build file.
if path.is_file() {
if filename_looks_like_dockerfile(path) {
let dockerfile = path
.canonicalize()
.into_diagnostic()
.wrap_err_with(|| format!("failed to resolve path: {}", path.display()))?;
let context = dockerfile
.parent()
.ok_or_else(|| miette::miette!("Dockerfile has no parent directory"))?
.to_path_buf();
return Ok(ResolvedSource::Dockerfile {
dockerfile,
context,
});
}
let dockerfile = path
.canonicalize()
.into_diagnostic()
.wrap_err_with(|| format!("failed to resolve path: {}", path.display()))?;
let context = dockerfile
.parent()
.ok_or_else(|| miette::miette!("file has no parent directory"))?
.to_path_buf();
return Ok(ResolvedSource::Dockerfile {
dockerfile,
context,
});
}

// 2. Existing directory containing a Dockerfile or Containerfile.
if path.is_dir() {
let canonical_context = path
.canonicalize()
.into_diagnostic()
.wrap_err_with(|| format!("failed to resolve path: {}", path.display()))?;

if value_looks_like_local_source(value) {
let candidate_dockerfile = canonical_context.join("Dockerfile");
let candidate_containerfile = canonical_context.join("Containerfile");

let dockerfile = if candidate_dockerfile.is_file() {
candidate_dockerfile
} else if candidate_containerfile.is_file() {
candidate_containerfile
} else {
return Err(miette::miette!(
"local --from file is not a Dockerfile: {}",
"No Dockerfile or Containerfile found in directory: {}",
path.display()
));
}
}
};

// 2. Existing directory containing a Dockerfile.
if path.is_dir() {
let candidate = path.join("Dockerfile");
if candidate.is_file() {
let context = path
.canonicalize()
.into_diagnostic()
.wrap_err_with(|| format!("failed to resolve path: {}", path.display()))?;
let dockerfile = context.join("Dockerfile");
return Ok(ResolvedSource::Dockerfile {
dockerfile,
context,
});
}
return Err(miette::miette!(
"No Dockerfile found in directory: {}",
path.display()
));
return Ok(ResolvedSource::Dockerfile {
dockerfile,
context: canonical_context,
});
}

if path.exists() {
Expand All @@ -2604,8 +2602,7 @@ fn resolve_from(value: &str) -> Result<ResolvedSource> {
// Docker pull errors.
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.",
"local --from path does not exist: {}\nUse an existing Dockerfile/Containerfile, a directory containing Dockerfile/Containerfile, or a container image path.",
path.display()
));
}
Expand All @@ -2623,7 +2620,10 @@ 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")
|| lower.ends_with(".dockerfile")
|| lower.contains("containerfile")
|| lower.ends_with(".containerfile")
}

fn value_looks_like_local_source(value: &str) -> bool {
Expand Down Expand Up @@ -9561,6 +9561,139 @@ mod tests {
);
}

#[test]
fn resolve_from_accepts_containerfile_and_custom_files() {
let cwd = std::env::current_dir().expect("resolve current dir");

let temp = tempfile::Builder::new()
.prefix("openshell-relative-test-")
.tempdir_in(&cwd)
.expect("failed to create tempdir");
let temp_canonical = temp.path().canonicalize().unwrap();

// 1. Direct Containerfile test with exact path assertions
let containerfile = temp.path().join("Containerfile");
fs::write(&containerfile, "FROM alpine").unwrap();
let res = resolve_from(containerfile.to_str().unwrap()).unwrap();
if let super::ResolvedSource::Dockerfile {
dockerfile,
context,
} = res
{
assert_eq!(dockerfile, containerfile.canonicalize().unwrap());
assert_eq!(context, temp_canonical);
} else {
panic!("expected Dockerfile source for Containerfile");
}

// 2. Directory containing Containerfile fallback with exact path assertions
let dir_res = resolve_from(temp.path().to_str().unwrap()).unwrap();
if let super::ResolvedSource::Dockerfile {
dockerfile,
context,
} = dir_res
{
assert_eq!(dockerfile, containerfile.canonicalize().unwrap());
assert_eq!(context, temp_canonical);
} else {
panic!("expected Dockerfile source for directory with Containerfile");
}

// 3. Custom build file test with exact path assertions
let custom_file = temp.path().join("MyBuild.file");
fs::write(&custom_file, "FROM alpine").unwrap();
let custom_res = resolve_from(custom_file.to_str().unwrap()).unwrap();
if let super::ResolvedSource::Dockerfile {
dockerfile,
context,
} = custom_res
{
assert_eq!(dockerfile, custom_file.canonicalize().unwrap());
assert_eq!(context, temp_canonical);
} else {
panic!("expected Dockerfile source for custom build file");
}

// 4. Relative directory test (locks canonicalization fix)
let project = temp.path().join("project");
fs::create_dir(&project).expect("failed to create project directory");

let project_containerfile = project.join("Containerfile");
fs::write(&project_containerfile, "FROM alpine")
.expect("failed to write project Containerfile");
let relative_project = project
.strip_prefix(&cwd)
.expect("project should be under cwd");

match resolve_from(
relative_project
.to_str()
.expect("relative project path should be UTF-8"),
)
.expect("expected relative directory source")
{
super::ResolvedSource::Dockerfile {
dockerfile,
context,
} => {
assert_eq!(
dockerfile,
project_containerfile
.canonicalize()
.expect("failed to canonicalize project Containerfile")
);
assert_eq!(
context,
project
.canonicalize()
.expect("failed to canonicalize project directory")
);
}
super::ResolvedSource::Image(image) => {
panic!("expected Dockerfile source, got image {image}");
}
}
}

#[test]
fn resolve_from_directory_prefers_dockerfile_over_containerfile() {
let temp = tempfile::tempdir().expect("failed to create tempdir");

let dockerfile = temp.path().join("Dockerfile");
let containerfile = temp.path().join("Containerfile");

fs::write(&dockerfile, "FROM docker").expect("failed to write Dockerfile");

fs::write(&containerfile, "FROM container").expect("failed to write Containerfile");

let expected_context = temp
.path()
.canonicalize()
.expect("failed to canonicalize context");

match resolve_from(temp.path().to_str().expect("temp path is not UTF-8"))
.expect("expected Dockerfile source")
{
super::ResolvedSource::Dockerfile {
dockerfile: resolved,
context,
} => {
assert_eq!(
resolved,
dockerfile
.canonicalize()
.expect("failed to canonicalize Dockerfile")
);

assert_eq!(context, expected_context);
}

super::ResolvedSource::Image(image) => {
panic!("expected Dockerfile source, got image {image}");
}
}
}

#[test]
fn resolve_from_keeps_dockerfile_named_image_refs_as_images() {
let image_ref = "ghcr.io/acme/dockerfile-runner:latest";
Expand Down
2 changes: 1 addition & 1 deletion docs/sandboxes/manage-sandboxes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ 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
Local directories, Dockerfiles, Containerfiles, or custom build files require a local gateway because the CLI builds
through the local Docker daemon. Use a registry image reference for remote
gateways.

Expand Down
Loading