Files
felhom-agent/internal/backup/runner.go
T
Claude Code 043c7622bc R-85 Phase 2: tier rotation, persisted state, one heavy op at a time
The scheduler could only ever see cfg.Backup.BackupTarget(), so the offsite
tier's archives were never candidates — which is why demo-hp's DR tier reported
'applied' with zero snapshots for five days and nobody noticed.

Selection: oldest-first (operator ruling, Option 1). Never-proven sorts first,
which is where the offsite tier starts. Ties break on target id so ordering is
deterministic rather than following Go's randomised map order. Rotation credit
only on SUCCESS — a permanently failing tier must keep sorting first, not look
freshly proven and stop being retried.

- backup.RestoreTestState: persisted last-success per tier (atomic tmp+rename).
  This genuinely needs persistence unlike R-84: R-84 had ground truth to consult
  (the archive is still on the storage), whereas a restore-test destroys its
  scratch and leaves no artifact. Corrupt/missing file -> 'nothing proven'.
- backup.InFlight: host-wide one-heavy-op gate shared with the local-API backup
  path. A LINK concern, not a lock one — an offsite restore pulls multi-GB over
  the same tunnel a backup pushes one, and at ~33 MB/min both drift toward
  timeout, which is how a healthy tier gets recorded as failed. Callers DEFER,
  never cancel.
- PickRestoreCandidateOn: newest archive on a named tier; '' is not an error, or
  every fresh box looks broken for its first week.
- An empty tier is skipped and the next tried; it cannot starve, since it is
  still least-recently-proven once it has an archive.
- POST /backup joins the gate (409 naming the holder).

Red-proofs A/E/F observed with the documented text. Full suite green (29
packages, rc=0).
2026-07-26 21:00:42 +02:00

401 lines
17 KiB
Go

