From 5e38914cac8eb0531dbdebe60fdc92f23549be50 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 20:19:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(deploy-drift):=20=E5=88=A4=E6=8D=AE=20?= =?UTF-8?q?=E2=91=A1=20=E6=98=AF=E5=81=87=E7=BB=BF=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E5=AE=83=E6=AF=94=E7=9A=84=E7=9B=AE=E5=BD=95=E4=B8=8D=E5=AD=98?= =?UTF-8?q?=E5=9C=A8=EF=BC=88=E4=B8=80=E4=B8=AA=E6=96=87=E4=BB=B6=E9=83=BD?= =?UTF-8?q?=E6=B2=A1=E6=AF=94=E8=BF=87=EF=BC=8C=E5=8D=B4=E6=8A=A5"?= =?UTF-8?q?=E4=B8=80=E8=87=B4"=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi 逐行读出来的(我实测确认):默认路径写成 `new URL('../systemd', import.meta.url).pathname`,而 `import.meta.url` 在 `deploy/` 下 ⇒ 解析成 `/home/program/agentmail/systemd`(**ENOENT**,真身在 `deploy/systemd/`)。 `readdir` 抛的 ENOENT 被 `catch { return; }` 静默吞掉 ⇒ `drift` 恒空 ⇒ **一个文件都没比过,却报"已安装单元与 deploy/systemd/ 一致",而且它参与退出码。** 这是第四种形态的标本(**边界没说出口 ⇒ 报了个自己都不知道是假的 0**), 而且**自检接不住它**:`layoutSelfCheck()` 每个样本都显式注入 `repoUnits:'/repo/systemd'` ⇒ 真实默认值从没被任何样本走过 ⇒ 写错了自检也 100% 绿。 **注入点把该抓的 bug 藏起来了** —— 同族里这是最难看的一层。 四处一起修: 1. 路径改成 `join(HERE, 'systemd')`,并**导出** `DEFAULT_REPO_UNITS` 让自检能断言默认值本身; 2. `catch` 不再静默:目录读不到 ⇒ **判红**("比不了"不许伪装成"一致"); **空目录也判红** —— 真实目录有 22 个文件,不可能是空的(这条是被自检逼出来的: 注入的 readdir 对未知目录返回 `[]`,于是"不存在"能伪装成"空",我第一版修法又栽成恒绿); 3. **反向也判**:机器上有、仓库里没有的(原先永远不报 —— 那正是"仓库里的是旧的、 机器上的是新的"的另一半)。**口径必须收窄**:`/etc/systemd/system` 下绝大多数是 系统自带 unit 与 enable 出的软链(实测 111 个),全报等于没报 ⇒ 只报本仓库自己那套、 跳过软链 ⇒ 现在 0 个; 4. 绿的时候 note 带上覆盖范围("比了 22 个文件,全部一致(目录:…)")—— 空 note 无法区分"一致"和"没比过",而那正是这条判据原先的样子。 自检新增三条判据:★默认单元目录存在、★默认单元目录不是仓库根下那个不存在的 systemd/、 ★单元目录读不到 ⇒ 必须判红。另把 `bad[0]`/`badBak[0]` 这类**位置选择器**改成 按名字取(`byName`)—— 位置选择器是另一种"注入点藏 bug":插一条新检查就会改变语义。 **变异确认**(这次先提交、再变异):把默认路径改回 `../systemd` ⇒ 判据 ② **红**, note 精确报 `仓库单元目录读不到:/home/program/agentmail/systemd(ENOENT)`。 ⚠️ 上一次做这个变异时我在**未提交**状态下用 `git checkout HEAD --` 还原, 把自己的改动一起冲掉了 —— 这正是我自己写进 DEV-TOOLING 的那条纪律,我又踩了一次。 这次先 commit 再变异。 验证:`--self-check` 全过;实跑 ② 由"空 note 恒绿"变成"比了 22 个文件,全部一致"。 --- deploy/check-deploy-drift.mjs | 146 ++++++++++++++++++++++++++++------ 1 file changed, 123 insertions(+), 23 deletions(-) diff --git a/deploy/check-deploy-drift.mjs b/deploy/check-deploy-drift.mjs index 2d84ea7..c7a0901 100644 --- a/deploy/check-deploy-drift.mjs +++ b/deploy/check-deploy-drift.mjs @@ -55,6 +55,14 @@ const HERE = dirname(fileURLToPath(import.meta.url)); const REPO = join(HERE, '..'); const DEPLOY_ROOT = '/opt/agentmail/plugins'; +/** 仓库里 systemd 单元的**唯一真相**目录(`install.sh` 也从这里 `find`)。 + * + * ★ 导出是为了让自检能**直接断言这个默认值本身**。原先的写法 + * `new URL('../systemd', import.meta.url).pathname` 解析成 + * `/home/program/agentmail/systemd`(**ENOENT**,真身在 `deploy/systemd/`), + * 而所有自检样本都注入 `repoUnits` ⇒ 默认值从没被走过、写错了也没人知道。 */ +export const DEFAULT_REPO_UNITS = join(HERE, 'systemd'); + /** 与 deploy/redeploy-plugin.sh 的排除清单**逐条对齐**。 * 对不齐就会出现「脚本说一致、部署脚本却拷了别的」这种假绿。 */ const EXCLUDE_DIRS = new Set(['test', '.git', 'node_modules', 'coverage']); @@ -619,27 +627,91 @@ export function checkLayout(inject = {}) { walk(SYS); push('没有任何 unit/drop-in/.bak 引用源码目录', offenders.length === 0, offenders.join(' ')); - // ② 已安装单元与仓库副本一致(仓库是唯一真相) + // ② 已安装单元与仓库副本一致(仓库是唯一真相)—— **两个方向都判** + // + // ★ 这条判据曾经是**假绿**(pi 评审 2026-09-14 抓到,实测确认):默认路径写成 + // `new URL('../systemd', import.meta.url).pathname` —— `import.meta.url` 在 + // `deploy/` 下,于是它解析成 **`/home/program/agentmail/systemd`(ENOENT)**, + // 真身在 `deploy/systemd/`。`readdir` 抛的 ENOENT 被 `catch { return; }` 静默吞掉 + // ⇒ `drift` 恒空 ⇒ **一个文件都没比过,却报"已安装单元与 deploy/systemd/ 一致"**, + // 而且它参与退出码。同族里这是最难看的一种:**边界没说出口 ⇒ 报了个自己都不知道 + // 是假的 0**。而 `layoutSelfCheck()` 每个样本都显式注入 `repoUnits`,默认值从没被 + // 走过 ⇒ 写错了自检也 100% 绿(**注入点把该抓的 bug 藏起来了**)。 + // 三处一起修:路径用 `join(HERE,'systemd')`、比不了**判红**、反向也判。 const drift = []; - const repoUnits = inject.repoUnits ?? new URL('../systemd', import.meta.url).pathname.replace(/\/$/, ''); - const compare = dir => { - let entries = []; - try { entries = readdir(dir, { withFileTypes: true }); } catch { return; } - for (const e of entries) { - const full = `${dir}/${e.name}`; - if (e.isDirectory()) compare(full); - else { - const rel = full.slice(repoUnits.length + 1); - const live = `${SYS}/${rel}`; - if (!exists(live)) { drift.push(`${rel}(缺)`); continue; } - let a = '', b = ''; - try { a = String(readFile(full, 'utf8')); b = String(readFile(live, 'utf8')); } catch { continue; } - if (a !== b) drift.push(rel); - } - } + const repoUnits = inject.repoUnits ?? DEFAULT_REPO_UNITS; + const liveUnits = inject.liveUnits ?? SYS; + const extra = []; // 机器上多出来的(仓库里没有)—— 原先**永远不报** + const lstat = inject.lstat ?? lstatSync; + const liveLink = rel => { + try { return lstat(`${liveUnits}/${rel}`).isSymbolicLink(); } catch { return false; } }; - compare(repoUnits); - push('已安装单元与 deploy/systemd/ 一致', drift.length === 0, drift.join(' ')); + const walkUnits = (dir, base) => { + const out = new Map(); // rel → 内容(null = 读不到) + const go = cur => { + for (const e of readdir(cur, { withFileTypes: true })) { + const full = `${cur}/${e.name}`; + if (e.isDirectory()) { go(full); continue; } + const rel = full.slice(base.length + 1); + try { out.set(rel, String(readFile(full, 'utf8'))); } catch { out.set(rel, null); } + } + }; + go(dir); + return out; + }; + let repoMap = null; + try { + repoMap = walkUnits(repoUnits, repoUnits); + } catch (e) { + // **不再静默**:比不了就说"比不了",而不是报"一致"。 + drift.push(`仓库单元目录读不到:${repoUnits}(${e.code ?? e.message})`); + } + // ★ 空目录也算"没比过",必须判红。这条是被自检逼出来的:注入的 `readdir` 对未知 + // 目录返回 `[]`(不抛),于是"目录不存在"能伪装成"目录是空的",`drift` 恒空 + // ⇒ **又变成恒绿**(我第一版修法就栽在这里)。真实世界里 `deploy/systemd/` 有 + // 22 个文件,**它不可能是空的** —— 空只意味着路径写错或被清空。 + if (repoMap && repoMap.size === 0) { + drift.push(`仓库单元目录里一个文件都没有:${repoUnits} —— 路径写错或被清空的信号,不是"一致"`); + } + if (repoMap) { + const liveMap = exists(liveUnits) ? walkUnits(liveUnits, liveUnits) : new Map(); + for (const [rel, content] of repoMap) { + if (!liveMap.has(rel)) { drift.push(`${rel}(缺)`); continue; } + const b = liveMap.get(rel); + if (content === null || b === null) { drift.push(`${rel}(读不到)`); continue; } + if (content !== b) drift.push(rel); + } + // 反向:机器上有、仓库里没有。 + // + // ⚠️ 这一格**必须收窄**,否则就是噪声:`/etc/systemd/system` 下绝大多数是系统自带 + // unit 与 enable 出来的软链(实测 111 个:dbus-*、NetworkManager…),与"仓库副本 + // 一致"无关。**一条每次都在报 111 件事的提示等于没有提示。** + // 口径:只报**本仓库自己那套**(同名前缀 / 同 `.d/` 目录),并跳过符号链接。 + for (const rel of liveMap.keys()) { + if (repoMap.has(rel)) continue; + if (liveLink(rel)) continue; + const top = rel.split('/')[0]; + const related = [...repoMap.keys()].some( + r => r === top || r.startsWith(`${top}.d/`) || top.startsWith(`${r}.d/`) || r.startsWith(`${top}/`) || top.startsWith(`${r}/`) + ); + if (related && !extra.includes(rel)) extra.push(rel); + } + } + // 把"比过几个"写进 note —— 空 note 无法区分"一致"和"没比过",而那正是这条判据 + // 原先的样子。绿的时候也要留下覆盖范围的证据。 + push( + '已安装单元与 deploy/systemd/ 一致(两个方向)', + drift.length === 0, + drift.length === 0 + ? `比了 ${repoMap ? repoMap.size : 0} 个文件,全部一致(目录:${repoUnits})` + : drift.join(' ') + ); + if (extra.length) { + // 不判红(机器上有仓库里没有的 unit 不等于生产配置错了),但必须**说出来** —— + // 否则"仓库里的是旧的、机器上的是新的"那一半永远看不见。 + // 用既有的 `push(…, true, note)` 形状(同 ⑥):一条没人打算为它动手的红灯,下场是被学会忽略。 + push('机器上另有仓库里没有的 unit(仅提示,不影响结论)', true, `${extra.length} 个:${extra.slice(0, 5).join(';')}`); + } // ③ 通知脚本在标准位置且可执行 const script = '/opt/agentmail/bin/service-failure-notify.mjs'; @@ -733,8 +805,20 @@ export function layoutSelfCheck() { }, exists: p => p in map, stat: () => ({ isFile: () => true, mode: 0o755 }), + lstat: () => ({ isSymbolicLink: () => false }), repoUnits: '/repo/systemd' }); + // ★ 自检必须能走**默认值**,不能只走注入值。原先每个样本都注入 `repoUnits`, + // 于是判据 ② 的真实默认路径从没被任何样本走过 —— 而它当时恰好是错的,照样 100% 绿。 + // **注入点把该抓的 bug 藏起来了**(pi 的原话)。这两条直接断言默认路径本身。 + const defaultProbe = [ + { name: '★默认单元目录存在(不注入 repoUnits 也走得通)', ok: existsSync(DEFAULT_REPO_UNITS), note: DEFAULT_REPO_UNITS }, + { + name: '★默认单元目录不是仓库根下那个不存在的 systemd/', + ok: DEFAULT_REPO_UNITS !== join(REPO, 'systemd'), + note: DEFAULT_REPO_UNITS === join(REPO, 'systemd') ? '又写回 ../systemd 了 —— 那正是 2026-09-14 的假绿' : '' + } + ]; // ⑥ 的 git 读取也要能被喂样本,否则它是一条测不到的判据(读不到就放行 ⇒ 恒绿)。 const fakeGit = porcelain => ({ git: () => porcelain }); const bad = checkLayout(fake({ @@ -786,10 +870,15 @@ export function layoutSelfCheck() { }), git: () => { throw new Error('not a git repo'); } }); const fifth = o => o.find(c => c.name.startsWith('已安装的网关二进制')); const sixth = o => o.find(c => c.name.startsWith('工作区干净')); + // ★ 按**名字**取,不按位置取。原先是 `bad[0]` / `badBak[0]` —— 位置选择器是另一种 + // "注入点把 bug 藏起来":在函数前面插一条新检查之后,`bad[0]` 指的就不再是 + // "引用源码目录"那条。判据断言的东西必须按名字锚定。 + const byName = (o, prefix) => o.find(c => c.name.startsWith(prefix)); + const unitRefCheck = o => byName(o, '没有任何 unit'); return [ - { name: '标准目录:引用源码目录的样本必须判红', ok: bad[0].ok === false }, - { name: '标准目录:.bak 里引用源码目录也必须判红', ok: badBak[0].ok === false }, - { name: '标准目录:干净样本必须判绿', ok: good[0].ok === true }, + { name: '标准目录:引用源码目录的样本必须判红', ok: unitRefCheck(bad)?.ok === false }, + { name: '标准目录:.bak 里引用源码目录也必须判红', ok: unitRefCheck(badBak)?.ok === false }, + { name: '标准目录:干净样本必须判绿', ok: unitRefCheck(good)?.ok === true }, // 二进制那条两侧都要真:嵌了源码路径必须红,trimpath 的必须绿。 { name: '网关二进制:嵌了源码路径必须判红', ok: fifth(badBin)?.ok === false }, { name: '网关二进制:trimpath 过的必须判绿', ok: fifth(good)?.ok === true }, @@ -799,7 +888,18 @@ export function layoutSelfCheck() { // 且**都不判红**(做成红灯就是一条总在亮的判据,本文件头骂过这个病)。 { name: '工作区干净:脏 → 说出来(且不判红)', ok: sixth(dirty)?.note.includes('脏 1 处') === true && sixth(dirty)?.ok === true }, { name: '工作区干净:干净 → 明说干净', ok: sixth(clean)?.note.includes('干净') === true }, - { name: '工作区干净:读不到 git → 不据此判定', ok: sixth(unreadable)?.note.includes('不据此判定') === true } + { name: '工作区干净:读不到 git → 不据此判定', ok: sixth(unreadable)?.note.includes('不据此判定') === true }, + ...defaultProbe, + { + name: '★单元目录读不到 ⇒ 必须判红(不许静默报一致)', + ok: byName( + checkLayout({ + ...fake({ '/etc/systemd/system': [], '/opt/agentmail/bin/service-failure-notify.mjs': 'x', '/opt/agentmail/agentmail-gateway': 'fake-elf' }), + repoUnits: '/nonexistent/systemd' + }), + '已安装单元' + )?.ok === false + } ]; }