Skip to content

The bootc host image

Updated

Virtainer Free ships as a single content-addressed object: the operating system, the agent, the hypervisor, both guest kernels, the guest firmware and the host initramfs all live in one bootc image (ADR-0112). This is what makes an upgrade a switch instead of a patch, and what makes acceptance meaningful: the thing that was tested is the thing a machine boots, byte for byte.

It also means the build has to be able to say what went into it. There is no lock file to read afterwards, so the bake records what it actually resolved.

Machine Does Has a checkout Produces
Workstation tools/preflight.sh, tools/release.sh Yes The tag pushed to the build machine
Build machine tools/ci/build-release.sh builds and packages the release in containers, then tools/build-host-image.sh bakes and pushes the image Yes Tarball, initramfs, SBOM, notes, SHA256SUMS, the bootc image, host-image-provenance.json
Acceptance host boots the image, runs the matrix No Evidence and the release receipt

The build machine is separate because the machine that builds the image is not the machine that runs it, and it is the only machine that needs to reach upstream release hosts. Everything it needs travels with the commit: tools/release.sh pushes HEAD, the build machine checks that commit out, and the containers it builds in carry the toolchain, so neither machine compiles a release by hand. The bake script itself still resolves its inputs under two layouts and does not care which one it is in: a repository checkout, or a flat directory beside it.

packaging/bootc/Containerfile composes the image in five stages:

  1. Build a minimal root filesystem. A quay.io/fedora/fedora-bootc base is used only as a tool, via bootc-base-imagectl build-rootfs --manifest=minimal, and the result is copied into a FROM scratch stage. The base is referenced by a floating tag on purpose, and the bake resolves it to a digest and a kernel version at build time.
  2. Install runtime packages, then rebuild the initramfs with dracut so that Ignition can run on first boot. The initramfs is built --no-hostonly --reproducible, because this image boots on machines other than the one that built it.
  3. Overlay the staged tree. This is the only copy of product content in the recipe, and it is kept late so that editing it does not invalidate the expensive layers above.
  4. Enable units, seed volatile /var, self-check, clean, lint.
  5. Restore the metadata that FROM scratch discarded: the bootc and ostree labels, the stop signal and the init command.

Before the image is pushed, the recipe drops to the unprivileged nobody user and reads the first four bytes of each boot artefact: firmware, both guest kernels, and the initramfs. Cloud Hypervisor and the storage daemon run as unprivileged per-machine accounts, so one missing other-readable bit is every VM failing to start. On a bootc host /usr is read-only, which makes build time the only chance to get this right.

The recipe also installs a kernel boot parameter disabling systemd’s GPT auto-discovery. Without it, systemd can stack its own automount over the /boot bind mount that ostree created. At shutdown the teardown order can leave bare autofs at that path, the staged deployment’s bootloader write then fails, and the machine comes back on the old image with the upgrade having done nothing at all.

Terminal window
tools/build-host-image.sh <release-dir> --channel <stable|rc> [--tag <tag>] [--cache <dir>] [--push]
  1. Verify before doing anything. The release directory must carry a SHA256SUMS that checks out. Unverified bytes are refused, and the message names the checksum mismatch as the cause.
  2. Derive the version from the single virtainer-*-linux-*.tar.gz in the directory, and refuse a tag containing +: legal in SemVer, illegal in an OCI tag, and better discovered here than as a registry rejection after a long build.
  3. Require the same-version initramfs by name. The guest init evolves with the agent, so an out-of-band copy goes stale silently.
  4. Stage the tree at the four documented paths and modes: the binary at /usr/bin 0755, both units at /usr/lib/systemd/system 0644, the initramfs at /usr/share/virtainer/initramfs 0444.
  5. Resolve the base image to a digest and a kernel version, reading the reference out of the Containerfile rather than repeating it here.
  6. Fetch the third-party components into the staged tree.
  7. Record provenance and fold it into the release directory’s SHA256SUMS, regenerating its own line so a rerun does not accumulate duplicates.
  8. Build with the capabilities and FUSE device that bootc-base-imagectl needs to compose the root filesystem.
  9. Push the exact version tag, then the floating tag as a retag of the same image.
  10. Read the digest back from the registry and report that one. The local and registry digests differ because layers are recompressed on the way up, and only the registry digest is what a host that pulled the image will report.

Components float, and the record is made afterwards

Section titled “Components float, and the record is made afterwards”

tools/host-image-components.json states where to look for each third-party runtime component, never which version. Every bake resolves the current upstream release, and the answer only exists after the network call. What was fetched is written to host-image-provenance.json: base image reference, digest and kernel, plus one entry per component with its resolved version, URL and SHA-256.

Integrity is verified when upstream publishes something to verify against. The fetcher looks for a checksum sidecar or a shared manifest and fails closed on a mismatch. None of the current upstreams publishes one, so every entry records upstream_checksum_verified: false. The logic is in place so that a component which starts publishing checksums starts being checked without a change here.

Each channel publishes to its own container repository. A release candidate goes to a separate repository whose name carries no product meaning, which keeps unaccepted builds off the product name; it is not an access-control measure, and the console’s upgrade picker is where the real restriction lives. Only the candidate channel is ever baked: stable is filled by copying an accepted candidate, by digest.

The bake pushes the exact version tag and then moves latest to the same image. latest exists for hand-written install and switch commands. The upgrade picker filters it out, because every entry in that list has to be a definite version: choosing a floating tag would replace the whole machine’s operating system without saying which version it becomes. latest can move backwards if an older version is deliberately re-baked.

tools/test-host-image-build-contract.sh runs under preflight with the container tooling stubbed, and pins the observable shape of a bake: that it refuses tampered bytes and a mismatched initramfs, that it stages the documented paths and modes and not into /etc, that it writes well-formed provenance and folds it into SHA256SUMS exactly once, that latest appears only as a push and never as a second build, that exactly two pushes happen, that the reported digest is the registry’s, and that a prerelease version survives tag derivation.

Further guards read the recipe as text, because each of them was once a real incident: a test asserts the Containerfile still installs the git-core the AppVM build path needs, another asserts the GPT auto-discovery parameter is still shipped, and a third asserts the host cannot be put to sleep.