合并 master 到图片画布素材分支

保留图片画布素材元数据迁移记录

合入 Pingora 网关与生产运维更新

解决外部生成 worker 测试夹具时间戳冲突
This commit is contained in:
2026-06-19 17:01:31 +08:00
92 changed files with 59009 additions and 501 deletions
+114 -3
View File
@@ -18,6 +18,10 @@ usage() {
--skip-web-build 跳过主站与后台构建,仅复制已有 dist 产物
--skip-api-build 跳过 api-server 构建,仅复制已有 release 二进制
--skip-spacetime-build 跳过 spacetime-module 构建,仅复制已有 wasm
--include-pingora-gateway
额外构建并打包 Pingora 影子网关二进制,默认不启用
--skip-pingora-gateway-build
跳过 Pingora 构建,仅复制已有 release 二进制
--no-migration-bootstrap-secret
构建不带迁移引导密钥的 spacetime-module wasm
EOF
@@ -32,6 +36,28 @@ require_command() {
fi
}
require_any_command() {
local label="$1"
shift
local command_name
for command_name in "$@"; do
if command -v "${command_name}" >/dev/null 2>&1; then
return
fi
done
echo "[production-release] 缺少 ${label}: $*" >&2
exit 1
}
require_pingora_build_toolchain() {
require_command cargo
require_command cmake
require_any_command "C 编译器" cc clang gcc
require_any_command "C++ 编译器" c++ clang++ g++
}
copy_required_file() {
local source_path="$1"
local target_path="$2"
@@ -140,6 +166,7 @@ write_release_manifest() {
RELEASE_INCLUDE_WEB="${BUILD_WEB}" \
RELEASE_INCLUDE_API="${BUILD_API}" \
RELEASE_INCLUDE_SPACETIME="${BUILD_SPACETIME}" \
RELEASE_INCLUDE_PINGORA_GATEWAY="${INCLUDE_PINGORA_GATEWAY}" \
RELEASE_INCLUDE_MIGRATION_BOOTSTRAP_SECRET="${MIGRATION_BOOTSTRAP_SECRET_ARTIFACT}" \
node <<'NODE'
const fs = require('fs');
@@ -166,6 +193,13 @@ if (process.env.RELEASE_INCLUDE_SPACETIME === '1') {
checksum_path: 'spacetime_module.wasm.sha256',
});
}
if (process.env.RELEASE_INCLUDE_PINGORA_GATEWAY === '1') {
artifacts.push({
component: 'pingora-gateway',
path: 'pingora-gateway',
checksum_path: 'pingora-gateway.sha256',
});
}
if (process.env.RELEASE_INCLUDE_MIGRATION_BOOTSTRAP_SECRET === '1') {
artifacts.push({
component: 'spacetime-module',
@@ -196,6 +230,8 @@ COMPONENT="all"
SKIP_WEB_BUILD=0
SKIP_API_BUILD=0
SKIP_SPACETIME_BUILD=0
SKIP_PINGORA_GATEWAY_BUILD=0
INCLUDE_PINGORA_GATEWAY=0
MIGRATION_BOOTSTRAP_SECRET=""
MIGRATION_BOOTSTRAP_SECRET_ARTIFACT=0
MIGRATION_BOOTSTRAP_SECRET_MODE="auto"
@@ -227,6 +263,14 @@ while [[ $# -gt 0 ]]; do
SKIP_SPACETIME_BUILD=1
shift
;;
--include-pingora-gateway)
INCLUDE_PINGORA_GATEWAY=1
shift
;;
--skip-pingora-gateway-build)
SKIP_PINGORA_GATEWAY_BUILD=1
shift
;;
--no-migration-bootstrap-secret)
MIGRATION_BOOTSTRAP_SECRET=""
MIGRATION_BOOTSTRAP_SECRET_MODE="disabled"
@@ -276,6 +320,7 @@ WEB_DIR="${TARGET_DIR}/web"
ADMIN_WEB_DIR="${WEB_DIR}/admin"
CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-${SERVER_RS_DIR}/target}"
API_BINARY_SOURCE="${CARGO_TARGET_DIR}/x86_64-unknown-linux-gnu/release/api-server"
PINGORA_GATEWAY_BINARY_SOURCE="${CARGO_TARGET_DIR}/x86_64-unknown-linux-gnu/release/pingora-gateway"
WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module.wasm"
RELEASE_SOURCE_BRANCH="${SOURCE_BRANCH:-${GIT_BRANCH:-}}"
RELEASE_SOURCE_BRANCH="${RELEASE_SOURCE_BRANCH#origin/}"
@@ -310,7 +355,6 @@ if [[ -e "${TARGET_DIR}" ]]; then
exit 1
fi
require_command node
require_command node
require_command sha256sum
@@ -322,6 +366,10 @@ if [[ "${BUILD_SPACETIME}" -eq 1 && "${SKIP_SPACETIME_BUILD}" -ne 1 ]]; then
require_command cargo
fi
if [[ "${INCLUDE_PINGORA_GATEWAY}" -eq 1 && "${SKIP_PINGORA_GATEWAY_BUILD}" -ne 1 ]]; then
require_pingora_build_toolchain
fi
if [[ "${BUILD_WEB}" -eq 1 && "${SKIP_WEB_BUILD}" -ne 1 ]]; then
require_command node
require_command npm
@@ -427,6 +475,24 @@ if [[ "${BUILD_API}" -eq 1 ]]; then
write_sha256_file "${TARGET_DIR}/api-server"
fi
if [[ "${INCLUDE_PINGORA_GATEWAY}" -eq 1 && "${SKIP_PINGORA_GATEWAY_BUILD}" -ne 1 ]]; then
echo "[production-release] 构建 pingora-gateway -> x86_64-unknown-linux-gnu"
(
cd "${SERVER_RS_DIR}"
cargo build \
-p pingora-gateway \
--release \
--target x86_64-unknown-linux-gnu \
--manifest-path "${SERVER_RS_DIR}/Cargo.toml"
)
fi
if [[ "${INCLUDE_PINGORA_GATEWAY}" -eq 1 ]]; then
copy_required_file "${PINGORA_GATEWAY_BINARY_SOURCE}" "${TARGET_DIR}/pingora-gateway" "pingora-gateway release binary"
chmod +x "${TARGET_DIR}/pingora-gateway"
write_sha256_file "${TARGET_DIR}/pingora-gateway"
fi
if [[ "${BUILD_SPACETIME}" -eq 1 && "${SKIP_SPACETIME_BUILD}" -ne 1 ]]; then
echo "[production-release] 构建 spacetime-module -> wasm32-unknown-unknown"
(
@@ -445,10 +511,20 @@ if [[ "${BUILD_SPACETIME}" -eq 1 ]]; then
write_migration_bootstrap_secret_file
fi
mkdir -p "${TARGET_DIR}/scripts" "${TARGET_DIR}/scripts/ops" "${TARGET_DIR}/deploy"
mkdir -p "${TARGET_DIR}/scripts" "${TARGET_DIR}/scripts/deploy" "${TARGET_DIR}/scripts/ops" "${TARGET_DIR}/deploy"
cp "${SCRIPT_DIR}/deploy/maintenance-on.sh" "${TARGET_DIR}/scripts/maintenance-on.sh"
cp "${SCRIPT_DIR}/deploy/maintenance-off.sh" "${TARGET_DIR}/scripts/maintenance-off.sh"
cp "${SCRIPT_DIR}/deploy/maintenance-status.sh" "${TARGET_DIR}/scripts/maintenance-status.sh"
cp "${SCRIPT_DIR}/deploy/production-api-deploy.sh" "${TARGET_DIR}/scripts/deploy/production-api-deploy.sh"
cp "${SCRIPT_DIR}/deploy/maintenance-on.sh" "${TARGET_DIR}/scripts/deploy/maintenance-on.sh"
cp "${SCRIPT_DIR}/deploy/maintenance-off.sh" "${TARGET_DIR}/scripts/deploy/maintenance-off.sh"
cp "${SCRIPT_DIR}/deploy/pingora-direct-enable.sh" "${TARGET_DIR}/scripts/deploy/pingora-direct-enable.sh"
cp "${SCRIPT_DIR}/deploy/pingora-direct-rollback.sh" "${TARGET_DIR}/scripts/deploy/pingora-direct-rollback.sh"
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"
cp "${SCRIPT_DIR}/deploy/jenkins-agent-reverse-tunnel.ps1" "${TARGET_DIR}/scripts/jenkins-agent-reverse-tunnel.ps1"
@@ -457,6 +533,16 @@ chmod +x \
"${TARGET_DIR}/scripts/maintenance-on.sh" \
"${TARGET_DIR}/scripts/maintenance-off.sh" \
"${TARGET_DIR}/scripts/maintenance-status.sh" \
"${TARGET_DIR}/scripts/deploy/production-api-deploy.sh" \
"${TARGET_DIR}/scripts/deploy/maintenance-on.sh" \
"${TARGET_DIR}/scripts/deploy/maintenance-off.sh" \
"${TARGET_DIR}/scripts/deploy/pingora-direct-enable.sh" \
"${TARGET_DIR}/scripts/deploy/pingora-direct-rollback.sh" \
"${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"
@@ -467,10 +553,24 @@ copy_required_file "${SCRIPT_DIR}/spacetime-authorize-migration-operator.mjs" "$
copy_required_file "${SCRIPT_DIR}/spacetime-revoke-migration-operator.mjs" "${TARGET_DIR}/scripts/spacetime-revoke-migration-operator.mjs" "数据库迁移撤权脚本"
copy_required_file "${SCRIPT_DIR}/database-backup-to-oss.mjs" "${TARGET_DIR}/scripts/database-backup-to-oss.mjs" "数据库 OSS 备份脚本"
copy_required_file "${SCRIPT_DIR}/ops/production-health-patrol.mjs" "${TARGET_DIR}/scripts/ops/production-health-patrol.mjs" "生产健康巡检脚本"
copy_required_file "${SCRIPT_DIR}/ops/pingora-current-release-audit.mjs" "${TARGET_DIR}/scripts/ops/pingora-current-release-audit.mjs" "Pingora current release 自审脚本"
copy_required_file "${SCRIPT_DIR}/ops/pingora-direct-rehearsal-status.mjs" "${TARGET_DIR}/scripts/ops/pingora-direct-rehearsal-status.mjs" "Pingora 直连切换彩排状态脚本"
copy_required_file "${SCRIPT_DIR}/ops/pingora-cutover-status-snapshot.mjs" "${TARGET_DIR}/scripts/ops/pingora-cutover-status-snapshot.mjs" "Pingora 直连切换状态快照脚本"
copy_required_file "${SCRIPT_DIR}/ops/pingora-cutover-evidence-bundle.mjs" "${TARGET_DIR}/scripts/ops/pingora-cutover-evidence-bundle.mjs" "Pingora 直连切换证据包脚本"
copy_required_file "${SCRIPT_DIR}/ops/pingora-cutover-command-evidence.mjs" "${TARGET_DIR}/scripts/ops/pingora-cutover-command-evidence.mjs" "Pingora 直连切换命令证据脚本"
copy_required_file "${SCRIPT_DIR}/ops/pingora-cutover-evidence-verify.mjs" "${TARGET_DIR}/scripts/ops/pingora-cutover-evidence-verify.mjs" "Pingora 直连切换证据验真脚本"
copy_required_file "${SCRIPT_DIR}/ops/pingora-cutover-evidence-audit.mjs" "${TARGET_DIR}/scripts/ops/pingora-cutover-evidence-audit.mjs" "Pingora 直连切换证据根目录审计脚本"
copy_required_file "${SCRIPT_DIR}/check-production-health-patrol-env.mjs" "${TARGET_DIR}/scripts/check-production-health-patrol-env.mjs" "生产健康巡检 env 复核脚本"
copy_required_file "${SCRIPT_DIR}/check-pingora-release-readiness.mjs" "${TARGET_DIR}/scripts/check-pingora-release-readiness.mjs" "Pingora release readiness 聚合门禁脚本"
copy_required_file "${SCRIPT_DIR}/check-pingora-direct-preflight.mjs" "${TARGET_DIR}/scripts/check-pingora-direct-preflight.mjs" "Pingora 直连预检脚本"
copy_required_file "${SCRIPT_DIR}/check-pingora-direct-live.mjs" "${TARGET_DIR}/scripts/check-pingora-direct-live.mjs" "Pingora 直连 live smoke 脚本"
copy_required_file "${SCRIPT_DIR}/check-pingora-canary-live.mjs" "${TARGET_DIR}/scripts/check-pingora-canary-live.mjs" "Pingora canary live smoke 脚本"
copy_required_file "${SCRIPT_DIR}/check-pingora-canary-access-log-parity.mjs" "${TARGET_DIR}/scripts/check-pingora-canary-access-log-parity.mjs" "Pingora canary access log 对账脚本"
copy_required_dir "${REPO_ROOT}/deploy/systemd" "${TARGET_DIR}/deploy/systemd" "systemd 配置"
copy_required_dir "${REPO_ROOT}/deploy/nginx" "${TARGET_DIR}/deploy/nginx" "Nginx 配置"
copy_required_dir "${REPO_ROOT}/deploy/env" "${TARGET_DIR}/deploy/env" "生产环境示例"
copy_required_dir "${REPO_ROOT}/deploy/pingora" "${TARGET_DIR}/deploy/pingora" "Pingora 配置"
cat >"${TARGET_DIR}/README.md" <<EOF
# Genarrative Production Release
@@ -482,13 +582,24 @@ cat >"${TARGET_DIR}/README.md" <<EOF
- \`web/\`:主站静态资源,\`web/admin/\` 为后台静态资源,\`web/maintenance.html\` 为维护页。
- \`web.tar.gz\` / \`web.tar.gz.sha256\`Web 发布流水线使用的静态资源压缩包与校验文件。
- \`api-server\`:生产 Linux release 可执行文件。
- \`pingora-gateway\`:可选 Pingora 影子网关可执行文件,仅在显式 \`--include-pingora-gateway\` 时包含。
- \`spacetime_module.wasm\`SpacetimeDB 模块 wasm。
- \`migration-bootstrap-secret.txt\`:构建 \`spacetime_module.wasm\` 时注入的迁移引导密钥,仅用于创建首个迁移操作员;请作为敏感文件保存到 Jenkins Secret Text,授权完成后不要长期留在公开归档中。
- \`*.sha256\`:发布产物 checksum,用于部署前校验。
- \`release-manifest.json\`:发布版本、源码 commit 与产物清单。
- \`scripts/\`:维护模式脚本、数据库导入导出脚本、数据库 OSS 备份脚本、生产健康巡检脚本、迁移授权脚本和 Jenkins inbound agent systemd 安装脚本。
- \`scripts/\`:维护模式脚本、数据库导入导出脚本、数据库 OSS 备份脚本、生产健康巡检脚本、Pingora release readiness 聚合门禁、直连启用 / 回退、realpath canary 启用 / 关闭、health patrol env 切换 / TLS 证书同步 / 预检 / direct live smoke / canary live smoke / canary access log 对账 / current release 自审 / 直连彩排状态 / 状态快照 / 证据包 / 命令证据 / 证据验真 / 证据根目录审计脚本、迁移授权脚本和 Jenkins inbound agent systemd 安装脚本。
- \`scripts/deploy/production-api-deploy.sh\`API Deploy 执行入口;同目录的 \`maintenance-on.sh\` / \`maintenance-off.sh\` 必须来自同一发布包。
- \`deploy/\`systemd、Nginx 和生产环境变量示例;\`deploy/nginx/genarrative-dev-http.conf\` 仅供无域名开发服初始化使用。
## Pingora 直连证据总审计
正式切换 runbook 的最终证据根目录总审计必须使用 current release 随包脚本,并同时保留两条真实切换脚本身份要求:
- \`--require-command-executable enable-apply:pingora-direct-enable-apply:/opt/genarrative/current/scripts/deploy/pingora-direct-enable.sh\`
- \`--require-command-executable rollback-apply:pingora-direct-rollback-apply:/opt/genarrative/current/scripts/deploy/pingora-direct-rollback.sh\`
这两项用于证明 enable / rollback apply 命令证据里真实执行的是 \`/opt/genarrative/current\` 下的随包脚本,而不是 Jenkins 工作区、源码 checkout 或旧发布目录中的同名脚本。
## 生产部署口径
本发布包不包含旧一体化 \`start.sh\`、\`stop.sh\` 或 \`web-server.mjs\`。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,493 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
const PARITY_SCRIPT = 'scripts/check-pingora-canary-access-log-parity.mjs';
const failures = [];
const tmpRoot = mkdtempSync(
path.join(tmpdir(), 'genarrative-pingora-canary-log-parity-'),
);
try {
main();
} finally {
rmSync(tmpRoot, { recursive: true, force: true });
}
if (failures.length > 0) {
console.error('[check:pingora-canary-access-log-parity] FAILED');
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
console.log('[check:pingora-canary-access-log-parity] OK');
function main() {
assertScriptShape();
assertParitySucceeds();
assertRealpathParitySucceeds();
assertMissingPingoraRecordFails();
assertStatusMismatchFails();
assertRequiredPathFails();
assertRejectsRelativeLogPaths();
assertRejectsFilesystemRootLogPaths();
assertRejectsLogPathControlCharacters();
assertRejectsPrefixAndPathControlCharacters();
assertRejectsParsedLogPathControlCharacters();
assertRejectsInvalidSinceLines();
}
function assertScriptShape() {
const content = readFileSync(PARITY_SCRIPT, 'utf8');
assertIncludes(
content,
'该脚本只读比较 Nginx canary handoff access log 和 Pingora access log',
'canary access log parity 脚本 usage 必须说明只读边界。',
);
assertIncludes(
content,
'request_id',
'canary access log parity 必须按 request_id 对照。',
);
assertIncludes(
content,
'realpath',
'canary access log parity 必须支持真实路径 canary 对账模式。',
);
if (
content.includes('writeFile') ||
content.includes('rmSync(') ||
content.includes('nginx -s reload')
) {
failures.push('canary access log parity 脚本不应写文件、删除文件或 reload Nginx。');
}
}
function assertParitySucceeds() {
const fixture = prepareFixture('ok');
writeLogs(fixture, [
nginxLine('rid-health', 'GET', '/__genarrative_pingora_canary/healthz', 200),
nginxLine(
'rid-api',
'GET',
'/__genarrative_pingora_canary/api/creation-entry/config',
200,
),
], [
pingoraLine('rid-health', 'GET', '/__genarrative_pingora/healthz', 200, {
route: 'shadow_probe',
}),
pingoraLine('rid-api', 'GET', '/api/creation-entry/config', 200, {
route: 'api_proxy',
proxyTarget: 'api-server',
}),
]);
const result = runParity(fixture, [
'--path',
'/__genarrative_pingora_canary/healthz',
'--path',
'/__genarrative_pingora_canary/api/creation-entry/config',
'--json',
]);
assertStatus(result, 0, '日志对照完整时必须通过。');
if (result.status !== 0) {
return;
}
const payload = parseJson(result.stdout, '日志对照 JSON 输出');
assertEqual(payload.summary.matchedCount, 2, '应匹配两条 canary 请求。');
assertEqual(payload.summary.missingCount, 0, '不应缺少 Pingora 对应日志。');
}
function assertRealpathParitySucceeds() {
const fixture = prepareFixture('realpath-ok');
writeLogs(fixture, [
nginxLine(
'rid-real-health',
'GET',
'/__genarrative_pingora_realpath_canary/healthz',
200,
),
nginxLine('rid-real-api', 'GET', '/api/creation-entry/config', 200),
nginxLine('rid-real-asset', 'GET', '/assets/app.js', 200),
], [
pingoraLine('rid-real-health', 'GET', '/__genarrative_pingora/healthz', 200, {
route: 'shadow_probe',
}),
pingoraLine('rid-real-api', 'GET', '/api/creation-entry/config', 200, {
route: 'api_proxy',
proxyTarget: 'api-server',
}),
pingoraLine('rid-real-asset', 'GET', '/assets/app.js', 200, {
route: 'static',
}),
]);
const result = runParity(fixture, [
'--realpath',
'--path',
'/__genarrative_pingora_realpath_canary/healthz',
'--path',
'/api/creation-entry/config',
'--path',
'/assets/app.js',
'--json',
]);
assertStatus(result, 0, '真实路径日志对照完整时必须通过。');
if (result.status !== 0) {
return;
}
const payload = parseJson(result.stdout, '真实路径日志对照 JSON 输出');
assertEqual(payload.mode, 'realpath', '真实路径对账 JSON 必须标记 realpath 模式。');
assertEqual(payload.summary.matchedCount, 3, '应匹配三条真实路径 canary 请求。');
assertEqual(payload.summary.missingCount, 0, '真实路径对账不应缺少 Pingora 对应日志。');
}
function assertMissingPingoraRecordFails() {
const fixture = prepareFixture('missing-pingora');
writeLogs(fixture, [
nginxLine('rid-missing', 'GET', '/__genarrative_pingora_canary/v1/identity', 200),
], []);
const result = runParity(fixture);
assertStatus(result, 1, '缺少同 request_id Pingora 日志时必须失败。');
assertIncludes(
`${result.stdout}\n${result.stderr}`,
'缺少对应 Pingora access log',
'缺少 Pingora 日志时必须给出明确错误。',
);
}
function assertStatusMismatchFails() {
const fixture = prepareFixture('status-mismatch');
writeLogs(fixture, [
nginxLine('rid-status', 'GET', '/__genarrative_pingora_canary/api/test', 200),
], [
pingoraLine('rid-status', 'GET', '/api/test', 503),
]);
const result = runParity(fixture);
assertStatus(result, 1, 'Nginx/Pingora 状态码不一致时必须失败。');
assertIncludes(
`${result.stdout}\n${result.stderr}`,
'status 200 != 503',
'状态码不一致必须给出明确错误。',
);
}
function assertRequiredPathFails() {
const fixture = prepareFixture('missing-required-path');
writeLogs(fixture, [
nginxLine('rid-health', 'GET', '/__genarrative_pingora_canary/healthz', 200),
], [
pingoraLine('rid-health', 'GET', '/__genarrative_pingora/healthz', 200),
]);
const result = runParity(fixture, [
'--path',
'/__genarrative_pingora_canary/api/creation-entry/config',
]);
assertStatus(result, 1, '必需路径未出现在 Nginx canary 日志时必须失败。');
assertIncludes(
`${result.stdout}\n${result.stderr}`,
'Nginx canary 日志缺少必需路径',
'必需路径缺失必须给出明确错误。',
);
}
function assertRejectsRelativeLogPaths() {
const fixture = prepareFixture('relative-path');
const result = spawnSync(
'node',
[
PARITY_SCRIPT,
'--nginx-log-file',
'nginx.log',
'--pingora-log-file',
fixture.pingoraLogFile,
],
{
cwd: process.cwd(),
encoding: 'utf8',
},
);
if ((result.status ?? 0) === 0) {
failures.push('日志路径为相对路径时必须失败。');
}
assertIncludes(
`${result.stdout}\n${result.stderr}`,
'--nginx-log-file 必须是绝对路径',
'相对 Nginx 日志路径必须给出明确错误。',
);
}
function assertRejectsFilesystemRootLogPaths() {
const fixture = prepareFixture('filesystem-root-path');
const result = spawnSync(
'node',
[
PARITY_SCRIPT,
'--nginx-log-file',
'/',
'--pingora-log-file',
fixture.pingoraLogFile,
],
{
cwd: process.cwd(),
encoding: 'utf8',
},
);
if ((result.status ?? 0) === 0) {
failures.push('日志路径指向文件系统根目录时必须失败。');
}
assertIncludes(
`${result.stdout}\n${result.stderr}`,
'--nginx-log-file 不能是文件系统根目录',
'文件系统根目录 Nginx 日志路径必须给出明确错误。',
);
}
function assertRejectsLogPathControlCharacters() {
const fixture = prepareFixture('log-path-control-character');
const result = spawnSync(
'node',
[
PARITY_SCRIPT,
'--nginx-log-file',
`${fixture.nginxLogFile}\nspoofed`,
'--pingora-log-file',
fixture.pingoraLogFile,
],
{
cwd: process.cwd(),
encoding: 'utf8',
},
);
if ((result.status ?? 0) === 0) {
failures.push('日志路径包含换行时必须失败。');
}
assertIncludes(
`${result.stdout}\n${result.stderr}`,
'--nginx-log-file 不能包含换行或 NUL 字符',
'带控制字符的 Nginx 日志路径必须给出明确错误。',
);
}
function assertRejectsPrefixAndPathControlCharacters() {
const fixture = prepareFixture('prefix-path-control-character');
writeLogs(fixture, [
nginxLine('rid-health', 'GET', '/__genarrative_pingora_canary/healthz', 200),
], [
pingoraLine('rid-health', 'GET', '/__genarrative_pingora/healthz', 200),
]);
const prefixResult = runParity(fixture, [
'--prefix',
'/__genarrative_pingora_canary\nspoofed',
]);
if ((prefixResult.status ?? 0) === 0) {
failures.push('canary prefix 包含换行时必须失败。');
}
assertIncludes(
`${prefixResult.stdout}\n${prefixResult.stderr}`,
'--prefix 不能包含换行或 NUL 字符',
'带控制字符的 canary prefix 必须给出明确错误。',
);
const pathResult = runParity(fixture, [
'--path',
'/__genarrative_pingora_canary/healthz\nspoofed',
]);
if ((pathResult.status ?? 0) === 0) {
failures.push('必需 canary path 包含换行时必须失败。');
}
assertIncludes(
`${pathResult.stdout}\n${pathResult.stderr}`,
'--path 不能包含换行或 NUL 字符',
'带控制字符的必需路径必须给出明确错误。',
);
}
function assertRejectsParsedLogPathControlCharacters() {
const fixture = prepareFixture('parsed-log-path-control-character');
writeLogs(fixture, [
nginxLine('rid-health', 'GET', '/__genarrative_pingora_canary/healthz', 200),
], [
pingoraLine('rid-health', 'GET', '/__genarrative_pingora/healthz%0Aspoofed', 200),
]);
const result = runParity(fixture);
if ((result.status ?? 0) === 0) {
failures.push('Pingora 日志 path 解析后包含换行时必须失败。');
}
assertIncludes(
`${result.stdout}\n${result.stderr}`,
'Pingora access log 第 1 行 path 不能包含换行或 NUL 字符',
'带控制字符的 Pingora 日志 path 必须给出明确错误。',
);
}
function assertRejectsInvalidSinceLines() {
const fixture = prepareFixture('invalid-since-lines');
writeLogs(fixture, [
nginxLine('rid-health', 'GET', '/__genarrative_pingora_canary/healthz', 200),
], [
pingoraLine('rid-health', 'GET', '/__genarrative_pingora/healthz', 200),
]);
const cliResult = runParity(fixture, ['--since-lines', '0']);
if ((cliResult.status ?? 0) === 0) {
failures.push('非正数 --since-lines 必须失败。');
}
assertIncludes(
`${cliResult.stdout}\n${cliResult.stderr}`,
'--since-lines 必须是正整数',
'非法 --since-lines 必须给出明确错误。',
);
const controlResult = runParity(fixture, ['--since-lines', '10\nspoofed']);
if ((controlResult.status ?? 0) === 0) {
failures.push('带控制字符的 --since-lines 必须失败。');
}
assertIncludes(
`${controlResult.stdout}\n${controlResult.stderr}`,
'--since-lines 不能包含换行或 NUL 字符',
'带控制字符的 --since-lines 必须给出明确错误。',
);
const envResult = spawnSync(
'node',
[
PARITY_SCRIPT,
'--nginx-log-file',
fixture.nginxLogFile,
'--pingora-log-file',
fixture.pingoraLogFile,
],
{
cwd: process.cwd(),
encoding: 'utf8',
env: {
...process.env,
GENARRATIVE_PINGORA_CANARY_ACCESS_LOG_SINCE_LINES: 'abc',
},
},
);
if ((envResult.status ?? 0) === 0) {
failures.push('非法 env since-lines 必须失败。');
}
assertIncludes(
`${envResult.stdout}\n${envResult.stderr}`,
'GENARRATIVE_PINGORA_CANARY_ACCESS_LOG_SINCE_LINES 必须是正整数',
'非法 env since-lines 必须给出明确错误。',
);
}
function prepareFixture(name) {
const root = path.join(tmpRoot, name);
mkdirSync(root, { recursive: true });
return {
root,
nginxLogFile: path.join(root, 'nginx.access.log'),
pingoraLogFile: path.join(root, 'pingora.access.log'),
};
}
function writeLogs(fixture, nginxLines, pingoraLines) {
writeFileSync(fixture.nginxLogFile, `${nginxLines.join('\n')}\n`, 'utf8');
writeFileSync(fixture.pingoraLogFile, `${pingoraLines.join('\n')}\n`, 'utf8');
}
function nginxLine(requestId, method, uri, status) {
return [
'127.0.0.1 - - [16/Jun/2026:02:00:00 +0800]',
`"${method} ${uri} HTTP/1.1"`,
`${status} 12 "-" "agent"`,
'request_time=0.001 upstream_connect_time=0.000',
'upstream_header_time=0.001 upstream_response_time=0.001',
`upstream_status=${status} request_id=${requestId}`,
].join(' ');
}
function pingoraLine(
requestId,
method,
requestPath,
status,
options = {},
) {
return [
`request_id=${requestId}`,
`method=${method}`,
`path=${requestPath}`,
`uri=${requestPath}`,
'host=example.com',
'client_ip=127.0.0.1',
`status=${status}`,
`route=${options.route || 'api_proxy'}`,
`proxy_target=${options.proxyTarget || '-'}`,
'upstream=127.0.0.1:8082',
'content_length=-',
'body_bytes_seen=0',
'protection_class=api',
'protection_client=127.0.0.1',
'elapsed_ms=2',
'error=-',
].join('\t');
}
function runParity(fixture, args = []) {
return spawnSync(
'node',
[
PARITY_SCRIPT,
'--nginx-log-file',
fixture.nginxLogFile,
'--pingora-log-file',
fixture.pingoraLogFile,
...args,
],
{
cwd: process.cwd(),
encoding: 'utf8',
},
);
}
function parseJson(text, label) {
try {
return JSON.parse(text);
} catch (error) {
failures.push(`${label} 不是合法 JSON: ${error.message}`);
return {};
}
}
function assertStatus(result, expected, reason) {
if ((result.status ?? 0) !== expected) {
failures.push(
`${reason} 实际退出码 ${result.status}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
);
}
}
function assertEqual(actual, expected, reason) {
if (actual !== expected) {
failures.push(`${reason} 实际 ${actual},预期 ${expected}`);
}
}
function assertIncludes(value, expected, reason) {
const haystack = Array.isArray(value) ? value.join('\n') : String(value);
if (!haystack.includes(expected)) {
failures.push(`${reason} 缺少: ${expected}`);
}
}
@@ -0,0 +1,427 @@
#!/usr/bin/env node
import { readFileSync } from 'node:fs';
import path from 'node:path';
const DEFAULT_CANARY_PREFIX = '/__genarrative_pingora_canary';
const REALPATH_HEALTHZ_PATH = '/__genarrative_pingora_realpath_canary/healthz';
const SHADOW_HEALTHZ_PATH = '/__genarrative_pingora/healthz';
const config = parseArgs(process.argv.slice(2));
const failures = [];
const nginxRecords = readLogRecords(config.nginxLogFile, parseNginxAccessLine);
const pingoraRecords = readLogRecords(
config.pingoraLogFile,
parseKeyValueAccessLine,
);
const parity = buildParity(nginxRecords, pingoraRecords);
if (config.json) {
console.log(`${JSON.stringify(parity, null, 2)}\n`);
}
if (failures.length > 0) {
console.error('[pingora-canary-access-log-parity] FAILED');
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
if (!config.json) {
console.log(
`[pingora-canary-access-log-parity] OK (${parity.summary.matchedCount}/${parity.summary.nginxCanaryCount} matched)`,
);
}
function parseArgs(argv) {
const result = {
nginxLogFile:
process.env.GENARRATIVE_PINGORA_CANARY_NGINX_ACCESS_LOG ||
'/var/log/nginx/genarrative.access.log',
pingoraLogFile:
process.env.GENARRATIVE_PINGORA_CANARY_PINGORA_ACCESS_LOG ||
'/var/log/genarrative/pingora-gateway.access.log',
prefix:
process.env.GENARRATIVE_PINGORA_CANARY_PREFIX || DEFAULT_CANARY_PREFIX,
mode: normalizeMode(
process.env.GENARRATIVE_PINGORA_CANARY_MODE || 'prefix',
'GENARRATIVE_PINGORA_CANARY_MODE',
),
sinceLines: parseOptionalPositiveInt(
process.env.GENARRATIVE_PINGORA_CANARY_ACCESS_LOG_SINCE_LINES,
2000,
'GENARRATIVE_PINGORA_CANARY_ACCESS_LOG_SINCE_LINES',
),
requiredPaths: [],
json: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case '-h':
case '--help':
usage();
process.exit(0);
break;
case '--nginx-log-file':
result.nginxLogFile = requireValue(argv, ++index, arg);
break;
case '--pingora-log-file':
result.pingoraLogFile = requireValue(argv, ++index, arg);
break;
case '--prefix':
result.prefix = normalizePrefix(requireValue(argv, ++index, arg), arg);
break;
case '--mode':
result.mode = normalizeMode(requireValue(argv, ++index, arg), arg);
break;
case '--realpath':
result.mode = 'realpath';
break;
case '--since-lines':
result.sinceLines = parseRequiredPositiveInt(
requireValue(argv, ++index, arg),
'--since-lines',
);
break;
case '--path':
result.requiredPaths.push(
normalizeRequestPath(requireValue(argv, ++index, arg), arg),
);
break;
case '--json':
result.json = true;
break;
default:
throw new Error(`未知参数: ${arg}`);
}
}
result.prefix = normalizePrefix(result.prefix, '--prefix');
for (const [label, file] of [
['--nginx-log-file', result.nginxLogFile],
['--pingora-log-file', result.pingoraLogFile],
]) {
validateSafeAbsoluteFilePath(file, label);
}
return result;
}
function usage() {
console.log(`Usage:
node scripts/check-pingora-canary-access-log-parity.mjs [options]
Options:
--nginx-log-file <path> Nginx access log,默认 /var/log/nginx/genarrative.access.log。
--pingora-log-file <path> Pingora access log,默认 /var/log/genarrative/pingora-gateway.access.log。
--prefix <path> canary 前缀,默认 /__genarrative_pingora_canary。
--mode <prefix|realpath> 对账模式,默认 prefixrealpath 要求 Nginx path 与 Pingora path 对齐。
--realpath Shortcut for --mode realpath。
--since-lines <count> 只读取日志尾部行数,默认 2000。
--path <path> 必须出现并完成对照的原始 canary 路径,可重复。
--json 输出 JSON。
该脚本只读比较 Nginx canary handoff access log 和 Pingora access log,不修改日志、不 reload Nginx 或 Pingora。
Nginx canary exact healthz 会转发到 Pingora shadow healthz,其余前缀路径按 rewrite 后路径对照。
真实路径 canary 使用独立 Nginx access log;除 realpath healthz 探针外,Nginx 与 Pingora path 必须一致。
`);
}
function requireValue(argv, index, flag) {
const value = argv[index];
if (!value || value.startsWith('--')) {
throw new Error(`${flag} 缺少参数值`);
}
return value;
}
function parseOptionalPositiveInt(raw, fallback, label) {
if (raw === undefined || raw === null || String(raw).trim() === '') {
return fallback;
}
return parseRequiredPositiveInt(raw, label);
}
function parseRequiredPositiveInt(raw, label) {
const rawText = String(raw ?? '');
validateNoControlCharacters(rawText, label);
const text = rawText.trim();
if (!/^[1-9]\d*$/.test(text)) {
throw new Error(`${label} 必须是正整数。`);
}
return Number.parseInt(text, 10);
}
function normalizeMode(raw, label) {
validateNoControlCharacters(raw, label);
const value = String(raw || '').trim();
if (!value || value === 'prefix') {
return 'prefix';
}
if (value === 'realpath') {
return 'realpath';
}
throw new Error(`${label} 必须是 prefix 或 realpath。`);
}
function validateSafeAbsoluteFilePath(value, flag) {
validateNoControlCharacters(value, flag);
if (!path.isAbsolute(value)) {
throw new Error(`${flag} 必须是绝对路径。`);
}
if (isFilesystemRootPath(value)) {
throw new Error(`${flag} 不能是文件系统根目录。`);
}
}
function isFilesystemRootPath(value) {
const resolved = path.resolve(String(value));
return resolved === path.parse(resolved).root;
}
function normalizePrefix(prefix, label) {
validateNoControlCharacters(prefix, label);
if (!prefix || prefix === '/') {
return DEFAULT_CANARY_PREFIX;
}
const withLeadingSlash = prefix.startsWith('/') ? prefix : `/${prefix}`;
return withLeadingSlash.endsWith('/')
? withLeadingSlash.slice(0, -1)
: withLeadingSlash;
}
function normalizeRequestPath(value, label = '--path') {
validateNoControlCharacters(value, label);
const raw = String(value || '').trim();
if (!raw) {
return '/';
}
const pathOnly = raw.includes('://') ? new URL(raw).pathname : raw.split('?')[0];
return pathOnly.startsWith('/') ? pathOnly : `/${pathOnly}`;
}
function validateNoControlCharacters(value, label) {
if (/[\0\r\n]/u.test(String(value ?? ''))) {
throw new Error(`${label} 不能包含换行或 NUL 字符。`);
}
}
function readLogRecords(filePath, parser) {
let text;
try {
text = readFileSync(filePath, 'utf8');
} catch (error) {
failures.push(`无法读取日志文件 ${filePath}: ${error.message}`);
return [];
}
return text
.split(/\r?\n/u)
.filter(Boolean)
.slice(-config.sinceLines)
.map((line, index) => parseLogRecord(parser, line, index + 1, filePath))
.filter(Boolean);
}
function parseLogRecord(parser, line, lineNumber, filePath) {
try {
return parser(line, lineNumber);
} catch (error) {
failures.push(
`日志文件 ${filePath}${lineNumber} 行解析失败: ${error.message}`,
);
return null;
}
}
function parseNginxAccessLine(line, lineNumber) {
const requestMatch = line.match(/"([A-Z]+)\s+([^"\s]+)\s+HTTP\/[^"]+"/u);
const statusMatch = line.match(/"\s+(\d{3})\s+/u);
const requestIdMatch = line.match(/(?:^|\s)request_id=([^\s]+)/u);
if (!requestMatch || !statusMatch || !requestIdMatch) {
return null;
}
const uri = requestMatch[2];
return {
source: 'nginx',
lineNumber,
requestId: requestIdMatch[1],
method: requestMatch[1],
uri,
path: normalizeRequestPath(uri, `Nginx access log 第 ${lineNumber} 行 URI`),
status: Number.parseInt(statusMatch[1], 10),
raw: line,
};
}
function parseKeyValueAccessLine(line, lineNumber) {
const fields = {};
for (const part of line.split(/\t/u)) {
const separator = part.indexOf('=');
if (separator <= 0) {
continue;
}
fields[part.slice(0, separator)] = unescapeAccessLogValue(
part.slice(separator + 1),
);
}
if (!fields.request_id || !fields.path || !fields.status) {
return null;
}
return {
source: 'pingora',
lineNumber,
requestId: fields.request_id,
method: fields.method || '',
uri: fields.uri || fields.path,
path: normalizeRequestPath(
fields.path,
`Pingora access log 第 ${lineNumber} 行 path`,
),
status: Number.parseInt(fields.status, 10),
route: fields.route || '',
proxyTarget: fields.proxy_target || '',
upstream: fields.upstream || '',
raw: line,
};
}
function unescapeAccessLogValue(value) {
return String(value)
.replaceAll('%09', '\t')
.replaceAll('%0A', '\n')
.replaceAll('%0D', '\r');
}
function buildParity(nginxRecords, pingoraRecords) {
const pingoraByRequestId = new Map();
for (const record of pingoraRecords) {
if (!pingoraByRequestId.has(record.requestId)) {
pingoraByRequestId.set(record.requestId, []);
}
pingoraByRequestId.get(record.requestId).push(record);
}
const nginxCanary = nginxRecords.filter((record) => isCanaryRecord(record));
if (nginxCanary.length === 0) {
failures.push(noCanaryRecordsMessage());
}
const matches = [];
const missing = [];
const mismatches = [];
const seenCanaryPaths = new Set();
for (const nginxRecord of nginxCanary) {
seenCanaryPaths.add(nginxRecord.path);
const expectedPath = stripCanaryPrefix(nginxRecord.path);
const candidates = pingoraByRequestId.get(nginxRecord.requestId) || [];
const match = candidates.find((record) => record.path === expectedPath);
if (!match) {
missing.push({
requestId: nginxRecord.requestId,
nginxPath: nginxRecord.path,
expectedPingoraPath: expectedPath,
});
continue;
}
const mismatch = {
requestId: nginxRecord.requestId,
nginxPath: nginxRecord.path,
pingoraPath: match.path,
problems: [],
};
if (nginxRecord.method && match.method && nginxRecord.method !== match.method) {
mismatch.problems.push(`method ${nginxRecord.method} != ${match.method}`);
}
if (nginxRecord.status !== match.status) {
mismatch.problems.push(`status ${nginxRecord.status} != ${match.status}`);
}
if (mismatch.problems.length > 0) {
mismatches.push(mismatch);
continue;
}
matches.push({
requestId: nginxRecord.requestId,
nginxPath: nginxRecord.path,
pingoraPath: match.path,
status: nginxRecord.status,
route: match.route,
proxyTarget: match.proxyTarget,
});
}
for (const requiredPath of config.requiredPaths) {
if (!seenCanaryPaths.has(requiredPath)) {
failures.push(`Nginx canary 日志缺少必需路径: ${requiredPath}`);
}
}
for (const item of missing) {
failures.push(
`缺少对应 Pingora access log: request_id=${item.requestId} nginx_path=${item.nginxPath} expected_pingora_path=${item.expectedPingoraPath}`,
);
}
for (const item of mismatches) {
failures.push(
`Nginx/Pingora access log 不一致: request_id=${item.requestId} nginx_path=${item.nginxPath} pingora_path=${item.pingoraPath} ${item.problems.join(', ')}`,
);
}
return {
ok: failures.length === 0,
mode: config.mode,
prefix: config.prefix,
summary: {
nginxCanaryCount: nginxCanary.length,
pingoraCount: pingoraRecords.length,
matchedCount: matches.length,
missingCount: missing.length,
mismatchCount: mismatches.length,
},
matches,
missing,
mismatches,
};
}
function isCanaryRecord(record) {
if (config.mode === 'realpath') {
return (
record.path === REALPATH_HEALTHZ_PATH ||
record.path === '/api/creation-entry/config' ||
record.path.startsWith('/v1/database/') ||
record.path.startsWith('/v1/identity') ||
record.path === '/assets/app.js' ||
record.path === '/generated-pingora-canary-smoke'
);
}
return (
record.path.startsWith(`${config.prefix}/`) || record.path === config.prefix
);
}
function noCanaryRecordsMessage() {
if (config.mode === 'realpath') {
return `Nginx 日志尾部 ${config.sinceLines} 行中没有真实路径 canary 请求`;
}
return `Nginx 日志尾部 ${config.sinceLines} 行中没有 canary 前缀请求: ${config.prefix}`;
}
function stripCanaryPrefix(canaryPath) {
if (config.mode === 'realpath') {
if (canaryPath === REALPATH_HEALTHZ_PATH) {
return SHADOW_HEALTHZ_PATH;
}
return canaryPath;
}
if (canaryPath === `${config.prefix}/healthz`) {
return SHADOW_HEALTHZ_PATH;
}
if (canaryPath === config.prefix) {
return '/';
}
const stripped = canaryPath.slice(config.prefix.length);
return stripped || '/';
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,72 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
const CANARY_LIVE_SCRIPT = 'scripts/check-pingora-canary-live.mjs';
const failures = [];
main();
if (failures.length > 0) {
console.error('[check:pingora-canary-live-guard] FAILED');
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
console.log('[check:pingora-canary-live-guard] OK');
function main() {
assertRejectsControlCharacter('--base-url', [
'--base-url',
'http://127.0.0.1\n--fake-flag',
]);
assertRejectsControlCharacter('--prefix', [
'--base-url',
'http://127.0.0.1',
'--prefix',
'/__genarrative_pingora_canary\nX-Injected: yes',
]);
assertRejectsControlCharacter('--host', [
'--base-url',
'http://127.0.0.1',
'--host',
'example.com\nX-Injected: yes',
]);
assertRejectsControlCharacter('--path', [
'--base-url',
'http://127.0.0.1',
'--path',
'/api/creation-entry/config\nX-Injected: yes',
]);
assertRejectsControlCharacter('--timeout-ms', [
'--base-url',
'http://127.0.0.1',
'--timeout-ms',
'5000\n1',
]);
}
function assertRejectsControlCharacter(label, args) {
const result = spawnSync('node', [CANARY_LIVE_SCRIPT, ...args], {
cwd: process.cwd(),
encoding: 'utf8',
env: process.env,
});
if ((result.status ?? 0) === 0) {
failures.push(`${label} 带控制字符时必须在发起 canary 请求前失败。`);
return;
}
assertIncludes(
`${result.stdout}\n${result.stderr}`,
`${label} 不能包含换行或 NUL 字符`,
`${label} 带控制字符时必须给出明确错误。`,
);
}
function assertIncludes(value, expected, reason) {
if (!String(value).includes(expected)) {
failures.push(`${reason} 缺少: ${expected}`);
}
}
+360
View File
@@ -0,0 +1,360 @@
#!/usr/bin/env node
import http from 'node:http';
import https from 'node:https';
const DEFAULT_CANARY_PREFIX = '/__genarrative_pingora_canary';
const REALPATH_HEALTHZ_PATH = '/__genarrative_pingora_realpath_canary/healthz';
const HANDOFF_HEADER_DISPLAY = 'X-Genarrative-Nginx-Handoff';
const HANDOFF_HEADER = 'x-genarrative-nginx-handoff';
const HANDOFF_VALUES = {
prefix: 'pingora-canary',
realpath: 'pingora-realpath-canary',
};
const failures = [];
let config;
try {
config = parseArgs(process.argv.slice(2));
await main();
} catch (error) {
failures.push(error instanceof Error ? error.message : String(error));
}
if (failures.length > 0) {
console.error('[pingora-canary-live] FAILED');
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
console.log('[pingora-canary-live] OK');
function usage() {
console.log(`Usage:
node scripts/check-pingora-canary-live.mjs --base-url <url> [options]
Options:
--base-url <url> Nginx/public base URL with canary snippet enabled.
--prefix <path> Canary prefix, default /__genarrative_pingora_canary.
--mode <prefix|realpath>
Canary mode, default prefix.
--realpath Shortcut for --mode realpath.
--host <host> Optional Host header for local --resolve style checks.
--path <path> Extra canary path to probe; repeatable.
--timeout-ms <ms> Request timeout, default 5000.
--json Print JSON result.
Environment aliases:
GENARRATIVE_PINGORA_CANARY_BASE_URL
GENARRATIVE_PINGORA_CANARY_PREFIX
GENARRATIVE_PINGORA_CANARY_MODE
GENARRATIVE_PINGORA_CANARY_HOST
GENARRATIVE_PINGORA_CANARY_TIMEOUT_MS
`);
}
function parseArgs(argv) {
const result = {
baseUrl: process.env.GENARRATIVE_PINGORA_CANARY_BASE_URL || '',
prefix:
process.env.GENARRATIVE_PINGORA_CANARY_PREFIX || DEFAULT_CANARY_PREFIX,
mode: normalizeMode(
process.env.GENARRATIVE_PINGORA_CANARY_MODE || 'prefix',
'GENARRATIVE_PINGORA_CANARY_MODE',
),
host: process.env.GENARRATIVE_PINGORA_CANARY_HOST || '',
timeoutMs: parseOptionalPositiveInt(
process.env.GENARRATIVE_PINGORA_CANARY_TIMEOUT_MS,
5000,
'GENARRATIVE_PINGORA_CANARY_TIMEOUT_MS',
),
json: false,
extraPaths: [],
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case '-h':
case '--help':
usage();
process.exit(0);
break;
case '--base-url':
result.baseUrl = requireValue(argv, ++index, arg);
break;
case '--prefix':
result.prefix = normalizePrefix(requireValue(argv, ++index, arg));
break;
case '--mode':
result.mode = normalizeMode(requireValue(argv, ++index, arg), arg);
break;
case '--realpath':
result.mode = 'realpath';
break;
case '--host':
result.host = requireValue(argv, ++index, arg);
break;
case '--path':
result.extraPaths.push(requireValue(argv, ++index, arg));
break;
case '--timeout-ms':
result.timeoutMs = parseRequiredPositiveInt(
requireValue(argv, ++index, arg),
'--timeout-ms',
);
break;
case '--json':
result.json = true;
break;
default:
throw new Error(`未知参数: ${arg}`);
}
}
result.prefix = normalizePrefix(result.prefix);
if (!result.baseUrl) {
throw new Error('缺少 --base-url 或 GENARRATIVE_PINGORA_CANARY_BASE_URL');
}
validateNoControlCharacters(result.baseUrl, '--base-url');
new URL(result.baseUrl);
validateNoControlCharacters(result.prefix, '--prefix');
if (result.host) {
validateHostOption(result.host, '--host');
}
for (const extraPath of result.extraPaths) {
validateNoControlCharacters(extraPath, '--path');
}
return result;
}
function requireValue(argv, index, flag) {
const value = argv[index];
if (!value || value.startsWith('--')) {
throw new Error(`${flag} 缺少参数值`);
}
return value;
}
function validateHostOption(value, flag) {
const raw = String(value);
validateNoControlCharacters(raw, flag);
if (raw !== raw.trim() || raw.includes('://') || /[\s/?#@]/.test(raw)) {
throw new Error(
`${flag} 只能是 host 或 host:port,不能包含 scheme、路径、查询、片段或空白字符`,
);
}
try {
const parsed = new URL(`https://${raw}`);
if (
!parsed.hostname ||
parsed.pathname !== '/' ||
parsed.search ||
parsed.hash ||
parsed.username ||
parsed.password
) {
throw new Error('invalid host');
}
} catch {
throw new Error(`${flag} 不是合法的 host 或 host:port`);
}
}
function validateNoControlCharacters(value, label) {
if (/[\0\r\n]/u.test(String(value))) {
throw new Error(`${label} 不能包含换行或 NUL 字符。`);
}
}
function parseOptionalPositiveInt(raw, fallback, label) {
if (raw === undefined || raw === null || String(raw).trim() === '') {
return fallback;
}
return parseRequiredPositiveInt(raw, label);
}
function parseRequiredPositiveInt(raw, label) {
validateNoControlCharacters(raw, label);
const text = String(raw ?? '').trim();
if (!/^[1-9]\d*$/.test(text)) {
throw new Error(`${label} 必须是正整数。`);
}
return Number.parseInt(text, 10);
}
function normalizeMode(raw, label) {
validateNoControlCharacters(raw, label);
const value = String(raw || '').trim();
if (!value || value === 'prefix') {
return 'prefix';
}
if (value === 'realpath') {
return 'realpath';
}
throw new Error(`${label} 必须是 prefix 或 realpath。`);
}
function normalizePrefix(prefix) {
if (!prefix || prefix === '/') {
return DEFAULT_CANARY_PREFIX;
}
const withLeadingSlash = prefix.startsWith('/') ? prefix : `/${prefix}`;
return withLeadingSlash.endsWith('/')
? withLeadingSlash.slice(0, -1)
: withLeadingSlash;
}
function joinUrl(baseUrl, path) {
const base = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
const suffix = path.startsWith('/') ? path : `/${path}`;
return `${base}${suffix}`;
}
function canaryUrl(path) {
const suffix = path.startsWith('/') ? path : `/${path}`;
if (config.mode === 'realpath') {
return joinUrl(config.baseUrl, suffix);
}
return joinUrl(config.baseUrl, `${config.prefix}${suffix}`);
}
async function main() {
const handoffValue = HANDOFF_VALUES[config.mode];
const checks = [
{
name: 'healthz',
path:
config.mode === 'realpath' ? REALPATH_HEALTHZ_PATH : '/healthz',
expectedStatus: 200,
assertBody: (body) => body.includes('"gateway":"pingora-shadow"'),
bodyReason: 'body 应包含 gateway=pingora-shadow',
},
{
name: 'api-config',
path: '/api/creation-entry/config',
expectedStatuses: [200, 401, 403, 503],
},
{
name: 'spacetime-identity',
path: '/v1/identity',
expectedStatuses: [200, 401, 403, 404, 405, 503],
},
{
name: 'web-assets',
path: '/assets/app.js',
expectedStatuses: [200, 404, 503],
},
{
name: 'generated-forbidden',
path: '/generated-pingora-canary-smoke',
expectedStatus: 404,
},
];
for (const path of config.extraPaths) {
checks.push({
name: `extra:${path}`,
path,
expectedStatuses: [200, 204, 301, 302, 304, 401, 403, 404, 503],
});
}
const results = [];
for (const check of checks) {
const result = await runCheck(check, handoffValue);
results.push(result);
}
if (config.json) {
console.log(
JSON.stringify({ ok: failures.length === 0, results }, null, 2),
);
}
}
async function runCheck(check, handoffValue) {
const url = canaryUrl(check.path);
const response = await requestUrl(url);
const expectedStatuses =
check.expectedStatuses ?? [check.expectedStatus].filter(Boolean);
const handoff = response.headers[HANDOFF_HEADER] || '';
if (!expectedStatuses.includes(response.statusCode)) {
failures.push(
`${check.name}: ${url} 返回 ${response.statusCode},预期 ${expectedStatuses.join('/')}`,
);
}
if (handoff !== handoffValue) {
failures.push(
`${check.name}: 缺少 ${HANDOFF_HEADER_DISPLAY}: ${handoffValue},实际 ${handoff || '-'}`,
);
}
if (check.assertBody && !check.assertBody(response.body)) {
failures.push(`${check.name}: ${check.bodyReason}`);
}
console.log(
`[pingora-canary-live] ${check.name} ${response.statusCode} ${response.elapsedMs}ms`,
);
return {
name: check.name,
mode: config.mode,
url,
statusCode: response.statusCode,
elapsedMs: response.elapsedMs,
handoff,
};
}
function requestUrl(url) {
return new Promise((resolve, reject) => {
const startedAt = Date.now();
const parsed = new URL(url);
const client = parsed.protocol === 'https:' ? https : http;
const headers = {
'User-Agent': 'genarrative-pingora-canary-live/1.0',
Accept: 'application/json,text/plain,*/*',
Connection: 'close',
};
if (config.host) {
headers.Host = config.host;
}
const request = client.request(
parsed,
{
method: 'GET',
timeout: config.timeoutMs,
headers,
},
(response) => {
let body = '';
response.setEncoding('utf8');
response.on('data', (chunk) => {
if (body.length < 4096) {
body += chunk;
}
});
response.on('end', () => {
resolve({
elapsedMs: Date.now() - startedAt,
statusCode: response.statusCode || 0,
headers: response.headers,
body,
});
});
},
);
request.on('timeout', () => {
request.destroy(new Error(`请求超时: ${url}`));
});
request.on('error', reject);
request.end();
});
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
const DIRECT_LIVE_SCRIPT = 'scripts/check-pingora-direct-live.mjs';
const failures = [];
const tmpRoot = mkdtempSync(
path.join(tmpdir(), 'genarrative-pingora-direct-live-guard-'),
);
try {
main();
} finally {
rmSync(tmpRoot, { recursive: true, force: true });
}
if (failures.length > 0) {
console.error('[check:pingora-direct-live-guard] FAILED');
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
console.log('[check:pingora-direct-live-guard] OK');
function main() {
assertRejectsControlCharacter('--https-base-url', [
'--https-base-url',
'https://127.0.0.1\n--fake-flag',
]);
assertRejectsControlCharacter('--http-base-url', [
'--https-base-url',
'https://127.0.0.1',
'--http-base-url',
'http://127.0.0.1\r--fake-flag',
]);
assertRejectsControlCharacter('--host', [
'--https-base-url',
'https://127.0.0.1',
'--host',
'example.com\nX-Injected: yes',
]);
assertRejectsControlCharacter('--redirect-host', [
'--https-base-url',
'https://127.0.0.1',
'--redirect-host',
'example.com\nhidden',
]);
assertRejectsControlCharacter('--redirect-base-url', [
'--https-base-url',
'https://127.0.0.1',
'--redirect-base-url',
'https://example.com\nhidden',
]);
assertRejectsInvalidRedirectBaseUrl();
assertRejectsControlCharacter('--probe-token', [
'--https-base-url',
'https://127.0.0.1',
'--probe-token',
'probe\nX-Injected: yes',
'--skip-wss',
]);
assertRejectsControlCharacter('--path', [
'--https-base-url',
'https://127.0.0.1',
'--path',
'/api/health\nX-Injected: yes',
'--skip-wss',
]);
assertRejectsControlCharacter('--pingora-access-log', [
'--https-base-url',
'https://127.0.0.1',
'--pingora-access-log',
`${path.join(tmpRoot, 'pingora.access.log')}\n--fake-flag`,
'--skip-wss',
]);
assertRejectsControlCharacter('--spacetime-database', [
'--https-base-url',
'https://127.0.0.1',
'--spacetime-database',
'genarrative\nprod',
'--skip-wss',
]);
assertRejectsControlCharacter('--timeout-ms', [
'--https-base-url',
'https://127.0.0.1',
'--timeout-ms',
'5000\n1',
'--skip-wss',
]);
}
function assertRejectsInvalidRedirectBaseUrl() {
const result = spawnSync(
'node',
[
DIRECT_LIVE_SCRIPT,
'--https-base-url',
'https://127.0.0.1',
'--redirect-base-url',
'http://example.com',
],
{
cwd: process.cwd(),
encoding: 'utf8',
env: process.env,
},
);
if ((result.status ?? 0) === 0) {
failures.push('--redirect-base-url 必须拒绝非 HTTPS base URL。');
return;
}
assertIncludes(
`${result.stdout}\n${result.stderr}`,
'--redirect-base-url 必须使用 https://',
'--redirect-base-url 非 HTTPS 时必须给出明确错误。',
);
}
function assertRejectsControlCharacter(label, args) {
const result = spawnSync('node', [DIRECT_LIVE_SCRIPT, ...args], {
cwd: process.cwd(),
encoding: 'utf8',
env: process.env,
});
if ((result.status ?? 0) === 0) {
failures.push(`${label} 带控制字符时必须在发起 live 请求前失败。`);
return;
}
assertIncludes(
`${result.stdout}\n${result.stderr}`,
`${label} 不能包含换行或 NUL 字符`,
`${label} 带控制字符时必须给出明确错误。`,
);
}
function assertIncludes(value, expected, reason) {
if (!String(value).includes(expected)) {
failures.push(`${reason} 缺少: ${expected}`);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,134 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import {
chmodSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
const PREFLIGHT_SCRIPT = 'scripts/check-pingora-direct-preflight.mjs';
const failures = [];
const tmpRoot = mkdtempSync(
path.join(tmpdir(), 'genarrative-pingora-direct-preflight-guard-'),
);
try {
main();
} finally {
rmSync(tmpRoot, { recursive: true, force: true });
}
if (failures.length > 0) {
console.error('[check:pingora-direct-preflight-guard] FAILED');
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
console.log('[check:pingora-direct-preflight-guard] OK');
function main() {
assertRejectsEnvFileWithControlCharacters();
assertRejectsSystemdServiceWithControlCharactersBeforeSystemctl();
}
function assertRejectsEnvFileWithControlCharacters() {
const fixture = prepareFixture('env-file-control-character');
const result = runPreflight(fixture, [
'--env-file',
`${path.join(fixture.root, 'pingora-gateway.env')}\n--fake-flag`,
'--require-live-env',
]);
assertNonZero(result, 'direct preflight 必须拒绝带换行的 --env-file。');
assertIncludes(
`${result.stdout}\n${result.stderr}`,
'--env-file 不能包含换行或 NUL 字符',
'带换行的 --env-file 必须给出明确错误。',
);
}
function assertRejectsSystemdServiceWithControlCharactersBeforeSystemctl() {
const fixture = prepareFixture('systemd-service-control-character');
const result = runPreflight(fixture, [
'--systemd-cat',
'--systemd-service',
'genarrative-pingora-gateway.service\n--fake-flag',
]);
assertNonZero(
result,
'direct preflight 必须拒绝带换行的 systemd service 参数。',
);
assertIncludes(
`${result.stdout}\n${result.stderr}`,
'子命令参数 不能包含换行或 NUL 字符',
'带换行的 systemd service 参数必须在执行 systemctl 前失败。',
);
const commandsLog = readFileSync(fixture.commandsLog, 'utf8');
if (commandsLog.includes('systemctl')) {
failures.push('systemd service 含控制字符时必须在执行 systemctl 前失败。');
}
}
function prepareFixture(name) {
const root = path.join(tmpRoot, name);
const fakeBin = path.join(root, 'bin');
const commandsLog = path.join(root, 'commands.log');
mkdirSync(fakeBin, { recursive: true });
writeFileSync(commandsLog, '', 'utf8');
writeFileSync(
path.join(fakeBin, 'systemctl'),
[
'#!/usr/bin/env bash',
`printf 'systemctl %s\\n' "$*" >> ${shellQuote(commandsLog)}`,
'cat <<SYSTEMD',
'# /etc/systemd/system/genarrative-pingora-gateway.service',
'[Service]',
'AmbientCapabilities=CAP_NET_BIND_SERVICE',
'CapabilityBoundingSet=CAP_NET_BIND_SERVICE',
'SYSTEMD',
'',
].join('\n'),
'utf8',
);
chmodSync(path.join(fakeBin, 'systemctl'), 0o755);
return { root, fakeBin, commandsLog };
}
function runPreflight(fixture, args) {
return spawnSync('node', ['--', PREFLIGHT_SCRIPT, ...args], {
cwd: process.cwd(),
encoding: 'utf8',
env: {
...process.env,
PATH: `${fixture.fakeBin}:${process.env.PATH || ''}`,
},
});
}
function assertNonZero(result, reason) {
if ((result.status ?? 0) === 0) {
failures.push(
`${reason}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
);
}
}
function assertIncludes(value, expected, reason) {
if (!String(value).includes(expected)) {
failures.push(`${reason} 缺少: ${expected}`);
}
}
function shellQuote(value) {
return `'${String(value).replace(/'/g, "'\\''")}'`;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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}`);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,173 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
const BUILD_SCRIPT = 'scripts/build-production-release.sh';
const failures = [];
const tmpRoot = mkdtempSync(
path.join(tmpdir(), 'genarrative-pingora-production-release-build-'),
);
try {
main();
} finally {
rmSync(tmpRoot, { recursive: true, force: true });
}
if (failures.length > 0) {
console.error('[check:pingora-production-release-build] FAILED');
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
console.log('[check:pingora-production-release-build] OK');
function main() {
const fixture = prepareFixture();
const result = runBuild(fixture);
assertStatus(result, 0, 'Pingora production release 真实构建烟测应成功。');
if (result.status !== 0) {
return;
}
const releaseDir = path.join(process.cwd(), 'build', fixture.version);
try {
assertFileExists(
path.join(releaseDir, 'api-server'),
'发布包必须包含 api-server 占位 release binary。',
);
assertFileExists(
path.join(releaseDir, 'api-server.sha256'),
'发布包必须包含 api-server checksum。',
);
assertFileExists(
path.join(releaseDir, 'pingora-gateway'),
'真实 include Pingora 时发布包必须包含 pingora-gateway。',
);
assertExecutable(
path.join(releaseDir, 'pingora-gateway'),
'pingora-gateway 必须保留可执行权限。',
);
assertFileExists(
path.join(releaseDir, 'pingora-gateway.sha256'),
'真实 include Pingora 时发布包必须包含 pingora-gateway checksum。',
);
assertFileExists(
path.join(releaseDir, 'scripts/check-pingora-direct-live.mjs'),
'真实 include Pingora 的发布包仍必须携带 direct live smoke 脚本。',
);
assertFileExists(
path.join(releaseDir, 'scripts/ops/pingora-current-release-audit.mjs'),
'真实 include Pingora 的发布包仍必须携带 current release 自审脚本。',
);
assertFileExists(
path.join(releaseDir, 'deploy/pingora/nginx-route-parity.matrix.json'),
'真实 include Pingora 的发布包仍必须携带 Nginx/Pingora 路由矩阵。',
);
const manifest = readJson(path.join(releaseDir, 'release-manifest.json'));
if (manifest.component_type !== 'api-server') {
failures.push(
`release manifest component_type 应为 api-server,实际 ${manifest.component_type}`,
);
}
if (!manifest.artifacts?.some((item) => item.path === 'api-server')) {
failures.push('release manifest 必须登记 api-server artifact。');
}
if (!manifest.artifacts?.some((item) => item.path === 'pingora-gateway')) {
failures.push(
'真实 include Pingora 时 release manifest 必须登记 pingora-gateway artifact。',
);
}
} finally {
rmSync(releaseDir, { recursive: true, force: true });
}
}
function prepareFixture() {
const cargoTargetDir = path.join(tmpRoot, 'cargo-target');
const binaryDir = path.join(
cargoTargetDir,
'x86_64-unknown-linux-gnu/release',
);
const version = `check-pingora-production-release-build-${process.pid}-${Date.now()}`;
mkdirSync(binaryDir, { recursive: true });
const apiBinary = path.join(binaryDir, 'api-server');
writeFileSync(apiBinary, '#!/usr/bin/env bash\nexit 0\n', 'utf8');
spawnSync('chmod', ['0755', apiBinary], { encoding: 'utf8' });
return { cargoTargetDir, version };
}
function runBuild(fixture) {
return spawnSync(
'bash',
[
BUILD_SCRIPT,
'--component',
'api-server',
'--name',
fixture.version,
'--skip-api-build',
'--include-pingora-gateway',
],
{
cwd: process.cwd(),
encoding: 'utf8',
env: {
...process.env,
CARGO_TARGET_DIR: fixture.cargoTargetDir,
PATH: `${path.join(process.env.HOME || '', '.local', 'bin')}:${process.env.PATH || ''}`,
SOURCE_BRANCH: 'test-branch',
SOURCE_COMMIT: 'test-commit',
},
},
);
}
function readJson(filePath) {
try {
return JSON.parse(readFileSync(filePath, 'utf8'));
} catch (error) {
failures.push(`${filePath} 不是合法 JSON: ${error.message}`);
return {};
}
}
function assertFileExists(filePath, reason) {
if (!existsSync(filePath)) {
failures.push(`${reason} 缺少: ${filePath}`);
}
}
function assertExecutable(filePath, reason) {
if (!existsSync(filePath)) {
return;
}
if ((statSync(filePath).mode & 0o111) === 0) {
failures.push(`${reason} 文件不可执行: ${filePath}`);
}
}
function assertStatus(result, expected, reason) {
const actual = result.status ?? 0;
if (actual !== expected) {
failures.push(
`${reason} 预期退出码 ${expected},实际 ${actual}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
);
}
}
@@ -0,0 +1,432 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import {
chmodSync,
existsSync,
mkdtempSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
const ENABLE_SCRIPT = 'scripts/deploy/pingora-realpath-canary-enable.sh';
const DISABLE_SCRIPT = 'scripts/deploy/pingora-realpath-canary-disable.sh';
const failures = [];
const tmpRoot = mkdtempSync(path.join(tmpdir(), 'genarrative-realpath-canary-'));
try {
main();
} finally {
rmSync(tmpRoot, { recursive: true, force: true });
}
if (failures.length > 0) {
console.error('[check:pingora-realpath-canary-toggle] FAILED');
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
console.log('[check:pingora-realpath-canary-toggle] OK');
function main() {
assertScriptShape();
assertDryRunDoesNotWrite();
assertApplyWritesRenderedConfigAndRunsLive();
assertEnableRollsBackWhenLiveFails();
assertDisableRemovesConfig();
assertDisableRollsBackWhenReloadFails();
assertRejectsUnsafeInputs();
}
function assertScriptShape() {
for (const script of [ENABLE_SCRIPT, DISABLE_SCRIPT]) {
const content = readFileSync(script, 'utf8');
assertIncludes(content, '--apply', `${script} 必须默认 dry-run 并要求显式 --apply。`);
assertIncludes(content, 'nginx -t', `${script} 帮助文案必须说明会先做 nginx -t。`);
assertIncludes(
content,
'zz-genarrative-pingora-realpath-canary.conf',
`${script} 必须默认使用晚于主站配置加载的 conf.d 文件名。`,
);
assertIncludes(
content,
'不能包含换行或 NUL 字符',
`${script} 必须拒绝控制字符参数。`,
);
assertIncludes(content, '不能是文件系统根目录', `${script} 必须拒绝根路径。`);
}
const enableContent = readFileSync(ENABLE_SCRIPT, 'utf8');
assertIncludes(
enableContent,
'check-pingora-canary-live.mjs',
'enable 脚本必须默认运行 realpath live smoke。',
);
assertIncludes(
enableContent,
'恢复写入前配置',
'enable 脚本失败时必须恢复写入前配置。',
);
const disableContent = readFileSync(DISABLE_SCRIPT, 'utf8');
assertIncludes(
disableContent,
'恢复删除前配置',
'disable 脚本失败时必须恢复删除前配置。',
);
}
function assertDryRunDoesNotWrite() {
const fixture = createFixture('dry-run');
const targetPath = path.join(fixture.nginxDir, 'zz-genarrative-pingora-realpath-canary.conf');
const result = runScript(ENABLE_SCRIPT, [
'--probe-token',
'dry-run-token',
'--host',
'dev.genarrative.world',
'--template-path',
fixture.templatePath,
'--target-path',
targetPath,
'--nginx-binary',
fixture.nginxBinary,
'--systemctl-binary',
fixture.systemctlBinary,
'--live-script',
fixture.liveScript,
]);
assertStatus(result, 0, 'enable dry-run 应成功。');
if (existsSync(targetPath)) {
failures.push('enable dry-run 不应写入目标 Nginx 配置。');
}
assertIncludes(result.stdout, 'dry-run', 'enable dry-run 应输出 dry-run 提示。');
}
function assertApplyWritesRenderedConfigAndRunsLive() {
const fixture = createFixture('apply-ok');
const targetPath = path.join(fixture.nginxDir, 'zz-genarrative-pingora-realpath-canary.conf');
const result = runScript(ENABLE_SCRIPT, [
'--apply',
'--probe-token',
'apply-real-token',
'--host',
'dev.genarrative.world',
'--base-url',
'http://127.0.0.1:18083',
'--template-path',
fixture.templatePath,
'--target-path',
targetPath,
'--nginx-binary',
fixture.nginxBinary,
'--systemctl-binary',
fixture.systemctlBinary,
'--live-script',
fixture.liveScript,
'--no-status',
]);
assertStatus(result, 0, 'enable apply 应成功。');
const rendered = readFileSync(targetPath, 'utf8');
assertIncludes(rendered, '"apply-real-token"', 'enable apply 应替换 probe token。');
if (rendered.includes('__GENARRATIVE_PINGORA_PROBE_TOKEN__')) {
failures.push('enable apply 后目标配置不应保留 probe token 占位符。');
}
const calls = readFileSync(fixture.callsPath, 'utf8');
assertIncludes(calls, 'nginx -t', 'enable apply 必须执行 nginx -t。');
assertIncludes(calls, 'systemctl reload nginx.service', 'enable apply 必须 reload nginx。');
assertIncludes(
calls,
'live --realpath --base-url http://127.0.0.1:18083 --host dev.genarrative.world',
'enable apply 必须执行 realpath live smoke。',
);
}
function assertEnableRollsBackWhenLiveFails() {
const fixture = createFixture('enable-live-fails', { liveExitCode: 7 });
const targetPath = path.join(fixture.nginxDir, 'zz-genarrative-pingora-realpath-canary.conf');
writeFileSync(targetPath, 'previous-config\n', 'utf8');
const result = runScript(
ENABLE_SCRIPT,
[
'--apply',
'--probe-token',
'rollback-token',
'--host',
'dev.genarrative.world',
'--template-path',
fixture.templatePath,
'--target-path',
targetPath,
'--nginx-binary',
fixture.nginxBinary,
'--systemctl-binary',
fixture.systemctlBinary,
'--live-script',
fixture.liveScript,
'--no-status',
],
fixture.env,
);
if (result.status === 0) {
failures.push('realpath live smoke 失败时 enable apply 必须失败。');
}
const restored = readFileSync(targetPath, 'utf8');
if (restored !== 'previous-config\n') {
failures.push('realpath live smoke 失败时 enable apply 必须恢复旧配置。');
}
assertIncludes(
result.stderr,
'恢复写入前配置',
'enable live 失败时必须说明已恢复写入前配置。',
);
}
function assertDisableRemovesConfig() {
const fixture = createFixture('disable-ok');
const targetPath = path.join(fixture.nginxDir, 'zz-genarrative-pingora-realpath-canary.conf');
writeFileSync(targetPath, 'enabled-config\n', 'utf8');
const result = runScript(
DISABLE_SCRIPT,
[
'--apply',
'--target-path',
targetPath,
'--nginx-binary',
fixture.nginxBinary,
'--systemctl-binary',
fixture.systemctlBinary,
'--no-status',
],
fixture.env,
);
assertStatus(result, 0, 'disable apply 应成功。');
if (existsSync(targetPath)) {
failures.push('disable apply 成功后必须删除 realpath canary 配置。');
}
const calls = readFileSync(fixture.callsPath, 'utf8');
assertIncludes(calls, 'nginx -t', 'disable apply 必须执行 nginx -t。');
assertIncludes(calls, 'systemctl reload nginx.service', 'disable apply 必须 reload nginx。');
}
function assertDisableRollsBackWhenReloadFails() {
const fixture = createFixture('disable-reload-fails', { reloadExitCode: 9 });
const targetPath = path.join(fixture.nginxDir, 'zz-genarrative-pingora-realpath-canary.conf');
writeFileSync(targetPath, 'enabled-config\n', 'utf8');
const result = runScript(DISABLE_SCRIPT, [
'--apply',
'--target-path',
targetPath,
'--nginx-binary',
fixture.nginxBinary,
'--systemctl-binary',
fixture.systemctlBinary,
'--no-status',
]);
if (result.status === 0) {
failures.push('reload 失败时 disable apply 必须失败。');
}
const restored = readFileSync(targetPath, 'utf8');
if (restored !== 'enabled-config\n') {
failures.push('reload 失败时 disable apply 必须恢复删除前配置。');
}
assertIncludes(
result.stderr,
'恢复删除前配置',
'disable reload 失败时必须说明已恢复删除前配置。',
);
}
function assertRejectsUnsafeInputs() {
const fixture = createFixture('unsafe-inputs');
const targetPath = path.join(fixture.nginxDir, 'zz-genarrative-pingora-realpath-canary.conf');
const cases = [
{
name: 'missing token',
script: ENABLE_SCRIPT,
args: [
'--apply',
'--host',
'dev.genarrative.world',
'--template-path',
fixture.templatePath,
'--target-path',
targetPath,
'--nginx-binary',
fixture.nginxBinary,
'--systemctl-binary',
fixture.systemctlBinary,
'--live-script',
fixture.liveScript,
],
expected: '--apply 必须提供 --probe-token',
},
{
name: 'url host',
script: ENABLE_SCRIPT,
args: [
'--probe-token',
'unsafe-token',
'--host',
'https://dev.genarrative.world',
'--template-path',
fixture.templatePath,
'--target-path',
targetPath,
'--nginx-binary',
fixture.nginxBinary,
'--systemctl-binary',
fixture.systemctlBinary,
'--live-script',
fixture.liveScript,
],
expected: '不能是 URL',
},
{
name: 'relative target',
script: DISABLE_SCRIPT,
args: ['--target-path', 'relative.conf'],
expected: '--target-path 必须是绝对路径',
},
{
name: 'root target',
script: DISABLE_SCRIPT,
args: ['--target-path', '/'],
expected: '--target-path 不能是文件系统根目录',
},
];
for (const item of cases) {
const result = runScript(item.script, item.args);
if (result.status === 0) {
failures.push(`${item.name}: 不安全参数必须失败。`);
}
assertIncludes(
`${result.stderr}\n${result.stdout}`,
item.expected,
`${item.name}: 应输出明确错误。`,
);
}
}
function createFixture(name, options = {}) {
const root = path.join(tmpRoot, name);
const nginxDir = path.join(root, 'nginx-conf');
mkdirSync(nginxDir, { recursive: true });
const callsPath = path.join(root, 'calls.log');
const templatePath = path.join(root, 'genarrative-pingora-realpath-canary.conf');
const nginxBinary = path.join(root, 'nginx');
const systemctlBinary = path.join(root, 'systemctl');
const liveScript = path.join(root, 'live.mjs');
writeFileSync(
templatePath,
[
'server {',
' listen 127.0.0.1:18083;',
' access_log /var/log/nginx/genarrative-pingora-realpath-canary.access.log genarrative_upstream;',
' location = /__genarrative_pingora_realpath_canary/healthz {',
' proxy_set_header X-Genarrative-Pingora-Probe "__GENARRATIVE_PINGORA_PROBE_TOKEN__";',
' proxy_pass http://127.0.0.1:18081/__genarrative_pingora/healthz;',
' }',
'}',
'',
].join('\n'),
'utf8',
);
writeFileSync(
nginxBinary,
[
'#!/usr/bin/env bash',
`echo "nginx $*" >> ${shellQuote(callsPath)}`,
'if [[ "${GENARRATIVE_FAKE_NGINX_T_EXIT:-0}" != "0" ]]; then exit "${GENARRATIVE_FAKE_NGINX_T_EXIT}"; fi',
'exit 0',
'',
].join('\n'),
'utf8',
);
writeFileSync(
systemctlBinary,
[
'#!/usr/bin/env bash',
`echo "systemctl $*" >> ${shellQuote(callsPath)}`,
`reload_exit="\${GENARRATIVE_FAKE_RELOAD_EXIT:-${Number(options.reloadExitCode || 0)}}"`,
'if [[ "$1" == "reload" && "${reload_exit}" != "0" ]]; then exit "${reload_exit}"; fi',
'exit 0',
'',
].join('\n'),
'utf8',
);
writeFileSync(
liveScript,
[
'#!/usr/bin/env node',
"import { appendFileSync } from 'node:fs';",
`appendFileSync(${JSON.stringify(callsPath)}, 'live ' + process.argv.slice(2).join(' ') + '\\n');`,
`process.exit(Number(process.env.GENARRATIVE_FAKE_LIVE_EXIT || ${Number(options.liveExitCode || 0)}));`,
'',
].join('\n'),
'utf8',
);
chmodExecutable(nginxBinary);
chmodExecutable(systemctlBinary);
chmodExecutable(liveScript);
return {
root,
nginxDir,
callsPath,
templatePath,
nginxBinary,
systemctlBinary,
liveScript,
env: {
GENARRATIVE_FAKE_RELOAD_EXIT: String(options.reloadExitCode || 0),
GENARRATIVE_FAKE_LIVE_EXIT: String(options.liveExitCode || 0),
},
};
}
function runScript(script, args, env = {}) {
return spawnSync('bash', [script, ...args], {
cwd: process.cwd(),
env: { ...process.env, ...env },
encoding: 'utf8',
});
}
function chmodExecutable(file) {
chmodSync(file, 0o755);
}
function shellQuote(value) {
return `'${String(value).replaceAll("'", "'\\''")}'`;
}
function assertStatus(result, expected, message) {
if (result.status !== expected) {
failures.push(
`${message} 实际退出码 ${result.status}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
);
}
}
function assertIncludes(content, needle, message) {
if (!content.includes(needle)) {
failures.push(`${message} 缺少 ${needle}`);
}
}

Some files were not shown because too many files have changed in this diff Show More