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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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" }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user