Files
felhom-controller/controller/internal/quiesce/agestate_test.go
T
admin 86ea482fc1 controller v0.178.0 — R-88 Part 2: only a positive 'never' fires the valve
MinAgent: 0.105.0. scheduledRunAllowed fired on any nil age; it now requires a
licence from valveLicensed, which grants it for AgeStateAbsent and for a LEGACY
agent, and refuses it for AgeStateUnknown. An unreadable storage no longer
masquerades as a first-ever backup and no longer quiesces apps outside the window.

A missing wire field means legacy, not unknown — deliberately. Treating it as
unknown would stop the valve firing on un-upgraded boxes and starve genuinely new
ones. Degrade logged once; unrecognised future values also map to legacy.

Caught in passing: TieredBackend is satisfied by a RUNTIME assertion, so the
signature change compiled and vetted clean while quiesceBackend silently stopped
satisfying it — which would have degraded every box to the single-tier path with
no error. Added a compile-time witness.

Also corrects the notifier comment that claimed operator-only came from a missing
customerMessages entry; enforcement is hub-side operatorOnlyEvents (hub 0.79.0).
2026-07-27 18:08:56 +02:00

174 lines
6.8 KiB
Go

package quiesce
import (
"context"
"log"
"strings"
"testing"
)
// R-88 Part 2 (controller half) — a nil age is no longer self-licensing.
//
// The valve fires only on a POSITIVE claim of "never backed up". These tests assert BEHAVIOUR (were
// stacks stopped?), never a log line — a controller that logs the right thing and then does the
// wrong thing must fail here.
// stateBackend is a tierBackend whose DueFor also reports an age_state wire string.
type stateBackend struct {
*tierBackend
wire map[string]string // target → age_state as sent by the agent ("" = legacy)
}
func (b *stateBackend) DueFor(ctx context.Context, target string) (bool, *int64, string, error) {
due, age, _, err := b.tierBackend.DueFor(ctx, target)
return due, age, b.wire[target], err
}
func stateLoop(t *testing.T, wire map[string]string, st *fakeStacks, logTo *strings.Builder) *Loop {
t.Helper()
be := newTierBackend()
be.tiers = []BackupTier{{Target: "local"}}
be.dueSet["local"] = true
be.phases["local"] = []string{phaseDone}
sb := &stateBackend{tierBackend: be, wire: wire}
l := windowLoop(t, sb, st, "02:30", atBudapest(12, 0)) // 12:00 — firmly OUTSIDE [04:30, 08:30)
if logTo != nil {
l.logger = log.New(logTo, "", 0)
}
return l
}
// ── SCENARIO A — UNKNOWN does not bypass the window ──────────────────────────────────────────
//
// COMPANION RED-PROOF (observed): make valveLicensed return true for AgeStateUnknown (the pre-fix
// behaviour, where any nil age fired the valve) and this fails with
//
// "R-88 Part 2: an UNKNOWN age bypassed the backup window and stopped 1 stack(s) — an unreadable
// storage must not masquerade as a first-ever backup"
//
// Restored.
func TestAgeState_UnknownDoesNotBypassTheWindow(t *testing.T) {
st := &fakeStacks{running: []string{"bookstack"}}
l := stateLoop(t, map[string]string{"local": "unknown"}, st, nil)
if err := l.runOnce(context.Background()); err != nil {
t.Fatal(err)
}
if got := len(st.stoppedNames()); got != 0 {
t.Fatalf("R-88 Part 2: an UNKNOWN age bypassed the backup window and stopped %d stack(s) — "+
"an unreadable storage must not masquerade as a first-ever backup", got)
}
}
// ── SCENARIO B — ABSENT still runs outside the window ────────────────────────────────────────
//
// B is what makes A safe. An implementation that never licensed the valve would pass A and silently
// starve every new box.
//
// COMPANION RED-PROOF (observed): drop AgeStateAbsent from valveLicensed (keeping only legacy) and
// this fails with
//
// "a genuine first-ever backup (absent) must RUN outside the window; 0 stack(s) stopped — the
// safety valve was lost and a new box would starve"
//
// Restored.
func TestAgeState_AbsentStillRunsOutsideTheWindow(t *testing.T) {
st := &fakeStacks{running: []string{"bookstack"}}
l := stateLoop(t, map[string]string{"local": "absent"}, st, nil)
if err := l.runOnce(context.Background()); err != nil {
t.Fatal(err)
}
if len(st.stoppedNames()) == 0 {
t.Fatal("a genuine first-ever backup (absent) must RUN outside the window; 0 stack(s) stopped — " +
"the safety valve was lost and a new box would starve")
}
}
// ── SCENARIO C — old agent, new controller: TODAY'S behaviour exactly ────────────────────────
//
// Asserts BEHAVIOUR, not the degrade log line: a controller that logs the degrade and then defers
// would pass a log-only assertion while silently changing behaviour on every un-upgraded box.
//
// COMPANION RED-PROOF (observed): drop AgeStateLegacy from valveLicensed (treating a missing field
// as unknown — the "safer-looking" choice) and this fails with
//
// "C: a pre-v0.105.0 agent must behave EXACTLY as before — nil age fires the valve. 0 stack(s)
// stopped; an un-upgraded box just silently stopped backing up outside its window"
//
// Restored.
func TestAgeState_LegacyAgentKeepsTodaysBehaviour(t *testing.T) {
st := &fakeStacks{running: []string{"bookstack"}}
var logbuf strings.Builder
l := stateLoop(t, map[string]string{"local": ""}, st, &logbuf) // NO field on the wire
if err := l.runOnce(context.Background()); err != nil {
t.Fatal(err)
}
if len(st.stoppedNames()) == 0 {
t.Fatal("C: a pre-v0.105.0 agent must behave EXACTLY as before — nil age fires the valve. " +
"0 stack(s) stopped; an un-upgraded box just silently stopped backing up outside its window")
}
// ...and the degrade must be VISIBLE, or a fleet drifts without anyone knowing.
if !strings.Contains(logbuf.String(), "age_state") {
t.Fatalf("the legacy degrade must be logged once; log:\n%s", logbuf.String())
}
}
// The degrade is logged ONCE, not every poll.
func TestAgeState_LegacyDegradeLoggedOnce(t *testing.T) {
st := &fakeStacks{running: []string{"bookstack"}}
var logbuf strings.Builder
l := stateLoop(t, map[string]string{"local": ""}, st, &logbuf)
for i := 0; i < 3; i++ {
if err := l.runOnce(context.Background()); err != nil {
t.Fatal(err)
}
}
if n := strings.Count(logbuf.String(), "pre-v0.105.0"); n != 1 {
t.Fatalf("the legacy degrade must be logged ONCE per process, got %d", n)
}
}
// An unrecognised FUTURE state maps to legacy, not to unknown — a newer agent inventing a fourth
// value must not accidentally acquire "unknown" semantics from a controller that never heard of it.
func TestAgeState_UnrecognisedWireValueIsLegacy(t *testing.T) {
for _, wire := range []string{"", "known", "absent", "unknown", "quantum", "TRUE", "0"} {
got := ageStateFromWire(wire)
switch wire {
case "known", "absent", "unknown":
if string(got) != wire {
t.Errorf("%q must map to itself, got %q", wire, got)
}
default:
if got != AgeStateLegacy {
t.Errorf("%q must map to LEGACY (fail toward known behaviour), got %q", wire, got)
}
}
}
}
// valveLicensed as a truth table — the contract, independent of the loop.
func TestAgeState_ValveLicenceTable(t *testing.T) {
age := int64(3600)
cases := []struct {
name string
t dueTier
want bool
}{
{"absent licenses", dueTier{state: AgeStateAbsent}, true},
{"legacy licenses (un-upgraded agent keeps old behaviour)", dueTier{state: AgeStateLegacy}, true},
{"unknown does NOT license", dueTier{state: AgeStateUnknown}, false},
{"known with a real age needs no licence", dueTier{state: AgeStateKnown, ageSecs: &age}, false},
}
for _, c := range cases {
if got := valveLicensed([]dueTier{c.t}); got != c.want {
t.Errorf("%s: valveLicensed = %v, want %v", c.name, got, c.want)
}
}
// One unknown tier must not be licensed by a sibling that is merely known-with-age.
if valveLicensed([]dueTier{{state: AgeStateUnknown}, {state: AgeStateKnown, ageSecs: &age}}) {
t.Error("a known sibling must not license an unknown tier's valve")
}
}