pi 的四条,逐条实测: **1. `install(1)` 不是 rename —— 而那句话是整节设计的理由** 头部原话:"install(1) 本质是 rename,是原子的 —— 要么完整换掉,要么原样不动"。 按他给的命令实测 `strace … install -m 0755 /bin/true /tmp/t`: 目标不存在:`openat(t, O_WRONLY|O_CREAT|O_EXCL)` 目标已存在:`unlinkat(t, 0)` → `openat(… O_CREAT|O_EXCL)` → 写入 **全程没有 rename/renameat**。即复制路径,**旧文件在新文件写完整之前就没了**。 中途失败实证:`ulimit -f 1` ⇒ 退出码 **153**(SIGXFSZ),目标变成 **1024 字节截断 ELF**, 原 14 字节内容**已被销毁** —— 正是本段前半句写的风险,`install` 并不免疫。 (第一次测时我把退出码经管道取到了 `head` 的 0 —— 正是 docs 第 6 条那个坑,重测才拿到 153。) ★ 他补的第二个坑也确认:`/tmp` 与 `/opt/agentmail` **不同文件系统** (实测设备号 40 vs 2049)⇒ 就算换成 `mv` 也不原子(跨 fs 退化成 copy+unlink)。 已改成真原子三步:**目标同目录**暂存 → `install`(动的是"还没人用的名字")→ 一次 `mv -f`。 对照 `redeploy-plugin.sh` 的 `mv "$STAGING" "$SNAP"` 是**真原子**(同 fs)—— 同一仓库原先两套"原子切换",一套真、一套名义上的。 **2. 缺并发锁(环境前提表的第五列:同时性)** 原表(变量/命令/空间/身份)漏了这一类,而它不是假设:工作区是多 agent 共用的。 两个部署同时跑 ⇒ 各自 stop(一次失败、状态没人看)→ 两次写同一目标(配合上面那条 ⇒ 真能留半截)→ 两次后置验证互相把对方的"验证不过"当自己结论 → 谁回滚不确定。 三个脚本都加 `flock`(**不是**"检查锁文件存在",那本身有竞态)。 实测:同一把锁上第二个进程 `flock -n` 失败;脚本形态下 `install.sh` 的 `--check` 不建锁 (干跑只读、且刻意允许无写权限运行)。 **3. journalctl 抽成可喂函数 —— 并且他对我那次"复现"的更正成立** 他说我复现的是**"空输出"支**,不是"读不到"支。实测确认: `journalctl -u 不存在的-unit` 退出码 **0** ⇒ 我测到的是 else 分支。 已抽成 `am_scan_logs <unit> <since> <pattern> [命令]`,输出三态 `clean`/`hit`/`unreadable:<码>`(与既有 `describeEnvError`、`judgeRestart` 同一做法: 把能被样本喂的部分抽出来)。**用 `/bin/false`、`/bin/true`、假"输出含 panic"的脚本 三个样本喂过**(不碰生产):`unreadable:1` / `clean` / `hit` —— 三条支路现在都有覆盖。 判据也随之分开:**"命令不在"由 `AGENTMAIL_REQUIRE` 兜、"命令在但读不到"由这个函数兜**, 两列在代码里分开,而不只是注释里分开。 **4. 低优先项里 umask 那条是真问题(我原来以为可忽略)** 实测 `install -d` 权限位受 umask 影响;而本机生产 `/opt/agentmail/data` = **755**、 `agentmail.db` = **644**(全局可读),同一脚本里 `agent-config`/`pi-config` 却是显式 `-m 0700` —— 同一脚本两套口径,而那个库里是全部往来邮件。 已改:`install -d -m 0700 "$PREFIX/data"`、`-m 0700 "$ETC"`(密码与密钥)。 (现有生产权限不在本次改动范围,属部署后生效。) **顺带修一处我自己的口径不一致**:`install.sh` 的 root 检查用 `exit 1`(判据失败), 而另外两个脚本与 `env-defaults.sh` 的"环境不足"都用 **2** —— 调用者无法据此区分 "该重跑"还是"该修代码"。已统一为 2。 ★ 加锁过程中我自己连踩三次"复制粘贴的上下文假设"(都已修,并记进 docs 第 18 条): `install.sh` 没有 `bad()` ⇒ 127;`redeploy-plugin.sh` 没有 `$PREFIX`(用 `$DEST_ROOT`)⇒ `unbound variable`;`install.sh --check` 无写权限 ⇒ 建锁 `Permission denied` 又变 127。 **同一份代码搬到另一个脚本里,能引用的变量和函数是不一样的。** 验证:install.sh --check exit 0;npm test exit 0;prune 自检 22/22;drift 自检 35/0; check-shared-libs exit 0;全部 deploy 脚本 bash -n 通过。
380 lines
19 KiB
Bash
Executable File
380 lines
19 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"
|
||
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 就把它从仓库带走。
|
||
run "rm -rf '$REPO/server/internal/static/static/assets'"
|
||
run "rm -f '$REPO/server/internal/static/static/index.html'"
|
||
run "cp -r '$REPO/client/electron/dist/.' '$REPO/server/internal/static/static/'"
|
||
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 部署 ⇒ 生产还是旧的那份",
|
||
# 而判据 ③ 只判"在不在、有没有执行位",不判**是哪一份** ⇒ 全绿。
|
||
# (已把 ③ 一并改成比内容。)
|
||
install -d "$PREFIX/bin" || { bad "建不了 $PREFIX/bin"; exit 2; }
|
||
if ! install -m 0755 "$REPO/deploy/service-failure-notify.mjs" "$PREFIX/bin/service-failure-notify.mjs"; then
|
||
bad "装不了故障通知脚本"
|
||
exit 2
|
||
fi
|
||
ok "故障通知脚本已装到 /opt/agentmail/bin/"
|
||
|
||
# ---------------------------------------------------------------- 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. 停服 → 原子替换 → 起服"
|
||
# 为什么仍要 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'"
|
||
_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'"
|
||
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
|
||
|
||
echo
|
||
echo " 仍需人工确认(脚本无法代替):"
|
||
echo " [ ] 真实发一封邮件端到端跑通(不是只看进程起来了)"
|
||
echo " [ ] WebUI 能登录且列表正常"
|
||
|
||
echo
|
||
if [ "$CHECK_FAIL" -gt 0 ]; then
|
||
say "结论: 验证有 $CHECK_FAIL 项失败 —— 正在回滚,不要「先上着再修」"
|
||
if [ -n "$BINBAK" ]; then
|
||
run "systemctl stop '$SERVICE'"
|
||
run "install -m 0755 '$BINBAK' '$TARGET'"
|
||
run "systemctl start '$SERVICE'"
|
||
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
|