Files
felhom-controller/controller/cmd/controller/r379_hold_gate_test.go
T
admin 2c724c9283
gates / gates (push) Successful in 11s
R-379/R-380: put the customer's undo copy back when a database restore fails
R-379 and R-380 were one failure. Both ended with a half-restored database; the
only difference was whether it looked broken. Postgres emptied and crash-looped;
MariaDB applied part of the dump and reported health=healthy with a zero-row
schema-version table. Measured live on demo-hp 2026-08-22.

The undo copy was already taken and already good - proven by hand that day on
both engines. Nothing in the product could apply it. Now it does, with the same
ImportDump call, before any restart and inside the DB-only window.

The WHOLE undo set, matched on this run's stamp. writeSafetyDump returned one
path for an app with two databases; a rollback on that would restore one and
leave the other half-written.

When the rollback also fails the app is HELD STOPPED (operator ruling): a running
app on a half-written database lets the customer make the damage permanent. Every
start path refuses it - customer button, appstop Recover, boot sweep - via the
shared driveStartGate, checked ABOVE its driveless early return because these
apps have no drive. The marker is ended so nothing auto-restarts it. The row goes
red. Cleared with --clear-restore-hold, an operator CLI route.

--single-transaction is a belt on Postgres only; MariaDB DDL is not transactional
and that is why the rollback is the fix.

R-381: the engine's stderr stops reaching the customer (615 bytes on MariaDB, its
middle rows out of their own database) and starts reaching the operator log,
which never had it.
R-382: the summary log prints the volume count it already held.
Undo copies resolve to their own app, are marked IsUndo, and are capped at 3 per
app, pruned from the capture side. The reported render-as-an-app symptom did NOT
reproduce - the live page was read first and had zero occurrences.

Tests 1468 -> 1483. Eight red-proofs; ONE PASSED and is reported: the R-381
behavioural test injected below ImportDump. A guard at that layer now convicts.
2026-08-22 18:03:18 +02:00

144 lines
4.9 KiB
Go

package main
import (
"go/ast"
"go/parser"
"go/token"
"io"
"log"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-379 SCENARIO D — a held app, later. A hold that only one start path honours is not a hold, so
// each path is proven rather than asserted.
func holdTestSettings(t *testing.T) *settings.Settings {
t.Helper()
s, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
return s
}
// The SHARED gate refuses a held app — this is the path both the boot sweep (via bootDriveGate) and
// the app-stop guard's Recover() reach.
//
// POSITIVE CONTROL BUILT IN: the same app, same gate, is allowed once the hold is cleared. Without
// it "the gate refuses" could be true because the gate refuses everything.
func TestR379_DriveStartGate_RefusesAHeldApp_AndAllowsItAfterClearing(t *testing.T) {
sett := holdTestSettings(t)
g := driveStartGate{sett: sett} // nil mgr on purpose: the hold must be answered BEFORE the drive
// Before: no hold. The nil manager makes this "cannot determine", which is NOT the hold's reason.
if _, why := g.MayStart("docmost"); why == "" {
t.Fatal("fixture: expected some reason from the bare gate")
} else if strings.Contains(why, "held after a failed restore") {
t.Fatalf("no hold exists yet, but the gate cited one: %q", why)
}
if err := sett.SetRestoreHold(settings.RestoreHold{Stack: "docmost", At: "2026-08-22T14:00:00Z"}); err != nil {
t.Fatal(err)
}
ok, why := g.MayStart("docmost")
if ok {
t.Fatal("a held app must not be startable")
}
if !strings.Contains(why, "held after a failed restore") {
t.Errorf("the refusal must NAME the hold, got %q", why)
}
// POSITIVE CONTROL: clear it, and the gate stops citing the hold.
cleared, err := sett.ClearRestoreHold("docmost")
if err != nil || !cleared {
t.Fatalf("clearing the hold failed: cleared=%v err=%v", cleared, err)
}
if _, why := g.MayStart("docmost"); strings.Contains(why, "held after a failed restore") {
t.Errorf("the hold was cleared but the gate still cites it: %q", why)
}
}
// The hold is checked ABOVE the `HDD_PATH == ""` early return. The apps this exists for are
// DRIVELESS — a failed database restore is the case, and 40 of 53 catalogue apps have no drive — so
// a hold placed after that return would never be consulted for the exact class it was built for.
func TestR379_HoldIsCheckedBeforeTheDrivelessEarlyReturn(t *testing.T) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "main.go", nil, 0)
if err != nil {
t.Fatal(err)
}
var body *ast.BlockStmt
for _, d := range f.Decls {
fn, ok := d.(*ast.FuncDecl)
if ok && fn.Recv != nil && fn.Name.Name == "MayStart" && fn.Body != nil {
// driveStartGate is the receiver we want; bootDriveGate's MayStart is separate.
if st, ok := fn.Recv.List[0].Type.(*ast.Ident); ok && st.Name == "driveStartGate" {
body = fn.Body
}
}
}
if body == nil {
t.Fatal("driveStartGate.MayStart not found in main.go")
}
holdPos, drivelessPos := -1, -1
ast.Inspect(body, func(n ast.Node) bool {
if c, ok := n.(*ast.CallExpr); ok {
if sel, ok := c.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "GetRestoreHold" && holdPos < 0 {
holdPos = fset.Position(c.Pos()).Line
}
}
// the `hdd == ""` driveless early return
if b, ok := n.(*ast.BinaryExpr); ok && b.Op == token.EQL && drivelessPos < 0 {
if id, ok := b.X.(*ast.Ident); ok && id.Name == "hdd" {
drivelessPos = fset.Position(b.Pos()).Line
}
}
return true
})
if holdPos < 0 {
t.Fatal("driveStartGate.MayStart never consults GetRestoreHold — a held app would start from the boot sweep and from Recover()")
}
if drivelessPos < 0 {
t.Fatal("the driveless early return was not found — this test can no longer prove the ordering")
}
if holdPos > drivelessPos {
t.Fatalf("the hold check (line %d) is AFTER the driveless early return (line %d) — it would never fire for the 40 driveless apps this exists for", holdPos, drivelessPos)
}
}
// The production wiring: main() must hand the backup manager its hold notifier, or a held app is
// held silently and no operator ever hears. AST, not strings.Contains — a commented-out call still
// contains the string.
func TestR379_MainWiresTheRestoreHoldNotifier(t *testing.T) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "main.go", nil, 0)
if err != nil {
t.Fatal(err)
}
var found bool
for _, d := range f.Decls {
fn, ok := d.(*ast.FuncDecl)
if !ok || fn.Name.Name != "main" || fn.Body == nil {
continue
}
ast.Inspect(fn.Body, func(n ast.Node) bool {
c, ok := n.(*ast.CallExpr)
if !ok {
return true
}
if sel, ok := c.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "SetRestoreHoldNotify" {
found = true
return false
}
return true
})
}
if !found {
t.Fatal("main() never calls SetRestoreHoldNotify — an app would be held with nobody told")
}
}