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:
1 parent
a75c5c9bc5
commit
6b2fa29710
7 files changed
+927
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -96,6 +96,8 @@ export interface Options {
|
||||
/** v18 部署校验 */
|
||||
check?: boolean;
|
||||
force?: boolean;
|
||||
/** v2.1.0 rollback 逃生门:直接指定远端备份路径为回滚源 */
|
||||
backup?: string;
|
||||
/** v17 音频 */
|
||||
mute?: string;
|
||||
volume?: string;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Reference in new issue
Block a user