Files
felhom-controller/controller/internal/selfupdate/appupdate_lock_test.go
T
admin 811f75736e
gates / gates (push) Successful in 23s
v0.261.0 — the controller no longer swaps itself out from under an app update (R-608, R-609)
The controller self-updates daily at 04:30 by default, and after any hub report
once a floor sits above the box. That swap restarts the controller container.
The window proposed for automatic app updates is 02:30-05:00. It contains 04:30.

R-608 — a two-way lock, wired in main.go (stacks never imports selfupdate):
- stacks.Manager.AnyUpdating() -> Updater.SetAppUpdatingCheck, consulted in the
  same three places as the existing backupRunning gate.
- Updater.IsUpdateRunning -> Manager.SetSelfUpdatingCheck; UpdatePreflight
  refuses `self_updating`.
- MEASURED: the gap was narrower than assumed. The update's `backing-up` phase
  already takes the backup single-flight, so that one phase was covered. The
  other six were not, and `starting`/`verifying` are where data may have moved.
- The lock must NOT latch: a held app does not block the controller's own
  updates, including the release that might fix the hold.

R-609 — the 409 carries `data.reason`, additively. transient (busy, updating,
deploying, migrating, self_updating) vs terminal (held, downgrade). Found while
writing the test: the router refuses a HELD app on its own line before the
preflight, so `held` would have been the one reason missing.

Five red-proofs, each seen to fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-21 14:23:59 +02:00

90 lines
3.6 KiB
Go

package selfupdate
import (
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
// R-608 (v0.261.0) — the self-updater's half of the lock.
//
// The controller swaps itself daily at `self_update.auto_update_time` (04:30 by default) AND after
// any hub report once a floor sits above this box — so at any hour. That swap restarts this process.
// Until v0.261.0 the only busy gate was `backupRunning`, which covers the app update's `backing-up`
// phase (it takes the backup single-flight) and none of the others.
// TestR608_TriggerUpdateRefusedWhileAnAppUpdates is a CONSEQUENCE test: does the swap actually
// refuse, and does it say so in a sentence the household can read in its own language?
//
// COMPANION RED-PROOF (run 2026-09-21): delete the `u.appUpdating` block from TriggerUpdate. The
// refusal assertion then fails — the swap proceeds on top of a live app update.
func TestR608_TriggerUpdateRefusedWhileAnAppUpdates(t *testing.T) {
agent := &fakeAgent{}
u := newTestUpdater(t, "0.260.0", agent)
u.queryFn = func() (string, error) { return "0.261.0", nil }
u.pullFn = func(string) error { return nil }
appUpdating := true
u.SetAppUpdatingCheck(func() bool { return appUpdating })
err := u.TriggerUpdate("manual")
if err == nil {
t.Fatal("while a guarded app update is in flight the controller swap must be REFUSED")
}
if m, ok := util.AsMsg(err); !ok {
t.Error("the refusal must carry a bundle key, or an English household reads Hungarian")
} else if m.Key() != "err.selfupdate.alkalmazas_frissites_folyamatban" {
t.Errorf("key = %q", m.Key())
}
if !strings.Contains(err.Error(), "alkalmaz") {
t.Errorf("the Hungarian fallback must name the app update, got %q", err.Error())
}
if len(agent.swapCalls()) != 0 {
t.Fatalf("NOTHING may reach the agent on a refusal, got %v", agent.swapCalls())
}
// TRANSIENT, not latching: the moment the app update ends, the same trigger goes through with no
// human action in between. A gate that latches would strand the box on an old controller.
appUpdating = false
if err := u.TriggerUpdate("manual"); err != nil {
t.Fatalf("once the app update has finished the swap must proceed, got %v", err)
}
waitDone(t, u)
if len(agent.swapCalls()) != 1 {
t.Errorf("expected exactly one swap after the gate cleared, got %v", agent.swapCalls())
}
}
// TestR608_DryRunReportsTheAppUpdate — the operator's dry run must SAY why an update will not run.
// A dry run that reports "available" while the trigger would refuse is a confident wrong answer.
func TestR608_DryRunReportsTheAppUpdate(t *testing.T) {
u := newTestUpdater(t, "0.260.0", &fakeAgent{})
u.queryFn = func() (string, error) { return "0.261.0", nil }
if got := u.DryRun().AppUpdating; got {
t.Error("control: with no app update in flight the dry run must report false")
}
u.SetAppUpdatingCheck(func() bool { return true })
if got := u.DryRun().AppUpdating; !got {
t.Error("the dry run must report that an app update is holding the swap")
}
}
// TestR608_NilAppUpdatingCheckIsSafe — unwired means the pre-v0.261.0 behaviour, never fail-closed.
// Failing closed on an UNWIRED gate would strand every box whose construction path misses it.
func TestR608_NilAppUpdatingCheckIsSafe(t *testing.T) {
agent := &fakeAgent{}
u := newTestUpdater(t, "0.260.0", agent)
u.queryFn = func() (string, error) { return "0.261.0", nil }
u.pullFn = func(string) error { return nil }
if err := u.TriggerUpdate("manual"); err != nil {
t.Fatalf("an unwired app-update gate must not refuse, got %v", err)
}
waitDone(t, u)
if u.DryRun().AppUpdating {
t.Error("an unwired gate must report false, not true")
}
}