Files
felhom-agent/internal/reconcile/recover.go
T
admin b527430ec7 v0.6.0-rc1: slice 6 Phase A — backup + the self-restore-test (local target)
The guest-level backup layer + the journaled self-restore-test (restore→boot→verify→
teardown) that closes "a backup you haven't restored isn't a backup". All benign
(reuses the slice-4 classifier/gate/journal; no new destructive class/crypto). Local
target only; PBS = Phase B. Restore to a NEW guest only. Backups crash-consistent.

- proxmox: DestroyLXC, VzdumpOptions.Notes (notes-template), LatestBackupVolID.
- reconcile: Engine.RunRestoreTest (journal Scratch entry BEFORE mutation; net link-down
  pre-boot; defer teardown always; benign gated destroy) + Recover extended to reap a
  leaked scratch guest (Scratch flag, special-cased before the UPID path; idempotent).
- internal/backup: runner (vzdump + archive resolve + bulk-gap = backup!=1) + cadence
  scheduler (4th daemon goroutine, default 24h) + in-memory report store.
- hub: Backup/RestoreTest filled; collector seams; cross-repo golden byte-identical +
  bidirectional key-set tests; hub handler logs a FAILED restore-test prominently.
- config BackupConfig (band 990000-990009 default); --selftest=backup / restore-test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:49:39 +02:00

173 lines
6.6 KiB
Go

