#!/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, renameSync, statSync, unlinkSync, 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, rotationTargets, shouldRotate, zeroFailures, } from './lib/startup-guard-core.mjs'; // ---------- 路径与状态 ---------- const LKG_FILE = 'agent-lkg.json'; const HASH_CHUNK = 512 * 1024; /** 状态文件路径:/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 日志:/logs/startup-guard.log(失败静默——日志不拦路)。 * * 超过 LOG_MAX_BYTES 时滚存:`.log.1`(次新)… `.log.N`(最旧),`N = LOG_KEEP`。 * 轮换本身也**失败静默**:日志出问题绝不能反过来拦住被控端启动。 */ export function appendGuardLog(dataDir, obj, opts = {}) { try { mkdirSync(join(dataDir, 'logs'), { recursive: true }); const logPath = join(dataDir, 'logs', 'startup-guard.log'); try { const size = existsSync(logPath) ? statSync(logPath).size : 0; if (shouldRotate(size, opts.maxBytes)) { const targets = rotationTargets(logPath, opts.keep); const oldest = targets[targets.length - 1]; if (existsSync(oldest)) unlinkSync(oldest); for (let i = targets.length - 1; i > 0; i -= 1) { if (existsSync(targets[i - 1])) renameSync(targets[i - 1], targets[i]); } renameSync(logPath, targets[0]); } } catch { /* 轮换失败不影响写入 */ } const line = `${JSON.stringify({ ts: new Date().toISOString(), ...obj })}\n`; writeFileSync(logPath, line, { flag: 'a' }); } catch { /* 日志失败不影响启动 */ } } // ---------- LKG 与备份 ---------- /** 读 LKG 记录(/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); } } /** * 枚举备份候选: 下 `.bak` / `.bak-`,按 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/(同 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)}` }; } } // ---------- 被看守进程的环境 ---------- /** * 被看守进程(agent)的运行环境:**强制注入 NODEAGENT_HOME = data-dir**。 * * 为什么强制:agent 的 `agentDir()`(数据目录、LKG 文件、归零状态文件所在地) * 由环境变量 NODEAGENT_HOME 决定。而 Windows 计划任务的 * `New-ScheduledTaskAction` **无法携带环境变量**,若安装进程恰好持有 * **进程级** NODEAGENT_HOME,agent 运行时的 agentDir() 就会与守卫的 * --data-dir 漂移:守卫读不到 LKG/状态文件(安全退化:no-lkg 放行, * 但看门狗白养)。由守卫把自己认定的 data-dir 显式传给被看守进程, * 三端行为即与 supervisor 环境解耦且一致(Linux unit / macOS plist 的 * EnvironmentVariables 本就是同值,双保险,幂等无害)。 */ export function spawnEnv(dataDir, baseEnv = process.env) { return { ...baseEnv, NODEAGENT_HOME: dataDir }; } // ---------- 端口探活 ---------- /** * 解析被控端端口:--port 参数优先,否则读 /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; // NODEAGENT_HOME 强制为 data-dir:agentDir()(LKG/状态文件所在地)必须与 // 守卫读的 --data-dir 是同一个目录,否则恢复链路静默失效(见 spawnEnv 注释) const child = spawn(cmdName, cmdArgs, { stdio: 'inherit', cwd: opts.installDir, env: spawnEnv(opts.dataDir), }); 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); }); }