Skip to content
Merged
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
46 changes: 40 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, Fedora, or Red Hat UBI, any tag.
- **Base image selection** — Ubuntu, Fedora, Red Hat UBI, or Red Hat Hardened Images (HummingBird), 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.
Expand Down Expand Up @@ -54,22 +54,56 @@ If no `config.toml` is found in the resolved directory, or the file is empty, bu

If a directory is given explicitly (via `--config` or the environment variable) but it does not exist, the command fails immediately.

### Schema
### Base images

**Ubuntu** (default)

```toml
[openshell_image_builder.base_image]
image = "ubuntu"
tag = "24.04"
```

**Fedora**

```toml
[openshell_image_builder.base_image]
image = "fedora"
tag = "latest"
```

**Red Hat UBI**

```toml
[openshell_image_builder.base_image]
image = "ubi"
tag = "latest"
```

**Red Hat Hardened Images (Hummingbird)**

```toml
[openshell_image_builder.base_image]
image = "hummingbird"
tag = "latest-builder"
```

### Full schema reference

```toml
[openshell_image_builder]
version = 1

[openshell_image_builder.base_image]
image = "ubuntu" # "ubuntu", "fedora", or "ubi"
tag = "24.04" # ubuntu: "24.04", "22.04", … — fedora: "latest", "43", "42", … — ubi: "10.2-1780377767", …
image = "ubuntu" # "ubuntu", "fedora", "ubi", or "hummingbird"
tag = "24.04"
```

| Field | Default | Description |
| ------------------------------------------ | -------- | ---------------------------- |
| `openshell_image_builder.version` | `1` | Configuration schema version |
| `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`, … |
| `openshell_image_builder.base_image.image` | `ubuntu` | Base image name (`ubuntu`, `fedora`, `ubi`, or `hummingbird`) |
| `openshell_image_builder.base_image.tag` | `24.04` | Base image tag — Ubuntu: `24.04`, `22.04`, …; Fedora: `latest`, `43`, `42`, …; UBI: `latest`, `10.2-1780377767`, …; Hummingbird: `latest-builder`, … |

### Loading from a specific config directory

Expand Down
86 changes: 81 additions & 5 deletions src/containerfile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ pub fn generate(
"which",
],
),
"hummingbird" => dnf_system_stage(
"registry.access.redhat.com/hi/core-runtime",
tag,
&["bind-utils", "openssh-server", "procps-ng", "which", "tar"],
),
"ubuntu" => ubuntu_system_stage(tag),
image => {
return Err(ContainerfileError::NotSupported {
Expand Down Expand Up @@ -208,10 +213,10 @@ fn dnf_system_stage(base_image: &str, tag: &str, packages: &[&str]) -> String {
format!(
r#"# System base
FROM {base_image}:{tag} AS system

WORKDIR /sandbox

# Core system dependencies
USER 0
RUN dnf install -y --setopt=install_weak_deps=False \
{pkg_lines}
&& dnf clean all
Expand Down Expand Up @@ -250,6 +255,7 @@ RUN printf 'export PS1="\\u@\\h:\\w\\$ "\n' \
chown sandbox:sandbox /sandbox/.bashrc /sandbox/.profile && \
chown -R sandbox:sandbox /sandbox

ENV HOME=/sandbox
USER sandbox

{agent_settings_section}{skills_section}{agent_section}ENTRYPOINT ["/bin/bash"]
Expand Down Expand Up @@ -288,7 +294,17 @@ mod tests {
version: 1,
base_image: BaseImageConfig {
image: "ubi".to_string(),
tag: "10.2-1780377767".to_string(),
tag: "latest".to_string(),
},
}
}

fn hummingbird_config() -> Config {
Config {
version: 1,
base_image: BaseImageConfig {
image: "hummingbird".to_string(),
tag: "latest-builder".to_string(),
},
}
}
Expand Down Expand Up @@ -427,9 +443,7 @@ mod tests {
#[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")
);
assert!(content.contains("FROM registry.access.redhat.com/ubi10/ubi:latest AS system"));
}

#[test]
Expand Down Expand Up @@ -467,6 +481,68 @@ mod tests {
assert!(content.contains("COPY policy.yaml /etc/openshell/policy.yaml"));
}

#[test]
fn hummingbird_generates_successfully() {
assert!(generate(&hummingbird_config(), None, &[], false, &[]).is_ok());
}

#[test]
fn hummingbird_containerfile_contains_tag() {
let content = generate(&hummingbird_config(), None, &[], false, &[]).unwrap();
assert!(
content.contains(
"FROM registry.access.redhat.com/hi/core-runtime:latest-builder AS system"
)
);
}

#[test]
fn hummingbird_containerfile_tag_is_substituted() {
let content = generate(&hummingbird_config(), None, &[], false, &[]).unwrap();
assert!(!content.contains("{tag}"));
}

#[test]
fn hummingbird_with_agent_includes_install() {
let content = generate(&hummingbird_config(), Some(&MockAgent), &[], false, &[]).unwrap();
assert!(content.contains("RUN echo mock-agent"));
}

#[test]
fn hummingbird_agent_install_runs_as_sandbox_user() {
let content = generate(&hummingbird_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 hummingbird_without_agent_omits_install() {
let content = generate(&hummingbird_config(), None, &[], false, &[]).unwrap();
assert!(!content.contains("RUN echo mock-agent"));
}

#[test]
fn hummingbird_copies_policy_yaml() {
let content = generate(&hummingbird_config(), None, &[], false, &[]).unwrap();
assert!(content.contains("COPY policy.yaml /etc/openshell/policy.yaml"));
}

#[test]
fn home_env_set_to_sandbox() {
for content in [
generate(&ubuntu_config("24.04"), None, &[], false, &[]).unwrap(),
generate(&fedora_config(), None, &[], false, &[]).unwrap(),
generate(&ubi_config(), None, &[], false, &[]).unwrap(),
generate(&hummingbird_config(), None, &[], false, &[]).unwrap(),
] {
assert!(content.contains("ENV HOME=/sandbox"));
}
}

#[test]
fn not_supported_error_message() {
let err = ContainerfileError::NotSupported {
Expand Down
102 changes: 100 additions & 2 deletions tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,17 @@ 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",
"[openshell_image_builder.base_image]\nimage = \"ubi\"\ntag = \"latest\"\n",
)
.unwrap();
dir
}

fn hummingbird_config_dir() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("config.toml"),
"[openshell_image_builder.base_image]\nimage = \"hummingbird\"\ntag = \"latest-builder\"\n",
)
.unwrap();
dir
Expand Down Expand Up @@ -81,6 +91,11 @@ static UBI_CLAUDE_IMAGE: OnceLock<String> = OnceLock::new();
static UBI_OPENCODE_IMAGE: OnceLock<String> = OnceLock::new();
static UBI_CLAUDE_VERTEXAI_IMAGE: OnceLock<String> = OnceLock::new();
static UBI_OPENCODE_VERTEXAI_IMAGE: OnceLock<String> = OnceLock::new();
static HUMMINGBIRD_IMAGE: OnceLock<String> = OnceLock::new();
static HUMMINGBIRD_CLAUDE_IMAGE: OnceLock<String> = OnceLock::new();
static HUMMINGBIRD_OPENCODE_IMAGE: OnceLock<String> = OnceLock::new();
static HUMMINGBIRD_CLAUDE_VERTEXAI_IMAGE: OnceLock<String> = OnceLock::new();
static HUMMINGBIRD_OPENCODE_VERTEXAI_IMAGE: OnceLock<String> = OnceLock::new();
static UBUNTU_CLAUDE_SKILLS_IMAGE: OnceLock<String> = OnceLock::new();
static UBUNTU_OPENCODE_SKILLS_IMAGE: OnceLock<String> = OnceLock::new();

Expand Down Expand Up @@ -338,6 +353,84 @@ fn ubi_opencode_vertexai_image() -> &'static str {
})
}

fn hummingbird_image() -> &'static str {
HUMMINGBIRD_IMAGE.get_or_init(|| {
let config = hummingbird_config_dir();
build_image(
"openshell-test-hummingbird:integration",
&["--config", config.path().to_str().unwrap()],
)
})
}

