- Status: done
- Date: 2026-06-13
- Specs touched:
CONTROL_PLANE.md(# Locked: host-agent launches the brain container — added the OCI-label lockstep mechanism); realizesBUILD.md# First-boot brain bootstrap (steps 2 + 4)
Closes #164 (M1a, part of #161), the next link after m0-control-plane-images.md (#163, which built the brain image + baked the offline bundle into the QEMU lane but wired no launch). M0's bar was "the images build and load"; M1a's is "host-agent starts the brain." CONTROL_PLANE.md and BUILD.md already described the five-step first-boot bootstrap but no code implemented it — cmd/host-agent-real did PAM / Avahi / users / health and never launched a container, and the QEMU medium lane symlinked host-agent-real to /bin/true.
New package internal/hostagent/brainlaunch. A small, testable bootstrap (Launch) over a consumer-side Docker interface:
- docker-load if absent.
ImagePresentfirst; if the image isn't already in Docker,docker loadthe bundled tarball (offline-first — the box boots with zero internet,BUILD.md# 5 Distribution C). - Lockstep major-version check before launch. Read the brain image's declared wire-protocol major from the
malmo.protocol.majorOCI label and refuse (ErrProtocolMismatch) if it isn't the major this host-agent speaks. A botched partial update (brain image newer than host-agent) is caught here with a clear error instead of as opaque first-request failures. A missing label renders empty and is also refused — host-agent will not launch a brain it can't verify. - Run the brain with
--restart unless-stopped. The container mounts the host-agent socket directory (so the brain reaches host-agent) and/var/lib/malmo(SQLite state lives under it), and reads its config fromMALMO_*env. After this first launch Docker keeps the brain alive across host-agent restarts; host-agent does not supervise it in steady state — so a brain container that already exists is a no-op (Launchis idempotent on the container name, the recovery key beingdocker rm+ a host-agent restart).
The brain is deliberately not given the Docker socket — it reaches Docker only through the socket-proxy the control-plane stack brings up later (CONTROL_PLANE.md # Docker socket exposure). Until M1b wires that stack, the brain comes up degraded: its startup EnsureIngress / Reconcile / Caddy calls fail-soft (all already best-effort in cmd/brain/main.go) and it still binds its listener and logs malmo-brain listening — which is exactly M1a's "Done when: brain logs show it came up." It does reach host-agent over the mounted socket, so host-agent ready logs too.
Protocol constant. internal/protocol gains Major = 1 and ImageProtocolMajorLabel = "malmo.protocol.major" (dotted prefix matching the runtime malmo.instance_id convention). cmd/brain/Dockerfile stamps LABEL malmo.protocol.major="1"; the value must track protocol.Major and the /v<N> URL prefix — bumped together, the same conservative "lockstep pair" posture as cmd/brain's expectedAgentVersion, until the release-manifest model lands.
Wiring (cmd/host-agent-real/main.go). After the socket is bound and the agent is mounted — Docker is ready by here, the unit is After=docker.service — Launch runs with a 2-minute context. It is best-effort: a failure (including a refused mismatch) logs at Error and host-agent keeps serving its socket, so the box stays diagnosable rather than being torn into recovery. brainLaunchConfig reads MALMO_BRAIN_IMAGE / MALMO_BRAIN_IMAGE_TAR (production defaults malmo-brain:latest + /var/lib/malmo/brain-image.tar), fixing the data root at /var/lib/malmo and dialing the same socket host-agent just bound.
QEMU medium lane un-stub (dev/test-qemu/). bootstrap.sh now builds host-agent-real (CGO on, CGO_CFLAGS=-D_GNU_SOURCE, as the Makefile sets — a dynamic binary linking the host's libpam/glibc, run on the Debian VM which carries libpam0g) and stages the real binary at /usr/lib/malmo/host-agent-real in place of the /bin/true symlink, with a preflight check for the libpam headers. A medium-lane drop-in (host-agent.service.d/10-malmo-brain-image.conf) points the bootstrap at the bundle's malmo-brain:dev tag + tarball path and orders host-agent After=malmo-load-images.service so the image is loaded first. mkosi.postinst.chroot enables host-agent.service (preset + manual .wants symlink): the /bin/true-exits hazard that kept it disabled — RuntimeDirectory=malmo tearing down /run/malmo/ (with storage.json) on exit — is gone now that the unit runs the real, long-lived binary.
CONTROL_PLANE.md# Locked: host-agent launches the brain container — realized: load-if-absent,unless-stopped, launch-time lockstep check as a function call. Added one sentence pinning the mechanism (the OCI label) so spec and reality agree.BUILD.md# First-boot brain bootstrap — steps 2 (docker-load) and 4 (start the container) implemented. Step 3 (pull a newer tag from the registry) is deferred with the rest of the real update path per epic #161; only the bundled image is used.
internal/hostagent/brainlaunchunit tests (no Docker daemon —Launchis exercised against a recording fake): image-absent → load → run; image-present → skip load; the run spec (name, image,unless-stopped, socket-dir + data-dir mounts,MALMO_STATE_DIR/MALMO_AGENT_SOCKenv); lockstep mismatch refuses (errors.Is(ErrProtocolMismatch), no run) and missing label refuses; existing-container no-op; load/image-check/container-check/run errors propagate.Launch95.7% covered; thecli.godocker-exec wrapper is the integration seam (0% by unit tests, exercised by the VM lane), the same boundary aslifecycle'scliDocker.make checkgreen — gofmt + vet + OpenAPI-fresh (no contract change — the guard is runtime, the generated client is untouched) + the full Go suite, including the CGO/PAMcmd/host-agent-realcompile of the new wiring.- VM-boot acceptance is not verified here — like M0, the medium lane needs
sudo make test-medium-qemuon an mkosi/swtpm/QEMU host, which this environment lacks. The lane is authored to the "Done when" (a runningmalmo-brainlaunched by host-agent; brain logs show it came up) but a real boot pass is outstanding.
- Run the medium lane on a real QEMU host to confirm host-agent launches the brain and the brain logs
malmo-brain listening(the M0 VM-boot acceptance is still outstanding too; both ride the samesudo make test-medium-qemu). A medium-assertion that greps for the runningmalmo-braincontainer + the listening log is the natural M2 (#167) addition. - M1b (#165): the brain brings up the control-plane stack (Caddy +
malmo-ui+ socket-proxy) and getsDOCKER_HOSTpointed at the proxy — until then the brain runs degraded with no Docker reach, as described above. - Registry pull (BUILD.md step 3) and a distinct production image pin / release manifest stay deferred (#161). The lockstep label value is hand-kept in lockstep with
protocol.Major; the release-manifest model will replace the hand-keeping. - glibc skew of the test-lane host-agent build — the medium lane builds
host-agent-realdynamically on the build host; a much newer build host than the Debian VM could produce a binary the VM's glibc won't load. Not hit in practice (production ships a.debbuilt for the target), noted for whoever runs the lane on an exotic host.