feat(deploy): 漂移检查补 ⑤b —— 比 vcs.revision,接住"二进制落后 HEAD"
## 缺口
`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=<sha>` 以**明文字节**编进二进制,所以直接按字节匹配读出,
不依赖 `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),非本次引入。
This commit is contained in:
@ -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=<sha>` 以**明文字节**编进二进制(默认 `-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)) },
|
||||
// 「工作区干净」是"说出来但不改结论"的提示:脏 / 干净 / 读不到三种都被报出来,
|
||||
|
||||
Reference in New Issue
Block a user