387a1c26e3
清理已跟踪原始日志与个人本地配置 修复前后端有效门禁、测试和构建问题 补齐生产 Jenkins、SpacetimeDB 本地命令与文档约束 收紧图片编辑器状态、附件与媒体引用测试 排除已下线旧创作入口测试并清理 warning
392 lines
12 KiB
JavaScript
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;
|
|
}
|
|
}
|