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:
@@ -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
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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" }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user