Files
nodeagent/tests/unit/rollback.test.mjs
T
jkooe 6b2fa29710 feat(cli): nodeagent rollback —— 从 LKG 回滚被控端(ARCH T03 / PRD P0-2)
- 新增 apps/cli/src/cmd/rollback.ts:读 LKG → 指纹比对定位备份 →
  覆盖写入口(同 deploy 姿势)→ 写后校验(失败用回滚源还原)→
  受控重启 → probeOnce 复核 build.hash == LKG(90s 墙钟)
- 纯函数 planRollback/pickBackupByHash/hashMatches/backupNameRegex +
  流式 hashRemoteFile(client 注入,可假测);hash_full 全量优先、
  老记录回落 12 位前缀(A-P0-3/R-2 向后兼容)
- --dry-run 只读零写;--backup 逃生门直指备份路径;退出码 0/1/2 分明
- HELP/README 写明能力圈边界:RPC 命令需被控端可达,「完全起不来」
  走看门狗自动恢复或带外恢复;不提供自动回滚(DELIVERY-PIPELINE §6)
- 19 条单测:无 LKG 零调用 / already-lkg 零写 / 写后校验失败自动还原 /
  无匹配零写 / dry-run 只读 / 多候选 mtime 新→早 / Windows 路径与元字符
2026-10-25 23:45:02 +08:00

