v0.189.0 — desired state + the app-stop crash marker (R-166 / D-b)
gates / gates (push) Successful in 8s
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:
@@ -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
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user