The node's userspace is built from pinned upstream sources with a
Containerfile, driven by podman. Podman is the first-class target; the file is
plain Dockerfile syntax, so docker build works too. Nothing is built on the
host.
For the developer guide -- how the distribution is built end to end, and how to change it -- see
../docs/BUILD.md. This file is the builder's own reference.
Every component comes from its own upstream project, pinned by sha256. Where a
component would not build against our environment, we change our own recipe
(e.g. busybox's tc applet is disabled because a recent kernel UAPI dropped the
CBQ structures it uses) rather than borrowing a downstream patch.
"From scratch" is four independent layers:
| Layer | Who does it here |
|---|---|
| 1. C library | glibc built from GNU source (pass -1) — our version, our build |
| 2. Kernel | linux built from source + board/kernel.fragment |
| 3. Userspace | one Containerfile stage per pass, one recipe per component |
| 4. Assembly + image | overlay + genimage (pass 6) |
The compiler is the seed gcc of the builder image (Fedora), used only to build things — it never ships. What ships is the glibc we compiled.
Why not a prebuilt cross-toolchain (Bootlin, crosstool-NG)? Because those are built for embedded, by a meta build system we are deliberately leaving behind. Consuming one inherits exactly the provenance we want to avoid. Owning the libc — the part that actually ships — is the answer; the compiler is a build tool.
The userspace is ~33 recipes, not thousands. Grouped by what can build in parallel:
- pass -1 — libc: kernel UAPI headers (from the pinned
linuxsource) + glibc 2.44 fromftp.gnu.org, into/sysroot. The seed gcc's own runtime (C++ headers,libgcc_s,libstdc++) is copied in after: that is compiler runtime, not the libc. - pass 2 — socle:
libzlib, expat, libffi, pcre2, libpng, pixman, libdrm, libseccomp, libmnl, popt, libfribidi - pass 3 — tools:
util-linux, libnftnl, iptables, kmod, e2fsprogs, gptfdisk, iproute2, kbd, busybox - pass 4 — console:
freetype → fontconfig → libglib2 → cairo → harfbuzz → pango(a chain; needs the socle and util-linux's libmount) - pass 1 — kernel:
linux+ the fragment (independent; builds alongside) - pass 5 — runtime:
containerd, runcand the CNI plugins, from upstream release artifacts - pass 6 — assemble: systemd-boot and genimage, plus the
inputs
assemble.shneeds;build.shruns it intobuild/out
Each component is its own container layer, so changing one recipe rebuilds only it and what depends on it.
/sysroot is the tree we own: the libc pass seeds it, every recipe installs into
it with DESTDIR=/sysroot, and every compile points at it with
--sysroot=/sysroot. It carries our glibc, the kernel headers and the components
— and nothing from the base distribution: one directory we own, carried forward
with COPY --from=<pass> /sysroot /sysroot.
From the repository root:
make image # the whole disk (build/out/vates.qcow2)or, from build/, a single stage:
./build.sh socle # one pass (default)
./build.sh console # the console stack
./build.sh kernel # the kernel
./build.sh follow # tail the build logbuild.sh fetches every pinned source from its upstream URL into dl/ and
verifies its sha256 before the build, so the container needs no network.
The base image is Fedora (RHEL-family), never Debian; override with
VATES_SCRATCH_BASE. For Docker instead of Podman, the Containerfile is
unchanged:
docker build -f Containerfile --target console \
--build-arg BASE=fedora:44 -t vates-scratch:console .pins.conf lists each component's version, source tarball, sha256 and upstream
URL. It is maintained by hand — there is no generator reading another project's
metadata. To add or bump a component, edit the file; the sha256 comes from the
upstream release (its checksum or signature file) or from sha256sum of a
tarball verified some other way.
build.sh podman/docker driver; fetches+verifies sources, builds a stage
Containerfile the passes, as stages
pins.conf maintained: version|source|sha256|url per component
board/ our kernel and busybox fragments (self-contained copy)
overlay/ what goes into the rootfs (our config)
lib/common.sh native env + fetch/verify/unpack + autotools/meson drivers
lib/build-libc.sh pass -1: kernel headers + glibc + compiler runtime seed
lib/cross.ini meson cross file
lib/run-recipe.sh sources common.sh + the recipe, runs build()
recipes/<pass>/<c>.sh one recipe per component
recipes/host/*.sh host tools built for the builder (genimage, systemd-boot)
dl/ fetched source tarballs (gitignored)
Inside the Containerfile, beyond the passes: a go-base stage (Fedora's Go) with
vates (the vates binaries) built from it; the guest pass (the Rust Xen guest
agent and libxenstore) on socle; and systemd-boot built in the disk stage.
- graph extracted, passes designed
- pins from upstream, all URLs verified
- pass -1 (glibc) + pass 2 (socle) — glibc links and runs, verified in-pass
- pass 3 (tools) + pass 4 (console) + pass 5 (containerd/runc/CNI)
- pass 1 (kernel) + pass 6 (assemble) — a bootable A/B disk (
out/vates.qcow2) - the disk boots under UEFI: systemd-boot → kernel →
vates-sysinit→ console - nothing is built on the host: the vates binaries, the Rust Xen agent and systemd-boot are all built by the Containerfile (recipes, with the Go and Rust toolchains borrowed from the base)
- the disk boots and
make cluster CP=1 WORKERS=0brings up a real single-node control plane: etcd, apiserver, controller-manager, scheduler, kube-vip, kube-proxy, flannel and coredns all Running, node Ready - dm-verity, the updater (
vateskctl ab update), the CAPI provider