8abc1b8852
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>
144 lines
6.2 KiB
Go
144 lines
6.2 KiB
Go
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,
|
|
}
|
|
}
|