textmachine/platform/internal/backup/backup_live_test.go

199 lines
6.7 KiB
Go

package backup
import (
"context"
"crypto/rand"
"encoding/hex"
"net/url"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"github.com/jackc/pgx/v5"
"textmachine/platform/internal/login"
"textmachine/platform/internal/money"
"textmachine/platform/internal/pgstore"
)
// TestARestorePointCanActuallyBeRestored is the test this whole package exists for.
//
// ⚠ WITHOUT IT, A BACKUP IS A BELIEF. Everything else here proves that files were written and that
// their digests match. NONE of that proves the one property an operator will one day need: that the
// credit ledger comes BACK. So this test takes a real point with the real `pg_dump`, restores it
// into a database that has never seen this deployment, and reads the money and the book out of the
// restored copy — the same claim the runbook's restore procedure makes, executed.
//
// Gated on a live Postgres and on the standard tools being on PATH, like every other live test here:
// a bare clone stays green and says why.
func TestARestorePointCanActuallyBeRestored(t *testing.T) {
admin := os.Getenv("TM_PLATFORM_TEST_DSN")
if admin == "" {
t.Skip("TM_PLATFORM_TEST_DSN not set: a restore cannot be demonstrated without a live Postgres")
}
dump, err := exec.LookPath(pgDumpName())
if err != nil {
t.Skipf("%s not on PATH: a restore point cannot be taken (%v)", pgDumpName(), err)
}
restore, err := exec.LookPath(pgRestoreName())
if err != nil {
t.Skipf("%s not on PATH: a restore cannot be demonstrated (%v)", pgRestoreName(), err)
}
ctx := t.Context()
sourceDSN := freshDB(t, admin)
store, err := pgstore.Open(ctx, sourceDSN)
if err != nil {
t.Fatal(err)
}
defer store.Close()
// A deployment with something to lose: an account, credit in the ledger, and a book.
now := time.Now().UTC()
userID, err := store.UpsertIdentity(ctx, login.Identity{
Provider: "google", Subject: "sub-restore-probe", Email: "probe@example.org", EmailVerified: true,
}, now, 0)
if err != nil {
t.Fatal(err)
}
const granted = money.MicroUSD(25_000_000) // $25
if _, err := store.Grant(ctx, userID, granted, "admin", "restore-probe-grant", "probe", now); err != nil {
t.Fatal(err)
}
root := t.TempDir()
book := aBook(t, root, "bk_restoreprobe")
bookID, err := store.AddBook(ctx, pgstore.NewBook{
OwnerID: userID, Title: "Мастер Гу", SourceLang: "zh", TargetLang: "ru",
ChapterCount: 3, CharacterCount: 42, Workdir: book.Workdir, Now: now,
})
if err != nil {
t.Fatal(err)
}
before, err := store.ReadAccount(ctx, userID)
if err != nil {
t.Fatal(err)
}
if before.Balance != granted {
t.Fatalf("the probe deployment does not hold what it was granted: %s", before.Balance.USD())
}
dest := t.TempDir()
svc := &Service{
Cfg: Config{Dir: dest, Every: time.Hour, Keep: 2, PgDumpBin: dump, PgRestoreBin: restore,
DSN: sourceDSN, EngineBinary: "/not/used/here"},
Store: store, Engine: &fakeEngine{},
}
res, err := svc.Take(ctx)
if err != nil {
t.Fatalf("taking a live restore point: %v", err)
}
if problems, err := Verify(res.Path); err != nil || len(problems) != 0 {
t.Fatalf("the live restore point does not verify: %v (%v)", problems, err)
}
// ⚠ THE DISK IS GONE. Everything below reads ONLY the restore point and a database that has
// never seen this deployment; the source is not touched again.
targetDSN := freshDBEmpty(t, admin)
cmd := exec.CommandContext(ctx, restore, "--clean", "--if-exists", "--no-owner",
"--dbname="+targetDSN, filepath.Join(res.Path, DumpFile))
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("pg_restore into an empty database failed: %v\n%s", err, out)
}
restored, err := pgstore.Open(ctx, targetDSN)
if err != nil {
t.Fatal(err)
}
defer restored.Close()
after, err := restored.ReadAccount(ctx, userID)
if err != nil {
t.Fatalf("the account is not on the restored copy: %v", err)
}
if after.Balance != granted {
t.Errorf("the restored balance is %s, want %s: the credit ledger did not survive",
after.Balance.USD(), granted.USD())
}
got, _, err := restored.GetBook(ctx, userID, bookID)
if err != nil {
t.Fatalf("the book is not on the restored copy: %v", err)
}
if got.Title != "Мастер Гу" || got.ChapterCount != 3 || got.CharacterCount != 42 {
t.Errorf("the restored book is not the one that was backed up: %+v", got)
}
// And the book's paid work: the file the runbook tells the operator to put back is in the point,
// under the name the notes give, carrying the engine's consistent copy rather than the live file.
body, err := os.ReadFile(filepath.Join(res.Path, BooksDir, bookID, ProjectDBName))
if err != nil {
// The point stores books by the id the STORE gave them, which is the id the restored rows
// carry — so this is also the pin that says the two agree.
t.Fatalf("the book's database is not in the restore point under its own id: %v", err)
}
if string(body) != "CONSISTENT-COPY" {
t.Errorf("the stored database is not the engine's consistent copy: %q", body)
}
}
func pgDumpName() string {
if v := os.Getenv("TM_PLATFORM_TEST_PGDUMP"); v != "" {
return v
}
return "pg_dump"
}
func pgRestoreName() string {
if v := os.Getenv("TM_PLATFORM_TEST_PGRESTORE"); v != "" {
return v
}
return "pg_restore"
}
// freshDB makes a migrated scratch database, and freshDBEmpty an unmigrated one — the restore has to
// land on a database that has never seen this deployment, or the test would be proving that
// `pg_restore` can overwrite a schema it already agrees with.
//
// ⚠ A COPY of the helper in cmd/tmplatformctl and internal/runs, deliberately and for the reason
// stated there: sharing it needs a package that imports pgstore, and pgstore's own battery lives in
// `package pgstore`, so that package could not exist without a cycle.
func freshDB(t *testing.T, admin string) string {
t.Helper()
dsn := freshDBEmpty(t, admin)
if err := pgstore.Migrate(context.Background(), dsn); err != nil {
t.Fatalf("migrate: %v", err)
}
return dsn
}
func freshDBEmpty(t *testing.T, admin string) string {
t.Helper()
ctx := context.Background()
var suffix [6]byte
if _, err := rand.Read(suffix[:]); err != nil {
t.Fatal(err)
}
name := "tm_backup_test_" + hex.EncodeToString(suffix[:])
conn, err := pgx.Connect(ctx, admin)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer conn.Close(ctx)
if _, err := conn.Exec(ctx, `create database `+pgx.Identifier{name}.Sanitize()); err != nil {
t.Fatalf("create database: %v", err)
}
t.Cleanup(func() {
c, err := pgx.Connect(context.Background(), admin)
if err != nil {
return
}
defer c.Close(context.Background())
_, _ = c.Exec(context.Background(), `drop database `+pgx.Identifier{name}.Sanitize()+` with (force)`)
})
u, err := url.Parse(admin)
if err != nil {
t.Fatalf("TM_PLATFORM_TEST_DSN must be a URL: %v", err)
}
u.Path = "/" + name
return u.String()
}