Files
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

126 lines
5.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}