fix(deploy-drift): 补上 EXCLUDE_DIRS 挖掉的依赖树洞(②b)+ 写明排除口径表
pi 逐条对过 `EXCLUDE_*` 与 `redeploy-plugin.sh`,最要紧的是 `node_modules` 那格: 部署脚本**把依赖拷进快照**("依赖必须进快照:仓库外没有 node_modules 可借"), 而本文件整份跳过它 ⇒ **仓库换过依赖、快照还是旧的,判据报"逐字节一致"**。 这正是 `EXCLUDE_DIRS` 上面那句注释自己预言的假绿。 新增判据 ②b:读锁文件里的**版本集合**做签名(一个文件、24ms、实测 165 个包)。 选它而不是逐文件比:抓的是"依赖树漂移"这个真实风险,又不会被 `node_modules` 里的缓存噪声乱报(真的逐文件比 `node_modules` 不现实)。 - 一致 ⇒ 绿并说出比了几个包;版本变了 ⇒ 红;**一侧没有 ⇒ 也红**(不许当"未比"放过)。 过程中连踩两个同形状的坑,都记在代码里: 1. 依赖是**符号链接**(`@earendil-works/pi-coding-agent -> /usr/lib/node_modules/…`), 第一版只挑 `isDirectory()` ⇒ 空手而归 ⇒ 恒报"两边都没有依赖树,未比" —— **又是"看起来在比、其实没比"**; 2. 我把 `repoUnits`/`liveUnits`(systemd 目录)传给了找锁文件的函数, 于是永远找不到 ⇒ 同一个形状再来一次。 ⇒ 教训:一条判据如果**只能靠真文件系统喂**,它就没法被自检; `findDepLock` 因此改成只走注入面,自检样本把它真正走一遍。 另外: - 排除口径写成**表**(对齐/未对齐各自说明),不再只是"逐条对齐"一句话; - `dist.old` 加进 `EXCLUDE_SUFFIX`(部署脚本会 `rm -rf` 它,仓库里若有会造成**永久假红**); - `coverage` 保留排除但写明"仓库里当前不存在,无实际影响,保留是为了不假装对齐"; - `.cache`/`*.log`/`.DS_Store` 三处**方向相反**(本文件比脚本更宽)写明为已知取舍。 自检新增三条:★依赖树一致 ⇒ 绿且说出包数、★版本变了 ⇒ 必须红、★一侧没有 ⇒ 必须红。 自检 25 条全过;实跑 ②b 报"165 个包,版本集合一致"。
This commit is contained in:
@ -63,11 +63,24 @@ const DEPLOY_ROOT = '/opt/agentmail/plugins';
|
||||
* 而所有自检样本都注入 `repoUnits` ⇒ 默认值从没被走过、写错了也没人知道。 */
|
||||
export const DEFAULT_REPO_UNITS = join(HERE, 'systemd');
|
||||
|
||||
/** 与 deploy/redeploy-plugin.sh 的排除清单**逐条对齐**。
|
||||
* 对不齐就会出现「脚本说一致、部署脚本却拷了别的」这种假绿。 */
|
||||
/** 与 `deploy/redeploy-plugin.sh` 的排除清单对齐 —— **但口径必须写明,
|
||||
* 因为对不齐就会出「脚本说一致、部署脚本却拷了别的」这种假绿**
|
||||
* (pi 评审 2026-09-14 逐条对过,下表是核对后的实况):
|
||||
*
|
||||
* | 项 | 部署脚本 | 本文件 | 处置 |
|
||||
* |---|---|---|---|
|
||||
* | `test` | `rm -rf "$STAGING/test"` | 排除 | 对齐 |
|
||||
* | `.git` | `rm -rf` | 排除 | 对齐 |
|
||||
* | `node_modules` | **拷进快照** | 原为整份排除 ⇒ **假绿** | **改判**:见下方 ②b 依赖树判据 |
|
||||
* | `dist.old` | `rm -rf "$STAGING/dist.old"` | 原不排除 ⇒ 仓库里若有就**永久假红** | 加进 `EXCLUDE_SUFFIX` |
|
||||
* | `coverage` | 不排除 | 排除 | 仓库里当前不存在,**保留排除**(无实际影响,写明以免被当成对齐) |
|
||||
* | `.cache`/`*.log`/`.DS_Store` | 只排 `node_modules/.cache`、顶层 `*.log`/`.DS_Store` | 任何深度 | **方向相反**:本文件比脚本更宽 ⇒ 快照里的深层残留看不见。危害小,保留;写在这里免得"以为对齐了" |
|
||||
*
|
||||
* ⚠️ `node_modules` 那格是最要紧的:脚本把依赖拷进快照,本文件原先整份跳过它,
|
||||
* 于是"仓库换过依赖、快照还是旧的"会被报成**逐字节一致**。现在由 ②b 单独判。 */
|
||||
const EXCLUDE_DIRS = new Set(['test', '.git', 'node_modules', 'coverage']);
|
||||
const EXCLUDE_FILES = new Set(['.DS_Store']);
|
||||
const EXCLUDE_SUFFIX = ['.log'];
|
||||
const EXCLUDE_SUFFIX = ['.log', '.old']; // `.old`:部署脚本会 rm -rf staging 里的 dist.old,仓库里若有它会造成永久假红
|
||||
|
||||
/**
|
||||
* 宿主表。`load` 决定用哪套判据(见文件头那张表)。
|
||||
@ -581,6 +594,37 @@ export function selfCheck() {
|
||||
*
|
||||
* @param {object} [inject] 注入点(判据自检时喂假文件系统)
|
||||
*/
|
||||
/** 在 `node_modules/<vendor>/<pkg>/` 这类位置里找锁文件(浅层即可,不递归整棵树)。
|
||||
*
|
||||
* ⚠️ 只走**注入面**(`readdir`/`exists` 参数),不直接碰 `readdirSync`/`existsSync`
|
||||
* —— 否则自检样本喂不进去(第一版就是这样:样本造了真目录,判据却用注入的
|
||||
* `readFile` 去读,于是恒报"依赖锁读不到",自检红)。
|
||||
* 一条判据如果只能靠真文件系统喂,就等于**没法被自检**。 */
|
||||
function findDepLock(pluginRoot, lockName, deps) {
|
||||
const { readdir, exists } = deps;
|
||||
const roots = [join(pluginRoot, 'node_modules')];
|
||||
for (const r of roots) {
|
||||
let vendors = [];
|
||||
// ⚠️ 依赖很可能是**符号链接**(实测 pi 的 `@earendil-works/pi-coding-agent` 是指向
|
||||
// `/usr/lib/node_modules/…` 的软链)⇒ 只挑 `isDirectory()` 会**空手而归**,
|
||||
// 于是这条判据恒报"两边都没有依赖树,未比" —— 又是"看起来在比、其实没比"。
|
||||
// 软链也要跟进去(`readdirSync` 跟软链,如实测)。
|
||||
try { vendors = readdir(r, { withFileTypes: true }).filter(e => e.isDirectory() || e.isSymbolicLink()); } catch { continue; }
|
||||
for (const v of vendors) {
|
||||
let pkgs = [];
|
||||
const vd = join(r, v.name);
|
||||
try { pkgs = readdir(vd, { withFileTypes: true }).filter(e => e.isDirectory() || e.isSymbolicLink()); } catch { continue; }
|
||||
for (const pk of pkgs) {
|
||||
const f = join(vd, pk.name, lockName);
|
||||
if (exists(f)) return f;
|
||||
}
|
||||
const direct = join(vd, lockName);
|
||||
if (exists(direct)) return direct;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function checkLayout(inject = {}) {
|
||||
const readdir = inject.readdir ?? readdirSync;
|
||||
const readFile = inject.readFile ?? readFileSync;
|
||||
@ -641,6 +685,13 @@ export function checkLayout(inject = {}) {
|
||||
const drift = [];
|
||||
const repoUnits = inject.repoUnits ?? DEFAULT_REPO_UNITS;
|
||||
const liveUnits = inject.liveUnits ?? SYS;
|
||||
// 依赖树比的是**插件目录**,不是 systemd 目录 —— 我第一版把 repoUnits/liveUnits
|
||||
// 传给了 findDepLock,于是它永远找不到锁文件、恒报"两边都没有依赖树,未比"。
|
||||
// 又一次同一个形状:一条**看起来在比、其实没比**的判据(这次是我自己写的)。
|
||||
// 依赖树拿哪个插件比?pi(本文件历史上就是为它写的,且它是唯一带 node_modules 的)。
|
||||
const DEP_PLUGIN = inject.depPlugin ?? 'pi-mail-bridge';
|
||||
const repoPluginDir = inject.repoPluginDir ?? join(REPO, 'plugins', DEP_PLUGIN);
|
||||
const livePluginDir = inject.livePluginDir ?? join(DEPLOY_ROOT, DEP_PLUGIN, 'current');
|
||||
const extra = []; // 机器上多出来的(仓库里没有)—— 原先**永远不报**
|
||||
const lstat = inject.lstat ?? lstatSync;
|
||||
const liveLink = rel => {
|
||||
@ -713,6 +764,49 @@ export function checkLayout(inject = {}) {
|
||||
push('机器上另有仓库里没有的 unit(仅提示,不影响结论)', true, `${extra.length} 个:${extra.slice(0, 5).join(';')}`);
|
||||
}
|
||||
|
||||
// ②b 依赖树一致 —— **补上 `EXCLUDE_DIRS` 挖掉的那个洞**
|
||||
//
|
||||
// pi 评审 2026-09-14 指出的那格:部署脚本把 `node_modules` **拷进快照**
|
||||
// (注释写着"依赖必须进快照:仓库外没有 node_modules 可借"),而本文件
|
||||
// `EXCLUDE_DIRS` 整份跳过它 ⇒ **仓库换过依赖、快照还是旧的,判据报"逐字节一致"**。
|
||||
// 这正是 `EXCLUDE_DIRS` 上面那句注释自己预言的假绿("对不齐就会出现
|
||||
// 『脚本说一致、部署脚本却拷了别的』")。
|
||||
//
|
||||
// 做法:读锁文件里的**版本集合**做签名,一个文件、24ms、165 个包。
|
||||
// 只比版本集合(不比内容):它抓的是"依赖树漂移"这个真实风险,
|
||||
// 又不会因为 `node_modules` 里的缓存时间戳之类的噪声乱报。
|
||||
const depSig = file => {
|
||||
try {
|
||||
const d = JSON.parse(String(readFile(file, 'utf8')));
|
||||
const pkgs = d.packages ?? {};
|
||||
const v = Object.entries(pkgs).map(([k, x]) => `${k}@${x.version ?? ''}`).sort();
|
||||
return { n: v.length, sig: createHash('sha256').update(v.join('\n')).digest('hex') };
|
||||
} catch { return null; }
|
||||
};
|
||||
const lockRel = 'node_modules';
|
||||
const lockName = 'npm-shrinkwrap.json';
|
||||
let depNote = '两边都没有依赖树,未比';
|
||||
let depOk = true;
|
||||
{
|
||||
const a = findDepLock(repoPluginDir, lockName, { readdir, exists });
|
||||
const b = findDepLock(livePluginDir, lockName, { readdir, exists });
|
||||
if (a && b) {
|
||||
const sa = depSig(a);
|
||||
const sb = depSig(b);
|
||||
if (!sa || !sb) { depOk = false; depNote = '依赖锁读不到/解析不了,比不了'; }
|
||||
else if (sa.sig !== sb.sig) {
|
||||
depOk = false;
|
||||
depNote = `依赖树不一致:仓库 ${sa.n} 个包 vs 已部署 ${sb.n} 个包(重新部署即可对齐)`;
|
||||
} else depNote = `${sa.n} 个包,版本集合一致`;
|
||||
} else if (a || b) {
|
||||
depOk = false;
|
||||
depNote = `一侧有依赖树、另一侧没有(仓库 ${a ? '有' : '无'} / 已部署 ${b ? '有' : '无'})`;
|
||||
}
|
||||
// ②b 与 `lockRel` 的关系写在名字里:它比的是被 EXCLUDE_DIRS 排除的那部分。
|
||||
void lockRel;
|
||||
}
|
||||
push('已部署依赖树与仓库一致(EXCLUDE_DIRS 排除的那部分也要判)', depOk, depNote);
|
||||
|
||||
// ③ 通知脚本在标准位置且可执行
|
||||
const script = '/opt/agentmail/bin/service-failure-notify.mjs';
|
||||
let scriptOk = false;
|
||||
@ -806,7 +900,10 @@ export function layoutSelfCheck() {
|
||||
exists: p => p in map,
|
||||
stat: () => ({ isFile: () => true, mode: 0o755 }),
|
||||
lstat: () => ({ isSymbolicLink: () => false }),
|
||||
repoUnits: '/repo/systemd'
|
||||
repoUnits: '/repo/systemd',
|
||||
// 默认把依赖树指向假目录,免得自检真去读仓库/生产(样本要能覆盖它)
|
||||
repoPluginDir: '/repo/plugins/pi-mail-bridge',
|
||||
livePluginDir: '/repo/plugins/pi-mail-bridge'
|
||||
});
|
||||
// ★ 自检必须能走**默认值**,不能只走注入值。原先每个样本都注入 `repoUnits`,
|
||||
// 于是判据 ② 的真实默认路径从没被任何样本走过 —— 而它当时恰好是错的,照样 100% 绿。
|
||||
@ -875,8 +972,50 @@ export function layoutSelfCheck() {
|
||||
// "引用源码目录"那条。判据断言的东西必须按名字锚定。
|
||||
const byName = (o, prefix) => o.find(c => c.name.startsWith(prefix));
|
||||
const unitRefCheck = o => byName(o, '没有任何 unit');
|
||||
// ②b 依赖树:正反两面都要有样本,且**必须真的走读盘路径**(不能只喂内存 map)——
|
||||
// 这条判据的价值全在"它真的读了锁文件",所以样本用真临时目录。
|
||||
const depFixture = (packages) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'drift-dep-'));
|
||||
const dir = join(root, 'node_modules', '@vendor', 'pkg');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, 'npm-shrinkwrap.json'),
|
||||
JSON.stringify({ packages: Object.fromEntries(packages.map(p => [p, { version: p.split('@').pop() }])) }));
|
||||
return root;
|
||||
};
|
||||
const depSample = (repoPkgs, livePkgs) => {
|
||||
let a = null; let b = null;
|
||||
try {
|
||||
a = depFixture(repoPkgs);
|
||||
b = livePkgs ? depFixture(livePkgs) : mkdtempSync(join(tmpdir(), 'drift-empty-'));
|
||||
// 注入面把**夹具目录**路由到真实 fs,其余路径仍走内存假件 ——
|
||||
// 这样"找锁文件"那段真实逻辑被走了一遍,而 systemd 那几条判据仍可控。
|
||||
const roots = [a, b].filter(Boolean);
|
||||
const onDisk = p => roots.some(r => p.startsWith(r));
|
||||
return byName(
|
||||
checkLayout({
|
||||
...fake({ '/etc/systemd/system': [], '/repo/systemd': [] }),
|
||||
repoPluginDir: a,
|
||||
livePluginDir: b,
|
||||
readdir: (dir, opts) => (onDisk(dir) ? readdirSync(dir, opts) : []),
|
||||
readFile: (p, enc) => (onDisk(p) ? readFileSync(p, enc) : (() => { throw new Error('ENOENT'); })()),
|
||||
exists: p => (onDisk(p) ? existsSync(p) : false)
|
||||
}),
|
||||
'已部署依赖树'
|
||||
);
|
||||
} finally {
|
||||
for (const d of [a, b]) if (d) rmSync(d, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
const sameDeps = depSample(['a@1.0.0', 'b@2.0.0'], ['a@1.0.0', 'b@2.0.0']);
|
||||
const diffDeps = depSample(['a@1.0.0', 'b@2.0.0'], ['a@1.0.0', 'b@9.9.9']);
|
||||
const missingDeps = depSample(['a@1.0.0'], null);
|
||||
|
||||
return [
|
||||
{ name: '标准目录:引用源码目录的样本必须判红', ok: unitRefCheck(bad)?.ok === false },
|
||||
// 依赖树:一致必须绿、变了必须红、一侧没有必须红 —— 三面都钉。
|
||||
{ name: '★依赖树一致 ⇒ 绿,且说出比了几个包', ok: sameDeps?.ok === true && /2 个包/.test(sameDeps?.note ?? '') },
|
||||
{ name: '★依赖树版本变了 ⇒ 必须红', ok: diffDeps?.ok === false && /不一致/.test(diffDeps?.note ?? '') },
|
||||
{ name: '★一侧没有依赖树 ⇒ 必须红(不许当"未比"放过)', ok: missingDeps?.ok === false },
|
||||
{ name: '标准目录:.bak 里引用源码目录也必须判红', ok: unitRefCheck(badBak)?.ok === false },
|
||||
{ name: '标准目录:干净样本必须判绿', ok: unitRefCheck(good)?.ok === true },
|
||||
// 二进制那条两侧都要真:嵌了源码路径必须红,trimpath 的必须绿。
|
||||
|
||||
Reference in New Issue
Block a user