199 lines
6.7 KiB
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()
|
|
}
|