补齐 Pingora runtime-only 彩排门禁

将 current release runtime-only 基础门禁接入直连彩排状态复核

保留 /opt/genarrative/current 调用路径,避免 symlink 展开导致 systemd ExecStart 自审误判

补充 release readiness plan 自测和 Pingora 运维文档

在 dev 服务器真实验证 runtime-only 门禁、Nginx 公网边界、Pingora shadow 和 realpath canary
This commit is contained in:
2026-06-18 14:33:54 +08:00
parent 4bca480f3c
commit 10e559701f
5 changed files with 178 additions and 6 deletions
+1 -1
View File
@@ -71,7 +71,7 @@
- 真实路径 canary 不再手工编辑 `/etc/nginx/conf.d/`。目标机从 current release 执行 `/opt/genarrative/current/scripts/deploy/pingora-realpath-canary-enable.sh --apply --probe-token <token> --host <域名> --base-url http://127.0.0.1:18083`;脚本会渲染到 `/etc/nginx/conf.d/zz-genarrative-pingora-realpath-canary.conf`,保持晚于主站 `log_format genarrative_upstream` 加载,执行 `nginx -t`、reload Nginx,并默认运行 realpath live smoke。`nginx -t`、reload 或 live smoke 任一失败都会恢复写入前配置。关闭时执行 `/opt/genarrative/current/scripts/deploy/pingora-realpath-canary-disable.sh --apply`,脚本会删除该 `zz-...` 文件,执行 `nginx -t` 和 reload,失败时恢复删除前配置。
- 真实路径 canary reload 后可再次运行 `node -- /opt/genarrative/current/scripts/check-pingora-canary-live.mjs --realpath --base-url http://127.0.0.1:18083 --host <域名>`,再运行 `node -- /opt/genarrative/current/scripts/check-pingora-canary-access-log-parity.mjs --realpath --nginx-log-file /var/log/nginx/genarrative-pingora-realpath-canary.access.log --pingora-log-file /var/log/genarrative/pingora-gateway.access.log --path /__genarrative_pingora_realpath_canary/healthz --path /api/creation-entry/config --path /v1/identity --path /assets/app.js`。真实路径模式除 healthz 探针外要求 Nginx path 与 Pingora path 完全一致。
- 正式切换前在源码 checkout / CI / 构建环境跑 `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 <域名> --live-nginx-access-log /var/log/nginx/genarrative.access.log --live-pingora-access-log /var/log/genarrative/pingora-gateway.access.log`;启用真实路径 canary 时追加 `--require-realpath-live --realpath-live-base-url http://127.0.0.1:18083 --realpath-live-host <域名> --realpath-live-nginx-access-log /var/log/nginx/genarrative-pingora-realpath-canary.access.log --realpath-live-pingora-access-log /var/log/genarrative/pingora-gateway.access.log`
- 目标机 `/opt/genarrative/current` 只能跑随包 runtime-only 门禁:`/opt/genarrative/current/scripts/check-pingora-release-readiness.mjs --release-runtime-only --require-live --live-base-url http://127.0.0.1 --live-host <域名> --live-nginx-access-log /var/log/nginx/genarrative.access.log --live-pingora-access-log /var/log/genarrative/pingora-gateway.access.log`;验证真实路径 canary 或 Pingora 直连入口时分别追加 `--require-realpath-live ...``--require-direct ...` 参数。生成正式直连切换 runbook 时,`--direct-redirect-host` 和回退 smoke Host 必须与 `--direct-host` 同 hostname,只允许端口不同。
- 目标机 `/opt/genarrative/current` 只能跑随包 runtime-only 门禁:`/opt/genarrative/current/scripts/check-pingora-release-readiness.mjs --release-runtime-only --require-live --live-base-url http://127.0.0.1 --live-host <域名> --live-nginx-access-log /var/log/nginx/genarrative.access.log --live-pingora-access-log /var/log/genarrative/pingora-gateway.access.log`;验证真实路径 canary 或 Pingora 直连入口时分别追加 `--require-realpath-live ...``--require-direct ...` 参数。未带 `--require-direct` 的 runtime-only 基础门禁会自动执行直连彩排状态复核,确认公网 `80/443` 仍由 Nginx 接流、Pingora shadow `127.0.0.1:18081`、realpath canary `127.0.0.1:18083` 和 current release 自审均通过;启用后 `--require-direct` 复核不再要求 Nginx 接公网彩排状态。生成正式直连切换 runbook 时,`--direct-redirect-host` 和回退 smoke Host 必须与 `--direct-host` 同 hostname,只允许端口不同。
## 验证
@@ -2348,7 +2348,7 @@
- 决策补充:Pingora Nginx canary snippet 变更必须运行 `npm run check:nginx-pingora-canary`;该脚本静态校验本机来源限制、handoff 响应头、probe token 占位、前缀 rewrite、低缓冲和 SpacetimeDB WebSocket Upgrade。目标 agent 或 CI 有 Nginx 时必须运行 `node scripts/check-nginx-pingora-canary.mjs --require-nginx`,把 snippet 包进临时 `server {}` 强制执行 `nginx -t`
- 决策补充:Pingora 与 Nginx 的核心路由 parity 以 `deploy/pingora/nginx-route-parity.matrix.json` 为共享检查输入;涉及 Nginx 模板、Pingora 路由、限流分组或路由文档时必须同步更新矩阵,并运行 `npm run check:pingora-route-parity``cargo test -p pingora-gateway --manifest-path server-rs/Cargo.toml matches_nginx_route_parity_matrix`。Rust 单测读取同一份矩阵验证 `classify_path`、body limit 和接流保护分组,Node 检查同时覆盖生产 / 开发 Nginx 模板和试点文档片段。
- 决策补充:Pingora 前缀 canary 在目标 Nginx 中人工 include 并 reload 后,必须运行 `npm run check:pingora-canary-live`;该脚本只读访问 `__genarrative_pingora_canary` 前缀下的 healthz、代表性 API、SpacetimeDB identity、静态资源和拒绝入口,并强制校验 `X-Genarrative-Nginx-Handoff: pingora-canary`,避免只通过 snippet 静态检查却没有证明真实 handoff 链路可用。
- 决策补充:Pingora release readiness 分为源码全量门禁和 current release runtime-only 门禁。源码 checkout / CI / 构建环境继续运行默认 `check-pingora-release-readiness.mjs`,覆盖 Cargo、npm、Docker、Nginx 静态 / 真机校验和发布包构建烟测;目标机 `/opt/genarrative/current` 的启用前基础门禁和启用后 `--require-direct` 复核必须调用随包 `scripts/check-pingora-release-readiness.mjs --release-runtime-only`,只执行 current release 自审、live canary、access log 对账、direct preflight、health patrol env 复核和 direct live smoke。API release / current release 必须随包携带 `scripts/check-pingora-release-readiness.mjs``scripts/check-pingora-canary-live.mjs`、canary access log 对账脚本和 direct preflight / live 子脚本;runtime-only 模式不得依赖源码 checkout、npm project root、Docker 或目标机 Nginx 静态校验。
- 决策补充:Pingora release readiness 分为源码全量门禁和 current release runtime-only 门禁。源码 checkout / CI / 构建环境继续运行默认 `check-pingora-release-readiness.mjs`,覆盖 Cargo、npm、Docker、Nginx 静态 / 真机校验和发布包构建烟测;目标机 `/opt/genarrative/current` 的启用前基础门禁和启用后 `--require-direct` 复核必须调用随包 `scripts/check-pingora-release-readiness.mjs --release-runtime-only`,只执行 current release 自审、启用前直连彩排状态、live canary、access log 对账、direct preflight、health patrol env 复核和 direct live smoke。未带 `--require-direct` 时 runtime-only 必须自动运行随包 `scripts/ops/pingora-direct-rehearsal-status.mjs --expect-public-gateway nginx --require-pingora-shadow --require-realpath-canary --require-current-release-gateway --fail-on-critical`,确认公网 `80/443` 仍由 Nginx 接流、Pingora shadow `127.0.0.1:18081`、realpath canary `127.0.0.1:18083` 和 current release 自审均通过;启用后 `--require-direct` 复核不再要求 Nginx 接公网彩排状态,改为检查 direct preflight、health patrol 直连模式和 direct live smoke。API release / current release 必须随包携带 `scripts/check-pingora-release-readiness.mjs``scripts/check-pingora-canary-live.mjs`、canary access log 对账脚本、直连彩排状态脚本和 direct preflight / live 子脚本;runtime-only 模式不得依赖源码 checkout、npm project root、Docker 或目标机 Nginx 静态校验。
- 决策补充:Pingora 直连静态响应必须显式写入缓存头。HTML、目录 index 和 SPA fallback 默认 `Cache-Control: no-cache``/assets/*``/admin/assets/*` 中带 Vite 指纹文件名的资源默认 `Cache-Control: public, max-age=31536000, immutable`;非指纹静态和 ACME challenge 默认 `no-cache`。三档由 `GENARRATIVE_PINGORA_GATEWAY_HTML_CACHE_CONTROL``GENARRATIVE_PINGORA_GATEWAY_ASSET_CACHE_CONTROL``GENARRATIVE_PINGORA_GATEWAY_STATIC_CACHE_CONTROL` 覆盖,配置值不能包含换行或 NUL`npm run check:pingora-gateway-smoke` 必须覆盖这些缓存头,避免直连后入口 HTML 被长期缓存或指纹资源失去长期缓存收益。
## 2026-06-11 资产计费边界改为 fail-closed 并补偿退款
File diff suppressed because one or more lines are too long
@@ -1,7 +1,14 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
@@ -89,6 +96,8 @@ function main() {
assertDefaultPlanIncludesCanaryAccessLogParitySmoke();
assertDefaultPlanIncludesPingoraProductionReleaseBuildSmoke();
assertReleaseRuntimeOnlyPlanUsesCurrentReleaseScripts();
assertReleaseRuntimeOnlyPlanIncludesDirectRehearsalStatus();
assertReleaseRuntimeOnlyKeepsInvokedCurrentSymlinkPath();
assertReleaseRuntimeOnlyRejectsSourceOnlyFlags();
assertDryRunCutoverPlanIncludesDirectEnableAndRollbackRunbook();
assertDryRunCutoverDefaultRollbackBodyIgnoresProcessEnv();
@@ -2194,6 +2203,141 @@ function assertReleaseRuntimeOnlyPlanUsesCurrentReleaseScripts() {
}
}
function assertReleaseRuntimeOnlyPlanIncludesDirectRehearsalStatus() {
const basePlan = readPlan(['--release-runtime-only', '--dry-run-plan']);
const baseStep = findStep(
basePlan,
'目标 Pingora direct rehearsal 状态复核',
);
if (!baseStep) {
failures.push(
'release runtime-only 基础计划必须包含直连彩排状态复核。',
);
return;
}
assertAbsoluteScriptArg(
baseStep.args,
'scripts/ops/pingora-direct-rehearsal-status.mjs',
'release runtime-only 直连彩排状态复核必须使用 current release 随包脚本。',
);
assertIncludes(
baseStep.args,
'--release-root',
'release runtime-only 直连彩排状态复核必须显式传 current release 根目录。',
);
assertIncludes(
baseStep.args,
'--expect-public-gateway',
'release runtime-only 直连彩排状态复核必须声明公网入口期望。',
);
assertIncludes(
baseStep.args,
'nginx',
'release runtime-only 直连彩排状态复核必须确认 Nginx 仍接公网。',
);
assertIncludes(
baseStep.args,
'--require-pingora-shadow',
'release runtime-only 直连彩排状态复核必须要求 Pingora shadow 高端口在线。',
);
assertIncludes(
baseStep.args,
'--require-realpath-canary',
'release runtime-only 直连彩排状态复核必须要求 realpath canary 高端口在线。',
);
assertIncludes(
baseStep.args,
'--require-current-release-gateway',
'release runtime-only 直连彩排状态复核必须复用 current release Pingora 自审。',
);
assertIncludes(
baseStep.args,
'--fail-on-critical',
'release runtime-only 直连彩排状态复核出现 CRITICAL 必须阻断门禁。',
);
const directPlan = readPlan([
...requireDirectBaseArgs,
'--release-runtime-only',
'--dry-run-plan',
]);
if (findStep(directPlan, '目标 Pingora direct rehearsal 状态复核')) {
failures.push(
'release runtime-only --require-direct 阶段不能继续要求 Nginx 接公网的彩排状态。',
);
}
}
function assertReleaseRuntimeOnlyKeepsInvokedCurrentSymlinkPath() {
const tmpRoot = mkdtempSync(
path.join(tmpdir(), 'genarrative-release-readiness-current-'),
);
try {
const releaseDir = path.join(tmpRoot, 'releases', 'test-release');
const currentLink = path.join(tmpRoot, 'current');
const scriptDir = path.join(releaseDir, 'scripts');
mkdirSync(scriptDir, { recursive: true });
symlinkSync(releaseDir, currentLink, 'dir');
const invokedScript = path.join(
currentLink,
'scripts',
'check-pingora-release-readiness.mjs',
);
const realScript = path.join(
scriptDir,
'check-pingora-release-readiness.mjs',
);
symlinkSync(
path.join(process.cwd(), 'scripts/check-pingora-release-readiness.mjs'),
realScript,
);
const result = spawnSync(
'node',
[invokedScript, '--release-runtime-only', '--dry-run-plan'],
{
cwd: process.cwd(),
encoding: 'utf8',
env: readinessPlanEnv(),
},
);
if ((result.status ?? 0) !== 0) {
failures.push(
`通过 current symlink 读取 runtime-only plan 失败,退出码 ${result.status}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
);
return;
}
const plan = JSON.parse(result.stdout);
const audit = findStep(plan, 'current release 自包含自审');
const rehearsal = findStep(plan, '目标 Pingora direct rehearsal 状态复核');
for (const [label, step] of [
['current release 自审', audit],
['直连彩排状态复核', rehearsal],
]) {
if (!step) {
failures.push(
`通过 current symlink 生成的 runtime-only plan 缺少${label}步骤。`,
);
continue;
}
assertIncludes(
step.args,
currentLink,
`${label}必须保留调用方 current symlink 作为 release root,而不是展开到真实 release 目录。`,
);
if (step.args.includes(releaseDir)) {
failures.push(
`${label}不应把 current symlink 展开成真实 release 目录: ${releaseDir}`,
);
}
}
} catch (error) {
failures.push(`current symlink runtime-only plan 自测异常: ${error.message}`);
} finally {
rmSync(tmpRoot, { recursive: true, force: true });
}
}
function assertReleaseRuntimeOnlyRejectsSourceOnlyFlags() {
for (const flag of ['--require-docker', '--pull-docker', '--require-nginx']) {
const result = runReadinessExpectFailure([
+29 -1
View File
@@ -6,7 +6,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = process.cwd();
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const scriptDir = path.dirname(resolveInvokedScriptPath());
const releaseRoot = path.resolve(scriptDir, '..');
const DEFAULT_CUTOVER_EVIDENCE_TIMELINE_MAX_SPAN_MS = 24 * 60 * 60 * 1000;
const DEFAULT_CUTOVER_RUN_ID_PREFIX = 'pingora-direct-';
@@ -46,6 +46,14 @@ if (failures.length > 0) {
console.log('\n[pingora-release-readiness] 通过');
function resolveInvokedScriptPath() {
const invoked = process.argv[1];
if (invoked) {
return path.resolve(invoked);
}
return fileURLToPath(import.meta.url);
}
function parseArgs(argv) {
const result = {
requireDocker: false,
@@ -1802,6 +1810,26 @@ function buildReleaseRuntimeSteps(config) {
},
];
if (!config.requireDirect) {
steps.push({
name: '目标 Pingora direct rehearsal 状态复核',
command: 'node',
args: [
'--',
releaseScriptPath('scripts/ops/pingora-direct-rehearsal-status.mjs'),
'--release-root',
releaseRoot,
'--expect-public-gateway',
'nginx',
'--require-pingora-shadow',
'--require-realpath-canary',
'--require-current-release-gateway',
'--fail-on-critical',
],
cwd: releaseRoot,
});
}
appendTargetLiveSteps(steps, config, releaseScriptPath);
appendTargetRealpathLiveSteps(steps, config, releaseScriptPath);
appendTargetDirectSteps(steps, config, releaseScriptPath);