Re-drilled the HOST reboot (felhom-pve) x2 after the follow-up challenge: both recover all 8 drive-backed apps automatically; host-btime prefix advances so the host path triggers processGuestBootChange; the boot-race manifests on host reboots too (not just guest). Corrected the REPORT's 'host path unaffected' claim. Regression analysis: the v0.68 sweep genuinely exercised the recovery (it surfaced a real state-filter bug on the host reboot), so the agent path worked then and regressed after (controller.yaml reset to golden no-local_api baseline on each recreate; old MaybeIngest never re-merged). v0.70.0 config round-trip exonerated (GET /api/config is verbatim). ensureLocalAPI closes the regression on both paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7.8 KiB
REPORT — controller v0.71.0: fix guest-reboot recovery of drive-backed apps (2026-06-16)
Deployed: controller v0.71.0 on guest 9201 / felhom-pve (bootstrap-managed; healthy).
Commits (trunk): 25e5cb5 (boot-race fix, first cut) → e2de234 (full fix: + agent-path blocker + periodic retry + docs/tests).
Live-accepted: two pct reboot 9201 cycles, all 8 drive-backed apps recovered automatically, zero manual starts.
Phase A — diagnosis (pinned live, not guessed): THREE sub-causes
A guest pct reboot strands drive-backed apps because in-guest dockerd auto-starts the unless-stopped
apps ~18s BEFORE the agent re-binds the drive, so the create-time volume bind fails
(mkdir /mnt/felhom-drives/<drive>/userdata: permission denied) and RestartCount=0 means it is never
retried → stuck Exited. The intended recovery (processGuestBootChange) did not fire. Live repro
(paperless-webserver: exit=128, RestartCount=0, State.Error=mkdir…permission denied) was the fixture.
Diagnosis pinned three distinct sub-causes:
- AGENT-PATH BLOCKER (the live root cause).
/api/disks→{"error":"agent not configured"}:agentClient()requirescfg.LocalAPI.Endpoint, which was empty, soprocessGuestBootChange(and the entire drive gate) returned at its first guard — never reaching any boot-id/bind logic. The authoritativebootstrap.jsonhad a completelocal_apiblock (endpoint/fingerprint/token), butbootstrap.MaybeIngest(bootstrap.go:101) returned immediately on "already configured" (cfg.Customer.ID != ""), so a controller.yaml seeded beforelocal_apiexisted never got it merged. Evidence:LastGuestBootIDwas stuck at the guest's first-boot value across every reboot (it was never updated because the function bailed before reaching the persist). - BOOT-RACE READINESS GATE.
processGuestBootChangesampled the agent'sBoundUnderParentonce during fast startup (racing the ~18s rebind), recreated nothing, and persisted the new boot-id — burning its one-shot. (Confirmed by manually wiringlocal_api: the old sample-once would still have missed; the new poll caught it.) - SINGLE-SHOT FRAGILITY.
processGuestBootChangeran only once at startup; right after a guest reboot the agent's local API can be briefly unreachable/stale, so the single attempt bailed with no retry.
(The periodic drive-gate never recovered them either: its first observation was after the rebind →
present + not-disconnected → no transition; settings showed felhom-flash disconnected=None.)
Phase B — fix (harden the existing mechanism, no parallel one)
ensureLocalAPI(internal/bootstrap/bootstrap.go):MaybeIngestnow calls it on the already-configured path — whencfg.LocalAPI.Endpointis empty it mergeslocal_apifrom bootstrap.json into the existing controller.yaml in place (no hub re-pull, existing config preserved), idempotent + fail-safe.driveBindLive+pollLiveBinds(internal/web/intermediary.go):processGuestBootChangenow gates on the real live in-guest bind —driveBindLivechecks whether/mnt/felhom-drives/<drive>is an actual mountpoint in the controller's own/mnt(rslave)/proc/self/mountinfo(true only once the agent's bind propagated, exactly when docker can recreate the app);pollLiveBindswaits for it (bounded ~120s, poll 2s) before recreating via the normal pipeline (compose down→up -d).shouldRecreateOnBootis unchanged and state-independent → stuck-Exitedcreate-time-failure apps are included. Drives that never go live in the window are left to the gate.- Periodic retry (
driveGateLoop):processGuestBootChangenow runs on every periodic tick too — idempotent (boot-id gated) — so a momentarily-unreachable agent right after a reboot no longer permanently strands recovery.
Both reboot paths share this code, the same agent dependency, and the same boot-race — so both were broken by the regression and both are fixed here (see the regression analysis + host-reboot drill below).
Phase C — tests (non-hollow, pre-fix companions, red-proofed)
internal/web/intermediary_test.go:pollLiveBindswaits through the rebind window then reports live (recreate fires); a never-live drive stays absent (no spurious recreate); an explicit companion that a single early sample misses the not-yet-live bind. Red-proofed against a no-wait single-sample.internal/bootstrap/bootstrap_test.go:ensureLocalAPImergeslocal_apiinto an already-configured controller.yaml that lacks it (companion: pre-fixMaybeIngestleftLocalAPI.Endpointempty — red-proofed) and no-ops when already present. Full controller suite green;go vetclean.
Phase D — live acceptance (the real gate)
Built + deployed felhom-controller:0.71.0 to guest 9201 (the redeploy itself validated ensureLocalAPI:
the container recreate reset controller.yaml, the code re-merged local_api, /api/disks → 200). Then
two pct reboot 9201 cycles (zero manual intervention):
| reboot | boot-id | gate log | result |
|---|---|---|---|
| #1 | …7348791 |
"waiting (≤2m0s) for live drive bind(s) … → live bind confirmed — recreating" ×8 | all 8 Up |
| #2 | …7367438 |
same full sequence ×8 | all 8 Up |
Both recovered all 8 drive-backed apps automatically. (komga reports its container healthcheck "unhealthy" but is up and serving — a pre-existing, unrelated issue.)
HOST reboot (re-drilled after the follow-up challenge — the guest reboots only prove the guest path):
two systemctl reboot of felhom-pve itself.
| host reboot | host btime | boot-id | result |
|---|---|---|---|
| #1 | 1781545822→1781620637 |
1781620637-1443 — recreate ×8 (apps were state=stopped) |
all 8 Up |
| #2 | 1781620637→1781620928 |
1781620928-1516 — recreate ×8 |
all 8 Up |
Both recovered automatically, zero manual starts. Two findings: (a) the host-btime prefix advances, so
the host-reboot path triggers processGuestBootChange (the persisted LastGuestBootID now tracks each
boot — it was frozen at the first-boot value before the fix); (b) the boot-race manifests on host
reboots too (the gate recreated state=stopped apps), so the host path is not immune — it has the
same race and the same agent-path dependency as the guest path.
Regression analysis — "how did the earlier (v0.68) host-reboot sweep pass?"
It genuinely passed and genuinely exercised processGuestBootChange:
documentation/audits/storage-lifecycle-acceptance-2026-06-15.md records the host reboot surfacing a
real bug (5 apps stayed exited because the recreate filtered on container state, fixed in v0.68.1). If
agentClient() had been failing then, processGuestBootChange would have bailed and that bug could never
have appeared. So the agent path worked at v0.68 and regressed afterward — "boot-id determinism was
never exercising" is false; it was.
The regression: controller.yaml is reset to the golden's "configured-but-no-local_api" baseline on
every container recreate (each deploy), and the old MaybeIngest returned immediately on "already
configured" → local_api was never re-merged → agentClient() → "agent not configured" → the whole
drive gate + boot recovery silently died, on both reboot paths. (My v0.70.0 config-apply round-trip is
exonerated: GET /api/config returns the file verbatim — router.go:1150, no redaction — so it
preserved local_api.) ensureLocalAPI re-merges local_api on every startup, closing the regression
permanently.
Notes
- The Komga healthcheck quirk is pre-existing and out of scope.
- The
local_api-merge gap affected the whole drive gate on this golden, not just boot recovery —ensureLocalAPIrestores the agent path generally.