Files
MailUI4Agents/deploy/redeploy-gateway.sh
JianFeeeee 3be824849d fix(deploy): env-defaults 的顺序错(③ 的探测依赖 ④ 的产物)+ 非 root 的 HOME 陷阱 + 兜底行可见 + 补 root 断言
pi 读了新加的 `deploy/lib/env-defaults.sh`(三个 source 点他都确认对),指出三条,逐一实测后处理:

**1. 顺序错(真错,而且正好落在它自己要消的那类假设上)**
③ 用 `command -v go` 探测,而 `command -v` **走 PATH**;④ 才修 PATH ⇒
**在 ④ 要修的那个环境里(PATH 为空),③ 的探测必然失败**,紧接着 ④ 把 PATH 装上、
后面的步骤**又能**找到 go —— **探测结论与实际可用性相反**。
实测(`env -i`):顺序翻转前 `go` 在 ③ 处找不到、④ 之后 `/usr/bin/go` 就在了。
已把 PATH 那段**挪到最前**,并在文件头写明顺序是**正确性而不是风格**。
(pi 自己也说了严重度:今天对 go 大概率无影响,因为 ① 已兜住 HOME、现代 go 会从 `$HOME/go`
自推缓存 —— 他把它当形状问题提,这个判断我认;一条探测所依赖的东西正是同文件后面要修的东西,
这正是本文件存在的理由。)

**2. `HOME=/root` 在非 root 调用者手里会把环境问题变成代码问题**
`redeploy-gateway.sh` 原先没有 EUID 断言(只有 `install.sh` 有),于是"非 root + 空 HOME"
会拿到 `HOME=/root`,接着 `go build`/`npm ci` 往 `/root/go`、`/root/.npm` 写 ⇒ **EACCES**,
而那串报错看起来是代码/工程问题 —— 正是本文件要消的东西。
已按身份分叉 + **验证可写**(判据落在"能不能写",不落在"路径长得像不像"),
兜底落到 `${TMPDIR:-/tmp}/agentmail-home-$(id -u)`;连一处可写的都找不到 ⇒ exit 2 + 人话。

★ **顺着他的思路又实测出第二个口子**:`HOME` **已给**但不可写时,上面只判"未设"就放行 ——
后果与空 HOME 完全相同,只是入参不同(`sudo -E`、从 root shell 继承、容器挂错)。
`mkdir -p` 对**已存在的不可写目录会返回成功**,所以必须单独判 `-w`。
实测 `setpriv --reuid=65534 env -i HOME=/root` ⇒ `touch $HOME/probe` 被拒。
已覆盖"已给但不可写",并**先说清再改判**(`[WARN] 调用者给的 HOME=… 不可写;改判到 …`),
不静默换目录 —— 静默换会让"东西写到哪去了"变成谜。
五种场景实测(全空 / 环境齐 / 非 root+空 / 非 root+不可写 HOME / root+可写):全部符合预期。

**3. "某条脚本忘了 source"没有信号** ⇒ 采纳
`AGENTMAIL_ENV_DEFAULTS` 只是被 export、值不进正常输出 ⇒ 谁把 `source` 删了,
输出与"环境本来就齐"**完全同形**(又是"看起来在兜、其实没兜")。
新增 `agentmail_env_report()`,三个脚本各自打一行(兜了哪些 / "(无 —— 调用者已提供全部)";
忘了 source 就没有这一行)。实测三个脚本在空环境下各自都打出来了 ——
这也把验收从"一条脚本"变成"三条各自可读"。

**4. 顺带补 `redeploy-gateway.sh` 的 root 断言**
它要往 `$PREFIX`(默认 /opt/agentmail)写,非 root 必然失败在写权限上,
而报错来自 `install`/`cp`、看起来像工程问题。用退出码 **2**(环境/权限),口径与 env-defaults 一致。
实测非 root 下:`[FAIL] 环境不足:本脚本要写 /opt/agentmail,需要 root。` 退出码 2。

验证:install.sh --check 空环境 exit 0、正常环境 exit 0;npm test exit 0;
prune 自检 22/22;drift 自检 35/0;check-shared-libs exit 0;四个脚本 bash -n 通过。
2026-09-14 21:01:19 +08:00

300 lines
13 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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"
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
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