Files
felhom-controller/controller/cmd/controller/slice4_wiring_test.go
T
admin 2d63714eca R-645: the night backup skips an app whose pinned version is not the one it runs (09 decision 142)
Every night leg (DB dump, volume dump, recovery-unit capture, Tier-2 mirror) now leaves alone an app
whose app.yaml pin (pinned_images) differs from its running record (installed_images) - the state a
failed update leaves behind. A hold lifted by hand (--clear-restore-hold + restart) no longer lets the
capture write the just-failed definition over the good unit. Unknown (no pin, no record, a service not
observed) never skips. The log says it per leg; the backups page shows one amber line, hu + en
(backup.status.version_skip). Seam: backup.Manager.SetVersionCheck <- stacks.Manager.PinNotRunning.

Tests: TestR645_HandLiftedHoldKeepsTheGoodUnit (whole night run + Tier 2, unit tree fingerprint),
TestR645_VersionSkipSentence, TestR645_PinNotRunning_*, TestR645_BackupRowSaysTheNightBackupSkipsIt,
TestR645_VersionCheckIsWiredAtStartup.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 11:39:25 +02:00

123 lines
4.4 KiB
Go

package main
import (
"go/ast"
"go/parser"
"go/token"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// Update arc slice 4 — the seams are WIRED, in the order that makes them true. A seam built and never
// wired has shipped in this project seven times; each assertion here fails if a line moves or goes.
func slice4CallLines(t *testing.T) (map[string][]int, *ast.File, *token.FileSet) {
t.Helper()
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "main.go", nil, 0)
if err != nil {
t.Fatal(err)
}
lines := map[string][]int{}
ast.Inspect(f, func(n ast.Node) bool {
if call, ok := n.(*ast.CallExpr); ok {
if sel, ok := call.Fun.(*ast.SelectorExpr); ok {
lines[sel.Sel.Name] = append(lines[sel.Sel.Name], fset.Position(call.Pos()).Line)
} else if id, ok := call.Fun.(*ast.Ident); ok {
lines[id.Name] = append(lines[id.Name], fset.Position(call.Pos()).Line)
}
}
return true
})
return lines, f, fset
}
func TestSlice4_UpdateGuardsAreWiredAtStartup(t *testing.T) {
lines, _, _ := slice4CallLines(t)
if len(lines["SetUpdateGuards"]) == 0 {
t.Fatal("SetUpdateGuards is never called — every update would be refused as unwired")
}
if len(lines["RecoverUpdates"]) == 0 || len(lines["runBootReconcile"]) == 0 {
t.Fatal("RecoverUpdates or the boot reconciler is missing from main.go")
}
if lines["RecoverUpdates"][0] > lines["runBootReconcile"][0] {
t.Errorf("RecoverUpdates (line %d) must run BEFORE the boot reconciler (line %d), or the sweep can start a half-updated app",
lines["RecoverUpdates"][0], lines["runBootReconcile"][0])
}
if len(lines["ResumeInterruptedUpdates"]) == 0 || lines["ResumeInterruptedUpdates"][0] < lines["SetUpdateGuards"][0] {
t.Error("ResumeInterruptedUpdates must run AFTER SetUpdateGuards — a resumed update that fails must be able to HOLD")
}
}
func funcBodySource(t *testing.T, f *ast.File, fset *token.FileSet, recv, name string) string {
t.Helper()
for _, d := range f.Decls {
fn, ok := d.(*ast.FuncDecl)
if !ok || fn.Name.Name != name || fn.Body == nil {
continue
}
if recv != "" {
if fn.Recv == nil {
continue
}
id, ok := fn.Recv.List[0].Type.(*ast.Ident)
if !ok || id.Name != recv {
continue
}
}
var names []string
ast.Inspect(fn.Body, func(n ast.Node) bool {
if sel, ok := n.(*ast.SelectorExpr); ok {
names = append(names, sel.Sel.Name)
}
return true
})
return strings.Join(names, " ")
}
t.Fatalf("func %s.%s not found", recv, name)
return ""
}
func TestSlice4_BootSweepAndDeadAppAlarmKnowAboutUpdates(t *testing.T) {
_, f, fset := slice4CallLines(t)
if !strings.Contains(funcBodySource(t, f, fset, "bootDriveGate", "MayStart"), "IsUpdating") {
t.Error("the boot sweep must refuse an app a guarded update is moving")
}
if !strings.Contains(funcBodySource(t, f, fset, "", "scanDeployedAppRunStates"), "UpdatingStacks") {
t.Error("the dead-app alarm must not count an app the update itself is recreating")
}
}
// The shared start gate names WHICH hold it is honouring — an operator reading the boot log must not
// be told a restore failed when an update did.
func TestSlice4_DriveStartGate_NamesAnUpdateHold(t *testing.T) {
sett := holdTestSettings(t)
if err := sett.SetRestoreHold(settings.RestoreHold{Stack: "bookstack", At: "2026-09-13T08:00:00Z", Reason: settings.HoldReasonUpdateFailed}); err != nil {
t.Fatal(err)
}
ok, why := driveStartGate{sett: sett}.MayStart("bookstack")
if ok || !strings.Contains(why, "held after a failed update") {
t.Errorf("ok=%v why=%q", ok, why)
}
}
// v0.238.1: the backup manager must be told which apps are mid-update, or the nightly legs overwrite a
// restore point during an update's health wait (found live, Scenario F).
func TestSlice4_UpdatingCheckIsWiredAtStartup(t *testing.T) {
lines, _, _ := slice4CallLines(t)
if len(lines["SetUpdatingCheck"]) == 0 {
t.Fatal("backupMgr.SetUpdatingCheck is never called — the nightly legs cannot see an update in progress")
}
}
// R-645 (09 §3 decision 142): the backup manager must be told which apps are not running their pinned
// version, or the night backup writes a just-failed definition over the good unit after a hand-lifted hold.
func TestR645_VersionCheckIsWiredAtStartup(t *testing.T) {
lines, _, _ := slice4CallLines(t)
if len(lines["SetVersionCheck"]) == 0 {
t.Fatal("backupMgr.SetVersionCheck is never called — the night backup cannot see an app off its pin")
}
}