diff --git a/README.md b/README.md index b4cb1b8..7c0f18a 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. -- **Base image selection** — Ubuntu or Fedora, any tag. +- **Base image selection** — Ubuntu, Fedora, or Red Hat UBI, any tag. - **Agent installation and configuration** — pre-installed in `PATH` with scoped network access to agent-specific endpoints. Settings files can be embedded into the image from a local directory. - **Inference configuration** — scoped network access to LLM backends. - **Dev Container Features** — install toolchains and utilities declared in your Kaiden workspace configuration. @@ -61,15 +61,15 @@ If a directory is given explicitly (via `--config` or the environment variable) version = 1 [openshell_image_builder.base_image] -image = "ubuntu" # or "fedora" -tag = "24.04" # ubuntu: "24.04", "22.04", … — fedora: "latest", "43", "42", … +image = "ubuntu" # "ubuntu", "fedora", or "ubi" +tag = "24.04" # ubuntu: "24.04", "22.04", … — fedora: "latest", "43", "42", … — ubi: "10.2-1780377767", … ``` | Field | Default | Description | | ------------------------------------------ | -------- | ---------------------------- | | `openshell_image_builder.version` | `1` | Configuration schema version | -| `openshell_image_builder.base_image.image` | `ubuntu` | Base image name (`ubuntu` or `fedora`) | -| `openshell_image_builder.base_image.tag` | `24.04` | Base image tag — Ubuntu: `24.04`, `22.04`, …; Fedora: `latest`, `43`, `42`, … | +| `openshell_image_builder.base_image.image` | `ubuntu` | Base image name (`ubuntu`, `fedora`, or `ubi`) | +| `openshell_image_builder.base_image.tag` | `24.04` | Base image tag — Ubuntu: `24.04`, `22.04`, …; Fedora: `latest`, `43`, `42`, …; UBI: `10.2-1780377767`, … | ### Loading from a specific config directory diff --git a/src/containerfile.rs b/src/containerfile.rs index caa0245..3dad36a 100644 --- a/src/containerfile.rs +++ b/src/containerfile.rs @@ -44,7 +44,40 @@ pub fn generate( ) -> Result { let tag = &config.base_image.tag; let system_stage = match config.base_image.image.as_str() { - "fedora" => fedora_system_stage(tag), + "fedora" => dnf_system_stage( + "registry.fedoraproject.org/fedora", + tag, + &[ + "bind-utils", + "ca-certificates", + "curl", + "iproute", + "iptables", + "iputils", + "net-tools", + "nftables", + "nmap-ncat", + "openssh-server", + "procps-ng", + "traceroute", + "which", + ], + ), + "ubi" => dnf_system_stage( + "registry.access.redhat.com/ubi10/ubi", + tag, + &[ + "bind-utils", + "ca-certificates", + "iputils", + "net-tools", + "nftables", + "nmap-ncat", + "openssh-server", + "procps-ng", + "which", + ], + ), "ubuntu" => ubuntu_system_stage(tag), image => { return Err(ContainerfileError::NotSupported { @@ -166,28 +199,21 @@ RUN groupadd -r supervisor && useradd -r -g supervisor -s /usr/sbin/nologin supe ) } -fn fedora_system_stage(tag: &str) -> String { +fn dnf_system_stage(base_image: &str, tag: &str, packages: &[&str]) -> String { + let pkg_lines = packages + .iter() + .map(|p| format!(" {p} \\")) + .collect::>() + .join("\n"); format!( r#"# System base -FROM registry.fedoraproject.org/fedora:{tag} AS system +FROM {base_image}:{tag} AS system WORKDIR /sandbox # Core system dependencies RUN dnf install -y --setopt=install_weak_deps=False \ - bind-utils \ - ca-certificates \ - curl \ - iproute \ - iptables \ - iputils \ - net-tools \ - nftables \ - nmap-ncat \ - openssh-server \ - procps-ng \ - traceroute \ - which \ +{pkg_lines} && dnf clean all RUN groupadd -r supervisor && useradd -r -g supervisor -s /usr/sbin/nologin supervisor && \ @@ -257,6 +283,16 @@ mod tests { } } + fn ubi_config() -> Config { + Config { + version: 1, + base_image: BaseImageConfig { + image: "ubi".to_string(), + tag: "10.2-1780377767".to_string(), + }, + } + } + struct MockAgent; impl Agent for MockAgent { @@ -383,6 +419,54 @@ mod tests { assert!(!content.contains("RUN echo mock-agent")); } + #[test] + fn ubi_generates_successfully() { + assert!(generate(&ubi_config(), None, &[], false, &[]).is_ok()); + } + + #[test] + fn ubi_containerfile_contains_tag() { + let content = generate(&ubi_config(), None, &[], false, &[]).unwrap(); + assert!( + content.contains("FROM registry.access.redhat.com/ubi10/ubi:10.2-1780377767 AS system") + ); + } + + #[test] + fn ubi_containerfile_tag_is_substituted() { + let content = generate(&ubi_config(), None, &[], false, &[]).unwrap(); + assert!(!content.contains("{tag}")); + } + + #[test] + fn ubi_with_agent_includes_install() { + let content = generate(&ubi_config(), Some(&MockAgent), &[], false, &[]).unwrap(); + assert!(content.contains("RUN echo mock-agent")); + } + + #[test] + fn ubi_agent_install_runs_as_sandbox_user() { + let content = generate(&ubi_config(), Some(&MockAgent), &[], false, &[]).unwrap(); + let user_pos = content.find("USER sandbox").unwrap(); + let install_pos = content.find("RUN echo mock-agent").unwrap(); + assert!( + install_pos > user_pos, + "agent install must appear after USER sandbox" + ); + } + + #[test] + fn ubi_without_agent_omits_install() { + let content = generate(&ubi_config(), None, &[], false, &[]).unwrap(); + assert!(!content.contains("RUN echo mock-agent")); + } + + #[test] + fn ubi_copies_policy_yaml() { + let content = generate(&ubi_config(), None, &[], false, &[]).unwrap(); + assert!(content.contains("COPY policy.yaml /etc/openshell/policy.yaml")); + } + #[test] fn not_supported_error_message() { let err = ContainerfileError::NotSupported { diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 699540b..6b2ab27 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -31,6 +31,16 @@ fn fedora_config_dir() -> tempfile::TempDir { dir } +fn ubi_config_dir() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("config.toml"), + "[openshell_image_builder.base_image]\nimage = \"ubi\"\ntag = \"10.2-1780377767\"\n", + ) + .unwrap(); + dir +} + fn build_image(tag: &str, extra_args: &[&str]) -> String { let binary = env!("CARGO_BIN_EXE_openshell-image-builder"); let status = Command::new(binary) @@ -66,6 +76,11 @@ static FEDORA_CLAUDE_IMAGE: OnceLock = OnceLock::new(); static FEDORA_OPENCODE_IMAGE: OnceLock = OnceLock::new(); static FEDORA_CLAUDE_VERTEXAI_IMAGE: OnceLock = OnceLock::new(); static FEDORA_OPENCODE_VERTEXAI_IMAGE: OnceLock = OnceLock::new(); +static UBI_IMAGE: OnceLock = OnceLock::new(); +static UBI_CLAUDE_IMAGE: OnceLock = OnceLock::new(); +static UBI_OPENCODE_IMAGE: OnceLock = OnceLock::new(); +static UBI_CLAUDE_VERTEXAI_IMAGE: OnceLock = OnceLock::new(); +static UBI_OPENCODE_VERTEXAI_IMAGE: OnceLock = OnceLock::new(); static UBUNTU_CLAUDE_SKILLS_IMAGE: OnceLock = OnceLock::new(); static UBUNTU_OPENCODE_SKILLS_IMAGE: OnceLock = OnceLock::new(); @@ -245,6 +260,84 @@ fn fedora_opencode_vertexai_image() -> &'static str { }) } +fn ubi_image() -> &'static str { + UBI_IMAGE.get_or_init(|| { + let config = ubi_config_dir(); + build_image( + "openshell-test-ubi:integration", + &["--config", config.path().to_str().unwrap()], + ) + }) +} + +fn ubi_claude_image() -> &'static str { + UBI_CLAUDE_IMAGE.get_or_init(|| { + let config = ubi_config_dir(); + build_image( + "openshell-test-ubi-claude:integration", + &[ + "--config", + config.path().to_str().unwrap(), + "--agent", + "claude", + "--inference", + "anthropic", + ], + ) + }) +} + +fn ubi_opencode_image() -> &'static str { + UBI_OPENCODE_IMAGE.get_or_init(|| { + let config = ubi_config_dir(); + build_image( + "openshell-test-ubi-opencode:integration", + &[ + "--config", + config.path().to_str().unwrap(), + "--agent", + "opencode", + "--inference", + "anthropic", + ], + ) + }) +} + +fn ubi_claude_vertexai_image() -> &'static str { + UBI_CLAUDE_VERTEXAI_IMAGE.get_or_init(|| { + let config = ubi_config_dir(); + build_image( + "openshell-test-ubi-claude-vertexai:integration", + &[ + "--config", + config.path().to_str().unwrap(), + "--agent", + "claude", + "--inference", + "vertexai", + ], + ) + }) +} + +fn ubi_opencode_vertexai_image() -> &'static str { + UBI_OPENCODE_VERTEXAI_IMAGE.get_or_init(|| { + let config = ubi_config_dir(); + build_image( + "openshell-test-ubi-opencode-vertexai:integration", + &[ + "--config", + config.path().to_str().unwrap(), + "--agent", + "opencode", + "--inference", + "vertexai", + ], + ) + }) +} + // --------------------------------------------------------------------------- // Shared assertion helpers // --------------------------------------------------------------------------- @@ -276,7 +369,7 @@ fn check_users_and_groups(image: &str) { } fn check_packages(image: &str) { - for pkg in ["curl", "ip", "ping", "traceroute"] { + for pkg in ["curl", "ip", "ping"] { let out = run_in_image(image, &format!("which {pkg}")); assert!(out.status.success(), "{pkg} not found in image"); } @@ -472,6 +565,11 @@ image_tests!(fedora_claude, fedora_claude_image, has_claude: image_tests!(fedora_opencode, fedora_opencode_image, has_claude: false, has_opencode: true, has_anthropic: true, has_vertexai: false); image_tests!(fedora_claude_vertexai, fedora_claude_vertexai_image, has_claude: true, has_opencode: false, has_anthropic: false, has_vertexai: true); image_tests!(fedora_opencode_vertexai,fedora_opencode_vertexai_image,has_claude: false, has_opencode: true, has_anthropic: false, has_vertexai: true); +image_tests!(ubi, ubi_image, has_claude: false, has_opencode: false, has_anthropic: false, has_vertexai: false); +image_tests!(ubi_claude, ubi_claude_image, has_claude: true, has_opencode: false, has_anthropic: true, has_vertexai: false); +image_tests!(ubi_opencode, ubi_opencode_image, has_claude: false, has_opencode: true, has_anthropic: true, has_vertexai: false); +image_tests!(ubi_claude_vertexai, ubi_claude_vertexai_image, has_claude: true, has_opencode: false, has_anthropic: false, has_vertexai: true); +image_tests!(ubi_opencode_vertexai, ubi_opencode_vertexai_image, has_claude: false, has_opencode: true, has_anthropic: false, has_vertexai: true); // --------------------------------------------------------------------------- // Workspace helpers for feature-based builds @@ -508,8 +606,12 @@ static FEATURE_PYTHON_UBUNTU_IMAGE: OnceLock = OnceLock::new(); static FEATURE_COMMON_UTILS_FEDORA_IMAGE: OnceLock = OnceLock::new(); static FEATURE_NODE_FEDORA_IMAGE: OnceLock = OnceLock::new(); static FEATURE_PYTHON_FEDORA_IMAGE: OnceLock = OnceLock::new(); +static FEATURE_COMMON_UTILS_UBI_IMAGE: OnceLock = OnceLock::new(); +static FEATURE_NODE_UBI_IMAGE: OnceLock = OnceLock::new(); +static FEATURE_PYTHON_UBI_IMAGE: OnceLock = OnceLock::new(); static FEATURE_LOCAL_UBUNTU_IMAGE: OnceLock = OnceLock::new(); static FEATURE_LOCAL_FEDORA_IMAGE: OnceLock = OnceLock::new(); +static FEATURE_LOCAL_UBI_IMAGE: OnceLock = OnceLock::new(); const COMMON_UTILS_WORKSPACE: &str = r#"{ "features": { @@ -599,6 +701,39 @@ fn feature_python_fedora_image() -> &'static str { }) } +fn feature_common_utils_ubi_image() -> &'static str { + FEATURE_COMMON_UTILS_UBI_IMAGE.get_or_init(|| { + let config = ubi_config_dir(); + build_image_with_workspace( + "openshell-test-feature-common-utils-ubi:integration", + COMMON_UTILS_WORKSPACE, + &["--config", config.path().to_str().unwrap()], + ) + }) +} + +fn feature_node_ubi_image() -> &'static str { + FEATURE_NODE_UBI_IMAGE.get_or_init(|| { + let config = ubi_config_dir(); + build_image_with_workspace( + "openshell-test-feature-node-ubi:integration", + NODE_WORKSPACE, + &["--config", config.path().to_str().unwrap()], + ) + }) +} + +fn feature_python_ubi_image() -> &'static str { + FEATURE_PYTHON_UBI_IMAGE.get_or_init(|| { + let config = ubi_config_dir(); + build_image_with_workspace( + "openshell-test-feature-python-ubi:integration", + PYTHON_WORKSPACE, + &["--config", config.path().to_str().unwrap()], + ) + }) +} + fn local_feature_workspace_dir() -> tempfile::TempDir { let dir = tempfile::tempdir().unwrap(); let kaiden = dir.path().join(".kaiden"); @@ -656,6 +791,16 @@ fn feature_local_fedora_image() -> &'static str { }) } +fn feature_local_ubi_image() -> &'static str { + FEATURE_LOCAL_UBI_IMAGE.get_or_init(|| { + let config = ubi_config_dir(); + build_image_with_local_feature( + "openshell-test-feature-local-ubi:integration", + &["--config", config.path().to_str().unwrap()], + ) + }) +} + // --------------------------------------------------------------------------- // Skills image helpers // --------------------------------------------------------------------------- @@ -843,6 +988,7 @@ feature_local_tests!( feature_local_fedora_image, fedora_image ); +feature_local_tests!(feature_local_ubi, feature_local_ubi_image, ubi_image); feature_common_utils_tests!( feature_common_utils_ubuntu, @@ -854,8 +1000,14 @@ feature_common_utils_tests!( feature_common_utils_fedora_image, fedora_image ); +feature_common_utils_tests!( + feature_common_utils_ubi, + feature_common_utils_ubi_image, + ubi_image +); feature_node_tests!(feature_node_ubuntu, feature_node_ubuntu_image, ubuntu_image); feature_node_tests!(feature_node_fedora, feature_node_fedora_image, fedora_image); +feature_node_tests!(feature_node_ubi, feature_node_ubi_image, ubi_image); feature_python_tests!( feature_python_ubuntu, feature_python_ubuntu_image, @@ -866,6 +1018,7 @@ feature_python_tests!( feature_python_fedora_image, fedora_image ); +feature_python_tests!(feature_python_ubi, feature_python_ubi_image, ubi_image); // --------------------------------------------------------------------------- // Agent settings integration tests @@ -1187,14 +1340,23 @@ fn cleanup_images() { "openshell-test-fedora-opencode:integration", "openshell-test-fedora-claude-vertexai:integration", "openshell-test-fedora-opencode-vertexai:integration", + "openshell-test-ubi:integration", + "openshell-test-ubi-claude:integration", + "openshell-test-ubi-opencode:integration", + "openshell-test-ubi-claude-vertexai:integration", + "openshell-test-ubi-opencode-vertexai:integration", "openshell-test-feature-common-utils-ubuntu:integration", "openshell-test-feature-node-ubuntu:integration", "openshell-test-feature-python-ubuntu:integration", "openshell-test-feature-common-utils-fedora:integration", "openshell-test-feature-node-fedora:integration", "openshell-test-feature-python-fedora:integration", + "openshell-test-feature-common-utils-ubi:integration", + "openshell-test-feature-node-ubi:integration", + "openshell-test-feature-python-ubi:integration", "openshell-test-feature-local-ubuntu:integration", "openshell-test-feature-local-fedora:integration", + "openshell-test-feature-local-ubi:integration", "openshell-test-ubuntu-claude-settings:integration", "openshell-test-ubuntu-claude-with-claude-json:integration", "openshell-test-ubuntu-opencode-settings:integration",