Files
cma-management/deploy.sh
T
wecom-fullstackandHermes CI Fix 32f368a846
ci/woodpecker/push/main Pipeline failed
fix(infra): pnpm 工具链去脆弱化 —— frontend/package.json 落 packageManager pin + deploy.sh pnpm 版本断言(fail-closed)
① frontend/package.json 补 "packageManager": "pnpm@10.33.2+sha512.a90faf6f…":
   corepack 在项目未声明该字段时按全局 lastKnownGood.json 解析版本,而该文件会被任何跑过
   corepack 的项目改写(2026-10-13 → 12.11.2 ⇒ MODULE_NOT_FOUND;2026-10-14 → 9.15.9 ⇒
   packages field missing)。pin 落库后 corepack 只认 pin,宿主机状态漂移不再影响部署树。
   选 10.33.2 的依据:pnpm-workspace.yaml 的 allowBuilds 仅 pnpm 10 识别;部署树 node_modules
   .modules.yaml packageManager=pnpm@10.33.2(store/v11/v10);lockfileVersion 9.0;2026-10-13
   22:53 该版本下部署成功。
② deploy.sh 新增 assert_pnpm_version()(fail-closed,两处调用:1.2a 首次 pnpm 调用前、[2] 构建前):
   期望版本只取自 package.json 的 packageManager(单一源),与 pnpm -v 实读逐字比对,缺失/不一致
   即中止;两次调用都先于 [3] 铺站与 [5] 重启 ⇒ 任何生产写之前失败关闭。

零生产写:候选仅落本 worktree 分支,未 push、未动 /srv/hermes/shared/cma-deploy 与 dev 树。
2026-10-15 08:35:20 +08:00

