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
16 changes: 16 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,22 @@ tasks:
# Build
# ==========================================================================

apk:lock:
desc: Pin every package QEMU's and e2fsprogs' builders install (each part's apk.list) at what the pinned Alpine's mirror has today, into its apk.lock.
# A fresh install of the list on the pinned Alpine, its database read back: the versions the
# mirror serves now, which is what a bump means. A new lock is a new QEMU or e2fsprogs build,
# and a changed QEMU is a changed machine: hack/fingerprint-diff says so in the release.
cmds:
- for: [qemu, e2fsprogs]
cmd: |
set -euo pipefail
db=$(mktemp)
docker run --rm -v {{.ROOT_DIR}}/{{.ITEM}}/apk.list:/apk.list:ro $({{.VERSIONS}} ref alpine) \
sh -c 'apk add --no-cache $(grep -v "^#" /apk.list) > /dev/null && cat /lib/apk/db/installed' > "$db"
go run ./hack/apklock "$db" > {{.ITEM}}/apk.lock
rm -f "$db"
echo "{{.ITEM}}/apk.lock: $(wc -l < {{.ITEM}}/apk.lock) packages"

build:
desc: Build QEMU, the guest kernel, e2fsprogs, the base image and the tools into _output/
cmds:
Expand Down
9 changes: 8 additions & 1 deletion docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,14 @@ recorded - the apk databases QEMU and e2fsprogs were statically linked in (each
component with no version fails the release. It is inside the tarball at
`usr/share/spin-stack/sbom/machine.cdx.json`, beside it as `<tarball>.cdx.json`, and attested
over the tarball as `<tarball>.cdx.sigstore.json` (`cosign verify-blob-attestation --type
cyclonedx`, with the identity above).
cyclonedx`, with the identity above). QEMU, the kernel and qboot carry their patches as pedigree:
each patch file by name and hash, so the SBOM says the binary is upstream's source plus these.

The builders' packages are a commit's, not the mirror's on the day: `qemu/apk.list` and
`e2fsprogs/apk.list` say what each installs, and `qemu/apk.lock` and `e2fsprogs/apk.lock` pin every
package installed, dependencies included, at a version. A pin Alpine has dropped from its mirror
fails a cold build at `apk add`; `task apk:lock` writes both locks again from a fresh install on the
pinned Alpine, and the diff is the review.
`workflows/workflows_test.go` holds every workflow to attesting before it publishes.

## The feature matrix
Expand Down
10 changes: 5 additions & 5 deletions e2fsprogs/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,12 @@ ARG JOBS=8

SHELL ["/bin/sh", "-o", "pipefail", "-c"]

# e2fsprogs/apk.list says what; e2fsprogs/apk.lock the version of everything installed, so the
# libraries the static tools link are a commit's. A version the mirror no longer has fails here:
# task apk:lock moves the lock.
COPY e2fsprogs/apk.list e2fsprogs/apk.lock /tmp/apk/
RUN --mount=type=cache,sharing=locked,id=e2fsprogs-apk,target=/var/cache/apk \
apk add --cache-dir /var/cache/apk \
build-base linux-headers pkgconf curl xz binutils \
libarchive-dev libarchive-static \
acl-static zlib-static xz-static zstd-static bzip2-static lz4-static \
expat-static openssl-libs-static
apk add --cache-dir /var/cache/apk $(grep -v '^#' /tmp/apk/apk.list) $(cat /tmp/apk/apk.lock)

WORKDIR /build

Expand Down
6 changes: 6 additions & 0 deletions e2fsprogs/apk.list
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# What e2fsprogs' builder installs, each package at the version e2fsprogs/apk.lock pins
# (task apk:lock). The -static ones are what the tools link.
build-base linux-headers pkgconf curl xz binutils
libarchive-dev libarchive-static
acl-static zlib-static xz-static zstd-static bzip2-static lz4-static
expat-static openssl-libs-static
75 changes: 75 additions & 0 deletions e2fsprogs/apk.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
acl-dev=2.3.2-r1
acl-libs=2.3.2-r1
acl-static=2.3.2-r1
alpine-baselayout-data=3.7.2-r1
alpine-baselayout=3.7.2-r1
alpine-keys=2.6-r0
alpine-release=3.24.2-r0
apk-tools=3.0.8-r0
binutils=2.45.1-r1
brotli-libs=1.2.0-r1
build-base=0.5-r4
busybox-binsh=1.37.0-r31
busybox=1.37.0-r31
bzip2-dev=1.0.8-r6
bzip2-static=1.0.8-r6
c-ares=1.34.8-r0
ca-certificates-bundle=20260909-r0
curl=8.22.0-r0
expat-dev=2.8.5-r0
expat-static=2.8.5-r0
expat=2.8.5-r0
file=5.47-r2
fortify-headers=3.0.1-r2
g++=15.2.0-r5
gcc=15.2.0-r5
gmp=6.3.0-r4
isl26=0.26-r2
jansson=2.15.0-r0
libapk=3.0.8-r0
libarchive-dev=3.8.7-r0
libarchive-static=3.8.7-r0
libarchive=3.8.7-r0
libatomic=15.2.0-r5
libbz2=1.0.8-r6
libcrypto3=3.5.9-r0
libcurl=8.22.0-r0
libexpat=2.8.5-r0
libgcc-static=15.2.0-r5
libgcc=15.2.0-r5
libgomp=15.2.0-r5
libidn2=2.3.8-r0
libmagic=5.47-r2
libpsl=0.21.5-r3
libssl3=3.5.9-r0
libstdc++-dev=15.2.0-r5
libstdc++=15.2.0-r5
libunistring=1.4.2-r0
linux-headers=7.0.0-r1
lz4-dev=1.10.0-r1
lz4-libs=1.10.0-r1
lz4-static=1.10.0-r1
make=4.4.1-r4
mpc1=1.3.1-r1
mpfr4=4.2.2-r0
musl-dev=1.2.6-r2
musl-utils=1.2.6-r2
musl=1.2.6-r2
nghttp2-libs=1.70.0-r0
openssl-dev=3.5.9-r0
openssl-libs-static=3.5.9-r0
patch=2.8-r0
pkgconf=2.5.1-r0
scanelf=1.3.9-r1
ssl_client=1.37.0-r31
xz-dev=5.8.4-r0
xz-libs=5.8.4-r0
xz-static=5.8.4-r0
xz=5.8.4-r0
zlib-dev=1.3.2-r1
zlib-static=1.3.2-r1
zlib=1.3.2-r1
zstd-dev=1.5.7-r2
zstd-libs=1.5.7-r2
zstd-static=1.5.7-r2
zstd=1.5.7-r2
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ tool (
)

require (
github.com/spin-stack/go-tools v0.0.0-20261013153230-6e2b16ee38be
github.com/spin-stack/go-tools v0.0.0-20261013164036-99d744796066
golang.org/x/sys v0.48.0
)

Expand All @@ -23,5 +23,5 @@ require (
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/mod v0.41.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/tools v0.50.0 // indirect
golang.org/x/tools v0.51.0 // indirect
)
12 changes: 6 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ github.com/in-toto/attestation v1.2.0 h1:aPRUZ3azbqD7yEBD5fP3TD8Dszf+YHo284SOcpa
github.com/in-toto/attestation v1.2.0/go.mod h1:r79G45gOmzPismgObLSL+rZTFxUgZLOQJI6LofTZgXk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/spin-stack/go-tools v0.0.0-20261013153230-6e2b16ee38be h1:pi7rsTCKW9m8ZYpPY/dk1di7/RYhM5fepPrh001sJI8=
github.com/spin-stack/go-tools v0.0.0-20261013153230-6e2b16ee38be/go.mod h1:cKPqYf5z2CcqsABqoifumRkiEEYSCBlRCwVNP+3Hz5A=
github.com/spin-stack/go-tools v0.0.0-20261013164036-99d744796066 h1:T4uAeYrQ9e9Fml3PcZGzgji5sIAoebXK9zy94u5IBwg=
github.com/spin-stack/go-tools v0.0.0-20261013164036-99d744796066/go.mod h1:cy1ojfIbEjrfZi0lGzE+wjueaWw1jrotHQ+vyFz9pZQ=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/terminalstatic/go-xsd-validate v0.1.8 h1:UVrTCy1j3DhwaYTTUF+QYO/Nan13S0tf+Jwi+p45Bf0=
Expand All @@ -30,7 +30,7 @@ golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
80 changes: 80 additions & 0 deletions hack/apklock/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// SPDX-License-Identifier: Apache-2.0

// Command apklock writes the lock of an Alpine builder: every package an apk database says is
// installed, as name=version, sorted - what the builder's `apk add` is then held to, so the static
// libraries QEMU and e2fsprogs link are the versions a commit names, not whatever the mirror held
// that day. task apk:lock runs it over a fresh install of the part's apk.list.
//
// go run ./hack/apklock <installed db> > <part>/apk.lock
package main

import (
"fmt"
"io"
"os"
"slices"
"strings"
)

func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) // mutate-exempt: the process's own arguments, past its name; run is what the tests drive
}

// run answers the exit status: 0 the lock written, 2 no database to read.
func run(args []string, stdout, stderr io.Writer) int {
if len(args) != 1 {
_, _ = fmt.Fprintln(stderr, "apklock: <installed db>")
return 2
}
raw, err := os.ReadFile(args[0])
if err != nil {
_, _ = fmt.Fprintln(stderr, "apklock:", err)
return 2
}
lock, err := Lock(string(raw))
if err != nil {
_, _ = fmt.Fprintln(stderr, "apklock:", err)
return 2
}
_, _ = io.WriteString(stdout, lock)
return 0
}

// Lock is an apk installed database's packages as name=version lines, sorted.
func Lock(db string) (string, error) {
var pins []string
for _, block := range strings.Split(db, "\n\n") {
var name, version string
for _, line := range strings.Split(block, "\n") {
switch k, v, _ := strings.Cut(line, ":"); k {
case "P":
name = v
case "V":
version = v
}
}
switch {
case name == "" && version == "":
continue
case name == "" || version == "":
return "", fmt.Errorf("a package with name %q and version %q", name, version)
}
pins = append(pins, name+"="+version)
}
if len(pins) == 0 {
return "", fmt.Errorf("no package installed")
}
slices.Sort(pins)
return strings.Join(pins, "\n") + "\n", nil
}

// Listed is the package names of an apk.list: one or more a line, # comments and blank lines left
// out.
func Listed(list string) []string {
var names []string
for _, line := range strings.Split(list, "\n") {
line, _, _ = strings.Cut(line, "#")
names = append(names, strings.Fields(line)...)
}
return names
}
79 changes: 79 additions & 0 deletions hack/apklock/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// SPDX-License-Identifier: Apache-2.0

package main

import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)

// A lock is every installed package, name=version, sorted; a package with half its record is no
// lock, and neither is none.
func TestALockIsEveryInstalledPackage(t *testing.T) {
db := "C:Q1abc=\nP:zlib-static\nV:1.3.1-r2\nA:x86_64\n\nP:glib-static\nV:2.88.1-r1\n\n"
got, err := Lock(db)
if err != nil || got != "glib-static=2.88.1-r1\nzlib-static=1.3.1-r2\n" {
t.Fatalf("lock %q, %v", got, err)
}
for name, db := range map[string]string{
"no version": "P:glib\n",
"no name": "V:1\n",
"nothing": "\n",
"one whole, one half": "P:musl\nV:1.2.5-r10\n\nP:glib\n",
} {
if _, err := Lock(db); err == nil {
t.Errorf("%s: locked", name)
}
}
}

// The command writes the lock of the database it is given, and nothing for none.
func TestTheCommandWritesTheLock(t *testing.T) {
path := filepath.Join(t.TempDir(), "installed")
if err := os.WriteFile(path, []byte("P:musl\nV:1.2.5-r10\n"), 0o600); err != nil {
t.Fatal(err)
}
var out, errs bytes.Buffer
if run([]string{path}, &out, &errs) != 0 || out.String() != "musl=1.2.5-r10\n" {
t.Fatalf("wrote %q: %s", out.String(), errs.String())
}
broken := filepath.Join(t.TempDir(), "broken")
if err := os.WriteFile(broken, []byte("P:musl\n"), 0o600); err != nil {
t.Fatal(err)
}
for _, args := range [][]string{nil, {filepath.Join(t.TempDir(), "none")}, {broken}} {
if run(args, &out, &errs) != 2 {
t.Errorf("%v: no refusal", args)
}
}
}

// Each builder's lock pins every package its list asks for: a name added to a list and not
// locked would be installed at whatever version the mirror has.
func TestEachListIsLocked(t *testing.T) {
for _, part := range []string{"qemu", "e2fsprogs"} {
list, err := os.ReadFile(filepath.Join("..", "..", part, "apk.list"))
if err != nil {
t.Fatal(err)
}
lock, err := os.ReadFile(filepath.Join("..", "..", part, "apk.lock"))
if err != nil {
t.Fatal(err)
}
names := Listed(string(list))
if len(names) == 0 {
t.Fatalf("%s's list names nothing", part)
}
for _, n := range names {
if !strings.Contains("\n"+string(lock), "\n"+n+"=") {
t.Errorf("%s/apk.list asks for %s, which %s/apk.lock does not pin: task apk:lock", part, n, part)
}
}
}
if got := Listed("# a comment\nglib-static pixman-static # why\n\nzlib-static\n"); strings.Join(got, " ") != "glib-static pixman-static zlib-static" {
t.Errorf("listed %q", got)
}
}
17 changes: 13 additions & 4 deletions hack/release
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ pins=$(go tool versions env qemu kernel e2fsprogs qboot)
while IFS='=' read -r key value; do
declare "${key}=${value}"
done <<< "${pins}"
# Where each tarball is fetched from, as the pin says: SOURCES names the URL the build checked the
# sha256 of, never one written here, which named a mirror the kernel was not on.
QEMU_URL=$(go tool versions url qemu)
KERNEL_URL=$(go tool versions url kernel)
E2FSPROGS_URL=$(go tool versions url e2fsprogs)

RELEASE_NAME="spin-machine-${VERSION}-linux-${KERNEL_ARCH}"
RELEASE_DIR="${OUTPUT_DIR}/release/${RELEASE_NAME}"
Expand Down Expand Up @@ -170,7 +175,7 @@ QEMU ${QEMU_VERSION}
usr/share/spin-stack/bin/qemu-system-x86_64-tcg
usr/share/spin-stack/bin/qemu-img
firmware: usr/share/spin-stack/qemu/*, except patches/, qboot.bin and the qboot files below
source: https://download.qemu.org/qemu-${QEMU_VERSION}.tar.xz
source: ${QEMU_URL}
sha256: ${QEMU_SHA256}
built by: qemu/Dockerfile, with usr/share/spin-stack/qemu/patches applied to that source
(each says what it changes and why) and the configure flags in that file
Expand All @@ -186,14 +191,14 @@ qboot ${QBOOT_COMMIT}
e2fsprogs ${E2FSPROGS_VERSION}
binaries: usr/share/spin-stack/bin/mkfs.ext4, debugfs, e2fsck, dumpe2fs
config: usr/share/spin-stack/e2fsprogs/mke2fs.conf, upstream's, from the same tarball
source: https://mirrors.edge.kernel.org/pub/linux/kernel/people/tytso/e2fsprogs/v${E2FSPROGS_VERSION}/e2fsprogs-${E2FSPROGS_VERSION}.tar.xz
source: ${E2FSPROGS_URL}
sha256: ${E2FSPROGS_SHA256}
built by: e2fsprogs/Dockerfile, unpatched, statically linked against the Alpine libraries
named there; their sources are the Alpine package sources for that release

Linux ${KERNEL_VERSION}
binary: usr/share/spin-stack/kernel/vmlinux
source: https://cdn.kernel.org/pub/linux/kernel/v${KERNEL_VERSION%%.*}.x/linux-${KERNEL_VERSION}.tar.xz
source: ${KERNEL_URL}
sha256: ${KERNEL_SHA256}
built by: kernel/Dockerfile, with the patches shipped alongside the binary in
usr/share/spin-stack/kernel/patches applied to that source (each says what
Expand Down Expand Up @@ -242,7 +247,11 @@ sbom apk -name e2fsprogs-libraries -version "${E2FSPROGS_VERSION}" -distro alpin
-only libarchive-static,acl-static,zlib-static,xz-static,zstd-static,bzip2-static,lz4-static,expat-static,openssl-libs-static,musl-dev,gcc \
-out "${PARTS}/e2fsprogs-libraries.cdx.json" "${OUTPUT_DIR}/sbom/e2fsprogs.apk"
sbom dpkg -name base-image -version "${VERSION}" -distro ubuntu -out "${PARTS}/base-image.cdx.json" "${SHARE}/packages.txt"
sbom pins -name sources -version "${VERSION}" -out "${PARTS}/sources.cdx.json" qemu qboot e2fsprogs kernel ubuntu task git-lfs buildkit
# QEMU, qboot and the kernel are built with this repository's patches: each is in its component's
# pedigree, so the SBOM does not say upstream's source is what was compiled.
sbom pins -name sources -version "${VERSION}" -out "${PARTS}/sources.cdx.json" \
-patches qemu=qemu/patches -patches qboot=qemu/qboot -patches kernel="${OUTPUT_DIR}/kernel/patches" \
qemu qboot e2fsprogs kernel ubuntu task git-lfs buildkit
# The files themselves, by their hashes: the firmware is the QEMU tarball's own build of it.
for f in "${SHARE}"/bin/qemu-system-x86_64 "${SHARE}"/bin/qemu-system-x86_64-tcg "${SHARE}"/bin/qemu-img \
"${SHARE}"/qemu/bios.bin "${SHARE}"/qemu/bios-256k.bin "${SHARE}"/qemu/pvh.bin "${SHARE}"/qemu/kvmvapic.bin "${SHARE}"/qemu/efi-virtio.rom; do
Expand Down
Loading