bookworm-smart-assistant/scripts/deploy-transactional.sh

287 lines
10 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# ═══════════════════════════════════════════════════════════════════
# B7: 事务性部署脚本模板 (Transactional Deploy)
# 用途: 在 ECS 上执行零停机部署,支持原子回滚
#
# 安全设计:
# - 蓝绿部署: 新版本部署到 staging 目录,切换符号链接激活
# - Gold Release: 维护已知良好版本标记,回滚始终回到 gold
# - Migration 检测: 含 DB migration 时禁止自动回滚
# - nohup 安全: SSH 断开不中断部署
# - 全程审计日志
#
# 用法 (由 deploy-wrapper.sh 白名单调用):
# bash deploy-transactional.sh deploy
# bash deploy-transactional.sh rollback <commit-hash>
# bash deploy-transactional.sh status
# bash deploy-transactional.sh health
# ═══════════════════════════════════════════════════════════════════
set -euo pipefail
# ─── 配置 ─────────────────────────────────────────────────────
APP_NAME="${APP_NAME:-bookworm-web}"
APP_BASE="/var/www/${APP_NAME}"
APP_CURRENT="${APP_BASE}/current" # 符号链接 → 当前活跃版本
APP_RELEASES="${APP_BASE}/releases" # 版本目录
APP_SHARED="${APP_BASE}/shared" # 共享资源 (.env, uploads, logs)
APP_REPO="${APP_BASE}/repo" # Git 仓库
GOLD_FILE="${APP_BASE}/gold-release.txt"
DEPLOY_LOG="/var/log/${APP_NAME}-deploy.log"
MAX_RELEASES=5 # 保留最近 N 个版本
HEALTH_URL="${HEALTH_URL:-http://localhost:3000/api/health}"
HEALTH_TIMEOUT=30 # 健康检查超时秒数
# ─── 工具函数 ─────────────────────────────────────────────────
ts() { date '+%Y-%m-%d %H:%M:%S'; }
log() { echo "[$(ts)] $*" | tee -a "${DEPLOY_LOG}"; }
die() { log "FATAL: $*"; exit 1; }
# ─── 部署命令 ─────────────────────────────────────────────────
cmd_deploy() {
log "═══ 开始部署 ${APP_NAME} ═══"
# 1. 拉取最新代码
log "Step 1/7: 拉取代码..."
cd "${APP_REPO}"
git fetch origin main --prune
git reset --hard origin/main
local COMMIT
COMMIT=$(git rev-parse HEAD)
local SHORT_COMMIT="${COMMIT:0:8}"
log "目标 commit: ${SHORT_COMMIT}"
# 2. 检测是否包含 migration
local HAS_MIGRATION=false
if git diff HEAD~1 --name-only 2>/dev/null | grep -qi 'migration\|migrate\|schema'; then
HAS_MIGRATION=true
log "WARNING: 检测到数据库 migration回滚将受限"
fi
# 3. 创建新版本目录 (蓝绿)
local RELEASE_DIR="${APP_RELEASES}/$(date +%Y%m%d_%H%M%S)_${SHORT_COMMIT}"
log "Step 2/7: 创建版本目录 ${RELEASE_DIR}..."
mkdir -p "${RELEASE_DIR}"
rsync -a --exclude=node_modules --exclude=.git "${APP_REPO}/" "${RELEASE_DIR}/"
# 4. 安装依赖 + 构建
log "Step 3/7: 安装依赖..."
cd "${RELEASE_DIR}"
if [ -f "pnpm-lock.yaml" ]; then
pnpm install --frozen-lockfile --prod 2>&1 | tail -5
elif [ -f "package-lock.json" ]; then
npm ci --production 2>&1 | tail -5
fi
log "Step 4/7: 构建..."
if grep -q '"build"' package.json 2>/dev/null; then
pnpm build 2>&1 | tail -10 || npm run build 2>&1 | tail -10
fi
# 5. 链接共享资源
log "Step 5/7: 链接共享资源..."
[ -f "${APP_SHARED}/.env" ] && ln -sf "${APP_SHARED}/.env" "${RELEASE_DIR}/.env"
[ -d "${APP_SHARED}/uploads" ] && ln -sf "${APP_SHARED}/uploads" "${RELEASE_DIR}/uploads"
# 6. 数据库 migration (如果有)
if [ "${HAS_MIGRATION}" = true ]; then
log "Step 5.5/7: 执行数据库 migration..."
if [ -f "${RELEASE_DIR}/prisma/schema.prisma" ]; then
cd "${RELEASE_DIR}" && npx prisma migrate deploy 2>&1 | tail -5
fi
# migration 成功后标记,用于判断是否可回滚
echo "MIGRATION_APPLIED=${COMMIT}" >> "${RELEASE_DIR}/.deploy-meta"
fi
# 7. 原子切换符号链接
log "Step 6/7: 切换活跃版本..."
local TMP_LINK="${APP_CURRENT}.new"
ln -sfn "${RELEASE_DIR}" "${TMP_LINK}"
mv -Tf "${TMP_LINK}" "${APP_CURRENT}"
log "符号链接已切换: current → ${RELEASE_DIR}"
# 8. 重启应用
log "Step 7/7: 重启应用..."
if command -v pm2 &>/dev/null; then
cd "${APP_CURRENT}" && pm2 reload ecosystem.config.js --update-env 2>&1 || \
pm2 reload all 2>&1
fi
# 9. 健康检查
log "等待应用启动..."
local healthy=false
for i in $(seq 1 ${HEALTH_TIMEOUT}); do
if curl -sf --max-time 5 "${HEALTH_URL}" >/dev/null 2>&1; then
healthy=true
break
fi
sleep 1
done
if [ "${healthy}" = true ]; then
log "健康检查通过!"
# 标记为 gold release
echo "${COMMIT}" > "${GOLD_FILE}"
log "已标记 gold release: ${SHORT_COMMIT}"
# 清理旧版本 (保留最近 N 个)
cleanup_old_releases
log "═══ 部署成功 (${SHORT_COMMIT}) ═══"
echo "DEPLOY_SUCCESS"
else
log "ERROR: 健康检查超时 (${HEALTH_TIMEOUT}s)"
# 回滚 (仅非 migration 场景)
if [ "${HAS_MIGRATION}" = true ]; then
log "ERROR: 含 migration无法自动回滚。请手动处理。"
echo "DEPLOY_FAIL_MIGRATION_NO_ROLLBACK"
exit 1
fi
# 自动回滚到前一个版本
log "正在自动回滚..."
cmd_rollback_to_previous
echo "DEPLOY_FAIL_ROLLED_BACK"
exit 1
fi
}
# ─── 回滚命令 ─────────────────────────────────────────────────
cmd_rollback() {
local target_commit="${1:-}"
if [ -z "${target_commit}" ]; then
# 无指定 commit → 回滚到 gold release
if [ -f "${GOLD_FILE}" ]; then
target_commit=$(cat "${GOLD_FILE}")
log "回滚到 gold release: ${target_commit:0:8}"
else
die "无 gold release 记录,无法回滚"
fi
fi
# 校验 commit hash 格式
if [[ ! "${target_commit}" =~ ^[0-9a-f]{40}$ ]]; then
die "无效的 commit hash: ${target_commit}"
fi
# 查找对应的版本目录
local target_dir=""
for dir in "${APP_RELEASES}"/*/; do
if [[ "${dir}" == *"${target_commit:0:8}"* ]]; then
target_dir="${dir%/}"
break
fi
done
if [ -n "${target_dir}" ] && [ -d "${target_dir}" ]; then
# 检查目标版本是否含 migration
if [ -f "${target_dir}/.deploy-meta" ] && grep -q "MIGRATION_APPLIED" "${target_dir}/.deploy-meta"; then
log "WARNING: 目标版本含已执行的 migration回滚可能导致 schema 不一致"
fi
# 切换符号链接
local TMP_LINK="${APP_CURRENT}.new"
ln -sfn "${target_dir}" "${TMP_LINK}"
mv -Tf "${TMP_LINK}" "${APP_CURRENT}"
pm2 reload all 2>&1 || true
log "已回滚到: ${target_dir}"
echo "ROLLBACK_SUCCESS"
else
# 版本目录不存在,从 git 恢复
log "版本目录不存在,从 git checkout 恢复..."
cd "${APP_REPO}" && git checkout "${target_commit}" -- .
cmd_deploy
fi
}
cmd_rollback_to_previous() {
# 获取当前版本之前的最新版本目录
local current_target
current_target=$(readlink -f "${APP_CURRENT}" 2>/dev/null || echo "")
local prev_dir=""
for dir in $(ls -dt "${APP_RELEASES}"/*/); do
dir="${dir%/}"
if [ "${dir}" != "${current_target}" ]; then
prev_dir="${dir}"
break
fi
done
if [ -n "${prev_dir}" ]; then
log "回滚到前一版本: ${prev_dir}"
local TMP_LINK="${APP_CURRENT}.new"
ln -sfn "${prev_dir}" "${TMP_LINK}"
mv -Tf "${TMP_LINK}" "${APP_CURRENT}"
pm2 reload all 2>&1 || true
echo "ROLLBACK_SUCCESS"
elif [ -f "${GOLD_FILE}" ]; then
log "无前一版本,回滚到 gold release"
cmd_rollback "$(cat "${GOLD_FILE}")"
else
die "无可用的回滚目标"
fi
}
# ─── 状态/健康命令 ────────────────────────────────────────────
cmd_status() {
echo "=== ${APP_NAME} 部署状态 ==="
echo "当前版本: $(readlink -f "${APP_CURRENT}" 2>/dev/null || echo 'N/A')"
echo "Gold release: $(cat "${GOLD_FILE}" 2>/dev/null | head -c 8 || echo 'N/A')"
echo "可用版本:"
ls -dt "${APP_RELEASES}"/*/ 2>/dev/null | head -${MAX_RELEASES} | while read -r d; do
echo " - $(basename "${d}")"
done
echo "PM2 状态:"
pm2 status 2>&1 || echo " pm2 不可用"
}
cmd_health() {
local code
code=$(curl -sf -o /dev/null -w "%{http_code}" --max-time 10 "${HEALTH_URL}" 2>/dev/null || echo "000")
if [ "${code}" = "200" ]; then
echo "HEALTH_OK (${HEALTH_URL}${code})"
else
echo "HEALTH_FAIL (${HEALTH_URL}${code})"
exit 1
fi
}
# ─── 清理旧版本 ──────────────────────────────────────────────
cleanup_old_releases() {
local count
count=$(ls -d "${APP_RELEASES}"/*/ 2>/dev/null | wc -l)
if [ "${count}" -gt "${MAX_RELEASES}" ]; then
local to_remove=$((count - MAX_RELEASES))
log "清理 ${to_remove} 个旧版本..."
ls -dt "${APP_RELEASES}"/*/ | tail -${to_remove} | while read -r d; do
# 不删除 gold release 对应的版本
local gold_commit
gold_commit=$(cat "${GOLD_FILE}" 2>/dev/null || echo "")
if [ -n "${gold_commit}" ] && [[ "${d}" == *"${gold_commit:0:8}"* ]]; then
log "保留 gold release 版本: $(basename "${d}")"
continue
fi
log "删除旧版本: $(basename "${d}")"
rm -rf "${d}"
done
fi
}
# ─── 入口 ─────────────────────────────────────────────────────
case "${1:-}" in
deploy) cmd_deploy ;;
rollback) cmd_rollback "${2:-}" ;;
status) cmd_status ;;
health) cmd_health ;;
*)
echo "用法: $0 {deploy|rollback [commit]|status|health}"
exit 1
;;
esac