package backup import ( "context" "log" "os" "path/filepath" "strings" "testing" "time" ) // R-645 (09 §3 decision 142) — the night backup skips an app whose pinned version is not the version // it runs. // // THE HAND-LIFT SHAPE, measured 2026-09-23 on 9202: docmost was HELD after a failed 0.95.0 → 0.96.0 // update; `--clear-restore-hold docmost` + the restart ran, and three seconds later the capture wrote // the 0.96.0 definition into the unit the hold sentence had named. This drives the whole night run // (DB leg, volume leg, capture) and then Tier 2 over that state — the hold is gone, the stack dir names // the failed version, the pin says 0.96.0, the running record says 0.95.0 — and asserts the // CONSEQUENCE: the good unit's tree is byte-identical afterwards, and its compose still names 0.95.0. // // COMPANION RED-PROOF: make versionSkip return ("", false) — the unit's compose/docker-compose.yml then // names docmost/docmost:0.96.0 and the tree fingerprint differs, and this test fails. func TestR645_HandLiftedHoldKeepsTheGoodUnit(t *testing.T) { h := newAdmissionHarness(t, "docmost", "free") h.m.settings = slice4Settings(t) ns := h.nsRoot() // The good unit: written by an earlier backup of 0.95.0. Its manifest carries no data stamps (a unit // from before v0.275.0), so nothing else in the capture freezes its definition — this skip is the // only thing standing between the failed definition and the restore point. h.seedUnit(t, "docmost", 0) goodCompose := "services:\n docmost:\n image: docmost/docmost:0.95.0\n" if err := os.WriteFile(filepath.Join(RecoveryUnitComposePath(ns, "docmost"), "docker-compose.yml"), []byte(goodCompose), 0o644); err != nil { t.Fatal(err) } // The stack dir after the failed update: it names the version that just failed. for _, app := range []string{"docmost", "free"} { dir := filepath.Join(h.dir, "stacks", app) if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } img := "docmost/docmost:0.96.0" if app == "free" { img = "free/free:1.0" } if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte("services:\n "+app+":\n image: "+img+"\n"), 0o644); err != nil { t.Fatal(err) } } // The hold existed and was lifted by hand — exactly the operator CLI's act. if err := h.m.HoldAfterFailedUpdate("docmost", time.Now(), time.Now(), UpdateTierLocal); err != nil { t.Fatal(err) } if _, err := h.m.settings.ClearRestoreHold("docmost"); err != nil { t.Fatal(err) } if held, _ := h.m.RestoreHoldFor("docmost"); held { t.Fatal("setup: the hold must be lifted") } // stacks.Manager.PinNotRunning's answer for this shape (pinned by TestR645_PinNotRunning_* there). h.m.SetVersionCheck(func(name string) (string, bool) { if name == "docmost" { return "docmost runs docmost/docmost:0.95.0, pinned docmost/docmost:0.96.0", true } return "", false }) var dumped []string h.m.discoverDBs = func(context.Context) ([]DiscoveredDB, error) { return []DiscoveredDB{ {StackName: "docmost", ContainerName: "docmost-postgres"}, {StackName: "free", ContainerName: "free-postgres"}, }, nil } h.m.dumpOne = func(_ context.Context, db DiscoveredDB, dumpDir string, _ *log.Logger, _ bool) DumpResult { dumped = append(dumped, db.StackName) _ = os.MkdirAll(dumpDir, 0o755) p := filepath.Join(dumpDir, db.StackName+"-postgres.sql") _ = os.WriteFile(p, []byte("-- FRESH DUMP OF THE FAILED VERSION\n"), 0o644) return DumpResult{DB: db, FilePath: p, Size: 36} } unitRoot := RecoveryUnitPath(ns, "docmost") before := treeFingerprint(t, unitRoot) _ = h.m.runDBDumpsInternal(context.Background()) var mirrored []string h.m.perAppTier2 = func(name string) error { mirrored = append(mirrored, name); return nil } h.m.RunAllTier2() if after := treeFingerprint(t, unitRoot); after != before { t.Errorf("the good unit was rewritten by the night run after a hand-lifted hold:\nbefore:\n%s\nafter:\n%s", before, after) } b, _ := os.ReadFile(filepath.Join(RecoveryUnitComposePath(ns, "docmost"), "docker-compose.yml")) if !strings.Contains(string(b), "docmost/docmost:0.95.0") { t.Errorf("the unit's definition must still be the good 0.95.0 one, got:\n%s", b) } for _, list := range [][]string{dumped, h.volDumped, h.prov.stopped, mirrored} { for _, n := range list { if n == "docmost" { t.Errorf("a night leg touched docmost (db=%v vol=%v stopped=%v mirrored=%v)", dumped, h.volDumped, h.prov.stopped, mirrored) } } } // It says so in the log — the operator's half of the ruling. if !strings.Contains(h.logs.String(), "Recovery unit NOT captured for docmost — it is not running its pinned version") { t.Errorf("the skip must be logged, log:\n%s", h.logs.String()) } // Positive control: the app that runs its pin is backed up as before. if len(dumped) != 1 || dumped[0] != "free" || len(h.volDumped) != 1 || h.volDumped[0] != "free" || len(mirrored) != 1 || mirrored[0] != "free" { t.Errorf("positive control: the other app must still be dumped and mirrored (db=%v vol=%v mirrored=%v)", dumped, h.volDumped, mirrored) } if _, err := os.Stat(RecoveryUnitManifestPath(ns, "free")); err != nil { t.Errorf("positive control: the other app's unit must be captured: %v", err) } } // The page's half: the household reads one plain sentence, in their language; nothing when the app // runs its pin or when nothing is wired (unknown never skips). func TestR645_VersionSkipSentence(t *testing.T) { m := &Manager{} if skip, why := m.VersionSkipFor("docmost", "hu"); skip || why != "" { t.Fatalf("no check wired must read as no skip, got (%v, %q)", skip, why) } m.SetVersionCheck(func(name string) (string, bool) { return "x", name == "docmost" }) skip, hu := m.VersionSkipFor("docmost", "hu") if !skip || !strings.Contains(hu, "docmost") || !strings.Contains(hu, "jszakai ment") || !strings.Contains(hu, "Vedd fel") { t.Errorf("hu sentence = %q", hu) } _, en := m.VersionSkipFor("docmost", "en") if !strings.Contains(en, "night backup leaves it out") || strings.Contains(en, "jszakai") { t.Errorf("en sentence = %q", en) } if skip, _ := m.VersionSkipFor("free", "hu"); skip { t.Error("an app running its pin must not read as skipped") } }