- Status: done
- Date: 2026-05-27
- Specs touched:
TESTING.md,BOOT.md
Closes the "no real boot validation" gap called out in
boot-pipeline-units.md # Known
gaps. The dist/systemd/ units shipped in 0019 were hand-validated by
reading only; this slice puts them in front of a live systemd
inside systemd-nspawn --boot and asserts the shape TESTING.md
applying, synthetic targets activating in the right shape.
The fast lane established by nspawn-usermgr-lane.md ran a
Go test binary inside the namespace without systemd as PID 1. This
slice introduces the boot mode variant of that pattern — same
ephemeral-overlay hygiene, but with --boot so unit files actually
load and systemctl can answer questions.
The cached debian:bookworm rootfs from docker export is minimal —
no systemd, no /sbin/init. --boot needs both. Added
systemd-sysv to the apt install step. It pulls in systemd as a
dependency; ~30 MB on disk, harmless to the no-boot lanes.
Canary changed from a touch-only marker to a versioned content gate
(v2). The bootstrap rebuilds when the content doesn't match —
otherwise users with a slice-0018 rootfs would silently skip the new
install step and the boot lane would fail on a stale base. Refusing to
upgrade is loud (rebuilding log line), not silent.
Runs inside the booted nspawn under malmo-boot-test.service. Six
assertion groups:
systemd-analyze verifyparses every unit. (The bookworm systemd inside the container has theverifysubcommand; the host's older systemd 245 does not support--root, so the static check lives inside.)systemctl cat <unit>fordocker,smbd,avahi-daemonfinds each drop-in'smalmo-storage-ready.targetreference. Stub parent units (see below) make this work in a rootfs that lacks the real services.systemctl list-dependencies malmo-storage-ready.targetlistsmalmo-storage-verify.servicein its dependency tree.malmo-storage-verify.servicecarriesBefore=malmo-storage-ready.target.host-agent.servicecarriesAfter=malmo-storage-ready.target docker.service,OnFailure=malmo-recovery.target, andStartLimitBurst=5.systemctl start malmo-storage-verify.servicesucceeds, writes a parseable/run/malmo/health/storage.json, and the payload is Level-0-shaped (empty findings against a clean rootfs).
Verdict is written to /var/lib/malmo-boot-result (PASS or FAIL: <line>);
that file is bind-mounted RW from the host so the driver can read it
after the container powers off.
Mirrors run-usermgr-tests.sh's shape (caller resolution, Go binary
discovery under sudo, root check, bootstrap-if-absent). Then:
- Builds
malmo-storage-verifystatically (CGO_ENABLED=0) under the invoking user so the Go module cache stays user-owned. The Go build is needed because the rootfs has no Go toolchain — same pattern as 0018. - Stages units into
.dev/nspawn/boot-stage/etc/systemd/system/:- Real units + targets from
dist/systemd/. - Drop-ins copied to
<unit>.service.d/malmo.conf(matching the on-target layout indist/systemd/README.md# Layout). - Stub parent units for
docker.service,smbd.service,avahi-daemon.service(Type=oneshot ExecStart=/bin/true). systemd does not surface drop-ins whose parent unit is missing; the stubs letsystemctl cat <svc>succeed without installing the real packages. - The
malmo-boot-test.servicedriver unit (oneshot, afterbasic.target,ExecStopPost=/bin/systemctl --no-block poweroff), plus abasic.target.wants/symlink to enable it. (We attach tobasic.targetrather thanmulti-user.targetbecause the latter blocks on getty/console services in the minimal rootfs;basic.targetis enough for thesystemctl show/list-dependenciesqueries the assertions run.)
- Real units + targets from
- Boots
systemd-nspawn --boot --ephemeralwith the staging tree bound onto/etc/systemd/system, the verifier binary bound at/usr/lib/malmo/malmo-storage-verify,/bin/truestubbed at/usr/lib/malmo/host-agent-real(sohost-agent.servicecan load — it never starts because nothing pulls it in), the assertions script at/usr/local/bin/boot-assertions.sh, and the host's result file bound RW at/var/lib/malmo-boot-result. - Wraps the whole nspawn invocation in
timeout 60s— assertions should complete in single-digit seconds; the ceiling catches a hung container so CI doesn't burn an hour. - Reads the verdict, exits 0 on
PASS, non-zero on anything else.
--ephemeral is non-negotiable (matches 0018). --register=yes is
deliberately omitted to avoid host-side systemd-machined flake.
sudo -E ./dev/test-nspawn/run-boot-chain-tests.sh. Wired into
.PHONY and make help.
TESTING.md# Fast lane — "Unit dependency errors", "Drop-in overrides applying correctly", "Synthetic targets activating in the right shape" are all asserted here. "Service-level integration: brain ↔ host-agent ↔ Caddy contracts" istest-health/test-caddy's job; this slice is unit-shape only.BOOT.md# The storage-ready target — the dependency chainmalmo-storage-ready.target ← malmo-storage-verify.serviceis asserted at boot.BOOT.md# Failure → recovery target —OnFailure=malmo-recovery.targetonhost-agent.serviceis asserted; the verifier's absence ofOnFailure=is implicit (anyOnFailure=line would surface viasystemctl show, but we don't assert the negative explicitly).
- Stub parent units for docker/smbd/avahi-daemon. The drop-ins are
validated against
Type=oneshot ExecStart=/bin/truestubs, not the real packages. Catches typos and missing references, doesn't catch ordering bugs that emerge only with the real services. The medium lane (QEMU+swtpm, separate slice) will exercise the real chain. host-agent.servicenever starts. ItsExecStartis stubbed to/bin/true, and no.wantssymlink is staged so multi-user.target doesn't pull it in. Assertions querysystemctl show(unit metadata only), notis-active. The fast lane proves shape; reach-active is medium-lane work.- No CI integration. Same status as
test-usermgr-nspawnandtest-health— there is no CI on this repo yet. Each lane is invocable locally viamake; wiring all three into a CI workflow is a separate slice. - Host systemd is 245 (Ubuntu 20.04) in the current dev
environment.
systemd-analyze verify --root=is not supported on systemd <247, so the static-verify step lives inside the booted container (systemd 252+ from bookworm). On hosts with newer systemd, we could short-circuit with a host-side check; not worth the conditional today. --overlay-ronot used. Originally planned (overlay the staging tree on top of the rootfs's/etc/systemd/system); shipped with--bind-roof the whole staging directory instead. Simpler, fewer moving parts; revisit if staging conflicts with rootfs-provided units surface.
Things that bit during bring-up — recorded here so the next person adding a fast-lane service doesn't relearn them:
- Replacing
/etc/systemd/systemwith--bind-rodrops the rootfs'sdefault.targetsymlink. systemd then falls back to/lib/systemd/system/default.target→graphical.targetand hangs waiting for a display manager. Staging needs an explicitdefault.target → /lib/systemd/system/multi-user.targetsymlink. /runis remounted as tmpfs by systemd PID 1, masking any bind-mount placed under/run/. Result-file binds and any host-injected files that need to survive boot must live under/var/lib/or another non-tmpfs path.- Graceful shutdown of the container is unreliable in a minimal
rootfs whose
/etc/systemd/systemhas been replaced.systemctl poweroff(with or without--no-block,--force,--force --force) andkill -SIGRTMIN+4 1all stall onStopJobtimeouts. The fast lane sidesteps this by polling the result file from the host and SIGKILL'ing the nspawn supervisor as soon as a verdict is written.rc=137from nspawn is therefore the expected happy-path exit code, not an error. set -e+wait $PID: when the host driver explicitly kills the container,waitreturns the SIGKILL exit code (137). Underset -ethat aborts the driver before it reads the verdict. UseNSPAWN_RC=0; wait "$NSPAWN_PID" || NSPAWN_RC=$?to capture without aborting.- Python and
jqare not in the minimal bookworm rootfs. JSON shape checks in the in-container assertion script have to be grep/case-based. The reporter emits pretty-printed JSON (with spaces), so collapse whitespace before pattern-matching. - Drop-ins without parent units are silently ignored by
systemctl cat. StubType=oneshot ExecStart=/bin/trueparent units for docker/smbd/avahi-daemon let drop-in assertions work in a rootfs that doesn't have the real services installed. --quietonsystemd-nspawndoes not suppress the container's PID-1 boot output (it only suppresses nspawn's own status messages). Leaving boot logs visible is fine — they're useful for debugging — but suppressing them would require--console=passive(systemd 247+) or output redirection.--private-networkis cheap and removes the only realistic host-side risk surface (container can otherwise bind to host interfaces). Included by default.- Failure injection verified. Two mutations (renaming the
drop-in's target reference, commenting out
Before=on the verifier) both surfaced as loud, specificFAIL:verdicts. The lane catches what it claims to catch.
In recommended order:
- CI workflow that runs the three nspawn lanes (
test-usermgr-nspawn,test-boot-chain-nspawn,test-health— the latter is non-nspawn but in the same fast-lane budget). Per-PR, blocks merge perTESTING.md# Where each lane runs. Requires a CI host withsystemd-nspawnavailable. - Extend the boot lane with the avahi reconciler chain once
0013-avahi-dbus-publisher.md's host-side units land — assert their drop-in ordering againstmalmo-storage-ready.targetandmalmo-recovery.target. - Replace stub parent units with real packages opportunistically
— installing
docker.ioin the rootfs is a non-starter (image bloat), butavahi-daemonandsambaare cheap and would let us assert against the real units. - Negative-case fixtures. A
fixtures/subdir of intentionally broken unit files (missingExecStart, danglingAfter=, etc.) driven throughsystemd-analyze verifyto confirm the verify step actually rejects them — guards against the "test always passes because nothing is checked" regression. storage-verify-timeoutsynthesis once the wrapper unit exists (carried forward fromboot-pipeline-units.md# Known gaps); this lane is the right home for asserting it.