textmachine/platform/internal/backup/backup_test.go

1056 lines
43 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package backup
import (
"bytes"
"context"
"encoding/json"
"errors"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"textmachine/platform/internal/pgstore"
"textmachine/platform/internal/runner"
)
// fakeEngine stands in for `tmctl backup`: it writes a file where the engine would and reports it,
// or refuses the way the engine refuses a book with no database.
type fakeEngine struct {
refuse bool
fail error
calls int
content string
// sealAfterWrite makes the directory the copy was written into read-only, so the sweep's own
// removal of that copy fails. It is the only way to reach the WARN that removal writes.
sealAfterWrite bool
}
func (f *fakeEngine) Backup(_ context.Context, _, workdir string) (runner.BackupOutcome, error) {
f.calls++
if f.fail != nil {
return runner.BackupOutcome{}, f.fail
}
if f.refuse {
return runner.BackupOutcome{Exited: true, ExitCode: 1,
Stderr: "tmctl backup: project database does not exist yet"}, nil
}
dir := filepath.Join(workdir, "backups")
if err := os.MkdirAll(dir, 0o750); err != nil {
return runner.BackupOutcome{}, err
}
path := filepath.Join(dir, "snapshot.db")
body := f.content
if body == "" {
body = "CONSISTENT-COPY"
}
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
return runner.BackupOutcome{}, err
}
if f.sealAfterWrite {
if err := os.Chmod(dir, 0o500); err != nil {
return runner.BackupOutcome{}, err
}
}
return runner.BackupOutcome{Exited: true, ExitCode: 0, Path: path}, nil
}
type fakeStore struct {
books []pgstore.BookDirectory
err error
}
func (f *fakeStore) BooksForBackup(context.Context) ([]pgstore.BookDirectory, error) {
return f.books, f.err
}
// fakePg writes a file where pg_dump would and exits 0, or fails. Written as a shell script because
// the code under test SPAWNS the tool: a Go double would prove the wiring this platform does not have.
func fakePg(t *testing.T, dir, name string, fail bool) string {
t.Helper()
sh, err := exec.LookPath("sh")
if err != nil {
t.Skip("sh not on PATH: the pg_dump double cannot be built")
}
path := filepath.Join(dir, name)
script := "#!" + sh + "\n"
if fail {
script += "echo 'pg_dump: error: connection failed' >&2\nexit 1\n"
} else {
// --file=<path> is the argument the real tool takes and the one this code passes.
script += "for a in \"$@\"; do case \"$a\" in --file=*) printf 'PGDUMP' > \"${a#--file=}\";; esac; done\nexit 0\n"
}
if err := os.WriteFile(path, []byte(script), 0o700); err != nil {
t.Fatal(err)
}
return path
}
// fakeRefusing stands in for a `pg_restore --list` that cannot read the archive — a truncated dump,
// a version mismatch that wrote a header and stopped.
func fakeRefusing(t *testing.T, dir, name string) string {
t.Helper()
sh, err := exec.LookPath("sh")
if err != nil {
t.Skip("sh not on PATH")
}
path := filepath.Join(dir, name)
body := "#!" + sh + "\necho 'pg_restore: error: did not find magic string in file header' >&2\nexit 1\n"
if err := os.WriteFile(path, []byte(body), 0o700); err != nil {
t.Fatal(err)
}
return path
}
func fakeOK(t *testing.T, dir, name string) string {
t.Helper()
sh, err := exec.LookPath("sh")
if err != nil {
t.Skip("sh not on PATH")
}
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte("#!"+sh+"\nexit 0\n"), 0o700); err != nil {
t.Fatal(err)
}
return path
}
// aBook makes a book directory shaped like a real one: a configuration, a source, a LIVE database
// with its write-ahead sidecar, a sidecar artefact, and the engine's own `backups/` subdirectory.
func aBook(t *testing.T, root, id string) pgstore.BookDirectory {
t.Helper()
dir := filepath.Join(root, id)
if err := os.MkdirAll(filepath.Join(dir, "backups"), 0o750); err != nil {
t.Fatal(err)
}
for name, body := range map[string]string{
"book.yaml": "book_id: " + id + "\ntitle: t\n",
"source.txt": "第1章\n",
id + ".db": "LIVE-AND-POSSIBLY-TORN",
id + ".db-wal": "WAL",
id + ".db.manifest.json": `{"manifest_version":"tm-manifest-v2"}`,
"events.jsonl": "{}\n",
"backups/older-engine-point.db": "AN OLDER ENGINE RESTORE POINT",
} {
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
return pgstore.BookDirectory{ID: id, Workdir: dir, Status: "not_started"}
}
func service(t *testing.T, books []pgstore.BookDirectory, eng *fakeEngine, dumpFails bool) (*Service, string) {
t.Helper()
tools := t.TempDir()
dest := t.TempDir()
return &Service{
Cfg: Config{
Dir: dest, Every: time.Hour, Keep: 3,
PgDumpBin: fakePg(t, tools, "pg_dump", dumpFails),
PgRestoreBin: fakeOK(t, tools, "pg_restore"),
DSN: "postgres://u:pw@127.0.0.1:5432/db?sslmode=disable",
EngineBinary: "/opt/engine/tmctl",
},
Store: &fakeStore{books: books}, Engine: eng,
}, dest
}
// The whole shape of a restore point in one pass: the money registry, every book's files, the
// engine's consistent copy standing in for the live database, and a manifest that can find each one.
//
// Mutation caught: skipping the engine call (the live, possibly torn file becomes the backup);
// copying the live database anyway (a restore has two candidates and no way to choose); leaving the
// engine's own `backups/` in (every pass copies every previous pass); publishing without the manifest
// (nothing can be verified afterwards).
func TestARestorePointCarriesTheLedgerAndEachBooksConsistentDatabase(t *testing.T) {
root := t.TempDir()
books := []pgstore.BookDirectory{aBook(t, root, "bk_one"), aBook(t, root, "bk_two")}
eng := &fakeEngine{}
svc, dest := service(t, books, eng, false)
res, err := svc.Take(t.Context())
if err != nil {
t.Fatalf("Take: %v", err)
}
if res.Books != 2 || res.Skipped != 0 || !res.Complete {
t.Fatalf("result does not describe two copied books: %+v", res)
}
if eng.calls != 2 {
t.Errorf("the engine was asked for %d consistent copies, want one per book", eng.calls)
}
m, err := ReadManifest(res.Path)
if err != nil {
t.Fatal(err)
}
if m.Version != ManifestVersion || !m.Complete || len(m.Books) != 2 {
t.Fatalf("manifest: %+v", m)
}
if m.Postgres.Name != DumpFile || m.Postgres.Bytes == 0 {
t.Errorf("the money registry is not in the point: %+v", m.Postgres)
}
for _, b := range m.Books {
names := map[string]bool{}
for _, f := range b.Files {
names[f.Name] = true
}
db := filepath.ToSlash(filepath.Join(BooksDir, b.BookID, ProjectDBName))
if !names[db] {
t.Errorf("%s: the consistent database is missing: %v", b.BookID, names)
}
body, err := os.ReadFile(filepath.Join(res.Path, filepath.FromSlash(db)))
if err != nil || string(body) != "CONSISTENT-COPY" {
// The point of the whole mechanism: what is stored is the engine's snapshot, never the
// file a run may be writing.
t.Errorf("%s: the stored database is not the engine's copy: %q, %v", b.BookID, body, err)
}
for _, want := range []string{"book.yaml", "source.txt", b.BookID + ".db.manifest.json", "events.jsonl"} {
if !names[filepath.ToSlash(filepath.Join(BooksDir, b.BookID, want))] {
t.Errorf("%s: %s did not make it into the point: %v", b.BookID, want, names)
}
}
for _, unwanted := range []string{b.BookID + ".db", b.BookID + ".db-wal", "backups/older-engine-point.db"} {
if names[filepath.ToSlash(filepath.Join(BooksDir, b.BookID, unwanted))] {
t.Errorf("%s: %s should not be in a restore point: %v", b.BookID, unwanted, names)
}
}
}
// The engine's copy is taken AWAY, not left behind: otherwise every pass grows each book's own
// directory by a whole database.
for _, b := range books {
if _, err := os.Stat(filepath.Join(b.Workdir, "backups", "snapshot.db")); !errors.Is(err, os.ErrNotExist) {
t.Errorf("%s: the engine's copy was left in the book's directory (%v)", b.ID, err)
}
}
if points, _ := List(dest); len(points) != 1 {
t.Errorf("published points: %v", points)
}
}
// The money registry has no partial outcome: a point without it answers nothing about who paid what,
// so a failed dump publishes NOTHING and leaves no staging directory behind either.
//
// Mutation caught: publishing anyway (a restore point that looks complete and carries no ledger);
// returning early without the cleanup (a full copy of the deployment accumulates per failure).
func TestAFailedLedgerDumpPublishesNothingAndLeavesNothing(t *testing.T) {
root := t.TempDir()
svc, dest := service(t, []pgstore.BookDirectory{aBook(t, root, "bk_one")}, &fakeEngine{}, true)
if _, err := svc.Take(t.Context()); err == nil {
t.Fatal("a failed pg_dump was reported as a restore point")
}
points, err := List(dest)
if err != nil || len(points) != 0 {
t.Errorf("a point was published without the ledger: %v (%v)", points, err)
}
entries, err := os.ReadDir(dest)
if err != nil {
t.Fatal(err)
}
if len(entries) != 0 {
t.Errorf("the staging directory was left behind: %v", entries)
}
}
// A book that cannot be copied does NOT sink the pass — nine books saved beat none — but the point
// says so about itself, and Verify repeats it. The alternative is the dangerous one: a point that
// silently holds less than it claims.
func TestABookThatCannotBeCopiedMarksThePointIncompleteRatherThanFailingIt(t *testing.T) {
root := t.TempDir()
ok := aBook(t, root, "bk_ok")
// ⚠ A MISSING DIRECTORY IS TWO FACTS, AND THIS TEST ASSERTED ONLY THE HARMLESS ONE UNTIL 05.09.
// Its earlier edition treated ANY absent directory as an ordinary skip that leaves the point
// complete — reasoning that a rejected upload takes its directory with it. True for a rejected
// book; false for every other status, and the false half is the dangerous one: an unmounted books
// volume, or a books tree moved while the absolute workdirs in Postgres stayed put, makes EVERY
// book an "ordinary skip" and publishes a point holding only the ledger, marked complete, with the
// age gauge green. Retention then evicts the last point that held any paid work on schedule.
// The assertion below is the corrected one and is strictly stronger.
gone := pgstore.BookDirectory{ID: "bk_gone", Workdir: filepath.Join(root, "not_here"), Status: "not_started"}
svc, _ := service(t, []pgstore.BookDirectory{ok, gone}, &fakeEngine{}, false)
res, err := svc.Take(t.Context())
if err != nil {
t.Fatalf("one absent book ended the whole pass: %v", err)
}
if res.Books != 1 || res.Skipped != 1 {
t.Fatalf("counts: %+v", res)
}
m, err := ReadManifest(res.Path)
if err != nil {
t.Fatal(err)
}
if m.Complete {
t.Errorf("a book whose status says its directory should be there went missing, and the point still calls itself complete: %+v", m)
}
var skipped string
for _, b := range m.Books {
if b.BookID == "bk_gone" {
skipped = b.Skipped
}
}
if skipped == "" || !strings.Contains(skipped, "not_started") {
t.Errorf("the hole does not name the status that makes it one: %q", skipped)
}
// The other half: a REJECTED book legitimately has no directory — its reject path removed it with
// the source — and that must NOT make every point of every deployment read incomplete, which is
// how the flag stops meaning anything.
rejected := pgstore.BookDirectory{ID: "bk_rejected", Workdir: filepath.Join(root, "also_not_here"), Status: "rejected"}
svcR, _ := service(t, []pgstore.BookDirectory{aBook(t, root, "bk_fine"), rejected}, &fakeEngine{}, false)
resR, err := svcR.Take(t.Context())
if err != nil {
t.Fatal(err)
}
if !resR.Complete || resR.Skipped != 1 {
t.Errorf("a rejected book with no directory was treated as a hole: %+v", resR)
}
// An engine that FAILS is different: something is wrong and the point is not what it claims.
svc2, _ := service(t, []pgstore.BookDirectory{aBook(t, root, "bk_two")}, &fakeEngine{fail: errors.New("no such binary")}, false)
res2, err := svc2.Take(t.Context())
if err != nil {
t.Fatalf("a failing engine ended the whole pass: %v", err)
}
if res2.Complete {
t.Error("a book the engine could not copy left the point marked complete")
}
problems, err := Verify(res2.Path)
if err != nil {
t.Fatal(err)
}
if len(problems) == 0 || !strings.Contains(strings.Join(problems, " "), "INCOMPLETE") {
t.Errorf("Verify does not repeat the point's own verdict: %v", problems)
}
}
// ⛔ THE WORST OBJECT THIS PACKAGE CAN PRODUCE: a point that says `complete: true` over a hole. It was
// REAL — measured 05.09 by running two `tmplatformctl backup` at once against a live stand — and it
// came from treating every non-zero engine exit as one ordinary "skip".
//
// The engine refuses to overwrite a restore point it already made under the same timestamp
// (backend/internal/store/backup.go: "refusing to overwrite a restore point"), so the loser of a
// same-second race made the winner's engine call fail — and the winner published `books=0 skipped=1
// complete=true`. The discriminator is the book's own intake status and nothing else.
//
// Mutation caught: collapsing the two branches back into one skip; reading the discriminator off the
// exit code or the engine's message; making a not-yet-cut book fail the whole pass.
func TestAnEngineRefusalOnACutBookIsAHoleAndNotASkip(t *testing.T) {
root := t.TempDir()
cut := aBook(t, root, "bk_cut") // status not_started: intake finished, a database must exist
svc, _ := service(t, []pgstore.BookDirectory{cut}, &fakeEngine{refuse: true}, false)
res, err := svc.Take(t.Context())
if err != nil {
t.Fatalf("one refused book ended the whole pass: %v", err)
}
if res.Complete {
t.Error("a point holding NO copy of a cut book was published as complete")
}
m, err := ReadManifest(res.Path)
if err != nil {
t.Fatal(err)
}
if m.Complete || len(m.Books) != 1 || m.Books[0].Skipped == "" {
t.Errorf("the manifest does not name the hole: %+v", m)
}
// The other half: a book whose intake has NOT finished has nothing to copy yet, and that must NOT
// make every point of every deployment read "incomplete" — which is how the flag stops meaning
// anything.
for _, status := range []string{"uploading", "parsing"} {
early := aBook(t, root, "bk_"+status)
early.Status = status
svc2, _ := service(t, []pgstore.BookDirectory{early}, &fakeEngine{refuse: true}, false)
res2, err := svc2.Take(t.Context())
if err != nil {
t.Fatalf("%s: %v", status, err)
}
if !res2.Complete || res2.Skipped != 1 {
t.Errorf("%s: a book with no database yet was treated as a hole: %+v", status, res2)
}
}
}
// Two passes in the same second must not share a staging directory: the winner renames it away under
// the loser's feet, and the loser's half-written files are what the winner then publishes.
//
// Mutation caught: MkdirAll instead of Mkdir — the exact one-word difference that produced the
// measured failure above.
func TestTwoPassesInOneSecondDoNotShareAStagingDirectory(t *testing.T) {
root := t.TempDir()
svc, dest := service(t, []pgstore.BookDirectory{aBook(t, root, "bk_one")}, &fakeEngine{}, false)
fixed := time.Date(2026, 9, 5, 12, 0, 0, 0, time.UTC)
svc.Now = func() time.Time { return fixed }
if _, err := svc.Take(t.Context()); err != nil {
t.Fatal(err)
}
// A second pass in the same second finds the published point and refuses BEFORE doing any work.
if _, err := svc.Take(t.Context()); err == nil {
t.Fatal("a second pass in the same second was allowed to run")
}
// And with the first pass still in flight — its staging directory present, nothing published —
// the second must refuse on the CLAIM rather than start writing into it.
staging := filepath.Join(dest, partialPrefix+"20260905T130000Z")
if err := os.Mkdir(staging, 0o750); err != nil {
t.Fatal(err)
}
svc.Now = func() time.Time { return fixed.Add(time.Hour) }
_, err := svc.Take(t.Context())
if err == nil || !strings.Contains(err.Error(), "already being written") {
t.Fatalf("a pass wrote into another pass's staging directory: %v", err)
}
if points, _ := List(dest); len(points) != 1 {
t.Errorf("published points: %v", points)
}
}
// ⛔ THE ENGINE'S `.env` OF PROVIDER KEYS MUST NEVER REACH A RESTORE POINT. The runbook tells the
// operator to ship this directory OFF THE HOST; a backup that carried the deployment's API keys with
// it would be a credential leak on a schedule.
//
// Mutation caught: dropping the dotfile rule (the keys ride out with every generation).
func TestProviderKeysBesideABookAreNeverCopiedAndTheOmissionIsRecorded(t *testing.T) {
root := t.TempDir()
b := aBook(t, root, "bk_keys")
if err := os.WriteFile(filepath.Join(b.Workdir, ".env"), []byte("DEEPSEEK_API_KEY=sk-live-do-not-copy"+"\n"), 0o600); err != nil {
t.Fatal(err)
}
svc, _ := service(t, []pgstore.BookDirectory{b}, &fakeEngine{}, false)
res, err := svc.Take(t.Context())
if err != nil {
t.Fatal(err)
}
// Not by reading the manifest — by walking what actually landed on the disk.
var leaked []string
err = filepath.WalkDir(res.Path, func(p string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
body, rerr := os.ReadFile(p)
if rerr == nil && strings.Contains(string(body), "sk-live-do-not-copy") {
leaked = append(leaked, p)
}
return nil
})
if err != nil {
t.Fatal(err)
}
if len(leaked) > 0 {
t.Fatalf("provider keys are inside the restore point: %v", leaked)
}
// And the omission is NAMED, so nothing goes missing quietly.
m, err := ReadManifest(res.Path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(strings.Join(m.Books[0].NotCopied, " "), ".env") {
t.Errorf("the skipped dotfile is not recorded: %v", m.Books[0].NotCopied)
}
}
// A book's own data can live in a SUBDIRECTORY — `langpack_extend` is a per-book overlay of the
// pair's canon, resolved against book.yaml. An earlier version of this package skipped every
// subdirectory and still called the point COMPLETE, which is a book's canon lost in silence.
//
// Mutation caught: going back to `if e.IsDir() { continue }`; or recursing into the engine's own
// `backups/`, which is what makes a backup directory grow quadratically.
func TestABooksSubdirectoriesAreCopiedButTheEnginesOwnBackupsAreNot(t *testing.T) {
root := t.TempDir()
b := aBook(t, root, "bk_deep")
if err := os.MkdirAll(filepath.Join(b.Workdir, "langpack", "zh-ru"), 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(b.Workdir, "langpack", "zh-ru", "heading.txt"), []byte("Глава {n}"+"\n"), 0o600); err != nil {
t.Fatal(err)
}
svc, _ := service(t, []pgstore.BookDirectory{b}, &fakeEngine{}, false)
res, err := svc.Take(t.Context())
if err != nil {
t.Fatal(err)
}
if body, err := os.ReadFile(filepath.Join(res.Path, BooksDir, "bk_deep", "langpack", "zh-ru", "heading.txt")); err != nil ||
string(body) != "Глава {n}"+"\n" {
t.Errorf("a book's own data in a subdirectory did not survive: %q (%v)", body, err)
}
if _, err := os.Stat(filepath.Join(res.Path, BooksDir, "bk_deep", engineBackupsDir)); !errors.Is(err, os.ErrNotExist) {
t.Errorf("the engine's own restore points were copied into ours (%v)", err)
}
m, err := ReadManifest(res.Path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(strings.Join(m.Books[0].NotCopied, " "), engineBackupsDir+"/") {
t.Errorf("the skipped directory is not recorded: %v", m.Books[0].NotCopied)
}
}
// A pass that runs out of its budget publishes NOTHING. Truncating instead would drop the NEWEST
// books — the listing is oldest-first — while resetting the age gauge, so every generation would omit
// exactly the work being paid for now and the one alertable number would stay green.
func TestAPassThatRunsOutOfItsBudgetPublishesNothing(t *testing.T) {
root := t.TempDir()
books := []pgstore.BookDirectory{aBook(t, root, "bk_a"), aBook(t, root, "bk_b")}
svc, dest := service(t, books, &fakeEngine{}, false)
ctx, cancel := context.WithCancel(t.Context())
cancel() // the deadline has already passed when the books are reached
if _, err := svc.Take(ctx); err == nil {
t.Fatal("a pass with no budget left published a restore point")
}
if points, _ := List(dest); len(points) != 0 {
t.Errorf("a truncated point was published: %v", points)
}
entries, _ := os.ReadDir(dest)
if len(entries) != 0 {
t.Errorf("staging was left behind: %v", entries)
}
}
// The age gauge reports the newest COMPLETE point. A point that could not copy a book is worth
// keeping and is not a backup OF THAT BOOK, so letting its timestamp reset the age would hide exactly
// the state an operator needs paging for.
func TestTheAgeGaugeIgnoresAnIncompletePoint(t *testing.T) {
root := t.TempDir()
good := aBook(t, root, "bk_ok")
svc, _ := service(t, []pgstore.BookDirectory{good}, &fakeEngine{}, false)
at := time.Date(2026, 9, 5, 12, 0, 0, 0, time.UTC)
svc.Now = func() time.Time { return at }
if _, err := svc.Take(t.Context()); err != nil {
t.Fatal(err)
}
age, has, err := svc.Age(at.Add(time.Hour))
if err != nil || !has || age != time.Hour {
t.Fatalf("a complete point: age=%v has=%v err=%v", age, has, err)
}
// A LATER point that is incomplete must not make the deployment look freshly backed up.
svc.Store = &fakeStore{books: []pgstore.BookDirectory{good}}
svc.Engine = &fakeEngine{refuse: true}
at = at.Add(6 * time.Hour)
if _, err := svc.Take(t.Context()); err != nil {
t.Fatal(err)
}
age, has, err = svc.Age(at.Add(time.Hour))
if err != nil || !has || age != 7*time.Hour {
t.Errorf("an incomplete point reset the age: age=%v has=%v err=%v (want 7h, the last COMPLETE one)", age, has, err)
}
}
// A book that fails PART WAY must leave NOTHING behind, not a half-copied database the manifest does
// not mention.
//
// ⚠ The runbook's restore step is `cp -a books/<id>/. <workdir>/`. A truncated `project.db` sitting in
// a point whose manifest says the book was skipped is therefore not an untidiness: it is an operator
// copying a torn database over a good one, having been told this book was not in the point at all.
//
// Mutation caught: dropping the RemoveAll — the partial files are published, Verify never hashes them
// because the manifest has no entry for them, and `--verify` reports the point sound.
func TestAPartlyCopiedBookLeavesNothingInThePoint(t *testing.T) {
root := t.TempDir()
b := aBook(t, root, "bk_halfway")
// The engine's snapshot lands, and the rest of the copy then fails: a directory the copier cannot
// read reproduces "failed after some files were already written".
unreadable := filepath.Join(b.Workdir, "locked")
if err := os.Mkdir(unreadable, 0o000); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(unreadable, 0o750) })
svc, _ := service(t, []pgstore.BookDirectory{b}, &fakeEngine{}, false)
res, err := svc.Take(t.Context())
if err != nil {
t.Fatalf("one book's failure ended the whole pass: %v", err)
}
if res.Complete {
t.Error("a point missing a book was published as complete")
}
if _, err := os.Stat(filepath.Join(res.Path, BooksDir, "bk_halfway")); !errors.Is(err, os.ErrNotExist) {
left, _ := os.ReadDir(filepath.Join(res.Path, BooksDir, "bk_halfway"))
names := []string{}
for _, e := range left {
names = append(names, e.Name())
}
t.Errorf("a skipped book left files in the point that the manifest does not mention: %v", names)
}
// And the manifest is an exact description of what is on the disk: everything it lists verifies,
// and it lists everything.
if problems, err := Verify(res.Path); err != nil {
t.Fatal(err)
} else if len(problems) != 1 || !strings.Contains(problems[0], "INCOMPLETE") {
t.Errorf("Verify: %v", problems)
}
}
// The dump is READ BACK before the point is published, and a dump that cannot be read back does not
// become a restore point. Nothing observed this: the double `pg_restore` exited 0 whatever it was
// handed, so removing the check entirely would have left every test green.
func TestADumpThatCannotBeReadBackIsNotPublished(t *testing.T) {
root := t.TempDir()
tools := t.TempDir()
dest := t.TempDir()
svc := &Service{
Cfg: Config{Dir: dest, Every: time.Hour, Keep: 3,
PgDumpBin: fakePg(t, tools, "pg_dump", false),
PgRestoreBin: fakeRefusing(t, tools, "pg_restore"),
DSN: "postgres://u:pw@127.0.0.1:5432/db?sslmode=disable",
EngineBinary: "/opt/engine/tmctl"},
Store: &fakeStore{books: []pgstore.BookDirectory{aBook(t, root, "bk_one")}}, Engine: &fakeEngine{},
}
_, err := svc.Take(t.Context())
if err == nil {
t.Fatal("a dump pg_restore cannot read was published as a restore point")
}
if !strings.Contains(err.Error(), "cannot be read back") {
t.Errorf("the error does not say what is wrong: %v", err)
}
if points, _ := List(dest); len(points) != 0 {
t.Errorf("published anyway: %v", points)
}
}
// Two guards their comments argue for at length and nothing checked: a book directory holding
// something that is not a regular file (following a symlink would copy from outside the book), and
// the removal of a `.partial-` a killed pass left behind.
func TestTheSymlinkGuardAndTheStaleStagingPruneBothWork(t *testing.T) {
root := t.TempDir()
b := aBook(t, root, "bk_one")
// A symlink pointing OUT of the book, which must not be followed into the point.
outside := filepath.Join(t.TempDir(), "somebody-elses-secret")
if err := os.WriteFile(outside, []byte("NOT THIS BOOK'S"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(b.Workdir, "escape.txt")); err != nil {
t.Skipf("this filesystem does not do symlinks: %v", err)
}
svc, dest := service(t, []pgstore.BookDirectory{b}, &fakeEngine{}, false)
now := time.Date(2026, 9, 5, 12, 0, 0, 0, time.UTC)
svc.Now = func() time.Time { return now }
// A leftover staging directory from a pass killed two days ago.
stale := filepath.Join(dest, partialPrefix+"20260903T120000Z")
if err := os.MkdirAll(stale, 0o750); err != nil {
t.Fatal(err)
}
// ...and one from a pass that may still be running.
fresh := filepath.Join(dest, partialPrefix+"20260905T113000Z")
if err := os.MkdirAll(fresh, 0o750); err != nil {
t.Fatal(err)
}
res, err := svc.Take(t.Context())
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(res.Path, BooksDir, "bk_one", "escape.txt")); !errors.Is(err, os.ErrNotExist) {
t.Errorf("a symlink out of the book was followed into the restore point (%v)", err)
}
m, err := ReadManifest(res.Path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(strings.Join(m.Books[0].NotCopied, " "), "escape.txt") {
t.Errorf("the skipped symlink is not recorded: %v", m.Books[0].NotCopied)
}
if _, err := os.Stat(stale); !errors.Is(err, os.ErrNotExist) {
t.Errorf("a day-old staging leftover was not pruned (%v)", err)
}
if _, err := os.Stat(fresh); err != nil {
t.Errorf("a staging directory young enough to be a LIVE pass was removed under it: %v", err)
}
}
// ⛔ A BOOK WHOSE SOURCE IS NAMED `source.db` MUST NOT LOSE ITS SOURCE. The intake keeps the user's
// own extension verbatim (`extensionOf` is not an allowlist), so a reader who uploads `novel.db` gets
// `source.db` on disk — and a rule that skips everything ending in `.db` drops the book's own text
// from every restore point. The source is the one file in that directory nobody can be asked to
// supply again.
//
// Mutation caught: dropping the `!isTheUploadedSource(name)` half of the guard.
func TestASourceNamedLikeADatabaseIsStillCopied(t *testing.T) {
root := t.TempDir()
b := aBook(t, root, "bk_dbsource")
// The book's real source, under the name the intake would have written for an upload called
// `novel.db` — and the live project database beside it, which must still be skipped.
if err := os.WriteFile(filepath.Join(b.Workdir, "source.db"), []byte("第一章 текст книги"), 0o600); err != nil {
t.Fatal(err)
}
svc, _ := service(t, []pgstore.BookDirectory{b}, &fakeEngine{}, false)
res, err := svc.Take(t.Context())
if err != nil {
t.Fatal(err)
}
body, err := os.ReadFile(filepath.Join(res.Path, BooksDir, "bk_dbsource", "source.db"))
if err != nil || string(body) != "第一章 текст книги" {
t.Fatalf("the book's source was dropped because it is named like a database: %q (%v)", body, err)
}
// ...and the live database beside it is STILL skipped, so the guard did not simply turn the rule off.
if _, err := os.Stat(filepath.Join(res.Path, BooksDir, "bk_dbsource", "bk_dbsource.db")); !errors.Is(err, os.ErrNotExist) {
t.Errorf("the live project database was copied after all (%v)", err)
}
m, err := ReadManifest(res.Path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(strings.Join(m.Books[0].NotCopied, " "), "bk_dbsource.db ") {
t.Errorf("the live database is not recorded as skipped: %v", m.Books[0].NotCopied)
}
}
// ⛔ RETENTION MUST NOT BE A COUNTDOWN TO DATA LOSS. Retention counts DIRECTORIES, and a deployment
// producing degraded points — an unmounted books volume, an engine that stopped answering — produces
// them on the same schedule as good ones. With the defaults that is `Keep × Every` before every point
// holding the paid work has been evicted, and the operator's only signal arrives after the last real
// backup is already gone.
//
// Mutation caught: dropping the newestComplete exception from prune.
func TestARunOfDegradedPointsCannotEvictTheLastCompleteOne(t *testing.T) {
root := t.TempDir()
good := aBook(t, root, "bk_ok")
svc, dest := service(t, []pgstore.BookDirectory{good}, &fakeEngine{}, false)
svc.Cfg.Keep = 2
at := time.Date(2026, 9, 5, 0, 0, 0, 0, time.UTC)
svc.Now = func() time.Time { return at }
// One good point.
res, err := svc.Take(t.Context())
if err != nil {
t.Fatal(err)
}
theGoodOne := res.Stamp
// Then the outage: the book's directory is gone while its status says it should be there, so every
// later point is incomplete. Four of them — twice the retention count.
if err := os.RemoveAll(good.Workdir); err != nil {
t.Fatal(err)
}
for range 4 {
at = at.Add(6 * time.Hour)
if _, err := svc.Take(t.Context()); err != nil {
t.Fatalf("a degraded pass failed outright: %v", err)
}
}
points, err := List(dest)
if err != nil {
t.Fatal(err)
}
var found bool
for _, p := range points {
if p == theGoodOne {
found = true
}
}
if !found {
t.Fatalf("the only point holding the paid work was evicted by retention; what is left: %v", points)
}
// And it is still the one the gauge reports, so the operator is not told the deployment is fresh.
age, has, err := svc.Age(at)
if err != nil || !has {
t.Fatalf("age: %v %v", has, err)
}
if age != 24*time.Hour {
t.Errorf("age %v: the gauge is not reporting the last COMPLETE point", age)
}
}
// ⛔ PV2-02: A BOOK STILL IN INTAKE MUST GET ITS FILES INTO THE POINT. It has no project database
// yet, and an earlier version answered that by taking NOTHING — not the uploaded source, not
// `book.yaml`, not the journal — while the point still called itself complete.
//
// ⚠ `parsing` is not a five-minute window: the intake's `not_configured` and `storage_unavailable`
// never become terminal (internal/books/parse.go), so a host with a broken book template holds every
// upload in that state indefinitely. Those uploads are exactly the ones with no other copy anywhere,
// and this package's own comment calls the source irreplaceable.
//
// Mutation caught: returning from copyBook before copyBookFiles on the not-yet-cut branch.
func TestABookStillInIntakeStillGetsItsSourceIntoThePoint(t *testing.T) {
root := t.TempDir()
for _, status := range []string{"uploading", "parsing"} {
b := aBook(t, root, "bk_"+status)
b.Status = status
svc, _ := service(t, []pgstore.BookDirectory{b}, &fakeEngine{refuse: true}, false)
res, err := svc.Take(t.Context())
if err != nil {
t.Fatalf("%s: %v", status, err)
}
if !res.Complete {
t.Errorf("%s: a book that legitimately has no database made the point incomplete", status)
}
// THE POINT: its own files are here, whatever the database situation.
for _, want := range []string{"source.txt", "book.yaml", "events.jsonl"} {
p := filepath.Join(res.Path, BooksDir, b.ID, want)
if _, err := os.Stat(p); err != nil {
t.Errorf("%s: %s is not in the point — the user's own upload was dropped: %v", status, want, err)
}
}
m, err := ReadManifest(res.Path)
if err != nil {
t.Fatal(err)
}
if len(m.Books[0].Files) == 0 {
t.Errorf("%s: the manifest lists no files for a book whose directory was full", status)
}
if m.Books[0].Skipped == "" {
t.Errorf("%s: the absent database is not explained", status)
}
}
}
// ⛔ PV2-03: A REJECTED BOOK THAT KEPT ITS DIRECTORY IS NOT A HOLE. Only one of the two terminal
// reject paths removes the directory — `ReasonSourceUnreadable` — while the other (the attempt budget
// exhausted because the engine would not run) deliberately KEEPS the user's file
// (internal/books/parse.go, `if reason == ReasonSourceUnreadable { s.removeDir(...) }`).
//
// Such a book has a directory and no database, which fell between the branches and made every point
// incomplete FOREVER: the age gauge then reads +Inf permanently, the one alertable signal of real
// backup loss is stuck red and worthless, and prune's "never evict the newest COMPLETE point" becomes
// vacuous because no complete point exists. And the cause that produces such books — the engine not
// running — is the same deployment incident.
//
// Mutation caught: dropping `|| b.Status == statusRejected` from the engine-refusal branch.
func TestARejectedBookThatKeptItsDirectoryIsNotAHole(t *testing.T) {
root := t.TempDir()
b := aBook(t, root, "bk_rejected_kept")
b.Status = "rejected" // its directory is right there: the reject path kept the user's file
svc, dest := service(t, []pgstore.BookDirectory{b}, &fakeEngine{refuse: true}, false)
at := time.Date(2026, 9, 5, 12, 0, 0, 0, time.UTC)
svc.Now = func() time.Time { return at }
res, err := svc.Take(t.Context())
if err != nil {
t.Fatal(err)
}
if !res.Complete {
t.Fatal("a rejected book that kept its directory made the point incomplete — the gauge would stick at +Inf forever")
}
// The gauge must actually report an age, which is the half that matters operationally.
age, has, err := svc.Age(at.Add(time.Hour))
if err != nil || !has || age != time.Hour {
t.Errorf("the age gauge has no complete point to report: age=%v has=%v err=%v", age, has, err)
}
// And the user's file the deployment deliberately kept is in the point.
if _, err := os.Stat(filepath.Join(res.Path, BooksDir, b.ID, "source.txt")); err != nil {
t.Errorf("the source the reject path kept was not backed up: %v", err)
}
if points, _ := List(dest); len(points) != 1 {
t.Errorf("points: %v", points)
}
}
// Verify is what makes the manifest a guarantee rather than a description: a copy that rotted on the
// disk is indistinguishable from a good one by size alone, and the moment to find out is not the
// moment you need it.
func TestVerifyCatchesAFileThatChangedUnderTheManifest(t *testing.T) {
root := t.TempDir()
svc, _ := service(t, []pgstore.BookDirectory{aBook(t, root, "bk_one")}, &fakeEngine{}, false)
res, err := svc.Take(t.Context())
if err != nil {
t.Fatal(err)
}
if problems, err := Verify(res.Path); err != nil || len(problems) != 0 {
t.Fatalf("a fresh point does not verify: %v (%v)", problems, err)
}
db := filepath.Join(res.Path, BooksDir, "bk_one", ProjectDBName)
// The SAME LENGTH, one byte different — which is the case a size check cannot see and the one
// silent corruption actually looks like.
if err := os.WriteFile(db, []byte("CONSISTENT-COPX"), 0o600); err != nil {
t.Fatal(err)
}
problems, err := Verify(res.Path)
if err != nil {
t.Fatal(err)
}
if len(problems) != 1 || !strings.Contains(problems[0], "sha256") {
t.Errorf("a silently corrupted database was not caught: %v", problems)
}
}
// A manifest of a shape this build does not know is REFUSED rather than half-read. That is the whole
// reason the document carries a version, and the failure it prevents is the worst kind: a restore
// that reads three fields of five and reports success.
func TestARestorePointOfAnUnknownShapeIsRefused(t *testing.T) {
dir := t.TempDir()
point := filepath.Join(dir, "20260905T120000Z")
if err := os.MkdirAll(point, 0o750); err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(Manifest{Version: "tm-platform-restore-point-v9", Stamp: "20260905T120000Z"})
if err := os.WriteFile(filepath.Join(point, ManifestFile), raw, 0o600); err != nil {
t.Fatal(err)
}
if _, err := ReadManifest(point); err == nil || !strings.Contains(err.Error(), "this build reads") {
t.Errorf("a future restore point was read as if it were this one's: %v", err)
}
}
// Keep is enforced AFTER a publish and never before: a prune that ran first would, on a host that
// then failed to write the new point, leave fewer copies than it started with.
func TestOldPointsGoOnlyOnceANewOneExists(t *testing.T) {
root := t.TempDir()
svc, dest := service(t, []pgstore.BookDirectory{aBook(t, root, "bk_one")}, &fakeEngine{}, false)
svc.Cfg.Keep = 2
now := time.Date(2026, 9, 5, 12, 0, 0, 0, time.UTC)
svc.Now = func() time.Time { return now }
for range 4 {
if _, err := svc.Take(t.Context()); err != nil {
t.Fatal(err)
}
now = now.Add(time.Hour)
}
points, err := List(dest)
if err != nil {
t.Fatal(err)
}
if len(points) != 2 {
t.Fatalf("kept %v, want the newest 2", points)
}
if points[1] != "20260905T150000Z" || points[0] != "20260905T140000Z" {
t.Errorf("the wrong two survived: %v", points)
}
}
// The pass decides for itself whether a point is due, so the schedule survives a restart with no
// state of its own — and a deployment on a fifteen-second sweep tick does not take a full copy of
// itself every fifteen seconds.
func TestTheSweepTakesAPointOnlyWhenOneIsDue(t *testing.T) {
root := t.TempDir()
svc, dest := service(t, []pgstore.BookDirectory{aBook(t, root, "bk_one")}, &fakeEngine{}, false)
svc.Cfg.Every = 6 * time.Hour
now := time.Date(2026, 9, 5, 12, 0, 0, 0, time.UTC)
svc.Now = func() time.Time { return now }
if err := svc.Sweep(t.Context()); err != nil { // no point at all: one is due
t.Fatal(err)
}
now = now.Add(5 * time.Hour)
if err := svc.Sweep(t.Context()); err != nil {
t.Fatal(err)
}
if points, _ := List(dest); len(points) != 1 {
t.Fatalf("a point was taken before it was due: %v", points)
}
now = now.Add(2 * time.Hour)
if err := svc.Sweep(t.Context()); err != nil {
t.Fatal(err)
}
if points, _ := List(dest); len(points) != 2 {
t.Fatalf("no point was taken when one was due: %v", points)
}
age, has, err := svc.Age(now)
if err != nil || !has || age != 0 {
t.Errorf("age of the newest point: %v %v %v", age, has, err)
}
}
// The DSN carries a password and it must never reach an argv: /proc/<pid>/cmdline is readable by
// every process on the host.
//
// Mutation caught: passing the connection string through as `-d <dsn>`, which is the shortest way to
// write this function and leaks the password to every local process.
func TestThePasswordTravelsInTheEnvironmentAndNeverInTheArgv(t *testing.T) {
args, env, err := dumpArgs("postgres://alice:s3cr3t@db.example:6432/tm?sslmode=disable", "/tmp/out.dump")
if err != nil {
t.Fatal(err)
}
if strings.Contains(strings.Join(args, " "), "s3cr3t") {
t.Fatalf("the password is on the command line: %q", args)
}
var found bool
for _, kv := range env {
if kv == "PGPASSWORD=s3cr3t" {
found = true
}
}
if !found {
t.Error("the password did not reach the child's environment, so the dump would prompt and hang")
}
joined := strings.Join(args, " ")
for _, want := range []string{"db.example", "6432", "alice", "/tm", "--format=custom",
"--file=/tmp/out.dump", "--no-password"} {
if !strings.Contains(joined, want) {
t.Errorf("missing %s in %q", want, joined)
}
}
// ⛔ AND EVERY OTHER PARAMETER THE OPERATOR PUT IN THE DSN, `sslmode` above all. An earlier version
// rebuilt the connection from host/port/user/dbname and dropped the rest, so a deployment whose
// DSN says `verify-full` had its nightly dump go out under libpq's default `prefer` — TLS optional,
// certificate unchecked — and nothing said so, because pg_dump succeeds either way.
strict, _, err := dumpArgs("postgres://alice:s3cr3t@db.example:6432/tm?sslmode=verify-full&sslrootcert=/etc/ca.pem&connect_timeout=9", "/tmp/o")
if err != nil {
t.Fatal(err)
}
sj := strings.Join(strict, " ")
for _, want := range []string{"sslmode=verify-full", "sslrootcert=/etc/ca.pem", "connect_timeout=9"} {
if !strings.Contains(sj, want) {
t.Errorf("the DSN's %s did not reach pg_dump — the dump would run under libpq defaults: %q", want, sj)
}
}
if strings.Contains(sj, "s3cr3t") {
t.Errorf("forwarding the DSN carried the password onto the argv: %q", sj)
}
// A DSN that cannot be parsed must not put itself into the error: that is the same leak by
// another door.
_, _, err = dumpArgs("://not a dsn:hunter2@", "/tmp/x")
if err == nil || strings.Contains(err.Error(), "hunter2") {
t.Errorf("the unparseable DSN leaked into the message: %v", err)
}
}
// The restore instructions travel INSIDE the point, because a restore happens on the worst day this
// deployment has and possibly from a copy that travelled away from this repository.
func TestAPointCarriesItsOwnRestoreInstructions(t *testing.T) {
root := t.TempDir()
svc, _ := service(t, []pgstore.BookDirectory{aBook(t, root, "bk_one")}, &fakeEngine{}, false)
res, err := svc.Take(t.Context())
if err != nil {
t.Fatal(err)
}
m, err := ReadManifest(res.Path)
if err != nil {
t.Fatal(err)
}
joined := strings.Join(m.Notes, "\n")
for _, want := range []string{"pg_restore", ProjectDBName, "project_db"} {
if !strings.Contains(joined, want) {
t.Errorf("the notes do not say how to restore (%s missing): %v", want, m.Notes)
}
}
}
// ⛔ THE SWEEP'S OWN SENTENCE MUST NOT NAME THE BOOK, and this is the carrier that made the rule a
// mechanism rather than a checklist: the copy it could not remove lives INSIDE the book's directory,
// so `"path", res.Path` on a WARN is `<books dir>/<book id>` — the identifier the zone's standard
// keeps out of logs (PD-139, PD-99). It is this platform's OWN text, not an engine's, and it was the
// fourth carrier of a class two earlier rounds had each declared closed (acceptance of 11.09, F4).
//
// Reaching it needs the removal to FAIL, which the fixture arranges by sealing the directory the
// engine wrote into. The control half is the diagnosis: an operator still learns what went wrong.
func TestTheSweepsOwnWarningDoesNotNameTheBook(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("the fixture denies itself a directory, which root is not denied")
}
root := t.TempDir()
const id = "bk_THESWEEPSOWNBOOK"
book := aBook(t, root, id)
eng := &fakeEngine{sealAfterWrite: true}
svc, _ := service(t, []pgstore.BookDirectory{book}, eng, false)
var buf bytes.Buffer
svc.Log = slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
t.Cleanup(func() { _ = os.Chmod(filepath.Join(book.Workdir, "backups"), 0o700) })
if err := svc.Sweep(context.Background()); err != nil {
t.Fatalf("the pass failed outright, so it never reached the removal this test is about: %v", err)
}
logged := buf.String()
if !strings.Contains(logged, "could not be removed after it was taken into the restore point") {
t.Fatalf("the removal never failed, so this fixture measured nothing:\n%s", logged)
}
// The diagnosis survives…
if !strings.Contains(logged, "permission denied") {
t.Errorf("the errno did not survive the removal of the path:\n%s", logged)
}
// …and the book does not.
for _, line := range strings.Split(strings.TrimSpace(logged), "\n") {
if line == "" || strings.Contains(line, `"level":"ERROR"`) {
continue
}
if strings.Contains(line, id) || strings.Contains(line, book.Workdir) {
t.Errorf("a line below ERROR carries the book: %s", line)
}
}
}