startup-guard.log 一直是 appendFileSync 无限追加。看门狗常驻被控端, 「坏版本 + supervisor 反复重启」时写得很快,磁盘写满会连带把 agent 的 审计写入一起拖死(看门狗自己就在同一台机器上)。 - core(零依赖纯函数):shouldRotate(严格大于阈值,NaN 不触发)+ rotationTargets; - guard:超阈值先丢最旧 .N → 逐个后移 → 当前滚为 .1,滚动与写入均失败静默 (日志问题绝不能反过来拦住被控端启动); - 单测 +3:阈值边界(含恰好等于/Nan)、命名序列、真实滚存三轮(验证只留 3 份、 当前日志重新开始、未超阈值只追加)。
505 lines
23 KiB
JavaScript
505 lines
23 KiB
JavaScript
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { createHash } from 'node:crypto';
|
||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
import {
|
||
STARTUP_GRACE_MS,
|
||
backupNameRegex,
|
||
decideAfterExit,
|
||
decideRestore,
|
||
hashMatches,
|
||
initialState,
|
||
nextFailures,
|
||
planRestore,
|
||
readState,
|
||
rotationTargets,
|
||
shouldRotate,
|
||
zeroFailures,
|
||
} from '../../scripts/lib/startup-guard-core.mjs';
|
||
import {
|
||
appendGuardLog,
|
||
hashFileSync,
|
||
listBackupCandidates,
|
||
parseGuardArgs,
|
||
readLkgRecord,
|
||
readStateFile,
|
||
resolvePort,
|
||
restoreFromLkg,
|
||
spawnEnv,
|
||
statePath,
|
||
writeState,
|
||
} from '../../scripts/startup-guard.mjs';
|
||
// 跨端一致性:守卫与 CLI rollback 必须认同一批备份、同一套指纹规则
|
||
import { backupNameRegex as cliBackupNameRegex, hashMatches as cliHashMatches } from '../../apps/cli/dist/cmd/rollback.js';
|
||
|
||
/**
|
||
* 看门狗守卫(决策 R-1 / ARCH A-P0-6)。
|
||
*
|
||
* 为什么需要守卫:rollback 是 RPC 命令,被控端「完全起不来」时无监听可连。
|
||
* 这里钉死看门狗的**判断**不漂移:
|
||
* 1. 阈值边界:N-1 不恢复、N 才恢复;
|
||
* 2. 有 LKG 且指纹匹配才恢复;无 LKG / 无匹配 → 放行且**不擦除 install-dir**;
|
||
* 3. 状态文件损坏按 0(不猜、不拒绝服务);
|
||
* 4. 启动期死亡计数、运行期死亡不计数(防计划内重启误触发回滚);
|
||
* 5. 成功后归零;
|
||
* 6. 恢复日志必须落盘;
|
||
* 7. 与 CLI rollback 的备份命名/指纹规则同口径(否则两边认不出同一批文件)。
|
||
*/
|
||
|
||
// ---------- 纯函数:状态机 ----------
|
||
|
||
test('readState:缺失/空串/损坏 JSON/字段非法一律按 0', () => {
|
||
assert.deepEqual(readState(undefined), { failures: 0, updated_at: '' });
|
||
assert.deepEqual(readState(''), { failures: 0, updated_at: '' });
|
||
assert.deepEqual(readState(' '), { failures: 0, updated_at: '' });
|
||
assert.deepEqual(readState('{ not json'), { failures: 0, updated_at: '' });
|
||
assert.deepEqual(readState('[]'), { failures: 0, updated_at: '' });
|
||
assert.deepEqual(readState('{"failures":"3"}'), { failures: 0, updated_at: '' });
|
||
assert.deepEqual(readState('{"failures":-2}'), { failures: 0, updated_at: '' });
|
||
assert.deepEqual(readState('{"failures":2.5}'), { failures: 0, updated_at: '' });
|
||
assert.deepEqual(readState('{"failures":3,"updated_at":"2026-10-25T00:00:00Z"}'), {
|
||
failures: 3,
|
||
updated_at: '2026-10-25T00:00:00Z',
|
||
});
|
||
});
|
||
|
||
test('readState:CRLF 状态文件同样可解析(Windows 手工编辑场景)', () => {
|
||
const raw = '{\r\n "failures": 2,\r\n "updated_at": "t"\r\n}\r\n';
|
||
assert.deepEqual(readState(raw), { failures: 2, updated_at: 't' });
|
||
});
|
||
|
||
test('decideRestore:阈值边界 N-1 不恢复、N 恢复', () => {
|
||
assert.equal(decideRestore({ failures: 0 }, 3), false);
|
||
assert.equal(decideRestore({ failures: 2 }, 3), false, 'N-1 必须不恢复');
|
||
assert.equal(decideRestore({ failures: 3 }, 3), true, 'N 必须恢复');
|
||
assert.equal(decideRestore({ failures: 9 }, 3), true);
|
||
// 阈值非法 → 不恢复(守卫自身配置错误时不许乱动入口文件)
|
||
assert.equal(decideRestore({ failures: 99 }, 0), false);
|
||
assert.equal(decideRestore({ failures: 99 }, -1), false);
|
||
assert.equal(decideRestore({ failures: 99 }, 2.5), false);
|
||
assert.equal(decideRestore({ failures: 99 }, 'x'), false);
|
||
});
|
||
|
||
test('nextFailures:递增且带时间戳;zeroFailures 归零', () => {
|
||
const s1 = nextFailures({ failures: 0, updated_at: '' }, 'T1');
|
||
assert.deepEqual(s1, { failures: 1, updated_at: 'T1' });
|
||
const s2 = nextFailures(s1, 'T2');
|
||
assert.deepEqual(s2, { failures: 2, updated_at: 'T2' });
|
||
assert.deepEqual(zeroFailures('T3'), { failures: 0, updated_at: 'T3' });
|
||
});
|
||
|
||
test('decideAfterExit:启动期死亡(<宽限期)→ 计数 +1', () => {
|
||
const d = decideAfterExit({
|
||
code: 1, signal: null, livedMs: 800, stopping: false, serviceUp: false,
|
||
state: { failures: 1, updated_at: '' }, nowIso: 'T',
|
||
});
|
||
assert.equal(d.counted, true);
|
||
assert.equal(d.persist, true);
|
||
assert.equal(d.state.failures, 2);
|
||
assert.equal(d.exitCode, 1);
|
||
assert.equal(d.reason, 'startup-failure');
|
||
});
|
||
|
||
test('decideAfterExit:运行期死亡(>=宽限期)→ 不计数(计划内重启杀的是长命进程)', () => {
|
||
const d = decideAfterExit({
|
||
code: null, signal: 'SIGKILL', livedMs: STARTUP_GRACE_MS + 1, stopping: false, serviceUp: false,
|
||
state: { failures: 2, updated_at: '' }, nowIso: 'T',
|
||
});
|
||
assert.equal(d.counted, false);
|
||
assert.equal(d.persist, false, '状态没变就不写盘');
|
||
assert.equal(d.state.failures, 2, '计数不许被计划内重启搅动');
|
||
assert.equal(d.exitCode, 1, '退出码仍交回 supervisor 重启');
|
||
assert.equal(d.reason, 'runtime-exit');
|
||
});
|
||
|
||
test('decideAfterExit:服务已被别的实例提供 → 归零退出 0(撞车不算失败)', () => {
|
||
const d = decideAfterExit({
|
||
code: 1, signal: null, livedMs: 500, stopping: false, serviceUp: true,
|
||
state: { failures: 2, updated_at: '' }, nowIso: 'T',
|
||
});
|
||
assert.equal(d.counted, false);
|
||
assert.equal(d.state.failures, 0);
|
||
assert.equal(d.exitCode, 0);
|
||
assert.equal(d.reason, 'service-up');
|
||
});
|
||
|
||
test('decideAfterExit:本守卫被要求停(systemctl stop)→ 不计数', () => {
|
||
const d = decideAfterExit({
|
||
code: 1, signal: null, livedMs: 60_000, stopping: true, serviceUp: false,
|
||
state: { failures: 1, updated_at: '' }, nowIso: 'T',
|
||
});
|
||
assert.equal(d.counted, false);
|
||
assert.equal(d.persist, false);
|
||
assert.equal(d.state.failures, 1);
|
||
assert.equal(d.reason, 'stopping');
|
||
});
|
||
|
||
test('decideAfterExit:干净退出 0 → 成功后归零', () => {
|
||
const d = decideAfterExit({
|
||
code: 0, signal: null, livedMs: 5, stopping: false, serviceUp: false,
|
||
state: { failures: 2, updated_at: '' }, nowIso: 'T',
|
||
});
|
||
assert.equal(d.counted, false);
|
||
assert.equal(d.state.failures, 0);
|
||
assert.equal(d.exitCode, 0);
|
||
assert.equal(d.reason, 'clean-exit');
|
||
});
|
||
|
||
// ---------- 纯函数:备份命名与指纹(含跨端一致性) ----------
|
||
|
||
test('backupNameRegex:认两套备份命名(deploy 固定名 + selfupdate 时间戳名)', () => {
|
||
const re = backupNameRegex('agent.mjs');
|
||
assert.ok(re.test('agent.mjs.bak'), 'deploy 固定名');
|
||
assert.ok(re.test('agent.mjs.bak-1729300000000'), 'selfupdate 时间戳名');
|
||
assert.ok(!re.test('agent.mjs.bakX'), '后缀必须整段匹配');
|
||
assert.ok(!re.test('agent.mjs'), '入口自己不是备份');
|
||
assert.ok(!re.test('other.mjs.bak'), '别名的备份不认');
|
||
const re2 = backupNameRegex('index.js');
|
||
assert.ok(re2.test('index.js.bak'));
|
||
});
|
||
|
||
test('跨端一致:守卫与 CLI rollback 的备份命名/指纹规则同口径', () => {
|
||
// 两边认不出同一批备份 = 看门狗恢复的文件与 rollback 枚举的文件不一致
|
||
const names = ['agent.mjs.bak', 'agent.mjs.bak-123', 'agent.mjs.bakX', 'agent.mjs', 'x.bak', 'a+b.c.bak'];
|
||
for (const entry of ['agent.mjs', 'index.js', 'a+b.c']) {
|
||
const a = backupNameRegex(entry);
|
||
const b = cliBackupNameRegex(entry);
|
||
for (const n of names) {
|
||
assert.equal(a.test(n), b.test(n), `口径漂移: entry=${entry} name=${n}`);
|
||
}
|
||
}
|
||
const full = createHash('sha256').update('x').digest('hex');
|
||
assert.equal(hashMatches(full, full.slice(0, 12)), cliHashMatches(full, full.slice(0, 12)));
|
||
assert.equal(hashMatches(full, full), cliHashMatches(full, full));
|
||
assert.equal(hashMatches('', 'x'), cliHashMatches('', 'x'));
|
||
});
|
||
|
||
test('hashMatches:全量相等/12 位前缀回落;空值与反向不匹配', () => {
|
||
const full = createHash('sha256').update('content').digest('hex');
|
||
assert.equal(hashMatches(full, full), true);
|
||
assert.equal(hashMatches(full, full.slice(0, 12)), true, '老 LKG 12 位前缀回落');
|
||
assert.equal(hashMatches(full.slice(0, 12), full), false, '不得反向匹配');
|
||
assert.equal(hashMatches('', full), false);
|
||
assert.equal(hashMatches(full, ''), false);
|
||
});
|
||
|
||
test('planRestore:无匹配返回 null;按给定顺序取第一个匹配者', () => {
|
||
const good = createHash('sha256').update('good').digest('hex');
|
||
const bad = createHash('sha256').update('bad').digest('hex');
|
||
assert.equal(planRestore([], good), null);
|
||
assert.equal(planRestore([{ path: '/a', hash: bad }], good), null);
|
||
assert.deepEqual(planRestore([{ path: '/a', hash: good }], good), { path: '/a', hash: good });
|
||
assert.deepEqual(
|
||
planRestore([{ path: '/new', hash: bad }, { path: '/old', hash: good }], good),
|
||
{ path: '/old', hash: good },
|
||
'匹配优先于新近(新备份不是 LKG 时不许被抢)',
|
||
);
|
||
});
|
||
|
||
// ---------- 参数解析 ----------
|
||
|
||
test('parseGuardArgs:-- 之后是完整启动命令;缺值/未知参数报错', () => {
|
||
const o = parseGuardArgs([
|
||
'--data-dir', '/d', '--lkg-dir', '/l', '--install-dir', '/i',
|
||
'--threshold', '3', '--port', '8765', '--entry', 'agent.mjs',
|
||
'--', 'node', '/i/agent.mjs',
|
||
]);
|
||
assert.equal(o.dataDir, '/d');
|
||
assert.equal(o.lkgDir, '/l');
|
||
assert.equal(o.installDir, '/i');
|
||
assert.equal(o.threshold, 3);
|
||
assert.equal(o.port, 8765);
|
||
assert.equal(o.entry, 'agent.mjs');
|
||
assert.deepEqual(o.cmd, ['node', '/i/agent.mjs']);
|
||
assert.equal(parseGuardArgs([]).cmd.length, 0, '没有 -- 就没有启动命令');
|
||
assert.throws(() => parseGuardArgs(['--data-dir']), /缺少取值/);
|
||
assert.throws(() => parseGuardArgs(['--wat']), /未知参数/);
|
||
});
|
||
|
||
test('spawnEnv:强制注入 NODEAGENT_HOME = data-dir,其余环境原样保留', () => {
|
||
const env = spawnEnv('/data/na', { PATH: '/usr/bin', NODEAGENT_HOME: '/stale' });
|
||
assert.equal(env.NODEAGENT_HOME, '/data/na',
|
||
'data-dir 是权威:覆盖陈旧值,防 agentDir() 与守卫 data-dir 漂移(Windows 计划任务不带环境变量)');
|
||
assert.equal(env.PATH, '/usr/bin', '其余环境变量原样保留');
|
||
const inherit = spawnEnv('/data/na');
|
||
assert.equal(inherit.NODEAGENT_HOME, '/data/na', '默认继承当前进程环境');
|
||
});
|
||
|
||
// ---------- IO 集成(真实临时目录) ----------
|
||
|
||
const GOOD = Buffer.from('GOOD-AGENT-LKG-CONTENT');
|
||
const BAD = Buffer.from('BROKEN-AGENT-CONTENT');
|
||
const goodHash = createHash('sha256').update(GOOD).digest('hex');
|
||
|
||
function makeBox() {
|
||
const root = mkdtempSync(join(tmpdir(), 'na-guard-'));
|
||
const dataDir = join(root, 'data');
|
||
const lkgDir = join(root, 'install');
|
||
const installDir = lkgDir;
|
||
mkdirSync(dataDir, { recursive: true });
|
||
mkdirSync(installDir, { recursive: true });
|
||
return { root, dataDir, lkgDir, installDir };
|
||
}
|
||
|
||
function writeLkg(dataDir, rec) {
|
||
writeFileSync(join(dataDir, 'agent-lkg.json'), `${JSON.stringify(rec, null, 2)}\n`, 'utf8');
|
||
}
|
||
|
||
test('restoreFromLkg:有 LKG 且指纹匹配 → 就地覆写入口 + 内容正确', () => {
|
||
const { dataDir, lkgDir, installDir } = makeBox();
|
||
writeLkg(dataDir, {
|
||
hash: goodHash.slice(0, 12), hash_full: goodHash, bytes: GOOD.length,
|
||
script_path: join(installDir, 'agent.mjs'), recorded_at: 1, node_version: 'v22', platform: 'linux',
|
||
});
|
||
writeFileSync(join(lkgDir, 'agent.mjs.bak'), GOOD);
|
||
writeFileSync(join(installDir, 'agent.mjs'), BAD);
|
||
|
||
const r = restoreFromLkg({ dataDir, lkgDir, installDir, entry: 'agent.mjs' });
|
||
assert.equal(r.restored, true);
|
||
assert.equal(r.backup, join(lkgDir, 'agent.mjs.bak'));
|
||
assert.deepEqual(readFileSync(join(installDir, 'agent.mjs')), GOOD, '入口必须被还原成 LKG 内容');
|
||
});
|
||
|
||
test('restoreFromLkg:新备份不是 LKG 时不许被抢(匹配优先于新近)', () => {
|
||
const { dataDir, lkgDir, installDir } = makeBox();
|
||
writeLkg(dataDir, {
|
||
hash: goodHash.slice(0, 12), hash_full: goodHash, bytes: GOOD.length,
|
||
script_path: join(installDir, 'agent.mjs'), recorded_at: 1, node_version: 'v22', platform: 'linux',
|
||
});
|
||
const stale = join(lkgDir, 'agent.mjs.bak-1000');
|
||
const fresh = join(lkgDir, 'agent.mjs.bak-2000');
|
||
writeFileSync(stale, GOOD);
|
||
writeFileSync(fresh, BAD);
|
||
// fresh 更新(mtime 更大),但它不是 LKG
|
||
const now = Date.now() / 1000;
|
||
utimesSync(fresh, now, now);
|
||
utimesSync(stale, now - 3600, now - 3600);
|
||
writeFileSync(join(installDir, 'agent.mjs'), BAD);
|
||
|
||
const r = restoreFromLkg({ dataDir, lkgDir, installDir, entry: 'agent.mjs' });
|
||
assert.equal(r.restored, true);
|
||
assert.equal(r.backup, stale, '必须命中指纹匹配的旧备份,而不是更新的那个');
|
||
});
|
||
|
||
test('restoreFromLkg:老 LKG 只有 12 位 hash 也能按前缀匹配', () => {
|
||
const { dataDir, lkgDir, installDir } = makeBox();
|
||
writeLkg(dataDir, {
|
||
hash: goodHash.slice(0, 12), bytes: GOOD.length, // 无 hash_full(v2.1.0 之前的记录)
|
||
script_path: join(installDir, 'agent.mjs'), recorded_at: 1, node_version: 'v22', platform: 'win32',
|
||
});
|
||
writeFileSync(join(lkgDir, 'agent.mjs.bak'), GOOD);
|
||
writeFileSync(join(installDir, 'agent.mjs'), BAD);
|
||
const r = restoreFromLkg({ dataDir, lkgDir, installDir, entry: 'agent.mjs' });
|
||
assert.equal(r.restored, true);
|
||
});
|
||
|
||
test('restoreFromLkg:无 LKG → 放行且 install-dir 一个字节都不动', () => {
|
||
const { dataDir, lkgDir, installDir } = makeBox();
|
||
writeFileSync(join(lkgDir, 'agent.mjs.bak'), GOOD);
|
||
writeFileSync(join(installDir, 'agent.mjs'), BAD);
|
||
const before = readFileSync(join(installDir, 'agent.mjs'));
|
||
const r = restoreFromLkg({ dataDir, lkgDir, installDir, entry: 'agent.mjs' });
|
||
assert.equal(r.restored, false);
|
||
assert.equal(r.reason, 'no-lkg');
|
||
assert.deepEqual(readFileSync(join(installDir, 'agent.mjs')), before, '无 LKG 绝不清空入口');
|
||
});
|
||
|
||
test('restoreFromLkg:有 LKG 但无指纹匹配 → 放行且 install-dir 不动', () => {
|
||
const { dataDir, lkgDir, installDir } = makeBox();
|
||
writeLkg(dataDir, {
|
||
hash: 'aaaaaaaaaaaa', hash_full: createHash('sha256').update('other').digest('hex'),
|
||
script_path: join(installDir, 'agent.mjs'), recorded_at: 1, node_version: 'v22', platform: 'linux',
|
||
});
|
||
writeFileSync(join(lkgDir, 'agent.mjs.bak'), GOOD);
|
||
writeFileSync(join(installDir, 'agent.mjs'), BAD);
|
||
const r = restoreFromLkg({ dataDir, lkgDir, installDir, entry: 'agent.mjs' });
|
||
assert.equal(r.restored, false);
|
||
assert.equal(r.reason, 'no-match');
|
||
assert.equal(r.candidates.length, 1, '候选指纹要带回供排查');
|
||
assert.deepEqual(readFileSync(join(installDir, 'agent.mjs')), BAD, '无匹配绝不清空入口');
|
||
});
|
||
|
||
test('restoreFromLkg:无备份候选 → no-candidates,不动入口', () => {
|
||
const { dataDir, lkgDir, installDir } = makeBox();
|
||
writeLkg(dataDir, {
|
||
hash: goodHash.slice(0, 12), hash_full: goodHash,
|
||
script_path: join(installDir, 'agent.mjs'), recorded_at: 1, node_version: 'v22', platform: 'linux',
|
||
});
|
||
writeFileSync(join(installDir, 'agent.mjs'), BAD);
|
||
const r = restoreFromLkg({ dataDir, lkgDir, installDir, entry: 'agent.mjs' });
|
||
assert.equal(r.restored, false);
|
||
assert.equal(r.reason, 'no-candidates');
|
||
assert.deepEqual(readFileSync(join(installDir, 'agent.mjs')), BAD);
|
||
});
|
||
|
||
test('restoreFromLkg:目标不可写(异常路径)→ 吞掉返回 error,不抛', () => {
|
||
const { dataDir, lkgDir, installDir } = makeBox();
|
||
writeLkg(dataDir, {
|
||
hash: goodHash.slice(0, 12), hash_full: goodHash,
|
||
script_path: join(installDir, 'agent.mjs'), recorded_at: 1, node_version: 'v22', platform: 'linux',
|
||
});
|
||
writeFileSync(join(lkgDir, 'agent.mjs.bak'), GOOD);
|
||
mkdirSync(join(installDir, 'agent.mjs')); // 入口是目录 → 写必失败
|
||
const r = restoreFromLkg({ dataDir, lkgDir, installDir, entry: 'agent.mjs' });
|
||
assert.equal(r.restored, false);
|
||
assert.ok(r.reason.startsWith('error:'), '异常必须被吞成 error 返回');
|
||
});
|
||
|
||
test('listBackupCandidates:按 mtime 新→早,只收 <entry>.bak*', () => {
|
||
const { lkgDir } = makeBox();
|
||
writeFileSync(join(lkgDir, 'agent.mjs.bak'), GOOD);
|
||
writeFileSync(join(lkgDir, 'agent.mjs.bak-1'), GOOD);
|
||
writeFileSync(join(lkgDir, 'agent.json'), '{}');
|
||
writeFileSync(join(lkgDir, 'other.bak'), GOOD);
|
||
const now = Date.now() / 1000;
|
||
utimesSync(join(lkgDir, 'agent.mjs.bak'), now - 100, now - 100);
|
||
utimesSync(join(lkgDir, 'agent.mjs.bak-1'), now, now);
|
||
const cands = listBackupCandidates(lkgDir, 'agent.mjs');
|
||
assert.deepEqual(cands.map((c) => c.name), ['agent.mjs.bak-1', 'agent.mjs.bak']);
|
||
});
|
||
|
||
test('hashFileSync:与 createHash 全量一致', () => {
|
||
const { lkgDir } = makeBox();
|
||
writeFileSync(join(lkgDir, 'agent.mjs.bak'), GOOD);
|
||
assert.equal(hashFileSync(join(lkgDir, 'agent.mjs.bak')), goodHash);
|
||
});
|
||
|
||
// ---------- 状态文件与日志 ----------
|
||
|
||
test('readStateFile/writeState:落盘可读回;损坏文件按 0', () => {
|
||
const { dataDir } = makeBox();
|
||
writeState(dataDir, { failures: 2, updated_at: 'T' });
|
||
assert.deepEqual(readStateFile(dataDir), { failures: 2, updated_at: 'T' });
|
||
assert.ok(existsSync(statePath(dataDir)));
|
||
// 损坏 → 0(不猜)
|
||
writeFileSync(statePath(dataDir), 'garbage', 'utf8');
|
||
assert.deepEqual(readStateFile(dataDir), { failures: 0, updated_at: '' });
|
||
// 缺失 → 0
|
||
rmSync(statePath(dataDir), { force: true });
|
||
assert.deepEqual(readStateFile(dataDir), { failures: 0, updated_at: '' });
|
||
});
|
||
|
||
test('appendGuardLog:JSONL 日志落盘且含关键字段', () => {
|
||
const { dataDir } = makeBox();
|
||
appendGuardLog(dataDir, { action: 'restore', restored: true, backup: '/x/agent.mjs.bak' });
|
||
appendGuardLog(dataDir, { action: 'child-exit', code: 1, counted: true, failures: 2 });
|
||
const p = join(dataDir, 'logs', 'startup-guard.log');
|
||
assert.ok(existsSync(p), '恢复/退出事件必须留日志');
|
||
const lines = readFileSync(p, 'utf8').trim().split('\n');
|
||
assert.equal(lines.length, 2);
|
||
const first = JSON.parse(lines[0]);
|
||
assert.equal(first.action, 'restore');
|
||
assert.equal(first.restored, true);
|
||
assert.ok(first.ts, '日志必须带时间戳');
|
||
});
|
||
|
||
test('readLkgRecord:损坏/缺字段 → null', () => {
|
||
const { dataDir } = makeBox();
|
||
assert.equal(readLkgRecord(dataDir), null);
|
||
writeFileSync(join(dataDir, 'agent-lkg.json'), 'nope', 'utf8');
|
||
assert.equal(readLkgRecord(dataDir), null);
|
||
writeFileSync(join(dataDir, 'agent-lkg.json'), JSON.stringify({ bytes: 1 }), 'utf8');
|
||
assert.equal(readLkgRecord(dataDir), null, '缺 hash 字段视为无效');
|
||
});
|
||
|
||
test('resolvePort:--port 优先 → agent.json 回落 → 都没有返回 null', () => {
|
||
const { dataDir } = makeBox();
|
||
assert.equal(resolvePort(dataDir, 9000), 9000);
|
||
writeFileSync(join(dataDir, 'agent.json'), JSON.stringify({ port: 8765 }), 'utf8');
|
||
assert.equal(resolvePort(dataDir, null), 8765, '未传 --port 时读 agent.json');
|
||
assert.equal(resolvePort(dataDir, 9000), 9000, '传了就以参数为准');
|
||
rmSync(join(dataDir, 'agent.json'), { force: true });
|
||
assert.equal(resolvePort(dataDir, null), null, 'Hub 模式无端口 → 跳过端口判断');
|
||
});
|
||
|
||
// ---------- 端到端小剧场:连续崩溃 → 恢复 → 再起 ----------
|
||
|
||
test('场景:崩溃三次后第四次守卫执行恢复并归零计数', () => {
|
||
const { root, dataDir, lkgDir, installDir } = makeBox();
|
||
writeLkg(dataDir, {
|
||
hash: goodHash.slice(0, 12), hash_full: goodHash, bytes: GOOD.length,
|
||
script_path: join(installDir, 'agent.mjs'), recorded_at: 1, node_version: 'v22', platform: 'linux',
|
||
});
|
||
writeFileSync(join(lkgDir, 'agent.mjs.bak'), GOOD);
|
||
writeFileSync(join(installDir, 'agent.mjs'), BAD);
|
||
|
||
// 前三次:坏版本启动即死 → 计数 1、2、3(均未达阈值,不恢复)
|
||
let state = initialState('T0');
|
||
for (let i = 1; i <= 3; i += 1) {
|
||
const d = decideAfterExit({
|
||
code: 1, signal: null, livedMs: 600, stopping: false, serviceUp: false, state, nowIso: `T${i}`,
|
||
});
|
||
assert.equal(d.counted, true);
|
||
state = d.state;
|
||
assert.equal(decideRestore(state, 3), i === 3, `第 ${i} 次后阈值判定`);
|
||
}
|
||
assert.equal(state.failures, 3);
|
||
|
||
// 第四次:达到阈值 → 恢复 + 归零
|
||
const r = restoreFromLkg({ dataDir, lkgDir, installDir, entry: 'agent.mjs' });
|
||
assert.equal(r.restored, true);
|
||
state = zeroFailures('T4');
|
||
assert.deepEqual(readFileSync(join(installDir, 'agent.mjs')), GOOD);
|
||
assert.equal(state.failures, 0);
|
||
|
||
// 恢复日志已在盘上
|
||
appendGuardLog(dataDir, { action: 'restore', restored: true, backup: r.backup });
|
||
assert.ok(readFileSync(join(dataDir, 'logs', 'startup-guard.log'), 'utf8').includes('restore'));
|
||
rmSync(root, { recursive: true, force: true });
|
||
});
|
||
|
||
// ===========================================================================
|
||
// 日志轮换:看门狗常驻被控端,日志无界增长会把磁盘写满
|
||
// ===========================================================================
|
||
|
||
test('shouldRotate:严格大于阈值才轮换(恰好等于不滚)', () => {
|
||
assert.equal(shouldRotate(0), false);
|
||
assert.equal(shouldRotate(1024, 1024), false, '恰好等于阈值不轮换,避免边界抖动');
|
||
assert.equal(shouldRotate(1025, 1024), true);
|
||
assert.equal(shouldRotate(Number.NaN, 1024), false, 'NaN(stat 失败)不得触发轮换');
|
||
});
|
||
|
||
test('rotationTargets:从新到旧的 .1 … .N 命名', () => {
|
||
assert.deepEqual(rotationTargets('/x/startup-guard.log', 3), [
|
||
'/x/startup-guard.log.1',
|
||
'/x/startup-guard.log.2',
|
||
'/x/startup-guard.log.3',
|
||
]);
|
||
});
|
||
|
||
test('appendGuardLog:超阈值滚存,最旧一份被丢弃,当前日志重新开始', () => {
|
||
const root = mkdtempSync(join(tmpdir(), 'na-guard-rotate-'));
|
||
const dataDir = join(root, 'data');
|
||
const logPath = join(dataDir, 'logs', 'startup-guard.log');
|
||
mkdirSync(join(dataDir, 'logs'), { recursive: true }); // 先造出超阈值的大文件,再交给 appendGuardLog 轮换
|
||
const maxBytes = 200;
|
||
|
||
// 三次超阈值写入 → .1 / .2 / .3 依次生成,且每次都留下一条记录
|
||
for (let i = 1; i <= 3; i += 1) {
|
||
writeFileSync(logPath, `${'x'.repeat(maxBytes + 10)}\n`);
|
||
appendGuardLog(dataDir, { action: 'probe', i }, { maxBytes, keep: 3 });
|
||
}
|
||
assert.ok(existsSync(`${logPath}.1`), '应生成 .1');
|
||
assert.ok(existsSync(`${logPath}.2`), '应生成 .2');
|
||
assert.ok(existsSync(`${logPath}.3`), '应生成 .3');
|
||
|
||
// 第四次:.3 被丢弃,.2→.3、.1→.2、当前→.1
|
||
writeFileSync(logPath, `${'y'.repeat(maxBytes + 10)}\n`);
|
||
appendGuardLog(dataDir, { action: 'probe', i: 4 }, { maxBytes, keep: 3 });
|
||
assert.ok(existsSync(`${logPath}.3`), '仍只有 3 份历史');
|
||
assert.ok(!existsSync(`${logPath}.4`), '不得无限增长出 .4');
|
||
|
||
// 当前日志只剩本次一行(可解析 JSON)
|
||
const lines = readFileSync(logPath, 'utf8').trim().split('\n');
|
||
assert.equal(lines.length, 1);
|
||
assert.equal(JSON.parse(lines[0]).i, 4);
|
||
|
||
// 未超阈值时只追加,不轮换
|
||
appendGuardLog(dataDir, { action: 'small' }, { maxBytes, keep: 3 });
|
||
assert.equal(readFileSync(logPath, 'utf8').trim().split('\n').length, 2);
|
||
|
||
rmSync(root, { recursive: true, force: true });
|
||
});
|