pi 给了"第五次"的两条线索,都在我读得到的地方,逐条实测确认后修完:
**1. TMPDIR 与 HOME 不同规则(就在同一个文件里)**
① `HOME` 那边写了两条规则:`mkdir -p` 对**已存在的不可写目录会返回成功** ⇒ 必须单独判 `-w`;
判据落在"能不能写"不落在"路径像不像"。**同一条规则没落到 ② `TMPDIR` 上** ——
而 ENOSPC 正是这条链的元老问题(四次史里第 3 条就是 TMPDIR)。两种失败形状:
已给但**不可写**(EACCES)、可写但**已满**(`-w` 抓不到,要的是**空间**判定)。
已补 `-d` + `-w` + 可用空间(`df -Pk`,读不到⇒**不据此判定**;`0` 是**真的没有**);
不足 ⇒ 人话 + exit 2。**不 import** 插件那份 `test/lib/tmp-space.mjs`:
`deploy/` 侧要能独立分发,为去重引进平台代码不划算(按既定理由,写最小版本)。
实测 `TMPDIR=/root/nope` ⇒ `[FAIL] 环境不足:TMPDIR=… 不存在或不可写` + 退出码 2。
**2. 环境自足只覆盖"变量",没覆盖"命令" —— 其中 journalctl 两处是假绿**
这节的要害是 pi 给的那句判据,我认:**"命令不在"必须走 2/红 + 人话;
"命令在但输出为空"才是判定结果。** 原先两处把两者压成同一个字符串 `"0"`:
journalctl 失败(被 `2>/dev/null` 吞掉)⇒ grep 读空 ⇒ `fc="0"` ⇒ **打印"无 panic/fatal"**。
实测复现:`journalctl -u 不存在的-unit | grep -icE 'panic'` ⇒ `fc=[0]`。
`sse` 那条同形、后果更坏:**把"读不到日志"归因成"插件没连上"**,让人去查密钥。
⚠️ 顺带实测:**`PIPESTATUS` 分不开这两种情况**(命令不存在与"存在但无匹配"都给 1),
所以不能靠管道状态区分 —— 必须**先取输出、成功后再过滤**,命令存在性另做前提检查。
改法:两处都改成"先取日志、看退出码";读不到 ⇒ `warn` 明说"读不到、无法据此判断"
(既不假绿也不假红)。并给三个脚本加 `AGENTMAIL_REQUIRE` 前提检查
(缺一个 ⇒ exit 2 + 人话),与四次史的处理**同形**,只是对象从变量换成命令。
实测:`AGENTMAIL_REQUIRE` 里放不存在的命令 ⇒ 退出码 2。
**3. 顺带修 pi 点到的两处同族问题**
· `install.sh` 的 `HEAD_REV="$(git … rev-parse --short HEAD)"`:`set -e` 下失败**直接中止**
(实测退出码 127、无翻译);而且 HEAD_REV 为空会让下一句报
"这个包比源码旧:产物 gitRev=… ≠ HEAD=" —— **把"这里不是 git 仓库"说成"产物过期"**。
已改成显式判失败 + 明说"读不到当前 HEAD,跳过新旧比对"。
· 同块第 94 行末尾挂着一个 `|| true` ⇒ 整行退出码恒 0 ⇒ 它作为 `if` 条件**永远为真**
("判据的形式在、区分力不在")。已改成显式计算、去掉 `|| true`。
docs 补两条纪律:15「"命令不在" ≠ "命令在但输出为空"」(含 PIPESTATUS 分不开的实测)、
16「一条规则写了,要检查它是否落到了所有同类对象上」。
验证:install.sh --check 空环境 exit 0、正常 exit 0;TMPDIR 不可写 exit 2;
npm test exit 0;prune 自检 22/22;drift 自检 35/0;check-shared-libs exit 0。
323 lines
15 KiB
Bash
Executable File
323 lines
15 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# redeploy-gateway.sh —— Gateway 二进制热替换(原子化,带备份与后置验证)
|
||
#
|
||
# 为什么需要这个脚本:日常改后端只需要换二进制,跑整个 install.sh 太重
|
||
# (它会重装 npm 依赖、重写 systemd 单元、重新生成 env)。而手工
|
||
# 「systemctl stop → cp → start」有两个隐患:
|
||
#
|
||
# 1. cp 是就地写入,会改坏**正在运行中进程**的可执行映像。
|
||
# 即使先 stop 了,中途失败也会留下一个半截二进制且旧的已被覆盖。
|
||
# install(1) 本质是 rename,是原子的 —— 要么完整换掉,要么原样不动。
|
||
#
|
||
# 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; }
|
||
command -v sqlite3 >/dev/null 2>&1 || { bad "缺少 sqlite3,无法安全备份数据库"; exit 2; }
|
||
|
||
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 保证的是「文件替换本身」原子,
|
||
# 不代表可以热换正在服务的进程。
|
||
run "systemctl stop '$SERVICE'"
|
||
run "install -m 0755 '$STAGE' '$TARGET'" || {
|
||
bad "替换失败"
|
||
[ -n "$BINBAK" ] && run "install -m 0755 '$BINBAK' '$TARGET'"
|
||
run "systemctl start '$SERVICE'"
|
||
exit 1
|
||
}
|
||
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);
|
||
# 这里处理的是"命令在、但读不到内容"。
|
||
_jlog="$(journalctl -u "$SERVICE" --since '2 min ago' --no-pager 2>/dev/null)"; _jrc=$?
|
||
if [ "$_jrc" != "0" ]; then
|
||
warn "读不到 $SERVICE 的日志(journalctl 退出码 $_jrc)—— 无法据此判断 panic/fatal"
|
||
elif printf '%s' "$_jlog" | grep -qiE 'panic|fatal|SIGSEGV'; then
|
||
bad "近 2 分钟出现 panic/fatal(见 journalctl -u $SERVICE)"; CHECK_FAIL=$((CHECK_FAIL+1))
|
||
else
|
||
ok "近 2 分钟无 panic/fatal(已读到日志)"
|
||
fi
|
||
|
||
# 桥重连:Gateway 重启会掐断所有 SSE,插件应当在几秒内自己回来。
|
||
# 一个都没回来通常意味着密钥被撤销(停用 Agent 会撤销密钥)或端口没起。
|
||
_jlog2="$(journalctl -u "$SERVICE" --since '1 min ago' --no-pager 2>/dev/null)"; _jrc2=$?
|
||
if [ "$_jrc2" != "0" ]; then
|
||
warn "读不到日志(journalctl 退出码 $_jrc2)—— 无法据此判断 SSE 是否重连"
|
||
else
|
||
sse="$(printf '%s' "$_jlog2" | grep -c 'Client connected' || true)"
|
||
if [ "${sse:-0}" -gt 0 ]; then
|
||
ok "已有 $sse 个 SSE 客户端重新连上"
|
||
else
|
||
warn "暂未看到 SSE 重连 —— 若插件应当在线,检查密钥是否被撤销(停用会撤销密钥)"
|
||
fi
|
||
fi
|
||
unset _jlog _jlog2 _jrc _jrc2 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
|