Files
felhom-agent/internal/hub/dr_recipe.go
T
admin bf8e3be3f4 agent: report served local-API leaf fingerprint (hub re-key detection, Part A) v0.48.0
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
2026-06-29 23:14:52 +02:00

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