The bootc host image
Updated
One image is the unit of everything
Section titled “One image is the unit of everything”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.
Three machines
Section titled “Three machines”| 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.
The recipe
Section titled “The recipe”packaging/bootc/Containerfile composes the image in five stages:
- Build a minimal root filesystem. A
quay.io/fedora/fedora-bootcbase is used only as a tool, viabootc-base-imagectl build-rootfs --manifest=minimal, and the result is copied into aFROM scratchstage. 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. - Install runtime packages, then rebuild the initramfs with
dracutso 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. - 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.
- Enable units, seed volatile
/var, self-check, clean, lint. - Restore the metadata that
FROM scratchdiscarded: the bootc and ostree labels, the stop signal and the init command.
A self-check that belongs in the build
Section titled “A self-check that belongs in the build”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.
The bake
Section titled “The bake”tools/build-host-image.sh <release-dir> --channel <stable|rc> [--tag <tag>] [--cache <dir>] [--push]- Verify before doing anything. The release directory must carry a
SHA256SUMSthat checks out. Unverified bytes are refused, and the message names the checksum mismatch as the cause. - Derive the version from the single
virtainer-*-linux-*.tar.gzin 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. - Require the same-version initramfs by name. The guest init evolves with the agent, so an out-of-band copy goes stale silently.
- Stage the tree at the four documented paths and modes: the binary at
/usr/bin0755, both units at/usr/lib/systemd/system0644, the initramfs at/usr/share/virtainer/initramfs0444. - Resolve the base image to a digest and a kernel version, reading the reference out of the Containerfile rather than repeating it here.
- Fetch the third-party components into the staged tree.
- Record provenance and fold it into the release directory’s
SHA256SUMS, regenerating its own line so a rerun does not accumulate duplicates. - Build with the capabilities and FUSE device that
bootc-base-imagectlneeds to compose the root filesystem. - Push the exact version tag, then the floating tag as a retag of the same image.
- 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.
Channels and the floating tag
Section titled “Channels and the floating tag”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.
What guards a change to any of this
Section titled “What guards a change to any of this”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.