DB のバックアップと復元確認のスクリプトを追加(NAS の DSM タスクスケジューラ用)
This commit is contained in:
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/bin/bash
|
||||
# tver DBバックアップ(Synology ds923p / DSMタスクスケジューラで日次実行)
|
||||
#
|
||||
# 配置先: /volume1/docker/tver-db/backup-db.sh
|
||||
# DSM設定: コントロールパネル → タスクスケジューラ → 作成
|
||||
# → スケジュールされたタスク → ユーザー定義のスクリプト
|
||||
# ユーザーはroot、実行周期は毎日とする。
|
||||
# 実行コマンド: bash /volume1/docker/tver-db/backup-db.sh
|
||||
#
|
||||
# コンテナ内のpg_dumpを使い、サーバーとクライアントのバージョンを合わせる。
|
||||
# PostgreSQLのローカル接続を使うため、DBパスワードはスクリプトへ書かない。
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BACKUP_DIR="${BACKUP_DIR:-/volume1/backups/tver}"
|
||||
KEEP_DAYS="${KEEP_DAYS:-14}"
|
||||
CONTAINER="${CONTAINER:-tver-postgres}"
|
||||
DB_USER="${DB_USER:-tver}"
|
||||
DB_NAME="${DB_NAME:-tver}"
|
||||
DOCKER="${DOCKER:-$(command -v docker || printf '/usr/local/bin/docker')}"
|
||||
STAMP="${STAMP:-$(date +%Y%m%d_%H%M%S)}"
|
||||
OUT="${BACKUP_DIR}/tver_${STAMP}.sql.gz"
|
||||
RAW_TMP="${BACKUP_DIR}/.tver_${STAMP}.$$.sql"
|
||||
GZ_TMP="${OUT}.tmp.$$"
|
||||
|
||||
case "$KEEP_DAYS" in
|
||||
''|*[!0-9]*)
|
||||
echo "ERROR: KEEP_DAYS must be a non-negative integer: $KEEP_DAYS" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
umask 077
|
||||
|
||||
cleanup() {
|
||||
rm -f "$RAW_TMP" "$GZ_TMP"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# 先に非圧縮の一時ファイルへ出し、pg_dump成功と空でないことを確認する。
|
||||
"$DOCKER" exec "$CONTAINER" pg_dump -U "$DB_USER" -d "$DB_NAME" > "$RAW_TMP"
|
||||
if [[ ! -s "$RAW_TMP" ]]; then
|
||||
echo "ERROR: dump is empty" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
gzip -9 -c "$RAW_TMP" > "$GZ_TMP"
|
||||
if [[ ! -s "$GZ_TMP" ]]; then
|
||||
echo "ERROR: compressed dump is empty" >&2
|
||||
exit 1
|
||||
fi
|
||||
# 完成ファイルは上書きしない。同名が同時に作られても、ハードリンクの
|
||||
# 作成に成功した一方だけを公開する。一時ファイルは同じディレクトリに
|
||||
# あるため同一ファイルシステム内でリンクできる。
|
||||
if ! ln "$GZ_TMP" "$OUT"; then
|
||||
echo "ERROR: backup already exists or could not be published: $OUT" >&2
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$GZ_TMP" "$RAW_TMP"
|
||||
|
||||
# KEEP_DAYS日より古い完了済みバックアップだけを削除する。
|
||||
find "$BACKUP_DIR" -name 'tver_*.sql.gz' -type f -mtime "+$KEEP_DAYS" -delete
|
||||
|
||||
SIZE_BYTES="$(wc -c < "$OUT" | tr -d '[:space:]')"
|
||||
echo "OK: $OUT ($SIZE_BYTES bytes)"
|
||||
Executable
+73
@@ -0,0 +1,73 @@
|
||||
#!/bin/bash
|
||||
# tver DB復元確認(Synology ds923pで手動実行)
|
||||
#
|
||||
# 配置先: /volume1/docker/tver-db/restore-test.sh
|
||||
# 実行コマンド: bash /volume1/docker/tver-db/restore-test.sh
|
||||
# 最新のバックアップを同じPostgreSQLコンテナ内の一時DBへ復元し、主要4表の
|
||||
# 行数を本番DBと比較する。本番DBにはSELECTだけを実行する。
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BACKUP_DIR="${BACKUP_DIR:-/volume1/backups/tver}"
|
||||
LOG_FILE="${LOG_FILE:-${BACKUP_DIR}/restore-test.log}"
|
||||
CONTAINER="${CONTAINER:-tver-postgres}"
|
||||
DB_USER="${DB_USER:-tver}"
|
||||
DB_NAME="${DB_NAME:-tver}"
|
||||
RESTORE_DB="${RESTORE_DB:-tver_restore_test}"
|
||||
DOCKER="${DOCKER:-$(command -v docker || printf '/usr/local/bin/docker')}"
|
||||
TABLES=(download_jobs subscriptions known_episodes schema_migrations)
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
touch "$LOG_FILE"
|
||||
chmod 600 "$LOG_FILE"
|
||||
exec > >(tee -a "$LOG_FILE") 2>&1
|
||||
|
||||
if [[ "$RESTORE_DB" == "$DB_NAME" ]]; then
|
||||
echo "ERROR: RESTORE_DB must differ from DB_NAME" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 終了状態にかかわらず、一時DBの削除を最後に試みる。
|
||||
cleanup() {
|
||||
status=$?
|
||||
trap - EXIT
|
||||
set +e
|
||||
"$DOCKER" exec "$CONTAINER" dropdb --force --if-exists -U "$DB_USER" "$RESTORE_DB"
|
||||
cleanup_status=$?
|
||||
if ((status != 0)); then
|
||||
echo "復元確認に失敗"
|
||||
exit "$status"
|
||||
fi
|
||||
exit "$cleanup_status"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
LATEST="$(find "$BACKUP_DIR" -name 'tver_*.sql.gz' -type f -print | sort | tail -n 1)"
|
||||
if [[ -z "$LATEST" ]]; then
|
||||
echo "ERROR: backup dump was not found in $BACKUP_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "復元確認を開始: $LATEST"
|
||||
"$DOCKER" exec "$CONTAINER" dropdb --force --if-exists -U "$DB_USER" "$RESTORE_DB"
|
||||
"$DOCKER" exec "$CONTAINER" createdb -U "$DB_USER" "$RESTORE_DB"
|
||||
|
||||
gzip -dc "$LATEST" \
|
||||
| "$DOCKER" exec -i "$CONTAINER" psql -q -o /dev/null -v ON_ERROR_STOP=1 -U "$DB_USER" -d "$RESTORE_DB"
|
||||
|
||||
for table in "${TABLES[@]}"; do
|
||||
query="SELECT count(*) FROM public.${table};"
|
||||
production_count="$("$DOCKER" exec "$CONTAINER" psql -A -t -q -U "$DB_USER" -d "$DB_NAME" -c "$query" | tr -d '[:space:]')"
|
||||
restored_count="$("$DOCKER" exec "$CONTAINER" psql -A -t -q -U "$DB_USER" -d "$RESTORE_DB" -c "$query" | tr -d '[:space:]')"
|
||||
if [[ ! "$production_count" =~ ^[0-9]+$ || ! "$restored_count" =~ ^[0-9]+$ ]]; then
|
||||
echo "ERROR: $table の行数を取得できませんでした: production=$production_count restore=$restored_count" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$production_count" != "$restored_count" ]]; then
|
||||
echo "ERROR: $table の行数が一致しません: production=$production_count restore=$restored_count" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: $table production=$production_count restore=$restored_count"
|
||||
done
|
||||
|
||||
echo "復元確認に成功: $LATEST"
|
||||
@@ -0,0 +1,401 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
printf '%s\n' "$*" >> "${FAKE_DOCKER_LOG:?}"
|
||||
|
||||
command_line=" $* "
|
||||
if [[ "$command_line" == *" pg_dump "* ]]; then
|
||||
case "${FAKE_DOCKER_MODE:-success}" in
|
||||
dump-fail)
|
||||
printf 'pg_dump failed\n' >&2
|
||||
exit 42
|
||||
;;
|
||||
dump-partial-fail)
|
||||
printf '%s\n' '-- incomplete dump --'
|
||||
printf 'pg_dump failed after output\n' >&2
|
||||
exit 42
|
||||
;;
|
||||
empty)
|
||||
exit 0
|
||||
;;
|
||||
concurrent)
|
||||
barrier="${FAKE_DOCKER_BARRIER:?}"
|
||||
mkdir -p "$barrier"
|
||||
touch "$barrier/$$"
|
||||
for _ in {1..500}; do
|
||||
count="$(find "$barrier" -type f | wc -l | tr -d '[:space:]')"
|
||||
if ((count >= 2)); then
|
||||
break
|
||||
fi
|
||||
sleep 0.01
|
||||
done
|
||||
if ((count < 2)); then
|
||||
printf 'concurrent backup barrier timed out\n' >&2
|
||||
exit 46
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
printf '%s\n' '-- fake PostgreSQL dump --' 'CREATE TABLE example(id integer);'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$command_line" == *" createdb "* ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$command_line" == *" dropdb "* ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$command_line" == *" psql "* && "$command_line" == *" ON_ERROR_STOP=1 "* ]]; then
|
||||
cat > "${FAKE_RESTORE_INPUT:?}"
|
||||
if [[ "${FAKE_DOCKER_MODE:-success}" == "restore-fail" ]]; then
|
||||
printf 'restore failed\n' >&2
|
||||
exit 43
|
||||
fi
|
||||
if [[ "$command_line" != *" -o /dev/null "* ]]; then
|
||||
printf '%s\n' 'SET' 'CREATE TABLE'
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$command_line" == *" psql "* && "$command_line" == *" SELECT count(*) FROM public."* ]]; then
|
||||
database=""
|
||||
query=""
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
-d)
|
||||
database="$2"
|
||||
shift 2
|
||||
;;
|
||||
-c)
|
||||
query="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
table="${query#*public.}"
|
||||
table="${table%%;*}"
|
||||
case "$table" in
|
||||
download_jobs) count=3 ;;
|
||||
subscriptions) count=2 ;;
|
||||
known_episodes) count=5 ;;
|
||||
schema_migrations) count=3 ;;
|
||||
*) printf 'unexpected table: %s\n' "$table" >&2; exit 44 ;;
|
||||
esac
|
||||
if [[ "${FAKE_DOCKER_MODE:-success}" == "count-mismatch" && "$database" == "${RESTORE_DB:-tver_restore_test}" && "$table" == "known_episodes" ]]; then
|
||||
count=4
|
||||
fi
|
||||
printf '%s\n' "$count"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf 'unexpected docker invocation: %s\n' "$*" >&2
|
||||
exit 45
|
||||
Reference in New Issue
Block a user