startup-guard.log 一直是 appendFileSync 无限追加。看门狗常驻被控端, 「坏版本 + supervisor 反复重启」时写得很快,磁盘写满会连带把 agent 的 审计写入一起拖死(看门狗自己就在同一台机器上)。 - core(零依赖纯函数):shouldRotate(严格大于阈值,NaN 不触发)+ rotationTargets; - guard:超阈值先丢最旧 .N → 逐个后移 → 当前滚为 .1,滚动与写入均失败静默 (日志问题绝不能反过来拦住被控端启动); - 单测 +3:阈值边界(含恰好等于/Nan)、命名序列、真实滚存三轮(验证只留 3 份、 当前日志重新开始、未超阈值只追加)。
504 lines
18 KiB
JavaScript
504 lines
18 KiB
JavaScript
#!/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;
|
||
|
||
/** 状态文件路径:<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(失败静默——日志不拦路)。
|
||
*
|
||
* 超过 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 记录(<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)}` };
|
||
}
|
||
}
|
||
|
||
// ---------- 被看守进程的环境 ----------
|
||
|
||
/**
|
||
* 被看守进程(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 参数优先,否则读 <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;
|
||
// 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);
|
||
});
|
||
}
|