Files
nodeagent/scripts/startup-guard.mjs
T
jkooe cb468df7da feat(agent): 看门狗自动恢复 LKG —— 启动连续失败 N 次从 LKG 恢复(决策 R-1)
scripts/startup-guard.mjs + scripts/lib/startup-guard-core.mjs(新增):
- 被控端启动连续失败 N 次(默认 3)自动从 LKG 指纹匹配的备份恢复入口脚本
- 30s 启动宽限期 + 端口探活,避免计划内重启与 supervisor 重启撞车被误计数
- 无 LKG / 无指纹匹配只记日志放行,绝不擦除 install-dir
- 恢复成功或服务已就绪均归零失败计数;被控端启动成功后 resetStartupGuardState 归零
  (apps/agent/src/index.ts 为本任务对 agent 的唯一改动点)

三端 supervisor 接入:
- Windows install.ps1:计划任务包一层 guard,RestartCount 3→10(保证阈值可达)
- Linux install-linux.sh:systemd unit 的 ExecStart 包一层 guard
- macOS launchd:无安装器,docs/DEVELOPMENT.md §7.4 给出完整接法

docs:DELIVERY-PIPELINE §6 由「不做自动回滚」收窄为
「手动 rollback + 看门狗自动恢复 + 边界明示」,附录 C 同步;
DEVELOPMENT.md 新增 §7.4 三端 supervisor 接入说明。
2026-10-26 00:09:56 +08:00

