2d63714eca
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
123 lines
4.4 KiB
Go
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")
|
|
}
|
|
}
|