fix(deploy)!: install 不是 rename(头部那句"原子"论断不成立,实测半截二进制)+ 加并发锁 + journalctl 抽成可喂函数 + data/ 权限

pi 的四条,逐条实测:

**1. `install(1)` 不是 rename —— 而那句话是整节设计的理由**
头部原话:"install(1) 本质是 rename,是原子的 —— 要么完整换掉,要么原样不动"。
按他给的命令实测 `strace … install -m 0755 /bin/true /tmp/t`:
  目标不存在:`openat(t, O_WRONLY|O_CREAT|O_EXCL)`
  目标已存在:`unlinkat(t, 0)` → `openat(… O_CREAT|O_EXCL)` → 写入
**全程没有 rename/renameat**。即复制路径,**旧文件在新文件写完整之前就没了**。
中途失败实证:`ulimit -f 1` ⇒ 退出码 **153**(SIGXFSZ),目标变成 **1024 字节截断 ELF**,
原 14 字节内容**已被销毁** —— 正是本段前半句写的风险,`install` 并不免疫。
(第一次测时我把退出码经管道取到了 `head` 的 0 —— 正是 docs 第 6 条那个坑,重测才拿到 153。)

★ 他补的第二个坑也确认:`/tmp` 与 `/opt/agentmail` **不同文件系统**
(实测设备号 40 vs 2049)⇒ 就算换成 `mv` 也不原子(跨 fs 退化成 copy+unlink)。
已改成真原子三步:**目标同目录**暂存 → `install`(动的是"还没人用的名字")→ 一次 `mv -f`。
对照 `redeploy-plugin.sh` 的 `mv "$STAGING" "$SNAP"` 是**真原子**(同 fs)——
同一仓库原先两套"原子切换",一套真、一套名义上的。

**2. 缺并发锁(环境前提表的第五列:同时性)**
原表(变量/命令/空间/身份)漏了这一类,而它不是假设:工作区是多 agent 共用的。
两个部署同时跑 ⇒ 各自 stop(一次失败、状态没人看)→ 两次写同一目标(配合上面那条 ⇒
真能留半截)→ 两次后置验证互相把对方的"验证不过"当自己结论 → 谁回滚不确定。
三个脚本都加 `flock`(**不是**"检查锁文件存在",那本身有竞态)。
实测:同一把锁上第二个进程 `flock -n` 失败;脚本形态下 `install.sh` 的 `--check` 不建锁
(干跑只读、且刻意允许无写权限运行)。

**3. journalctl 抽成可喂函数 —— 并且他对我那次"复现"的更正成立**
他说我复现的是**"空输出"支**,不是"读不到"支。实测确认:
`journalctl -u 不存在的-unit` 退出码 **0** ⇒ 我测到的是 else 分支。
已抽成 `am_scan_logs <unit> <since> <pattern> [命令]`,输出三态
`clean`/`hit`/`unreadable:<码>`(与既有 `describeEnvError`、`judgeRestart` 同一做法:
把能被样本喂的部分抽出来)。**用 `/bin/false`、`/bin/true`、假"输出含 panic"的脚本
三个样本喂过**(不碰生产):`unreadable:1` / `clean` / `hit` —— 三条支路现在都有覆盖。
判据也随之分开:**"命令不在"由 `AGENTMAIL_REQUIRE` 兜、"命令在但读不到"由这个函数兜**,
两列在代码里分开,而不只是注释里分开。

**4. 低优先项里 umask 那条是真问题(我原来以为可忽略)**
实测 `install -d` 权限位受 umask 影响;而本机生产 `/opt/agentmail/data` = **755**、
`agentmail.db` = **644**(全局可读),同一脚本里 `agent-config`/`pi-config` 却是显式 `-m 0700`
—— 同一脚本两套口径,而那个库里是全部往来邮件。
已改:`install -d -m 0700 "$PREFIX/data"`、`-m 0700 "$ETC"`(密码与密钥)。
(现有生产权限不在本次改动范围,属部署后生效。)

**顺带修一处我自己的口径不一致**:`install.sh` 的 root 检查用 `exit 1`(判据失败),
而另外两个脚本与 `env-defaults.sh` 的"环境不足"都用 **2** —— 调用者无法据此区分
"该重跑"还是"该修代码"。已统一为 2。

