agent v0.38.0: DR recipe — emit secret-free storage/guest/PBS half in host-report

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>
This commit is contained in:
2026-06-16 18:32:15 +02:00
parent c8d7ec6bb5
commit 8abc1b8852
11 changed files with 396 additions and 82 deletions
+26
View File
@@ -3,6 +3,32 @@
All notable changes to **felhom-agent** are recorded here. Update on every code
change that gets pushed.
## v0.38.0 — DR recipe: emit the secret-free storage/guest/PBS half in the host-report (2026-06-16)
**DR recipe slice (agent half).** Additive `dr_recipe` section on the host-report — the agent half of the
secret-free reconstruction recipe (`SPIKE-dr-recipe-2026-06-16.md`) that complements escrow (keys) +
PBS/restic (bytes): the non-secret SCAFFOLDING an operator must rebuild before the PBS bytes can land.
The hub assembles it with the controller's app half into one customer recipe.
- `internal/hub/dr_recipe.go``DRRecipeHostHalf{recipe_version, guests[], pbs, drives[], pve_storage[]}`
built by the pure `BuildDRRecipeHostHalf(guests, targets, pbs)` from facts the report ALREADY collects
(no new privileged reads): `guests[]` = each guest's sizing (`GuestSpec`, skip status-unknown);
`drives[]` = the user-data external drives (usb/local-dir with a `uuid:` durable-id + mount path) with
`{durable_id, role, mount_path, intent, total_bytes}`; `pve_storage[]` = every storage target
`{name, type, content}` (the `storage.cfg` scaffolding); `pbs` = the latest snapshot's coordinates
`{repo_id (the pbs storage id), namespace, latest_snapshot_id}`. Wired into `Collect()` after the facts
are gathered; `HostReport.DRRecipe` (always set, never null).
- **BOUNDARY (the Phase-1 lesson):** every field is an identifier / intent / size / coordinate — NEVER a
key, password, token, hash, or `ENC:` value. The PBS encryption key stays in escrow; the access token in
identity-escrow; the restic password in escrow — the recipe names only the `repo_id`/`namespace`/
`durable_id`/`restic_repo_coord` the restore TARGETS. `recipe_version=1`; read is ignore-unknown
(forward-compat). The wire shape is pinned in the cross-repo golden (`host-report.golden.json` here +
the hub's copy — keep them byte-identical; manual checksum-diff on any change).
- Tests: `TestBuildDRRecipeHostHalf` (drives = only user-data; pve_storage = all; pbs = latest; guests
skip nil-spec), `..._NoPBS` (omitted, non-nil slices), `TestDRRecipeHostHalf_NoSecrets` (the lighter
boundary mirror — serialized half carries NO credential-shaped key; the load-bearing version is on the
controller emitter), and the `dr_recipe` key-set added to `TestHostReport_ContractMatchesGolden`.
## v0.37.0 — host-reboot remount re-resolves enrolled drives by filesystem UUID (2026-06-16)
**TASK A — close out the reboot story (agent half).** On a host reboot the kernel can re-enumerate block
+12
View File
@@ -47,6 +47,18 @@ The reported `StorageTarget` shape is a cross-repo contract duplicated in `felho
`internal/hub/testdata/host-report.golden.json` is byte-identical with the hub's copy and a
bidirectional key-set test guards drift.
### DR recipe — the storage/guest/PBS half (v0.38.0)
The host-report carries an additive `dr_recipe` section (`internal/hub/dr_recipe.go`) — the agent half
of the secret-free reconstruction recipe (`SPIKE-dr-recipe-2026-06-16.md`). It is the non-secret
**re-provision scaffolding** that complements escrow (keys) + PBS/restic (bytes): `guests[]` sizing,
`drives[]` (user-data drives by `durable_id` → role → mount → intent), `pve_storage[]` (the
`storage.cfg` defs), and `pbs` coordinates. Built by the pure `BuildDRRecipeHostHalf` from facts the
report already collects — **no new reads**. **Boundary:** every field is an identifier/intent/size/
coordinate — never a key/password/token/hash/`ENC:`; the PBS key + restic password stay in escrow.
`recipe_version=1`, ignore-unknown on read, pinned in the cross-repo golden. The hub assembles it with
the controller's app half into one customer recipe.
### The privileged `HostOps` surface (slice 5 Phase B)
The write side — the one place the agent steps outside its Proxmox API token into OS-root —
+40 -79
View File
@@ -1,95 +1,56 @@
# REPORT — felhom-agent v0.37.0 (host-reboot remount by durable-id/UUID)
# REPORT — felhom-agent v0.38.0 (DR recipe: agent storage/guest/PBS half)
**TASK — Close out the reboot story (agent half: Task A + Task A2).**
**TASK — DR recipe slice (agent half).** Emit the secret-free reconstruction-recipe scaffolding the
agent already owns, as an additive host-report section. Grounded in `SPIKE-dr-recipe-2026-06-16.md`.
## Task A — host-reboot remount resolves by filesystem UUID, never a cached node
## What
### Diagnose-first (root cause, pinned LIVE on felhom-pve)
The recipe is the non-secret **re-provision plan** that complements escrow (keys) and PBS/restic
(bytes): guest sizing, drive inventory (durable-id → role → mount → intent), PVE storage defs, and PBS
coordinates — the host/guest/storage scaffolding the operator must rebuild on new hardware *before* the
PBS bytes can land. The agent owns every fact (`StorageTarget`, `GuestSpec`, `PBSSnapshot`), so it emits
its half additively in the existing host-report; the hub assembles it with the controller's app half.
On a host reboot the kernel re-enumerates block devices; a drive can move node (felhom-usb
`/dev/sdb``/dev/sdc`). Two things could leave an enrolled drive unmounted after a reboot:
## Implementation
1. A systemd `.mount` unit left **`disabled`** by a prior detach (`Unmount` = stop + disable)
never gets a `multi-user.target.wants` symlink, so it does **not** auto-mount at boot — and
nothing in the agent re-asserted it at startup.
2. Any remount that trusted a remembered `/dev/sdX` node would target the WRONG device after a
letter move.
- `internal/hub/dr_recipe.go` — wire types + the pure `BuildDRRecipeHostHalf(guests, targets, pbs)`:
- `guests[]` ← each `Guest.Spec` (cores/memory/disk), skipping status-unknown guests.
- `drives[]` ← user-data external drives only (`isUserDataDrive`: usb/local-dir with a non-empty
durable-id + mount path) → `{durable_id, role, mount_path, intent=enrolled, total_bytes}`.
- `pve_storage[]`**every** storage target `{name, type, content}` (rebuild `storage.cfg`).
- `pbs` ← the latest snapshot's `{repo_id (the pbs storage id), namespace, latest_snapshot_id}`.
- `HostReport.DRRecipe *DRRecipeHostHalf json:"dr_recipe"` (always set in `Collect()`, never null).
- No new reads — derived from the just-collected report facts.
Confirmed live: felhom-usb's mount unit was `disabled` (`systemctl is-enabled``disabled`,
no wants-symlink) while felhom-flash's was `enabled`. The `What=` line was already correct
(`/dev/disk/by-uuid/<UUID>`), so the fix is to **re-assert the unit by UUID at startup**, not to
rewrite the unit.
## Boundary (non-negotiable)
### Fix
Every field is an identifier / intent / size / coordinate. The PBS **key** stays in escrow, the access
**token** in identity-escrow, the restic **password** in escrow — the recipe names only the coordinates
the restore targets. This is the exact axis the retired infra-backup violated (it shipped
`encryption_key_b64`/`restic_password`/`cf_api_token`).
- `storage.ResolveStorageDevice(durableID)` (`durable_device.go`) — resolves the enrolled
`uuid:<fs-uuid>` storage scheme to its **current** backing `/dev` node by re-scanning
`/dev/disk/by-uuid` (`EvalSymlinks`); rejects any non-`uuid:` scheme (anti-node-binding) and
errors when the UUID is absent so a caller skips a gone drive instead of fail-mounting.
- `storage.parseFelhomMountUnit` (`mountunit.go`) — pure inverse of `renderMountUnit`, gated on a
`Managed by felhom-agent` marker; extracts Name/UUID/Where/Type/Options, ignores foreign units.
- `(*SudoHostOps).ReassertEnrolledMounts(ctx)` (`hostops.go`) — for each enrolled `.mount` unit,
re-resolve by UUID and re-run `EnsureMount` (idempotent `systemctl enable --now`). Skips ONLY the
durable steady state (mounted AND enabled), via the pure `shouldReassertMount`; a
**mounted-but-DISABLED** unit (the exact live felhom-usb state — serves now, but a reboot would not
auto-mount it) is still re-asserted so the wants-symlink is re-created. Enabled-state read with a
privilege-free `os.Lstat` of the `multi-user.target.wants` symlink (`unitEnabled`) — no
`systemctl is-enabled` subprocess / no new sudoers entry. Absent UUIDs skipped (retry next tick).
- `main.go` — runs `ReassertEnrolledMounts` at startup **before** `ReassertGuestBinds`, and again
ahead of the bind re-assert on the periodic 20s tick, so host mounts are live before the guest
binds re-establish.
## Tests (non-hollow)
### Tests (non-hollow, seam = device resolution; Linux)
- `TestBuildDRRecipeHostHalf` — drives = only user-data; pve_storage = all targets; pbs = latest
snapshot; guests skip nil-spec.
- `TestBuildDRRecipeHostHalf_NoPBS` — no snapshots → `pbs` omitted, slices non-nil.
- `TestDRRecipeHostHalf_NoSecrets` — the boundary mirror: serialized half has NO key matching
`(?i)(password|secret|token|hash|passphrase|api[_-]?key|\bkey\b|enc:)`. (The load-bearing boundary
test, with a synthetic-secret app + allowlist red-proof, lives on the controller emitter.)
- `dr_recipe` key-set + sub-array element key-sets added to `TestHostReport_ContractMatchesGolden`
(the cross-repo golden, byte-pinned with the hub's copy).
- `TestResolveStorageDevice_ToleratesDeviceLetterMove` — fake `/dev/disk/by-uuid/<uuid>` symlink
pointed at `sdc` (moved from the enroll-time `sdb`); asserts `ResolveStorageDevice` returns the
CURRENT node `sdc`. **Companion (red-proof):** asserts the cached enroll-time node (`sdb`) differs
from the freshly-resolved node — a node-trusting remount would mount the wrong device.
- `TestResolveStorageDevice_AbsentAndScheme` — absent UUID errors; only `uuid:` is resolvable
(`/dev/sdb1`, `store:`, `byid:`, `""` all rejected).
- `TestParseFelhomMountUnit``renderMountUnit``parseFelhomMountUnit` round-trip + rejects a
non-felhom unit.
- `TestShouldReassertMount` — the four mounted/enabled combinations; pins that mounted-but-disabled
(the live bug) re-asserts and only mounted+enabled is skipped.
- `TestUnitEnabled` — wants-symlink presence ⇒ enabled (privilege-free `Lstat`).
## Versioning
## Task A2 — verdict: enrolling a NEW drive does NOT need an LXC restart
**NO.** Source-verified the enroll path: `/disks/guest-attach``handleDiskGuestAttach`
`GuestBinder.AttachDrive` (intermediary-mount model, "no pct, no reboot") — a live bind under the
single shared parent `/mnt/felhom-drives`, which provides unbounded **named** live slots. It does
NOT take the legacy `RebootGuest` branch. The operator's pre-created-slot-pool idea is unnecessary.
`recipe_version=1`, carried in the section. Read is ignore-unknown (encoding/json default) for
forward-compat, mirroring `storage_manifest`. Golden discipline: `host-report.golden.json` here must stay
byte-identical to the hub's copy — manual checksum-diff on any wire change (the golden spans three repos).
## Gate
`go build ./...`, `go vet ./...`, `go test ./...` all green locally (Windows: the symlink tests
skip — they assert on Linux, the agent's OS). Linux green gate + companion red-proof run on the
build server (192.168.0.180, go1.26.0) — see live-acceptance section.
`go build`, `go vet`, `go test ./...` all green (local + build server). Deployed to felhom-pve.
## Live acceptance — real host reboot ×2 on felhom-pve (agent v0.37.0 deployed)
## Deferred (NOT in this slice)
**Pre-reboot live proof of the root cause + fix:** felhom-usb's `mnt-felhom\x2dusb.mount` was
`disabled` while felhom-flash's was `enabled`. After deploying v0.37.0 and restarting the agent,
`ReassertEnrolledMounts` logged `re-asserted enrolled mount by UUID (enable --now) … wasMounted=true`
and the unit went `enabled` (wants-symlink created) — the mounted-but-disabled case handled.
**The `/dev/sdX` reshuffle actually occurred and was tolerated as a no-op** (the headline result):
| Drive (UUID) | before #1 | after #1 | after #2 |
|---|---|---|---|
| felhom-usb (`da9e7089…`, 931G) | `sdb` | **`sdc`** | `sdb` |
| felhom-flash (`81a26531…`, 119G) | `sdc` | **`sdb`** | `sdc` |
The two drives **swapped letters** on reboot #1 and swapped back on reboot #2. Both times each
landed at its correct mountpoint by UUID — `findmnt /mnt/felhom-usb` → the `da9e7089` device,
`/mnt/felhom-flash` → the `81a26531` device — never the wrong device. Both units `enabled`+`active`,
no agent storage errors. On a clean boot the (now-enabled) units auto-mount, so `ReassertEnrolledMounts`
finds them mounted+enabled and skips (the intended no-churn steady state).
- **Ordering:** `felhom-shared-parent.service` Finished `18:02:16` / `18:06:01`; `pve-guests.service`
Starting `18:02:22` / `18:06:07` — shared parent re-establishes ~6s BEFORE the guests, both reboots.
- **Guest recovery:** guest 9201 came up and all drive-backed apps recovered (immich, paperless,
jellyfin, calibre, audiobookshelf, radarr, romm, komga) both reboots; romm's backing mount
`/mnt/felhom-drives/felhom-flash/userdata/roms` resolved correctly across the swap.
Secrets (local_api token / leaf fingerprint) handled out-of-band — none committed.
The agent-side `restore_directive` consumption / recovery-mode execution (`syncer.go:92`) — slice-10D.
This slice only EMITS the recipe.
+1 -1
View File
@@ -44,7 +44,7 @@ import (
// version is the agent version. Overridable at build time with
// -ldflags "-X main.version=<v>"; defaults to the in-repo CHANGELOG version.
var version = "0.37.0"
var version = "0.38.0"
// runGuestHook is the PVE pre-start hook body (`felhom-agent guest-hook <vmid> <phase>`). On the
// pre-start phase it creates placeholder dirs for any absent bind-mount source so the guest always boots
+3
View File
@@ -120,6 +120,9 @@ func (c *Collector) Collect(ctx context.Context) (*HostReport, error) {
AuditTail: []AuditEntry{},
Cloudflared: Cloudflared{Status: c.cloudflaredStatus(ctx)},
}
// DR recipe host-half — derived from the just-collected guest/storage/PBS facts (no new reads).
// Secret-free by construction (identifiers/intents/sizes/coordinates only).
report.DRRecipe = BuildDRRecipeHostHalf(report.Guests, report.StorageTargets, report.PBSSnapshots)
return report, nil
}
+12 -1
View File
@@ -42,7 +42,7 @@ func TestHostReport_ContractMatchesGolden(t *testing.T) {
},
{
Name: "usb-backup", Type: StorageTypeUSB, DurableID: "uuid:x",
State: StorageStateAttached, Reachable: true,
State: StorageStateAttached, Reachable: true, MountPath: "/mnt/usb-backup", TotalBytes: 2000000000000,
Smart: SmartSummary{Health: SmartUnknown},
},
},
@@ -73,6 +73,8 @@ func TestHostReport_ContractMatchesGolden(t *testing.T) {
AuditTail: []AuditEntry{},
Cloudflared: Cloudflared{Status: "active"},
}
// dr_recipe host-half: built from the same guest/storage/pbs facts (the production path).
report.DRRecipe = BuildDRRecipeHostHalf(report.Guests, report.StorageTargets, report.PBSSnapshots)
b, _ := json.Marshal(report)
var got map[string]any
json.Unmarshal(b, &got)
@@ -95,6 +97,15 @@ func TestHostReport_ContractMatchesGolden(t *testing.T) {
assertSameKeys(t, "restore_tests[0]", firstElem(golden["restore_tests"]), firstElem(got["restore_tests"]))
// slice-6-Phase-B addition — pbs_snapshots[0] key set.
assertSameKeys(t, "pbs_snapshots[0]", firstElem(golden["pbs_snapshots"]), firstElem(got["pbs_snapshots"]))
// DR-recipe host-half — the agent's secret-free reconstruction-scaffolding section. Assert the
// dr_recipe key set + each sub-array's element key set (the cross-repo wire pinned in the golden).
grec, srec := golden["dr_recipe"], got["dr_recipe"]
assertSameKeys(t, "dr_recipe", grec, srec)
assertSameKeys(t, "dr_recipe.pbs", field(grec, "pbs"), field(srec, "pbs"))
assertSameKeys(t, "dr_recipe.guests[0]", firstElem(field(grec, "guests")), firstElem(field(srec, "guests")))
assertSameKeys(t, "dr_recipe.drives[0]", firstElem(field(grec, "drives")), firstElem(field(srec, "drives")))
assertSameKeys(t, "dr_recipe.pve_storage[0]", firstElem(field(grec, "pve_storage")), firstElem(field(srec, "pve_storage")))
}
// field extracts a nested object value from a decoded JSON map (nil if absent/not a map).
+143
View File
@@ -0,0 +1,143 @@
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,
}
}
+126
View File
@@ -0,0 +1,126 @@
package hub
import (
"encoding/json"
"regexp"
"testing"
)
// secretNameRe matches any JSON key that smells like a credential. The DR recipe must contain NONE
// (the Phase-1 lesson: the retired infra-backup shipped encryption_key_b64/restic_password/cf_api_token).
// Mirrored on the controller app-half emitter (the heavier boundary test lives there).
var secretNameRe = regexp.MustCompile(`(?i)(password|secret|token|hash|passphrase|api[_-]?key|\bkey\b|enc:)`)
func TestBuildDRRecipeHostHalf(t *testing.T) {
guests := []Guest{
{VMID: 9201, Name: "cust", Status: "running", Spec: &GuestSpec{Cores: 4, MemoryBytes: 12 << 30, DiskBytes: 32 << 30}},
{VMID: 9202, Name: "unknown", Status: "unknown"}, // nil Spec → skipped (no sizing)
}
targets := []StorageTarget{
{Name: "local", Type: StorageTypeLocal, Content: "vztmpl,iso"},
{Name: "local-lvm", Type: StorageTypeLVMThin, DurableID: "pve/data", Content: "rootdir,images"},
{Name: "felhom-pbs", Type: StorageTypePBS, DurableID: "repo+fp", Content: "backup"},
{Name: "felhom-usb", Type: StorageTypeUSB, DurableID: "uuid:da9e7089", Role: "bulk-data",
MountPath: "/mnt/felhom-usb", TotalBytes: 931 << 30},
{Name: "felhom-flash", Type: StorageTypeLocalDir, DurableID: "uuid:81a26531", Role: "primary",
MountPath: "/mnt/felhom-flash", TotalBytes: 119 << 30},
}
pbs := []PBSSnapshot{
{Namespace: "root", BackupID: "9201", BackupTime: "2026-06-10T00:00:00Z"},
{Namespace: "root", BackupID: "9201", BackupTime: "2026-06-16T08:00:00Z"}, // latest
}
h := BuildDRRecipeHostHalf(guests, targets, pbs)
if h.RecipeVersion != 1 {
t.Errorf("recipe_version=%d, want 1", h.RecipeVersion)
}
// guests: only the spec'd one.
if len(h.Guests) != 1 || h.Guests[0].VMID != 9201 || h.Guests[0].Cores != 4 || h.Guests[0].MemoryBytes != 12<<30 {
t.Errorf("guests = %+v, want only vmid 9201 with its sizing", h.Guests)
}
// pve_storage: ALL five targets (the storage.cfg scaffolding).
if len(h.PVEStorage) != 5 {
t.Errorf("pve_storage len=%d, want 5 (every target)", len(h.PVEStorage))
}
// drives: ONLY the two user-data drives (usb + local-dir with uuid + mount). NOT local/lvm/pbs.
if len(h.Drives) != 2 {
t.Fatalf("drives len=%d, want 2 user-data drives, got %+v", len(h.Drives), h.Drives)
}
byDur := map[string]DRDrive{}
for _, d := range h.Drives {
byDur[d.DurableID] = d
if d.Intent != "enrolled" {
t.Errorf("drive %s intent=%q, want enrolled", d.DurableID, d.Intent)
}
}
if d, ok := byDur["uuid:da9e7089"]; !ok || d.Role != "bulk-data" || d.MountPath != "/mnt/felhom-usb" || d.TotalBytes != 931<<30 {
t.Errorf("felhom-usb drive wrong: %+v", d)
}
if _, ok := byDur["uuid:81a26531"]; !ok {
t.Error("felhom-flash (local-dir user-data drive) missing from drives")
}
// pbs: latest snapshot's coords + the pbs storage id as repo_id.
if h.PBS == nil || h.PBS.RepoID != "felhom-pbs" || h.PBS.Namespace != "root" || h.PBS.LatestSnapshotID != "9201" {
t.Errorf("pbs coord = %+v, want repo felhom-pbs/root/9201", h.PBS)
}
}
// TestBuildDRRecipeHostHalf_NoPBS: no snapshots → pbs omitted (nil), no panic.
func TestBuildDRRecipeHostHalf_NoPBS(t *testing.T) {
h := BuildDRRecipeHostHalf(nil, []StorageTarget{{Name: "local", Type: StorageTypeLocal}}, nil)
if h.PBS != nil {
t.Errorf("pbs should be nil with no snapshots, got %+v", h.PBS)
}
if h.Guests == nil || h.Drives == nil || h.PVEStorage == nil {
t.Error("slices must be non-nil (marshal as [], not null)")
}
}
// TestDRRecipeHostHalf_NoSecrets is the agent-side boundary assertion (the lighter mirror of the
// controller's load-bearing boundary test): a fully-populated host-half must carry NO field whose
// name smells like a credential. If a future field leaks a key/token/hash in, this fails.
func TestDRRecipeHostHalf_NoSecrets(t *testing.T) {
h := BuildDRRecipeHostHalf(
[]Guest{{VMID: 9201, Spec: &GuestSpec{Cores: 4, MemoryBytes: 1, DiskBytes: 1}}},
[]StorageTarget{
{Name: "felhom-pbs", Type: StorageTypePBS, Content: "backup"},
{Name: "felhom-usb", Type: StorageTypeUSB, DurableID: "uuid:da9e7089", Role: "bulk-data", MountPath: "/mnt/felhom-usb", TotalBytes: 1},
},
[]PBSSnapshot{{Namespace: "root", BackupID: "9201", BackupTime: "2026-06-16T08:00:00Z"}},
)
b, err := json.Marshal(h)
if err != nil {
t.Fatal(err)
}
assertNoSecretKeys(t, b)
}
// assertNoSecretKeys walks decoded JSON and fails on any object key matching secretNameRe. Shared by
// the agent boundary assertions. (durable_id/repo_id/latest_snapshot_id/restic_repo_coord are
// identifiers/coordinates — none match the credential regex.)
func assertNoSecretKeys(t *testing.T, jsonBytes []byte) {
t.Helper()
var v any
if err := json.Unmarshal(jsonBytes, &v); err != nil {
t.Fatal(err)
}
var walk func(prefix string, node any)
walk = func(prefix string, node any) {
switch n := node.(type) {
case map[string]any:
for k, child := range n {
if secretNameRe.MatchString(k) {
t.Errorf("secret-shaped key %q at %s — the recipe must carry no credential field", k, prefix)
}
walk(prefix+"."+k, child)
}
case []any:
for i, child := range n {
walk(prefix, child)
_ = i
}
}
}
walk("<root>", v)
}
+5
View File
@@ -26,6 +26,11 @@ type HostReport struct {
Cloudflared Cloudflared `json:"cloudflared"`
AuditTail []AuditEntry `json:"audit_tail"` // populated by a later slice
// DR recipe — the agent (storage/guest/PBS) half of the secret-free reconstruction recipe
// (SPIKE-dr-recipe-2026-06-16). Derived from the facts above; carries ONLY identifiers/intents/
// sizes/coordinates, never a secret. The hub assembles it with the controller's app half.
DRRecipe *DRRecipeHostHalf `json:"dr_recipe"`
}
// HostMetrics is the host block, sourced from proxmox NodeStatus.
+3
View File
@@ -29,6 +29,9 @@ func TestHostReport_FieldNamesAndEmptyCollections(t *testing.T) {
AuditTail: []AuditEntry{},
Cloudflared: Cloudflared{Status: "active"},
}
// dr_recipe is always set on the real path (Collect); set it here too so the "no null" invariant
// covers it (empty pbs is omitempty → omitted, never null).
r.DRRecipe = BuildDRRecipeHostHalf(r.Guests, r.StorageTargets, r.PBSSnapshots)
b, err := json.Marshal(r)
if err != nil {
t.Fatal(err)
+25 -1
View File
@@ -131,5 +131,29 @@
}
],
"cloudflared": { "status": "active" },
"audit_tail": []
"audit_tail": [],
"dr_recipe": {
"recipe_version": 1,
"guests": [
{ "vmid": 100, "cores": 2, "memory_bytes": 2147483648, "disk_bytes": 21474836480 }
],
"pbs": {
"repo_id": "felhom-pbs",
"namespace": "root",
"latest_snapshot_id": "9001"
},
"drives": [
{
"durable_id": "uuid:0fc63daf-8483-4772-8e79-3d69d8477de4",
"role": "",
"mount_path": "/mnt/usb-backup",
"intent": "enrolled",
"total_bytes": 2000000000000
}
],
"pve_storage": [
{ "name": "local-lvm", "type": "lvmthin", "content": "rootdir,images" },
{ "name": "usb-backup", "type": "usb", "content": "backup" }
]
}
}