- Status: done
- Date: 2026-06-16
- Specs touched:
APP_LIFECYCLE.md,DECISIONS.md
The first brain slice of #167 (M2 — full-stack integration assertions in the QEMU lane), carved off ahead of the QEMU harness work because it is the blocker the air-gapped app-install assertion sits on. The M2 lane runs the guest with no Docker Hub (QEMU netdev restrict=on) to prove the offline image bundle is complete; but lifecycle.install resolves every image digest by an unconditional docker pull + registry RepoDigest inspect (internal/lifecycle/pinning.go). Air-gapped, the pull hard-fails and a docker save/loaded image carries no RepoDigest to resolve against — so any install on a baked box fails before M2's assertions can run. This slice gives the brain an offline install path; the QEMU harness, the bundled app image, the brain-container catalog mount, and the five assertions are the follow-up slices of #167.
resolveImages/pullAndResolvegain anofflineparameter (internal/lifecycle/pinning.go). The promised digest (frommanifest.Images, "" for Door-2) is threaded in. On a pull failure in offline mode, the newresolveOfflinehelper falls back to the locally-present image:ImageInspectis the presence probe (it succeeds with an emptyRepoDigestlist for a loaded image and errors only when the image is absent), and the trusted digest — the catalog promise, or the@sha256:ref for a digest-pinned image — becomes the pin. Two cases stay fatal and are distinguished from a transient pull error: no trusted digest to fall back on (a Door-2 install can't be pinned offline), and a genuinely-absent image (the bundle is incomplete — the missing-image hard-fail the air-gapped lane exists to catch). The online path is byte-for-byte unchanged: a successful pull still resolves and verifies the registry digest.Manager.offlineInstall+SetOfflineInstall(internal/lifecycle/lifecycle.go). A box-level mode, not a per-install option, set as a struct field in the same style assharedRoot/healthWait.installpassesm.offlineInstalltoresolveImages.cmd/brainwiresMALMO_OFFLINE_INSTALL(cmd/brain/main.go): a newenvBoolhelper parses the flag (off by default), andmaincallslife.SetOfflineInstall(cfg.offlineInstall). A baked, registry-less box sets it; a box with a registry leaves it off and pulls + verifies as before.
A silent "pull failed → use whatever image is local" fallback would let an online box mask a transient registry outage: on an update, the registry being briefly unreachable while the previous version's image is still present locally would silently accept the stale image and pin the new (un-verified) promised digest — an integrity hole. The explicit mode confines the trust-the-bundle behavior to boxes that genuinely have no registry. For a fresh install of a never-seen image the fallback can't fire even in offline mode (the image isn't local → presence probe fails → hard-fail), so the mode only ever reuses bytes the offline bundle deliberately loaded.
internal/lifecycle/pinning_offline_test.go(5 cases, full install throughManager.Install):- offline + locally-loaded image + catalog promise → installs, override pins the promised digest, driver order
Pull → ImageInspect → ComposeUp; - offline + image absent → hard-fails, no
ComposeUp, SQLite row rolled back; - offline + no catalog promise (empty
Images) → hard-fails; - online (mode off) + pull fails while the image is present locally → still fatal (the gating guard);
- offline mode + pull succeeds → still resolves the registry digest (happy path unchanged).
- offline + locally-loaded image + catalog promise → installs, override pins the promised digest, driver order
- The fake docker driver grew a
loadedset soImageInspectcan model "present, noRepoDigest" (a docker-loaded image) vs "absent" (error), mirroring the real CLI. make checkgreen (gofmt, vet, OpenAPI freshness — no API surface changed — full Go suite).
- Not exercised on a real air-gapped box yet. The logic is covered hermetically against the fake; the end-to-end "guest with no internet docker-loads the bundle and installs whoami" proof is the QEMU full-stack lane — the next slice of #167. This slice only unblocks it.
- The digest-pinned (
@sha256:) offline branch is not covered throughInstall. M2's whoami compose references images by tag, so the tag path is what the lane needs; the digest-form fallback is symmetric and low-risk but tested only by construction, not a test. - Offline mode trusts the catalog promise without re-deriving the loaded image's manifest digest. A
docker save/loadimage exposes no manifest digest (only a configId), so there is nothing to compare offline — the signed catalog is the trust anchor by design (APP_STORE.md# Trust model). Recorded inDECISIONS.md2026-06-16.
The remaining #167 slices, on top of this one:
- bundle
traefik/whoami(the app image) into the offline image set +docker loadit at first boot; - bundle a test-only catalog (whoami + a
documents:writefolder grant) and mount it into the brain container (MALMO_CATALOG_DIR+ abrainlaunchmount — the brain container has no catalog today); - air-gap the guest (
restrict=onon the QEMU netdevs) and setMALMO_OFFLINE_INSTALLon the VM brain; - extend
medium-assertions.shwith the five M2 assertions (dashboard, real PAM login, app install end-to-end, content survives uninstall, socket-proxy boundary) and bumpCANARY_VERSION.