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:
+21
-13
@@ -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,
|
||||
|
||||
@@ -54,7 +54,7 @@ func TestBuildDRRecipeHostHalf(t *testing.T) {
|
||||
t.Errorf("drive %s intent=%q, want enrolled", d.DurableID, d.Intent)
|
||||
}
|
||||
}
|
||||
if d, ok := byDur["uuid:da9e7089"]; !ok || d.Role != "bulk-data" || d.MountPath != "/mnt/felhom-usb" || d.TotalBytes != 931<<30 {
|
||||
if d, ok := byDur["uuid:da9e7089"]; !ok || d.MountPath != "/mnt/felhom-usb" || d.TotalBytes != 931<<30 {
|
||||
t.Errorf("felhom-usb drive wrong: %+v", d)
|
||||
}
|
||||
if _, ok := byDur["uuid:81a26531"]; !ok {
|
||||
@@ -77,6 +77,42 @@ func TestBuildDRRecipeHostHalf_NoPBS(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDRRecipeHostHalf_V1DriveShape pins the v1 host-half drive shape: a drive object carries ONLY
|
||||
// {durable_id, mount_path, intent, total_bytes} (fs_type is omitempty) — and specifically NEITHER the
|
||||
// dropped "role" NOR "restic_repo_coord" keys. Re-adding either field to DRDrive makes this fail
|
||||
// (the companion: `Role string \`json:"role"\`` reintroduces the "role" key → caught here).
|
||||
func TestDRRecipeHostHalf_V1DriveShape(t *testing.T) {
|
||||
h := BuildDRRecipeHostHalf(
|
||||
nil,
|
||||
[]StorageTarget{
|
||||
{Name: "felhom-usb", Type: StorageTypeUSB, DurableID: "uuid:da9e7089", Role: "bulk-data",
|
||||
MountPath: "/mnt/felhom-usb", TotalBytes: 931 << 30},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if len(h.Drives) != 1 {
|
||||
t.Fatalf("want 1 drive, got %d", len(h.Drives))
|
||||
}
|
||||
b, err := json.Marshal(h.Drives[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var keys map[string]json.RawMessage
|
||||
if err := json.Unmarshal(b, &keys); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, banned := range []string{"role", "restic_repo_coord"} {
|
||||
if _, ok := keys[banned]; ok {
|
||||
t.Errorf("v1 drive must NOT carry %q key (it was dropped); got %s", banned, b)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"durable_id", "mount_path", "intent", "total_bytes"} {
|
||||
if _, ok := keys[want]; !ok {
|
||||
t.Errorf("v1 drive missing required key %q; got %s", want, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDRRecipeHostHalf_NoSecrets is the agent-side boundary assertion (the lighter mirror of the
|
||||
// controller's load-bearing boundary test): a fully-populated host-half must carry NO field whose
|
||||
// name smells like a credential. If a future field leaks a key/token/hash in, this fails.
|
||||
@@ -97,8 +133,8 @@ func TestDRRecipeHostHalf_NoSecrets(t *testing.T) {
|
||||
}
|
||||
|
||||
// assertNoSecretKeys walks decoded JSON and fails on any object key matching secretNameRe. Shared by
|
||||
// the agent boundary assertions. (durable_id/repo_id/latest_snapshot_id/restic_repo_coord are
|
||||
// identifiers/coordinates — none match the credential regex.)
|
||||
// the agent boundary assertions. (durable_id/repo_id/latest_snapshot_id are identifiers/coordinates —
|
||||
// none match the credential regex.)
|
||||
func assertNoSecretKeys(t *testing.T, jsonBytes []byte) {
|
||||
t.Helper()
|
||||
var v any
|
||||
|
||||
@@ -145,7 +145,6 @@
|
||||
"drives": [
|
||||
{
|
||||
"durable_id": "uuid:0fc63daf-8483-4772-8e79-3d69d8477de4",
|
||||
"role": "",
|
||||
"mount_path": "/mnt/usb-backup",
|
||||
"intent": "enrolled",
|
||||
"total_bytes": 2000000000000
|
||||
|
||||
Reference in New Issue
Block a user