diff --git a/deploy.sh b/deploy.sh index 3bfcf76b..2f5ea7e0 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,57 +1,206 @@ #!/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 "" -# 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" +# ── 演练模式安全闸(必须最先判定: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 "" - echo "请先提交代码: git add -A && git commit -m '...' && git push origin main" +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 -echo " ✓ 工作区干净,无未提交修改" +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. 拉取最新代码 -echo "[1/5] 拉取最新代码..." -git pull origin main -echo " ✓ 代码已更新" +# 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 "$SCRIPT_DIR/backend" +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 -cd "$SCRIPT_DIR" echo " ✓ 鉴权面审计通过(无 B 类缺口)" -# 2. 构建前端 +# 2. 构建前端(在部署树内构建:pnpm/corepack 的生成物落在这里,污染不到开发区) echo "[2/5] 构建前端..." -cd "$SCRIPT_DIR/frontend" +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 /var/www/cma/assets/ -cp -r dist/* /var/www/cma/ +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 "$SCRIPT_DIR/backend" -if [ -f venv/bin/activate ]; then +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 @@ -65,18 +214,58 @@ echo " ✓ 依赖检查完成" # 新模型必须由**有 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 +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 $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" + 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 "$SCRIPT_DIR" -echo " ✓ 结构迁移完成(幂等;journal: backend/scripts/backups/db-migrate-journal/)" +cd "$DEPLOY_SRC" +echo " ✓ 结构迁移完成(幂等;journal: $DEPLOY_SRC/backend/scripts/backups/db-migrate-journal/)" -# 5. 重启后端服务 +# 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" </dev/null || nginx -s reload @@ -130,10 +319,10 @@ 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 +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 /root/cma-management/backend && venv/bin/python3 scripts/schema_check.py" + echo " ❌ Schema不一致,请检查: cd $DEPLOY_SRC/backend && venv/bin/python3 scripts/schema_check.py" SMOKE_FAIL=1 fi # 凭据同源(2026-09-14 cma-defaultcred-code-fix):登录冒烟不再硬编码默认账号/口令 —— 轮换后必须自动跟随。 @@ -170,12 +359,15 @@ 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" diff --git a/deploy/cma-deploy-dirty-guard.sh b/deploy/cma-deploy-dirty-guard.sh new file mode 100755 index 00000000..e2122a7f --- /dev/null +++ b/deploy/cma-deploy-dirty-guard.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# ============================================================ +# CMA 部署前「部署源」前置检查(宿主侧,由 gitea-cma-deploy-hook.sh 调用) +# ------------------------------------------------------------ +# 版本: cma-deploy-source-decouple-20260921(单 project-20260921-deploy-dirtyguard-rootfix) +# +# 旧行为(cma-deploy-dirty-guard-20260918):开发工作区出现**任何** tracked modified 即 exit 1。 +# 初衷是「防 deploy.sh 里的 `git pull` 覆盖未提交改动」。但开发区同时是部署源 ⇒ 多 agent 并行下 +# 必然反复自锁(corepack 生成物、在途任务文件、unplugin-vue-components 生成物…),发布链被自己的 +# 纪律闸门卡死(09-18 / 09-21 两次实测)。 +# +# 新行为:部署内容 = **已提交的 origin/main 树**(deploy.sh 用独立检出 $CMA_DEPLOY_SRC 重建; +# 部署过程不再对开发区做 pull/stash/checkout)。开发区的脏净与部署内容无关 ⇒ 本闸门**降级为告警**: +# 打印开发区状态供留痕,不因 modified 中止部署。真正的硬闸门仍有三道,且都在 deploy.sh 里: +# [0] 部署源解析(fetch 失败即中止) [1c] 部署树 ≠ 已提交树即中止 [1b] 鉴权面审计 B 类缺口即中止。 +# 本脚本只在「仓库不可用」这一种情况失败关闭(部署本身需要这个仓库)。 +# +# 用法: cma-deploy-dirty-guard.sh [repo_dir] # 默认 /root/cma-management +# 退出码: 0 = 可继续部署(无论开发区脏净); 1 = 仓库不可用 +# 依赖: git。本脚本**不写**开发区任何文件(旧版会把 corepack 生成物 checkout 回去,已移除)。 +# ============================================================ +set -uo pipefail + +REPO="${1:-/root/cma-management}" + +cd "$REPO" 2>/dev/null || { echo "❌ 仓库不可用: $REPO"; exit 1; } +if ! git rev-parse --git-dir > /dev/null 2>&1; then + echo "❌ 不是 git 仓库: $REPO" + exit 1 +fi + +BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo '?')" +HEAD_SHA="$(git rev-parse HEAD 2>/dev/null || echo '?')" +ORIGIN_SHA="$(git rev-parse origin/main 2>/dev/null || echo '?')" +DIRTY="$(git status --porcelain 2>/dev/null | grep -vE "__pycache__|^\?\?" | head -20)" +DIRTY_N="$(git status --porcelain 2>/dev/null | grep -vcE "__pycache__|^\?\?")" + +echo " ℹ️ 开发区 $REPO: 分支=$BRANCH HEAD=${HEAD_SHA:0:8} origin/main=${ORIGIN_SHA:0:8}" +if [ "$DIRTY_N" -gt 0 ]; then + echo " ⚠️ 开发区有 $DIRTY_N 处未提交 tracked 改动 —— 不阻断部署(这些改动不会上线,也不会被丢弃):" + echo "$DIRTY" | sed 's/^/ /' +else + echo " ✓ 开发区无未提交 tracked 改动" +fi +echo " ℹ️ 部署内容 = 已提交的 origin/main 树(与开发区脏净解耦)" +exit 0 diff --git a/deploy/gitea-cma-deploy-hook.sh b/deploy/gitea-cma-deploy-hook.sh new file mode 100755 index 00000000..f75d1d4a --- /dev/null +++ b/deploy/gitea-cma-deploy-hook.sh @@ -0,0 +1,105 @@ +#!/bin/bash +# Gitea post-receive 部署触发器(由 /root/.ssh/authorized_keys 的 forced command 调用) +# 落地:ops-20260918-gitea-deploy-hook-ssh-fix +# 容器侧私钥只能触发本脚本(不能执行任意 root 命令),且来源 IP 限定在 gitea 容器网段。 +# HOME 必须显式为 /root:profile HOME 下 corepack 缓存缺 bin/pnpm.cjs → pnpm build 报 MODULE_NOT_FOUND。 +# 2026-09-18(0-P1-project-20260918-deploy-dirty-guard):deploy.sh 之前增加「提交纪律」前置闸门 +# —— frontend/package.json 的 corepack 生成物(packageManager 单字段)不再永久卡死发布链, +# 其它任何脏改动仍照旧拦截(deploy.sh 第 0 步原闸门保留,双重保险)。 +# 2026-09-21(cma-deploy-source-decouple-20260921,单 project-20260921-deploy-dirtyguard-rootfix): +# 根治「开发区任何 modified 即中止」——部署内容改为**已提交的 origin/main 树**(deploy.sh 用独立检出 +# 重建;部署过程不再 pull 开发区)⇒ 本前置闸门降级为**告警**:打印开发区状态供留痕,不因 modified 拦截; +# 仅在「仓库不可用」时失败关闭(部署本身仍需这个仓库)。 +set -uo pipefail +export HOME=/root +export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +export LANG=C.UTF-8 + +# ============================================================ +# [gate-trail] 动作前留痕(树外执行体入口,协议 v1.0.0) +# chokepoint : gitea-cma-deploy-hook(channel=remote-trigger) +# 注册表 : /etc/gate-trail/governed-entries.json +# 留痕 : /var/log/gate-trail/trail.jsonl(append-only) +# 覆盖语义 : 父级入口覆盖下游子步骤(deploy.sh → db_migrate.py --apply), +# 单点挂载即覆盖整条链,下游不再各自留痕(见注册表 also_covered_by) +# 依据 : 派单 yanxue-20260921-deploy-channel-gate-trail-mount +# (协议母版 yanxue-20260918-051623-tree-external-unit-gate-trail) +# ⚠️ 失败关闭 : begin 返回 2(HIGH/CRITICAL 且无有效审批)→ 中止部署 +# ⚠️ 取值纪律 : 必须先接住输出再取 $?;写成 `gate-trail ... | python3` 会把 exit 2 读成 0 +# ⚠️ 收尾纪律 : 用 EXIT trap 闭合(含 dirty-guard 拦截等提前 exit 路径) +# ============================================================ +TRAIL_CHANNEL="remote-trigger" +TRAIL_ACTION="Gitea push → post-receive → SSH forced-command → CMA 部署链(deploy.sh)" +TRAIL_CMD="bash /root/cma-management/deploy.sh (trigger: gitea post-receive / authorized_keys forced-command)" +TRAIL_TASK="${GATE_TRAIL_TASK:-}" +TRAIL_ID="" +TRAIL_CLOSED="" + +# 收尾:任何退出路径都闭合留痕(BEGIN 之后才注册 trap,避免无 ID 时空转) +trail_closeup() { + local _rc=$? + if [ -z "${TRAIL_CLOSED:-}" ] && [ -n "${TRAIL_ID:-}" ] && command -v gate-trail >/dev/null 2>&1; then + if [ "$_rc" -eq 0 ]; then + gate-trail end --id "$TRAIL_ID" --result pass \ + --evidence "deploy-hook exit=0 @ $(date '+%F %T %Z')" >/dev/null 2>&1 || true + else + gate-trail end --id "$TRAIL_ID" --result fail \ + --evidence "deploy-hook exit=$_rc @ $(date '+%F %T %Z')(含前置闸门拦截等中止路径)" >/dev/null 2>&1 || true + fi + TRAIL_CLOSED=1 + fi + return "$_rc" +} + +if command -v gate-trail >/dev/null 2>&1; then + TRAIL_JSON=$(gate-trail begin --channel "$TRAIL_CHANNEL" --action "$TRAIL_ACTION" \ + --cmd "$TRAIL_CMD" --task "$TRAIL_TASK" \ + --asset /root/cma-management --json 2>/dev/null) + rc=$? + if [ "$rc" -eq 2 ]; then + echo "[deploy-hook] ⛔ gate-trail 拒绝执行(高危且无有效审批)—— 中止部署" >&2 + exit 2 + fi + TRAIL_ID=$(printf '%s' "$TRAIL_JSON" | python3 -c 'import json,sys +try: + print(json.load(sys.stdin).get("trail_id","")) +except Exception: + print("")') + if [ -z "$TRAIL_ID" ]; then + echo "[deploy-hook] ⛔ gate-trail 未返回 trail_id —— 按失败关闭处理,中止部署" >&2 + exit 2 + fi + echo "[deploy-hook] 🧾 gate-trail 留痕已开: $TRAIL_ID" + trap trail_closeup EXIT +else + echo "[deploy-hook] ⚠️ gate-trail 不可用,本次动作将无留痕(违反 gate-trail 协议)" >&2 +fi + + +echo "[deploy-hook] 宿主机已接受 SSH(key=gitea-deploy-hook@sxbh.ltd, forced-command)" +echo "[deploy-hook] 请求命令(被忽略/仅记录)=${SSH_ORIGINAL_COMMAND:-}" + +cd /root/cma-management || { echo "[deploy-hook] ✗ /root/cma-management 不可用"; exit 1; } + +# [0/5] 部署源前置检查(实现见 cma-deploy-dirty-guard.sh) +# 2026-09-21 起:开发区脏净**不再**中止部署(部署内容 = 已提交的 origin/main 树,见 deploy.sh); +# 本步只打印开发区状态供留痕,仅「仓库不可用」失败关闭。 +DIRTY_GUARD=/usr/local/sbin/cma-deploy-dirty-guard.sh +echo "[deploy-hook] [0/5] 部署源前置检查(开发区脏净仅告警)..." +if [ ! -r "$DIRTY_GUARD" ]; then + echo "[deploy-hook] ✗ 前置检查脚本缺失或不可读: $DIRTY_GUARD(按失败关闭中止部署)" + exit 1 +fi +if ! bash "$DIRTY_GUARD" /root/cma-management; then + echo "[deploy-hook] ✗ 仓库不可用 → 中止部署" + exit 1 +fi + +bash deploy.sh 2>&1 +rc=$? +if [ "$rc" -eq 0 ]; then + echo "[deploy-hook] ✓ 部署成功" +else + echo "[deploy-hook] ✗ 部署失败 (exit: $rc)" +fi +exit "$rc" diff --git a/deploy/install-host-deploy-hook.sh b/deploy/install-host-deploy-hook.sh new file mode 100755 index 00000000..877cfed8 --- /dev/null +++ b/deploy/install-host-deploy-hook.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# ============================================================ +# 宿主部署链组件安装/升级(CMA) +# cma-deploy-source-decouple-20260921(单 project-20260921-deploy-dirtyguard-rootfix) +# +# 把本仓 deploy/ 下的宿主侧组件安装到 /usr/local/sbin/(改前自动备份、改后校验 md5)。 +# 组件(宿主侧,容器内 gitea post-receive → SSH forced-command 进入本仓库时执行): +# gitea-cma-deploy-hook.sh → /usr/local/sbin/gitea-cma-deploy-hook.sh +# cma-deploy-dirty-guard.sh → /usr/local/sbin/cma-deploy-dirty-guard.sh +# +# 用法: +# bash deploy/install-host-deploy-hook.sh # 安装/升级(含备份 + 校验) +# bash deploy/install-host-deploy-hook.sh --check # 只比对(不写任何文件) +# +# 说明: +# - 备份目录 /root/backups/cma-deploy-source-decouple-/ +# - 一行回滚: cp <备份目录>/<文件名> /usr/local/sbin/<文件名> && md5sum -c <备份目录>/MD5SUMS.rollback +# ============================================================ +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" +SRC_DIR="$REPO_DIR/deploy" +DST_DIR="/usr/local/sbin" +FILES=(gitea-cma-deploy-hook.sh cma-deploy-dirty-guard.sh) +MODE="${1:-install}" + +for f in "${FILES[@]}"; do + [ -r "$SRC_DIR/$f" ] || { echo "❌ 源文件缺失: $SRC_DIR/$f"; exit 1; } +done + +if [ "$MODE" = "--check" ]; then + rc=0 + for f in "${FILES[@]}"; do + if [ -r "$DST_DIR/$f" ] && cmp -s "$SRC_DIR/$f" "$DST_DIR/$f"; then + echo "✓ 一致: $DST_DIR/$f" + else + echo "≠ 不一致: $DST_DIR/$f(需要安装)"; rc=1 + fi + done + exit "$rc" +fi + +TS="$(date +%Y%m%d-%H%M%S)" +BAK_DIR="/root/backups/cma-deploy-source-decouple-$TS" +mkdir -p "$BAK_DIR" + +for f in "${FILES[@]}"; do + if [ -e "$DST_DIR/$f" ]; then + cp -a "$DST_DIR/$f" "$BAK_DIR/$f" + md5sum "$DST_DIR/$f" >> "$BAK_DIR/MD5SUMS.rollback" + else + echo "" >> "$BAK_DIR/MD5SUMS.rollback" + fi +done + +for f in "${FILES[@]}"; do + install -m 0755 -o root -g root "$SRC_DIR/$f" "$DST_DIR/$f" +done + +echo "备份目录: $BAK_DIR" +for f in "${FILES[@]}"; do + echo "已安装: $DST_DIR/$f md5=$(md5sum "$DST_DIR/$f" | awk '{print $1}')" +done +bash -n "$DST_DIR/gitea-cma-deploy-hook.sh" && echo "✓ 语法检查通过" +echo "✓ 安装完成(生效于下一次 push 触发的部署)"