From 6b2fa297105fd4a957c50b2f0ea34dc5937812c3 Mon Sep 17 00:00:00 2001 From: jkooe <62494645+jkooe@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:45:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(cli):=20nodeagent=20rollback=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E4=BB=8E=20LKG=20=E5=9B=9E=E6=BB=9A=E8=A2=AB?= =?UTF-8?q?=E6=8E=A7=E7=AB=AF=EF=BC=88ARCH=20T03=20/=20PRD=20P0-2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 路径与元字符 --- README.md | 6 + apps/cli/src/cmd/rollback.ts | 557 +++++++++++++++++++++++++++++++++++ apps/cli/src/help.ts | 8 + apps/cli/src/index.ts | 4 + apps/cli/src/options.ts | 3 + apps/cli/src/types.ts | 2 + tests/unit/rollback.test.mjs | 347 ++++++++++++++++++++++ 7 files changed, 927 insertions(+) create mode 100644 apps/cli/src/cmd/rollback.ts create mode 100644 tests/unit/rollback.test.mjs diff --git a/README.md b/README.md index 5f1fe64..e571717 100644 --- a/README.md +++ b/README.md @@ -482,10 +482,16 @@ nodeagent keygen [--id mac_01] # 生成 Ed25519 密钥 + AC ```bash nodeagent restart [--delay 2000] # 受控重启被控端(配置变更后让它生效) nodeagent deploy [--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 diff --git a/apps/cli/src/cmd/rollback.ts b/apps/cli/src/cmd/rollback.ts new file mode 100644 index 0000000..ba327df --- /dev/null +++ b/apps/cli/src/cmd/rollback.ts @@ -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 枚举备份候选(`.bak` / `.bak-`),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; + /** 回滚内容候选的文件名模式(`.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( + capability: string, + args: Record, + timeoutMs?: number, + ): Promise>; +} + +/** 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 推送式:固定名 `.bak`(覆盖写) + * - selfupdate 拉取式:时间戳名 `.bak-` + * + * 平台相关字符串(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 { + const say = (line: string): void => { + if (!opts.json) console.log(line); + }; + + // ① system.info + const infoRes = await client.invoke(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 { + // ⑦ 独立连接触发重启,吞掉连接中断(重启会切断本连接,属预期行为; + // 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 { + 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; +} diff --git a/apps/cli/src/help.ts b/apps/cli/src/help.ts index 16191bc..76f0d4d 100644 --- a/apps/cli/src/help.ts +++ b/apps/cli/src/help.ts @@ -67,6 +67,14 @@ export const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI) nodeagent deploy [--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) diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index bff1667..69789b2 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -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 { await cmdDeploy(f, opts); return; } + case 'rollback': + await cmdRollback(opts); + return; case 'install': { const pkg = positionals[0]; if (!pkg) fail('用法: nodeagent install <包名|ID>'); diff --git a/apps/cli/src/options.ts b/apps/cli/src/options.ts index 58061bd..b7927f4 100644 --- a/apps/cli/src/options.ts +++ b/apps/cli/src/options.ts @@ -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, diff --git a/apps/cli/src/types.ts b/apps/cli/src/types.ts index 7c1878b..d107249 100644 --- a/apps/cli/src/types.ts +++ b/apps/cli/src/types.ts @@ -96,6 +96,8 @@ export interface Options { /** v18 部署校验 */ check?: boolean; force?: boolean; + /** v2.1.0 rollback 逃生门:直接指定远端备份路径为回滚源 */ + backup?: string; /** v17 音频 */ mute?: string; volume?: string; diff --git a/tests/unit/rollback.test.mjs b/tests/unit/rollback.test.mjs new file mode 100644 index 0000000..e339e27 --- /dev/null +++ b/tests/unit/rollback.test.mjs @@ -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'); +});