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: — 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, } }