#!/usr/bin/env node import { chmodSync, chownSync, lstatSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; const TARGET_KEYS = [ 'GENARRATIVE_PINGORA_GATEWAY_LISTEN', 'GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN', 'GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN', 'GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE', 'GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE', ]; const SHADOW_VALUES = { GENARRATIVE_PINGORA_GATEWAY_LISTEN: '127.0.0.1:18081', GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN: '', GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN: '', GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE: '', GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE: '', }; 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 nextText = rewriteEnvFile(originalText, SHADOW_VALUES); const checkFile = writeDryRunEnv(nextText); try { console.log( `[pingora-gateway-env-shadow-switch] apply=${config.apply} env=${config.envFile}`, ); for (const key of TARGET_KEYS) { console.log(`~ ${key}=${SHADOW_VALUES[key]}`); } assertShadowEnv(checkFile); if (!config.apply) { console.log( '[pingora-gateway-env-shadow-switch] 当前是 dry-run;追加 --apply 后才会写入 env 文件。', ); } else { writeEnvFileAtomically(config.envFile, nextText); } console.log('[pingora-gateway-env-shadow-switch] 完成'); } finally { rmSync(path.dirname(checkFile), { recursive: true, force: true }); } function parseArgs(argv) { const result = { envFile: process.env.GENARRATIVE_PINGORA_GATEWAY_ENV_FILE || '/etc/genarrative/pingora-gateway.env', 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 '--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 不能是文件系统根目录。'); } return result; } function printUsage() { console.log(`Usage: node scripts/deploy/pingora-gateway-env-shadow-switch.mjs [options] Options: --env-file Pingora gateway env 文件,默认 /etc/genarrative/pingora-gateway.env。 --apply 写入 env 文件;默认只 dry-run 并用临时 env 复核。 `); } 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(`pingora gateway 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-pingora-gateway-env-shadow-switch-'), ); const filePath = path.join(dir, 'pingora-gateway.env'); writeFileSync(filePath, content, { encoding: 'utf8', mode: DRY_RUN_ENV_FILE_MODE, }); chmodSync(filePath, DRY_RUN_ENV_FILE_MODE); return filePath; } function assertShadowEnv(filePath) { const values = parseEnvFile(readFileSync(filePath, 'utf8')); if ( values.GENARRATIVE_PINGORA_GATEWAY_LISTEN !== SHADOW_VALUES.GENARRATIVE_PINGORA_GATEWAY_LISTEN ) { throw new Error( 'shadow env 复核失败:GENARRATIVE_PINGORA_GATEWAY_LISTEN 必须是 127.0.0.1:18081。', ); } for (const key of [ 'GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN', 'GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN', 'GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE', 'GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE', ]) { if ((values[key] || '') !== '') { throw new Error(`shadow env 复核失败:${key} 必须为空。`); } } } function parseEnvFile(text) { const values = {}; for (const line of text.split(/\r?\n/u)) { const assignment = parseAssignmentLine(line); if (!assignment) { continue; } values[assignment.key] = line .slice(line.indexOf('=') + 1) .trim() .replace(/^['"]|['"]$/gu, ''); } return values; } 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 不能是符号链接;请直接传真实 Pingora gateway env 文件路径: ${filePath}`, ); } if (!currentStat.isFile()) { throw new Error(`--env-file 必须指向普通文件: ${filePath}`); } return currentStat; } 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; }