Files
nodeagent/scripts/lib/startup-guard-core.mjs
T
jkooe 419f6dca13 fix(agent): 看门狗日志按 1MB×4 轮换 —— 修常驻被控端的无界增长
startup-guard.log 一直是 appendFileSync 无限追加。看门狗常驻被控端,
「坏版本 + supervisor 反复重启」时写得很快,磁盘写满会连带把 agent 的
审计写入一起拖死(看门狗自己就在同一台机器上)。

- core(零依赖纯函数):shouldRotate(严格大于阈值,NaN 不触发)+ rotationTargets;
- guard:超阈值先丢最旧 .N → 逐个后移 → 当前滚为 .1,滚动与写入均失败静默
  (日志问题绝不能反过来拦住被控端启动);
- 单测 +3:阈值边界(含恰好等于/Nan)、命名序列、真实滚存三轮(验证只留 3 份、
  当前日志重新开始、未超阈值只追加)。
2026-10-27 08:04:20 +08:00

201 lines
9.2 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.
/**
* 看门狗核心(startup-guard-core.mjs)—— 纯函数 + 零依赖 + 零 IO。
*
* 本文件是 scripts/startup-guard.mjs 的「大脑」:所有**判断**都在这里,
* 且全部是可单测的纯函数;文件系统、子进程、网络都在 startup-guard.mjs 里
* 以薄封装注入。铁律:本文件**不 import 任何东西**(连 node: 内置都不用),
* 保证它在任何 Node ≥22 环境里都能被加载、被测试。
*
* ## 这个看门狗解决什么问题(决策 R-1)
* `nodeagent rollback` 是 RPC 命令,**被控端进程完全起不来时无监听可连**,
* CLI 救不了(ARCH A-P0-6)。故加一层启动看护:启动连续失败 N 次,
* 自动从 LKG(最后已知可用版本)恢复入口文件。
*
* ## 与 DELIVERY-PIPELINE §6 的关系
* §6 原取舍「不做自动回滚」的三问(无法归因/回滚循环/失控)在这里的答复:
* - 无法归因 → 只看**启动期**死亡(存活 < STARTUP_GRACE_MS),
* 运行期死亡不计(计划内重启杀的是长命进程,见 decideAfterExit);
* - 回滚循环 → 恢复后计数归零,且恢复目标只有一个(LKG),不滚动;
* - 失控 → 恢复动作全量记 JSONL 日志,且**无 LKG/无匹配时绝不擦除 install-dir**。
*/
/**
* 启动宽限期(ms):子进程存活超过它再死,视为**运行期事件**而非启动失败。
*
* 为什么需要:本项目的受控端重启机制(system.agent.restart)是
* 「detached 进程杀旧起新」——旧进程是被 kill -9/taskkill 打死的**长命**进程。
* 若不区分启动期/运行期,一次计划内重启就会被记成「启动失败」,
* 累积 N 次后触发**误回滚**(把好版本回滚掉,比坏版本停摆更糟)。
* 30s 的依据:agent 正常启动(含首次生成证书)在秒级完成;
* 坏版本(语法错/缺模块/端口冲突)在 1-2s 内死亡。
*/
export const STARTUP_GRACE_MS = 30_000;
/**
* 看门狗日志轮换阈值(字节):超过即滚存一份。
*
* 为什么需要:日志是 `appendFileSync` 无界追加的 —— 每次启动失败/恢复各写一行,
* 长期运行(尤其「坏版本 + supervisor 反复重启」)会把磁盘写满,而看门狗自己
* 就常驻在这台机器上,磁盘满会连带把 agent 的审计写入一起拖死。
* 1MB × 4 份(当前 + 3 份历史)≈ 4MB 上限,对排障足够(最近几百次启动)。
*/
export const LOG_MAX_BYTES = 1_048_576;
/** 保留的历史份数(`.1` 最新 … `.N` 最旧)。 */
export const LOG_KEEP = 3;
/** 是否需要轮换(纯函数):严格大于阈值才轮换,避免恰好等于阈值时抖动着反复滚。 */
export function shouldRotate(sizeBytes, maxBytes = LOG_MAX_BYTES) {
return Number.isFinite(sizeBytes) && sizeBytes > maxBytes;
}
/** 轮换目标文件名(从新到旧):`<log>.1` … `<log>.N`。 */
export function rotationTargets(logPath, keep = LOG_KEEP) {
const out = [];
for (let i = 1; i <= keep; i += 1) out.push(`${logPath}.${i}`);
return out;
}
/** 全新状态(文件缺失时的起点)。 */
export function initialState(nowIso = '') {
return { failures: 0, updated_at: nowIso };
}
/** 启动成功后的状态:计数归零(与 agent 侧 listen 成功后写状态同口径)。 */
export function zeroFailures(nowIso = '') {
return { failures: 0, updated_at: nowIso };
}
/**
* 解析状态文件文本 → `{failures, updated_at}`。
*
* 缺失/损坏/字段非法一律按 0 —— **不猜、但也不因此拒绝服务**:
* 状态文件只是计数,坏了的代价从「重新数」开始,而不是「不启动」。
* nowIso 仅在文本合法时保留原 updated_at;调用方需要时自行覆盖。
*/
export function readState(rawText) {
if (typeof rawText !== 'string' || rawText.trim() === '') return initialState();
let obj;
try {
obj = JSON.parse(rawText);
} catch {
return initialState();
}
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return initialState();
// 只认 number 型的非负整数:字符串 "3"、布尔、null 一律按 0(不猜)
const n = obj.failures;
const failures = typeof n === 'number' && Number.isInteger(n) && n >= 0 ? n : 0;
const updatedAt = typeof obj.updated_at === 'string' ? obj.updated_at : '';
return { failures, updated_at: updatedAt };
}
/** 失败一次后的新状态(nowIso 由调用方注入,保证可测)。 */
export function nextFailures(state, nowIso = '') {
const prev = Number.isInteger(state?.failures) && state.failures >= 0 ? state.failures : 0;
return { failures: prev + 1, updated_at: nowIso };
}
/**
* 是否达到恢复阈值:连续失败 **>= threshold** 才恢复。
*
* 阈值非法(非正整数)→ 不恢复(放行启动)—— 守卫自身配置错误时,
* 宁可多试几次,也不能乱动入口文件。
*/
export function decideRestore(state, threshold) {
const t = Number(threshold);
if (!Number.isInteger(t) || t < 1) return false;
return (state?.failures ?? 0) >= t;
}
/**
* 子进程退出后的处置决策(纯函数,本模块最重要的判断)。
*
* @param {object} input
* @param {number|null} [input.code] 子进程退出码(被信号打死时为 null)
* @param {string|null} [input.signal] 打死子进程的信号名(正常退出为 null)
* @param {number} input.livedMs 子进程存活时长
* @param {boolean} input.stopping 本 guard 是否正在被要求停止
* (systemctl stop / 任务停止 → 已转发信号给子进程)
* @param {boolean} input.serviceUp 端口是否已被(别的实例)服务
* @param {object} input.state 进入本次决策前的状态
* @param {string} input.nowIso 当前时间(注入)
* @returns {{state: object, counted: boolean, persist: boolean, exitCode: number, reason: string}}
*
* 优先级(从高到低):
* 1. serviceUp —— 服务已由别的实例提供(撞车/接管):使命达成,归零退出 0;
* 2. stopping —— 本 guard 被要求停:不计数,按子进程退出码退出;
* 3. 干净退出 0 —— 成功,归零退出 0;
* 4. 运行期死亡(livedMs >= 宽限期)—— 不计「启动失败」,退出码交回外层 supervisor;
* 5. 启动期死亡且无人接管 —— 连续启动失败 +1,退出码交回外层 supervisor。
*
* persist 表示「本次决策是否要落盘」(stopping / runtime-exit 不写:
* 状态没变,写了反而把 updated_at 搅乱)。
*/
export function decideAfterExit(input) {
const {
code = 0,
signal = null,
livedMs = 0,
stopping = false,
serviceUp = false,
state = initialState(),
nowIso = '',
} = input ?? {};
if (serviceUp) {
return { state: zeroFailures(nowIso), counted: false, persist: true, exitCode: 0, reason: 'service-up' };
}
if (stopping) {
return { state, counted: false, persist: false, exitCode: code ?? 1, reason: 'stopping' };
}
if (!signal && code === 0) {
return { state: zeroFailures(nowIso), counted: false, persist: true, exitCode: 0, reason: 'clean-exit' };
}
const lived = Number.isFinite(livedMs) ? livedMs : 0;
if (lived >= STARTUP_GRACE_MS) {
return { state, counted: false, persist: false, exitCode: code ?? 1, reason: 'runtime-exit' };
}
return { state: nextFailures(state, nowIso), counted: true, persist: true, exitCode: code ?? 1, reason: 'startup-failure' };
}
/**
* 备份文件名匹配:`<entry>.bak`(deploy 固定名)与 `<entry>.bak-<ts>`(selfupdate 时间戳名)。
*
* 与控制端 `apps/cli/src/cmd/rollback.ts` 的 `backupNameRegex` **同口径** ——
* 两边认不出同一批备份 = 看门狗恢复的文件与 rollback 枚举的文件不一致。
* (tests/unit/startup-guard.test.mjs 有跨端一致性守卫。)
* 文件名里的正则元字符一律转义;Windows 反斜杠不当分隔符处理(文件名内不出现)。
*/
export function backupNameRegex(entryName) {
const base = String(entryName ?? '');
const esc = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`^${esc}\\.bak(-.*)?$`);
}
/**
* 指纹比对:全量相等,或老 LKG(12 位)按前缀比。
*
* 与 CLI 的 `hashMatches` 同口径:LkgRecord 老数据只有 12 位 hash
* (ARCH A-P0-3/R-2),不得要求被控端先升级才能被看门狗恢复。
*/
export function hashMatches(got, want) {
if (typeof got !== 'string' || typeof want !== 'string' || !got || !want) return false;
if (got === want) return true;
return want.length < got.length && got.startsWith(want);
}
/**
* 从「已算好指纹」的备份候选里挑恢复源(纯函数;mtime 新→早的排序在外层做)。
*
* @param {Array<{path: string, hash: string, mtime?: number}>} cands
* @param {string} wantHash LKG 指纹(hash_full ?? hash)
* @returns {{path: string, hash: string}|null} 第一个匹配者;无匹配返回 null
*/
export function planRestore(cands, wantHash) {
if (!wantHash || !Array.isArray(cands)) return null;
for (const c of cands) {
if (c && hashMatches(c.hash, wantHash)) return { path: c.path, hash: c.hash };
}
return null;
}