560 lines
21 KiB
Go
560 lines
21 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/holdreport"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/report"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
)
|
|
|
|
// D64 = B (operator ruling 2026-10-29 12:10) + R-1343: the operator clears a hold — from the hub (clear_hold) or with
|
|
// the box command — IN THE RUNNING CONTROLLER, so the hold is gone from memory and from settings.json together and no
|
|
// later save brings it back.
|
|
//
|
|
// RED-PROOFS (2026-10-29):
|
|
// - TestR1343_TheBoxCommandClearsThroughTheRunningController: with clearRestoreHoldCLI's socket call skipped (the
|
|
// old second-process edit of settings.json), it fails on „the hold came back after a later save of the running
|
|
// controller" — the R-1343 measurement, in a test;
|
|
// - TestD64_ClearPutBackPendingDropsTheMarkerEntry: with the dropFromMarker call removed from
|
|
// ClearHoldByOperator, it fails on „a later controller start poured the old copy back";
|
|
// - TestD64_ClearHoldViaTheHub: with the forgetUpdateHeld call removed from clearHoldNow, it fails on „the
|
|
// update's held flag was not forgotten".
|
|
// - TestD64_ClearHoldViaTheHub + TestD64_ClearHoldStaysGoneAfterALaterSave: with ClearHoldByOperator clearing a
|
|
// SECOND Settings loaded from disk (the old CLI's shape) instead of the running one, they fail on „the running
|
|
// controller still holds the app" / „the hold came back after a later save of the running controller".
|
|
|
|
// d64Rig is a running controller's hold side: its Settings, the app-stop guard wired to them, and a log.
|
|
type d64Rig struct {
|
|
dir string
|
|
sett *settings.Settings
|
|
guard *backup.AppStopGuard
|
|
logBuf *syncBuffer
|
|
logger *log.Logger
|
|
forgot []string
|
|
holdDeps holdClearDeps
|
|
}
|
|
|
|
type syncBuffer struct {
|
|
mu sync.Mutex
|
|
b bytes.Buffer
|
|
}
|
|
|
|
func (s *syncBuffer) Write(p []byte) (int, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.b.Write(p)
|
|
}
|
|
func (s *syncBuffer) String() string { s.mu.Lock(); defer s.mu.Unlock(); return s.b.String() }
|
|
|
|
func newD64Rig(t *testing.T) *d64Rig {
|
|
t.Helper()
|
|
r := &d64Rig{dir: t.TempDir(), logBuf: &syncBuffer{}}
|
|
r.logger = log.New(r.logBuf, "", 0)
|
|
sett, err := settings.Load(filepath.Join(r.dir, "settings.json"), r.logger)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r.sett = sett
|
|
r.guard = backup.NewAppStopGuard(filepath.Join(r.dir, "appstop-state.json"), r.logger)
|
|
r.guard.SetHolds(sett)
|
|
r.holdDeps = holdClearDeps{
|
|
clear: r.guard.ClearHoldByOperatorThen,
|
|
forgetUpdateHeld: func(s string) bool { r.forgot = append(r.forgot, s); return true },
|
|
logger: r.logger,
|
|
}
|
|
return r
|
|
}
|
|
|
|
func (r *d64Rig) hold(t *testing.T, h settings.RestoreHold) {
|
|
t.Helper()
|
|
if h.At == "" {
|
|
h.At = "2026-10-29T08:18:00Z"
|
|
}
|
|
if err := r.sett.SetRestoreHold(h); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func (r *d64Rig) onDisk(t *testing.T, stack string) bool {
|
|
t.Helper()
|
|
s, err := settings.Load(filepath.Join(r.dir, "settings.json"), log.New(io.Discard, "", 0))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, held := s.GetRestoreHold(stack)
|
|
return held
|
|
}
|
|
|
|
func (r *d64Rig) actions() *report.OperatorActions {
|
|
return report.NewOperatorActions(context.Background(), operatorActionHandlers(nil, nil, r.holdDeps, nil, r.logger), r.logger)
|
|
}
|
|
|
|
func onlyResult(t *testing.T, o *report.OperatorActions, id int64) report.OperatorActionResult {
|
|
t.Helper()
|
|
for _, x := range o.Results() {
|
|
if x.ID == id {
|
|
return x
|
|
}
|
|
}
|
|
t.Fatalf("no result for #%d: %+v", id, o.Results())
|
|
return report.OperatorActionResult{}
|
|
}
|
|
|
|
// The hub's clear_hold: the hold is gone (memory and disk), the household's held sentence is gone, the update's
|
|
// held flag is forgotten, the box logs who did it, and the result names what was cleared.
|
|
func TestD64_ClearHoldViaTheHub(t *testing.T) {
|
|
r := newD64Rig(t)
|
|
r.hold(t, settings.RestoreHold{Stack: "paperless", ReplayError: "ERROR: relation exists", RollbackErr: "psql: refused"})
|
|
cfg := &config.Config{}
|
|
cfg.Paths.DataDir = r.dir
|
|
mgr := backup.NewManager(cfg, r.sett, r.logger)
|
|
if held, why := mgr.RestoreHoldForLang("paperless", "hu"); !held || why == "" {
|
|
t.Fatal("fixture: the household must read a held sentence before the clear")
|
|
}
|
|
|
|
o := r.actions()
|
|
o.Reconcile([]report.OperatorAction{{ID: 12, Action: report.OpClearHold, Arg: "paperless"}})
|
|
res := onlyResult(t, o, 12)
|
|
if res.Outcome != report.OutcomeDone {
|
|
t.Fatalf("result %+v", res)
|
|
}
|
|
for _, want := range []string{"the hold on paperless is cleared", "held after a failed restore whose rollback also failed", "NOT started by this"} {
|
|
if !strings.Contains(res.Message, want) {
|
|
t.Errorf("result message %q lacks %q", res.Message, want)
|
|
}
|
|
}
|
|
if _, held := r.sett.GetRestoreHold("paperless"); held {
|
|
t.Fatal("the running controller still holds the app")
|
|
}
|
|
if r.onDisk(t, "paperless") {
|
|
t.Fatal("settings.json still holds the app")
|
|
}
|
|
if held, why := mgr.RestoreHoldForLang("paperless", "hu"); held || why != "" {
|
|
t.Fatalf("the household still reads a held sentence: %q", why)
|
|
}
|
|
if len(r.forgot) != 1 || r.forgot[0] != "paperless" {
|
|
t.Fatalf("the update's held flag was not forgotten: %v", r.forgot)
|
|
}
|
|
if lg := r.logBuf.String(); !strings.Contains(lg, "paperless: hold CLEARED by the operator via the hub, action #12") {
|
|
t.Fatalf("the box's log must say who cleared it:\n%s", lg)
|
|
}
|
|
}
|
|
|
|
// Refusals change nothing: an app nobody ever held, a second press on an app already cleared, a bad name.
|
|
func TestD64_ClearHoldRefusals(t *testing.T) {
|
|
r := newD64Rig(t)
|
|
r.hold(t, settings.RestoreHold{Stack: "kimai", Reason: settings.HoldReasonUpdateFailed})
|
|
o := r.actions()
|
|
o.Reconcile([]report.OperatorAction{{ID: 1, Action: report.OpClearHold, Arg: "nosuchapp"}})
|
|
if res := onlyResult(t, o, 1); res.Outcome != report.OutcomeRefused || !strings.Contains(res.Message, "nosuchapp has no hold") {
|
|
t.Fatalf("unknown app: %+v", res)
|
|
}
|
|
if _, held := r.sett.GetRestoreHold("kimai"); !held || !r.onDisk(t, "kimai") {
|
|
t.Fatal("a refusal changed another app's hold")
|
|
}
|
|
if len(r.forgot) != 0 {
|
|
t.Fatalf("a refusal touched the update state: %v", r.forgot)
|
|
}
|
|
o.Reconcile([]report.OperatorAction{{ID: 2, Action: report.OpClearHold, Arg: "kimai"}})
|
|
o.Reconcile([]report.OperatorAction{{ID: 3, Action: report.OpClearHold, Arg: "kimai"}})
|
|
if res := onlyResult(t, o, 3); res.Outcome != report.OutcomeRefused || !strings.Contains(res.Message, "kimai has no hold") {
|
|
t.Fatalf("not held (second press): %+v", res)
|
|
}
|
|
if _, err := clearHoldNow(r.holdDeps, "../kimai", "test"); err == nil || !strings.Contains(err.Error(), "not a valid app name") {
|
|
t.Fatalf("a bad name must be refused by the door itself too: %v", err)
|
|
}
|
|
}
|
|
|
|
// R-1343's order: clear, then ANOTHER component saves settings (the hold reporter, a hub report), then the file is
|
|
// read back — the hold must stay gone, in memory and on disk.
|
|
func TestD64_ClearHoldStaysGoneAfterALaterSave(t *testing.T) {
|
|
r := newD64Rig(t)
|
|
r.hold(t, settings.RestoreHold{Stack: "radicale", Reason: settings.HoldReasonRestoreMixed})
|
|
o := r.actions()
|
|
o.Reconcile([]report.OperatorAction{{ID: 5, Action: report.OpClearHold, Arg: "radicale"}})
|
|
if res := onlyResult(t, o, 5); res.Outcome != report.OutcomeDone {
|
|
t.Fatalf("result %+v", res)
|
|
}
|
|
if err := r.sett.SetReportedHolds(map[string]string{"other": "x|y"}); err != nil { // another component's save
|
|
t.Fatal(err)
|
|
}
|
|
if r.onDisk(t, "radicale") {
|
|
t.Fatal("the hold came back after a later save of the running controller")
|
|
}
|
|
if _, held := r.sett.GetRestoreHold("radicale"); held {
|
|
t.Fatal("the running controller holds the app again")
|
|
}
|
|
}
|
|
|
|
// The same order through the BOX COMMAND: it reaches the running controller over the socket; no restart.
|
|
func TestR1343_TheBoxCommandClearsThroughTheRunningController(t *testing.T) {
|
|
r := newD64Rig(t)
|
|
r.hold(t, settings.RestoreHold{Stack: "radicale", Reason: settings.HoldReasonRestoreMixed})
|
|
ln, err := listenHoldCtl(holdCtlPath(r.dir), func(stack, who string) (string, error) { return clearHoldNow(r.holdDeps, stack, who) }, r.logger)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer ln.Close()
|
|
|
|
var out, errb bytes.Buffer
|
|
if code := clearRestoreHoldCLI(r.dir, "radicale", &out, &errb); code != 0 {
|
|
t.Fatalf("exit %d, stdout %q, stderr %q", code, out.String(), errb.String())
|
|
}
|
|
if err := r.sett.SetReportedHolds(map[string]string{"other": "x|y"}); err != nil { // the running controller's next save
|
|
t.Fatal(err)
|
|
}
|
|
if r.onDisk(t, "radicale") {
|
|
t.Fatal("the hold came back after a later save of the running controller")
|
|
}
|
|
if !strings.Contains(out.String(), "Cleared by the RUNNING controller") || !strings.Contains(out.String(), "no restart is needed") {
|
|
t.Fatalf("the command must say it went through the running controller and needs no restart:\n%s", out.String())
|
|
}
|
|
if !strings.Contains(r.logBuf.String(), "hold CLEARED by the operator on the box (--clear-restore-hold)") {
|
|
t.Fatalf("the box's log must say who cleared it:\n%s", r.logBuf.String())
|
|
}
|
|
|
|
// A second run: refused through the socket, exit 2, nothing edited.
|
|
out.Reset()
|
|
errb.Reset()
|
|
if code := clearRestoreHoldCLI(r.dir, "radicale", &out, &errb); code != 2 || !strings.Contains(errb.String(), "radicale has no hold") {
|
|
t.Fatalf("not held: exit %d, stderr %q", code, errb.String())
|
|
}
|
|
}
|
|
|
|
// No controller runs (no socket, or a socket nobody listens on): the command edits settings.json and says so plainly,
|
|
// naming the guest restart for the case a controller runs after all.
|
|
func TestR1343_TheBoxCommandFallsBackWhenNoControllerRuns(t *testing.T) {
|
|
for _, stale := range []bool{false, true} {
|
|
r := newD64Rig(t)
|
|
r.hold(t, settings.RestoreHold{Stack: "kimai", Reason: settings.HoldReasonUpdateFailed})
|
|
if stale { // a socket left by a controller that died
|
|
ln, err := listenHoldCtl(holdCtlPath(r.dir), func(string, string) (string, error) { return "", nil }, r.logger)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ln.(*net.UnixListener).SetUnlinkOnClose(false)
|
|
ln.Close()
|
|
}
|
|
var out, errb bytes.Buffer
|
|
if code := clearRestoreHoldCLI(r.dir, "kimai", &out, &errb); code != 0 {
|
|
t.Fatalf("stale=%v: exit %d, stderr %q", stale, code, errb.String())
|
|
}
|
|
for _, want := range []string{"No running controller answered", "Editing settings.json directly", "INSIDE THE GUEST", "pct exec <vmid> -- systemctl restart felhom-controller-bootstrap.service"} {
|
|
if !strings.Contains(out.String(), want) {
|
|
t.Errorf("stale=%v: output lacks %q:\n%s", stale, want, out.String())
|
|
}
|
|
}
|
|
if r.onDisk(t, "kimai") {
|
|
t.Fatalf("stale=%v: settings.json still holds the app", stale)
|
|
}
|
|
out.Reset()
|
|
errb.Reset()
|
|
if code := clearRestoreHoldCLI(r.dir, "kimai", &out, &errb); code != 2 {
|
|
t.Fatalf("stale=%v: not held must exit 2, got %d", stale, code)
|
|
}
|
|
if code := clearRestoreHoldCLI(r.dir, "../x", &out, &errb); code != 2 {
|
|
t.Fatalf("stale=%v: a bad name must exit 2, got %d", stale, code)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A put-back WAITING for its drive is also in the app-stop marker: clearing the hold must drop it there too, or the
|
|
// next controller start with the drive bound would pour the old copy over what the app wrote after the clear. The
|
|
// copy itself stays on disk (nothing is deleted).
|
|
func TestD64_ClearPutBackPendingDropsTheMarkerEntry(t *testing.T) {
|
|
r := newD64Rig(t)
|
|
undo := filepath.Join(r.dir, "drive", "felhom-data", "backups", "restore-undo", "immich", "20261029T080000Z")
|
|
if err := os.MkdirAll(undo, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(undo, "undo.json"), []byte(`{"stack":"immich"}`), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := r.guard.Begin("unit-restore:immich", backup.ReasonUnitRestore, []string{"immich"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := r.guard.SetUndo("immich", undo); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r.hold(t, settings.RestoreHold{Stack: "immich", Reason: settings.HoldReasonPutBackPending, SafetyDump: undo})
|
|
|
|
o := r.actions()
|
|
o.Reconcile([]report.OperatorAction{{ID: 3, Action: report.OpClearHold, Arg: "immich"}})
|
|
res := onlyResult(t, o, 3)
|
|
if res.Outcome != report.OutcomeDone || !strings.Contains(res.Message, "waiting put-back is dropped") || !strings.Contains(res.Message, undo) {
|
|
t.Fatalf("result %+v", res)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(undo, "undo.json")); err != nil {
|
|
t.Fatal("the copy must stay on disk")
|
|
}
|
|
// The consequence: a later controller start with the drive bound pours nothing back.
|
|
poured := &recordingPutBacker{}
|
|
g2 := backup.NewAppStopGuard(filepath.Join(r.dir, "appstop-state.json"), r.logger)
|
|
g2.SetHolds(r.sett)
|
|
g2.SetPutBacker(poured)
|
|
g2.SetStarter(nopStarter{})
|
|
g2.SetUndoReachable(func(string, string) bool { return true })
|
|
g2.Recover()
|
|
if len(poured.stacks) != 0 {
|
|
t.Fatalf("a later controller start poured the old copy back over the app: %v", poured.stacks)
|
|
}
|
|
}
|
|
|
|
type recordingPutBacker struct{ stacks []string }
|
|
|
|
func (p *recordingPutBacker) PutBackInterruptedRestore(stack, _ string) error {
|
|
p.stacks = append(p.stacks, stack)
|
|
return nil
|
|
}
|
|
|
|
type nopStarter struct{}
|
|
|
|
func (nopStarter) StartStack(string) error { return nil }
|
|
|
|
// The hold reporter's next tick tells the hub the hold is gone (it reads the same settings record; nothing cached).
|
|
func TestD64_TheHoldReporterTellsTheHubAfterTheClear(t *testing.T) {
|
|
r := newD64Rig(t)
|
|
r.hold(t, settings.RestoreHold{Stack: "kimai", Reason: settings.HoldReasonUpdateFailed})
|
|
var events []string
|
|
rep := holdreport.New(r.sett, func(et, _, _ string, _ interface{}) { events = append(events, et) }, r.logger)
|
|
rep.Tick(timeAt(0))
|
|
o := r.actions()
|
|
o.Reconcile([]report.OperatorAction{{ID: 1, Action: report.OpClearHold, Arg: "kimai"}})
|
|
rep.Tick(timeAt(60))
|
|
if strings.Join(events, ",") != holdreport.EventHeld+","+holdreport.EventCleared {
|
|
t.Fatalf("events %v — want app_held then app_hold_cleared", events)
|
|
}
|
|
}
|
|
|
|
// „Not reachable from outside": the channel is a unix socket (no TCP anywhere in the file), 0600 in a 0700 directory
|
|
// — even when the directory existed with a looser mode — and main opens it once, under the data dir.
|
|
func TestHoldCtl_NotReachableFromOutside(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := os.MkdirAll(filepath.Join(dir, holdCtlDir), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ln, err := listenHoldCtl(holdCtlPath(dir), func(string, string) (string, error) { return "", nil }, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer ln.Close()
|
|
if n := ln.Addr().Network(); n != "unix" {
|
|
t.Fatalf("the channel listens on %q, want unix", n)
|
|
}
|
|
fi, err := os.Lstat(holdCtlPath(dir))
|
|
if err != nil || fi.Mode()&os.ModeSocket == 0 || fi.Mode().Perm() != 0o600 {
|
|
t.Fatalf("socket mode %v (err %v), want a socket with 0600", fi.Mode(), err)
|
|
}
|
|
di, _ := os.Stat(filepath.Join(dir, holdCtlDir))
|
|
if di.Mode().Perm() != 0o700 {
|
|
t.Fatalf("socket dir mode %v, want 0700", di.Mode().Perm())
|
|
}
|
|
|
|
// No TCP and no HTTP in the channel's file: every net.Listen/Dial names "unix".
|
|
f, err := parser.ParseFile(token.NewFileSet(), "holdclear.go", nil, parser.ImportsOnly)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, im := range f.Imports {
|
|
if p, _ := strconv.Unquote(im.Path.Value); strings.HasPrefix(p, "net/http") {
|
|
t.Fatalf("holdclear.go imports %s — the channel must not be HTTP", p)
|
|
}
|
|
}
|
|
full, err := parser.ParseFile(token.NewFileSet(), "holdclear.go", nil, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
nets := 0
|
|
ast.Inspect(full, func(n ast.Node) bool {
|
|
call, ok := n.(*ast.CallExpr)
|
|
if !ok {
|
|
return true
|
|
}
|
|
sel, ok := call.Fun.(*ast.SelectorExpr)
|
|
if !ok {
|
|
return true
|
|
}
|
|
if x, ok := sel.X.(*ast.Ident); ok && x.Name == "net" && len(call.Args) > 0 {
|
|
nets++
|
|
if lit, ok := call.Args[0].(*ast.BasicLit); !ok || lit.Value != `"unix"` {
|
|
t.Errorf("net.%s is called with a network other than \"unix\"", sel.Sel.Name)
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
if nets < 2 {
|
|
t.Fatalf("expected the listen and the dial in holdclear.go, found %d net calls", nets)
|
|
}
|
|
|
|
// main opens it exactly once, at holdCtlPath(cfg.Paths.DataDir).
|
|
mf, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
opened := 0
|
|
ast.Inspect(mf, func(n ast.Node) bool {
|
|
call, ok := n.(*ast.CallExpr)
|
|
if !ok {
|
|
return true
|
|
}
|
|
if id, ok := call.Fun.(*ast.Ident); ok && id.Name == "listenHoldCtl" {
|
|
opened++
|
|
arg, ok := call.Args[0].(*ast.CallExpr)
|
|
if fn, ok2 := arg.Fun.(*ast.Ident); !ok || !ok2 || fn.Name != "holdCtlPath" {
|
|
t.Error("main must open the channel at holdCtlPath(<data dir>)")
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
if opened != 1 {
|
|
t.Fatalf("main.go opens the channel %d time(s), want 1", opened)
|
|
}
|
|
}
|
|
|
|
// The socket answers only its one request; anything else is refused and calls nothing.
|
|
func TestHoldCtl_UnknownRequestIsRefused(t *testing.T) {
|
|
dir := t.TempDir()
|
|
called := 0
|
|
ln, err := listenHoldCtl(holdCtlPath(dir), func(string, string) (string, error) { called++; return "x", nil }, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer ln.Close()
|
|
conn, err := net.Dial("unix", holdCtlPath(dir))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer conn.Close()
|
|
if _, err := conn.Write([]byte(`{"op":"set_hold","stack":"x"}` + "\n")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var resp holdCtlResponse
|
|
if err := json.NewDecoder(conn).Decode(&resp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp.Outcome != report.OutcomeRefused || called != 0 {
|
|
t.Fatalf("response %+v, calls %d — want refused and nothing called", resp, called)
|
|
}
|
|
}
|
|
|
|
// Something at the socket's path that is NOT a socket is refused, never removed.
|
|
func TestHoldCtl_ANonSocketFileIsNotReplaced(t *testing.T) {
|
|
dir := t.TempDir()
|
|
p := holdCtlPath(dir)
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(p, []byte("keep me"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := listenHoldCtl(p, func(string, string) (string, error) { return "", nil }, nil); err == nil {
|
|
t.Fatal("a regular file at the socket path must be refused")
|
|
}
|
|
if b, _ := os.ReadFile(p); string(b) != "keep me" {
|
|
t.Fatal("the file at the socket path was changed")
|
|
}
|
|
}
|
|
|
|
// main wires every piece once (the seam-built-but-never-wired trap): the clear door into the operator actions, the
|
|
// R-1340 text into the hold notification, the R-1341 gate into the OOM loop, and the box command.
|
|
func TestD64_MainWiresTheNewPieces(t *testing.T) {
|
|
f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
found := map[string]int{}
|
|
var opArgs []ast.Expr
|
|
ast.Inspect(f, func(n ast.Node) bool {
|
|
call, ok := n.(*ast.CallExpr)
|
|
if !ok {
|
|
return true
|
|
}
|
|
switch fn := call.Fun.(type) {
|
|
case *ast.SelectorExpr:
|
|
if x, ok := fn.X.(*ast.Ident); ok {
|
|
found[x.Name+"."+fn.Sel.Name]++
|
|
}
|
|
case *ast.Ident:
|
|
found[fn.Name]++
|
|
if fn.Name == "operatorActionHandlers" {
|
|
opArgs = call.Args
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
for _, want := range []string{"heldStoppedOperatorText", "heldHoldFromNotify", "oomLog.fresh", "clearRestoreHoldCLI", "listenHoldCtl", "clearHoldNow"} {
|
|
if found[want] != 1 {
|
|
t.Errorf("main.go calls %s %d time(s), want exactly 1", want, found[want])
|
|
}
|
|
}
|
|
if len(opArgs) < 3 {
|
|
t.Fatal("operatorActionHandlers call not found")
|
|
}
|
|
if id, ok := opArgs[2].(*ast.Ident); !ok || id.Name != "holdDeps" {
|
|
t.Error("main must hand holdDeps (the running controller's clear door) to operatorActionHandlers")
|
|
}
|
|
}
|
|
|
|
func timeAt(sec int) time.Time { return time.Date(2026, 10, 29, 12, 0, sec, 0, time.UTC) }
|
|
|
|
// No controller runs: the box command's own clear drops a waiting put-back from the app-stop marker too (the same
|
|
// door), so the next start with the drive bound pours nothing back.
|
|
// RED-PROOF (2026-10-29): with the fallback clearing through settings.ClearRestoreHold alone (its first shape in
|
|
// 5b3b541), this fails on „a later controller start poured the old copy back".
|
|
func TestR1343_TheFallbackDropsAWaitingPutBackToo(t *testing.T) {
|
|
r := newD64Rig(t)
|
|
undo := filepath.Join(r.dir, "drive", "felhom-data", "backups", "restore-undo", "immich", "20261029T080000Z")
|
|
if err := os.MkdirAll(undo, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := r.guard.Begin("unit-restore:immich", backup.ReasonUnitRestore, []string{"immich"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := r.guard.SetUndo("immich", undo); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r.hold(t, settings.RestoreHold{Stack: "immich", Reason: settings.HoldReasonPutBackPending, SafetyDump: undo})
|
|
var out, errb bytes.Buffer
|
|
if code := clearRestoreHoldCLI(r.dir, "immich", &out, &errb); code != 0 {
|
|
t.Fatalf("exit %d, stderr %q", code, errb.String())
|
|
}
|
|
if !strings.Contains(out.String(), "waiting put-back is dropped") {
|
|
t.Errorf("output must say the put-back is dropped:\n%s", out.String())
|
|
}
|
|
sett2, err := settings.Load(filepath.Join(r.dir, "settings.json"), log.New(io.Discard, "", 0))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
poured := &recordingPutBacker{}
|
|
g2 := backup.NewAppStopGuard(filepath.Join(r.dir, "appstop-state.json"), r.logger)
|
|
g2.SetHolds(sett2)
|
|
g2.SetPutBacker(poured)
|
|
g2.SetStarter(nopStarter{})
|
|
g2.SetUndoReachable(func(string, string) bool { return true })
|
|
g2.Recover()
|
|
if len(poured.stacks) != 0 {
|
|
t.Fatalf("a later controller start poured the old copy back over the app: %v", poured.stacks)
|
|
}
|
|
if _, err := os.Stat(undo); err != nil {
|
|
t.Fatal("the copy must stay on disk")
|
|
}
|
|
}
|