diff --git a/CHANGELOG.md b/CHANGELOG.md index 514808c..5a90b16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index f1116b4..9891718 100644 --- a/README.md +++ b/README.md @@ -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 — diff --git a/REPORT.md b/REPORT.md index c8bc7a0..1c0d5c8 100644 --- a/REPORT.md +++ b/REPORT.md @@ -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/`), 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:` 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/` 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. diff --git a/cmd/felhom-agent/main.go b/cmd/felhom-agent/main.go index 2b03671..0804ca3 100644 --- a/cmd/felhom-agent/main.go +++ b/cmd/felhom-agent/main.go @@ -44,7 +44,7 @@ import ( // version is the agent version. Overridable at build time with // -ldflags "-X main.version="; 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 `). On the // pre-start phase it creates placeholder dirs for any absent bind-mount source so the guest always boots diff --git a/internal/hub/collect.go b/internal/hub/collect.go index 51ce70f..228c4a1 100644 --- a/internal/hub/collect.go +++ b/internal/hub/collect.go @@ -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 } diff --git a/internal/hub/contract_test.go b/internal/hub/contract_test.go index 11e8c7f..27c9405 100644 --- a/internal/hub/contract_test.go +++ b/internal/hub/contract_test.go @@ -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). diff --git a/internal/hub/dr_recipe.go b/internal/hub/dr_recipe.go new file mode 100644 index 0000000..6b8b8c3 --- /dev/null +++ b/internal/hub/dr_recipe.go @@ -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: — 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, + } +} diff --git a/internal/hub/dr_recipe_test.go b/internal/hub/dr_recipe_test.go new file mode 100644 index 0000000..0b16016 --- /dev/null +++ b/internal/hub/dr_recipe_test.go @@ -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("", v) +} diff --git a/internal/hub/report.go b/internal/hub/report.go index 197da85..937ecfb 100644 --- a/internal/hub/report.go +++ b/internal/hub/report.go @@ -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. diff --git a/internal/hub/report_test.go b/internal/hub/report_test.go index 313c65e..3449798 100644 --- a/internal/hub/report_test.go +++ b/internal/hub/report_test.go @@ -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) diff --git a/internal/hub/testdata/host-report.golden.json b/internal/hub/testdata/host-report.golden.json index 7309266..9462d95 100644 --- a/internal/hub/testdata/host-report.golden.json +++ b/internal/hub/testdata/host-report.golden.json @@ -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" } + ] + } }