The local whole-guest tier keeps one restore point per day for 3 days: keep-daily instead of keep-last (R-1149, operator D17, unreleased)
gates / gates (push) Successful in 47s
gates / gates (push) Successful in 47s
This commit is contained in:
1 parent
1693da4d1a
commit
1b6e31675b
6 files changed
+153
-11
No files matched your search
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
||||
)
|
||||
|
||||
@@ -25,11 +26,46 @@ type keepLastTier struct {
|
||||
mu sync.Mutex
|
||||
clock *testClock
|
||||
keepLast int
|
||||
// spec, when set, is the `--prune-backups` value the agent passes (R-1149) and the tier prunes the way Proxmox
|
||||
// reads it: keep-last=N → the newest N; keep-daily=N → the newest copy of each of the N newest days with one.
|
||||
spec string
|
||||
t *testing.T
|
||||
archives []time.Time
|
||||
fail bool
|
||||
runs int
|
||||
}
|
||||
|
||||
// pruneBySpec applies a Proxmox `--prune-backups` spec to archives (oldest first) and returns what is kept.
|
||||
func pruneBySpec(t *testing.T, archives []time.Time, spec string) []time.Time {
|
||||
var n int
|
||||
var mode string
|
||||
if _, err := fmt.Sscanf(spec, "keep-last=%d", &n); err == nil {
|
||||
mode = "last"
|
||||
} else if _, err := fmt.Sscanf(spec, "keep-daily=%d", &n); err == nil {
|
||||
mode = "daily"
|
||||
} else {
|
||||
t.Fatalf("the model does not know the prune spec %q", spec)
|
||||
}
|
||||
var kept []time.Time
|
||||
days := map[string]bool{}
|
||||
for i := len(archives) - 1; i >= 0; i-- { // newest first, as Proxmox walks them
|
||||
a := archives[i]
|
||||
switch mode {
|
||||
case "last":
|
||||
if len(kept) < n {
|
||||
kept = append([]time.Time{a}, kept...)
|
||||
}
|
||||
case "daily":
|
||||
d := a.Format("2006-01-02")
|
||||
if !days[d] && len(days) < n {
|
||||
days[d] = true
|
||||
kept = append([]time.Time{a}, kept...)
|
||||
}
|
||||
}
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
func (k *keepLastTier) BackupWithSnapshotHook(_ context.Context, vmid int, _ func()) (hub.Backup, error) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
@@ -39,7 +75,9 @@ func (k *keepLastTier) BackupWithSnapshotHook(_ context.Context, vmid int, _ fun
|
||||
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 {
|
||||
if k.spec != "" {
|
||||
k.archives = pruneBySpec(k.t, k.archives, k.spec)
|
||||
} else 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
|
||||
@@ -291,3 +329,49 @@ func TestBackupCadenceCap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// R-1149 — the consequence of keep-daily on the local tier, with the agent's REAL prune spec (config.BackupTiers →
|
||||
// PruneSpec, what runDaemon hands the runner). Three honest nights, then a compromise at 01:00 that floods
|
||||
// POST /backup for the rest of the day. The cap lets 3 runs through inside the rolling 24 h (two at once, one when
|
||||
// last night's success ages out). With keep-last 3 those three were the only copies left; with keep-daily 3 the two
|
||||
// earlier nights survive the whole day.
|
||||
//
|
||||
// COMPANION RED-PROOF (observed 2026-10-19): make BackupTier.PruneSpec return keep-last for the primary → this fails
|
||||
// with "after a day of flooding the local tier holds 0 copies made before the compromise (kept […]) — want the 2
|
||||
// earlier nights". Restored.
|
||||
func TestBackupCap_R1149_LocalFloodKeepsOlderDays(t *testing.T) {
|
||||
tiers, _ := config.BackupConfig{LocalBackupTarget: "local", LocalBackupRetention: 3}.BackupTiers()
|
||||
spec := tiers[0].PruneSpec()
|
||||
day := time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)
|
||||
clock := &testClock{t: day}
|
||||
local := &keepLastTier{clock: clock, keepLast: 3, spec: spec, t: t}
|
||||
srv := capServer(t, filepath.Join(t.TempDir(), "s.json"), clock, local, &keepLastTier{clock: clock, keepLast: 2})
|
||||
for d := 3; d >= 1; d-- { // the nights of D-3, D-2, D-1 at 03:00
|
||||
clock.t = day.Add(-time.Duration(d)*24*time.Hour + 3*time.Hour)
|
||||
if code := runBackup(t, srv, "local"); code != http.StatusAccepted {
|
||||
t.Fatalf("seeding night D-%d: %d", d, code)
|
||||
}
|
||||
}
|
||||
compromise := day.Add(time.Hour)
|
||||
ran := 0
|
||||
for clock.t = compromise; clock.t.Before(day.Add(24 * time.Hour)); clock.Advance(10 * time.Minute) {
|
||||
if runBackup(t, srv, "local") == http.StatusAccepted {
|
||||
ran++
|
||||
}
|
||||
}
|
||||
if ran != 3 {
|
||||
t.Fatalf("the flood ran %d backups on day D — want 3 (the cap)", ran)
|
||||
}
|
||||
local.mu.Lock()
|
||||
kept := append([]time.Time(nil), local.archives...)
|
||||
local.mu.Unlock()
|
||||
pre := 0
|
||||
for _, a := range kept {
|
||||
if a.Before(compromise) {
|
||||
pre++
|
||||
}
|
||||
}
|
||||
if pre != 2 {
|
||||
t.Fatalf("after a day of flooding the local tier holds %d copies made before the compromise (kept %v) — want the 2 earlier nights", pre, kept)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user