#!/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 --public-base-url (--public-host |--clear-public-host) [options] Options: --env-file health-patrol env 文件,默认 /etc/genarrative/health-patrol.env。 --gateway-mode nginx 或 pingora-direct。 --public-base-url 写入 GENARRATIVE_HEALTH_PATROL_PUBLIC_BASE_URL。 --public-host 写入 GENARRATIVE_HEALTH_PATROL_PUBLIC_HOST。 --clear-public-host 清空 GENARRATIVE_HEALTH_PATROL_PUBLIC_HOST。 --check-script 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; } }