Part 0 (live): flash apps on 9201 were down due to an operator pct reboot at 10:26 UTC + a boot-ordering race — dockerd auto-starts unless-stopped flash apps ~18s before the agent re-binds felhom-flash, so the create-time bind mkdir fails (permission denied) and RestartCount=0 never retries. Drive healthy, data intact, no USB drop, durable-id fine, drive-gate uninvolved. v0.70.0 self-restart RULED OUT (container restart, not a guest reboot; +38min after exits). Fix: restarted the 7 apps via the controller (drive present) — all Up. Flagged the intermediary mount app-start race as an architectural gap. Parts 1-3 (cited): characterized escrow (K + identity under recovery code R, fingerprint-gated, hub zero-knowledge) + PBS whole-CT contents (rootfs/secrets in, external drives out) + capstone DONE vs PENDING (agent-side recovery orchestration not wired, syncer.go:92). Defined the secret-free DR recipe (guest sizing + drive durable-id/role inventory + PVE storage + app bindings + PBS coords), sourced from facts the agent/controller already hold, landing in the reserved WireDesiredState.storage_manifest placeholder. Field-by-field boundary proof + a no-secrets test spec. Fork list + recommendation: spec/emit/store the recipe now, defer re-enrollment auth to slice 10D, never touch the escrow/PBS secret path. No code changes, no version bump. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
30 KiB
SPIKE — Phase 2 DR recipe: characterize existing DR, define the secret-free recipe, lock the boundary (+ storage diagnosis)
Date: 2026-06-16
Type: Report-only spike. No code changes, no version bump. Every claim cited against pushed source at file:line. Part 0 is a live diagnosis on guest 9201 / felhom-pve (one non-destructive fix applied — restarting already-present apps — and noted).
Grounding: SPIKE-infra-backup-2026-06-15.md line 24 — the revival is "a slim, secret-free DR recipe (customer → apps → drive/durable-id intents → guest sizing) that complements PBS's ingredients. Secrets are never in it; they are recovered from the PBS whole-CT snapshot, never regenerated."
One-screen summary
- PART 0 (storage diagnosis): the flash-backed apps did not crash and felhom-flash did not drop. Root cause: an operator
pct reboot(PVEvzreboot) of guest 9201 at 10:26 UTC + a boot-ordering race. On guest boot, dockerd auto-starts theunless-stoppedflash apps ~18 s before the agent re-binds felhom-flash into the guest; docker fails to create the not-yet-present bind source (mkdir /mnt/felhom-drives/felhom-flash/userdata: permission denied), and because that is a create-time mount failure (RestartCount=0) the restart policy never retries — the apps stayExitedforever even after the bind lands. The drive is healthy, mounted, bound (host + guest), data intact; no USB drop, no durable-id failure, the drive-gate was uninvolved. The v0.70.0 self-restart is RULED OUT (it restarts the felhom-controller Docker container viaos.Exit(0), never the LXC guest, and ran 11:04–11:17 UTC — ~38 min after the exits). Fix applied: started the 7 apps via the controller (drive present now) — all backUp. Architectural gap flagged: the intermediary mount is C1-immune (guest boots) but does not prevent this app-start race. - PART 1 (existing DR): DONE — PBS whole-CT escrow + consume (
internal/escrow/*, fingerprint-gated, real-data drilled), identity-escrow ({tunnel_token, pbs_token}age-wrapped under the same recovery code R), tunnel re-establishment, and the hub's zero-knowledge recovery-mode/re-enroll orchestration (hub/internal/api/dr.go). PENDING — the agent-side recovery orchestration that consumes the directive (the agent carriesrestore_directiveforward-compat but explicitly does not act on it —internal/desired/syncer.go:92), and the secret-free recipe itself. - PART 2 (recipe): the gap is the non-secret scaffolding PBS does not capture — guest sizing, drive inventory (durable-id → role → mount → enroll/decommission intent), PVE storage defs, app inventory + per-app storage bindings. The agent already holds every fact (
StorageTarget{durable_id, role, mount_path, total/used/avail}atreport.go:86; guest specs; PBS coords) — it can emit the recipe. The hub→agent wire already reservesstorage_manifest/backup_policy/pbs_namespaceplaceholders (report.go:271-273). Boundary: every recipe field is an identifier/intent/size — no key, password, token, or hash. Secrets stay in PBS + escrow. - PART 3: fork list + recommendation: emit the recipe agent-side as an additive host-report section (it owns the storage truth), store it plaintext on the hub next to the host-report, version the wire shape, and defer the re-enrollment auth (recovery-mode consumption) to the existing slice-10D track — the recipe complements escrow+PBS, it does not touch them.
PART 0 — Storage diagnosis: felhom-flash plugged in, yet flash-backed apps exited
What I observed (live, 12:00–12:10 UTC)
Seven flash-backed apps Exited: audiobookshelf, calibre-web, immich-server, jellyfin, komga, radarr, romm. Exit codes 143 (audiobookshelf/immich-server/komga) and 128 (calibre-web/jellyfin/radarr/romm). felhom-flash physically connected.
Timezone note (this was load-bearing): docker reports UTC (…Z); the host journal is +02:00 (CEST). All times below normalized to UTC unless marked "local".
End-to-end walk (not "is the device present")
- The drive is healthy and fully bound — host side.
lsblk: felhom-flash =/dev/sdb1, ext4,LABEL=…,UUID=81a26531-62d8-408d-812f-a178b1d35310, mounted at/mnt/felhom-flash. The intermediary chain is intact:- stable parent:
/dev/mapper/pve-root on /mnt/felhom-drives(on rootfs, always present), - per-drive:
/dev/sdb1 on /mnt/felhom-drives/felhom-flash.
- stable parent:
- Bound through to the guest — guest side. Inside 9201,
/proc/mountsshows/dev/sdb1 /mnt/felhom-drives/felhom-flash ext4 rw,relatime, andls /mnt/felhom-drives/felhom-flash→appdata backups media userdata. The flash data is reachable in the guest right now. - The agent never lost it. The reconcile loop logs every ~20 s, continuously:
reconcile: enrolled drive bound under shared parent (live, no reboot) … where=/mnt/felhom-flash guest_path=/mnt/felhom-drives/felhom-flash durable_id=uuid:81a26531-…— including across the incident window. (internal/reconcile+internal/localapiguest-bind; durable-id matched.) - No kernel-level USB event.
dmesg -Tandjournalctl -k --since 10:20 --until 10:35(UTC window) show zero USB disconnect / re-enumeration /sd/ext4 I/O errorlines. The task's "transiently dropped + re-enumerated under a new node" hypothesis is NOT supported — the kernel never saw the device leave, and the agent re-bound it by the sameuuid:81a26531throughout. Durable-id tracking did not fail.
What actually happened (root cause, with evidence)
- Guest 9201 was rebooted by the operator at 10:26 UTC.
uptime -s= boot 10:26 (12:26 local);/proc/uptime≈ 1.7 h. Host journal at 12:26:08 local:pvedaemon[…]: <root@pam> end task UPID:demo-felhom:…:vzreboot:9201:root@pam: OK. This is a PVE-levelpct rebootof the LXC guest, operator-initiated. - The "exits" are the reboot shutdown, not a fault. romm's own log:
[2026-06-16 12:26:02] Stopping nginx … Stopping gunicorn … Worker … was sent SIGTERM!; jellyfin:[12:25:59] Disposing CoreAppHost. Exit 143 = 128+SIGTERM; 128 = the image's PID1 exit on SIGTERM. The FinishedAt timestamps (10:25:58–10:26:02 UTC) are the guest-shutdown moment. - All seven are
restart: unless-stopped— same policy as the DB/redis containers (romm-db,immich-postgres, …), which did come back (Up ~1.7 h). So policy is not the differentiator. - THE SMOKING GUN — a create-time bind-mount failure at boot:
On boot, dockerd tries to restart the flash apps. Their binds point under
romm State.Error = "error while creating mount source path '/mnt/felhom-drives/felhom-flash/userdata/roms': mkdir /mnt/felhom-drives/felhom-flash/userdata: permission denied" RestartCount=0 jellyfin State.Error = "...mkdir /mnt/felhom-drives/felhom-flash/userdata: permission denied" RestartCount=0/mnt/felhom-drives/felhom-flash/…, but the real felhom-flash is not bound into the guest yet — the agent re-binds it ~18 s after boot (host journal:guest-attach: drive bound under shared parent (normalized to one bind, live) … where=/mnt/felhom-flash … 12:26:26local = 10:26:26 UTC, vs boot 10:26:08). At that instant/mnt/felhom-drives/felhom-flashis the empty,nobody-owned stable-parent placeholder (the parent is/dev/mapper/pve-root,drwxr-xr-x nobody nogroupunder the unprivileged-LXC idmap), so the guest-root docker daemon's attempt tomkdirthe missing bind source is permission-denied. RestartCount=0→ the restart policy never retries. A create-time mount failure is not an exit-after-run, sounless-stoppeddoes not apply — the container is leftExitedpermanently even though the bind lands 18 s later. The DB containers have no such bind (named volumes on rootfs) → they start immediately and stay up. That is the whole asymmetry.
Ruling out the v0.70.0 self-restart (explicitly, as requested)
- The self-restart shipped this session is
os.Exit(0)of the felhom-controller Docker container (controller/internal/api/selfrestart.go); the container isrestart: unless-stopped, so Docker restarts that one container. It never issues a guest reboot. - The incident was a PVE
vzrebootof the LXC guest (host journal, step 5) — a different layer entirely. - Timing: the exits were 10:25:58–10:26:02 UTC; my self-restart testing ran 11:04–11:17 UTC (controller
StartedAt11:17:51 UTC, "Up 46 min" at 12:04). The exits precede my testing by ~38 min. (The task's cited "~11:54–11:57" window matches neither the exits nor any controller restart I made; the controller's only live restart is 11:17:51 UTC.) Not implicated.
Fix applied (non-destructive, and it validates the diagnosis)
The drive is present and the data intact, so the only thing wrong is that the apps never started. I started them through the controller's own pipeline (POST /api/stacks/{name}/start, the same path the UI uses) — romm first as a canary (Up, accessing /romm/library on flash), then the rest. Result: audiobookshelf, calibre-web, jellyfin, radarr healthy; immich-server, komga health-starting (normal); romm up. That they start cleanly now confirms the root cause was the boot-ordering race, not any drive fault. No remount/rebind was needed (the mount was already correct); no destructive action taken.
Architectural gap (flag — out of this spike's build scope, worth a follow-up)
The intermediary-mount model (SPIKE-intermediary-mount-2026-06-15.md) is C1-immune (the guest boots clean because the bind source is the always-present stable parent). But it does not prevent an app-start race: the per-drive bind is (re)asserted by the agent's reconcile tick seconds after the guest's dockerd has already tried (and permanently failed, RestartCount=0) to auto-start drive-backed apps. Any operator pct reboot, host reboot, or guest auto-restart reproduces this. Candidate fixes (for a future slice, not here):
- (A) order the bind before docker: have the agent assert guest binds as part of guest-start (or gate the in-guest
docker.servicestart until/mnt/felhom-drives/<drive>is populated), so the source exists before dockerd's auto-start. - (B) controller startup reconcile: after confirming a drive bind is live, the controller starts any drive-backed app that is
Exitedwith a create-time mount error (a targeted, idempotent "drive-present → start gate-eligible apps" pass). Note: today's drive-gate stops apps when a drive goes missing but does not appear to re-start apps that failed to create at boot — that asymmetry is the bug surface. - (Not C) putting the drive back as a
pct mpNwould make it present at boot but reintroduces C1 (drive absent at boot → guest bricks) — the very thing the intermediary model removed.
PART 1 — The existing DR mechanism (cited)
1A. Escrow + recovery — what is escrowed, how it's wrapped, what it unlocks
Two secrets, one recovery code R. R = 10 EFF-large-wordlist words (internal/escrow/wordlist.go:47, RecoveryCodeWords=10, ~129.2 bits), hyphen-joined, surfaced to the customer once, never logged or hub-stored.
- K-escrow (the PBS encryption key). The live PBS client key
K(e.g./etc/pve/priv/storage/felhom-pbs.enc) is copied and re-keyed tokdf=scryptunderRvia PBS-nativeproxmox-backup-client key change-passphrase(internal/escrow/escrow.goWrap()~143). Result: an opaque blob PBS itself cannot open withoutR.CreateResult.Blob(escrow.go:49). - Identity-escrow (10D.1).
IdentityBundle{TunnelToken, PBSToken}(internal/escrow/identity.go:22-27) — "the secrets a re-enrolling box needs to come back 'as host X'" — wrapped under the sameRviaage -p(scrypt + ChaCha20-Poly1305) →CreateResult.IdentityBlob(escrow.go:56;identity.goWrapIdentity()~29). Additive: the K-escrow path is unchanged when nil (escrow.go:43). - Self-verify before shipping: Create unwraps a copy with
Rand checks the recovered fingerprint matches, "Never ship a blob we can't recover" (escrow.go~96).
The hub is zero-knowledge. escrowUploadRequest{BlobB64, KeyFingerprint, Posture, IdentityBlobB64, DirectiveJSON} (hub/internal/api/handler.go:619-631); the handler "Store the OPAQUE bytes. No decrypt path exists — the hub cannot open this" (handler.go:678 area), persisted via SaveHostEscrow / SaveHostDRBundle (hub/internal/store/store.go, GetHostDRBundle returns opaque KEscrowBlob/IdentityBlob/DirectiveJSON). dr.go:14-18: "the hub ORCHESTRATES recovery … but holds no usable secret … the escrow blobs it serves are opaque (need R, which the hub never has)."
Consume + fingerprint gate (the unlock path). internal/escrow/consume.go — four inputs: (blob, R, expectedFingerprint, keyDest) (consume.go:13-32). Flow: (1) Unwrap with R (key change-passphrase --kdf none); a wrong R "fails closed at the scrypt KDF: nonzero exit, no key emitted" (consume.go:63). (2) Fingerprint gate — recompute the recovered key's fingerprint and compare to the hub-served expectedFingerprint; mismatch → fail fast, no install (consume.go:69-79). (3) Atomic install at keyDest 0600 (consume.go:81). Tested: right-R+right-FP installs; wrong-R no install; FP-mismatch no install (internal/escrow/consume_test.go).
What R ultimately unlocks → how it decrypts the snapshot. R → unwrap K-escrow → plaintext K (PBS client encryption key) → installed at keyDest ($XDG_CONFIG_HOME/proxmox-backup/encryption-key.json or --keyfile). PBS restore reads K from there and decrypts the client-side-encrypted snapshot chunks. R is the only out-of-band secret; it also unlocks the identity bundle for tunnel + PBS re-auth. (See 1B for the encryption side.)
1B. What the PBS whole-CT snapshot contains vs not
- CONTAINS: the guest rootfs + CT config via vzdump → PBS. "A Docker NAMED volume lives in the LXC rootfs (
/var/lib/docker/volumes/…/_data) and is ALWAYS captured by vzdump" (internal/proxmox/doc.go:52). So the in-guestapp.yaml(encrypted), the controller's encryption key, DB dumps, configs, and named-volume app data are all in the snapshot. Backup isCrashConsistent: true(internal/backup/runner.go:83). - DOES NOT contain — the bulk-volume gap: external user-data drives (
felhom-usb,felhom-flash) are bind-mountedmpNwithbackup=0/unset, which vzdump excludes.uncoveredMountpointslists them (internal/backup/runner.go:91-98, 228-252: "covered ONLY when it carries an explicitbackup=1"). These are restic cross-drive territory, not in the PBS whole-CT snapshot — and notably,felhom-flash'suserdata/media,appdata/romm/resources, etc. (Part 0) are NOT in PBS. Surfaced asBackup.UncoveredVolumes(internal/hub/report.go:178-194). - Encrypted? Yes, client-side, by
K.PBSSnapshot.Encryptedis derived from any data file withcrypt-mode == "encrypt"(internal/pbs/report.go:44-53,client.go:94). The PBS server is zero-knowledge: "the PBS server has no client key" (internal/pbs/doc.go:20). Verify is key-free / ciphertext-level (internal/pbs/client.go:60). - Host-report fields:
Backup{TargetID, VMID, Archive, Mode, CrashConsistent, SizeBytes, Success, Error, StartedAt, DurationSeconds, UncoveredVolumes}(report.go:182-194);PBSSnapshot{Namespace, BackupType, BackupID, BackupTime, SizeBytes, Owner, Protected, Encrypted, VerifyState, VerifyUPID}(report.go:223-234);VerifyState∈ok|failed|noneis "the load-bearing field" (report.go:219).
1C. The capstone drill + DONE vs PENDING
Drilled in documentation/tests/slice10-escrow-consumption-spike-findings.md and slice10d-identity-restore-spike-findings.md:
- S1 identity round-trip (DONE):
{tunnel_token, pbs_token}recovered byte-identical on a fresh box from blob +R; wrongRfails closed. - S2 tunnel re-establishment (DONE): the new connector with the recovered tunnel token re-registers; Cloudflare routes to it (no DNS change). Caveat flagged in the drill: the old connector/token stay valid → 10D must rotate them or delete the stale connector, and the hub's CF token is only zone-scoped WAF-Edit, not account-scoped Tunnel:Edit — a production blocker.
- S3 PBS whole-CT restore (DONE): real encrypted
spike-lxc(~2.5 GB,crypt-mode=encrypt) restored on a keyless box using only the recoveredK, fingerprint-gated.
Manual / operator-in-the-loop steps: customer provides R; operator arms recovery-mode on the hub (the out-of-band "the old box is truly lost" gate); operator performs the external credential rotation (tunnel/PBS); operator signs a destructive restore-overwrite.
Hub side — DONE. hub/internal/api/dr.go:34-183: handleSetRecoveryMode/handleClearRecoveryMode (TTL-bounded, auto-expires), handleReEnroll (revokes old host key, mints new, serves opaque blobs + directive), handleGetRestoreDirective (gated on recovery mode). store.go: InRecoveryMode, SetRecoveryMode/ClearRecoveryMode, RotateHostAPIKey, GetHostDRBundle. Desired-state serving (SetHostDesired, GET /hosts/{id}/desired-state) is live (slice 10A).
Agent side — PENDING (the gap). The agent carries but does not consume the recovery path: internal/desired/syncer.go:92-94 — "restore_directive present (consumed in slice 10D — ignored in 10A)"; the wire struct WireDesiredState.RestoreDirective is "forward-compat … 10A carries it through to the cache but does NOT consume it" (internal/hub/report.go:266, 289-296). There is no agent code that detects recovery mode, fetches re-enroll blobs, unwraps with R, or executes a restore. internal/escrow/consume.go:17: "The DR orchestration around it (re-enroll in restore mode, source the directive …)" is the missing wrapper.
The current end-to-end DR gap, precisely: the crypto and real-data recovery are proven and the hub can serve the opaque blobs + directive, but (a) the agent has no recovery-orchestration executor, (b) the out-of-band R handshake (how the fresh box obtains R) is unspecified, (c) external credential rotation (CF tunnel + PBS token) is manual/blocked on token scope, and (d) there is no secret-free recipe telling the operator what host scaffolding to rebuild before the PBS bytes can land. This spike specs (d).
PART 2 — The secret-free DR recipe (the gap it fills)
2A. THE GAP — what PBS does not capture (the scaffolding the operator must rebuild)
To put the PBS bytes back, the operator must first rebuild the host + guest + storage scaffolding on new hardware. None of this is in the PBS whole-CT snapshot (which is inside-the-guest bytes) or in the escrow (which is secrets):
- Guest sizing — cores / memory_bytes / disk_bytes to recreate the LXC at the right size (matches
GuestSpecalready on the wire,desired-state.golden.json:8). - Drive inventory — for each user-data drive: durable-id (
uuid:…, wipe-bindingbyid:<wwn>/byuuid:), role (primary | bulk-data | vzdump-target | pbs-offsite), mount path (/mnt/<name>→ intermediary/mnt/felhom-drives/<name>), capacity, and enroll vs decommissioned intent. This is exactly whatStorageTargetalready reports (2B). - PVE storage definitions — which Proxmox storages exist (id, type ∈
local-dir|lvmthin|usb|nfs|cifs|pbs|local, content), so the new host's/etc/pve/storage.cfgcan be rebuilt (StorageTarget.{Name,Type,Content}). - App inventory + per-app storage bindings — which apps are deployed and which drive/path each binds (e.g. romm.library →
felhom-flash:userdata/roms) — so apps come up against the right (restic-restored) bulk data. The controller owns this (catalog refs + non-secret deploy fields + storage-role intents). - PBS coordinates — datastore/namespace/snapshot id to restore from (
PBSSnapshot.{Namespace,BackupID}, the desired-statepbs_namespace).
Recipe contents (proposed, all non-secret):
recipe_version: 1
customer_id, domain, tier
guest: { vmid, cores, memory_bytes, disk_bytes }
pbs: { repo_id, namespace, latest_snapshot_id } # coordinates, NOT the key
drives: [ { durable_id, wipe_durable_id, role, mount_path, fs_type,
total_bytes, intent: enrolled|decommissioned, userdata_layout } ]
pve_storage: [ { name, type, content } ] # to rebuild storage.cfg
apps: [ { name, catalog_ref, enabled,
storage_bindings: [ { container_path, drive_durable_id, subpath } ],
non_secret_deploy_fields: { ... } } ] # NO env secrets
The recipe is the re-provision plan; PBS is the ingredients; escrow holds the keys. Three jobs, three stores.
2B. SOURCE / FLOW — reuse, don't duplicate
- The agent already holds the storage/guest facts.
StorageTarget(internal/hub/report.go:86-121) carriesName, Type, DurableID, State, Reachable, Total/Used/AvailBytes, Content, MountPath, BackingDevice, ClassHint, Role. Guest specs and PBS snapshots are already in the host-report (Backups[],PBSSnapshots[], guest list). The agent can emit the storage+guest+PBS half of the recipe from data it already collects — additively, as a new host-report section (or a dedicated push), no new privileged reads. - The controller owns the app half — app inventory, catalog refs, and per-app storage bindings (it generates the compose and knows the drive bindings; Part 0 showed romm's binds resolve to
felhom-flash/userdata/roms). It already pushes a hub report; the app recipe is an additive, secret-free section there. - Where the hub stores it (plaintext-safe, non-secret). Next to the host-report (e.g. a
dr_recipecolumn/table keyed by host/customer +recipe_version), not encrypted (it has no secrets) and separate fromhost_escrow(which stays opaque). This is the clean inverse of the retired infra-backup: same "re-provision plan" goal, zero secrets, so plaintext-at-rest is correct rather than a violation. - How the operator consumes it during DR. The hub→agent wire already reserves the landing slot:
WireDesiredState.StorageManifest/BackupPolicy/PBSNamespaceare forward-compat opaque placeholders "kept opaque so the wire is stable as those land" (internal/hub/report.go:266-273). DR flow: operator reads the stored recipe → authors the new host's desired-state (guest specs +storage_manifestfrom the recipe) → the agent provisions the guest at the right size and re-enrolls drives by durable-id with their roles → then escrow recoversK/identity and PBS restores the rootfs bytes (and restic restores the bulk drives) into the rebuilt scaffolding. The recipe feeds provisioning; escrow+PBS feed the data. No overlap.
2C. BOUNDARY — the Phase-1 lesson, proven field-by-field
The rule (non-negotiable): the recipe contains only identifiers, intents, sizes, and coordinates — never a key, password, token, hash, or any ENC:/secret value. Secrets live in the PBS whole-CT snapshot and the escrow blobs, recovered with R, never regenerated (consistent with the controller's fail-closed data_key gate). This is exactly where the retired infra-backup failed: it shipped encryption_key_b64, restic_password, and a controller_config_b64 with cf_api_token/session_secret/password_hash (SPIKE-infra-backup-2026-06-15.md:100-115) — a zero-knowledge violation. The recipe must not.
| Recipe field | Sensitive? | Why not |
|---|---|---|
customer_id, domain, tier |
No | Public identifiers |
guest.{cores,memory_bytes,disk_bytes,vmid} |
No | Sizing integers (already on the wire, GuestSpec) |
drives[].durable_id / wipe_durable_id |
No | FS-UUID / byid WWN — a hardware identifier, not a credential |
drives[].{role,mount_path,fs_type,total_bytes,intent,userdata_layout} |
No | Topology + intent; identical class to today's StorageTarget (already reported plaintext) |
pve_storage[].{name,type,content} |
No | Storage definitions, no auth (the PBS token is escrowed, not here) |
pbs.{repo_id,namespace,latest_snapshot_id} |
No | Coordinates; the encryption key is escrow-only, the access token is identity-escrow-only |
apps[].{name,catalog_ref,enabled,storage_bindings} |
No | Catalog reference + path bindings; compose comes from the catalog/PBS |
apps[].non_secret_deploy_fields |
Must be filtered | Only non-secret deploy fields; every secret-typed field excluded at the source |
The one place a leak could enter is apps[].non_secret_deploy_fields (free-form deploy config). The boundary is enforced at the emitter (controller), which already distinguishes secret vs non-secret deploy fields (it encrypts ENC: secrets in app.yaml and the retired infra-backup work confirmed the controller knows which env fields are secret-typed).
A test that would catch a secret leaking in (spec for the build slice): a recipe_no_secrets_test that (1) builds a recipe from a fixture customer whose apps include secret-typed deploy fields (passwords/tokens/keys) and asserts the serialized recipe JSON contains none of those values and no key whose name matches (?i)(password|secret|token|key|hash|passphrase|api[_-]?key|ENC:); (2) a golden-shape test pinning the recipe's allowed top-level keys so a new field can't silently land without review; (3) a property check that every apps[].* field passing through is on an explicit allowlist, not a denylist (allowlist = a new sensitive field is excluded by default). Mirror it on both emitters (controller app-half, agent storage-half) so drift is caught on either side — the same cross-repo golden discipline already used for the host-report contract.
PART 3 — Fork list + recommendation
Recommendation (spec the recipe; defer the auth; never touch the secrets path):
- Recipe schema + fields — adopt the 2A shape (
recipe_version+ customer/guest/pbs/drives/pve_storage/apps). Fork: exact field set forapps[].non_secret_deploy_fields(allowlist contents) — needs the catalog/controller owner to enumerate the non-secret deploy fields per app. Recommend: start with{catalog_ref, enabled, storage_bindings}only; add non-secret deploy fields incrementally behind the allowlist test. - Agent-emit vs hub-derive — emit from the agent (storage/guest/PBS half) as an additive host-report section, and from the controller (app half) as an additive report section; the hub assembles + stores, it does not derive facts it doesn't own. Rationale: the agent is the storage source of truth (
StorageTarget), the controller owns the app set; the hub deriving either would re-introduce drift. Fork: one combined recipe record vs two half-records stitched at read time. - Storage location/format — plaintext JSON on the hub, keyed by host/customer +
recipe_version, in a dedicateddr_recipestore separate fromhost_escrow(opaque) and from the retiredinfra_backup*tables (dropped Phase 1). Hub-manageable: download / prune / pin / "drive a DR re-provision". Recommend: new table, not a reuse ofinfra_backup_versions. - Tie-in to escrow / PBS / capstone — the recipe is the re-provision plan only: it feeds provisioning + desired-state (the reserved
WireDesiredState.StorageManifestplaceholder,report.go:271), then the existing escrow→Kand PBS-restore (Part 1) put secrets+bytes into the rebuilt scaffolding, and restic restores the bulk drives the recipe enumerated (theUncoveredVolumesgap). The recipe must not carry keys/tokens (those stay in escrow) — it only names thepbs.namespace/snapshot_idanddrives[].durable_idthe restore targets. - Re-enrollment auth (recovery-mode toggle + out-of-band
R) — DEFER. It is the separate PENDING slice-10D track (agent-side consumption ofrestore_directive+ recovery mode,syncer.go:92). This slice specs and emits/stores the recipe; it does not implement the restore orchestration or theRhandshake. Rationale: the recipe is valuable on its own (operator can rebuild scaffolding by hand today) and is independently testable; coupling it to the auth slice delays both. - Recipe wire-shape versioning — carry an explicit
recipe_version(start at 1) and keep the hub store and both emitters version-aware, with a cross-repo golden byte-pinned like the existingdesired-state.golden.json/ host-report contracts, so the agent and controller halves can evolve without silent drift. Fork: strict-reject vs ignore-unknown-fields on version skew. Recommend: ignore-unknown on read (forward-compat, as the wire already does forstorage_manifest), pin known keys in the golden.
Bottom line: every fact the recipe needs is already collected (agent StorageTarget + guests + PBS; controller app set + bindings), the landing slot already exists (WireDesiredState.StorageManifest), and the boundary is enforceable at the emitter with an allowlist + no-secrets test. The recipe complements escrow (keys) and PBS/restic (bytes) without duplicating or weakening either — turning "I have PBS bytes" into "I can re-provision this customer."
Appendix — Part 0 evidence index (live, read-only except the app-start fix)
- App states / exit codes /
State.Error/RestartCount:docker inspecton guest 9201. - Guest reboot: host
journalctlvzreboot:9201:root@pam OK@ 12:26:08 local; guestuptime -s10:26 UTC. - Mount chain: host
lsblk/mount; guest/proc/mounts+ls /mnt/felhom-drives/felhom-flash. - No USB event: host
dmesg -T+journalctl -k(10:20–10:35 UTC) empty for usb/sd/ext4. - Agent rebind:
journalctl -u felhom-agentguest-attach: drive bound … 12:26:26local + continuousreconcile: enrolled drive bound. - Fix:
POST /api/stacks/{name}/start×7 → allUp; romm canary reads/romm/libraryon flash.
Report-only. No source modified. The only production action was restarting seven already-deployed apps whose drive + data were present — service restoration, non-destructive, and itself the confirmation of the root cause.