依据:A 侧(wecom-yanxue)裁定 编号 2「准予但需调整」+ R1(裁定件 yanxue-20260928-newtable-grant-surface-impl-plan-ruling.md,md5 272d1a8b4db0db0e9f35f5e62695ae50) 三条开口 (a) 空锚=合法零集 / (b) --anchor-file 载体等价 / (c) drill WARN 不阻断(两条独立分支) 改动面(3 件,全部开发工作树): - 新增 backend/scripts/grant_surface_sync.py:项目侧原型逐字复制 + Δ1/Δ2/Δ3/Δ4 + R1 Δ1 锚语义(未给=缺锚 rc=2 / 空集=合法零集)、Δ2 连接身份与 db_migrate 同口径并拒绝 cma_api、 Δ3 --anchor-file(读 db-migrate-journal 指针件)、Δ4 anchor_source 入 JSON R1-a 同源自证(journal 在册 + status=applied + finished_at 一致 + 目录内最新 -applied) R1-b 未收敛锚累积由 db_migrate.write_anchor_pointer 实现、R1-c 收敛回写 converged_at - db_migrate.py 加性 +2 处:新增 write_anchor_pointer()(原子写 journal/last-anchor.json); cmd_apply 成功/失败两路径各调一次;零变更面未动(列级差异只告警 / create_all 幂等 / --check 不写指针 / --rollback 与 resolve_journal 语义 / JSON 键集) - deploy.sh 加性 1 步 [4b-2/5] 授权面收敛(插在「✓ 结构迁移完成」与「# 5. 重启后端服务」之间): apply 跑 --apply --anchor-file ... --drift-policy warn,rc!=0 ⇒ 打印缺裁定清单 + exit 1 (后端不重启、旧码继续服务);drill 跑 --check,读数原样打印、WARN 不阻断 证据(全部零生产面): - py_compile x3 通过;bash -n deploy.sh 通过(PYTHONPYCACHEPREFIX 隔离,仓内零新增 pyc) - 纯函数用例 11/11(零连库):语句均为 GRANT / 无 REVOKE / 语句表 ⊆ write_tables / 域外权限 SystemExit - 沙箱 S1–S9 回归 9/9 一致(SUMMARY.txt 与项目侧基线逐字节相同 md5 1a697e002a2cba8ae9b5ad9cc71b022d) - 锚/R1 新例 18/18:缺锚/缺件/status=failed/R1-a 四负例均 rc=2 零写入;空锚 rc=0 executed=[] - R1 验收链:发布1 建表 X(∉白名单) ⇒ rc=3 零写入 + 片段 exit 1 ⇒ 不修白名单重跑 ⇒ 仍 rc=3 ⇒ 补白名单 ⇒ rc=0 + X 获授权 + converged_at 落定 ⇒ 三次重跑 executed=[](幂等) - 既有 backend/tests:1509 passed / 0 failed,改前(HEAD 导出树)与改后失败集零差异 边界:本地 commit,零 push / 零部署 / 零重启 / 零连生产库 / 零 DDL-DML-GRANT-REVOKE; 上线(push + 部署 ⇒ 授权面生效)另单另铸 approval ID 且必附 R2 预演 diff(本单不为任何动作铸 ID)
414 lines
21 KiB
Bash
Executable File
414 lines
21 KiB
Bash
Executable File
#!/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"
|
||
|
||
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
|
||
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"
|
||
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"
|