| name | add-base-image |
| description | Step-by-step checklist for adding a new base image to openshell-image-builder, covering the Containerfile match arm, unit tests, and integration tests |
| argument-hint | <image-name> <registry-url> <package-manager> |
Add Base Image
End-to-end checklist for supporting a new base image in openshell-image-builder.
Description
The base image is selected by the user via config.toml:
[openshell_image_builder.base_image]
image = "myimage"
tag = "latest"
The name is a plain string — no enum, no CLI flag change. Adding a new base image only touches two files: src/containerfile.rs and tests/integration_test.rs.
Step 1 — decide the package manager
The codebase already has two helpers in src/containerfile.rs:
ubuntu_system_stage(tag) — APT, docker.io/library/ubuntu.
dnf_system_stage(base_image, tag, packages) — DNF, used by fedora, ubi, and hummingbird.
For a new image, reuse the helper whose package manager matches. If the new image uses a third package manager (e.g., apk for Alpine), write a new fn alpine_system_stage(tag: &str) -> String following the same pattern as the existing two: FROM <registry>:{tag} AS system, install packages, create supervisor and sandbox users.
Required users and groups — every base image must create them the same way:
groupadd -r supervisor && useradd -r -g supervisor -s /usr/sbin/nologin supervisor
groupadd -r sandbox && useradd -r -g sandbox -d /sandbox -s /bin/bash sandbox
Minimum packages needed regardless of package manager: ca-certificates, curl, openssh-server (or equivalent sshd), tar, which, procps (or equivalent).
Step 2 — update README.md
Three places reference the supported base image names; all must be kept in sync:
-
Introduction bullet (line ~9) — the prose list "Ubuntu, Fedora, Red Hat UBI, or Red Hat Hardened Images (HummingBird)". Add the new image name in the same style.
-
"Base images" subsection — the four named config.toml example blocks. Add a new block with the image name as a heading and the typical default tag:
**My Image**
\`\`\`toml
[openshell_image_builder.base_image]
image = "myimage"
tag = "latest"
\`\`\`
-
"Full schema reference" table — two occurrences:
- The inline comment on the
image key: # "ubuntu", "fedora", "ubi", or "hummingbird" → add "myimage".
- The
base_image.image row description and the base_image.tag row description — add the new name and its typical tag examples.
Step 3 — add the match arm in generate() (src/containerfile.rs)
Inside generate(), add a new arm to the match config.base_image.image.as_str() block before the catch-all image => arm:
"myimage" => dnf_system_stage(
"registry.example.com/myimage",
tag,
&[
"ca-certificates",
"curl",
"openssh-server",
"procps-ng",
"tar",
"which",
],
),
The final_stage is shared across all base images and never needs changes.
Step 4 — unit tests (src/containerfile.rs, #[cfg(test)])
Add a config helper alongside the existing ones (fedora_config, ubi_config, etc.):
fn myimage_config() -> Config {
Config {
version: 1,
base_image: BaseImageConfig {
image: "myimage".to_string(),
tag: "latest".to_string(),
},
}
}
Then add these tests — every existing base image has all of them, keep the set complete:
#[test]
fn myimage_generates_successfully() {
assert!(build_cf(&myimage_config(), None, &[], false, &[]).is_ok());
}
#[test]
fn myimage_containerfile_contains_tag() {
let content = build_cf(&myimage_config(), None, &[], false, &[]).unwrap();
assert!(content.contains("FROM registry.example.com/myimage:latest AS system"));
}
#[test]
fn myimage_containerfile_tag_is_substituted() {
let content = build_cf(&myimage_config(), None, &[], false, &[]).unwrap();
assert!(!content.contains("{tag}"));
}
#[test]
fn myimage_with_agent_includes_install() {
let content = build_cf(&myimage_config(), Some(&MockAgent), &[], false, &[]).unwrap();
assert!(content.contains("RUN echo mock-agent"));
}
#[test]
fn myimage_agent_install_runs_as_sandbox_user() {
let content = build_cf(&myimage_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 myimage_without_agent_omits_install() {
let content = build_cf(&myimage_config(), None, &[], false, &[]).unwrap();
assert!(!content.contains("RUN echo mock-agent"));
}
#[test]
fn myimage_copies_policy_yaml() {
let content = build_cf(&myimage_config(), None, &[], false, &[]).unwrap();
assert!(content.contains("COPY policy.yaml /etc/openshell/policy.yaml"));
}
Also extend the home_env_set_to_sandbox test to include the new config, so it keeps covering every base image:
fn home_env_set_to_sandbox() {
for content in [
...,
build_cf(&myimage_config(), None, &[], false, &[]).unwrap(),
] {
assert!(content.contains("ENV HOME=/sandbox"));
}
}
Step 5 — integration tests (tests/integration_test.rs)
Config helper
If the new image needs a non-default tag, add a config dir helper alongside fedora_config_dir:
fn myimage_config_dir() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("config.toml"),
"[openshell_image_builder.base_image]\nimage = \"myimage\"\ntag = \"latest\"\n",
)
.unwrap();
dir
}
Image singletons and accessors
Add one OnceLock and one accessor per agent × inference combination. Follow the naming convention exactly — the integration test matrix currently covers these combinations for every base image:
| Combination | Accessor name | Extra args |
|---|
| (no agent) | myimage_image | [] |
| claude + anthropic | myimage_claude_image | ["--agent", "claude", "--inference", "anthropic"] |
| opencode + anthropic | myimage_opencode_image | ["--agent", "opencode", "--inference", "anthropic"] |
| claude + vertexai | myimage_claude_vertexai_image | ["--agent", "claude", "--inference", "vertexai"] |
| opencode + vertexai | myimage_opencode_vertexai_image | ["--agent", "opencode", "--inference", "vertexai"] |
| opencode + ollama | myimage_opencode_ollama_image | ["--agent", "opencode", "--inference", "ollama"] |
Example for the no-agent variant (pass the --config flag when a config dir is needed):
static MYIMAGE_IMAGE: OnceLock<String> = OnceLock::new();
fn myimage_image() -> &'static str {
MYIMAGE_IMAGE.get_or_init(|| {
let config = myimage_config_dir();
build_image(
"openshell-test-myimage:integration",
&["--config", config.path().to_str().unwrap()],
)
})
}
image_tests! calls
Add one call per combination in the matrix block (around line 678):
image_tests!(myimage, myimage_image, has_claude: false, has_opencode: false, has_anthropic: false, has_vertexai: false, has_ollama: false);
image_tests!(myimage_claude, myimage_claude_image, has_claude: true, has_opencode: false, has_anthropic: true, has_vertexai: false, has_ollama: false);
image_tests!(myimage_opencode, myimage_opencode_image, has_claude: false, has_opencode: true, has_anthropic: true, has_vertexai: false, has_ollama: false);
image_tests!(myimage_claude_vertexai, myimage_claude_vertexai_image, has_claude: true, has_opencode: false, has_anthropic: false, has_vertexai: true, has_ollama: false);
image_tests!(myimage_opencode_vertexai,myimage_opencode_vertexai_image,has_claude: false, has_opencode: true, has_anthropic: false, has_vertexai: true, has_ollama: false);
image_tests!(myimage_opencode_ollama, myimage_opencode_ollama_image, has_claude: false, has_opencode: true, has_anthropic: false, has_vertexai: false, has_ollama: true);
Cleanup
Add all six tags to the cleanup_images array in the #[ctor::dtor] at the bottom of the file:
"openshell-test-myimage:integration",
"openshell-test-myimage-claude:integration",
"openshell-test-myimage-opencode:integration",
"openshell-test-myimage-claude-vertexai:integration",
"openshell-test-myimage-opencode-vertexai:integration",
"openshell-test-myimage-opencode-ollama:integration",
Checklist