12 KiB
Slice 6 Phase A — backup + restore-test orchestration (felhom-agent)
Context
The agent can observe/report storage (slice 5) and reconcile benign guest ops behind a signed-op gate (slice 4), but it has no guest-level backup/restore layer and — the point of this slice — no self-restore-test, which closes the "a backup you haven't restored isn't a backup" theme (doc 03 §8). This adds: a vzdump backup primitive that resolves the produced archive, a benign restore-to-a-new guest, and a journaled self-restore-test (restore → boot → verify → teardown) that inherits the slice-4 journal/serialization/crash recovery so a mid-test crash can't leak a scratch guest. Provisioning/identity-reset/golden base (§9) are slice 7; PBS/offsite/zero-knowledge (§8 offsite tier) are Phase B.
Everything here is benign (backup, restore-to-new, scratch teardown): reuse the slice-4 classifier/gate/journal — no new destructive class, no new crypto. Version → v0.6.0-rc1, stop at the checkpoint.
Locked decisions (from the task + clarifications):
- Backups are crash-consistent only (marked so in the report); app-consistency needs the controller quiesce (slice 8).
- Restore is to a NEW guest only (no overwrite anywhere this slice).
- Restore-test verify = net link-down (benign SetConfig) → boot → reaches
running(no in-guest probe — slice 8). Link-down avoids the cloned source MAC/IP conflicting on a live host; it is test-safety, not slice-7 identity reset. - Restore-test cadence defaults ON at 24h, configurable; runs only when a valid scratch VMID band is set; also on-demand via the selftest harness.
- Scratch VMID band 990000–990009 (lowest-free, excludes 9999/real guests); refuse to run if unset/invalid.
- Local target only this phase;
PBSSnapshotstays astruct{}stub (Phase B). - Bulk volumes: report the gap only (which
backup=0mountpoints the guest vzdump omits).
The crash-safe core (load-bearing)
The restore-test is journaled as a single entry whose VMID is the scratch id and
Kind = "scratch_restore_test" with a new Scratch bool flag. The entry is terminal only
after teardown — NOT when the restore sub-task's UPID completes. Recover must special-case
Scratch entries before the generic UPID-recheck path (else it would mark the entry
succeeded because the restore task ran OK, and drop it while the guest still exists → leak).
RunRestoreTest (pseudocode) — defer teardown so it runs on every path incl. failed verify:
vmid := pickScratchVMID(ListLXC, [min,max], exclude 9999) // refuse if band unset/invalid
opID := "scratch-restore-"+vmid+"-"+seq
append({OpID:opID, VMID:vmid, Kind:"scratch_restore_test", Scratch:true, State:OpStarted}) // BEFORE any mutation
defer teardownScratch(opID, vmid) // gate.Authorize(IntentForScratchDestroy)=benign -> DestroyLXC -> WaitTask -> terminal
upid := RestoreLXC{VMID:vmid, Archive, Storage:RestoreStorage}; waitOK(upid) // UPID = error-detection, NOT terminal
for net := range GuestConfig(vmid).Nets(): SetConfig(vmid, {net: existing+",link_down=1"}) // benign
Start(vmid); res.Reached = waitRunning(GuestStatus, vmid, BootTimeout)
res = {Archive, ScratchVMID:vmid, Pass:res.Reached, Verified:"boot+running", Duration, Err}
return res // defer tears down regardless
Recover scratch branch (added before the existing UPID logic):
if entry.Scratch {
guests, err := ListLXC; if err { Unresolved++; continue } // can't decide -> leave in-flight
if !contains(guests, entry.VMID) { append(terminal(succeeded)); ScratchClean++; continue } // already gone
dec := gate.Authorize(IntentForScratchDestroy(hostID, entry.VMID), nil)
if !dec.Allowed { Unresolved++; continue } // fail-safe (should be benign)
upid, err := DestroyLXC(entry.VMID); if err { Unresolved++; continue } // retry next Recover
waitOK(upid); append(terminal(succeeded)); ScratchDestroyed++
}
Idempotent: crash mid-destroy → next Recover finds the guest gone → ScratchClean. Both
teardown paths (normal + recovery) go through gate.Authorize (benign ClassGuestDestroy +
Provenance{AgentTaggedScratch:true}) for the audit trail.
Files to create
internal/reconcile/restoretest.go(+restoretest_test.go):RunRestoreTest(ctx, RestoreTestSpec{Archive, RestoreStorage, BootTimeout}) RestoreTestResult(engine method — it needs the journal/gate/queue internals),RestoreTestResult(reconcile-local data — must NOT returnhub.RestoreTest, to avoid a reconcile→hub edge),IntentForScratchDestroy(hostID, vmid),pickScratchVMID. Runs the sequence on the scratch VMID'sQueuelane.internal/backup/{doc,runner,store,schedule}.go(+ tests):runner.go:BackupRunner.Backup(ctx, vmid) (hub.Backup, error)=Vzdump+WaitTask+volid-resolve + bulk-gap fromGuestConfig.MountPoints()backup=0;LatestBackup(ctx, target) (volid, error); theRestoreTestRunnerseam (satisfied by*reconcile.Engine);RestoreTestResult → hub.RestoreTestmapping.store.go: mutex-guarded in-memory latest-Backup-per-target + latest-RestoreTest, implementing the hubBackupReporter/RestoreTestReporterseams.schedule.go: cadence goroutine (default 24h; 0=disabled) →LatestBackup→engine.RunRestoreTest→ writeStore. No-ops cleanly when no backup exists yet.- Imports
reconcile+hub+proxmox(acyclic; hub imports neither).
configs/: example agent config with thebackupblock.
Files to modify
internal/proxmox/mutate.go:DestroyLXC(ctx, vmid)→DELETE /nodes/{node}/lxc/{vmid}withpurge=1&destroy-unreferenced-disks=1viadataString(async→UPID); addNotes stringtoVzdumpOptions→notes-templateparam (probenotes-templatevsnoteson the demo PVE 9.2.2 first, read-only).internal/proxmox/query.go:LatestBackupVolID(ctx, store, vmid) (string, error)—StorageContentfilteredContent=="backup" && VMID==vmid, maxCTime.internal/reconcile/state.go: addRestoreLXC,DestroyLXC,GuestStatusto theGuestAPIinterface (*proxmox.Clientalready satisfies all three).internal/reconcile/journal.go: addScratch bool \json:"scratch,omitempty"`; carry it (andKind) throughterminal()`.internal/reconcile/recover.go: the scratch branch above; addScratchClean/ScratchDestroyedtoRecoverResult.internal/reconcile/recover_test.go+engine_test.go:fakeAPIgainsRestoreLXC/DestroyLXC/GuestStatusrecorders; newTestRecover_LeakedScratchDestroyed(in-flight Scratch + ListLXC returns the VMID → DestroyLXC called, gate benign, no longer in-flight),…AlreadyGone(ScratchClean),…ListUnreadable(Unresolved).internal/hub/report.go: fillBackup+RestoreTeststructs (see wire shapes);PBSSnapshotstaysstruct{}.internal/hub/collect.go: addBackupReporter { Backups(ctx) []Backup }+RestoreTestReporter { RestoreTests(ctx) []RestoreTest }consumer seams (mirrorStorageObserver);collectBackups/collectRestoreTestsdegrade nil/err → non-nil empty.internal/hub/contract_test.go+internal/hub/testdata/host-report.golden.json: populatedbackups[0]/restore_tests[0]; assert their key sets (bidirectional, slice-5 pattern).internal/config/config.go:BackupConfig{RestoreTestCadenceSeconds, ScratchVMIDMin, ScratchVMIDMax, LocalBackupTarget, RestoreStorage}+RestoreTestCadence()accessor (0→24h default) + env overlay; validate band (min>0, max>=min, 9999 excluded) only when cadence>0.Config.Validatestays proxmox-only.cmd/felhom-agent/main.go: wirebackup.StoreintoNewCollector; add the cadence goroutine alongsideengine.Run/loop.Run/watchdog.Run; addbackup+restore-testtoselftestFlag.Set+ the switch + a-archiveflag;runSelftestBackup/runSelftestRestoreTestreuse therunSelftestStoragewiring (NewGate(nil, hostID, SlogAudit{}, logger)+ journal + engine).- felhom.eu/hub:
hub/internal/api/handler.go— addhostBackup/hostRestoreTestmirror structs tohostReportPayload, parse, persist via existingreport_json(no new DB columns — slice-5 precedent), and log a FAILED restore-test prominently ([WARN], the loudest DR signal).hub/internal/api/testdata/host-report.golden.jsonbyte-identical with the agent golden;host_test.goaddsTestHostBackup_GoldenContract/TestHostRestoreTest_GoldenContract(bidirectional key-set, slice-5 pattern). CHANGELOG.md(prepend v0.6.0-rc1),REPORT.md(overwrite),CLAUDE.mdcurrent-state line.
Proposed wire shapes (draft — must land byte-identical in both repos)
type Backup struct {
TargetID string `json:"target_id"` // backup storage name
VMID int `json:"vmid"`
Archive string `json:"archive"` // produced volid
Mode string `json:"mode"` // snapshot|stop
CrashConsistent bool `json:"crash_consistent"` // always true this slice
SizeBytes int64 `json:"size_bytes"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
StartedAt string `json:"started_at"` // RFC3339
DurationSeconds float64 `json:"duration_seconds"`
UncoveredVolumes []string `json:"uncovered_volumes"` // backup=0 mountpoints (bulk gap)
}
type RestoreTest struct {
SourceArchive string `json:"source_archive"`
SourceTier string `json:"source_tier"` // "local" (pbs = Phase B)
ScratchVMID int `json:"scratch_vmid"`
Pass bool `json:"pass"`
Verified string `json:"verified"` // "boot+running"
Error string `json:"error,omitempty"`
TestedAt string `json:"tested_at"` // RFC3339
DurationSeconds float64 `json:"duration_seconds"`
}
type PBSSnapshot struct{} // Phase B stub
Verification
go test ./...(local, Windows) +go test -race ./...on the build server (192.168.0.180, cgo) — the cadence adds a goroutine; the Store is mutex-guarded.- New unit tests: vzdump async UPID→WaitTask + volid-resolve (fake API); restore-to-new benign
ClassCreatepasses the gate; restore-test end-to-end against fakes incl. (a) teardown-on-failed-verify and (b) journal-recovery cleanup (extend the slice-4 recover test: in-flight Scratch →Recoverdestroys the leaked guest, idempotent when already gone); cadence fires on interval / no-ops when disabled; cross-repo golden + hub-ingest key-set tests. - Build the linux binary on 192.168.0.180, relay to
felhom-pve(see memorydemo-felhom-live-agent:MSYS_NO_PATHCONV=1, config at/root/.config/felhom-agent/agent.json, mode=direct, served-cert pinBA:7C:99…). - Live on the demo (the checkpoint validation):
--selftest=backup -vmid <small stopped guest>to a local target (e.g.felhom-usborlocal, content=backup) → print theBackuprecord; then--selftest=restore-test -archive <volid>→ restore into a 990000-band scratch guest, net-link-down, boot, verifyrunning, teardown → print theRestoreTestrecord. Confirm no leaked scratch guest remains (pct list), and that a simulated mid-test crash + restart triggersRecoverteardown.
Push & checkpoint
Push v0.6.0-rc1 to felhom-agent (and the hub changes to felhom.eu, deploy per the GitOps runbook if needed), update CHANGELOG/REPORT, then stop and await validation (restore-test teardown + recovery, the benign classifications, reporting) + the live demo restore-test. Phase B (next): PBS — datastore on the USB, zero-knowledge key custody, restore-from-PBS, PBS integrity-verify as the lighter frequent check.