A cadence cap on the local-API whole-guest backup route (R-973, unreleased)
gates / gates (push) Successful in 49s
gates / gates (push) Successful in 49s
Each successful run prunes its tier, so a compromised controller calling POST /backup in a loop pushed out every pre-compromise restore point. A tier with a cadence of at most 24 h now allows 3 successful runs per rolling 24 h (night, a press, the next night); a longer tier 1 per max(cadence - 24 h, cadence/2), 6 days for the weekly off-site tier. Only successes count. A refusal is a 429, a WARN, saved, and for 24 h a critical host-report status that the hub turns into its existing operator-only agent_capability_degraded event. The success history and the refusal are kept in the R-894 state file, so a restart does not reset the cap. No sudoers change. Tests TestBackupCap_* and the state, status and wiring tests, red-proved.
This commit is contained in:
1 parent
0963bae3c3
commit
f17e1979bd
10 files changed
+722
-8
No files matched your search
@@ -90,6 +90,10 @@ type BackupStore interface {
|
||||
type LastKnownBackupStore interface {
|
||||
RecordBackupSuccess(target string, b hub.Backup) error
|
||||
LastKnownSuccess(target string, vmid int) (time.Time, bool)
|
||||
// R-973: the cadence cap's restart-proof history and its refusals.
|
||||
RecentBackupSuccesses(target string, vmid int, since time.Time) []time.Time
|
||||
RecordBackupRefusal(target string, vmid int, at time.Time) error
|
||||
LastBackupRefusal() (target string, vmid int, at time.Time, ok bool)
|
||||
}
|
||||
|
||||
// StorageView yields the host's observed storage targets (for mapping a mount's storage id →
|
||||
@@ -302,6 +306,11 @@ type Server struct {
|
||||
logger *slog.Logger
|
||||
now func() time.Time
|
||||
|
||||
// R-973 cadence cap: this process's successful runs per tier+guest, and the newest refusal.
|
||||
capMu sync.Mutex
|
||||
capRuns map[backupJobKey][]time.Time
|
||||
capRefusal capRefusal
|
||||
|
||||
disks DiskOps // slice 8C (optional)
|
||||
diskGate StorageGate // slice 8C (optional)
|
||||
guestList GuestLister // slice 8C (optional)
|
||||
@@ -880,6 +889,12 @@ func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request, vmid int)
|
||||
"a backup is already in flight on target "+busyTarget+" (job "+busyJob+") — only one backup runs at a time per guest")
|
||||
return
|
||||
}
|
||||
// R-973: the cadence cap (backup_cap.go). Checked under jobsMu, after the in-flight answers, so two
|
||||
// requests cannot both pass it (only one run per guest is ever in flight).
|
||||
if s.capRefuses(w, key, tier) {
|
||||
s.jobsMu.Unlock()
|
||||
return
|
||||
}
|
||||
// Job ids must be unique PER TIER, and by construction rather than by clock luck: two tiers
|
||||
// started inside the same nanosecond (the weekly both-due night, or any injected clock) would
|
||||
// otherwise collide and hand the second caller the first tier's id. The PRIMARY keeps the
|
||||
@@ -943,6 +958,9 @@ func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request, vmid int)
|
||||
s.logger.Info("local-api: backup job complete", "vmid", vmid, "target", tier.TargetID, "job", jobID, "archive", b.Archive)
|
||||
}
|
||||
s.store.RecordBackup(b)
|
||||
if b.Success {
|
||||
s.recordCapRun(key, b) // R-973
|
||||
}
|
||||
if b.Success && s.lastKnown != nil {
|
||||
if err := s.lastKnown.RecordBackupSuccess(tier.TargetID, b); err != nil {
|
||||
// Not fatal: the backup exists. Only the fallback after a restart loses this copy.
|
||||
|
||||
Reference in new issue
Block a user