459 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* 启动看门狗(startup-guard.mjs)—— 被控端「启动连续失败 N 次」自动从 LKG 恢复。
*
* ## 为什么需要(决策 R-1,ARCH A-P0-6)
* `nodeagent rollback` 是 RPC 命令:被控端**完全起不来时没有监听可连**,CLI 无能为力。
* 本脚本补上这条链路:外层 supervisor(Windows 计划任务 / Linux systemd /
* macOS launchd)每次拉起被控端都先过本守卫;启动连续失败达到阈值时,
* 自动用 LKG 记录 + 备份指纹匹配恢复入口文件,再放行启动。
*
* ## 裸环境铁律
* **零项目依赖、不 import 项目内模块**(连 @nodeagent/* 都不碰)——
* 它要在被控端的裸环境里跑:只依赖 Node ≥22 内置模块 + 同目录的
* `lib/startup-guard-core.mjs`(纯函数,也无依赖)。分发时两个文件必须
* 保持相对位置一起拷贝(见 install-linux.sh / install.ps1 / pack.mjs)。
*
* ## 用法
* node scripts/startup-guard.mjs \
* --data-dir <数据目录> --lkg-dir <备份所在目录> --install-dir <安装目录> \
* [--entry agent.mjs] [--threshold 3] [--port 8765] \
* -- <被看守的完整启动命令...>
*
* ## 行为契约(与 supervisor 的退出码约定)
* - 退出 0:服务已由别的实例提供 / 干净停止 / 本守卫被要求停;
* - 退出非 0:启动失败(已计数)或运行期退出 —— supervisor 据此重启,
* 重启后本守卫重新读计数,达到阈值即执行 LKG 恢复。
*
* ## 安全边界(写进 DELIVERY-PIPELINE §6 的明示)
* - **无 LKG / 无指纹匹配的备份 → 记日志后放行,绝不擦除 install-dir**;
* - agent.mjs 自身损坏到 Node 都无法解析、或 Node 运行时缺失时,
* 本守卫同样无法运行 —— 那种场景只能带外恢复(附录 C)。
*/
import { createHash } from 'node:crypto';
import { spawn } from 'node:child_process';
import {
closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, readSync, statSync, writeFileSync,
} from 'node:fs';
import { basename, join, resolve } from 'node:path';
import net from 'node:net';
import { fileURLToPath } from 'node:url';
import {
STARTUP_GRACE_MS,
backupNameRegex,
decideAfterExit,
decideRestore,
initialState,
planRestore,
readState,
zeroFailures,
} from './lib/startup-guard-core.mjs';
// ---------- 路径与状态 ----------
const LKG_FILE = 'agent-lkg.json';
const HASH_CHUNK = 512 * 1024;
/** 状态文件路径:<data-dir>/state/startup-state.json */
export function statePath(dataDir) {
return join(dataDir, 'state', 'startup-state.json');
}
/** 读失败计数;文件缺失/损坏按 0(不猜、不拒绝服务)。 */
export function readStateFile(dataDir) {
try {
const p = statePath(dataDir);
if (!existsSync(p)) return initialState();
return readState(readFileSync(p, 'utf8'));
} catch {
return initialState();
}
}
/** 写失败计数(含目录创建);失败返回 false(不抛——计数丢了下次重新数)。 */
export function writeState(dataDir, state) {
try {
mkdirSync(join(dataDir, 'state'), { recursive: true });
writeFileSync(statePath(dataDir), `${JSON.stringify(state, null, 2)}\n`, 'utf8');
return true;
} catch {
return false;
}
}
/** 追加一行 JSONL 日志:<data-dir>/logs/startup-guard.log(失败静默——日志不拦路)。 */
export function appendGuardLog(dataDir, obj) {
try {
mkdirSync(join(dataDir, 'logs'), { recursive: true });
const line = `${JSON.stringify({ ts: new Date().toISOString(), ...obj })}\n`;
writeFileSync(join(dataDir, 'logs', 'startup-guard.log'), line, { flag: 'a' });
} catch {
/* 日志失败不影响启动 */
}
}
// ---------- LKG 与备份 ----------
/** 读 LKG 记录(<data-dir>/agent-lkg.json);缺失/损坏返回 null。 */
export function readLkgRecord(dataDir) {
try {
const p = join(dataDir, LKG_FILE);
if (!existsSync(p)) return null;
const rec = JSON.parse(readFileSync(p, 'utf8'));
if (!rec || typeof rec.hash !== 'string' || !rec.hash) return null;
return rec;
} catch {
return null;
}
}
/** 流式计算文件 sha256(分块读,不吃内存)。 */
export function hashFileSync(p) {
const fd = openSync(p, 'r');
try {
const h = createHash('sha256');
const buf = Buffer.alloc(HASH_CHUNK);
for (;;) {
const n = readSync(fd, buf, 0, buf.length, null);
if (n === 0) break;
h.update(buf.subarray(0, n));
}
return h.digest('hex');
} finally {
closeSync(fd);
}
}
/**
* 枚举备份候选:<lkg-dir> 下 `<entry>.bak` / `<entry>.bak-<ts>`,按 mtime 新→早。
*
* 与 CLI rollback 的 backupNameRegex 同口径(tests 有跨端一致性守卫):
* deploy 固定名 + selfupdate 时间戳名两套命名都要认。
*/
export function listBackupCandidates(lkgDir, entryName) {
const re = backupNameRegex(entryName);
let items;
try {
items = readdirSync(lkgDir, { withFileTypes: true });
} catch {
return [];
}
const out = [];
for (const it of items) {
if (!it.isFile() || !re.test(it.name)) continue;
try {
const st = statSync(join(lkgDir, it.name));
out.push({ path: join(lkgDir, it.name), name: it.name, mtime: st.mtimeMs, size: st.size });
} catch {
/* 单个文件 stat 失败就跳过 */
}
}
out.sort((a, b) => b.mtime - a.mtime);
return out;
}
/**
* 从 LKG 恢复入口文件(达到阈值时调用)。
*
* 流程:读 LKG → 枚举备份 → 流式指纹匹配(hash_full 全量优先,老记录 12 位前缀回落)
* → 命中则**就地覆写** install-dir/<entry>(同 selfupdate 姿势:不 rename,
* Windows 上 rename 会被占用的文件/杀软挡下,真机教训)→ 写后校验。
*
* 安全边界:无 LKG / 无候选 / 无匹配 / 读失败 → 原样返回不恢复,
* **绝不清空或部分擦写 install-dir**。任何异常都吞掉返回 error ——
* 守卫自己坏掉时也要放行启动,不能把「恢复」变成「致人死亡」。
*
* @returns {{restored: boolean, backup: string|null, reason: string, entry?: string,
* hash?: string, candidates?: string[]}}
*/
export function restoreFromLkg({ dataDir, lkgDir, installDir, entry }) {
try {
const lkg = readLkgRecord(dataDir);
if (!lkg) return { restored: false, backup: null, reason: 'no-lkg' };
const want = lkg.hash_full ?? lkg.hash;
const entryName = entry || basename(lkg.script_path || '') || 'agent.mjs';
const cands = listBackupCandidates(lkgDir, entryName);
if (cands.length === 0) return { restored: false, backup: null, reason: 'no-candidates', entry: entryName };
const hashed = [];
for (const c of cands) {
try {
hashed.push({ path: c.path, hash: hashFileSync(c.path), mtime: c.mtime });
} catch {
/* 跳过读不了的候选 */
}
}
const picked = planRestore(hashed, want);
if (!picked) {
return {
restored: false,
backup: null,
reason: 'no-match',
entry: entryName,
candidates: hashed.map((h) => `${h.path} ${h.hash.slice(0, 12)}`),
};
}
const target = join(installDir, entryName);
const buf = readFileSync(picked.path);
writeFileSync(target, buf);
// 写后校验:目标内容必须等于备份内容(防写一半/被杀软改坏)
const after = hashFileSync(target);
if (after !== picked.hash) {
return { restored: false, backup: picked.path, reason: 'verify-failed', entry: entryName };
}
return { restored: true, backup: picked.path, hash: picked.hash, entry: entryName, bytes: buf.length };
} catch (err) {
return { restored: false, backup: null, reason: `error: ${err instanceof Error ? err.message : String(err)}` };
}
}
// ---------- 端口探活 ----------
/**
* 解析被控端端口:--port 参数优先,否则读 <data-dir>/agent.json 的 port 字段。
* 都没有(如 Hub 模式不监听本地端口)→ null,此时跳过所有端口判断。
*/
export function resolvePort(dataDir, portArg) {
if (Number.isInteger(portArg) && portArg > 0 && portArg < 65536) return portArg;
try {
const p = join(dataDir, 'agent.json');
if (existsSync(p)) {
const cfg = JSON.parse(readFileSync(p, 'utf8'));
const n = Number(cfg.port);
if (Number.isInteger(n) && n > 0 && n < 65536) return n;
}
} catch {
/* 忽略:端口未知就不做端口判断 */
}
return null;
}
/**
* 端口是否已被服务(TCP 能连上即算,不做协议握手 —— 只要「有人在听」)。
*
* 用途有两个,都指向同一目的「别添乱」:
* ① 启动前:服务已由别的实例提供 → 本守卫直接退出 0,不再 spawn(避免撞车);
* ② 子进程死后:服务已由别的实例接管 → 不算启动失败(撞车输的那次不算)。
*/
export function serviceUp(port, host = '127.0.0.1', timeoutMs = 500) {
if (!port) return Promise.resolve(false);
return new Promise((done) => {
const s = net.connect({ host, port });
const finish = (v) => {
s.destroy();
done(v);
};
s.setTimeout(timeoutMs, () => finish(false));
s.once('connect', () => finish(true));
s.once('error', () => finish(false));
});
}
// ---------- 参数解析 ----------
/**
* 解析守卫自己的参数(纯函数,可单测)。
* `--` 之后的所有 token 原样作为「被看守的完整启动命令」。
*/
export function parseGuardArgs(argv) {
const sep = argv.indexOf('--');
const own = sep >= 0 ? argv.slice(0, sep) : argv.slice();
const out = {
dataDir: '',
lkgDir: '',
installDir: '',
entry: '',
threshold: 3,
port: null,
cmd: sep >= 0 ? argv.slice(sep + 1) : [],
help: false,
};
for (let i = 0; i < own.length; i += 1) {
const a = own[i];
const take = () => {
const v = own[i + 1];
if (v === undefined || v.startsWith('--')) throw new Error(`参数 ${a} 缺少取值`);
i += 1;
return v;
};
switch (a) {
case '--data-dir': out.dataDir = take(); break;
case '--lkg-dir': out.lkgDir = take(); break;
case '--install-dir': out.installDir = take(); break;
case '--entry': out.entry = take(); break;
case '--threshold': out.threshold = Number(take()); break;
case '--port': out.port = Number(take()); break;
case '-h': case '--help': out.help = true; break;
default: throw new Error(`未知参数: ${a}`);
}
}
return out;
}
const USAGE = '用法: node startup-guard.mjs --data-dir <目录> --install-dir <目录> [--lkg-dir <目录>] ' +
'[--entry agent.mjs] [--threshold 3] [--port 8765] -- <启动命令...>';
// ---------- 主流程 ----------
/**
* 看守一个子进程直到它退出,并按 decideAfterExit 的决策处理计数与退出码。
* (spawn / 信号转发都在这里;IO 之外的判断全部在 core 纯函数里。)
*/
async function supervise(opts, port) {
const [cmdName, ...cmdArgs] = opts.cmd;
const child = spawn(cmdName, cmdArgs, { stdio: 'inherit', cwd: opts.installDir, env: process.env });
let stopping = false;
const forwardSignal = (sig) => {
stopping = true;
if (child.exitCode === null && child.signalCode === null) {
try {
child.kill(sig);
} catch {
/* 已退出 */
}
// 兜底:子进程不理信号就强杀(守卫不能陪着赖着不走)
setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) {
try {
child.kill('SIGKILL');
} catch {
/* 已退出 */
}
}
}, 10_000).unref();
}
};
process.on('SIGTERM', () => forwardSignal('SIGTERM'));
process.on('SIGINT', () => forwardSignal('SIGINT'));
const startedAt = Date.now();
const exit = await new Promise((done) => {
child.on('exit', (code, signal) => done({ code, signal }));
child.on('error', (err) => {
// spawn 本身失败(命令不存在等)= 启动期失败,按 code=1 计数
appendGuardLog(opts.dataDir, { action: 'spawn-error', error: err.message });
done({ code: 1, signal: null });
});
});
const livedMs = Date.now() - startedAt;
const up = await serviceUp(port);
const decision = decideAfterExit({
code: exit.code,
signal: exit.signal,
livedMs,
stopping,
serviceUp: up,
state: readStateFile(opts.dataDir),
nowIso: new Date().toISOString(),
});
appendGuardLog(opts.dataDir, {
action: 'child-exit',
code: exit.code,
signal: exit.signal,
lived_ms: livedMs,
counted: decision.counted,
persist: decision.persist,
reason: decision.reason,
service_up: up,
failures: decision.state.failures,
grace_ms: STARTUP_GRACE_MS,
});
if (decision.persist) writeState(opts.dataDir, decision.state);
console.error(
`[startup-guard] 子进程退出(code=${exit.code ?? 'null'} signal=${exit.signal ?? 'null'},` +
`存活 ${Math.round(livedMs / 1000)}s,${decision.reason},失败计数 ${decision.state.failures})`,
);
return decision.exitCode;
}
async function main() {
let opts;
try {
opts = parseGuardArgs(process.argv.slice(2));
} catch (err) {
console.error(`[startup-guard] ${err instanceof Error ? err.message : String(err)}\n${USAGE}`);
return 2;
}
if (opts.help) {
console.log(USAGE);
return 0;
}
if (!opts.dataDir || !opts.installDir || opts.cmd.length === 0) {
console.error(`[startup-guard] 缺少必要参数(--data-dir / --install-dir / -- 启动命令)\n${USAGE}`);
return 2;
}
if (!Number.isInteger(opts.threshold) || opts.threshold < 1) {
console.error(`[startup-guard] --threshold 需为正整数,收到: ${opts.threshold}`);
return 2;
}
const lkgDir = opts.lkgDir || opts.installDir;
const port = resolvePort(opts.dataDir, opts.port);
const dataDir = opts.dataDir;
// ① 服务已由别的实例提供(撞车/接管场景)→ 使命达成,不再 spawn
if (await serviceUp(port)) {
appendGuardLog(dataDir, { action: 'already-up', port, reason: 'service-up' });
console.error(`[startup-guard] 端口 ${port} 已被服务,本实例不重复启动`);
return 0;
}
// ② 读失败计数;达到阈值 → 尝试从 LKG 恢复(无 LKG/无匹配 → 放行,不动 install-dir)
let state = readStateFile(dataDir);
if (decideRestore(state, opts.threshold)) {
const r = restoreFromLkg({ dataDir, lkgDir, installDir: opts.installDir, entry: opts.entry });
appendGuardLog(dataDir, {
action: 'restore',
failures: state.failures,
threshold: opts.threshold,
restored: r.restored,
backup: r.backup,
entry: r.entry,
reason: r.reason,
candidates: r.candidates,
});
if (r.restored) {
state = zeroFailures(new Date().toISOString());
writeState(dataDir, state);
console.error(`[startup-guard] 已从 LKG 恢复入口: ${r.backup} → ${join(opts.installDir, r.entry)}(失败计数归零)`);
} else {
console.error(`[startup-guard] 未执行恢复(${r.reason})—— 按原状尝试启动`);
}
}
// ③ 看守启动命令(退出码契约见文件头)
console.error(`[startup-guard] 看守中: ${opts.cmd.join(' ')}(阈值 ${opts.threshold},当前失败 ${state.failures} 次)`);
appendGuardLog(dataDir, {
action: 'spawn',
cmd: opts.cmd,
failures: state.failures,
threshold: opts.threshold,
port,
});
return supervise(opts, port);
}
// ---------- 入口 ----------
const isMain = (() => {
try {
const self = resolve(fileURLToPath(import.meta.url));
const argv1 = process.argv[1] ? resolve(process.argv[1]) : '';
return !!argv1 && self === argv1;
} catch {
return false;
}
})();
if (isMain) {
main()
.then((code) => process.exit(code))
.catch((err) => {
// 守卫自身崩溃:放行一次(不让守卫成为启动瓶颈),但留下证据
console.error(`[startup-guard] 守卫自身异常(已放行): ${err instanceof Error ? err.message : String(err)}`);
process.exit(0);
});
}