diff --git a/README.md b/README.md index 112bc3f..99b02da 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ OpenShell ships a set of [pre-built sandbox images](https://github.com/NVIDIA/OpenShell-Community), but they are general-purpose. `openshell-image-builder` lets you build your own: lightweight, workspace-specific images that contain only what you need — without writing a Containerfile by hand. -The tool assembles the image in layers — base image, agent installation, agent settings, OpenShell network policy, and project-specific toolchains. Use `--runtime` to select which container CLI drives the build (`podman`, `docker`, or the macOS `container` CLI): +The tool assembles the image in layers — base image, agent installation, agent settings, OpenShell network policy, project-specific toolchains, and image-mount init scripts. Use `--runtime` to select which container CLI drives the build (`podman`, `docker`, or the macOS `container` CLI): 1. **Base image** — Ubuntu, Fedora, Red Hat UBI, or Red Hat Hardened Images (HummingBird), any tag. Ubuntu 24.04 is the default. 2. **Agent installation** (`--agent`) — the agent binary is pre-installed in `PATH`. @@ -21,6 +21,7 @@ The tool assembles the image in layers — base image, agent installation, agent - **Inference network rules** — LLM backend endpoints are added by `--inference`. - **Workspace network rules** — user-defined hosts declared in `.kaiden/workspace.json` are added to the policy when `--with-workspace-config` is used. 5. **Installation of project-specific toolchains** — toolchains and utilities declared as Dev Container Features in `.kaiden/workspace.json` are installed in the image when `--with-workspace-config` is used. +6. **Image-mount init scripts** (`--image-mount`) — shell init snippets from images-to-mount YAML files are appended to `/sandbox/.bashrc` and `/sandbox/.zshrc`. ### workspace.json fields @@ -498,6 +499,53 @@ An invalid or unparseable host entry (e.g. a bare space or malformed URL) causes With this configuration, `cargo build` and `cargo fetch` inside the sandbox can download crate metadata and source tarballs. +## Image-mount init scripts + +Use `--image-mount` to bake the initialisation snippet from an [images-to-mount](https://github.com/feloy/images-to-mount) YAML file into the image's shell startup files. The flag can be repeated to process multiple YAML files. + +### YAML format + +```yaml +image: docker.io/curlimages/curl:latest # the image to mount — not used by this tool +init: export PATH=$MOUNT/usr/bin:$PATH # shell snippet added to .bashrc and .zshrc +``` + +The `image` field is present in the file but is ignored by `openshell-image-builder`. Only the `init` field is read. + +`$MOUNT` in the `init` value is replaced at build time with `/sandbox/mnt/`, where `` is the filename stem of the YAML file — for example, `curl.yaml` → `/sandbox/mnt/curl`. + +### What gets written + +The resolved `init` snippet (with `$MOUNT` replaced) is appended to: + +- `/sandbox/.bashrc` — sourced by interactive bash sessions +- `/sandbox/.zshrc` — sourced by interactive zsh sessions (created if it does not already exist) + +The append happens in the same `RUN` layer that creates the profile files, so both files are owned by the `sandbox` user. + +### Example + +```sh +# From a local YAML file — mount name derived from filename: "curl" +openshell-image-builder \ + --runtime podman \ + --image-mount /path/to/curl.yaml \ + myimage:latest + +# From a URL +openshell-image-builder \ + --runtime podman \ + --image-mount https://raw.githubusercontent.com/feloy/images-to-mount/main/curl.yaml \ + myimage:latest + +# Multiple mounts — the flag can be repeated +openshell-image-builder \ + --runtime podman \ + --image-mount /path/to/curl.yaml \ + --image-mount /path/to/jq.yaml \ + myimage:latest +``` + ## Full option reference ``` @@ -516,6 +564,7 @@ openshell-image-builder [OPTIONS] | `--with-workspace-config` | Read `.kaiden/workspace.json` and apply its features, skills, and network rules | | `--with-policy` | Include OpenShell sandbox policy (`/etc/openshell/policy.yaml`) in the image | | `--with-agent-settings` | Generate and include agent settings in the image (see [Agent settings](#agent-settings)) | +| `--image-mount ` | Append the `init` snippet from an images-to-mount YAML file to `.bashrc` and `.zshrc`; may be repeated (see [Image-mount init scripts](#image-mount-init-scripts)) | | `-v` / `-vv` | Increase log verbosity (info / debug) | ## Examples diff --git a/src/containerfile.rs b/src/containerfile.rs index 707cb12..a58669e 100644 --- a/src/containerfile.rs +++ b/src/containerfile.rs @@ -37,14 +37,21 @@ impl std::fmt::Display for ContainerfileError { impl std::error::Error for ContainerfileError {} +/// Options forwarded from the CLI that control the content of the generated Containerfile. +pub struct ContainerfileOptions<'a> { + pub agent: Option<&'a dyn Agent>, + pub features: &'a [StagedFeature], + pub with_agent_settings: bool, + pub skill_names: &'a [String], + pub env_vars: &'a HashMap, + pub with_policy: bool, + /// Shell init snippets, one per `--image-mount` YAML, with `$MOUNT` already resolved. + pub image_mount_inits: &'a [String], +} + pub fn generate( config: &Config, - agent: Option<&dyn Agent>, - features: &[StagedFeature], - with_agent_settings: bool, - skill_names: &[String], - env_vars: &HashMap, - with_policy: bool, + opts: &ContainerfileOptions<'_>, ) -> Result { let tag = &config.base_image.tag; let system_stage = match config.base_image.image.as_str() { @@ -104,12 +111,13 @@ pub fn generate( Ok(format!( "{system_stage}\n{}", final_stage( - agent, - features, - with_agent_settings, - skill_names, - env_vars, - with_policy + opts.agent, + opts.features, + opts.with_agent_settings, + opts.skill_names, + opts.env_vars, + opts.with_policy, + opts.image_mount_inits, ) )) } @@ -245,6 +253,17 @@ RUN groupadd -r supervisor && useradd -r -g supervisor -s /usr/sbin/nologin supe ) } +/// Escapes `init` for use as the format string in a shell `printf '...\n'` call. +/// +/// - Backslashes are doubled so printf does not interpret them as escape sequences. +/// - Real newline characters are replaced with `\n` for printf to output as newlines. +/// - Single quotes are escaped for the surrounding single-quoted shell string. +fn init_for_printf(init: &str) -> String { + init.replace('\\', "\\\\") + .replace('\n', "\\n") + .replace('\'', "'\\''") +} + fn final_stage( agent: Option<&dyn Agent>, features: &[StagedFeature], @@ -252,6 +271,7 @@ fn final_stage( skill_names: &[String], env_vars: &HashMap, with_policy: bool, + image_mount_inits: &[String], ) -> String { let agent_section = agent .map(|a| format!("{}\n\n", a.install())) @@ -280,13 +300,26 @@ fn final_stage( } else { "" }; + // Build the shell snippet that appends each image-mount init to .bashrc and .zshrc. + // Each init line is emitted as a printf call so that $VAR references in the init + // are written literally (single-quoted) and expanded at container runtime. + let mount_init_snippet: String = image_mount_inits + .iter() + .filter(|s| !s.trim().is_empty()) + .map(|init| { + let escaped = init_for_printf(init.trim()); + // Regular (non-raw) string: \\\n → backslash + newline (Dockerfile continuation) + // \\n → backslash-n two chars (printf newline escape) + format!(" && \\\n printf '{escaped}\\n' >> /sandbox/.bashrc && \\\n printf '{escaped}\\n' >> /sandbox/.zshrc") + }) + .collect(); format!( r#"# Final base image FROM system AS final {features_section}{policy_section}RUN printf 'export PS1="\\u@\\h:\\w\\$ "\n' \ > /sandbox/.bashrc && \ - printf '[ -f ~/.bashrc ] && . ~/.bashrc\n' > /sandbox/.profile && \ + printf '[ -f ~/.bashrc ] && . ~/.bashrc\n' > /sandbox/.profile{mount_init_snippet} && \ chown sandbox:sandbox /sandbox/.bashrc /sandbox/.profile && \ chown -R sandbox:sandbox /sandbox @@ -314,12 +347,15 @@ mod tests { ) -> Result { generate( config, - agent, - features, - with_agent_settings, - skill_names, - &HashMap::new(), - with_policy, + &ContainerfileOptions { + agent, + features, + with_agent_settings, + skill_names, + env_vars: &HashMap::new(), + with_policy, + image_mount_inits: &[], + }, ) } @@ -985,8 +1021,19 @@ mod tests { "ANTHROPIC_BASE_URL".to_string(), "https://proxy.example.com".to_string(), ); - let content = - generate(&ubuntu_config("24.04"), None, &[], false, &[], &vars, false).unwrap(); + let content = generate( + &ubuntu_config("24.04"), + &ContainerfileOptions { + agent: None, + features: &[], + with_agent_settings: false, + skill_names: &[], + env_vars: &vars, + with_policy: false, + image_mount_inits: &[], + }, + ) + .unwrap(); assert!(content.contains("ENV ANTHROPIC_BASE_URL=\"https://proxy.example.com\"")); } @@ -997,8 +1044,19 @@ mod tests { "ANTHROPIC_BASE_URL".to_string(), "https://proxy.example.com".to_string(), ); - let content = - generate(&ubuntu_config("24.04"), None, &[], false, &[], &vars, false).unwrap(); + let content = generate( + &ubuntu_config("24.04"), + &ContainerfileOptions { + agent: None, + features: &[], + with_agent_settings: false, + skill_names: &[], + env_vars: &vars, + with_policy: false, + image_mount_inits: &[], + }, + ) + .unwrap(); let user_pos = content.find("USER sandbox").unwrap(); let env_pos = content.find("ENV ANTHROPIC_BASE_URL=").unwrap(); assert!( @@ -1019,10 +1077,193 @@ mod tests { let mut vars = HashMap::new(); vars.insert("Z_VAR".to_string(), "z".to_string()); vars.insert("A_VAR".to_string(), "a".to_string()); - let content = - generate(&ubuntu_config("24.04"), None, &[], false, &[], &vars, false).unwrap(); + let content = generate( + &ubuntu_config("24.04"), + &ContainerfileOptions { + agent: None, + features: &[], + with_agent_settings: false, + skill_names: &[], + env_vars: &vars, + with_policy: false, + image_mount_inits: &[], + }, + ) + .unwrap(); let a_pos = content.find("ENV A_VAR=").unwrap(); let z_pos = content.find("ENV Z_VAR=").unwrap(); assert!(a_pos < z_pos, "env vars must be sorted alphabetically"); } + + // image_mount_inits + + #[test] + fn image_mount_init_appended_to_bashrc() { + let inits = vec!["export PATH=/sandbox/mnt/curl/usr/bin:$PATH".to_string()]; + let content = generate( + &ubuntu_config("24.04"), + &ContainerfileOptions { + agent: None, + features: &[], + with_agent_settings: false, + skill_names: &[], + env_vars: &HashMap::new(), + with_policy: false, + image_mount_inits: &inits, + }, + ) + .unwrap(); + assert!(content.contains( + "printf 'export PATH=/sandbox/mnt/curl/usr/bin:$PATH\\n' >> /sandbox/.bashrc" + )); + } + + #[test] + fn image_mount_init_appended_to_zshrc() { + let inits = vec!["export PATH=/sandbox/mnt/curl/usr/bin:$PATH".to_string()]; + let content = generate( + &ubuntu_config("24.04"), + &ContainerfileOptions { + agent: None, + features: &[], + with_agent_settings: false, + skill_names: &[], + env_vars: &HashMap::new(), + with_policy: false, + image_mount_inits: &inits, + }, + ) + .unwrap(); + assert!(content.contains( + "printf 'export PATH=/sandbox/mnt/curl/usr/bin:$PATH\\n' >> /sandbox/.zshrc" + )); + } + + #[test] + fn image_mount_init_appears_before_chown() { + let inits = vec!["export PATH=/sandbox/mnt/curl/usr/bin:$PATH".to_string()]; + let content = generate( + &ubuntu_config("24.04"), + &ContainerfileOptions { + agent: None, + features: &[], + with_agent_settings: false, + skill_names: &[], + env_vars: &HashMap::new(), + with_policy: false, + image_mount_inits: &inits, + }, + ) + .unwrap(); + let init_pos = content + .find("printf 'export PATH=/sandbox/mnt/curl/usr/bin:$PATH\\n' >> /sandbox/.bashrc") + .unwrap(); + let chown_pos = content + .find("chown sandbox:sandbox /sandbox/.bashrc") + .unwrap(); + assert!( + init_pos < chown_pos, + "image-mount init printf must appear before chown" + ); + } + + #[test] + fn image_mount_init_appears_after_profile_creation() { + let inits = vec!["export PATH=/sandbox/mnt/curl/usr/bin:$PATH".to_string()]; + let content = generate( + &ubuntu_config("24.04"), + &ContainerfileOptions { + agent: None, + features: &[], + with_agent_settings: false, + skill_names: &[], + env_vars: &HashMap::new(), + with_policy: false, + image_mount_inits: &inits, + }, + ) + .unwrap(); + let profile_pos = content.find("> /sandbox/.profile").unwrap(); + let init_pos = content.find(">> /sandbox/.bashrc").unwrap(); + assert!( + init_pos > profile_pos, + "image-mount init printf must appear after profile creation" + ); + } + + #[test] + fn no_image_mount_produces_no_zshrc_line() { + let content = build_cf(&ubuntu_config("24.04"), None, &[], false, &[], false).unwrap(); + assert!( + !content.contains(".zshrc"), + "no .zshrc line expected when no --image-mount is given" + ); + } + + #[test] + fn multiple_image_mounts_each_produce_bashrc_and_zshrc_lines() { + let inits = vec![ + "export PATH=/sandbox/mnt/curl/usr/bin:$PATH".to_string(), + "export PATH=/sandbox/mnt/jq/usr/bin:$PATH".to_string(), + ]; + let content = generate( + &ubuntu_config("24.04"), + &ContainerfileOptions { + agent: None, + features: &[], + with_agent_settings: false, + skill_names: &[], + env_vars: &HashMap::new(), + with_policy: false, + image_mount_inits: &inits, + }, + ) + .unwrap(); + assert!(content.contains("export PATH=/sandbox/mnt/curl/usr/bin:$PATH")); + assert!(content.contains("export PATH=/sandbox/mnt/jq/usr/bin:$PATH")); + // Both must appear twice (once for .bashrc, once for .zshrc) + assert_eq!( + content + .matches("export PATH=/sandbox/mnt/curl/usr/bin:$PATH") + .count(), + 2, + "curl init must appear twice (bashrc + zshrc)" + ); + assert_eq!( + content + .matches("export PATH=/sandbox/mnt/jq/usr/bin:$PATH") + .count(), + 2, + "jq init must appear twice (bashrc + zshrc)" + ); + } + + #[test] + fn image_mount_init_single_quote_escaped() { + let inits = vec!["export X='hello'".to_string()]; + let content = generate( + &ubuntu_config("24.04"), + &ContainerfileOptions { + agent: None, + features: &[], + with_agent_settings: false, + skill_names: &[], + env_vars: &HashMap::new(), + with_policy: false, + image_mount_inits: &inits, + }, + ) + .unwrap(); + // Single quotes inside a single-quoted shell string are escaped as '\'' + assert!( + content.contains("export X='\\''hello'\\''"), + "single quotes in init must be escaped for shell" + ); + } + + #[test] + fn empty_image_mount_inits_produces_no_zshrc() { + let content = build_cf(&ubuntu_config("24.04"), None, &[], false, &[], false).unwrap(); + assert!(!content.contains(".zshrc")); + } } diff --git a/src/image_mount.rs b/src/image_mount.rs new file mode 100644 index 0000000..e00c31d --- /dev/null +++ b/src/image_mount.rs @@ -0,0 +1,199 @@ +// Copyright (C) 2026 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +use std::path::Path; + +use serde::Deserialize; + +/// The structure of the images-to-mount YAML file. +/// The `image` field is present in the file but is not used by this tool. +#[derive(Deserialize)] +struct ImageMountFile { + #[allow(dead_code)] + image: Option, + init: Option, +} + +/// Derives the mount name from a local path or URL. +/// +/// The name is the last path component with any `.yaml` or `.yml` extension +/// stripped. For example `/some/path/curl.yaml` → `"curl"`. +pub fn mount_name(path_or_url: &str) -> Option { + // For URLs (which always use '/'), extract the last '/'-delimited segment. + // For local filesystem paths, delegate to std::path::Path so that the + // OS-native separator (e.g. '\' on Windows) is handled correctly. + let last = if path_or_url.starts_with("http://") || path_or_url.starts_with("https://") { + path_or_url.rsplit('/').next()?.to_string() + } else { + Path::new(path_or_url) + .file_name()? + .to_string_lossy() + .into_owned() + }; + let stem = last + .strip_suffix(".yaml") + .or_else(|| last.strip_suffix(".yml")) + .unwrap_or(&last) + .to_string(); + if stem.is_empty() { None } else { Some(stem) } +} + +fn load_yaml_content(path_or_url: &str) -> Result> { + if path_or_url.starts_with("http://") || path_or_url.starts_with("https://") { + Ok(ureq::get(path_or_url).call()?.into_string()?) + } else { + Ok(std::fs::read_to_string(path_or_url)?) + } +} + +/// Loads an images-to-mount YAML file (from a local path or URL) and returns +/// the `init` value with every `$MOUNT` placeholder replaced by +/// `/sandbox/mnt/`, where `` is the file stem of `path_or_url`. +pub fn load_init(path_or_url: &str) -> Result> { + let name = mount_name(path_or_url).ok_or_else(|| { + format!("--image-mount: cannot determine mount name from '{path_or_url}'") + })?; + let content = load_yaml_content(path_or_url)?; + let file: ImageMountFile = serde_yml::from_str(&content) + .map_err(|e| format!("--image-mount: invalid YAML in '{path_or_url}': {e}"))?; + let raw_init = file.init.unwrap_or_default(); + let mount = format!("/sandbox/mnt/{name}"); + Ok(raw_init.trim_end().replace("$MOUNT", &mount)) +} + +#[cfg(test)] +mod tests { + use super::*; + + // mount_name + + #[test] + fn mount_name_strips_yaml_extension_from_filename() { + assert_eq!(mount_name("curl.yaml"), Some("curl".to_string())); + } + + #[test] + fn mount_name_strips_yml_extension_from_filename() { + assert_eq!(mount_name("curl.yml"), Some("curl".to_string())); + } + + #[test] + fn mount_name_strips_extension_from_absolute_path() { + assert_eq!( + mount_name("/some/path/to/curl.yaml"), + Some("curl".to_string()) + ); + } + + #[test] + fn mount_name_strips_extension_from_url() { + assert_eq!( + mount_name("https://example.com/curl.yaml"), + Some("curl".to_string()) + ); + } + + #[test] + fn mount_name_strips_extension_from_github_raw_url() { + assert_eq!( + mount_name("https://raw.githubusercontent.com/feloy/images-to-mount/main/curl.yaml"), + Some("curl".to_string()) + ); + } + + #[test] + fn mount_name_returns_stem_without_extension() { + assert_eq!(mount_name("my-tool"), Some("my-tool".to_string())); + } + + #[test] + fn mount_name_returns_none_for_empty_stem() { + assert_eq!(mount_name(".yaml"), None); + } + + // load_init + + #[test] + fn load_init_replaces_mount_placeholder() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("curl.yaml"); + std::fs::write( + &path, + "image: docker.io/curlimages/curl:latest\ninit: export PATH=$MOUNT/usr/bin:$PATH\n", + ) + .unwrap(); + let init = load_init(path.to_str().unwrap()).unwrap(); + assert_eq!(init, "export PATH=/sandbox/mnt/curl/usr/bin:$PATH"); + } + + #[test] + fn load_init_uses_filename_stem_as_mount_name() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("my-tool.yaml"); + std::fs::write(&path, "image: example\ninit: source $MOUNT/init.sh\n").unwrap(); + let init = load_init(path.to_str().unwrap()).unwrap(); + assert_eq!(init, "source /sandbox/mnt/my-tool/init.sh"); + } + + #[test] + fn load_init_trims_trailing_whitespace() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("tool.yaml"); + std::fs::write(&path, "image: example\ninit: \"export X=$MOUNT \"\n").unwrap(); + let init = load_init(path.to_str().unwrap()).unwrap(); + assert_eq!(init, "export X=/sandbox/mnt/tool"); + } + + #[test] + fn load_init_handles_missing_init_field() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("tool.yaml"); + std::fs::write(&path, "image: example\n").unwrap(); + let init = load_init(path.to_str().unwrap()).unwrap(); + assert_eq!(init, ""); + } + + #[test] + fn load_init_handles_multiline_init() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("tool.yaml"); + std::fs::write( + &path, + "image: example\ninit: |\n export PATH=$MOUNT/bin:$PATH\n export LD_LIBRARY_PATH=$MOUNT/lib:$LD_LIBRARY_PATH\n", + ) + .unwrap(); + let init = load_init(path.to_str().unwrap()).unwrap(); + assert_eq!( + init, + "export PATH=/sandbox/mnt/tool/bin:$PATH\nexport LD_LIBRARY_PATH=/sandbox/mnt/tool/lib:$LD_LIBRARY_PATH" + ); + } + + #[test] + fn load_init_fails_on_missing_file() { + let result = load_init("/nonexistent/path/tool.yaml"); + assert!(result.is_err()); + } + + #[test] + fn load_init_fails_on_invalid_yaml() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("tool.yaml"); + std::fs::write(&path, "not: valid: yaml: [[[").unwrap(); + let result = load_init(path.to_str().unwrap()); + assert!(result.is_err()); + } +} diff --git a/src/main.rs b/src/main.rs index 89c0e44..858cddd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,6 +19,7 @@ mod config; mod containerfile; mod feature; mod host; +mod image_mount; mod inference; mod policy; mod workspace; @@ -100,6 +101,12 @@ struct Cli { with_policy: bool, #[arg(long, help = "Generate and include agent settings in the image")] with_agent_settings: bool, + #[arg( + long, + action = clap::ArgAction::Append, + help = "Path or URL to a YAML file describing a container image to mount (may be repeated)" + )] + image_mount: Vec, } fn main() { @@ -129,6 +136,10 @@ fn main() { cli.model.as_deref(), cli.with_policy, cli.with_agent_settings, + &cli.image_mount + .iter() + .map(|s| s.as_str()) + .collect::>(), &container_cli, &ContainerRunner, ) { @@ -148,6 +159,7 @@ fn run( model: Option<&str>, with_policy: bool, with_agent_settings: bool, + image_mounts: &[&str], runtime: &ContainerCli, runner: &dyn Runner, ) -> Result<(), Box> { @@ -208,14 +220,21 @@ fn run( )?; std::fs::write(context_dir.path().join("policy.yaml"), policy_yaml)?; } + let image_mount_inits: Vec = image_mounts + .iter() + .map(|path_or_url| image_mount::load_init(path_or_url)) + .collect::>()?; let output = containerfile::generate( &config, - agent.as_deref(), - &features, - has_agent_settings, - &skill_names, - &agent_env_vars, - with_policy, + &containerfile::ContainerfileOptions { + agent: agent.as_deref(), + features: &features, + with_agent_settings: has_agent_settings, + skill_names: &skill_names, + env_vars: &agent_env_vars, + with_policy, + image_mount_inits: &image_mount_inits, + }, )?; build(&output, tag, runtime, runner, context_dir.path())?; Ok(()) @@ -863,6 +882,7 @@ mod tests { None, false, false, + &[], &ContainerCli::Podman, &FakeRunner(0), ); @@ -882,6 +902,7 @@ mod tests { None, false, false, + &[], &ContainerCli::Podman, &FakeRunner(0), ); @@ -901,6 +922,7 @@ mod tests { None, false, false, + &[], &ContainerCli::Podman, &FakeRunner(0), ); @@ -920,6 +942,7 @@ mod tests { None, false, false, + &[], &ContainerCli::Podman, &FakeRunner(0), ); @@ -945,6 +968,7 @@ mod tests { None, false, false, + &[], &ContainerCli::Podman, &FakeRunner(1), ); @@ -964,6 +988,7 @@ mod tests { None, false, false, + &[], &ContainerCli::Podman, &FakeRunner(0), ); @@ -989,6 +1014,7 @@ mod tests { Some("claude-opus-4-5"), false, false, + &[], &ContainerCli::Podman, &FakeRunner(0), ); @@ -1150,6 +1176,7 @@ mod tests { None, true, false, + &[], &ContainerCli::Podman, &FakeRunner(0), ); @@ -1169,6 +1196,7 @@ mod tests { None, false, true, + &[], &ContainerCli::Podman, &FakeRunner(0), ); @@ -1188,6 +1216,7 @@ mod tests { None, false, false, + &[], &ContainerCli::Podman, &FakeRunner(0), ); @@ -1200,6 +1229,86 @@ mod tests { ); } + #[test] + fn run_with_image_mount_from_file_succeeds() { + let yaml_dir = tempfile::tempdir().unwrap(); + let yaml_path = yaml_dir.path().join("curl.yaml"); + std::fs::write( + &yaml_path, + "image: docker.io/curlimages/curl:latest\ninit: export PATH=$MOUNT/usr/bin:$PATH\n", + ) + .unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let result = run( + "test:latest", + Some(tmp.path().to_path_buf()), + false, + None, + None, + None, + None, + false, + false, + &[yaml_path.to_str().unwrap()], + &ContainerCli::Podman, + &FakeRunner(0), + ); + assert!(result.is_ok(), "expected Ok, got {result:?}"); + } + + #[test] + fn run_with_multiple_image_mounts_succeeds() { + let yaml_dir = tempfile::tempdir().unwrap(); + let curl_path = yaml_dir.path().join("curl.yaml"); + let jq_path = yaml_dir.path().join("jq.yaml"); + std::fs::write( + &curl_path, + "image: docker.io/curlimages/curl:latest\ninit: export PATH=$MOUNT/usr/bin:$PATH\n", + ) + .unwrap(); + std::fs::write( + &jq_path, + "image: ghcr.io/jqlang/jq:latest\ninit: export PATH=$MOUNT/bin:$PATH\n", + ) + .unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let result = run( + "test:latest", + Some(tmp.path().to_path_buf()), + false, + None, + None, + None, + None, + false, + false, + &[curl_path.to_str().unwrap(), jq_path.to_str().unwrap()], + &ContainerCli::Podman, + &FakeRunner(0), + ); + assert!(result.is_ok(), "expected Ok, got {result:?}"); + } + + #[test] + fn run_with_image_mount_invalid_path_returns_error() { + let tmp = tempfile::tempdir().unwrap(); + let result = run( + "test:latest", + Some(tmp.path().to_path_buf()), + false, + None, + None, + None, + None, + false, + false, + &["/nonexistent/path/tool.yaml"], + &ContainerCli::Podman, + &FakeRunner(0), + ); + assert!(result.is_err(), "expected Err for missing image-mount file"); + } + #[test] fn stage_agent_settings_with_openai_and_model_creates_opencode_config() { let context = tempfile::tempdir().unwrap(); diff --git a/tests/integration_test.rs b/tests/integration_test.rs index f9111d6..9e9d94f 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -115,6 +115,7 @@ static HUMMINGBIRD_OPENCODE_OPENAI_IMAGE: OnceLock = OnceLock::new(); static UBUNTU_OPENCODE_OPENAI_MODEL_IMAGE: OnceLock = OnceLock::new(); static UBUNTU_NO_POLICY_IMAGE: OnceLock = OnceLock::new(); static UBUNTU_CLAUDE_NO_AGENT_SETTINGS_IMAGE: OnceLock = OnceLock::new(); +static UBUNTU_IMAGE_MOUNT_IMAGE: OnceLock = OnceLock::new(); fn config_dir_with_agent_settings(agent: &str, files: &[(&str, &str)]) -> tempfile::TempDir { let dir = tempfile::tempdir().unwrap(); @@ -1330,6 +1331,25 @@ fn ubuntu_claude_no_agent_settings_image() -> &'static str { }) } +fn ubuntu_image_mount_image() -> &'static str { + UBUNTU_IMAGE_MOUNT_IMAGE.get_or_init(|| { + // Write a temporary YAML file with a known name so the mount name is predictable. + let dir = tempfile::tempdir().unwrap(); + let yaml_path = dir.path().join("curl.yaml"); + std::fs::write( + &yaml_path, + "image: docker.io/curlimages/curl:latest\ninit: export PATH=$MOUNT/usr/bin:$PATH\n", + ) + .unwrap(); + let tag = build_image( + "openshell-test-ubuntu-image-mount:integration", + &["--image-mount", yaml_path.to_str().unwrap()], + ); + // `dir` is dropped here; the image is already built so the file is no longer needed. + tag + }) +} + // --------------------------------------------------------------------------- // Feature integration tests — one macro per feature, instantiated per base image // --------------------------------------------------------------------------- @@ -2649,6 +2669,95 @@ mod with_agent_settings { } } +// --------------------------------------------------------------------------- +// --image-mount flag tests +// --------------------------------------------------------------------------- + +mod image_mount { + use super::*; + + #[test] + #[ignore] + fn bashrc_contains_init_with_resolved_mount_path() { + let out = run_in_image( + ubuntu_image_mount_image(), + "grep -q 'export PATH=/sandbox/mnt/curl/usr/bin' /sandbox/.bashrc", + ); + assert!( + out.status.success(), + "resolved init not found in /sandbox/.bashrc" + ); + } + + #[test] + #[ignore] + fn zshrc_contains_init_with_resolved_mount_path() { + let out = run_in_image( + ubuntu_image_mount_image(), + "grep -q 'export PATH=/sandbox/mnt/curl/usr/bin' /sandbox/.zshrc", + ); + assert!( + out.status.success(), + "resolved init not found in /sandbox/.zshrc" + ); + } + + #[test] + #[ignore] + fn bashrc_owned_by_sandbox() { + let out = run_in_image(ubuntu_image_mount_image(), "stat -c '%U' /sandbox/.bashrc"); + assert!(out.status.success(), "failed to stat /sandbox/.bashrc"); + assert_eq!( + String::from_utf8_lossy(&out.stdout).trim(), + "sandbox", + "/sandbox/.bashrc not owned by sandbox" + ); + } + + #[test] + #[ignore] + fn zshrc_owned_by_sandbox() { + let out = run_in_image(ubuntu_image_mount_image(), "stat -c '%U' /sandbox/.zshrc"); + assert!(out.status.success(), "failed to stat /sandbox/.zshrc"); + assert_eq!( + String::from_utf8_lossy(&out.stdout).trim(), + "sandbox", + "/sandbox/.zshrc not owned by sandbox" + ); + } + + #[test] + #[ignore] + fn base_image_without_flag_has_no_zshrc() { + // The base ubuntu image (built without --image-mount) must not have .zshrc. + let out = run_in_image(ubuntu_image(), "test -f /sandbox/.zshrc"); + assert!( + !out.status.success(), + "/sandbox/.zshrc should not exist in an image built without --image-mount" + ); + } + + // Rejection test: invalid path — does not call podman, so no #[ignore]. + #[test] + fn invalid_path_exits_nonzero() { + let binary = env!("CARGO_BIN_EXE_openshell-image-builder"); + let output = Command::new(binary) + .args([ + "--runtime", + "podman", + "--image-mount", + "/nonexistent/path/tool.yaml", + "should-not-be-built:test", + ]) + .output() + .expect("binary should run"); + assert!( + !output.status.success(), + "--image-mount with nonexistent file must exit non-zero" + ); + } +} + // --------------------------------------------------------------------------- // Cleanup — runs when the test process exits, after all tests complete // --------------------------------------------------------------------------- @@ -2711,6 +2820,7 @@ fn cleanup_images() { "openshell-test-no-workspace-config-network-hosts-ubuntu:integration", "openshell-test-ubuntu-no-policy:integration", "openshell-test-ubuntu-claude-no-agent-settings:integration", + "openshell-test-ubuntu-image-mount:integration", ] { Command::new("podman") .args(["rmi", "--force", tag])