package reconcile
import (
"context"
"time"
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
)
// Recover consumes the journal's in-flight set at startup: resume-or-rollback for any
// op that was mid-execution when the agent crashed (doc 03 §10). This MUST run before
// the engine begins issuing new mutations.
//
// Why it is load-bearing for signed destructive ops (and why it lands with the gate):
// the idempotency-key store dedupes a COMPLETED op, but an op that crashed AFTER the
// Proxmox POST and BEFORE its terminal record (OpTaskRunning) is not covered by that —
// its nonce is already consumed, so a redelivery is rejected as a replay, yet it never
// reached a terminal state. Only this startup consumer can resolve it: re-check the
// Proxmox task and record the real outcome.
//
// Resolution per in-flight entry:
// - has a task id (OpTaskRunning): re-read the task status once. Stopped → record the
// real terminal state (OK → succeeded, else failed). Still running → leave it
// in-flight (a later Recover or the task's own completion resolves it). Unreadable →
// leave it (cannot safely decide).
// - no task id (OpStarted only): the Proxmox POST was never confirmed, so the op
// never took effect — record failed (fail-safe, the documented FileNonceStore
// direction). A convergent reconcile op is simply re-issued next pass; a one-shot
// op did NOT mark its idempotency key applied, so it is not falsely deduped.
func (e *Engine) Recover(ctx context.Context) RecoverResult {
var res RecoverResult
if e.journal == nil {
return res
}
for _, entry := range e.journal.InFlight() {
res.Examined++
// Scratch entries (slice-6 restore-test) are resolved by TEARDOWN, not by
// re-checking a sub-task UPID — a leaked scratch guest is the failure mode that
// matters. Handle them BEFORE the generic UPID path (else the restore sub-task's OK
// status would mark the entry succeeded while the guest still exists → leak).
if entry.Scratch {
e.recoverScratch(ctx, entry, &res)
continue
}
if entry.UPID == "" {
// POST never confirmed → abandon (fail-safe).
e.append(terminal(entry, OpFailed))
res.RolledBack++
e.logger.Warn("recover: in-flight op had no task id; marked failed (fail-safe)",
"op_id", entry.OpID, "vmid", entry.VMID, "kind", entry.Kind)
continue
}
st, err := e.api.TaskStatusOnce(ctx, entry.UPID)
if err != nil {
res.Unresolved++
e.logger.Warn("recover: cannot read in-flight task status; left in-flight",
"op_id", entry.OpID, "upid", entry.UPID, "err", err)
continue
}
if st.Running() {
res.StillRunning++
e.logger.Info("recover: in-flight task still running; left in-flight",
"op_id", entry.OpID, "upid", entry.UPID)
continue
}
// Stopped: record the real outcome.
if st.OK() {
e.append(terminal(entry, OpSucceeded))
res.Resumed++
e.logger.Info("recover: in-flight task completed OK; marked succeeded",
"op_id", entry.OpID, "upid", entry.UPID)
} else {
e.append(terminal(entry, OpFailed))
res.Failed++
e.logger.Warn("recover: in-flight task ended non-OK; marked failed",
"op_id", entry.OpID, "upid", entry.UPID, "exitstatus", st.ExitStatus)
}
}
if res.Examined > 0 {
e.logger.Info("recover: in-flight journal reconciled", "result", res)
}
return res
}
// recoverScratch resolves a leaked restore-test scratch guest (slice 6, doc 03 §8/§10).
// The invariant: a Scratch entry in-flight at startup means "scratch guest VMID may exist
// and must be destroyed." It is idempotent — if the guest is already gone (crash after the
// destroy task but before the terminal record), it records terminal-clean. The teardown
// routes through the gate as a benign ClassGuestDestroy (agent-tagged scratch provenance) —
// the same audit-bearing path the normal teardown uses.
func (e *Engine) recoverScratch(ctx context.Context, entry JournalEntry, res *RecoverResult) {
lxc, err := e.api.ListLXC(ctx)
if err != nil {
// Can't tell whether the guest exists → leave in-flight; a later Recover retries.
res.Unresolved++
e.logger.Warn("recover: cannot list guests to resolve leaked scratch; left in-flight",
"op_id", entry.OpID, "vmid", entry.VMID, "err", err)
return
}
exists := false
for _, g := range lxc {
if g.VMID == entry.VMID {
exists = true
break
}
}
if !exists {
// Already gone (idempotent) → the scratch left no leak.
e.append(terminal(entry, OpSucceeded))
res.ScratchClean++
e.logger.Info("recover: leaked-scratch entry resolved; guest already gone",
"op_id", entry.OpID, "vmid", entry.VMID)
return
}
dec := e.gate.Authorize(IntentForScratchDestroy(e.hostID, entry.VMID), nil)
if !dec.Allowed {
// Should be benign; if not, fail-safe (leave in-flight, do NOT force a destroy).
res.Unresolved++
e.logger.Error("recover: scratch teardown refused by gate (unexpected); left in-flight",
"op_id", entry.OpID, "vmid", entry.VMID, "reason", dec.Reason)
return
}
upid, err := e.api.DestroyLXC(ctx, entry.VMID)
if err != nil {
res.Unresolved++
e.logger.Warn("recover: destroying leaked scratch failed; left in-flight (will retry)",
"op_id", entry.OpID, "vmid", entry.VMID, "err", err)
return
}
if upid != "" {
if _, err := e.api.WaitTask(ctx, upid, proxmox.WaitOptions{}); err != nil {
res.Unresolved++
e.logger.Warn("recover: leaked-scratch destroy task failed; left in-flight (will retry)",
"op_id", entry.OpID, "vmid", entry.VMID, "err", err)
return
}
}
e.append(terminal(entry, OpSucceeded))
res.ScratchDestroyed++
e.logger.Warn("recover: destroyed leaked restore-test scratch guest",
"op_id", entry.OpID, "vmid", entry.VMID)
}
// RecoverResult summarizes a startup recovery pass.
type RecoverResult struct {
Examined int
Resumed int // task found completed OK and recorded succeeded
Failed int // task found ended non-OK and recorded failed
RolledBack int // no task id → abandoned (fail-safe)
StillRunning int // task still executing → left in-flight
Unresolved int // task status unreadable → left in-flight
ScratchClean int // scratch entry resolved: guest already gone (no leak)
ScratchDestroyed int // scratch entry resolved: leaked guest destroyed
}
// terminal builds a terminal journal record preserving the op's identity, with the
// idempotency key + scratch flag carried through.
func terminal(e JournalEntry, state OpState) JournalEntry {
return JournalEntry{
OpID: e.OpID,
VMID: e.VMID,
Kind: e.Kind,
UPID: e.UPID,
State: state,
IdempKey: e.IdempKey,
Scratch: e.Scratch,
At: time.Now().UTC(),
}
}