e9c3dc1120
保留 SpacetimeDB 历史表、迁移白名单与旧业务源码 切换前端 active 入口并解除旧创作页面和路由编译链 移除旧后端路由、worker 与纯业务 crate 依赖 收敛 SpacetimeDB 模块为历史数据壳 同步 Nginx、Pingora、验证门禁与架构文档
2013 lines
62 KiB
JavaScript
2013 lines
62 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { spawnSync } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import {
|
|
chmodSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
rmSync,
|
|
statSync,
|
|
symlinkSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
const BUNDLE_SCRIPT = 'scripts/ops/pingora-cutover-evidence-bundle.mjs';
|
|
const failures = [];
|
|
const tmpRoot = mkdtempSync(
|
|
path.join(tmpdir(), 'genarrative-pingora-cutover-evidence-'),
|
|
);
|
|
|
|
try {
|
|
main();
|
|
} finally {
|
|
rmSync(tmpRoot, { recursive: true, force: true });
|
|
}
|
|
|
|
if (failures.length > 0) {
|
|
console.error('[check:pingora-cutover-evidence-bundle] FAILED');
|
|
for (const failure of failures) {
|
|
console.error(`- ${failure}`);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log('[check:pingora-cutover-evidence-bundle] OK');
|
|
|
|
function main() {
|
|
assertScriptShape();
|
|
assertBundleSucceedsAndWritesManifest();
|
|
assertBundleSummarizesPingoraEnvShadow();
|
|
assertBundleSummarizesDirectPingoraEnvAsNotShadow();
|
|
assertBundleWritesCutoverRunId();
|
|
assertBundleCanArchiveDirectLiveEvidence();
|
|
assertDirectLiveFailureStillWritesEvidenceAndFails();
|
|
assertDirectLiveMissingAccessLogSummaryFails();
|
|
assertDirectLiveMissingStaticHeadersSummaryFails();
|
|
assertDirectLiveStaticHeaderDiagnosticsFails();
|
|
assertDirectLiveParseFailureWritesParseErrorEvidence();
|
|
assertBundleRedactsProbeTokensFromArtifacts();
|
|
assertCriticalSnapshotStillWritesEvidenceAndFails();
|
|
assertSnapshotParseFailureWritesParseErrorEvidence();
|
|
assertRejectsUnsafePhase();
|
|
assertRejectsUnsafeCutoverRunId();
|
|
assertRejectsSnapshotArgsWithControlCharacters();
|
|
assertRejectsDirectLiveArgsWithControlCharacters();
|
|
assertRejectsRelativePaths();
|
|
assertRejectsFilesystemRootPaths();
|
|
assertRejectsSymlinkOutputRootBeforeSnapshot();
|
|
assertRejectsSymlinkOutputRootParentBeforeSnapshot();
|
|
assertRejectsFileOutputRootBeforeSnapshot();
|
|
assertRejectsInvalidTimeout();
|
|
assertRejectsInvalidBoolEnv();
|
|
}
|
|
|
|
function assertScriptShape() {
|
|
const content = readFileSync(BUNDLE_SCRIPT, 'utf8');
|
|
assertIncludes(
|
|
content,
|
|
'只写 --output-root 下的新证据目录',
|
|
'证据包脚本 usage 必须说明只写证据目录。',
|
|
);
|
|
assertIncludes(
|
|
content,
|
|
'BUNDLE_DIR_MODE = 0o750',
|
|
'证据包脚本必须显式固定新证据目录权限。',
|
|
);
|
|
assertIncludes(
|
|
content,
|
|
'EVIDENCE_FILE_MODE = 0o640',
|
|
'证据包脚本必须显式固定证据文件权限。',
|
|
);
|
|
assertIncludes(
|
|
content,
|
|
"flag: 'wx'",
|
|
'证据包脚本写证据文件时必须避免覆盖既有文件。',
|
|
);
|
|
assertIncludes(
|
|
content,
|
|
'validatePhase(config.phase);',
|
|
'证据包脚本必须校验 phase,避免目录名和 manifest 阶段漂移。',
|
|
);
|
|
assertIncludes(
|
|
content,
|
|
'cutoverRunId',
|
|
'证据包脚本必须支持写入切换批次 ID。',
|
|
);
|
|
assertIncludes(
|
|
content,
|
|
'--cutover-run-id',
|
|
'证据包脚本 usage 必须公开切换批次 ID 参数。',
|
|
);
|
|
assertIncludes(
|
|
content,
|
|
'validateOutputRootForWriting(outputRoot);',
|
|
'证据包脚本写入前必须校验 output-root 路径安全。',
|
|
);
|
|
assertIncludes(
|
|
content,
|
|
'--output-root 不能是符号链接',
|
|
'证据包脚本必须拒绝符号链接 output-root。',
|
|
);
|
|
assertIncludes(
|
|
content,
|
|
'--output-root 已存在但不是目录',
|
|
'证据包脚本必须拒绝非目录 output-root。',
|
|
);
|
|
assertIncludes(
|
|
content,
|
|
'snapshot.stdout.txt',
|
|
'证据包必须保存快照 stdout。',
|
|
);
|
|
assertIncludes(
|
|
content,
|
|
'direct-live.stdout.txt',
|
|
'证据包必须能保存 direct live stdout。',
|
|
);
|
|
assertIncludes(
|
|
content,
|
|
'parseDirectLiveJson',
|
|
'证据包必须能从 direct live stdout 中提取 JSON 结果。',
|
|
);
|
|
assertIncludes(
|
|
content,
|
|
'pingoraEnvShadow',
|
|
'证据包 manifest 必须提升 Pingora env shadow 摘要。',
|
|
);
|
|
assertIncludes(
|
|
content,
|
|
'--run-direct-live',
|
|
'证据包必须暴露 direct live 归档开关。',
|
|
);
|
|
assertIncludes(
|
|
content,
|
|
'manifest.json',
|
|
'证据包必须写 manifest。',
|
|
);
|
|
assertIncludes(
|
|
content,
|
|
'SECRET_VALUE_FLAGS',
|
|
'证据包命令记录必须集中维护敏感参数脱敏列表。',
|
|
);
|
|
assertIncludes(
|
|
content,
|
|
'args: redactSecretArgs',
|
|
'证据包命令记录必须保存结构化脱敏参数数组。',
|
|
);
|
|
assertIncludes(
|
|
content,
|
|
'<redacted>',
|
|
'证据包命令记录必须用 <redacted> 隐藏敏感参数值。',
|
|
);
|
|
if (
|
|
content.includes("execFile('systemctl'") ||
|
|
content.includes('execFile("systemctl"') ||
|
|
content.includes('nginx -s reload')
|
|
) {
|
|
failures.push('证据包脚本不应直接操作 systemd 或 Nginx。');
|
|
}
|
|
}
|
|
|
|
function assertBundleSucceedsAndWritesManifest() {
|
|
const fixture = prepareFixture('ok');
|
|
const result = runBundle(fixture, {
|
|
status: 'OK',
|
|
extraArgs: [
|
|
'--phase',
|
|
'post-enable',
|
|
'--expected-gateway-mode',
|
|
'pingora-direct',
|
|
'--expected-public-base-url',
|
|
'https://127.0.0.1',
|
|
'--expected-public-host',
|
|
'example.com',
|
|
'--run-health-patrol',
|
|
'--require-pingora-gateway',
|
|
'--fail-on-critical',
|
|
],
|
|
});
|
|
|
|
assertStatus(result, 0, 'OK 快照应生成证据包并成功退出。');
|
|
if (result.status !== 0) {
|
|
return;
|
|
}
|
|
const output = parseJson(result.stdout, 'OK 证据包 stdout');
|
|
assertEqual(output.phase, 'post-enable', '证据包 stdout 必须记录阶段。');
|
|
assertEqual(output.status, 'OK', '证据包 stdout 必须记录 OK 状态。');
|
|
assertFileExists(output.bundleDir, '证据包目录必须存在。');
|
|
|
|
const manifest = readJson(path.join(output.bundleDir, 'manifest.json'));
|
|
assertEqual(manifest.phase, 'post-enable', 'manifest 必须记录阶段。');
|
|
assertEqual(manifest.summary.status, 'OK', 'manifest 必须记录快照状态。');
|
|
assertEqual(
|
|
manifest.summary.pingoraEnvShadow?.ok,
|
|
true,
|
|
'manifest 必须记录 Pingora env 已处于 shadow 高端口。',
|
|
);
|
|
assertEqual(
|
|
manifest.commands?.[0]?.exitCode,
|
|
0,
|
|
'manifest 必须记录快照命令退出码。',
|
|
);
|
|
assertIncludes(
|
|
manifest.commands?.[0]?.command || '',
|
|
'--run-health-patrol',
|
|
'manifest 命令必须记录快照执行参数。',
|
|
);
|
|
assertIncludes(
|
|
manifest.commands?.[0]?.command || '',
|
|
'--require-pingora-gateway',
|
|
'manifest 命令必须记录 Pingora 发布物自审要求。',
|
|
);
|
|
assertEqual(
|
|
manifest.commands?.[0]?.executable,
|
|
'node',
|
|
'manifest 命令记录必须保存可执行文件。',
|
|
);
|
|
assertIncludes(
|
|
manifest.commands?.[0]?.args || [],
|
|
'--require-pingora-gateway',
|
|
'manifest 命令记录必须保存结构化参数数组。',
|
|
);
|
|
assertManifestFileMetadata(
|
|
output.bundleDir,
|
|
manifest.files?.snapshot,
|
|
'snapshot.json',
|
|
);
|
|
assertManifestFileMetadata(
|
|
output.bundleDir,
|
|
manifest.files?.snapshotStdout,
|
|
'snapshot.stdout.txt',
|
|
);
|
|
assertManifestFileMetadata(
|
|
output.bundleDir,
|
|
manifest.files?.snapshotStderr,
|
|
'snapshot.stderr.txt',
|
|
);
|
|
assertManifestFileMetadata(
|
|
output.bundleDir,
|
|
manifest.files?.snapshotCommand,
|
|
'snapshot-command.json',
|
|
);
|
|
assertFileExists(
|
|
path.join(output.bundleDir, 'snapshot.json'),
|
|
'证据包必须保存解析后的 snapshot.json。',
|
|
);
|
|
assertFileExists(
|
|
path.join(output.bundleDir, 'snapshot.stdout.txt'),
|
|
'证据包必须保存 snapshot stdout。',
|
|
);
|
|
assertFileExists(
|
|
path.join(output.bundleDir, 'snapshot.stderr.txt'),
|
|
'证据包必须保存 snapshot stderr。',
|
|
);
|
|
assertFileExists(
|
|
path.join(output.bundleDir, 'snapshot-command.json'),
|
|
'证据包必须保存快照命令记录。',
|
|
);
|
|
assertMode(output.bundleDir, 0o750, '证据包目录权限必须是 0750。');
|
|
for (const fileName of [
|
|
'manifest.json',
|
|
'snapshot.json',
|
|
'snapshot.stdout.txt',
|
|
'snapshot.stderr.txt',
|
|
'snapshot-command.json',
|
|
]) {
|
|
assertMode(
|
|
path.join(output.bundleDir, fileName),
|
|
0o640,
|
|
`${fileName} 权限必须是 0640。`,
|
|
);
|
|
}
|
|
const snapshot = readJson(path.join(output.bundleDir, 'snapshot.json'));
|
|
assertIncludes(
|
|
snapshot.args,
|
|
'--expected-gateway-mode',
|
|
'证据包必须把 expected gateway mode 透传给状态快照。',
|
|
);
|
|
assertIncludes(
|
|
snapshot.args,
|
|
'--require-pingora-gateway',
|
|
'证据包必须把 Pingora 发布物自审要求透传给状态快照。',
|
|
);
|
|
assertFileUnchanged(
|
|
fixture.healthEnvFile,
|
|
fixture.originalHealthEnvText,
|
|
'证据包不应改写 health patrol env。',
|
|
);
|
|
const commandRecord = readJson(path.join(output.bundleDir, 'snapshot-command.json'));
|
|
assertEqual(
|
|
commandRecord.executable,
|
|
'node',
|
|
'snapshot-command.json 必须保存可执行文件。',
|
|
);
|
|
assertIncludes(
|
|
commandRecord.args || [],
|
|
'--expected-gateway-mode',
|
|
'snapshot-command.json 必须保存结构化参数数组。',
|
|
);
|
|
}
|
|
|
|
function assertBundleSummarizesPingoraEnvShadow() {
|
|
const fixture = prepareFixture('pingora-env-shadow-summary');
|
|
const result = runBundle(fixture, {
|
|
status: 'OK',
|
|
extraArgs: ['--phase', 'post-rollback', '--fail-on-critical'],
|
|
});
|
|
|
|
assertStatus(result, 0, 'shadow env 快照应生成 OK 摘要。');
|
|
if (result.status !== 0) {
|
|
return;
|
|
}
|
|
const output = parseJson(result.stdout, 'shadow env 证据包 stdout');
|
|
const manifest = readJson(path.join(output.bundleDir, 'manifest.json'));
|
|
assertEqual(
|
|
manifest.summary.pingoraEnvShadow?.present,
|
|
true,
|
|
'manifest.summary.pingoraEnvShadow 必须标记 env 摘要存在。',
|
|
);
|
|
assertEqual(
|
|
manifest.summary.pingoraEnvShadow?.listen,
|
|
'127.0.0.1:18081',
|
|
'manifest.summary.pingoraEnvShadow 必须记录 shadow listen。',
|
|
);
|
|
assertEqual(
|
|
manifest.summary.pingoraEnvShadow?.tlsListen,
|
|
'',
|
|
'manifest.summary.pingoraEnvShadow 必须记录 TLS 低端口为空。',
|
|
);
|
|
assertEqual(
|
|
manifest.summary.pingoraEnvShadow?.httpRedirectListen,
|
|
'',
|
|
'manifest.summary.pingoraEnvShadow 必须记录 HTTP redirect 低端口为空。',
|
|
);
|
|
assertEqual(
|
|
manifest.summary.pingoraEnvShadow?.tlsCertFile,
|
|
'',
|
|
'manifest.summary.pingoraEnvShadow 必须记录 TLS cert 路径为空。',
|
|
);
|
|
assertEqual(
|
|
manifest.summary.pingoraEnvShadow?.tlsKeyFile,
|
|
'',
|
|
'manifest.summary.pingoraEnvShadow 必须记录 TLS key 路径为空。',
|
|
);
|
|
assertEqual(
|
|
manifest.summary.pingoraEnvShadow?.mode,
|
|
'shadow',
|
|
'manifest.summary.pingoraEnvShadow 必须记录 snapshot 判定的 shadow 姿态。',
|
|
);
|
|
assertEqual(
|
|
manifest.summary.pingoraEnvShadow?.shadowReady,
|
|
true,
|
|
'manifest.summary.pingoraEnvShadow 必须记录 shadowReady=true。',
|
|
);
|
|
assertEqual(
|
|
manifest.summary.pingoraEnvShadow?.ok,
|
|
true,
|
|
'manifest.summary.pingoraEnvShadow.ok 必须为 true。',
|
|
);
|
|
}
|
|
|
|
function assertBundleSummarizesDirectPingoraEnvAsNotShadow() {
|
|
const fixture = prepareFixture('pingora-env-direct-summary', {
|
|
snapshotPingoraEnvValues: {
|
|
listen: '127.0.0.1:18081',
|
|
tlsListen: '0.0.0.0:443',
|
|
httpRedirectListen: '0.0.0.0:80',
|
|
tlsCertFile: '/etc/genarrative/pingora-tls/example/fullchain.pem',
|
|
tlsKeyFile: '/etc/genarrative/pingora-tls/example/privkey.pem',
|
|
},
|
|
});
|
|
const result = runBundle(fixture, {
|
|
status: 'OK',
|
|
extraArgs: ['--phase', 'post-enable', '--fail-on-critical'],
|
|
});
|
|
|
|
assertStatus(
|
|
result,
|
|
0,
|
|
'direct env 快照默认只生成摘要,不应让证据包本身失败。',
|
|
);
|
|
if (result.status !== 0) {
|
|
return;
|
|
}
|
|
const output = parseJson(result.stdout, 'direct env 证据包 stdout');
|
|
const manifest = readJson(path.join(output.bundleDir, 'manifest.json'));
|
|
assertEqual(
|
|
manifest.summary.pingoraEnvShadow?.ok,
|
|
false,
|
|
'direct 低端口 env 摘要必须标记 ok=false。',
|
|
);
|
|
assertIncludes(
|
|
manifest.summary.pingoraEnvShadow?.diagnostics || [],
|
|
'tlsListen 应为空,实际 0.0.0.0:443',
|
|
'direct 低端口 env 摘要必须记录 TLS 低端口诊断。',
|
|
);
|
|
assertIncludes(
|
|
manifest.summary.pingoraEnvShadow?.diagnostics || [],
|
|
'httpRedirectListen 应为空,实际 0.0.0.0:80',
|
|
'direct 低端口 env 摘要必须记录 HTTP redirect 低端口诊断。',
|
|
);
|
|
assertIncludes(
|
|
manifest.summary.pingoraEnvShadow?.diagnostics || [],
|
|
'tlsCertFile 应为空,实际 /etc/genarrative/pingora-tls/example/fullchain.pem',
|
|
'direct 低端口 env 摘要必须记录 TLS cert 残留诊断。',
|
|
);
|
|
assertEqual(
|
|
manifest.summary.pingoraEnvShadow?.mode,
|
|
'direct',
|
|
'direct 低端口 env 摘要必须记录 snapshot 判定的 direct 姿态。',
|
|
);
|
|
}
|
|
|
|
function assertBundleWritesCutoverRunId() {
|
|
const fixture = prepareFixture('cutover-run-id');
|
|
const result = runBundle(fixture, {
|
|
status: 'OK',
|
|
extraArgs: [
|
|
'--phase',
|
|
'pre-cutover',
|
|
'--cutover-run-id',
|
|
'cutover-20260617T010000Z',
|
|
'--fail-on-critical',
|
|
],
|
|
});
|
|
|
|
assertStatus(result, 0, '提供 cutover run id 时证据包应成功退出。');
|
|
if (result.status !== 0) {
|
|
return;
|
|
}
|
|
const output = parseJson(result.stdout, 'cutover run id 证据包 stdout');
|
|
assertEqual(
|
|
output.cutoverRunId,
|
|
'cutover-20260617T010000Z',
|
|
'证据包 stdout 必须返回 cutoverRunId。',
|
|
);
|
|
const manifest = readJson(path.join(output.bundleDir, 'manifest.json'));
|
|
assertEqual(
|
|
manifest.cutoverRunId,
|
|
'cutover-20260617T010000Z',
|
|
'manifest 必须记录 cutoverRunId。',
|
|
);
|
|
}
|
|
|
|
function assertBundleCanArchiveDirectLiveEvidence() {
|
|
const fixture = prepareFixture('direct-live-ok', {
|
|
directLiveStatus: 'OK',
|
|
});
|
|
const result = runBundle(fixture, {
|
|
status: 'OK',
|
|
extraArgs: directLiveArgs(fixture, [
|
|
'--phase',
|
|
'post-enable',
|
|
'--expected-gateway-mode',
|
|
'pingora-direct',
|
|
'--expected-public-base-url',
|
|
'https://127.0.0.1',
|
|
'--expected-public-host',
|
|
'example.com',
|
|
'--run-health-patrol',
|
|
'--require-pingora-gateway',
|
|
'--fail-on-critical',
|
|
]),
|
|
});
|
|
|
|
assertStatus(result, 0, 'direct live 成功时证据包应成功退出。');
|
|
if (result.status !== 0) {
|
|
return;
|
|
}
|
|
const output = parseJson(result.stdout, 'direct live 证据包 stdout');
|
|
assertFileExists(output.directLivePath, '证据包 stdout 必须返回 direct live JSON 路径。');
|
|
const manifest = readJson(path.join(output.bundleDir, 'manifest.json'));
|
|
assertEqual(
|
|
manifest.summary.directLiveStatus,
|
|
'OK',
|
|
'manifest 必须记录 direct live 状态。',
|
|
);
|
|
assertEqual(
|
|
manifest.summary.directLiveAccessLog?.checked,
|
|
2,
|
|
'manifest 必须记录 direct live access log 检查请求数。',
|
|
);
|
|
assertEqual(
|
|
manifest.summary.directLiveAccessLog?.matchedCount,
|
|
2,
|
|
'manifest 必须记录 direct live access log 匹配数量。',
|
|
);
|
|
assertEqual(
|
|
manifest.summary.directLiveAccessLog?.missingCount,
|
|
0,
|
|
'manifest 必须记录 direct live access log 缺失数量。',
|
|
);
|
|
assertEqual(
|
|
manifest.summary.directLiveAccessLog?.mismatchCount,
|
|
0,
|
|
'manifest 必须记录 direct live access log 漂移数量。',
|
|
);
|
|
assertEqual(
|
|
manifest.summary.directLiveStaticHeaders?.normal?.cacheControl,
|
|
'no-cache',
|
|
'manifest 必须提升普通静态资源 Cache-Control 证据。',
|
|
);
|
|
assertEqual(
|
|
manifest.summary.directLiveStaticHeaders?.normal?.rangeContentRange,
|
|
'bytes 0-0/27',
|
|
'manifest 必须提升普通静态资源 Content-Range 证据。',
|
|
);
|
|
assertEqual(
|
|
manifest.summary.directLiveStaticHeaders?.normal?.etag304StatusCode,
|
|
304,
|
|
'manifest 必须提升普通静态资源 ETag 304 证据。',
|
|
);
|
|
assertEqual(
|
|
manifest.summary.directLiveStaticHeaders?.normal?.lastModified304StatusCode,
|
|
304,
|
|
'manifest 必须提升普通静态资源 Last-Modified 304 证据。',
|
|
);
|
|
assertEqual(
|
|
manifest.summary.directLiveStaticHeaders?.fingerprinted?.cacheControl,
|
|
'public, max-age=31536000, immutable',
|
|
'manifest 必须提升指纹静态资源 Cache-Control 证据。',
|
|
);
|
|
assertEqual(
|
|
manifest.summary.directLiveStaticHeaders?.fingerprinted?.rangeContentRange,
|
|
'bytes 0-0/41',
|
|
'manifest 必须提升指纹静态资源 Content-Range 证据。',
|
|
);
|
|
assertEqual(
|
|
manifest.summary.directLiveStaticHeaders?.fingerprinted?.etag304StatusCode,
|
|
304,
|
|
'manifest 必须提升指纹静态资源 ETag 304 证据。',
|
|
);
|
|
assertEqual(
|
|
manifest.summary.directLiveStaticHeaders?.fingerprinted
|
|
?.lastModified304StatusCode,
|
|
304,
|
|
'manifest 必须提升指纹静态资源 Last-Modified 304 证据。',
|
|
);
|
|
assertEqual(
|
|
manifest.commands?.[1]?.name,
|
|
'pingora-direct-live',
|
|
'manifest 必须记录 direct live 命令。',
|
|
);
|
|
assertIncludes(
|
|
manifest.commands?.[1]?.args || [],
|
|
'--pingora-access-log',
|
|
'direct live 命令记录必须保留 access log 参数。',
|
|
);
|
|
assertIncludes(
|
|
manifest.commands?.[1]?.command || '',
|
|
'--require-wss-upgrade',
|
|
'direct live 命令必须强制 WSS 101。',
|
|
);
|
|
assertManifestFileMetadata(
|
|
output.bundleDir,
|
|
manifest.files?.directLive,
|
|
'direct-live.json',
|
|
);
|
|
assertManifestFileMetadata(
|
|
output.bundleDir,
|
|
manifest.files?.directLiveStdout,
|
|
'direct-live.stdout.txt',
|
|
);
|
|
assertManifestFileMetadata(
|
|
output.bundleDir,
|
|
manifest.files?.directLiveStderr,
|
|
'direct-live.stderr.txt',
|
|
);
|
|
assertManifestFileMetadata(
|
|
output.bundleDir,
|
|
manifest.files?.directLiveCommand,
|
|
'direct-live-command.json',
|
|
);
|
|
assertFileExists(
|
|
path.join(output.bundleDir, 'direct-live.json'),
|
|
'证据包必须保存 direct-live.json。',
|
|
);
|
|
assertFileExists(
|
|
path.join(output.bundleDir, 'direct-live.stdout.txt'),
|
|
'证据包必须保存 direct live stdout。',
|
|
);
|
|
assertFileExists(
|
|
path.join(output.bundleDir, 'direct-live.stderr.txt'),
|
|
'证据包必须保存 direct live stderr。',
|
|
);
|
|
assertFileExists(
|
|
path.join(output.bundleDir, 'direct-live-command.json'),
|
|
'证据包必须保存 direct live 命令记录。',
|
|
);
|
|
const directLive = readJson(path.join(output.bundleDir, 'direct-live.json'));
|
|
assertEqual(directLive.ok, true, 'direct-live.json 必须记录 ok=true。');
|
|
const staticAssetCheck = directLive.results?.find(
|
|
(check) => check.name === 'https-static-asset',
|
|
);
|
|
if (!staticAssetCheck) {
|
|
failures.push('direct-live.json 必须保留静态资源检查结果。');
|
|
} else {
|
|
assertEqual(
|
|
staticAssetCheck.headers?.['cache-control'],
|
|
'no-cache',
|
|
'direct-live.json 必须保留普通静态资源 Cache-Control 证据。',
|
|
);
|
|
assertEqual(
|
|
staticAssetCheck.range?.headers?.['content-range'],
|
|
'bytes 0-0/27',
|
|
'direct-live.json 必须保留普通静态资源 Content-Range 证据。',
|
|
);
|
|
assertEqual(
|
|
staticAssetCheck.notModified?.etag?.statusCode,
|
|
304,
|
|
'direct-live.json 必须保留普通静态资源 ETag 304 证据。',
|
|
);
|
|
assertEqual(
|
|
staticAssetCheck.notModified?.lastModified?.statusCode,
|
|
304,
|
|
'direct-live.json 必须保留普通静态资源 Last-Modified 304 证据。',
|
|
);
|
|
assertEqual(
|
|
staticAssetCheck.fingerprinted?.headers?.['cache-control'],
|
|
'public, max-age=31536000, immutable',
|
|
'direct-live.json 必须保留指纹静态资源 Cache-Control 证据。',
|
|
);
|
|
assertEqual(
|
|
staticAssetCheck.fingerprinted?.range?.headers?.['content-range'],
|
|
'bytes 0-0/41',
|
|
'direct-live.json 必须保留指纹静态资源 Content-Range 证据。',
|
|
);
|
|
assertEqual(
|
|
staticAssetCheck.fingerprinted?.notModified?.etag?.statusCode,
|
|
304,
|
|
'direct-live.json 必须保留指纹静态资源 ETag 304 证据。',
|
|
);
|
|
assertEqual(
|
|
staticAssetCheck.fingerprinted?.notModified?.lastModified?.statusCode,
|
|
304,
|
|
'direct-live.json 必须保留指纹静态资源 Last-Modified 304 证据。',
|
|
);
|
|
}
|
|
const accessLogCheck = directLive.results?.find(
|
|
(check) => check.name === 'direct-access-log',
|
|
);
|
|
if (!accessLogCheck) {
|
|
failures.push('direct-live.json 必须保留 direct-access-log 检查结果。');
|
|
} else {
|
|
assertEqual(
|
|
accessLogCheck.scannedLineCount,
|
|
1,
|
|
'direct-access-log 检查必须记录扫描日志行数。',
|
|
);
|
|
assertEqual(
|
|
accessLogCheck.matchedCount,
|
|
2,
|
|
'direct-access-log 检查必须记录 matchedCount。',
|
|
);
|
|
assertEqual(
|
|
accessLogCheck.missingCount,
|
|
0,
|
|
'direct-access-log 检查必须记录 missingCount=0。',
|
|
);
|
|
assertEqual(
|
|
accessLogCheck.mismatchCount,
|
|
0,
|
|
'direct-access-log 检查必须记录 mismatchCount=0。',
|
|
);
|
|
assertEqual(
|
|
accessLogCheck.matched?.[0]?.expectedMethod,
|
|
'GET',
|
|
'direct-access-log 检查必须保留已匹配 request_id 的预期方法。',
|
|
);
|
|
assertEqual(
|
|
accessLogCheck.matched?.[0]?.actualMethod,
|
|
'GET',
|
|
'direct-access-log 检查必须保留已匹配 request_id 的实际方法。',
|
|
);
|
|
assertEqual(
|
|
accessLogCheck.matched?.[0]?.expectedStatusCode,
|
|
200,
|
|
'direct-access-log 检查必须保留已匹配 request_id 的预期状态码。',
|
|
);
|
|
assertEqual(
|
|
accessLogCheck.matched?.[0]?.actualStatusCode,
|
|
200,
|
|
'direct-access-log 检查必须保留已匹配 request_id 的实际状态码。',
|
|
);
|
|
assertEqual(
|
|
Array.isArray(accessLogCheck.missing),
|
|
true,
|
|
'direct-access-log 检查必须保留 missing 明细数组。',
|
|
);
|
|
assertEqual(
|
|
Array.isArray(accessLogCheck.mismatches),
|
|
true,
|
|
'direct-access-log 检查必须保留 mismatches 明细数组。',
|
|
);
|
|
}
|
|
}
|
|
|
|
function assertDirectLiveFailureStillWritesEvidenceAndFails() {
|
|
const fixture = prepareFixture('direct-live-critical', {
|
|
directLiveStatus: 'CRITICAL',
|
|
});
|
|
const result = runBundle(fixture, {
|
|
status: 'OK',
|
|
extraArgs: directLiveArgs(fixture, [
|
|
'--phase',
|
|
'post-enable',
|
|
'--expected-gateway-mode',
|
|
'pingora-direct',
|
|
'--expected-public-base-url',
|
|
'https://127.0.0.1',
|
|
'--expected-public-host',
|
|
'example.com',
|
|
'--fail-on-critical',
|
|
]),
|
|
});
|
|
|
|
assertStatus(result, 1, 'direct live 失败时证据包应失败。');
|
|
const output = parseJson(result.stdout, 'direct live 失败证据包 stdout');
|
|
assertEqual(output.status, 'CRITICAL', 'direct live 失败时 stdout 必须记录 CRITICAL。');
|
|
assertFileExists(output.bundleDir, 'direct live 失败证据包目录必须存在。');
|
|
if (!output.bundleDir) {
|
|
return;
|
|
}
|
|
const manifest = readJson(path.join(output.bundleDir, 'manifest.json'));
|
|
assertEqual(
|
|
manifest.summary.directLiveStatus,
|
|
'CRITICAL',
|
|
'direct live 失败时 manifest 必须记录 CRITICAL。',
|
|
);
|
|
assertEqual(
|
|
manifest.summary.directLiveExitCode,
|
|
1,
|
|
'direct live 失败时 manifest 必须记录退出码。',
|
|
);
|
|
assertFileExists(
|
|
path.join(output.bundleDir, 'direct-live.stdout.txt'),
|
|
'direct live 失败时仍必须保存 stdout。',
|
|
);
|
|
assertFileExists(
|
|
path.join(output.bundleDir, 'direct-live.stderr.txt'),
|
|
'direct live 失败时仍必须保存 stderr。',
|
|
);
|
|
assertFileExists(
|
|
path.join(output.bundleDir, 'direct-live-command.json'),
|
|
'direct live 失败时仍必须保存命令记录。',
|
|
);
|
|
}
|
|
|
|
function assertDirectLiveMissingAccessLogSummaryFails() {
|
|
const fixture = prepareFixture('direct-live-missing-access-log', {
|
|
directLiveOutputMode: 'without-access-log',
|
|
});
|
|
const result = runBundle(fixture, {
|
|
status: 'OK',
|
|
extraArgs: directLiveArgs(fixture, [
|
|
'--phase',
|
|
'post-enable',
|
|
'--expected-gateway-mode',
|
|
'pingora-direct',
|
|
'--expected-public-base-url',
|
|
'https://127.0.0.1',
|
|
'--expected-public-host',
|
|
'example.com',
|
|
'--fail-on-critical',
|
|
]),
|
|
});
|
|
|
|
assertStatus(
|
|
result,
|
|
1,
|
|
'direct live JSON 缺少 direct-access-log 时证据包应失败。',
|
|
);
|
|
const output = parseJson(result.stdout, 'direct live 缺 access log 证据包 stdout');
|
|
assertEqual(
|
|
output.status,
|
|
'CRITICAL',
|
|
'direct live 缺 access log 时 stdout 必须记录 CRITICAL。',
|
|
);
|
|
assertFileExists(output.bundleDir, 'direct live 缺 access log 证据包目录必须存在。');
|
|
if (!output.bundleDir) {
|
|
return;
|
|
}
|
|
const manifest = readJson(path.join(output.bundleDir, 'manifest.json'));
|
|
assertEqual(
|
|
manifest.summary.directLiveStatus,
|
|
'CRITICAL',
|
|
'direct live 缺 access log 时 manifest 必须记录 CRITICAL。',
|
|
);
|
|
assertEqual(
|
|
manifest.summary.directLiveAccessLog?.present,
|
|
false,
|
|
'direct live 缺 access log 时 manifest 必须记录缺失原因。',
|
|
);
|
|
}
|
|
|
|
function assertDirectLiveMissingStaticHeadersSummaryFails() {
|
|
const fixture = prepareFixture('direct-live-missing-static-headers', {
|
|
directLiveOutputMode: 'without-static-headers',
|
|
});
|
|
const result = runBundle(fixture, {
|
|
status: 'OK',
|
|
extraArgs: directLiveArgs(fixture, [
|
|
'--phase',
|
|
'post-enable',
|
|
'--expected-gateway-mode',
|
|
'pingora-direct',
|
|
'--expected-public-base-url',
|
|
'https://127.0.0.1',
|
|
'--expected-public-host',
|
|
'example.com',
|
|
'--fail-on-critical',
|
|
]),
|
|
});
|
|
|
|
assertStatus(
|
|
result,
|
|
1,
|
|
'direct live JSON 缺少静态响应头证据时证据包应失败。',
|
|
);
|
|
const output = parseJson(result.stdout, 'direct live 缺静态头证据包 stdout');
|
|
assertEqual(
|
|
output.status,
|
|
'CRITICAL',
|
|
'direct live 缺静态头时 stdout 必须记录 CRITICAL。',
|
|
);
|
|
assertFileExists(output.bundleDir, 'direct live 缺静态头证据包目录必须存在。');
|
|
if (!output.bundleDir) {
|
|
return;
|
|
}
|
|
const manifest = readJson(path.join(output.bundleDir, 'manifest.json'));
|
|
assertEqual(
|
|
manifest.summary.directLiveStatus,
|
|
'CRITICAL',
|
|
'direct live 缺静态头时 manifest 必须记录 CRITICAL。',
|
|
);
|
|
assertEqual(
|
|
manifest.summary.directLiveStaticHeaders?.present,
|
|
false,
|
|
'direct live 缺静态头时 manifest 必须记录缺失原因。',
|
|
);
|
|
}
|
|
|
|
function assertDirectLiveStaticHeaderDiagnosticsFails() {
|
|
const fixture = prepareFixture('direct-live-bad-static-headers', {
|
|
directLiveOutputMode: 'bad-static-headers',
|
|
});
|
|
const result = runBundle(fixture, {
|
|
status: 'OK',
|
|
extraArgs: directLiveArgs(fixture, [
|
|
'--phase',
|
|
'post-enable',
|
|
'--expected-gateway-mode',
|
|
'pingora-direct',
|
|
'--expected-public-base-url',
|
|
'https://127.0.0.1',
|
|
'--expected-public-host',
|
|
'example.com',
|
|
'--fail-on-critical',
|
|
]),
|
|
});
|
|
|
|
assertStatus(
|
|
result,
|
|
1,
|
|
'direct live 静态响应头摘要缺少 Range / 304 证据时证据包应失败。',
|
|
);
|
|
const output = parseJson(result.stdout, 'direct live 静态头漂移证据包 stdout');
|
|
assertEqual(
|
|
output.status,
|
|
'CRITICAL',
|
|
'direct live 静态头漂移时 stdout 必须记录 CRITICAL。',
|
|
);
|
|
if (!output.bundleDir) {
|
|
return;
|
|
}
|
|
const manifest = readJson(path.join(output.bundleDir, 'manifest.json'));
|
|
assertEqual(
|
|
manifest.summary.directLiveStatus,
|
|
'CRITICAL',
|
|
'direct live 静态头漂移时 manifest 必须记录 CRITICAL。',
|
|
);
|
|
assertEqual(
|
|
manifest.summary.directLiveStaticHeaders?.normal?.ok,
|
|
false,
|
|
'direct live 静态头漂移时 manifest 必须标记普通静态资源摘要失败。',
|
|
);
|
|
assertIncludes(
|
|
manifest.summary.directLiveStaticHeaders?.diagnostics || [],
|
|
'normal: Range statusCode 应为 206,实际 -',
|
|
'direct live 静态头漂移时 manifest 必须记录 Range 诊断。',
|
|
);
|
|
assertIncludes(
|
|
manifest.summary.directLiveStaticHeaders?.diagnostics || [],
|
|
'normal: ETag 304 statusCode 应为 304,实际 -',
|
|
'direct live 静态头漂移时 manifest 必须记录 ETag 304 诊断。',
|
|
);
|
|
}
|
|
|
|
function assertDirectLiveParseFailureWritesParseErrorEvidence() {
|
|
const fixture = prepareFixture('direct-live-parse-error', {
|
|
directLiveOutputMode: 'log-only',
|
|
});
|
|
const result = runBundle(fixture, {
|
|
status: 'OK',
|
|
extraArgs: directLiveArgs(fixture, [
|
|
'--phase',
|
|
'post-enable',
|
|
'--expected-gateway-mode',
|
|
'pingora-direct',
|
|
'--expected-public-base-url',
|
|
'https://127.0.0.1',
|
|
'--expected-public-host',
|
|
'example.com',
|
|
'--fail-on-critical',
|
|
]),
|
|
});
|
|
|
|
assertStatus(result, 1, 'direct live 未输出 JSON 时证据包应失败。');
|
|
const output = parseJson(result.stdout, 'direct live 解析失败证据包 stdout');
|
|
assertEqual(
|
|
output.status,
|
|
'CRITICAL',
|
|
'direct live 解析失败时 stdout 必须记录 CRITICAL。',
|
|
);
|
|
assertFileExists(
|
|
output.directLiveParseErrorPath,
|
|
'证据包 stdout 必须返回 direct live parse error 路径。',
|
|
);
|
|
assertFileExists(output.bundleDir, 'direct live 解析失败证据包目录必须存在。');
|
|
if (!output.bundleDir) {
|
|
return;
|
|
}
|
|
const manifest = readJson(path.join(output.bundleDir, 'manifest.json'));
|
|
assertEqual(
|
|
manifest.files?.directLive,
|
|
null,
|
|
'direct live 解析失败时 manifest 不应指向 direct-live.json。',
|
|
);
|
|
assertEqual(
|
|
manifest.files?.directLiveParseError?.path,
|
|
'direct-live-parse-error.txt',
|
|
'direct live 解析失败时 manifest 必须指向 parse error 文件。',
|
|
);
|
|
assertManifestFileMetadata(
|
|
output.bundleDir,
|
|
manifest.files?.directLiveParseError,
|
|
'direct-live-parse-error.txt',
|
|
);
|
|
assertEqual(
|
|
manifest.summary.directLiveStatus,
|
|
'CRITICAL',
|
|
'direct live 解析失败时 manifest 必须记录 CRITICAL。',
|
|
);
|
|
assertFileExists(
|
|
path.join(output.bundleDir, 'direct-live.stdout.txt'),
|
|
'direct live 解析失败时仍必须保存 stdout。',
|
|
);
|
|
assertFileExists(
|
|
path.join(output.bundleDir, 'direct-live.stderr.txt'),
|
|
'direct live 解析失败时仍必须保存 stderr。',
|
|
);
|
|
}
|
|
|
|
function assertCriticalSnapshotStillWritesEvidenceAndFails() {
|
|
const fixture = prepareFixture('critical');
|
|
const result = runBundle(fixture, {
|
|
status: 'CRITICAL',
|
|
extraArgs: [
|
|
'--phase',
|
|
'post-enable',
|
|
'--expected-gateway-mode',
|
|
'pingora-direct',
|
|
'--fail-on-critical',
|
|
],
|
|
});
|
|
|
|
assertStatus(result, 1, 'CRITICAL 快照应保留证据并返回失败。');
|
|
const output = parseJson(result.stdout, 'CRITICAL 证据包 stdout');
|
|
assertEqual(output.status, 'CRITICAL', '失败证据包 stdout 必须记录 CRITICAL。');
|
|
assertFileExists(output.bundleDir, '失败证据包目录也必须存在。');
|
|
if (!output.bundleDir) {
|
|
return;
|
|
}
|
|
const manifest = readJson(path.join(output.bundleDir, 'manifest.json'));
|
|
assertEqual(
|
|
manifest.summary.status,
|
|
'CRITICAL',
|
|
'失败证据包 manifest 必须记录 CRITICAL。',
|
|
);
|
|
assertEqual(
|
|
manifest.summary.snapshotExitCode,
|
|
1,
|
|
'失败证据包 manifest 必须记录快照退出码。',
|
|
);
|
|
assertFileExists(
|
|
path.join(output.bundleDir, 'snapshot.json'),
|
|
'失败证据包仍必须保存 snapshot.json。',
|
|
);
|
|
assertMode(output.bundleDir, 0o750, '失败证据包目录权限必须是 0750。');
|
|
assertMode(
|
|
path.join(output.bundleDir, 'manifest.json'),
|
|
0o640,
|
|
'失败证据包 manifest 权限必须是 0640。',
|
|
);
|
|
assertFileUnchanged(
|
|
fixture.healthEnvFile,
|
|
fixture.originalHealthEnvText,
|
|
'失败证据包不应改写 health patrol env。',
|
|
);
|
|
}
|
|
|
|
function assertSnapshotParseFailureWritesParseErrorEvidence() {
|
|
const fixture = prepareFixture('snapshot-parse-error', {
|
|
snapshotOutputMode: 'log-only',
|
|
});
|
|
const result = runBundle(fixture, {
|
|
status: 'OK',
|
|
extraArgs: [
|
|
'--phase',
|
|
'post-enable',
|
|
'--expected-gateway-mode',
|
|
'pingora-direct',
|
|
'--fail-on-critical',
|
|
],
|
|
});
|
|
|
|
assertStatus(result, 1, 'snapshot 未输出 JSON 时证据包应失败。');
|
|
const output = parseJson(result.stdout, 'snapshot 解析失败证据包 stdout');
|
|
assertEqual(
|
|
output.status,
|
|
'CRITICAL',
|
|
'snapshot 解析失败时 stdout 必须记录 CRITICAL。',
|
|
);
|
|
assertFileExists(
|
|
output.snapshotParseErrorPath,
|
|
'证据包 stdout 必须返回 snapshot parse error 路径。',
|
|
);
|
|
assertFileExists(output.bundleDir, 'snapshot 解析失败证据包目录必须存在。');
|
|
if (!output.bundleDir) {
|
|
return;
|
|
}
|
|
const manifest = readJson(path.join(output.bundleDir, 'manifest.json'));
|
|
assertEqual(
|
|
manifest.files?.snapshot,
|
|
null,
|
|
'snapshot 解析失败时 manifest 不应指向 snapshot.json。',
|
|
);
|
|
assertEqual(
|
|
manifest.files?.snapshotParseError?.path,
|
|
'snapshot-parse-error.txt',
|
|
'snapshot 解析失败时 manifest 必须指向 parse error 文件。',
|
|
);
|
|
assertManifestFileMetadata(
|
|
output.bundleDir,
|
|
manifest.files?.snapshotParseError,
|
|
'snapshot-parse-error.txt',
|
|
);
|
|
assertEqual(
|
|
manifest.summary.snapshotStatus,
|
|
'CRITICAL',
|
|
'snapshot 解析失败时 manifest 必须记录 snapshot CRITICAL。',
|
|
);
|
|
assertFileExists(
|
|
path.join(output.bundleDir, 'snapshot.stdout.txt'),
|
|
'snapshot 解析失败时仍必须保存 stdout。',
|
|
);
|
|
assertFileExists(
|
|
path.join(output.bundleDir, 'snapshot-parse-error.txt'),
|
|
'snapshot 解析失败时必须保存 parse error 文件。',
|
|
);
|
|
}
|
|
|
|
function assertBundleRedactsProbeTokensFromArtifacts() {
|
|
const fixture = prepareFixture('redact-probe-token', {
|
|
snapshotIncludesProbeFlags: true,
|
|
});
|
|
const result = runBundle(fixture, {
|
|
status: 'OK',
|
|
extraArgs: directLiveArgs(fixture, [
|
|
'--phase',
|
|
'post-enable',
|
|
'--expected-gateway-mode',
|
|
'pingora-direct',
|
|
'--run-health-patrol',
|
|
'--fail-on-critical',
|
|
]),
|
|
});
|
|
|
|
assertStatus(result, 0, '带 probe token 的证据包应成功生成。');
|
|
if (result.status !== 0) {
|
|
return;
|
|
}
|
|
const output = parseJson(result.stdout, 'probe token 证据包 stdout');
|
|
const filesToCheck = [
|
|
'manifest.json',
|
|
'snapshot.json',
|
|
'snapshot.stdout.txt',
|
|
'snapshot-command.json',
|
|
'direct-live-command.json',
|
|
];
|
|
for (const fileName of filesToCheck) {
|
|
const content = readTextFile(
|
|
path.join(output.bundleDir, fileName),
|
|
`证据包 ${fileName}`,
|
|
);
|
|
assertNotIncludes(
|
|
content,
|
|
'health-patrol-secret-token',
|
|
`${fileName} 不能泄露 health patrol probe token 原文。`,
|
|
);
|
|
assertNotIncludes(
|
|
content,
|
|
'pingora-gateway-secret-token',
|
|
`${fileName} 不能泄露 Pingora probe token 原文。`,
|
|
);
|
|
assertNotIncludes(
|
|
content,
|
|
'direct-live-secret-token',
|
|
`${fileName} 不能泄露 direct live probe token 原文。`,
|
|
);
|
|
}
|
|
const commandRecord = readJson(path.join(output.bundleDir, 'snapshot-command.json'));
|
|
assertIncludes(
|
|
commandRecord.args || [],
|
|
'--health-patrol-env-file',
|
|
'snapshot-command.json 结构化参数数组必须保留快照参数。',
|
|
);
|
|
|
|
const snapshot = readJson(path.join(output.bundleDir, 'snapshot.json'));
|
|
assertEqual(
|
|
snapshot.healthPatrolEnv?.values?.hasPingoraProbeToken,
|
|
true,
|
|
'证据包 snapshot.json 可以记录 health patrol probe token 是否存在。',
|
|
);
|
|
assertEqual(
|
|
snapshot.pingoraEnv?.values?.hasProbeToken,
|
|
true,
|
|
'证据包 snapshot.json 可以记录 Pingora probe token 是否存在。',
|
|
);
|
|
const healthPatrolCheck = snapshot.checks?.find(
|
|
(check) => check.name === 'production-health-patrol',
|
|
);
|
|
if (!healthPatrolCheck) {
|
|
failures.push('证据包 snapshot.json 必须保留生产巡检子检查结果。');
|
|
} else {
|
|
assertIncludes(
|
|
`${healthPatrolCheck.stdout}\n${healthPatrolCheck.stderr}`,
|
|
'<redacted>',
|
|
'证据包 snapshot.json 中的子检查 stdout/stderr 必须保留脱敏占位符。',
|
|
);
|
|
}
|
|
}
|
|
|
|
function readTextFile(filePath, label) {
|
|
try {
|
|
return readFileSync(filePath, 'utf8');
|
|
} catch (error) {
|
|
failures.push(`无法读取 ${label}: ${error.message}`);
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function assertManifestFileMetadata(bundleDir, metadata, expectedPath) {
|
|
if (!metadata || typeof metadata !== 'object') {
|
|
failures.push(`${expectedPath} 必须在 manifest.files 中记录结构化元数据。`);
|
|
return;
|
|
}
|
|
assertEqual(
|
|
metadata.path,
|
|
expectedPath,
|
|
`${expectedPath} manifest 元数据必须记录文件名。`,
|
|
);
|
|
const filePath = path.join(bundleDir, expectedPath);
|
|
const content = readFileSync(filePath);
|
|
assertEqual(
|
|
metadata.sizeBytes,
|
|
content.length,
|
|
`${expectedPath} manifest 元数据必须记录文件大小。`,
|
|
);
|
|
assertEqual(
|
|
metadata.sha256,
|
|
createHash('sha256').update(content).digest('hex'),
|
|
`${expectedPath} manifest 元数据必须记录 sha256。`,
|
|
);
|
|
}
|
|
|
|
function directLiveArgs(fixture, prefixArgs = []) {
|
|
return [
|
|
...prefixArgs,
|
|
'--direct-live-script',
|
|
fixture.directLiveScript,
|
|
'--run-direct-live',
|
|
'--direct-https-base-url',
|
|
'https://127.0.0.1',
|
|
'--direct-http-base-url',
|
|
'http://127.0.0.1',
|
|
'--direct-host',
|
|
'example.com',
|
|
'--direct-redirect-host',
|
|
'example.com',
|
|
'--direct-probe-token',
|
|
'direct-live-secret-token',
|
|
'--direct-spacetime-database',
|
|
'genarrative-prod',
|
|
'--direct-pingora-access-log',
|
|
fixture.directAccessLog,
|
|
'--direct-access-log-since-lines',
|
|
'99',
|
|
];
|
|
}
|
|
|
|
function assertRejectsRelativePaths() {
|
|
const fixture = prepareFixture('relative-path');
|
|
for (const [flag, value, expected] of [
|
|
['--output-root', 'pingora-evidence', '--output-root 必须是绝对路径'],
|
|
['--release-root', 'current', '--release-root 必须是绝对路径'],
|
|
['--snapshot-script', 'snapshot.mjs', '--snapshot-script 必须是绝对路径'],
|
|
]) {
|
|
const result = spawnSync(
|
|
'node',
|
|
[
|
|
BUNDLE_SCRIPT,
|
|
'--release-root',
|
|
fixture.releaseRoot,
|
|
'--output-root',
|
|
fixture.outputRoot,
|
|
'--health-patrol-env-file',
|
|
fixture.healthEnvFile,
|
|
'--pingora-env-file',
|
|
fixture.pingoraEnvFile,
|
|
'--snapshot-script',
|
|
fixture.snapshotScript,
|
|
flag,
|
|
value,
|
|
],
|
|
{
|
|
cwd: process.cwd(),
|
|
encoding: 'utf8',
|
|
},
|
|
);
|
|
if ((result.status ?? 0) === 0) {
|
|
failures.push(`${flag} 使用相对路径时必须失败。`);
|
|
}
|
|
assertIncludes(
|
|
`${result.stdout}\n${result.stderr}`,
|
|
expected,
|
|
`${flag} 相对路径必须给出明确错误。`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function assertRejectsFilesystemRootPaths() {
|
|
const fixture = prepareFixture('filesystem-root-path');
|
|
for (const [flag, expected] of [
|
|
['--release-root', '--release-root 不能是文件系统根目录'],
|
|
['--output-root', '--output-root 不能是文件系统根目录'],
|
|
['--health-patrol-env-file', '--health-patrol-env-file 不能是文件系统根目录'],
|
|
['--pingora-env-file', '--pingora-env-file 不能是文件系统根目录'],
|
|
['--snapshot-script', '--snapshot-script 不能是文件系统根目录'],
|
|
]) {
|
|
const result = spawnSync(
|
|
'node',
|
|
[
|
|
BUNDLE_SCRIPT,
|
|
'--release-root',
|
|
fixture.releaseRoot,
|
|
'--output-root',
|
|
fixture.outputRoot,
|
|
'--health-patrol-env-file',
|
|
fixture.healthEnvFile,
|
|
'--pingora-env-file',
|
|
fixture.pingoraEnvFile,
|
|
'--snapshot-script',
|
|
fixture.snapshotScript,
|
|
flag,
|
|
'/',
|
|
],
|
|
{
|
|
cwd: process.cwd(),
|
|
encoding: 'utf8',
|
|
},
|
|
);
|
|
if ((result.status ?? 0) === 0) {
|
|
failures.push(`${flag} 使用文件系统根目录时必须失败。`);
|
|
}
|
|
assertIncludes(
|
|
`${result.stdout}\n${result.stderr}`,
|
|
expected,
|
|
`${flag} 文件系统根目录必须给出明确错误。`,
|
|
);
|
|
assertNotIncludes(
|
|
`${result.stdout}\n${result.stderr}`,
|
|
'[fake-snapshot]',
|
|
`${flag} 文件系统根目录被拒绝时不应继续执行状态快照。`,
|
|
);
|
|
}
|
|
|
|
const directLogResult = runBundle(fixture, {
|
|
status: 'OK',
|
|
extraArgs: [
|
|
...directLiveArgs(fixture, [
|
|
'--phase',
|
|
'post-enable',
|
|
'--expected-gateway-mode',
|
|
'pingora-direct',
|
|
]),
|
|
'--direct-pingora-access-log',
|
|
'/',
|
|
],
|
|
});
|
|
if ((directLogResult.status ?? 0) === 0) {
|
|
failures.push('--direct-pingora-access-log 使用文件系统根目录时必须失败。');
|
|
}
|
|
assertIncludes(
|
|
`${directLogResult.stdout}\n${directLogResult.stderr}`,
|
|
'--direct-pingora-access-log 不能是文件系统根目录',
|
|
'direct live access log 指向文件系统根目录必须给出明确错误。',
|
|
);
|
|
assertNotIncludes(
|
|
`${directLogResult.stdout}\n${directLogResult.stderr}`,
|
|
'[fake-snapshot]',
|
|
'direct live access log 根目录被拒绝时不应执行状态快照。',
|
|
);
|
|
}
|
|
|
|
function assertRejectsUnsafePhase() {
|
|
const fixture = prepareFixture('unsafe-phase');
|
|
for (const phase of ['post enable', '../post-enable', 'post/enable', '回退']) {
|
|
const result = runBundle(fixture, {
|
|
status: 'OK',
|
|
extraArgs: ['--phase', phase],
|
|
});
|
|
if ((result.status ?? 0) === 0) {
|
|
failures.push(`--phase=${phase} 包含不安全字符时必须失败。`);
|
|
}
|
|
assertIncludes(
|
|
`${result.stdout}\n${result.stderr}`,
|
|
'--phase 只能包含 ASCII 字母、数字、点、下划线或短横线',
|
|
`--phase=${phase} 必须给出明确错误。`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function assertRejectsUnsafeCutoverRunId() {
|
|
const fixture = prepareFixture('unsafe-cutover-run-id');
|
|
for (const runId of ['cutover 1', '../cutover', '切换']) {
|
|
const result = runBundle(fixture, {
|
|
status: 'OK',
|
|
extraArgs: ['--cutover-run-id', runId],
|
|
});
|
|
if ((result.status ?? 0) === 0) {
|
|
failures.push(`--cutover-run-id=${runId} 包含不安全字符时必须失败。`);
|
|
}
|
|
assertIncludes(
|
|
`${result.stdout}\n${result.stderr}`,
|
|
'--cutover-run-id 只能包含 ASCII 字母、数字、点、下划线或短横线',
|
|
`--cutover-run-id=${runId} 必须给出明确错误。`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function assertRejectsSnapshotArgsWithControlCharacters() {
|
|
const fixture = prepareFixture('snapshot-control-character-arg');
|
|
const result = runBundle(fixture, {
|
|
status: 'OK',
|
|
extraArgs: [
|
|
'--phase',
|
|
'pre-cutover',
|
|
'--release-root',
|
|
`${fixture.releaseRoot}\n--fake-flag`,
|
|
],
|
|
});
|
|
|
|
if ((result.status ?? 0) === 0) {
|
|
failures.push('证据包必须拒绝带换行的状态快照命令参数。');
|
|
}
|
|
assertIncludes(
|
|
`${result.stdout}\n${result.stderr}`,
|
|
'状态快照命令参数不能包含换行或 NUL 字符',
|
|
'状态快照命令参数控制字符负例必须给出明确错误。',
|
|
);
|
|
assertNotIncludes(
|
|
`${result.stdout}\n${result.stderr}`,
|
|
'[fake-snapshot]',
|
|
'状态快照命令参数被拒绝时不应执行状态快照。',
|
|
);
|
|
}
|
|
|
|
function assertRejectsDirectLiveArgsWithControlCharacters() {
|
|
const fixture = prepareFixture('direct-live-control-character-arg', {
|
|
directLiveStatus: 'OK',
|
|
});
|
|
for (const [flag, value, expected] of [
|
|
[
|
|
'--direct-probe-token',
|
|
'direct-token\n--fake-flag',
|
|
'--direct-probe-token 不能包含换行或 NUL 字符',
|
|
],
|
|
[
|
|
'--direct-spacetime-database',
|
|
'genarrative\nprod',
|
|
'--direct-spacetime-database 不能包含换行或 NUL 字符',
|
|
],
|
|
[
|
|
'--direct-pingora-access-log',
|
|
`${fixture.directAccessLog}\n--fake-flag`,
|
|
'--direct-pingora-access-log 不能包含换行或 NUL 字符',
|
|
],
|
|
[
|
|
'--direct-access-log-since-lines',
|
|
'99\n1',
|
|
'--direct-access-log-since-lines 不能包含换行或 NUL 字符',
|
|
],
|
|
]) {
|
|
const result = runBundle(fixture, {
|
|
status: 'OK',
|
|
extraArgs: [
|
|
...directLiveArgs(fixture, [
|
|
'--phase',
|
|
'post-enable',
|
|
'--expected-gateway-mode',
|
|
'pingora-direct',
|
|
'--fail-on-critical',
|
|
]),
|
|
flag,
|
|
value,
|
|
],
|
|
});
|
|
|
|
if ((result.status ?? 0) === 0) {
|
|
failures.push(`${flag} 带换行时必须在执行 direct live 前失败。`);
|
|
}
|
|
assertIncludes(
|
|
`${result.stdout}\n${result.stderr}`,
|
|
expected,
|
|
`${flag} 控制字符负例必须给出明确错误。`,
|
|
);
|
|
assertNotIncludes(
|
|
`${result.stdout}\n${result.stderr}`,
|
|
'[fake-snapshot]',
|
|
`${flag} 被拒绝时不应执行状态快照。`,
|
|
);
|
|
assertNotIncludes(
|
|
`${result.stdout}\n${result.stderr}`,
|
|
'[fake-direct-live]',
|
|
`${flag} 被拒绝时不应执行 direct live。`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function assertRejectsSymlinkOutputRootBeforeSnapshot() {
|
|
const fixture = prepareFixture('symlink-output-root');
|
|
const realTarget = path.join(tmpRoot, 'symlink-output-root-real-target');
|
|
rmSync(fixture.outputRoot, { recursive: true, force: true });
|
|
mkdirSync(realTarget, { recursive: true });
|
|
symlinkSync(realTarget, fixture.outputRoot, 'dir');
|
|
|
|
const result = runBundle(fixture, {
|
|
status: 'OK',
|
|
extraArgs: ['--phase', 'pre-cutover'],
|
|
});
|
|
|
|
if ((result.status ?? 0) === 0) {
|
|
failures.push('证据包必须拒绝符号链接 --output-root。');
|
|
}
|
|
assertIncludes(
|
|
`${result.stdout}\n${result.stderr}`,
|
|
'--output-root 不能是符号链接',
|
|
'符号链接 output-root 负例必须给出明确错误。',
|
|
);
|
|
assertNotIncludes(
|
|
`${result.stdout}\n${result.stderr}`,
|
|
'[fake-snapshot]',
|
|
'符号链接 output-root 被拒绝时不应继续执行状态快照。',
|
|
);
|
|
assertDirectoryEmpty(
|
|
realTarget,
|
|
'符号链接 output-root 被拒绝时不应写入真实目标目录。',
|
|
);
|
|
}
|
|
|
|
function assertRejectsSymlinkOutputRootParentBeforeSnapshot() {
|
|
const fixture = prepareFixture('symlink-output-root-parent');
|
|
const realTarget = path.join(
|
|
tmpRoot,
|
|
'symlink-output-root-parent-real-target',
|
|
);
|
|
const linkPath = path.join(tmpRoot, 'symlink-output-root-parent-link');
|
|
mkdirSync(realTarget, { recursive: true });
|
|
symlinkSync(realTarget, linkPath, 'dir');
|
|
|
|
const result = runBundle(
|
|
{
|
|
...fixture,
|
|
outputRoot: path.join(linkPath, 'evidence'),
|
|
},
|
|
{
|
|
status: 'OK',
|
|
extraArgs: ['--phase', 'pre-cutover'],
|
|
},
|
|
);
|
|
|
|
if ((result.status ?? 0) === 0) {
|
|
failures.push('证据包必须拒绝符号链接 --output-root 上级目录。');
|
|
}
|
|
assertIncludes(
|
|
`${result.stdout}\n${result.stderr}`,
|
|
'--output-root 上级目录不能是符号链接',
|
|
'符号链接 output-root 上级目录负例必须给出明确错误。',
|
|
);
|
|
assertNotIncludes(
|
|
`${result.stdout}\n${result.stderr}`,
|
|
'[fake-snapshot]',
|
|
'符号链接 output-root 上级目录被拒绝时不应继续执行状态快照。',
|
|
);
|
|
assertDirectoryEmpty(
|
|
realTarget,
|
|
'符号链接 output-root 上级目录被拒绝时不应写入真实目标目录。',
|
|
);
|
|
}
|
|
|
|
function assertRejectsFileOutputRootBeforeSnapshot() {
|
|
const fixture = prepareFixture('file-output-root');
|
|
rmSync(fixture.outputRoot, { recursive: true, force: true });
|
|
writeFileSync(fixture.outputRoot, 'not a directory\n', 'utf8');
|
|
|
|
const result = runBundle(fixture, {
|
|
status: 'OK',
|
|
extraArgs: ['--phase', 'pre-cutover'],
|
|
});
|
|
|
|
if ((result.status ?? 0) === 0) {
|
|
failures.push('证据包必须拒绝已存在但不是目录的 --output-root。');
|
|
}
|
|
assertIncludes(
|
|
`${result.stdout}\n${result.stderr}`,
|
|
'--output-root 已存在但不是目录',
|
|
'非目录 output-root 负例必须给出明确错误。',
|
|
);
|
|
assertNotIncludes(
|
|
`${result.stdout}\n${result.stderr}`,
|
|
'[fake-snapshot]',
|
|
'非目录 output-root 被拒绝时不应继续执行状态快照。',
|
|
);
|
|
assertEqual(
|
|
readFileSync(fixture.outputRoot, 'utf8'),
|
|
'not a directory\n',
|
|
'非目录 output-root 被拒绝时不应改写原文件。',
|
|
);
|
|
}
|
|
|
|
function assertRejectsInvalidTimeout() {
|
|
const fixture = prepareFixture('invalid-timeout');
|
|
const cliResult = runBundle(fixture, {
|
|
status: 'OK',
|
|
extraArgs: ['--timeout-ms', '0'],
|
|
});
|
|
if ((cliResult.status ?? 0) === 0) {
|
|
failures.push('证据包必须拒绝非正数 --timeout-ms。');
|
|
}
|
|
assertIncludes(
|
|
`${cliResult.stdout}\n${cliResult.stderr}`,
|
|
'--timeout-ms 必须是正整数',
|
|
'非法 --timeout-ms 必须给出明确错误。',
|
|
);
|
|
|
|
const envResult = runBundle(
|
|
fixture,
|
|
{
|
|
status: 'OK',
|
|
extraArgs: [],
|
|
},
|
|
{
|
|
GENARRATIVE_PINGORA_CUTOVER_SNAPSHOT_TIMEOUT_MS: 'abc',
|
|
},
|
|
);
|
|
if ((envResult.status ?? 0) === 0) {
|
|
failures.push('证据包必须拒绝非法 timeout env。');
|
|
}
|
|
assertIncludes(
|
|
`${envResult.stdout}\n${envResult.stderr}`,
|
|
'GENARRATIVE_PINGORA_CUTOVER_SNAPSHOT_TIMEOUT_MS 必须是正整数',
|
|
'非法 timeout env 必须给出明确错误。',
|
|
);
|
|
}
|
|
|
|
function assertRejectsInvalidBoolEnv() {
|
|
const fixture = prepareFixture('invalid-bool-env');
|
|
const cases = [
|
|
{
|
|
env: { GENARRATIVE_PINGORA_CUTOVER_SNAPSHOT_RUN_HEALTH_PATROL: 'ture' },
|
|
expected:
|
|
'GENARRATIVE_PINGORA_CUTOVER_SNAPSHOT_RUN_HEALTH_PATROL 必须是布尔值',
|
|
reason: '证据包必须拒绝拼写错误的 run health patrol env。',
|
|
},
|
|
{
|
|
env: { GENARRATIVE_PINGORA_CUTOVER_SNAPSHOT_REQUIRE_PINGORA_GATEWAY: 'maybe' },
|
|
expected:
|
|
'GENARRATIVE_PINGORA_CUTOVER_SNAPSHOT_REQUIRE_PINGORA_GATEWAY 必须是布尔值',
|
|
reason: '证据包必须拒绝非法 require pingora gateway env。',
|
|
},
|
|
{
|
|
env: { GENARRATIVE_PINGORA_CUTOVER_SNAPSHOT_FAIL_ON_CRITICAL: 'enabled' },
|
|
expected:
|
|
'GENARRATIVE_PINGORA_CUTOVER_SNAPSHOT_FAIL_ON_CRITICAL 必须是布尔值',
|
|
reason: '证据包必须拒绝非法 fail on critical env。',
|
|
},
|
|
];
|
|
|
|
for (const testCase of cases) {
|
|
const result = runBundle(
|
|
fixture,
|
|
{
|
|
status: 'OK',
|
|
extraArgs: [],
|
|
},
|
|
testCase.env,
|
|
);
|
|
if ((result.status ?? 0) === 0) {
|
|
failures.push(testCase.reason);
|
|
}
|
|
assertIncludes(
|
|
`${result.stdout}\n${result.stderr}`,
|
|
testCase.expected,
|
|
`${testCase.reason} 必须给出明确错误。`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function prepareFixture(name, options = {}) {
|
|
const root = path.join(tmpRoot, name);
|
|
const releaseRoot = path.join(root, 'current');
|
|
const outputRoot = path.join(root, 'evidence');
|
|
const healthEnvFile = path.join(root, 'etc', 'health-patrol.env');
|
|
const pingoraEnvFile = path.join(root, 'etc', 'pingora-gateway.env');
|
|
const snapshotScript = path.join(
|
|
releaseRoot,
|
|
'scripts/ops/pingora-cutover-status-snapshot.mjs',
|
|
);
|
|
const directLiveScript = path.join(
|
|
releaseRoot,
|
|
'scripts/check-pingora-direct-live.mjs',
|
|
);
|
|
const directAccessLog = path.join(root, 'var/log/pingora-gateway.access.log');
|
|
|
|
mkdirSync(path.dirname(snapshotScript), { recursive: true });
|
|
mkdirSync(path.dirname(directLiveScript), { recursive: true });
|
|
mkdirSync(path.dirname(directAccessLog), { recursive: true });
|
|
mkdirSync(path.dirname(healthEnvFile), { recursive: true });
|
|
mkdirSync(outputRoot, { recursive: true });
|
|
const healthEnvText = [
|
|
'GENARRATIVE_HEALTH_PATROL_GATEWAY_MODE=nginx',
|
|
'GENARRATIVE_HEALTH_PATROL_PUBLIC_BASE_URL=http://127.0.0.1',
|
|
'GENARRATIVE_HEALTH_PATROL_PUBLIC_HOST=',
|
|
'GENARRATIVE_HEALTH_PATROL_PINGORA_PROBE_TOKEN=health-patrol-secret-token',
|
|
'',
|
|
].join('\n');
|
|
writeFileSync(healthEnvFile, healthEnvText, 'utf8');
|
|
writeFileSync(
|
|
pingoraEnvFile,
|
|
[
|
|
'GENARRATIVE_PINGORA_GATEWAY_LISTEN=127.0.0.1:18081',
|
|
'GENARRATIVE_PINGORA_GATEWAY_PROBE_TOKEN=pingora-gateway-secret-token',
|
|
'',
|
|
].join('\n'),
|
|
'utf8',
|
|
);
|
|
writeFileSync(
|
|
snapshotScript,
|
|
[
|
|
'#!/usr/bin/env node',
|
|
'const args = process.argv.slice(2);',
|
|
'const status = process.env.FAKE_SNAPSHOT_STATUS || "OK";',
|
|
`const outputMode = ${JSON.stringify(options.snapshotOutputMode || 'json')};`,
|
|
`const pingoraEnvValues = ${JSON.stringify(
|
|
options.snapshotPingoraEnvValues || {
|
|
listen: '127.0.0.1:18081',
|
|
tlsListen: '',
|
|
httpRedirectListen: '',
|
|
tlsCertFile: '',
|
|
tlsKeyFile: '',
|
|
},
|
|
)};`,
|
|
'const shadowReady = pingoraEnvValues.listen === "127.0.0.1:18081" && !pingoraEnvValues.tlsListen && !pingoraEnvValues.httpRedirectListen && !pingoraEnvValues.tlsCertFile && !pingoraEnvValues.tlsKeyFile;',
|
|
'const directReady = Boolean(pingoraEnvValues.tlsListen && pingoraEnvValues.httpRedirectListen && pingoraEnvValues.tlsCertFile && pingoraEnvValues.tlsKeyFile);',
|
|
'const pingoraEnvPosture = { mode: directReady ? "direct" : shadowReady ? "shadow" : "mixed", shadowReady, directReady };',
|
|
'const phaseIndex = args.indexOf("--phase");',
|
|
'console.error(`[fake-snapshot] ${status}`);',
|
|
'if (outputMode === "log-only") {',
|
|
' console.log("[fake-snapshot] finished without json");',
|
|
' process.exit(0);',
|
|
'}',
|
|
...(options.snapshotIncludesProbeFlags
|
|
? [
|
|
'import { readFileSync } from "node:fs";',
|
|
'function readEnv(file) {',
|
|
' return Object.fromEntries(readFileSync(file, "utf8").split(/\\r?\\n/).filter(Boolean).filter((line) => !line.startsWith("#")).map((line) => line.split("=")).map(([key, ...value]) => [key, value.join("=")]));',
|
|
'}',
|
|
'const healthEnv = readEnv(args[args.indexOf("--health-patrol-env-file") + 1]);',
|
|
'const pingoraEnv = readEnv(args[args.indexOf("--pingora-env-file") + 1]);',
|
|
]
|
|
: []),
|
|
'console.log(JSON.stringify({',
|
|
' schemaVersion: 1,',
|
|
' phase: phaseIndex >= 0 ? args[phaseIndex + 1] : "manual",',
|
|
' summary: {',
|
|
' status,',
|
|
' criticalCount: status === "CRITICAL" ? 1 : 0,',
|
|
' warningCount: 0,',
|
|
' },',
|
|
...(options.snapshotIncludesProbeFlags
|
|
? [
|
|
' healthPatrolEnv: { values: { hasPingoraProbeToken: Boolean(healthEnv.GENARRATIVE_HEALTH_PATROL_PINGORA_PROBE_TOKEN || healthEnv.GENARRATIVE_PINGORA_GATEWAY_PROBE_TOKEN) } },',
|
|
' pingoraEnv: { values: { ...pingoraEnvValues, hasProbeToken: Boolean(pingoraEnv.GENARRATIVE_PINGORA_GATEWAY_PROBE_TOKEN) }, posture: pingoraEnvPosture },',
|
|
' checks: [{ name: "production-health-patrol", stdout: "probe stdout <redacted>:<redacted>", stderr: "probe stderr <redacted>:<redacted>" }],',
|
|
]
|
|
: [' pingoraEnv: { values: pingoraEnvValues, posture: pingoraEnvPosture },']),
|
|
' args,',
|
|
'}, null, 2));',
|
|
'if (status === "CRITICAL" && args.includes("--fail-on-critical")) process.exit(1);',
|
|
'',
|
|
].join('\n'),
|
|
'utf8',
|
|
);
|
|
chmodSync(snapshotScript, 0o755);
|
|
writeFileSync(
|
|
directAccessLog,
|
|
'ts=2026-06-16T00:00:00Z\trequest_id=direct-test\tpath=/\n',
|
|
'utf8',
|
|
);
|
|
writeFileSync(
|
|
directLiveScript,
|
|
[
|
|
'#!/usr/bin/env node',
|
|
'const args = process.argv.slice(2);',
|
|
`const status = ${JSON.stringify(options.directLiveStatus || 'OK')};`,
|
|
`const outputMode = ${JSON.stringify(options.directLiveOutputMode || 'json')};`,
|
|
'const accessLogIndex = args.indexOf("--pingora-access-log");',
|
|
'const sinceLinesIndex = args.indexOf("--access-log-since-lines");',
|
|
'const databaseIndex = args.indexOf("--spacetime-database");',
|
|
'console.log(`[pingora-direct-live] fake ${status}`);',
|
|
'console.error(`[fake-direct-live] ${status}`);',
|
|
'if (outputMode === "log-only") {',
|
|
' console.log("[pingora-direct-live] finished without json");',
|
|
' process.exit(0);',
|
|
'}',
|
|
'const accessLogResults = outputMode === "without-access-log" ? [] : [',
|
|
' {',
|
|
' name: "direct-access-log",',
|
|
' logFile: accessLogIndex >= 0 ? args[accessLogIndex + 1] : "",',
|
|
' sinceLines: sinceLinesIndex >= 0 ? Number(args[sinceLinesIndex + 1]) : 0,',
|
|
' scannedLineCount: 1,',
|
|
' checked: 2,',
|
|
' matchedCount: status === "OK" ? 2 : 1,',
|
|
' missingCount: status === "OK" ? 0 : 1,',
|
|
' mismatchCount: 0,',
|
|
' matched: [{',
|
|
' name: "https-root",',
|
|
' requestId: "direct-live-root",',
|
|
' expectedMethod: "GET",',
|
|
' expectedPath: "/",',
|
|
' expectedStatusCode: 200,',
|
|
' actualMethod: "GET",',
|
|
' actualPath: "/",',
|
|
' actualStatusCode: 200,',
|
|
' }],',
|
|
' missing: status === "OK" ? [] : [{',
|
|
' name: "https-api-history",',
|
|
' requestId: "direct-live-api",',
|
|
' expectedMethod: "GET",',
|
|
' expectedPath: "/api/assets/history",',
|
|
' expectedStatusCode: 200,',
|
|
' }],',
|
|
' mismatches: [],',
|
|
' },',
|
|
'];',
|
|
'const staticAssetResult = {',
|
|
' name: "https-static-asset",',
|
|
' statusCode: 200,',
|
|
' requestId: "direct-live-static",',
|
|
' headers: {',
|
|
' "cache-control": "no-cache",',
|
|
' etag: "W/\\"1b-18bcfe56800\\"",',
|
|
' "last-modified": "Tue, 14 Nov 2023 22:13:20 GMT",',
|
|
' "accept-ranges": "bytes",',
|
|
' "content-length": "27",',
|
|
' },',
|
|
' range: {',
|
|
' name: "https-static-asset-range",',
|
|
' statusCode: 206,',
|
|
' headers: {',
|
|
' "content-range": "bytes 0-0/27",',
|
|
' "content-length": "1",',
|
|
' },',
|
|
' },',
|
|
' notModified: {',
|
|
' etag: {',
|
|
' name: "https-static-asset-etag-304",',
|
|
' statusCode: 304,',
|
|
' headers: {',
|
|
' "cache-control": "no-cache",',
|
|
' etag: "W/\\"1b-18bcfe56800\\"",',
|
|
' },',
|
|
' },',
|
|
' lastModified: {',
|
|
' name: "https-static-asset-last-modified-304",',
|
|
' statusCode: 304,',
|
|
' headers: {',
|
|
' "cache-control": "no-cache",',
|
|
' "last-modified": "Tue, 14 Nov 2023 22:13:20 GMT",',
|
|
' },',
|
|
' },',
|
|
' },',
|
|
' fingerprinted: {',
|
|
' name: "https-static-fingerprinted-asset",',
|
|
' statusCode: 200,',
|
|
' headers: {',
|
|
' "cache-control": "public, max-age=31536000, immutable",',
|
|
' etag: "W/\\"29-18bcfe56800\\"",',
|
|
' "last-modified": "Tue, 14 Nov 2023 22:13:20 GMT",',
|
|
' "accept-ranges": "bytes",',
|
|
' "content-length": "41",',
|
|
' },',
|
|
' range: {',
|
|
' name: "https-static-fingerprinted-asset-range",',
|
|
' statusCode: 206,',
|
|
' headers: {',
|
|
' "content-range": "bytes 0-0/41",',
|
|
' "content-length": "1",',
|
|
' },',
|
|
' },',
|
|
' notModified: {',
|
|
' etag: {',
|
|
' name: "https-static-fingerprinted-asset-etag-304",',
|
|
' statusCode: 304,',
|
|
' headers: {',
|
|
' "cache-control": "public, max-age=31536000, immutable",',
|
|
' etag: "W/\\"29-18bcfe56800\\"",',
|
|
' },',
|
|
' },',
|
|
' lastModified: {',
|
|
' name: "https-static-fingerprinted-asset-last-modified-304",',
|
|
' statusCode: 304,',
|
|
' headers: {',
|
|
' "cache-control": "public, max-age=31536000, immutable",',
|
|
' "last-modified": "Tue, 14 Nov 2023 22:13:20 GMT",',
|
|
' },',
|
|
' },',
|
|
' },',
|
|
' },',
|
|
'};',
|
|
'if (outputMode === "bad-static-headers") {',
|
|
' delete staticAssetResult.range;',
|
|
' delete staticAssetResult.notModified;',
|
|
'}',
|
|
'const staticAssetResults = outputMode === "without-static-headers" ? [] : [staticAssetResult];',
|
|
'console.log(JSON.stringify({',
|
|
' ok: status === "OK",',
|
|
' results: [',
|
|
' { name: "https-root", statusCode: 200, requestId: "direct-live-root" },',
|
|
' ...staticAssetResults,',
|
|
' ...accessLogResults,',
|
|
' { name: "wss-spacetime-subscribe", database: databaseIndex >= 0 ? args[databaseIndex + 1] : "" },',
|
|
' ],',
|
|
' args,',
|
|
'}, null, 2));',
|
|
'console.log(status === "OK" ? "[pingora-direct-live] OK" : "[pingora-direct-live] FAILED");',
|
|
'if (status !== "OK") process.exit(1);',
|
|
'',
|
|
].join('\n'),
|
|
'utf8',
|
|
);
|
|
chmodSync(directLiveScript, 0o755);
|
|
|
|
return {
|
|
releaseRoot,
|
|
outputRoot,
|
|
healthEnvFile,
|
|
pingoraEnvFile,
|
|
snapshotScript,
|
|
directLiveScript,
|
|
directAccessLog,
|
|
originalHealthEnvText: healthEnvText,
|
|
};
|
|
}
|
|
|
|
function runBundle(fixture, { status, extraArgs }, extraEnv = {}) {
|
|
return spawnSync(
|
|
'node',
|
|
[
|
|
BUNDLE_SCRIPT,
|
|
'--release-root',
|
|
fixture.releaseRoot,
|
|
'--output-root',
|
|
fixture.outputRoot,
|
|
'--health-patrol-env-file',
|
|
fixture.healthEnvFile,
|
|
'--pingora-env-file',
|
|
fixture.pingoraEnvFile,
|
|
'--snapshot-script',
|
|
fixture.snapshotScript,
|
|
...extraArgs,
|
|
],
|
|
{
|
|
cwd: process.cwd(),
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
GENARRATIVE_PINGORA_CUTOVER_RUN_ID: '',
|
|
...extraEnv,
|
|
FAKE_SNAPSHOT_STATUS: status,
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
function parseJson(text, label) {
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch (error) {
|
|
failures.push(`${label} 不是合法 JSON: ${error.message}`);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function readJson(filePath) {
|
|
try {
|
|
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
} catch (error) {
|
|
failures.push(`${filePath} 不是合法 JSON: ${error.message}`);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function assertFileExists(filePath, reason) {
|
|
if (!filePath || !existsSync(filePath)) {
|
|
failures.push(`${reason} 缺少: ${filePath}`);
|
|
}
|
|
}
|
|
|
|
function assertFileUnchanged(filePath, expected, reason) {
|
|
const actual = readFileSync(filePath, 'utf8');
|
|
if (actual !== expected) {
|
|
failures.push(reason);
|
|
}
|
|
}
|
|
|
|
function assertDirectoryEmpty(directoryPath, reason) {
|
|
try {
|
|
const entries = readdirSync(directoryPath);
|
|
if (entries.length > 0) {
|
|
failures.push(`${reason} 实际包含: ${entries.join(', ')}`);
|
|
}
|
|
} catch (error) {
|
|
failures.push(`无法读取 ${directoryPath} 目录内容: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
function assertMode(filePath, expected, reason) {
|
|
try {
|
|
const actual = statSync(filePath).mode & 0o777;
|
|
if (actual !== expected) {
|
|
failures.push(
|
|
`${reason} 实际 ${actual.toString(8)},预期 ${expected.toString(8)}。`,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
failures.push(`无法读取 ${filePath} 权限: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
function assertStatus(result, expected, reason) {
|
|
if ((result.status ?? 0) !== expected) {
|
|
failures.push(
|
|
`${reason} 实际退出码 ${result.status}。\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function assertEqual(actual, expected, reason) {
|
|
if (actual !== expected) {
|
|
failures.push(`${reason} 实际 ${actual},预期 ${expected}。`);
|
|
}
|
|
}
|
|
|
|
function assertIncludes(value, expected, reason) {
|
|
const haystack = Array.isArray(value) ? value.join('\n') : String(value);
|
|
if (!haystack.includes(expected)) {
|
|
failures.push(`${reason} 缺少: ${expected}`);
|
|
}
|
|
}
|
|
|
|
function assertNotIncludes(value, unexpected, reason) {
|
|
const haystack = Array.isArray(value) ? value.join('\n') : String(value);
|
|
if (haystack.includes(unexpected)) {
|
|
failures.push(`${reason} 不应包含: ${unexpected}`);
|
|
}
|
|
}
|