#!/usr/bin/env node import { spawnSync } from 'node:child_process'; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; const ENABLE_SCRIPT = 'scripts/deploy/pingora-realpath-canary-enable.sh'; const DISABLE_SCRIPT = 'scripts/deploy/pingora-realpath-canary-disable.sh'; const failures = []; const tmpRoot = mkdtempSync(path.join(tmpdir(), 'genarrative-realpath-canary-')); try { main(); } finally { rmSync(tmpRoot, { recursive: true, force: true }); } if (failures.length > 0) { console.error('[check:pingora-realpath-canary-toggle] FAILED'); for (const failure of failures) { console.error(`- ${failure}`); } process.exit(1); } console.log('[check:pingora-realpath-canary-toggle] OK'); function main() { assertScriptShape(); assertDryRunDoesNotWrite(); assertApplyWritesRenderedConfigAndRunsLive(); assertEnableRollsBackWhenLiveFails(); assertDisableRemovesConfig(); assertDisableRollsBackWhenReloadFails(); assertRejectsUnsafeInputs(); } function assertScriptShape() { for (const script of [ENABLE_SCRIPT, DISABLE_SCRIPT]) { const content = readFileSync(script, 'utf8'); assertIncludes(content, '--apply', `${script} 必须默认 dry-run 并要求显式 --apply。`); assertIncludes(content, 'nginx -t', `${script} 帮助文案必须说明会先做 nginx -t。`); assertIncludes( content, 'zz-genarrative-pingora-realpath-canary.conf', `${script} 必须默认使用晚于主站配置加载的 conf.d 文件名。`, ); assertIncludes( content, '不能包含换行或 NUL 字符', `${script} 必须拒绝控制字符参数。`, ); assertIncludes(content, '不能是文件系统根目录', `${script} 必须拒绝根路径。`); } const enableContent = readFileSync(ENABLE_SCRIPT, 'utf8'); assertIncludes( enableContent, 'check-pingora-canary-live.mjs', 'enable 脚本必须默认运行 realpath live smoke。', ); assertIncludes( enableContent, '恢复写入前配置', 'enable 脚本失败时必须恢复写入前配置。', ); const disableContent = readFileSync(DISABLE_SCRIPT, 'utf8'); assertIncludes( disableContent, '恢复删除前配置', 'disable 脚本失败时必须恢复删除前配置。', ); } function assertDryRunDoesNotWrite() { const fixture = createFixture('dry-run'); const targetPath = path.join(fixture.nginxDir, 'zz-genarrative-pingora-realpath-canary.conf'); const result = runScript(ENABLE_SCRIPT, [ '--probe-token', 'dry-run-token', '--host', 'dev.genarrative.world', '--template-path', fixture.templatePath, '--target-path', targetPath, '--nginx-binary', fixture.nginxBinary, '--systemctl-binary', fixture.systemctlBinary, '--live-script', fixture.liveScript, ]); assertStatus(result, 0, 'enable dry-run 应成功。'); if (existsSync(targetPath)) { failures.push('enable dry-run 不应写入目标 Nginx 配置。'); } assertIncludes(result.stdout, 'dry-run', 'enable dry-run 应输出 dry-run 提示。'); } function assertApplyWritesRenderedConfigAndRunsLive() { const fixture = createFixture('apply-ok'); const targetPath = path.join(fixture.nginxDir, 'zz-genarrative-pingora-realpath-canary.conf'); const result = runScript(ENABLE_SCRIPT, [ '--apply', '--probe-token', 'apply-real-token', '--host', 'dev.genarrative.world', '--base-url', 'http://127.0.0.1:18083', '--template-path', fixture.templatePath, '--target-path', targetPath, '--nginx-binary', fixture.nginxBinary, '--systemctl-binary', fixture.systemctlBinary, '--live-script', fixture.liveScript, '--no-status', ]); assertStatus(result, 0, 'enable apply 应成功。'); const rendered = readFileSync(targetPath, 'utf8'); assertIncludes(rendered, '"apply-real-token"', 'enable apply 应替换 probe token。'); if (rendered.includes('__GENARRATIVE_PINGORA_PROBE_TOKEN__')) { failures.push('enable apply 后目标配置不应保留 probe token 占位符。'); } const calls = readFileSync(fixture.callsPath, 'utf8'); assertIncludes(calls, 'nginx -t', 'enable apply 必须执行 nginx -t。'); assertIncludes(calls, 'systemctl reload nginx.service', 'enable apply 必须 reload nginx。'); assertIncludes( calls, 'live --realpath --base-url http://127.0.0.1:18083 --host dev.genarrative.world', 'enable apply 必须执行 realpath live smoke。', ); } function assertEnableRollsBackWhenLiveFails() { const fixture = createFixture('enable-live-fails', { liveExitCode: 7 }); const targetPath = path.join(fixture.nginxDir, 'zz-genarrative-pingora-realpath-canary.conf'); writeFileSync(targetPath, 'previous-config\n', 'utf8'); const result = runScript( ENABLE_SCRIPT, [ '--apply', '--probe-token', 'rollback-token', '--host', 'dev.genarrative.world', '--template-path', fixture.templatePath, '--target-path', targetPath, '--nginx-binary', fixture.nginxBinary, '--systemctl-binary', fixture.systemctlBinary, '--live-script', fixture.liveScript, '--no-status', ], fixture.env, ); if (result.status === 0) { failures.push('realpath live smoke 失败时 enable apply 必须失败。'); } const restored = readFileSync(targetPath, 'utf8'); if (restored !== 'previous-config\n') { failures.push('realpath live smoke 失败时 enable apply 必须恢复旧配置。'); } assertIncludes( result.stderr, '恢复写入前配置', 'enable live 失败时必须说明已恢复写入前配置。', ); } function assertDisableRemovesConfig() { const fixture = createFixture('disable-ok'); const targetPath = path.join(fixture.nginxDir, 'zz-genarrative-pingora-realpath-canary.conf'); writeFileSync(targetPath, 'enabled-config\n', 'utf8'); const result = runScript( DISABLE_SCRIPT, [ '--apply', '--target-path', targetPath, '--nginx-binary', fixture.nginxBinary, '--systemctl-binary', fixture.systemctlBinary, '--no-status', ], fixture.env, ); assertStatus(result, 0, 'disable apply 应成功。'); if (existsSync(targetPath)) { failures.push('disable apply 成功后必须删除 realpath canary 配置。'); } const calls = readFileSync(fixture.callsPath, 'utf8'); assertIncludes(calls, 'nginx -t', 'disable apply 必须执行 nginx -t。'); assertIncludes(calls, 'systemctl reload nginx.service', 'disable apply 必须 reload nginx。'); } function assertDisableRollsBackWhenReloadFails() { const fixture = createFixture('disable-reload-fails', { reloadExitCode: 9 }); const targetPath = path.join(fixture.nginxDir, 'zz-genarrative-pingora-realpath-canary.conf'); writeFileSync(targetPath, 'enabled-config\n', 'utf8'); const result = runScript(DISABLE_SCRIPT, [ '--apply', '--target-path', targetPath, '--nginx-binary', fixture.nginxBinary, '--systemctl-binary', fixture.systemctlBinary, '--no-status', ]); if (result.status === 0) { failures.push('reload 失败时 disable apply 必须失败。'); } const restored = readFileSync(targetPath, 'utf8'); if (restored !== 'enabled-config\n') { failures.push('reload 失败时 disable apply 必须恢复删除前配置。'); } assertIncludes( result.stderr, '恢复删除前配置', 'disable reload 失败时必须说明已恢复删除前配置。', ); } function assertRejectsUnsafeInputs() { const fixture = createFixture('unsafe-inputs'); const targetPath = path.join(fixture.nginxDir, 'zz-genarrative-pingora-realpath-canary.conf'); const cases = [ { name: 'missing token', script: ENABLE_SCRIPT, args: [ '--apply', '--host', 'dev.genarrative.world', '--template-path', fixture.templatePath, '--target-path', targetPath, '--nginx-binary', fixture.nginxBinary, '--systemctl-binary', fixture.systemctlBinary, '--live-script', fixture.liveScript, ], expected: '--apply 必须提供 --probe-token', }, { name: 'url host', script: ENABLE_SCRIPT, args: [ '--probe-token', 'unsafe-token', '--host', 'https://dev.genarrative.world', '--template-path', fixture.templatePath, '--target-path', targetPath, '--nginx-binary', fixture.nginxBinary, '--systemctl-binary', fixture.systemctlBinary, '--live-script', fixture.liveScript, ], expected: '不能是 URL', }, { name: 'relative target', script: DISABLE_SCRIPT, args: ['--target-path', 'relative.conf'], expected: '--target-path 必须是绝对路径', }, { name: 'root target', script: DISABLE_SCRIPT, args: ['--target-path', '/'], expected: '--target-path 不能是文件系统根目录', }, ]; for (const item of cases) { const result = runScript(item.script, item.args); if (result.status === 0) { failures.push(`${item.name}: 不安全参数必须失败。`); } assertIncludes( `${result.stderr}\n${result.stdout}`, item.expected, `${item.name}: 应输出明确错误。`, ); } } function createFixture(name, options = {}) { const root = path.join(tmpRoot, name); const nginxDir = path.join(root, 'nginx-conf'); mkdirSync(nginxDir, { recursive: true }); const callsPath = path.join(root, 'calls.log'); const templatePath = path.join(root, 'genarrative-pingora-realpath-canary.conf'); const nginxBinary = path.join(root, 'nginx'); const systemctlBinary = path.join(root, 'systemctl'); const liveScript = path.join(root, 'live.mjs'); writeFileSync( templatePath, [ 'server {', ' listen 127.0.0.1:18083;', ' access_log /var/log/nginx/genarrative-pingora-realpath-canary.access.log genarrative_upstream;', ' location = /__genarrative_pingora_realpath_canary/healthz {', ' proxy_set_header X-Genarrative-Pingora-Probe "__GENARRATIVE_PINGORA_PROBE_TOKEN__";', ' proxy_pass http://127.0.0.1:18081/__genarrative_pingora/healthz;', ' }', '}', '', ].join('\n'), 'utf8', ); writeFileSync( nginxBinary, [ '#!/usr/bin/env bash', `echo "nginx $*" >> ${shellQuote(callsPath)}`, 'if [[ "${GENARRATIVE_FAKE_NGINX_T_EXIT:-0}" != "0" ]]; then exit "${GENARRATIVE_FAKE_NGINX_T_EXIT}"; fi', 'exit 0', '', ].join('\n'), 'utf8', ); writeFileSync( systemctlBinary, [ '#!/usr/bin/env bash', `echo "systemctl $*" >> ${shellQuote(callsPath)}`, `reload_exit="\${GENARRATIVE_FAKE_RELOAD_EXIT:-${Number(options.reloadExitCode || 0)}}"`, 'if [[ "$1" == "reload" && "${reload_exit}" != "0" ]]; then exit "${reload_exit}"; fi', 'exit 0', '', ].join('\n'), 'utf8', ); writeFileSync( liveScript, [ '#!/usr/bin/env node', "import { appendFileSync } from 'node:fs';", `appendFileSync(${JSON.stringify(callsPath)}, 'live ' + process.argv.slice(2).join(' ') + '\\n');`, `process.exit(Number(process.env.GENARRATIVE_FAKE_LIVE_EXIT || ${Number(options.liveExitCode || 0)}));`, '', ].join('\n'), 'utf8', ); chmodExecutable(nginxBinary); chmodExecutable(systemctlBinary); chmodExecutable(liveScript); return { root, nginxDir, callsPath, templatePath, nginxBinary, systemctlBinary, liveScript, env: { GENARRATIVE_FAKE_RELOAD_EXIT: String(options.reloadExitCode || 0), GENARRATIVE_FAKE_LIVE_EXIT: String(options.liveExitCode || 0), }, }; } function runScript(script, args, env = {}) { return spawnSync('bash', [script, ...args], { cwd: process.cwd(), env: { ...process.env, ...env }, encoding: 'utf8', }); } function chmodExecutable(file) { chmodSync(file, 0o755); } function shellQuote(value) { return `'${String(value).replaceAll("'", "'\\''")}'`; } function assertStatus(result, expected, message) { if (result.status !== expected) { failures.push( `${message} 实际退出码 ${result.status}。\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, ); } } function assertIncludes(content, needle, message) { if (!content.includes(needle)) { failures.push(`${message} 缺少 ${needle}`); } }