From 8f43fedeb2ed133e24f426d68255287d7263fe0e Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Mon, 7 Sep 2026 22:37:32 -0300 Subject: [PATCH] One layout, one name for the image, and a fingerprint that cannot be shifted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes, all of them removing a way for two things to disagree. **One layout.** The same four files had three shapes: what the build extracted into _output/, what the tarball carried under usr/share/spin-stack/, and what a consumer rearranged them into — with a translation step between each pair and a discovery function guessing which shape it had been handed. The builds now write the release tree directly, so `_output/` and the tarball are the same shape byte for byte, and `machine.Open` reads either. It checks rather than trusting: a release with a hole in it says which file is missing, instead of surfacing three seconds later as a QEMU that exits for want of an option ROM. That also removes COMPAT_KERNEL_NAME, the symlink hatch that existed so a consumer resolving the kernel under another name would not have to change. **base.qcow2 is rootfs.qcow2.** `base` names the role — a qcow2 backing file — which the directory and the prose already say, and it collides with "base image" in the container sense. `rootfs` names what the file holds. **Fingerprint is length-prefixed.** It hashed `key=value\n` lines whose values are a CPU model, a device list and a line out of /proc/cpuinfo — none of them guaranteed free of the separator, so two different machines could produce one byte stream. It also now states which three things a restore is allowed to differ in and why each is safe: the vsock context id is not in the migration stream and the guest re-reads it after the transport reset, a disk is cold-plugged and found with a PCI rescan, and what sits behind a NIC is a host file descriptor the guest never sees. Verified: qemu:build, kernel:build and image:build all write the release tree; a guest booted from an overlay on the renamed image mounts its ext4 root; `task release` packs the tarball and `spin-machine fingerprint --release` reads the unpacked tree with no path fixed up on the way. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019GHRRFvbnZi5q3KSTvyW5a --- .github/workflows/image.yml | 4 +- .github/workflows/kernel.yml | 8 +- .github/workflows/qemu.yml | 2 +- NOTICE | 2 +- README.md | 22 +++-- Taskfile.yml | 2 +- cmd/spin-machine/main.go | 58 +++++++----- hack/release | 27 ++---- image/Dockerfile | 2 +- image/Taskfile.yml | 14 +-- image/build.sh | 23 +++-- kernel/Dockerfile | 11 ++- kernel/Taskfile.yml | 2 +- machine/machine.go | 36 ++++++-- machine/release.go | 170 +++++++++++++++++++++++++++++++++++ machine/release_test.go | 98 ++++++++++++++++++++ qemu/Dockerfile | 2 +- qemu/Taskfile.yml | 4 +- 18 files changed, 397 insertions(+), 90 deletions(-) create mode 100644 machine/release.go create mode 100644 machine/release_test.go diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml index 9794dc5..a1a75bc 100644 --- a/.github/workflows/image.yml +++ b/.github/workflows/image.yml @@ -60,8 +60,8 @@ jobs: - name: What this image is run: | - ls -la _output/base.qcow2 - echo "sha256: $(sha256sum _output/base.qcow2 | cut -d' ' -f1)" + ls -la _output/image/rootfs.qcow2 + echo "sha256: $(sha256sum _output/image/rootfs.qcow2 | cut -d' ' -f1)" # Not uploaded as an artifact: it is ~860 MB and the release workflow rebuilds it # from the same inputs. What this job is for is finding out that it still builds and diff --git a/.github/workflows/kernel.yml b/.github/workflows/kernel.yml index 22feb97..0c65c04 100644 --- a/.github/workflows/kernel.yml +++ b/.github/workflows/kernel.yml @@ -59,15 +59,15 @@ jobs: # when somebody asks why their templates stopped matching. - name: What this kernel is run: | - echo "kernel sha256: $(sha256sum _output/vmlinux | cut -d' ' -f1)" - echo "config sha256: $(sha256sum _output/kernel-config | cut -d' ' -f1)" + echo "kernel sha256: $(sha256sum _output/kernel/vmlinux | cut -d' ' -f1)" + echo "config sha256: $(sha256sum _output/kernel/kernel-config | cut -d' ' -f1)" - name: Upload the kernel uses: actions/upload-artifact@v4 with: name: vmlinux-${{ github.sha }} path: | - _output/vmlinux - _output/kernel-config + _output/kernel/vmlinux + _output/kernel/kernel-config retention-days: 30 if-no-files-found: error diff --git a/.github/workflows/qemu.yml b/.github/workflows/qemu.yml index 81d153a..7ba4c0d 100644 --- a/.github/workflows/qemu.yml +++ b/.github/workflows/qemu.yml @@ -75,7 +75,7 @@ jobs: name: qemu-${{ steps.qemu.outputs.version }}-linux-amd64 path: | _output/bin/ - _output/share/ + _output/qemu/ retention-days: 30 if-no-files-found: error diff --git a/NOTICE b/NOTICE index 4017ee0..8ff3586 100644 --- a/NOTICE +++ b/NOTICE @@ -31,7 +31,7 @@ almost entirely other people's software under other people's licences: vmlinux Linux, GPL-2.0-only WITH Linux-syscall-note. - base.qcow2 + rootfs.qcow2 An Ubuntu 26.04 userland: several thousand files under GPL, LGPL, MIT, BSD, Apache-2.0 and others. usr/share/spin-stack/packages.txt in a release lists every package and version it was built from, and diff --git a/README.md b/README.md index f36092f..5f09d37 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ cmd/ spin-machine (boot one, print its fingerprint) and the debug init it CLAUDE.md how to work in here qemu/ Dockerfile + devices.mak kernel/ Dockerfile + config-- -image/ mkosi configuration producing base.qcow2 (ext4 inside) +image/ mkosi configuration producing rootfs.qcow2 (ext4 inside) hack/ release ``` @@ -58,13 +58,25 @@ One tarball: | `bin/qemu-img` | | | `qemu/{bios.bin,bios-256k.bin,pvh.bin,kvmvapic.bin,efi-virtio.rom}` | | | `kernel/vmlinux` | plus `kernel-config` | -| `image/base.qcow2` | read-only, 0444 | +| `image/rootfs.qcow2` | read-only, 0444 | | `machine.env` | the version and the three checksums that decide template validity | | `SOURCES` | every upstream source by version, URL and SHA-256, and the written offer | | `packages.txt` | every package and exact version in the base image | `LICENSE` and `NOTICE` sit at the root of the tarball, next to `install.sh`. +`task build` writes that same tree into `_output/`, byte for byte the layout above, and +`machine.Open` reads either. There is one layout: nothing rearranges the files on the way +out of a build, into a tarball or into a consumer, because the three used to differ and +what fell out of the translation between them was a path that existed and held the +previous release's kernel. + +```go +rel, err := machine.Open("/usr/share/spin-stack") // says which file is missing, if one is +spec := rel.Spec() // QEMU, Kernel, Firmware +img, err := rel.Rootfs() +``` + ## The machine `machine/` is the definition, and it is Go rather than a document because a definition @@ -76,7 +88,7 @@ spec := machine.Spec{ QEMU: …, Kernel: …, Initrd: …, Firmware: …, BootCPUs: 2, Memory: machine.Memory{SizeMB: 2048, File: "/…/pc.ram", Shared: true}, - Disks: []machine.Disk{{Path: "base.qcow2", Format: "qcow2", Readonly: true}}, + Disks: []machine.Disk{{Path: overlay, Format: "qcow2"}}, VsockCID: 7, } args, err := spec.Args() // the QEMU command line @@ -155,7 +167,7 @@ process is systemd. Booting a bare shell answers a different question — `syste in it replies *"System has not been booted with systemd as init system (PID 1)"*, which is true and useless. -It boots this QEMU and this kernel over a throwaway qcow2 overlay on `base.qcow2`, through +It boots this QEMU and this kernel over a throwaway qcow2 overlay on `rootfs.qcow2`, through `spin-machine boot`, with the serial console on stdio. The initrd it boots is `cmd/spin-machine-init`: static Go that mounts `/proc`, `/sys`, @@ -277,7 +289,7 @@ moving a file. A **qcow2 base, read-only, with a fresh qcow2 overlay per VM**: ``` -qemu-img create -f qcow2 -F qcow2 -b base.qcow2 overlay.qcow2 +qemu-img create -f qcow2 -F qcow2 -b rootfs.qcow2 overlay.qcow2 ``` Copy-on-write happens in the block layer, where QEMU does it, rather than in a filesystem diff --git a/Taskfile.yml b/Taskfile.yml index 68e865a..c433a26 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -254,7 +254,7 @@ tasks: # The image inside the tarball, and not the one in _output. They are the same # file today and the point of this task is the day they are not: a release is # verified by booting what it ships, or it is not verified. - img="$share/image/base.qcow2" + img="$share/image/rootfs.qcow2" test -f "$img" || { echo "the tarball carries no image at $img" >&2; exit 1; } # Three questions, because "it printed something" answers none of them. diff --git a/cmd/spin-machine/main.go b/cmd/spin-machine/main.go index fbbf908..cc9c312 100644 --- a/cmd/spin-machine/main.go +++ b/cmd/spin-machine/main.go @@ -129,13 +129,13 @@ type options struct { } func flags(fs *flag.FlagSet, o *options) *flag.FlagSet { - fs.StringVar(&o.release, "release", "_output", "a release tree: bin/, share/spin-stack/qemu/, vmlinux, base.qcow2") + fs.StringVar(&o.release, "release", "_output", "an unpacked release tree; every path below defaults out of it") fs.StringVar(&o.qemu, "qemu", "", "QEMU binary (default: /bin/qemu-system-x86_64)") fs.StringVar(&o.kernel, "kernel", "", "kernel image (default: /vmlinux)") fs.StringVar(&o.initrd, "initrd", "", "initrd (default: none)") - fs.StringVar(&o.firmware, "firmware", "", "firmware directory (default: /share/spin-stack/qemu)") + fs.StringVar(&o.firmware, "firmware", "", "firmware directory (default: /qemu)") - fs.StringVar(&o.disk, "disk", "", "disk image (default: /base.qcow2)") + fs.StringVar(&o.disk, "disk", "", "disk image (default: /rootfs.qcow2)") fs.StringVar(&o.diskFormat, "disk-format", "qcow2", "format of the disk image; never guessed") fs.BoolVar(&o.readonly, "disk-readonly", false, "open the disk read-only") fs.StringVar(&o.serial, "disk-serial", "", "virtio-blk serial the guest can resolve the disk by") @@ -164,8 +164,12 @@ func flags(fs *flag.FlagSet, o *options) *flag.FlagSet { // spec turns the flags into a machine, filling in every path from the release // tree so that the common case is one flag. +// +// The tree is opened rather than assumed, so a release missing a part says so +// here, naming the file — and not three seconds later as a QEMU that exits for +// want of an option ROM. func (o *options) spec() (machine.Spec, error) { - rel, err := filepath.Abs(o.release) + rel, err := machine.Open(o.release) if err != nil { return machine.Spec{}, err } @@ -176,27 +180,33 @@ func (o *options) spec() (machine.Spec, error) { return fallback } - s := machine.Spec{ - QEMU: or(o.qemu, filepath.Join(rel, "bin", "qemu-system-x86_64")), - Kernel: or(o.kernel, filepath.Join(rel, "vmlinux")), - Initrd: o.initrd, - Firmware: or(o.firmware, filepath.Join(rel, "share", "spin-stack", "qemu")), - CPU: o.cpuModel, - BootCPUs: o.cpus, - MaxCPUs: o.maxCPUs, - Memory: machine.Memory{ - SizeMB: o.memoryMB, - MaxMB: o.maxMemMB, - File: o.memFile, - Shared: o.memShare, - }, - VsockCID: o.vsockCID, - QMPSocket: o.qmp, - Incoming: o.incoming, - Serial: o.console, + s := rel.Spec() + s.QEMU = or(o.qemu, s.QEMU) + s.Kernel = or(o.kernel, s.Kernel) + s.Firmware = or(o.firmware, s.Firmware) + s.Initrd = o.initrd + s.CPU = o.cpuModel + s.BootCPUs = o.cpus + s.MaxCPUs = o.maxCPUs + s.Memory = machine.Memory{ + SizeMB: o.memoryMB, + MaxMB: o.maxMemMB, + File: o.memFile, + Shared: o.memShare, + } + s.VsockCID = o.vsockCID + s.QMPSocket = o.qmp + s.Incoming = o.incoming + s.Serial = o.console + + // The base image is what this boots unless told otherwise, and "-" is how a + // caller asks for a machine with no disk at all. + disk := o.disk + if disk == "" { + if disk, err = rel.Rootfs(); err != nil { + return machine.Spec{}, err + } } - - disk := or(o.disk, filepath.Join(rel, "base.qcow2")) if disk != "-" { s.Disks = []machine.Disk{{ Path: disk, diff --git a/hack/release b/hack/release index 1dc2f46..b5c4847 100755 --- a/hack/release +++ b/hack/release @@ -1,7 +1,7 @@ #!/usr/bin/env bash # # One tarball, one version: qemu-system-x86_64, qemu-img, the five firmware files, vmlinux -# and base.qcow2. +# and rootfs.qcow2. # # Not because it is tidy. `machine.Spec.Fingerprint` hashes the QEMU binary, the kernel and # the initrd by content to decide whether a VM may restore from a template, so a release in @@ -67,27 +67,14 @@ echo "Firmware:" # efi-virtio.rom is the option ROM of the one NIC the build keeps, and a VM given a # virtio-net-pci without it does not start at all. for f in bios.bin bios-256k.bin pvh.bin kvmvapic.bin efi-virtio.rom; do - require "${OUTPUT_DIR}/share/spin-stack/qemu/${f}" "${SHARE}/qemu/${f}" "firmware ${f}" + require "${OUTPUT_DIR}/qemu/${f}" "${SHARE}/qemu/${f}" "firmware ${f}" done echo "Kernel:" -require "${OUTPUT_DIR}/vmlinux" "${SHARE}/kernel/vmlinux" "the guest kernel" 0644 -require "${OUTPUT_DIR}/kernel-config" "${SHARE}/kernel/kernel-config" "the kernel config" -# An optional second name for the kernel, off by default. -# -# The kernel is `vmlinux`, which is what the ELF is. A consumer that resolves it under some -# other name can be given a symlink here rather than being changed — but this repository -# does not know that any such consumer exists, and hardcoding one repository's filename -# into another's release is exactly how two projects end up knowing about each other. -# -# COMPAT_KERNEL_NAME=spinbox-kernel-x86_64 task release -if [ -n "${COMPAT_KERNEL_NAME:-}" ]; then - ln -sfn vmlinux "${SHARE}/kernel/${COMPAT_KERNEL_NAME}" - echo " ok: ${COMPAT_KERNEL_NAME} -> vmlinux" -fi - +require "${OUTPUT_DIR}/kernel/vmlinux" "${SHARE}/kernel/vmlinux" "the guest kernel" 0644 +require "${OUTPUT_DIR}/kernel/kernel-config" "${SHARE}/kernel/kernel-config" "the kernel config" echo "Base image:" -require "${OUTPUT_DIR}/base.qcow2" "${SHARE}/image/base.qcow2" "the base image" 0444 +require "${OUTPUT_DIR}/image/rootfs.qcow2" "${SHARE}/image/rootfs.qcow2" "the base image" 0444 require "${OUTPUT_DIR}/packages.txt" "${SHARE}/packages.txt" "the image's package list" require "${OUTPUT_DIR}/licenses.txt" "${SHARE}/licenses.txt" "the image's licence texts" @@ -103,7 +90,7 @@ require "${OUTPUT_DIR}/licenses.txt" "${SHARE}/licenses.txt" "the image's licenc echo "built=$(date -u +%Y-%m-%dT%H:%M:%SZ)" echo "qemu_sha256=$(sha256sum "${SHARE}/bin/qemu-system-x86_64" | cut -d' ' -f1)" echo "kernel_sha256=$(sha256sum "${SHARE}/kernel/vmlinux" | cut -d' ' -f1)" - echo "image_sha256=$(sha256sum "${SHARE}/image/base.qcow2" | cut -d' ' -f1)" + echo "image_sha256=$(sha256sum "${SHARE}/image/rootfs.qcow2" | cut -d' ' -f1)" } > "${SHARE}/machine.env" echo "Manifest:" sed 's/^/ /' "${SHARE}/machine.env" @@ -146,7 +133,7 @@ Linux ${KERNEL_VERSION} binary as usr/share/spin-stack/kernel/kernel-config Ubuntu userland - image: usr/share/spin-stack/image/base.qcow2 + image: usr/share/spin-stack/image/rootfs.qcow2 contents: usr/share/spin-stack/packages.txt — every package and its exact version licences: usr/share/spin-stack/licenses.txt — the copyright and licence text of each source: the Ubuntu archive. For any package listed there: diff --git a/image/Dockerfile b/image/Dockerfile index ecff4d8..737502c 100644 --- a/image/Dockerfile +++ b/image/Dockerfile @@ -1,4 +1,4 @@ -# The toolchain that builds base.qcow2. It is not the image build itself: that is +# The toolchain that builds rootfs.qcow2. It is not the image build itself: that is # image/build.sh, run inside a container from this Dockerfile. # # Why the split, since everything else here is a `docker buildx build --target extract`: diff --git a/image/Taskfile.yml b/image/Taskfile.yml index aba3b42..d9fe50c 100644 --- a/image/Taskfile.yml +++ b/image/Taskfile.yml @@ -6,7 +6,7 @@ version: '3' tasks: build: - desc: Build base.qcow2 (Ubuntu 26.04 userland, ext4, partitionless) into _output/. + desc: Build rootfs.qcow2 (Ubuntu 26.04 userland, ext4, partitionless) into _output/. cmds: - task: builder - mkdir -p {{.OUTPUT_ABS}} {{.MKOSI_CACHE_DIR}} @@ -60,8 +60,8 @@ tasks: - | set -euo pipefail out={{.OUTPUT_ABS}} - for f in "$out/bin/spin-machine" "$out/bin/qemu-system-x86_64" "$out/vmlinux" \ - "$out/base.qcow2" "$out/debug-initramfs.cpio.gz"; do + for f in "$out/bin/spin-machine" "$out/bin/qemu-system-x86_64" "$out/kernel/vmlinux" \ + "$out/image/rootfs.qcow2" "$out/debug-initramfs.cpio.gz"; do test -f "$f" || { echo "missing $f — run: task build" >&2; exit 1; } done @@ -72,9 +72,9 @@ tasks: # cannot be touched. scratch=$(mktemp -d)/scratch.qcow2 trap 'rm -rf "$(dirname "$scratch")"' EXIT - "$out/bin/qemu-img" create -f qcow2 -F qcow2 -b "$out/base.qcow2" "$scratch" >/dev/null + "$out/bin/qemu-img" create -f qcow2 -F qcow2 -b "$out/image/rootfs.qcow2" "$scratch" >/dev/null - before=$(sha256sum "$out/base.qcow2" | cut -d' ' -f1) + before=$(sha256sum "$out/image/rootfs.qcow2" | cut -d' ' -f1) echo "booting {{.INIT}} in the base image — {{.MEMORY}} MiB, {{.CPUS}} vCPU" echo " writes go to a throwaway overlay; the base is read-only and checked after" @@ -110,10 +110,10 @@ tasks: --append "spinmachine.root=/dev/vda spinmachine.init={{.INIT}} spinmachine.getty=1" || true echo - after=$(sha256sum "$out/base.qcow2" | cut -d' ' -f1) + after=$(sha256sum "$out/image/rootfs.qcow2" | cut -d' ' -f1) if [ "$before" != "$after" ]; then echo "👹 the base image changed during that session: $before -> $after" >&2 echo " every overlay in existence is now built on a file that moved." >&2 exit 1 fi - echo "base.qcow2 unchanged; the overlay held $(du -h "$scratch" | cut -f1) of writes and is gone" + echo "rootfs.qcow2 unchanged; the overlay held $(du -h "$scratch" | cut -f1) of writes and is gone" diff --git a/image/build.sh b/image/build.sh index 0e4b47a..ad1638b 100755 --- a/image/build.sh +++ b/image/build.sh @@ -1,6 +1,6 @@ #!/bin/bash -# Build base.qcow2 from the tree mkosi assembles. Runs inside the image/Dockerfile -# container; writes $OUT (default /out/base.qcow2). +# Build rootfs.qcow2 from the tree mkosi assembles. Runs inside the image/Dockerfile +# container; writes $OUT (default /out/image/rootfs.qcow2). # # What it produces: a partitionless ext4 filesystem holding the workload's userland, # wrapped in a read-only qcow2 that every VM maps as a backing file. @@ -10,7 +10,7 @@ # overlayfs in the guest. Copy-on-write was done by the filesystem. Here it moves to the # block layer, where QEMU does it: # -# qemu-img create -f qcow2 -F qcow2 -b base.qcow2 overlay.qcow2 +# qemu-img create -f qcow2 -F qcow2 -b rootfs.qcow2 overlay.qcow2 # # which is the shape a chain of images is made of: one base, many overlays. # @@ -19,7 +19,12 @@ # filesystem here starts with a kernel config change one directory over. set -euo pipefail -OUT="${OUT:-/out/base.qcow2}" +OUT="${OUT:-/out/image/rootfs.qcow2}" + +# The two texts that describe the image rather than being it. They go at the root of the +# tree and not beside the image, because that is where a release carries them: one +# licences.txt and one packages.txt for the whole machine, not one per part. +SHARE="${SHARE:-$(dirname "$(dirname "$OUT")")}" # Normalizes timestamps in the tree, so the build's wall clock is not baked into every # file. It is not on its own a claim that the image is bit-reproducible: the userland is # assembled from a live archive, and the filesystem is sized from what came out of it, so @@ -27,7 +32,7 @@ OUT="${OUT:-/out/base.qcow2}" # release actually contains is the checksum in machine.env. export SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-0}" -mkdir -p "$(dirname "$OUT")" /work/out /cache +mkdir -p "$(dirname "$OUT")" "$SHARE" /work/out /cache cd /work echo "==> mkosi: assembling the userland" @@ -75,8 +80,8 @@ test -s "$harvested" || { echo " without the copyright notices its licences require. See" >&2 echo " image/mkosi.postinst.chroot." >&2 exit 1; } -mv "$harvested" "$(dirname "$OUT")/licenses.txt" -echo "==> $(grep -c '^======== ' "$(dirname "$OUT")/licenses.txt" || true) licence texts, $(du -h "$(dirname "$OUT")/licenses.txt" | cut -f1)" +mv "$harvested" "$SHARE/licenses.txt" +echo "==> $(grep -c '^======== ' "$SHARE/licenses.txt" || true) licence texts, $(du -h "$SHARE/licenses.txt" | cut -f1)" # --- the bill of materials -------------------------------------------------------------- # @@ -90,8 +95,8 @@ echo "==> $(grep -c '^======== ' "$(dirname "$OUT")/licenses.txt" || true) licen # release points at for the userland half: an Ubuntu source package is fetched by name and # version, and those are the two things here. dpkg-query --admindir="$tree/var/lib/dpkg" -W -f='${Package} ${Version} ${Architecture}\n' \ - 2>/dev/null | sort > "$(dirname "$OUT")/packages.txt" -echo "==> $(wc -l < "$(dirname "$OUT")/packages.txt") packages recorded" + 2>/dev/null | sort > "$SHARE/packages.txt" +echo "==> $(wc -l < "$SHARE/packages.txt") packages recorded" # --- filesystem ------------------------------------------------------------------------- # diff --git a/kernel/Dockerfile b/kernel/Dockerfile index f1a4809..47661cc 100644 --- a/kernel/Dockerfile +++ b/kernel/Dockerfile @@ -226,8 +226,11 @@ EOT # Output stage # ============================================ # -# Named vmlinux, which is what the ELF is. hack/release can add a second name beside it -# for a consumer that resolves the kernel differently; see COMPAT_KERNEL_NAME there. +# The tree this stage writes is the release tree: kernel/vmlinux, at the path a release +# carries it and the path machine.Release reads it from. It used to extract to the root and +# let hack/release do the moving, which meant the same four files had one layout here, a +# second in the tarball and a third wherever a consumer rearranged them into — with a +# translation step between each pair, and each of those a place to be wrong. FROM scratch AS kernel -COPY --from=kernel-build /build/vmlinux /vmlinux -COPY --from=kernel-build /build/kernel-config /kernel-config +COPY --from=kernel-build /build/vmlinux /kernel/vmlinux +COPY --from=kernel-build /build/kernel-config /kernel/kernel-config diff --git a/kernel/Taskfile.yml b/kernel/Taskfile.yml index 60efdac..9fcaf07 100644 --- a/kernel/Taskfile.yml +++ b/kernel/Taskfile.yml @@ -39,7 +39,7 @@ tasks: cmds: - | set -euo pipefail - k={{.OUTPUT_ABS}}/vmlinux + k={{.OUTPUT_ABS}}/kernel/vmlinux test -f "$k" || { echo "no kernel at $k — run: task kernel:build" >&2; exit 1; } # The PVH notes are the entry point: there is no bootloader and no UEFI here, and # QEMU enters this ELF through pvh.bin using them. The build strips the symbol diff --git a/machine/machine.go b/machine/machine.go index bc38ec1..89fa3c7 100644 --- a/machine/machine.go +++ b/machine/machine.go @@ -645,6 +645,19 @@ func (s Spec) Fingerprint() (string, error) { shape := s.TemplateShape() h := sha256.New() + + // Length-prefixed, so that no two different machines can produce the same + // byte stream by moving a delimiter into a value. Every value here is + // something a caller supplies or a tool prints — a CPU model name, a device + // list, a line out of /proc/cpuinfo — and none of them is guaranteed to be + // free of the separator. Prefixing costs nothing and removes the question. + // + // The error is discarded because hash.Hash's Write never returns one; that is + // part of the interface's contract. + write := func(key, value string) { + _, _ = fmt.Fprintf(h, "%s=%d:%s\n", key, len(value), value) + } + for _, f := range []struct{ name, path string }{ {"qemu", s.QEMU}, {"kernel", s.Kernel}, @@ -653,18 +666,20 @@ func (s Spec) Fingerprint() (string, error) { if f.path == "" { // An absent initrd is part of the identity too: a machine that boots // one and a machine that does not are different machines. - fmt.Fprintf(h, "%s=none\n", f.name) + write(f.name, "none") continue } sum, err := fileSum(f.path) if err != nil { return "", fmt.Errorf("fingerprinting %s: %w", f.name, err) } - fmt.Fprintf(h, "%s=%s\n", f.name, sum) + write(f.name, sum) } - fmt.Fprintf(h, "machine=%s\ncpu=%s\nsmp=%s\nmemory=%s\n", - shape.Machine, shape.CPU, shape.SMP, shape.Memory) - fmt.Fprintf(h, "devices=%s\n", s.topology()) + write("machine", shape.Machine) + write("cpu", shape.CPU) + write("smp", shape.SMP) + write("memory", shape.Memory) + write("devices", s.topology()) // The host's own CPU, but only when the guest is being shown it. // @@ -685,7 +700,7 @@ func (s Spec) Fingerprint() (string, error) { return "", fmt.Errorf("fingerprinting the host CPU, which model %q exposes to the guest: %w", strings.SplitN(shape.CPU, ",", 2)[0], err) } - fmt.Fprintf(h, "host-cpu=%s\n", cpu) + write("host-cpu", cpu) } return hex.EncodeToString(h.Sum(nil)), nil @@ -709,7 +724,14 @@ func (s Spec) Fingerprint() (string, error) { // // The files and identifiers, because two VMs with one disk each are the same // machine whether that disk is a database or a scratch overlay — that is the -// whole reason a template is worth having. +// whole reason a template is worth having. Each of the three is a thing a +// restore is known to be allowed to differ in, and each for its own reason: the +// vsock context id is not carried in the migration stream at all, and the guest +// re-reads it when QEMU resets the transport after the restore; a disk is +// cold-plugged onto the running guest afterwards and found with a PCI rescan; +// and what sits behind a NIC is a host-side file descriptor the guest never +// sees. What is present here is the device *model at its slot*, which is what +// the state being loaded describes. // // The disks and NICs, because a machine restored from a template does not have // them yet. A template is built from the emptiest VM there is — it does not know diff --git a/machine/release.go b/machine/release.go new file mode 100644 index 0000000..0d8c3a4 --- /dev/null +++ b/machine/release.go @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: Apache-2.0 + +package machine + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" +) + +// A release is one directory tree, and this is the only description of its +// shape. +// +// It is here rather than in each consumer because the alternative was measured: +// the same four files had three layouts — the tarball's, the one a fetch script +// rearranged them into, and the one a program went looking for — with a +// translation step between each pair and a discovery function guessing which it +// had been handed. Every one of those was a place the answer could be wrong, and +// the way it went wrong was a path that existed and held the previous release's +// kernel. There is one layout now: whatever the tarball unpacks to, read as it +// lies. +const ( + qemuName = "bin/qemu-system-x86_64" + qemuTCGName = "bin/qemu-system-x86_64-tcg" + qemuImgName = "bin/qemu-img" + firmwareDir = "qemu" + kernelName = "kernel/vmlinux" + rootfsName = "image/rootfs.qcow2" + manifest = "machine.env" +) + +// Release is an unpacked spin-machine release: the tree the tarball holds under +// usr/share/spin-stack. +// +// It answers where the parts of one machine are, and nothing else. It does not +// download, unpack, verify a signature or decide which version to use — a +// release arrives by whatever means its consumer already has, and by the time +// this opens one the question is only whether it is whole. +type Release struct { + dir string + env map[string]string +} + +// Open reads the release tree at dir and reports what is missing, if anything. +// +// It checks rather than trusting, because a release with one file absent is +// worse than no release: it installs, and the gap surfaces later as a QEMU that +// exits for want of an option ROM or a kernel that is the previous version. The +// cost of finding out here is four stats. +// +// The TCG binary and the root filesystem are not required. A host that only ever +// runs guests under KVM needs neither, and refusing to start for the want of a +// 900 MB file it will not open would be a check that costs more than it saves — +// Rootfs and QEMUTCG report their own absence to whoever asks for them. +func Open(dir string) (*Release, error) { + abs, err := filepath.Abs(dir) + if err != nil { + return nil, fmt.Errorf("resolving release directory %s: %w", dir, err) + } + + r := &Release{dir: abs} + for _, want := range []string{qemuName, qemuImgName, kernelName, firmwareDir + "/pvh.bin"} { + p := filepath.Join(abs, want) + if _, err := os.Stat(p); err != nil { + return nil, fmt.Errorf("%s is not a whole machine: %w", abs, err) + } + } + + r.env, err = readEnv(filepath.Join(abs, manifest)) + if err != nil { + return nil, err + } + return r, nil +} + +// Dir is the root of the release tree. +func (r *Release) Dir() string { return r.dir } + +// QEMU is the emulator every guest runs under: KVM only, no TCG. +func (r *Release) QEMU() string { return filepath.Join(r.dir, qemuName) } + +// QEMUImg creates and rebases the qcow2 files a guest's disks are. +func (r *Release) QEMUImg() string { return filepath.Join(r.dir, qemuImgName) } + +// Firmware is the directory QEMU loads option ROMs and BIOS blobs from, which is +// what Spec.Firmware wants. Without it the machine cannot find pvh.bin, and PVH +// is the only way into this kernel. +func (r *Release) Firmware() string { return filepath.Join(r.dir, firmwareDir) } + +// Kernel is the guest kernel. +func (r *Release) Kernel() string { return filepath.Join(r.dir, kernelName) } + +// QEMUTCG is the emulating build, for a machine with no /dev/kvm — a CI runner, +// a laptop outside the kvm group. It is a different binary and not a flag +// because a host serving tenants should not be able to fall back to software +// emulation by accident: that failure presents as a VM that is fifty times +// slower, not as one that did not start. +// +// It is not part of a machine's identity and must never run one that a KVM guest +// will later restore from. +func (r *Release) QEMUTCG() (string, error) { return r.optional(qemuTCGName, "the TCG build of QEMU") } + +// Rootfs is the read-only base image every workspace overlays. +// +// Never opened for writing: many VMs map this one file through a qcow2 backing +// chain, and a write to it invalidates every overlay in existence silently — +// they keep working until they read a cluster that moved. A new image is written +// beside it and renamed over it. +func (r *Release) Rootfs() (string, error) { return r.optional(rootfsName, "the base image") } + +func (r *Release) optional(name, what string) (string, error) { + p := filepath.Join(r.dir, name) + if _, err := os.Stat(p); err != nil { + return "", fmt.Errorf("%s is not in this release: %w", what, err) + } + return p, nil +} + +// Version is the release's own version, as machine.env records it. Empty if the +// tree has no manifest. +// +// It names a build for a human reading a log. It is not what decides whether a +// template may be restored — Fingerprint is, by content — so two hosts agreeing +// on this string is evidence and not proof. +func (r *Release) Version() string { return r.env["version"] } + +// Spec returns the parts of a machine this release supplies: the three paths +// that come out of the tarball. The caller fills in everything that is about one +// VM rather than about the machine — memory, CPU count, disks, vsock, console — +// and an initrd, which a release does not carry because what runs as PID 1 is +// the caller's business. +func (r *Release) Spec() Spec { + return Spec{ + QEMU: r.QEMU(), + Kernel: r.Kernel(), + Firmware: r.Firmware(), + } +} + +// readEnv parses machine.env: key=value lines, # comments, no quoting. A tree +// without one is not an error — a developer's build directory is a release for +// every purpose except being named. +func readEnv(path string) (map[string]string, error) { + f, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return map[string]string{}, nil + } + return nil, fmt.Errorf("reading the release manifest: %w", err) + } + defer f.Close() + + env := map[string]string{} + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if k, v, ok := strings.Cut(line, "="); ok { + env[strings.TrimSpace(k)] = strings.TrimSpace(v) + } + } + if err := sc.Err(); err != nil { + return nil, fmt.Errorf("reading %s: %w", path, err) + } + return env, nil +} diff --git a/machine/release_test.go b/machine/release_test.go new file mode 100644 index 0000000..2d92bd5 --- /dev/null +++ b/machine/release_test.go @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: Apache-2.0 + +package machine + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// tree writes a release-shaped directory containing exactly the given files. +func tree(t *testing.T, files ...string) string { + t.Helper() + dir := t.TempDir() + for _, f := range files { + p := filepath.Join(dir, f) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(f), 0o644); err != nil { + t.Fatal(err) + } + } + return dir +} + +var whole = []string{qemuName, qemuImgName, kernelName, firmwareDir + "/pvh.bin"} + +func TestOpenNamesWhatIsMissing(t *testing.T) { + // One file at a time, because a release with a hole in it is the failure + // this exists to turn into a sentence: the message has to say which file. + for _, absent := range whole { + var kept []string + for _, f := range whole { + if f != absent { + kept = append(kept, f) + } + } + if _, err := Open(tree(t, kept...)); err == nil { + t.Errorf("Open succeeded on a tree with no %s", absent) + } else if !strings.Contains(err.Error(), filepath.Base(absent)) { + t.Errorf("missing %s: error does not name it: %v", absent, err) + } + } +} + +func TestOpenAcceptsAMachineWithoutTheOptionalParts(t *testing.T) { + // The TCG binary and the base image are not required, and asking for one + // that is absent must fail where it is asked for rather than at Open. + r, err := Open(tree(t, whole...)) + if err != nil { + t.Fatalf("Open on a whole machine: %v", err) + } + if _, err := r.Rootfs(); err == nil { + t.Error("Rootfs returned a path for an image that is not there") + } + if _, err := r.QEMUTCG(); err == nil { + t.Error("QEMUTCG returned a path for a binary that is not there") + } + if v := r.Version(); v != "" { + t.Errorf("Version of a tree with no manifest = %q, want empty", v) + } +} + +func TestSpecTakesItsPathsFromTheRelease(t *testing.T) { + dir := tree(t, append(whole, rootfsName, "machine.env")...) + if err := os.WriteFile(filepath.Join(dir, "machine.env"), + []byte("# a comment\nversion=v20260908.01\nkernel_version = 6.19.2\n"), 0o644); err != nil { + t.Fatal(err) + } + + r, err := Open(dir) + if err != nil { + t.Fatal(err) + } + if got := r.Version(); got != "v20260908.01" { + t.Errorf("Version = %q", got) + } + if got := r.env["kernel_version"]; got != "6.19.2" { + t.Errorf("kernel_version = %q, want the value with its spaces trimmed", got) + } + + s := r.Spec() + for _, c := range []struct{ name, got, want string }{ + {"QEMU", s.QEMU, filepath.Join(dir, qemuName)}, + {"Kernel", s.Kernel, filepath.Join(dir, kernelName)}, + {"Firmware", s.Firmware, filepath.Join(dir, firmwareDir)}, + } { + if c.got != c.want { + t.Errorf("Spec().%s = %q, want %q", c.name, c.got, c.want) + } + } + // The rest of a machine is about one VM and is the caller's to fill in. + if s.Initrd != "" || s.Memory.SizeMB != 0 || len(s.Disks) != 0 { + t.Errorf("Spec() decided something a release does not know: %+v", s) + } +} diff --git a/qemu/Dockerfile b/qemu/Dockerfile index 13145d8..2108455 100644 --- a/qemu/Dockerfile +++ b/qemu/Dockerfile @@ -416,4 +416,4 @@ FROM scratch AS extract COPY --from=builder /opt/qemu/bin/qemu-system-x86_64 /bin/qemu-system-x86_64 COPY --from=builder /opt/qemu-tcg/bin/qemu-system-x86_64 /bin/qemu-system-x86_64-tcg COPY --from=builder /opt/qemu/bin/qemu-img /bin/qemu-img -COPY --from=builder /build/pc-bios/ /share/spin-stack/qemu/ +COPY --from=builder /build/pc-bios/ /qemu/ diff --git a/qemu/Taskfile.yml b/qemu/Taskfile.yml index 3c933e1..aa0f3b0 100644 --- a/qemu/Taskfile.yml +++ b/qemu/Taskfile.yml @@ -20,7 +20,7 @@ tasks: # a firmware blob this build no longer produces stays on disk, indistinguishable from # one it does. That is how a 331 MB firmware tree outlived the build that stopped # shipping it, and how a qemu-nbd would outlive the decision to stop shipping one. - - rm -rf {{.OUTPUT_ABS}}/bin/qemu-* {{.OUTPUT_ABS}}/share/spin-stack/qemu + - rm -rf {{.OUTPUT_ABS}}/bin/qemu-* {{.OUTPUT_ABS}}/qemu - mkdir -p {{.OUTPUT_ABS}} - | docker buildx build \ @@ -56,7 +56,7 @@ tasks: # efi-virtio.rom is the option ROM of the one NIC this build keeps; a VM given a # virtio-net-pci without it does not start. for f in bios.bin bios-256k.bin pvh.bin kvmvapic.bin efi-virtio.rom; do - test -f {{.OUTPUT_ABS}}/share/spin-stack/qemu/$f || { + test -f {{.OUTPUT_ABS}}/qemu/$f || { echo "missing firmware $f — run: task qemu:build" >&2; exit 1; } done # Run, not just present. A binary that cannot start on the host that extracted it