diff --git a/.agents/skills/openshell-cli/SKILL.md b/.agents/skills/openshell-cli/SKILL.md index a97b6a7af2..5e68a9fa88 100644 --- a/.agents/skills/openshell-cli/SKILL.md +++ b/.agents/skills/openshell-cli/SKILL.md @@ -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. @@ -631,7 +631,7 @@ $ openshell sandbox upload --help | Policy revision history | `openshell policy list ` | | View global policy | `openshell policy get --global --full` | | Review proposed rules | `openshell rule get --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 -d` | | Expose an HTTP service | `openshell service expose [service]` | | Upload files to sandbox | `openshell sandbox upload ` | diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index df4e983e5b..02eae81585 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1296,15 +1296,15 @@ 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 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/: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, diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 7b68487ea1..7c56497775 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -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 = match from { Some(val) => { let resolved = resolve_from(val)?; @@ -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 { 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() { @@ -2604,8 +2602,7 @@ fn resolve_from(value: &str) -> Result { // 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() )); } @@ -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 { @@ -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"; diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index 05418f8f7d..125d086606 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -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.