dbcb306fcf
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.
150 lines
5.2 KiB
Go
150 lines
5.2 KiB
Go
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
|
|
}
|