## 洞
树脏时 Go **照样**写 `vcs.revision=<HEAD>`,只在 `vcs.modified` 上留痕 ⇒
只比 revision 会把"**未提交的代码**"当成"就是那份提交"。
实测(/tmp/vm2:HEAD=2931050a,改一行**不提交**再 build):
revision=2931050a == HEAD ⇒ 我 ⑤b 判**绿**
但二进制里是未提交的那行代码 ⇒ **假绿** ✓ pi 对
线上那份正是 `vcs.modified=true` ⇒ 它**不对应任何已提交版本**。
## 修法:说清楚,但**不判红**
`revision == HEAD` 与"不含未提交代码"是**两个量**,不能拿前者冒充后者 ——
但脏树在本仓是**多 agent 并发编辑的常态**,判红就是造一条总在亮的判据。
★ 同一个事实在本仓**已有既定严重度**:`check-deploy-drift` 判据⑥
// ⑥ 工作区干净度 —— **WARN,不参与退出码**。
⇒ 两处都改成 **WARN/note 披露,不改 ok / 不加 CHECK_FAIL**,
与判据⑥ 同严重度 —— 否则同仓同一个量会有两套红绿。
redeploy-gateway.sh §7: modified=true ⇒ warn(不动 CHECK_FAIL)
check-deploy-drift ⑤b: revision 相同但 modified=true ⇒ 绿 + note 点明"含未提交代码"
## 判据自检
新增一格:`revision 相同但 modified=true ⇒ 绿,且 note 点明"含未提交代码"`。
自检 **61 → 62 项**,`判据自检失败` 不出现。
★ 这条是本判据**唯一**的假绿形状,所以它必须有一格专门钉住——
否则下一个人把 note 删了、或把 ok 改成 false,都没有判据会响。
516 lines
28 KiB
Bash
Executable File
516 lines
28 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# redeploy-gateway.sh —— Gateway 二进制热替换(原子化,带备份与后置验证)
|
||
#
|
||
# 为什么需要这个脚本:日常改后端只需要换二进制,跑整个 install.sh 太重
|
||
# (它会重装 npm 依赖、重写 systemd 单元、重新生成 env)。而手工
|
||
# 「systemctl stop → cp → start」有两个隐患:
|
||
#
|
||
# 1. cp 是就地写入,会改坏**正在运行中进程**的可执行映像。
|
||
# 即使先 stop 了,中途失败也会留下一个半截二进制且旧的已被覆盖。
|
||
# ★ 更正(pi 评审 2026-09-14,已用 strace 实测):**`install(1)` 不是 rename。**
|
||
# 实测 `strace … install -m 0755 /bin/true /tmp/t`:
|
||
# openat(/tmp/t, O_WRONLY|O_CREAT|O_EXCL) ← 目标不存在时
|
||
# unlinkat(/tmp/t, 0) → openat(… O_CREAT|O_EXCL) ← 目标已存在时**先删旧再建新**
|
||
# 全程没有 rename/renameat。即"复制"路径:**旧文件在新文件写完整之前就没了**。
|
||
# 实证"中途失败留半截":`ulimit -f 1` 下安装 /bin/true ⇒ 退出码 **153**(SIGXFSZ),
|
||
# 目标变成 **1024 字节的截断 ELF**,原来那 14 字节内容**已被销毁** ——
|
||
# 正是本段前半句写的那个风险,`install` 并不免疫,它只是不再覆盖旧文件(旧的已先删)。
|
||
# 所以真正原子的写法是**目标同目录 + 最后一次 mv**(见第 6 节)。
|
||
#
|
||
# 2. 不备份数据库。SQLite 在 WAL 模式下 cp 会拿到不一致快照
|
||
# (主库文件与 -wal 不同步),恢复时可能丢最近写入甚至损坏。
|
||
# 必须用 sqlite3 .backup,它走的是官方在线备份 API。
|
||
#
|
||
# 纪律来自 git-release-discipline skill 第五章:
|
||
# 发布终点不是「推上去了」,而是后置验证清单全绿。任一项不过就回滚,
|
||
# 不要「先上着再修」。
|
||
#
|
||
# 用法:
|
||
# bash deploy/redeploy-gateway.sh # 完整流程(含测试)
|
||
# bash deploy/redeploy-gateway.sh --skip-tests # 跳过测试(急救时用)
|
||
# bash deploy/redeploy-gateway.sh --dry-run # 只打印将执行的动作
|
||
#
|
||
# 退出码: 0=成功 1=失败或验证不过(已尝试回滚) 2=参数/环境问题
|
||
|
||
set -uo pipefail
|
||
|
||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||
PREFIX="${AGENTMAIL_PREFIX:-/opt/agentmail}"
|
||
|
||
# 环境自足:一处给全(理由与四次历史见该文件头注释)
|
||
# shellcheck source=./lib/env-defaults.sh
|
||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/env-defaults.sh"
|
||
# 本脚本依赖的外部命令:缺一个就 exit 2 + 人话(见 env-defaults.sh 的 ③b 一节)。
|
||
# 为什么必须显式声明:**"命令不在"与"命令在但输出为空"必须分开** ——
|
||
# 下游把前者读成后者时就会产出假绿(journalctl 那两处就是:工具缺失被读成"无 panic")。
|
||
AGENTMAIL_REQUIRE="git go npm node curl systemctl journalctl sqlite3 install flock"
|
||
agentmail_env_report
|
||
|
||
# ★ root 断言(pi 评审 2026-09-14 指出本脚本缺它,只有 install.sh 有):
|
||
# 本脚本要往 $PREFIX(默认 /opt/agentmail,系统路径)写 —— 非 root 必然失败在写权限上。
|
||
# 而失败时的报错来自 `install`/`cp`,看起来像**工程问题**。
|
||
# 与 env-defaults.sh 里那条"别把环境问题报成代码问题"是同一条纪律:
|
||
# 与其让它晚一点、以晦涩的方式失败,不如在这里一行说清。
|
||
# 用退出码 2(环境/权限),与 env-defaults.sh 的口径一致,不冒充判据失败(1)。
|
||
[ "$(id -u)" = "0" ] || {
|
||
printf '\n [FAIL] 环境不足:本脚本要写 %s,需要 root。\n' "$PREFIX" >&2
|
||
printf ' 药方:sudo bash deploy/redeploy-gateway.sh\n' >&2
|
||
exit 2
|
||
}
|
||
TARGET="$PREFIX/agentmail-gateway"
|
||
DB="$PREFIX/data/agentmail.db"
|
||
SERVICE="agentmail-gateway"
|
||
HEALTH_URL="${AGENTMAIL_HEALTH_URL:-http://127.0.0.1:8180/health}"
|
||
TS="$(date +%Y%m%d-%H%M%S)"
|
||
|
||
SKIP_TESTS=0
|
||
DRY_RUN=0
|
||
SYNC_WEB=1
|
||
|
||
while [ $# -gt 0 ]; do
|
||
case "$1" in
|
||
--skip-tests) SKIP_TESTS=1; shift ;;
|
||
--skip-web) SYNC_WEB=0; shift ;;
|
||
--dry-run) DRY_RUN=1; shift ;;
|
||
-h|--help) sed -n '2,30p' "$0"; exit 0 ;;
|
||
*) echo "未知参数: $1" >&2; exit 2 ;;
|
||
esac
|
||
done
|
||
|
||
export GOPROXY="${GOPROXY:-https://goproxy.cn,direct}"
|
||
|
||
say() { printf '\n=== %s\n' "$*"; }
|
||
ok() { printf ' [ OK ] %s\n' "$*"; }
|
||
bad() { printf ' [FAIL] %s\n' "$*"; }
|
||
warn() { printf ' [WARN] %s\n' "$*"; }
|
||
|
||
run() {
|
||
if [ "$DRY_RUN" = 1 ]; then
|
||
printf ' [dry-run] %s\n' "$*"
|
||
return 0
|
||
fi
|
||
printf ' $ %s\n' "$*"
|
||
eval "$@"
|
||
}
|
||
|
||
[ -d "$REPO/server" ] || { bad "找不到 $REPO/server"; exit 2; }
|
||
# $PREFIX 必须**先**判存在:下面建锁要往它里面写文件(首次安装时它可能还没有)。
|
||
[ -d "$PREFIX" ] || { bad "找不到 $PREFIX(先跑 deploy/install.sh)"; exit 2; }
|
||
|
||
# ★ **部署锁**(pi 评审 2026-09-14):这是我那张"环境前提"表里原先没有的一类 ——
|
||
# 不是变量、不是命令、不是空间、不是身份,而是**同时性**。
|
||
# 而这台机器上它**不是假设**:`docs/DEV-TOOLING.md` 自己记过"这个工作区是多 agent 共用的,
|
||
# 另一条会话正在改 deploy/*.sh"。两个部署同时跑会怎样:
|
||
# 各自 `systemctl stop`(其中一次的 stop 失败,而它的状态**没人看**)→
|
||
# 两次写同一个 `$TARGET`(配合"install 非原子"⇒ 真能留下半截)→
|
||
# 两次后置验证互相把对方的"验证不过"当成自己的结论 → **谁回滚谁不确定**。
|
||
# 放在任何写操作之前(这里)。用 flock,不是"检查文件存在"——后者本身有竞态。
|
||
# 判据:两个同时启动 ⇒ 第二个 exit 2 并点名。
|
||
_LOCK="$PREFIX/.deploy.lock"
|
||
exec 9>"$_LOCK" || { bad "无法创建部署锁 $_LOCK"; exit 2; }
|
||
if ! flock -n 9; then
|
||
bad "环境不足:另一个部署正在跑($_LOCK 被占用)—— 不要并发部署,等它结束"
|
||
exit 2
|
||
fi
|
||
# 锁随进程退出自动释放(fd 9 关闭);不需要手工 rm。
|
||
|
||
say "0. 计划"
|
||
printf ' 仓库 : %s\n' "$REPO"
|
||
printf ' 目标 : %s\n' "$TARGET"
|
||
printf ' 数据库 : %s\n' "$DB"
|
||
printf ' 服务 : %s\n' "$SERVICE"
|
||
[ "$DRY_RUN" = 1 ] && printf ' 模式 : DRY-RUN(不落地)\n' || printf ' 模式 : 真实执行\n'
|
||
|
||
# ---------------------------------------------------------------- 1 前端产物
|
||
if [ "$SYNC_WEB" = 1 ] && [ -d "$REPO/client/electron/dist/assets" ]; then
|
||
say "1. 同步前端产物进 go:embed 目录"
|
||
# 只清构建产物:placeholder.html 在版本库里(让 go:embed 在新克隆里能编译),
|
||
# 删掉它会让 git 看到一个本地删除,下一次 commit -a 就把它从仓库带走。
|
||
# ★ 每一步都要**接收退出码**(还清 `DEBTS.json: redeploy-script-unguarded-steps`)。
|
||
# `run()` 内部是 `eval`,调用点不接的话失败既不中断也不上报 —— 实测形状
|
||
# (该欠账原文):`mkdir`/`cp` 被拒后仍打出 `[ OK ] 已拷入`,紧接着又打出
|
||
# `[FAIL] staging 里没有入口`:**一段输出里两个矛盾信号,且 OK 在前**。
|
||
# 这里 `cp` 尤其要紧:前端产物没拷进去,go:embed 会把**旧界面**打进二进制,
|
||
# 而所有单测仍是绿的(2026-09-14 就是这么踩的,见下面那段注释)。
|
||
# 退出码用 2(环境),与文件头的 0/1/2 约定一致 —— 这不是"检查不过",是"这一步没做成"。
|
||
run "rm -rf '$REPO/server/internal/static/static/assets'" || { bad "清不掉旧 assets(权限?)"; exit 2; }
|
||
run "rm -f '$REPO/server/internal/static/static/index.html'" || { bad "删不掉旧 index.html"; exit 2; }
|
||
run "cp -r '$REPO/client/electron/dist/.' '$REPO/server/internal/static/static/'" \
|
||
|| { bad "拷不进前端产物 —— 继续下去会把旧界面嵌进二进制"; exit 2; }
|
||
ok "前端产物已同步"
|
||
|
||
# 运行时脚本必须装在安装根下 —— 单元/drop-in 里引用的是
|
||
# /opt/agentmail/bin/service-failure-notify.mjs,不是仓库路径。
|
||
# 漏了这一步,故障通知会在"仓库被挪走/改名"时静默失效(2026-09-14 修的就是这个)。
|
||
# 前端产物必须比源码新,否则会把旧界面打进二进制。
|
||
# 2026-09-14 实测踩过:改了 src/lib/appearance.ts 的请求路径却没跑 vite build,
|
||
# 部署脚本照样"同步成功",服务出去的还是旧 bundle —— 表现为接口 404
|
||
# (/api/v1/api/v1/… 双前缀),而所有单测都是绿的。
|
||
if [ -d "$REPO/client/electron/src" ] && [ -d "$REPO/client/electron/dist" ]; then
|
||
newer=$(find "$REPO/client/electron/src" -type f -newer "$REPO/client/electron/dist/index.html" 2>/dev/null | head -3)
|
||
if [ -n "$newer" ]; then
|
||
echo " [FAIL] 前端 dist 比源码旧(先跑:cd client/electron && npm run build)"
|
||
echo " 更新的文件:$(echo "$newer" | tr '\n' ' ')"
|
||
exit 1
|
||
fi
|
||
echo " [ OK ] 前端产物比源码新"
|
||
fi
|
||
else
|
||
say "1. 跳过前端同步"
|
||
[ "$SYNC_WEB" = 0 ] && ok "--skip-web" || warn "client/electron/dist 不存在,先跑 cd client/electron && npm run build"
|
||
fi
|
||
|
||
# 运行时脚本必须装在安装根下 —— 单元/drop-in 里引用的是
|
||
# /opt/agentmail/bin/service-failure-notify.mjs,不是仓库路径。
|
||
# 漏了这一步,故障通知会在"仓库被挪走/改名"时静默失效(2026-09-14 修的就是这个)。
|
||
#
|
||
# ★ 这一段**必须在 if/else 之外**(pi 评审 2026-09-14 抓到,实测确认):
|
||
# 它原先夹在 `if SYNC_WEB…` 分支里(在 `echo " [ OK ] 前端产物比源码新"` 之后、
|
||
# `else` 之前),于是 `--skip-web`、或 `client/electron/dist/assets` 不存在时,
|
||
# **运行时脚本根本不装** —— 而它跟前端产物没有任何关系,只是恰好被写进了同一支。
|
||
# 后果是"改了仓库里的通知脚本、用 --skip-web 部署 ⇒ 生产还是旧的那份",
|
||
# 而判据 ③ 只判"在不在、有没有执行位",不判**是哪一份** ⇒ 全绿。
|
||
# (已把 ③ 一并改成比内容。)
|
||
# ★ `--dry-run` 的语义是"只打印将执行的动作"(见文件头用法),但下面两条 `install`
|
||
# 原先**无守卫**:`--dry-run` 会真的建目录、真的把一个 21KB 脚本写进安装根。
|
||
# 实测(AGENTMAIL_PREFIX=/tmp/fp2 bash deploy/redeploy-gateway.sh --dry-run):
|
||
# /tmp/fp2/bin/service-failure-notify.mjs 21453 字节 ← 不该出现的文件
|
||
# 这与 `run()` 的既有口径也不一致(其它写操作都走 run 并被 DRY_RUN 拦下)。
|
||
# ⇒ 用 run() 包住;`--dry-run` 只剩打印。
|
||
run "install -d '$PREFIX/bin'" || { bad "建不了 $PREFIX/bin"; exit 2; }
|
||
if [ "$DRY_RUN" = 1 ]; then
|
||
printf ' [dry-run] install -m 0755 %s %s\n' \
|
||
"$REPO/deploy/service-failure-notify.mjs" "$PREFIX/bin/service-failure-notify.mjs"
|
||
elif ! install -m 0755 "$REPO/deploy/service-failure-notify.mjs" "$PREFIX/bin/service-failure-notify.mjs"; then
|
||
bad "装不了故障通知脚本"
|
||
exit 2
|
||
else
|
||
ok "故障通知脚本已装到 /opt/agentmail/bin/"
|
||
fi
|
||
|
||
# 边界工具 am-sandbox(Landlock)—— 只有**本机真的能建立边界**时才装。
|
||
#
|
||
# 为什么不无条件装:`am-sandbox` 自己 fail closed(套不上边界就拒绝执行命令,退出码 126),
|
||
# 所以在没有 Landlock 的机器上装它等于给"档位=workspace"发一张空头支票 —— 装一个
|
||
# 用不了的工具比不装更坑。这里先真跑一遍边界判据(19 项:界内可写/界外被拒/软链接
|
||
# 逃逸/子进程继承/对照),绿了才 install。
|
||
#
|
||
# 不把判据失败当致命错误:它可能只是内核老 —— 那种机器仍然可以部署网关,
|
||
# 只是沙箱接线会在运行时 fail closed(那一步自己会叫)。
|
||
say "1b. 边界工具(Landlock 沙箱)"
|
||
if [ "$DRY_RUN" = 1 ]; then
|
||
# 同 1. 的理由:`--dry-run` 不该真的构建、更不该真的 install。
|
||
# 这里原来会**真跑一次 go build + 19 项内核判据**(数秒)并写入 $PREFIX/bin。
|
||
printf ' [dry-run] go build -o %s ./cmd/am-sandbox && check-sandbox.sh && install -m 0755 → %s/bin/am-sandbox\n' \
|
||
"/tmp/am-sandbox-build-$TS" "$PREFIX"
|
||
else
|
||
SANDBOX_STAGE="/tmp/am-sandbox-build-$TS"
|
||
rm -f "$SANDBOX_STAGE"
|
||
if ( cd "$REPO/server" && go build -trimpath -o "$SANDBOX_STAGE" ./cmd/am-sandbox ); then
|
||
if AM_SANDBOX="$SANDBOX_STAGE" bash "$REPO/deploy/check-sandbox.sh" >/tmp/am-sandbox-check.log 2>&1; then
|
||
install -m 0755 "$SANDBOX_STAGE" "$PREFIX/bin/am-sandbox"
|
||
ok "边界工具已装(判据 $(grep -c '\[ OK \]' /tmp/am-sandbox-check.log) 项全绿)"
|
||
else
|
||
warn "本机建立不起 Landlock 边界:不装 am-sandbox(见 /tmp/am-sandbox-check.log)"
|
||
fi
|
||
else
|
||
warn "am-sandbox 构建失败(不影响网关本身)"
|
||
fi
|
||
rm -f "$SANDBOX_STAGE"
|
||
fi
|
||
|
||
# ---------------------------------------------------------------- 2 静态检查与测试
|
||
say "2. 构建前检查"
|
||
if [ "$DRY_RUN" = 1 ]; then
|
||
printf ' [dry-run] go vet ./... && go test ./...\n'
|
||
else
|
||
( cd "$REPO/server" && go vet ./... ) || { bad "go vet 不过,终止"; exit 1; }
|
||
ok "go vet 通过"
|
||
if [ "$SKIP_TESTS" = 1 ]; then
|
||
warn "--skip-tests:跳过测试(急救模式,事后必须补跑)"
|
||
else
|
||
( cd "$REPO/server" && timeout 280 go test ./... -timeout 250s ) \
|
||
|| { bad "测试不过,终止部署"; exit 1; }
|
||
ok "go test 全包通过"
|
||
fi
|
||
fi
|
||
|
||
# ---------------------------------------------------------------- 3 构建
|
||
say "3. 构建二进制"
|
||
STAGE="/tmp/agentmail-gateway-build-$TS"
|
||
# 先删再建:go build -o 到已存在的路径时可能拿到 stale 二进制(此坑中过多次)
|
||
# ★ `-trimpath`:Go 默认把源文件的**绝对路径**编进二进制。2026-09-14 实测:本机
|
||
# 历史二进制都是 trimpath 的(里面一处源码路径都没有),19:05 那次不是 ——
|
||
# 于是生产件 /opt/agentmail/agentmail-gateway 里躺着 57 处 /home/program/agentmail/…。
|
||
# 改标准目录部署是为了"运行时不再依赖源码目录";没有 -trimpath 时这条只做到一半:
|
||
# 依赖确实没了,但**源仓库位置还印在产物上**。判据在 check-deploy-drift(标准目录 ⑤)。
|
||
run "rm -f '$STAGE'"
|
||
if [ "$DRY_RUN" = 1 ]; then
|
||
printf ' [dry-run] (cd server && go build -trimpath -o %s ./cmd/server)\n' "$STAGE"
|
||
else
|
||
( cd "$REPO/server" && go build -trimpath -o "$STAGE" ./cmd/server ) \
|
||
|| { bad "构建失败"; exit 1; }
|
||
ok "构建完成 $(du -h "$STAGE" | cut -f1)"
|
||
fi
|
||
|
||
# ---------------------------------------------------------------- 4 备份数据库
|
||
say "4. 备份数据库(sqlite3 .backup,不能用 cp —— WAL 下 cp 会拿到不一致快照)"
|
||
DBBAK=""
|
||
if [ -f "$DB" ]; then
|
||
DBBAK="/tmp/agentmail-pre-deploy-$TS.db"
|
||
run "sqlite3 '$DB' \".backup '$DBBAK'\"" || { bad "备份失败,终止部署"; exit 1; }
|
||
if [ "$DRY_RUN" != 1 ]; then
|
||
integ="$(sqlite3 "$DBBAK" 'PRAGMA integrity_check;' 2>&1 | head -1)"
|
||
if [ "$integ" = "ok" ]; then
|
||
ok "备份完成 $DBBAK($(du -h "$DBBAK" | cut -f1)),integrity_check=ok"
|
||
else
|
||
bad "备份完整性异常: $integ"; exit 1
|
||
fi
|
||
fi
|
||
else
|
||
warn "数据库不存在(首次部署?): $DB"
|
||
fi
|
||
|
||
# ---------------------------------------------------------------- 5 旧二进制留档
|
||
say "5. 旧二进制留档"
|
||
BINBAK=""
|
||
if [ -f "$TARGET" ]; then
|
||
BINBAK="${TARGET}.bak-${TS}"
|
||
run "install -m 0755 '$TARGET' '$BINBAK'"
|
||
ok "旧二进制留档 $BINBAK"
|
||
else
|
||
warn "目标不存在(首次安装)"
|
||
fi
|
||
|
||
# ---------------------------------------------------------------- 6 原子替换
|
||
say "6. 停服 → 原子替换 → 起服"
|
||
|
||
# ★ **信号兜底:服务停着而脚本死了,是这套流程最危险的窗口**(pi 评审 2026-09-14)。
|
||
#
|
||
# 原子 `mv` 修的是"半截二进制",**没有修"服务停着没人知道"**:
|
||
# 第 250 行 stop、第 267 行 start 之间若被外部信号打断(Ctrl-C、宿主杀部署进程、
|
||
# 会话被回收、OOM),脚本直接退出 ⇒ **服务留在停止状态,而脚本什么也不说** ⇒
|
||
# 后果是"邮件全停 + 无人告知",比半截二进制更难被发现。
|
||
# 所以:进入窗口前挂 trap,出了窗口就摘掉;trap 只在"确实还停着"时才动手。
|
||
#
|
||
# 为什么这是第六列的候选(pi 的分类):它既不是变量、命令、空间、身份,也不是
|
||
# 并发(那是"同时有两个部署"),而是**"过程被中断"** —— 环境前提表里没有这一类。
|
||
_SERVICE_STOPPED=0
|
||
_restore_on_signal() {
|
||
local sig="$1"
|
||
if [ "$_SERVICE_STOPPED" = "1" ]; then
|
||
# ⚠️ 用双引号:单引号会让 `$SERVICE` **不展开**、原样打出去(我第一版就是这样,
|
||
# 探针里实测看到字面量 `$SERVICE`)。这是"消息里带了变量但没展开"的形状 ——
|
||
# 看的人会以为服务名真的叫 `$SERVICE`。
|
||
printf '\n [WARN] 收到 %s 且 %s 仍处于停止状态 —— 正在起回来(否则邮件会一直停)\n' \
|
||
"$sig" "$SERVICE" >&2
|
||
systemctl start "$SERVICE" 2>/dev/null || \
|
||
printf ' [FAIL] 起服务失败:请手工 systemctl start %s\n' "$SERVICE" >&2
|
||
printf ' 原子替换未完成;目标二进制可能仍是旧版(原子 mv 保证不会半截)。\n' >&2
|
||
fi
|
||
exit 130
|
||
}
|
||
trap '_restore_on_signal INT' INT
|
||
trap '_restore_on_signal TERM' TERM
|
||
trap '_restore_on_signal HUP' HUP
|
||
# 为什么仍要 stop:SQLite 单写者,且换掉二进制后旧进程还在跑旧代码,
|
||
# 与新库 schema 可能不一致。下面保证的是「文件替换本身」原子,
|
||
# 不代表可以热换正在服务的进程。
|
||
#
|
||
# ★ **为什么不能直接 `install "$STAGE" "$TARGET"`**(pi 评审 2026-09-14,实测):
|
||
# ① `install` 不是 rename(见头部更正)⇒ 中途失败会留下**半截二进制**、旧文件已先被 unlink;
|
||
# ② 就算换成 `mv` 也不原子:`$STAGE` 在 `/tmp`(tmpfs,设备号 40),
|
||
# `$TARGET` 在 `/opt/agentmail`(设备号 2049)—— **不同文件系统**,
|
||
# coreutils 的 `mv` 跨 fs 会退化成 copy+unlink,同样不是原子的。
|
||
# 真原子的三步:**同目录**暂存 → 复制(慢没关系,动的是"还没人用的名字")→ 一次 `mv -f`。
|
||
# 对照:`redeploy-plugin.sh` 的 `mv "$STAGING" "$SNAP"` 是**真原子**(两者都在 `$DEST` 下、同 fs),
|
||
# 同一个仓库里原先两套"原子切换",一套真、一套名义上的。
|
||
run "systemctl stop '$SERVICE'" || {
|
||
# ★ stop 失败必须当场停手(欠账 `redeploy-script-unguarded-steps` 点名的形状:
|
||
# "其中一次的 stop 失败,而它的状态**没人看**")。
|
||
# 为什么这条尤其不能放过:`systemctl start` 对**已在运行**的服务是 no-op ⇒
|
||
# stop 没成功时,后面那句 start 什么也不做,于是**旧进程继续跑旧代码**,
|
||
# 而脚本一路走到后置验证、报"部署成功" —— 正是本文件头列的第 2 类漂移
|
||
# ("部署脚本跑过了 ≠ 线上跑的是当前代码")被脚本**自己在内部**造出来。
|
||
# (新加的判据 ⑤b 能事后发现它,但那是补救;源头在这里。)
|
||
bad "停不下 $SERVICE —— 继续的话,后面的 start 对运行中的服务是 no-op,会留下'以为换了、其实没换'"
|
||
exit 2
|
||
}
|
||
_SERVICE_STOPPED=1 # ← 从这里开始,"脚本死了但服务停着"就是事故
|
||
_NEW="$TARGET.new.$$"
|
||
# 复制到**目标同目录**:这一步慢/失败都无所谓,因为 `$_NEW` 还没有任何人用。
|
||
if ! run "install -m 0755 '$STAGE' '$_NEW'"; then
|
||
bad "暂存到目标目录失败($_NEW)"
|
||
run "rm -f '$_NEW'"
|
||
run "systemctl start '$SERVICE'"
|
||
exit 1
|
||
fi
|
||
# 唯一需要原子的那一步:同 fs ⇒ 真 rename,要么全换、要么原样不动。
|
||
if ! run "mv -f '$_NEW' '$TARGET'"; then
|
||
bad "原子替换失败(mv -f $_NEW $TARGET)—— 旧二进制未被动过"
|
||
run "rm -f '$_NEW'"
|
||
run "systemctl start '$SERVICE'"
|
||
exit 1
|
||
fi
|
||
unset _NEW
|
||
run "systemctl start '$SERVICE'"
|
||
_SERVICE_STOPPED=0 # ← 服务已回来,危险窗口结束
|
||
trap - INT TERM HUP
|
||
run "sleep 6"
|
||
|
||
# ---------------------------------------------------------------- 7 后置验证
|
||
say "7. 后置验证清单(发布终点是这张单子全绿,不是「换上去了」)"
|
||
if [ "$DRY_RUN" = 1 ]; then
|
||
cat <<'EOF'
|
||
[dry-run] 真实执行时逐项校验:
|
||
[ ] 服务 active
|
||
[ ] /health 可达
|
||
[ ] 近 2 分钟无 panic/fatal
|
||
[ ] 四个 Agent 桥的 SSE 重新连上
|
||
EOF
|
||
echo
|
||
echo " DRY-RUN 结束。确认无误后去掉 --dry-run 重跑。"
|
||
exit 0
|
||
fi
|
||
|
||
CHECK_FAIL=0
|
||
|
||
if systemctl is-active --quiet "$SERVICE"; then
|
||
ok "服务 $SERVICE active"
|
||
else
|
||
bad "服务未 active"; CHECK_FAIL=$((CHECK_FAIL+1))
|
||
fi
|
||
|
||
if curl -sf -m 5 "$HEALTH_URL" >/dev/null 2>&1; then
|
||
ok "健康检查通过 $HEALTH_URL"
|
||
else
|
||
bad "健康检查失败 $HEALTH_URL"; CHECK_FAIL=$((CHECK_FAIL+1))
|
||
fi
|
||
|
||
# A: 日志读不到不等于没有 panic(pi 评审 2026-09-14 指出这两处是假绿,实测复现)。
|
||
#
|
||
# 原写法 fc="$(journalctl ... 2>/dev/null | grep -icE 'panic|fatal|SIGSEGV' || true)",
|
||
# journalctl 失败时(无权限读日志、unit 不存在、dbus 不通)错误被 2>/dev/null 吞掉
|
||
# => grep 读空输入 => 输出 0、退出 1 => || true => fc="0" => 报"无 panic"。
|
||
# 实测复现:journalctl -u 不存在的-unit | grep -icE 'panic' => fc=[0]。
|
||
# 下面 sse 那条同形,后果更坏:它把"读不到日志"归因成"插件没连上",
|
||
# 提示人去查密钥,而问题在日志读不到。
|
||
#
|
||
# 实测:PIPESTATUS 也分不开(命令不存在与"存在但无匹配"都给 1),
|
||
# 所以不能靠管道状态区分,必须先把日志取出来、成功后再 grep。
|
||
# 命令是否存在已由 env-defaults.sh 的 AGENTMAIL_REQUIRE 兜住(缺了提前 exit 2);
|
||
# 这里处理的是"命令在、但读不到内容"。
|
||
# 用法: am_scan_logs <unit> <since> <grep 模式> [命令,默认 journalctl]
|
||
# 输出: `clean`(读到了、没命中) | `hit`(读到了、命中) | `unreadable:<码>`(**读不到**)
|
||
#
|
||
# ★ 抽成函数是为了**能被样本喂**(pi 建议 2026-09-14),与既有的 `describeEnvError`、
|
||
# `judgeRestart` 是同一个做法:把"能被样本喂的部分"抽出来,否则这条分支永远只能靠真部署触发。
|
||
# pi 同时指出我原先那次"复现"其实测的是**另一支**:`journalctl -u 不存在的-unit`
|
||
# 退出码是 **0**(实测确认),走的是"命令在但输出为空";真正的"读不到"(`_jrc != 0`)
|
||
# 仍然没被走过。所以要靠第 4 个参数(命令)来喂失败。
|
||
#
|
||
# 用 if/else 而不是 `printf | grep`:管道有 SIGPIPE 的边角(读端提前退出时写端可能被信号杀掉),
|
||
# 而 `grep -q` 会提前退出 —— 这里要的是**确定的**三种输出,不要边角。
|
||
am_scan_logs() {
|
||
local unit="$1" since="$2" pattern="$3" cmd="${4:-journalctl}" out rc
|
||
out="$("$cmd" -u "$unit" --since "$since" --no-pager 2>/dev/null)"; rc=$?
|
||
if [ "$rc" != "0" ]; then printf 'unreadable:%s' "$rc"; return; fi
|
||
if printf '%s' "$out" | grep -qiE "$pattern"; then printf 'hit'; else printf 'clean'; fi
|
||
}
|
||
|
||
_scan="$(am_scan_logs "$SERVICE" '2 min ago' 'panic|fatal|SIGSEGV')"
|
||
case "$_scan" in
|
||
unreadable:*) warn "读不到 $SERVICE 的日志(journalctl 退出码 ${_scan#unreadable:})—— 无法据此判断 panic/fatal" ;;
|
||
hit) bad "近 2 分钟出现 panic/fatal(见 journalctl -u $SERVICE)"; CHECK_FAIL=$((CHECK_FAIL+1)) ;;
|
||
*) ok "近 2 分钟无 panic/fatal(已读到日志)" ;;
|
||
esac
|
||
|
||
# 桥重连:Gateway 重启会掐断所有 SSE,插件应当在几秒内自己回来。
|
||
# 一个都没回来通常意味着密钥被撤销(停用 Agent 会撤销密钥)或端口没起。
|
||
_scan2="$(am_scan_logs "$SERVICE" '1 min ago' 'Client connected')"
|
||
case "$_scan2" in
|
||
unreadable:*) warn "读不到日志(journalctl 退出码 ${_scan2#unreadable:})—— 无法据此判断 SSE 是否重连" ;;
|
||
hit) ok "已有 SSE 客户端重新连上" ;;
|
||
# 读到了、但没有:这时才该提示去查密钥。
|
||
*) warn "暂未看到 SSE 重连 —— 若插件应当在线,检查密钥是否被撤销(停用会撤销密钥)" ;;
|
||
esac
|
||
unset _scan _scan2 2>/dev/null || true
|
||
|
||
# 部署后的**同一次**校验:装上去的那份二进制就是当前 HEAD 建的。
|
||
#
|
||
# ★ 为什么必须有这一条(pi 2026-09-25 提出,我这边已实现为 check-deploy-drift 判据 ⑤b):
|
||
# §7 上面那几项(服务 active / /health 200 / 无 panic / SSE 重连)**全都与代码版本无关** ——
|
||
# 用一个**旧二进制**跑这套清单会**全绿**。2026-09-25 实证:线上跑的是 09-19 的二进制,
|
||
# 而 §7 当时全过、`/health` 200 ⇒ 没有任何一项能说出"装的不是当前代码"。
|
||
# 当时唯一的发现手段是人手动跑 `go version -m` —— 一个**不在任何门禁里**的动作。
|
||
# ⇒ 这里把那个动作自动化:`vcs.revision` 是 Go 以明文编进二进制的(默认 -buildvcs=auto)。
|
||
#
|
||
# 三态(与 ⑤b 同口径,别改成两态):
|
||
# · 相等 ⇒ 绿
|
||
# · 不等 ⇒ **红**(就地回滚)
|
||
# · 取不到任一侧 ⇒ 不判(`-buildvcs=false`/老构建/非 git 构建是合法的;判红会恒红)
|
||
# 不判时**必须说出来**,否则读者分不清"比过且相同"与"根本没比"。
|
||
_binrev="$(go version -m "$TARGET" 2>/dev/null | sed -n 's/.*vcs\.revision=//p' | head -1)"
|
||
_headrev="$(git -C "$REPO" rev-parse HEAD 2>/dev/null)"
|
||
if [ -z "$_binrev" ] || [ -z "$_headrev" ]; then
|
||
warn "无法比对二进制版本(二进制 revision='${_binrev}',HEAD='${_headrev}')—— **不判**,不等于一致"
|
||
elif [ "$_binrev" = "$_headrev" ]; then
|
||
ok "已装二进制 = 当前 HEAD(${_binrev%"${_binrev#????????}"})"
|
||
else
|
||
bad "已装二进制是 ${_binrev%"${_binrev#????????}"},HEAD 是 ${_headrev%"${_headrev#????????}"} —— 装的不是当前代码"
|
||
CHECK_FAIL=$((CHECK_FAIL+1))
|
||
fi
|
||
|
||
# ★ 上一句的**假绿洞**(pi 2026-09-25 指出,我实测复现):`revision == HEAD` **不等于**
|
||
# "二进制就是那份提交的代码" —— 树脏时 Go 照样写 `vcs.revision=<HEAD>`,
|
||
# 只是额外记一个 `vcs.modified=true`。
|
||
# 实测(/tmp/vm2:HEAD=2931050a,改一行**不提交**再 build):
|
||
# revision=2931050a == HEAD ⇒ 上面那句判**绿**
|
||
# 但二进制里是未提交的代码 ⇒ **假绿**
|
||
# 线上那份正是 `vcs.modified=true` ⇒ 它**不对应任何已提交版本**,只比 revision 会漏掉这一点。
|
||
#
|
||
# ⚠️ 但**不把它判红**:本仓是多 agent 并发编辑,脏树是常态,而"总在亮的判据会被学会忽略"
|
||
# (本仓既有的立场,见 check-deploy-drift 判据⑥:「工作区干净度 —— **WARN,不参与退出码**」)。
|
||
# 同一个事实在本仓已有既定严重度,这里就必须同为 WARN —— 否则同仓同一个量两套红绿。
|
||
# ⇒ 说出来,但不改结论:**"等于 HEAD" 与 "不含未提交代码" 是两个量**,别让前者冒充后者。
|
||
_binmod="$(go version -m "$TARGET" 2>/dev/null | sed -n 's/.*vcs\.modified=//p' | head -1)"
|
||
if [ "$_binmod" = "true" ]; then
|
||
warn "该二进制记着 vcs.modified=true ⇒ **含未提交代码**,不对应任何已提交版本(revision 相等也不代表就是那份提交)"
|
||
fi
|
||
unset _binrev _headrev _binmod 2>/dev/null || true
|
||
|
||
echo
|
||
echo " 仍需人工确认(脚本无法代替):"
|
||
echo " [ ] 真实发一封邮件端到端跑通(不是只看进程起来了)"
|
||
echo " [ ] WebUI 能登录且列表正常"
|
||
|
||
echo
|
||
if [ "$CHECK_FAIL" -gt 0 ]; then
|
||
say "结论: 验证有 $CHECK_FAIL 项失败 —— 正在回滚,不要「先上着再修」"
|
||
if [ -n "$BINBAK" ]; then
|
||
run "systemctl stop '$SERVICE'"
|
||
_SERVICE_STOPPED=1
|
||
run "install -m 0755 '$BINBAK' '$TARGET'"
|
||
run "systemctl start '$SERVICE'"
|
||
_SERVICE_STOPPED=0
|
||
ok "已回滚到 $BINBAK"
|
||
else
|
||
warn "无旧二进制可回滚"
|
||
fi
|
||
[ -n "$DBBAK" ] && echo " 如需恢复数据库(先停服务): install -m 0644 '$DBBAK' '$DB'"
|
||
exit 1
|
||
fi
|
||
|
||
say "结论: 自动项全绿"
|
||
# 构建暂存收尾。这份 24MB 副本只是为了「构建 → install」那一步的原子性,装完就没
|
||
# 用了;原先没人删 ⇒ 每次部署在 `$TMPD` 留一份。2026-09-14 实测:7 份 / 162MB,
|
||
# 而 `$TMPD` 是 9.8G 的 tmpfs —— 占满后**连部署自己的第一步都跑不动**
|
||
# (go build 报 ENOSPC)。存量由 `deploy/prune-deploy-artifacts.sh` 按窗口收。
|
||
# 只在这个**成功分支**上删:失败/回滚时那份二进制正是要留的现场,见上面的回滚分支。
|
||
rm -f "$STAGE"
|
||
ok "构建暂存已收($STAGE)"
|
||
echo " 回滚命令(留档 24 小时内有效):"
|
||
[ -n "$BINBAK" ] && echo " install -m 0755 '$BINBAK' '$TARGET' && systemctl restart '$SERVICE'"
|
||
[ -n "$DBBAK" ] && echo " install -m 0644 '$DBBAK' '$DB' # 先 systemctl stop"
|
||
exit 0
|