controller v0.73.0: DR recipe — emit secret-free customer+apps half in hub report

DR recipe slice (controller half), grounded in SPIKE-dr-recipe-2026-06-16. The
controller emitter is the BOUNDARY enforcement point: v1 ships an explicit
allowlist {catalog_ref, enabled, storage_bindings} and reads NOTHING from
AppConfig.Env, so no ENC:/token/password can leak. storage_bindings parsed from
the compose (${HDD_PATH}/${USERDATA_PATH} volume binds -> {container_path,
drive, subpath}).

Load-bearing tests: TestBuildAppRecipe_NoSecrets (synthetic-secret app -> none
leak) + TestBuildAppRecipe_AllowlistIsLoadBearing (red-proof companion) +
TestAppStorageBindings + TestBuildDRRecipeAppHalf. Red-proofed live: forcing the
emitter to dump Env makes the boundary test fail. recipe_version=1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 18:38:59 +02:00
parent 0d159d7e34
commit f3146ac7bf
7 changed files with 441 additions and 45 deletions
+45 -45
View File
@@ -1,58 +1,58 @@
# REPORT — controller v0.72.0: FileBrowser converges on boot-recreate (2026-06-16)
# REPORT — controller v0.73.0: DR recipe (customer + apps half)
**Deployed:** controller **v0.72.0** on guest 9201 / felhom-pve (bootstrap-managed; healthy).
**Commit (trunk):** `6ea2538` (Task B) + `c8d…`-era REPORT update.
**Live-accepted:** two REAL host reboots of felhom-pve — FileBrowser re-synced after the drive-backed
apps were recreated, both times; all drive-backed apps recovered with zero manual intervention.
**TASK — DR recipe slice (controller half).** Emit the secret-free customer + apps half of the
reconstruction recipe as an additive hub-report section. Grounded in `SPIKE-dr-recipe-2026-06-16.md`.
**The controller emitter is the boundary enforcement point** — it is the component that distinguishes
secret from non-secret deploy fields, so v1 ships only an allowlist and the no-secrets boundary test
lives here.
## Task B — the gap
## Implementation
Follow-up to v0.71.0's guest-reboot recovery. A host-reboot test found `processGuestBootChange`
recreated the drive-backed app stacks (so their `${HDD_PATH}` binds re-resolved against the now-live
drives) but **never re-synced FileBrowser**. FileBrowser is base-infra: it binds each drive's
`userdata` directory but has no `HDD_PATH`, so it is **not** in the boot-recreate set — its mounts went
stale after a reboot (the early first-boot bring-up bound them before the drives were live).
- `internal/report/dr_recipe.go`:
- `DRRecipeAppHalf{recipe_version, customer{id,display,domain}, apps[]}`.
- `BuildDRRecipeAppHalf(custID, custName, domain, stacks, composeReader)` — pure given the reader;
one `AppRecipe` per DEPLOYED, non-protected stack.
- `AppRecipe{catalog_ref, enabled, storage_bindings}` — the **entire v1 surface**. `buildAppRecipe`
reads NOTHING from `AppConfig.Env`.
- `appStorageBindings(composeYAML, hddPath)` — pure compose parser; each `${HDD_PATH}`/`${USERDATA_PATH}`
volume bind → `{container_path, drive=basename(HDD_PATH), subpath}`; named volumes excluded.
- `readComposeFile` — the production reader (best-effort; unreadable → no bindings, never a failure).
- `Report.DRRecipe *DRRecipeAppHalf` wired into `BuildReport` (customer fields + `GetStacks()`).
## Fix
## The boundary (the Phase-1 lesson)
In `processGuestBootChange` (`internal/web/intermediary.go`), **after** `pollLiveBinds` confirms the
live binds and the drive-backed apps are recreated, the boot-recreate path now triggers
`go s.SyncFileBrowserMounts()` so FileBrowser converges against the now-live drives. The recreate loop
was refactored into a pure, seam-testable helper:
The recipe carries ONLY `{catalog_ref, enabled, storage_bindings}` — identifiers/paths. It NEVER touches
`AppConfig.Env`, where the controller keeps `ENC:` secrets. This is an **allowlist** (a new field is
excluded by default), the inverse of the retired infra-backup that shipped `restic_password` /
`cf_api_token`. Secrets stay in the PBS whole-CT snapshot + escrow, recovered with R, never here.
```go
func recreateDriveBackedApps(stacks []bootStack, presentStable map[string]bool,
recreate func(bootStack), syncFB func()) (recreated, skipped int)
```
## Tests (the load-bearing boundary test + companion)
`syncFB` is invoked exactly once, AFTER all recreates — the FileBrowser sync can never run before the
drive-backed apps are back.
- `TestBuildAppRecipe_NoSecrets` — emit a recipe for an app whose `Env` carries an `ENC:` value + a
token-shaped value; assert NONE of the values and NO credential-shaped key survive; assert the
allowlisted facts DID emit (non-vacuous).
- `TestBuildAppRecipe_AllowlistIsLoadBearing` — the companion/red-proof: a guard-removed shape leaks the
token; the production emitter does not.
- `TestAppStorageBindings` (+ `_NoHDD`) — pins the compose parse (roms + resources bindings; named volume
excluded; rootfs app → 0 bindings).
- `TestBuildDRRecipeAppHalf` — assemble-correctness (deployed + non-protected only) + whole-half secret
sweep.
- **Live red-proof:** forcing `buildAppRecipe` to dump `Env` made `TestBuildAppRecipe_NoSecrets` and the
companion FAIL (caught the token value + the `DB_PASSWORD`/`SECRET_KEY`/`IGDB_CLIENT_SECRET` keys);
reverted → green.
## Tests (non-hollow, seam = the FB sync)
## Versioning
- `TestRecreateDriveBackedApps_SyncsFileBrowserAfterRecreate` — records the call sequence; asserts
`syncFB` runs exactly once and strictly AFTER every `recreate`. Red-proofed (stubbing out the
`syncFB()` call makes it fail).
- `TestRecreateDriveBackedApps_SyncsEvenWithNoRecreate` — FileBrowser still converges when nothing
needed recreating (e.g. binds already present).
`recipe_version=1`; read is ignore-unknown for forward-compat. The hub assembles this half with the
agent's storage/guest/PBS half (agent v0.38.0) into one customer recipe.
`go build`, `go vet`, `go test ./...` all green on the build server (192.168.0.180).
## Gate / deploy
## Live acceptance — real host reboot ×2 on felhom-pve
`go build`, `go vet`, `go test ./...` all green (local + build server). Built + pushed image v0.73.0;
deployed to guest 9201 (bootstrap-managed).
Both reboots: `processGuestBootChange` fired on the new boot-id, confirmed the live binds, recreated
every drive-backed app, **then** ran the FileBrowser sync. Captured controller logs:
## Deferred (NOT in this slice)
```
[gate] boot 1781625729-1612: live bind confirmed — recreating drive-backed app … onto /mnt/felhom-drives/felhom-flash (×8 apps)
[gate] boot 1781625729-1612: re-syncing FileBrowser mounts against the live binds
[web] FileBrowser mounts synced — 3 storage path(s), config updated
```
(reboot #2 identical on boot-id `1781625955-1516`.) Post-reboot FileBrowser binds all three drives
non-stale — `felhom-usb`, `felhom-flash`, `felhom-data``/srv/felhom-*` — and the underlying agent
tolerated a `/dev/sdb``/dev/sdc` reshuffle by mounting each drive by UUID (agent v0.37.0, Task A).
The earlier first-boot `Failed to recreate FileBrowser` line (drives not yet live) is the exact pre-fix
symptom; the boot-recreate path now recovers it.
Demo dashboard has no password set → controller API is open on the in-guest path; no secrets committed.
Free-form non-secret deploy fields (the SPIKE's `non_secret_deploy_fields`) — v1 is the three-field
allowlist only; additional fields land incrementally behind the same allowlist test. No re-provisioning
automation; no recovery-mode consumption.