348 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import {
backupNameRegex,
hashMatches,
hashRemoteFile,
pickBackupByHash,
planRollback,
remoteBasename,
remoteDirname,
runRollback,
} from '../../apps/cli/dist/cmd/rollback.js';
/**
* `nodeagent rollback` 守卫(ARCH T03 / PRD P0-2 验收②全覆盖)。
*
* 为什么需要守卫:rollback 是「发布即失联」场景的最后一道手动恢复通道。
* 它自己写坏入口 = 把最后一次恢复机会也毁掉。故钉死:
* 1. 无 LKG → 零调用零改动(不能病急乱投医);
* 2. 当前即 LKG → 零写入;
* 3. 写后校验失败 → 必须用回滚源还原(不能把半截文件留在入口上);
* 4. --dry-run → 只有读 invoke(fs.list/fs.read/system.info),零写零重启;
* 5. 指纹匹配:全量优先、老记录 12 位前缀回落、mtime 新→早优先。
*/
// ---------- 假被控端(内存文件系统 + 调用记录) ----------
const CHUNK = 512 * 1024;
class FakeAgent {
constructor(files, opts = {}) {
this.files = new Map(Object.entries(files));
this.calls = [];
this.mtimes = new Map();
/** 故障注入:对 target 的第一次写落到「被篡改内容」上(模拟杀软/磁盘把写好的文件改坏) */
this.corruptFirstWrite = opts.corruptFirstWrite ?? false;
this.writesToTarget = 0;
}
async invoke(cap, args) {
this.calls.push({ cap, args });
switch (cap) {
case 'system.info':
return { status: 'ok', data: this.files.get('__info__') ?? {} };
case 'fs.list': {
const entries = [...this.files.entries()]
.filter(([p]) => !p.startsWith('__'))
.map(([p, buf]) => ({
name: p.split(/[\\/]/).pop(),
path: p,
type: 'file',
size: buf.length,
mtime: this.mtimes.get(p) ?? 0,
}));
return { status: 'ok', data: { entries, total: entries.length, truncated: false } };
}
case 'fs.read': {
const buf = this.files.get(args.path);
if (!buf) return { status: 'failed', error: { name: 'E_EXECUTION_FAILED', message: `不存在: ${args.path}` } };
const off = args.offset ?? 0;
const chunk = buf.subarray(off, off + (args.max_bytes ?? CHUNK));
return {
status: 'ok',
data: { data: chunk.toString('base64'), bytes: chunk.length, eof: off + chunk.length >= buf.length },
};
}
case 'fs.write': {
const incoming = Buffer.from(args.data ?? '', 'base64');
if (args.append) {
const cur = this.files.get(args.path) ?? Buffer.alloc(0);
this.files.set(args.path, Buffer.concat([cur, incoming]));
} else if (this.corruptFirstWrite && this.writesToTarget === 0) {
// 模拟「写下去了但内容被改坏」:存一份被篡改的内容
this.writesToTarget += 1;
this.files.set(args.path, Buffer.from('CORRUPTED-BY-AV'));
} else {
this.writesToTarget += 1;
this.files.set(args.path, incoming);
}
return { status: 'ok', data: { bytes: incoming.length } };
}
default:
return { status: 'failed', error: { name: 'E_CAPABILITY_NOT_FOUND', message: cap } };
}
}
}
/** 常用夹具:LKG 内容(= 备份内容)与当前内容(不同指纹) */
const LKG_BYTES = Buffer.from('LKG-AGENT-CONTENT-v2.0.3');
const CUR_BYTES = Buffer.from('CURRENT-AGENT-CONTENT-v2.1.0-broken');
const lkgHashFull = createHash('sha256').update(LKG_BYTES).digest('hex');
const lkgHash12 = lkgHashFull.slice(0, 12);
function infoWithLkg(extra = {}) {
return {
agent_script: '/opt/nodeagent/agent.mjs',
build: { hash: createHash('sha256').update(CUR_BYTES).digest('hex').slice(0, 12) },
lkg: {
lkg: {
hash: lkgHash12,
hash_full: lkgHashFull,
bytes: LKG_BYTES.length,
script_path: '/opt/nodeagent/agent.mjs',
backup_glob: 'agent.mjs.bak*',
recorded_at: 1_700_000_000_000,
node_version: 'v22.20.0',
platform: 'linux',
},
current_hash: 'x',
matches: false,
byte_delta: 0,
},
...extra,
};
}
// ---------- 纯函数 ----------
test('planRollback:无 LKG 且无 --backup → no-lkg', () => {
const p = planRollback({ agent_script: '/a/agent.mjs', build: { hash: 'abc' }, lkg: { lkg: null } });
assert.equal(p.action, 'no-lkg');
assert.equal(p.targetHash, '');
});
test('planRollback:当前指纹 == LKG → already-lkg(零写入分支)', () => {
const p = planRollback(infoWithLkg({ build: { hash: lkgHash12 } }));
assert.equal(p.action, 'already-lkg');
assert.equal(p.targetHash, lkgHash12);
});
test('planRollback:给了 --backup → explicit(逃生门优先于一切判断)', () => {
const p = planRollback(infoWithLkg({ build: { hash: lkgHash12 } }), '/x/agent.mjs.bak-123');
assert.equal(p.action, 'explicit');
assert.equal(p.scriptPath, '/opt/nodeagent/agent.mjs');
});
test('planRollback:有 LKG 且当前不同 → match,targetHash 取 hash_full', () => {
const p = planRollback(infoWithLkg());
assert.equal(p.action, 'match');
assert.equal(p.targetHash, lkgHashFull);
});
test('planRollback:老记录无 hash_full → targetHash 回落 12 位', () => {
const info = infoWithLkg();
delete info.lkg.lkg.hash_full;
const p = planRollback(info);
assert.equal(p.action, 'match');
assert.equal(p.targetHash, lkgHash12);
});
test('pickBackupByHash:全量相等/12 位前缀都算命中;空候选返回 null', () => {
const other = createHash('sha256').update('other').digest('hex');
assert.equal(pickBackupByHash([], lkgHashFull), null);
assert.equal(pickBackupByHash([{ path: '/a', hash: other }], lkgHashFull), null);
assert.deepEqual(
pickBackupByHash([{ path: '/a', hash: lkgHashFull }], lkgHashFull),
{ path: '/a', hash: lkgHashFull },
);
assert.deepEqual(
pickBackupByHash([{ path: '/a', hash: lkgHashFull }], lkgHash12),
{ path: '/a', hash: lkgHashFull },
);
});
test('pickBackupByHash:mtime 新→早优先(顺序由调用方排好,本函数取第一个命中)', () => {
const stale = createHash('sha256').update('stale').digest('hex');
const cands = [
{ path: '/new.bak', hash: stale, mtime: 200 },
{ path: '/old.bak', hash: lkgHashFull, mtime: 100 },
];
assert.deepEqual(pickBackupByHash(cands, lkgHashFull), { path: '/old.bak', hash: lkgHashFull });
});
test('hashMatches:空值与仅前缀长于全长都不算命中', () => {
assert.equal(hashMatches('', lkgHash12), false);
assert.equal(hashMatches(lkgHashFull, ''), false);
assert.equal(hashMatches(lkgHash12, lkgHashFull), false, '12 位不得反向匹配 64 位');
});
test('路径纯函数:Windows 反斜杠与正则元字符都不破坏 basename/正则', () => {
assert.equal(remoteBasename('D:\\Nodeagent\\apps\\agent\\dist\\index.js'), 'index.js');
assert.equal(remoteDirname('D:\\Nodeagent\\agent.mjs'), 'D:\\Nodeagent');
const re = backupNameRegex('index.js');
assert.ok(re.test('index.js.bak'));
assert.ok(re.test('index.js.bak-1729300000000'));
assert.ok(!re.test('index.js.bakX'), '后缀必须整段匹配');
assert.ok(!re.test('other.js.bak'));
// 文件名含正则元字符(+ . 等)时必须按字面匹配
const re2 = backupNameRegex('a+b.c');
assert.ok(re2.test('a+b.c.bak'));
assert.ok(!re2.test('aab.c.bak'), '元字符必须被转义');
});
test('hashRemoteFile:分块流式累加,指纹与整文件一次性 sha256 一致', async () => {
const big = Buffer.alloc(CHUNK * 2 + 123, 7);
const agent = new FakeAgent({ '/x/agent.mjs.bak': big });
const h = await hashRemoteFile(agent, '/x/agent.mjs.bak');
assert.equal(h.hash, createHash('sha256').update(big).digest('hex'));
assert.equal(h.bytes, big.length);
});
// ---------- 编排(runRollback,client 注入) ----------
test('无 LKG:planRollback 判 no-lkg,且不发起任何 fs 调用', async () => {
const agent = new FakeAgent({
__info__: { agent_script: '/opt/nodeagent/agent.mjs', build: { hash: 'abc' }, lkg: { lkg: null } },
});
const r = await runRollback(agent, { json: true });
assert.equal(r.code, 2);
assert.equal(r.plan.action, 'no-lkg');
assert.deepEqual(
agent.calls.map((c) => c.cap),
['system.info'],
'无 LKG 时必须停在 system.info(零改动)',
);
});
test('指纹已匹配(already-lkg):零写入、退出 0', async () => {
const agent = new FakeAgent({
__info__: infoWithLkg({ build: { hash: lkgHash12 } }),
'/opt/nodeagent/agent.mjs': LKG_BYTES,
});
const r = await runRollback(agent, { json: true });
assert.equal(r.code, 0);
assert.equal(r.plan.action, 'already-lkg');
assert.deepEqual(
agent.calls.filter((c) => c.cap === 'fs.write' || c.cap === 'system.agent.restart'),
[],
'已是 LKG 时不允许任何写/重启',
);
});
test('正常回滚:指纹命中 → 覆盖写入 → 写后校验通过 → 待重启', async () => {
const agent = new FakeAgent({
__info__: infoWithLkg(),
'/opt/nodeagent/agent.mjs': CUR_BYTES,
'/opt/nodeagent/agent.mjs.bak': LKG_BYTES,
});
agent.mtimes = new Map([['/opt/nodeagent/agent.mjs.bak', 100]]);
const r = await runRollback(agent, { json: true });
assert.equal(r.code, 0);
assert.equal(r.needsRestart, true);
assert.equal(r.source.path, '/opt/nodeagent/agent.mjs.bak');
assert.equal(r.restored, false);
// 入口内容确实被替换成 LKG 内容
assert.deepEqual(agent.files.get('/opt/nodeagent/agent.mjs'), LKG_BYTES);
// 写后校验走过一次 fs.read 回读
const reads = agent.calls.filter((c) => c.cap === 'fs.read' && c.args.path === '/opt/nodeagent/agent.mjs');
assert.ok(reads.length >= 1, '必须有写后回读校验');
});
test('写后校验失败:自动用回滚源还原,退出码 1', async () => {
const agent = new FakeAgent(
{
__info__: infoWithLkg(),
'/opt/nodeagent/agent.mjs': CUR_BYTES,
'/opt/nodeagent/agent.mjs.bak': LKG_BYTES,
},
{ corruptFirstWrite: true },
);
agent.mtimes = new Map([['/opt/nodeagent/agent.mjs.bak', 100]]);
const r = await runRollback(agent, { json: true });
assert.equal(r.code, 1, '写后校验失败必须报执行失败');
assert.equal(r.restored, true, '必须执行还原写');
assert.equal(r.needsRestart, false, '校验失败不得进入重启');
// 最终入口内容 == 回滚源内容(还原生效,没把坏内容留在入口上)
assert.deepEqual(agent.files.get('/opt/nodeagent/agent.mjs'), LKG_BYTES);
const writes = agent.calls.filter((c) => c.cap === 'fs.write' && c.args.path === '/opt/nodeagent/agent.mjs');
assert.ok(writes.length >= 2, '应有「回滚写 + 还原写」两次写入');
});
test('无匹配备份:列候选指纹、零写退出(exit 2)', async () => {
const agent = new FakeAgent({
__info__: infoWithLkg(),
'/opt/nodeagent/agent.mjs': CUR_BYTES,
'/opt/nodeagent/agent.mjs.bak': Buffer.from('UNRELATED-CONTENT'),
});
agent.mtimes = new Map([['/opt/nodeagent/agent.mjs.bak', 100]]);
const r = await runRollback(agent, { json: true });
assert.equal(r.code, 2);
assert.equal(r.source, null);
assert.equal(r.candidates.length, 1, '候选指纹必须带回供排查');
assert.ok(!r.error.includes('undefined'));
assert.deepEqual(
agent.calls.filter((c) => c.cap === 'fs.write'),
[],
'无匹配时一个字节都不许写',
);
});
test('--dry-run:只读 invoke(system.info/fs.list/fs.read),零写零重启', async () => {
const agent = new FakeAgent({
__info__: infoWithLkg(),
'/opt/nodeagent/agent.mjs': CUR_BYTES,
'/opt/nodeagent/agent.mjs.bak': LKG_BYTES,
});
agent.mtimes = new Map([['/opt/nodeagent/agent.mjs.bak', 100]]);
const r = await runRollback(agent, { json: true, dryRun: true });
assert.equal(r.code, 0);
assert.equal(r.needsRestart, false);
assert.equal(r.source.path, '/opt/nodeagent/agent.mjs.bak');
const caps = new Set(agent.calls.map((c) => c.cap));
assert.deepEqual([...caps].sort(), ['fs.list', 'fs.read', 'system.info']);
assert.deepEqual(agent.files.get('/opt/nodeagent/agent.mjs'), CUR_BYTES, 'dry-run 不许动入口');
});
test('--backup 逃生门:跳过 LKG/指纹匹配,以指定备份为源与期望指纹', async () => {
const agent = new FakeAgent({
__info__: { agent_script: '/opt/nodeagent/agent.mjs', build: { hash: 'zzz' }, lkg: { lkg: null } },
'/opt/nodeagent/agent.mjs': CUR_BYTES,
'/opt/backups/agent.mjs.bak-123': LKG_BYTES,
});
const r = await runRollback(agent, { json: true, backup: '/opt/backups/agent.mjs.bak-123' });
assert.equal(r.code, 0);
assert.equal(r.plan.action, 'explicit');
assert.equal(r.needsRestart, true);
assert.deepEqual(agent.files.get('/opt/nodeagent/agent.mjs'), LKG_BYTES);
});
test('--backup 指向不存在的文件:前提不满足退出 2,不写入口', async () => {
const agent = new FakeAgent({
__info__: infoWithLkg(),
'/opt/nodeagent/agent.mjs': CUR_BYTES,
});
const r = await runRollback(agent, { json: true, backup: '/opt/backups/none.bak' });
assert.equal(r.code, 2);
assert.deepEqual(
agent.calls.filter((c) => c.cap === 'fs.write'),
[],
);
});
test('多候选按 mtime 新→早:旧备份才是 LKG 时也能命中(不被新备份抢先)', async () => {
const agent = new FakeAgent({
__info__: infoWithLkg(),
'/opt/nodeagent/agent.mjs': CUR_BYTES,
'/opt/nodeagent/agent.mjs.bak': Buffer.from('NEWER-BUT-UNRELATED'),
'/opt/nodeagent/agent.mjs.bak-1000': LKG_BYTES,
});
agent.mtimes = new Map([
['/opt/nodeagent/agent.mjs.bak', 500],
['/opt/nodeagent/agent.mjs.bak-1000', 100],
]);
const r = await runRollback(agent, { json: true, dryRun: true });
assert.equal(r.source.path, '/opt/nodeagent/agent.mjs.bak-1000');
});