149a3b092e
Tracks felhom-agent v0.39.0, which dropped role + restic_repo_coord from the host-half drives[]. Hub reads drives as json.RawMessage (verbatim passthrough) → no store/handler change needed. Dropped role from the store golden + drHostHalf fixture; re-synced the api host-report golden byte-identical with the agent copy (it previously lacked dr_recipe entirely). Added TestAssembleDRRecipe_V1DriveShape regression guard (demonstrated to fail when the fixture re-adds role, then reverted). No behavior change; redeploy optional. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
212 lines
8.3 KiB
Go
212 lines
8.3 KiB
Go
package store
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"reflect"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// The two halves as the agent (host) and controller (app) emit them — keys must match the cross-repo
|
|
// golden (the agent's host-report.golden.json dr_recipe section + the controller's emitter).
|
|
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", "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" } ]
|
|
}`
|
|
|
|
const drAppHalf = `{
|
|
"recipe_version": 1,
|
|
"customer": { "id": "cust-demo", "display": "Demo Customer", "domain": "demo-felhom.eu" },
|
|
"apps": [ { "catalog_ref": "romm", "enabled": true, "storage_bindings": [ { "container_path": "/roms", "drive": "felhom-flash", "subpath": "userdata/roms" } ] } ]
|
|
}`
|
|
|
|
// TestDRRecipe_StoreRoundTrip: each half upserts independently and preserves the other; GetDRRecipe
|
|
// returns both.
|
|
func TestDRRecipe_StoreRoundTrip(t *testing.T) {
|
|
s := newTestStore(t)
|
|
|
|
// App half lands first.
|
|
if err := s.SaveDRRecipeAppHalf("cust-demo", 1, []byte(drAppHalf)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec, _ := s.GetDRRecipe("cust-demo")
|
|
if rec == nil || rec.AppHalfJSON == "" || rec.HostHalfJSON != "" {
|
|
t.Fatalf("after app-half: want app set, host empty, got %+v", rec)
|
|
}
|
|
|
|
// Host half lands later — must NOT clobber the app half.
|
|
if err := s.SaveDRRecipeHostHalf("cust-demo", "host-01", 1, []byte(drHostHalf)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec, _ = s.GetDRRecipe("cust-demo")
|
|
if rec == nil || rec.AppHalfJSON == "" || rec.HostHalfJSON == "" || rec.HostID != "host-01" {
|
|
t.Fatalf("after host-half: both halves must be present + host_id set, got %+v", rec)
|
|
}
|
|
|
|
// A re-report of the host half preserves the app half (and vice-versa).
|
|
if err := s.SaveDRRecipeHostHalf("cust-demo", "host-01", 1, []byte(drHostHalf)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec, _ = s.GetDRRecipe("cust-demo")
|
|
if rec.AppHalfJSON == "" {
|
|
t.Fatal("re-reporting the host half clobbered the app half")
|
|
}
|
|
|
|
// Absent customer → nil, no error.
|
|
if got, err := s.GetDRRecipe("nobody"); err != nil || got != nil {
|
|
t.Fatalf("absent customer should be (nil,nil), got (%v,%v)", got, err)
|
|
}
|
|
}
|
|
|
|
// TestAssembleDRRecipe_MatchesGolden: assembling both halves yields the golden's key shape (the
|
|
// cross-repo wire pin) and the correct stitched values.
|
|
func TestAssembleDRRecipe_MatchesGolden(t *testing.T) {
|
|
rec := &DRRecipe{CustomerID: "cust-demo", RecipeVersion: 1, HostHalfJSON: drHostHalf, AppHalfJSON: drAppHalf}
|
|
asm, err := AssembleDRRecipe(rec)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, _ := json.Marshal(asm)
|
|
var got map[string]any
|
|
json.Unmarshal(b, &got)
|
|
|
|
raw, err := os.ReadFile("testdata/dr-recipe.golden.json")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var golden map[string]any
|
|
if err := json.Unmarshal(raw, &golden); err != nil {
|
|
t.Fatalf("golden invalid: %v", err)
|
|
}
|
|
|
|
// Top-level key set must match the golden (the assembled wire shape).
|
|
if ga, gb := keysOf(golden), keysOf(got); !reflect.DeepEqual(ga, gb) {
|
|
t.Errorf("assembled key drift:\n golden=%v\n got =%v", ga, gb)
|
|
}
|
|
// And the stitched values: customer from the app half, drives/pbs from the host half.
|
|
if asm.RecipeVersion != 1 {
|
|
t.Errorf("recipe_version=%d want 1", asm.RecipeVersion)
|
|
}
|
|
if !jsonContains(t, asm.Customer, "cust-demo") || !jsonContains(t, asm.Customer, "demo-felhom.eu") {
|
|
t.Errorf("customer not stitched from app half: %s", asm.Customer)
|
|
}
|
|
if !jsonContains(t, asm.Drives, "uuid:da9e7089-cf8e-4617-adcb-a377743fae00") {
|
|
t.Errorf("drives not stitched from host half: %s", asm.Drives)
|
|
}
|
|
if !jsonContains(t, asm.Apps, "romm") {
|
|
t.Errorf("apps not stitched from app half: %s", asm.Apps)
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
futureHost := `{ "recipe_version": 2, "drives": [], "pve_storage": [], "guests": [],
|
|
"future_section": { "whatever": 1 }, "network_topology": ["a","b"] }`
|
|
rec := &DRRecipe{CustomerID: "c", RecipeVersion: 2, HostHalfJSON: futureHost, AppHalfJSON: drAppHalf}
|
|
asm, err := AssembleDRRecipe(rec)
|
|
if err != nil {
|
|
t.Fatalf("ignore-unknown failed to parse a forward-compat half: %v", err)
|
|
}
|
|
if asm.RecipeVersion != 2 {
|
|
t.Errorf("recipe_version=%d, want max(2,1)=2", asm.RecipeVersion)
|
|
}
|
|
// The unknown sections are dropped (not in AssembledRecipe), but the assembly did not error.
|
|
b, _ := json.Marshal(asm)
|
|
if string(b) == "" {
|
|
t.Fatal("empty assembly")
|
|
}
|
|
}
|
|
|
|
// TestAssembleDRRecipe_PartialHalves: only one half present → assemble what we have, no error.
|
|
func TestAssembleDRRecipe_PartialHalves(t *testing.T) {
|
|
onlyApp, err := AssembleDRRecipe(&DRRecipe{AppHalfJSON: drAppHalf})
|
|
if err != nil || onlyApp.Apps == nil || onlyApp.Drives != nil {
|
|
t.Errorf("only-app assembly wrong: %+v err=%v", onlyApp, err)
|
|
}
|
|
onlyHost, err := AssembleDRRecipe(&DRRecipe{HostHalfJSON: drHostHalf})
|
|
if err != nil || onlyHost.Drives == nil || onlyHost.Customer != nil {
|
|
t.Errorf("only-host assembly wrong: %+v err=%v", onlyHost, err)
|
|
}
|
|
empty, err := AssembleDRRecipe(nil)
|
|
if err != nil || empty.RecipeVersion != 1 {
|
|
t.Errorf("nil assembly should be a v1 empty recipe, got %+v err=%v", empty, err)
|
|
}
|
|
}
|
|
|
|
// TestAssembleDRRecipe_NoSecrets: defense-in-depth — the assembled output carries no credential-shaped
|
|
// key. (The load-bearing boundary is enforced at the controller emitter; this guards the hub side.)
|
|
func TestAssembleDRRecipe_NoSecrets(t *testing.T) {
|
|
asm, _ := AssembleDRRecipe(&DRRecipe{HostHalfJSON: drHostHalf, AppHalfJSON: drAppHalf})
|
|
b, _ := json.Marshal(asm)
|
|
re := regexp.MustCompile(`(?i)(password|secret|token|hash|passphrase|api[_-]?key|\bkey\b|enc:)`)
|
|
var v any
|
|
json.Unmarshal(b, &v)
|
|
var walk func(any)
|
|
walk = func(n any) {
|
|
switch x := n.(type) {
|
|
case map[string]any:
|
|
for k, c := range x {
|
|
if re.MatchString(k) {
|
|
t.Errorf("secret-shaped key %q in assembled recipe", k)
|
|
}
|
|
walk(c)
|
|
}
|
|
case []any:
|
|
for _, c := range x {
|
|
walk(c)
|
|
}
|
|
}
|
|
}
|
|
walk(v)
|
|
}
|
|
|
|
func jsonContains(t *testing.T, raw json.RawMessage, substr string) bool {
|
|
t.Helper()
|
|
return len(raw) > 0 && string(raw) != "null" && strings.Contains(string(raw), substr)
|
|
}
|
|
|
|
func keysOf(m map[string]any) []string {
|
|
ks := make([]string, 0, len(m))
|
|
for k := range m {
|
|
ks = append(ks, k)
|
|
}
|
|
sort.Strings(ks)
|
|
return ks
|
|
}
|