#!/bin/sh # 备份恢复演练:主机侧运行(root,或能读 /etc/codex-app-server/env 的用户)。 # # 用法(主机上):sh /opt/codex-app-server/deploy/restore-drill.sh # 可选环境变量:BACKUP_DIR(默认 /var/lib/codex-app-server/backups)。 # # 演练范围(P2-ops,诚实边界):本演练只证明“数据库备份可恢复” # (行数比对通过)。它不证明 worktree / 链路 / 上传文件可恢复, # 因为那些根本不在备份里(见 deploy/README.md“备份制度”一节): # 代码备份 tar 明确排除 work/,pg-backup 只备数据库。不要把 # “恢复演练 PASS”读成“整机可恢复”。 # # 步骤: # 1. 跑一次 backup_postgres 生成新鲜备份(.sql)。 # 2. 新建一个测试库 codex_control_restore_test_<时间戳>,用 # cloud.backup.restore_postgres 把备份恢复进去。 # 3. 比对生产库与测试库的 tasks / projects / users / audit_log 行数, # 行数相等即通过;只比行数,不比对具体内容,不打印任何行内容。 # 4. 比对完立即删掉测试库(trap 保证即使中途失败也删)。 # # 安全红线(写进脚本逻辑里,不是只写在注释里): # - 恢复目标永远是新生成的测试库名,绝不等于生产库名;restore_postgres # 本身也会拒绝目标库名等于 CLOUD_DB_NAME(allow_production 绝不在 # 本脚本里设为 True),双重保险。 # - 本脚本绝不恢复到生产库,绝不删生产库;删的只会是本次生成的测试库。 # - 全程只打印 PASS/FAIL 与行数,不打印密码,不打印备份内容。 # - 本脚本以 root 身份读 env 文件是为了拿到数据库连接参数与密码, # 密码只经 PGPASSWORD 环境变量传给 psql/python 子进程,绝不出现在 # 命令行参数里,也绝不被 echo 出来。 set -eu APP_DIR=${APP_DIR:-/opt/codex-app-server} BACKUP_DIR=${BACKUP_DIR:-/var/lib/codex-app-server/backups} ENV_FILE=${ENV_FILE:-/etc/codex-app-server/env} if [ ! -f "$ENV_FILE" ]; then echo "FAIL:找不到 env 文件:$ENV_FILE" >&2 exit 1 fi # shellcheck disable=SC1090 set -a . "$ENV_FILE" set +a if [ -z "${CLOUD_DB_NAME:-}" ] || [ -z "${CLOUD_DB_USER:-}" ]; then echo "FAIL:env 文件里缺少 CLOUD_DB_NAME 或 CLOUD_DB_USER" >&2 exit 1 fi export PGPASSWORD="${CLOUD_DB_PASSWORD:-}" HOST_ARG=${CLOUD_DB_HOST:-127.0.0.1} PORT_ARG=${CLOUD_DB_PORT:-5432} STAMP=$(date +%Y%m%d%H%M%S) TEST_DB="codex_control_restore_test_$STAMP" drop_test_db() { # trap 保证:无论演练成功失败,测试库都会被删掉,不留垃圾库。 psql -h "$HOST_ARG" -p "$PORT_ARG" -U "$CLOUD_DB_USER" \ -d postgres -c "DROP DATABASE IF EXISTS \"$TEST_DB\"" >/dev/null 2>&1 || true } trap drop_test_db EXIT # 1. 新鲜备份(backup_postgres 的连接参数与密码都走环境变量, # 密码只经 PGPASSWORD 传给子进程,绝不出现在命令行或输出里)。 mkdir -p "$BACKUP_DIR" BACKUP_FILE=$(python3 - "$APP_DIR" "$BACKUP_DIR" <<'PY' import sys sys.path.insert(0, sys.argv[1]) from cloud.backup import backup_postgres print(backup_postgres(sys.argv[2])) PY ) if [ ! -f "$BACKUP_FILE" ]; then echo "FAIL:备份生成失败" >&2 exit 1 fi echo "备份已生成(只打印路径,不打印内容):$BACKUP_FILE" # 2. 建测试库并恢复 psql -h "$HOST_ARG" -p "$PORT_ARG" -U "$CLOUD_DB_USER" \ -d postgres -c "CREATE DATABASE \"$TEST_DB\"" >/dev/null python3 - "$APP_DIR" "$BACKUP_FILE" "$TEST_DB" <<'PY' import sys sys.path.insert(0, sys.argv[1]) from cloud.backup import restore_postgres # allow_production 故意不传(默认 False):目标库名绝不能等于生产库名。 print(restore_postgres(sys.argv[2], sys.argv[3])) PY echo "恢复完成,目标测试库:$TEST_DB" # 3. 行数比对(只比行数,不打印行内容) FAIL=0 for TABLE in tasks projects users audit_log; do PROD_N=$(psql -h "$HOST_ARG" -p "$PORT_ARG" -U "$CLOUD_DB_USER" \ -d "$CLOUD_DB_NAME" -t -A -c "SELECT COUNT(*) FROM $TABLE" | tr -d ' \n') REST_N=$(psql -h "$HOST_ARG" -p "$PORT_ARG" -U "$CLOUD_DB_USER" \ -d "$TEST_DB" -t -A -c "SELECT COUNT(*) FROM $TABLE" | tr -d ' \n') if [ "$PROD_N" = "$REST_N" ]; then echo "PASS $TABLE:生产库=$PROD_N 恢复库=$REST_N" else echo "FAIL $TABLE:生产库=$PROD_N 恢复库=$REST_N" >&2 FAIL=1 fi done # 4. 删测试库由 trap 执行;这里先显式删一次,trap 再删是空操作。 drop_test_db trap - EXIT if [ "$FAIL" = "1" ]; then echo "恢复演练 FAIL:有表行数不一致" >&2 exit 1 fi echo "恢复演练 PASS:全部 4 张表行数一致,测试库已删除"