切换三个桥时这两个缺陷都真的触发了,记下来避免重犯。
# 缺陷一:白名单拷贝漏文件 → 服务直接起不来
第一版 staging 用白名单:`package.json lib src index.js dist`,漏掉了 dsh 的
`cordis.patch.yml`(dsh 读它做 overlay 配置)。后果不是「少个文件」而是**服务崩溃循环**:
Error: dsh: failed to read overlay .../dsh-mail-bridge/cordis.patch.yml: ENOENT
白名单的失败模式天生如此:**默认不带**,漏一个就等启动时炸,而那时旧版本已经被换掉。
改为整包复制 + 剔除明确不需要的(`test/`、`.git`、`node_modules/.cache`、`*.log`)。
# 缺陷二:存活判据写成了某一个平台特有的措辞
第一版后置验证 grep 日志里的「已接入」。那句话**只有 pi 与 opencode 会打印**,
dsh 启动时只输出 `dsh web: http://127.0.0.1:3080` —— 于是**一次成功的 dsh 部署
被判成失败**,脚本按设计回滚……回滚到了缺 cordis.patch.yml 的坏快照,
把 dsh 推进崩溃循环。
改为查网关库(平台无关,且是真的端到端):
SELECT count(*) FROM agents WHERE agent_name='$PLUGIN' AND last_seen > '$RESTART_AT'
**时刻必须用 UTC**:`agents.last_seen` 是 UTC(CURRENT_TIMESTAMP 语义),
而 `date` 默认给本地时间 —— 拿 11:44 去比 03:44 会永远为假,于是每次部署都被判成
「桥没连上」并回滚,**门禁主动破坏生产**。已用 `date -u`。
判据双向验证过:以「3 分钟前」为重启时刻 → 命中 1;以未来时刻 → 命中 0。
# 本次切换结果
pi /opt/agentmail/plugins/pi-mail-bridge/current/src/index.mjs
opencode /opt/agentmail/plugins/opencode-mail-bridge/current/index.js
dsh /opt/agentmail/plugins/dsh-mail-bridge/current/dist/index.js
三处配置已迁移(备份在 /root/config-backups/pre-snapshot-20260912-113826/):
pi 的 unit、opencode.jsonc 的 plugin 项、dsh profile 的 link:(含 pnpm install 重建软链)。
验证:三桥进程**打开仓库文件数均为 0**;快照与仓库文件 inode 不同(独立副本);
四个 Agent 心跳新鲜;dsh 读 cordis.patch.yml 走的就是该软链(坏快照时它起不来,
好快照时它 active —— 这条是最硬的证据)。
231 lines
10 KiB
Bash
Executable File
231 lines
10 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
#
|
||
# 把 JS 桥插件部署成**仓库外的快照**,并原子切换 —— 替代「让生产直接跑仓库工作区」。
|
||
#
|
||
# # 为什么必须有这个脚本
|
||
#
|
||
# 在此之前的实际形态(实测):
|
||
#
|
||
# pi systemd: ExecStart=node /home/program/agentmail/plugins/pi-mail-bridge/src/index.mjs
|
||
# opencode opencode.jsonc: "file:///home/program/agentmail/plugins/opencode-mail-bridge"
|
||
# dsh profiles/web/package.json: "dsh-mail-bridge": "link:/home/program/.../dsh-mail-bridge"
|
||
#
|
||
# 三个桥跑的都是**仓库工作区**。于是:
|
||
#
|
||
# - 一次编辑 + 重启 = 上线,没有构建、没有评审、没有版本;
|
||
# - 没有回滚目标:网关有 `.bak-<时间戳>`,三个桥一个都没有;
|
||
# - `git checkout` / `git stash` / 半成品编辑会**静默**改变线上行为;
|
||
# - 仓库同时兼作构建目录(`dist/`、`node_modules/` 都在里面)。
|
||
#
|
||
# 对照:homeagent 插件本来就是这个规范形态(跑的是
|
||
# `/home/newqqagent/plugins/homeagent-mail-bridge/plugin.bin` 部署副本),
|
||
# 所以这里做的不是发明新办法,而是把已有的那个形态推广到三个 JS 桥。
|
||
#
|
||
# # 纪律(与 redeploy-gateway.sh 同一套)
|
||
#
|
||
# 1 前置断言 → 2 staging 拷贝 → 3 门禁(语法/构建)→ 4 原子切换
|
||
# → 5 重启 → 6 后置验证(服务 active **且** 桥日志出现「已接入」)
|
||
# → 任一步失败即切回 .prev 并重启
|
||
#
|
||
# 退出码: 0=成功 1=失败或验证不过(已尝试回滚) 2=参数/环境问题
|
||
#
|
||
set -uo pipefail
|
||
|
||
REPO=${REPO:-/home/program/agentmail}
|
||
GATEWAY_DB=${GATEWAY_DB:-/opt/agentmail/data/agentmail.db}
|
||
DEST_ROOT=${DEST_ROOT:-/opt/agentmail/plugins}
|
||
STAGE_ONLY=0
|
||
PLUGIN=""
|
||
|
||
usage() { sed -n '2,40p' "$0"; }
|
||
|
||
for arg in "$@"; do
|
||
case "$arg" in
|
||
-h|--help) usage; exit 0 ;;
|
||
--stage-only) STAGE_ONLY=1 ;;
|
||
pi|opencode|dsh) PLUGIN="$arg" ;;
|
||
*) echo "未知参数: $arg" >&2; exit 2 ;;
|
||
esac
|
||
done
|
||
[ -n "$PLUGIN" ] || { echo "用法: $0 <pi|opencode|dsh> [--stage-only]" >&2; exit 2; }
|
||
|
||
say() { printf '\n=== %s\n' "$*"; }
|
||
ok() { printf ' [ OK ] %s\n' "$*"; }
|
||
bad() { printf ' [FAIL] %s\n' "$*"; }
|
||
warn() { printf ' [WARN] %s\n' "$*"; }
|
||
info() { printf ' %s\n' "$*"; }
|
||
|
||
TS=$(date +%Y%m%d-%H%M%S)
|
||
SRC="$REPO/plugins/$PLUGIN-mail-bridge"
|
||
DEST="$DEST_ROOT/$PLUGIN-mail-bridge"
|
||
SNAP="$DEST/$TS"
|
||
STAGING="$DEST/.$TS.staging"
|
||
|
||
# 每个桥的差异集中在这张表里:入口、systemd 单元(opencode/dsh 不是独立 unit)。
|
||
case "$PLUGIN" in
|
||
pi) ENTRY="src/index.mjs"; UNIT="pi-mail-bridge"; NEEDS_BUILD=0 ;;
|
||
opencode) ENTRY="index.js"; UNIT="opencode-serve"; NEEDS_BUILD=0 ;;
|
||
dsh) ENTRY="dist/index.js"; UNIT="dsh"; NEEDS_BUILD=1 ;;
|
||
esac
|
||
|
||
say "部署 $PLUGIN-mail-bridge → $SNAP"
|
||
|
||
# ── 1. 前置断言 ────────────────────────────────────────────────
|
||
[ -d "$SRC" ] || { bad "源目录不存在: $SRC"; exit 2; }
|
||
[ -f "$SRC/package.json" ] || { bad "缺少 package.json: $SRC"; exit 2; }
|
||
command -v systemctl >/dev/null 2>&1 || { bad "缺少 systemctl"; exit 2; }
|
||
[ -f "$GATEWAY_DB" ] || { bad "找不到网关库 $GATEWAY_DB(无法验证桥是否连上)"; exit 2; }
|
||
if ! systemctl cat "$UNIT" >/dev/null 2>&1; then
|
||
bad "找不到 systemd 单元 $UNIT(插件要先有一个运行宿主才能谈部署)"; exit 2
|
||
fi
|
||
|
||
if [ "$NEEDS_BUILD" = 1 ]; then
|
||
[ -f "$SRC/tsconfig.json" ] || { bad "dsh 需要 tsconfig.json 才能构建"; exit 2; }
|
||
command -v npx >/dev/null 2>&1 || { bad "缺少 npx,无法构建 dsh 插件"; exit 2; }
|
||
fi
|
||
|
||
# ── 3. staging 拷贝(仓库外的快照)──────────────────────────────
|
||
mkdir -p "$DEST"
|
||
rm -rf "$STAGING"
|
||
mkdir -p "$STAGING"
|
||
|
||
# 整包复制,再剔除开发用目录。
|
||
#
|
||
# **不能白名单列出要拷什么。** 第一版白名单是 `package.json lib src index.js dist`,
|
||
# 漏掉了 dsh 的 `cordis.patch.yml`(dsh 读它做 overlay 配置)。后果不是"少个文件"
|
||
# 而是**服务起不来**,而且只在重启那一刻才暴露:
|
||
#
|
||
# Error: dsh: failed to read overlay .../dsh-mail-bridge/cordis.patch.yml: ENOENT
|
||
#
|
||
# 白名单的失败模式天生如此:漏一个就等着启动时炸,而启动时旧版本已经被换掉了。
|
||
# 黑名单反过来 —— 默认带走,只排除明确不需要的。
|
||
cp -a "$SRC/." "$STAGING/"
|
||
rm -rf "$STAGING/test" "$STAGING/.git" "$STAGING/node_modules/.cache" \
|
||
"$STAGING/.DS_Store" "$STAGING/dist.old" 2>/dev/null
|
||
find "$STAGING" -maxdepth 1 -name '*.log' -delete 2>/dev/null
|
||
# 依赖必须进快照:仓库外没有 node_modules 可借,缺了它入口根本起不来。
|
||
if [ -d "$SRC/node_modules" ]; then
|
||
cp -a "$SRC/node_modules" "$STAGING/"
|
||
ok "已拷入 node_modules(生产不借用仓库的依赖)"
|
||
else
|
||
warn "源目录没有 node_modules —— 若入口依赖外部包,启动时会失败"
|
||
fi
|
||
|
||
# 需要编译的插件:**产出到 staging**,不写仓库里的 dist/。
|
||
#
|
||
# 先在仓库里构建再拷贝会有两个问题:一是失败的构建也会 emit(tsc 默认
|
||
# noEmitOnError=false),于是仓库的 dist/ 被半成品覆盖;二是生产产物与
|
||
# 工作区之间多了一条看不见的耦合。
|
||
if [ "$NEEDS_BUILD" = 1 ]; then
|
||
if ( cd "$SRC" && TMPDIR=${TMPDIR:-/tmp} npx tsc -p tsconfig.json --outDir "$STAGING/dist" >/tmp/"$PLUGIN"-tsc.log 2>&1 ); then
|
||
ok "tsc 构建完成(产出到 staging)"
|
||
else
|
||
bad "tsc 构建失败(见 /tmp/$PLUGIN-tsc.log):"
|
||
sed 's/^/ /' /tmp/"$PLUGIN"-tsc.log | head -8 >&2
|
||
rm -rf "$STAGING"; exit 1
|
||
fi
|
||
fi
|
||
|
||
[ -f "$STAGING/$ENTRY" ] || { bad "staging 里没有入口 $ENTRY"; rm -rf "$STAGING"; exit 1; }
|
||
ok "staging 就绪: $STAGING"
|
||
|
||
# ── 4. 门禁:语法检查(不启动服务,因此不会碰生产)────────────
|
||
if node --check "$STAGING/$ENTRY" 2>/tmp/"$PLUGIN"-check.log; then
|
||
ok "入口语法检查通过($ENTRY)"
|
||
else
|
||
bad "入口语法检查失败:"; sed 's/^/ /' /tmp/"$PLUGIN"-check.log >&2
|
||
rm -rf "$STAGING"; exit 1
|
||
fi
|
||
|
||
# 逐个解析入口的 import 图,确认快照自足。
|
||
#
|
||
# **不能只比对 package.json 的 dependencies**:pi 的 dependencies 是 `{}`,
|
||
# 而它 import 了 `@earendil-works/pi-coding-agent` —— 声明是假的。只查声明
|
||
# 等于空跑,而漏掉的代价出现在最糟的时刻(current 已切、服务重启、插件起不来,
|
||
# 旧版本已被换掉)。
|
||
if ! node "$REPO/deploy/check-plugin-snapshot.mjs" "$STAGING" "$ENTRY" 2>&1 | sed 's/^/ /'; then
|
||
bad "快照不自足(缺依赖),销毁 staging 且不切换"
|
||
rm -rf "$STAGING"
|
||
exit 1
|
||
fi
|
||
ok "快照自足(入口的 import 图全部可解析)"
|
||
|
||
if [ "$STAGE_ONLY" = 1 ]; then
|
||
# 干跑:把快照留在 .staging,**不切 current、不重启**。
|
||
say "干跑结束(--stage-only)"
|
||
info "快照留在: $STAGING"
|
||
info "未做: 原子切换 / 重启 $UNIT / 后置验证"
|
||
info "要真部署:$0 $PLUGIN"
|
||
exit 0
|
||
fi
|
||
|
||
# ── 5. 原子切换 ────────────────────────────────────────────────
|
||
PREV=""
|
||
[ -L "$DEST/current" ] && PREV=$(basename "$(readlink -f "$DEST/current")")
|
||
|
||
mv "$STAGING" "$SNAP" || { bad "staging → 快照 移动失败"; exit 1; }
|
||
ln -sfn "$TS" "$DEST/current"
|
||
ok "current → $TS${PREV:+(上一版 $PREV)}"
|
||
|
||
rollback() {
|
||
if [ -n "$PREV" ] && [ -d "$DEST/$PREV" ]; then
|
||
ln -sfn "$PREV" "$DEST/current"
|
||
systemctl restart "$UNIT" >/dev/null 2>&1
|
||
warn "已回滚到 $PREV 并重启 $UNIT"
|
||
else
|
||
warn "无上一版可回滚(这是首次部署)—— 请手工处理"
|
||
fi
|
||
}
|
||
|
||
# ── 6. 重启 + 后置验证 ─────────────────────────────────────────
|
||
# 记下重启时刻,后面用「网关库里的 last_seen 是否比它新」判断桥真的连上了。
|
||
# **必须用 UTC。** `agents.last_seen` 是 UTC(SQLite 的 CURRENT_TIMESTAMP 语义),
|
||
# 而 `date` 默认给本地时间。拿本地时间(如 11:44)去比 UTC 值(03:44)会**永远为假**,
|
||
# 于是每一次部署都被判成"桥没连上"并回滚 —— 判据写错会让门禁主动破坏生产。
|
||
RESTART_AT=$(date -u '+%Y-%m-%d %H:%M:%S')
|
||
systemctl restart "$UNIT" || { bad "重启 $UNIT 失败"; rollback; exit 1; }
|
||
|
||
# 存活判据分两层:
|
||
#
|
||
# 1. `systemctl is-active` —— 进程在不在。**不够**:桥可能进程活着却没连上
|
||
# Gateway(密钥过期、Gateway 未起、依赖在惰加载时才暴露)。
|
||
# 2. **网关库里 `last_seen` 是否比重启时刻新** —— 平台无关,且是真的端到端
|
||
# (桥 → 心跳 → 服务端落库)。这一条替代了第一版的「日志出现『已接入』」:
|
||
# 那句话只有 **pi 与 opencode** 会打印,dsh 启动时只输出
|
||
# `dsh web: http://127.0.0.1:3080` —— 照那个判据,**一次成功的 dsh 部署
|
||
# 会被判成失败并回滚**(实测就是这么把 dsh 弄进崩溃循环的:回滚到缺
|
||
# cordis.patch.yml 的坏快照)。
|
||
#
|
||
# 教训:判据里不要写某一个平台特有的措辞。
|
||
command -v sqlite3 >/dev/null 2>&1 || { bad "缺少 sqlite3,无法验证桥是否连上网关"; rollback; exit 1; }
|
||
DEADLINE=$(( $(date +%s) + 90 ))
|
||
HEARTBEAT=0
|
||
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
|
||
SEEN=$(sqlite3 "$GATEWAY_DB" \
|
||
"SELECT count(*) FROM agents WHERE agent_name='$PLUGIN' AND last_seen > '$RESTART_AT';" 2>/dev/null)
|
||
if [ "${SEEN:-0}" != "0" ]; then HEARTBEAT=1; break; fi
|
||
sleep 3
|
||
done
|
||
|
||
ACTIVE=$(systemctl is-active "$UNIT" 2>/dev/null)
|
||
|
||
say "后置验证"
|
||
[ "$ACTIVE" = active ] && ok "$UNIT 处于 active" || bad "$UNIT 状态为 $ACTIVE"
|
||
if [ "$HEARTBEAT" = 1 ]; then
|
||
ok "网关收到 $PLUGIN 的新心跳(last_seen 晚于重启时刻)—— 桥真的连上了"
|
||
else
|
||
bad "90 秒内网关未收到 $PLUGIN 的新心跳 —— 桥没连上(进程活着不等于连上了)"
|
||
info "诊断:journalctl -u $UNIT --since '-3 minutes' | tail -30"
|
||
fi
|
||
|
||
if [ "$ACTIVE" != active ] || [ "$HEARTBEAT" != 1 ]; then
|
||
say "结论: 验证不过 —— 回滚,不要「先上着再修」"
|
||
rollback
|
||
exit 1
|
||
fi
|
||
|
||
say "结论: 部署成功"
|
||
info "运行路径: $DEST/current/$ENTRY(仓库不再是生产代码)"
|
||
info "回滚命令: ln -sfn '${PREV:-$TS}' '$DEST/current' && systemctl restart $UNIT"
|
||
exit 0
|