用户注意到:「当前 agentmail 是在源码目录部署的,应当改为标准目录部署」。 查证后有三处实证(都不是猜测): 1. ★ **失败通知钩子执行的是仓库里的脚本** (`/home/program/agentmail/deploy/service-failure-notify.mjs`,8 处引用: 4 个 drop-in + zcode/zcode-mail-bridge 单元 + agentmail-failure-flush)。 仓库一挪/一改名,故障通知就**静默失效** —— 而那条管线正是用来报告服务故障的。 2. **opencode-serve 的 cwd 就是源码目录**(`WorkingDirectory=/home/program/agentmail`)。 3. ★ **仓库里的 `deploy/*.service` 是旧的源码目录版本**(ExecStart 指向 `/home/program/agentmail/plugins/...`),而机器上的已被改过 —— 也就是说 **谁跑一次 install.sh 就会把部署退回源码目录**。drop-in 更是只存在于 /etc 里, 仓库完全没有它们。 ## 改动 - **唯一真相**:`deploy/systemd/` 镜像 systemd 目录结构,收进全部单元与 drop-in (8 个单元 + 12 个 drop-in),路径全部改到 `/opt`。 - 运行时脚本装到 **`/opt/agentmail/bin/service-failure-notify.mjs`**(自包含, 无相对导入);`install.sh` 与 `redeploy-gateway.sh` 都会幂等地装它。 - opencode 的 cwd 改为 `/opt/agentmail`(与网关一致),已重启生效 (`/proc/<pid>/cwd` 已核)。 - 删掉 `deploy/*.service` 的旧副本,避免两个真相。 - dsh 的 `cordis.patch.yml` 注释里的安装示例也改到标准位置(运行时用的是环境变量, 那条注释是唯一残留)。 ## 判据(`deploy/check-deploy-drift.mjs` 新增「标准目录部署」四条 + 自检) ① 任何 unit/drop-in 都不得引用源码目录;② 已安装单元与 `deploy/systemd/` 逐字节一致; ③ 通知脚本在标准位置且可执行;④ 各服务的 cwd/ExecStart 不在源码目录 (homeagent/dsh/zcode 是**别的产品**的标准位置,按白名单放行)。 自检两个样本:引用源码目录的必须红、干净样本必须绿(证明不是恒真)。 顺带修掉一处**真漂移**:仓库里 dsh 的 `dist/index.js` 落后于部署件(改了 src 没重建), 重建后 `check-deploy-drift` 报「四个宿主都在跑当前代码」。 ## 复核 - `/etc/systemd/system/` 引用仓库:**0** 个文件;`/opt/agentmail` 下只剩旧二进制/备份里 的构建路径(Go 嵌的源码路径,无害)与一条注释。 - 四个宿主都在跑当前代码;标准目录四项全绿。 - 全部服务 active,opencode/网关 cwd 均已在安装根下。
242 lines
9.2 KiB
Bash
Executable File
242 lines
9.2 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}"
|
||
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 修的就是这个)。
|
||
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
|