Files
MailUI4Agents/deploy/redeploy-plugin.sh
JianFeeeee cbe5ef5cec fix(deploy): 快照改整包复制 + 存活判据改看网关心跳 —— 修两个会毁掉生产的缺陷
切换三个桥时这两个缺陷都真的触发了,记下来避免重犯。

# 缺陷一:白名单拷贝漏文件 → 服务直接起不来

第一版 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 —— 这条是最硬的证据)。
2026-09-12 11:47:32 +08:00

231 lines
10 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
#
# 把 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/。
#
# 先在仓库里构建再拷贝会有两个问题:一是失败的构建也会 emittsc 默认
# 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` 是 UTCSQLite 的 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