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:
2026-06-16 18:32:15 +02:00
parent c8d7ec6bb5
commit 8abc1b8852
11 changed files with 396 additions and 82 deletions
+3
View File
@@ -120,6 +120,9 @@ func (c *Collector) Collect(ctx context.Context) (*HostReport, error) {
AuditTail: []AuditEntry{},
Cloudflared: Cloudflared{Status: c.cloudflaredStatus(ctx)},
}
// DR recipe host-half — derived from the just-collected guest/storage/PBS facts (no new reads).
// Secret-free by construction (identifiers/intents/sizes/coordinates only).
report.DRRecipe = BuildDRRecipeHostHalf(report.Guests, report.StorageTargets, report.PBSSnapshots)
return report, nil
}
+12 -1
View File
@@ -42,7 +42,7 @@ func TestHostReport_ContractMatchesGolden(t *testing.T) {
},
{
Name: "usb-backup", Type: StorageTypeUSB, DurableID: "uuid:x",
State: StorageStateAttached, Reachable: true,
State: StorageStateAttached, Reachable: true, MountPath: "/mnt/usb-backup", TotalBytes: 2000000000000,
Smart: SmartSummary{Health: SmartUnknown},
},
},
@@ -73,6 +73,8 @@ func TestHostReport_ContractMatchesGolden(t *testing.T) {
AuditTail: []AuditEntry{},
Cloudflared: Cloudflared{Status: "active"},
}
// dr_recipe host-half: built from the same guest/storage/pbs facts (the production path).
report.DRRecipe = BuildDRRecipeHostHalf(report.Guests, report.StorageTargets, report.PBSSnapshots)
b, _ := json.Marshal(report)
var got map[string]any
json.Unmarshal(b, &got)
@@ -95,6 +97,15 @@ func TestHostReport_ContractMatchesGolden(t *testing.T) {
assertSameKeys(t, "restore_tests[0]", firstElem(golden["restore_tests"]), firstElem(got["restore_tests"]))
// slice-6-Phase-B addition — pbs_snapshots[0] key set.
assertSameKeys(t, "pbs_snapshots[0]", firstElem(golden["pbs_snapshots"]), firstElem(got["pbs_snapshots"]))
// DR-recipe host-half — the agent's secret-free reconstruction-scaffolding section. Assert the
// dr_recipe key set + each sub-array's element key set (the cross-repo wire pinned in the golden).
grec, srec := golden["dr_recipe"], got["dr_recipe"]
assertSameKeys(t, "dr_recipe", grec, srec)
assertSameKeys(t, "dr_recipe.pbs", field(grec, "pbs"), field(srec, "pbs"))
assertSameKeys(t, "dr_recipe.guests[0]", firstElem(field(grec, "guests")), firstElem(field(srec, "guests")))
assertSameKeys(t, "dr_recipe.drives[0]", firstElem(field(grec, "drives")), firstElem(field(srec, "drives")))
assertSameKeys(t, "dr_recipe.pve_storage[0]", firstElem(field(grec, "pve_storage")), firstElem(field(srec, "pve_storage")))
}
// field extracts a nested object value from a decoded JSON map (nil if absent/not a map).
+143
View File
@@ -0,0 +1,143 @@
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
// 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.
//
// 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
// snapshot + escrow blobs, recovered with R, never regenerated, never here. TestDRRecipeHostHalf_NoSecrets
// asserts no field name matches the secret regex. The hub assembles this half with the controller's
// app half into one customer recipe.
//
// 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.
const DRRecipeVersion = 1
// DRRecipeHostHalf is the agent-emitted half (guest/drive/storage/PBS scaffolding). Derived entirely
// from facts the report already collects — no new privileged reads.
type DRRecipeHostHalf struct {
RecipeVersion int `json:"recipe_version"`
Guests []DRGuest `json:"guests"`
PBS *DRPBSCoord `json:"pbs,omitempty"`
Drives []DRDrive `json:"drives"`
PVEStorage []DRPVEStorage `json:"pve_storage"`
}
// DRGuest is the sizing needed to recreate the LXC at the right size (GuestSpec, already on the wire).
type DRGuest struct {
VMID int `json:"vmid"`
Cores int `json:"cores"`
MemoryBytes int64 `json:"memory_bytes"`
DiskBytes int64 `json:"disk_bytes"`
}
// DRPBSCoord is WHERE the whole-CT snapshot lives — COORDINATES ONLY. The encryption key is escrow-only;
// the access token is identity-escrow-only. Neither is here.
type DRPBSCoord struct {
RepoID string `json:"repo_id"` // the PVE pbs storage id (e.g. "felhom-pbs") — not a token
Namespace string `json:"namespace"` // PBS namespace the restore targets
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.
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)
}
// DRPVEStorage is a PVE storage definition (to rebuild /etc/pve/storage.cfg scaffolding) — no auth.
type DRPVEStorage struct {
Name string `json:"name"`
Type string `json:"type"`
Content string `json:"content"`
}
// driveIntentEnrolled is the v1 intent for an emitted user-data drive. The agent's authoritative
// per-drive intent (enrolled/ejected/decommissioned) lives in the GuestBindStore; v1 emits the
// reachable user-data drives it observes as enrolled, with the field present for forward refinement.
const driveIntentEnrolled = "enrolled"
// BuildDRRecipeHostHalf assembles the agent half from the already-collected report facts — pure, so
// it is unit-tested directly (no live reads). drives[] = the user-data external drives (usb/local-dir
// with a durable-id); pve_storage[] = every storage target (the storage.cfg scaffolding); pbs = the
// latest PBS snapshot's coordinates; guests[] = each guest's sizing (skip guests with no spec).
func BuildDRRecipeHostHalf(guests []Guest, targets []StorageTarget, pbs []PBSSnapshot) *DRRecipeHostHalf {
h := &DRRecipeHostHalf{
RecipeVersion: DRRecipeVersion,
Guests: []DRGuest{},
Drives: []DRDrive{},
PVEStorage: []DRPVEStorage{},
}
for _, g := range guests {
if g.Spec == nil { // status unknown — no sizing to recreate from
continue
}
h.Guests = append(h.Guests, DRGuest{
VMID: g.VMID,
Cores: g.Spec.Cores,
MemoryBytes: g.Spec.MemoryBytes,
DiskBytes: g.Spec.DiskBytes,
})
}
var pbsRepoID string
for _, t := range targets {
h.PVEStorage = append(h.PVEStorage, DRPVEStorage{Name: t.Name, Type: t.Type, Content: t.Content})
if t.Type == StorageTypePBS && pbsRepoID == "" {
pbsRepoID = t.Name // the pbs storage id is a coordinate, not the key
}
if isUserDataDrive(t) {
h.Drives = append(h.Drives, DRDrive{
DurableID: t.DurableID,
Role: t.Role,
MountPath: t.MountPath,
Intent: driveIntentEnrolled,
TotalBytes: t.TotalBytes,
})
}
}
if c := latestPBSCoord(pbs, pbsRepoID); c != nil {
h.PBS = c
}
return h
}
// isUserDataDrive selects the external user-data drives the recipe enumerates (felhom-usb / felhom-flash
// class): a usb or local-dir storage with a filesystem-UUID durable id and a host mount path. local /
// lvmthin / pbs / nfs / cifs are scaffolding (they land in pve_storage) but are not user-data drives.
func isUserDataDrive(t StorageTarget) bool {
if t.Type != StorageTypeUSB && t.Type != StorageTypeLocalDir {
return false
}
return t.DurableID != "" && t.MountPath != ""
}
// latestPBSCoord picks the most-recent snapshot (lexical max of the RFC3339 backup_time) and returns
// its coordinates. Returns nil when there is no snapshot to target.
func latestPBSCoord(snaps []PBSSnapshot, repoID string) *DRPBSCoord {
if len(snaps) == 0 {
return nil
}
sorted := append([]PBSSnapshot(nil), snaps...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].BackupTime > sorted[j].BackupTime })
latest := sorted[0]
return &DRPBSCoord{
RepoID: repoID,
Namespace: latest.Namespace,
LatestSnapshotID: latest.BackupID,
}
}
+126
View File
@@ -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)
}
+5
View File
@@ -26,6 +26,11 @@ type HostReport struct {
Cloudflared Cloudflared `json:"cloudflared"`
AuditTail []AuditEntry `json:"audit_tail"` // populated by a later slice
// DR recipe — the agent (storage/guest/PBS) half of the secret-free reconstruction recipe
// (SPIKE-dr-recipe-2026-06-16). Derived from the facts above; carries ONLY identifiers/intents/
// sizes/coordinates, never a secret. The hub assembles it with the controller's app half.
DRRecipe *DRRecipeHostHalf `json:"dr_recipe"`
}
// HostMetrics is the host block, sourced from proxmox NodeStatus.
+3
View File
@@ -29,6 +29,9 @@ func TestHostReport_FieldNamesAndEmptyCollections(t *testing.T) {
AuditTail: []AuditEntry{},
Cloudflared: Cloudflared{Status: "active"},
}
// dr_recipe is always set on the real path (Collect); set it here too so the "no null" invariant
// covers it (empty pbs is omitempty → omitted, never null).
r.DRRecipe = BuildDRRecipeHostHalf(r.Guests, r.StorageTargets, r.PBSSnapshots)
b, err := json.Marshal(r)
if err != nil {
t.Fatal(err)
+25 -1
View File
@@ -131,5 +131,29 @@
}
],
"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",
"role": "",
"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" }
]
}
}