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 路径与元字符
This commit is contained in:
jkooe committed 2026-10-25 23:45:02 +08:00
1 parent a75c5c9bc5
commit 6b2fa29710
7 files changed
+927

No files matched your search

+6
View File
@@ -482,10 +482,16 @@ nodeagent keygen [--id mac_01] # 生成 Ed25519 密钥 + AC
```bash
nodeagent restart [--delay 2000] # 受控重启被控端(配置变更后让它生效)
nodeagent deploy <agent.mjs> [--path <远端路径>] # 一键升级(备份 → 上传 → 重启 → 复验)
nodeagent rollback [--dry-run] # 从 LKG 回滚(指纹比对 → 覆盖 → 重启 → 复核)
nodeagent daemon [start|stop|status] # 常驻连接池(批量操作省去每次握手)
nodeagent fanout <能力名> [--nodes a,b] [--args JSON] # 多设备并发下发并汇总
```
> **回滚的能力圈边界**:`rollback` 是 RPC 命令,**需被控端可达**——救的是「新版本能启动但需回退」。
> 新版本「完全起不来」(无监听可连)时:被控端看门狗会在启动连续失败 N 次后自动从 LKG 恢复
> (`scripts/startup-guard.mjs`);仍不行再走带外恢复(`docs/DELIVERY-PIPELINE.md` 附录 C)。
> 自动回滚刻意不做,理由见该文 §6。
### 长任务与剪贴板
```bash
+557
View File
@@ -0,0 +1,557 @@
import { createHash } from 'node:crypto';
import { CapabilityNames, type InvokeResult } from '@nodeagent/protocol';
import { printJson, probeOnce, withClient } from '../core.js';
/**
* `nodeagent rollback` —— 从 LKG(最后已知可用版本)恢复被控端(ARCH T03 / PRD P0-2)。
*
* ## 能力圈边界(必须向用户讲清楚,见 DELIVERY-PIPELINE §6 与 A-P0-6)
* 本命令本质是 **RPC 命令,要求被控端可达**。它覆盖的场景是
* 「新版本能启动但需要回退(行为异常 / 主动避险)」;
* 「新版本进程完全起不来」时没有监听可连,CLI 无能为力 —— 那种场景走:
* ① 看门狗自动恢复(scripts/startup-guard.mjs:启动连续失败 N 次自动从 LKG 恢复);
* ② 物理/带外恢复(DELIVERY-PIPELINE 附录 C:备份文件手工还原)。
* 本命令**不提供自动回滚**:无法归因、回滚循环、失控三问见 DELIVERY-PIPELINE §6。
*
* ## 执行流程(对齐 deploy 的成熟姿势,见 cmdDeploy)
* ① system.info → agent_script / build.hash / lkg
* ② 无 LKG → 报错退出(exit 2),零改动
* ③ current == LKG.hash → 「当前即 LKG」提示退出(提示 --backup 逃生门)
* ④ fs.list 枚举备份候选(`<basename>.bak` / `<basename>.bak-<ts>`),mtime 新→早
* ⑤ 流式读 + sha256 指纹匹配(全量 hash_full 优先,老记录回落 12 位前缀)
* —— 无匹配 → 列候选指纹报错退出(exit 2,不动任何文件)
* ⑥ 覆盖写入(同 deploy:首块 append:false + 续块 append:true)→ 回读校验
* ├─ 一致 → ⑦
* └─ 不一致 → 用回滚源再覆盖一次(还原到回滚源内容=原状)→ 报错退出(exit 1)
* ⑦ system.agent.restart(独立连接,吞连接中断)
* ⑧ probeOnce 轮询直到 build.hash == 期望指纹(90s 墙钟)
*
* ## --dry-run
* 只做 ①~⑤(含匹配结论),**零写入**:无 fs.write / 无 restart。
*/
// ---------- 公开类型 ----------
/** LKG 记录(system.info.lkg.lkg)。hash_full/backup_glob 为 v2.1.0 只增字段,老记录没有。 */
export interface LkgInfo {
/** 入口脚本 sha256 前 12 位(与 system.info 的 build.hash 同口径) */
hash: string;
/** sha256 全量 64 位(v2.1.0 起记录;老记录无此字段,回落 12 位前缀比对) */
hash_full?: string;
bytes: number;
script_path: string;
/** 回滚内容候选的文件名模式(`<basename>.bak*`,v2.1.0 起记录) */
backup_glob?: string;
recorded_at: number;
node_version: string;
platform: string;
}
/** system.info 里 rollback 关心的字段(其余字段忽略)。 */
export interface SystemInfoLike {
agent_script?: string;
build?: { hash?: string };
lkg?: {
lkg: LkgInfo | null;
current_hash: string | null;
matches: boolean;
byte_delta: number | null;
};
}
/** rollback 可注入的最小客户端面(NodeAgentClient 结构上满足它)。 */
export interface RollbackClient {
invoke<T = unknown>(
capability: string,
args: Record<string, unknown>,
timeoutMs?: number,
): Promise<InvokeResult<T>>;
}
/** rollback 命令的选项面(Options 的子集,便于单测构造)。 */
export interface RollbackOptions {
/** 高级逃生门:直接把该远端路径当回滚源(跳过 LKG 与指纹匹配) */
backup?: string;
/** 只读比对,零写入 */
dryRun?: boolean;
/** 原始 JSON 输出 */
json?: boolean;
}
/** planRollback 的判定结果。 */
export type RollbackAction = 'no-lkg' | 'already-lkg' | 'explicit' | 'match';
export interface RollbackPlan {
action: RollbackAction;
/** 期望的回滚后指纹:LKG 指纹(match)/ 备份指纹(explicit,调用方算好后回填)/ 空(no-lkg) */
targetHash: string;
/** 被控端入口脚本路径(回滚写入目标) */
scriptPath: string;
}
/** 备份候选(fs.list 的一条)。 */
export interface BackupCandidate {
path: string;
name: string;
type: string;
size: number;
mtime: number;
}
/** 指纹比对条目(候选 + 其远端内容指纹)。 */
export interface HashedCandidate {
path: string;
hash: string;
mtime: number;
}
/** runRollback 的结构化结果(不直接碰进程退出码,便于单测断言)。 */
export interface RollbackResult {
/** 0 成功/已是 LKG;1 执行失败;2 前提不满足 */
code: 0 | 1 | 2;
plan: RollbackPlan;
/** 命中/指定的回滚源(未找到为 null) */
source: { path: string; hash: string } | null;
/** 候选指纹清单(无匹配时供排查) */
candidates: HashedCandidate[];
/** 写后校验失败后是否已执行还原写 */
restored: boolean;
/** 是否需要进入「重启 + 复核」阶段(dry-run 恒为 false) */
needsRestart: boolean;
/** 失败原因(成功为 undefined) */
error?: string;
}
// ---------- 纯函数 ----------
/**
* 由入口文件名生成备份候选的本地过滤正则。
*
* 覆盖两套命名(ARCH A-P0-3):
* - deploy 推送式:固定名 `<basename>.bak`(覆盖写)
* - selfupdate 拉取式:时间戳名 `<basename>.bak-<ts>`
*
* 平台相关字符串(Windows 反斜杠路径、文件名)全部收敛进本函数:
* basename 的切分兼容 `\` 与 `/`,正则元字符一律转义。
*/
export function backupNameRegex(basename: string): RegExp {
const base = remoteBasename(basename);
const esc = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`^${esc}\\.bak(-.*)?$`);
}
/** 取远端路径的 basename(兼容 Windows 反斜杠)。 */
export function remoteBasename(p: string): string {
return p.split(/[\\/]/).pop() ?? '';
}
/** 取远端路径的目录部分(兼容 Windows 反斜杠);无目录时返回空串。 */
export function remoteDirname(p: string): string {
const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
return i > 0 ? p.slice(0, i) : '';
}
/**
* 指纹比对:全量相等,或老记录(12 位)按前缀比。
*
* 为什么允许前缀:LkgRecord 老数据只有 12 位 hash(A-P0-3/R-2),
* 控制端不得要求被控端先升级才能回滚 —— 向后兼容,不猜。
*/
export function hashMatches(got: string, want: string): boolean {
if (!got || !want) return false;
if (got === want) return true;
return want.length < got.length && got.startsWith(want);
}
/**
* 回滚计划(纯函数):决定 action 与期望指纹。
*
* - `no-lkg`:无 LKG 记录且未指定 --backup → 前提不满足
* - `already-lkg`:当前指纹已是 LKG → 零写入退出(提示 --backup 逃生门)
* - `explicit`:给了 --backup → 以备份自身为源与期望指纹(targetHash 由调用方回填)
* - `match`:枚举备份 + 指纹匹配定位 LKG 内容
*/
export function planRollback(info: SystemInfoLike, backupPath?: string): RollbackPlan {
const lkg = info.lkg?.lkg ?? null;
const scriptPath = info.agent_script ?? lkg?.script_path ?? '';
if (backupPath) {
return { action: 'explicit', targetHash: '', scriptPath };
}
if (!lkg) {
return { action: 'no-lkg', targetHash: '', scriptPath };
}
const current = info.build?.hash ?? '';
if (current && lkg.hash && current === lkg.hash) {
return { action: 'already-lkg', targetHash: lkg.hash, scriptPath };
}
return { action: 'match', targetHash: lkg.hash_full ?? lkg.hash, scriptPath };
}
/**
* 从「已算好指纹」的候选里挑回滚源(纯函数;mtime 新→早的排序在外层做)。
*
* 为什么排序在外层:本函数只负责「第一个指纹匹配者」,与时间序解耦,可独立测。
*/
export function pickBackupByHash(
cands: HashedCandidate[],
wantHash: string,
): { path: string; hash: string } | null {
if (!wantHash) return null;
for (const c of cands) {
if (hashMatches(c.hash, wantHash)) return { path: c.path, hash: c.hash };
}
return null;
}
// ---------- 远端 IO(client 注入 → 可假测) ----------
/** 分块大小:与 cmdDeploy 一致(512KB),单条 RPC 的安全分块 */
const CHUNK = 512 * 1024;
/**
* 流式计算远端文件指纹(fs.read 分块 + sha256)。
*
* ⚠️ offset 前进量必须用**本次返回字节数**,不能用返回记录里的起始 offset
* (拿它前进会原地打转 —— cmdDeploy 的真机教训:备份 33B→268KB 且卡死 200s)。
*/
export async function hashRemoteFile(
client: RollbackClient,
path: string,
): Promise<{ hash: string; bytes: number }> {
const hash = createHash('sha256');
let offset = 0;
for (;;) {
const r = await client.invoke<{ data?: string; bytes?: number; eof?: boolean }>(
CapabilityNames.FsRead,
{ path, offset, max_bytes: CHUNK, encoding: 'base64' },
);
if (r.status !== 'ok') {
throw new Error(`读取失败: ${r.error?.name ?? 'E_EXECUTION_FAILED'}: ${r.error?.message ?? '未知错误'}`);
}
const d = r.data ?? {};
const chunk = d.data ?? '';
if (chunk) hash.update(Buffer.from(chunk, 'base64'));
const n = d.bytes ?? 0;
offset += n;
if (d.eof || n === 0) break;
}
return { hash: hash.digest('hex'), bytes: offset };
}
/**
* 流式复制远端文件(src → dst),返回复制内容的指纹与字节数。
*
* 写姿势与 cmdDeploy 的上传段完全一致:首块 `append:false`(截断写),
* 续块 `append:true`。base64 必须显式声明 —— 默认 utf8 会把二进制当文本读写坏
* (真机踩过:备份 33 字节的垃圾)。
*/
async function copyRemoteFile(
client: RollbackClient,
srcPath: string,
dstPath: string,
): Promise<{ hash: string; bytes: number }> {
const hash = createHash('sha256');
let offset = 0;
let first = true;
for (;;) {
const r = await client.invoke<{ data?: string; bytes?: number; eof?: boolean }>(
CapabilityNames.FsRead,
{ path: srcPath, offset, max_bytes: CHUNK, encoding: 'base64' },
);
if (r.status !== 'ok') {
throw new Error(`读取失败: ${r.error?.name ?? 'E_EXECUTION_FAILED'}: ${r.error?.message ?? '未知错误'}`);
}
const d = r.data ?? {};
const chunk = d.data ?? '';
const n = d.bytes ?? 0;
if (chunk) {
hash.update(Buffer.from(chunk, 'base64'));
const w = await client.invoke(CapabilityNames.FsWrite, {
path: dstPath,
data: chunk,
encoding: 'base64',
append: !first,
});
if (w.status !== 'ok') {
throw new Error(`写入失败: ${w.error?.name ?? 'E_EXECUTION_FAILED'}: ${w.error?.message ?? '未知错误'}`);
}
first = false;
}
offset += n;
if (d.eof || n === 0) break;
}
// 空文件也要落到目标(否则目标保留旧内容,与「复制」语义相反)
if (first) {
const w = await client.invoke(CapabilityNames.FsWrite, { path: dstPath, data: '' });
if (w.status !== 'ok') {
throw new Error(`写入失败: ${w.error?.name ?? 'E_EXECUTION_FAILED'}: ${w.error?.message ?? '未知错误'}`);
}
}
return { hash: hash.digest('hex'), bytes: offset };
}
// ---------- 主编排(①~⑥,client 注入 → 可假测) ----------
/**
* 执行 rollback 的 ①~⑥ 阶段(不含重启与复核)。
*
* 为什么与 cmdRollback 分开:重启/复核要真实网络(probeOnce 反复重连),
* 单测只覆盖「决策 + 写入 + 校验」这段编排 —— 故 client 注入、
* 进程退出码用返回值表达(code),由 cmdRollback 翻译成 process.exitCode。
*/
export async function runRollback(
client: RollbackClient,
opts: RollbackOptions,
): Promise<RollbackResult> {
const say = (line: string): void => {
if (!opts.json) console.log(line);
};
// ① system.info
const infoRes = await client.invoke<SystemInfoLike>(CapabilityNames.SystemInfo, {});
if (infoRes.status !== 'ok') {
return {
code: 2,
plan: { action: 'no-lkg', targetHash: '', scriptPath: '' },
source: null,
candidates: [],
restored: false,
needsRestart: false,
error: `无法读取被控端信息: ${infoRes.error?.message ?? '未知错误'}`,
};
}
const info = infoRes.data ?? {};
const plan = planRollback(info, opts.backup);
const lkg = info.lkg?.lkg ?? null;
if (lkg) {
say(`LKG 记录 : ${lkg.hash}${lkg.hash_full ? `(全量 ${lkg.hash_full.slice(0, 16)}…)` : ''} ${lkg.bytes} 字节`);
say(` 记录于 ${new Date(lkg.recorded_at).toLocaleString('zh-CN')} · node ${lkg.node_version} · ${lkg.platform}`);
}
const current = info.build?.hash ?? 'unknown';
say(`当前指纹 : ${current}${info.agent_script ? `\n入口路径 : ${info.agent_script}` : ''}`);
// ② 无 LKG(且未给 --backup)
if (plan.action === 'no-lkg') {
return {
code: 2,
plan,
source: null,
candidates: [],
restored: false,
needsRestart: false,
error: '被控端没有 LKG 记录(尚未成功启动过或记录已丢失)—— 无回滚目标,零改动',
};
}
// ③ 当前即 LKG
if (plan.action === 'already-lkg') {
say('✓ 当前版本就是 LKG,无需回滚');
say(' 如需回退到更早的备份,用逃生门: nodeagent rollback --backup <远端备份路径>');
return { code: 0, plan, source: null, candidates: [], restored: false, needsRestart: false };
}
if (!plan.scriptPath) {
return {
code: 2,
plan,
source: null,
candidates: [],
restored: false,
needsRestart: false,
error: '无法确定被控端入口路径(system.info 未上报 agent_script,LKG 也无 script_path)',
};
}
// ④ 枚举备份候选(fs.list + 本地正则过滤;正则才是权威判据)
const dir = remoteDirname(plan.scriptPath);
const base = remoteBasename(plan.scriptPath);
const glob = lkg?.backup_glob ?? `${base}.bak*`;
const listRes = await client.invoke<{ entries?: BackupCandidate[] }>(
CapabilityNames.FsList,
{ path: dir, pattern: glob, max_entries: 500 },
);
if (listRes.status !== 'ok') {
return {
code: 2,
plan,
source: null,
candidates: [],
restored: false,
needsRestart: false,
error: `枚举备份失败: ${listRes.error?.message ?? '未知错误'}`,
};
}
const re = backupNameRegex(base);
const cands: BackupCandidate[] = (listRes.data?.entries ?? [])
.filter((e) => e.type === 'file' && re.test(e.name ?? ''))
.sort((a, b) => (b.mtime ?? 0) - (a.mtime ?? 0)); // mtime 新→早
if (cands.length === 0) {
return {
code: 2,
plan,
source: null,
candidates: [],
restored: false,
needsRestart: false,
error: `未找到备份候选(${dir} 下无 ${glob})—— 回滚内容不存在,零改动`,
};
}
// ⑤ 指纹匹配(流式读 + sha256)
const hashed: HashedCandidate[] = [];
for (const c of cands) {
try {
const h = await hashRemoteFile(client, c.path);
hashed.push({ path: c.path, hash: h.hash, mtime: c.mtime });
} catch (err) {
say(` ⚠️ 跳过无法读取的候选 ${c.path}: ${err instanceof Error ? err.message : String(err)}`);
}
}
say(`备份候选 ${hashed.length} 个(mtime 新→早),指纹比对中…`);
let source: { path: string; hash: string } | null;
let expectHash: string;
if (plan.action === 'explicit') {
// 逃生门:--backup 指定的路径即回滚源;期望指纹 = 它自身的内容指纹
const backupPath = opts.backup as string;
try {
const h = await hashRemoteFile(client, backupPath);
source = { path: backupPath, hash: h.hash };
expectHash = h.hash;
say(`逃生门 : 指定备份 ${backupPath}(指纹 ${h.hash.slice(0, 12)})`);
} catch (err) {
return {
code: 2,
plan,
source: null,
candidates: hashed,
restored: false,
needsRestart: false,
error: `无法读取指定备份 ${backupPath}: ${err instanceof Error ? err.message : String(err)}`,
};
}
} else {
source = pickBackupByHash(hashed, plan.targetHash);
if (!source) {
// 无匹配:列候选指纹供排查,零改动退出
for (const h of hashed) say(` · ${h.path} ${h.hash.slice(0, 12)}`);
return {
code: 2,
plan,
source: null,
candidates: hashed,
restored: false,
needsRestart: false,
error: '没有备份的指纹与 LKG 匹配 —— 已列出各候选指纹,未改动任何文件',
};
}
expectHash = plan.targetHash;
say(`✓ 命中回滚源: ${source.path}(指纹 ${source.hash.slice(0, 12)})`);
}
if (opts.dryRun) {
say('--dry-run:只读比对完成,未写入、未重启');
return { code: 0, plan, source, candidates: hashed, restored: false, needsRestart: false };
}
// ⑥ 覆盖写入 + 写后校验(不一致 → 用回滚源还原 → 报错)
try {
const written = await copyRemoteFile(client, source.path, plan.scriptPath);
const back = await hashRemoteFile(client, plan.scriptPath);
if (back.hash !== written.hash || back.hash !== expectHash) {
say('✗ 写后校验失败,正在用回滚源还原…');
await copyRemoteFile(client, source.path, plan.scriptPath);
return {
code: 1,
plan,
source,
candidates: hashed,
restored: true,
needsRestart: false,
error:
`写后校验失败(期望 ${expectHash.slice(0, 12)},实得 ${back.hash.slice(0, 12)})—— ` +
`已用回滚源还原 ${plan.scriptPath};请检查磁盘/杀软/路径权限后重试`,
};
}
say(`✓ 已写入 ${plan.scriptPath} 并通过写后校验(指纹 ${back.hash.slice(0, 12)})`);
return { code: 0, plan, source, candidates: hashed, restored: false, needsRestart: true };
} catch (err) {
return {
code: 1,
plan,
source,
candidates: hashed,
restored: false,
needsRestart: false,
error: `回滚写入失败: ${err instanceof Error ? err.message : String(err)}`,
};
}
}
// ---------- 重启与复核(⑦⑧,真实网络) ----------
/** 触发受控重启 + 轮询复核指纹上线(90s 墙钟)。返回是否复核通过。 */
export async function restartAndVerify(expectHash: string): Promise<boolean> {
// ⑦ 独立连接触发重启,吞掉连接中断(重启会切断本连接,属预期行为;
// cmdDeploy 真机教训:当成失败抛出会把成功的部署误判为失败)
try {
const r = await withClient((c) =>
c.invoke(CapabilityNames.AgentRestart, { delay_ms: 1500, reason: 'rollback' }),
);
if (r.status === 'failed') {
console.error(`✗ 重启失败: ${r.error?.message ?? '未知错误'}`);
return false;
}
} catch (err) {
console.log(`(重启切断了连接,属预期:${err instanceof Error ? err.message : String(err)})`);
}
console.log('✓ 已触发重启,复核回滚版本上线…');
// ⑧ probeOnce 轮询(永不抛出的探测;withClient 连不上会 fail 退出,不能用它)
let lastSeen = '连不上';
const deadline = Date.now() + 90_000;
while (Date.now() < deadline) {
await new Promise((t) => setTimeout(t, 1500));
const p = await probeOnce();
if (!p.ok) continue;
lastSeen = p.buildHash ?? 'unknown';
if (p.buildHash === expectHash) {
console.log(`✓ 回滚完成并复核通过:指纹 ${p.buildHash},PID ${p.pid ?? '?'},能力 ${p.caps} 项`);
return true;
}
}
console.error(`✗ 90s 内未复核到回滚版本(期望 ${expectHash.slice(0, 12)},最后见到 ${lastSeen})`);
console.error(' 排查:被控端可能没起来 —— 看 <数据目录>/logs/startup-guard.log;');
console.error(' 仍不行则按 docs/DELIVERY-PIPELINE.md 附录 C 走带外恢复');
return false;
}
// ---------- CLI 入口 ----------
/** CLI 主编排:连接被控端 → 执行回滚 →(非 dry-run)重启复核。 */
export async function cmdRollback(opts: RollbackOptions): Promise<void> {
const result = await withClient((c) => runRollback(c, opts));
if (opts.json) {
printJson({
code: result.code,
action: result.plan.action,
target_hash: result.plan.targetHash,
script_path: result.plan.scriptPath,
source: result.source,
candidates: result.candidates.map((c) => ({ path: c.path, hash: c.hash })),
restored: result.restored,
error: result.error,
});
} else if (result.error) {
console.error(`✗ ${result.error}`);
}
if (result.code === 0 && result.needsRestart && result.source) {
const ok = await restartAndVerify(result.plan.targetHash || result.source.hash);
if (!ok) process.exitCode = 1;
return;
}
process.exitCode = result.code;
}
+8
View File
@@ -67,6 +67,14 @@ export const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI)
nodeagent deploy <agent.mjs> [--path <远端路径>] [--check] [--force]
一键升级被控端 (v11/v18):比指纹 → 备份 → 上传 → 重启
→ 轮询校验;--check 只比对不部署
nodeagent rollback [--dry-run] [--backup <远端备份路径>]
从 LKG(最后已知可用版本)回滚被控端 (v2.1.0):
指纹比对定位备份 → 覆盖回入口 → 受控重启 → 复核上线
⚠️ 需被控端可达(RPC 命令):新版本「完全起不来」时
没有监听可连,请依赖看门狗自动恢复(启动连续失败 N 次,
见 docs/DELIVERY-PIPELINE.md §6)或带外恢复(附录 C)
自动回滚刻意不做(无法归因/回滚循环/失控)
--dry-run 只读比对零写入;--backup 为高级逃生门
nodeagent group [add <组名> <设备...> | remove <组名>] 设备分组管理 (v12)
nodeagent fanout <能力名> [--nodes a,b|@组名] [--args JSON]
多设备并发调用并汇总 (v11)
+4
View File
@@ -40,6 +40,7 @@ import {
import {
cmdBg, cmdClip, cmdDeploy, cmdEvents, cmdFanout, cmdLog, cmdMacro, cmdMonitor, cmdRecord, cmdTask, cmdTasks,
} from './cmd/ops.js';
import { cmdRollback } from './cmd/rollback.js';
import { cmdAwait, cmdKey, cmdMouse, cmdScreen, cmdScreenshot } from './cmd/gui.js';
import { cmdCat, cmdLs, cmdPull, cmdPush, cmdStat } from './cmd/fs.js';
import { cmdNet } from './cmd/net.js';
@@ -260,6 +261,9 @@ async function main(): Promise<void> {
await cmdDeploy(f, opts);
return;
}
case 'rollback':
await cmdRollback(opts);
return;
case 'install': {
const pkg = positionals[0];
if (!pkg) fail('用法: nodeagent install <包名|ID>');
+3
View File
@@ -127,6 +127,8 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
'forget-cert': { type: 'boolean', default: false },
check: { type: 'boolean', default: false },
force: { type: 'boolean', default: false },
// v2.1.0 rollback 逃生门(--backup <远端备份路径>)
backup: { type: 'string' },
mute: { type: 'string' },
volume: { type: 'string' },
seconds: { type: 'string' },
@@ -208,6 +210,7 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
forgetCert: Boolean(values['forget-cert']),
check: Boolean(values['check']),
force: Boolean(values['force']),
backup: values['backup'] as string | undefined,
mute: values['mute'] as string | undefined,
volume: values['volume'] as string | undefined,
seconds: values['seconds'] as string | undefined,
+2
View File
@@ -96,6 +96,8 @@ export interface Options {
/** v18 部署校验 */
check?: boolean;
force?: boolean;
/** v2.1.0 rollback 逃生门:直接指定远端备份路径为回滚源 */
backup?: string;
/** v17 音频 */
mute?: string;
volume?: string;
+347
View File
@@ -0,0 +1,347 @@
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');
});