v0.189.0 — desired state + the app-stop crash marker (R-166 / D-b)
gates / gates (push) Successful in 8s

The box stops inferring the customer's intent from a container count and reads
what they actually asked for.

Part 1 — desired state. AppConfig gains a tri-state `desired_state`
(""/running/stopped), written ONLY by the customer's own action: the API action
switch, DeployStack, UpdateOptionalConfig's redeploy branch, and the .fab
import. Intent is written BEFORE the act and a failed write REFUSES the act.
StartStack/StopStack are deliberately not writers — 14 callers, only 2 are the
customer. bootrecon.isBootOrphan now reads intent instead of len(Containers)>0,
which closes R-157 mechanism B (a power cut or interrupted deploy left an app
with zero containers, read as a deliberate stop, and stranded silently).

ABSENT MEANS UNKNOWN, NEVER "running": every pre-v0.189.0 app.yaml reads absent,
so the legacy fallback is byte-identical to the old rule. A running-only startup
backfill converges the unambiguous cases; `stopped` is never inferred.

Part 2 — backup.AppStopGuard, a persisted marker over every stop→work→start
window (volume dump, offbox reconstitute, .fab export). Its own file, never
quiesce's. Written before the stop, cleared only after a restart that succeeded,
kept when one fails. Recover() completes before the boot reconciler is launched
and returns its outcome, which main.go reports on the existing backup_failed
event once the notifier exists. A defer is not the mechanism — a SIGKILL runs
none (Campaign 8 fault 10).

Also: SaveAppConfig rebuilt AppConfig field-by-field (the R-100 shape) and would
have dropped desired_state on every save across nine call sites. Replaced with
copy-and-overlay. Measured: app.yaml does not round-trip unknown YAML keys.

