Files
felhom-agent/internal/backup/rotation_test.go
T
Claude Code 043c7622bc R-85 Phase 2: tier rotation, persisted state, one heavy op at a time
The scheduler could only ever see cfg.Backup.BackupTarget(), so the offsite
tier's archives were never candidates — which is why demo-hp's DR tier reported
'applied' with zero snapshots for five days and nobody noticed.

Selection: oldest-first (operator ruling, Option 1). Never-proven sorts first,
which is where the offsite tier starts. Ties break on target id so ordering is
deterministic rather than following Go's randomised map order. Rotation credit
only on SUCCESS — a permanently failing tier must keep sorting first, not look
freshly proven and stop being retried.

- backup.RestoreTestState: persisted last-success per tier (atomic tmp+rename).
  This genuinely needs persistence unlike R-84: R-84 had ground truth to consult
  (the archive is still on the storage), whereas a restore-test destroys its
  scratch and leaves no artifact. Corrupt/missing file -> 'nothing proven'.
- backup.InFlight: host-wide one-heavy-op gate shared with the local-API backup
  path. A LINK concern, not a lock one — an offsite restore pulls multi-GB over
  the same tunnel a backup pushes one, and at ~33 MB/min both drift toward
  timeout, which is how a healthy tier gets recorded as failed. Callers DEFER,
  never cancel.
- PickRestoreCandidateOn: newest archive on a named tier; '' is not an error, or
  every fresh box looks broken for its first week.
- An empty tier is skipped and the next tried; it cannot starve, since it is
  still least-recently-proven once it has an archive.
- POST /backup joins the gate (409 naming the holder).

Red-proofs A/E/F observed with the documented text. Full suite green (29
packages, rc=0).
2026-07-26 21:00:42 +02:00

336 lines
12 KiB
Go

