Files
tver/deploy/nas/tver-db/scripts_test.go

402 lines
14 KiB
Go

package tverdb_test
import (
"bytes"
"compress/gzip"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func TestBackupDB(t *testing.T) {
t.Run("success and retention boundary", func(t *testing.T) {
env := newScriptEnv(t, "success")
old := filepath.Join(env.backupDir, "tver_20260901_000000.sql.gz")
boundary := filepath.Join(env.backupDir, "tver_20260920_000000.sql.gz")
unrelated := []string{
filepath.Join(env.backupDir, "restore-test.log"),
filepath.Join(env.backupDir, "other_20260901_000000.sql.gz"),
filepath.Join(env.backupDir, "tver_20260901_000000.sql"),
}
writeFile(t, old, "old")
writeFile(t, boundary, "boundary")
for _, path := range unrelated {
writeFile(t, path, "keep")
}
now := time.Now()
oldTime := now.Add(-(15*24*time.Hour + time.Hour))
boundaryTime := now.Add(-(15*24*time.Hour - time.Hour))
if err := os.Chtimes(old, oldTime, oldTime); err != nil {
t.Fatal(err)
}
if err := os.Chtimes(boundary, boundaryTime, boundaryTime); err != nil {
t.Fatal(err)
}
for _, path := range unrelated {
if err := os.Chtimes(path, oldTime, oldTime); err != nil {
t.Fatal(err)
}
}
output, err := env.run(t, "backup-db.sh", "STAMP=20261004_010203", "KEEP_DAYS=14", "CONTAINER=custom-postgres", "DB_USER=custom-user", "DB_NAME=custom-db")
if err != nil {
t.Fatalf("backup failed: %v\n%s", err, output)
}
created := filepath.Join(env.backupDir, "tver_20261004_010203.sql.gz")
if got := readGzip(t, created); !strings.Contains(got, "CREATE TABLE example") {
t.Fatalf("dump=%q", got)
}
if _, err := os.Stat(old); !os.IsNotExist(err) {
t.Fatalf("old backup remains: %v", err)
}
if _, err := os.Stat(boundary); err != nil {
t.Fatalf("14-day backup was deleted: %v", err)
}
for _, path := range unrelated {
if got := readFile(t, path); got != "keep" {
t.Fatalf("unrelated file was changed: %s content=%q", path, got)
}
}
if !strings.Contains(output, "OK: "+created) || !strings.Contains(output, "bytes") {
t.Fatalf("success output=%q", output)
}
if log := readFile(t, env.dockerLog); !strings.Contains(log, "exec custom-postgres pg_dump -U custom-user -d custom-db") {
t.Fatalf("docker log=%q", log)
}
})
t.Run("creates missing backup directory", func(t *testing.T) {
env := newScriptEnv(t, "success")
if err := os.RemoveAll(env.backupDir); err != nil {
t.Fatal(err)
}
output, err := env.run(t, "backup-db.sh", "STAMP=20261004_010203")
if err != nil {
t.Fatalf("backup failed: %v\n%s", err, output)
}
if got := readGzip(t, filepath.Join(env.backupDir, "tver_20261004_010203.sql.gz")); !strings.Contains(got, "CREATE TABLE example") {
t.Fatalf("dump=%q", got)
}
})
t.Run("does not replace an existing completed backup", func(t *testing.T) {
env := newScriptEnv(t, "success")
completed := filepath.Join(env.backupDir, "tver_20261004_010203.sql.gz")
writeGzip(t, completed, "existing")
output, err := env.run(t, "backup-db.sh", "STAMP=20261004_010203")
if err == nil {
t.Fatalf("backup replaced an existing file: %s", output)
}
if got := readGzip(t, completed); got != "existing" {
t.Fatalf("existing backup was changed: %q", got)
}
assertNoBackupTemps(t, env.backupDir)
})
t.Run("same stamp concurrently allows exactly one backup", func(t *testing.T) {
env := newScriptEnv(t, "concurrent")
barrier := filepath.Join(env.dir, "barrier")
extra := []string{"STAMP=20261004_010203", "FAKE_DOCKER_BARRIER=" + barrier}
commands := make([]*exec.Cmd, 2)
outputs := make([]bytes.Buffer, 2)
for i := range commands {
commands[i] = exec.Command("bash", "backup-db.sh")
commands[i].Env = append(append(append([]string{}, os.Environ()...), env.baseEnv...), extra...)
commands[i].Stdout = &outputs[i]
commands[i].Stderr = &outputs[i]
if err := commands[i].Start(); err != nil {
t.Fatal(err)
}
}
succeeded := 0
for i, command := range commands {
if err := command.Wait(); err == nil {
succeeded++
} else if _, ok := err.(*exec.ExitError); !ok {
t.Fatalf("backup %d wait: %v output=%q", i, err, outputs[i].String())
}
}
if succeeded != 1 {
t.Fatalf("concurrent successes=%d want=1 outputs=%q / %q", succeeded, outputs[0].String(), outputs[1].String())
}
if got := readGzip(t, filepath.Join(env.backupDir, "tver_20261004_010203.sql.gz")); !strings.Contains(got, "CREATE TABLE example") {
t.Fatalf("completed dump=%q", got)
}
assertNoBackupTemps(t, env.backupDir)
})
for _, mode := range []string{"dump-fail", "dump-partial-fail", "empty"} {
t.Run(mode, func(t *testing.T) {
env := newScriptEnv(t, mode)
output, err := env.run(t, "backup-db.sh", "STAMP=20261004_010203")
if err == nil {
t.Fatalf("backup succeeded unexpectedly: %s", output)
}
matches, globErr := filepath.Glob(filepath.Join(env.backupDir, "tver_*.sql.gz"))
if globErr != nil || len(matches) != 0 {
t.Fatalf("partial backups=%v err=%v", matches, globErr)
}
assertNoBackupTemps(t, env.backupDir)
if mode == "empty" && !strings.Contains(output, "empty") {
t.Fatalf("empty dump error missing: %q", output)
}
})
}
for _, keepDays := range []string{"-1", "invalid"} {
t.Run("invalid retention "+keepDays, func(t *testing.T) {
env := newScriptEnv(t, "success")
output, err := env.run(t, "backup-db.sh", "STAMP=20261004_010203", "KEEP_DAYS="+keepDays)
if err == nil || !strings.Contains(output, "KEEP_DAYS") {
t.Fatalf("invalid KEEP_DAYS: err=%v output=%q", err, output)
}
if _, err := os.Stat(env.dockerLog); !os.IsNotExist(err) {
t.Fatalf("pg_dump was invoked: %v", err)
}
})
}
}
func TestRestoreTest(t *testing.T) {
t.Run("restores latest dump and compares rows", func(t *testing.T) {
env := newScriptEnv(t, "success")
writeGzip(t, filepath.Join(env.backupDir, "tver_20261003_010203.sql.gz"), "older")
writeGzip(t, filepath.Join(env.backupDir, "tver_20261004_010203.sql.gz"), "newest")
output, err := env.run(t, "restore-test.sh")
if err != nil {
t.Fatalf("restore test failed: %v\n%s", err, output)
}
for _, table := range []string{"download_jobs", "subscriptions", "known_episodes", "schema_migrations"} {
if !strings.Contains(output, "OK: "+table) {
t.Errorf("missing result for %s: %s", table, output)
}
}
if got := readFile(t, env.restoreInput); got != "newest" {
t.Fatalf("restored dump=%q", got)
}
log := readFile(t, env.dockerLog)
if !strings.Contains(log, "psql -q -o /dev/null -v ON_ERROR_STOP=1") {
t.Fatalf("psql output suppression is missing: %q", log)
}
cleanupCommand := "dropdb --force --if-exists -U tver tver_restore_test"
if strings.Count(log, cleanupCommand) != 2 || !strings.HasSuffix(strings.TrimSpace(log), cleanupCommand) {
t.Fatalf("temporary DB cleanup log=%q", log)
}
for _, line := range strings.Split(strings.TrimSpace(log), "\n") {
if (strings.Contains(line, " createdb ") || strings.Contains(line, " dropdb ")) && !strings.HasSuffix(line, "tver_restore_test") {
t.Fatalf("production DB mutation was attempted: %q", line)
}
if strings.Contains(line, " psql ") && strings.Contains(line, " -d tver ") && !strings.Contains(line, "SELECT count(*)") {
t.Fatalf("production DB received a non-count command: %q", line)
}
if strings.Contains(line, " ON_ERROR_STOP=1 ") && !strings.Contains(line, " -d tver_restore_test") {
t.Fatalf("dump was restored into a non-temporary DB: %q", line)
}
}
if restoreLog := readFile(t, filepath.Join(env.backupDir, "restore-test.log")); !strings.Contains(restoreLog, "復元確認に成功") || strings.Contains(restoreLog, "SET\n") || strings.Contains(restoreLog, "CREATE TABLE\n") {
t.Fatalf("restore log=%q", restoreLog)
}
})
for _, mode := range []string{"restore-fail", "count-mismatch"} {
t.Run(mode+" cleans temporary database", func(t *testing.T) {
env := newScriptEnv(t, mode)
writeGzip(t, filepath.Join(env.backupDir, "tver_20261004_010203.sql.gz"), "dump")
output, err := env.run(t, "restore-test.sh")
if err == nil {
t.Fatalf("restore test succeeded unexpectedly: %s", output)
}
log := strings.TrimSpace(readFile(t, env.dockerLog))
if !strings.HasSuffix(log, "dropdb --force --if-exists -U tver tver_restore_test") {
t.Fatalf("cleanup was not last command: %q", log)
}
if mode == "count-mismatch" && !strings.Contains(output, "ERROR: known_episodes") {
t.Fatalf("mismatch not reported: %q", output)
}
if mode == "restore-fail" {
if !strings.Contains(output, "restore failed") {
t.Fatalf("restore error missing from output: %q", output)
}
if restoreLog := readFile(t, filepath.Join(env.backupDir, "restore-test.log")); !strings.Contains(restoreLog, "restore failed") {
t.Fatalf("restore error missing from log: %q", restoreLog)
}
}
})
}
t.Run("rejects production database as restore target", func(t *testing.T) {
env := newScriptEnv(t, "success")
writeGzip(t, filepath.Join(env.backupDir, "tver_20261004_010203.sql.gz"), "dump")
output, err := env.run(t, "restore-test.sh", "RESTORE_DB=tver")
if err == nil || !strings.Contains(output, "RESTORE_DB must differ from DB_NAME") {
t.Fatalf("production restore target was accepted: err=%v output=%q", err, output)
}
if _, err := os.Stat(env.dockerLog); !os.IsNotExist(err) {
t.Fatalf("docker was invoked: %v", err)
}
if log := readFile(t, filepath.Join(env.backupDir, "restore-test.log")); !strings.Contains(log, "RESTORE_DB must differ from DB_NAME") {
t.Fatalf("rejection was not logged: %q", log)
}
})
t.Run("missing dump fails and cleans temporary database", func(t *testing.T) {
env := newScriptEnv(t, "success")
output, err := env.run(t, "restore-test.sh")
if err == nil || !strings.Contains(output, "backup dump was not found") {
t.Fatalf("missing dump: err=%v output=%q", err, output)
}
log := strings.TrimSpace(readFile(t, env.dockerLog))
if log != "exec tver-postgres dropdb --force --if-exists -U tver tver_restore_test" {
t.Fatalf("unexpected cleanup: %q", log)
}
if restoreLog := readFile(t, filepath.Join(env.backupDir, "restore-test.log")); !strings.Contains(restoreLog, "backup dump was not found") {
t.Fatalf("failure was not logged: %q", restoreLog)
}
})
t.Run("honors database and container overrides", func(t *testing.T) {
env := newScriptEnv(t, "success")
writeGzip(t, filepath.Join(env.backupDir, "tver_20261004_010203.sql.gz"), "dump")
output, err := env.run(t, "restore-test.sh", "CONTAINER=custom-postgres", "DB_USER=custom-user", "DB_NAME=production", "RESTORE_DB=temporary")
if err != nil {
t.Fatalf("restore failed: %v\n%s", err, output)
}
for _, line := range strings.Split(strings.TrimSpace(readFile(t, env.dockerLog)), "\n") {
if !strings.Contains(line, " custom-postgres ") || strings.Contains(line, " tver-postgres ") || !strings.Contains(line, " -U custom-user ") {
t.Fatalf("override missing: %q", line)
}
if (strings.Contains(line, " createdb ") || strings.Contains(line, " dropdb ")) && !strings.HasSuffix(line, "temporary") {
t.Fatalf("wrong temporary DB: %q", line)
}
}
})
t.Run("invalid gzip fails and cleans temporary database", func(t *testing.T) {
env := newScriptEnv(t, "success")
writeFile(t, filepath.Join(env.backupDir, "tver_20261004_010203.sql.gz"), "not gzip")
output, err := env.run(t, "restore-test.sh")
if err == nil {
t.Fatalf("invalid gzip succeeded: %s", output)
}
log := strings.TrimSpace(readFile(t, env.dockerLog))
if !strings.HasSuffix(log, "dropdb --force --if-exists -U tver tver_restore_test") {
t.Fatalf("cleanup was not last command: %q", log)
}
})
}
type scriptEnv struct {
dir, backupDir, dockerLog, restoreInput string
baseEnv []string
}
func newScriptEnv(t *testing.T, mode string) scriptEnv {
t.Helper()
dir := t.TempDir()
backupDir := filepath.Join(dir, "backups")
if err := os.MkdirAll(backupDir, 0o755); err != nil {
t.Fatal(err)
}
scriptDir, err := filepath.Abs(".")
if err != nil {
t.Fatal(err)
}
dockerDir := filepath.Join(scriptDir, "testdata")
return scriptEnv{
dir: dir,
backupDir: backupDir,
dockerLog: filepath.Join(dir, "docker.log"),
restoreInput: filepath.Join(dir, "restore.sql"),
baseEnv: []string{
"PATH=" + dockerDir + string(os.PathListSeparator) + os.Getenv("PATH"),
"BACKUP_DIR=" + backupDir,
"LOG_FILE=" + filepath.Join(backupDir, "restore-test.log"),
"FAKE_DOCKER_LOG=" + filepath.Join(dir, "docker.log"),
"FAKE_RESTORE_INPUT=" + filepath.Join(dir, "restore.sql"),
"FAKE_DOCKER_MODE=" + mode,
},
}
}
func (e scriptEnv) run(t *testing.T, script string, extraEnv ...string) (string, error) {
t.Helper()
cmd := exec.Command("bash", script)
cmd.Env = append(append(os.Environ(), e.baseEnv...), extraEnv...)
var output bytes.Buffer
cmd.Stdout = &output
cmd.Stderr = &output
err := cmd.Run()
return output.String(), err
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
func readFile(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(data)
}
func writeGzip(t *testing.T, path, content string) {
t.Helper()
file, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
writer := gzip.NewWriter(file)
if _, err := io.WriteString(writer, content); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
if err := file.Close(); err != nil {
t.Fatal(err)
}
}
func readGzip(t *testing.T, path string) string {
t.Helper()
file, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer file.Close()
reader, err := gzip.NewReader(file)
if err != nil {
t.Fatal(err)
}
defer reader.Close()
data, err := io.ReadAll(reader)
if err != nil {
t.Fatal(err)
}
return string(data)
}
func assertNoBackupTemps(t *testing.T, backupDir string) {
t.Helper()
entries, err := os.ReadDir(backupDir)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
name := entry.Name()
if strings.HasPrefix(name, ".tver_") || strings.Contains(name, ".sql.gz.tmp.") {
t.Errorf("temporary backup remains: %s", name)
}
}
}