pi 这一封四个实质点,逐个实测后处理: **C. 口径扩到全部文件**(他给的是算术,不是口味,我认): 原先按后缀取(`.conf/.service/.timer/.bak*`),我说的"零违规就不扩"是把口味当论证。 他把成本量化了:差集极小 ⇒ 多读几次文件(几十 KB),而收益是那个 `0` 从 **"有范围的 0"**(只对我划的圈成立)变成**"闭合的 0"**(对整棵 /etc/systemd 成立)。 他还补了一句我没想到的:这条判据只报**内容里含仓库路径**的文件, 所以含仓库串的 `.dpkg-old`/`~`/无后缀文件**恰恰都是真信号**(过期的旧真相), 不是噪声 —— 我先前"二进制会变成噪声"的担心本来就不成立。 **验收实测:比了 133 个文件(全部,不筛后缀)、命中 0。** 另按他要求把"零违规"这个前提写进注释,并说明"红/WARN 拆分"为什么推迟 (零违规时拆分是重构不是修 bug;出现第一个非白名单命中时再决定分档)。 **反例 1(②b 对 pi 是跑不到的分支)**:确认。pi 的依赖是全局包软链 (`-> /usr/lib/node_modules/@earendil-works/pi-coding-agent`),`cp -a` 保留软链 ⇒ 两侧 realpath 到**同一个 inode**(实测 `statSync(a).ino === statSync(b).ino`) ⇒ 版本集合按构造相等 ⇒ **②b 对 pi 永远不会红**。这正是本文件自己列过的第三种形态 (断言在、区分力不在),比"没写判据"更坏因为它看起来是绿的。 已改:两侧 realpath 相同时**明说"恒等、区分力为零"**并指出它真正覆盖谁(有 vendored 树的宿主), 不再报"版本集合一致"这种让人误以为验过的措辞。自检加了这一条。 **反例 2(① 的 realpath 盲区)**:确认,形状真实且三条判据全都看不见 —— ①只 grep 内容(仓库那份 unit 文本里没有仓库字面量)、②比内容(live 就是 repo 那个 inode, 必然"一致")、④只查固定名单。已加 realpath 判据:被检文件 realpath 落在仓库里 ⇒ 红, 与内容无关。自检加**正反两面**(内容干净但指向仓库 ⇒ 红;指向仓库外 ⇒ 不许红, 否则这条判据恒红)。实测:本机 `/etc/systemd/system` 下 0 条指向仓库的软链 (即这个 0 现在才是闭合的)。 **反例 3(环境假设第四次 ⇒ 建议收成一处)**:采纳。四次的形态一模一样 (HOME ⇒ 又一次 HOME ⇒ TMPDIR ⇒ GOMODCACHE/GOPATH),每次"再加一个预检"只挡已知那一个。 新增 `deploy/lib/env-defaults.sh`:一处给全 HOME/TMPDIR/GOMODCACHE(GOPATH)/PATH, 只设**未设**的变量,注释里写明四次历史与"否则第五次一定会来"; 三个部署脚本开头 source 它;**删掉**我上一轮加的那个分散 go 预检。 实测:在 `HOME`/`TMPDIR`/`GOPATH`/`GOMODCACHE` **全空**的环境里 `bash deploy/install.sh --check` **exit 0**(go vet + go test 自己站起来), 兜住的变量会在 `AGENTMAIL_ENV_DEFAULTS` 里说明。 docs 补两条纪律:13「锚点必须一一对应 —— 连'文件名'都会骗你」(E 的探针教训)、 14「退出码也有量纲」(--self-check 的退出码不是自检的结论)。 验证:npm test exit 0;check-shared-libs exit 0;drift --self-check **35/0**; prune 干跑 exit 0;install.sh --check 空环境 exit 0。
287 lines
12 KiB
Bash
Executable File
287 lines
12 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"
|
||
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
|
||
|
||
fc="$(journalctl -u "$SERVICE" --since '2 min ago' --no-pager 2>/dev/null \
|
||
| grep -icE 'panic|fatal|SIGSEGV' || true)"
|
||
if [ "${fc:-0}" = "0" ]; then
|
||
ok "近 2 分钟无 panic/fatal"
|
||
else
|
||
bad "近 2 分钟出现 $fc 条 panic/fatal"; CHECK_FAIL=$((CHECK_FAIL+1))
|
||
fi
|
||
|
||
# 桥重连:Gateway 重启会掐断所有 SSE,插件应当在几秒内自己回来。
|
||
# 一个都没回来通常意味着密钥被撤销(停用 Agent 会撤销密钥)或端口没起。
|
||
sse="$(journalctl -u "$SERVICE" --since '1 min ago' --no-pager 2>/dev/null \
|
||
| grep -c 'Client connected' || true)"
|
||
if [ "${sse:-0}" -gt 0 ]; then
|
||
ok "已有 $sse 个 SSE 客户端重新连上"
|
||
else
|
||
warn "暂未看到 SSE 重连 —— 若插件应当在线,检查密钥是否被撤销(停用会撤销密钥)"
|
||
fi
|
||
|
||
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
|