- 新增 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 路径与元字符
348 lines
14 KiB
JavaScript
348 lines
14 KiB
JavaScript
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');
|
||
});
|