Files
admin 06e0bc9c25 agent v0.51.0: local vzdump retention default (--prune-backups keep-last=3)
The preventive counterpart to host_disk + storage_fill detectors: the periodic local
whole-guest vzdump now prunes its own old archives (keep-last=3, clamped >=1) so a box
can't refill its own root via its own backups. Local target only — PBS never pruned
(resolved via ListStorage; fail-safe skip on unknown). Seeded in host-install.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxLA1mZurFq9kt8hneFeCs
2026-06-30 19:44:07 +02:00

100 lines
4.3 KiB
Go

package backup
import (
"context"
"testing"
"gitea.dooplex.hu/admin/felhom-agent/internal/config"
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
)
// localTarget is a non-PBS dir storage; pbsTarget is a PBS storage — for the scope gate.
var (
localTargetStores = []proxmox.Storage{{Storage: "local", Type: "dir", Content: "backup"}}
pbsTargetStores = []proxmox.Storage{{Storage: "felhom-pbs", Type: "pbs", Content: "backup"}}
)
func okAPI(stores []proxmox.Storage) *fakeBackupAPI {
return &fakeBackupAPI{vzdumpUPID: "UPID:vzdump:1", storages: stores}
}
// TestPrune_LocalCarriesKeepLast: a LOCAL-target backup with retention carries `--prune-backups
// keep-last=3` on the vzdump. Companion: the SAME runner built with no retention ("") emits NO prune
// option — proving the flag only rides when retention is set (a no-prune build accumulates).
func TestPrune_LocalCarriesKeepLast(t *testing.T) {
api := okAPI(localTargetStores)
r := NewBackupRunner(api, "local", proxmox.ModeStop, "", "keep-last=3", quiet())
_, _ = r.Backup(context.Background(), 9201) // archive-resolution may fail in the fake; we assert the captured vzdump opts
if len(api.vzdumps) != 1 || api.vzdumps[0].PruneBackups != "keep-last=3" {
t.Fatalf("local backup must carry prune-backups keep-last=3, got %q", api.vzdumps[0].PruneBackups)
}
// COMPANION: no retention → no prune option (dumps would accumulate).
api2 := okAPI(localTargetStores)
r2 := NewBackupRunner(api2, "local", proxmox.ModeStop, "", "", quiet())
_, _ = r2.Backup(context.Background(), 9201)
if api2.vzdumps[0].PruneBackups != "" {
t.Fatalf("a no-retention runner must NOT prune, got %q", api2.vzdumps[0].PruneBackups)
}
}
// TestPrune_NeverPrunesPBS is the scope rule (§9): retention is NOT applied when the target is a PBS
// storage (offsite retention is a separate lifecycle). Companion: the identical retention on a LOCAL
// target IS applied — proving the gate keys on storage type, not luck.
func TestPrune_NeverPrunesPBS(t *testing.T) {
api := okAPI(pbsTargetStores)
r := NewBackupRunner(api, "felhom-pbs", proxmox.ModeStop, "", "keep-last=3", quiet())
_, _ = r.Backup(context.Background(), 9201)
if api.vzdumps[0].PruneBackups != "" {
t.Fatalf("a PBS target must NEVER be pruned by the per-run flag, got %q", api.vzdumps[0].PruneBackups)
}
// COMPANION: same retention, local target → applied.
api2 := okAPI(localTargetStores)
r2 := NewBackupRunner(api2, "local", proxmox.ModeStop, "", "keep-last=3", quiet())
_, _ = r2.Backup(context.Background(), 9201)
if api2.vzdumps[0].PruneBackups != "keep-last=3" {
t.Fatalf("control: a local target with the same retention MUST be pruned, got %q", api2.vzdumps[0].PruneBackups)
}
}
// TestPrune_FailSafeOnUnknownTarget: if the target's type can't be confirmed (lookup error / not in the
// list), the run SKIPS pruning rather than risk pruning a PBS/unknown storage.
func TestPrune_FailSafeOnUnknownTarget(t *testing.T) {
// target not present in the list → skip.
api := &fakeBackupAPI{vzdumpUPID: "UPID:vzdump:1", storages: localTargetStores}
r := NewBackupRunner(api, "some-other-store", proxmox.ModeStop, "", "keep-last=3", quiet())
_, _ = r.Backup(context.Background(), 9201)
if api.vzdumps[0].PruneBackups != "" {
t.Fatalf("an unknown target must skip pruning (fail-safe), got %q", api.vzdumps[0].PruneBackups)
}
}
// TestPrune_KeepLastClamp (§7-B): a 0/negative/unset LocalBackupRetention clamps to ≥1 (default 3) so the
// vzdump NEVER prunes the archive it just made. Companion: a no-clamp impl that returns 0 would emit
// keep-last=0 → PVE prunes everything → FAILS the "≥1" assertion.
func TestPrune_KeepLastClamp(t *testing.T) {
cases := []struct {
set int
want int
}{
{0, 3}, // unset → default
{-5, 3}, // negative → default
{1, 1}, // honored
{3, 3}, // honored
{10, 10}, // honored
}
for _, c := range cases {
b := config.BackupConfig{LocalBackupRetention: c.set}
if got := b.KeepLast(); got != c.want {
t.Errorf("KeepLast(%d) = %d, want %d", c.set, got, c.want)
}
if b.KeepLast() < 1 {
t.Fatalf("keep-last must NEVER be < 1 (would prune the fresh backup), got %d for %d", b.KeepLast(), c.set)
}
}
if spec := (config.BackupConfig{}).PruneBackupsSpec(); spec != "keep-last=3" {
t.Fatalf("default PruneBackupsSpec = %q, want keep-last=3", spec)
}
}