Load backup credentials from env/secrets files and push via a temporary credential helper so tokens never appear in process listings. Co-authored-by: Cursor <cursoragent@cursor.com>
209 lines
7.2 KiB
Bash
Executable File
209 lines
7.2 KiB
Bash
Executable File
#!/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 <dev|test|main> <目标备份目录>
|
||
#
|
||
# 口令(按优先级,不硬编码):
|
||
# 1. 环境变量 SYSTEM_DB_PASSWORD
|
||
# 2. ${HOME}/.secrets/backup-env 中 SYSTEM_DB_PASSWORD_<ENV>
|
||
# 3. configs/.env.<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 <<EOF
|
||
用法:
|
||
$0 <dev|test|main> <目标备份目录>
|
||
|
||
示例:
|
||
$0 dev ./backups/system_dev_20260912
|
||
$0 test /tmp/agent_system_test_backup
|
||
|
||
口令来源(勿写入仓库):
|
||
SYSTEM_DB_PASSWORD 或 \${HOME}/.secrets/backup-env 或 configs/.env.<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 <<EOF > "${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 "============================================================"
|