完善 Pingora 直连切换门禁

新增 Pingora shadow env 回切脚本与对应检查。

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

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

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

更新生产发布与运维护栏对 Pingora 发布包的校验。
This commit is contained in:
2026-06-18 21:10:45 +08:00
parent 3c1fd004a7
commit e3258f6f1d
31 changed files with 3591 additions and 398 deletions
+2
View File
@@ -523,6 +523,7 @@ cp "${SCRIPT_DIR}/deploy/pingora-direct-rollback.sh" "${TARGET_DIR}/scripts/depl
cp "${SCRIPT_DIR}/deploy/pingora-realpath-canary-enable.sh" "${TARGET_DIR}/scripts/deploy/pingora-realpath-canary-enable.sh"
cp "${SCRIPT_DIR}/deploy/pingora-realpath-canary-disable.sh" "${TARGET_DIR}/scripts/deploy/pingora-realpath-canary-disable.sh"
cp "${SCRIPT_DIR}/deploy/pingora-health-patrol-env-switch.mjs" "${TARGET_DIR}/scripts/deploy/pingora-health-patrol-env-switch.mjs"
cp "${SCRIPT_DIR}/deploy/pingora-gateway-env-shadow-switch.mjs" "${TARGET_DIR}/scripts/deploy/pingora-gateway-env-shadow-switch.mjs"
cp "${SCRIPT_DIR}/deploy/pingora-tls-cert-sync.mjs" "${TARGET_DIR}/scripts/deploy/pingora-tls-cert-sync.mjs"
cp "${SCRIPT_DIR}/deploy/jenkins-inbound-agent-start.sh" "${TARGET_DIR}/scripts/jenkins-inbound-agent-start.sh"
cp "${SCRIPT_DIR}/deploy/install-jenkins-inbound-agent.sh" "${TARGET_DIR}/scripts/install-jenkins-inbound-agent.sh"
@@ -540,6 +541,7 @@ chmod +x \
"${TARGET_DIR}/scripts/deploy/pingora-realpath-canary-enable.sh" \
"${TARGET_DIR}/scripts/deploy/pingora-realpath-canary-disable.sh" \
"${TARGET_DIR}/scripts/deploy/pingora-health-patrol-env-switch.mjs" \
"${TARGET_DIR}/scripts/deploy/pingora-gateway-env-shadow-switch.mjs" \
"${TARGET_DIR}/scripts/deploy/pingora-tls-cert-sync.mjs" \
"${TARGET_DIR}/scripts/jenkins-inbound-agent-start.sh" \
"${TARGET_DIR}/scripts/install-jenkins-inbound-agent.sh"
+11 -4
View File
@@ -444,10 +444,16 @@ function runNginxSyntaxCheck(prefixContent, realpathContent) {
PROBE_TOKEN_PLACEHOLDER,
'local-nginx-canary-check-token',
);
const renderedRealpathSnippet = realpathContent.replaceAll(
PROBE_TOKEN_PLACEHOLDER,
'local-nginx-canary-check-token',
);
const renderedRealpathSnippet = realpathContent
.replaceAll(PROBE_TOKEN_PLACEHOLDER, 'local-nginx-canary-check-token')
.replace(
'/var/log/nginx/genarrative-pingora-realpath-canary.access.log',
path.join(tempRoot, 'genarrative-pingora-realpath-canary.access.log'),
)
.replace(
'/var/log/nginx/genarrative-pingora-realpath-canary.error.log',
path.join(tempRoot, 'genarrative-pingora-realpath-canary.error.log'),
);
const nginxConfig = `
pid ${tempRoot}/nginx.pid;
error_log stderr notice;
@@ -463,6 +469,7 @@ http {
'request_time=$request_time upstream_connect_time=$upstream_connect_time '
'upstream_header_time=$upstream_header_time upstream_response_time=$upstream_response_time '
'upstream_status=$upstream_status request_id=$request_id';
access_log ${tempRoot}/access.log genarrative_upstream;
server {
listen 127.0.0.1:18082;
@@ -5,6 +5,7 @@ import {
chmodSync,
copyFileSync,
existsSync,
symlinkSync,
mkdirSync,
mkdtempSync,
readFileSync,
@@ -47,6 +48,7 @@ function main() {
assertRequirePingoraGatewayFailsWhenMissing();
assertIncludedPingoraGatewayMustBeExecutable();
assertSystemdShowRequiresCurrentReleaseExecStart();
assertSystemdShowAcceptsCurrentSymlinkExecStart();
assertRejectsRelativeReleaseRoot();
assertRejectsFilesystemRootReleaseRoot();
assertRejectsPathArgsWithControlCharacters();
@@ -300,6 +302,43 @@ function assertSystemdShowRequiresCurrentReleaseExecStart() {
);
}
function assertSystemdShowAcceptsCurrentSymlinkExecStart() {
const fixture = prepareFixture('systemd-current-symlink', {
releaseDirectoryName: 'releases/current-build',
});
const currentRoot = path.join(fixture.root, 'current');
symlinkSync(fixture.releaseRoot, currentRoot, 'dir');
const currentBinary = path.join(currentRoot, 'pingora-gateway');
const result = runAudit(fixture, ['--systemd-show'], {
systemdExecStart: currentBinary,
});
assertStatus(
result,
0,
'systemd ExecStart 指向 current symlink 且解析到本次 release 时必须通过。',
);
if (result.status !== 0) {
return;
}
const payload = parseJson(result.stdout, 'current symlink ExecStart 自审输出');
assertEqual(
payload.systemd.status,
'OK',
'current symlink ExecStart 必须让 systemd 自审通过。',
);
assertEqual(
payload.systemd.execStartBinary,
currentBinary,
'systemd 自审必须记录 ExecStart 中的原始 current symlink 路径。',
);
assertEqual(
payload.systemd.expectedRealpath,
payload.systemd.execStartRealpath,
'systemd 自审必须用真实路径确认 current symlink 指向本次 release 二进制。',
);
}
function assertRejectsRelativeReleaseRoot() {
const result = spawnSync(
'node',
@@ -416,7 +455,7 @@ function assertRejectsInvalidBoolEnv() {
function prepareFixture(name, options = {}) {
const root = path.join(tmpRoot, name);
const releaseRoot = path.join(root, 'current');
const releaseRoot = path.join(root, options.releaseDirectoryName || 'current');
const fakeBin = path.join(root, 'bin');
const commandsLog = path.join(root, 'commands.log');
const includePingoraGateway = options.includePingoraGateway !== false;
@@ -472,6 +511,7 @@ function prepareReleaseRoot(releaseRoot, options) {
'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',
@@ -498,6 +538,7 @@ function prepareReleaseRoot(releaseRoot, options) {
'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));
File diff suppressed because it is too large Load Diff
@@ -42,6 +42,8 @@ console.log('[check:pingora-cutover-evidence-bundle] OK');
function main() {
assertScriptShape();
assertBundleSucceedsAndWritesManifest();
assertBundleSummarizesPingoraEnvShadow();
assertBundleSummarizesDirectPingoraEnvAsNotShadow();
assertBundleWritesCutoverRunId();
assertBundleCanArchiveDirectLiveEvidence();
assertDirectLiveFailureStillWritesEvidenceAndFails();
@@ -132,6 +134,11 @@ function assertScriptShape() {
'parseDirectLiveJson',
'证据包必须能从 direct live stdout 中提取 JSON 结果。',
);
assertIncludes(
content,
'pingoraEnvShadow',
'证据包 manifest 必须提升 Pingora env shadow 摘要。',
);
assertIncludes(
content,
'--run-direct-live',
@@ -197,6 +204,11 @@ function assertBundleSucceedsAndWritesManifest() {
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,
@@ -301,6 +313,118 @@ function assertBundleSucceedsAndWritesManifest() {
);
}
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, {
@@ -1562,6 +1686,18 @@ function prepareFixture(name, options = {}) {
'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") {',
@@ -1589,10 +1725,10 @@ function prepareFixture(name, options = {}) {
...(options.snapshotIncludesProbeFlags
? [
' healthPatrolEnv: { values: { hasPingoraProbeToken: Boolean(healthEnv.GENARRATIVE_HEALTH_PATROL_PINGORA_PROBE_TOKEN || healthEnv.GENARRATIVE_PINGORA_GATEWAY_PROBE_TOKEN) } },',
' pingoraEnv: { values: { hasProbeToken: Boolean(pingoraEnv.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);',
@@ -40,6 +40,9 @@ console.log('[check:pingora-cutover-status-snapshot] OK');
function main() {
assertScriptShape();
assertSnapshotSucceedsWithFakeSystemctlAndReleaseRoot();
assertSnapshotAcceptsExpectedDirectPingoraEnvMode();
assertSnapshotRejectsDirectPingoraEnvModeDrift();
assertSnapshotRejectsShadowPingoraEnvModeDrift();
assertSnapshotRedactsEnvProbeTokens();
assertFailOnCriticalRejectsEnvDriftWithoutWritingEnv();
assertFailOnCriticalRejectsMissingDirectCapability();
@@ -120,6 +123,16 @@ function assertScriptShape() {
'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('状态快照脚本不应写文件或删除文件。');
}
@@ -173,6 +186,16 @@ function assertSnapshotSucceedsWithFakeSystemctlAndReleaseRoot() {
'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',
);
@@ -246,6 +269,122 @@ function assertSnapshotSucceedsWithFakeSystemctlAndReleaseRoot() {
);
}
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',
@@ -657,15 +796,7 @@ function prepareFixture(name, options) {
writeFileSync(healthEnvFile, healthEnvText, 'utf8');
writeFileSync(
pingoraEnvFile,
[
'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_PROBE_TOKEN=${options.pingoraProbeToken || 'fake-token'}`,
'',
].join('\n'),
pingoraEnvText(options),
'utf8',
);
writeFileSync(
@@ -686,6 +817,32 @@ function prepareFixture(name, options) {
};
}
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',
@@ -753,6 +910,7 @@ function prepareReleaseRoot(releaseRoot) {
'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',
@@ -778,6 +936,7 @@ function prepareReleaseRoot(releaseRoot) {
'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));
+43 -10
View File
@@ -16,6 +16,9 @@ import path from 'node:path';
const ENABLE_SCRIPT = 'scripts/deploy/pingora-direct-enable.sh';
const PREFLIGHT_SCRIPT = 'scripts/check-pingora-direct-preflight.mjs';
const failures = [];
const [DIRECT_TLS_PORT, DIRECT_HTTP_REDIRECT_PORT] = allocateLoopbackPorts(2);
const DIRECT_TLS_LISTEN = `127.0.0.1:${DIRECT_TLS_PORT}`;
const DIRECT_HTTP_REDIRECT_LISTEN = `127.0.0.1:${DIRECT_HTTP_REDIRECT_PORT}`;
const tmpRoot = mkdtempSync(path.join(tmpdir(), 'genarrative-pingora-enable-'));
@@ -271,8 +274,8 @@ function assertDirectPreflightRejectsPublicForwardedForTrust() {
writeFileSync(
envPath,
[
'GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN=127.0.0.1:18443',
'GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN=127.0.0.1:18080',
`GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN=${DIRECT_TLS_LISTEN}`,
`GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN=${DIRECT_HTTP_REDIRECT_LISTEN}`,
'GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE=/tmp/cert.pem',
'GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE=/tmp/key.pem',
'GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO=https',
@@ -292,8 +295,8 @@ function assertDirectPreflightRejectsPublicForwardedForTrust() {
writeFileSync(
envPath,
[
'GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN=127.0.0.1:18443',
'GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN=127.0.0.1:18080',
`GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN=${DIRECT_TLS_LISTEN}`,
`GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN=${DIRECT_HTTP_REDIRECT_LISTEN}`,
'GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE=/tmp/cert.pem',
'GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE=/tmp/key.pem',
'GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO=https',
@@ -390,8 +393,8 @@ function assertReleaseLayoutDryRunUsesBundledDirectChecks() {
writeFileSync(
envPath,
[
'GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN=127.0.0.1:18443',
'GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN=127.0.0.1:18080',
`GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN=${DIRECT_TLS_LISTEN}`,
`GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN=${DIRECT_HTTP_REDIRECT_LISTEN}`,
'GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO=https',
`GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE=${certPath}`,
`GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE=${keyPath}`,
@@ -833,8 +836,8 @@ function assertDryRunRunsPreflightWhenRequested() {
writeFileSync(
envPath,
[
'GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN=127.0.0.1:18443',
'GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN=127.0.0.1:18080',
`GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN=${DIRECT_TLS_LISTEN}`,
`GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN=${DIRECT_HTTP_REDIRECT_LISTEN}`,
'GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO=https',
`GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE=${certPath}`,
`GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE=${keyPath}`,
@@ -1845,8 +1848,8 @@ function assertApplyFailsWhenDirectLiveSmokeFails() {
writeFileSync(
envPath,
[
'GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN=127.0.0.1:18443',
'GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN=127.0.0.1:18080',
`GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN=${DIRECT_TLS_LISTEN}`,
`GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN=${DIRECT_HTTP_REDIRECT_LISTEN}`,
'GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO=https',
`GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE=${certPath}`,
`GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE=${keyPath}`,
@@ -2347,3 +2350,33 @@ function assertIncludes(content, needle, reason) {
failures.push(`${reason} 缺少: ${needle}`);
}
}
function allocateLoopbackPorts(count) {
const ports = [];
for (let offset = 0; ports.length < count && offset < 1000; offset += 1) {
const candidate = 28080 + offset;
const result = spawnSync(
process.execPath,
[
'-e',
[
"const net = require('node:net');",
`const server = net.createServer();`,
`server.once('error', () => process.exit(1));`,
`server.listen(${candidate}, '127.0.0.1', () => server.close(() => process.exit(0)));`,
].join(''),
],
{
cwd: process.cwd(),
encoding: 'utf8',
},
);
if ((result.status ?? 1) === 0) {
ports.push(candidate);
}
}
if (ports.length !== count) {
throw new Error('无法为 Pingora direct enable 自测分配空闲 loopback 端口。');
}
return ports;
}
@@ -452,6 +452,7 @@ function prepareReleaseRoot(releaseRoot) {
'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',
];
for (const relativePath of copyScripts) {
@@ -463,6 +464,7 @@ function prepareReleaseRoot(releaseRoot) {
'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',
]) {
chmodSync(path.join(releaseRoot, relativePath), 0o755);
@@ -0,0 +1,352 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import {
chmodSync,
existsSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
const SWITCH_SCRIPT = 'scripts/deploy/pingora-gateway-env-shadow-switch.mjs';
const failures = [];
const tmpRoot = mkdtempSync(
path.join(tmpdir(), 'genarrative-pingora-gateway-env-shadow-switch-'),
);
try {
main();
} finally {
rmSync(tmpRoot, { recursive: true, force: true });
}
if (failures.length > 0) {
console.error('[check:pingora-gateway-env-shadow-switch] FAILED');
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
console.log('[check:pingora-gateway-env-shadow-switch] OK');
function main() {
assertScriptShape();
assertDryRunDoesNotModifyEnv();
assertApplyRestoresShadowAndPreservesOtherKeys();
assertApplyPreservesEnvFileMode();
assertMissingManagedKeysAreAppended();
assertDuplicateManagedKeysFail();
assertRejectsRelativeAndRootEnvFile();
assertRejectsSymlinkEnvFileBeforeWrite();
assertRejectsControlCharacterEnvFile();
}
function assertScriptShape() {
const content = readFileSync(SWITCH_SCRIPT, 'utf8');
assertIncludes(content, '--apply', '切换脚本必须显式要求 --apply 才写 env。');
assertIncludes(
content,
'当前是 dry-run',
'切换脚本必须在 dry-run 中明确不会写 env。',
);
assertIncludes(
content,
'GENARRATIVE_PINGORA_GATEWAY_LISTEN: \'127.0.0.1:18081\'',
'切换脚本必须固定恢复 Pingora shadow 高端口监听。',
);
assertIncludes(
content,
'GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN: \'\'',
'切换脚本必须清空 TLS 低端口监听。',
);
assertIncludes(
content,
'GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN: \'\'',
'切换脚本必须清空 HTTP redirect 低端口监听。',
);
assertIncludes(
content,
'GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE: \'\'',
'切换脚本必须清空 TLS 证书链路径,避免无 TLS_LISTEN 但残留 cert。',
);
assertIncludes(
content,
'GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE: \'\'',
'切换脚本必须清空 TLS 私钥路径,避免无 TLS_LISTEN 但残留 key。',
);
assertIncludes(
content,
'assertShadowEnv(checkFile);',
'切换脚本必须先用临时目标 env 复核 shadow 口径。',
);
assertIncludes(
content,
'writeEnvFileAtomically(config.envFile, nextText);',
'切换脚本通过复核后才可原子写入真实 env。',
);
assertIncludes(
content,
'--env-file 不能是符号链接',
'apply 写入真实 env 前必须拒绝符号链接目标。',
);
assertIncludes(
content,
'DRY_RUN_ENV_FILE_MODE = 0o600',
'临时复核 env 文件权限必须固定为 0600。',
);
assertIncludes(
content,
'chownSync(tempFile, currentStat.uid, currentStat.gid);',
'真实 env 原子替换必须保留原文件 owner/group。',
);
}
function assertDryRunDoesNotModifyEnv() {
const envFile = writeEnv('dry-run.env', {
GENARRATIVE_PINGORA_GATEWAY_LISTEN: '0.0.0.0:443',
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/genarrative/pingora-tls/example/fullchain.pem',
GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE: '/etc/genarrative/pingora-tls/example/privkey.pem',
});
const before = readFileSync(envFile, 'utf8');
const result = runSwitch(['--env-file', envFile]);
assertStatus(result, 0, 'dry-run 应成功。');
assertEqual(
readFileSync(envFile, 'utf8'),
before,
'dry-run 不应修改真实 Pingora gateway env。',
);
assertIncludes(
result.stdout,
'当前是 dry-run',
'dry-run 输出必须明确不会写入 env。',
);
}
function assertApplyRestoresShadowAndPreservesOtherKeys() {
const envFile = writeEnv('apply.env', {
GENARRATIVE_PINGORA_GATEWAY_UPSTREAM_API: 'http://127.0.0.1:8082',
GENARRATIVE_PINGORA_GATEWAY_LISTEN: '0.0.0.0:443',
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/genarrative/pingora-tls/example/fullchain.pem',
GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE: '/etc/genarrative/pingora-tls/example/privkey.pem',
GENARRATIVE_PINGORA_GATEWAY_PROTECTION_ENABLED: 'true',
});
const result = runSwitch(['--env-file', envFile, '--apply']);
const content = readFileSync(envFile, 'utf8');
assertStatus(result, 0, 'apply 应成功。');
assertIncludes(
content,
'GENARRATIVE_PINGORA_GATEWAY_LISTEN=127.0.0.1:18081',
'apply 必须恢复 shadow 高端口监听。',
);
assertIncludes(
content,
'GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN=',
'apply 必须清空 TLS 低端口监听。',
);
assertIncludes(
content,
'GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN=',
'apply 必须清空 HTTP redirect 低端口监听。',
);
assertIncludes(
content,
'GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE=',
'apply 必须清空 TLS 证书链路径。',
);
assertIncludes(
content,
'GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE=',
'apply 必须清空 TLS 私钥路径。',
);
assertIncludes(
content,
'GENARRATIVE_PINGORA_GATEWAY_UPSTREAM_API=http://127.0.0.1:8082',
'apply 不应修改其它 Pingora gateway env。',
);
assertIncludes(
content,
'GENARRATIVE_PINGORA_GATEWAY_PROTECTION_ENABLED=true',
'apply 不应修改接流保护配置。',
);
}
function assertApplyPreservesEnvFileMode() {
const envFile = writeEnv('mode.env', {
GENARRATIVE_PINGORA_GATEWAY_LISTEN: '0.0.0.0:443',
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/genarrative/pingora-tls/example/fullchain.pem',
GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE: '/etc/genarrative/pingora-tls/example/privkey.pem',
});
chmodSync(envFile, 0o640);
const before = statSync(envFile);
const result = runSwitch(['--env-file', envFile, '--apply']);
const after = statSync(envFile);
assertStatus(result, 0, 'apply 保留权限 smoke 应成功。');
assertEqual(
after.mode & 0o777,
before.mode & 0o777,
'apply 原子替换必须保留 env 文件权限。',
);
}
function assertMissingManagedKeysAreAppended() {
const envFile = writeEnv('append.env', {
GENARRATIVE_PINGORA_GATEWAY_UPSTREAM_API: 'http://127.0.0.1:8082',
});
const result = runSwitch(['--env-file', envFile, '--apply']);
const content = readFileSync(envFile, 'utf8');
assertStatus(result, 0, '缺失目标键时 apply 应成功。');
assertIncludes(
content,
'GENARRATIVE_PINGORA_GATEWAY_LISTEN=127.0.0.1:18081',
'缺失 LISTEN 时必须追加 shadow 默认值。',
);
assertIncludes(
content,
'GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN=',
'缺失 TLS_LISTEN 时必须追加空值。',
);
assertIncludes(
content,
'GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN=',
'缺失 HTTP_REDIRECT_LISTEN 时必须追加空值。',
);
assertIncludes(
content,
'GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE=',
'缺失 TLS_CERT_FILE 时必须追加空值。',
);
assertIncludes(
content,
'GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE=',
'缺失 TLS_KEY_FILE 时必须追加空值。',
);
}
function assertDuplicateManagedKeysFail() {
const envFile = path.join(tmpRoot, 'duplicate.env');
writeFileSync(
envFile,
[
'GENARRATIVE_PINGORA_GATEWAY_LISTEN=0.0.0.0:443',
'GENARRATIVE_PINGORA_GATEWAY_LISTEN=127.0.0.1:18081',
'',
].join('\n'),
'utf8',
);
const result = runSwitch(['--env-file', envFile]);
assertStatus(result, 1, '重复目标键必须失败。');
assertIncludes(
result.stderr,
'pingora gateway env 中存在重复配置',
'重复目标键失败时必须说明具体原因。',
);
}
function assertRejectsRelativeAndRootEnvFile() {
const relative = runSwitch(['--env-file', 'relative.env']);
assertStatus(relative, 1, '相对 env 路径必须失败。');
assertIncludes(
relative.stderr,
'--env-file 必须是绝对路径',
'相对 env 路径失败时必须说明原因。',
);
const root = runSwitch(['--env-file', '/']);
assertStatus(root, 1, '文件系统根目录 env 路径必须失败。');
assertIncludes(
root.stderr,
'--env-file 不能是文件系统根目录',
'根目录 env 路径失败时必须说明原因。',
);
}
function assertRejectsSymlinkEnvFileBeforeWrite() {
const target = writeEnv('symlink-target.env', {
GENARRATIVE_PINGORA_GATEWAY_LISTEN: '0.0.0.0:443',
});
const link = path.join(tmpRoot, 'symlink.env');
symlinkSync(target, link);
const before = readFileSync(target, 'utf8');
const result = runSwitch(['--env-file', link, '--apply']);
assertStatus(result, 1, '符号链接 env 必须失败。');
assertIncludes(
result.stderr,
'--env-file 不能是符号链接',
'符号链接 env 失败时必须说明原因。',
);
assertEqual(
readFileSync(target, 'utf8'),
before,
'符号链接 env 被拒绝后不应写真实目标文件。',
);
}
function assertRejectsControlCharacterEnvFile() {
const result = spawnSync(
process.execPath,
['--', SWITCH_SCRIPT, '--env-file', `${tmpRoot}/bad\n.env`],
{
cwd: process.cwd(),
encoding: 'utf8',
},
);
assertStatus(result, 1, '控制字符 env 路径必须失败。');
assertIncludes(
result.stderr,
'--env-file 不能包含换行或 NUL 字符',
'控制字符 env 路径失败时必须说明原因。',
);
}
function runSwitch(args) {
return spawnSync(process.execPath, ['--', SWITCH_SCRIPT, ...args], {
cwd: process.cwd(),
encoding: 'utf8',
});
}
function writeEnv(fileName, values) {
const filePath = path.join(tmpRoot, fileName);
const lines = Object.entries(values).map(([key, value]) => `${key}=${value}`);
writeFileSync(filePath, `${lines.join('\n')}\n`, 'utf8');
return filePath;
}
function assertStatus(result, expected, message) {
if ((result.status ?? 0) !== expected) {
failures.push(
`${message} 实际退出码 ${result.status}。stdout=${result.stdout || '<empty>'} stderr=${result.stderr || '<empty>'}`,
);
}
}
function assertIncludes(value, expected, message) {
if (!String(value || '').includes(expected)) {
failures.push(`${message} 缺少 ${expected}。`);
}
}
function assertEqual(actual, expected, message) {
if (actual !== expected) {
failures.push(`${message} 实际 ${actual},预期 ${expected}。`);
}
}
+258 -2
View File
@@ -19,6 +19,9 @@ const sockets = new Set();
const tempDirs = [];
const SPACETIME_WEBSOCKET_PROTOCOL = 'v2.bsatn.spacetimedb';
const SECRET_VALUE_FLAGS = new Set(['--probe-token']);
const PNG_MAGIC_BYTES = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
]);
const config = parseArgs(process.argv.slice(2));
@@ -54,6 +57,7 @@ async function main() {
const api = await startApiMock();
const spacetime = await startSpacetimeMock();
const gitea = await startGiteaMock();
const listenPort = await getFreePort();
const tlsListenPort = await getFreePort();
const redirectListenPort = await getFreePort();
@@ -120,6 +124,22 @@ async function main() {
'GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN',
'HTTP 重定向未配置 TLS 入口',
);
expectInvalidConfigRejected(
gatewayBinary,
{
GENARRATIVE_PINGORA_GATEWAY_GITEA_HOSTS: 'git.genarrative.world',
},
'GENARRATIVE_PINGORA_GATEWAY_GITEA_UPSTREAM',
'Gitea Host 缺少上游',
);
expectInvalidConfigRejected(
gatewayBinary,
{
GENARRATIVE_PINGORA_GATEWAY_GITEA_UPSTREAM: '127.0.0.1:3000',
},
'GENARRATIVE_PINGORA_GATEWAY_GITEA_HOSTS',
'Gitea 上游缺少 Host',
);
const gateway = spawn(gatewayBinary, [], {
cwd: repoRoot,
env: {
@@ -132,6 +152,9 @@ async function main() {
GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_TARGET_SCHEME: 'https',
GENARRATIVE_PINGORA_GATEWAY_API_UPSTREAM: `127.0.0.1:${api.port}`,
GENARRATIVE_PINGORA_GATEWAY_SPACETIME_UPSTREAM: `127.0.0.1:${spacetime.port}`,
GENARRATIVE_PINGORA_GATEWAY_GITEA_HOSTS:
'git.genarrative.world, Git-Alias.Genarrative.World:443',
GENARRATIVE_PINGORA_GATEWAY_GITEA_UPSTREAM: `127.0.0.1:${gitea.port}`,
GENARRATIVE_PINGORA_GATEWAY_WEB_ROOT: webRoot,
GENARRATIVE_PINGORA_GATEWAY_ACME_ROOT: acmeRoot,
GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_FILE: maintenanceFile,
@@ -188,6 +211,7 @@ async function main() {
accessLogFile,
api,
spacetime,
gitea,
);
}
@@ -207,9 +231,12 @@ function expectInvalidConfigRejected(
},
shell: false,
encoding: 'utf8',
timeout: 3000,
});
if (result.status === 0) {
if (result.error?.code === 'ETIMEDOUT') {
failures.push(`配置错误启动失败: ${label} 未被拒绝,进程超时后已终止`);
} else if (result.status === 0) {
failures.push(`配置错误启动失败: ${label} 未被拒绝`);
}
const output = `${result.stdout || ''}\n${result.stderr || ''}`;
@@ -253,6 +280,17 @@ function parseArgs(argv) {
async function prepareStaticRoots(webRoot, acmeRoot) {
await mkdir(path.join(webRoot, 'admin', 'assets'), { recursive: true });
await mkdir(path.join(webRoot, 'assets'), { recursive: true });
await mkdir(
path.join(
webRoot,
'Icons',
"Admurin's Pixel Items",
"Admurin's Pixel Items",
'General',
'Singles',
),
{ recursive: true },
);
await mkdir(path.join(acmeRoot, '.well-known', 'acme-challenge'), {
recursive: true,
});
@@ -285,6 +323,21 @@ async function prepareStaticRoots(webRoot, acmeRoot) {
path.join(webRoot, 'assets', 'large-image.webp'),
Buffer.alloc(4096, 0x52),
);
await writeFile(
path.join(
webRoot,
'Icons',
"Admurin's Pixel Items",
"Admurin's Pixel Items",
'General',
'Singles',
'499_Iron_Gear.png',
),
Buffer.concat([
PNG_MAGIC_BYTES,
Buffer.from([0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52]),
]),
);
await writeFile(
path.join(webRoot, 'admin', 'assets', 'admin.js'),
'console.log("admin asset");',
@@ -454,6 +507,43 @@ async function startSpacetimeMock() {
};
}
async function startGiteaMock() {
const state = {
requests: [],
};
const server = http.createServer(async (request, response) => {
const body = await readRequestBody(request);
state.requests.push({
method: request.method || '',
url: request.url || '',
headers: request.headers,
body,
});
response.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'X-Upstream': 'gitea',
});
response.end(
JSON.stringify({
ok: true,
upstream: 'gitea',
method: request.method,
url: request.url,
forwardedProto: request.headers['x-forwarded-proto'] || '',
forwardedHost: request.headers['x-forwarded-host'] || '',
host: request.headers.host || '',
}),
);
});
const port = await listen(server);
return {
port,
state,
};
}
async function runSmokeCases(
baseUrl,
tlsBaseUrl,
@@ -463,6 +553,7 @@ async function runSmokeCases(
accessLogFile,
api,
spacetime,
gitea,
) {
await expectHttp(
baseUrl,
@@ -677,6 +768,32 @@ async function runSmokeCases(
undefined,
'gzip types 不压缩图片资源',
);
await expectStaticPng(
baseUrl,
"/Icons/Admurin%27s%20Pixel%20Items/Admurin%27s%20Pixel%20Items/General/Singles/499_Iron_Gear.png",
'百分号编码图标路径返回 PNG',
);
await expectHttp(
baseUrl,
'/assets%2ffavicon.svg',
404,
'',
'静态路径拒绝编码斜杠',
);
await expectRawPathHttp(
baseUrl,
'/assets/%2e%2e/favicon.svg',
404,
'',
'静态路径拒绝编码上级目录',
);
await expectHttp(
baseUrl,
'/assets/%GG',
404,
'',
'静态路径拒绝非法百分号编码',
);
await expectHttp(
baseUrl,
'/admin/assets/admin.js',
@@ -894,6 +1011,50 @@ async function runSmokeCases(
spacetime,
);
const giteaBeforeRequests = gitea.state.requests.length;
const giteaResponse = await expectHttp(
baseUrl,
'/api/v1/repos/GenarrativeAI/Genarrative',
200,
'"upstream":"gitea"',
'Gitea Host 路由转发',
{
headers: {
Host: 'git.genarrative.world:443',
'X-Request-Id': 'gitea-host-request-id',
},
},
);
const giteaPayload = JSON.parse(giteaResponse.body || '{}');
ensure(
giteaPayload.host === 'git.genarrative.world:443',
`Gitea 上游 Host 未透传:${giteaPayload.host || '-'}`,
);
ensure(
giteaPayload.forwardedHost === 'git.genarrative.world:443',
`Gitea 上游 X-Forwarded-Host 未透传:${giteaPayload.forwardedHost || '-'}`,
);
ensure(
giteaPayload.forwardedProto === 'https',
`Gitea 上游 X-Forwarded-Proto 未对齐:${giteaPayload.forwardedProto || '-'}`,
);
await expectHttp(
baseUrl,
'/api/v1/repos/GenarrativeAI/Genarrative',
200,
'"upstream":"gitea"',
'Gitea Host 别名路由转发',
{
headers: {
Host: 'git-alias.genarrative.world',
},
},
);
ensure(
gitea.state.requests.length === giteaBeforeRequests + 2,
`Gitea Host 路由没有稳定打到 Gitea mock:${gitea.state.requests.length - giteaBeforeRequests}`,
);
await expectAccessLog(accessLogFile);
await expectAccessLogEntries(accessLogFile, [
{
@@ -932,10 +1093,34 @@ async function runSmokeCases(
path: '/assets/app.js',
status: 206,
},
{
requestId: 'gitea-host-request-id',
method: 'GET',
path: '/api/v1/repos/GenarrativeAI/Genarrative',
status: 200,
proxyTarget: 'Gitea',
},
]);
await mkdir(path.dirname(maintenanceFile), { recursive: true });
await writeFile(maintenanceFile, 'enabled');
const giteaRequestsBeforeMaintenance = gitea.state.requests.length;
await expectHttp(
baseUrl,
'/user/login',
200,
'"upstream":"gitea"',
'维护模式不拦截 Gitea Host',
{
headers: {
Host: 'git.genarrative.world',
},
},
);
ensure(
gitea.state.requests.length === giteaRequestsBeforeMaintenance + 1,
'维护模式 Gitea Host 请求没有打到 Gitea mock',
);
await expectHttp(
baseUrl,
'/api/creation-entry/config',
@@ -1116,6 +1301,49 @@ async function expectNotCompressedResponse(baseUrl, route, bodyNeedle, label) {
}
}
async function expectStaticPng(baseUrl, route, label) {
console.log(`[pingora-gateway-smoke] ${label}`);
const response = await requestHttp(`${baseUrl}${route}`, {
rawBody: true,
});
if (response.status !== 200) {
failures.push(`${label}: 期望 HTTP 200,实际 ${response.status}`);
return;
}
if (!String(response.headers['content-type'] || '').startsWith('image/png')) {
failures.push(
`${label}: 期望 Content-Type=image/png,实际 ${response.headers['content-type'] || '-'}`,
);
}
if (!response.bodyBuffer.subarray(0, 8).equals(PNG_MAGIC_BYTES)) {
failures.push(`${label}: 响应体不是 PNG magic bytes`);
}
if (response.body.includes('<!doctype html>')) {
failures.push(`${label}: 命中了 SPA fallback HTML`);
}
}
async function expectRawPathHttp(baseUrl, route, status, bodyNeedle, label) {
console.log(`[pingora-gateway-smoke] ${label}`);
let response;
try {
response = await rawHttpPathRequest(baseUrl, route);
} catch (error) {
failures.push(
`${label}: ${error instanceof Error ? error.message : String(error)}`,
);
return;
}
if (response.status !== status) {
failures.push(`${label}: 期望 HTTP ${status},实际 ${response.status}`);
}
if (bodyNeedle && !response.body.includes(bodyNeedle)) {
failures.push(`${label}: 响应体缺少 ${bodyNeedle}`);
}
}
async function expectCompressedResponse(baseUrl, route, bodyNeedle, options) {
const label = options.label;
console.log(`[pingora-gateway-smoke] ${label}`);
@@ -1183,7 +1411,7 @@ async function expectAccessLogEntries(accessLogFile, expectedEntries) {
entry.method === expected.method &&
entry.path === expected.path &&
Number.parseInt(entry.status || '', 10) === expected.status &&
entry.proxy_target === 'Local'
entry.proxy_target === (expected.proxyTarget || 'Local')
);
});
} catch {
@@ -1469,6 +1697,22 @@ async function rawHttpRequest(url, headers = {}) {
});
}
function rawHttpPathRequest(baseUrl, route, headers = {}) {
return new Promise((resolve, reject) => {
const target = new URL(baseUrl);
const socket = net.connect(
{ host: target.hostname, port: Number(target.port) },
() => {
socket.write(buildRawHttpPathRequest(target, route, headers));
collectRawHttpResponse(socket, `${baseUrl}${route}`, 35000)
.then(resolve, reject)
.finally(() => socket.destroy());
},
);
socket.on('error', reject);
});
}
function buildRawHttpRequest(target, headers) {
const mergedHeaders = {
Host: target.host,
@@ -1486,6 +1730,18 @@ function buildRawHttpRequest(target, headers) {
].join('\r\n');
}
function buildRawHttpPathRequest(target, route, headers) {
const mergedHeaders = {
Host: target.host,
Connection: 'close',
...headers,
};
const headerLines = Object.entries(mergedHeaders).map(
([name, value]) => `${name}: ${value}`,
);
return [`GET ${route} HTTP/1.1`, ...headerLines, '', ''].join('\r\n');
}
function collectRawHttpResponse(socket, url, timeoutMs) {
return new Promise((resolve, reject) => {
let raw = Buffer.alloc(0);
File diff suppressed because it is too large Load Diff
+185 -32
View File
@@ -146,13 +146,11 @@ function parseArgs(argv) {
),
cutoverRunId: process.env.GENARRATIVE_PINGORA_CUTOVER_RUN_ID || '',
rollbackNginxSmokeUrl:
process.env.GENARRATIVE_PINGORA_ROLLBACK_NGINX_SMOKE_URL ||
'http://127.0.0.1/healthz',
process.env.GENARRATIVE_PINGORA_ROLLBACK_NGINX_SMOKE_URL || '',
rollbackNginxSmokeHost:
process.env.GENARRATIVE_PINGORA_ROLLBACK_NGINX_SMOKE_HOST || '',
rollbackNginxSmokeExpectBody:
process.env.GENARRATIVE_PINGORA_ROLLBACK_NGINX_SMOKE_EXPECT_BODY ||
'"ok":true',
process.env.GENARRATIVE_PINGORA_ROLLBACK_NGINX_SMOKE_EXPECT_BODY || '',
rollbackHealthPatrolPublicBaseUrl:
process.env.GENARRATIVE_PINGORA_ROLLBACK_HEALTH_PATROL_PUBLIC_BASE_URL ||
'',
@@ -502,9 +500,9 @@ function parseArgs(argv) {
'启用 --require-direct 时必须提供 --direct-preflight-check-service-binary-executable 或 GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_CHECK_SERVICE_BINARY_EXECUTABLE=true',
);
}
if (result.requireDirect && !result.directPreflightCheckPortsFree) {
if (result.dryRunCutover && !result.directPreflightCheckPortsFree) {
throw new Error(
'启用 --require-direct 时必须提供 --direct-preflight-check-ports-free 或 GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_CHECK_PORTS_FREE=true',
'启用 --dry-run-cutover 时必须提供 --direct-preflight-check-ports-free 或 GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_CHECK_PORTS_FREE=true,用于切换前证明 Nginx 和其它进程已释放 80/443。',
);
}
if (result.requireDirect && result.directSkipWss) {
@@ -567,6 +565,11 @@ function parseArgs(argv) {
'启用 --dry-run-cutover 时必须提供 --rollback-nginx-smoke-url 或 GENARRATIVE_PINGORA_ROLLBACK_NGINX_SMOKE_URL。',
);
}
if (result.dryRunCutover && !result.rollbackNginxSmokeExpectBody) {
throw new Error(
'启用 --dry-run-cutover 时必须提供 --rollback-nginx-smoke-expect-body 或 GENARRATIVE_PINGORA_ROLLBACK_NGINX_SMOKE_EXPECT_BODY,且该片段必须来自切换前真实 Nginx 入口。',
);
}
if (result.dryRunCutover && !result.rollbackNginxSmokeHost) {
result.rollbackNginxSmokeHost = result.directHost || result.liveHost;
}
@@ -711,7 +714,7 @@ Options:
--direct-preflight-check-service-binary-executable
direct entry 预检要求 service ExecStart 指向的 pingora-gateway 已存在且可执行;--require-direct 时必须显式提供。
--direct-preflight-check-ports-free
direct entry 预检要求 TLS/HTTP redirect 端口当前可绑定;--require-direct 时必须显式提供。
direct entry 预检要求 TLS/HTTP redirect 端口当前可绑定;--dry-run-cutover / direct enable apply 前必须显式提供,启用后 --require-direct 复核不要求端口空闲。
--direct-spacetime-database <name>
direct live WSS subscribe 使用的数据库名;也可用 GENARRATIVE_PINGORA_DIRECT_SPACETIME_DATABASE。--require-direct 时必须显式提供。
--direct-health-patrol-env-file <path>
@@ -728,11 +731,11 @@ Options:
生成切换 runbook 时传给证据根目录总审计的标准五段时间线最大跨度,默认 ${DEFAULT_CUTOVER_EVIDENCE_TIMELINE_MAX_SPAN_MS}ms。
--cutover-run-id <id> 生成切换 runbook 时写入所有证据 manifest 的本次切换批次 ID;不传则自动生成 ${DEFAULT_CUTOVER_RUN_ID_PREFIX}<UTC时间>。
--rollback-nginx-smoke-url <url>
生成切换 runbook 时传给回退脚本的 Nginx smoke URL,默认 http://127.0.0.1/healthz。
生成切换 runbook 时传给回退脚本的 Nginx smoke URL;必须显式填写切换前真实 Nginx 入口。
--rollback-nginx-smoke-host <host>
生成切换 runbook 时传给回退脚本的 Host header;默认复用 --direct-host,显式传入时必须和 --direct-host 使用同一 hostname。
--rollback-nginx-smoke-expect-body <text>
生成切换 runbook 时传给回退脚本的 Nginx smoke 响应体预期片段,默认 "ok":true。
生成切换 runbook 时传给回退脚本的 Nginx smoke 响应体预期片段;必须显式来自切换前真实响应,例如首页 <!doctype html>。
--rollback-health-patrol-public-base-url <url>
生成切换 runbook 时回退后恢复的 Nginx public base URL;应填切换前 health-patrol env 的原值。
--rollback-health-patrol-public-host <host>
@@ -751,10 +754,10 @@ Options:
node scripts/check-pingora-release-readiness.mjs --require-docker --pull-docker --require-nginx --require-live --live-base-url http://127.0.0.1 --live-host <域名>
Pingora 直连入口切换窗口追加:
node scripts/check-pingora-release-readiness.mjs --require-direct --direct-https-base-url https://127.0.0.1 --direct-http-base-url http://127.0.0.1 --direct-host <域名> --direct-redirect-host <域名或host:port> --direct-spacetime-database <库名> --direct-pingora-access-log /var/log/genarrative/pingora-gateway.access.log --direct-health-patrol-env-file /etc/genarrative/health-patrol.env --direct-preflight-env-file /etc/genarrative/pingora-gateway.env --direct-preflight-systemd --direct-preflight-check-cert-readable --direct-preflight-check-service-env-file --direct-preflight-check-service-user-cert-readable --direct-preflight-check-service-binary-executable --direct-preflight-check-ports-free
node scripts/check-pingora-release-readiness.mjs --require-direct --direct-https-base-url https://127.0.0.1 --direct-http-base-url http://127.0.0.1 --direct-host <域名> --direct-redirect-host <域名或host:port> --direct-spacetime-database <库名> --direct-pingora-access-log /var/log/genarrative/pingora-gateway.access.log --direct-health-patrol-env-file /etc/genarrative/health-patrol.env --direct-preflight-env-file /etc/genarrative/pingora-gateway.env --direct-preflight-systemd --direct-preflight-check-cert-readable --direct-preflight-check-service-env-file --direct-preflight-check-service-user-cert-readable --direct-preflight-check-service-binary-executable
只生成直连切换 runbook:
node scripts/check-pingora-release-readiness.mjs --dry-run-cutover --require-direct --direct-https-base-url https://127.0.0.1 --direct-http-base-url http://127.0.0.1 --direct-host <域名> --direct-redirect-host <域名或host:port> --direct-spacetime-database <库名> --direct-pingora-access-log /var/log/genarrative/pingora-gateway.access.log --direct-health-patrol-env-file /etc/genarrative/health-patrol.env --direct-preflight-env-file /etc/genarrative/pingora-gateway.env --direct-preflight-systemd --direct-preflight-check-cert-readable --direct-preflight-check-service-env-file --direct-preflight-check-service-user-cert-readable --direct-preflight-check-service-binary-executable --direct-preflight-check-ports-free --cutover-evidence-output-root /var/log/genarrative/pingora-cutover-evidence --rollback-nginx-smoke-url http://127.0.0.1/healthz --rollback-health-patrol-public-base-url http://127.0.0.1
node scripts/check-pingora-release-readiness.mjs --dry-run-cutover --require-direct --direct-https-base-url https://127.0.0.1 --direct-http-base-url http://127.0.0.1 --direct-host <域名> --direct-redirect-host <域名或host:port> --direct-spacetime-database <库名> --direct-pingora-access-log /var/log/genarrative/pingora-gateway.access.log --direct-health-patrol-env-file /etc/genarrative/health-patrol.env --direct-preflight-env-file /etc/genarrative/pingora-gateway.env --direct-preflight-systemd --direct-preflight-check-cert-readable --direct-preflight-check-service-env-file --direct-preflight-check-service-user-cert-readable --direct-preflight-check-service-binary-executable --direct-preflight-check-ports-free --cutover-evidence-output-root /var/log/genarrative/pingora-cutover-evidence --rollback-nginx-smoke-url https://<域名>/ --rollback-nginx-smoke-expect-body '<!doctype html>' --rollback-health-patrol-public-base-url http://127.0.0.1
`);
}
@@ -928,6 +931,10 @@ function buildCutoverPlan(config) {
releaseRoot,
'scripts/deploy/pingora-direct-rollback.sh',
);
const gatewayEnvShadowSwitchScript = path.join(
releaseRoot,
'scripts/deploy/pingora-gateway-env-shadow-switch.mjs',
);
const healthPatrolEnvSwitchScript = path.join(
releaseRoot,
'scripts/deploy/pingora-health-patrol-env-switch.mjs',
@@ -1048,6 +1055,34 @@ function buildCutoverPlan(config) {
config.rollbackPingoraShadowProbeToken,
);
}
const healthPatrolDirectSwitchArgs = [
'--apply',
'--env-file',
config.directHealthPatrolEnvFile,
'--gateway-mode',
'pingora-direct',
'--public-base-url',
config.directHttpsBaseUrl,
'--public-host',
config.directHost,
];
const pingoraGatewayShadowSwitchArgs = [
'--apply',
'--env-file',
config.directPreflightEnvFile,
];
const healthPatrolNginxSwitchArgs = [
'--apply',
'--env-file',
config.directHealthPatrolEnvFile,
'--gateway-mode',
'nginx',
'--public-base-url',
config.rollbackHealthPatrolPublicBaseUrl,
...(config.rollbackHealthPatrolPublicHost
? ['--public-host', config.rollbackHealthPatrolPublicHost]
: ['--clear-public-host']),
];
return [
{
@@ -1196,18 +1231,39 @@ function buildCutoverPlan(config) {
command: 'node',
args: [
'--',
commandEvidenceScript,
'--phase',
'post-enable',
'--command-name',
'pingora-health-patrol-direct-env-switch',
'--cutover-run-id',
config.cutoverRunId,
'--output-root',
config.cutoverEvidenceOutputRoot,
'--expected-executable',
healthPatrolEnvSwitchScript,
'--require-arg',
'--apply',
'--env-file',
config.directHealthPatrolEnvFile,
'--gateway-mode',
'--require-arg',
'pingora-direct',
'--public-base-url',
config.directHttpsBaseUrl,
'--public-host',
config.directHost,
'--',
healthPatrolEnvSwitchScript,
...healthPatrolDirectSwitchArgs,
],
when: 'direct enable apply 成功后执行,避免巡检继续要求 nginx.service active。',
when: 'direct enable apply 成功后执行,并用命令证据归档 health patrol env 从 nginx 切到 pingora-direct 的真实 stdout / stderr / 退出码,避免巡检继续要求 nginx.service active。',
},
{
phase: 'post-enable',
name: '切换后 health patrol direct 命令证据 manifest 只读验真',
command: 'node',
args: [
'--',
evidenceVerifyScript,
'--bundle-dir',
'<health-patrol-direct-env-switch-bundle-dir>',
'--require-summary-ok',
],
when: 'health patrol direct env switch 命令证据生成后,将上一步 stdout 中的 bundleDir 替换占位符执行,确认 env 切换命令 stdout / stderr / command-record 与 manifest 元数据一致,且 manifest.summary.status 为 OK。',
},
{
phase: 'post-enable',
@@ -1251,6 +1307,8 @@ function buildCutoverPlan(config) {
config.directPreflightEnvFile,
'--expected-gateway-mode',
'pingora-direct',
'--expected-pingora-env-mode',
'direct',
'--expected-public-base-url',
config.directHttpsBaseUrl,
'--expected-public-host',
@@ -1302,8 +1360,12 @@ function buildCutoverPlan(config) {
args: [
'--',
releaseReadinessScript,
...readinessArgs(config, { includeDirect: true }),
...readinessArgs(config, {
includeDirect: true,
includeDirectPortsFree: false,
}),
],
when: 'Pingora 已接管 80/443 后执行;此阶段不再检查端口空闲,因为端口应由 Pingora 直连入口占用。',
},
{
phase: 'rollback-dry-run',
@@ -1312,25 +1374,83 @@ function buildCutoverPlan(config) {
args: rollbackBaseArgs,
when: '启用失败、direct live smoke 失败或业务验证失败时先执行。',
},
{
phase: 'rollback-prep',
name: '回退前 Pingora env 预置回 shadow',
command: 'node',
args: [
'--',
commandEvidenceScript,
'--phase',
'rollback-prep',
'--command-name',
'pingora-gateway-shadow-env-switch',
'--cutover-run-id',
config.cutoverRunId,
'--output-root',
config.cutoverEvidenceOutputRoot,
'--expected-executable',
gatewayEnvShadowSwitchScript,
'--require-arg',
'--apply',
'--',
gatewayEnvShadowSwitchScript,
...pingoraGatewayShadowSwitchArgs,
],
when: 'rollback apply 前执行,并用命令证据归档 Pingora gateway env 从 80/443 direct 配置恢复为 shadow 高端口配置;否则回退脚本移除低端口 capability 后重启 Pingora 可能仍按 80/443 配置启动失败。',
},
{
phase: 'rollback-prep',
name: '回退前 Pingora env shadow 命令证据 manifest 只读验真',
command: 'node',
args: [
'--',
evidenceVerifyScript,
'--bundle-dir',
'<pingora-gateway-shadow-env-switch-bundle-dir>',
'--require-summary-ok',
],
when: 'Pingora gateway shadow env switch 命令证据生成后,将上一步 stdout 中的 bundleDir 替换占位符执行,确认 env 切换命令 stdout / stderr / command-record 与 manifest 元数据一致,且 manifest.summary.status 为 OK。',
},
{
phase: 'rollback-prep',
name: '回退前 health patrol 预置回 Nginx',
command: 'node',
args: [
'--',
commandEvidenceScript,
'--phase',
'rollback-prep',
'--command-name',
'pingora-health-patrol-nginx-env-switch',
'--cutover-run-id',
config.cutoverRunId,
'--output-root',
config.cutoverEvidenceOutputRoot,
'--expected-executable',
healthPatrolEnvSwitchScript,
'--require-arg',
'--apply',
'--env-file',
config.directHealthPatrolEnvFile,
'--gateway-mode',
'--require-arg',
'nginx',
'--public-base-url',
config.rollbackHealthPatrolPublicBaseUrl,
...(config.rollbackHealthPatrolPublicHost
? ['--public-host', config.rollbackHealthPatrolPublicHost]
: ['--clear-public-host']),
'--',
healthPatrolEnvSwitchScript,
...healthPatrolNginxSwitchArgs,
],
when: 'rollback apply 前执行,确保回退脚本内置 health patrol env 复核能校验切换前 public base URL / Host。',
when: 'rollback apply 前执行,并用命令证据归档 health patrol env 从 pingora-direct 预置回 nginx,确保回退脚本内置 health patrol env 复核能校验切换前 public base URL / Host。',
},
{
phase: 'rollback-prep',
name: '回退前 health patrol nginx 命令证据 manifest 只读验真',
command: 'node',
args: [
'--',
evidenceVerifyScript,
'--bundle-dir',
'<health-patrol-nginx-env-switch-bundle-dir>',
'--require-summary-ok',
],
when: 'health patrol nginx env switch 命令证据生成后,将上一步 stdout 中的 bundleDir 替换占位符执行,确认 env 切换命令 stdout / stderr / command-record 与 manifest 元数据一致,且 manifest.summary.status 为 OK。',
},
{
phase: 'rollback-apply',
@@ -1420,6 +1540,8 @@ function buildCutoverPlan(config) {
config.directPreflightEnvFile,
'--expected-gateway-mode',
'nginx',
'--expected-pingora-env-mode',
'shadow',
'--expected-public-base-url',
config.rollbackHealthPatrolPublicBaseUrl,
...(config.rollbackHealthPatrolPublicHost
@@ -1465,29 +1587,53 @@ function buildCutoverPlan(config) {
'post-enable',
'--require-phase-direct-live-static-headers',
'post-enable',
'--require-phase-pingora-env-shadow',
'post-rollback',
'--require-command',
'enable-apply:pingora-direct-enable-apply',
'--require-command',
'post-enable:pingora-health-patrol-direct-env-switch',
'--require-command',
'rollback-prep:pingora-gateway-shadow-env-switch',
'--require-command',
'rollback-prep:pingora-health-patrol-nginx-env-switch',
'--require-command',
'rollback-apply:pingora-direct-rollback-apply',
'--require-command-executable',
`enable-apply:pingora-direct-enable-apply:${enableScript}`,
'--require-command-executable',
`post-enable:pingora-health-patrol-direct-env-switch:${healthPatrolEnvSwitchScript}`,
'--require-command-executable',
`rollback-prep:pingora-gateway-shadow-env-switch:${gatewayEnvShadowSwitchScript}`,
'--require-command-executable',
`rollback-prep:pingora-health-patrol-nginx-env-switch:${healthPatrolEnvSwitchScript}`,
'--require-command-executable',
`rollback-apply:pingora-direct-rollback-apply:${rollbackScript}`,
'--require-command-arg',
'enable-apply:pingora-direct-enable-apply:--apply',
'--require-command-arg',
'post-enable:pingora-health-patrol-direct-env-switch:--apply',
'--require-command-arg',
'post-enable:pingora-health-patrol-direct-env-switch:pingora-direct',
'--require-command-arg',
'rollback-prep:pingora-gateway-shadow-env-switch:--apply',
'--require-command-arg',
'rollback-prep:pingora-health-patrol-nginx-env-switch:--apply',
'--require-command-arg',
'rollback-prep:pingora-health-patrol-nginx-env-switch:nginx',
'--require-command-arg',
'rollback-apply:pingora-direct-rollback-apply:--apply',
'--require-cutover-run-id',
config.cutoverRunId,
'--timeline-max-span-ms',
String(config.cutoverEvidenceTimelineMaxSpanMs),
],
when: '三阶段证据包和 enable / rollback apply 命令证据都生成并分别验真后执行,自动找每个 phase 与命令的最新 bundleDir 并再次做只读 manifest 验真,且要求 post-enable manifest.summary.directLiveAccessLog 和 directLiveStaticHeaders 可直接复盘 request_id 对账、静态缓存、校验器、Range 和 304 证据。',
when: '三阶段证据包、enable / rollback apply 命令证据和三条 env 变更命令证据都生成并分别验真后执行,自动找每个 phase 与命令的最新 bundleDir 并再次做只读 manifest 验真,且要求 post-enable manifest.summary.directLiveAccessLog 和 directLiveStaticHeaders 可直接复盘 request_id 对账、静态缓存、校验器、Range 和 304 证据,同时要求 post-rollback manifest.summary.pingoraEnvShadow 证明 Pingora env 已恢复 shadow 高端口。',
},
];
}
function readinessArgs(config, { includeDirect }) {
function readinessArgs(config, { includeDirect, includeDirectPortsFree = true }) {
return [
'--release-runtime-only',
...(config.requireLive
@@ -1546,7 +1692,9 @@ function readinessArgs(config, { includeDirect }) {
'--direct-preflight-check-service-env-file',
'--direct-preflight-check-service-user-cert-readable',
'--direct-preflight-check-service-binary-executable',
'--direct-preflight-check-ports-free',
...(includeDirectPortsFree
? ['--direct-preflight-check-ports-free']
: []),
]
: []),
];
@@ -1656,6 +1804,11 @@ function buildSteps(config) {
command: 'npm',
args: ['run', 'check:pingora-health-patrol-env-switch'],
},
{
name: 'Pingora gateway env shadow 切换烟测',
command: 'npm',
args: ['run', 'check:pingora-gateway-env-shadow-switch'],
},
{
name: 'Pingora current release 自审烟测',
command: 'npm',
+37
View File
@@ -67,6 +67,7 @@ function main() {
assertMissingPingoraRealpathCanaryEnableFails();
assertMissingPingoraRealpathCanaryDisableFails();
assertMissingPingoraHealthPatrolEnvSwitchFails();
assertMissingPingoraGatewayEnvShadowSwitchFails();
assertMissingPingoraTlsCertSyncFails();
assertMissingEnvExamplesFails();
assertMissingPingoraDirectCheckFails();
@@ -119,6 +120,13 @@ function assertDeployCopiesPingoraDirectReleaseDependencies() {
),
'current release 必须包含 Pingora health patrol env 切换脚本。',
);
assertFileExists(
path.join(
releaseDir,
'scripts/deploy/pingora-gateway-env-shadow-switch.mjs',
),
'current release 必须包含 Pingora gateway env shadow 切换脚本。',
);
assertFileExists(
path.join(releaseDir, 'scripts/deploy/pingora-tls-cert-sync.mjs'),
'current release 必须包含 Pingora TLS 证书同步脚本。',
@@ -996,6 +1004,31 @@ function assertMissingPingoraHealthPatrolEnvSwitchFails() {
}
}
function assertMissingPingoraGatewayEnvShadowSwitchFails() {
const fixture = prepareFixture('missing-pingora-gateway-env-shadow-switch');
rmSync(
path.join(
fixture.sourceDir,
'scripts/deploy/pingora-gateway-env-shadow-switch.mjs',
),
);
const result = runDeploy(fixture);
if (result.status === 0) {
failures.push(
'发布产物缺少 Pingora gateway env shadow 切换脚本时部署必须失败。',
);
}
assertIncludes(
result.stderr,
'发布产物缺少 Pingora gateway env shadow 切换脚本',
'缺少 Pingora gateway env shadow 切换脚本时必须给出明确错误。',
);
if (!existsSync(fixture.maintenanceFile)) {
failures.push('部署失败时必须保持维护模式。');
}
}
function assertMissingPingoraRealpathCanaryEnableFails() {
const fixture = prepareFixture('missing-pingora-realpath-canary-enable');
rmSync(
@@ -1215,6 +1248,10 @@ function prepareFixture(name) {
'scripts/deploy/pingora-health-patrol-env-switch.mjs',
path.join(sourceDir, 'scripts/deploy/pingora-health-patrol-env-switch.mjs'),
);
copyFile(
'scripts/deploy/pingora-gateway-env-shadow-switch.mjs',
path.join(sourceDir, 'scripts/deploy/pingora-gateway-env-shadow-switch.mjs'),
);
copyFile(
'scripts/deploy/pingora-tls-cert-sync.mjs',
path.join(sourceDir, 'scripts/deploy/pingora-tls-cert-sync.mjs'),
+17 -1
View File
@@ -132,6 +132,20 @@ function assertApiReleaseContainsPingoraDirectDependencies() {
),
'Pingora health patrol env 切换脚本必须可执行。',
);
assertFileExists(
path.join(
releaseDir,
'scripts/deploy/pingora-gateway-env-shadow-switch.mjs',
),
'API release 必须包含 Pingora gateway env shadow 切换脚本。',
);
assertExecutable(
path.join(
releaseDir,
'scripts/deploy/pingora-gateway-env-shadow-switch.mjs',
),
'Pingora gateway env shadow 切换脚本必须可执行。',
);
assertFileExists(
path.join(releaseDir, 'scripts/deploy/pingora-tls-cert-sync.mjs'),
'API release 必须包含 Pingora TLS 证书同步脚本。',
@@ -479,7 +493,9 @@ function assertReleaseReadinessCutoverPlanBindsCurrentExecutables(releaseDir) {
'--cutover-run-id',
'check-production-api-release',
'--rollback-nginx-smoke-url',
'http://127.0.0.1/healthz',
'https://example.com/',
'--rollback-nginx-smoke-expect-body',
'<!doctype html>',
'--rollback-health-patrol-public-base-url',
'http://127.0.0.1',
],
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,254 @@
#!/usr/bin/env node
import {
chownSync,
chmodSync,
lstatSync,
mkdtempSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
const TARGET_KEYS = [
'GENARRATIVE_PINGORA_GATEWAY_LISTEN',
'GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN',
'GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN',
'GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE',
'GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE',
];
const SHADOW_VALUES = {
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: '',
};
const DRY_RUN_ENV_FILE_MODE = 0o600;
const config = parseArgs(process.argv.slice(2));
if (config.apply) {
assertEnvFileIsWritableRegularFile(config.envFile);
}
const originalText = readFileSync(config.envFile, 'utf8');
const nextText = rewriteEnvFile(originalText, SHADOW_VALUES);
const checkFile = writeDryRunEnv(nextText);
try {
console.log(
`[pingora-gateway-env-shadow-switch] apply=${config.apply} env=${config.envFile}`,
);
for (const key of TARGET_KEYS) {
console.log(`~ ${key}=${SHADOW_VALUES[key]}`);
}
assertShadowEnv(checkFile);
if (!config.apply) {
console.log(
'[pingora-gateway-env-shadow-switch] 当前是 dry-run;追加 --apply 后才会写入 env 文件。',
);
} else {
writeEnvFileAtomically(config.envFile, nextText);
}
console.log('[pingora-gateway-env-shadow-switch] 完成');
} finally {
rmSync(path.dirname(checkFile), { recursive: true, force: true });
}
function parseArgs(argv) {
const result = {
envFile:
process.env.GENARRATIVE_PINGORA_GATEWAY_ENV_FILE ||
'/etc/genarrative/pingora-gateway.env',
apply: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case '-h':
case '--help':
printUsage();
process.exit(0);
break;
case '--env-file':
result.envFile = requireValue(argv, ++index, arg);
break;
case '--apply':
result.apply = true;
break;
default:
throw new Error(`未知参数: ${arg}`);
}
}
if (!path.isAbsolute(result.envFile)) {
throw new Error(`--env-file 必须是绝对路径: ${result.envFile}`);
}
validateNoControlCharacters(result.envFile, '--env-file');
if (isFilesystemRootPath(result.envFile)) {
throw new Error('--env-file 不能是文件系统根目录。');
}
return result;
}
function printUsage() {
console.log(`Usage:
node scripts/deploy/pingora-gateway-env-shadow-switch.mjs [options]
Options:
--env-file <path> Pingora gateway env 文件,默认 /etc/genarrative/pingora-gateway.env。
--apply 写入 env 文件;默认只 dry-run 并用临时 env 复核。
`);
}
function requireValue(argv, index, flag) {
const value = argv[index];
if (value === undefined || value.startsWith('--')) {
throw new Error(`${flag} 缺少参数值`);
}
return value;
}
function rewriteEnvFile(text, nextValuesByKey) {
const lines = text.replace(/\r\n/gu, '\n').split('\n');
if (lines.at(-1) === '') {
lines.pop();
}
const seen = new Set();
const output = [];
for (const line of lines) {
const assignment = parseAssignmentLine(line);
if (!assignment || !TARGET_KEYS.includes(assignment.key)) {
output.push(line);
continue;
}
if (seen.has(assignment.key)) {
throw new Error(`pingora gateway env 中存在重复配置: ${assignment.key}`);
}
seen.add(assignment.key);
output.push(
`${assignment.leading}${assignment.exportPrefix}${assignment.key}=${nextValuesByKey[assignment.key]}`,
);
}
for (const key of TARGET_KEYS) {
if (!seen.has(key)) {
output.push(`${key}=${nextValuesByKey[key]}`);
}
}
return `${output.join('\n')}\n`;
}
function parseAssignmentLine(line) {
const match = line.match(/^(\s*)(export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/u);
if (!match) {
return null;
}
return {
leading: match[1] || '',
exportPrefix: match[2] || '',
key: match[3],
};
}
function writeDryRunEnv(content) {
const dir = mkdtempSync(
path.join(tmpdir(), 'genarrative-pingora-gateway-env-shadow-switch-'),
);
const filePath = path.join(dir, 'pingora-gateway.env');
writeFileSync(filePath, content, {
encoding: 'utf8',
mode: DRY_RUN_ENV_FILE_MODE,
});
chmodSync(filePath, DRY_RUN_ENV_FILE_MODE);
return filePath;
}
function assertShadowEnv(filePath) {
const values = parseEnvFile(readFileSync(filePath, 'utf8'));
if (
values.GENARRATIVE_PINGORA_GATEWAY_LISTEN !==
SHADOW_VALUES.GENARRATIVE_PINGORA_GATEWAY_LISTEN
) {
throw new Error(
'shadow env 复核失败:GENARRATIVE_PINGORA_GATEWAY_LISTEN 必须是 127.0.0.1:18081。',
);
}
for (const key of [
'GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN',
'GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN',
'GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE',
'GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE',
]) {
if ((values[key] || '') !== '') {
throw new Error(`shadow env 复核失败:${key} 必须为空。`);
}
}
}
function parseEnvFile(text) {
const values = {};
for (const line of text.split(/\r?\n/u)) {
const assignment = parseAssignmentLine(line);
if (!assignment) {
continue;
}
values[assignment.key] = line
.slice(line.indexOf('=') + 1)
.trim()
.replace(/^['"]|['"]$/gu, '');
}
return values;
}
function writeEnvFileAtomically(filePath, content) {
const currentStat = assertEnvFileIsWritableRegularFile(filePath);
const currentMode = currentStat.mode & 0o777;
const tempFile = path.join(
path.dirname(filePath),
`.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`,
);
try {
writeFileSync(tempFile, content, { encoding: 'utf8', mode: currentMode });
chownSync(tempFile, currentStat.uid, currentStat.gid);
chmodSync(tempFile, currentMode);
assertEnvFileIsWritableRegularFile(filePath);
renameSync(tempFile, filePath);
} catch (error) {
rmSync(tempFile, { force: true });
throw error;
}
}
function assertEnvFileIsWritableRegularFile(filePath) {
const currentStat = lstatSync(filePath);
if (currentStat.isSymbolicLink()) {
throw new Error(
`--env-file 不能是符号链接;请直接传真实 Pingora gateway env 文件路径: ${filePath}`,
);
}
if (!currentStat.isFile()) {
throw new Error(`--env-file 必须指向普通文件: ${filePath}`);
}
return currentStat;
}
function validateNoControlCharacters(value, label) {
if (/[\0\r\n]/u.test(String(value ?? ''))) {
throw new Error(`${label} 不能包含换行或 NUL 字符。`);
}
}
function isFilesystemRootPath(value) {
const resolved = path.resolve(String(value));
return resolved === path.parse(resolved).root;
}
+7
View File
@@ -706,6 +706,7 @@ PINGORA_ROLLBACK_SCRIPT_SOURCE="${SOURCE_DIR}/scripts/deploy/pingora-direct-roll
PINGORA_REALPATH_CANARY_ENABLE_SCRIPT_SOURCE="${SOURCE_DIR}/scripts/deploy/pingora-realpath-canary-enable.sh"
PINGORA_REALPATH_CANARY_DISABLE_SCRIPT_SOURCE="${SOURCE_DIR}/scripts/deploy/pingora-realpath-canary-disable.sh"
PINGORA_HEALTH_PATROL_ENV_SWITCH_SCRIPT_SOURCE="${SOURCE_DIR}/scripts/deploy/pingora-health-patrol-env-switch.mjs"
PINGORA_GATEWAY_ENV_SHADOW_SWITCH_SCRIPT_SOURCE="${SOURCE_DIR}/scripts/deploy/pingora-gateway-env-shadow-switch.mjs"
PINGORA_TLS_CERT_SYNC_SCRIPT_SOURCE="${SOURCE_DIR}/scripts/deploy/pingora-tls-cert-sync.mjs"
PINGORA_PREFLIGHT_SCRIPT_SOURCE="${SOURCE_DIR}/scripts/check-pingora-direct-preflight.mjs"
PINGORA_LIVE_SCRIPT_SOURCE="${SOURCE_DIR}/scripts/check-pingora-direct-live.mjs"
@@ -812,6 +813,12 @@ if [[ ! -f "${PINGORA_HEALTH_PATROL_ENV_SWITCH_SCRIPT_SOURCE}" ]]; then
fi
cp "${PINGORA_HEALTH_PATROL_ENV_SWITCH_SCRIPT_SOURCE}" "${RELEASE_CONTENT_DIR}/scripts/deploy/pingora-health-patrol-env-switch.mjs"
chmod 0755 "${RELEASE_CONTENT_DIR}/scripts/deploy/pingora-health-patrol-env-switch.mjs"
if [[ ! -f "${PINGORA_GATEWAY_ENV_SHADOW_SWITCH_SCRIPT_SOURCE}" ]]; then
echo "[production-api-deploy] 发布产物缺少 Pingora gateway env shadow 切换脚本: ${SOURCE_DIR}/scripts/deploy/pingora-gateway-env-shadow-switch.mjs" >&2
exit 1
fi
cp "${PINGORA_GATEWAY_ENV_SHADOW_SWITCH_SCRIPT_SOURCE}" "${RELEASE_CONTENT_DIR}/scripts/deploy/pingora-gateway-env-shadow-switch.mjs"
chmod 0755 "${RELEASE_CONTENT_DIR}/scripts/deploy/pingora-gateway-env-shadow-switch.mjs"
if [[ ! -f "${PINGORA_TLS_CERT_SYNC_SCRIPT_SOURCE}" ]]; then
echo "[production-api-deploy] 发布产物缺少 Pingora TLS 证书同步脚本: ${SOURCE_DIR}/scripts/deploy/pingora-tls-cert-sync.mjs" >&2
exit 1
+37 -2
View File
@@ -3,7 +3,7 @@
import { execFile } from 'node:child_process';
import { createHash } from 'node:crypto';
import { constants as fsConstants } from 'node:fs';
import { access, readFile, stat } from 'node:fs/promises';
import { access, readFile, realpath, stat } from 'node:fs/promises';
import path from 'node:path';
const REQUIRED_ARTIFACTS = [
@@ -25,6 +25,7 @@ const REQUIRED_ARTIFACTS = [
{ path: 'scripts/deploy/pingora-realpath-canary-enable.sh', executable: true },
{ path: 'scripts/deploy/pingora-realpath-canary-disable.sh', executable: true },
{ path: 'scripts/deploy/pingora-health-patrol-env-switch.mjs', executable: true },
{ path: 'scripts/deploy/pingora-gateway-env-shadow-switch.mjs', executable: true },
{ path: 'scripts/deploy/pingora-tls-cert-sync.mjs', executable: true },
{ path: 'deploy/systemd/genarrative-pingora-gateway.service' },
{ path: 'deploy/systemd/genarrative-pingora-gateway-direct-entry.conf' },
@@ -555,6 +556,7 @@ async function inspectReleaseManifest(input, pingoraGateway) {
async function inspectSystemd(input) {
const expectedBinary = path.join(input.releaseRoot, 'pingora-gateway');
const expectedRealpath = await resolveOptionalRealpath(expectedBinary);
const result = await runCommand(
'systemctl',
[
@@ -578,7 +580,17 @@ async function inspectSystemd(input) {
}
const execStart = show.ExecStart || '';
if (result.code === 0 && !execStart.includes(expectedBinary)) {
const execStartBinary = parseExecStartBinary(execStart);
const execStartRealpath = execStartBinary
? await resolveOptionalRealpath(execStartBinary)
: null;
const execStartMatches =
execStartBinary === expectedBinary ||
(expectedRealpath &&
execStartRealpath &&
expectedRealpath === execStartRealpath);
if (result.code === 0 && !execStartMatches) {
diagnostics.push(
`ExecStart 未指向 current release 网关二进制: expected ${expectedBinary}, actual ${execStart || '(空)'}`,
);
@@ -590,6 +602,9 @@ async function inspectSystemd(input) {
service: input.systemdService,
status,
expectedBinary,
expectedRealpath,
execStartBinary,
execStartRealpath,
fragmentPath: show.FragmentPath || '',
dropInPaths: show.DropInPaths || '',
user: show.User || '',
@@ -599,6 +614,26 @@ async function inspectSystemd(input) {
};
}
async function resolveOptionalRealpath(filePath) {
try {
return await realpath(filePath);
} catch {
return null;
}
}
function parseExecStartBinary(execStart) {
const pathMatch = execStart.match(/\bpath=([^ ;]+)(?:\s|;|$)/u);
if (pathMatch) {
return pathMatch[1];
}
const argvMatch = execStart.match(/\bargv\[\]=([^ ;]+)(?:\s|;|$)/u);
if (argvMatch) {
return argvMatch[1];
}
return '';
}
function parseSystemctlShow(text) {
const result = {};
for (const line of text.split(/\r?\n/u)) {
+117 -1
View File
@@ -16,7 +16,22 @@ const CUTOVER_TIMELINE_ORDER = [
phase: 'enable-apply',
commandName: 'pingora-direct-enable-apply',
},
{
type: 'command',
phase: 'post-enable',
commandName: 'pingora-health-patrol-direct-env-switch',
},
{ type: 'phase', phase: 'post-enable' },
{
type: 'command',
phase: 'rollback-prep',
commandName: 'pingora-gateway-shadow-env-switch',
},
{
type: 'command',
phase: 'rollback-prep',
commandName: 'pingora-health-patrol-nginx-env-switch',
},
{
type: 'command',
phase: 'rollback-apply',
@@ -59,6 +74,7 @@ function parseArgs(argv) {
requiredPhases: [],
requiredPhaseDirectLiveAccessLog: [],
requiredPhaseDirectLiveStaticHeaders: [],
requiredPhasePingoraEnvShadow: [],
requiredCommands: [],
requiredCommandExecutables: [],
requiredCommandArgs: [],
@@ -95,6 +111,11 @@ function parseArgs(argv) {
requireValue(argv, ++index, arg),
);
break;
case '--require-phase-pingora-env-shadow':
result.requiredPhasePingoraEnvShadow.push(
requireValue(argv, ++index, arg),
);
break;
case '--require-command-executable':
result.requiredCommandExecutables.push(requireValue(argv, ++index, arg));
break;
@@ -137,6 +158,9 @@ function parseArgs(argv) {
result.requiredPhaseDirectLiveStaticHeaders = normalizeRequiredPhases(
result.requiredPhaseDirectLiveStaticHeaders,
);
result.requiredPhasePingoraEnvShadow = normalizeRequiredPhases(
result.requiredPhasePingoraEnvShadow,
);
const requiredCommands = normalizeRequiredCommands(result.requiredCommands);
result.requiredCommandExecutables = normalizeRequiredCommandExecutables(
result.requiredCommandExecutables,
@@ -172,6 +196,8 @@ Options:
要求某个阶段 manifest.summary.directLiveAccessLog 已存在且 direct live request_id access log 对账成功;正式切换默认用于 post-enable。
--require-phase-direct-live-static-headers <phase>
要求某个阶段 manifest.summary.directLiveStaticHeaders 已存在且包含可判定的 direct live 静态响应头摘要;正式切换默认用于 post-enable。
--require-phase-pingora-env-shadow <phase>
要求某个阶段 manifest.summary.pingoraEnvShadow 已存在且证明 Pingora env 已恢复 shadow 高端口;正式切换默认用于 post-rollback。
--require-command-executable <phase>:<commandName>:<absolutePath>
要求某个命令证据的 manifest.expectedExecutable、manifest.command.executable 和 command-record.json executable 与该绝对路径一致;可重复。
--require-command-arg <phase>:<commandName>:<arg>
@@ -183,7 +209,7 @@ Options:
--allow-extra-root-entries
允许证据根目录中存在非证据目录条目;默认拒绝,正式切换归档不应使用。
该脚本只读扫描 Pingora 直连切换证据根目录,按 manifest.phase 找到每个阶段的最新证据目录,也可按 manifest.phase + manifest.commandName 找到真实切换命令证据,并调用随包证据验真脚本的 --require-summary-ok 严格模式校验 schemaVersion=1 manifest.files 的 sizeBytes / sha256 与 manifest.summary.status=OK;所有候选证据都必须带合法 manifest.generatedAt,最新证据选择和标准时间线证明只使用该字段,不用目录 mtime 兜底;若同一阶段或同一命令的最新 manifest.generatedAt 重复,则拒绝按目录名打平并要求重新归档或清理证据根目录;命令证据的 manifest.command 与 command-record.json 也必须是 schemaVersion=1;可用 --require-phase-direct-live-access-log 要求指定阶段必须带 direct live access log 对账摘要,防止旧 post-enable 证据包缺少 request_id 反查复盘入口;可用 --require-phase-direct-live-static-headers 要求指定阶段必须带 direct live 静态响应头摘要,防止旧 post-enable 证据包缺少 Cache-Control、ETag、Last-Modified、Range 和 304 复盘入口;证据根目录默认只能包含带 manifest.json 的证据目录,不允许夹带普通文件、无 manifest 目录或符号链接;不会修改证据目录、不会 reload systemd、不会访问 Nginx 或 Pingora。
该脚本只读扫描 Pingora 直连切换证据根目录,按 manifest.phase 找到每个阶段的最新证据目录,也可按 manifest.phase + manifest.commandName 找到真实切换命令证据,并调用随包证据验真脚本的 --require-summary-ok 严格模式校验 schemaVersion=1 manifest.files 的 sizeBytes / sha256 与 manifest.summary.status=OK;所有候选证据都必须带合法 manifest.generatedAt,最新证据选择和标准时间线证明只使用该字段,不用目录 mtime 兜底;若同一阶段或同一命令的最新 manifest.generatedAt 重复,则拒绝按目录名打平并要求重新归档或清理证据根目录;命令证据的 manifest.command 与 command-record.json 也必须是 schemaVersion=1;可用 --require-phase-direct-live-access-log 要求指定阶段必须带 direct live access log 对账摘要,防止旧 post-enable 证据包缺少 request_id 反查复盘入口;可用 --require-phase-direct-live-static-headers 要求指定阶段必须带 direct live 静态响应头摘要,防止旧 post-enable 证据包缺少 Cache-Control、ETag、Last-Modified、Range 和 304 复盘入口;可用 --require-phase-pingora-env-shadow 要求指定阶段必须带 Pingora env shadow 摘要,防止旧 post-rollback 证据包无法证明 active env 已从低端口 direct 恢复到 127.0.0.1:18081;证据根目录默认只能包含带 manifest.json 的证据目录,不允许夹带普通文件、无 manifest 目录或符号链接;不会修改证据目录、不会 reload systemd、不会访问 Nginx 或 Pingora。
`);
}
@@ -634,6 +660,7 @@ async function discoverEvidence(evidenceRoot, options = {}) {
summaryDirectLiveAccessLog: manifest.summary?.directLiveAccessLog ?? null,
summaryDirectLiveStaticHeaders:
manifest.summary?.directLiveStaticHeaders ?? null,
summaryPingoraEnvShadow: manifest.summary?.pingoraEnvShadow ?? null,
commandName,
manifestCommand: manifest.command || null,
commandRecordFileName: manifest.files?.commandRecord?.path || null,
@@ -730,6 +757,9 @@ async function buildAudit(config, discovery) {
const requiredStaticHeaders = config.requiredPhaseDirectLiveStaticHeaders.includes(
phase,
);
const requiredPingoraEnvShadow = config.requiredPhasePingoraEnvShadow.includes(
phase,
);
const allPhaseCandidates = discovery.candidates.filter(
(candidate) => candidate.phase === phase && candidate.commandName === null,
);
@@ -747,8 +777,10 @@ async function buildAudit(config, discovery) {
requiredCutoverRunId: config.requiredCutoverRunId || null,
requiredDirectLiveAccessLog: requiredAccessLog,
requiredDirectLiveStaticHeaders: requiredStaticHeaders,
requiredPingoraEnvShadow,
directLiveAccessLog: null,
directLiveStaticHeaders: null,
pingoraEnvShadow: null,
diagnostics: [
missingEvidenceDiagnostic(
'该阶段',
@@ -771,8 +803,10 @@ async function buildAudit(config, discovery) {
generatedAt: latestSelection.generatedAt,
requiredDirectLiveAccessLog: requiredAccessLog,
requiredDirectLiveStaticHeaders: requiredStaticHeaders,
requiredPingoraEnvShadow,
directLiveAccessLog: null,
directLiveStaticHeaders: null,
pingoraEnvShadow: null,
ambiguousBundleDirs: latestSelection.ambiguousBundleDirs,
diagnostics: latestSelection.diagnostics,
});
@@ -784,6 +818,7 @@ async function buildAudit(config, discovery) {
const manifestCheck = checkPhaseManifest(latest, {
requiredAccessLog,
requiredStaticHeaders,
requiredPingoraEnvShadow,
});
phases.push({
phase,
@@ -797,8 +832,10 @@ async function buildAudit(config, discovery) {
summaryStatus: latest.summaryStatus,
requiredDirectLiveAccessLog: requiredAccessLog,
requiredDirectLiveStaticHeaders: requiredStaticHeaders,
requiredPingoraEnvShadow,
directLiveAccessLog: latest.summaryDirectLiveAccessLog,
directLiveStaticHeaders: latest.summaryDirectLiveStaticHeaders,
pingoraEnvShadow: latest.summaryPingoraEnvShadow,
commandName: latest.commandName,
verify,
manifestCheck,
@@ -960,6 +997,28 @@ async function buildAudit(config, discovery) {
}
function buildOperatorSummary(input) {
const pingoraEnvShadowEvidence = input.phases
.filter((phase) => phase.requiredPingoraEnvShadow)
.map((phase) => {
const summary = phase.pingoraEnvShadow ?? null;
const firstDiagnostic = phase.diagnostics?.[0] ?? null;
return {
phase: phase.phase,
status: phase.status,
latestBundleDir: phase.latestBundleDir ?? null,
manifestPath: phase.manifestPath ?? null,
cutoverRunId: phase.cutoverRunId ?? null,
generatedAt: phase.generatedAt ?? null,
shadow: {
required: true,
ok: phase.manifestCheck?.pingoraEnvShadowCheck?.ok === true,
reason:
phase.manifestCheck?.pingoraEnvShadowCheck?.reason ??
firstDiagnostic,
summary,
},
};
});
const directLiveEvidence = input.phases
.filter(
(phase) =>
@@ -1058,6 +1117,7 @@ function buildOperatorSummary(input) {
},
]),
],
pingoraEnvShadowEvidence,
directLiveEvidence,
timeline: {
checked: input.timeline.checked,
@@ -1124,6 +1184,7 @@ function checkPhaseManifest(candidate, options = {}) {
const {
requiredAccessLog = false,
requiredStaticHeaders = false,
requiredPingoraEnvShadow = false,
} = options;
const diagnostics = [];
if (candidate.commandName !== null) {
@@ -1152,18 +1213,73 @@ function checkPhaseManifest(candidate, options = {}) {
`最新阶段证据 manifest.summary.directLiveStaticHeaders 必须包含 direct live 静态响应头摘要:${staticHeadersCheck.reason}`,
);
}
const pingoraEnvShadowCheck = checkPingoraEnvShadowSummary(
candidate.summaryPingoraEnvShadow,
);
if (requiredPingoraEnvShadow && !pingoraEnvShadowCheck.ok) {
diagnostics.push(
`最新阶段证据 manifest.summary.pingoraEnvShadow 必须证明 Pingora env 已恢复 shadow:${pingoraEnvShadowCheck.reason}`,
);
}
return {
ok: diagnostics.length === 0,
expectedSummaryStatus: 'OK',
actualSummaryStatus: candidate.summaryStatus,
requiredDirectLiveAccessLog: requiredAccessLog,
requiredDirectLiveStaticHeaders: requiredStaticHeaders,
requiredPingoraEnvShadow,
directLiveAccessLogCheck: accessLogCheck,
directLiveStaticHeadersCheck: staticHeadersCheck,
pingoraEnvShadowCheck,
diagnostics,
};
}
function checkPingoraEnvShadowSummary(summary) {
if (!summary || typeof summary !== 'object' || Array.isArray(summary)) {
return { ok: false, reason: '字段缺失或不是对象。' };
}
const failures = [];
if (summary.present !== true) {
failures.push('present 必须为 true');
}
if (summary.ok !== true) {
failures.push('ok 必须为 true');
}
if (summary.listen !== '127.0.0.1:18081') {
failures.push(
`listen 必须是 127.0.0.1:18081,实际为 ${formatNullable(summary.listen)}`,
);
}
if (summary.tlsListen !== '') {
failures.push(`tlsListen 必须为空,实际为 ${formatNullable(summary.tlsListen)}`);
}
if (summary.httpRedirectListen !== '') {
failures.push(
`httpRedirectListen 必须为空,实际为 ${formatNullable(summary.httpRedirectListen)}`,
);
}
if (summary.tlsCertFile !== '') {
failures.push(
`tlsCertFile 必须为空,实际为 ${formatNullable(summary.tlsCertFile)}`,
);
}
if (summary.tlsKeyFile !== '') {
failures.push(
`tlsKeyFile 必须为空,实际为 ${formatNullable(summary.tlsKeyFile)}`,
);
}
if (summary.mode && summary.mode !== 'shadow') {
failures.push(`mode 必须是 shadow,实际为 ${formatNullable(summary.mode)}`);
}
if (summary.shadowReady === false) {
failures.push('shadowReady 必须为 true');
}
return failures.length === 0
? { ok: true, reason: null }
: { ok: false, reason: failures.join(';') };
}
function checkDirectLiveAccessLogSummary(summary) {
if (!summary || typeof summary !== 'object' || Array.isArray(summary)) {
return { ok: false, reason: '字段缺失或不是对象。' };
@@ -15,6 +15,7 @@ import {
import path from 'node:path';
const GATEWAY_MODES = new Set(['nginx', 'pingora-direct']);
const PINGORA_ENV_MODES = new Set(['shadow', 'direct']);
const BUNDLE_DIR_MODE = 0o750;
const EVIDENCE_FILE_MODE = 0o640;
const SECRET_VALUE_FLAGS = new Set([
@@ -73,6 +74,7 @@ if (
const directLiveAccessLog = summarizeDirectLiveAccessLog(directLiveRun);
const directLiveStaticHeaders = summarizeDirectLiveStaticHeaders(directLiveRun);
const pingoraEnvShadow = summarizePingoraEnvShadow(snapshotRun);
const directLiveStatus = directLiveRun
? isDirectLiveEvidenceOk(
directLiveRun,
@@ -106,6 +108,7 @@ const manifest = {
directLiveExitCode: directLiveRun?.result.code ?? null,
directLiveAccessLog: directLiveAccessLog?.summary ?? null,
directLiveStaticHeaders: directLiveStaticHeaders?.summary ?? null,
pingoraEnvShadow: pingoraEnvShadow?.summary ?? null,
criticalCount: snapshotCriticalCount + directLiveCriticalCount,
warningCount: snapshot.ok
? Number(snapshot.value?.summary?.warningCount || 0)
@@ -166,6 +169,85 @@ if (!result.ok) {
process.exit(1);
}
function summarizePingoraEnvShadow(snapshotRun) {
if (!snapshotRun.parsed.ok) {
return {
ok: false,
summary: {
present: false,
listen: '',
tlsListen: '',
httpRedirectListen: '',
ok: false,
diagnostics: ['snapshot JSON parse failed'],
},
};
}
const pingoraEnv = snapshotRun.parsed.value?.pingoraEnv;
const values = pingoraEnv?.values;
if (!values || typeof values !== 'object' || Array.isArray(values)) {
return {
ok: false,
summary: {
present: false,
listen: '',
tlsListen: '',
httpRedirectListen: '',
ok: false,
diagnostics: ['snapshot JSON missing pingoraEnv.values'],
},
};
}
const summary = {
present: true,
mode: String(pingoraEnv?.posture?.mode || ''),
shadowReady: pingoraEnv?.posture?.shadowReady === true,
directReady: pingoraEnv?.posture?.directReady === true,
listen: String(values.listen || ''),
tlsListen: String(values.tlsListen || ''),
httpRedirectListen: String(values.httpRedirectListen || ''),
tlsCertFile: String(values.tlsCertFile || ''),
tlsKeyFile: String(values.tlsKeyFile || ''),
ok: false,
diagnostics: [],
};
if (summary.listen !== '127.0.0.1:18081') {
summary.diagnostics.push(
`listen 应为 127.0.0.1:18081,实际 ${summary.listen || '-'}`,
);
}
if (summary.tlsListen !== '') {
summary.diagnostics.push(
`tlsListen 应为空,实际 ${summary.tlsListen}`,
);
}
if (summary.httpRedirectListen !== '') {
summary.diagnostics.push(
`httpRedirectListen 应为空,实际 ${summary.httpRedirectListen}`,
);
}
if (summary.tlsCertFile !== '') {
summary.diagnostics.push(
`tlsCertFile 应为空,实际 ${summary.tlsCertFile}`,
);
}
if (summary.tlsKeyFile !== '') {
summary.diagnostics.push(
`tlsKeyFile 应为空,实际 ${summary.tlsKeyFile}`,
);
}
if (summary.mode && summary.mode !== 'shadow') {
summary.diagnostics.push(`mode 应为 shadow,实际 ${summary.mode}`);
}
if (summary.shadowReady === false) {
summary.diagnostics.push('shadowReady 应为 true');
}
summary.ok = summary.diagnostics.length === 0;
return { ok: summary.ok, summary };
}
function summarizeDirectLiveAccessLog(directLiveRun) {
if (!directLiveRun) {
return null;
@@ -453,6 +535,8 @@ function parseArgs(argv) {
process.env.GENARRATIVE_PINGORA_DIRECT_ACCESS_LOG_SINCE_LINES || '2000',
expectedGatewayMode:
process.env.GENARRATIVE_HEALTH_PATROL_EXPECTED_GATEWAY_MODE || '',
expectedPingoraEnvMode:
process.env.GENARRATIVE_PINGORA_EXPECTED_ENV_MODE || '',
expectedPublicBaseUrl:
process.env.GENARRATIVE_HEALTH_PATROL_EXPECTED_PUBLIC_BASE_URL || '',
expectedPublicHost:
@@ -541,6 +625,9 @@ function parseArgs(argv) {
case '--expected-gateway-mode':
result.expectedGatewayMode = requireValue(argv, ++index, arg);
break;
case '--expected-pingora-env-mode':
result.expectedPingoraEnvMode = requireValue(argv, ++index, arg);
break;
case '--expected-public-base-url':
result.expectedPublicBaseUrl = requireValue(argv, ++index, arg);
break;
@@ -610,6 +697,8 @@ Options:
--direct-access-log-since-lines <n>
透传给 direct live smoke 的 access log tail 行数,默认 2000。
--expected-gateway-mode <mode> 可选,nginx 或 pingora-direct;会透传给状态快照。
--expected-pingora-env-mode <mode>
可选,shadow 或 direct;会透传给状态快照。
--expected-public-base-url <url> 可选,要求 health-patrol public base URL 与该值一致。
--expected-public-host <host> 可选,要求 health-patrol public Host 与该值一致。
--require-empty-public-host 可选,要求 health-patrol public Host 为空。
@@ -650,6 +739,14 @@ function validateConfig(config) {
`--expected-gateway-mode 只支持 nginx 或 pingora-direct: ${config.expectedGatewayMode}`,
);
}
if (
config.expectedPingoraEnvMode &&
!PINGORA_ENV_MODES.has(config.expectedPingoraEnvMode)
) {
throw new Error(
`--expected-pingora-env-mode 只支持 shadow 或 direct: ${config.expectedPingoraEnvMode}`,
);
}
if (config.expectedPublicHost !== null) {
validateHostOption(config.expectedPublicHost, '--expected-public-host');
}
@@ -961,6 +1058,9 @@ function buildSnapshotArgs(config) {
...(config.expectedGatewayMode
? ['--expected-gateway-mode', config.expectedGatewayMode]
: []),
...(config.expectedPingoraEnvMode
? ['--expected-pingora-env-mode', config.expectedPingoraEnvMode]
: []),
...(config.expectedPublicBaseUrl
? ['--expected-public-base-url', config.expectedPublicBaseUrl]
: []),
+123 -13
View File
@@ -12,6 +12,7 @@ const STATUS_RANK = {
};
const GATEWAY_MODES = new Set(['nginx', 'pingora-direct']);
const PINGORA_ENV_MODES = new Set(['shadow', 'direct']);
const SERVICES = [
'genarrative-api.service',
'spacetimedb.service',
@@ -38,6 +39,7 @@ const RELEASE_ARTIFACTS = [
'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',
'deploy/systemd/genarrative-pingora-gateway.service',
'deploy/systemd/genarrative-pingora-gateway-direct-entry.conf',
'deploy/nginx/snippets/genarrative-pingora-canary.conf',
@@ -68,6 +70,8 @@ function parseArgs(argv) {
pingoraEnvFile:
process.env.GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_ENV_FILE ||
'/etc/genarrative/pingora-gateway.env',
expectedPingoraEnvMode:
process.env.GENARRATIVE_PINGORA_EXPECTED_ENV_MODE || '',
expectedGatewayMode:
process.env.GENARRATIVE_HEALTH_PATROL_EXPECTED_GATEWAY_MODE || '',
expectedPublicBaseUrl:
@@ -113,6 +117,9 @@ function parseArgs(argv) {
case '--pingora-env-file':
result.pingoraEnvFile = requireValue(argv, ++index, arg);
break;
case '--expected-pingora-env-mode':
result.expectedPingoraEnvMode = requireValue(argv, ++index, arg);
break;
case '--expected-gateway-mode':
result.expectedGatewayMode = requireValue(argv, ++index, arg);
break;
@@ -177,6 +184,14 @@ function parseArgs(argv) {
`--expected-gateway-mode 只支持 nginx 或 pingora-direct: ${result.expectedGatewayMode}`,
);
}
if (
result.expectedPingoraEnvMode &&
!PINGORA_ENV_MODES.has(result.expectedPingoraEnvMode)
) {
throw new Error(
`--expected-pingora-env-mode 只支持 shadow 或 direct: ${result.expectedPingoraEnvMode}`,
);
}
if (result.expectedPublicHost !== null) {
validateHostOption(result.expectedPublicHost, '--expected-public-host');
}
@@ -202,6 +217,8 @@ Options:
--health-patrol-env-file <path> health-patrol env 文件,默认 /etc/genarrative/health-patrol.env。
--pingora-env-file <path> pingora-gateway env 文件,默认 /etc/genarrative/pingora-gateway.env。
--expected-gateway-mode <mode> 可选,nginx 或 pingora-direct;提供后会检查 env 和 systemd capability 方向。
--expected-pingora-env-mode <mode>
可选,shadow 或 direct;提供后会检查 active Pingora env 姿态。
--expected-public-base-url <url> 可选,要求 health-patrol public base URL 与该值一致。
--expected-public-host <host> 可选,要求 health-patrol public Host 与该值一致。
--require-empty-public-host 可选,要求 health-patrol public Host 为空。
@@ -402,25 +419,118 @@ async function inspectPingoraEnv(input) {
return {
...parsed,
values: {},
posture: {
expectedMode: input.expectedPingoraEnvMode || '',
mode: 'unknown',
shadowReady: false,
directReady: false,
diagnostics: parsed.diagnostics || [],
},
};
}
const values = parsed.values;
const envValues = {
listen: values.GENARRATIVE_PINGORA_GATEWAY_LISTEN || '',
tlsListen: values.GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN || '',
httpRedirectListen:
values.GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN || '',
tlsCertFile: values.GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE || '',
tlsKeyFile: values.GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE || '',
forwardedProto:
values.GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO || '',
trustXForwardedFor:
values.GENARRATIVE_PINGORA_GATEWAY_TRUST_X_FORWARDED_FOR || '',
trustedFrontProxyConfirmed:
values.GENARRATIVE_PINGORA_GATEWAY_TRUSTED_FRONT_PROXY_CONFIRMED || '',
protectionEnabled:
values.GENARRATIVE_PINGORA_GATEWAY_PROTECTION_ENABLED || '',
instanceCount:
values.GENARRATIVE_PINGORA_GATEWAY_INSTANCE_COUNT || '',
sharedProtectionConfirmed:
values.GENARRATIVE_PINGORA_GATEWAY_SHARED_PROTECTION_CONFIRMED || '',
hasProbeToken: Boolean(values.GENARRATIVE_PINGORA_GATEWAY_PROBE_TOKEN),
accessLogFile:
values.GENARRATIVE_PINGORA_GATEWAY_ACCESS_LOG_FILE || '',
};
const posture = summarizePingoraEnvPosture(
envValues,
input.expectedPingoraEnvMode,
);
const diagnostics = [...parsed.diagnostics, ...posture.diagnostics];
const status =
input.expectedPingoraEnvMode && posture.diagnostics.length > 0
? 'CRITICAL'
: parsed.status;
return {
path: input.pingoraEnvFile,
status: 'OK',
status,
secretValues: collectSecretValuesFromEnv(values),
values: {
listen: values.GENARRATIVE_PINGORA_GATEWAY_LISTEN || '',
tlsListen: values.GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN || '',
httpRedirectListen:
values.GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN || '',
tlsCertFile: values.GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE || '',
tlsKeyFile: values.GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE || '',
hasProbeToken: Boolean(values.GENARRATIVE_PINGORA_GATEWAY_PROBE_TOKEN),
accessLogFile:
values.GENARRATIVE_PINGORA_GATEWAY_ACCESS_LOG_FILE || '',
},
diagnostics: [],
values: envValues,
posture,
diagnostics,
};
}
function summarizePingoraEnvPosture(values, expectedMode) {
const shadowReady =
values.listen === '127.0.0.1:18081' &&
values.tlsListen === '' &&
values.httpRedirectListen === '' &&
values.tlsCertFile === '' &&
values.tlsKeyFile === '';
const directReady =
values.tlsListen !== '' &&
values.httpRedirectListen !== '' &&
values.tlsCertFile !== '' &&
values.tlsKeyFile !== '' &&
values.forwardedProto === 'https';
const mode = directReady ? 'direct' : shadowReady ? 'shadow' : 'mixed';
const diagnostics = [];
if (expectedMode === 'shadow') {
if (values.listen !== '127.0.0.1:18081') {
diagnostics.push(
`Pingora shadow env 要求 listen=127.0.0.1:18081,实际 ${values.listen || '-'}`,
);
}
for (const [label, value] of [
['tlsListen', values.tlsListen],
['httpRedirectListen', values.httpRedirectListen],
['tlsCertFile', values.tlsCertFile],
['tlsKeyFile', values.tlsKeyFile],
]) {
if (value !== '') {
diagnostics.push(
`Pingora shadow env 要求 ${label} 为空,实际 ${value}`,
);
}
}
}
if (expectedMode === 'direct') {
for (const [label, value] of [
['tlsListen', values.tlsListen],
['httpRedirectListen', values.httpRedirectListen],
['tlsCertFile', values.tlsCertFile],
['tlsKeyFile', values.tlsKeyFile],
]) {
if (value === '') {
diagnostics.push(`Pingora direct env 要求 ${label} 已配置。`);
}
}
if (values.forwardedProto !== 'https') {
diagnostics.push(
`Pingora direct env 要求 forwardedProto=https,实际 ${values.forwardedProto || '-'}`,
);
}
}
return {
expectedMode: expectedMode || '',
mode,
shadowReady,
directReady,
diagnostics,
};
}