完善 Pingora 直连切换门禁
新增 Pingora shadow env 回切脚本与对应检查。 补齐直连证据包时间线和 cutoverRunId 审计门禁。 支持 Gitea Host 透传并更新直连多域名文档。 修复百分号编码静态图标路径并补 smoke 覆盖。 更新生产发布与运维护栏对 Pingora 发布包的校验。
This commit is contained in:
+10
-8
File diff suppressed because one or more lines are too long
@@ -16,6 +16,11 @@ GENARRATIVE_PINGORA_GATEWAY_LISTEN=127.0.0.1:18081
|
||||
# GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_TARGET_SCHEME=https
|
||||
GENARRATIVE_PINGORA_GATEWAY_API_UPSTREAM=127.0.0.1:8082
|
||||
GENARRATIVE_PINGORA_GATEWAY_SPACETIME_UPSTREAM=127.0.0.1:3101
|
||||
# 可选:同一公网 IP 上还承载 Gitea 时,按 Host 整站转发到 Gitea。
|
||||
# 命中这些 Host 的请求不走应用维护页、API body limit 或网关接流保护,避免影响 git clone / push。
|
||||
# 直连 443 如果要同时服务这些域名,当前单 listener TLS 配置必须使用覆盖全部 Host 的同一张证书。
|
||||
# GENARRATIVE_PINGORA_GATEWAY_GITEA_HOSTS=git.genarrative.world
|
||||
# GENARRATIVE_PINGORA_GATEWAY_GITEA_UPSTREAM=127.0.0.1:3000
|
||||
|
||||
GENARRATIVE_PINGORA_GATEWAY_WEB_ROOT=/srv/genarrative/web
|
||||
GENARRATIVE_PINGORA_GATEWAY_ACME_ROOT=/var/www/html
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -123,7 +123,7 @@ pipeline {
|
||||
|
||||
stage('Archive') {
|
||||
steps {
|
||||
archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/api-server,build/${env.EFFECTIVE_BUILD_VERSION}/api-server.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/database-backup-to-oss.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/ops/production-health-patrol.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/ops/pingora-current-release-audit.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/ops/pingora-direct-rehearsal-status.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/ops/pingora-cutover-status-snapshot.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/ops/pingora-cutover-evidence-bundle.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/ops/pingora-cutover-command-evidence.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/ops/pingora-cutover-evidence-verify.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/ops/pingora-cutover-evidence-audit.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/check-production-health-patrol-env.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/check-pingora-release-readiness.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/check-pingora-direct-preflight.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/check-pingora-direct-live.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/check-pingora-canary-live.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/check-pingora-canary-access-log-parity.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/production-api-deploy.sh,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/maintenance-on.sh,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/maintenance-off.sh,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/pingora-direct-enable.sh,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/pingora-direct-rollback.sh,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/pingora-realpath-canary-enable.sh,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/pingora-realpath-canary-disable.sh,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/pingora-health-patrol-env-switch.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/deploy/systemd/**,build/${env.EFFECTIVE_BUILD_VERSION}/deploy/nginx/**,build/${env.EFFECTIVE_BUILD_VERSION}/deploy/env/**,build/${env.EFFECTIVE_BUILD_VERSION}/deploy/pingora/**", fingerprint: true
|
||||
archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/api-server,build/${env.EFFECTIVE_BUILD_VERSION}/api-server.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/database-backup-to-oss.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/ops/production-health-patrol.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/ops/pingora-current-release-audit.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/ops/pingora-direct-rehearsal-status.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/ops/pingora-cutover-status-snapshot.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/ops/pingora-cutover-evidence-bundle.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/ops/pingora-cutover-command-evidence.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/ops/pingora-cutover-evidence-verify.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/ops/pingora-cutover-evidence-audit.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/check-production-health-patrol-env.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/check-pingora-release-readiness.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/check-pingora-direct-preflight.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/check-pingora-direct-live.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/check-pingora-canary-live.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/check-pingora-canary-access-log-parity.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/production-api-deploy.sh,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/maintenance-on.sh,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/maintenance-off.sh,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/pingora-direct-enable.sh,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/pingora-direct-rollback.sh,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/pingora-realpath-canary-enable.sh,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/pingora-realpath-canary-disable.sh,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/pingora-health-patrol-env-switch.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/pingora-gateway-env-shadow-switch.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/deploy/systemd/**,build/${env.EFFECTIVE_BUILD_VERSION}/deploy/nginx/**,build/${env.EFFECTIVE_BUILD_VERSION}/deploy/env/**,build/${env.EFFECTIVE_BUILD_VERSION}/deploy/pingora/**", fingerprint: true
|
||||
script {
|
||||
if (params.INCLUDE_PINGORA_GATEWAY) {
|
||||
archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/pingora-gateway,build/${env.EFFECTIVE_BUILD_VERSION}/pingora-gateway.sha256", fingerprint: true
|
||||
|
||||
@@ -66,7 +66,7 @@ pipeline {
|
||||
copyArtifacts(
|
||||
projectName: params.BUILD_JOB_NAME,
|
||||
selector: specific(params.BUILD_NUMBER_TO_DEPLOY),
|
||||
filter: "build/${params.BUILD_VERSION}/api-server,build/${params.BUILD_VERSION}/api-server.sha256,build/${params.BUILD_VERSION}/release-manifest.json,build/${params.BUILD_VERSION}/scripts/database-backup-to-oss.mjs,build/${params.BUILD_VERSION}/scripts/ops/production-health-patrol.mjs,build/${params.BUILD_VERSION}/scripts/ops/pingora-current-release-audit.mjs,build/${params.BUILD_VERSION}/scripts/ops/pingora-direct-rehearsal-status.mjs,build/${params.BUILD_VERSION}/scripts/ops/pingora-cutover-status-snapshot.mjs,build/${params.BUILD_VERSION}/scripts/ops/pingora-cutover-evidence-bundle.mjs,build/${params.BUILD_VERSION}/scripts/ops/pingora-cutover-command-evidence.mjs,build/${params.BUILD_VERSION}/scripts/ops/pingora-cutover-evidence-verify.mjs,build/${params.BUILD_VERSION}/scripts/ops/pingora-cutover-evidence-audit.mjs,build/${params.BUILD_VERSION}/scripts/check-production-health-patrol-env.mjs,build/${params.BUILD_VERSION}/scripts/check-pingora-release-readiness.mjs,build/${params.BUILD_VERSION}/scripts/check-pingora-direct-preflight.mjs,build/${params.BUILD_VERSION}/scripts/check-pingora-direct-live.mjs,build/${params.BUILD_VERSION}/scripts/check-pingora-canary-live.mjs,build/${params.BUILD_VERSION}/scripts/check-pingora-canary-access-log-parity.mjs,build/${params.BUILD_VERSION}/scripts/deploy/production-api-deploy.sh,build/${params.BUILD_VERSION}/scripts/deploy/maintenance-on.sh,build/${params.BUILD_VERSION}/scripts/deploy/maintenance-off.sh,build/${params.BUILD_VERSION}/scripts/deploy/pingora-direct-enable.sh,build/${params.BUILD_VERSION}/scripts/deploy/pingora-direct-rollback.sh,build/${params.BUILD_VERSION}/scripts/deploy/pingora-realpath-canary-enable.sh,build/${params.BUILD_VERSION}/scripts/deploy/pingora-realpath-canary-disable.sh,build/${params.BUILD_VERSION}/scripts/deploy/pingora-health-patrol-env-switch.mjs,build/${params.BUILD_VERSION}/deploy/systemd/**,build/${params.BUILD_VERSION}/deploy/nginx/**,build/${params.BUILD_VERSION}/deploy/env/**,build/${params.BUILD_VERSION}/deploy/pingora/**",
|
||||
filter: "build/${params.BUILD_VERSION}/api-server,build/${params.BUILD_VERSION}/api-server.sha256,build/${params.BUILD_VERSION}/release-manifest.json,build/${params.BUILD_VERSION}/scripts/database-backup-to-oss.mjs,build/${params.BUILD_VERSION}/scripts/ops/production-health-patrol.mjs,build/${params.BUILD_VERSION}/scripts/ops/pingora-current-release-audit.mjs,build/${params.BUILD_VERSION}/scripts/ops/pingora-direct-rehearsal-status.mjs,build/${params.BUILD_VERSION}/scripts/ops/pingora-cutover-status-snapshot.mjs,build/${params.BUILD_VERSION}/scripts/ops/pingora-cutover-evidence-bundle.mjs,build/${params.BUILD_VERSION}/scripts/ops/pingora-cutover-command-evidence.mjs,build/${params.BUILD_VERSION}/scripts/ops/pingora-cutover-evidence-verify.mjs,build/${params.BUILD_VERSION}/scripts/ops/pingora-cutover-evidence-audit.mjs,build/${params.BUILD_VERSION}/scripts/check-production-health-patrol-env.mjs,build/${params.BUILD_VERSION}/scripts/check-pingora-release-readiness.mjs,build/${params.BUILD_VERSION}/scripts/check-pingora-direct-preflight.mjs,build/${params.BUILD_VERSION}/scripts/check-pingora-direct-live.mjs,build/${params.BUILD_VERSION}/scripts/check-pingora-canary-live.mjs,build/${params.BUILD_VERSION}/scripts/check-pingora-canary-access-log-parity.mjs,build/${params.BUILD_VERSION}/scripts/deploy/production-api-deploy.sh,build/${params.BUILD_VERSION}/scripts/deploy/maintenance-on.sh,build/${params.BUILD_VERSION}/scripts/deploy/maintenance-off.sh,build/${params.BUILD_VERSION}/scripts/deploy/pingora-direct-enable.sh,build/${params.BUILD_VERSION}/scripts/deploy/pingora-direct-rollback.sh,build/${params.BUILD_VERSION}/scripts/deploy/pingora-realpath-canary-enable.sh,build/${params.BUILD_VERSION}/scripts/deploy/pingora-realpath-canary-disable.sh,build/${params.BUILD_VERSION}/scripts/deploy/pingora-health-patrol-env-switch.mjs,build/${params.BUILD_VERSION}/scripts/deploy/pingora-gateway-env-shadow-switch.mjs,build/${params.BUILD_VERSION}/deploy/systemd/**,build/${params.BUILD_VERSION}/deploy/nginx/**,build/${params.BUILD_VERSION}/deploy/env/**,build/${params.BUILD_VERSION}/deploy/pingora/**",
|
||||
target: '.',
|
||||
fingerprintArtifacts: true
|
||||
)
|
||||
@@ -92,7 +92,7 @@ pipeline {
|
||||
sh '''
|
||||
bash -lc '
|
||||
set -euo pipefail
|
||||
chmod +x "build/${BUILD_VERSION}/scripts/deploy/production-api-deploy.sh" "build/${BUILD_VERSION}/scripts/deploy/maintenance-on.sh" "build/${BUILD_VERSION}/scripts/deploy/maintenance-off.sh" "build/${BUILD_VERSION}/scripts/deploy/pingora-direct-enable.sh" "build/${BUILD_VERSION}/scripts/deploy/pingora-direct-rollback.sh" "build/${BUILD_VERSION}/scripts/deploy/pingora-realpath-canary-enable.sh" "build/${BUILD_VERSION}/scripts/deploy/pingora-realpath-canary-disable.sh" "build/${BUILD_VERSION}/scripts/deploy/pingora-health-patrol-env-switch.mjs"
|
||||
chmod +x "build/${BUILD_VERSION}/scripts/deploy/production-api-deploy.sh" "build/${BUILD_VERSION}/scripts/deploy/maintenance-on.sh" "build/${BUILD_VERSION}/scripts/deploy/maintenance-off.sh" "build/${BUILD_VERSION}/scripts/deploy/pingora-direct-enable.sh" "build/${BUILD_VERSION}/scripts/deploy/pingora-direct-rollback.sh" "build/${BUILD_VERSION}/scripts/deploy/pingora-realpath-canary-enable.sh" "build/${BUILD_VERSION}/scripts/deploy/pingora-realpath-canary-disable.sh" "build/${BUILD_VERSION}/scripts/deploy/pingora-health-patrol-env-switch.mjs" "build/${BUILD_VERSION}/scripts/deploy/pingora-gateway-env-shadow-switch.mjs"
|
||||
"build/${BUILD_VERSION}/scripts/deploy/production-api-deploy.sh" \
|
||||
--source-dir "build/${BUILD_VERSION}" \
|
||||
--version "${BUILD_VERSION}" \
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"check:pingora-direct-enable": "node scripts/check-pingora-direct-enable.mjs",
|
||||
"check:pingora-direct-rollback": "node scripts/check-pingora-direct-rollback.mjs",
|
||||
"check:pingora-health-patrol-env-switch": "node scripts/check-pingora-health-patrol-env-switch.mjs",
|
||||
"check:pingora-gateway-env-shadow-switch": "node scripts/check-pingora-gateway-env-shadow-switch.mjs",
|
||||
"check:pingora-tls-cert-sync": "node scripts/check-pingora-tls-cert-sync.mjs",
|
||||
"check:pingora-current-release-audit": "node scripts/check-pingora-current-release-audit.mjs",
|
||||
"check:pingora-direct-rehearsal-status": "node scripts/check-pingora-direct-rehearsal-status.mjs",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user