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
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user