449 lines
24 KiB
Bash
Executable File
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.
#!/bin/bash
# 管理会计OS — 一键拉取、构建并部署到生产
#
# 部署源 = **已提交的 origin/main 树**(独立检出,与开发工作区解耦)
# cma-deploy-source-decouple-20260921(任总 2026-09-21 10:28「根治」;单 project-20260921-deploy-dirtyguard-rootfix)
#
# 为什么改:开发区 /root/cma-management → /srv/hermes/shared/cma-management 过去**既是编辑区又是部署源**
# (SCRIPT_DIR 即部署根),而 [0] 对**任何** tracked modified 即 exit 1 ⇒ 多 agent 并行下必然反复自锁:
# 2026-09-18 frontend/package.json(corepack pnpm build 自加行);
# 2026-09-21 10:09 batch5 在途 6 文件;
# 2026-09-21 11:07 构建又写出 frontend/components.d.ts(unplugin-vue-components 生成物)。
# 现在:部署内容 = 已提交的 origin/main(fetch → checkout --detach -f → clean -fdx),
# 开发区只作「入口 + 运行时资产单源(.env / venv / node_modules)」,其脏净**不参与**部署判定,
# 部署过程也**不** pull / stash / checkout 开发区(在途改动既不上线、也不被丢弃)。
#
# 保留不变(不得弱化):[1b] 鉴权面审计闸门(B 类缺口即中止)、构建→铺站→重启→冒烟全流程、一行回滚能力。
#
# 可选环境变量:
# CMA_DEPLOY_SRC 部署树路径(默认 /srv/hermes/shared/cma-deploy)
# CMA_WWW_DIR 前端铺站目录(默认 /var/www/cma)
# CMA_DEPLOY_MODE deploy(默认)| drill(离线演练:www 必须在 /var/www 之外、systemctl/nginx 必须是桩、
# 迁移只跑 --check、不装依赖、不改 unit)
# CMA_DEPLOY_SKIP_FETCH=1 跳过 fetch,沿用本地 origin/main 引用(离线演练用)
# SMOKE_HEALTH_URL / SMOKE_READY_MAX 冒烟就绪探测覆盖(见下方 cma-readiness-poll 块)
set -e
# pnpm/corepack 需要 /root 下的缓存(profile HOME 缺 bin/pnpm.cjs → pnpm build 报 MODULE_NOT_FOUND)
export HOME="${CMA_DEPLOY_HOME:-/root}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_DIR="$SCRIPT_DIR" # 开发区/入口工作区(可脏:仅作入口与资产单源)
DEPLOY_SRC="${CMA_DEPLOY_SRC:-/srv/hermes/shared/cma-deploy}" # 部署树:恒等于已提交的 origin/main
WWW_DIR="${CMA_WWW_DIR:-/var/www/cma}"
MODE="${CMA_DEPLOY_MODE:-deploy}"
UNIT_FILE="${CMA_DEPLOY_UNIT_FILE:-/etc/systemd/system/cma-backend.service}"
ENV_SRC="$REPO_DIR/backend/.env"
VENV_SRC="$REPO_DIR/backend/venv"
# ── pnpm 版本断言(2026-10-14 deploy-chain-rootfix;fail-closed)────────────────
# 背景:corepack 在项目**未声明** `packageManager` 时,按全局
# /root/.cache/node/corepack/lastKnownGood.json 解析 pnpm 版本 —— 该文件会被**任何**
# 跑过 corepack 的项目改写(本型事故历史 ≥3 次:2026-10-13 12.11.2 ⇒ `Cannot find
# module '…/bin/pnpm.cjs'`;2026-10-14 9.15.9 ⇒ `packages field missing or empty`)。
# ⇒ 无 pin 的部署树会**静默换工具链**,一路跑到 pnpm build 才炸,且修复只能落在宿主机状态上。
# 做法:期望版本**只**取自 `frontend/package.json` 的 `packageManager`(单一源),与
# `pnpm -v` 实读逐字比对(也覆盖 COREPACK_ENABLE_PROJECT_SPEC=0 等让 pin 失效的形态);
# 缺失或不一致即中止。断言先于任何 pnpm 调用与任何生产写([3] 铺站 / [5] 重启)。
# 用法:在部署树 frontend 目录内调用 `assert_pnpm_version`。
assert_pnpm_version() {
local expect actual
expect="$(sed -n 's/.*"packageManager"[[:space:]]*:[[:space:]]*"pnpm@\([^"+]*\).*/\1/p' package.json 2>/dev/null | head -1)"
if [ -z "$expect" ]; then
echo "❌ pnpm 版本断言失败:frontend/package.json 未声明 \"packageManager\": \"pnpm@<ver>+sha512…\""
echo " 无 pin ⇒ corepack 按全局 lastKnownGood 解析 ⇒ 工具链不可复现 ⇒ 中止(生产未被触碰)"
exit 1
fi
actual="$(pnpm -v 2>/dev/null | tail -1 | tr -d '[:space:]' || true)"
if [ "$actual" != "$expect" ]; then
echo "❌ pnpm 版本断言失败:package.json pin=$expect,实读 pnpm -v=${actual:-<空>}"
echo " 多为 corepack lastKnownGood 漂移(/root/.cache/node/corepack/lastKnownGood.json)"
echo " 修法:HOME=/root corepack prepare pnpm@$expect --activate | 中止(生产未被触碰)"
exit 1
fi
echo " ✓ pnpm 版本断言通过:pnpm $actual == package.json pin $expect"
}
echo "===== 管理会计OS 生产部署 ====="
echo " 入口工作区(开发区): $REPO_DIR"
echo " 部署树(已提交树) : $DEPLOY_SRC"
echo " 模式 : $MODE"
echo ""
# ── 演练模式安全闸(必须最先判定:drill 绝不允许碰到生产面)──
if [ "$MODE" = "drill" ]; then
case "$WWW_DIR" in
/var/www|/var/www/*) echo "❌ 演练模式禁止写生产 www 目录: $WWW_DIR(请用 CMA_WWW_DIR 指向临时目录)"; exit 1;;
esac
for c in systemctl nginx; do
p="$(command -v "$c" 2>/dev/null || true)"
case "$p" in
""|/usr/bin/*|/bin/*|/usr/sbin/*|/sbin/*)
echo "❌ 演练模式要求 $c 指向桩脚本(PATH 前置沙箱 bin 目录),当前='${p:-<缺失>}' —— 中止以免误触生产"
exit 1;;
esac
done
echo " ℹ️ 演练模式:www=$WWW_DIR;systemctl=$(command -v systemctl);nginx=$(command -v nginx)"
echo ""
fi
# 0. 部署源解析:部署内容 = 已提交的 origin/main(开发区的脏净降级为**告警**,不再 exit)
# 旧 [0] 的初衷是「防 git pull 覆盖丢代码」;改为已提交树后该风险不存在(部署树永远从 origin/main 重建,
# 开发区不再被 pull)——故不得保留「开发区任何 modified 即中止」。
echo "[0/5] 部署源解析(origin/main 已提交树)..."
cd "$REPO_DIR"
DEV_BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo '?')"
DEV_HEAD="$(git rev-parse HEAD)"
DIRTY="$(git status --porcelain 2>/dev/null | grep -vE "__pycache__|^\?\?" | head -20)"
if [ -n "$DIRTY" ]; then
echo " ⚠️ 开发区存在未提交改动(不阻断部署;这些改动不会上线,也不会被丢弃/覆盖):"
echo "$DIRTY" | sed 's/^/ /'
else
echo " ✓ 开发区工作区干净"
fi
if [ "${CMA_DEPLOY_SKIP_FETCH:-0}" = "1" ]; then
echo " ℹ️ CMA_DEPLOY_SKIP_FETCH=1 → 跳过 fetch,沿用本地 origin/main 引用"
else
if ! git fetch --quiet origin main; then
echo "❌ git fetch origin main 失败 —— 无法确定部署源(失败关闭)"
exit 1
fi
fi
DEPLOY_SHA="$(git rev-parse origin/main)"
DEPLOY_SUBJECT="$(git log -1 --pretty=%s "$DEPLOY_SHA" 2>/dev/null || echo '')"
echo " ✓ 部署源: origin/main = ${DEPLOY_SHA:0:8} ${DEPLOY_SUBJECT}"
if [ "$DEV_HEAD" != "$DEPLOY_SHA" ]; then
AHEAD="$(git rev-list --count origin/main..HEAD 2>/dev/null || echo '?')"
echo " ⚠️ 开发区 HEAD($DEV_BRANCH ${DEV_HEAD:0:8}) ≠ 部署源(${DEPLOY_SHA:0:8}),本地领先 $AHEAD 笔"
echo " ⇒ 未推送提交不会上线;要上线请先 git push origin main"
fi
# 1. 同步部署树到 origin/main(部署树恒等于已提交树;开发区零触碰)
echo "[1/5] 同步部署树 → origin/main ..."
if [ ! -e "$DEPLOY_SRC/.git" ]; then
echo " ℹ️ 首次使用:创建部署检出 $DEPLOY_SRC(git worktree,detached)"
mkdir -p "$(dirname "$DEPLOY_SRC")"
if ! git -C "$REPO_DIR" worktree add --detach "$DEPLOY_SRC" "$DEPLOY_SHA"; then
echo "❌ 部署树创建失败: $DEPLOY_SRC"
exit 1
fi
else
if ! git -C "$DEPLOY_SRC" rev-parse --git-dir > /dev/null 2>&1; then
echo "❌ $DEPLOY_SRC 已存在但不是 git 检出 —— 请人工确认后再部署"
exit 1
fi
git -C "$DEPLOY_SRC" checkout --detach -f "$DEPLOY_SHA" > /dev/null
# -e 保留运行时资产(依赖/密钥/日志/迁移 journal),其余一律还原为已提交态
git -C "$DEPLOY_SRC" clean -fdx -q \
-e backend/venv -e backend/.env -e backend/logs \
-e backend/scripts/backups \
-e frontend/node_modules -e frontend/.env
fi
# 1.1 部署树自检(起点):必须恒等于已提交树 —— 旧的「脏即中止」只保留在这里(对象是**部署树**,不是开发区)
DT_DIRTY="$(git -C "$DEPLOY_SRC" status --porcelain 2>/dev/null | grep -vE "^\?\?" | head -20)"
if [ -n "$DT_DIRTY" ]; then
echo "❌ 部署树存在未提交修改(部署树必须恒等于已提交树):"
echo "$DT_DIRTY" | sed 's/^/ /'
exit 1
fi
DT_HEAD="$(git -C "$DEPLOY_SRC" rev-parse HEAD)"
if [ "$DT_HEAD" != "$DEPLOY_SHA" ]; then
echo "❌ 部署树 HEAD(${DT_HEAD:0:8}) ≠ 部署源(${DEPLOY_SHA:0:8})"; exit 1
fi
echo " ✓ 部署树 = 已提交树(HEAD=${DEPLOY_SHA:0:8},无 tracked 修改)"
# 1.2 运行时资产接线(单源、幂等):.env / venv 指向开发区主副本,避免密钥与依赖双份漂移
if [ ! -e "$ENV_SRC" ]; then
echo "❌ 运行时密钥文件缺失: $ENV_SRC(部署树需要它)"; exit 1
fi
if [ ! -e "$VENV_SRC/bin/python3" ]; then
echo "❌ 运行时 venv 缺失: $VENV_SRC/bin/python3(部署树需要它)"; exit 1
fi
ln -sfn "$ENV_SRC" "$DEPLOY_SRC/backend/.env"
ln -sfn "$VENV_SRC" "$DEPLOY_SRC/backend/venv"
mkdir -p "$DEPLOY_SRC/backend/logs" "$DEPLOY_SRC/backend/scripts/backups"
if [ -f "$REPO_DIR/frontend/.env" ]; then
ln -sfn "$REPO_DIR/frontend/.env" "$DEPLOY_SRC/frontend/.env"
fi
# 1.2a pnpm 版本断言(fail-closed;先于**首次** pnpm 调用):坏工具链不得先污染部署树 node_modules
if [ -d "$DEPLOY_SRC/frontend" ]; then
( cd "$DEPLOY_SRC/frontend" && assert_pnpm_version )
fi
if [ ! -e "$DEPLOY_SRC/frontend/node_modules" ]; then
echo " ℹ️ 部署树缺 frontend/node_modules → 尝试 pnpm install --frozen-lockfile --prefer-offline"
if ! ( cd "$DEPLOY_SRC/frontend" && pnpm install --frozen-lockfile --prefer-offline ); then
if [ -d "$REPO_DIR/frontend/node_modules" ]; then
echo " ⚠️ 部署树 pnpm install 失败 → 回退为链接开发区 node_modules(依赖内容与开发区一致;锁定文件仍以已提交为准)"
ln -sfn "$REPO_DIR/frontend/node_modules" "$DEPLOY_SRC/frontend/node_modules"
else
echo "❌ 部署树无 node_modules 且开发区也没有 → 无法构建前端"
exit 1
fi
fi
fi
# 1d. 后端可导入自检(接线完成后执行;缺 .env/venv/依赖会让服务起不来 → 重启前失败关闭)
if ! timeout 60 sh -c "cd '$DEPLOY_SRC/backend' && ./venv/bin/python3 -c 'import app.main'"; then
echo "❌ 部署树后端导入自检失败(app.main 无法导入)→ 中止(生产未被触碰)"
exit 1
fi
echo " ✓ 部署树后端可导入(app.main)+ 运行时资产已接线"
# 1b. 鉴权面审计闸门(T1, 2026-09-15):B 类缺口(用户端点无鉴权)存在即中止部署
echo "[1b/5] 鉴权面审计闸门..."
cd "$DEPLOY_SRC/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
echo " ✓ 鉴权面审计通过(无 B 类缺口)"
# 2. 构建前端(在部署树内构建:pnpm/corepack 的生成物落在这里,污染不到开发区)
echo "[2/5] 构建前端..."
cd "$DEPLOY_SRC/frontend"
# [2] 段前 pnpm 版本断言(fail-closed):与 package.json 的 packageManager pin 不一致即中止,
# 先于 [3] 铺站与 [5] 重启 ⇒ 任何生产写之前失败关闭。
assert_pnpm_version
pnpm build
echo " ✓ 构建完成"
# 构建工具链(corepack/pnpm、unplugin-auto-import/vue-components)会在部署树里写生成物 ——
# 部署树可弃、下次部署 checkout -f + clean 重置,故仅提示;旧版这些生成物落在**开发区**,
# 正是发布链自锁的来源之一(2026-09-18 package.json / 2026-09-21 components.d.ts)。
DT_POST="$(git -C "$DEPLOY_SRC" status --porcelain 2>/dev/null | head -5)"
if [ -n "$DT_POST" ]; then
echo " ℹ️ 构建后部署树生成物(仅提示,不影响开发区):"
echo "$DT_POST" | sed 's/^/ /'
fi
# 3. 部署前端到 Nginx
echo "[3/5] 部署前端..."
rm -rf "$WWW_DIR/assets/"
cp -r dist/* "$WWW_DIR/"
DIFF_OUT="$(diff -rq dist "$WWW_DIR" 2>&1 | head -5 || true)"
if [ -n "$DIFF_OUT" ]; then
echo " ⚠️ 铺站后 dist 与 $WWW_DIR 存在差异(仅提示):"
echo "$DIFF_OUT" | sed 's/^/ /'
else
echo " ✓ 铺站一致性: dist == $WWW_DIR"
fi
echo " ✓ 部署完成"
# 4. 更新后端依赖(失败不阻断 — 依赖已在系统Python中,避免PEP668中断部署)
echo "[4/5] 更新后端依赖..."
cd "$DEPLOY_SRC/backend"
if [ "$MODE" = "drill" ]; then
echo " ℹ️ 演练模式:跳过依赖安装(共享 venv 只读)"
elif [ -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 拒 → 崩溃循环」。
# [4b-2] 授权面收敛:与本节同属「重启前特权步」,锚 = 本次 create_all 的 created_tables
# (经 journal/last-anchor.json 转交);本步只发 GRANT、从不 REVOKE、缺裁定即中止
echo "[4b/5] 数据库结构迁移(特权账号,重启前)..."
cd "$DEPLOY_SRC/backend"
MIGRATE_ARG="--apply"
if [ "$MODE" = "drill" ]; then MIGRATE_ARG="--check"; echo " ℹ️ 演练模式:只跑 --check(只读,不落 DDL)"; fi
if ! ./venv/bin/python3 scripts/db_migrate.py "$MIGRATE_ARG"; then
echo "❌ 数据库结构迁移失败 → 中止部署(后端未重启,旧码继续服务)"
echo " 排查: cd $DEPLOY_SRC/backend && ./venv/bin/python3 scripts/db_migrate.py --check"
echo " 回滚: cd $DEPLOY_SRC/backend && ./venv/bin/python3 scripts/db_migrate.py --rollback latest"
exit 1
fi
cd "$DEPLOY_SRC"
echo " ✓ 结构迁移完成(幂等;journal: $DEPLOY_SRC/backend/scripts/backups/db-migrate-journal/)"
# 4b-2. 授权面收敛(白名单驱动 · 增量锚定 · fail-closed · 只增不减)
# 背景:新表建表([4b] create_all)不在任何自动授权步内 ⇒ 表在、写权缺 ⇒ 端点 500 / 1142
# 锚 = 本次 db_migrate --apply 写出的 journal/last-anchor.json(R1-a 同源自证:锚件引用本次 journal)
# 本步只发 GRANT(从不 REVOKE);缺白名单裁定 ⇒ 非零退出、后端不重启、旧码继续服务
# 回滚:去掉本步([4b-2/5])即可 —— 本步不建对象、不改表结构、不 REVOKE,不执行即无残留
echo "[4b-2/5] 授权面收敛(白名单驱动 · 增量锚定 · fail-closed)..."
cd "$DEPLOY_SRC/backend"
if [ "$MODE" = "drill" ]; then
# drill:只读预检,读数原样打印,WARN 不阻断(与 [4b] 的「只读预检」口径一致)
SYNC_RC=0
./venv/bin/python3 scripts/grant_surface_sync.py --check \
--anchor-file scripts/backups/db-migrate-journal/last-anchor.json || SYNC_RC=$?
if [ "$SYNC_RC" -ne 0 ]; then
echo " [warn] [4b-2] drill 读数非零 rc=$SYNC_RC(缺裁定/缺锚清单见上方 coverage.missing_caliber)"
echo " [warn] 生产 apply 时会据此中止(exit 1,后端不重启);drill 不阻断"
fi
SYNC_PLAN_RC=0
./venv/bin/python3 scripts/grant_surface_sync.py --check || SYNC_PLAN_RC=$?
if [ "$SYNC_PLAN_RC" -ne 0 ]; then
echo " [warn] [4b-2] drill 全域只读预演 rc=$SYNC_PLAN_RC(见上方 plan/matrix/drift)"
else
echo " [info] [4b-2] drill 全域只读预演已完成(语句计划与 statements_md5 见上方 plan)"
fi
else
SYNC_RC=0
./venv/bin/python3 scripts/grant_surface_sync.py --apply \
--anchor-file scripts/backups/db-migrate-journal/last-anchor.json --drift-policy warn \
|| SYNC_RC=$?
if [ "$SYNC_RC" -ne 0 ]; then
echo "❌ [4b-2] 授权面收敛失败 rc=$SYNC_RC → 中止部署(后端未重启,旧码继续服务)"
echo " 缺裁定清单见上方 coverage.missing_caliber(未登记白名单的新建表须先走裁定另单)"
echo " 回滚: 去掉本步([4b-2/5])即可;本步从不 REVOKE/DROP,不执行即无残留"
exit 1
fi
fi
cd "$DEPLOY_SRC"
echo " ✓ 授权面收敛完成(只增不减;指针: $DEPLOY_SRC/backend/scripts/backups/db-migrate-journal/last-anchor.json)"
# 5. 重启后端服务(运行树 = 部署树:幂等改写 unit,改前备份,附一行回滚)
echo "[5/5] 重启后端服务..."
if [ "$MODE" = "drill" ]; then
echo " ℹ️ 演练模式:跳过 unit 改写/服务重启(下列 systemctl/nginx 为 PATH 桩,仅留调用证据)"
else
# >>> cma-runtime-unit-switch(最小可抽取块:drill/test 按行首标记 awk 抽取后原样执行,改结构请沿用标记)
DESIRED_WD="$DEPLOY_SRC/backend"
if grep -qF "WorkingDirectory=$DESIRED_WD" "$UNIT_FILE" 2>/dev/null; then
echo " ✓ 运行树已是部署树(WorkingDirectory=$DESIRED_WD)"
else
if [ ! -r "$UNIT_FILE" ]; then
echo "❌ 找不到 systemd unit: $UNIT_FILE"; exit 1
fi
UNIT_BAK="${UNIT_FILE}.bak-$(date +%Y%m%d-%H%M%S)"
cp -a "$UNIT_FILE" "$UNIT_BAK"
cat > "$UNIT_FILE" <<UNITEOF
[Unit]
Description=管理会计OS后端(运行树=部署树,见 deploy.sh cma-deploy-source-decouple-20260921)
After=network-online.target mysql.service
Wants=network-online.target
[Service]
Type=simple
User=root
WorkingDirectory=$DESIRED_WD
EnvironmentFile=$DESIRED_WD/.env
ExecStart=$DESIRED_WD/venv/bin/python3 -m uvicorn app.main:app --host 127.0.0.1 --port 8010 --workers 1 --limit-max-requests 10000 --timeout-keep-alive 30 --no-access-log
Restart=always
RestartSec=3
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
UNITEOF
echo " ℹ️ 运行树切到部署树(WorkingDirectory=$DESIRED_WD)"
echo " 一行回滚: cp $UNIT_BAK $UNIT_FILE && systemctl daemon-reload && systemctl restart cma-backend"
fi
# <<< cma-runtime-unit-switch
fi
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 "$DEPLOY_SRC/backend/venv/bin/python3" "$DEPLOY_SRC/backend/scripts/schema_check.py" > /dev/null 2>&1; then
echo " ✅ Schema一致性通过"
else
echo " ❌ Schema不一致,请检查: cd $DEPLOY_SRC/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"
echo " 一行回滚: cp ${UNIT_FILE}.bak-* ${UNIT_FILE} && systemctl daemon-reload && systemctl restart cma-backend"
exit 1
fi
echo " ✅ 全部冒烟测试通过"
echo ""
echo "===== 部署完成 ====="
echo " 部署源 : origin/main = ${DEPLOY_SHA:0:8} ${DEPLOY_SUBJECT}"
echo " 部署树 : $DEPLOY_SRC"
echo " 前端: https://cma.sxbh.ltd"
echo " 后端: http://127.0.0.1:8010/docs"
echo " 后端状态: systemctl status cma-backend"