Files
Genarrative/scripts/deploy/pingora-health-patrol-env-switch.mjs
T
kdletters 387a1c26e3 完成全量检查整改
清理已跟踪原始日志与个人本地配置
修复前后端有效门禁、测试和构建问题
补齐生产 Jenkins、SpacetimeDB 本地命令与文档约束
收紧图片编辑器状态、附件与媒体引用测试
排除已下线旧创作入口测试并清理 warning
2026-07-17 16:56:46 +08:00

392 lines
12 KiB
JavaScript

#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import {
chmodSync,
chownSync,
lstatSync,
mkdtempSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const GATEWAY_MODES = new Set(['nginx', 'pingora-direct']);
const TARGET_KEYS = [
'GENARRATIVE_HEALTH_PATROL_GATEWAY_MODE',
'GENARRATIVE_HEALTH_PATROL_PUBLIC_BASE_URL',
'GENARRATIVE_HEALTH_PATROL_PUBLIC_HOST',
];
const DRY_RUN_ENV_FILE_MODE = 0o600;
const config = parseArgs(process.argv.slice(2));
if (config.apply) {
assertEnvFileIsWritableRegularFile(config.envFile);
}
const originalText = readFileSync(config.envFile, 'utf8');
const nextValues = {
GENARRATIVE_HEALTH_PATROL_GATEWAY_MODE: config.gatewayMode,
GENARRATIVE_HEALTH_PATROL_PUBLIC_BASE_URL: normalizeBaseUrl(
config.publicBaseUrl,
'--public-base-url',
),
GENARRATIVE_HEALTH_PATROL_PUBLIC_HOST: config.clearPublicHost
? ''
: config.publicHost,
};
const nextText = rewriteEnvFile(originalText, nextValues);
const checkFile = writeDryRunEnv(nextText);
try {
console.log(
`[pingora-health-patrol-env-switch] apply=${config.apply} env=${config.envFile} mode=${config.gatewayMode}`,
);
for (const key of TARGET_KEYS) {
console.log(`~ ${key}=${nextValues[key]}`);
}
runHealthPatrolEnvCheck(checkFile);
if (!config.apply) {
console.log(
'[pingora-health-patrol-env-switch] 当前是 dry-run;追加 --apply 后才会写入 env 文件。',
);
} else {
writeEnvFileAtomically(config.envFile, nextText);
}
console.log('[pingora-health-patrol-env-switch] 完成');
} finally {
rmSync(path.dirname(checkFile), { recursive: true, force: true });
}
function parseArgs(argv) {
const result = {
envFile:
process.env.GENARRATIVE_HEALTH_PATROL_ENV_FILE ||
'/etc/genarrative/health-patrol.env',
gatewayMode: process.env.GENARRATIVE_HEALTH_PATROL_GATEWAY_MODE || '',
publicBaseUrl:
process.env.GENARRATIVE_HEALTH_PATROL_PUBLIC_BASE_URL || '',
publicHost: process.env.GENARRATIVE_HEALTH_PATROL_PUBLIC_HOST || null,
clearPublicHost: readBoolEnv(
'GENARRATIVE_HEALTH_PATROL_CLEAR_PUBLIC_HOST',
),
checkScript:
process.env.GENARRATIVE_HEALTH_PATROL_ENV_CHECK_SCRIPT ||
defaultEnvCheckScript(),
apply: false,
};
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 '--gateway-mode':
result.gatewayMode = requireValue(argv, ++index, arg);
break;
case '--public-base-url':
result.publicBaseUrl = requireValue(argv, ++index, arg);
break;
case '--public-host':
result.publicHost = requireValue(argv, ++index, arg);
break;
case '--clear-public-host':
result.clearPublicHost = true;
break;
case '--check-script':
result.checkScript = requireValue(argv, ++index, arg);
break;
case '--apply':
result.apply = true;
break;
default:
throw new Error(`未知参数: ${arg}`);
}
}
if (!path.isAbsolute(result.envFile)) {
throw new Error(`--env-file 必须是绝对路径: ${result.envFile}`);
}
validateNoControlCharacters(result.envFile, '--env-file');
if (isFilesystemRootPath(result.envFile)) {
throw new Error('--env-file 不能是文件系统根目录。');
}
if (!path.isAbsolute(result.checkScript)) {
throw new Error(`--check-script 必须是绝对路径: ${result.checkScript}`);
}
validateNoControlCharacters(result.checkScript, '--check-script');
if (isFilesystemRootPath(result.checkScript)) {
throw new Error('--check-script 不能是文件系统根目录。');
}
if (!GATEWAY_MODES.has(result.gatewayMode)) {
throw new Error(
`--gateway-mode 只支持 nginx 或 pingora-direct: ${result.gatewayMode}`,
);
}
if (!result.publicBaseUrl) {
throw new Error('必须提供 --public-base-url。');
}
normalizeBaseUrl(result.publicBaseUrl, '--public-base-url');
if (result.publicHost !== null && result.clearPublicHost) {
throw new Error('--public-host 和 --clear-public-host 不能同时使用。');
}
if (result.publicHost === null && !result.clearPublicHost) {
throw new Error(
'必须显式提供 --public-host 或 --clear-public-host,避免沿用旧 Host 覆盖。',
);
}
if (result.publicHost !== null) {
validateHostOption(result.publicHost, '--public-host');
}
if (
result.gatewayMode === 'pingora-direct' &&
isLoopbackUrl(result.publicBaseUrl) &&
result.clearPublicHost
) {
throw new Error(
'Pingora direct 模式使用本机 public base URL 时必须提供 --public-host,避免巡检命中默认 vhost。',
);
}
return result;
}
function printUsage() {
console.log(`Usage:
node scripts/deploy/pingora-health-patrol-env-switch.mjs --gateway-mode <nginx|pingora-direct> --public-base-url <url> (--public-host <host>|--clear-public-host) [options]
Options:
--env-file <path> health-patrol env 文件,默认 /etc/genarrative/health-patrol.env。
--gateway-mode <mode> nginx 或 pingora-direct。
--public-base-url <url> 写入 GENARRATIVE_HEALTH_PATROL_PUBLIC_BASE_URL。
--public-host <host> 写入 GENARRATIVE_HEALTH_PATROL_PUBLIC_HOST。
--clear-public-host 清空 GENARRATIVE_HEALTH_PATROL_PUBLIC_HOST。
--check-script <path> env 复核脚本,默认使用同一 release 内的 check-production-health-patrol-env.mjs。
--apply 写入 env 文件;默认只 dry-run 并用临时 env 复核。
`);
}
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 defaultEnvCheckScript() {
const scriptPath = fileURLToPath(import.meta.url);
const releaseRoot = path.resolve(path.dirname(scriptPath), '../..');
return path.join(releaseRoot, 'scripts/check-production-health-patrol-env.mjs');
}
function requireValue(argv, index, flag) {
const value = argv[index];
if (value === undefined || value.startsWith('--')) {
throw new Error(`${flag} 缺少参数值`);
}
return value;
}
function rewriteEnvFile(text, nextValuesByKey) {
const lines = text.replace(/\r\n/gu, '\n').split('\n');
if (lines.at(-1) === '') {
lines.pop();
}
const seen = new Set();
const output = [];
for (const line of lines) {
const assignment = parseAssignmentLine(line);
if (!assignment || !TARGET_KEYS.includes(assignment.key)) {
output.push(line);
continue;
}
if (seen.has(assignment.key)) {
throw new Error(`health patrol env 中存在重复配置: ${assignment.key}`);
}
seen.add(assignment.key);
output.push(
`${assignment.leading}${assignment.exportPrefix}${assignment.key}=${nextValuesByKey[assignment.key]}`,
);
}
for (const key of TARGET_KEYS) {
if (!seen.has(key)) {
output.push(`${key}=${nextValuesByKey[key]}`);
}
}
return `${output.join('\n')}\n`;
}
function parseAssignmentLine(line) {
const match = line.match(/^(\s*)(export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/u);
if (!match) {
return null;
}
return {
leading: match[1] || '',
exportPrefix: match[2] || '',
key: match[3],
};
}
function writeDryRunEnv(content) {
const dir = mkdtempSync(
path.join(tmpdir(), 'genarrative-health-patrol-env-switch-'),
);
const filePath = path.join(dir, 'health-patrol.env');
writeFileSync(filePath, content, {
encoding: 'utf8',
mode: DRY_RUN_ENV_FILE_MODE,
});
chmodSync(filePath, DRY_RUN_ENV_FILE_MODE);
return filePath;
}
function writeEnvFileAtomically(filePath, content) {
const currentStat = assertEnvFileIsWritableRegularFile(filePath);
const currentMode = currentStat.mode & 0o777;
const tempFile = path.join(
path.dirname(filePath),
`.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`,
);
try {
writeFileSync(tempFile, content, { encoding: 'utf8', mode: currentMode });
chownSync(tempFile, currentStat.uid, currentStat.gid);
chmodSync(tempFile, currentMode);
assertEnvFileIsWritableRegularFile(filePath);
renameSync(tempFile, filePath);
} catch (error) {
rmSync(tempFile, { force: true });
throw error;
}
}
function assertEnvFileIsWritableRegularFile(filePath) {
const currentStat = lstatSync(filePath);
if (currentStat.isSymbolicLink()) {
throw new Error(
`--env-file 不能是符号链接;请直接传真实 health patrol env 文件路径: ${filePath}`,
);
}
if (!currentStat.isFile()) {
throw new Error(`--env-file 必须指向普通文件: ${filePath}`);
}
return currentStat;
}
function runHealthPatrolEnvCheck(envFile) {
const checkArgs = [
config.checkScript,
'--env-file',
envFile,
'--expected-gateway-mode',
config.gatewayMode,
'--expected-public-base-url',
normalizeBaseUrl(config.publicBaseUrl, '--public-base-url'),
];
if (config.clearPublicHost) {
checkArgs.push('--require-empty-public-host');
} else {
checkArgs.push('--expected-public-host', config.publicHost);
}
console.log(`+ node ${checkArgs.join(' ')}`);
const result = spawnSync(process.execPath, ['--', ...checkArgs], {
encoding: 'utf8',
});
if (result.stdout) {
process.stdout.write(result.stdout);
}
if (result.stderr) {
process.stderr.write(result.stderr);
}
if (result.status !== 0) {
process.exit(result.status || 1);
}
}
function normalizeBaseUrl(value, label) {
validateNoControlCharacters(value, label);
try {
const parsed = new URL(value);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
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);
validateNoControlCharacters(raw, label);
if (raw !== raw.trim() || raw.includes('://') || /[\s/?#@]/u.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 validateNoControlCharacters(value, label) {
if (/[\0\r\n]/u.test(String(value ?? ''))) {
throw new Error(`${label} 不能包含换行或 NUL 字符。`);
}
}
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;
}
}