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
@@ -0,0 +1,146 @@
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
||||
)
|
||||
|
||||
// The whole-guest backup cadence cap (R-973, security review 2026-10-12).
|
||||
//
|
||||
// POST /backup is reachable from inside the guest, and every successful run prunes the tier
|
||||
// (`--prune-backups keep-last=N` on a local tier; ep0's server-side keep-last 2 on the off-site tier). So
|
||||
// a compromised controller calling it in a loop pushed every pre-compromise whole-guest restore point out
|
||||
// within minutes — the threat `07` §8 row 10 counts the off-site tier as the answer to. The agent now
|
||||
// refuses a run that the schedule in `07` §6 would never ask for:
|
||||
//
|
||||
// - a tier whose cadence is at most 24 h (the local tier): at most 3 successful runs in any rolling
|
||||
// 24 h. The schedule is one night run; a „Mentés most" press makes a LOCAL copy and, since R-899, the
|
||||
// night still takes its own after it, which can land inside the same 24 h as the previous night (the
|
||||
// window opens anywhere in [W+2h, W+6h)). So a normal day needs at most 3: two nights plus one press.
|
||||
// A second press inside 24 h is refused — the margin is spent on the night, which must never be refused
|
||||
// because of the household's presses.
|
||||
// - a longer tier (the 7-day off-site tier): at most 1 successful run per max(cadence − 24 h, cadence/2)
|
||||
// — 6 days for 7 days. The controller asks /backup/due first and runs only when the newest copy is older
|
||||
// than the cadence, and the button never targets this tier (`07` §6, controller v0.301.0), so a
|
||||
// legitimate run never comes sooner; 24 h of slack absorbs clock and archive-time differences.
|
||||
//
|
||||
// Only SUCCESSES count — a failed run prunes nothing and must stay retryable. The count survives an agent
|
||||
// restart: it reads the success history saved on disk (LastKnownBackupStore, the R-894 file) as well as
|
||||
// this process's own record. A refusal answers 429, is logged at WARN, is saved, and raises the operator
|
||||
// signal BackupCapRefusal feeds (a critical capability in the host report → the hub's existing
|
||||
// `agent_capability_degraded` event, operator-only). Pinned by TestBackupCap_* (backup_cap_test.go).
|
||||
|
||||
// backupCapRefusalHold is how long one refusal keeps the operator signal raised.
|
||||
const backupCapRefusalHold = 24 * time.Hour
|
||||
|
||||
// backupCadenceCap returns the rolling window and the most successful runs allowed in it for a tier.
|
||||
func backupCadenceCap(cadence time.Duration) (window time.Duration, max int) {
|
||||
if cadence <= 0 {
|
||||
cadence = defaultBackupCadence
|
||||
}
|
||||
if cadence <= 24*time.Hour {
|
||||
return 24 * time.Hour, 3
|
||||
}
|
||||
w := cadence - 24*time.Hour
|
||||
if w < cadence/2 {
|
||||
w = cadence / 2
|
||||
}
|
||||
return w, 1
|
||||
}
|
||||
|
||||
// recentCapRuns returns the start times of the successful runs on this tier for this guest inside the
|
||||
// window ending now — this process's record and the saved one, each counted once. Caller holds no lock.
|
||||
func (s *Server) recentCapRuns(key backupJobKey, since time.Time) []time.Time {
|
||||
seen := map[int64]bool{}
|
||||
var out []time.Time
|
||||
add := func(t time.Time) {
|
||||
if t.Before(since) || seen[t.Unix()] {
|
||||
return
|
||||
}
|
||||
seen[t.Unix()] = true
|
||||
out = append(out, t)
|
||||
}
|
||||
s.capMu.Lock()
|
||||
for _, t := range s.capRuns[key] {
|
||||
add(t)
|
||||
}
|
||||
s.capMu.Unlock()
|
||||
if s.lastKnown != nil {
|
||||
for _, t := range s.lastKnown.RecentBackupSuccesses(key.target, key.vmid, since) {
|
||||
add(t)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Before(out[j]) })
|
||||
return out
|
||||
}
|
||||
|
||||
// recordCapRun notes a successful run for the cap (the saved half is written by RecordBackupSuccess).
|
||||
func (s *Server) recordCapRun(key backupJobKey, b hub.Backup) {
|
||||
t, err := time.Parse(time.RFC3339, b.StartedAt)
|
||||
if err != nil {
|
||||
t = s.now()
|
||||
}
|
||||
s.capMu.Lock()
|
||||
defer s.capMu.Unlock()
|
||||
if s.capRuns == nil {
|
||||
s.capRuns = map[backupJobKey][]time.Time{}
|
||||
}
|
||||
runs := append(s.capRuns[key], t.UTC())
|
||||
if len(runs) > 8 {
|
||||
runs = runs[len(runs)-8:]
|
||||
}
|
||||
s.capRuns[key] = runs
|
||||
}
|
||||
|
||||
// capRefuses applies the cap to a new run request. It writes the 429 and returns true when refused.
|
||||
func (s *Server) capRefuses(w http.ResponseWriter, key backupJobKey, tier BackupTier) bool {
|
||||
window, max := backupCadenceCap(tier.Cadence)
|
||||
now := s.now()
|
||||
runs := s.recentCapRuns(key, now.Add(-window))
|
||||
if len(runs) < max {
|
||||
return false
|
||||
}
|
||||
next := runs[len(runs)-max].Add(window)
|
||||
s.capMu.Lock()
|
||||
s.capRefusal = capRefusal{target: key.target, vmid: key.vmid, at: now.UTC()}
|
||||
s.capMu.Unlock()
|
||||
if s.lastKnown != nil {
|
||||
if err := s.lastKnown.RecordBackupRefusal(key.target, key.vmid, now); err != nil {
|
||||
s.logger.Warn("local-api: could not save the backup refusal on disk (R-973)", "vmid", key.vmid, "target", key.target, "err", err)
|
||||
}
|
||||
}
|
||||
s.logger.Warn("local-api: backup REFUSED — more whole-guest backups than the schedule allows (R-973 cadence cap; check the guest)",
|
||||
"vmid", key.vmid, "target", key.target, "runs_in_window", len(runs), "allowed", max,
|
||||
"window_h", int(window.Hours()), "next_allowed", next.UTC().Format(time.RFC3339))
|
||||
writeStatus(w, http.StatusTooManyRequests, false, nil, fmt.Sprintf(
|
||||
"too many whole-guest backups on target %q: %d successful in the last %d h, the schedule allows %d — refused (the next one is allowed after %s)",
|
||||
key.target, len(runs), int(window.Hours()), max, next.UTC().Format(time.RFC3339)))
|
||||
return true
|
||||
}
|
||||
|
||||
type capRefusal struct {
|
||||
target string
|
||||
vmid int
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// BackupCapRefusal reports the newest cadence-cap refusal still inside the 24 h hold (this process's or
|
||||
// the saved one) — the source of the operator signal in the host report (R-973). ok=false = none.
|
||||
func (s *Server) BackupCapRefusal() (target string, vmid int, at time.Time, ok bool) {
|
||||
s.capMu.Lock()
|
||||
r := s.capRefusal
|
||||
s.capMu.Unlock()
|
||||
if s.lastKnown != nil {
|
||||
if t, v, a, found := s.lastKnown.LastBackupRefusal(); found && a.After(r.at) {
|
||||
r = capRefusal{target: t, vmid: v, at: a}
|
||||
}
|
||||
}
|
||||
if r.at.IsZero() || s.now().Sub(r.at) > backupCapRefusalHold {
|
||||
return "", 0, time.Time{}, false
|
||||
}
|
||||
return r.target, r.vmid, r.at, true
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
||||
)
|
||||
|
||||
// R-973 — the whole-guest backup cadence cap. A compromised controller calling POST /backup in a loop
|
||||
// pushed out every pre-compromise restore point: each success prunes the tier. These tests model the tier
|
||||
// the way Proxmox and ep0 keep it (keep-last N, pruned after each success) and ask what is LEFT.
|
||||
|
||||
// keepLastTier is a whole-guest tier: every successful run adds an archive stamped with the clock and
|
||||
// prunes to keepLast, as vzdump's --prune-backups keep-last (local) and ep0's keep-last 2 (off-site) do.
|
||||
type keepLastTier struct {
|
||||
mu sync.Mutex
|
||||
clock *testClock
|
||||
keepLast int
|
||||
archives []time.Time
|
||||
fail bool
|
||||
runs int
|
||||
}
|
||||
|
||||
func (k *keepLastTier) BackupWithSnapshotHook(_ context.Context, vmid int, _ func()) (hub.Backup, error) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
k.runs++
|
||||
now := k.clock.Now()
|
||||
if k.fail {
|
||||
return hub.Backup{VMID: vmid, StartedAt: now.Format(time.RFC3339)}, fmt.Errorf("vzdump failed")
|
||||
}
|
||||
k.archives = append(k.archives, now)
|
||||
if len(k.archives) > k.keepLast {
|
||||
k.archives = k.archives[len(k.archives)-k.keepLast:]
|
||||
}
|
||||
return hub.Backup{VMID: vmid, Success: true, Archive: "x", StartedAt: now.Format(time.RFC3339)}, nil
|
||||
}
|
||||
|
||||
// oldestBefore reports whether the tier still holds an archive made before t (a pre-compromise point).
|
||||
func (k *keepLastTier) oldestBefore(t time.Time) bool {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
return len(k.archives) > 0 && k.archives[0].Before(t)
|
||||
}
|
||||
|
||||
func (k *keepLastTier) count() (runs, kept int) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
return k.runs, len(k.archives)
|
||||
}
|
||||
|
||||
type testClock struct {
|
||||
mu sync.Mutex
|
||||
t time.Time
|
||||
}
|
||||
|
||||
func (c *testClock) Now() time.Time { c.mu.Lock(); defer c.mu.Unlock(); return c.t }
|
||||
func (c *testClock) Advance(d time.Duration) { c.mu.Lock(); c.t = c.t.Add(d); c.mu.Unlock() }
|
||||
|
||||
// capServer builds the two-tier box of `07` §6: local 24 h keep-last 3, off-site 7 d keep-last 2, the
|
||||
// success history on disk at path (a second capServer on the same path is an agent restart).
|
||||
func capServer(t *testing.T, path string, clock *testClock, local, offsite *keepLastTier) *Server {
|
||||
t.Helper()
|
||||
srv, err := NewServer(Options{
|
||||
ListenAddr: "127.0.0.1:0", Guests: &fakeGuests{}, Backups: &fakeBackups{}, Store: &fakeStore{},
|
||||
Storage: fakeStorage{targets: []hub.StorageTarget{{Name: "local"}, {Name: "felhom-pbs"}}},
|
||||
Tokens: staticTokens{"A": 8200},
|
||||
BackupTiers: []BackupTier{
|
||||
{TargetID: "local", Cadence: 24 * time.Hour, Primary: true, Service: local},
|
||||
{TargetID: "felhom-pbs", Cadence: 7 * 24 * time.Hour, Service: offsite},
|
||||
},
|
||||
LastKnownBackups: backup.NewBackupSuccessState(path),
|
||||
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv.baseCtx = context.Background()
|
||||
srv.now = clock.Now
|
||||
return srv
|
||||
}
|
||||
|
||||
// runBackup POSTs one backup and waits for the job to end. Returns the HTTP status of the POST.
|
||||
func runBackup(t *testing.T, srv *Server, target string) int {
|
||||
t.Helper()
|
||||
rr := do(t, srv.Handler(), "POST", "/backup?target="+target, "A", "")
|
||||
if rr.Code == http.StatusAccepted {
|
||||
waitFor(t, func() bool {
|
||||
srv.jobsMu.Lock()
|
||||
defer srv.jobsMu.Unlock()
|
||||
j := srv.jobs[backupJobKey{vmid: 8200, target: target}]
|
||||
return j != nil && !backupInFlight(j.Phase)
|
||||
})
|
||||
}
|
||||
return rr.Code
|
||||
}
|
||||
|
||||
// The off-site tier — the one `07` §8 row 10 counts on against ransomware. Before the cap ten calls in a
|
||||
// row left ep0 holding only the attacker's two copies. Now ONE runs (the tier was due), the other nine
|
||||
// are refused, and the newest pre-compromise copy survives; five days of flooding later it still does.
|
||||
//
|
||||
// COMPANION RED-PROOF (observed 2026-10-18): make capRefuses always return false → this fails with
|
||||
// "the off-site tier lost every pre-compromise copy: 10 runs" . Restored.
|
||||
func TestBackupCap_OffsiteFloodKeepsAPreCompromiseCopy(t *testing.T) {
|
||||
clock := &testClock{t: testNow}
|
||||
local := &keepLastTier{clock: clock, keepLast: 3}
|
||||
off := &keepLastTier{clock: clock, keepLast: 2}
|
||||
path := filepath.Join(t.TempDir(), "backup-success-state.json")
|
||||
srv := capServer(t, path, clock, local, off)
|
||||
|
||||
// The honest history: two weekly copies, the newest 7 days and 1 hour old (so the tier is due).
|
||||
for _, age := range []time.Duration{14*24*time.Hour + time.Hour, 7*24*time.Hour + time.Hour} {
|
||||
clock.t = testNow.Add(-age)
|
||||
if code := runBackup(t, srv, "felhom-pbs"); code != http.StatusAccepted {
|
||||
t.Fatalf("seeding the honest weekly copy: %d", code)
|
||||
}
|
||||
}
|
||||
clock.t = testNow
|
||||
compromise := testNow
|
||||
|
||||
refused := 0
|
||||
for i := 0; i < 10; i++ {
|
||||
if runBackup(t, srv, "felhom-pbs") == http.StatusTooManyRequests {
|
||||
refused++
|
||||
}
|
||||
clock.Advance(time.Minute)
|
||||
}
|
||||
runs, _ := off.count()
|
||||
if !off.oldestBefore(compromise) {
|
||||
t.Fatalf("the off-site tier lost every pre-compromise copy: %d runs", runs-2)
|
||||
}
|
||||
if runs-2 != 1 || refused != 9 {
|
||||
t.Fatalf("flood: %d runs, %d refused — want 1 run (the tier was due) and 9 refused", runs-2, refused)
|
||||
}
|
||||
for d := 0; d < 5; d++ { // five more days of flooding, every hour
|
||||
for h := 0; h < 24; h++ {
|
||||
clock.Advance(time.Hour)
|
||||
if code := runBackup(t, srv, "felhom-pbs"); code != http.StatusTooManyRequests {
|
||||
t.Fatalf("day %d hour %d: an off-site run was allowed %v after the last one", d, h, clock.Now().Sub(compromise))
|
||||
}
|
||||
}
|
||||
}
|
||||
if !off.oldestBefore(compromise) {
|
||||
t.Fatal("five days of flooding pushed out the pre-compromise off-site copy")
|
||||
}
|
||||
}
|
||||
|
||||
// The local tier: a flood runs exactly 3 in a day (the schedule's own day: two nights + one press) and
|
||||
// every further request is refused. Honest about the limit: with keep-last 3 those 3 replace the 3 kept
|
||||
// points — the cap slows a flush from minutes to a day; it cannot keep a point a 3-per-day pace prunes.
|
||||
func TestBackupCap_LocalFloodRunsAtMostThreeADay(t *testing.T) {
|
||||
clock := &testClock{t: testNow}
|
||||
local := &keepLastTier{clock: clock, keepLast: 3}
|
||||
srv := capServer(t, filepath.Join(t.TempDir(), "s.json"), clock, local, &keepLastTier{clock: clock, keepLast: 2})
|
||||
refused := 0
|
||||
for i := 0; i < 20; i++ {
|
||||
if runBackup(t, srv, "local") == http.StatusTooManyRequests {
|
||||
refused++
|
||||
}
|
||||
clock.Advance(10 * time.Minute)
|
||||
}
|
||||
if runs, _ := local.count(); runs != 3 || refused != 17 {
|
||||
t.Fatalf("local flood over ~3 h: %d runs, %d refused — want 3 and 17", runs, refused)
|
||||
}
|
||||
clock.Advance(24 * time.Hour) // a day later the window has moved on
|
||||
if code := runBackup(t, srv, "local"); code != http.StatusAccepted {
|
||||
t.Fatalf("a run a day later must be allowed: %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// The schedule itself is never refused: a night at W+6h, a press at 10:00, the next night at W+2h (all
|
||||
// three inside one rolling 24 h) run; a second press that day is refused; nights keep running after it.
|
||||
func TestBackupCap_TheScheduledDayIsNeverRefused(t *testing.T) {
|
||||
day := time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)
|
||||
clock := &testClock{t: day.Add(8*time.Hour + 29*time.Minute)} // W=02:30 → W+6h ≈ 08:29
|
||||
local := &keepLastTier{clock: clock, keepLast: 3}
|
||||
srv := capServer(t, filepath.Join(t.TempDir(), "s.json"), clock, local, &keepLastTier{clock: clock, keepLast: 2})
|
||||
steps := []struct {
|
||||
at time.Time
|
||||
want int
|
||||
what string
|
||||
}{
|
||||
{day.Add(8*time.Hour + 29*time.Minute), http.StatusAccepted, "night 1 at W+6h"},
|
||||
{day.Add(10 * time.Hour), http.StatusAccepted, "a press at 10:00"},
|
||||
{day.Add(24*time.Hour + 4*time.Hour + 30*time.Minute), http.StatusAccepted, "night 2 at W+2h (20 h later)"},
|
||||
{day.Add(24*time.Hour + 7*time.Hour), http.StatusTooManyRequests, "a second press inside the same 24 h"},
|
||||
{day.Add(48*time.Hour + 4*time.Hour + 30*time.Minute), http.StatusAccepted, "night 3"},
|
||||
}
|
||||
for _, s := range steps {
|
||||
clock.t = s.at
|
||||
if got := runBackup(t, srv, "local"); got != s.want {
|
||||
t.Fatalf("%s: got %d, want %d", s.what, got, s.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A restart must not reset the cap: the history is read back from disk.
|
||||
//
|
||||
// COMPANION RED-PROOF (observed 2026-10-18): make recentCapRuns ignore s.lastKnown → this fails with
|
||||
// "after a restart the 4th run in a day was allowed (got 202)". Restored.
|
||||
func TestBackupCap_SurvivesARestart(t *testing.T) {
|
||||
clock := &testClock{t: testNow}
|
||||
path := filepath.Join(t.TempDir(), "s.json")
|
||||
local := &keepLastTier{clock: clock, keepLast: 3}
|
||||
srv := capServer(t, path, clock, local, &keepLastTier{clock: clock, keepLast: 2})
|
||||
for i := 0; i < 3; i++ {
|
||||
if code := runBackup(t, srv, "local"); code != http.StatusAccepted {
|
||||
t.Fatalf("run %d: %d", i+1, code)
|
||||
}
|
||||
clock.Advance(time.Minute)
|
||||
}
|
||||
srv2 := capServer(t, path, clock, local, &keepLastTier{clock: clock, keepLast: 2}) // the restart
|
||||
if code := runBackup(t, srv2, "local"); code != http.StatusTooManyRequests {
|
||||
t.Fatalf("after a restart the 4th run in a day was allowed (got %d)", code)
|
||||
}
|
||||
}
|
||||
|
||||
// Failed runs prune nothing and must stay retryable: they do not count.
|
||||
func TestBackupCap_FailuresDoNotCount(t *testing.T) {
|
||||
clock := &testClock{t: testNow}
|
||||
local := &keepLastTier{clock: clock, keepLast: 3, fail: true}
|
||||
srv := capServer(t, filepath.Join(t.TempDir(), "s.json"), clock, local, &keepLastTier{clock: clock, keepLast: 2})
|
||||
for i := 0; i < 6; i++ {
|
||||
if code := runBackup(t, srv, "local"); code != http.StatusAccepted {
|
||||
t.Fatalf("failed attempt %d was refused: %d", i+1, code)
|
||||
}
|
||||
clock.Advance(15 * time.Minute)
|
||||
}
|
||||
local.mu.Lock()
|
||||
local.fail = false
|
||||
local.mu.Unlock()
|
||||
if code := runBackup(t, srv, "local"); code != http.StatusAccepted {
|
||||
t.Fatalf("a success after six failures was refused: %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// A refusal raises the operator signal for 24 h, and the signal survives a restart.
|
||||
func TestBackupCap_RefusalRaisesTheOperatorSignal(t *testing.T) {
|
||||
clock := &testClock{t: testNow}
|
||||
path := filepath.Join(t.TempDir(), "s.json")
|
||||
off := &keepLastTier{clock: clock, keepLast: 2}
|
||||
srv := capServer(t, path, clock, &keepLastTier{clock: clock, keepLast: 3}, off)
|
||||
if _, _, _, ok := srv.BackupCapRefusal(); ok {
|
||||
t.Fatal("a fresh box must not raise the signal")
|
||||
}
|
||||
runBackup(t, srv, "felhom-pbs")
|
||||
clock.Advance(time.Hour)
|
||||
if code := runBackup(t, srv, "felhom-pbs"); code != http.StatusTooManyRequests {
|
||||
t.Fatalf("second off-site run an hour later: %d, want 429", code)
|
||||
}
|
||||
target, vmid, at, ok := srv.BackupCapRefusal()
|
||||
if !ok || target != "felhom-pbs" || vmid != 8200 || !at.Equal(clock.Now()) {
|
||||
t.Fatalf("signal after a refusal: ok=%v target=%q vmid=%d at=%v", ok, target, vmid, at)
|
||||
}
|
||||
clock.Advance(23 * time.Hour)
|
||||
srv2 := capServer(t, path, clock, &keepLastTier{clock: clock, keepLast: 3}, off) // a restart
|
||||
if _, _, _, ok := srv2.BackupCapRefusal(); !ok {
|
||||
t.Fatal("the signal did not survive a restart")
|
||||
}
|
||||
clock.Advance(2 * time.Hour)
|
||||
if _, _, _, ok := srv2.BackupCapRefusal(); ok {
|
||||
t.Fatal("the signal must clear 24 h after the last refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupCadenceCap(t *testing.T) {
|
||||
cases := []struct {
|
||||
cadence time.Duration
|
||||
window time.Duration
|
||||
max int
|
||||
}{
|
||||
{0, 24 * time.Hour, 3},
|
||||
{24 * time.Hour, 24 * time.Hour, 3},
|
||||
{12 * time.Hour, 24 * time.Hour, 3},
|
||||
{7 * 24 * time.Hour, 6 * 24 * time.Hour, 1},
|
||||
{36 * time.Hour, 18 * time.Hour, 1},
|
||||
{30 * 24 * time.Hour, 29 * 24 * time.Hour, 1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if w, m := backupCadenceCap(c.cadence); w != c.window || m != c.max {
|
||||
t.Errorf("cadence %v: got %v/%d, want %v/%d", c.cadence, w, m, c.window, c.max)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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