diff --git a/docs/PRODUCTION-RELEASE-CHECKLIST.md b/docs/PRODUCTION-RELEASE-CHECKLIST.md index ac866b727..0f1cd99e9 100644 --- a/docs/PRODUCTION-RELEASE-CHECKLIST.md +++ b/docs/PRODUCTION-RELEASE-CHECKLIST.md @@ -31,7 +31,19 @@ ## 3. 数据库备份 -生产 Alembic 升级前必须创建数据库备份: +生产 Alembic 升级前必须创建数据库备份。优先使用脚本: + +```bash +BACKUP_ROOT=/root/xiaoxia-backups \ +POSTGRES_CONTAINER=xiaoxia-postgres \ +POSTGRES_USER=xiaoxia \ +POSTGRES_DB=xiaoxia_saas \ +scripts/backup_postgres.sh +``` + +脚本会输出备份目录,并写入 `manifest.txt` 与 `alembic_version.txt`。 + +手动等价命令: ```bash BACKUP_DIR=/root/xiaoxia-backups/$(date +%Y%m%d-%H%M%S) @@ -94,6 +106,12 @@ docker exec xiaoxia-postgres psql -U xiaoxia -d xiaoxia_saas -Atc 'select versio ## 7. 回滚策略 +生成恢复计划(不会自动恢复数据库): + +```bash +scripts/restore_postgres_plan.sh /root/xiaoxia-backups/ +``` + 应用回滚: - 若数据库未发生不可逆迁移,优先回滚到上一稳定 tag 并重新部署。 @@ -104,7 +122,7 @@ docker exec xiaoxia-postgres psql -U xiaoxia -d xiaoxia_saas -Atc 'select versio - Alembic downgrade 只有在 revision 明确支持且已验证时才能执行。 - 首次生产接入阶段默认使用备份恢复作为最终兜底。 - 恢复前必须二次确认备份路径、目标数据库、停机窗口。 -- 恢复步骤需按生产数据库实际容器名、DB 名、volume 策略另写一次性 runbook;不要在紧急状态下即兴拼命令。 +- 恢复步骤必须先由 `scripts/restore_postgres_plan.sh` 生成并复核,不允许紧急状态下即兴拼命令。 ## 8. 发布记录 diff --git a/scripts/backup_postgres.sh b/scripts/backup_postgres.sh new file mode 100755 index 000000000..1dcc14557 --- /dev/null +++ b/scripts/backup_postgres.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +BACKUP_ROOT="${BACKUP_ROOT:-/root/xiaoxia-backups}" +POSTGRES_CONTAINER="${POSTGRES_CONTAINER:-xiaoxia-postgres}" +POSTGRES_USER="${POSTGRES_USER:-xiaoxia}" +POSTGRES_DB="${POSTGRES_DB:-xiaoxia_saas}" + +TS="$(date +%Y%m%d-%H%M%S)" +BACKUP_DIR="$BACKUP_ROOT/$TS" +DUMP_PATH="$BACKUP_DIR/${POSTGRES_DB}.dump" +VERSION_PATH="$BACKUP_DIR/alembic_version.txt" +MANIFEST_PATH="$BACKUP_DIR/manifest.txt" + +mkdir -p "$BACKUP_DIR" + +docker exec "$POSTGRES_CONTAINER" pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc > "$DUMP_PATH" +docker exec "$POSTGRES_CONTAINER" psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc 'select version_num from alembic_version;' > "$VERSION_PATH" 2>/dev/null || true + +if [ ! -s "$DUMP_PATH" ]; then + echo "ERROR: backup dump is empty: $DUMP_PATH" >&2 + exit 1 +fi + +{ + echo "created_at=$TS" + echo "postgres_container=$POSTGRES_CONTAINER" + echo "postgres_user=$POSTGRES_USER" + echo "postgres_db=$POSTGRES_DB" + echo "dump_path=$DUMP_PATH" + echo "alembic_version=$(cat "$VERSION_PATH" 2>/dev/null || true)" + echo "dump_size_bytes=$(wc -c < "$DUMP_PATH")" +} > "$MANIFEST_PATH" + +ls -lh "$DUMP_PATH" +echo "OK backup created: $BACKUP_DIR" diff --git a/scripts/restore_postgres_plan.sh b/scripts/restore_postgres_plan.sh new file mode 100755 index 000000000..8a05b4737 --- /dev/null +++ b/scripts/restore_postgres_plan.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +BACKUP_DIR="${1:-}" +POSTGRES_CONTAINER="${POSTGRES_CONTAINER:-xiaoxia-postgres}" +POSTGRES_USER="${POSTGRES_USER:-xiaoxia}" +POSTGRES_DB="${POSTGRES_DB:-xiaoxia_saas}" + +if [ -z "$BACKUP_DIR" ]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +DUMP_PATH="$BACKUP_DIR/${POSTGRES_DB}.dump" +MANIFEST_PATH="$BACKUP_DIR/manifest.txt" + +if [ ! -s "$DUMP_PATH" ]; then + echo "ERROR: backup dump not found or empty: $DUMP_PATH" >&2 + exit 1 +fi + +cat </dev/null || echo ' ') + +Manual restore commands (requires maintenance window): + + docker stop xiaoxia-api-production xiaoxia-worker-production || true + docker exec $POSTGRES_CONTAINER pg_restore -U $POSTGRES_USER -d $POSTGRES_DB --clean --if-exists --no-owner --no-privileges < $DUMP_PATH + docker start xiaoxia-api-production xiaoxia-worker-production + docker exec $POSTGRES_CONTAINER psql -U $POSTGRES_USER -d $POSTGRES_DB -Atc 'select version_num from alembic_version;' + +This script intentionally does not execute restore automatically. +EOF diff --git a/tests/unit/test_release_scripts.py b/tests/unit/test_release_scripts.py new file mode 100644 index 000000000..9fea15e5d --- /dev/null +++ b/tests/unit/test_release_scripts.py @@ -0,0 +1,20 @@ +from pathlib import Path + + +def test_restore_postgres_plan_is_non_destructive(): + script = Path("scripts/restore_postgres_plan.sh").read_text(encoding="utf-8") + executable_prefix = script.split("cat <