86ea482fc1
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).
174 lines
6.8 KiB
Go
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")
|
|
}
|
|
}
|