fn hummingbird_claude_image() -> &'static str {
HUMMINGBIRD_CLAUDE_IMAGE.get_or_init(|| {
let config = hummingbird_config_dir();
build_image(
"openshell-test-hummingbird-claude:integration",
&[
"--config",
config.path().to_str().unwrap(),
"--agent",
"claude",
"--inference",
"anthropic",
],
)
})
}

fn hummingbird_opencode_image() -> &'static str {
HUMMINGBIRD_OPENCODE_IMAGE.get_or_init(|| {
let config = hummingbird_config_dir();
build_image(
"openshell-test-hummingbird-opencode:integration",
&[
"--config",
config.path().to_str().unwrap(),
"--agent",
"opencode",
"--inference",
"anthropic",
],
)
})
}

fn hummingbird_claude_vertexai_image() -> &'static str {
HUMMINGBIRD_CLAUDE_VERTEXAI_IMAGE.get_or_init(|| {
let config = hummingbird_config_dir();
build_image(
"openshell-test-hummingbird-claude-vertexai:integration",
&[
"--config",
config.path().to_str().unwrap(),
"--agent",
"claude",
"--inference",
"vertexai",
],
)
})
}

fn hummingbird_opencode_vertexai_image() -> &'static str {
HUMMINGBIRD_OPENCODE_VERTEXAI_IMAGE.get_or_init(|| {
let config = hummingbird_config_dir();
build_image(
"openshell-test-hummingbird-opencode-vertexai:integration",
&[
"--config",
config.path().to_str().unwrap(),
"--agent",
"opencode",
"--inference",
"vertexai",
],
)
})
}

// ---------------------------------------------------------------------------
// Shared assertion helpers
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -369,7 +462,7 @@ fn check_users_and_groups(image: &str) {
}

fn check_packages(image: &str) {
for pkg in ["curl", "ip", "ping"] {
for pkg in ["curl", "tar"] {
let out = run_in_image(image, &format!("which {pkg}"));
assert!(out.status.success(), "{pkg} not found in image");
}
Expand Down Expand Up @@ -570,6 +663,11 @@ image_tests!(ubi_claude, ubi_claude_image, has_claude:
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);
image_tests!(hummingbird, hummingbird_image, has_claude: false, has_opencode: false, has_anthropic: false, has_vertexai: false);
image_tests!(hummingbird_claude, hummingbird_claude_image, has_claude: true, has_opencode: false, has_anthropic: true, has_vertexai: false);
image_tests!(hummingbird_opencode, hummingbird_opencode_image, has_claude: false, has_opencode: true, has_anthropic: true, has_vertexai: false);
image_tests!(hummingbird_claude_vertexai, hummingbird_claude_vertexai_image, has_claude: true, has_opencode: false, has_anthropic: false, has_vertexai: true);
image_tests!(hummingbird_opencode_vertexai, hummingbird_opencode_vertexai_image, has_claude: false, has_opencode: true, has_anthropic: false, has_vertexai: true);

// ---------------------------------------------------------------------------
// Workspace helpers for feature-based builds
Expand Down