★ 加锁过程中我自己连踩三次"复制粘贴的上下文假设"(都已修,并记进 docs 第 18 条):
`install.sh` 没有 `bad()` ⇒ 127;`redeploy-plugin.sh` 没有 `$PREFIX`(用 `$DEST_ROOT`)⇒
`unbound variable`;`install.sh --check` 无写权限 ⇒ 建锁 `Permission denied` 又变 127。
**同一份代码搬到另一个脚本里,能引用的变量和函数是不一样的。**

验证:install.sh --check exit 0;npm test exit 0;prune 自检 22/22;drift 自检 35/0;
check-shared-libs exit 0;全部 deploy 脚本 bash -n 通过。
This commit is contained in:
2026-09-14 21:19:46 +08:00
parent 8e3b04a267
commit 1056b22cbd
4 changed files with 167 additions and 30 deletions

View File

@ -19,10 +19,12 @@ REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# 下游把前者读成后者时就会产出假绿(journalctl 那两处就是:工具缺失被读成"无 panic")。
AGENTMAIL_REQUIRE="git go npm npx node curl systemctl"
agentmail_env_report
# 与 `redeploy-gateway.sh` / `reset-demo.sh` 同源(pi 评审 2026-09-14 对出来的):
# 那两处都写 `${AGENTMAIL_PREFIX:-/opt/agentmail}`,只有这里写死 ——
# 谁设了那个变量,install 装到 A、redeploy 和 drift-check 看 B,三个工具各说各话。
PREFIX="${AGENTMAIL_PREFIX:-/opt/agentmail}"
ETC=/etc/agentmail
#
@ -45,8 +47,34 @@ for a in "$@"; do
esac
done
# ★ **部署锁**(pi 评审 2026-09-14):环境前提里原先缺的第五列 —— **同时性**。
# 这台机器的工作区是多 agent 共用的(docs/DEV-TOOLING.md 自己记过),并发部署会互相踩:
# 两次写同一个目录/文件、两次后置验证互相把对方的"验证不过"当自己的结论、谁回滚不确定。
# 用 flock("检查文件存在"本身有竞态);锁随进程退出自动释放(fd 9 关闭)。
#
# ⚠️ 两个只有在这个脚本里才成立的前提(都是我自己实测撞出来的):
# · **本脚本没有 `bad` 这类输出函数**(它用裸 `echo >&2`)——照抄别的脚本的写法会得到
# `bad: command not found`(退出码 127),把"锁没拿到"报成"脚本坏了"。
# · **`--check` 阶段不建锁**:干跑是只读的、不需要互斥,而它又刻意允许在**没有写权限**时
# 运行(`/opt/agentmail/.deploy.lock: Permission denied` ⇒ 又变成 127)。
# 干跑的价值恰恰是"还没权限的人也能把门跑一遍",所以锁必须在写分支里。
if [[ $CHECK_ONLY -eq 0 ]]; then
[[ $EUID -eq 0 ]] || { echo "需要 root:sudo $0(只验证不安装用 ./deploy/install.sh --check)" >&2; exit 1; }
_LOCK="$PREFIX/.deploy.lock"
if ! exec 9>"$_LOCK"; then
echo " [FAIL] 环境不足:无法创建部署锁 $_LOCK" >&2
exit 2
fi
if ! flock -n 9; then
echo " [FAIL] 环境不足:另一个部署正在跑($_LOCK 被占用)—— 等它结束再跑" >&2
exit 2
fi
fi
if [[ $CHECK_ONLY -eq 0 ]]; then
# ★ 退出码 **2**(环境/权限),不是 1(判据失败)—— 与另外两个部署脚本、
# 以及 env-defaults.sh 里"环境不足"的口径统一。原来这里是 1:
# "没有权限"与"门禁没过"在退出码上不可区分,调用者没法据此决定"该重跑还是该修代码"。
[[ $EUID -eq 0 ]] || { echo " [FAIL] 环境不足:需要 root:sudo $0(只验证不安装用 ./deploy/install.sh --check)" >&2; exit 2; }
else
echo "==> 干跑模式(--check):不会写 $PREFIX 或 $ETC"
# 写权限是这一步唯一的硬门槛,先说清楚 —— 下一个拿到权限的人要一眼看到需要什么
@ -257,7 +285,16 @@ rm -f "$REPO/server/agentmail-gateway"
( cd "$REPO/server" && go vet ./... && go test ./... && go build -trimpath -o "$REPO/server/agentmail-gateway" ./cmd/server )
echo "==> 安装到 $PREFIX"
install -d "$PREFIX" "$PREFIX/data" "$ETC"
# ★ 权限位**显式给**,不靠 umask(pi 评审 2026-09-14 提的低优先项,实测是真问题):
# `install -d` 的权限受进程 umask 影响,而下面这些目录里装着**邮件数据库**。
# 实测本机生产:`/opt/agentmail/data` = **755**、`agentmail.db` = **644**(全局可读),
# 而同一段代码里 `agent-config`/`pi-config` 是显式 `-m 0700` —— 同一个脚本里两套口径。
# 数据库里是全部往来邮件(含正文与地址),不该全局可读。
# 目录 0700 是真正的保护(库文件权限由创建它的进程决定);
# `/etc/agentmail` 里是密码与密钥,同样收到 0700。
install -d -m 0755 "$PREFIX"
install -d -m 0700 "$PREFIX/data"
install -d -m 0700 "$ETC"
install -m 0755 "$REPO/server/agentmail-gateway" "$PREFIX/agentmail-gateway"
# ---- env 文件:仅在缺失时生成,密码随机 ----

