fix(deploy-drift): 判据 ② 是假绿 —— 它比的目录不存在(一个文件都没比过,却报"一致")
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 个文件,全部一致"。
This commit is contained in:
@ -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
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user