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:
@@ -600,7 +600,13 @@ func (r *Router) actionStack(w http.ResponseWriter, req *http.Request, action, n
|
||||
// TestR439_UpdateOfAHeldAppIsRefused.
|
||||
if action == "start" || action == "restart" || action == "update" {
|
||||
if held, why := r.restoreHoldFor(name); held {
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: why})
|
||||
// `reason` (v0.261.0) — THIS LINE FIRES BEFORE UpdatePreflight, so without it a held app
|
||||
// answers 409 with no machine-readable reason at all, and an unattended caller cannot
|
||||
// tell it from a passing backup window: it would press a terminally-refused button for
|
||||
// ever. Found while writing the reason test, not by reading. `held` is TERMINAL until a
|
||||
// person acts, and it is the one reason that matters most to get right.
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: why,
|
||||
Data: map[string]string{"reason": "held"}})
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -652,7 +658,15 @@ func (r *Router) actionStack(w http.ResponseWriter, req *http.Request, action, n
|
||||
// byte — which is why this line is safe to change for all of them at once, and why the
|
||||
// R-524 downgrade refusal below is not a key nobody reads (the "seam built but never
|
||||
// wired" class).
|
||||
writeJSON(w, status, apiResponse{OK: false, Error: r.errText(req, ref)})
|
||||
//
|
||||
// `data.reason` (v0.261.0) is ADDITIVE and is for a caller that is not a person. The
|
||||
// sentence says WHAT happened; only the reason says whether trying again can ever work —
|
||||
// `busy`/`updating`/`deploying`/`migrating`/`self_updating` are TRANSIENT, `held` and
|
||||
// `downgrade` are TERMINAL until a human acts. An unattended caller that cannot tell
|
||||
// those apart either gives up on a passing backup window or presses a refused button for
|
||||
// ever. The sentence is unchanged, so no page moves.
|
||||
writeJSON(w, status, apiResponse{OK: false, Error: r.errText(req, ref),
|
||||
Data: map[string]string{"reason": ref.Reason}})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user