2c724c9283
gates / gates (push) Successful in 11s
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.
144 lines
4.9 KiB
Go
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")
|
|
}
|
|
}
|