811f75736e
gates / gates (push) Successful in 23s
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
90 lines
3.6 KiB
Go
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")
|
|
}
|
|
}
|