3d955e4edd
Ruling 1 (2 weeks of weekly offsite backups): localPruneSpec's blanket PBS refusal is now scoped — an ADDITIONAL tier with an explicit keep_last may prune its PBS target. The refusal still applies in full to the PRIMARY tier, because BackupTarget() defaults to felhom-pbs and KeepLast() defaults to 3, so a box with neither key set would silently prune its offsite DR to 3 restore points. An additional tier cannot have that accident (keep_last defaults to 0). Ruling 3 (first backup runs as long as needed; nothing else starts until done): - additional-tier wait bound 6h -> 12h (measured ~33 MB/min => ~5h for a first full 10 GB snapshot; 12h gives margin but stays bounded so a hung task still surfaces) - ONE BACKUP AT A TIME PER GUEST across all tiers: POST /backup returns 409 when a DIFFERENT tier is in flight, naming the busy tier, with NO data object so nothing is parseable as the caller's own job. Same tier still returns that job (202, unchanged). - snapshotted now counts as in-flight, not just running — after the snapshot the vzdump is still uploading and holding the lock. The old check left a window where a second POST started a real second vzdump. Latent bug, closed. Full suite green (29 packages); red-proof observed and restored.
183 lines
7.4 KiB
Go
183 lines
7.4 KiB
Go
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// R-82 Slice A.1 — per-target cadence + retention resolution.
|
|
//
|
|
// The load-bearing property is ADDITIVITY: every config that exists on a live box today must
|
|
// resolve to exactly one tier that behaves as it does now. The second property is that a
|
|
// mis-configured tier is REJECTED LOUDLY rather than defaulted — a weekly DR tier silently running
|
|
// daily would fill the datastore, and a silently dropped tier is the "applied and empty" fault
|
|
// R-82 exists to fix.
|
|
|
|
func TestBackupTiers_LegacyConfigIsUnchanged(t *testing.T) {
|
|
// Exactly the shape live on demo-felhom today.
|
|
var b BackupConfig
|
|
raw := `{"local_backup_target":"local","local_backup_retention":3,"backup_cadence_seconds":0}`
|
|
if err := json.Unmarshal([]byte(raw), &b); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tiers, warnings := b.BackupTiers()
|
|
if len(warnings) != 0 {
|
|
t.Fatalf("a legacy config must produce NO warnings; got %v", warnings)
|
|
}
|
|
if len(tiers) != 1 {
|
|
t.Fatalf("a config with no backup_targets must resolve to exactly ONE tier; got %+v", tiers)
|
|
}
|
|
got := tiers[0]
|
|
if got.TargetID != "local" || got.Cadence != 24*time.Hour || got.KeepLast != 3 || !got.Primary {
|
|
t.Fatalf("legacy tier changed: %+v", got)
|
|
}
|
|
}
|
|
|
|
// An empty BackupConfig still resolves — to the felhom-pbs default target, 24h, keep-last 3.
|
|
// (Unchanged pre-R-82 behaviour; pinned so the default target can't drift unnoticed.)
|
|
func TestBackupTiers_ZeroConfigKeepsDefaults(t *testing.T) {
|
|
tiers, warnings := BackupConfig{}.BackupTiers()
|
|
if len(warnings) != 0 || len(tiers) != 1 {
|
|
t.Fatalf("zero config: tiers=%+v warnings=%v", tiers, warnings)
|
|
}
|
|
if tiers[0].TargetID != defaultBackupTarget || tiers[0].Cadence != 24*time.Hour || tiers[0].KeepLast != 3 {
|
|
t.Fatalf("zero-config defaults changed: %+v", tiers[0])
|
|
}
|
|
}
|
|
|
|
// The whole point: local daily + PBS weekly, each with its OWN retention.
|
|
func TestBackupTiers_LocalDailyPlusPBSWeekly(t *testing.T) {
|
|
var b BackupConfig
|
|
raw := `{
|
|
"local_backup_target":"local",
|
|
"local_backup_retention":3,
|
|
"backup_cadence_seconds":86400,
|
|
"backup_targets":[{"target_id":"felhom-pbs","cadence_seconds":604800,"keep_last":2}]
|
|
}`
|
|
if err := json.Unmarshal([]byte(raw), &b); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tiers, warnings := b.BackupTiers()
|
|
if len(warnings) != 0 {
|
|
t.Fatalf("unexpected warnings: %v", warnings)
|
|
}
|
|
if len(tiers) != 2 {
|
|
t.Fatalf("want 2 tiers, got %+v", tiers)
|
|
}
|
|
if !tiers[0].Primary || tiers[0].TargetID != "local" || tiers[0].Cadence != 24*time.Hour || tiers[0].KeepLast != 3 {
|
|
t.Fatalf("primary tier wrong: %+v", tiers[0])
|
|
}
|
|
if tiers[1].Primary || tiers[1].TargetID != "felhom-pbs" || tiers[1].Cadence != 7*24*time.Hour || tiers[1].KeepLast != 2 {
|
|
t.Fatalf("PBS tier wrong: %+v", tiers[1])
|
|
}
|
|
// THE knob-sharing check: the two retentions are independent values, not one shared number.
|
|
if tiers[0].KeepLast == tiers[1].KeepLast {
|
|
t.Fatalf("this fixture sets 3 and 2 deliberately — equal values mean the knob is shared: %+v", tiers)
|
|
}
|
|
}
|
|
|
|
// A tier with no cadence is REJECTED, not defaulted. Defaulting would turn a weekly DR tier into a
|
|
// daily one and fill the 37.2 GB datastore (R-82 Phase 0, P0.3).
|
|
func TestBackupTiers_MissingCadenceIsRejectedLoudly(t *testing.T) {
|
|
b := BackupConfig{
|
|
LocalBackupTarget: "local",
|
|
ExtraTargets: []BackupTargetConfig{{TargetID: "felhom-pbs", KeepLast: 2}},
|
|
}
|
|
tiers, warnings := b.BackupTiers()
|
|
if len(tiers) != 1 {
|
|
t.Fatalf("a cadence-less tier must NOT be armed; got %+v", tiers)
|
|
}
|
|
if len(warnings) != 1 || !strings.Contains(warnings[0], "cadence_seconds must be > 0") {
|
|
t.Fatalf("rejection must be reported so the caller can log it loudly; got %v", warnings)
|
|
}
|
|
if !strings.Contains(warnings[0], "felhom-pbs") {
|
|
t.Fatalf("the warning must name the tier it dropped; got %q", warnings[0])
|
|
}
|
|
}
|
|
|
|
func TestBackupTiers_RejectsEmptyAndDuplicateTargets(t *testing.T) {
|
|
b := BackupConfig{
|
|
LocalBackupTarget: "local",
|
|
ExtraTargets: []BackupTargetConfig{
|
|
{TargetID: "", CadenceSeconds: 3600},
|
|
{TargetID: "local", CadenceSeconds: 3600}, // repeats the primary
|
|
{TargetID: "felhom-pbs", CadenceSeconds: 604800}, // good
|
|
{TargetID: "felhom-pbs", CadenceSeconds: 99}, // duplicate
|
|
},
|
|
}
|
|
tiers, warnings := b.BackupTiers()
|
|
if len(tiers) != 2 || tiers[1].TargetID != "felhom-pbs" || tiers[1].Cadence != 7*24*time.Hour {
|
|
t.Fatalf("want primary + one PBS tier at the FIRST definition; got %+v", tiers)
|
|
}
|
|
if len(warnings) != 3 {
|
|
t.Fatalf("want 3 rejections (empty, duplicate-of-primary, duplicate); got %v", warnings)
|
|
}
|
|
}
|
|
|
|
// keep_last unset means DO NOT PRUNE. That is the fail-safe: a DR tier must never start pruning
|
|
// itself because someone forgot a field.
|
|
func TestBackupTiers_UnsetKeepLastMeansNoPrune(t *testing.T) {
|
|
b := BackupConfig{
|
|
LocalBackupTarget: "local",
|
|
ExtraTargets: []BackupTargetConfig{{TargetID: "felhom-pbs", CadenceSeconds: 604800}},
|
|
}
|
|
tiers, _ := b.BackupTiers()
|
|
if len(tiers) != 2 {
|
|
t.Fatalf("got %+v", tiers)
|
|
}
|
|
if tiers[1].KeepLast != 0 {
|
|
t.Fatalf("an unset keep_last must resolve to 0 = never prune; got %d", tiers[1].KeepLast)
|
|
}
|
|
// And a negative is clamped to the same fail-safe rather than becoming a prune spec.
|
|
b.ExtraTargets[0].KeepLast = -5
|
|
tiers, _ = b.BackupTiers()
|
|
if tiers[1].KeepLast != 0 {
|
|
t.Fatalf("a negative keep_last must clamp to 0 (never prune); got %d", tiers[1].KeepLast)
|
|
}
|
|
}
|
|
|
|
// The primary's retention still comes from the legacy knob with its legacy clamp — untouched.
|
|
func TestBackupTiers_PrimaryRetentionClampUnchanged(t *testing.T) {
|
|
for _, tc := range []struct{ in, want int }{{0, 3}, {-1, 3}, {1, 1}, {7, 7}} {
|
|
b := BackupConfig{LocalBackupTarget: "local", LocalBackupRetention: tc.in}
|
|
tiers, _ := b.BackupTiers()
|
|
if tiers[0].KeepLast != tc.want {
|
|
t.Fatalf("LocalBackupRetention=%d → KeepLast=%d, want %d", tc.in, tiers[0].KeepLast, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// R-82 live-failure regression (2026-07-26): the runner hard-coded a 30-minute vzdump wait, which
|
|
// is right for a local vzdump and wrong for an offsite PBS upload. The first full ~10 GB PBS
|
|
// snapshot on demo-felhom ran past 30 min; the agent gave up waiting and recorded success=false
|
|
// WHILE THE BACKUP WAS STILL RUNNING — a false failure that leaves the tier permanently "due" and
|
|
// makes the next attempt collide with the guest lock vzdump still holds.
|
|
func TestBackupTiers_WaitTimeoutIsPerTier(t *testing.T) {
|
|
b := BackupConfig{
|
|
LocalBackupTarget: "local",
|
|
ExtraTargets: []BackupTargetConfig{{TargetID: "felhom-pbs", CadenceSeconds: 604800}},
|
|
}
|
|
tiers, _ := b.BackupTiers()
|
|
if len(tiers) != 2 {
|
|
t.Fatalf("got %+v", tiers)
|
|
}
|
|
if tiers[0].WaitTimeout != 30*time.Minute {
|
|
t.Fatalf("the PRIMARY must keep the historical 30m wait (unchanged behaviour); got %s", tiers[0].WaitTimeout)
|
|
}
|
|
if tiers[1].WaitTimeout != 12*time.Hour {
|
|
t.Fatalf("an offsite tier must default to a GENEROUS wait (operator ruling: let the first backup run as long as needed) — a false timeout is worse than a slow pass; got %s", tiers[1].WaitTimeout)
|
|
}
|
|
// And it must be overridable per tier.
|
|
b.ExtraTargets[0].WaitTimeoutSeconds = 3600
|
|
tiers, _ = b.BackupTiers()
|
|
if tiers[1].WaitTimeout != time.Hour {
|
|
t.Fatalf("wait_timeout_seconds must override; got %s", tiers[1].WaitTimeout)
|
|
}
|
|
// The two tiers must NOT share one bound.
|
|
if tiers[0].WaitTimeout == tiers[1].WaitTimeout {
|
|
t.Fatalf("wait bounds are shared between tiers — the whole point is that they differ: %+v", tiers)
|
|
}
|
|
}
|