bf8e3be3f4
HostReport.LeafFingerprint rides the served fp (from EnsureLeaf) on every report; empty when local API disabled. Collector.SetLeafFingerprint threads it like Capabilities. Hub watches it for a re-key. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pg8ANF97SEeKYSN5Jxw3qJ
153 lines
6.9 KiB
Go
153 lines
6.9 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 (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
|
|
|
|
// 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. 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:<fs-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) *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,
|
|
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,
|
|
}
|
|
}
|