package quiesce import ( "bytes" "log" "path/filepath" "strings" "testing" "time" ) // R-899 (operator ruling 2026-10-08, option A): a daytime whole-guest backup never moves the night's backup. // These assert the CONSEQUENCE — is tonight's local backup started? — not the ledger's mechanism. func budapest(t *testing.T, y int, m time.Month, d, hh, mm int) time.Time { t.Helper() return time.Date(y, m, d, hh, mm, 0, 0, budapestLocation()) } // r899Loop is a tiered loop with the window gate on (W = 02:30 → gate [04:30, 08:30)) and a settable clock. func r899Loop(t *testing.T, be *tierBackend, st *fakeStacks, markerPath string, now *time.Time, logs *bytes.Buffer) *Loop { t.Helper() l := New(Options{ Backend: be, Stacks: st, MarkerPath: markerPath, StatusPoll: time.Millisecond, MaxQuiesce: 30 * time.Second, Logger: log.New(logs, "", 0), }) l.windowStartFn = func() string { return "02:30" } l.now = func() time.Time { return *now } return l } func press(t *testing.T, l *Loop) { t.Helper() if err := l.TriggerNow(); err != nil { t.Fatalf("TriggerNow: %v", err) } l.mu.Lock() l.mu.Unlock() //nolint:staticcheck // wait for the async cycle } // The 2026-10-07 case, replayed: last night's backup at 04:35, a press at 08:49, and the agent then answers // „not due" at 04:35 the next night (its newest archive is the press, 19.7 h old). Tonight must still back up. // Before R-899 the cycle started nothing and the night had no OS leg and no kernel step. func TestR899_DaytimePressDoesNotCancelTheNight(t *testing.T) { st := &fakeStacks{running: []string{"opengist"}} be := newTierBackend() be.tiers = []BackupTier{{Target: "local", Primary: true}, {Target: "felhom-pbs"}} var logs bytes.Buffer now := budapest(t, 2026, 10, 7, 4, 35) l := r899Loop(t, be, st, filepath.Join(t.TempDir(), "quiesce-state.json"), &now, &logs) be.setDue("local", true) if err := l.runOnce(t.Context()); err != nil { t.Fatalf("night 1: %v", err) } be.setDue("local", false) now = budapest(t, 2026, 10, 7, 8, 49) press(t, l) now = budapest(t, 2026, 10, 8, 4, 35) // agent: local NOT due (the press is 19.7 h old) if err := l.runOnce(t.Context()); err != nil { t.Fatalf("night 2: %v", err) } got := be.startedTargets() if len(got) != 3 || got[2] != "local" { t.Fatalf("night 2 must take its own local backup despite the morning press; started=%v\nlogs:\n%s", got, logs.String()) } if !strings.Contains(logs.String(), "R-899") { t.Fatalf("the forced night must say why in the log; logs:\n%s", logs.String()) } // The press was marked as a press; the two scheduled runs were not. if m := be.manualStarts; len(m) != 3 || m[0] || !m[1] || m[2] { t.Fatalf("press mark per start = %v, want [false true false]", m) } // And once tonight's backup ran, the debt is paid: a later poll in the same window starts nothing. now = budapest(t, 2026, 10, 8, 4, 45) if err := l.runOnce(t.Context()); err != nil { t.Fatalf("night 2, later poll: %v", err) } if got := be.startedTargets(); len(got) != 3 { t.Fatalf("tonight's backup already ran — no second one; started=%v", got) } } // Outside the window the owed night waits for the window (the press's own age never fires the valve). func TestR899_OwedNightWaitsForTheWindow(t *testing.T) { st := &fakeStacks{running: []string{"opengist"}} be := newTierBackend() be.tiers = []BackupTier{{Target: "local", Primary: true}} var logs bytes.Buffer now := budapest(t, 2026, 10, 7, 8, 49) l := r899Loop(t, be, st, filepath.Join(t.TempDir(), "quiesce-state.json"), &now, &logs) press(t, l) now = budapest(t, 2026, 10, 7, 15, 0) if err := l.runOnce(t.Context()); err != nil { t.Fatal(err) } if got := be.startedTargets(); len(got) != 1 { t.Fatalf("outside the window nothing may start; started=%v", got) } now = budapest(t, 2026, 10, 8, 4, 31) if err := l.runOnce(t.Context()); err != nil { t.Fatal(err) } if got := be.startedTargets(); len(got) != 2 || got[1] != "local" { t.Fatalf("inside the window the owed night runs; started=%v", got) } } // A press INSIDE the window after tonight's backup forces nothing more tonight. func TestR899_PressAfterTonightsBackupForcesNothing(t *testing.T) { st := &fakeStacks{running: []string{"opengist"}} be := newTierBackend() be.tiers = []BackupTier{{Target: "local", Primary: true}} var logs bytes.Buffer now := budapest(t, 2026, 10, 8, 4, 35) l := r899Loop(t, be, st, filepath.Join(t.TempDir(), "quiesce-state.json"), &now, &logs) be.setDue("local", true) if err := l.runOnce(t.Context()); err != nil { t.Fatal(err) } be.setDue("local", false) now = budapest(t, 2026, 10, 8, 5, 0) press(t, l) now = budapest(t, 2026, 10, 8, 5, 10) if err := l.runOnce(t.Context()); err != nil { t.Fatal(err) } if got := be.startedTargets(); len(got) != 2 { t.Fatalf("tonight's scheduled backup already ran before the press — no third backup; started=%v", got) } } // Without a press, an agent „not due" stands (no extra backups for a box nobody pressed). func TestR899_NoPressNoExtraBackup(t *testing.T) { st := &fakeStacks{running: []string{"opengist"}} be := newTierBackend() be.tiers = []BackupTier{{Target: "local", Primary: true}} var logs bytes.Buffer now := budapest(t, 2026, 10, 8, 4, 35) l := r899Loop(t, be, st, filepath.Join(t.TempDir(), "quiesce-state.json"), &now, &logs) if err := l.runOnce(t.Context()); err != nil { t.Fatal(err) } if got := be.startedTargets(); len(got) != 0 { t.Fatalf("no press, agent not due → nothing; started=%v", got) } } // The press is remembered across a controller restart (the ledger is on disk beside the marker). func TestR899_PressSurvivesARestart(t *testing.T) { st := &fakeStacks{running: []string{"opengist"}} be := newTierBackend() be.tiers = []BackupTier{{Target: "local", Primary: true}} var logs bytes.Buffer marker := filepath.Join(t.TempDir(), "quiesce-state.json") now := budapest(t, 2026, 10, 7, 8, 49) press(t, r899Loop(t, be, st, marker, &now, &logs)) now = budapest(t, 2026, 10, 8, 4, 35) l2 := r899Loop(t, be, st, marker, &now, &logs) // a new process if err := l2.runOnce(t.Context()); err != nil { t.Fatal(err) } if got := be.startedTargets(); len(got) != 2 || got[1] != "local" { t.Fatalf("after a restart the press still does not count for tonight; started=%v", got) } } // The gate-open instant, across midnight and a DST change. func TestR899_LastGateOpen(t *testing.T) { cases := []struct { now time.Time win string want time.Time }{ {budapest(t, 2026, 10, 8, 4, 35), "02:30", budapest(t, 2026, 10, 8, 4, 30)}, {budapest(t, 2026, 10, 8, 4, 29), "02:30", budapest(t, 2026, 10, 7, 4, 30)}, {budapest(t, 2026, 10, 8, 1, 0), "23:30", budapest(t, 2026, 10, 8, 1, 30).AddDate(0, 0, -1)}, {budapest(t, 2026, 10, 25, 5, 0), "02:30", budapest(t, 2026, 10, 25, 4, 30)}, // DST ends that night } for _, c := range cases { got, ok := lastGateOpen(c.now, c.win) if !ok || !got.Equal(c.want) { t.Errorf("lastGateOpen(%s, %s) = %s, want %s", c.now, c.win, got, c.want) } } }