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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user