View File

@ -7,7 +7,15 @@
#
# 1. cp 是就地写入,会改坏**正在运行中进程**的可执行映像。
# 即使先 stop 了,中途失败也会留下一个半截二进制且旧的已被覆盖。
# install(1) 本质是 rename,是原子的 —— 要么完整换掉,要么原样不动。
# ★ 更正(pi 评审 2026-09-14,已用 strace 实测):**`install(1)` 不是 rename。**
# 实测 `strace … install -m 0755 /bin/true /tmp/t`:
# openat(/tmp/t, O_WRONLY|O_CREAT|O_EXCL) ← 目标不存在时
# unlinkat(/tmp/t, 0) → openat(… O_CREAT|O_EXCL) ← 目标已存在时**先删旧再建新**
# 全程没有 rename/renameat。即"复制"路径:**旧文件在新文件写完整之前就没了**。
# 实证"中途失败留半截":`ulimit -f 1` 下安装 /bin/true ⇒ 退出码 **153**(SIGXFSZ),
# 目标变成 **1024 字节的截断 ELF**,原来那 14 字节内容**已被销毁** ——
# 正是本段前半句写的那个风险,`install` 并不免疫,它只是不再覆盖旧文件(旧的已先删)。
# 所以真正原子的写法是**目标同目录 + 最后一次 mv**(见第 6 节)。
#
# 2. 不备份数据库。SQLite 在 WAL 模式下 cp 会拿到不一致快照
# (主库文件与 -wal 不同步),恢复时可能丢最近写入甚至损坏。
@ -86,7 +94,25 @@ run() {
}
[ -d "$REPO/server" ] || { bad "找不到 $REPO/server"; exit 2; }
command -v sqlite3 >/dev/null 2>&1 || { bad "缺少 sqlite3,无法安全备份数据库"; exit 2; }
# $PREFIX 必须**先**判存在:下面建锁要往它里面写文件(首次安装时它可能还没有)。
[ -d "$PREFIX" ] || { bad "找不到 $PREFIX(先跑 deploy/install.sh)"; exit 2; }
# ★ **部署锁**(pi 评审 2026-09-14):这是我那张"环境前提"表里原先没有的一类 ——
# 不是变量、不是命令、不是空间、不是身份,而是**同时性**。
# 而这台机器上它**不是假设**:`docs/DEV-TOOLING.md` 自己记过"这个工作区是多 agent 共用的,
# 另一条会话正在改 deploy/*.sh"。两个部署同时跑会怎样:
# 各自 `systemctl stop`(其中一次的 stop 失败,而它的状态**没人看**)→
# 两次写同一个 `$TARGET`(配合"install 非原子"⇒ 真能留下半截)→
# 两次后置验证互相把对方的"验证不过"当成自己的结论 → **谁回滚谁不确定**。
# 放在任何写操作之前(这里)。用 flock,不是"检查文件存在"——后者本身有竞态。
# 判据:两个同时启动 ⇒ 第二个 exit 2 并点名。
_LOCK="$PREFIX/.deploy.lock"
exec 9>"$_LOCK" || { bad "无法创建部署锁 $_LOCK"; exit 2; }
if ! flock -n 9; then
bad "环境不足:另一个部署正在跑($_LOCK 被占用)—— 不要并发部署,等它结束"
exit 2
fi
# 锁随进程退出自动释放(fd 9 关闭);不需要手工 rm。
say "0. 计划"
printf ' 仓库 : %s\n' "$REPO"
@ -210,15 +236,34 @@ fi
# ---------------------------------------------------------------- 6 原子替换
say "6. 停服 → 原子替换 → 起服"
# 为什么仍要 stop:SQLite 单写者,且换掉二进制后旧进程还在跑旧代码,
# 与新库 schema 可能不一致。install 保证的是「文件替换本身」原子,
# 与新库 schema 可能不一致。下面保证的是「文件替换本身」原子,
# 不代表可以热换正在服务的进程。
#
# ★ **为什么不能直接 `install "$STAGE" "$TARGET"`**(pi 评审 2026-09-14,实测):
# ① `install` 不是 rename(见头部更正)⇒ 中途失败会留下**半截二进制**、旧文件已先被 unlink;
# ② 就算换成 `mv` 也不原子:`$STAGE` 在 `/tmp`(tmpfs,设备号 40),
# `$TARGET` 在 `/opt/agentmail`(设备号 2049)—— **不同文件系统**,
# coreutils 的 `mv` 跨 fs 会退化成 copy+unlink,同样不是原子的。
# 真原子的三步:**同目录**暂存 → 复制(慢没关系,动的是"还没人用的名字")→ 一次 `mv -f`。
# 对照:`redeploy-plugin.sh` 的 `mv "$STAGING" "$SNAP"` 是**真原子**(两者都在 `$DEST` 下、同 fs),
# 同一个仓库里原先两套"原子切换",一套真、一套名义上的。
run "systemctl stop '$SERVICE'"
run "install -m 0755 '$STAGE' '$TARGET'" || {
bad "替换失败"
[ -n "$BINBAK" ] && run "install -m 0755 '$BINBAK' '$TARGET'"
_NEW="$TARGET.new.$$"
# 复制到**目标同目录**:这一步慢/失败都无所谓,因为 `$_NEW` 还没有任何人用。
if ! run "install -m 0755 '$STAGE' '$_NEW'"; then
bad "暂存到目标目录失败($_NEW)"
run "rm -f '$_NEW'"
run "systemctl start '$SERVICE'"
exit 1
}
fi
# 唯一需要原子的那一步:同 fs ⇒ 真 rename,要么全换、要么原样不动。
if ! run "mv -f '$_NEW' '$TARGET'"; then
bad "原子替换失败(mv -f $_NEW $TARGET)—— 旧二进制未被动过"
run "rm -f '$_NEW'"
run "systemctl start '$SERVICE'"
exit 1
fi
unset _NEW
run "systemctl start '$SERVICE'"
run "sleep 6"
@ -264,29 +309,41 @@ fi
# 所以不能靠管道状态区分,必须先把日志取出来、成功后再 grep。
# 命令是否存在已由 env-defaults.sh 的 AGENTMAIL_REQUIRE 兜住(缺了提前 exit 2);
# 这里处理的是"命令在、但读不到内容"。
_jlog="$(journalctl -u "$SERVICE" --since '2 min ago' --no-pager 2>/dev/null)"; _jrc=$?
if [ "$_jrc" != "0" ]; then
warn "读不到 $SERVICE 的日志(journalctl 退出码 $_jrc)—— 无法据此判断 panic/fatal"
elif printf '%s' "$_jlog" | grep -qiE 'panic|fatal|SIGSEGV'; then
bad "近 2 分钟出现 panic/fatal(见 journalctl -u $SERVICE)"; CHECK_FAIL=$((CHECK_FAIL+1))
else
ok "近 2 分钟无 panic/fatal(已读到日志)"
fi
# 用法: am_scan_logs <unit> <since> <grep 模式> [命令,默认 journalctl]
# 输出: `clean`(读到了、没命中) | `hit`(读到了、命中) | `unreadable:<码>`(**读不到**)
#
# ★ 抽成函数是为了**能被样本喂**(pi 建议 2026-09-14),与既有的 `describeEnvError`、
# `judgeRestart` 是同一个做法:把"能被样本喂的部分"抽出来,否则这条分支永远只能靠真部署触发。
# pi 同时指出我原先那次"复现"其实测的是**另一支**:`journalctl -u 不存在的-unit`
# 退出码是 **0**(实测确认),走的是"命令在但输出为空";真正的"读不到"(`_jrc != 0`)
# 仍然没被走过。所以要靠第 4 个参数(命令)来喂失败。
#
# 用 if/else 而不是 `printf | grep`:管道有 SIGPIPE 的边角(读端提前退出时写端可能被信号杀掉),
# 而 `grep -q` 会提前退出 —— 这里要的是**确定的**三种输出,不要边角。
am_scan_logs() {
local unit="$1" since="$2" pattern="$3" cmd="${4:-journalctl}" out rc
out="$("$cmd" -u "$unit" --since "$since" --no-pager 2>/dev/null)"; rc=$?
if [ "$rc" != "0" ]; then printf 'unreadable:%s' "$rc"; return; fi
if printf '%s' "$out" | grep -qiE "$pattern"; then printf 'hit'; else printf 'clean'; fi
}
_scan="$(am_scan_logs "$SERVICE" '2 min ago' 'panic|fatal|SIGSEGV')"
case "$_scan" in
unreadable:*) warn "读不到 $SERVICE 的日志(journalctl 退出码 ${_scan#unreadable:})—— 无法据此判断 panic/fatal" ;;
hit) bad "近 2 分钟出现 panic/fatal(见 journalctl -u $SERVICE)"; CHECK_FAIL=$((CHECK_FAIL+1)) ;;
*) ok "近 2 分钟无 panic/fatal(已读到日志)" ;;
esac
# 桥重连:Gateway 重启会掐断所有 SSE,插件应当在几秒内自己回来。
# 一个都没回来通常意味着密钥被撤销(停用 Agent 会撤销密钥)或端口没起。
_jlog2="$(journalctl -u "$SERVICE" --since '1 min ago' --no-pager 2>/dev/null)"; _jrc2=$?
if [ "$_jrc2" != "0" ]; then
warn "读不到日志(journalctl 退出码 $_jrc2)—— 无法据此判断 SSE 是否重连"
else
sse="$(printf '%s' "$_jlog2" | grep -c 'Client connected' || true)"
if [ "${sse:-0}" -gt 0 ]; then
ok "已有 $sse 个 SSE 客户端重新连上"
else
warn "暂未看到 SSE 重连 —— 若插件应当在线,检查密钥是否被撤销(停用会撤销密钥)"
fi
fi
unset _jlog _jlog2 _jrc _jrc2 2>/dev/null || true
_scan2="$(am_scan_logs "$SERVICE" '1 min ago' 'Client connected')"
case "$_scan2" in
unreadable:*) warn "读不到日志(journalctl 退出码 ${_scan2#unreadable:})—— 无法据此判断 SSE 是否重连" ;;
hit) ok "已有 SSE 客户端重新连上" ;;
# 读到了、但没有:这时才该提示去查密钥。
*) warn "暂未看到 SSE 重连 —— 若插件应当在线,检查密钥是否被撤销(停用会撤销密钥)" ;;
esac
unset _scan _scan2 2>/dev/null || true
echo
echo " 仍需人工确认(脚本无法代替):"

