Files
Genarrative/scripts/ops/pingora-cutover-evidence-audit.mjs
T
kdletters e3258f6f1d 完善 Pingora 直连切换门禁
新增 Pingora shadow env 回切脚本与对应检查。

补齐直连证据包时间线和 cutoverRunId 审计门禁。

支持 Gitea Host 透传并更新直连多域名文档。

修复百分号编码静态图标路径并补 smoke 覆盖。

更新生产发布与运维护栏对 Pingora 发布包的校验。
2026-06-18 21:10:45 +08:00

2036 lines
67 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
import { execFile } from 'node:child_process';
import { constants as fsConstants } from 'node:fs';
import { access, lstat, readdir, readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const DEFAULT_EVIDENCE_ROOT = '/var/log/genarrative/pingora-cutover-evidence';
const DEFAULT_CUTOVER_TIMELINE_MAX_SPAN_MS = 24 * 60 * 60 * 1000;
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const CUTOVER_TIMELINE_ORDER = [
{ type: 'phase', phase: 'pre-cutover' },
{
type: 'command',
phase: 'enable-apply',
commandName: 'pingora-direct-enable-apply',
},
{
type: 'command',
phase: 'post-enable',
commandName: 'pingora-health-patrol-direct-env-switch',
},
{ type: 'phase', phase: 'post-enable' },
{
type: 'command',
phase: 'rollback-prep',
commandName: 'pingora-gateway-shadow-env-switch',
},
{
type: 'command',
phase: 'rollback-prep',
commandName: 'pingora-health-patrol-nginx-env-switch',
},
{
type: 'command',
phase: 'rollback-apply',
commandName: 'pingora-direct-rollback-apply',
},
{ type: 'phase', phase: 'post-rollback' },
];
const config = parseArgs(process.argv.slice(2));
await validateReadOnlyDirectory(config.evidenceRoot, '--evidence-root');
await validateVerifierScript(config.verifyScript);
const discovery = await discoverEvidence(config.evidenceRoot, {
allowExtraRootEntries: config.allowExtraRootEntries,
});
const audit = await buildAudit(config, discovery);
console.log(`${JSON.stringify(audit, null, 2)}\n`);
if (!audit.ok) {
process.exit(1);
}
function parseArgs(argv) {
const result = {
evidenceRoot:
process.env.GENARRATIVE_PINGORA_CUTOVER_EVIDENCE_ROOT ||
DEFAULT_EVIDENCE_ROOT,
verifyScript:
process.env.GENARRATIVE_PINGORA_CUTOVER_EVIDENCE_VERIFY_SCRIPT ||
path.join(SCRIPT_DIR, 'pingora-cutover-evidence-verify.mjs'),
timelineMaxSpanMs: parsePositiveInteger(
process.env.GENARRATIVE_PINGORA_CUTOVER_EVIDENCE_TIMELINE_MAX_SPAN_MS ||
DEFAULT_CUTOVER_TIMELINE_MAX_SPAN_MS,
'GENARRATIVE_PINGORA_CUTOVER_EVIDENCE_TIMELINE_MAX_SPAN_MS',
),
requiredCutoverRunId:
process.env.GENARRATIVE_PINGORA_CUTOVER_RUN_ID || '',
allowExtraRootEntries: false,
requiredPhases: [],
requiredPhaseDirectLiveAccessLog: [],
requiredPhaseDirectLiveStaticHeaders: [],
requiredPhasePingoraEnvShadow: [],
requiredCommands: [],
requiredCommandExecutables: [],
requiredCommandArgs: [],
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case '-h':
case '--help':
printUsage();
process.exit(0);
break;
case '--evidence-root':
result.evidenceRoot = requireValue(argv, ++index, arg);
break;
case '--verify-script':
result.verifyScript = requireValue(argv, ++index, arg);
break;
case '--phase':
case '--require-phase':
result.requiredPhases.push(requireValue(argv, ++index, arg));
break;
case '--require-command':
result.requiredCommands.push(requireValue(argv, ++index, arg));
break;
case '--require-phase-direct-live-access-log':
result.requiredPhaseDirectLiveAccessLog.push(
requireValue(argv, ++index, arg),
);
break;
case '--require-phase-direct-live-static-headers':
result.requiredPhaseDirectLiveStaticHeaders.push(
requireValue(argv, ++index, arg),
);
break;
case '--require-phase-pingora-env-shadow':
result.requiredPhasePingoraEnvShadow.push(
requireValue(argv, ++index, arg),
);
break;
case '--require-command-executable':
result.requiredCommandExecutables.push(requireValue(argv, ++index, arg));
break;
case '--require-command-arg':
result.requiredCommandArgs.push(requireValue(argv, ++index, arg));
break;
case '--timeline-max-span-ms':
result.timelineMaxSpanMs = parsePositiveInteger(
requireValue(argv, ++index, arg),
'--timeline-max-span-ms',
);
break;
case '--require-cutover-run-id':
result.requiredCutoverRunId = requireValue(argv, ++index, arg);
break;
case '--allow-extra-root-entries':
result.allowExtraRootEntries = true;
break;
default:
throw new Error(`未知参数: ${arg}`);
}
}
for (const [label, value] of [
['--evidence-root', result.evidenceRoot],
['--verify-script', result.verifyScript],
]) {
if (!path.isAbsolute(value)) {
throw new Error(`${label} 必须是绝对路径。`);
}
if (isFilesystemRootPath(value)) {
throw new Error(`${label} 不能是文件系统根目录。`);
}
validateNoControlCharacters(value, label);
}
result.requiredPhases = normalizeRequiredPhases(result.requiredPhases);
result.requiredPhaseDirectLiveAccessLog = normalizeRequiredPhases(
result.requiredPhaseDirectLiveAccessLog,
);
result.requiredPhaseDirectLiveStaticHeaders = normalizeRequiredPhases(
result.requiredPhaseDirectLiveStaticHeaders,
);
result.requiredPhasePingoraEnvShadow = normalizeRequiredPhases(
result.requiredPhasePingoraEnvShadow,
);
const requiredCommands = normalizeRequiredCommands(result.requiredCommands);
result.requiredCommandExecutables = normalizeRequiredCommandExecutables(
result.requiredCommandExecutables,
);
result.requiredCommandArgs = normalizeRequiredCommandArgs(
result.requiredCommandArgs,
);
result.requiredCommands = mergeRequiredCommands(
requiredCommands,
result.requiredCommandExecutables,
result.requiredCommandArgs,
);
if (result.requiredCutoverRunId) {
validateSafeCutoverRunId(
result.requiredCutoverRunId,
'--require-cutover-run-id',
);
}
return result;
}
function printUsage() {
console.log(`Usage:
node scripts/ops/pingora-cutover-evidence-audit.mjs [options]
Options:
--evidence-root <path> 证据根目录,默认 ${DEFAULT_EVIDENCE_ROOT}
--verify-script <path> 证据验真脚本,默认同目录 pingora-cutover-evidence-verify.mjs。
--require-phase <name> 要求并验真某个阶段的最新证据;可重复。--phase 是别名。
--require-command <phase>:<commandName>
要求并验真某个切换命令证据;可重复。
--require-phase-direct-live-access-log <phase>
要求某个阶段 manifest.summary.directLiveAccessLog 已存在且 direct live request_id access log 对账成功;正式切换默认用于 post-enable。
--require-phase-direct-live-static-headers <phase>
要求某个阶段 manifest.summary.directLiveStaticHeaders 已存在且包含可判定的 direct live 静态响应头摘要;正式切换默认用于 post-enable。
--require-phase-pingora-env-shadow <phase>
要求某个阶段 manifest.summary.pingoraEnvShadow 已存在且证明 Pingora env 已恢复 shadow 高端口;正式切换默认用于 post-rollback。
--require-command-executable <phase>:<commandName>:<absolutePath>
要求某个命令证据的 manifest.expectedExecutable、manifest.command.executable 和 command-record.json executable 与该绝对路径一致;可重复。
--require-command-arg <phase>:<commandName>:<arg>
要求某个命令证据的 manifest.command.args 和 command-record.json args 都包含该参数;可重复。
--timeline-max-span-ms <ms>
标准五段切换时间线允许的最大跨度,默认 ${DEFAULT_CUTOVER_TIMELINE_MAX_SPAN_MS}ms,可用 GENARRATIVE_PINGORA_CUTOVER_EVIDENCE_TIMELINE_MAX_SPAN_MS 覆盖。
--require-cutover-run-id <id>
只接受 manifest.cutoverRunId 与该 ID 一致的阶段和命令证据,避免混入其它切换批次。
--allow-extra-root-entries
允许证据根目录中存在非证据目录条目;默认拒绝,正式切换归档不应使用。
该脚本只读扫描 Pingora 直连切换证据根目录,按 manifest.phase 找到每个阶段的最新证据目录,也可按 manifest.phase + manifest.commandName 找到真实切换命令证据,并调用随包证据验真脚本的 --require-summary-ok 严格模式校验 schemaVersion=1 manifest.files 的 sizeBytes / sha256 与 manifest.summary.status=OK;所有候选证据都必须带合法 manifest.generatedAt,最新证据选择和标准时间线证明只使用该字段,不用目录 mtime 兜底;若同一阶段或同一命令的最新 manifest.generatedAt 重复,则拒绝按目录名打平并要求重新归档或清理证据根目录;命令证据的 manifest.command 与 command-record.json 也必须是 schemaVersion=1;可用 --require-phase-direct-live-access-log 要求指定阶段必须带 direct live access log 对账摘要,防止旧 post-enable 证据包缺少 request_id 反查复盘入口;可用 --require-phase-direct-live-static-headers 要求指定阶段必须带 direct live 静态响应头摘要,防止旧 post-enable 证据包缺少 Cache-Control、ETag、Last-Modified、Range 和 304 复盘入口;可用 --require-phase-pingora-env-shadow 要求指定阶段必须带 Pingora env shadow 摘要,防止旧 post-rollback 证据包无法证明 active env 已从低端口 direct 恢复到 127.0.0.1:18081;证据根目录默认只能包含带 manifest.json 的证据目录,不允许夹带普通文件、无 manifest 目录或符号链接;不会修改证据目录、不会 reload systemd、不会访问 Nginx 或 Pingora。
`);
}
function requireValue(argv, index, flag) {
const value = argv[index];
if (value === undefined || value.startsWith('--')) {
throw new Error(`${flag} 缺少参数值`);
}
return value;
}
function normalizeRequiredPhases(phases) {
const seen = new Set();
const normalized = [];
for (const phase of phases) {
const text = String(phase || '').trim();
validateSafePhase(text, '--require-phase');
if (!seen.has(text)) {
seen.add(text);
normalized.push(text);
}
}
return normalized;
}
function normalizeRequiredCommands(commands) {
const seen = new Set();
const normalized = [];
for (const command of commands) {
const text = String(command || '').trim();
const parts = text.split(':');
if (parts.length !== 2 || !parts[0] || !parts[1]) {
throw new Error(
'--require-command 必须使用 <phase>:<commandName> 格式。',
);
}
const [phase, commandName] = parts;
validateSafePhase(phase, '--require-command phase');
validateSafeCommandName(commandName, '--require-command commandName');
const key = `${phase}:${commandName}`;
if (!seen.has(key)) {
seen.add(key);
normalized.push({ phase, commandName });
}
}
return normalized;
}
function normalizeRequiredCommandExecutables(commandExecutables) {
const seen = new Map();
const normalized = [];
for (const item of commandExecutables) {
const text = String(item || '').trim();
const parts = text.split(':');
if (parts.length < 3 || !parts[0] || !parts[1]) {
throw new Error(
'--require-command-executable 必须使用 <phase>:<commandName>:<absolutePath> 格式。',
);
}
const [phase, commandName, ...pathParts] = parts;
const executable = pathParts.join(':');
validateSafePhase(phase, '--require-command-executable phase');
validateSafeCommandName(
commandName,
'--require-command-executable commandName',
);
if (!path.isAbsolute(executable)) {
throw new Error('--require-command-executable executable 必须是绝对路径。');
}
if (isFilesystemRootPath(executable)) {
throw new Error(
'--require-command-executable executable 不能是文件系统根目录。',
);
}
if (/[\0\r\n]/u.test(executable)) {
throw new Error(
'--require-command-executable executable 不能包含换行或 NUL 字符。',
);
}
const key = `${phase}:${commandName}`;
const previousExecutable = seen.get(key);
if (previousExecutable && previousExecutable !== executable) {
throw new Error(
'--require-command-executable 不能为同一个 <phase>:<commandName> 指定多个不同路径。',
);
}
if (!previousExecutable) {
seen.set(key, executable);
normalized.push({ phase, commandName, executable });
}
}
return normalized;
}
function normalizeRequiredCommandArgs(commandArgs) {
const seen = new Set();
const normalized = [];
for (const item of commandArgs) {
const text = String(item || '').trim();
const parts = text.split(':');
if (parts.length < 3 || !parts[0] || !parts[1]) {
throw new Error(
'--require-command-arg 必须使用 <phase>:<commandName>:<arg> 格式。',
);
}
const [phase, commandName, ...argParts] = parts;
const requiredArg = argParts.join(':');
validateSafePhase(phase, '--require-command-arg phase');
validateSafeCommandName(commandName, '--require-command-arg commandName');
validateRequiredCommandArg(requiredArg, '--require-command-arg arg');
const key = `${phase}:${commandName}:${requiredArg}`;
if (!seen.has(key)) {
seen.add(key);
normalized.push({ phase, commandName, arg: requiredArg });
}
}
return normalized;
}
function mergeRequiredCommands(
requiredCommands,
requiredCommandExecutables,
requiredCommandArgs,
) {
const seen = new Set();
const merged = [];
for (const command of [
...requiredCommands,
...requiredCommandExecutables,
...requiredCommandArgs,
]) {
const key = `${command.phase}:${command.commandName}`;
if (!seen.has(key)) {
seen.add(key);
merged.push({
phase: command.phase,
commandName: command.commandName,
});
}
}
return merged;
}
function findRequiredCommandExecutable(requiredCommandExecutables, command) {
return (
requiredCommandExecutables.find(
(item) =>
item.phase === command.phase && item.commandName === command.commandName,
)?.executable || null
);
}
function findRequiredCommandArgs(requiredCommandArgs, command) {
return requiredCommandArgs
.filter(
(item) =>
item.phase === command.phase && item.commandName === command.commandName,
)
.map((item) => item.arg);
}
function validateSafePhase(value, label) {
if (!isSafePhase(value)) {
throw new Error(
`${label} 只能包含 ASCII 字母、数字、点、下划线或短横线。`,
);
}
}
function validateSafeCommandName(value, label) {
if (!isSafePhase(value)) {
throw new Error(
`${label} 只能包含 ASCII 字母、数字、点、下划线或短横线。`,
);
}
}
function validateSafeCutoverRunId(value, label) {
if (!isSafePhase(value)) {
throw new Error(
`${label} 只能包含 ASCII 字母、数字、点、下划线或短横线。`,
);
}
}
function validateRequiredCommandArg(value, label) {
if (
typeof value !== 'string' ||
value.length === 0 ||
/[\0\r\n]/u.test(value)
) {
throw new Error(`${label} 必须是非空且不包含换行的字符串。`);
}
}
function isSafePhase(value) {
return /^[0-9A-Za-z._-]+$/u.test(String(value || ''));
}
function parsePositiveInteger(value, label) {
const text = String(value || '').trim();
if (!/^[1-9]\d*$/u.test(text)) {
throw new Error(`${label} 必须是正整数。`);
}
const parsed = Number(text);
if (!Number.isSafeInteger(parsed)) {
throw new Error(`${label} 超出 JavaScript 安全整数范围。`);
}
return parsed;
}
async function validateReadOnlyDirectory(dirPath, label) {
const stats = await lstat(dirPath);
if (stats.isSymbolicLink()) {
throw new Error(`${label} 不能是符号链接: ${dirPath}`);
}
if (!stats.isDirectory()) {
throw new Error(`${label} 必须是目录: ${dirPath}`);
}
await access(dirPath, fsConstants.R_OK | fsConstants.X_OK);
}
async function validateVerifierScript(scriptPath) {
const stats = await lstat(scriptPath);
if (stats.isSymbolicLink()) {
throw new Error(`--verify-script 不能是符号链接: ${scriptPath}`);
}
if (!stats.isFile()) {
throw new Error(`--verify-script 必须是文件: ${scriptPath}`);
}
await access(scriptPath, fsConstants.R_OK);
}
async function discoverEvidence(evidenceRoot, options = {}) {
const allowExtraRootEntries = Boolean(options.allowExtraRootEntries);
const entries = await readdir(evidenceRoot, { withFileTypes: true });
const candidates = [];
const diagnostics = [];
for (const entry of entries) {
const entryPath = path.join(evidenceRoot, entry.name);
const linkStats = await lstat(entryPath);
if (linkStats.isSymbolicLink()) {
if (!allowExtraRootEntries) {
diagnostics.push({
path: entry.name,
status: 'CRITICAL',
reason: '证据根目录下不能包含符号链接条目。',
});
}
continue;
}
if (!linkStats.isDirectory()) {
if (!allowExtraRootEntries) {
diagnostics.push({
path: entry.name,
status: 'CRITICAL',
reason: linkStats.isFile()
? '证据根目录只能包含证据目录,发现普通文件。'
: '证据根目录只能包含证据目录,发现非目录条目。',
});
}
continue;
}
const manifestPath = path.join(entryPath, 'manifest.json');
let manifestStats;
try {
manifestStats = await lstat(manifestPath);
} catch (error) {
if (error?.code === 'ENOENT') {
if (!allowExtraRootEntries) {
diagnostics.push({
path: entry.name,
status: 'CRITICAL',
reason: '证据根目录只能包含带 manifest.json 的证据目录。',
});
}
} else {
diagnostics.push({
path: entry.name,
status: 'CRITICAL',
reason: `读取 manifest 状态失败: ${error.message}`,
});
}
continue;
}
if (manifestStats.isSymbolicLink()) {
diagnostics.push({
path: entry.name,
manifestPath,
status: 'CRITICAL',
reason: 'manifest 不能是符号链接。',
});
continue;
}
if (!manifestStats.isFile()) {
diagnostics.push({
path: entry.name,
manifestPath,
status: 'CRITICAL',
reason: 'manifest 必须是普通文件。',
});
continue;
}
const manifest = await readJsonManifest(manifestPath, entry.name, diagnostics);
if (!manifest) {
continue;
}
if (manifest.schemaVersion !== 1) {
diagnostics.push({
path: entry.name,
manifestPath,
status: 'CRITICAL',
reason: 'manifest.schemaVersion 必须是 1。',
});
continue;
}
if (!manifest.phase || typeof manifest.phase !== 'string') {
diagnostics.push({
path: entry.name,
manifestPath,
status: 'CRITICAL',
reason: 'manifest 缺少 phase 字符串。',
});
continue;
}
if (!isSafePhase(manifest.phase)) {
diagnostics.push({
path: entry.name,
manifestPath,
status: 'CRITICAL',
reason:
'manifest.phase 只能包含 ASCII 字母、数字、点、下划线或短横线。',
});
continue;
}
const topLevelCommandName =
manifest.commandName === undefined ? null : manifest.commandName;
const embeddedCommandName =
manifest.command?.name === undefined ? null : manifest.command.name;
if (
topLevelCommandName !== null &&
(typeof topLevelCommandName !== 'string' ||
!isSafePhase(topLevelCommandName))
) {
diagnostics.push({
path: entry.name,
manifestPath,
status: 'CRITICAL',
reason:
'manifest.commandName 只能包含 ASCII 字母、数字、点、下划线或短横线。',
});
continue;
}
if (
embeddedCommandName !== null &&
(typeof embeddedCommandName !== 'string' ||
!isSafePhase(embeddedCommandName))
) {
diagnostics.push({
path: entry.name,
manifestPath,
status: 'CRITICAL',
reason:
'manifest.command.name 只能包含 ASCII 字母、数字、点、下划线或短横线。',
});
continue;
}
if (
topLevelCommandName !== null &&
embeddedCommandName !== null &&
topLevelCommandName !== embeddedCommandName
) {
diagnostics.push({
path: entry.name,
manifestPath,
status: 'CRITICAL',
reason: `manifest.commandName 与 manifest.command.name 必须一致,manifest.commandName=${formatNullable(topLevelCommandName)}manifest.command.name=${formatNullable(embeddedCommandName)}。`,
});
continue;
}
const commandName = topLevelCommandName || embeddedCommandName;
const cutoverRunId =
manifest.cutoverRunId === undefined ? null : manifest.cutoverRunId;
if (
cutoverRunId !== null &&
(typeof cutoverRunId !== 'string' || !isSafePhase(cutoverRunId))
) {
diagnostics.push({
path: entry.name,
manifestPath,
status: 'CRITICAL',
reason:
'manifest.cutoverRunId 只能包含 ASCII 字母、数字、点、下划线或短横线。',
});
continue;
}
const topLevelExpectedExecutable =
manifest.expectedExecutable === undefined
? null
: manifest.expectedExecutable;
const embeddedExpectedExecutable =
manifest.command?.expectedExecutable === undefined
? null
: manifest.command.expectedExecutable;
const expectedExecutableDiagnostics = [
...validateOptionalAbsolutePath(
topLevelExpectedExecutable,
'manifest.expectedExecutable',
),
...validateOptionalAbsolutePath(
embeddedExpectedExecutable,
'manifest.command.expectedExecutable',
),
];
if (expectedExecutableDiagnostics.length > 0) {
diagnostics.push({
path: entry.name,
manifestPath,
status: 'CRITICAL',
reason: expectedExecutableDiagnostics.join(' '),
});
continue;
}
const generatedAt = normalizeIsoTime(manifest.generatedAt);
if (!generatedAt) {
diagnostics.push({
path: entry.name,
manifestPath,
status: 'CRITICAL',
reason:
'manifest.generatedAt 必须是合法 ISO 时间;证据最新选择和时间线证明不能依赖目录 mtime。',
});
continue;
}
candidates.push({
phase: manifest.phase,
cutoverRunId,
bundleDir: entryPath,
manifestPath,
directoryName: entry.name,
generatedAt,
summaryStatus: manifest.summary?.status || null,
summaryExitCode: manifest.summary?.exitCode ?? null,
summarySignal: manifest.summary?.signal ?? null,
summaryDirectLiveAccessLog: manifest.summary?.directLiveAccessLog ?? null,
summaryDirectLiveStaticHeaders:
manifest.summary?.directLiveStaticHeaders ?? null,
summaryPingoraEnvShadow: manifest.summary?.pingoraEnvShadow ?? null,
commandName,
manifestCommand: manifest.command || null,
commandRecordFileName: manifest.files?.commandRecord?.path || null,
stdoutFileName: manifest.files?.stdout?.path || null,
stderrFileName: manifest.files?.stderr?.path || null,
commandStartedAt: normalizeIsoTime(manifest.command?.startedAt),
commandFinishedAt: normalizeIsoTime(manifest.command?.finishedAt),
commandDurationMs: manifest.command?.durationMs ?? null,
expectedExecutable:
topLevelExpectedExecutable ?? embeddedExpectedExecutable ?? null,
executable: manifest.command?.executable || null,
});
}
return { candidates, diagnostics };
}
function validateOptionalAbsolutePath(value, label) {
if (value === null) {
return [];
}
return validateRequiredAbsolutePath(value, label);
}
function validateRequiredAbsolutePath(value, label) {
if (typeof value !== 'string' || value.length === 0) {
return [`${label} 必须是绝对路径字符串。`];
}
if (!path.isAbsolute(value)) {
return [`${label} 必须是绝对路径。`];
}
if (isFilesystemRootPath(value)) {
return [`${label} 不能是文件系统根目录。`];
}
if (/[\0\r\n]/u.test(value)) {
return [`${label} 不能包含换行或 NUL 字符。`];
}
return [];
}
async function readJsonManifest(manifestPath, directoryName, diagnostics) {
let content;
try {
content = await readFile(manifestPath, 'utf8');
} catch (error) {
diagnostics.push({
path: directoryName,
manifestPath,
status: 'CRITICAL',
reason: `读取 manifest 失败: ${error.message}`,
});
return null;
}
try {
const manifest = JSON.parse(content);
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
diagnostics.push({
path: directoryName,
manifestPath,
status: 'CRITICAL',
reason: 'manifest 顶层必须是 JSON object。',
});
return null;
}
return manifest;
} catch (error) {
diagnostics.push({
path: directoryName,
manifestPath,
status: 'CRITICAL',
reason: `manifest 不是合法 JSON: ${error.message}`,
});
return null;
}
}
async function buildAudit(config, discovery) {
const phasesToCheck =
config.requiredPhases.length > 0
? config.requiredPhases
: config.requiredCommands.length > 0
? []
: sortedUnique(
discovery.candidates
.filter((candidate) => candidate.commandName === null)
.map((candidate) => candidate.phase),
);
const phases = [];
for (const phase of phasesToCheck) {
const requiredAccessLog = config.requiredPhaseDirectLiveAccessLog.includes(
phase,
);
const requiredStaticHeaders = config.requiredPhaseDirectLiveStaticHeaders.includes(
phase,
);
const requiredPingoraEnvShadow = config.requiredPhasePingoraEnvShadow.includes(
phase,
);
const allPhaseCandidates = discovery.candidates.filter(
(candidate) => candidate.phase === phase && candidate.commandName === null,
);
const candidates = filterCandidatesByCutoverRunId(
allPhaseCandidates,
config.requiredCutoverRunId,
)
.sort(compareEvidenceCandidates);
if (candidates.length === 0) {
phases.push({
phase,
status: 'MISSING',
candidateCount: 0,
allCandidateCount: allPhaseCandidates.length,
requiredCutoverRunId: config.requiredCutoverRunId || null,
requiredDirectLiveAccessLog: requiredAccessLog,
requiredDirectLiveStaticHeaders: requiredStaticHeaders,
requiredPingoraEnvShadow,
directLiveAccessLog: null,
directLiveStaticHeaders: null,
pingoraEnvShadow: null,
diagnostics: [
missingEvidenceDiagnostic(
'该阶段',
config.requiredCutoverRunId,
allPhaseCandidates.length,
),
],
});
continue;
}
const latestSelection = selectUniqueLatestEvidence(candidates);
if (!latestSelection.ok) {
phases.push({
phase,
status: 'AMBIGUOUS_LATEST',
candidateCount: candidates.length,
allCandidateCount: allPhaseCandidates.length,
requiredCutoverRunId: config.requiredCutoverRunId || null,
generatedAt: latestSelection.generatedAt,
requiredDirectLiveAccessLog: requiredAccessLog,
requiredDirectLiveStaticHeaders: requiredStaticHeaders,
requiredPingoraEnvShadow,
directLiveAccessLog: null,
directLiveStaticHeaders: null,
pingoraEnvShadow: null,
ambiguousBundleDirs: latestSelection.ambiguousBundleDirs,
diagnostics: latestSelection.diagnostics,
});
continue;
}
const latest = latestSelection.latest;
const verify = await runVerifier(config.verifyScript, latest.bundleDir);
const manifestCheck = checkPhaseManifest(latest, {
requiredAccessLog,
requiredStaticHeaders,
requiredPingoraEnvShadow,
});
phases.push({
phase,
status: phaseStatus(verify, manifestCheck),
candidateCount: candidates.length,
latestBundleDir: latest.bundleDir,
manifestPath: latest.manifestPath,
cutoverRunId: latest.cutoverRunId,
requiredCutoverRunId: config.requiredCutoverRunId || null,
generatedAt: latest.generatedAt,
summaryStatus: latest.summaryStatus,
requiredDirectLiveAccessLog: requiredAccessLog,
requiredDirectLiveStaticHeaders: requiredStaticHeaders,
requiredPingoraEnvShadow,
directLiveAccessLog: latest.summaryDirectLiveAccessLog,
directLiveStaticHeaders: latest.summaryDirectLiveStaticHeaders,
pingoraEnvShadow: latest.summaryPingoraEnvShadow,
commandName: latest.commandName,
verify,
manifestCheck,
diagnostics: [
...(verify.ok ? [] : ['最新证据目录 manifest 验真失败。']),
...manifestCheck.diagnostics,
],
});
}
const commands = [];
for (const requiredCommand of config.requiredCommands) {
const allCommandCandidates = discovery.candidates.filter(
(candidate) =>
candidate.phase === requiredCommand.phase &&
candidate.commandName === requiredCommand.commandName,
);
const candidates = filterCandidatesByCutoverRunId(
allCommandCandidates,
config.requiredCutoverRunId,
)
.sort(compareEvidenceCandidates);
if (candidates.length === 0) {
commands.push({
phase: requiredCommand.phase,
commandName: requiredCommand.commandName,
status: 'MISSING',
candidateCount: 0,
allCandidateCount: allCommandCandidates.length,
requiredCutoverRunId: config.requiredCutoverRunId || null,
diagnostics: [
missingEvidenceDiagnostic(
'该切换命令',
config.requiredCutoverRunId,
allCommandCandidates.length,
),
],
});
continue;
}
const latestSelection = selectUniqueLatestEvidence(candidates);
if (!latestSelection.ok) {
commands.push({
phase: requiredCommand.phase,
commandName: requiredCommand.commandName,
status: 'AMBIGUOUS_LATEST',
candidateCount: candidates.length,
allCandidateCount: allCommandCandidates.length,
requiredCutoverRunId: config.requiredCutoverRunId || null,
generatedAt: latestSelection.generatedAt,
ambiguousBundleDirs: latestSelection.ambiguousBundleDirs,
diagnostics: latestSelection.diagnostics,
});
continue;
}
const latest = latestSelection.latest;
const verify = await runVerifier(config.verifyScript, latest.bundleDir);
const expectedExecutable = findRequiredCommandExecutable(
config.requiredCommandExecutables,
requiredCommand,
);
const requiredArgs = findRequiredCommandArgs(
config.requiredCommandArgs,
requiredCommand,
);
const manifestCheck = await checkCommandManifest(
latest,
expectedExecutable,
requiredArgs,
);
commands.push({
phase: requiredCommand.phase,
commandName: requiredCommand.commandName,
status: commandStatus(verify, manifestCheck),
candidateCount: candidates.length,
latestBundleDir: latest.bundleDir,
manifestPath: latest.manifestPath,
cutoverRunId: latest.cutoverRunId,
requiredCutoverRunId: config.requiredCutoverRunId || null,
generatedAt: latest.generatedAt,
summaryStatus: latest.summaryStatus,
summaryExitCode: latest.summaryExitCode,
summarySignal: latest.summarySignal,
expectedExecutable: latest.expectedExecutable,
executable: latest.executable,
commandRecordPath: manifestCheck.commandRecordPath,
stdoutPath: manifestCheck.stdoutPath,
stderrPath: manifestCheck.stderrPath,
commandStartedAt: manifestCheck.commandRecordStartedAt,
commandFinishedAt: manifestCheck.commandRecordFinishedAt,
commandDurationMs: manifestCheck.commandRecordDurationMs,
requiredExecutable: expectedExecutable,
requiredArgs,
verify,
manifestCheck,
diagnostics: [
...(verify.ok ? [] : ['最新命令证据目录 manifest 验真失败。']),
...manifestCheck.diagnostics,
],
});
}
const criticalDiagnostics = discovery.diagnostics.filter(
(item) => item.status === 'CRITICAL',
);
const timeline = buildTimelineAudit(
phases,
commands,
config.timelineMaxSpanMs,
);
const failedPhases = phases.filter((phase) => phase.status !== 'OK');
const failedCommands = commands.filter((command) => command.status !== 'OK');
const ok =
phases.length + commands.length > 0 &&
failedPhases.length === 0 &&
failedCommands.length === 0 &&
criticalDiagnostics.length === 0 &&
timeline.ok;
return {
ok,
generatedAt: new Date().toISOString(),
evidenceRoot: config.evidenceRoot,
verifyScript: config.verifyScript,
requiredCutoverRunId: config.requiredCutoverRunId || null,
allowExtraRootEntries: config.allowExtraRootEntries,
timelineMaxSpanMs: config.timelineMaxSpanMs,
requiredPhases: config.requiredPhases,
requiredCommands: config.requiredCommands,
requiredCommandExecutables: config.requiredCommandExecutables,
requiredCommandArgs: config.requiredCommandArgs,
discoveredCount: discovery.candidates.length,
checkedCount: phases.length + commands.length,
failedCount:
failedPhases.length +
failedCommands.length +
criticalDiagnostics.length +
timeline.failedCount,
diagnostics: discovery.diagnostics,
summary: buildOperatorSummary({
ok,
phases,
commands,
criticalDiagnostics,
timeline,
checkedCount: phases.length + commands.length,
failedCount:
failedPhases.length +
failedCommands.length +
criticalDiagnostics.length +
timeline.failedCount,
}),
timeline,
phases,
commands,
};
}
function buildOperatorSummary(input) {
const pingoraEnvShadowEvidence = input.phases
.filter((phase) => phase.requiredPingoraEnvShadow)
.map((phase) => {
const summary = phase.pingoraEnvShadow ?? null;
const firstDiagnostic = phase.diagnostics?.[0] ?? null;
return {
phase: phase.phase,
status: phase.status,
latestBundleDir: phase.latestBundleDir ?? null,
manifestPath: phase.manifestPath ?? null,
cutoverRunId: phase.cutoverRunId ?? null,
generatedAt: phase.generatedAt ?? null,
shadow: {
required: true,
ok: phase.manifestCheck?.pingoraEnvShadowCheck?.ok === true,
reason:
phase.manifestCheck?.pingoraEnvShadowCheck?.reason ??
firstDiagnostic,
summary,
},
};
});
const directLiveEvidence = input.phases
.filter(
(phase) =>
phase.requiredDirectLiveAccessLog ||
phase.requiredDirectLiveStaticHeaders,
)
.map((phase) => {
const accessLogRequired = phase.requiredDirectLiveAccessLog === true;
const staticHeadersRequired =
phase.requiredDirectLiveStaticHeaders === true;
const accessLogSummary = phase.directLiveAccessLog ?? null;
const staticHeadersSummary = phase.directLiveStaticHeaders ?? null;
const accessLogChecked = accessLogRequired || accessLogSummary !== null;
const staticHeadersChecked =
staticHeadersRequired || staticHeadersSummary !== null;
const firstDiagnostic = phase.diagnostics?.[0] ?? null;
return {
phase: phase.phase,
status: phase.status,
latestBundleDir: phase.latestBundleDir ?? null,
manifestPath: phase.manifestPath ?? null,
cutoverRunId: phase.cutoverRunId ?? null,
generatedAt: phase.generatedAt ?? null,
accessLog: {
required: accessLogRequired,
ok: accessLogChecked
? phase.manifestCheck?.directLiveAccessLogCheck?.ok === true
: null,
reason: accessLogChecked
? phase.manifestCheck?.directLiveAccessLogCheck?.reason ??
firstDiagnostic
: null,
summary: accessLogSummary,
},
staticHeaders: {
required: staticHeadersRequired,
ok: staticHeadersChecked
? phase.manifestCheck?.directLiveStaticHeadersCheck?.ok === true
: null,
reason: staticHeadersChecked
? phase.manifestCheck?.directLiveStaticHeadersCheck?.reason ??
firstDiagnostic
: null,
summary: staticHeadersSummary,
},
};
});
return {
status: input.ok ? 'OK' : 'CRITICAL',
checkedCount: input.checkedCount,
failedCount: input.failedCount,
failedItems: [
...input.criticalDiagnostics.map((diagnostic) => ({
type: 'evidence-root',
status: diagnostic.status,
path: diagnostic.path ?? null,
manifestPath: diagnostic.manifestPath ?? null,
reason: diagnostic.reason ?? null,
})),
...input.phases
.filter((phase) => phase.status !== 'OK')
.map((phase) => ({
type: 'phase',
phase: phase.phase,
status: phase.status,
latestBundleDir: phase.latestBundleDir ?? null,
manifestPath: phase.manifestPath ?? null,
cutoverRunId: phase.cutoverRunId ?? null,
generatedAt: phase.generatedAt ?? null,
diagnostics: phase.diagnostics ?? [],
})),
...input.commands
.filter((command) => command.status !== 'OK')
.map((command) => ({
type: 'command',
phase: command.phase,
commandName: command.commandName,
status: command.status,
latestBundleDir: command.latestBundleDir ?? null,
manifestPath: command.manifestPath ?? null,
cutoverRunId: command.cutoverRunId ?? null,
generatedAt: command.generatedAt ?? null,
diagnostics: command.diagnostics ?? [],
})),
...(input.timeline.ok
? []
: [
{
type: 'timeline',
status: 'FAILED',
diagnostics: input.timeline.diagnostics ?? [],
failureBreakdown: input.timeline.failureBreakdown ?? null,
spanMs: input.timeline.spanMs ?? null,
maxSpanMs: input.timeline.maxSpanMs ?? null,
},
]),
],
pingoraEnvShadowEvidence,
directLiveEvidence,
timeline: {
checked: input.timeline.checked,
ok: input.timeline.ok,
failedCount: input.timeline.failedCount,
spanMs: input.timeline.spanMs,
maxSpanMs: input.timeline.maxSpanMs,
firstGeneratedAt: input.timeline.firstGeneratedAt,
lastGeneratedAt: input.timeline.lastGeneratedAt,
},
};
}
function filterCandidatesByCutoverRunId(candidates, requiredCutoverRunId) {
if (!requiredCutoverRunId) {
return candidates;
}
return candidates.filter(
(candidate) => candidate.cutoverRunId === requiredCutoverRunId,
);
}
function missingEvidenceDiagnostic(label, requiredCutoverRunId, allCandidateCount) {
if (!requiredCutoverRunId) {
return `没有找到${label}的证据 manifest。`;
}
if (allCandidateCount > 0) {
return `没有找到${label}且 cutoverRunId=${requiredCutoverRunId} 的证据 manifest;已有同名证据属于其它切换批次或缺少 cutoverRunId。`;
}
return `没有找到${label}的证据 manifest,无法满足 cutoverRunId=${requiredCutoverRunId}。`;
}
function selectUniqueLatestEvidence(candidates) {
const latest = candidates.at(-1);
const latestGeneratedAt = latest?.generatedAt || null;
const ambiguousCandidates = candidates.filter(
(candidate) => candidate.generatedAt === latestGeneratedAt,
);
if (ambiguousCandidates.length > 1) {
const ambiguousBundleDirs = ambiguousCandidates.map(
(candidate) => candidate.bundleDir,
);
return {
ok: false,
latest: null,
generatedAt: latestGeneratedAt,
ambiguousBundleDirs,
diagnostics: [
`最新证据 manifest.generatedAt=${latestGeneratedAt} 出现重复,不能按目录名打平选择;请重新归档或清理证据根目录。`,
`重复 generatedAt 的证据目录: ${ambiguousBundleDirs.join(', ')}`,
],
};
}
return {
ok: true,
latest,
generatedAt: latestGeneratedAt,
ambiguousBundleDirs: [],
diagnostics: [],
};
}
function checkPhaseManifest(candidate, options = {}) {
const {
requiredAccessLog = false,
requiredStaticHeaders = false,
requiredPingoraEnvShadow = false,
} = options;
const diagnostics = [];
if (candidate.commandName !== null) {
diagnostics.push(
`最新阶段证据不能带 manifest.commandName,实际为 ${formatNullable(candidate.commandName)}。`,
);
}
if (candidate.summaryStatus !== 'OK') {
diagnostics.push(
`最新阶段证据 manifest.summary.status 必须是 OK,实际为 ${formatNullable(candidate.summaryStatus)}。`,
);
}
const accessLogCheck = checkDirectLiveAccessLogSummary(
candidate.summaryDirectLiveAccessLog,
);
if (requiredAccessLog && !accessLogCheck.ok) {
diagnostics.push(
`最新阶段证据 manifest.summary.directLiveAccessLog 必须包含 direct live access log 对账摘要:${accessLogCheck.reason}`,
);
}
const staticHeadersCheck = checkDirectLiveStaticHeadersSummary(
candidate.summaryDirectLiveStaticHeaders,
);
if (requiredStaticHeaders && !staticHeadersCheck.ok) {
diagnostics.push(
`最新阶段证据 manifest.summary.directLiveStaticHeaders 必须包含 direct live 静态响应头摘要:${staticHeadersCheck.reason}`,
);
}
const pingoraEnvShadowCheck = checkPingoraEnvShadowSummary(
candidate.summaryPingoraEnvShadow,
);
if (requiredPingoraEnvShadow && !pingoraEnvShadowCheck.ok) {
diagnostics.push(
`最新阶段证据 manifest.summary.pingoraEnvShadow 必须证明 Pingora env 已恢复 shadow${pingoraEnvShadowCheck.reason}`,
);
}
return {
ok: diagnostics.length === 0,
expectedSummaryStatus: 'OK',
actualSummaryStatus: candidate.summaryStatus,
requiredDirectLiveAccessLog: requiredAccessLog,
requiredDirectLiveStaticHeaders: requiredStaticHeaders,
requiredPingoraEnvShadow,
directLiveAccessLogCheck: accessLogCheck,
directLiveStaticHeadersCheck: staticHeadersCheck,
pingoraEnvShadowCheck,
diagnostics,
};
}
function checkPingoraEnvShadowSummary(summary) {
if (!summary || typeof summary !== 'object' || Array.isArray(summary)) {
return { ok: false, reason: '字段缺失或不是对象。' };
}
const failures = [];
if (summary.present !== true) {
failures.push('present 必须为 true');
}
if (summary.ok !== true) {
failures.push('ok 必须为 true');
}
if (summary.listen !== '127.0.0.1:18081') {
failures.push(
`listen 必须是 127.0.0.1:18081,实际为 ${formatNullable(summary.listen)}`,
);
}
if (summary.tlsListen !== '') {
failures.push(`tlsListen 必须为空,实际为 ${formatNullable(summary.tlsListen)}`);
}
if (summary.httpRedirectListen !== '') {
failures.push(
`httpRedirectListen 必须为空,实际为 ${formatNullable(summary.httpRedirectListen)}`,
);
}
if (summary.tlsCertFile !== '') {
failures.push(
`tlsCertFile 必须为空,实际为 ${formatNullable(summary.tlsCertFile)}`,
);
}
if (summary.tlsKeyFile !== '') {
failures.push(
`tlsKeyFile 必须为空,实际为 ${formatNullable(summary.tlsKeyFile)}`,
);
}
if (summary.mode && summary.mode !== 'shadow') {
failures.push(`mode 必须是 shadow,实际为 ${formatNullable(summary.mode)}`);
}
if (summary.shadowReady === false) {
failures.push('shadowReady 必须为 true');
}
return failures.length === 0
? { ok: true, reason: null }
: { ok: false, reason: failures.join('') };
}
function checkDirectLiveAccessLogSummary(summary) {
if (!summary || typeof summary !== 'object' || Array.isArray(summary)) {
return { ok: false, reason: '字段缺失或不是对象。' };
}
if (summary.present !== true) {
return { ok: false, reason: 'present 必须为 true。' };
}
const checked = Number(summary.checked);
const matchedCount = Number(summary.matchedCount);
const missingCount = Number(summary.missingCount);
const mismatchCount = Number(summary.mismatchCount);
const failures = [];
if (!Number.isInteger(checked) || checked <= 0) {
failures.push('checked 必须是正整数');
}
if (!Number.isInteger(matchedCount) || matchedCount !== checked) {
failures.push('matchedCount 必须等于 checked');
}
if (!Number.isInteger(missingCount) || missingCount !== 0) {
failures.push('missingCount 必须为 0');
}
if (!Number.isInteger(mismatchCount) || mismatchCount !== 0) {
failures.push('mismatchCount 必须为 0');
}
return failures.length === 0
? { ok: true, reason: null }
: { ok: false, reason: failures.join('') };
}
function checkDirectLiveStaticHeadersSummary(summary) {
if (!summary || typeof summary !== 'object' || Array.isArray(summary)) {
return { ok: false, reason: '字段缺失或不是对象。' };
}
if (summary.present !== true) {
return { ok: false, reason: 'present 必须为 true。' };
}
if (summary.skipped) {
return {
ok: false,
reason: `静态检查被跳过:${formatNullable(summary.skipReason)}`,
};
}
const targets = [summary.normal, summary.fingerprinted].filter(
(target) => target && target.present,
);
if (targets.length === 0) {
return { ok: false, reason: '缺少 normal 或 fingerprinted 静态资产摘要。' };
}
const failures = [];
for (const [label, target] of [
['normal', summary.normal],
['fingerprinted', summary.fingerprinted],
]) {
if (!target || !target.present) {
continue;
}
if (target.ok !== true) {
failures.push(`${label}.ok 不是 true`);
}
for (const key of [
'cacheControl',
'etag',
'lastModified',
'acceptRanges',
'rangeContentRange',
]) {
if (typeof target[key] !== 'string' || target[key].length === 0) {
failures.push(`${label}.${key} 缺失`);
}
}
if (target.rangeStatusCode !== 206) {
failures.push(`${label}.rangeStatusCode 不是 206`);
}
if (target.etag304StatusCode !== 304) {
failures.push(`${label}.etag304StatusCode 不是 304`);
}
if (target.lastModified304StatusCode !== 304) {
failures.push(`${label}.lastModified304StatusCode 不是 304`);
}
}
return failures.length === 0
? { ok: true, reason: null }
: { ok: false, reason: failures.join('') };
}
async function checkCommandManifest(
candidate,
expectedExecutable = null,
requiredArgs = [],
) {
const diagnostics = [];
if (candidate.summaryStatus !== 'OK') {
diagnostics.push(
`最新命令证据 manifest.summary.status 必须是 OK,实际为 ${formatNullable(candidate.summaryStatus)}。`,
);
}
if (candidate.summaryExitCode !== 0) {
diagnostics.push(
`最新命令证据 manifest.summary.exitCode 必须是 0,实际为 ${formatNullable(candidate.summaryExitCode)}。`,
);
}
if (candidate.summarySignal !== null) {
diagnostics.push(
`最新命令证据 manifest.summary.signal 必须为空,实际为 ${formatNullable(candidate.summarySignal)}。`,
);
}
if (expectedExecutable) {
if (candidate.expectedExecutable !== expectedExecutable) {
diagnostics.push(
`最新命令证据 manifest.expectedExecutable 必须是 ${expectedExecutable},实际为 ${formatNullable(candidate.expectedExecutable)}。`,
);
}
if (candidate.executable !== expectedExecutable) {
diagnostics.push(
`最新命令证据 command.executable 必须是 ${expectedExecutable},实际为 ${formatNullable(candidate.executable)}。`,
);
}
}
diagnostics.push(...checkCommandOutputEvidenceFileNames(candidate));
const embeddedCommandCheck = checkCommandRecordObject(
candidate.manifestCommand,
'manifest.command',
candidate,
expectedExecutable,
requiredArgs,
);
diagnostics.push(...embeddedCommandCheck.diagnostics);
const commandRecordCheck = await readCommandRecord(candidate);
diagnostics.push(...commandRecordCheck.diagnostics);
if (commandRecordCheck.record) {
const fileCommandCheck = checkCommandRecordObject(
commandRecordCheck.record,
'command-record.json',
candidate,
expectedExecutable,
requiredArgs,
);
diagnostics.push(...fileCommandCheck.diagnostics);
if (embeddedCommandCheck.record) {
diagnostics.push(
...compareCommandRecords(
embeddedCommandCheck.record,
commandRecordCheck.record,
),
);
}
}
return {
ok: diagnostics.length === 0,
expectedSummaryStatus: 'OK',
actualSummaryStatus: candidate.summaryStatus,
expectedExitCode: 0,
actualExitCode: candidate.summaryExitCode,
expectedSignal: null,
actualSignal: candidate.summarySignal,
expectedExecutable,
actualExpectedExecutable: candidate.expectedExecutable,
actualExecutable: candidate.executable,
stdoutPath: candidate.stdoutFileName,
stderrPath: candidate.stderrFileName,
commandRecordStdoutPath: commandRecordCheck.record?.stdoutPath ?? null,
commandRecordStderrPath: commandRecordCheck.record?.stderrPath ?? null,
commandRecordPath: commandRecordCheck.path,
commandRecordReadOk: commandRecordCheck.ok,
commandRecordExpectedExecutable:
commandRecordCheck.record?.expectedExecutable ?? null,
commandRecordExecutable: commandRecordCheck.record?.executable ?? null,
commandRecordStartedAt: normalizeIsoTime(commandRecordCheck.record?.startedAt),
commandRecordFinishedAt: normalizeIsoTime(commandRecordCheck.record?.finishedAt),
commandRecordDurationMs: commandRecordCheck.record?.durationMs ?? null,
requiredArgs,
diagnostics,
};
}
function checkCommandOutputEvidenceFileNames(candidate) {
const diagnostics = [];
for (const [field, fileName] of [
['stdout', candidate.stdoutFileName],
['stderr', candidate.stderrFileName],
]) {
if (!fileName) {
diagnostics.push(
`最新命令证据 manifest.files.${field}.path 必须存在。`,
);
continue;
}
if (!isSafeEvidenceFileName(fileName)) {
diagnostics.push(
`最新命令证据 manifest.files.${field}.path 只能是证据目录内的普通文件名,实际为 ${formatNullable(fileName)}。`,
);
}
}
return diagnostics;
}
async function readCommandRecord(candidate) {
const fileName = candidate.commandRecordFileName;
if (!fileName) {
return {
ok: false,
path: null,
record: null,
diagnostics: ['最新命令证据 manifest.files.commandRecord.path 必须存在。'],
};
}
if (!isSafeEvidenceFileName(fileName)) {
return {
ok: false,
path: null,
record: null,
diagnostics: [
`最新命令证据 manifest.files.commandRecord.path 只能是证据目录内的普通文件名,实际为 ${formatNullable(fileName)}。`,
],
};
}
const recordPath = path.join(candidate.bundleDir, fileName);
try {
const stats = await lstat(recordPath);
if (stats.isSymbolicLink()) {
return {
ok: false,
path: recordPath,
record: null,
diagnostics: ['最新命令证据 command-record.json 不能是符号链接。'],
};
}
if (!stats.isFile()) {
return {
ok: false,
path: recordPath,
record: null,
diagnostics: ['最新命令证据 command-record.json 必须是普通文件。'],
};
}
const content = await readFile(recordPath, 'utf8');
const record = JSON.parse(content);
if (!record || typeof record !== 'object' || Array.isArray(record)) {
return {
ok: false,
path: recordPath,
record: null,
diagnostics: ['最新命令证据 command-record.json 顶层必须是 JSON object。'],
};
}
return { ok: true, path: recordPath, record, diagnostics: [] };
} catch (error) {
return {
ok: false,
path: recordPath,
record: null,
diagnostics: [
`读取最新命令证据 command-record.json 失败: ${error.message}`,
],
};
}
}
function checkCommandRecordObject(
record,
label,
candidate,
expectedExecutable,
requiredArgs = [],
) {
const diagnostics = [];
if (!record || typeof record !== 'object' || Array.isArray(record)) {
return {
ok: false,
record: null,
diagnostics: [`最新命令证据 ${label} 必须是 JSON object。`],
};
}
const checks = [
['schemaVersion', 1],
['phase', candidate.phase],
['name', candidate.commandName],
['exitCode', candidate.summaryExitCode],
['signal', candidate.summarySignal],
];
if (candidate.cutoverRunId !== null || record.cutoverRunId !== undefined) {
checks.push(['cutoverRunId', candidate.cutoverRunId]);
}
if (
candidate.expectedExecutable !== null ||
record.expectedExecutable !== undefined
) {
checks.push(['expectedExecutable', candidate.expectedExecutable]);
}
for (const [field, expected] of checks) {
const actual = record[field] ?? null;
if (actual !== expected) {
diagnostics.push(
`最新命令证据 ${label}.${field} 必须是 ${formatNullable(expected)},实际为 ${formatNullable(actual)}。`,
);
}
}
for (const [field, expected] of [
['stdoutPath', candidate.stdoutFileName],
['stderrPath', candidate.stderrFileName],
]) {
const actual = record[field] ?? null;
if (actual !== expected) {
diagnostics.push(
`最新命令证据 ${label}.${field} 必须是 ${formatNullable(expected)},实际为 ${formatNullable(actual)}。`,
);
}
}
diagnostics.push(...checkCommandRecordInvocation(record, label));
diagnostics.push(
...validateRequiredAbsolutePath(record.executable, `${label}.executable`),
);
const effectiveExpectedExecutable =
expectedExecutable || candidate.expectedExecutable;
if (effectiveExpectedExecutable) {
if (record.expectedExecutable !== effectiveExpectedExecutable) {
diagnostics.push(
`最新命令证据 ${label}.expectedExecutable 必须是 ${effectiveExpectedExecutable},实际为 ${formatNullable(record.expectedExecutable)}。`,
);
}
if (record.executable !== effectiveExpectedExecutable) {
diagnostics.push(
`最新命令证据 ${label}.executable 必须是 ${effectiveExpectedExecutable},实际为 ${formatNullable(record.executable)}。`,
);
}
}
diagnostics.push(...checkRequiredCommandArgs(record, label, requiredArgs));
diagnostics.push(...checkCommandRecordTimes(record, label, candidate));
return {
ok: diagnostics.length === 0,
record,
diagnostics,
};
}
function checkRequiredCommandArgs(record, label, requiredArgs) {
const diagnostics = [];
if (requiredArgs.length === 0) {
return diagnostics;
}
if (!Array.isArray(record.args)) {
diagnostics.push(
`最新命令证据 ${label}.args 必须是字符串数组,才能校验必需参数 ${formatNullable(requiredArgs)}。`,
);
return diagnostics;
}
for (const requiredArg of requiredArgs) {
if (!record.args.includes(requiredArg)) {
diagnostics.push(
`最新命令证据 ${label}.args 必须包含 ${formatNullable(requiredArg)}。`,
);
}
}
return diagnostics;
}
function checkCommandRecordInvocation(record, label) {
const diagnostics = [];
if (!Array.isArray(record.args)) {
diagnostics.push(`最新命令证据 ${label}.args 必须是字符串数组。`);
} else if (record.args.some((item) => typeof item !== 'string')) {
diagnostics.push(`最新命令证据 ${label}.args 必须只包含字符串。`);
} else if (record.args.some((item) => /[\0\r\n]/u.test(item))) {
diagnostics.push(
`最新命令证据 ${label}.args 不能包含换行或 NUL 字符。`,
);
}
if (typeof record.command !== 'string' || record.command.length === 0) {
diagnostics.push(`最新命令证据 ${label}.command 必须是非空字符串。`);
}
if (typeof record.cwd !== 'string' || !path.isAbsolute(record.cwd)) {
diagnostics.push(`最新命令证据 ${label}.cwd 必须是绝对路径字符串。`);
}
if (record.error !== null && typeof record.error !== 'string') {
diagnostics.push(`最新命令证据 ${label}.error 必须是字符串或 null。`);
}
return diagnostics;
}
function checkCommandRecordTimes(record, label, candidate) {
const diagnostics = [];
const startedAt = normalizeIsoTime(record.startedAt);
const finishedAt = normalizeIsoTime(record.finishedAt);
if (!startedAt) {
diagnostics.push(`最新命令证据 ${label}.startedAt 必须是合法 ISO 时间。`);
}
if (!finishedAt) {
diagnostics.push(`最新命令证据 ${label}.finishedAt 必须是合法 ISO 时间。`);
}
if (startedAt && finishedAt) {
const startedMs = Date.parse(startedAt);
const finishedMs = Date.parse(finishedAt);
if (finishedMs < startedMs) {
diagnostics.push(
`最新命令证据 ${label}.finishedAt 不能早于 startedAt。`,
);
}
if (candidate.generatedAt) {
const generatedMs = Date.parse(candidate.generatedAt);
if (generatedMs < finishedMs) {
diagnostics.push(
`最新命令证据 manifest.generatedAt 不能早于 ${label}.finishedAt。`,
);
}
}
const durationMs = record.durationMs;
if (!Number.isSafeInteger(durationMs) || durationMs < 0) {
diagnostics.push(
`最新命令证据 ${label}.durationMs 必须是非负安全整数。`,
);
} else {
const actualDurationMs = finishedMs - startedMs;
if (durationMs !== actualDurationMs) {
diagnostics.push(
`最新命令证据 ${label}.durationMs 必须等于 finishedAt - startedAt,实际记录 ${durationMs}ms,计算值 ${actualDurationMs}ms。`,
);
}
}
}
return diagnostics;
}
function compareCommandRecords(embeddedRecord, fileRecord) {
const diagnostics = [];
const scalarFields = [
'schemaVersion',
'name',
'phase',
'cutoverRunId',
'expectedExecutable',
'executable',
'command',
'cwd',
'error',
'stdoutPath',
'stderrPath',
'exitCode',
'signal',
];
for (const field of scalarFields) {
const embedded = embeddedRecord[field] ?? null;
const file = fileRecord[field] ?? null;
if (embedded !== file) {
diagnostics.push(
`最新命令证据 manifest.command.${field} 与 command-record.json.${field} 必须一致,manifest=${formatNullable(embedded)}command-record=${formatNullable(file)}。`,
);
}
}
for (const field of ['args']) {
const embedded = embeddedRecord[field] ?? null;
const file = fileRecord[field] ?? null;
if (!jsonValuesEqual(embedded, file)) {
diagnostics.push(
`最新命令证据 manifest.command.${field} 与 command-record.json.${field} 必须一致,manifest=${formatNullable(embedded)}command-record=${formatNullable(file)}。`,
);
}
}
return diagnostics;
}
function jsonValuesEqual(left, right) {
return JSON.stringify(left) === JSON.stringify(right);
}
function phaseStatus(verify, manifestCheck) {
if (!manifestCheck.ok) {
return 'MANIFEST_FAILED';
}
if (!verify.ok) {
return 'VERIFY_FAILED';
}
return 'OK';
}
function commandStatus(verify, manifestCheck) {
if (!manifestCheck.ok) {
return 'MANIFEST_FAILED';
}
if (!verify.ok) {
return 'VERIFY_FAILED';
}
return 'OK';
}
function formatNullable(value) {
return value === null || value === undefined ? '<missing>' : JSON.stringify(value);
}
function isSafeEvidenceFileName(value) {
return (
typeof value === 'string' &&
value.length > 0 &&
!/[\0\r\n]/u.test(value) &&
!path.isAbsolute(value) &&
!value.includes('/') &&
!value.includes('\\') &&
value !== '.' &&
value !== '..'
);
}
function buildTimelineAudit(phases, commands, timelineMaxSpanMs) {
const selected = [];
const missing = [];
for (const expected of CUTOVER_TIMELINE_ORDER) {
const item =
expected.type === 'phase'
? phases.find((phase) => phase.phase === expected.phase)
: commands.find(
(command) =>
command.phase === expected.phase &&
command.commandName === expected.commandName,
);
if (!item) {
missing.push(expected);
continue;
}
selected.push({
...expected,
generatedAt: item.generatedAt,
latestBundleDir: item.latestBundleDir,
manifestPath: item.manifestPath,
cutoverRunId: item.cutoverRunId,
status: item.status,
});
}
if (selected.length === 0 || missing.length > 0) {
return {
ok: true,
checked: false,
failedCount: 0,
reason:
selected.length === 0
? '未要求标准直连切换时间线证据。'
: '仅在标准直连切换时间线的阶段和命令证据都被要求时检查顺序。',
maxSpanMs: timelineMaxSpanMs,
spanMs: null,
firstGeneratedAt: null,
lastGeneratedAt: null,
expectedOrder: CUTOVER_TIMELINE_ORDER,
missing,
items: selected,
diagnostics: [],
};
}
const diagnostics = [];
const failureBreakdown = {
nonOkItems: 0,
missingGeneratedAt: 0,
cutoverRunIdMismatch: 0,
outOfOrder: 0,
spanExceeded: 0,
};
const nonOkItems = selected.filter((item) => item.status !== 'OK');
failureBreakdown.nonOkItems = nonOkItems.length;
if (nonOkItems.length > 0) {
diagnostics.push(
`标准切换时间线包含非 OK 证据: ${nonOkItems
.map((item) => `${formatTimelineItem(item)}=${item.status}`)
.join(', ')}。`,
);
}
const timelineCutoverRunIds = selected.map((item) => ({
item: formatTimelineItem(item),
cutoverRunId: item.cutoverRunId,
}));
const hasDeclaredCutoverRunId = timelineCutoverRunIds.some(
(item) => item.cutoverRunId !== null,
);
const distinctCutoverRunIds = new Set(
timelineCutoverRunIds.map((item) => item.cutoverRunId ?? '<missing>'),
);
if (hasDeclaredCutoverRunId && distinctCutoverRunIds.size > 1) {
failureBreakdown.cutoverRunIdMismatch = 1;
diagnostics.push(
`标准切换时间线包含不同 cutoverRunId 或缺少 cutoverRunId: ${timelineCutoverRunIds
.map((item) => `${item.item}=${formatNullable(item.cutoverRunId)}`)
.join(', ')},疑似混入不同切换批次证据。`,
);
}
const parsedTimes = [];
for (const item of selected) {
if (!item.generatedAt) {
failureBreakdown.missingGeneratedAt += 1;
diagnostics.push(
`${formatTimelineItem(item)} 缺少合法 manifest.generatedAt,无法证明切换时间线。`,
);
parsedTimes.push(null);
continue;
}
parsedTimes.push(Date.parse(item.generatedAt));
}
for (let index = 1; index < selected.length; index += 1) {
const previous = selected[index - 1];
const current = selected[index];
const previousTime = parsedTimes[index - 1];
const currentTime = parsedTimes[index];
if (
previousTime !== null &&
currentTime !== null &&
currentTime < previousTime
) {
failureBreakdown.outOfOrder += 1;
diagnostics.push(
`${formatTimelineItem(current)} 的 manifest.generatedAt 早于 ${formatTimelineItem(previous)},疑似混入不同切换窗口证据。`,
);
}
}
let firstGeneratedAt = null;
let lastGeneratedAt = null;
let spanMs = null;
if (parsedTimes.every((time) => time !== null && !Number.isNaN(time))) {
const firstTime = parsedTimes[0];
const lastTime = parsedTimes.at(-1);
firstGeneratedAt = selected[0].generatedAt;
lastGeneratedAt = selected.at(-1).generatedAt;
spanMs = lastTime - firstTime;
if (spanMs > timelineMaxSpanMs) {
failureBreakdown.spanExceeded = 1;
diagnostics.push(
`标准切换时间线从 ${firstGeneratedAt}${lastGeneratedAt} 的时间跨度 ${spanMs}ms 超过 ${timelineMaxSpanMs}ms,疑似混入不同切换窗口证据。`,
);
}
}
const timelineFailedCount = Object.values(failureBreakdown).reduce(
(sum, count) => sum + count,
0,
);
return {
ok: diagnostics.length === 0,
checked: true,
failedCount: timelineFailedCount,
failureBreakdown,
maxSpanMs: timelineMaxSpanMs,
spanMs,
firstGeneratedAt,
lastGeneratedAt,
cutoverRunIds: timelineCutoverRunIds,
expectedOrder: CUTOVER_TIMELINE_ORDER,
items: selected,
diagnostics,
};
}
function formatTimelineItem(item) {
if (item.type === 'command') {
return `${item.phase}:${item.commandName}`;
}
return item.phase;
}
function compareEvidenceCandidates(left, right) {
const leftTime = Date.parse(left.generatedAt);
const rightTime = Date.parse(right.generatedAt);
if (leftTime !== rightTime) {
return leftTime - rightTime;
}
return left.directoryName.localeCompare(right.directoryName);
}
function sortedUnique(values) {
return [...new Set(values)].sort((left, right) => left.localeCompare(right));
}
function normalizeIsoTime(value) {
if (
!value ||
typeof value !== 'string' ||
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u.test(value)
) {
return null;
}
const time = Date.parse(value);
if (Number.isNaN(time)) {
return null;
}
const normalized = new Date(time).toISOString();
return normalized === value ? normalized : null;
}
async function runVerifier(verifyScript, bundleDir) {
const result = await execFileJson(process.execPath, [
'--',
verifyScript,
'--bundle-dir',
bundleDir,
'--require-summary-ok',
]);
const parsed = parseJsonOutput(result.stdout);
return {
ok:
result.exitCode === 0 &&
parsed.ok &&
parsed.value?.ok === true &&
parsed.value?.requireSummaryOk === true,
exitCode: result.exitCode,
stdoutJson: parsed.ok,
checkedCount: parsed.value?.checkedCount ?? null,
failedCount: parsed.value?.failedCount ?? null,
requireSummaryOk: parsed.value?.requireSummaryOk ?? null,
summary: parsed.value?.summary ?? null,
stderr: result.stderr,
error: result.error,
};
}
function execFileJson(command, args) {
return new Promise((resolve) => {
execFile(
command,
args,
{
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024,
shell: false,
},
(error, stdout, stderr) => {
resolve({
exitCode:
typeof error?.code === 'number' ? error.code : error ? 1 : 0,
stdout: stdout || '',
stderr: stderr || '',
error: error?.message || null,
});
},
);
});
}
function parseJsonOutput(stdout) {
try {
return { ok: true, value: JSON.parse(stdout) };
} catch {
return { ok: false, value: null };
}
}
function isFilesystemRootPath(value) {
const resolved = path.resolve(String(value));
return resolved === path.parse(resolved).root;
}
function validateNoControlCharacters(value, label) {
if (/[\0\r\n]/u.test(String(value))) {
throw new Error(`${label} 不能包含换行或 NUL 字符。`);
}
}