diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index b85b018..e9d311b 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,5 +1,22 @@ # Felhom Hub — Changelog +## v0.13.1 — DR recipe v1 drive-shape sync: test-data + regression guard only (2026-06-16) + +**No behavior change — redeploy optional.** Tracks the agent's v0.39.0 v1 host-half drive shape (which +dropped `role` + `restic_repo_coord` from `drives[]`). Because the hub reads drives as `json.RawMessage` +(verbatim passthrough), no store/handler/struct change was needed — only test-data + a regression guard. + +- `internal/store/testdata/dr-recipe.golden.json` + the `drHostHalf` test fixture: dropped the `role` + key from `drives[0]` to match the v1 shape the agent now emits. +- `internal/api/testdata/host-report.golden.json`: re-synced to be **byte-identical** with the agent's + `internal/hub/testdata/host-report.golden.json` (sha256 `57f2a5e7…18b2f2b5`). The hub copy previously + lacked the `dr_recipe` section entirely; it is now a verbatim copy, so the cross-repo golden truly + matches and POSTing it through `/host-report` exercises the `SaveDRRecipeHostHalf` ingest path. +- New `TestAssembleDRRecipe_V1DriveShape` — the regression guard: a stored host half whose `drives[]` + carry NEITHER dropped field but WHICH HAS a `pbs` block assembles cleanly (pbs carried through, drives + passed through verbatim, neither `role` nor `restic_repo_coord` present). Demonstrated to FAIL when the + fixture re-adds `role`, then reverted. + ## v0.13.0 — DR recipe: assemble + store + view the secret-free reconstruction recipe (2026-06-16) **DR recipe slice (hub half)** — the assemble-store-view side of the secret-free reconstruction recipe diff --git a/hub/internal/api/testdata/host-report.golden.json b/hub/internal/api/testdata/host-report.golden.json index 7309266..16832c1 100644 --- a/hub/internal/api/testdata/host-report.golden.json +++ b/hub/internal/api/testdata/host-report.golden.json @@ -131,5 +131,28 @@ } ], "cloudflared": { "status": "active" }, - "audit_tail": [] + "audit_tail": [], + "dr_recipe": { + "recipe_version": 1, + "guests": [ + { "vmid": 100, "cores": 2, "memory_bytes": 2147483648, "disk_bytes": 21474836480 } + ], + "pbs": { + "repo_id": "felhom-pbs", + "namespace": "root", + "latest_snapshot_id": "9001" + }, + "drives": [ + { + "durable_id": "uuid:0fc63daf-8483-4772-8e79-3d69d8477de4", + "mount_path": "/mnt/usb-backup", + "intent": "enrolled", + "total_bytes": 2000000000000 + } + ], + "pve_storage": [ + { "name": "local-lvm", "type": "lvmthin", "content": "rootdir,images" }, + { "name": "usb-backup", "type": "usb", "content": "backup" } + ] + } } diff --git a/hub/internal/store/dr_recipe_test.go b/hub/internal/store/dr_recipe_test.go index 7048e1e..205bafb 100644 --- a/hub/internal/store/dr_recipe_test.go +++ b/hub/internal/store/dr_recipe_test.go @@ -16,7 +16,7 @@ const drHostHalf = `{ "recipe_version": 1, "guests": [ { "vmid": 9201, "cores": 4, "memory_bytes": 12884901888, "disk_bytes": 34359738368 } ], "pbs": { "repo_id": "felhom-pbs", "namespace": "root", "latest_snapshot_id": "9201" }, - "drives": [ { "durable_id": "uuid:da9e7089-cf8e-4617-adcb-a377743fae00", "role": "bulk-data", "mount_path": "/mnt/felhom-usb", "intent": "enrolled", "total_bytes": 1000000000000 } ], + "drives": [ { "durable_id": "uuid:da9e7089-cf8e-4617-adcb-a377743fae00", "mount_path": "/mnt/felhom-usb", "intent": "enrolled", "total_bytes": 1000000000000 } ], "pve_storage": [ { "name": "local-lvm", "type": "lvmthin", "content": "rootdir,images" }, { "name": "felhom-usb", "type": "usb", "content": "backup" } ] }` @@ -104,6 +104,35 @@ func TestAssembleDRRecipe_MatchesGolden(t *testing.T) { } } +// TestAssembleDRRecipe_V1DriveShape is the regression guard for the v1 host-half drive shape +// (agent v0.39.0 dropped role + restic_repo_coord): a stored host half whose drives carry NEITHER +// field, but WHICH HAS a pbs block, must assemble cleanly — pbs present, drives passed through +// verbatim (RawMessage passthrough means the hub needs no struct change for the dropped fields). +func TestAssembleDRRecipe_V1DriveShape(t *testing.T) { + const v1Host = `{ + "recipe_version": 1, + "guests": [ { "vmid": 9201, "cores": 4, "memory_bytes": 12884901888, "disk_bytes": 34359738368 } ], + "pbs": { "repo_id": "felhom-pbs", "namespace": "root", "latest_snapshot_id": "9201" }, + "drives": [ { "durable_id": "uuid:da9e7089", "mount_path": "/mnt/felhom-usb", "intent": "enrolled", "total_bytes": 1000000000000 } ], + "pve_storage": [ { "name": "felhom-usb", "type": "usb", "content": "backup" } ] + }` + asm, err := AssembleDRRecipe(&DRRecipe{CustomerID: "c", RecipeVersion: 1, HostHalfJSON: v1Host}) + if err != nil { + t.Fatalf("v1 host half failed to assemble: %v", err) + } + // pbs must survive the stitch. + if !jsonContains(t, asm.PBS, "felhom-pbs") || !jsonContains(t, asm.PBS, "9201") { + t.Errorf("pbs coord not carried through assembly: %s", asm.PBS) + } + // drives passed through verbatim, and carry NEITHER dropped field. + if !jsonContains(t, asm.Drives, "uuid:da9e7089") { + t.Errorf("drives not passed through: %s", asm.Drives) + } + if strings.Contains(string(asm.Drives), "role") || strings.Contains(string(asm.Drives), "restic_repo_coord") { + t.Errorf("v1 drives must not carry role/restic_repo_coord: %s", asm.Drives) + } +} + // TestAssembleDRRecipe_IgnoreUnknownAndVersionSkew: a half carrying an UNKNOWN top-level field and a // HIGHER recipe_version still assembles (forward-compat), and recipe_version reflects the max. func TestAssembleDRRecipe_IgnoreUnknownAndVersionSkew(t *testing.T) { diff --git a/hub/internal/store/testdata/dr-recipe.golden.json b/hub/internal/store/testdata/dr-recipe.golden.json index e2b4346..42cd50c 100644 --- a/hub/internal/store/testdata/dr-recipe.golden.json +++ b/hub/internal/store/testdata/dr-recipe.golden.json @@ -8,7 +8,6 @@ "drives": [ { "durable_id": "uuid:da9e7089-cf8e-4617-adcb-a377743fae00", - "role": "bulk-data", "mount_path": "/mnt/felhom-usb", "intent": "enrolled", "total_bytes": 1000000000000