#!/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}" 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 install -d /opt/agentmail/bin install -m 0755 "$REPO/deploy/service-failure-notify.mjs" /opt/agentmail/bin/service-failure-notify.mjs else say "1. 跳过前端同步" [ "$SYNC_WEB" = 0 ] && ok "--skip-web" || warn "client/electron/dist 不存在,先跑 cd client/electron && npm run build" 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 二进制(此坑中过多次) run "rm -f '$STAGE'" if [ "$DRY_RUN" = 1 ]; then printf ' [dry-run] (cd server && go build -o %s ./cmd/server)\n' "$STAGE" else ( cd "$REPO/server" && go build -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 "结论: 自动项全绿" 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