a7711d2dc3
新增 pingora-gateway 独立二进制 crate,覆盖路由、静态资源、压缩、接流保护、TLS 直连和访问日志能力。 新增 Nginx canary、realpath canary、direct preflight、direct live、direct enable 和 rollback 脚本。 新增 Pingora 切流证据包、命令证据、manifest 验真、根目录总审计和 release readiness 聚合门禁。 完善 API release、Jenkins、systemd、health patrol、生产部署和发布包自包含校验。 更新 Pingora 试点文档、Nginx README 与 Hermes 共享记忆。
314 lines
9.0 KiB
JavaScript
314 lines
9.0 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
const GATEWAY_MODES = new Set(['nginx', 'pingora-direct']);
|
|
|
|
const config = parseArgs(process.argv.slice(2));
|
|
const failures = [];
|
|
const envValues = readEnvFile(config.envFile);
|
|
|
|
checkGatewayMode();
|
|
checkPublicBaseUrl();
|
|
checkPublicHost();
|
|
|
|
if (failures.length > 0) {
|
|
console.error('[check:production-health-patrol-env] FAILED');
|
|
for (const failure of failures) {
|
|
console.error(`- ${failure}`);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log('[check:production-health-patrol-env] OK');
|
|
|
|
function parseArgs(argv) {
|
|
const result = {
|
|
envFile:
|
|
process.env.GENARRATIVE_HEALTH_PATROL_ENV_FILE ||
|
|
'/etc/genarrative/health-patrol.env',
|
|
expectedGatewayMode:
|
|
process.env.GENARRATIVE_HEALTH_PATROL_EXPECTED_GATEWAY_MODE || '',
|
|
expectedPublicBaseUrl:
|
|
process.env.GENARRATIVE_HEALTH_PATROL_EXPECTED_PUBLIC_BASE_URL || '',
|
|
expectedPublicHost:
|
|
process.env.GENARRATIVE_HEALTH_PATROL_EXPECTED_PUBLIC_HOST || null,
|
|
requireEmptyPublicHost:
|
|
readBoolEnv('GENARRATIVE_HEALTH_PATROL_REQUIRE_EMPTY_PUBLIC_HOST'),
|
|
};
|
|
|
|
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 '--expected-gateway-mode':
|
|
result.expectedGatewayMode = requireValue(argv, ++index, arg);
|
|
break;
|
|
case '--expected-public-base-url':
|
|
result.expectedPublicBaseUrl = requireValue(argv, ++index, arg);
|
|
break;
|
|
case '--expected-public-host':
|
|
result.expectedPublicHost = requireValue(argv, ++index, arg);
|
|
break;
|
|
case '--require-empty-public-host':
|
|
result.requireEmptyPublicHost = true;
|
|
break;
|
|
default:
|
|
throw new Error(`未知参数: ${arg}`);
|
|
}
|
|
}
|
|
|
|
if (!result.expectedGatewayMode) {
|
|
throw new Error(
|
|
'必须提供 --expected-gateway-mode 或 GENARRATIVE_HEALTH_PATROL_EXPECTED_GATEWAY_MODE。',
|
|
);
|
|
}
|
|
if (!path.isAbsolute(result.envFile)) {
|
|
throw new Error(`--env-file 必须是绝对路径: ${result.envFile}`);
|
|
}
|
|
if (isFilesystemRootPath(result.envFile)) {
|
|
throw new Error('--env-file 不能是文件系统根目录。');
|
|
}
|
|
if (!GATEWAY_MODES.has(result.expectedGatewayMode)) {
|
|
throw new Error(
|
|
`--expected-gateway-mode 只支持 nginx 或 pingora-direct: ${result.expectedGatewayMode}`,
|
|
);
|
|
}
|
|
if (result.expectedPublicBaseUrl) {
|
|
normalizeBaseUrl(result.expectedPublicBaseUrl, '--expected-public-base-url');
|
|
}
|
|
if (result.expectedPublicHost !== null) {
|
|
validateHostOption(result.expectedPublicHost, '--expected-public-host');
|
|
}
|
|
if (result.expectedPublicHost !== null && result.requireEmptyPublicHost) {
|
|
throw new Error(
|
|
'--expected-public-host 和 --require-empty-public-host 不能同时使用。',
|
|
);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function printUsage() {
|
|
console.log(`Usage:
|
|
node -- scripts/check-production-health-patrol-env.mjs --expected-gateway-mode <mode> [options]
|
|
|
|
Options:
|
|
--env-file <path> health-patrol env 文件,默认 /etc/genarrative/health-patrol.env。
|
|
--expected-gateway-mode <mode> nginx 或 pingora-direct。
|
|
--expected-public-base-url <url> 可选,要求 GENARRATIVE_HEALTH_PATROL_PUBLIC_BASE_URL 与该 URL 一致。
|
|
--expected-public-host <host> 可选,要求 GENARRATIVE_HEALTH_PATROL_PUBLIC_HOST 与该 Host 一致。
|
|
--require-empty-public-host 要求 GENARRATIVE_HEALTH_PATROL_PUBLIC_HOST 为空,适合回退到 Nginx 后复核。
|
|
`);
|
|
}
|
|
|
|
function readBoolEnv(name) {
|
|
const value = process.env[name];
|
|
if (value === undefined || value === null || String(value).trim() === '') {
|
|
return false;
|
|
}
|
|
const normalized = String(value).trim().toLowerCase();
|
|
if (['1', 'true', 'yes', 'on'].includes(normalized)) {
|
|
return true;
|
|
}
|
|
if (['0', 'false', 'no', 'off'].includes(normalized)) {
|
|
return false;
|
|
}
|
|
throw new Error(`${name} 必须是布尔值 true/false 或 1/0。`);
|
|
}
|
|
|
|
function requireValue(argv, index, flag) {
|
|
const value = argv[index];
|
|
if (value === undefined || value.startsWith('--')) {
|
|
throw new Error(`${flag} 缺少参数值`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function readEnvFile(filePath) {
|
|
let text;
|
|
try {
|
|
text = readFileSync(filePath, 'utf8');
|
|
} catch (error) {
|
|
throw new Error(`无法读取 health patrol env 文件 ${filePath}: ${error.message}`);
|
|
}
|
|
|
|
const values = new Map();
|
|
for (const [index, rawLine] of text.split(/\r?\n/u).entries()) {
|
|
let line = rawLine.trim();
|
|
if (!line || line.startsWith('#')) {
|
|
continue;
|
|
}
|
|
if (line.startsWith('export ')) {
|
|
line = line.slice('export '.length).trim();
|
|
}
|
|
const equalsIndex = line.indexOf('=');
|
|
if (equalsIndex <= 0) {
|
|
failures.push(`第 ${index + 1} 行不是 KEY=VALUE 格式: ${rawLine}`);
|
|
continue;
|
|
}
|
|
const key = line.slice(0, equalsIndex).trim();
|
|
const value = stripQuotes(line.slice(equalsIndex + 1).trim());
|
|
values.set(key, value);
|
|
}
|
|
return values;
|
|
}
|
|
|
|
function stripQuotes(value) {
|
|
if (value.length >= 2) {
|
|
const first = value[0];
|
|
const last = value[value.length - 1];
|
|
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
return value.slice(1, -1);
|
|
}
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function envValue(name) {
|
|
return envValues.get(name) ?? '';
|
|
}
|
|
|
|
function checkGatewayMode() {
|
|
const actual = envValue('GENARRATIVE_HEALTH_PATROL_GATEWAY_MODE');
|
|
if (!actual) {
|
|
failures.push(
|
|
'health patrol env 缺少 GENARRATIVE_HEALTH_PATROL_GATEWAY_MODE。',
|
|
);
|
|
return;
|
|
}
|
|
if (actual !== config.expectedGatewayMode) {
|
|
failures.push(
|
|
`GENARRATIVE_HEALTH_PATROL_GATEWAY_MODE 应为 ${config.expectedGatewayMode},实际 ${actual}。`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function checkPublicBaseUrl() {
|
|
if (!config.expectedPublicBaseUrl) {
|
|
return;
|
|
}
|
|
|
|
const actual = envValue('GENARRATIVE_HEALTH_PATROL_PUBLIC_BASE_URL');
|
|
if (!actual) {
|
|
failures.push(
|
|
'health patrol env 缺少 GENARRATIVE_HEALTH_PATROL_PUBLIC_BASE_URL。',
|
|
);
|
|
return;
|
|
}
|
|
const expected = normalizeBaseUrl(
|
|
config.expectedPublicBaseUrl,
|
|
'--expected-public-base-url',
|
|
);
|
|
const normalizedActual = normalizeBaseUrl(
|
|
actual,
|
|
'GENARRATIVE_HEALTH_PATROL_PUBLIC_BASE_URL',
|
|
);
|
|
if (normalizedActual !== expected) {
|
|
failures.push(
|
|
`GENARRATIVE_HEALTH_PATROL_PUBLIC_BASE_URL 应为 ${expected},实际 ${normalizedActual}。`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function checkPublicHost() {
|
|
const publicHost = envValue('GENARRATIVE_HEALTH_PATROL_PUBLIC_HOST');
|
|
const baseUrl =
|
|
config.expectedPublicBaseUrl ||
|
|
envValue('GENARRATIVE_HEALTH_PATROL_PUBLIC_BASE_URL');
|
|
|
|
if (publicHost) {
|
|
try {
|
|
validateHostOption(publicHost, 'GENARRATIVE_HEALTH_PATROL_PUBLIC_HOST');
|
|
} catch (error) {
|
|
failures.push(error.message);
|
|
}
|
|
}
|
|
|
|
if (config.expectedPublicHost !== null && publicHost !== config.expectedPublicHost) {
|
|
failures.push(
|
|
`GENARRATIVE_HEALTH_PATROL_PUBLIC_HOST 应为 ${config.expectedPublicHost},实际 ${publicHost || '(空)'}。`,
|
|
);
|
|
}
|
|
|
|
if (config.requireEmptyPublicHost && publicHost) {
|
|
failures.push(
|
|
`GENARRATIVE_HEALTH_PATROL_PUBLIC_HOST 应为空,实际 ${publicHost}。`,
|
|
);
|
|
}
|
|
|
|
if (
|
|
config.expectedGatewayMode === 'pingora-direct' &&
|
|
baseUrl &&
|
|
isLoopbackUrl(baseUrl) &&
|
|
!publicHost
|
|
) {
|
|
failures.push(
|
|
'Pingora direct 模式使用本机 public base URL 时必须配置 GENARRATIVE_HEALTH_PATROL_PUBLIC_HOST,避免巡检命中默认 vhost。',
|
|
);
|
|
}
|
|
}
|
|
|
|
function normalizeBaseUrl(value, label) {
|
|
try {
|
|
const parsed = new URL(value);
|
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
throw new Error('unsupported protocol');
|
|
}
|
|
return parsed.toString().replace(/\/+$/u, '');
|
|
} catch {
|
|
throw new Error(`${label} 必须是合法 http(s) URL: ${value}`);
|
|
}
|
|
}
|
|
|
|
function validateHostOption(value, label) {
|
|
const raw = String(value);
|
|
if (raw !== raw.trim() || raw.includes('://') || /[\s/?#@]/.test(raw)) {
|
|
throw new Error(
|
|
`${label} 只能是 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(`${label} 不是合法的 host 或 host:port。`);
|
|
}
|
|
}
|
|
|
|
function isFilesystemRootPath(value) {
|
|
const resolved = path.resolve(String(value));
|
|
return resolved === path.parse(resolved).root;
|
|
}
|
|
|
|
function isLoopbackUrl(value) {
|
|
try {
|
|
const hostname = new URL(value).hostname.toLowerCase();
|
|
return (
|
|
hostname === 'localhost' ||
|
|
hostname === '::1' ||
|
|
hostname === '[::1]' ||
|
|
hostname.startsWith('127.')
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|