完善 Pingora 直连切换门禁
新增 Pingora shadow env 回切脚本与对应检查。 补齐直连证据包时间线和 cutoverRunId 审计门禁。 支持 Gitea Host 透传并更新直连多域名文档。 修复百分号编码静态图标路径并补 smoke 覆盖。 更新生产发布与运维护栏对 Pingora 发布包的校验。
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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}。`);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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]
|
||||
: []),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user