agent v0.38.0: DR recipe — emit secret-free storage/guest/PBS half in host-report
DR recipe slice (agent half), grounded in SPIKE-dr-recipe-2026-06-16. Additive `dr_recipe` host-report section = the non-secret reconstruction scaffolding the operator must rebuild before PBS bytes can land. Built by pure BuildDRRecipeHostHalf from facts the report already collects (no new reads): guests[] sizing, drives[] (user-data by durable_id/role/mount/intent), pve_storage[] (storage.cfg), pbs coordinates. BOUNDARY (Phase-1 lesson): every field is an identifier/intent/size/coordinate — never a key/password/token/hash/ENC:. PBS key stays in escrow; restic password stays in escrow; the recipe names only the coordinates the restore targets. Tests: BuildDRRecipeHostHalf selection, NoPBS, NoSecrets (boundary mirror), dr_recipe key-set in the cross-repo golden contract test. recipe_version=1, ignore-unknown on read. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
package hub
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// secretNameRe matches any JSON key that smells like a credential. The DR recipe must contain NONE
|
||||
// (the Phase-1 lesson: the retired infra-backup shipped encryption_key_b64/restic_password/cf_api_token).
|
||||
// Mirrored on the controller app-half emitter (the heavier boundary test lives there).
|
||||
var secretNameRe = regexp.MustCompile(`(?i)(password|secret|token|hash|passphrase|api[_-]?key|\bkey\b|enc:)`)
|
||||
|
||||
func TestBuildDRRecipeHostHalf(t *testing.T) {
|
||||
guests := []Guest{
|
||||
{VMID: 9201, Name: "cust", Status: "running", Spec: &GuestSpec{Cores: 4, MemoryBytes: 12 << 30, DiskBytes: 32 << 30}},
|
||||
{VMID: 9202, Name: "unknown", Status: "unknown"}, // nil Spec → skipped (no sizing)
|
||||
}
|
||||
targets := []StorageTarget{
|
||||
{Name: "local", Type: StorageTypeLocal, Content: "vztmpl,iso"},
|
||||
{Name: "local-lvm", Type: StorageTypeLVMThin, DurableID: "pve/data", Content: "rootdir,images"},
|
||||
{Name: "felhom-pbs", Type: StorageTypePBS, DurableID: "repo+fp", Content: "backup"},
|
||||
{Name: "felhom-usb", Type: StorageTypeUSB, DurableID: "uuid:da9e7089", Role: "bulk-data",
|
||||
MountPath: "/mnt/felhom-usb", TotalBytes: 931 << 30},
|
||||
{Name: "felhom-flash", Type: StorageTypeLocalDir, DurableID: "uuid:81a26531", Role: "primary",
|
||||
MountPath: "/mnt/felhom-flash", TotalBytes: 119 << 30},
|
||||
}
|
||||
pbs := []PBSSnapshot{
|
||||
{Namespace: "root", BackupID: "9201", BackupTime: "2026-06-10T00:00:00Z"},
|
||||
{Namespace: "root", BackupID: "9201", BackupTime: "2026-06-16T08:00:00Z"}, // latest
|
||||
}
|
||||
|
||||
h := BuildDRRecipeHostHalf(guests, targets, pbs)
|
||||
|
||||
if h.RecipeVersion != 1 {
|
||||
t.Errorf("recipe_version=%d, want 1", h.RecipeVersion)
|
||||
}
|
||||
// guests: only the spec'd one.
|
||||
if len(h.Guests) != 1 || h.Guests[0].VMID != 9201 || h.Guests[0].Cores != 4 || h.Guests[0].MemoryBytes != 12<<30 {
|
||||
t.Errorf("guests = %+v, want only vmid 9201 with its sizing", h.Guests)
|
||||
}
|
||||
// pve_storage: ALL five targets (the storage.cfg scaffolding).
|
||||
if len(h.PVEStorage) != 5 {
|
||||
t.Errorf("pve_storage len=%d, want 5 (every target)", len(h.PVEStorage))
|
||||
}
|
||||
// drives: ONLY the two user-data drives (usb + local-dir with uuid + mount). NOT local/lvm/pbs.
|
||||
if len(h.Drives) != 2 {
|
||||
t.Fatalf("drives len=%d, want 2 user-data drives, got %+v", len(h.Drives), h.Drives)
|
||||
}
|
||||
byDur := map[string]DRDrive{}
|
||||
for _, d := range h.Drives {
|
||||
byDur[d.DurableID] = d
|
||||
if d.Intent != "enrolled" {
|
||||
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 {
|
||||
t.Errorf("felhom-usb drive wrong: %+v", d)
|
||||
}
|
||||
if _, ok := byDur["uuid:81a26531"]; !ok {
|
||||
t.Error("felhom-flash (local-dir user-data drive) missing from drives")
|
||||
}
|
||||
// pbs: latest snapshot's coords + the pbs storage id as repo_id.
|
||||
if h.PBS == nil || h.PBS.RepoID != "felhom-pbs" || h.PBS.Namespace != "root" || h.PBS.LatestSnapshotID != "9201" {
|
||||
t.Errorf("pbs coord = %+v, want repo felhom-pbs/root/9201", h.PBS)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildDRRecipeHostHalf_NoPBS: no snapshots → pbs omitted (nil), no panic.
|
||||
func TestBuildDRRecipeHostHalf_NoPBS(t *testing.T) {
|
||||
h := BuildDRRecipeHostHalf(nil, []StorageTarget{{Name: "local", Type: StorageTypeLocal}}, nil)
|
||||
if h.PBS != nil {
|
||||
t.Errorf("pbs should be nil with no snapshots, got %+v", h.PBS)
|
||||
}
|
||||
if h.Guests == nil || h.Drives == nil || h.PVEStorage == nil {
|
||||
t.Error("slices must be non-nil (marshal as [], not null)")
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func TestDRRecipeHostHalf_NoSecrets(t *testing.T) {
|
||||
h := BuildDRRecipeHostHalf(
|
||||
[]Guest{{VMID: 9201, Spec: &GuestSpec{Cores: 4, MemoryBytes: 1, DiskBytes: 1}}},
|
||||
[]StorageTarget{
|
||||
{Name: "felhom-pbs", Type: StorageTypePBS, Content: "backup"},
|
||||
{Name: "felhom-usb", Type: StorageTypeUSB, DurableID: "uuid:da9e7089", Role: "bulk-data", MountPath: "/mnt/felhom-usb", TotalBytes: 1},
|
||||
},
|
||||
[]PBSSnapshot{{Namespace: "root", BackupID: "9201", BackupTime: "2026-06-16T08:00:00Z"}},
|
||||
)
|
||||
b, err := json.Marshal(h)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertNoSecretKeys(t, b)
|
||||
}
|
||||
|
||||
// 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.)
|
||||
func assertNoSecretKeys(t *testing.T, jsonBytes []byte) {
|
||||
t.Helper()
|
||||
var v any
|
||||
if err := json.Unmarshal(jsonBytes, &v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var walk func(prefix string, node any)
|
||||
walk = func(prefix string, node any) {
|
||||
switch n := node.(type) {
|
||||
case map[string]any:
|
||||
for k, child := range n {
|
||||
if secretNameRe.MatchString(k) {
|
||||
t.Errorf("secret-shaped key %q at %s — the recipe must carry no credential field", k, prefix)
|
||||
}
|
||||
walk(prefix+"."+k, child)
|
||||
}
|
||||
case []any:
|
||||
for i, child := range n {
|
||||
walk(prefix, child)
|
||||
_ = i
|
||||
}
|
||||
}
|
||||
}
|
||||
walk("<root>", v)
|
||||
}
|
||||
Reference in New Issue
Block a user