package backup
import (
"context"
"fmt"
"log/slog"
"sort"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
"gitea.dooplex.hu/admin/felhom-agent/internal/reconcile"
)
// BackupAPI is the read+backup proxmox surface the runner needs. *proxmox.Client satisfies it.
type BackupAPI interface {
Vzdump(ctx context.Context, opts proxmox.VzdumpOptions) (string, error)
WaitTask(ctx context.Context, upid string, opts proxmox.WaitOptions) (proxmox.TaskStatus, error)
GuestConfig(ctx context.Context, vmid int) (proxmox.GuestConfig, error)
StorageContent(ctx context.Context, store string) ([]proxmox.StorageContent, error)
// ListStorage enumerates storages (name+type) — used to scope local-only retention (never prune PBS).
ListStorage(ctx context.Context) ([]proxmox.Storage, error)
// TaskLogTail reads trailing task-log lines — used to read the ACTUAL vzdump mode
// (PVE may downgrade a requested snapshot to stop for a stopped guest — spike B1).
TaskLogTail(ctx context.Context, upid string, limit int) ([]string, error)
}
// BackupRunner orchestrates a crash-consistent vzdump to a local target and reports the
// result (incl. the bulk-volume gap). An agent-initiated vzdump is crash-consistent only
// (no fsfreeze); the report says so.
type BackupRunner struct {
api BackupAPI
target string // backup storage (content=backup)
mode proxmox.BackupMode // default ModeSnapshot
notes string // optional notes-template
// retention is the per-run `--prune-backups` spec (e.g. "keep-last=3") applied to a LOCAL target after
// each successful backup, so the agent's own backups can't pile up and refill root. Empty → no prune
// (the legacy behaviour; restore-test/selftest runners pass ""). NEVER applied to a PBS target.
retention string
// waitTimeout bounds the WaitTask poll on this runner's vzdump. Per-TIER since R-82: 30m is
// right for a local vzdump and badly wrong for an offsite PBS upload (see the 2026-07-26 live
// failure recorded on config.BackupTargetConfig.WaitTimeoutSeconds). 0 → 30m (legacy).
waitTimeout time.Duration
// allowPBSPrune permits `--prune-backups` on a PBS-type target. OFF by default and ON only for
// an ADDITIONAL tier whose keep_last was set explicitly (operator ruling 2026-07-26: keep two
// weeks of weekly offsite backups).
//
// The blanket PBS refusal it replaces existed for a real reason and still applies to the
// PRIMARY tier: BackupTarget() DEFAULTS to "felhom-pbs" and KeepLast() DEFAULTS to 3, so a box
// with neither key set would silently prune its offsite DR to 3 restore points. An additional
// tier cannot have that accident — its keep_last defaults to 0 (never prune), so any value
// there is a deliberate act.
allowPBSPrune bool
logger *slog.Logger
now func() time.Time
}
// NewBackupRunner builds a runner. mode defaults to snapshot (works for a stopped guest and
// for lvm-thin); the caller may pass ModeStop for storages without snapshot support. retention is the
// per-run prune spec ("keep-last=N", or "" to never prune) — only the periodic local backup sets it.
func NewBackupRunner(api BackupAPI, target string, mode proxmox.BackupMode, notes, retention string, logger *slog.Logger) *BackupRunner {
return NewBackupRunnerWithWait(api, target, mode, notes, retention, 0, logger)
}
// NewBackupRunnerWithWait is NewBackupRunner plus an explicit vzdump wait bound (0 → 30m).
func NewBackupRunnerWithWait(api BackupAPI, target string, mode proxmox.BackupMode, notes, retention string, waitTimeout time.Duration, logger *slog.Logger) *BackupRunner {
return NewBackupRunnerFull(api, target, mode, notes, retention, waitTimeout, false, logger)
}
// NewBackupRunnerFull is the full constructor. allowPBSPrune must be true ONLY for an additional
// tier with an explicitly configured keep_last — see BackupRunner.allowPBSPrune.
func NewBackupRunnerFull(api BackupAPI, target string, mode proxmox.BackupMode, notes, retention string, waitTimeout time.Duration, allowPBSPrune bool, logger *slog.Logger) *BackupRunner {
if mode == "" {
mode = proxmox.ModeSnapshot
}
if logger == nil {
logger = slog.Default()
}
if waitTimeout <= 0 {
waitTimeout = 30 * time.Minute
}
return &BackupRunner{api: api, target: target, mode: mode, notes: notes, retention: retention,
waitTimeout: waitTimeout, allowPBSPrune: allowPBSPrune, logger: logger,
now: func() time.Time { return time.Now().UTC() }}
}
// localPruneSpec returns the `--prune-backups` spec to apply to THIS backup, or "" to skip pruning. It
// applies the configured retention ONLY when the target is a non-PBS storage — PBS offsite retention is a
// separate lifecycle and must never be pruned by the per-run flag (§9). Fail-safe: if the target's type
// can't be confirmed (lookup error / not found), it SKIPS pruning rather than risk pruning PBS — the
// host_disk + storage_fill detectors remain the safety net.
func (r *BackupRunner) localPruneSpec(ctx context.Context) string {
if r.retention == "" {
return ""
}
stores, err := r.api.ListStorage(ctx)
if err != nil {
r.logger.Warn("backup: could not resolve target storage type — skipping local prune this run", "target", r.target, "err", err)
return ""
}
for _, s := range stores {
if s.Storage == r.target {
if s.Type == "pbs" && !r.allowPBSPrune {
// Not opted in → never prune the offsite DR (the pre-R-82 rule, and still the rule
// for the primary tier, whose target+retention both DEFAULT and could prune by
// accident).
return ""
}
return r.retention
}
}
r.logger.Warn("backup: target storage not found in list — skipping local prune this run (fail-safe)", "target", r.target)
return ""
}
// snapshotMarker is the vzdump task-log line that signals the storage snapshot has been created
// and the backup is now reading from it — the point after which resuming the guest's app cannot
// affect the backup (slice 8B.2; validated on PVE 9.2.2: `INFO: create storage snapshot 'vzdump'`).
// It only appears in snapshot mode (stop mode takes no storage snapshot), so its presence ⟹
// snapshot mode — the basis for the controller's early resume.
const snapshotMarker = "create storage snapshot"
// snapshotWatchInterval is how often watchForSnapshot polls the task log. A package var so tests
// can shrink it (production: poll once a second — the marker appears in the first ~1s, §0).
var snapshotWatchInterval = time.Second
// Backup runs one vzdump of vmid to the local target and returns the report record.
func (r *BackupRunner) Backup(ctx context.Context, vmid int) (hub.Backup, error) {
return r.backup(ctx, vmid, nil)
}
// BackupWithSnapshotHook is Backup plus an onSnapshot callback invoked ONCE, mid-backup, when the
// storage snapshot has been taken (snapshot mode only) — the 8B.2 early-resume signal. In
// stop/downgraded mode the marker never appears, so onSnapshot is never called (the caller then
// resumes at completion). onSnapshot must be cheap + non-blocking (it runs on a watcher goroutine).
func (r *BackupRunner) BackupWithSnapshotHook(ctx context.Context, vmid int, onSnapshot func()) (hub.Backup, error) {
return r.backup(ctx, vmid, onSnapshot)
}
// backup is the shared body. A failure is returned BOTH as an error and as a
// Backup{Success:false,...} so the caller can record the failed attempt.
func (r *BackupRunner) backup(ctx context.Context, vmid int, onSnapshot func()) (hub.Backup, error) {
start := r.now()
rec := hub.Backup{
TargetID: r.target,
VMID: vmid,
Mode: string(r.mode),
CrashConsistent: true, // always, this slice (no controller quiesce)
StartedAt: start.Format(time.RFC3339),
}
if r.target == "" {
rec.Error = "no backup target configured"
return rec, fmt.Errorf("backup: %s", rec.Error)
}
// Bulk-volume gap: which mountpoints the vzdump will EXCLUDE (best-effort; a config-read
// failure just leaves the gap unknown, never fails the backup).
if cfg, err := r.api.GuestConfig(ctx, vmid); err == nil {
rec.UncoveredVolumes = uncoveredMountpoints(cfg.MountPoints())
} else {
r.logger.Warn("backup: could not read guest config for bulk-gap", "vmid", vmid, "err", err)
rec.UncoveredVolumes = []string{}
}
upid, err := r.api.Vzdump(ctx, proxmox.VzdumpOptions{
VMID: vmid, Storage: r.target, Mode: r.mode, Notes: r.notes,
PruneBackups: r.localPruneSpec(ctx), // local target → keep-last=N; PBS/unknown → "" (no prune)
})
if err != nil {
rec.Error = err.Error()
rec.DurationSeconds = time.Since(start).Seconds()
return rec, fmt.Errorf("backup: vzdump vmid %d: %w", vmid, err)
}
if upid != "" {
// 8B.2: while the backup runs, watch the task log for the storage-snapshot marker and
// fire onSnapshot once (snapshot mode only) so the controller can resume its app early.
if onSnapshot != nil {
watchCtx, stopWatch := context.WithCancel(ctx)
defer stopWatch()
go r.watchForSnapshot(watchCtx, upid, onSnapshot)
}
if _, err := r.api.WaitTask(ctx, upid, proxmox.WaitOptions{Timeout: r.waitTimeout}); err != nil {
rec.Error = err.Error()
rec.DurationSeconds = time.Since(start).Seconds()
return rec, fmt.Errorf("backup: vzdump task vmid %d: %w", vmid, err)
}
// Report the ACTUAL mode PVE used (it may downgrade snapshot→stop for a stopped
// guest — spike B1), read from the task log; fall back to the requested mode.
if lines, err := r.api.TaskLogTail(ctx, upid, 200); err == nil {
if actual := parseBackupMode(lines); actual != "" {
rec.Mode = actual
}
}
}
// Resolve the produced archive (volid + size) — the task status carries no result volid.
vol, size, err := r.latestArchive(ctx, vmid)
if err != nil {
rec.Error = fmt.Sprintf("backup succeeded but archive not resolved: %v", err)
rec.DurationSeconds = time.Since(start).Seconds()
return rec, fmt.Errorf("backup: resolve archive vmid %d: %w", vmid, err)
}
rec.Archive = vol
rec.SizeBytes = size
rec.Success = true
rec.DurationSeconds = time.Since(start).Seconds()
r.logger.Info("backup: completed", "vmid", vmid, "target", r.target, "archive", vol, "size_bytes", size,
"uncovered_volumes", len(rec.UncoveredVolumes))
return rec, nil
}
// watchForSnapshot polls the running backup's task log until it sees the storage-snapshot marker
// (→ onSnapshot once) or the requested mode is reported as `stop` (→ downgraded; the marker will
// never come, so stop watching) or ctx is cancelled (backup finished). Best-effort: a log-read
// error is retried on the next tick; onSnapshot fires at most once.
func (r *BackupRunner) watchForSnapshot(ctx context.Context, upid string, onSnapshot func()) {
ticker := time.NewTicker(snapshotWatchInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
lines, err := r.api.TaskLogTail(ctx, upid, 200)
if err != nil {
continue
}
// A stop-mode (or downgraded) backup never creates a storage snapshot → never resume early.
if m := parseBackupMode(lines); m != "" && m != string(proxmox.ModeSnapshot) {
return
}
for _, ln := range lines {
if strings.Contains(ln, snapshotMarker) {
onSnapshot()
return
}
}
}
}
}
// PickRestoreCandidate returns the newest backup archive on the target (any guest), or ""
// when there is none — the restore-test then no-ops cleanly.
func (r *BackupRunner) PickRestoreCandidate(ctx context.Context) (string, error) {
return r.PickRestoreCandidateOn(ctx, r.target)
}
// PickRestoreCandidateOn is PickRestoreCandidate for an ARBITRARY tier's storage (R-85 1.2), so the
// scheduler can rotate across tiers instead of only ever seeing this runner's own target.
//
// Contract preserved: "" + nil error when the storage holds no archive. **A tier with nothing to
// restore is not an error** — a brand-new offsite tier legitimately has nothing yet, and turning
// that into a failure would make every fresh box look broken for its first week.
func (r *BackupRunner) PickRestoreCandidateOn(ctx context.Context, target string) (string, error) {
if target == "" {
return "", nil
}
contents, err := r.api.StorageContent(ctx, target)
if err != nil {
return "", err
}
var best string
var bestCTime int64 = -1
for _, e := range contents {
if e.Content == "backup" && e.CTime > bestCTime {
bestCTime, best = e.CTime, e.VolID
}
}
return best, nil
}
// latestArchive finds the newest backup archive volid + size for vmid on the target.
func (r *BackupRunner) latestArchive(ctx context.Context, vmid int) (string, int64, error) {
contents, err := r.api.StorageContent(ctx, r.target)
if err != nil {
return "", 0, err
}
var vol string
var size, bestCTime int64 = 0, -1
for _, e := range contents {
if e.Content == "backup" && e.VMID == vmid && e.CTime > bestCTime {
bestCTime, vol, size = e.CTime, e.VolID, e.Size
}
}
if vol == "" {
return "", 0, fmt.Errorf("no backup archive found for vmid %d on %s", vmid, r.target)
}
return vol, size, nil
}
// NewestArchiveTime reports when this guest's newest backup archive LANDED ON THIS TARGET, from the
// storage itself. ok=false means the target genuinely holds no archive for this guest.
//
// R-84: this is the cure for the redundant-backup-after-restart problem. The agent's backup Store is
// in-memory ("lost on restart; the cadence re-populates"), so after every restart /backup/due
// reported "no successful backup recorded yet" and the controller dutifully took another one. On the
// local tier that is wasted minutes; on the OFFSITE tier it is a wasted multi-hour WAN upload after
// every agent deploy — and agent deploys are routine. Three redundant local backups were observed on
// demo-felhom in a single afternoon of deploys (2026-07-26).
//
// Asking the STORAGE rather than persisting the store is deliberate:
// - it is ground truth, not remembered state — if an archive was pruned or deleted it correctly
// stops counting, whereas a persisted record would keep claiming a backup that no longer exists;
// - it needs no new on-disk state and no migration;
// - it is the same source `latestArchive` already trusts to build the post-backup record.
//
// It answers ONLY "when did a backup last land", which is exactly what the due-check needs. The
// richer fields (size, duration, uncovered volumes, error) stay with the real in-memory records — a
// synthesized record would put invented numbers into the host-report.
func (r *BackupRunner) NewestArchiveTime(ctx context.Context, vmid int) (time.Time, bool, error) {
contents, err := r.api.StorageContent(ctx, r.target)
if err != nil {
return time.Time{}, false, err
}
var best int64 = -1
for _, e := range contents {
if e.Content == "backup" && e.VMID == vmid && e.CTime > best {
best = e.CTime
}
}
if best < 0 {
return time.Time{}, false, nil
}
return time.Unix(best, 0).UTC(), true, nil
}
// parseBackupMode extracts the actual mode from a vzdump task log line `… backup mode: <x>`
// (e.g. "INFO: backup mode: stop"). Returns "" if not found.
func parseBackupMode(lines []string) string {
const marker = "backup mode:"
for _, ln := range lines {
if i := strings.Index(ln, marker); i >= 0 {
return strings.TrimSpace(ln[i+len(marker):])
}
}
return ""
}
// uncoveredMountpoints returns the mountpoint paths the guest vzdump EXCLUDES. LXC mount
// points are OPT-IN to vzdump: a mpN with `backup=1` is covered; ANY other state — the
// `backup=` token absent OR `backup=0` — is excluded. We deliberately treat unset as
// uncovered (the safe DR direction: never imply an unprotected volume is backed up).
func uncoveredMountpoints(mps map[string]string) []string {
var out []string
for key, cfg := range mps {
if mountpointCovered(cfg) {
continue
}
out = append(out, mountpointLabel(key, cfg))
}
sort.Strings(out)
if out == nil {
return []string{}
}
return out
}
// mountpointCovered reports whether a mpN config string is included in the vzdump — true
// ONLY when it carries an explicit `backup=1`.
func mountpointCovered(cfg string) bool {
for _, tok := range strings.Split(cfg, ",") {
if v, ok := strings.CutPrefix(tok, "backup="); ok {
return v == "1"
}
}
return false // no backup= token → opt-out by default → not covered
}
// mountpointLabel prefers the mp=/path token, falling back to the mpN key.
func mountpointLabel(key, cfg string) string {
for _, tok := range strings.Split(cfg, ",") {
if p, ok := strings.CutPrefix(tok, "mp="); ok && p != "" {
return p
}
}
return key
}
// ToHubRestoreTest maps a reconcile restore-test result to the hub wire record (the backup
// package owns the reconcile→hub mapping so reconcile need not import hub for the result).
func ToHubRestoreTest(res reconcile.RestoreTestResult, testedAt time.Time) hub.RestoreTest {
rt := hub.RestoreTest{
SourceArchive: res.Archive,
SourceTier: res.SourceTier,
ScratchVMID: res.ScratchVMID,
Pass: res.Pass,
Verified: res.Verified,
TestedAt: testedAt.Format(time.RFC3339),
DurationSeconds: res.Duration.Seconds(),
Warnings: res.StartWarnings,
WarningsRecognized: res.WarningsRecognized,
MountParity: res.MountParity,
MountInventory: res.MountInventory,
}
if res.Err != nil {
rt.Error = res.Err.Error()
}
return rt
}