package backup
import (
"context"
"os"
"path/filepath"
"sync"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-agent/internal/reconcile"
)
// R-85 Phase 2 — tier rotation, persisted state, and the one-heavy-operation gate.
//
// The failure this prevents is not hypothetical: demo-hp's DR tier reported `applied` with ZERO
// snapshots for five days and nobody noticed, because the scheduler could only ever see the primary
// tier. Rotation is what makes the offsite tier testable at all.
// rotRunner records which archives it was asked to restore.
type rotRunner struct {
mu sync.Mutex
archives []string
pass bool
}
func (r *rotRunner) RunRestoreTest(_ context.Context, spec reconcile.RestoreTestSpec) reconcile.RestoreTestResult {
r.mu.Lock()
defer r.mu.Unlock()
r.archives = append(r.archives, spec.Archive)
return reconcile.RestoreTestResult{
Archive: spec.Archive, SourceTier: spec.SourceTier,
Pass: r.pass, Verified: "boot+running",
}
}
func (r *rotRunner) seen() []string {
r.mu.Lock()
defer r.mu.Unlock()
return append([]string(nil), r.archives...)
}
// archiveFor is a TierPicker over a fixed map: target → archive ("" = that tier holds none).
func archiveFor(m map[string]string) TierPicker {
return func(_ context.Context, target string) (string, error) {
return m[target], nil
}
}
func rotScheduler(t *testing.T, rr *rotRunner, st *RestoreTestState, pick TierPicker, gate *InFlight) *Scheduler {
t.Helper()
return NewScheduler(SchedulerOptions{
Runner: rr,
Store: NewStore(),
Spec: func(_ context.Context, archive string) reconcile.RestoreTestSpec {
return reconcile.RestoreTestSpec{RestoreStorage: "local-lvm", ScratchMin: 990000, ScratchMax: 990009}
},
Cadence: time.Hour,
Logger: quiet(),
Tiers: []string{"local", "felhom-pbs"},
TierPick: pick,
State: st,
InFlight: gate,
})
}
// ── SCENARIO A — both tiers get tested across consecutive cadences ───────────────────────────
//
// COMPANION RED-PROOF (observed): restore the single-target picker — set `Tiers`/`TierPick` to nil
// so `pickForThisRun` falls back to `s.pick` on the primary runner — and this fails with
// "both tiers must be exercised across 4 cadences; got [local:… local:… local:… local:…]",
// i.e. the offsite tier never appears. That is today's behaviour, and it is why demo-hp's DR tier
// went unproven for its entire existence.
func TestRotation_BothTiersExercisedAcrossCadences(t *testing.T) {
rr := &rotRunner{pass: true}
st := NewRestoreTestState(filepath.Join(t.TempDir(), "rt.json"))
s := rotScheduler(t, rr, st, archiveFor(map[string]string{
"local": "local:backup/vzdump-lxc-9201-x.tar.zst",
"felhom-pbs": "felhom-pbs:backup/ct/9201/2026-07-26T15:42:42Z",
}), &InFlight{})
s.now = func() time.Time { return time.Now().UTC() }
for i := 0; i < 4; i++ {
s.tick(context.Background())
}
got := rr.seen()
var sawLocal, sawPBS bool
for _, a := range got {
if len(a) >= 5 && a[:5] == "local" {
sawLocal = true
}
if len(a) >= 10 && a[:10] == "felhom-pbs" {
sawPBS = true
}
}
if !sawLocal || !sawPBS {
t.Fatalf("both tiers must be exercised across 4 cadences; got %v", got)
}
// Oldest-first must ALTERNATE, not clump — otherwise one tier is starved between visits.
if len(got) != 4 {
t.Fatalf("want 4 runs, got %d: %v", len(got), got)
}
if got[0] == got[1] {
t.Fatalf("consecutive runs hit the same tier — oldest-first is not rotating: %v", got)
}
}
// A tier with NO archive is skipped, not failed, and the other tier still runs. A brand-new offsite
// tier legitimately has nothing to restore; turning that into a failure would make every fresh box
// look broken for its first week.
func TestRotation_EmptyTierSkippedNotFailed(t *testing.T) {
rr := &rotRunner{pass: true}
st := NewRestoreTestState(filepath.Join(t.TempDir(), "rt.json"))
s := rotScheduler(t, rr, st, archiveFor(map[string]string{
"local": "local:backup/vzdump-lxc-9201-x.tar.zst",
"felhom-pbs": "", // provisioned but empty
}), &InFlight{})
s.tick(context.Background())
got := rr.seen()
if len(got) != 1 || got[0][:5] != "local" {
t.Fatalf("an empty tier must be skipped and the testable one still run; got %v", got)
}
}
// Nothing testable anywhere → a clean no-op, not an error and not a run.
func TestRotation_NoArchivesAnywhereIsANoOp(t *testing.T) {
rr := &rotRunner{pass: true}
s := rotScheduler(t, rr, NewRestoreTestState(filepath.Join(t.TempDir(), "rt.json")),
archiveFor(map[string]string{}), &InFlight{})
s.tick(context.Background())
if got := rr.seen(); len(got) != 0 {
t.Fatalf("no archives anywhere → no run; got %v", got)
}
}
// A FAILED restore-test must NOT earn rotation credit, or a tier that fails every time would look
// freshly proven and quietly stop being retried.
func TestRotation_FailureEarnsNoCredit(t *testing.T) {
rr := &rotRunner{pass: false}
st := NewRestoreTestState(filepath.Join(t.TempDir(), "rt.json"))
s := rotScheduler(t, rr, st, archiveFor(map[string]string{
"local": "local:backup/x.tar.zst",
"felhom-pbs": "felhom-pbs:backup/ct/9201/y",
}), &InFlight{})
s.tick(context.Background())
if _, ok := st.LastSuccess("local"); ok {
t.Fatal("a FAILED restore-test must not stamp the tier as proven")
}
if _, ok := st.LastSuccess("felhom-pbs"); ok {
t.Fatal("a FAILED restore-test must not stamp the tier as proven")
}
}
// ── SCENARIO E — rotation survives a restart ─────────────────────────────────────────────────
//
// COMPANION RED-PROOF (observed): make the state in-memory (construct a fresh
// `NewRestoreTestState` on a DIFFERENT path for the second scheduler, i.e. lose the file) and this
// fails with "after a restart the OTHER tier must be next; got felhom-pbs" — the same tier repeats
// and the other is starved indefinitely, which with agent deploys as routine as they are is not a
// corner case.
func TestRotation_SurvivesRestart(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "rt.json")
picks := archiveFor(map[string]string{
"local": "local:backup/x.tar.zst",
"felhom-pbs": "felhom-pbs:backup/ct/9201/y",
})
// First process: the OFFSITE tier is tested (never-proven sorts first).
rr1 := &rotRunner{pass: true}
st1 := NewRestoreTestState(path)
s1 := rotScheduler(t, rr1, st1, picks, &InFlight{})
s1.tick(context.Background())
first := rr1.seen()
if len(first) != 1 {
t.Fatalf("want one run, got %v", first)
}
// --- restart: brand-new state object reading the SAME file ---
rr2 := &rotRunner{pass: true}
st2 := NewRestoreTestState(path)
s2 := rotScheduler(t, rr2, st2, picks, &InFlight{})
s2.tick(context.Background())
second := rr2.seen()
if len(second) != 1 {
t.Fatalf("want one run after restart, got %v", second)
}
if second[0] == first[0] {
t.Fatalf("after a restart the OTHER tier must be next; got %s twice (rotation state was lost)", second[0])
}
}
// ── SCENARIO F — no collision with a backup ──────────────────────────────────────────────────
//
// COMPANION RED-PROOF (observed): drop the TryAcquire guard from `tick` and this fails with
// "the restore-test must DEFER while a backup holds the gate; concurrent operations = 2" — the
// count is the assertion, since "both completed" would pass against a fully concurrent
// implementation.
func TestRotation_DefersWhileABackupHoldsTheGate(t *testing.T) {
gate := &InFlight{}
release, _, ok := gate.TryAcquire("backup:felhom-pbs")
if !ok {
t.Fatal("precondition: the gate should have been free")
}
defer release()
rr := &rotRunner{pass: true}
s := rotScheduler(t, rr, NewRestoreTestState(filepath.Join(t.TempDir(), "rt.json")),
archiveFor(map[string]string{"local": "local:backup/x.tar.zst"}), gate)
s.tick(context.Background())
concurrent := 1 + len(rr.seen()) // the backup holding the gate, plus anything the tick started
if concurrent != 1 {
t.Fatalf("the restore-test must DEFER while a backup holds the gate; concurrent operations = %d", concurrent)
}
}
// Once the backup releases, the next cadence proceeds — deferral must not be permanent.
func TestRotation_ResumesAfterTheGateFrees(t *testing.T) {
gate := &InFlight{}
release, _, _ := gate.TryAcquire("backup:local")
rr := &rotRunner{pass: true}
s := rotScheduler(t, rr, NewRestoreTestState(filepath.Join(t.TempDir(), "rt.json")),
archiveFor(map[string]string{"local": "local:backup/x.tar.zst"}), gate)
s.tick(context.Background())
if len(rr.seen()) != 0 {
t.Fatal("should have deferred while held")
}
release()
s.tick(context.Background())
if len(rr.seen()) != 1 {
t.Fatalf("must resume once the gate frees; got %v", rr.seen())
}
}
// The gate itself: one holder at a time, named, and release is idempotent.
func TestInFlight_Semantics(t *testing.T) {
g := &InFlight{}
rel, busy, ok := g.TryAcquire("backup:local")
if !ok || busy != "" {
t.Fatalf("first acquire must succeed; ok=%v busy=%q", ok, busy)
}
if _, busy2, ok2 := g.TryAcquire("restore-test"); ok2 || busy2 != "backup:local" {
t.Fatalf("second acquire must fail and NAME the holder; ok=%v busy=%q", ok2, busy2)
}
rel()
rel() // idempotent — a double release must not free someone else's later claim
if g.Busy() != "" {
t.Fatalf("gate should be idle after release; busy=%q", g.Busy())
}
if _, _, ok3 := g.TryAcquire("restore-test"); !ok3 {
t.Fatal("gate must be reusable after release")
}
}
// A nil gate means "not wired" → no gating, pre-R-85 behaviour. Keeps every existing caller working.
func TestInFlight_NilIsUngated(t *testing.T) {
var g *InFlight
rel, _, ok := g.TryAcquire("x")
if !ok {
t.Fatal("a nil gate must not block")
}
rel()
if g.Busy() != "" {
t.Fatal("a nil gate is never busy")
}
}
// ── oldest-first ordering ────────────────────────────────────────────────────────────────────
func TestOldestFirst_Ordering(t *testing.T) {
st := NewRestoreTestState(filepath.Join(t.TempDir(), "rt.json"))
now := time.Now().UTC()
// Never-proven sorts FIRST — the case that matters, since the offsite tier starts there.
if got := st.OldestFirst([]string{"local", "felhom-pbs"}); got[0] != "felhom-pbs" {
// both never proven → deterministic tie-break by id
if got[0] != "felhom-pbs" && got[0] != "local" {
t.Fatalf("unexpected: %v", got)
}
}
_ = st.RecordSuccess("local", now)
if got := st.OldestFirst([]string{"local", "felhom-pbs"}); got[0] != "felhom-pbs" {
t.Fatalf("a never-proven tier must sort before a proven one; got %v", got)
}
_ = st.RecordSuccess("felhom-pbs", now.Add(time.Hour))
if got := st.OldestFirst([]string{"local", "felhom-pbs"}); got[0] != "local" {
t.Fatalf("the least recently proven must sort first; got %v", got)
}
}
// Ordering must be DETERMINISTIC for equal timestamps, or two tiers proven in the same second would
// rotate by Go's randomised map iteration — untestable, and occasionally starving.
func TestOldestFirst_DeterministicOnTies(t *testing.T) {
st := NewRestoreTestState(filepath.Join(t.TempDir(), "rt.json"))
now := time.Now().UTC()
_ = st.RecordSuccess("b-tier", now)
_ = st.RecordSuccess("a-tier", now)
for i := 0; i < 20; i++ {
if got := st.OldestFirst([]string{"b-tier", "a-tier"}); got[0] != "a-tier" {
t.Fatalf("tie-break must be deterministic; iteration %d gave %v", i, got)
}
}
}
// The state file round-trips, and a corrupt file degrades to "nothing proven" rather than wedging.
func TestRestoreTestState_PersistenceAndCorruption(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "rt.json")
now := time.Now().UTC().Truncate(time.Second)
st := NewRestoreTestState(path)
if err := st.RecordSuccess("felhom-pbs", now); err != nil {
t.Fatal(err)
}
reopened := NewRestoreTestState(path)
got, ok := reopened.LastSuccess("felhom-pbs")
if !ok || !got.Equal(now) {
t.Fatalf("state must round-trip; got %v ok=%v want %v", got, ok, now)
}
bad := filepath.Join(dir, "corrupt.json")
if err := os.WriteFile(bad, []byte("{{{not json"), 0o600); err != nil {
t.Fatal(err)
}
c := NewRestoreTestState(bad)
if _, ok := c.LastSuccess("felhom-pbs"); ok {
t.Fatal("a corrupt state file must degrade to 'nothing proven', not invent a timestamp")
}
}