feat(部署判据): 已退场宿主可显式豁免(zcode)+ 记我自己踩的两个坑
## 背景 zcode 宿主已在本机删除(进程无、`/etc/systemd/system/zcode*.service` 无、 `/opt/agentmail/plugins/` 下无该目录),而 `plugins/zcode-mail-bridge/` (62 个文件)与 `deploy/systemd/zcode.*`(3 个单元)**故意留在仓库里**以便恢复。 `HOSTS` 是硬编码四家,于是两条判据永久红,且结论区建议的 `bash deploy/redeploy-plugin.sh zcode` 是**错的动作** —— 那会部署一个用户已决定不再运行的宿主。 ## 改法:显式豁免表 `RETIRED_HOSTS` **豁免只换表述、不压红**(这是关键,理由见下): · `checkHost` 仍逐条列出「已退场」+ 理由,只是不计入 `stale`; · `checkLayout` 的 note 里明写「已退场宿主、故意不装:<单元>」。 为什么不能静默跳过:本文件反复强调「**没检查到** ≠ **不存在**」。 静默跳过 = 让人以为"检查了、没问题",而"这台机器上跑什么" 本身就是需要有人知道的**事实**。豁免必须**可见**。 ## ★★ 我自己连踩两个坑,都让豁免**静默生效**(不是"豁免得太宽"那种噪声) ① 第一版 `isRetiredHost` 只查 `units[0]`(zcode.service)。 实测"只装 zcode-mail-bridge.service、不装快照"时 **仍被判成已退场**,且那个新装上的单元在 note 里被写成 「故意不装」—— **自相矛盾且完全静默**。 ⇒ 改为 `units.every` 逐个查。根因:只抽查一个单元就宣称"全都没装"。 ② 第二版用 `existsSync` 判软链存在,而 `current` 是**相对**软链 (`→ 20260926-085613`)。快照目录被删、软链残留成**断链**时 `existsSync` 返回 false ⇒ 「装过又删剩」与「从没装过」读数 **完全同形**。实测断链场景仍被判「已退场」。 ⇒ 改用 `lstatSync`(看得见软链本身,含断链)。 ## 验证:四态都实测过 A 全都没装 ⇒ 豁免("已退场"仍出现在输出里) B 有效软链 ⇒ 报漂移 C **断链**软链 ⇒ 报漂移 D 只装一个单元 ⇒ 报漂移 `--self-check` 47 格仍全过。结论区回到 "四个宿主都在跑当前代码"。 ## 欠账 豁免逻辑本身**没有自检样本**进 `--self-check`(现靠人测四态)。 按本仓纪律"能自证就别靠人测",应补:注入假 fs 让四态各跑一遍, 且必须有一格是**断链**样本 —— 第一/二版都恰好漏在断链上。 已登记 `DEBTS.json` 的 `retired-host-exempt-never-tested`。
This commit is contained in:
@ -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<string, {plugin: string, units: string[], reason: string}>}
|
||||
*/
|
||||
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 不等于生产配置错了),但必须**说出来** ——
|
||||
|
||||
Reference in New Issue
Block a user