diff --git a/PROJECT.md b/PROJECT.md index 9b180cb..3b1873f 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -41,7 +41,7 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ | NAS のマウント | **NFS**。dockageの`/etc/fstab`へ`_netdev,nofail`付きで登録し、DSMでは接続元`192.168.10.155`だけを許可する | | NFS 権限 | 読み書き、Squash「すべてのユーザーをadminに割り当てる」、`sys`、非同期オン。DSMのadminアカウントが無効でもUID `65532`から書き込み確認済み | | DBの通信制限 | DSMファイアウォールでTCP `15434`を`192.168.10.155`からだけ許可する | -| DBバックアップ | 稼働後に、コンテナ内の`pg_dump`からNASへ保存する日次処理を用意する | +| DBバックアップ | NASのDSMタスクスケジューラからrootで毎日実行する。`tver-postgres`内の`pg_dump`をgzip圧縮して`/volume1/backups/tver/tver_.sql.gz`へ保存し、14日より古い世代を削除する。同名の完成ファイルは上書きせず失敗させる。初回は一時DBへの復元と主要4表の行数一致まで確認する | | 保持期間 | **削除は手動のみ**。自動削除はしない | | 待ち行列 | **Postgres のテーブルだけで実装する**。専用のキュー基盤は持たない | | 定期確認の間隔 | 登録番組の新しい回を **24 時間ごと**に確認する | @@ -105,6 +105,7 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ - 画面は `html/template` と htmx 2.0.11 を使い、htmx は Go の `embed` から `/static/` でローカル配信する - `compose.yaml` は web と worker を定義する。web は dockage VM の LAN アドレス `192.168.10.155:8080` で公開し、待ち受けは `WEB_ADDR` で変更できる。両方へ `DATABASE_URL` と同じ動画ボリュームを渡し、worker の並行数は `WORKER_CONCURRENCY`(既定1、最大2)で指定する - NAS用の`deploy/nas/tver-db/compose.yaml`は`postgres:17`を`15434:5432`で公開し、`/volume1/docker/tver-db/pgdata`へ保存する。パスワードは同ディレクトリのGit管理外`.env`から注入する +- NAS用の`backup-db.sh`はコンテナ内の`pg_dump`を検査後にgzip圧縮し、同一ディレクトリ内のハードリンクで完成ファイルを上書きせず公開する。`restore-test.sh`は最新ダンプを一時DBへ復元して`download_jobs`・`subscriptions`・`known_episodes`・`schema_migrations`の行数を本番DBと比較する。一時DBはEXIT trapで必ず削除し、復元SQLの通常出力はログへ記録しない - 本番配置と更新の手順は[[docs/deploy|tver 本番配置手順]]にまとめる。dockageでは`/volume1/video`を`/mnt/nas-tver`へNFSマウントし、その配下の`tver`だけをコンテナ内の`/videos`へ渡す - Giteaは匿名閲覧とSSHを無効にしているため、dockageのclone・pullは読み取り専用`tver-deploy`のrepository読み取りトークンを使う。Gitのcredential helperはGiteaのHTTPS URLに限定する - DB は NAS 上に tver 専用 Postgres 17 コンテナを置く。`internal/store` の埋め込み SQL を `schema_migrations` で管理し、`pg_advisory_lock` で複数プロセスからの同時適用を直列化する。web は起動時に `pgxpool` で接続してマイグレーションを適用する @@ -143,10 +144,11 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ - 本番の`/healthz`はHTTP `200`、新着バラエティは98件だった。4分の本編`ep5hcgopp2`は登録から20秒以内に完了し、92,340,468バイトの1080p H.264/AAC MP4をNASへ保存した。所有者はUID `1024`・GID `100`、`.incoming`は空で、`/library`とHTTP Range `206`も確認した [実機] 2026-10-04 確認 - NASは番組フォルダ内に所有者UID `0`の`@eaDir`を作る。ライブラリ一覧はDBから作るため、アプリへの影響はない [実機] 2026-10-04 確認 - `callNewerDetail/drama`は毎回100件を返し、応答に続きや総件数を示す項目はなかった。`result.contents`は`type=newer`で、`content`は`id`・`version`・`title`のみ。`page=2`・`offset=100`・`limit=200`を付けても先頭・末尾IDが同じ100件となり、パラメータは無視された。100件を超えて取得する手段は見つからなかったが、非公開APIのため不可能とは断定しない。新着一覧は直近100件までとなる一方、登録番組の自動確認は番組ごとの`callSeriesSeasons`→`callSeasonEpisodes`を使うため、新着から外れた回も取りこぼさない [実機] 2026-10-04 確認 +- dockage上の使い捨て`postgres:17`コンテナへマイグレーション001〜003を適用してテスト用の行を投入し、`backup-db.sh`と`restore-test.sh`を実行した。gzip圧縮・権限600のダンプ作成、同じ`STAMP`での2回目が`ln`の失敗で非0終了して既存ダンプを保持すること、主要4表の行数一致、一時DBの削除、ログに`psql`の標準出力が残らないことを確認した。NASの`tver-postgres`とDSMタスクスケジューラでの実行は未確認 [実機] 2026-10-04 確認 ## 課題 -- **DBバックアップは未着手。** コンテナ内の`pg_dump`→gzip→NAS `/volume1/backups/tver/`、既定14日保持の日次処理を用意する +- DSMの日次バックアップタスクを登録し、本物の`tver-postgres`でバックアップ作成と`restore-test.sh`による一時DBへの復元を1回確認する [要確認] - DRM・地域制限の実例探しはTVerへの大量アクセスになるため行わず、運用中に観測する。workerは失敗時の`error_message`をDBへ保存し、既知の文言に一致しない失敗は`unknown`にする。実例が出た時点で分類を見直す ## 確かめ方 @@ -161,6 +163,7 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ - worker のテストは `internal/worker/testdata` の偽 yt-dlp・ffprobe を使い、本物の TVer へ接続せずに正常完了、失敗分類、取消、終了時の待機復帰、進捗の間引き、heartbeat、出力検査、移動先、作業場所の片付けを確認する - 登録番組の定期確認は偽の TVer クライアントと差し替え可能な時計・タイマーを使い、24時間の境界、順次確認、1時間走査、失敗記録、予告の既知化、終了時の中断を確認する。store の抽出・既知化・投入・日時更新・エラー消去は embedded Postgres 17 で確認する - NAS用Composeは`deploy/nas/tver-db/compose.yaml`をYAMLパーサーで読み、`services.postgres`、環境変数、ポート、保存先、healthcheckを確認する。Dockerが使える環境では`docker compose --env-file deploy/nas/tver-db/.env.example -f deploy/nas/tver-db/compose.yaml config`でも展開結果を確認する +- DBバックアップスクリプトは`deploy/nas/tver-db/scripts_test.go`からPATH上の偽dockerを使って実行し、正常な圧縮、同名・同時実行時の上書き防止、空ダンプと`pg_dump`失敗、14日の世代境界、復元失敗時の一時DB削除、主要表の行数不一致を確認する。NAS実機では[[docs/deploy#5. PostgreSQLをバックアップする|配置手順]]に従って手動バックアップと復元確認を行う - 本番の確認手順は[[docs/deploy|tver 本番配置手順]]に従い、NFS上の`/mnt/nas-tver/tver`をUID `65532`で読み書き・rename・削除できること、DBがhealthyであること、webの`/healthz`、実動画の保存・再生・シークを確認する - `env GOPATH=/private/tmp/tver-gopath GOCACHE=/private/tmp/tver-go-cache GOMODCACHE=/private/tmp/tver-go-modcache GOPROXY=off go vet ./...` で静的解析する - `DATABASE_URL`、`VIDEO_DIR`、`WEB_ADDR=127.0.0.1:18080` を設定して `go run ./cmd/web` で web を起動できる。worker は同じ `DATABASE_URL` と `VIDEO_DIR` を設定して `go run ./cmd/worker` で起動する。worker の実行は TVer への実アクセスを伴うため手動確認は少数回に留める diff --git a/deploy/nas/tver-db/backup-db.sh b/deploy/nas/tver-db/backup-db.sh new file mode 100755 index 0000000..001a20e --- /dev/null +++ b/deploy/nas/tver-db/backup-db.sh @@ -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)" diff --git a/deploy/nas/tver-db/restore-test.sh b/deploy/nas/tver-db/restore-test.sh new file mode 100755 index 0000000..d11d751 --- /dev/null +++ b/deploy/nas/tver-db/restore-test.sh @@ -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" diff --git a/deploy/nas/tver-db/scripts_test.go b/deploy/nas/tver-db/scripts_test.go new file mode 100644 index 0000000..90ae84c --- /dev/null +++ b/deploy/nas/tver-db/scripts_test.go @@ -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) + } + } +} diff --git a/deploy/nas/tver-db/testdata/docker b/deploy/nas/tver-db/testdata/docker new file mode 100755 index 0000000..24be3b9 --- /dev/null +++ b/deploy/nas/tver-db/testdata/docker @@ -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 diff --git a/docs/deploy.md b/docs/deploy.md index 7f8526a..016a938 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -56,7 +56,13 @@ tags: /volume1/docker/tver-db/pgdata/ ``` -2. [[../deploy/nas/tver-db/compose.yaml|NAS用compose.yaml]] と `.env.example` を `/volume1/docker/tver-db/` に配置する。 +2. [[../deploy/nas/tver-db/compose.yaml|NAS用compose.yaml]]、`.env.example`、`backup-db.sh`、`restore-test.sh`を`/volume1/docker/tver-db/`に配置し、スクリプトを所有者だけが実行できるようにする。 + + ```bash + chmod 700 /volume1/docker/tver-db/backup-db.sh + chmod 700 /volume1/docker/tver-db/restore-test.sh + ``` + 3. `.env.example`を`.env`へコピーし、`POSTGRES_PASSWORD`に十分長いランダムな値を設定する。値には、URLへの埋め込みで追加のエスケープが不要な16進文字列を使える。 ```bash @@ -232,7 +238,88 @@ git pull --ff-only yt-dlpを更新するときは`Dockerfile.worker`の`YT_DLP_VERSION`の既定値を変更してリポジトリへ反映し、workerイメージをキャッシュなしで再ビルドする。更新後はworkerログと少数の実動画で抽出・保存を確認する。 -## 5. 稼働後に残る作業 +## 5. PostgreSQLをバックアップする -> [!todo] PostgreSQLバックアップ -> tver専用PostgreSQL 17コンテナ内の`pg_dump`で取得し、gzip圧縮してNASの`/volume1/backups/tver/`へ保存する日次処理を用意する。保持日数は既定14日とし、稼働確認後に別作業として実装する。 +バックアップはNAS上のDSMタスクスケジューラから毎日実行する。`tver-postgres`コンテナ内の`pg_dump`を使って`/volume1/backups/tver/tver_.sql.gz`へ保存し、既定では14日より古い世代を削除する。 + +> [!warning] バックアップ先も同じNASにある +> このバックアップはDBの誤操作や破損からの復元用であり、NAS本体の故障には備えられない。`/volume1/backups/tver/`はSynology Hyper Backupの対象にも含める。 + +### 5.1 DSMの日次タスクを登録する + +DSMの「コントロール パネル」→「タスク スケジューラ」で「スケジュールされたタスク」→「ユーザー定義のスクリプト」を作成する。 + +| 設定 | 値 | +|---|---| +| ユーザー | `root` | +| 実行周期 | 毎日 | +| ユーザー定義のスクリプト | `bash /volume1/docker/tver-db/backup-db.sh` | + +`DOCKER`、`CONTAINER`、`BACKUP_DIR`、`KEEP_DAYS`、`DB_USER`、`DB_NAME`は環境変数で上書きできる。通常運用では既定値を使う。 + +> [!todo] NAS実機で確認する +> DSMタスクの登録、1回の手動実行、日次実行の履歴、14日より古い世代の削除は未確認である。[要確認] + +### 5.2 手動でバックアップを確認する + +NASへSSH接続し、初回は手動で実行する。 + +```bash +bash /volume1/docker/tver-db/backup-db.sh +LATEST=$(find /volume1/backups/tver -name 'tver_*.sql.gz' -type f -print | sort | tail -n 1) +test -n "$LATEST" +test -s "$LATEST" +gzip -t "$LATEST" +ls -lh "$LATEST" +``` + +スクリプトが`OK:`、保存先、バイト数を表示し、`gzip -t`が成功することを確認する。`pg_dump`の失敗や空のダンプでは非0で終了し、完成ファイルを残さない。同じ秒の完成ファイルが既にある場合も非0で終了し、既存ファイルを上書きしない。同時実行時は同一ディレクトリ内のハードリンク作成に成功した一方だけが完成ファイルを公開する。 + +### 5.3 別DBへの復元を確認する + +最新ダンプを本番DBへ戻す前に、同じコンテナ内の一時DB`tver_restore_test`へ復元する。 + +```bash +bash /volume1/docker/tver-db/restore-test.sh +tail -n 100 /volume1/backups/tver/restore-test.log +``` + +`download_jobs`、`subscriptions`、`known_episodes`、`schema_migrations`の行数が本番DBと一致し、最後に「復元確認に成功」と表示されることを確認する。復元SQLの通常出力は抑制され、ログには開始、各表の結果、成功または失敗が残る。SQLエラーは標準エラーとしてログに残る。一時DBは成功・失敗のどちらでもEXIT trapで削除される。本番DBに対しては行数取得の`SELECT`だけを実行する。 + +> [!todo] 復元確認を実行する +> 本物のNASと`tver-postgres`では未実行である。手動バックアップ後に`restore-test.sh`を1回実行し、ログと一時DBの削除を確認する。[要確認] + +### 5.4 障害時に本番DBへ復元する + +> [!danger] 本番DBを置き換える操作 +> 先に[[#5.3 別DBへの復元を確認する]]を成功させ、復元対象のダンプを明示してから実行する。`tver`以外のDB名へ読み替えない。 + +1. dockageの`/opt/stacks/tver`でwebとworkerを停止し、DBへの書き込みを止める。 + + ```bash + cd /opt/stacks/tver + docker compose stop web worker + ``` + +2. NASで復元対象を指定し、存在・サイズ・gzip形式を確認する。 + + ```bash + DUMP=/volume1/backups/tver/tver_.sql.gz + test -s "$DUMP" + gzip -t "$DUMP" + ``` + +3. NASで本番DBを作り直し、ダンプを復元する。 + + ```bash + docker exec tver-postgres dropdb --if-exists -U tver tver + docker exec tver-postgres createdb -U tver tver + gzip -dc "$DUMP" | docker exec -i tver-postgres psql -v ON_ERROR_STOP=1 -U tver -d tver + ``` + +4. dockageでwebとworkerを再開し、[[#3. 動作を確認する]]を実施する。 + + ```bash + cd /opt/stacks/tver + docker compose start web worker + ```