From 3b5c51c41eb7055eda80a355b9839b745af624d0 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 20:22:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(deploy-drift):=20=E8=A1=A5=E4=B8=8A=20EXCLU?= =?UTF-8?q?DE=5FDIRS=20=E6=8C=96=E6=8E=89=E7=9A=84=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=E6=A0=91=E6=B4=9E=EF=BC=88=E2=91=A1b=EF=BC=89+=20=E5=86=99?= =?UTF-8?q?=E6=98=8E=E6=8E=92=E9=99=A4=E5=8F=A3=E5=BE=84=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 个包,版本集合一致"。 --- deploy/check-deploy-drift.mjs | 147 +++++++++++++++++++++++++++++++++- 1 file changed, 143 insertions(+), 4 deletions(-) diff --git a/deploy/check-deploy-drift.mjs b/deploy/check-deploy-drift.mjs index c7a0901..cac5dc1 100644 --- a/deploy/check-deploy-drift.mjs +++ b/deploy/check-deploy-drift.mjs @@ -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///` 这类位置里找锁文件(浅层即可,不递归整棵树)。 + * + * ⚠️ 只走**注入面**(`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 的必须绿。