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 (bytes): it is // the non-secret SCAFFOLDING an operator must rebuild before the PBS bytes can land — guest sizing, // drive inventory (durable-id → mount → intent → size), 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. // // v1 host-half drive shape (v0.39.0) = identifiers/intent/size ONLY: {durable_id, mount_path, intent, // fs_type?, total_bytes}. Two fields were deliberately DROPPED from v1: // - role — a drive's purpose (primary/bulk-data/…) is a hub/operator-owned manifest concept, not // cleanly derivable host-side (both demo externals are content=backup, yet one is the primary // data drive and the other holds no apps). Deferred until the hub/operator stamps it. // - restic_repo_coord — RESERVED for a future offsite bulk-volume backup tier. None exists today: // external-drive data has no offsite/second-failure-domain copy (cross-drive backup is rsync to // the SAME internal SSD), so the field named nothing real. Re-add when that tier ships. // // The pbs coord, by contrast, is resolved LIVE each collect (LiveSnapshotReporter) so the restore // SOURCE is present whenever PBS is reachable — not gated on the 6 h verify cadence. // // 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 // Recipe field states (R-106/R-109). A recipe is read at the worst possible moment — by an operator // rebuilding a machine that is gone — so a field the agent cannot resolve must SAY SO rather than emit // a default, an empty string, or a plausible-looking placeholder. A guess read as fact costs more than // an admitted gap: it sends the restore at the wrong archive and nothing contradicts it. This is the // same cannot-tell-must-not-lie rule R-117 needed a third state for. const ( DRStateResolved = "resolved" DRStateUnknown = "unknown" ) // Reasons a resolved-value field is unknown. Enum-shaped, never free text, so the wire stays pinnable // and TestDRRecipeHostHalf_NoSecrets has a fixed vocabulary to walk. const ( // DRReasonNoBackupConfig: the collector was built without a backup-config seam, so the agent could // not consult the very config its own scheduler reads. Nothing is guessed. DRReasonNoBackupConfig = "agent_backup_config_unavailable" // DRReasonNoSuchStorage: the configured target id matches no storage this host observes. The id is // still recorded (it IS what the config says) and the state says it could not be corroborated. DRReasonNoSuchStorage = "not_a_known_storage" // DRReasonNoPBSStorage: snapshots exist but no pbs storage was observed, so there is no storage.cfg // row to read the namespace from. DRReasonNoPBSStorage = "no_pbs_storage_observed" ) // PBSRootNamespace is how the recipe spells PBS's root namespace. The PBS API spells it as the EMPTY // string (and `pct restore --ns root` would name a namespace that does not exist) — "root" is a display // convention this wire has always used, kept here so the field's meaning did not change under R-106. // Only a box with no `namespace` line in its pbs storage.cfg stanza ever emits it. const PBSRootNamespace = "root" // 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"` // BackupTarget names WHICH storage holds the local whole-guest archives (R-109). Always present — // its own State field carries "I could not tell", so the section is never simply absent. BackupTarget *DRBackupTarget `json:"backup_target"` } // DRBackupTarget answers the one question pve_storage cannot: of every storage listed there, WHICH one // does this box's primary backup tier actually write its whole-guest archives to? // // Before R-109 the recipe listed each storage's name/type/content and said nothing about the target. // That was harmless while the target was the well-known `local`; the 2026-07-28 vzdump-target move // ended that. Every box now carries TWO content=backup dir storages — `felhom-backup` (live) and // `local` (archives frozen at the move, never refreshed since) — and they are indistinguishable by // name, type and content alone. A restorer picking the frozen one gets a guest that restores cleanly // and is silently months out of date, which is the worst shape a backup defect can take. type DRBackupTarget struct { // State is DRStateResolved | DRStateUnknown. A reader MUST consult it before trusting StorageID: // the id is also recorded in one unknown case (see DRReasonNoSuchStorage). State string `json:"state"` // StorageID is the PVE storage id of the PRIMARY backup tier. Empty only when the config could not // be consulted at all. StorageID string `json:"storage_id,omitempty"` // MountPath is where that storage's archives land on the host — the disambiguation a restorer // actually needs, since it is what separates felhom-backup's /mnt/hdd_1 from local's /var/lib/vz. // "" for a pbs target (no host mount) and when unresolved. MountPath string `json:"mount_path,omitempty"` // Reason is why State is unknown (one of the DRReason* constants); "" when resolved. Reason string `json:"reason,omitempty"` } // ConfiguredBackupTarget is what the agent's own backup config says the PRIMARY tier writes to. // // Known=false is a REAL state, not a nil-guard: it means the collector was constructed without the // backup-config seam (the --selftest one-shots did exactly this before v0.118.0), and the recipe then // records unknown instead of inventing a target. Deliberately a struct rather than a `(string, bool)` // return — the (value, ok) shape is what made "errors degrade to unknown, never to no-backup" // unimplementable in newestArchiveOn, and this field has the same three-way reading. type ConfiguredBackupTarget struct { StorageID string Known bool } // 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 is the PBS namespace the restore targets, resolved from the pbs storage's storage.cfg // stanza — the same field `vzdump --storage ` makes PVE read, so the recipe cannot disagree // with the backup that produced the snapshot. PBSRootNamespace when the box has no namespace // configured; "" when NamespaceState is unknown. // // R-106: this used to come from the listed snapshot's own `ns`, which PBS does not echo per item once // the request is already namespace-scoped via `?ns=` (internal/pbs/client.go). The field was // therefore always empty, ToHub normalised empty → "root", and every per-customer box reported the // root namespace while its backups were really in `demo-hp` / `demo-felhom`. Namespace string `json:"namespace"` // NamespaceState is DRStateResolved | DRStateUnknown — consult it before trusting Namespace. NamespaceState string `json:"namespace_state"` // NamespaceReason is why NamespaceState is unknown; "" when resolved. NamespaceReason string `json:"namespace_reason,omitempty"` LatestSnapshotID string `json:"latest_snapshot_id"` // most-recent snapshot's backup_id (a coordinate) } // DRDrive is one user-data drive: identifiers + intent + size. v1 carries ONLY these fields (role + // restic_repo_coord were dropped — see the file header for why). Every field is an identifier, intent, // or size; none is a credential. type DRDrive struct { DurableID string `json:"durable_id"` // uuid: — a hardware identifier, not a credential MountPath string `json:"mount_path"` Intent string `json:"intent"` // enrolled | ejected | decommissioned FSType string `json:"fs_type,omitempty"` TotalBytes int64 `json:"total_bytes"` } // 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, backupTarget ConfiguredBackupTarget) *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, pbsNamespace string var pbsStorageFound bool for _, t := range targets { h.PVEStorage = append(h.PVEStorage, DRPVEStorage{Name: t.Name, Type: t.Type, Content: t.Content}) if t.Type == StorageTypePBS && !pbsStorageFound { pbsStorageFound = true pbsRepoID = t.Name // the pbs storage id is a coordinate, not the key pbsNamespace = t.PBSNamespace // storage.cfg's namespace — "" here means the ROOT namespace } if isUserDataDrive(t) { h.Drives = append(h.Drives, DRDrive{ DurableID: t.DurableID, MountPath: t.MountPath, Intent: driveIntentEnrolled, TotalBytes: t.TotalBytes, }) } } h.BackupTarget = resolveBackupTarget(targets, backupTarget) if c := latestPBSCoord(pbs, pbsRepoID, pbsNamespace, pbsStorageFound); c != nil { h.PBS = c } return h } // resolveBackupTarget records WHICH storage the primary backup tier writes to (R-109), or records // explicitly that it could not tell. Three outcomes, and the two unknowns are deliberately distinct — // "I could not read my own config" and "my config names a storage that is not here" send an operator // to different places. // // MountPath prefers the live mount and falls back to the CONFIGURED path: during a rebuild the drive is // frequently absent, and when it is, MountPath empties out while ConfigPath is the only thing left that // still says which drive the row was about (the R-116 lesson). The storage's absence from the host is a // separate signal (E-2's backup_target_absent); it does not make the recipe's answer unknown, because // the question here is which storage.cfg row to restore FROM, and that is still known. func resolveBackupTarget(targets []StorageTarget, cfg ConfiguredBackupTarget) *DRBackupTarget { if !cfg.Known || cfg.StorageID == "" { return &DRBackupTarget{State: DRStateUnknown, Reason: DRReasonNoBackupConfig} } for _, t := range targets { if t.Name != cfg.StorageID { continue } mount := t.MountPath if mount == "" { mount = t.ConfigPath } return &DRBackupTarget{State: DRStateResolved, StorageID: cfg.StorageID, MountPath: mount} } return &DRBackupTarget{State: DRStateUnknown, StorageID: cfg.StorageID, Reason: DRReasonNoSuchStorage} } // 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. // // The namespace comes from the pbs STORAGE (storage.cfg), never from the snapshot — see DRPBSCoord's // Namespace comment for why the snapshot's own field cannot answer it (R-106). storageFound=false with // snapshots present is a genuine unknown: something listed snapshots, but there is no storage row to // read a namespace from, so the recipe says so rather than defaulting to root. func latestPBSCoord(snaps []PBSSnapshot, repoID, namespace string, storageFound bool) *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] c := &DRPBSCoord{ RepoID: repoID, LatestSnapshotID: latest.BackupID, NamespaceState: DRStateUnknown, NamespaceReason: DRReasonNoPBSStorage, } if storageFound { c.NamespaceState, c.NamespaceReason = DRStateResolved, "" // An empty configured namespace is not a missing answer — it IS the root namespace. if c.Namespace = namespace; c.Namespace == "" { c.Namespace = PBSRootNamespace } } return c }