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

755 lines
20 KiB
JavaScript

#!/usr/bin/env node
import { execFile } from 'node:child_process';
import { mkdir, writeFile } from 'node:fs/promises';
import http from 'node:http';
import https from 'node:https';
import { dirname } from 'node:path';
const STATUS_RANK = {
OK: 0,
WARNING: 1,
CRITICAL: 2,
};
const DEFAULT_PUBLIC_PATHS = [
'/',
];
const DEFAULT_SERVICES = [
'genarrative-api.service',
'genarrative-external-generation-controller.service',
'spacetimedb.service',
'nginx.service',
];
const PINGORA_DIRECT_SERVICES = [
'genarrative-api.service',
'genarrative-external-generation-controller.service',
'spacetimedb.service',
'genarrative-pingora-gateway.service',
];
const WORKER_SERVICE_PATTERN = 'genarrative-external-generation-worker@*.service';
const GATEWAY_MODES = new Set(['nginx', 'pingora-direct']);
function usage() {
console.log(`Usage:
node scripts/ops/production-health-patrol.mjs [options]
Options:
--api-base-url <url> API direct base URL, default http://127.0.0.1:8082
--spacetime-base-url <url> SpacetimeDB base URL, default http://127.0.0.1:3101
--public-base-url <url> Nginx/public base URL, default http://127.0.0.1
--public-host <host> Optional public Host header, useful when probing 127.0.0.1
--public-path <path> Public API path to probe; repeatable
--gateway-mode <mode> nginx or pingora-direct, default nginx
--pingora-base-url <url> Optional Pingora shadow gateway base URL
--pingora-probe-token <token> Optional Pingora shadow probe token
--status-file <path> Write the last patrol result as JSON
--timeout-ms <ms> HTTP/command timeout, default 5000
--slow-ms <ms> Mark successful probes slower than this as WARNING, default 3000
--fail-on-warning Exit 1 when the total status is WARNING
--skip-journal Skip recent journal error scan
--json Print JSON instead of text
`);
}
function readBoolEnv(name, fallback = false) {
const value = process.env[name];
if (value === undefined || value === null || String(value).trim() === '') {
return fallback;
}
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 parseOptionalPositiveInt(raw, fallback, label) {
if (raw === undefined || raw === null || String(raw).trim() === '') {
return fallback;
}
return parseRequiredPositiveInt(raw, label);
}
function parseRequiredPositiveInt(raw, label) {
const valueText = String(raw ?? '').trim();
if (!/^[1-9]\d*$/.test(valueText)) {
throw new Error(`${label} 必须是正整数。`);
}
return Number.parseInt(valueText, 10);
}
function parseArgs(argv) {
const config = {
apiBaseUrl:
process.env.GENARRATIVE_HEALTH_PATROL_API_BASE_URL ||
'http://127.0.0.1:8082',
spacetimeBaseUrl:
process.env.GENARRATIVE_HEALTH_PATROL_SPACETIME_BASE_URL ||
'http://127.0.0.1:3101',
publicBaseUrl:
process.env.GENARRATIVE_HEALTH_PATROL_PUBLIC_BASE_URL ||
'http://127.0.0.1',
publicHost: process.env.GENARRATIVE_HEALTH_PATROL_PUBLIC_HOST || '',
publicPaths: [],
gatewayMode: process.env.GENARRATIVE_HEALTH_PATROL_GATEWAY_MODE || 'nginx',
pingoraBaseUrl:
process.env.GENARRATIVE_HEALTH_PATROL_PINGORA_BASE_URL || '',
pingoraProbeToken:
process.env.GENARRATIVE_HEALTH_PATROL_PINGORA_PROBE_TOKEN ||
process.env.GENARRATIVE_PINGORA_GATEWAY_PROBE_TOKEN ||
'',
statusFile: process.env.GENARRATIVE_HEALTH_PATROL_STATUS_FILE || '',
timeoutMs: parseOptionalPositiveInt(
process.env.GENARRATIVE_HEALTH_PATROL_TIMEOUT_MS,
5000,
'GENARRATIVE_HEALTH_PATROL_TIMEOUT_MS',
),
slowMs: parseOptionalPositiveInt(
process.env.GENARRATIVE_HEALTH_PATROL_SLOW_MS,
3000,
'GENARRATIVE_HEALTH_PATROL_SLOW_MS',
),
failOnWarning: readBoolEnv('GENARRATIVE_HEALTH_PATROL_FAIL_ON_WARNING'),
skipJournal: readBoolEnv('GENARRATIVE_HEALTH_PATROL_SKIP_JOURNAL'),
json: false,
webhookUrl: process.env.GENARRATIVE_HEALTH_PATROL_WEBHOOK_URL || '',
};
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 '--api-base-url':
config.apiBaseUrl = requireValue(argv, ++index, arg);
break;
case '--spacetime-base-url':
config.spacetimeBaseUrl = requireValue(argv, ++index, arg);
break;
case '--public-base-url':
config.publicBaseUrl = requireValue(argv, ++index, arg);
break;
case '--public-host':
config.publicHost = requireValue(argv, ++index, arg);
break;
case '--public-path':
config.publicPaths.push(requireValue(argv, ++index, arg));
break;
case '--gateway-mode':
config.gatewayMode = requireValue(argv, ++index, arg);
break;
case '--pingora-base-url':
config.pingoraBaseUrl = requireValue(argv, ++index, arg);
break;
case '--pingora-probe-token':
config.pingoraProbeToken = requireValue(argv, ++index, arg);
break;
case '--status-file':
config.statusFile = requireValue(argv, ++index, arg);
break;
case '--timeout-ms':
config.timeoutMs = parseRequiredPositiveInt(
requireValue(argv, ++index, arg),
'--timeout-ms',
);
break;
case '--slow-ms':
config.slowMs = parseRequiredPositiveInt(
requireValue(argv, ++index, arg),
'--slow-ms',
);
break;
case '--fail-on-warning':
config.failOnWarning = true;
break;
case '--skip-journal':
config.skipJournal = true;
break;
case '--json':
config.json = true;
break;
default:
throw new Error(`未知参数: ${arg}`);
}
}
if (config.publicPaths.length === 0) {
config.publicPaths = DEFAULT_PUBLIC_PATHS;
}
config.gatewayMode = config.gatewayMode.trim();
if (!GATEWAY_MODES.has(config.gatewayMode)) {
throw new Error(
`--gateway-mode 只支持 nginx 或 pingora-direct: ${config.gatewayMode}`,
);
}
if (config.publicHost) {
validateHostOption(config.publicHost, '--public-host');
}
return config;
}
function requireValue(argv, index, flag) {
const value = argv[index];
if (!value || value.startsWith('--')) {
throw new Error(`${flag} 缺少参数值`);
}
return value;
}
function joinUrl(baseUrl, path) {
const base = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
const suffix = path.startsWith('/') ? path : `/${path}`;
return `${base}${suffix}`;
}
function validateHostOption(value, flag) {
if (
value.includes('://') ||
value.includes('/') ||
value.includes('?') ||
value.includes('#')
) {
throw new Error(`${flag} 只接受域名或 host:port,不要填 URL、路径或查询。`);
}
}
function maxStatus(checks) {
return checks.reduce((current, check) => {
return STATUS_RANK[check.status] > STATUS_RANK[current]
? check.status
: current;
}, 'OK');
}
function checkResult(name, status, summary, details = {}) {
return {
name,
status,
summary,
...details,
};
}
function runCommand(command, args, timeoutMs) {
return new Promise((resolve) => {
execFile(
command,
args,
{
timeout: timeoutMs,
windowsHide: true,
maxBuffer: 256 * 1024,
},
(error, stdout, stderr) => {
resolve({
command: [command, ...args].join(' '),
code: typeof error?.code === 'number' ? error.code : error ? 1 : 0,
signal: error?.signal || '',
stdout: String(stdout || ''),
stderr: String(stderr || ''),
timedOut: Boolean(error?.killed),
error: error ? error.message : '',
});
},
);
});
}
async function checkService(serviceName, timeoutMs) {
const result = await runCommand(
'systemctl',
['is-active', serviceName],
timeoutMs,
);
const state = result.stdout.trim() || result.stderr.trim() || result.error;
if (result.code === 0 && state === 'active') {
return checkResult(`service:${serviceName}`, 'OK', 'active', {
command: result.command,
});
}
return checkResult(
`service:${serviceName}`,
'CRITICAL',
`服务状态异常: ${state || `exit ${result.code}`}`,
{
command: result.command,
stderr: result.stderr.trim(),
},
);
}
async function checkActiveWorkerInstances(config) {
const result = await runCommand(
'systemctl',
[
'list-units',
WORKER_SERVICE_PATTERN,
'--type=service',
'--state=active',
'--no-legend',
'--plain',
'--no-pager',
],
config.timeoutMs,
);
if (result.code !== 0) {
return checkResult(
'service:external-generation-workers',
'CRITICAL',
'无法枚举外部生成 worker 实例',
{
command: result.command,
stderr: result.stderr.trim() || result.error,
},
);
}
const services = result.stdout
.split('\n')
.map((line) => line.trim().split(/\s+/u)[0])
.filter((service) =>
/^genarrative-external-generation-worker@.+\.service$/u.test(service),
);
if (services.length === 0) {
return checkResult(
'service:external-generation-workers',
'CRITICAL',
'没有 active 的外部生成 worker 实例',
{
command: result.command,
},
);
}
return checkResult(
'service:external-generation-workers',
'OK',
`${services.length} 个 worker active`,
{
command: result.command,
services,
},
);
}
function requestUrl(url, timeoutMs, headers = {}) {
return new Promise((resolve) => {
const startedAt = Date.now();
const parsed = new URL(url);
const client = parsed.protocol === 'https:' ? https : http;
const request = client.request(
parsed,
{
method: 'GET',
timeout: timeoutMs,
headers: {
'User-Agent': 'genarrative-health-patrol/1.0',
Accept: 'application/json,text/plain,*/*',
...headers,
},
},
(response) => {
let body = '';
response.setEncoding('utf8');
response.on('data', (chunk) => {
if (body.length < 2048) {
body += chunk;
}
});
response.on('end', () => {
resolve({
elapsedMs: Date.now() - startedAt,
statusCode: response.statusCode || 0,
body: body.slice(0, 2048),
});
});
},
);
request.on('timeout', () => {
request.destroy(new Error(`timeout after ${timeoutMs}ms`));
});
request.on('error', (error) => {
resolve({
elapsedMs: Date.now() - startedAt,
error: error.message,
});
});
request.end();
});
}
async function checkHttp(name, url, config, headers = {}) {
const result = await requestUrl(url, config.timeoutMs, headers);
const headerArgs = Object.entries(headers)
.map(
([key, value]) =>
` -H '${key}: ${key.toLowerCase() === 'host' ? value : '***'}'`,
)
.join('');
const curlCommand = `curl -fsS --max-time ${Math.ceil(
config.timeoutMs / 1000,
)}${headerArgs} ${url}`;
if (result.error) {
return checkResult(name, 'CRITICAL', `请求失败: ${result.error}`, {
command: curlCommand,
elapsedMs: result.elapsedMs,
});
}
const ok = result.statusCode >= 200 && result.statusCode < 300;
if (!ok) {
return checkResult(
name,
'CRITICAL',
`HTTP ${result.statusCode},耗时 ${result.elapsedMs}ms`,
{
command: curlCommand,
elapsedMs: result.elapsedMs,
body: result.body.trim(),
},
);
}
if (result.elapsedMs > config.slowMs) {
return checkResult(
name,
'WARNING',
`HTTP ${result.statusCode} 但耗时偏高: ${result.elapsedMs}ms`,
{
command: curlCommand,
elapsedMs: result.elapsedMs,
},
);
}
return checkResult(
name,
'OK',
`HTTP ${result.statusCode} ${result.elapsedMs}ms`,
{
command: curlCommand,
elapsedMs: result.elapsedMs,
},
);
}
async function checkPingoraShadow(config) {
if (!config.pingoraBaseUrl || !config.pingoraProbeToken) {
return checkResult(
'pingora:shadow-probe',
'WARNING',
'Pingora shadow 巡检配置不完整,需同时配置 base URL 与 probe token',
);
}
const path = '/__genarrative_pingora/healthz';
const url = joinUrl(config.pingoraBaseUrl, path);
const result = await requestUrl(url, config.timeoutMs, {
'X-Genarrative-Pingora-Probe': config.pingoraProbeToken,
});
const command = `curl -fsS --max-time ${Math.ceil(
config.timeoutMs / 1000,
)} -H 'X-Genarrative-Pingora-Probe: ***' ${url}`;
if (result.error) {
return checkResult(
'pingora:shadow-probe',
'CRITICAL',
`请求失败: ${result.error}`,
{
command,
elapsedMs: result.elapsedMs,
},
);
}
if (result.statusCode < 200 || result.statusCode >= 300) {
return checkResult(
'pingora:shadow-probe',
'CRITICAL',
`HTTP ${result.statusCode},耗时 ${result.elapsedMs}ms`,
{
command,
elapsedMs: result.elapsedMs,
body: result.body.trim(),
},
);
}
let payload;
try {
payload = JSON.parse(result.body);
} catch {
return checkResult(
'pingora:shadow-probe',
'CRITICAL',
`响应不是合法 JSON,耗时 ${result.elapsedMs}ms`,
{
command,
elapsedMs: result.elapsedMs,
body: result.body.trim(),
},
);
}
if (payload?.ok !== true || payload?.gateway !== 'pingora-shadow') {
return checkResult(
'pingora:shadow-probe',
'CRITICAL',
`响应不是 Pingora shadow 探针,耗时 ${result.elapsedMs}ms`,
{
command,
elapsedMs: result.elapsedMs,
body: result.body.trim(),
},
);
}
if (result.elapsedMs > config.slowMs) {
return checkResult(
'pingora:shadow-probe',
'WARNING',
`HTTP ${result.statusCode} 但耗时偏高: ${result.elapsedMs}ms`,
{
command,
elapsedMs: result.elapsedMs,
},
);
}
return checkResult(
'pingora:shadow-probe',
'OK',
`HTTP ${result.statusCode} ${result.elapsedMs}ms`,
{
command,
elapsedMs: result.elapsedMs,
maintenance: Boolean(payload.maintenance),
},
);
}
function gatewayServiceNames(config) {
return config.gatewayMode === 'pingora-direct'
? PINGORA_DIRECT_SERVICES
: DEFAULT_SERVICES;
}
async function checkRecentJournal(config) {
const serviceNames = [
...new Set([...gatewayServiceNames(config), WORKER_SERVICE_PATTERN]),
];
const args = [
...serviceNames.flatMap((serviceName) => ['-u', serviceName]),
'--since',
'15 minutes ago',
'-p',
'err..alert',
'--no-pager',
'-o',
'short-iso',
'-n',
'20',
];
const result = await runCommand('journalctl', args, config.timeoutMs);
if (result.code !== 0) {
return checkResult(
'journal:recent-errors',
'WARNING',
'无法读取最近错误日志',
{
command: result.command,
stderr: result.stderr.trim() || result.error,
},
);
}
const lines = result.stdout
.split('\n')
.map((line) => line.trim())
.filter((line) => line && line !== '-- No entries --');
if (lines.length === 0) {
return checkResult(
'journal:recent-errors',
'OK',
'最近 15 分钟无 err..alert 日志',
{
command: result.command,
},
);
}
return checkResult(
'journal:recent-errors',
'WARNING',
`最近 15 分钟有 ${lines.length} 条 err..alert 日志`,
{
command: result.command,
lines,
},
);
}
async function writeStatusFile(statusFile, payload) {
if (!statusFile) {
return;
}
await mkdir(dirname(statusFile), { recursive: true });
await writeFile(statusFile, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
}
async function notifyWebhook(config, payload) {
if (!config.webhookUrl || payload.status === 'OK') {
return;
}
const body = JSON.stringify(payload);
const parsed = new URL(config.webhookUrl);
const client = parsed.protocol === 'https:' ? https : http;
await new Promise((resolve) => {
const request = client.request(
parsed,
{
method: 'POST',
timeout: config.timeoutMs,
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
},
(response) => {
response.resume();
response.on('end', resolve);
},
);
request.on('timeout', () => {
request.destroy(new Error(`timeout after ${config.timeoutMs}ms`));
});
request.on('error', (error) => {
console.error(`[health-patrol] webhook notify failed: ${error.message}`);
resolve();
});
request.end(body);
});
}
function printText(payload) {
console.log(`[health-patrol] ${payload.status} ${payload.checkedAt}`);
for (const check of payload.checks) {
console.log(`[${check.status}] ${check.name}: ${check.summary}`);
if (check.command && check.status !== 'OK') {
console.log(` command: ${check.command}`);
}
if (check.stderr) {
console.log(` stderr: ${check.stderr}`);
}
if (check.body) {
console.log(` body: ${check.body}`);
}
if (Array.isArray(check.lines) && check.lines.length > 0) {
for (const line of check.lines) {
console.log(` ${line}`);
}
}
}
}
async function main() {
const config = parseArgs(process.argv.slice(2));
const checks = [];
for (const serviceName of gatewayServiceNames(config)) {
checks.push(await checkService(serviceName, config.timeoutMs));
}
checks.push(await checkActiveWorkerInstances(config));
checks.push(
await checkHttp(
'api:/healthz',
joinUrl(config.apiBaseUrl, '/healthz'),
config,
),
);
checks.push(
await checkHttp(
'api:/readyz',
joinUrl(config.apiBaseUrl, '/readyz'),
config,
),
);
checks.push(
await checkHttp(
'spacetimedb:/v1/ping',
joinUrl(config.spacetimeBaseUrl, '/v1/ping'),
config,
),
);
for (const path of config.publicPaths) {
const headers = config.publicHost ? { Host: config.publicHost } : {};
checks.push(
await checkHttp(
`public:${path}`,
joinUrl(config.publicBaseUrl, path),
config,
headers,
),
);
}
if (config.pingoraBaseUrl || config.pingoraProbeToken) {
checks.push(await checkPingoraShadow(config));
}
if (!config.skipJournal) {
checks.push(await checkRecentJournal(config));
}
const payload = {
status: maxStatus(checks),
checkedAt: new Date().toISOString(),
host: process.env.HOSTNAME || '',
gatewayMode: config.gatewayMode,
checks,
};
await writeStatusFile(config.statusFile, payload);
await notifyWebhook(config, payload);
if (config.json) {
console.log(JSON.stringify(payload, null, 2));
} else {
printText(payload);
}
if (payload.status === 'CRITICAL') {
process.exit(2);
}
if (payload.status === 'WARNING' && config.failOnWarning) {
process.exit(1);
}
}
main().catch((error) => {
console.error(
`[health-patrol] CRITICAL ${error instanceof Error ? error.message : String(error)}`,
);
process.exit(2);
});