#!/usr/bin/env bash # ============================================================================= # 系统业务库备份脚本 (backup-system-db.sh) # # 作用: # 1. 按环境 (dev/test/main) 定位 Docker 容器与数据库; # 2. 采用 pg_dump -Fc 导出系统业务库(含 task_runs、工作流治理等); # 3. 生成 manifest.json 记录 RPO 基准元数据与 SHA256。 # # 用法: # ./scripts/backup-system-db.sh <目标备份目录> # # 口令(按优先级,不硬编码): # 1. 环境变量 SYSTEM_DB_PASSWORD # 2. ${HOME}/.secrets/backup-env 中 SYSTEM_DB_PASSWORD_ # 3. configs/.env. 中的 SYSTEM_DB_PASSWORD / CONTEXT_DB_PASSWORD # 当前实现经 docker exec 以库内角色执行 pg_dump,通常不需要宿主机口令; # 若后续改为宿主机 psql 连接,必须提供上述之一。 # ============================================================================= set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" show_help() { cat < <目标备份目录> 示例: $0 dev ./backups/system_dev_20260912 $0 test /tmp/agent_system_test_backup 口令来源(勿写入仓库): SYSTEM_DB_PASSWORD 或 \${HOME}/.secrets/backup-env 或 configs/.env. EOF exit 0 } if [[ $# -lt 2 ]] || [[ "$1" == "--help" ]] || [[ "$1" == "-h" ]]; then show_help fi ENV_NAME="$1" TARGET_DIR="$2" case "${ENV_NAME}" in dev) CONTAINER_NAME="shuimu-context-pg-dev" DB_NAME="agent_system_dev" DB_USER="app" ENV_FILE="${ROOT_DIR}/configs/.env.dev" SECRET_KEY="SYSTEM_DB_PASSWORD_DEV" ;; test) CONTAINER_NAME="shuimu-system-pg-test" DB_NAME="agent_system_test" DB_USER="app" ENV_FILE="${ROOT_DIR}/configs/.env.test" SECRET_KEY="SYSTEM_DB_PASSWORD_TEST" ;; main) CONTAINER_NAME="shuimu-system-pg-main" DB_NAME="agent_system_main" DB_USER="app" ENV_FILE="${ROOT_DIR}/configs/.env.main" SECRET_KEY="SYSTEM_DB_PASSWORD_MAIN" ;; *) echo "[错误] 未知环境: ${ENV_NAME}(仅支持 dev/test/main)" exit 1 ;; esac resolve_db_password() { if [[ -n "${SYSTEM_DB_PASSWORD:-}" ]]; then printf '%s' "${SYSTEM_DB_PASSWORD}" return 0 fi local secrets_file="${HOME}/.secrets/backup-env" if [[ -f "${secrets_file}" ]]; then local from_secret from_secret=$(grep -E "^${SECRET_KEY}=" "${secrets_file}" | head -1 | cut -d= -f2- || true) if [[ -n "${from_secret}" ]]; then printf '%s' "${from_secret}" return 0 fi from_secret=$(grep -E '^SYSTEM_DB_PASSWORD=' "${secrets_file}" | head -1 | cut -d= -f2- || true) if [[ -n "${from_secret}" ]]; then printf '%s' "${from_secret}" return 0 fi fi if [[ -f "${ENV_FILE}" ]]; then local from_env from_env=$(grep -E '^SYSTEM_DB_PASSWORD=' "${ENV_FILE}" | head -1 | cut -d= -f2- || true) if [[ -z "${from_env}" ]]; then from_env=$(grep -E '^CONTEXT_DB_PASSWORD=' "${ENV_FILE}" | head -1 | cut -d= -f2- || true) fi if [[ -n "${from_env}" ]]; then printf '%s' "${from_env}" return 0 fi fi return 1 } # docker exec 路径通常不需要宿主机口令;解析失败不阻断备份。 DB_PASS="" if resolved="$(resolve_db_password 2>/dev/null)" && [[ -n "${resolved}" ]]; then DB_PASS="${resolved}" fi unset resolved # 仅保留变量供未来宿主机连接扩展;当前 pg_dump 走容器内角色。 : "${DB_PASS}" mkdir -p "${TARGET_DIR}" TARGET_DIR="$(cd "${TARGET_DIR}" && pwd)" if ! command -v docker >/dev/null 2>&1; then echo "[错误] 未找到 docker 命令" exit 1 fi if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then echo "[警告] 容器 ${CONTAINER_NAME} 未运行,尝试启动 ${ENV_NAME} 环境..." if [[ -f "${ROOT_DIR}/scripts/env-service.sh" ]]; then "${ROOT_DIR}/scripts/env-service.sh" "${ENV_NAME}" start else docker compose -f "${ROOT_DIR}/docker-compose.${ENV_NAME}.yml" up -d system-db fi fi echo "[等待] 检查 ${ENV_NAME} 系统库连接..." for i in {1..30}; do if docker exec -i "${CONTAINER_NAME}" pg_isready -U "${DB_USER}" -d "${DB_NAME}" >/dev/null 2>&1; then break fi sleep 1 if [ "$i" -eq 30 ]; then echo "[错误] 数据库连接超时" exit 1 fi done echo "[统计] 采集 ${ENV_NAME} 系统库指标..." TASK_RUNS=$(docker exec -i "${CONTAINER_NAME}" psql -U "${DB_USER}" -d "${DB_NAME}" -t -A -c \ "SELECT count(*) FROM task_runs;" 2>/dev/null || echo "0") PROJECTS=$(docker exec -i "${CONTAINER_NAME}" psql -U "${DB_USER}" -d "${DB_NAME}" -t -A -c \ "SELECT count(*) FROM projects;" 2>/dev/null || echo "0") DB_SIZE=$(docker exec -i "${CONTAINER_NAME}" psql -U "${DB_USER}" -d "${DB_NAME}" -t -A -c \ "SELECT pg_size_pretty(pg_database_size('${DB_NAME}'));" 2>/dev/null || echo "0 MB") GOOSE_VER=$(docker exec -i "${CONTAINER_NAME}" psql -U "${DB_USER}" -d "${DB_NAME}" -t -A -c \ "SELECT version_id FROM goose_db_version ORDER BY id DESC LIMIT 1;" 2>/dev/null || echo "0") DUMP_FILENAME="system_db_${ENV_NAME}.dump" TMP_CONTAINER_DUMP="/tmp/${DUMP_FILENAME}" DEST_DUMP_PATH="${TARGET_DIR}/${DUMP_FILENAME}" echo "[导出] pg_dump -Fc ${DB_NAME} ..." START_TIME=$(date +%s) docker exec "${CONTAINER_NAME}" pg_dump -U "${DB_USER}" -d "${DB_NAME}" -Fc -Z 6 -f "${TMP_CONTAINER_DUMP}" docker cp "${CONTAINER_NAME}:${TMP_CONTAINER_DUMP}" "${DEST_DUMP_PATH}" docker exec "${CONTAINER_NAME}" rm -f "${TMP_CONTAINER_DUMP}" DUMP_DURATION=$(( $(date +%s) - START_TIME )) DUMP_SIZE_HUMAN=$(ls -lh "${DEST_DUMP_PATH}" | awk '{print $5}') DUMP_SIZE_BYTES=$(stat -c "%s" "${DEST_DUMP_PATH}" 2>/dev/null || stat -f "%z" "${DEST_DUMP_PATH}" 2>/dev/null || echo "0") if command -v sha256sum >/dev/null 2>&1; then DUMP_SHA256=$(sha256sum "${DEST_DUMP_PATH}" | awk '{print $1}') elif command -v shasum >/dev/null 2>&1; then DUMP_SHA256=$(shasum -a 256 "${DEST_DUMP_PATH}" | awk '{print $1}') else DUMP_SHA256="unavailable" fi NOW_ISO=$(date -u +"%Y-%m-%dT%H:%M:%SZ") MANIFEST_FILE="${TARGET_DIR}/manifest.json" cat < "${MANIFEST_FILE}" { "manifest_version": "1.0", "created_at": "${NOW_ISO}", "environment": "${ENV_NAME}", "rpo_target_minutes": 60, "database": { "container_name": "${CONTAINER_NAME}", "database_name": "${DB_NAME}", "user": "${DB_USER}", "dump_file": "${DUMP_FILENAME}", "dump_sha256": "${DUMP_SHA256}", "dump_size_bytes": ${DUMP_SIZE_BYTES}, "dump_size_human": "${DUMP_SIZE_HUMAN}", "goose_version": ${GOOSE_VER:-0}, "task_runs_count": ${TASK_RUNS:-0}, "projects_count": ${PROJECTS:-0}, "database_size": "${DB_SIZE}" } } EOF echo "============================================================" echo ">>> [SUCCESS] 系统库备份完成 (${ENV_NAME})" echo ">>> Dump: ${DEST_DUMP_PATH} (${DUMP_SIZE_HUMAN}, ${DUMP_DURATION}s)" echo ">>> Manifest: ${MANIFEST_FILE}" echo ">>> 恢复演练: ./scripts/restore-system-db-drill.sh \"${TARGET_DIR}\"" echo "============================================================"