Files
felhom-controller/controller/internal/selfupdate/floor_test.go
T
admin 1310a0ebd7 v0.86.0: Phase 2 managed updates — floor-driven auto-update
The controller honors an operator-enforced minimum version (FLOOR) on the hub
report ACK and auto-updates to the floor when below it (managed default, no click),
reusing the Phase 1 in-guest-pull + agent-swap + rollback. Latest stays the opt-in
button; the floor is the auto-target, never latest.

- pusher.go: PushResponse += min_controller_version, latest_version (existing ACK seam)
- main.go: OnPushResponse → updater.SetFloor + MaybeAutoUpdate (rides report cycle)
- updater.go: SetFloor/GetFloor + MaybeAutoUpdate reusing performUpdate (auto-floor);
  no-op at/above floor, floor>latest, dev/no-agent/backup; no flap (in-mem+persisted)
- settings UI (HU): floor display + auto restart-poll during an auto-update
- tests: below/at/floor>latest/no-flap/raised-floor; below-floor red-proof verified
- no agent change (reuses Phase 1 POST /controller/swap)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FSZmmSFVzGwEzhYmxbkgBK
2026-06-27 11:59:47 +02:00

166 lines
5.3 KiB
Go

package selfupdate
import (
"testing"
)
// Scenario A — below floor auto-updates to the FLOOR (not latest), reusing the Phase 1 swap.
// This is the companion RED-PROOF: it must FAIL if MaybeAutoUpdate stops honoring the floor.
func TestMaybeAutoUpdate_BelowFloor_UpdatesToFloor(t *testing.T) {
agent := &fakeAgent{}
u := newTestUpdater(t, "0.86.0", agent)
// Registry latest is 0.87.0 (so the floor is pullable). Auto-target must be the FLOOR, not latest.
u.queryFn = func() (string, error) { return "0.87.0", nil }
var pulled string
u.pullFn = func(img string) error { pulled = img; return nil }
u.SetFloor("0.87.0")
u.MaybeAutoUpdate()
waitDone(t, u)
want := imageBase + ":0.87.0"
if pulled != want {
t.Errorf("pulled %q, want %q (the floor)", pulled, want)
}
calls := agent.swapCalls()
if len(calls) != 1 || calls[0] != want {
t.Errorf("agent swap calls = %v, want exactly [%q]", calls, want)
}
}
// Scenario B — at/above floor: no pull, no swap. Must NOT update even though latest > current.
func TestMaybeAutoUpdate_AtFloor_NoAction(t *testing.T) {
agent := &fakeAgent{}
u := newTestUpdater(t, "0.87.0", agent)
u.queryFn = func() (string, error) { return "0.99.0", nil } // latest far ahead — irrelevant
pulled := false
u.pullFn = func(string) error { pulled = true; return nil }
u.SetFloor("0.87.0") // current == floor
u.MaybeAutoUpdate()
if pulled {
t.Error("must NOT pull when at/above floor (latest>current is the customer's button, not the floor)")
}
if n := len(agent.swapCalls()); n != 0 {
t.Errorf("agent called %d times, want 0 (at floor)", n)
}
}
// Scenario D — no floor set: inert.
func TestMaybeAutoUpdate_NoFloor_Inert(t *testing.T) {
agent := &fakeAgent{}
u := newTestUpdater(t, "0.86.0", agent)
u.queryFn = func() (string, error) { return "0.99.0", nil }
pulled := false
u.pullFn = func(string) error { pulled = true; return nil }
u.SetFloor("") // explicitly none
u.MaybeAutoUpdate()
if pulled || len(agent.swapCalls()) != 0 {
t.Errorf("no floor must be inert; pulled=%v swaps=%d", pulled, len(agent.swapCalls()))
}
}
// Rule 2 — floor exceeds the latest available tag (operator misconfig): do nothing (don't chase a
// non-existent image).
func TestMaybeAutoUpdate_FloorAboveLatest_NoChase(t *testing.T) {
agent := &fakeAgent{}
u := newTestUpdater(t, "0.86.0", agent)
u.queryFn = func() (string, error) { return "0.87.0", nil } // latest available
pulled := false
u.pullFn = func(string) error { pulled = true; return nil }
u.SetFloor("0.88.0") // floor > latest available
u.MaybeAutoUpdate()
if pulled {
t.Error("must NOT pull when the floor exceeds the latest available tag")
}
if n := len(agent.swapCalls()); n != 0 {
t.Errorf("agent called %d times, want 0 (floor>latest)", n)
}
}
// Rule 4 — no flapping/storm: repeated reconciles for the same below-floor condition trigger exactly
// one auto-update.
func TestMaybeAutoUpdate_NoFlap(t *testing.T) {
agent := &fakeAgent{}
u := newTestUpdater(t, "0.86.0", agent)
u.queryFn = func() (string, error) { return "0.87.0", nil }
u.pullFn = func(string) error { return nil }
u.SetFloor("0.87.0")
u.MaybeAutoUpdate()
waitDone(t, u)
// Simulate further report cycles with the same floor + still-below current.
u.SetFloor("0.87.0")
u.MaybeAutoUpdate()
u.SetFloor("0.87.0")
u.MaybeAutoUpdate()
waitDone(t, u)
if n := len(agent.swapCalls()); n != 1 {
t.Errorf("agent swap calls = %d, want exactly 1 (no flapping)", n)
}
}
// Scenario C / E — precedence is reflected by whatever floor the notifier sets: a raised floor (e.g.
// an operator override or a global bump) is honored on the next reconcile.
func TestMaybeAutoUpdate_HonorsRaisedFloor(t *testing.T) {
agent := &fakeAgent{}
u := newTestUpdater(t, "0.86.0", agent)
u.queryFn = func() (string, error) { return "0.87.0", nil }
var pulled []string
u.pullFn = func(img string) error { pulled = append(pulled, img); return nil }
// First the global floor equals current → no action.
u.SetFloor("0.86.0")
u.MaybeAutoUpdate()
if len(agent.swapCalls()) != 0 {
t.Fatalf("floor==current should not update")
}
// Operator raises the floor (override or global bump) → now auto-updates to the new floor.
u.SetFloor("0.87.0")
u.MaybeAutoUpdate()
waitDone(t, u)
want := imageBase + ":0.87.0"
calls := agent.swapCalls()
if len(calls) != 1 || calls[0] != want {
t.Errorf("agent swap calls = %v, want [%q] after raised floor", calls, want)
}
}
// A dev build never auto-updates (can't compare versions).
func TestMaybeAutoUpdate_DevCurrent_NoAction(t *testing.T) {
agent := &fakeAgent{}
u := newTestUpdater(t, "dev", agent)
u.queryFn = func() (string, error) { return "0.87.0", nil }
pulled := false
u.pullFn = func(string) error { pulled = true; return nil }
u.SetFloor("0.87.0")
u.MaybeAutoUpdate()
if pulled || len(agent.swapCalls()) != 0 {
t.Errorf("dev build must not auto-update; pulled=%v swaps=%d", pulled, len(agent.swapCalls()))
}
}
// No agent (un-provisioned guest) → no auto-update.
func TestMaybeAutoUpdate_NoAgent_NoAction(t *testing.T) {
u := newTestUpdater(t, "0.86.0", nil)
u.queryFn = func() (string, error) { return "0.87.0", nil }
pulled := false
u.pullFn = func(string) error { pulled = true; return nil }
u.SetFloor("0.87.0")
u.MaybeAutoUpdate()
if pulled {
t.Error("must not pull when no agent is wired")
}
}