071faa482c
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口 完成项目 TypeScript/Prettier 与 Rust 全量格式化 修复 Pingora expected executable 门禁的空白敏感误报 同步开发运维文档与 AGC skill pack 格式化忽略规则
360 lines
9.8 KiB
JavaScript
360 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();
|
|
});
|
|
}
|