v0.39.0 — DR-recipe completion: live PBS coord + drop role/restic_repo_coord from v1 drive shape

Live PBS coord: new internal/pbs/live_reporter.go (LiveSnapshotReporter implements
hub.PBSReporter via the cheap Client.Snapshots() list with last-known-good fallback,
bounded by an 8s timeout, list-only — never triggers a verify). Closes the gap where
the recipe's pbs block was omitted whenever the verify-loop SnapshotStore was empty
(one-shot collect + the first ~6h after a daemon restart). SnapshotStore.Get added
(per-datastore LKG). Wired into the collector in both runDaemon and runSelftestHub;
the verify loop keeps Recording into the SAME shared store via one hoisted pbsTargets.

v1 host-half drive shape: dropped drives[].role (hub/operator-owned manifest concept,
not host-derivable) and drives[].restic_repo_coord (named a backup tier that doesn't
exist). Drive shape is now {durable_id, mount_path, intent, fs_type?, total_bytes}.
Hub reads drives as json.RawMessage → no hub struct change; goldens re-pinned
byte-identical (agent + hub copies).

Tests: live_reporter_test.go (T1 load-bearing coord-without-verify + T2..T6),
TestDRRecipeHostHalf_V1DriveShape; each companion demonstrated to fail pre-fix then
reverted. go build/vet/test green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 20:25:09 +02:00
parent 8abc1b8852
commit fbe113011d
8 changed files with 414 additions and 23 deletions
+21 -13
View File
@@ -3,9 +3,9 @@ package hub
import "sort"
// DR recipe — the agent (storage/guest/PBS) HALF of the secret-free reconstruction recipe
// (SPIKE-dr-recipe-2026-06-16). The recipe complements escrow (keys) + PBS/restic (bytes): it is
// (SPIKE-dr-recipe-2026-06-16). The recipe complements escrow (keys) + PBS (bytes): it is
// the non-secret SCAFFOLDING an operator must rebuild before the PBS bytes can land — guest sizing,
// drive inventory (durable-id → role → mount → intent), PVE storage defs, and PBS coordinates.
// drive inventory (durable-id → mount → intent → size), PVE storage defs, and PBS coordinates.
//
// BOUNDARY (non-negotiable, the Phase-1 lesson): every field here is an identifier, intent, size, or
// coordinate — NEVER a key, password, token, hash, or ENC: value. Secrets live in the PBS whole-CT
@@ -13,6 +13,17 @@ import "sort"
// asserts no field name matches the secret regex. The hub assembles this half with the controller's
// app half into one customer recipe.
//
// v1 host-half drive shape (v0.39.0) = identifiers/intent/size ONLY: {durable_id, mount_path, intent,
// fs_type?, total_bytes}. Two fields were deliberately DROPPED from v1:
// - role — a drive's purpose (primary/bulk-data/…) is a hub/operator-owned manifest concept, not
// cleanly derivable host-side (both demo externals are content=backup, yet one is the primary
// data drive and the other holds no apps). Deferred until the hub/operator stamps it.
// - restic_repo_coord — RESERVED for a future offsite bulk-volume backup tier. None exists today:
// external-drive data has no offsite/second-failure-domain copy (cross-drive backup is rsync to
// the SAME internal SSD), so the field named nothing real. Re-add when that tier ships.
// The pbs coord, by contrast, is resolved LIVE each collect (LiveSnapshotReporter) so the restore
// SOURCE is present whenever PBS is reachable — not gated on the 6 h verify cadence.
//
// recipe_version=1. The wire shape is byte-pinned in the cross-repo golden (host-report.golden.json
// here + the hub's copy) — see the manual checksum-diff discipline in CHANGELOG. Read is
// ignore-unknown (encoding/json default) for forward-compat, mirroring storage_manifest.
@@ -44,17 +55,15 @@ type DRPBSCoord struct {
LatestSnapshotID string `json:"latest_snapshot_id"` // most-recent snapshot's backup_id (a coordinate)
}
// DRDrive is one user-data drive: identifiers + intent + size. The restic_repo_coord NAMES where the
// bulk-volume backup lives (PBS excludes external drives — the UncoveredVolumes gap); the restic
// PASSWORD stays in escrow, never here.
// DRDrive is one user-data drive: identifiers + intent + size. v1 carries ONLY these fields (role +
// restic_repo_coord were dropped — see the file header for why). Every field is an identifier, intent,
// or size; none is a credential.
type DRDrive struct {
DurableID string `json:"durable_id"` // uuid:<fs-uuid> — a hardware identifier, not a credential
Role string `json:"role"`
MountPath string `json:"mount_path"`
Intent string `json:"intent"` // enrolled | ejected | decommissioned
FSType string `json:"fs_type,omitempty"`
TotalBytes int64 `json:"total_bytes"`
ResticRepoCoord string `json:"restic_repo_coord,omitempty"` // bulk-backup location coord (password in escrow)
DurableID string `json:"durable_id"` // uuid:<fs-uuid> — a hardware identifier, not a credential
MountPath string `json:"mount_path"`
Intent string `json:"intent"` // enrolled | ejected | decommissioned
FSType string `json:"fs_type,omitempty"`
TotalBytes int64 `json:"total_bytes"`
}
// DRPVEStorage is a PVE storage definition (to rebuild /etc/pve/storage.cfg scaffolding) — no auth.
@@ -102,7 +111,6 @@ func BuildDRRecipeHostHalf(guests []Guest, targets []StorageTarget, pbs []PBSSna
if isUserDataDrive(t) {
h.Drives = append(h.Drives, DRDrive{
DurableID: t.DurableID,
Role: t.Role,
MountPath: t.MountPath,
Intent: driveIntentEnrolled,
TotalBytes: t.TotalBytes,