e3258f6f1d
新增 Pingora shadow env 回切脚本与对应检查。 补齐直连证据包时间线和 cutoverRunId 审计门禁。 支持 Gitea Host 透传并更新直连多域名文档。 修复百分号编码静态图标路径并补 smoke 覆盖。 更新生产发布与运维护栏对 Pingora 发布包的校验。
1117 lines
35 KiB
JavaScript
1117 lines
35 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { spawnSync } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import {
|
|
chmodSync,
|
|
copyFileSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
const SNAPSHOT_SCRIPT = 'scripts/ops/pingora-cutover-status-snapshot.mjs';
|
|
const failures = [];
|
|
const tmpRoot = mkdtempSync(
|
|
path.join(tmpdir(), 'genarrative-pingora-cutover-snapshot-'),
|
|
);
|
|
|
|
try {
|
|
main();
|
|
} finally {
|
|
rmSync(tmpRoot, { recursive: true, force: true });
|
|
}
|
|
|
|
if (failures.length > 0) {
|
|
console.error('[check:pingora-cutover-status-snapshot] FAILED');
|
|
for (const failure of failures) {
|
|
console.error(`- ${failure}`);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log('[check:pingora-cutover-status-snapshot] OK');
|
|
|
|
function main() {
|
|
assertScriptShape();
|
|
assertSnapshotSucceedsWithFakeSystemctlAndReleaseRoot();
|
|
assertSnapshotAcceptsExpectedDirectPingoraEnvMode();
|
|
assertSnapshotRejectsDirectPingoraEnvModeDrift();
|
|
assertSnapshotRejectsShadowPingoraEnvModeDrift();
|
|
assertSnapshotRedactsEnvProbeTokens();
|
|
assertFailOnCriticalRejectsEnvDriftWithoutWritingEnv();
|
|
assertFailOnCriticalRejectsMissingDirectCapability();
|
|
assertFailOnCriticalRejectsSystemdPingoraEnvDrift();
|
|
assertFailOnCriticalRejectsReleaseAuditFailure();
|
|
assertRejectsRelativePaths();
|
|
assertRejectsPathArgsWithControlCharacters();
|
|
assertRejectsFilesystemRootReleaseRoot();
|
|
assertRejectsFilesystemRootEnvFiles();
|
|
assertRejectsInvalidTimeout();
|
|
assertRejectsInvalidBoolEnv();
|
|
}
|
|
|
|
function assertScriptShape() {
|
|
const content = readFileSync(SNAPSHOT_SCRIPT, 'utf8');
|
|
assertIncludes(
|
|
content,
|
|
'该脚本只读采集状态',
|
|
'状态快照脚本必须在 usage 中说明只读边界。',
|
|
);
|
|
assertIncludes(
|
|
content,
|
|
'scripts/ops/pingora-current-release-audit.mjs',
|
|
'状态快照必须把 current release 自审脚本纳入 release artifact 采集。',
|
|
);
|
|
assertIncludes(
|
|
content,
|
|
'scripts/ops/pingora-direct-rehearsal-status.mjs',
|
|
'状态快照必须把直连彩排状态脚本纳入 release artifact 采集。',
|
|
);
|
|
assertIncludes(
|
|
content,
|
|
'scripts/ops/pingora-cutover-evidence-audit.mjs',
|
|
'状态快照必须把证据根目录审计脚本纳入 release artifact 采集。',
|
|
);
|
|
assertIncludes(
|
|
content,
|
|
'scripts/ops/pingora-cutover-evidence-verify.mjs',
|
|
'状态快照必须把证据验真脚本纳入 release artifact 采集。',
|
|
);
|
|
assertIncludes(
|
|
content,
|
|
'current-release-audit',
|
|
'状态快照必须执行 current release 自审并把结果纳入 checks。',
|
|
);
|
|
assertIncludes(
|
|
content,
|
|
'releaseManifest',
|
|
'状态快照必须收录 current release manifest 自审结果。',
|
|
);
|
|
assertIncludes(
|
|
content,
|
|
'scripts/ops/production-health-patrol.mjs',
|
|
'状态快照必须能调用 current release 随包生产巡检脚本。',
|
|
);
|
|
assertIncludes(
|
|
content,
|
|
'scripts/check-production-health-patrol-env.mjs',
|
|
'状态快照必须复用 current release 随包 env 复核脚本。',
|
|
);
|
|
assertIncludes(
|
|
content,
|
|
'scripts/check-pingora-release-readiness.mjs',
|
|
'状态快照必须把 release readiness 聚合门禁脚本纳入 release artifact 采集。',
|
|
);
|
|
assertIncludes(
|
|
content,
|
|
'scripts/check-pingora-canary-live.mjs',
|
|
'状态快照必须把 canary live 脚本纳入 release artifact 采集。',
|
|
);
|
|
assertIncludes(
|
|
content,
|
|
'scripts/check-pingora-canary-access-log-parity.mjs',
|
|
'状态快照必须把 canary access log 对账脚本纳入 release artifact 采集。',
|
|
);
|
|
assertIncludes(
|
|
content,
|
|
'AmbientCapabilities=CAP_NET_BIND_SERVICE',
|
|
'状态快照必须记录 Pingora 低端口 capability 是否生效。',
|
|
);
|
|
assertIncludes(
|
|
content,
|
|
'--expected-pingora-env-mode',
|
|
'状态快照必须支持按阶段强制校验 active Pingora env 姿态。',
|
|
);
|
|
assertIncludes(
|
|
content,
|
|
'summarizePingoraEnvPosture',
|
|
'状态快照必须输出 Pingora env shadow/direct 姿态摘要。',
|
|
);
|
|
if (content.includes('writeFile') || content.includes('rmSync(')) {
|
|
failures.push('状态快照脚本不应写文件或删除文件。');
|
|
}
|
|
}
|
|
|
|
function assertSnapshotSucceedsWithFakeSystemctlAndReleaseRoot() {
|
|
const fixture = prepareFixture('direct-ok', {
|
|
gatewayMode: 'pingora-direct',
|
|
publicBaseUrl: 'https://127.0.0.1',
|
|
publicHost: 'example.com',
|
|
directCapability: true,
|
|
pingoraActive: true,
|
|
});
|
|
const result = runSnapshot(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, 'fake systemctl/release root 下 direct 快照应成功。');
|
|
if (result.status !== 0) {
|
|
return;
|
|
}
|
|
const payload = parseJson(result.stdout, 'direct 快照输出');
|
|
assertEqual(payload.phase, 'post-enable', '快照必须记录阶段标签。');
|
|
assertEqual(
|
|
payload.healthPatrolEnv.values.gatewayMode,
|
|
'pingora-direct',
|
|
'快照必须记录 health patrol gateway mode。',
|
|
);
|
|
assertEqual(
|
|
payload.systemd.pingoraUnit.hasAmbientCapability,
|
|
true,
|
|
'direct 快照必须看到 AmbientCapabilities。',
|
|
);
|
|
assertEqual(
|
|
payload.systemd.pingoraUnit.environmentFileMatchesPingoraEnvFile,
|
|
true,
|
|
'direct 快照必须确认 systemd EnvironmentFile 指向本次 pingora env。',
|
|
);
|
|
assertEqual(
|
|
payload.summary.status,
|
|
'OK',
|
|
'direct 快照所有 fake 检查通过时 summary 应为 OK。',
|
|
);
|
|
assertEqual(
|
|
payload.pingoraEnv?.posture?.mode,
|
|
'direct',
|
|
'状态快照必须能把完整低端口 TLS env 识别为 direct 姿态。',
|
|
);
|
|
assertEqual(
|
|
payload.pingoraEnv?.posture?.directReady,
|
|
true,
|
|
'完整低端口 TLS env 必须标记 directReady=true。',
|
|
);
|
|
const healthPatrolCheck = payload.checks.find(
|
|
(check) => check.name === 'production-health-patrol',
|
|
);
|
|
if (!healthPatrolCheck) {
|
|
failures.push('启用 --run-health-patrol 时快照必须收录生产巡检结果。');
|
|
} else {
|
|
assertIncludes(
|
|
healthPatrolCheck.stdout,
|
|
'"gatewayMode":"pingora-direct"',
|
|
'生产巡检子进程必须读取 health patrol env。',
|
|
);
|
|
}
|
|
const releaseAuditCheck = payload.checks.find(
|
|
(check) => check.name === 'current-release-audit',
|
|
);
|
|
if (!releaseAuditCheck) {
|
|
failures.push('状态快照必须收录 current release 自审结果。');
|
|
} else {
|
|
assertEqual(
|
|
releaseAuditCheck.status,
|
|
'OK',
|
|
'完整 fake release 的 current release 自审应通过。',
|
|
);
|
|
assertEqual(
|
|
releaseAuditCheck.details?.summary?.status,
|
|
'OK',
|
|
'状态快照必须保留 current release 自审 summary。',
|
|
);
|
|
const pingoraChecksum = releaseAuditCheck.details?.checksums?.find(
|
|
(checksum) => checksum.path === 'pingora-gateway',
|
|
);
|
|
if (!pingoraChecksum || pingoraChecksum.matches !== true) {
|
|
failures.push(
|
|
'状态快照必须保留 pingora-gateway checksum 匹配结果。',
|
|
);
|
|
}
|
|
assertEqual(
|
|
releaseAuditCheck.details?.releaseManifest?.status,
|
|
'OK',
|
|
'状态快照必须保留 release manifest 自审状态。',
|
|
);
|
|
}
|
|
const canaryLogParityArtifact = payload.releaseArtifacts.artifacts.find(
|
|
(item) => item.path === 'scripts/check-pingora-canary-access-log-parity.mjs',
|
|
);
|
|
if (!canaryLogParityArtifact || canaryLogParityArtifact.status !== 'OK') {
|
|
failures.push('快照必须确认 current release 已包含 canary access log 对账脚本。');
|
|
}
|
|
const rehearsalStatusArtifact = payload.releaseArtifacts.artifacts.find(
|
|
(item) => item.path === 'scripts/ops/pingora-direct-rehearsal-status.mjs',
|
|
);
|
|
if (!rehearsalStatusArtifact || rehearsalStatusArtifact.status !== 'OK') {
|
|
failures.push('快照必须确认 current release 已包含直连彩排状态脚本。');
|
|
}
|
|
const evidenceAuditArtifact = payload.releaseArtifacts.artifacts.find(
|
|
(item) => item.path === 'scripts/ops/pingora-cutover-evidence-audit.mjs',
|
|
);
|
|
if (!evidenceAuditArtifact || evidenceAuditArtifact.status !== 'OK') {
|
|
failures.push('快照必须确认 current release 已包含证据根目录审计脚本。');
|
|
}
|
|
const commandsLog = readTextFile(fixture.commandsLog, 'fake systemctl 日志');
|
|
assertIncludes(
|
|
commandsLog,
|
|
'systemctl is-active genarrative-pingora-gateway.service',
|
|
'快照必须通过 fake systemctl 检查 Pingora service。',
|
|
);
|
|
assertFileUnchanged(
|
|
fixture.healthEnvFile,
|
|
fixture.originalHealthEnvText,
|
|
'成功快照不应改写 health patrol env。',
|
|
);
|
|
}
|
|
|
|
function assertSnapshotAcceptsExpectedDirectPingoraEnvMode() {
|
|
const fixture = prepareFixture('direct-env-mode-ok', {
|
|
gatewayMode: 'pingora-direct',
|
|
publicBaseUrl: 'https://127.0.0.1',
|
|
publicHost: 'example.com',
|
|
directCapability: true,
|
|
pingoraActive: true,
|
|
});
|
|
const result = runSnapshot(fixture, [
|
|
'--phase',
|
|
'post-enable',
|
|
'--expected-gateway-mode',
|
|
'pingora-direct',
|
|
'--expected-pingora-env-mode',
|
|
'direct',
|
|
'--expected-public-base-url',
|
|
'https://127.0.0.1',
|
|
'--expected-public-host',
|
|
'example.com',
|
|
'--fail-on-critical',
|
|
]);
|
|
|
|
assertStatus(result, 0, 'direct env 姿态符合预期时快照应成功。');
|
|
const payload = parseJson(result.stdout, 'direct env 姿态快照输出');
|
|
assertEqual(
|
|
payload.pingoraEnv?.posture?.expectedMode,
|
|
'direct',
|
|
'快照必须记录 expected Pingora env mode。',
|
|
);
|
|
assertEqual(
|
|
payload.pingoraEnv?.posture?.directReady,
|
|
true,
|
|
'direct env 姿态正例必须 directReady=true。',
|
|
);
|
|
}
|
|
|
|
function assertSnapshotRejectsDirectPingoraEnvModeDrift() {
|
|
const fixture = prepareFixture('direct-env-mode-drift', {
|
|
gatewayMode: 'pingora-direct',
|
|
publicBaseUrl: 'https://127.0.0.1',
|
|
publicHost: 'example.com',
|
|
directCapability: true,
|
|
pingoraActive: true,
|
|
pingoraEnvMode: 'shadow',
|
|
});
|
|
const result = runSnapshot(fixture, [
|
|
'--phase',
|
|
'post-enable',
|
|
'--expected-gateway-mode',
|
|
'pingora-direct',
|
|
'--expected-pingora-env-mode',
|
|
'direct',
|
|
'--expected-public-base-url',
|
|
'https://127.0.0.1',
|
|
'--expected-public-host',
|
|
'example.com',
|
|
'--fail-on-critical',
|
|
]);
|
|
|
|
assertStatus(
|
|
result,
|
|
1,
|
|
'启用后要求 direct env 但 active env 仍是 shadow 时必须失败。',
|
|
);
|
|
const payload = parseJson(result.stdout, 'direct env 漂移快照输出');
|
|
assertEqual(
|
|
payload.pingoraEnv?.status,
|
|
'CRITICAL',
|
|
'direct env 漂移必须标记 pingoraEnv 为 CRITICAL。',
|
|
);
|
|
assertIncludes(
|
|
payload.pingoraEnv?.diagnostics || [],
|
|
'Pingora direct env 要求 tlsListen 已配置',
|
|
'direct env 漂移必须给出缺少 tlsListen 的诊断。',
|
|
);
|
|
}
|
|
|
|
function assertSnapshotRejectsShadowPingoraEnvModeDrift() {
|
|
const fixture = prepareFixture('shadow-env-mode-drift', {
|
|
gatewayMode: 'nginx',
|
|
publicBaseUrl: 'https://example.com',
|
|
publicHost: '',
|
|
directCapability: false,
|
|
pingoraActive: true,
|
|
});
|
|
const result = runSnapshot(fixture, [
|
|
'--phase',
|
|
'post-rollback',
|
|
'--expected-gateway-mode',
|
|
'nginx',
|
|
'--expected-pingora-env-mode',
|
|
'shadow',
|
|
'--expected-public-base-url',
|
|
'https://example.com',
|
|
'--require-empty-public-host',
|
|
'--fail-on-critical',
|
|
]);
|
|
|
|
assertStatus(
|
|
result,
|
|
1,
|
|
'回退后要求 shadow env 但 active env 仍有 direct 配置时必须失败。',
|
|
);
|
|
const payload = parseJson(result.stdout, 'shadow env 漂移快照输出');
|
|
assertEqual(
|
|
payload.pingoraEnv?.posture?.shadowReady,
|
|
false,
|
|
'shadow env 漂移必须标记 shadowReady=false。',
|
|
);
|
|
assertIncludes(
|
|
payload.pingoraEnv?.diagnostics || [],
|
|
'tlsCertFile 为空',
|
|
'shadow env 漂移必须给出 TLS cert 残留诊断。',
|
|
);
|
|
}
|
|
|
|
function assertFailOnCriticalRejectsEnvDriftWithoutWritingEnv() {
|
|
const fixture = prepareFixture('env-drift', {
|
|
gatewayMode: 'nginx',
|
|
publicBaseUrl: 'http://127.0.0.1',
|
|
publicHost: '',
|
|
directCapability: false,
|
|
pingoraActive: false,
|
|
});
|
|
const result = runSnapshot(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, 'health patrol env 漂移时 fail-on-critical 必须失败。');
|
|
const payload = parseJson(result.stdout, 'env 漂移快照输出');
|
|
assertEqual(
|
|
payload.healthPatrolEnv.status,
|
|
'CRITICAL',
|
|
'env 漂移必须标记为 CRITICAL。',
|
|
);
|
|
assertFileUnchanged(
|
|
fixture.healthEnvFile,
|
|
fixture.originalHealthEnvText,
|
|
'失败快照不应改写 health patrol env。',
|
|
);
|
|
}
|
|
|
|
function assertSnapshotRedactsEnvProbeTokens() {
|
|
const fixture = prepareFixture('redact-probe-token', {
|
|
gatewayMode: 'pingora-direct',
|
|
publicBaseUrl: 'https://127.0.0.1',
|
|
publicHost: 'example.com',
|
|
directCapability: true,
|
|
pingoraActive: true,
|
|
healthProbeToken: 'health-patrol-secret-token',
|
|
pingoraProbeToken: 'pingora-gateway-secret-token',
|
|
});
|
|
const result = runSnapshot(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',
|
|
'--fail-on-critical',
|
|
]);
|
|
|
|
assertStatus(result, 0, '带 probe token 的状态快照应成功。');
|
|
assertNotIncludes(
|
|
result.stdout,
|
|
'health-patrol-secret-token',
|
|
'状态快照 stdout 不能泄露 health patrol probe token 原文。',
|
|
);
|
|
assertNotIncludes(
|
|
result.stdout,
|
|
'pingora-gateway-secret-token',
|
|
'状态快照 stdout 不能泄露 Pingora probe token 原文。',
|
|
);
|
|
const payload = parseJson(result.stdout, 'probe token 脱敏快照输出');
|
|
assertEqual(
|
|
payload.healthPatrolEnv?.values?.hasPingoraProbeToken,
|
|
true,
|
|
'状态快照可以记录 health patrol probe token 是否存在。',
|
|
);
|
|
assertEqual(
|
|
payload.pingoraEnv?.values?.hasProbeToken,
|
|
true,
|
|
'状态快照可以记录 Pingora probe token 是否存在。',
|
|
);
|
|
const healthPatrolCheck = payload.checks?.find(
|
|
(check) => check.name === 'production-health-patrol',
|
|
);
|
|
assertIncludes(
|
|
healthPatrolCheck?.stdout || '',
|
|
'<redacted>',
|
|
'生产巡检 stdout 里的 probe token 必须被脱敏后进入快照。',
|
|
);
|
|
assertIncludes(
|
|
healthPatrolCheck?.stderr || '',
|
|
'<redacted>',
|
|
'生产巡检 stderr 里的 probe token 必须被脱敏后进入快照。',
|
|
);
|
|
assertNotIncludes(
|
|
healthPatrolCheck?.stdout || '',
|
|
'health-patrol-secret-token',
|
|
'生产巡检 stdout 进入快照时不能泄露 health patrol probe token 原文。',
|
|
);
|
|
assertNotIncludes(
|
|
healthPatrolCheck?.stdout || '',
|
|
'pingora-gateway-secret-token',
|
|
'生产巡检 stdout 进入快照时不能泄露 Pingora probe token 原文。',
|
|
);
|
|
assertNotIncludes(
|
|
healthPatrolCheck?.stderr || '',
|
|
'health-patrol-secret-token',
|
|
'生产巡检 stderr 进入快照时不能泄露 health patrol probe token 原文。',
|
|
);
|
|
assertNotIncludes(
|
|
healthPatrolCheck?.stderr || '',
|
|
'pingora-gateway-secret-token',
|
|
'生产巡检 stderr 进入快照时不能泄露 Pingora probe token 原文。',
|
|
);
|
|
}
|
|
|
|
function assertFailOnCriticalRejectsMissingDirectCapability() {
|
|
const fixture = prepareFixture('missing-capability', {
|
|
gatewayMode: 'pingora-direct',
|
|
publicBaseUrl: 'https://127.0.0.1',
|
|
publicHost: 'example.com',
|
|
directCapability: false,
|
|
pingoraActive: true,
|
|
});
|
|
const result = runSnapshot(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 模式缺少低端口 capability 时 fail-on-critical 必须失败。',
|
|
);
|
|
const payload = parseJson(result.stdout, '缺 capability 快照输出');
|
|
assertEqual(
|
|
payload.systemd.pingoraUnit.status,
|
|
'CRITICAL',
|
|
'缺少 direct capability 必须标记 Pingora unit 为 CRITICAL。',
|
|
);
|
|
}
|
|
|
|
function assertFailOnCriticalRejectsSystemdPingoraEnvDrift() {
|
|
const fixture = prepareFixture('systemd-pingora-env-drift', {
|
|
gatewayMode: 'pingora-direct',
|
|
publicBaseUrl: 'https://127.0.0.1',
|
|
publicHost: 'example.com',
|
|
directCapability: true,
|
|
pingoraActive: true,
|
|
systemdPingoraEnvFile: 'prefix',
|
|
});
|
|
const result = runSnapshot(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,
|
|
'systemd EnvironmentFile 与 --pingora-env-file 漂移时 fail-on-critical 必须失败。',
|
|
);
|
|
const payload = parseJson(result.stdout, 'systemd env 漂移快照输出');
|
|
assertEqual(
|
|
payload.systemd?.pingoraUnit?.environmentFileMatchesPingoraEnvFile,
|
|
false,
|
|
'systemd env 漂移时必须记录 EnvironmentFile 未匹配。',
|
|
);
|
|
assertIncludes(
|
|
payload.systemd?.pingoraUnit?.diagnostics || [],
|
|
'EnvironmentFile 未包含本次 --pingora-env-file',
|
|
'systemd env 漂移时必须给出明确诊断。',
|
|
);
|
|
}
|
|
|
|
function assertFailOnCriticalRejectsReleaseAuditFailure() {
|
|
const fixture = prepareFixture('bad-release-audit', {
|
|
gatewayMode: 'pingora-direct',
|
|
publicBaseUrl: 'https://127.0.0.1',
|
|
publicHost: 'example.com',
|
|
directCapability: true,
|
|
pingoraActive: true,
|
|
});
|
|
writeFileSync(
|
|
path.join(fixture.releaseRoot, 'pingora-gateway.sha256'),
|
|
`${'0'.repeat(64)} pingora-gateway\n`,
|
|
'utf8',
|
|
);
|
|
const result = runSnapshot(fixture, [
|
|
'--phase',
|
|
'post-enable',
|
|
'--expected-gateway-mode',
|
|
'pingora-direct',
|
|
'--expected-public-base-url',
|
|
'https://127.0.0.1',
|
|
'--expected-public-host',
|
|
'example.com',
|
|
'--require-pingora-gateway',
|
|
'--fail-on-critical',
|
|
]);
|
|
|
|
assertStatus(result, 1, 'current release 自审失败时 snapshot 必须失败。');
|
|
const payload = parseJson(result.stdout, '自审失败快照输出');
|
|
const releaseAuditCheck = payload.checks?.find(
|
|
(check) => check.name === 'current-release-audit',
|
|
);
|
|
if (!releaseAuditCheck || releaseAuditCheck.status !== 'CRITICAL') {
|
|
failures.push('自审失败时 current-release-audit check 必须标记 CRITICAL。');
|
|
}
|
|
const pingoraChecksum = releaseAuditCheck?.details?.checksums?.find(
|
|
(checksum) => checksum.path === 'pingora-gateway',
|
|
);
|
|
if (!pingoraChecksum || pingoraChecksum.status !== 'CRITICAL') {
|
|
failures.push('自审失败时 snapshot 必须保留 CRITICAL checksum 细节。');
|
|
}
|
|
}
|
|
|
|
function assertRejectsRelativePaths() {
|
|
const result = spawnSync(
|
|
'node',
|
|
[SNAPSHOT_SCRIPT, '--release-root', 'build/current'],
|
|
{
|
|
cwd: process.cwd(),
|
|
encoding: 'utf8',
|
|
},
|
|
);
|
|
if ((result.status ?? 0) === 0) {
|
|
failures.push('状态快照必须拒绝相对 release root。');
|
|
}
|
|
assertIncludes(
|
|
`${result.stdout}\n${result.stderr}`,
|
|
'--release-root 必须是绝对路径',
|
|
'相对 release root 必须给出明确错误。',
|
|
);
|
|
}
|
|
|
|
function assertRejectsPathArgsWithControlCharacters() {
|
|
const fixture = prepareFixture('control-character-path', {
|
|
gatewayMode: 'nginx',
|
|
publicBaseUrl: 'http://127.0.0.1',
|
|
publicHost: '',
|
|
directCapability: false,
|
|
pingoraActive: false,
|
|
});
|
|
const result = runSnapshot(fixture, [
|
|
'--release-root',
|
|
`${fixture.releaseRoot}\n--fake-flag`,
|
|
]);
|
|
if ((result.status ?? 0) === 0) {
|
|
failures.push('状态快照必须拒绝带换行的路径参数。');
|
|
}
|
|
assertIncludes(
|
|
`${result.stdout}\n${result.stderr}`,
|
|
'--release-root 不能包含换行或 NUL 字符',
|
|
'路径参数控制字符负例必须给出明确错误。',
|
|
);
|
|
assertNotIncludes(
|
|
readTextFile(fixture.commandsLog, 'fake systemctl 日志'),
|
|
'systemctl ',
|
|
'路径参数被拒绝时不应执行 systemctl 子命令。',
|
|
);
|
|
}
|
|
|
|
function assertRejectsFilesystemRootReleaseRoot() {
|
|
const result = spawnSync(
|
|
'node',
|
|
[SNAPSHOT_SCRIPT, '--release-root', '/'],
|
|
{
|
|
cwd: process.cwd(),
|
|
encoding: 'utf8',
|
|
},
|
|
);
|
|
if ((result.status ?? 0) === 0) {
|
|
failures.push('状态快照必须拒绝文件系统根目录 release root。');
|
|
}
|
|
assertIncludes(
|
|
`${result.stdout}\n${result.stderr}`,
|
|
'--release-root 不能是文件系统根目录',
|
|
'文件系统根目录 release root 必须给出明确错误。',
|
|
);
|
|
}
|
|
|
|
function assertRejectsFilesystemRootEnvFiles() {
|
|
for (const [flag, expected] of [
|
|
['--health-patrol-env-file', '--health-patrol-env-file 不能是文件系统根目录'],
|
|
['--pingora-env-file', '--pingora-env-file 不能是文件系统根目录'],
|
|
]) {
|
|
const result = spawnSync('node', [SNAPSHOT_SCRIPT, flag, '/'], {
|
|
cwd: process.cwd(),
|
|
encoding: 'utf8',
|
|
});
|
|
if ((result.status ?? 0) === 0) {
|
|
failures.push(`${flag} 使用文件系统根目录时必须失败。`);
|
|
}
|
|
assertIncludes(
|
|
`${result.stdout}\n${result.stderr}`,
|
|
expected,
|
|
`${flag} 指向文件系统根目录必须给出明确错误。`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function assertRejectsInvalidTimeout() {
|
|
const fixture = prepareFixture('invalid-timeout', {
|
|
gatewayMode: 'nginx',
|
|
publicBaseUrl: 'http://127.0.0.1',
|
|
publicHost: '',
|
|
directCapability: false,
|
|
pingoraActive: false,
|
|
});
|
|
const cliResult = runSnapshot(fixture, ['--timeout-ms', '0']);
|
|
if ((cliResult.status ?? 0) === 0) {
|
|
failures.push('状态快照必须拒绝非正数 --timeout-ms。');
|
|
}
|
|
assertIncludes(
|
|
`${cliResult.stdout}\n${cliResult.stderr}`,
|
|
'--timeout-ms 必须是正整数',
|
|
'非法 --timeout-ms 必须给出明确错误。',
|
|
);
|
|
|
|
const envResult = runSnapshot(fixture, [], {
|
|
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', {
|
|
gatewayMode: 'nginx',
|
|
publicBaseUrl: 'http://127.0.0.1',
|
|
publicHost: '',
|
|
directCapability: false,
|
|
pingoraActive: false,
|
|
});
|
|
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 = runSnapshot(fixture, [], 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 fakeBin = path.join(root, 'bin');
|
|
const commandsLog = path.join(root, 'commands.log');
|
|
const healthEnvFile = path.join(root, 'etc', 'health-patrol.env');
|
|
const pingoraEnvFile = path.join(root, 'etc', 'pingora-gateway.env');
|
|
|
|
mkdirSync(fakeBin, { recursive: true });
|
|
mkdirSync(path.dirname(healthEnvFile), { recursive: true });
|
|
prepareReleaseRoot(releaseRoot);
|
|
writeFileSync(commandsLog, '', 'utf8');
|
|
|
|
const healthEnvText = [
|
|
`GENARRATIVE_HEALTH_PATROL_GATEWAY_MODE=${options.gatewayMode}`,
|
|
`GENARRATIVE_HEALTH_PATROL_PUBLIC_BASE_URL=${options.publicBaseUrl}`,
|
|
`GENARRATIVE_HEALTH_PATROL_PUBLIC_HOST=${options.publicHost}`,
|
|
'GENARRATIVE_HEALTH_PATROL_API_BASE_URL=http://127.0.0.1:8082',
|
|
'GENARRATIVE_HEALTH_PATROL_SPACETIME_BASE_URL=http://127.0.0.1:3101',
|
|
`GENARRATIVE_HEALTH_PATROL_PINGORA_PROBE_TOKEN=${options.healthProbeToken || ''}`,
|
|
'',
|
|
].join('\n');
|
|
writeFileSync(healthEnvFile, healthEnvText, 'utf8');
|
|
writeFileSync(
|
|
pingoraEnvFile,
|
|
pingoraEnvText(options),
|
|
'utf8',
|
|
);
|
|
writeFileSync(
|
|
path.join(fakeBin, 'systemctl'),
|
|
fakeSystemctlScript(commandsLog, releaseRoot, pingoraEnvFile, options),
|
|
'utf8',
|
|
);
|
|
chmodExecutable(path.join(fakeBin, 'systemctl'));
|
|
|
|
return {
|
|
root,
|
|
releaseRoot,
|
|
fakeBin,
|
|
commandsLog,
|
|
healthEnvFile,
|
|
pingoraEnvFile,
|
|
originalHealthEnvText: healthEnvText,
|
|
};
|
|
}
|
|
|
|
function pingoraEnvText(options) {
|
|
const lines =
|
|
options.pingoraEnvMode === 'shadow'
|
|
? [
|
|
'GENARRATIVE_PINGORA_GATEWAY_LISTEN=127.0.0.1:18081',
|
|
'GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN=',
|
|
'GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN=',
|
|
'GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE=',
|
|
'GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE=',
|
|
'GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO=http',
|
|
]
|
|
: [
|
|
'GENARRATIVE_PINGORA_GATEWAY_LISTEN=127.0.0.1:18081',
|
|
'GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN=0.0.0.0:443',
|
|
'GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN=0.0.0.0:80',
|
|
'GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE=/etc/letsencrypt/live/example/fullchain.pem',
|
|
'GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE=/etc/letsencrypt/live/example/privkey.pem',
|
|
'GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO=https',
|
|
];
|
|
return [
|
|
...lines,
|
|
`GENARRATIVE_PINGORA_GATEWAY_PROBE_TOKEN=${options.pingoraProbeToken || 'fake-token'}`,
|
|
'',
|
|
].join('\n');
|
|
}
|
|
|
|
function prepareReleaseRoot(releaseRoot) {
|
|
for (const dir of [
|
|
'scripts',
|
|
'scripts/ops',
|
|
'scripts/deploy',
|
|
'deploy/systemd',
|
|
'deploy/nginx',
|
|
'deploy/env',
|
|
'deploy/pingora',
|
|
]) {
|
|
mkdirSync(path.join(releaseRoot, dir), { recursive: true });
|
|
}
|
|
writeFileSync(
|
|
path.join(releaseRoot, 'api-server'),
|
|
'#!/usr/bin/env bash\n',
|
|
'utf8',
|
|
);
|
|
chmodExecutable(path.join(releaseRoot, 'api-server'));
|
|
writeChecksum(releaseRoot, 'api-server');
|
|
writeFileSync(
|
|
path.join(releaseRoot, 'pingora-gateway'),
|
|
'#!/usr/bin/env bash\n',
|
|
'utf8',
|
|
);
|
|
chmodExecutable(path.join(releaseRoot, 'pingora-gateway'));
|
|
writeChecksum(releaseRoot, 'pingora-gateway');
|
|
copyFileSync(
|
|
SNAPSHOT_SCRIPT,
|
|
path.join(releaseRoot, 'scripts/ops/pingora-cutover-status-snapshot.mjs'),
|
|
);
|
|
copyFileSync(
|
|
'scripts/check-production-health-patrol-env.mjs',
|
|
path.join(releaseRoot, 'scripts/check-production-health-patrol-env.mjs'),
|
|
);
|
|
writeFileSync(
|
|
path.join(releaseRoot, 'scripts/ops/production-health-patrol.mjs'),
|
|
[
|
|
'#!/usr/bin/env node',
|
|
'console.log(JSON.stringify({',
|
|
' status: "OK",',
|
|
' gatewayMode: process.env.GENARRATIVE_HEALTH_PATROL_GATEWAY_MODE || "",',
|
|
' hasPingoraProbeToken: Boolean(process.env.GENARRATIVE_HEALTH_PATROL_PINGORA_PROBE_TOKEN || process.env.GENARRATIVE_PINGORA_GATEWAY_PROBE_TOKEN),',
|
|
' probeEcho: `${process.env.GENARRATIVE_HEALTH_PATROL_PINGORA_PROBE_TOKEN || ""}:${process.env.GENARRATIVE_PINGORA_GATEWAY_PROBE_TOKEN || ""}`,',
|
|
'}));',
|
|
'console.error(`probe stderr ${process.env.GENARRATIVE_HEALTH_PATROL_PINGORA_PROBE_TOKEN || ""}:${process.env.GENARRATIVE_PINGORA_GATEWAY_PROBE_TOKEN || ""}`);',
|
|
'',
|
|
].join('\n'),
|
|
'utf8',
|
|
);
|
|
for (const file of [
|
|
'scripts/database-backup-to-oss.mjs',
|
|
'scripts/ops/pingora-current-release-audit.mjs',
|
|
'scripts/ops/pingora-direct-rehearsal-status.mjs',
|
|
'scripts/ops/pingora-cutover-evidence-audit.mjs',
|
|
'scripts/ops/pingora-cutover-evidence-bundle.mjs',
|
|
'scripts/ops/pingora-cutover-command-evidence.mjs',
|
|
'scripts/ops/pingora-cutover-evidence-verify.mjs',
|
|
'scripts/check-pingora-release-readiness.mjs',
|
|
'scripts/check-pingora-direct-preflight.mjs',
|
|
'scripts/check-pingora-direct-live.mjs',
|
|
'scripts/check-pingora-canary-live.mjs',
|
|
'scripts/check-pingora-canary-access-log-parity.mjs',
|
|
'scripts/deploy/pingora-direct-enable.sh',
|
|
'scripts/deploy/pingora-direct-rollback.sh',
|
|
'scripts/deploy/pingora-realpath-canary-enable.sh',
|
|
'scripts/deploy/pingora-realpath-canary-disable.sh',
|
|
'scripts/deploy/pingora-health-patrol-env-switch.mjs',
|
|
'scripts/deploy/pingora-gateway-env-shadow-switch.mjs',
|
|
'scripts/deploy/pingora-tls-cert-sync.mjs',
|
|
'deploy/systemd/genarrative-pingora-gateway.service',
|
|
'deploy/systemd/genarrative-pingora-gateway-direct-entry.conf',
|
|
'deploy/nginx/snippets/genarrative-pingora-canary.conf',
|
|
'deploy/nginx/snippets/genarrative-pingora-realpath-canary.conf',
|
|
'deploy/env/health-patrol.env.example',
|
|
'deploy/env/pingora-direct-live.env.example',
|
|
'deploy/env/pingora-canary-live.env.example',
|
|
'deploy/pingora/pingora-gateway.env.example',
|
|
'deploy/pingora/nginx-route-parity.matrix.json',
|
|
]) {
|
|
const target = path.join(releaseRoot, file);
|
|
mkdirSync(path.dirname(target), { recursive: true });
|
|
if (existsSync(file)) {
|
|
copyFileSync(file, target);
|
|
} else {
|
|
writeFileSync(target, '', 'utf8');
|
|
}
|
|
}
|
|
for (const file of [
|
|
'scripts/deploy/pingora-direct-enable.sh',
|
|
'scripts/deploy/pingora-direct-rollback.sh',
|
|
'scripts/deploy/pingora-realpath-canary-enable.sh',
|
|
'scripts/deploy/pingora-realpath-canary-disable.sh',
|
|
'scripts/deploy/pingora-health-patrol-env-switch.mjs',
|
|
'scripts/deploy/pingora-gateway-env-shadow-switch.mjs',
|
|
'scripts/deploy/pingora-tls-cert-sync.mjs',
|
|
]) {
|
|
chmodExecutable(path.join(releaseRoot, file));
|
|
}
|
|
writeReleaseManifest(releaseRoot);
|
|
}
|
|
|
|
function fakeSystemctlScript(commandsLog, releaseRoot, pingoraEnvFile, options) {
|
|
const capabilityLines = options.directCapability
|
|
? [
|
|
'AmbientCapabilities=CAP_NET_BIND_SERVICE',
|
|
'CapabilityBoundingSet=CAP_NET_BIND_SERVICE',
|
|
]
|
|
: [];
|
|
const inactivePingora = options.pingoraActive ? 'active' : 'inactive';
|
|
const releaseBinaryPath = path.join(releaseRoot, 'pingora-gateway');
|
|
const systemdPingoraEnvFile =
|
|
options.systemdPingoraEnvFile === 'prefix'
|
|
? `${pingoraEnvFile}.bak`
|
|
: options.systemdPingoraEnvFile || pingoraEnvFile;
|
|
return [
|
|
'#!/usr/bin/env bash',
|
|
`printf 'systemctl %s\\n' "$*" >> ${shellQuote(commandsLog)}`,
|
|
'if [[ "$1" == "is-active" ]]; then',
|
|
' if [[ "$2" == "genarrative-pingora-gateway.service" ]]; then',
|
|
` echo ${shellQuote(inactivePingora)}`,
|
|
` [[ ${shellQuote(inactivePingora)} == "active" ]] && exit 0 || exit 3`,
|
|
' fi',
|
|
' echo active',
|
|
' exit 0',
|
|
'fi',
|
|
'if [[ "$1" == "cat" ]]; then',
|
|
' cat <<UNIT',
|
|
'[Service]',
|
|
'User=genarrative',
|
|
`EnvironmentFile=-${systemdPingoraEnvFile}`,
|
|
...capabilityLines,
|
|
'ExecStart=/opt/genarrative/current/pingora-gateway',
|
|
'UNIT',
|
|
' exit 0',
|
|
'fi',
|
|
'if [[ "$1" == "show" ]]; then',
|
|
' cat <<SHOW',
|
|
'FragmentPath=/etc/systemd/system/genarrative-pingora-gateway.service',
|
|
'DropInPaths=/etc/systemd/system/genarrative-pingora-gateway.service.d/direct-entry.conf',
|
|
'User=genarrative',
|
|
`ExecStart={ path=${releaseBinaryPath} ; argv[]=${releaseBinaryPath} ; }`,
|
|
'SHOW',
|
|
' exit 0',
|
|
'fi',
|
|
'exit 1',
|
|
'',
|
|
].join('\n');
|
|
}
|
|
|
|
function runSnapshot(fixture, args, extraEnv = {}) {
|
|
return spawnSync(
|
|
'node',
|
|
[
|
|
SNAPSHOT_SCRIPT,
|
|
'--release-root',
|
|
fixture.releaseRoot,
|
|
'--health-patrol-env-file',
|
|
fixture.healthEnvFile,
|
|
'--pingora-env-file',
|
|
fixture.pingoraEnvFile,
|
|
...args,
|
|
],
|
|
{
|
|
cwd: process.cwd(),
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
...extraEnv,
|
|
PATH: `${fixture.fakeBin}:${process.env.PATH || ''}`,
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
function chmodExecutable(filePath) {
|
|
chmodSync(filePath, 0o755);
|
|
}
|
|
|
|
function writeChecksum(directory, fileName) {
|
|
const content = readFileSync(path.join(directory, fileName));
|
|
const checksum = createHash('sha256').update(content).digest('hex');
|
|
writeFileSync(
|
|
path.join(directory, `${fileName}.sha256`),
|
|
`${checksum} ${fileName}\n`,
|
|
'utf8',
|
|
);
|
|
}
|
|
|
|
function writeReleaseManifest(releaseRoot) {
|
|
writeFileSync(
|
|
path.join(releaseRoot, 'release-manifest.api-server.json'),
|
|
`${JSON.stringify(
|
|
{
|
|
version: 'check-cutover-snapshot',
|
|
component_type: 'api-server',
|
|
artifacts: [
|
|
{
|
|
component: 'api-server',
|
|
path: 'api-server',
|
|
checksum_path: 'api-server.sha256',
|
|
},
|
|
{
|
|
component: 'pingora-gateway',
|
|
path: 'pingora-gateway',
|
|
checksum_path: 'pingora-gateway.sha256',
|
|
},
|
|
],
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
'utf8',
|
|
);
|
|
}
|
|
|
|
function shellQuote(value) {
|
|
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
|
}
|
|
|
|
function parseJson(text, label) {
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch (error) {
|
|
failures.push(`${label} 不是合法 JSON: ${error.message}`);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function readTextFile(filePath, label) {
|
|
try {
|
|
return readFileSync(filePath, 'utf8');
|
|
} catch (error) {
|
|
failures.push(`无法读取 ${label}: ${error.message}`);
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function assertFileUnchanged(filePath, expected, reason) {
|
|
const actual = readTextFile(filePath, filePath);
|
|
if (actual !== expected) {
|
|
failures.push(reason);
|
|
}
|
|
}
|
|
|
|
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}`);
|
|
}
|
|
}
|