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:
@@ -0,0 +1,199 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// R-609 (v0.261.0) — the 409 carries a machine-readable REASON, additively.
|
||||
//
|
||||
// WHY. `UpdateRefusal.Reason` has existed since v0.237.0 and never left the process: the body carried
|
||||
// only the translated sentence. A caller that is not a person cannot parse a Hungarian sentence, and
|
||||
// the distinction it needs is not decorative:
|
||||
//
|
||||
// TRANSIENT — busy, updating, deploying, migrating, self_updating → try again later
|
||||
// TERMINAL — held, downgrade → never press this again
|
||||
//
|
||||
// An unattended caller that cannot tell them apart either gives up on a passing backup window, or
|
||||
// presses a terminally-refused button on every pass for ever. `09` §6.2's caller reads exactly this.
|
||||
//
|
||||
// The SENTENCE is unchanged and stays where it was, so no page moves — this is purely additive.
|
||||
|
||||
// reasonOf pulls `data.reason` out of a refusal body, or "" when absent.
|
||||
func reasonOf(t *testing.T, resp apiResponse) string {
|
||||
t.Helper()
|
||||
if resp.Data == nil {
|
||||
return ""
|
||||
}
|
||||
m, ok := resp.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("data is not an object: %#v", resp.Data)
|
||||
}
|
||||
s, _ := m["reason"].(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// TestR609_EveryRefusalCarriesItsReason is table-driven over the refusal paths reachable without
|
||||
// docker, and it INCLUDES the router's own hold line — which fires before UpdatePreflight and was
|
||||
// the one that had no reason at all until this test was written.
|
||||
//
|
||||
// COMPANION RED-PROOF (run 2026-09-21): drop the `Data:` field from either writeJSON in actionStack's
|
||||
// update paths. The corresponding row fails with `reason = ""`. Reverted.
|
||||
func TestR609_EveryRefusalCarriesItsReason(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
arrange func(t *testing.T, r *Router, sett *settings.Settings, g *apiFakeGuards, dir string)
|
||||
wantReason string
|
||||
wantCode int
|
||||
}{
|
||||
{
|
||||
name: "held — the ROUTER's own line, before the preflight",
|
||||
arrange: func(t *testing.T, r *Router, sett *settings.Settings, g *apiFakeGuards, dir string) {
|
||||
if err := sett.SetRestoreHold(settings.RestoreHold{
|
||||
Stack: "app", At: "2026-09-21T08:00:00Z",
|
||||
Reason: settings.HoldReasonUpdateFailed, CopyDate: "2026-09-21T01:30:00Z",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
wantReason: "held", wantCode: http.StatusConflict,
|
||||
},
|
||||
{
|
||||
name: "no_backup — nothing on any tier and none can be taken",
|
||||
arrange: func(_ *testing.T, _ *Router, _ *settings.Settings, g *apiFakeGuards, _ string) {
|
||||
g.points, g.cannotBackUp = nil, true
|
||||
},
|
||||
wantReason: "no_backup", wantCode: http.StatusConflict,
|
||||
},
|
||||
{
|
||||
name: "self_updating — the controller is swapping itself (v0.261.0)",
|
||||
arrange: func(_ *testing.T, r *Router, _ *settings.Settings, _ *apiFakeGuards, _ string) {
|
||||
r.stackMgr.SetSelfUpdatingCheck(func() bool { return true })
|
||||
},
|
||||
wantReason: "self_updating", wantCode: http.StatusConflict,
|
||||
},
|
||||
{
|
||||
name: "downgrade — the box runs something newer than the catalog (R-524)",
|
||||
arrange: func(t *testing.T, r *Router, _ *settings.Settings, _ *apiFakeGuards, dir string) {
|
||||
setInstalledAndCatalog(t, r, dir, "nginx:1.28", "nginx:1.27")
|
||||
},
|
||||
wantReason: "downgrade", wantCode: http.StatusConflict,
|
||||
},
|
||||
{
|
||||
name: "not_found — an app that exists nowhere",
|
||||
arrange: func(_ *testing.T, _ *Router, _ *settings.Settings, _ *apiFakeGuards, _ string) {},
|
||||
wantReason: "not_found", wantCode: http.StatusNotFound,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
r, sett, g, dir := newSlice4Router(t)
|
||||
c.arrange(t, r, sett, g, dir)
|
||||
|
||||
name := "app"
|
||||
if c.wantReason == "not_found" {
|
||||
name = "no-such-app"
|
||||
}
|
||||
code, resp := postUpdateNamed(t, r, name)
|
||||
|
||||
if code != c.wantCode {
|
||||
t.Errorf("code = %d, want %d (error %q)", code, c.wantCode, resp.Error)
|
||||
}
|
||||
if resp.OK {
|
||||
t.Fatal("a refusal must not answer ok")
|
||||
}
|
||||
if got := reasonOf(t, resp); got != c.wantReason {
|
||||
t.Errorf("reason = %q, want %q", got, c.wantReason)
|
||||
}
|
||||
// The sentence is the household's and must still be there, in words — never a bare key.
|
||||
if resp.Error == "" {
|
||||
t.Error("the reason is ADDITIVE: the sentence must still be present")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestR609_ASuccessfulActionCarriesNoReason — the field is for refusals only, so a caller cannot
|
||||
// mistake a normal answer for one.
|
||||
func TestR609_ASuccessfulActionCarriesNoReason(t *testing.T) {
|
||||
r, _, _, _ := newSlice4Router(t)
|
||||
_, resp := postStopNamed(t, r, "app")
|
||||
if got := reasonOf(t, resp); got != "" {
|
||||
t.Errorf("a non-refusal must carry no reason, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// postUpdateNamed is postUpdate for an arbitrary app name (the not_found row needs one that is absent).
|
||||
func postUpdateNamed(t *testing.T, r *Router, name string) (int, apiResponse) {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
r.actionStack(w, httptest.NewRequest("POST", "/api/stacks/x/action", nil), "update", name)
|
||||
var resp apiResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("non-JSON body %q: %v", w.Body.String(), err)
|
||||
}
|
||||
return w.Code, resp
|
||||
}
|
||||
|
||||
// postStopNamed exercises a NON-update action, as the control that `reason` is refusal-only.
|
||||
func postStopNamed(t *testing.T, r *Router, name string) (int, apiResponse) {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
r.actionStack(w, httptest.NewRequest("POST", "/api/stacks/x/action", nil), "stop", name)
|
||||
var resp apiResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("non-JSON body %q: %v", w.Body.String(), err)
|
||||
}
|
||||
return w.Code, resp
|
||||
}
|
||||
|
||||
// setInstalledAndCatalog puts the app AHEAD of the catalog, which is R-524's Ahead arm.
|
||||
//
|
||||
// It writes the REAL files the real code reads — `installed_images` into the app's app.yaml and a
|
||||
// catalog template under <DataDir>/catalog-cache/templates/<app>/ — and then rescans. **No test-only
|
||||
// seam is added to production code for this**: the whole point of the api tests here is that they go
|
||||
// through the production handler over a real stacks.Manager, and a setter that only tests call would
|
||||
// be a second way to reach the state, which is how the two drift apart.
|
||||
func setInstalledAndCatalog(t *testing.T, r *Router, dir, installed, catalog string) {
|
||||
t.Helper()
|
||||
appYAML := slice4AppYAML + "installed_images:\n app:\n ref: " + installed +
|
||||
"\n digest: sha256:x\n at: \"2026-09-01T00:00:00Z\"\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte(appYAML), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catDir := filepath.Join(r.cfg.Paths.DataDir, "catalog-cache", "templates", "app")
|
||||
if err := os.MkdirAll(catDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(catDir, "docker-compose.yml"),
|
||||
[]byte("services:\n app:\n image: "+catalog+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.stackMgr.ScanStacks(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Positive control: the state this helper claims to create must actually exist, or the row it
|
||||
// serves would pass for the wrong reason (a different refusal firing first).
|
||||
if got := stacks.CatalogOrder(mustStack(t, r, "app")); got != stacks.UpdateOrderAhead {
|
||||
t.Fatalf("fixture did not produce the AHEAD state; CatalogOrder = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func mustStack(t *testing.T, r *Router, name string) stacks.Stack {
|
||||
t.Helper()
|
||||
st, ok := r.stackMgr.GetStack(name)
|
||||
if !ok {
|
||||
t.Fatalf("stack %q missing", name)
|
||||
}
|
||||
return *st
|
||||
}
|
||||
Reference in New Issue
Block a user