View File

@ -55,8 +55,30 @@ REPO=${REPO:-/home/program/agentmail}
# 下游把前者读成后者时就会产出假绿(journalctl 那两处就是:工具缺失被读成"无 panic")。
AGENTMAIL_REQUIRE="git node npx systemctl journalctl sqlite3"
agentmail_env_report
# ★ **部署锁**(pi 评审 2026-09-14):环境前提里原先缺的第五列 —— **同时性**。
# 这台机器的工作区是多 agent 共用的(docs/DEV-TOOLING.md 自己记过),并发部署会互相踩:
# 两次写同一个目录/文件、两次后置验证互相把对方的"验证不过"当自己的结论、谁回滚不确定。
# 放在任何写操作之前;用 flock("检查文件存在"本身有竞态)。
GATEWAY_DB=${GATEWAY_DB:-/opt/agentmail/data/agentmail.db}
DEST_ROOT=${DEST_ROOT:-/opt/agentmail/plugins}
#
# 锁的位置放在**变量定义之后**:本脚本没有 `$PREFIX`(它用 `$DEST_ROOT`),
# 而且**没有 `bad()`**(它第 89 行才定义)—— 我第一版两样都照抄了别的脚本,
# 实测分别得到 `PREFIX: unbound variable`(`set -u`)与 `bad: command not found`(127),
# 把"锁没拿到"报成"脚本坏了"。同一份代码搬到不同脚本里,**能引用的变量和函数是不同的**。
_LOCK="$DEST_ROOT/.deploy.lock"
if ! exec 9>"$_LOCK"; then
echo " [FAIL] 环境不足:无法创建部署锁 $_LOCK" >&2
exit 2
fi
if ! flock -n 9; then
echo " [FAIL] 环境不足:另一个部署正在跑($_LOCK 被占用)—— 等它结束再跑" >&2
exit 2
fi
# 锁随进程退出自动释放(fd 9 关闭)。
STAGE_ONLY=0
PLUGIN=""