Files
Genarrative/scripts/check-pingora-canary-live.mjs
T
kdletters e9c3dc1120 退役旧创作模板业务
保留 SpacetimeDB 历史表、迁移白名单与旧业务源码
切换前端 active 入口并解除旧创作页面和路由编译链
移除旧后端路由、worker 与纯业务 crate 依赖
收敛 SpacetimeDB 模块为历史数据壳
同步 Nginx、Pingora、验证门禁与架构文档
2026-07-17 22:07:52 +08:00

361 lines
9.8 KiB
JavaScript

#!/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-history',
path: '/api/assets/history',
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();
});
}