diff --git a/deploy/check-deploy-drift.mjs b/deploy/check-deploy-drift.mjs index a2d475c..2526b05 100644 --- a/deploy/check-deploy-drift.mjs +++ b/deploy/check-deploy-drift.mjs @@ -96,6 +96,117 @@ const EXCLUDE_DIRS = new Set(['test', '.git', 'node_modules', 'coverage']); const EXCLUDE_FILES = new Set(['.DS_Store']); const EXCLUDE_SUFFIX = ['.log', '.old']; // `.old`:部署脚本会 rm -rf staging 里的 dist.old,仓库里若有它会造成永久假红 +/** + * 已在本机**退场**、但代码**故意留在仓库里**的宿主。 + * + * # 为什么需要这张表 + * + * `HOSTS` 是"仓库认为应当部署的宿主"的清单。zcode 宿主已在本机删除 + * (进程无、`/etc/systemd/system/zcode*.service` 无、`/opt/agentmail/plugins/` + * 下无该目录),而 `plugins/zcode-mail-bridge/` 与 `deploy/systemd/zcode.*` + * **故意保留**(可随时恢复)。两侧于是永久不一致: + * + * - `checkHost` 报「未部署」⇒ `stale=true` ⇒ 结论区建议 `redeploy-plugin.sh zcode` + * - `checkLayout` 报「已安装单元缺 zcode*.service(3 项)」 + * + * 而照建议执行是**错的动作**:那条命令会部署一个用户已经决定不再运行的宿主。 + * + * ★ 所以这里不是"把红压下去",而是**换成一个可读的真陈述**: + * 豁免项仍会被逐条列在输出里(列名 + 理由),只是不再计入 stale。 + * 静默跳过 = 让人以为"检查了、没问题",与本文件反复强调的那条同源; + * 而"已退场"本身是需要有人知道的**事实** —— 它是"这台机器上跑什么"的真相。 + * + * # 口径(必须写明可证伪范围) + * + * 本表只豁免两件事,**不豁免任何内容比对**: + * ① `checkHost` 的「仓库目录存在 / 已部署(current 存在)」这一格; + * ② `checkLayout` 的「已安装单元缺 <该宿主的单元>」这一格。 + * + * 一旦该宿主重新部署(或部分部署),豁免立即失效、判据照常报它 —— + * 因为豁免的前提是"**全都没有**",不是"**现在没有**"。 + * `isRetiredHost` 要求 `!existsSync(linkPath) && !existsSync(unitPath)`, + * 两者任一存在就退化成正常检查。 + * + * @type {Record} + */ +const RETIRED_HOSTS = { + zcode: { + plugin: 'zcode-mail-bridge', + units: ['zcode.service', 'zcode-mail-bridge.service', 'zcode.service.d/10-dbus.conf'], + reason: '宿主已在本机删除(用户 2026-09-28 确认);插件与单元故意留在仓库里以便恢复' + } +}; + +/** + * 该宿主是否处于"已退场"状态 —— **全都没装**才算。 + * + * 刻意不接受"部分没装":装了一半(快照在、单元不在,或反之)是真的漂移, + * 必须照常报出来,否则这条豁免就成了"zcode 半坏也不报"的洞。 + * + * ★★ 第一版只查了 `units[0]`(zcode.service),**实测漏掉了半装**: + * 只放 `zcode-mail-bridge.service` 而不放快照时,它仍被判成"已退场"、 + * 且那个新装上的单元在 note 里被写成「故意不装」—— **自相矛盾且完全静默**。 + * 根因:只抽查一个单元就宣称"全都没装"。 + * ⇒ 改为**逐个单元查**(`units.every`),任何一个存在都算"装过"⇒ 豁免失效。 + * `units[0]` 之所以够用只对"三个单元里恰好第一个代表全体"的假想成立。 + * + * @param {string} host + * @returns {boolean} + */ +function isRetiredHost(host) { + const spec = RETIRED_HOSTS[host]; + if (!spec) return false; + const linkPath = join(DEPLOY_ROOT, spec.plugin, 'current'); + // 逐个查单元,而不是只看第一个 + const anyUnit = spec.units.some(u => existsSync(join('/etc/systemd/system', u))); + return !entryPresent(linkPath) && !anyUnit; +} + +/** + * 「这个路径存在吗」—— **不跟随符号链接**。 + * + * ★ 为什么不能用 `existsSync`:`current` 是个**相对**软链(`→ 20260926-085613`)。 + * 快照目录被删而软链残留时,它会变成**断链**,而 `existsSync` 对断链返回 + * `false` —— 于是「装过又删剩」与「从没装过」在读数上**完全同形**。 + * 实测(2026-09-28)正是这个坑:先手动造 `current → 20260101-000000`(断链) + * 来模拟"装了快照",豁免**仍然生效**并报「已退场」⇒ 判据对一个真实存在的 + * 部署痕迹**完全静默**。 + * + * 这与本文件反复记的是同一族:「**没检查到**」≠「**不存在**」。 + * `lstatSync` 能看到软链本身(含断链),这才是"这里被部署过"的正确读数。 + * + * @param {string} p + * @returns {boolean} + */ +function entryPresent(p) { + if (existsSync(p)) return true; // 正常文件/目录/有效软链 + return lstatSync(p, { throwIfNoEntry: false }) !== undefined; // 断链也算 +} + +/** + * 这个单元文件是否属于某个**已退场**宿主(且该宿主确实完全没装)。 + * + * @param {string} rel 相对 `deploy/systemd/` 的路径,如 `zcode.service.d/10-dbus.conf` + * @returns {boolean} + */ +function retiredUnit(rel) { + for (const host of Object.keys(RETIRED_HOSTS)) { + if (!isRetiredHost(host)) continue; + if (RETIRED_HOSTS[host].units.includes(rel)) return true; + } + return false; +} + +/** 已退场宿主的单元清单(写进判据 note,让豁免**可见**而不是静默)。 */ +function retiredUnitList() { + const out = []; + for (const host of Object.keys(RETIRED_HOSTS)) { + if (!isRetiredHost(host)) continue; + for (const u of RETIRED_HOSTS[host].units) out.push(`${u}(${host} 已退场)`); + } + return out.join(';'); +} + /** * 宿主表。`load` 决定用哪套判据(见文件头那张表)。 * @@ -404,6 +515,11 @@ function checkHost(spec) { const pass = (name, note = '') => result.checks.push({ name, ok: true, note }); if (!existsSync(repoDir)) return fail('仓库插件目录存在', repoDir), result; + if (isRetiredHost(host)) { + // 已退场:**不**计 stale,但把事实说出来(见 RETIRED_HOSTS 的注释)。 + pass('已退场(本机不部署,代码保留在仓库)', RETIRED_HOSTS[host].reason); + return result; + } if (!existsSync(linkPath)) return fail('已部署(current 存在)', `未部署:${linkPath}`), result; // ① 内容:快照 vs **仓库工作区当前内容**(四个宿主同一套) @@ -1011,6 +1127,15 @@ export function checkLayout(inject = {}) { if (repoMap) { const liveMap = exists(liveUnits) ? walkUnits(liveUnits, liveUnits) : new Map(); for (const [rel, content] of repoMap) { + // 已退场宿主的单元:**不**算"机器上缺"。 + // + // 这与"仓库里没有它"不是一回事 —— 仓库里**有** zcode 的三个单元, + // 而本机**故意**不装。判据分不出"忘了装"与"决定不装",于是默认报缺, + // 照建议执行 `redeploy-plugin.sh zcode` 就是部署一个用户已退场的宿主。 + // + // 窄到**逐个单元**豁免(不是整个宿主一刀切):只跳过 `units` 里列出的那几个, + // 仓库里若有其它 zcode 相关单元仍然照报。 + if (retiredUnit(rel)) continue; if (!liveMap.has(rel)) { drift.push(`${rel}(缺)`); continue; } const b = liveMap.get(rel); if (content === null || b === null) { drift.push(`${rel}(读不到)`); continue; } @@ -1034,12 +1159,18 @@ export function checkLayout(inject = {}) { } // 把"比过几个"写进 note —— 空 note 无法区分"一致"和"没比过",而那正是这条判据 // 原先的样子。绿的时候也要留下覆盖范围的证据。 + // + // ★ `retiredSkipped` 不从 `比了 N 个` 里减掉,而是**另起一句写明**: + // 减掉会让读者以为只比了 N-3 个;不减掉又会让"跳过的"这件事消失在数字里。 + // 两句话都在,读者能自己算出比了哪些、没比哪些。 + const retiredNote = retiredUnitList(); push( '已安装单元与 deploy/systemd/ 一致(两个方向)', drift.length === 0, - drift.length === 0 + (drift.length === 0 ? `比了 ${repoMap ? repoMap.size : 0} 个文件,全部一致(目录:${repoUnits})` - : drift.join(' ') + : drift.join(' ')) + + (retiredNote ? `;已退场宿主、故意不装:${retiredNote}` : '') ); if (extra.length) { // 不判红(机器上有仓库里没有的 unit 不等于生产配置错了),但必须**说出来** —— diff --git a/docs/DEBTS.json b/docs/DEBTS.json index a5bac33..3014292 100644 --- a/docs/DEBTS.json +++ b/docs/DEBTS.json @@ -242,6 +242,14 @@ "due": "下一次在 /tmp 落 .py 探针之前。(到期动作不是'小心一点',是**换落点或加 `-I`**。)", "where": "`/usr/local/lib/python3.12/re/__init__.py:124`(`import enum`)是 `import json` 也会走到的真链路;任何 `import json`/`re`/`enum`/`os`/`sys` 的脚本只要**与影子文件同目录**即中招", "note": "★★ 2026-09-27 登记(复核 pi `b1bd61ef` §四 时实测复现,**它对,我此前那处规避无效**)。\n\n## 机制(实测,/tmp/shadow)\n```\n★ 触发条件是**脚本文件所在目录**,**不是 cwd** —— pi 三次换 cwd 全炸是对的:\n 脚本在 /tmp/shadow/t1.py、cwd=`/` ⇒ 仍被污染 ⇒ `sys.path[0] == '/tmp/shadow'`\n ⇒ 正确说法: `sys.path[0]` = **脚本自身所在目录**; 换 cwd 完全无效。\n ⇒ `python3 -I` **有效**(隔离模式不把脚本目录放进 sys.path)✓\n⇒ ★★ 真链路比\"某个脚本 import 了 json\"宽得多: `json/__init__.py` 内部会 `import re`,\n 而 `re/__init__.py:124` 又 `import enum` ⇒ **任何 `import json` 的脚本**都会执行\n 同目录下的 `re.py` / `enum.py`。\n## ★★ 为什么这是\"最坏的一类\"失效(我实测的严重性)\n```\n伪造 `json.py` 让 `json.load()` 返回 `110`:\n ⇒ 被污染的脚本**正常跑完**、**rc=0**、stdout 混进别人的输出\n ⇒ 形状 = **\"混入别人的输出且可能 rc=0\"** —— 与 `ReleaseRelay` 那条同族:\n **不报错、不失败、只是答案换了**。本轮我已因它撤过一次结论(\"11/12 候选=0\")。\n⇒ ★★ 我此前说的\"**换目录跑**\"规避:对\"脚本在 /tmp\"**不成立**(就是本条)⇒ 撤销该规避。\n## ⇒ 我本会话的**结论为何不受影响**(自查,非辩解)\n```\n我所有 python 探针都是 **heredoc(`python3 - </dev/null` 拿到别人的文本\" —— 成立,但它**只是三因子之一**\n ⇒ ★ 完整的完全静默形态 = **混入(必然执行)** × **rc 由 catch 决定** × **stderr 被丢弃**\n ⇒ 三者同时成立时: **输出被替换、退出码正常、连报错通道都被关掉** ⇒ 无任何可观测征兆\n ⇒ 与 `recount-relay-counts.sh` 那条同族: **\"没报错\"不等于\"没出错\"**,判据必须独立于 rc\n### 四★ 采纳它的可判自检\n 报\"**条数**\"前先问「**这个量有几个来源**」,多来源必须**列出各自贡献**\n ⇒ 与已记的 ⑫′(报数带查询)、④′(按机制分类)同族 ⇒ 并入本条\n" + }, + { + "id": "retired-host-exempt-never-tested", + "count": 1, + "due": "有人决定把 zcode 真正部署回来时(豁免自动失效,无需手工改)", + "where": "deploy/check-deploy-drift.mjs 的 RETIRED_HOSTS(豁免本体)", + "kind": "scope", + "note": "2026-09-28 新登记。zcode 宿主已在本机删除,但插件与单元**故意留在仓库里**以便恢复;\ncheck-deploy-drift.mjs 的 HOSTS 是硬编码四家,于是两条判据永久红,且结论区建议的\n`redeploy-plugin.sh zcode` 是**错的动作**(会部署一个用户已决定不运行的宿主)。\n⇒ 加了显式豁免表 RETIRED_HOSTS,但**豁免只换表述、不压红**:\n · checkHost 仍逐条列出「已退场」+ 理由,只是不计入 stale;\n · checkLayout 的 note 里明写「已退场宿主、故意不装:<单元>」,不静默跳过。\n\n★★ 本条欠的是**真实踩过的坑**(两次,都是我自己写的检查自己漏):\n ① 第一版 isRetiredHost 只查 units[0],实测「只装 zcode-mail-bridge.service、\n 不装快照」时**仍被判成已退场**,且那个新装上的单元在 note 里被写成\n 「故意不装」—— 自相矛盾且完全静默。⇒ 改为 units.every 逐个查。\n ② 第二版用 existsSync 判软链存在,而 `current` 是**相对**软链:快照目录被删、\n 软链残留成**断链**时 existsSync 返回 false ⇒ 「装过又删剩」与「从没装过」\n 读数**完全同形**,实测断链场景仍被判「已退场」。⇒ 改用 lstatSync\n (看得见软链本身,含断链)。\n 同族:与 python-probe-shadowing、baseline-residue 一致 ——「没检查到」≠「不存在」。\n\n已实测四态:A 全没装⇒豁免;B 有效软链⇒报漂移;C 断链⇒报漂移;D 只装一个单元⇒报漂移。\n欠的是:豁免逻辑本身**没有自检样本**进 `--self-check`(现靠人测,\n 而判据的纪律是「能自证就别靠人测」⇒ 应补:注入假 fs 让四态各跑一遍,\n 且必须有一格是**断链**样本(第一/二版都恰好漏在断链上)。" } ] -} +} \ No newline at end of file