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

@ -232,7 +232,28 @@ bash deploy/prune-deploy-artifacts.sh --self-check # 判据自检(16 项,
⇒ 规矩:**"命令不在"走 2/红 + 人话;"命令在但输出为空"才是判定结果。**
同族放宽:环境自足不能只覆盖**变量**,也要覆盖**命令**(`journalctl`/`curl`/`systemctl`/
`git`/`go`/`npm` 都曾是被假设存在的那一类)。
16. **一条规则写了,要检查它是否落到了"所有同类对象"上 —— 而不是只落到你想到的那一个。**
16. **"原子替换"要指名哪一步原子 —— 而且要验 `install`/`mv` 到底做了什么。**
实例(pi 评审 2026-09-14,已用 strace + `ulimit -f` 实测):
`redeploy-gateway.sh` 头部断言"`install(1)` 本质是 rename,是原子的 ——
要么完整换掉,要么原样不动",**而那是整节设计的理由**(为什么优先 `install`、
为什么失败即回滚)。实测 `strace … install -m 0755 /bin/true /tmp/t`:
`unlinkat(t)` → `openat(t, O_CREAT|O_EXCL)` → 写入,**全程没有 rename**。
中途失败实证:`ulimit -f 1` 下安装 ⇒ 退出码 **153**(SIGXFSZ),
目标变成 **1024 字节的截断 ELF**,原 14 字节内容**已被销毁** ——
头部后半句自己写的风险("中途失败留下半截二进制")`install` **并不免疫**。
⇒ 真原子 = **临时文件放在目标同目录**(同 fs)+ 最后一次 `mv`。
还要注意 **`/tmp` 与 `/opt` 常是不同文件系统**(实测设备号 40 vs 2049),
所以"改成 `mv` 就原子了"同样不成立 —— 跨 fs 的 `mv` 退化成 copy+unlink。
17. **环境前提表有第五列:同时性(并发)。** 变量/命令/空间/身份之外,
还有"同一时刻只能有一个"。`prune`/`redeploy`/`install` 都会写同一批路径,
而工作区是多 agent 共用的 ⇒ 需要 `flock`(**不是**"检查锁文件是否存在"——那本身有竞态)。
⇒ 判据:"两个同时启动 ⇒ 第二个 exit 2 并点名"。
18. **同一份代码搬到另一个脚本里,"能引用的变量和函数"是不一样的。**
我给三个脚本加同一段锁,第一版照抄:`install.sh` 没有 `bad()`(用裸 `echo`)⇒
`bad: command not found`(127);`redeploy-plugin.sh` 没有 `$PREFIX`(它用 `$DEST_ROOT`)⇒
`PREFIX: unbound variable`(`set -u`);而 `install.sh` 的 `--check` 刻意允许无写权限运行
⇒ 在那里建锁又变成 `Permission denied`。三处都是"复制粘贴的上下文假设"。
19. **注释里的数字无法被判据守住。** —— 而不是只落到你想到的那一个。**
实例(pi 评审 2026-09-14):我在 `env-defaults.sh` 的 `HOME` 上写了两条规则
("`mkdir -p` 对已存在的不可写目录会返回成功 ⇒ 必须单独判 `-w`"、"判据落在能不能写、
不落在路径像不像"),**同一条规则没落到紧邻的 `TMPDIR` 上** —— 而 ENOSPC 正是这条链的