v0.97.0 — R-82 Slice A: per-target backup tiers (local daily + PBS weekly)

Mechanism only. No box changes behaviour until a backup_targets entry is
added to its config (Slice D); an untouched config resolves to exactly one
tier and behaves byte-identically to v0.96.0.

- config: BackupTargetConfig + ExtraTargets + BackupTiers(); each tier carries
  its OWN cadence and retention (keep-last=3 is three days on a daily tier and
  three weeks on a weekly one). A missing cadence is REJECTED, not defaulted —
  a weekly DR tier silently running daily would fill the 37.2 GB datastore.
  main.go logs every rejection at ERROR.
- /backup/due?target= judges a tier against its OWN newest successful backup.
  Without that filter a fresh local backup satisfies the weekly PBS cadence and
  the DR tier never runs — today's bug, re-created in code.
- GET /backup/tiers advertises the tiers; a 404 is the controller's pre-R-82
  capability probe (Slice B).
- Jobs keyed by (vmid,target): single-flight is per tier, which is what lets
  the weekly night run both backups in ONE quiesce window. Job ids are unique
  per tier by construction, not by clock luck.
- One runner per tier: the runner holds target+retention as immutable state,
  so parameterising one runner would risk pairing tier A's target with tier B's
  retention.

COMPATIBILITY (frozen): untargeted /backup/due, POST /backup and
/backup/status keep the primary tier and the pre-R-82 response BYTES —
Target is omitempty and stays empty. The primary's job-id format is unchanged.

NOT changed: the local tier; PBS is still never pruned by the per-run flag
(keep_last defaults to 0 = never prune — enabling DR pruning is irreversible
and needs an operator ruling).

Tests 748->768. Red-proof #1 observed and restored.
Phase 0: felhom.eu/documentation/audits/SPIKE-r82-phase0-2026-07-26.md
This commit is contained in:
Claude Code
2026-07-26 12:20:58 +02:00
parent dfd5d731ee
commit 739b3c3b58
8 changed files with 980 additions and 36 deletions
+31 -1
View File
@@ -1269,7 +1269,36 @@ func buildLocalAPIServer(cfg config.Config, px *proxmox.Client, store *backup.St
}
// v0.48.0: ride the served leaf fp on every host report so the hub can detect a re-key fleet-wide.
collector.SetLeafFingerprint(fp)
runner := backup.NewBackupRunner(px, cfg.Backup.BackupTarget(), "", "felhom local-api", cfg.Backup.PruneBackupsSpec(), logger)
// R-82: ONE RUNNER PER TIER. The runner holds its target, mode, notes and retention as
// immutable construction state, and localPruneSpec reads that retention — so parameterising a
// single runner by target would risk a call pairing tier A's target with tier B's retention.
// One runner per tier keeps each tier's policy structurally inseparable from its target.
backupTiers, tierWarnings := cfg.Backup.BackupTiers()
for _, wmsg := range tierWarnings {
// LOUD on purpose: a silently dropped backup tier is an "applied and empty" DR tier, which
// is the exact fault R-82 exists to fix. Never downgrade this to DEBUG.
logger.Error("backup tier REJECTED — this tier will never run", "detail", wmsg)
}
apiTiers := make([]localapi.BackupTier, 0, len(backupTiers))
var runner *backup.BackupRunner
for _, t := range backupTiers {
prune := ""
if t.KeepLast > 0 {
prune = fmt.Sprintf("keep-last=%d", t.KeepLast)
}
r := backup.NewBackupRunner(px, t.TargetID, "", "felhom local-api", prune, logger)
if t.Primary {
runner = r
}
apiTiers = append(apiTiers, localapi.BackupTier{
TargetID: t.TargetID,
Cadence: t.Cadence,
Primary: t.Primary,
Service: r,
})
logger.Info("backup tier armed", "target", t.TargetID, "cadence", t.Cadence.String(),
"keep_last", t.KeepLast, "primary", t.Primary)
}
// Guest data-drive passthrough (slice 10 P2): a root-CLI runner for the `pct set` bind + chown
// (same fenced ExecRunner the host-storage + provision back-half use).
gaMode := proxmox.RunnerMode(cfg.Privileged.Mode)
@@ -1283,6 +1312,7 @@ func buildLocalAPIServer(cfg config.Config, px *proxmox.Client, store *backup.St
AgentVersion: version, // v0.82.0: the X-Felhom-Agent-Version capability channel
Guests: px,
Backups: runner,
BackupTiers: apiTiers, // R-82: primary first; untargeted endpoints act on the primary
Store: store,
Storage: observer,
DriveTargets: driveTargets, // Impl-2a: registry+units drives for the /disks view (union w/ Observe storages)