v0.261.0 — the controller no longer swaps itself out from under an app update (R-608, R-609)
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
This commit is contained in:
2026-09-21 14:23:59 +02:00
parent d0d431b42b
commit 811f75736e
13 changed files with 591 additions and 2 deletions
+4
View File
@@ -189,6 +189,10 @@ type Stack struct {
// Manager handles all docker compose stack operations.
type Manager struct {
// selfUpdating (v0.261.0) reports whether the CONTROLLER is swapping itself. Set by
// SetSelfUpdatingCheck; nil means no gate. See update.go.
selfUpdating func() bool
cfg *config.Config
logger *log.Logger
composeCmd string
+59
View File
@@ -227,6 +227,27 @@ func (m *Manager) SetUpdateGuards(g UpdateGuards) {
m.mu.Unlock()
}
// SetSelfUpdatingCheck wires the OTHER half of the v0.261.0 lock: is the CONTROLLER swapping itself?
//
// A plain callback rather than a member of UpdateGuards, for two reasons. UpdateGuards is the BACKUP
// side's interface and this has nothing to do with backups; and `stacks` must never import
// `selfupdate` (selfupdate reaches the agent, and the import would run the wrong way), so the
// dependency is inverted here and satisfied in main.go with `updater.IsUpdateRunning`.
//
// Nil is safe and means the pre-v0.261.0 behaviour: no self-update gate.
func (m *Manager) SetSelfUpdatingCheck(fn func() bool) {
m.mu.Lock()
m.selfUpdating = fn
m.mu.Unlock()
}
func (m *Manager) selfUpdatingNow() bool {
m.mu.RLock()
fn := m.selfUpdating
m.mu.RUnlock()
return fn != nil && fn()
}
func (m *Manager) guards() UpdateGuards {
m.mu.RLock()
defer m.mu.RUnlock()
@@ -323,6 +344,14 @@ func (m *Manager) UpdatePreflight(name string) *UpdateRefusal {
if m.IsMigrating() {
return m.refuseUpdate(name, "migrating", MsgUpdateMigrating, "a data migration is running")
}
// v0.261.0 — the other half of the self-update lock. The controller's swap restarts this process;
// starting an app update into that is how an update loses its own supervisor mid-flight. TRANSIENT:
// the household is told to try again in a few minutes, and the caller in `09` §6.2 reads the
// machine-readable `downgrade`-style reason and retries rather than giving up.
if m.selfUpdatingNow() {
return m.refuseUpdateErr(name, "self_updating", util.MsgError("err.stacks.update_self_updating"),
"the controller is swapping itself — refusing to start an app update into a restart")
}
// R-524 — THE PIN NEVER MOVES BACKWARDS WITHOUT THE OPERATOR. MEASURED 2026-09-15 (BIGNIGHT
// Phase 6): privatebin was updated 2.0.5 → 2.0.6, the catalog was reverted to 2.0.5, and the
// „Frissítés" button behind the badge would have advanced the pin to the OLDER image — on a
@@ -439,6 +468,36 @@ func (m *Manager) IsUpdating(name string) bool {
return ok && s.Updating
}
// AnyUpdating reports whether a guarded update is in flight for ANY app.
//
// ── WHY IT EXISTS (v0.261.0) ────────────────────────────────────────────────────────────────────
//
// The CONTROLLER updates itself too — daily at `self_update.auto_update_time` (04:30 by default) and,
// once the hub serves a floor above this box, after any report, at any hour. That swap restarts the
// controller container. Until v0.261.0 its ONLY busy gate was `backupRunning`, so a self-update could
// land in the middle of a guarded app update.
//
// MEASURED, and the gap is narrower than it looks but real: the update's `backing-up` phase takes the
// backup single-flight (`RunAppBackupNow` → `acquireRunning`), so `backupMgr.IsRunning()` ALREADY
// covered that one phase. It covers none of the others — `checking`, `safety-dump`, `pinning`,
// `pulling`, `starting`, `verifying` — and `starting`/`verifying` are exactly where the new version
// may already have touched the app's data.
//
// ⚠ IT MUST ANSWER FALSE FOR A HELD APP. `Stack.Updating` is cleared by `finishUpdate` on done,
// failed AND held, so a held app does not hold this lock — otherwise one app that cannot come up
// would block the controller's own updates for ever, which is a worse failure than the one this
// prevents. TestR608_LockReleasesAfterHold pins that.
func (m *Manager) AnyUpdating() bool {
m.mu.RLock()
defer m.mu.RUnlock()
for _, s := range m.stacks {
if s.Updating {
return true
}
}
return false
}
// UpdatingStacks is the set of apps an update is currently moving — for the dead-app alarm, which
// must not count an app the update itself is recreating (R-330's class, a third mechanism).
func (m *Manager) UpdatingStacks() map[string]bool {
@@ -0,0 +1,125 @@
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)
}
}