Files
cma-management/deploy.sh
Hermes CI Fix 7aebe96bb3 feat(deploy): T2 frontend-layer assertions after [3] (insert-only)
3 independent hard gates: (1) per-file md5 reconciliation dist/assets vs webroot/assets, (2) entry chunk from webroot/index.html must exist in dist with same md5, (3) every chunk referenced by the main bundle must return HTTP 200 from SITE_URL. Env overrides FRONTEND_DIST_DIR / CMA_WEBROOT / SITE_URL for offline stub rehearsal (no build). No push to main.
2026-09-18 02:13:19 +08:00

279 lines
13 KiB
Bash
Executable File
Raw Permalink 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.
#!/bin/bash
# 管理会计OS — 一键拉取、构建并部署到生产
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "===== 管理会计OS 生产部署 ====="
echo ""
# 0. 提交纪律检查:本地有未提交修改则中止,防止 git pull 覆盖丢失
echo "[0/5] 提交纪律检查..."
cd "$SCRIPT_DIR"
DIRTY=$(git status --porcelain 2>/dev/null | grep -vE "__pycache__|^\?\?" | head -20)
if [ -n "$DIRTY" ]; then
echo "❌ 检测到本地未提交的修改,中止部署(防止 git pull 覆盖丢失代码):"
echo "$DIRTY"
echo ""
echo "请先提交代码: git add -A && git commit -m '...' && git push origin main"
exit 1
fi
echo " ✓ 工作区干净,无未提交修改"
# 1. 拉取最新代码
echo "[1/5] 拉取最新代码..."
git pull origin main
echo " ✓ 代码已更新"
# 1b. 鉴权面审计闸门(T1, 2026-09-15):B 类缺口(用户端点无鉴权)存在即中止部署
echo "[1b/5] 鉴权面审计闸门..."
cd "$SCRIPT_DIR/backend"
if ! ./venv/bin/python3 scripts/audit_api_auth_surface.py > /tmp/cma-auth-audit.log 2>&1; then
echo "❌ 鉴权面审计发现 B 类缺口(用户端点缺鉴权),中止部署。详见 /tmp/cma-auth-audit.log"
tail -20 /tmp/cma-auth-audit.log
exit 1
fi
cd "$SCRIPT_DIR"
echo " ✓ 鉴权面审计通过(无 B 类缺口)"
# 2. 构建前端
echo "[2/5] 构建前端..."
cd "$SCRIPT_DIR/frontend"
pnpm build
echo " ✓ 构建完成"
# 3. 部署前端到 Nginx
echo "[3/5] 部署前端..."
rm -rf /var/www/cma/assets/
cp -r dist/* /var/www/cma/
echo " ✓ 部署完成"
# 3b. 前端层一致性断言(T2, 2026-09-18):部署链静默替换门
# 背景:[3] 只有 rm -rf + cp,缓存层吐旧 index、或 index 指向已被删的旧 chunk 时,现象是
# 「首页 200、点进页面 404/白屏」,而其后的冒烟 5 项全为后端项(127.0.0.1:8010)→ 整轮部署全绿。
# 三条断言各自独立输出,任一不一致即 exit 1。离线演练只覆盖下面三个变量,无需 build。
# >>> cma-frontend-assert(最小可抽取块:scripts/tests 按行首标记 awk 抽取后原样执行,改结构请沿用标记)
FRONTEND_DIST_DIR="${FRONTEND_DIST_DIR:-$SCRIPT_DIR/frontend/dist}"
CMA_WEBROOT="${CMA_WEBROOT:-/var/www/cma}"
SITE_URL="${SITE_URL:-https://cma.sxbh.ltd}"
echo "[3b/5] 前端层一致性断言(dist ↔ webroot ↔ 引用链)..."
FRONTEND_ASSERT_FAIL=0
# ① 逐文件 md5 对账:dist/assets 与 webroot/assets 文件集合一致(无多、无少)且同名文件 md5 相同
DIST_ASSETS="$FRONTEND_DIST_DIR/assets"
WEB_ASSETS="$CMA_WEBROOT/assets"
DIST_FILES="$(ls -1 "$DIST_ASSETS" 2>/dev/null | sort)"
WEB_FILES="$(ls -1 "$WEB_ASSETS" 2>/dev/null | sort)"
MISSING_FILES="$(comm -23 <(printf '%s\n' "$DIST_FILES") <(printf '%s\n' "$WEB_FILES"))"
EXTRA_FILES="$(comm -13 <(printf '%s\n' "$DIST_FILES") <(printf '%s\n' "$WEB_FILES"))"
MD5_DIFF=""
while IFS= read -r F; do
[ -n "$F" ] || continue
D_MD5="$(md5sum "$DIST_ASSETS/$F" 2>/dev/null | awk '{print $1}')"
W_MD5="$(md5sum "$WEB_ASSETS/$F" 2>/dev/null | awk '{print $1}')"
[ "$D_MD5" = "$W_MD5" ] || MD5_DIFF="${MD5_DIFF}${MD5_DIFF:+, }$F(dist=$D_MD5 web=$W_MD5)"
done <<< "$DIST_FILES"
if [ -n "$MISSING_FILES" ] || [ -n "$EXTRA_FILES" ] || [ -n "$MD5_DIFF" ]; then
echo " ❌ 断言① 逐文件 md5 对账失败(dist=$DIST_ASSETS)"
[ -z "$MISSING_FILES" ] || echo " webroot 缺失: $(printf '%s' "$MISSING_FILES" | tr '\n' ' ')"
[ -z "$EXTRA_FILES" ] || echo " webroot 多出: $(printf '%s' "$EXTRA_FILES" | tr '\n' ' ')"
[ -z "$MD5_DIFF" ] || echo " md5 不一致: $MD5_DIFF"
FRONTEND_ASSERT_FAIL=1
else
echo " ✅ 断言① 逐文件 md5 对账通过($(printf '%s\n' "$DIST_FILES" | awk 'NF{n++} END{print n+0}') 个文件全部一致)"
fi
# ② 主包一致:webroot/index.html 解析出的入口 chunk 必须在 dist 存在且 md5 相同
INDEX_HTML="$CMA_WEBROOT/index.html"
MAIN_BUNDLE="$(grep -o '/assets/index-[A-Za-z0-9._-]*\.js' "$INDEX_HTML" 2>/dev/null | head -n 1)"
if [ -z "$MAIN_BUNDLE" ]; then
echo " ❌ 断言② 主包一致失败: 未能从 $INDEX_HTML 解析出入口 chunk(/assets/index-*.js)"
FRONTEND_ASSERT_FAIL=1
elif [ ! -f "$FRONTEND_DIST_DIR$MAIN_BUNDLE" ]; then
echo " ❌ 断言② 主包一致失败: index.html 引用的 $MAIN_BUNDLE 在 dist 不存在($FRONTEND_DIST_DIR)"
FRONTEND_ASSERT_FAIL=1
else
MAIN_D_MD5="$(md5sum "$FRONTEND_DIST_DIR$MAIN_BUNDLE" 2>/dev/null | awk '{print $1}')"
MAIN_W_MD5="$(md5sum "$CMA_WEBROOT$MAIN_BUNDLE" 2>/dev/null | awk '{print $1}')"
if [ -n "$MAIN_D_MD5" ] && [ "$MAIN_D_MD5" = "$MAIN_W_MD5" ]; then
echo " ✅ 断言② 主包一致通过($MAIN_BUNDLE dist=web=$MAIN_D_MD5)"
else
echo " ❌ 断言② 主包一致失败: $MAIN_BUNDLE dist=$MAIN_D_MD5 web=$MAIN_W_MD5"
FRONTEND_ASSERT_FAIL=1
fi
fi
# ③ 引用链可 200:主包内引用的懒加载 chunk 逐个请求 $SITE_URL,必须全部 HTTP 200(无静默跳过分支)
# 字面量形态取并集:现网 Vite 产物实测为相对形态 from"./Login-CCqwmqG_.js",另有 "/assets/*.js" 形态;
# 两者都归一到 /assets/<name> 再请求。解析不到任何引用 → 显式判失败(不得静默放行)。
MAIN_JS_FILE="$CMA_WEBROOT$MAIN_BUNDLE"
CHUNKS="$(grep -o -E '(\./|/assets/)[A-Za-z0-9._-]+\.js' "$MAIN_JS_FILE" 2>/dev/null | sed -E 's#^\./#/assets/#' | sort -u)"
CHUNK_TOTAL="$(printf '%s\n' "$CHUNKS" | awk 'NF{n++} END{print n+0}')"
if [ ! -f "$MAIN_JS_FILE" ]; then
echo " ❌ 断言③ 引用链可 200 失败: 主包在 webroot 不存在($MAIN_JS_FILE)"
FRONTEND_ASSERT_FAIL=1
elif [ "$CHUNK_TOTAL" -eq 0 ]; then
echo " ❌ 断言③ 引用链可 200 失败: 主包 $MAIN_BUNDLE 内未解析到任何 chunk 引用(./*.js 或 /assets/*.js)"
FRONTEND_ASSERT_FAIL=1
else
CHUNK_OK=0
CHUNK_BAD=""
while IFS= read -r C; do
[ -n "$C" ] || continue
C_CODE="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$SITE_URL$C" 2>/dev/null || echo 000)"
if [ "$C_CODE" = "200" ]; then
CHUNK_OK=$((CHUNK_OK + 1))
else
CHUNK_BAD="${CHUNK_BAD}${CHUNK_BAD:+, }$C(HTTP=$C_CODE)"
fi
done <<< "$CHUNKS"
if [ -n "$CHUNK_BAD" ]; then
echo " ❌ 断言③ 引用链可 200 失败($SITE_URL): $CHUNK_BAD"
FRONTEND_ASSERT_FAIL=1
else
echo " ✅ 断言③ 引用链可 200 通过($CHUNK_OK/$CHUNK_TOTAL chunk @ $SITE_URL)"
fi
fi
# 三条均为硬门:任一不一致即中止部署(nginx 直接服务静态文件,无需 reload)
if [ "$FRONTEND_ASSERT_FAIL" -ne 0 ]; then
echo ""
echo "🚨 前端层一致性断言未通过 → 中止部署(webroot 可能停留在旧包 / 半包)"
echo " dist=$FRONTEND_DIST_DIR webroot=$CMA_WEBROOT site=$SITE_URL"
exit 1
fi
echo " ✓ 前端层一致性断言通过(① 逐文件 md5 ② 主包 ③ 引用链 200)"
# <<< cma-frontend-assert
# 4. 更新后端依赖(失败不阻断 — 依赖已在系统Python中,避免PEP668中断部署)
echo "[4/5] 更新后端依赖..."
cd "$SCRIPT_DIR/backend"
if [ -f venv/bin/activate ]; then
source venv/bin/activate
pip install -r requirements.txt --quiet --no-cache-dir || echo " ⚠️ 依赖更新失败(不阻断)"
deactivate
else
echo " ⚠️ venv为软链精简版,跳过依赖更新"
fi
echo " ✓ 依赖检查完成"
# 4b. 数据库结构迁移(特权,重启前执行;幂等;可回滚)
# cma-create-all-minpriv-rootfix-20260916:DDL 已从应用启动剥离(CMA_AUTO_CREATE 默认关),
# 新模型必须由**有 DDL 权限的账号**在重启前建表;此处失败即中止部署(后端保持旧码运行),
# 避免 2026-09-16 那种「启动 create_all 被 cma_api 拒 → 崩溃循环」。
echo "[4b/5] 数据库结构迁移(特权账号,重启前)..."
cd "$SCRIPT_DIR/backend"
if ! ./venv/bin/python3 scripts/db_migrate.py --apply; then
echo "❌ 数据库结构迁移失败 → 中止部署(后端未重启,旧码继续服务)"
echo " 排查: cd $SCRIPT_DIR/backend && ./venv/bin/python3 scripts/db_migrate.py --check"
echo " 回滚: cd $SCRIPT_DIR/backend && ./venv/bin/python3 scripts/db_migrate.py --rollback latest"
exit 1
fi
cd "$SCRIPT_DIR"
echo " ✓ 结构迁移完成(幂等;journal: backend/scripts/backups/db-migrate-journal/)"
# 5. 重启后端服务
echo "[5/5] 重启后端服务..."
systemctl daemon-reload
systemctl restart cma-backend
systemctl reload nginx 2>/dev/null || nginx -s reload
echo " ✓ 服务重启完成"
# ── 部署后冒烟测试 ──
echo ""
echo "===== 冒烟测试 ====="
# 就绪轮询(2026-09-15 t2-cma-deploy-readiness-poll):journalctl 实测冷启动就绪间隔 2/2/3/3/5s,
# 固定 sleep 3 会让贴边/超时的冷启动误判为冒烟 ❌(假告警,污染后续判断)。
# 判据 = HTTP 200 **且** 响应体含 {"status":"ok"};每 0.5s 探一次,上限 SMOKE_READY_MAX 秒(默认 30)。
# 超时 → 明确报错 + exit 非 0(既不静默放行,也不让后续 4 项冒烟产出次生噪音掩盖根因)。
# SMOKE_HEALTH_URL / SMOKE_READY_MAX 可覆盖,供离线演练(桩服务 / 空端口 / 短上限)。
# >>> cma-readiness-poll(最小可抽取块:scripts/tests 按行首标记 awk 抽取后原样执行,改结构请沿用标记)
SMOKE_READY_MAX="${SMOKE_READY_MAX:-30}"
HEALTH_URL="${SMOKE_HEALTH_URL:-http://127.0.0.1:8010/health}"
READY_TRY=0
READY_T0=$(date +%s)
READY_OK=0
READY_BODY_FILE="$(mktemp -t cma-ready-body.XXXXXX)"
# 上限按**墙钟**判定(不是轮询次数):目标"接受连接但不响应"时单次探测要耗满 --max-time 2s,
# 用次数当上限会让"上限 30s"实际跑到 150s。墙钟判定下最坏 = SMOKE_READY_MAX + 单次探测(≤2s) 的余量。
while [ $(( $(date +%s) - READY_T0 )) -lt "$SMOKE_READY_MAX" ]; do
READY_CODE=$(curl -s -o "$READY_BODY_FILE" -w '%{http_code}' --max-time 2 "$HEALTH_URL" 2>/dev/null || echo 000)
if [ "$READY_CODE" = "200" ] && grep -q '"status"[[:space:]]*:[[:space:]]*"ok"' "$READY_BODY_FILE" 2>/dev/null; then
READY_OK=1
break
fi
sleep 0.5
READY_TRY=$((READY_TRY + 1))
done
READY_ELAPSED=$(( $(date +%s) - READY_T0 ))
if [ "$READY_OK" -ne 1 ]; then
READY_LAST=$(head -c 200 "$READY_BODY_FILE" 2>/dev/null)
rm -f "$READY_BODY_FILE"
echo " ⏱ 就绪探测: 耗时 ${READY_ELAPSED}s / 轮询 ${READY_TRY} 次(上限 ${SMOKE_READY_MAX}s)→ ❌ 未就绪"
echo ""
echo "🚨 后端就绪超时(${SMOKE_READY_MAX}s):$HEALTH_URL 未返回 HTTP 200 + {\"status\":\"ok\"}"
echo " 最近响应: ${READY_LAST:-<无响应>}"
echo " 请检查后端日志: journalctl -u cma-backend -n 50"
exit 1
fi
rm -f "$READY_BODY_FILE"
# <<< cma-readiness-poll
echo " ⏱ 就绪探测: 耗时 ${READY_ELAPSED}s / 轮询 ${READY_TRY} 次(上限 ${SMOKE_READY_MAX}s,命中就绪即停)"
SMOKE_FAIL=0
echo " ✅ 后端健康检查通过(${HEALTH_URL} → status=ok)"
if curl -sf http://127.0.0.1:8010/api/cma/bot/ping > /dev/null 2>&1; then
echo " ✅ BOT桥接API通过"
else
echo " ❌ BOT桥接API失败"; SMOKE_FAIL=1
fi
# Schema 一致性检查(数据库与ORM结构对齐)
if /root/cma-management/backend/venv/bin/python3 /root/cma-management/backend/scripts/schema_check.py > /dev/null 2>&1; then
echo " ✅ Schema一致性通过"
else
echo " ❌ Schema不一致,请检查: cd /root/cma-management/backend && venv/bin/python3 scripts/schema_check.py"
SMOKE_FAIL=1
fi
# 凭据同源(2026-09-14 cma-defaultcred-code-fix):登录冒烟不再硬编码默认账号/口令 —— 轮换后必须自动跟随。
# 落点:$CMA_ADMIN_ENV 或 600 密钥文件(默认 wecom-ops 主副本);缺失/缺键 → 显式报错并中止,禁止静默退回默认口令。
CMA_ADMIN_ENV_FILE="${CMA_ADMIN_ENV:-/root/.hermes/profiles/wecom-ops/.secrets/cma-admin.env}"
if [ ! -f "$CMA_ADMIN_ENV_FILE" ]; then
echo " ❌ 管理员凭据文件缺失: $CMA_ADMIN_ENV_FILE"
echo " 请准备 600 权限密钥文件(键名 CMA_ADMIN_USER / CMA_ADMIN_PASS),或用 CMA_ADMIN_ENV 指定路径"
exit 1
fi
set -a
# shellcheck disable=SC1090
. "$CMA_ADMIN_ENV_FILE"
set +a
if [ -z "${CMA_ADMIN_USER:-}" ] || [ -z "${CMA_ADMIN_PASS:-}" ]; then
echo " ❌ 凭据文件缺少 CMA_ADMIN_USER / CMA_ADMIN_PASS: $CMA_ADMIN_ENV_FILE"
exit 1
fi
echo " ℹ️ 登录冒烟凭据来源: $CMA_ADMIN_ENV_FILE(账号 ${CMA_ADMIN_USER},口令不回显)"
TOKEN=$(curl -s -X POST http://127.0.0.1:8010/api/cma/auth/login \
-H "Content-Type: application/json" \
-d "{\"username\":\"${CMA_ADMIN_USER}\",\"password\":\"${CMA_ADMIN_PASS}\",\"entity_id\":1}" | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null)
if [ -n "$TOKEN" ]; then
echo " ✅ 登录API通过"
if curl -sf -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8010/api/cma/kpis > /dev/null 2>&1; then
echo " ✅ KPI接口通过"
else
echo " ❌ KPI接口失败"; SMOKE_FAIL=1
fi
else
echo " ❌ 登录API失败"; SMOKE_FAIL=1
fi
if [ "$SMOKE_FAIL" -eq 1 ]; then
echo ""
echo "🚨 冒烟测试未全部通过!请检查后端日志: journalctl -u cma-backend -n 50"
exit 1
fi
echo " ✅ 全部冒烟测试通过"
echo ""
echo "===== 部署完成 ====="
echo " 前端: https://cma.sxbh.ltd"
echo " 后端: http://127.0.0.1:8010/docs"
echo " 后端状态: systemctl status cma-backend"