- Status: done — VM-boot acceptance PASSED on the maintainer env (
sudo -E make test-cloud-qemu, 2026-06-20): the hosted cloud image builds, converts to qcow2, boots once under OVMF UEFI (no swtpm, no LUKS), and the in-VM self-check reportsMALMO_CLOUD_ASSERTIONS: PASS— all four control-plane containers up, dashboard +/apiserved through Caddy, hosted/setupgate returning 503 - Date: 2026-06-20
- Specs touched:
ENVIRONMENT.md(# Provisioning, # Networking & discovery, # Boot — realized, not changed),BUILD.md(# 2/# 6 qcow2 artifact, # 5 offline bundle — realized),TESTING.md(# Full-stack control-plane integration — reused)
C2 of #196 (the cloud-VM track), after C1a (#202, the profile marker + brain read), C1b (#203, the lean image), C1c (#204, the slim host-agent), and C3a (#206, the hosted /setup gate). It emits the hosted cloud image, converts it to the qcow2 cloud artifact, and boots it once in QEMU to prove the control plane comes up and serves — no swtpm, no LUKS, no installer (ENVIRONMENT.md # Provisioning: "the disk IS the installed system"). The cloud analogue of the medium lane's full-stack boot, minus everything the hosted profile drops. The cloud-init-style seed (C3), the trimmed wizard (C4), and the seed→wizard→dashboard end-to-end (C5) stay out of scope: C2 asserts the brain serves (pre-setup), not that an admin exists.
Bringing the brain up on a real hosted image surfaced an unflagged gap that C3a's unit tests (which override MALMO_PROFILE_PATH) could not catch: the brain runs in a container that mounts only the agent socket dir + /var/lib/malmo, neither of which covers /etc/malmo — so profile.Read("/etc/malmo/profile") inside the container always returned Appliance (the no-op default) regardless of what the image stamped. An appliance-mode brain runs /setup open (first-caller-becomes-admin / 409-on-empty), which is exactly the public-by-default hole C2's security review forbids. C3a's 503 gate is real but only fires when the brain resolves hosted.
Fix (minimal, host-agent-side, no brain change):
internal/hostagent/brainlaunch—ConfiggainsProfileMarkerPath;runSpecmounts it read-only at the same path into the brain container when set, so the brain reads/etc/malmo/profileexactly as it would natively. Empty (an unmarked appliance box,make dev) skips the mount and the brain resolves appliance — unchanged.cmd/host-agent-real—brainLaunchConfigresolves the marker path (MALMO_PROFILE_PATH, defaultprofile.DefaultMarkerPath) and setsProfileMarkerPathonly when it exists as a regular file (a same-path bind of a missing source would make Docker auto-create a root-owned directory). Both the appliance and-tags hostedbuilds get it (shared, untagged).- Tests —
brainlaunch_test.go: the marker mount is absent by default and present + read-only whenProfileMarkerPathis set.
The boot proof's /setup → 503 assertion is the end-to-end check that this works: an appliance-mode brain would answer 409/200 there.
dev/cloud/test/— the boot-proof image definition. Itsmkosi.confInclude=s the leandev/cloud/mkosi.conf(C1b) rather than copying it, so the booted image — and the qcow2 converted from it — is the lean image plus instrumentation, never a divergent rebuild.make build-cloud-imagestill buildsdev/cloud/lean with no postinst; this directory adds the boot machinery via its own auto-detectedmkosi.postinst.chroot+mkosi.extra/(staged at build time), without touching the lean config.OutputDirectoryis redirected to.dev/cloud-bootso the two images never clobber.dev/cloud/test/mkosi.postinst.chroot— enables docker + containerd,systemd-networkd, host-agent, the first-boot image loader, and the assertions oneshot (.wantssymlinks); provisions themalmo/malmo-app/malmo-sharedidentities; brings the single virtio NIC up via systemd-networkd DHCP ([Match] Type=ether, the minimal cloud-native path — no NetworkManager); pre-commits machine-id + maskssystemd-firstboot. No sshd (hosted cut).dev/cloud/test/bootstrap.sh— preflight, builds the slim host-agent (go build -tags hosted, #204) + the control-plane image bundle (make control-plane-images, reused verbatim), stagesmkosi.extra/(the agent +host-agent.service+ a control-plane env drop-in, the image bundle +load-control-plane-images.sh+malmo-load-images.servicereused from the medium lane, the control-plane compose same-path at/var/lib/malmo/control-plane/, the PAM stack, the assertions oneshot + script), stages Docker's apt repo (trixie), and runsmkosi build. Canary-gated.dev/cloud/run-cloud-tests.sh— calls the bootstrap,qemu-img converts raw → themalmo-<version>-amd64.qcow2artifact (BUILD.md# 6), and boots it once under OVMF UEFI with one virtio NICrestrict=on(air-gapped — a stray pull hard-fails, proving the offline bundle is complete),snapshot=on(keeps the artifact pristine), serial-log capture +dump_serialon failure. No swtpm, no LUKS credential, no two-boot cycle, no SSH.dev/cloud/cloud-assertions.sh+malmo-cloud-assertions.service— a first-boot oneshot runs the in-VM self-check and writes its verdict (MALMO_CLOUD_ASSERTIONS: PASS|FAIL) to the serial console, which the driver greps. Serial-driven, not SSH-driven, because hosted ships no sshd (ENVIRONMENT.md# Access & files); the checks run in-guest over localhost, which is the real control-plane-up proof regardless. The checks: systemd userspace converged with no failed control-plane unit; PSI live (/proc/pressure/memorynon-empty — thepsi=1cmdline pairs with C1b);systemd-networkdactive + NetworkManager absent; docker active + the four baked images loaded; the four control-plane containers running; the proxy boundary (brain has no rawdocker.sock); the dashboard answers through Caddy (GET /200,/api/v1/me200|401); and the hosted/setupgate returns 503 (no seed — the open-/setup window stays closed).make test-cloud-qemu—sudo -E ./dev/cloud/run-cloud-tests.sh, mirroringtest-medium-qemu's shape, with help text.
C1b (#203) validated the lean image via mkosi summary only — the full mkosi build was blocked on its env (#189), so C2 is the first actual build of the cloud image and surfaced two genuine gaps in the lean package set, both added to dev/cloud/mkosi.conf (neither is a cut-list package, so the lean assertion is unaffected):
passwd(the shadow utilities —useradd/groupadd/gpasswd/chpasswd). On the appliance/medium image these arrive transitively via theopenssh-server/network-manager/avahipostinsts; the hosted image cuts all of those, so it lost them. Withoutpasswd,docker-ce's postinst (groupadd docker) fails the build, and at runtime host-agent's user management (/setup+ user CRUD) and the boot-proof postinst's malmo-identity provisioning would have no tools.systemd-boot-efi— the actual EFI executables mkosi'sBootloader=systemd-bootinstalls to the ESP. On trixie these moved to a separate package thatsystemd-bootonly Recommends, and mkosi builds withInstall-Recommends=false, so the build failed with "a systemd-boot binary was not found". (The medium lane is bookworm, whosesystemd-bootbundles the binary — why it never hit this.)
And two boot-path fixes, both latent in C1b (it was never booted):
- Partitioning (
dev/cloud/mkosi.repart/, new). C1b shipped no repart definitions on the assumption that mkosi's default is a sane bounded ESP+root. It is not — with no definitions, systemd-repart grows the root to fill the whole build disk (a ~1 TiB root → ~17 GiB of ext4 inode tables written at build time → "No space left on device"). Added a minimal unencrypted ESP (512 MiB) + ext4 root fixed at 8 GiB (sparse;GrowFileSystemfills the partition), same sizing as the medium lane so first boot'sdocker loadof the baked images has headroom — no LUKS (hosted's encryption-at-rest is provider/KMS-keyed,ENVIRONMENT.md# Storage). The boot image inherits it through theInclude. - Root discovery (
root=PARTUUIDon the cmdline). The lean cmdline had noroot=and relied on systemd gpt-auto, which needs the boot loader to set theLoaderDevicePartUUIDEFI variable — not set in this firmware/boot path, so the initrd's gpt-auto-generator logged "EFI loader partition unknown, exiting" andinitrd-switch-root.servicedropped to emergency mode. Pinned the root partition UUID in10-root.confand addedroot=PARTUUID=…toKernelCommandLine(the medium lane likewise passes an explicitroot=, viard.luks).
The first green build still failed the boot proof — only malmo-brain and malmo-docker-proxy came up; malmo-caddy/malmo-ui never did, because the brain could not reach the socket-proxy through the malmo-ingress bridge (dial tcp 172.18.0.2:2375: connect: no route to host, ARP INCOMPLETE both directions). The bridge was NO-CARRIER while the veths had carrier — i.e. the per-container veths were not contributing carrier to the bridge.
Root cause: the single-NIC .network matches [Match] Type=ether, which also matches Docker's br-<id> bridges and veth* endpoints. Left managed, systemd-networkd resets them, so the bridge never forwards and inter-container ARP never resolves. NetworkManager (the appliance/medium lane) ignores Docker's interfaces by default; networkd does not — which is exactly why the medium lane works and the cloud lane did not, despite the identical control-plane bundle. (This is L2: the previous build cycles chasing Docker's iptables raw-table / br_netfilter knobs were the wrong layer — those never affect ARP.)
Fix (dev/cloud/test/mkosi.postinst.chroot): stage /etc/systemd/network/05-docker-unmanaged.network matching Name=docker* veth* br-* with [Link] Unmanaged=yes. Its lower filename prefix means networkd evaluates it before 20-dhcp.network, so it claims the virtual links as unmanaged first and the real NIC still falls through to DHCP. The assertions' diagnostics gained bridge link show + networkctl list to confirm the Docker links read unmanaged on a green boot.
ENVIRONMENT.md# Provisioning — "no installer; the disk IS the installed system": a single UEFI boot of the disk image, no kiosk/installer, no disk-selection/confirm-wipe.ENVIRONMENT.md# Networking — "one virtual NIC brought up by the minimal cloud-native path, no NetworkManager": the systemd-networkdType=etherDHCP.network, asserted live (networkd active, nmcli absent).ENVIRONMENT.md# Boot — "no TPM unseal, no NM, no physical-disk storage assembly": the medium lane's swtpm/LUKS/two-boot cycle and its NM/Avahi network-state assertions are all gone.ENVIRONMENT.md# Admin bootstrap — "a box with no seed returns 503, never the appliance's open behavior": asserted on the booted VM, which is what makes the profile-plumbing fix load-bearing.BUILD.md# 6 — theqcow2cloud artifact, produced byqemu-img convert.TESTING.md# Full-stack control-plane integration — the bake + offline-load mechanism (make control-plane-images→ tarballs → first-bootdocker load) reused verbatim; the air-gap (restrict=on) carried over.
- VM-boot acceptance passed on the maintainer env (
sudo -E make test-cloud-qemu, accel=kvm, air-gapped, 2026-06-20): build → qcow2 → single UEFI boot →MALMO_CLOUD_ASSERTIONS: PASS. The raw is 8.5 GiB sparse (consumes ~960 MiB).make checkis green (incl. the brainlaunch tests) andbash -nis clean on all four scripts. - On a green run the serial log is not retained.
run-cloud-tests.shonly copies the serial to.dev/cloud-boot/last-serial.logviadump_serialon failure, and cleans the run dir on success — so after a PASS,last-serial.logstill holds the previous failing run. The PASS verdict is authoritative from the driver's stdout. A future tweak could always retain the last serial; left as-is to keep success runs side-effect-free. - The qcow2 carries the test assertions oneshot. Per the maintainer's call, the artifact is the boot-tested raw (one image, not a separate clean build). The
malmo-cloud-assertionsoneshot is therefore in the shipped qcow2; it is side-effect-free (reads only, no poweroff) and run-once (a marker short-circuits re-runs), so a real provisioned boot runs it once harmlessly. A later slice can split a production-packaging target from the boot-proof image if that becomes a concern. - Serial-driven, not SSH-driven. The issue's literal wording was SSH
hostfwd, but baking a test sshd + key into a qcow2 that can boot on a public IP is a security smell, and the lean image excludesopenssh-server. The verdict travels over the serial console instead; the air-gapped NIC (restrict=on) still proves the offline bundle is complete. (SSH on hosted VMs is on the roadmap as real keyed operator access at provision time — not a baked test key.) - The dashboard route host is
malmo.local(the brain'sMALMO_DASHBOARD_HOSTdefault; host-agent does not override it), asserted as a Host-header route match over localhost. The real hosted host ismalmo.<box-id>.malmo.network, which needs the seed + enrollment (C3/C3b) — out of scope here. No DNS/mDNS is involved in the assertion. build-cloud-image(C1b) stays lean and separate. It buildsdev/cloud/(no control plane, no postinst) and keeps asserting the lean package cut. The full hosted image (lean + control plane + agent) is built only by the boot path in this slice; a non-test production-build target is a later concern.MALMO_OFFLINE_INSTALLis intentionally not set in the10-cloud-brain.confdrop-in, even though the boot proof runs air-gapped (restrict=on). The proof installs no apps, and the control-plane compose only references locally-present, explicitly-tagged images, so it never needs a registry. A real hosted VM has internet (the registry is reachable), so offline-install mode would be wrong there. The consequence — only relevant inside the air-gapped proof, never on a deployed box — is that a user-triggered app install in that QEMU run would fail to pull; the assertions deliberately don't cover app install (C2 scope is control-plane-up only).
- Cloud-lane seed delivery (now unblocked by this slice). Inject
seed.jsoninto the booted VM (SMBIOS / systemd credential) and assert the C3a gate end-to-end: with a seed,/setupaccepts the bootstrap secret and creates the first admin. This is thehosted-setup-gate.mdfollow-up that was blocked on the boot harness. - C3b — seeded enrollment + DNS-01 cert (
*.<box-id>.malmo.network), then C4 (the trimmed hosted wizard) and C5 (seed→wizard→dashboard end-to-end), which build on this boot proof.