No hub change, no agent coupling, no user-visible string. 27/27 packages green;
7 red-proofs observed FAIL then restored.
This commit is contained in:
2026-08-02 18:40:17 +02:00
parent e7c44c0e0f
commit dbcb306fcf
17 changed files with 2211 additions and 33 deletions
@@ -0,0 +1,149 @@
package api
import (
"go/ast"
"go/parser"
"go/token"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-166 Part 1.3 — THE CUSTOMER-INTENT POINT.
//
// `stackMgr` is a concrete *stacks.Manager, so actionStack cannot be driven with a fake without
// Docker. The two properties that actually carry the correctness are therefore pinned the only way
// they can be: the mapping is a pure function with its own table test, and the ORDER (§8.2) is
// asserted structurally over actionStack's AST. Both fail if someone reverses the write and the act,
// which is the mistake that would undo a customer's Stop at the next boot.
func TestDesiredStateForAction_MapsEveryAction(t *testing.T) {
cases := []struct {
action string
want string
ok bool
}{
{"start", stacks.DesiredStateRunning, true},
// restart and update both END in `compose up -d`, so a customer who presses either is asking
// for the app to be up afterwards.
{"restart", stacks.DesiredStateRunning, true},
{"update", stacks.DesiredStateRunning, true},
{"stop", stacks.DesiredStateStopped, true},
// Anything unrecognised records NOTHING rather than guessing — a future action must not
// silently acquire an intent it was never meant to carry.
{"", "", false},
{"delete", "", false},
{"pause", "", false},
}
for _, tc := range cases {
got, ok := desiredStateForAction(tc.action)
if got != tc.want || ok != tc.ok {
t.Fatalf("desiredStateForAction(%q) = (%q, %v), want (%q, %v)", tc.action, got, ok, tc.want, tc.ok)
}
}
}
func TestDesiredStateForAction_NeverRecordsStoppedForANonStop(t *testing.T) {
// The asymmetry that matters: writing "stopped" for anything other than a Stop would permanently
// disable auto-recovery for an app nobody stopped.
for _, a := range []string{"start", "restart", "update", "deploy", "delete", ""} {
if got, _ := desiredStateForAction(a); got == stacks.DesiredStateStopped {
t.Fatalf("action %q maps to desired_state=stopped", a)
}
}
}
// TestActionStack_RecordsIntentBeforeActing is §8.2, asserted structurally.
//
// If the SetDesiredState call moved BELOW the action switch, a stop could remove every container
// while app.yaml still recorded `running` — and the boot reconciler would then start an app the
// customer had just deliberately stopped. That is the single worst outcome available in Part 1, and
// no behavioural test in this package can reach it without a Docker daemon.
func TestActionStack_RecordsIntentBeforeActing(t *testing.T) {
body := funcBody(t, "actionStack")
setPos, switchPos := -1, -1
ast.Inspect(body, func(n ast.Node) bool {
switch node := n.(type) {
case *ast.CallExpr:
if sel, ok := node.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "SetDesiredState" && setPos < 0 {
setPos = int(node.Pos())
}
case *ast.SwitchStmt:
// The action switch is the one whose tag is the `action` identifier.
if id, ok := node.Tag.(*ast.Ident); ok && id.Name == "action" && switchPos < 0 {
switchPos = int(node.Pos())
}
}
return true
})
if setPos < 0 {
t.Fatal("actionStack no longer calls SetDesiredState — the customer's start/stop decision is " +
"recorded nowhere, which is the R-166 defect un-fixed")
}
if switchPos < 0 {
t.Fatal("actionStack no longer has a `switch action` — this test needs updating")
}
if setPos >= switchPos {
t.Fatal("actionStack records the desired state AFTER performing the action (§8.2 violated): a " +
"stop whose intent write fails or lands late leaves zero containers with `running` " +
"recorded, and the boot reconciler would restart an app the customer just stopped")
}
}
// TestActionStack_RefusesTheActionWhenIntentCannotBeRecorded pins the other half of §8.2: a failed
// write REFUSES the act. Proceeding anyway would perform a stop that nothing records — exactly the
// ambiguity this release removes.
func TestActionStack_RefusesTheActionWhenIntentCannotBeRecorded(t *testing.T) {
body := funcBody(t, "actionStack")
refuses := false
ast.Inspect(body, func(n ast.Node) bool {
ifst, ok := n.(*ast.IfStmt)
if !ok || ifst.Init == nil {
return true
}
// Look for `if derr := ...SetDesiredState(...); derr != nil { ... return }`
assign, ok := ifst.Init.(*ast.AssignStmt)
if !ok || len(assign.Rhs) != 1 {
return true
}
call, ok := assign.Rhs[0].(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "SetDesiredState" {
return true
}
for _, stmt := range ifst.Body.List {
if _, isReturn := stmt.(*ast.ReturnStmt); isReturn {
refuses = true
}
}
return true
})
if !refuses {
t.Fatal("actionStack does not RETURN when SetDesiredState fails — it would go on to stop or " +
"start an app whose intent could not be recorded (§8.2)")
}
}
// funcBody parses router.go and returns the named method's body.
func funcBody(t *testing.T, name string) *ast.BlockStmt {
t.Helper()
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "router.go", nil, 0)
if err != nil {
t.Fatalf("parse router.go: %v", err)
}
for _, decl := range f.Decls {
if fn, ok := decl.(*ast.FuncDecl); ok && fn.Name.Name == name && fn.Body != nil {
return fn.Body
}
}
t.Fatalf("func %s not found in router.go", name)
return nil
}
+38
View File
@@ -534,6 +534,24 @@ func (r *Router) startGatedByMissingDrive(name string) (bool, string) {
return false, ""
}
// desiredStateForAction maps a stack action to the customer intent it expresses, or (_, false) for
// an action that expresses none. Pure, so the §8.1/§1.3 mapping is testable without a Manager.
//
// `restart` and `update` both mean running: a customer who updates or restarts an app is asking for
// it to be up afterwards, and both end in `compose up -d`. Anything not listed here — an unknown
// action string — records nothing rather than guessing, so a future action cannot silently acquire
// an intent it was never meant to carry.
func desiredStateForAction(action string) (string, bool) {
switch action {
case "start", "restart", "update":
return stacks.DesiredStateRunning, true
case "stop":
return stacks.DesiredStateStopped, true
default:
return "", false
}
}
func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
r.logger.Printf("[INFO] [api] %s requested for stack: %s", action, name)
r.dbg("actionStack: action=%s name=%s", action, name)
@@ -579,6 +597,26 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
}
}
// R-166: THE CUSTOMER-INTENT POINT. This switch is where a human's decision about whether their
// app should be running enters the system, and until v0.189.0 that decision was recorded nowhere
// — so the box had to infer it from container counts, and inferred wrong for a power cut and for
// an interrupted backup alike.
//
// Written BEFORE the act (§8.2) and a failed write REFUSES the act: performing a stop whose
// intent could not be recorded would recreate exactly the ambiguity this closes. Both gates that
// can legitimately refuse an action (protected-stack, drive-absent, memory) have already run
// above, so nothing is recorded for an action that was never going to happen.
if desired, ok := desiredStateForAction(action); ok {
if derr := r.stackMgr.SetDesiredState(name, desired); derr != nil {
r.logger.Printf("[ERROR] [api] %s for %s refused: could not record desired state: %v", action, name, derr)
writeJSON(w, http.StatusInternalServerError, apiResponse{
OK: false,
Error: "A művelet nem hajtható végre: az alkalmazás beállításai nem menthetők.",
})
return
}
}
var err error
switch action {
case "start":