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
126 lines
5.2 KiB
Go
126 lines
5.2 KiB
Go
package stacks
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// R-608 (v0.261.0) — the two-way lock between the controller's own update and a guarded app update.
|
||
//
|
||
// THE GAP THIS CLOSES, measured rather than assumed: the self-updater's only busy gate was
|
||
// `backupRunning`. The app update's `backing-up` phase DOES take the backup single-flight
|
||
// (`RunAppBackupNow` → `acquireRunning`), so that one phase was already covered. `checking`,
|
||
// `safety-dump`, `pinning`, `pulling`, `starting` and `verifying` were not — and the last two are
|
||
// where the new version may already have touched the customer's data. The controller's swap restarts
|
||
// this process, and 04:30 (the default `self_update.auto_update_time`) sits inside the 02:30–05:00
|
||
// window `09` §3b Q1 proposes for automatic app updates.
|
||
|
||
// TestR608_AnyUpdatingSeesAnUpdateInFlight is the mechanism half.
|
||
//
|
||
// COMPANION RED-PROOF (run 2026-09-21): make AnyUpdating always return false. The "while updating"
|
||
// sub-test then fails — and with it the whole gate, because every caller reads this one answer.
|
||
func TestR608_AnyUpdatingSeesAnUpdateInFlight(t *testing.T) {
|
||
m, _, _, _ := newSlice4Manager(t)
|
||
|
||
if m.AnyUpdating() {
|
||
t.Fatal("no update has started; AnyUpdating must be false")
|
||
}
|
||
|
||
release := make(chan struct{})
|
||
m.updateHealthFn = func(context.Context, string, time.Duration) (bool, string) {
|
||
<-release
|
||
return true, "released"
|
||
}
|
||
if err := m.StartGuardedUpdate("nextcloud"); err != nil {
|
||
t.Fatalf("start: %v", err)
|
||
}
|
||
deadline := time.Now().Add(3 * time.Second)
|
||
for !m.AnyUpdating() && time.Now().Before(deadline) {
|
||
time.Sleep(2 * time.Millisecond)
|
||
}
|
||
if !m.AnyUpdating() {
|
||
t.Fatal("an update is in flight and AnyUpdating says false — the controller could swap under it")
|
||
}
|
||
close(release)
|
||
waitUpdateDone(t, m, "nextcloud")
|
||
if m.AnyUpdating() {
|
||
t.Error("the update finished; the lock must not still be held")
|
||
}
|
||
}
|
||
|
||
// TestR608_LockReleasesAfterHold is the one that matters most, and it is a CONSEQUENCE test.
|
||
//
|
||
// A held app is an app that could not come up. If it kept this lock, the box would never update its
|
||
// own controller again — including the release that might FIX whatever held the app. That is a worse
|
||
// failure than the one the lock prevents, and it is the kind that is silent for weeks.
|
||
//
|
||
// COMPANION RED-PROOF (run 2026-09-21): in AnyUpdating, return true when `s.updateHeld` is set as
|
||
// well as when `s.Updating` is — the plausible "a held update is still an update" reading. This test
|
||
// then fails with the lock still held after the hold.
|
||
func TestR608_LockReleasesAfterHold(t *testing.T) {
|
||
m, _, _, _ := newSlice4Manager(t)
|
||
m.updateHealthFn = func(context.Context, string, time.Duration) (bool, string) { return false, "crash loop" }
|
||
|
||
if err := m.StartGuardedUpdate("nextcloud"); err != nil {
|
||
t.Fatalf("start: %v", err)
|
||
}
|
||
st := waitUpdateDone(t, m, "nextcloud")
|
||
if st.UpdatePhase != UpdatePhaseFailed {
|
||
t.Fatalf("this fixture must end HELD, or the test proves nothing; phase=%q", st.UpdatePhase)
|
||
}
|
||
if m.AnyUpdating() {
|
||
t.Error("a HELD app must not hold the self-update lock for ever")
|
||
}
|
||
}
|
||
|
||
// TestR608_PreflightRefusesWhileTheControllerSwaps is the reverse direction, and a CONSEQUENCE test:
|
||
// the question is not "is the callback wired" but "does the button refuse".
|
||
//
|
||
// COMPANION RED-PROOF (run 2026-09-21): delete the `m.selfUpdatingNow()` block from UpdatePreflight.
|
||
// The "while swapping" sub-test then fails with `ref = <nil>` — an app update is allowed to start
|
||
// into a controller restart.
|
||
func TestR608_PreflightRefusesWhileTheControllerSwaps(t *testing.T) {
|
||
m, _, _, _ := newSlice4Manager(t)
|
||
|
||
if ref := m.UpdatePreflight("nextcloud"); ref != nil {
|
||
t.Fatalf("control: with no self-update running the app update must be allowed, got %q", ref.Reason)
|
||
}
|
||
|
||
swapping := true
|
||
m.SetSelfUpdatingCheck(func() bool { return swapping })
|
||
|
||
ref := m.UpdatePreflight("nextcloud")
|
||
if ref == nil {
|
||
t.Fatal("while the controller swaps itself the app update must be REFUSED")
|
||
}
|
||
if ref.Reason != "self_updating" {
|
||
t.Errorf("reason = %q, want %q", ref.Reason, "self_updating")
|
||
}
|
||
// It must carry its bundle key, or an English household reads a Hungarian refusal — R-589's
|
||
// failure in a new place, and the reason v0.260.0 routed these through errText.
|
||
if ref.Cause == nil {
|
||
t.Error("the refusal must carry its key as a Cause, not only a Hungarian literal")
|
||
}
|
||
|
||
// And it must be TRANSIENT: once the swap ends the same app update is allowed, with no human
|
||
// action in between. A gate that latches is an outage.
|
||
swapping = false
|
||
if ref := m.UpdatePreflight("nextcloud"); ref != nil {
|
||
t.Errorf("after the swap the update must be allowed again, got %q", ref.Reason)
|
||
}
|
||
}
|
||
|
||
// TestR608_NilChecksAreSafe — both halves default to the pre-v0.261.0 behaviour when unwired. A
|
||
// Manager built by a test fixture or a future construction path must not panic or fail closed here:
|
||
// failing closed on an UNWIRED gate would refuse every update on such a box.
|
||
func TestR608_NilChecksAreSafe(t *testing.T) {
|
||
m, _, _, _ := newSlice4Manager(t)
|
||
if m.selfUpdatingNow() {
|
||
t.Error("an unwired self-update check must read false")
|
||
}
|
||
if ref := m.UpdatePreflight("nextcloud"); ref != nil {
|
||
t.Errorf("an unwired gate must not refuse an update, got %q", ref.Reason)
|
||
}
|
||
}
|