Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/kernel.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion .github/workflows/qemu.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<version>-<arch>
image/ mkosi configuration producing base.qcow2 (ext4 inside)
image/ mkosi configuration producing rootfs.qcow2 (ext4 inside)
hack/ release
```

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
58 changes: 34 additions & 24 deletions cmd/spin-machine/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: <release>/bin/qemu-system-x86_64)")
fs.StringVar(&o.kernel, "kernel", "", "kernel image (default: <release>/vmlinux)")
fs.StringVar(&o.initrd, "initrd", "", "initrd (default: none)")
fs.StringVar(&o.firmware, "firmware", "", "firmware directory (default: <release>/share/spin-stack/qemu)")
fs.StringVar(&o.firmware, "firmware", "", "firmware directory (default: <release>/qemu)")

fs.StringVar(&o.disk, "disk", "", "disk image (default: <release>/base.qcow2)")
fs.StringVar(&o.disk, "disk", "", "disk image (default: <release>/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")
Expand Down Expand Up @@ -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
}
Expand All @@ -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,
Expand Down
27 changes: 7 additions & 20 deletions hack/release
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"

Expand All @@ -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"
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion image/Dockerfile
Original file line number Diff line number Diff line change
@@ -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`:
Expand Down
14 changes: 7 additions & 7 deletions image/Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
Expand Down Expand Up @@ -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

Expand All @@ -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"
Expand Down Expand Up @@ -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"
23 changes: 14 additions & 9 deletions image/build.sh
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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.
#
Expand All @@ -19,15 +19,20 @@
# 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
# two builds days apart differ for reasons this variable does not touch. What a given
# 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"
Expand Down Expand Up @@ -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 --------------------------------------------------------------
#
Expand All @@ -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 -------------------------------------------------------------------------
#
Expand Down
11 changes: 7 additions & 4 deletions kernel/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading