From c6a497f4eddd178dfe376d69fd87948f412f3ce0 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 25 Sep 2026 06:19:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(deploy):=20=E6=BC=82=E7=A7=BB=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E8=A1=A5=20=E2=91=A4b=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E6=AF=94=20`vcs.revision`=EF=BC=8C=E6=8E=A5=E4=BD=8F"=E4=BA=8C?= =?UTF-8?q?=E8=BF=9B=E5=88=B6=E8=90=BD=E5=90=8E=20HEAD"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 缺口 `check-deploy-drift.mjs` 判据 ⑤ 只判**构建参数**(有没有带 `-trimpath`), 不判**这份二进制是哪次提交建的**。2026-09-25 实证: 线上 /opt/agentmail/agentmail-gateway vcs.revision=e8b260d(09-19 构建) 仓库 HEAD 已有 6 天的新提交(含两条 relay 幂等键/回滚修复) ⑤ 当日报的是"63 处源码路径"(另一个毛病)⇒ **完全没指出它落后 6 天** 当时是靠人手动跑 `go version -m` 才发现的。而"部署脚本跑过了 ≠ 线上跑的是当前代码" 正是这个文件头自己列的第 2 类漂移 —— 判据却缺了它。 ## 实现 Go 默认把 `vcs.revision=` 以**明文字节**编进二进制,所以直接按字节匹配读出, 不依赖 `go` 命令、不 spawn:与 ⑤ 读同一份 buffer。 ⚠️ 三态而非两态:**拿不到 revision(`-buildvcs=false`/老构建/非 git 构建)⇒ 不判**。 写成红会让合法环境恒红,写成绿等于没判 —— 所以只接受"不判",且 note 必须说出来, 否则读者分不清"比过且相同"与"根本没比"。只比相等/不等,**不判谁新谁旧** (判新旧要 git 图,而二进制里的 sha 未必在本地仓库里)。 ## 判据自检 新增 3 条(不等→红 / 相等→绿 / 拿不到→不判且 note 说出来),自检 **58 → 61 项**, `判据自检失败` 不出现。只钉"不等红"会放过"两侧都读不到时恒绿"的退化。 ## 顺带修一处脆锚 原有自检用 `o.find(c => c.name.startsWith('已安装的网关二进制'))` 取 ⑤。我新增的 ⑤b 名字以同一串开头 ⇒ `find` 会返回它,⑤ 的断言就可能测到 ⑤b。 已把选择器锚到**完整前缀** `'已安装的网关二进制不含源码路径'`,并把新判据命名为 `网关二进制构建版本…`。("按名字锚定"是对的,但**锚得太短就等于按位置**。) HEAD 版与新版 `--self-check` 同为 rc=1(来自既有真实漂移:3 个宿主待重部署 + 判据 6),非本次引入。 --- deploy/check-deploy-drift.mjs | 85 ++++++++++++++++++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/deploy/check-deploy-drift.mjs b/deploy/check-deploy-drift.mjs index d3692cc..6f48dc2 100644 --- a/deploy/check-deploy-drift.mjs +++ b/deploy/check-deploy-drift.mjs @@ -1181,6 +1181,53 @@ export function checkLayout(inject = {}) { } catch { /* binHits 保持 -1 = 读不到 */ } push('已安装的网关二进制不含源码路径(构建带 -trimpath)', binHits === 0, binNote); + // ⑤b 已安装的网关二进制**是不是当前代码** —— 比 `vcs.revision`。 + // + // 为什么需要单独一条:⑤ 只判"有没有带 -trimpath",那是**构建参数**的性质, + // 与"这份二进制是哪次提交建的"无关。2026-09-25 实证:线上二进制 + // vcs.revision=7b8c78f(09-19 构建),而仓库已有 6 天的新提交(含两条 + // relay 幂等键/回滚修复);⑤ 当日报的是"63 处源码路径"(另一个毛病), + // **完全没有指出它落后 6 天**。当时是靠人手动跑 `go version -m` 才发现的。 + // + // Go 会把 `vcs.revision=` 以**明文字节**编进二进制(默认 `-buildvcs=auto`), + // 所以这里直接按字节匹配读出,不依赖 `go` 命令、不 spawn 进程 —— + // 与 ⑤ 读同一份 buffer。 + // + // ⚠️ 判据只比"相等/不等",**不判方向**(谁新谁旧):判断新旧要 git 图, + // 而二进制里的 sha 未必在本地仓库里(可能是别处构建的)。 + // 这里报"不等"就够了 —— 它是"部署过之后没重启/没重构建"的**唯一**直接信号。 + // 拿不到 revision(老 build、非 git 构建、读不到文件)⇒ **不判**(不算失败), + // 否则会造出一条在合法环境下总亮的判据 —— 本文件头骂过的病。 + let binRevOk = true; + let binRevNote = ''; + try { + const buf = readFile(BIN); // Buffer;⑤ 已读过,这里复用读法 + const text = Buffer.isBuffer(buf) ? buf.toString('latin1') : String(buf); + const got = (text.match(/vcs\.revision=([0-9a-f]{40})/) || [])[1] ?? ''; + // 仓库 HEAD 的 sha:`git rev-parse HEAD` + let head = ''; + try { + const run = inject.git ?? ((args) => execFileSync('git', args, { cwd: REPO, encoding: 'utf8' })); + head = String(run(['rev-parse', 'HEAD'])).trim(); + } catch { head = ''; } + if (!got || !head) { + binRevOk = true; // 有一侧拿不到 ⇒ 不判(见上) + binRevNote = got && !head ? `二进制 vcs.revision=${got.slice(0, 8)},但读不到仓库 HEAD ⇒ 不判` + : !got && head ? `${BIN} 里没有 vcs.revision(可能是 -buildvcs=false 或老构建)⇒ 不判` + : `两侧都拿不到 ⇒ 不判`; + } else if (got === head) { + binRevNote = `与仓库 HEAD 一致(${got.slice(0, 8)})`; + } else { + binRevOk = false; + binRevNote = `${BIN} 是 ${got.slice(0, 8)},仓库 HEAD 是 ${head.slice(0, 8)} —— ` + + `已部署的不是当前代码(跑一次 redeploy-gateway.sh)`; + } + } catch { + // 读不到 BIN:⑤ 已经会报"读不到",这里不重复扣分 + binRevNote = `读不到 ${BIN} ⇒ 不判`; + } + push('网关二进制构建版本 = 当前 HEAD(比 vcs.revision)', binRevOk, binRevNote); + // ⑥ 工作区干净度 —— **WARN,不参与退出码**。 // // 判据 ① 比的是「仓库工作区 → 快照」这一跳。它覆盖不到「HEAD → 工作区」 @@ -1243,6 +1290,20 @@ export function layoutSelfCheck() { ]; // ⑥ 的 git 读取也要能被喂样本,否则它是一条测不到的判据(读不到就放行 ⇒ 恒绿)。 const fakeGit = porcelain => ({ git: () => porcelain }); + // ⑤b 版本判据的样本构造器。`rev` 是喂给二进制的 `vcs.revision=`, + // `head` 是 `git rev-parse HEAD` 的回包 —— 两侧**分别**可喂, + // 否则测不出"拿不到其中一侧 ⇒ 不判"那个分支。 + const binRevOf = ({ rev }, head) => checkLayout({ + ...fake({ + '/etc/systemd/system': [], + '/repo/systemd': [], + '/opt/agentmail/bin/service-failure-notify.mjs': 'x', + '/opt/agentmail/agentmail-gateway': + rev ? `ELF…vcs.revision=${rev}…github.com/agentmail/gateway…` : 'ELF…-buildmode=exe…github.com/agentmail/gateway…' + }), + git: args => (args[0] === 'rev-parse' ? head : '') + }); + const seventh = o => o.find(c => c.name.startsWith('网关二进制构建版本')); const bad = checkLayout(fake({ '/etc/systemd/system': [{ name: 'x.service', isDirectory: () => false }], '/etc/systemd/system/x.service': 'ExecStart=/home/program/agentmail/bin/x', @@ -1381,7 +1442,10 @@ export function layoutSelfCheck() { '/opt/agentmail/bin/service-failure-notify.mjs': 'x', '/opt/agentmail/agentmail-gateway': 'fake-elf' }), git: () => { throw new Error('not a git repo'); } }); - const fifth = o => o.find(c => c.name.startsWith('已安装的网关二进制')); + // ⚠️ 用**完整名字**取,不用 `startsWith('已安装的网关二进制')` —— ⑤ 与 ⑤b + // 两条都以那个前缀开头,`find` 只返回第一个 ⇒ ⑤b 的断言会静默地测到 ⑤。 + // (这正是本文件反复记的形状:按名字锚定,但锚得太短就等于按位置。) + 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]` 指的就不再是 @@ -1495,6 +1559,25 @@ export function layoutSelfCheck() { // 二进制那条两侧都要真:嵌了源码路径必须红,trimpath 的必须绿。 { name: '网关二进制:嵌了源码路径必须判红', ok: fifth(badBin)?.ok === false }, { name: '网关二进制:trimpath 过的必须判绿', ok: fifth(good)?.ok === true }, + // ⑤b 版本判据:**三面都要真**(相等绿 / 不等红 / 拿不到不判)。 + // 只钉"不等红"会放过"两侧都读不到时恒绿"的退化 —— 那正是本文件最怕的假绿。 + { + name: '★网关二进制:revision 与 HEAD 不同 ⇒ 必须红,且说"不是当前代码"', + ok: seventh(binRevOf({ rev: 'a'.repeat(40) }, 'b'.repeat(40)))?.ok === false + && /不是当前代码/.test(seventh(binRevOf({ rev: 'a'.repeat(40) }, 'b'.repeat(40)))?.note ?? '') + }, + { + name: '★网关二进制:revision 与 HEAD 相同 ⇒ 必须绿', + ok: seventh(binRevOf({ rev: 'c'.repeat(40) }, 'c'.repeat(40)))?.ok === true + }, + { + // 拿不到 revision(-buildvcs=false / 老构建 / 非 git 构建)⇒ **不判**。 + // 写成红则合法环境恒红;写成绿又等于没判 ⇒ 只接受"不判",且 note 必须说出来, + // 否则读者分不清"比过且相同"与"根本没比"。 + name: '★网关二进制:拿不到 revision ⇒ 不判(且 note 说出来)', + ok: seventh(binRevOf({ rev: '' }, 'd'.repeat(40)))?.ok === true + && /不判/.test(seventh(binRevOf({ rev: '' }, 'd'.repeat(40)))?.note ?? '') + }, // 编号:id 从 1 连续排到 N,不许跳号(原先只有 ⑥ 带编号,读者会去找不存在的 ⑤)。 { name: '标准目录:id 连续编号,不跳号', ok: good.every((c, i) => c.id === String(i + 1)) }, // 「工作区干净」是"说出来但不改结论"的提示:脏 / 干净 / 读不到三种都被报出来,