-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathTaskfile.yml
More file actions
334 lines (300 loc) · 15.7 KB
/
Copy pathTaskfile.yml
File metadata and controls
334 lines (300 loc) · 15.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
# spin-machine — a virtual machine: QEMU, the guest kernel, the base image, and the
# definition of the machine they make.
#
# The four are one thing. A VM restored from a template loads device and CPU state into a
# machine that has to be the same shape as the one the template was frozen from, and
# nothing checks that at run time. So `machine.Spec.Fingerprint` hashes the QEMU binary,
# the kernel and the initrd by content together with the machine's shape, and a release in
# which any of them moved has a different fingerprint by construction. That is why they
# ship as one tarball with one version: shipped separately, two could drift and leave the
# third to notice.
#
# task build everything, into _output/
# task shell boot the machine and look around inside it
# task test the machine definition
# task release one tarball, one version
#
# What is in this file is the vars every part reads and the targets that cross all of them.
# Each part's own targets live beside the thing they build — qemu/Taskfile.yml,
# kernel/Taskfile.yml, image/Taskfile.yml — namespaced, so `task qemu:build` is next to
# qemu/Dockerfile and nobody reads three hundred lines to change one flag.
#
# What is deliberately NOT here: the software that runs inside a guest. This repository
# builds a machine and knows nothing about what boots on it, and there is no longer an
# exception to that — the debug initramfs that used to be one was a second init, doing what
# the init a consumer already brings does, and the machine boots straight into /sbin/init
# without it.
version: '3'
silent: true
output: prefixed
includes:
qemu:
taskfile: qemu/Taskfile.yml
# The build context is the repository root: the Dockerfiles COPY qemu/devices.mak and
# image/mkosi.extra by their path from here, so every part sees the same tree and no
# `..` appears in a COPY.
dir: '{{.ROOT_DIR}}'
kernel:
taskfile: kernel/Taskfile.yml
dir: '{{.ROOT_DIR}}'
image:
taskfile: image/Taskfile.yml
dir: '{{.ROOT_DIR}}'
vars:
ROOT_DIR:
sh: pwd
OUTPUT_DIR: '{{.OUTPUT_DIR | default "_output"}}'
# The same directory, absolute, resolved once.
#
# Everything handed to `docker -v`, to a VM, or to a checksum has to be an absolute path,
# and `{{.OUTPUT_ABS}}` is only one when OUTPUT_DIR is relative. With
# OUTPUT_DIR=/tmp/build it produced <repository>/tmp/build, so QEMU and the kernel were
# extracted to /tmp/build and the base image was written somewhere else entirely — a
# build that succeeds and leaves half its output where nothing looks for it.
#
# -m so an output directory that does not exist yet still resolves.
OUTPUT_ABS:
sh: realpath -m "{{.OUTPUT_DIR}}"
# One version for all three artefacts. See the header.
VERSION:
sh: echo "${VERSION:-$(git describe --tags --exact-match 2>/dev/null || git rev-parse --short HEAD 2>/dev/null || echo dev)}"
# The version pins are NOT here. Each lives once, in the Dockerfile that builds the
# thing, and the Taskfile beside it reads that — see qemu/Taskfile.yml.
#
# They were here as well as there, which is one duplication and one worse thing: this
# file had to be in every workflow's path filter, since a version bump in it must rebuild
# what it pins. So `task lint` gaining a check, or a comment being fixed, fired QEMU, the
# kernel and the base image — three builds, tens of minutes each, for an edit that could
# not have changed any of them.
#
# What is left here is what does not change an artefact: how many jobs to compile with,
# where to put the output, which cache backend to use.
QEMU_JOBS: '{{.QEMU_JOBS | default 8}}'
KERNEL_ARCH: '{{.KERNEL_ARCH | default "x86_64"}}'
KERNEL_NPROC: '{{.KERNEL_NPROC | default 8}}'
# BuildKit cache backends. Local by default; CI overrides them with `type=gha` so the
# build is not duplicated in a workflow file — the Dockerfiles and the flags stay in one
# place, which is the rule these files exist for.
BUILDKIT_CACHE_DIR: '{{.BUILDKIT_CACHE_DIR | default (printf "%s/.cache/buildkit" .ROOT_DIR)}}'
QEMU_CACHE_FROM: '{{.QEMU_CACHE_FROM | default (printf "type=local,src=%s/qemu" .BUILDKIT_CACHE_DIR)}}'
QEMU_CACHE_TO: '{{.QEMU_CACHE_TO | default (printf "type=local,dest=%s/qemu,mode=max,compression=zstd" .BUILDKIT_CACHE_DIR)}}'
KERNEL_CACHE_FROM: '{{.KERNEL_CACHE_FROM | default (printf "type=local,src=%s/kernel" .BUILDKIT_CACHE_DIR)}}'
KERNEL_CACHE_TO: '{{.KERNEL_CACHE_TO | default (printf "type=local,dest=%s/kernel,mode=max,compression=zstd" .BUILDKIT_CACHE_DIR)}}'
# The container that builds the base image. Small — mkosi, e2fsprogs, qemu-utils — but it
# was the one buildx invocation here with no cache at all, so every CI run reinstalled it
# from the archive before any image work started.
IMAGE_CACHE_FROM: '{{.IMAGE_CACHE_FROM | default (printf "type=local,src=%s/image" .BUILDKIT_CACHE_DIR)}}'
IMAGE_CACHE_TO: '{{.IMAGE_CACHE_TO | default (printf "type=local,dest=%s/image,mode=max,compression=zstd" .BUILDKIT_CACHE_DIR)}}'
# mkosi's package cache, which is not a BuildKit cache because mkosi does not run under
# BuildKit (see image/Dockerfile). A rebuild of the base image is ~1.5 GB of apt.
MKOSI_CACHE_DIR: '{{.MKOSI_CACHE_DIR | default (printf "%s/.cache/mkosi" .ROOT_DIR)}}'
IMAGE_BUILDER_TAG: '{{.IMAGE_BUILDER_TAG | default "spin-machine/image-builder:dev"}}'
# Where the QEMU runtime image is published. The default is a local tag so
# `task qemu:image` needs no registry.
QEMU_IMAGE: '{{.QEMU_IMAGE | default "spin-stack/spin-machine-qemu"}}'
# An external runtime to check a release against, and the guest init it boots. Both
# empty by default: nothing here needs them, and `task verify:consumer` says so and
# skips rather than pointing at a path that may not exist.
#
# CONSUMER_RUNTIME is a command that takes `--image <qcow2>` and a `--` command to run
# in the guest, and CONSUMER_INITRD the initrd it expects to boot.
CONSUMER_RUNTIME: '{{.CONSUMER_RUNTIME | default ""}}'
CONSUMER_INITRD: '{{.CONSUMER_INITRD | default ""}}'
SUDO: '{{.SUDO | default "sudo"}}'
tasks:
default:
cmds:
- task --list
build:
desc: Build QEMU, the guest kernel, the base image and the tools into _output/
cmds:
- task: qemu:build
- task: kernel:build
- task: image:build
- task: tools
- cmd: echo "✓ build complete — {{.OUTPUT_ABS}}/"
tools:
desc: >-
Build spin-machine (starts a VM of this machine, prints its fingerprint) and the
debug initramfs `task shell` boots.
sources:
- machine/**/*.go
- cmd/**/*.go
- go.mod
generates:
- '{{.OUTPUT_ABS}}/bin/spin-machine'
cmds:
- mkdir -p {{.OUTPUT_ABS}}/bin
- CGO_ENABLED=0 go build -ldflags '-s -w' -o {{.OUTPUT_ABS}}/bin/spin-machine ./cmd/spin-machine
boot:bench:
desc: >-
What a boot costs, measured from the host and repeated enough to believe. Needs
/dev/kvm, a built release, and sudo — variants that mask a unit write into a throwaway
overlay through qemu-nbd. REPS= to change the count, DEBUG=1 for kernel and systemd
logging (which inflates what it measures, equally in every variant).
cmds:
- SPIN_BOOT_BENCH=1 go test ./boot/ -run TestBootCost -v -timeout 60m
boot:trace:
desc: >-
One boot's console printed against the host's clock, for finding a gap that belongs to
neither systemd nor the kernel. SPIN_GETTY_EXEC= replaces agetty to tell its own delays
apart from the tty's.
cmds:
- SPIN_BOOT_TRACE=1 go test ./boot/ -run TestBootTrace -v -timeout 10m
test:
desc: Test the machine definition.
cmds:
- go test ./...
lint:
desc: >-
Everything CI checks that is not a build: formatting, vet, the Taskfiles parse, and
every shell script is syntactically valid.
cmds:
- |
set -euo pipefail
unformatted=$(gofmt -l machine cmd)
if [ -n "$unformatted" ]; then
echo "not gofmt'd:"; echo "$unformatted"; exit 1
fi
echo "OK: gofmt"
- go vet ./...
- cmd: 'echo "OK: go vet"'
# `task --list` parses every included Taskfile, so a syntax error or a name defined
# twice fails here rather than in whichever lane first called it.
- task --list > /dev/null
- cmd: 'echo "OK: the Taskfiles parse"'
- |
set -euo pipefail
for f in hack/release image/build.sh image/mkosi.postinst.chroot \
image/mkosi.extra/usr/local/lib/spin-base/*.sh; do
bash -n "$f" || { echo "$f does not parse" >&2; exit 1; }
done
echo "OK: the shell scripts parse"
verify:args:
desc: >-
Ask the QEMU in _output/ whether it accepts the command line the machine package
builds. Every interesting Spec is started under the TCG binary, stopped before its
first instruction, and required to answer on QMP.
cmds:
# It crosses two parts — the machine definition and the binary — so it is here rather
# than in qemu/Taskfile.yml, and it refuses rather than skipping: the test itself
# skips when there is no binary, which is right for `go test ./...` in a source
# checkout and would be a gate that passes for the wrong reason here.
- |
set -euo pipefail
test -x {{.OUTPUT_ABS}}/bin/qemu-system-x86_64-tcg || {
echo "no {{.OUTPUT_ABS}}/bin/qemu-system-x86_64-tcg to ask." >&2
echo " task qemu:fetch the published build of the pinned version, seconds" >&2
echo " task qemu:build from source, tens of minutes" >&2
exit 1; }
# -count=1 because the answer depends on a file the test cache does not know about:
# a rebuilt QEMU with a device removed would otherwise be met with a cached pass.
# -v because what was rewritten for the TCG binary, and what a host without
# /dev/vhost-vsock left unchecked, is the part a reader has to see.
- SPIN_MACHINE_OUTPUT={{.OUTPUT_ABS}} go test ./machine -run TestQEMUAcceptsEveryArgument -count=1 -v
fingerprint:
desc: >-
Print this machine's identity: the hash of the QEMU binary, the kernel and the initrd
by content, together with the four arguments that decide the machine's shape. Two
machines with the same fingerprint can exchange templates.
deps: [tools]
cmds:
- '{{.OUTPUT_ABS}}/bin/spin-machine fingerprint --release {{.OUTPUT_ABS}} {{.CLI_ARGS}}'
shell:
desc: >-
Boot this machine and get a shell inside the base image, to see how it feels and what
it is missing. `task shell INIT=/sbin/init` boots systemd instead of a bare shell.
aliases: [vm]
interactive: true
cmds:
- task: image:shell
vars:
INIT: '{{.INIT}}'
# ==========================================================================
# Release
# ==========================================================================
release:
desc: Build everything and pack one versioned tarball.
cmds:
- task: build
# No QEMU_VERSION or KERNEL_VERSION: hack/release reads both from the Dockerfiles,
# the same way it already reads the tarball checksums. Passing them from here would
# put the pin back in this file, where a workflow would have to watch it.
- VERSION="{{.VERSION}}" OUTPUT_DIR="{{.OUTPUT_ABS}}" KERNEL_ARCH="{{.KERNEL_ARCH}}" hack/release
verify:consumer:
desc: >-
Check a release against an external runtime, if one is configured. Pass
CONSUMER_RUNTIME=<command> and CONSUMER_INITRD=<path>; with neither, this says so and
skips. Nothing in this repository needs it — it is the assertion that a release is
usable by something that boots guests.
cmds:
- task: release
- |
set -euo pipefail
if [ -z "{{.CONSUMER_RUNTIME}}" ]; then
echo "no CONSUMER_RUNTIME set — skipping. This repository builds a machine and"
echo "does not ship the software that boots on it; point this at a runtime to"
echo "check a release end to end:"
echo " task verify:consumer CONSUMER_RUNTIME=<cmd> CONSUMER_INITRD=<path>"
exit 0
fi
rel={{.OUTPUT_ABS}}/release/spin-machine-{{.VERSION}}-linux-{{.KERNEL_ARCH}}
test -f "$rel.tar.gz" || { echo "no release tarball at $rel.tar.gz" >&2; exit 1; }
# Removed with sudo: the VM below runs as root and leaves its state and log
# directories owned by root inside $stage, so a plain rm -rf leaves them and the
# trap prints permission errors after a run that succeeded.
stage=$(mktemp -d); trap '{{.SUDO}} rm -rf "$stage"' EXIT
tar -xzf "$rel.tar.gz" -C "$stage"
share="$stage/$(basename "$rel")/usr/share/spin-stack"
# A release is not bootable on its own, by design: the guest's init belongs to
# whoever runs guests. This is where the two halves are put together.
if [ -n "{{.CONSUMER_INITRD}}" ]; then
test -f "{{.CONSUMER_INITRD}}" || {
echo "no initrd at {{.CONSUMER_INITRD}}" >&2; exit 1; }
cp "{{.CONSUMER_INITRD}}" "$share/kernel/"
fi
# 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/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.
#
# One: does a write inside the guest reach the disk and come back? A boot that
# cannot write has a read-only overlay or no overlay at all, and prints exactly
# what a working one prints.
marker="spin-machine-$(date -u +%s)-$$"
before=$(sha256sum "$img" | cut -d' ' -f1)
out=$({{.SUDO}} SPIN_MACHINE_SHARE="$share" {{.CONSUMER_RUNTIME}} \
--image "$img" -- /bin/sh -c \
"echo $marker > /root/marker; sync; cat /root/marker" 2>/dev/null)
[ "$out" = "$marker" ] || {
echo "the guest could not write and read back a file; it printed: $out" >&2
exit 1; }
# Two: is that write private to the VM that made it? Every VM maps this one file
# through a backing chain, so a write that leaked out of an overlay is a container
# reading another container's disk.
leaked=$({{.SUDO}} SPIN_MACHINE_SHARE="$share" {{.CONSUMER_RUNTIME}} \
--image "$img" -- /bin/sh -c \
"cat /root/marker 2>/dev/null || echo absent" 2>/dev/null)
[ "$leaked" = "absent" ] || {
echo "a second VM saw the first one's write: $leaked" >&2
echo "the overlays are not isolated, or the base image was written to." >&2
exit 1; }
# Three: is the base itself untouched? It is the check that costs nothing and
# catches the worst failure — a base that moved under every overlay built on it.
after=$(sha256sum "$img" | cut -d' ' -f1)
[ "$before" = "$after" ] || {
echo "the base image changed during a boot: $before -> $after" >&2
echo "every overlay in existence is now built on a file that moved." >&2
exit 1; }
echo "OK: the packaged image boots under {{.CONSUMER_RUNTIME}}; writes land in the"
echo " overlay, do not leak to the next VM, and leave the base byte-identical"
clean:
desc: Remove build output. Leaves the caches; use clean:cache for those.
cmds:
- rm -rf {{.OUTPUT_ABS}}
clean:cache:
desc: Remove the BuildKit and mkosi caches. A full rebuild after this is hours.
cmds:
- rm -rf {{.BUILDKIT_CACHE_DIR}} {{.MKOSI_CACHE_DIR}}