Files
Genarrative/scripts/check-pingora-gateway-env-shadow-switch.mjs
T
kdletters e3258f6f1d 完善 Pingora 直连切换门禁
新增 Pingora shadow env 回切脚本与对应检查。

补齐直连证据包时间线和 cutoverRunId 审计门禁。

支持 Gitea Host 透传并更新直连多域名文档。

修复百分号编码静态图标路径并补 smoke 覆盖。

更新生产发布与运维护栏对 Pingora 发布包的校验。
2026-06-18 21:10:45 +08:00

353 lines
11 KiB
JavaScript

#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import {
chmodSync,
existsSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
const SWITCH_SCRIPT = 'scripts/deploy/pingora-gateway-env-shadow-switch.mjs';
const failures = [];
const tmpRoot = mkdtempSync(
path.join(tmpdir(), 'genarrative-pingora-gateway-env-shadow-switch-'),
);
try {
main();
} finally {
rmSync(tmpRoot, { recursive: true, force: true });
}
if (failures.length > 0) {
console.error('[check:pingora-gateway-env-shadow-switch] FAILED');
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
console.log('[check:pingora-gateway-env-shadow-switch] OK');
function main() {
assertScriptShape();
assertDryRunDoesNotModifyEnv();
assertApplyRestoresShadowAndPreservesOtherKeys();
assertApplyPreservesEnvFileMode();
assertMissingManagedKeysAreAppended();
assertDuplicateManagedKeysFail();
assertRejectsRelativeAndRootEnvFile();
assertRejectsSymlinkEnvFileBeforeWrite();
assertRejectsControlCharacterEnvFile();
}
function assertScriptShape() {
const content = readFileSync(SWITCH_SCRIPT, 'utf8');
assertIncludes(content, '--apply', '切换脚本必须显式要求 --apply 才写 env。');
assertIncludes(
content,
'当前是 dry-run',
'切换脚本必须在 dry-run 中明确不会写 env。',
);
assertIncludes(
content,
'GENARRATIVE_PINGORA_GATEWAY_LISTEN: \'127.0.0.1:18081\'',
'切换脚本必须固定恢复 Pingora shadow 高端口监听。',
);
assertIncludes(
content,
'GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN: \'\'',
'切换脚本必须清空 TLS 低端口监听。',
);
assertIncludes(
content,
'GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN: \'\'',
'切换脚本必须清空 HTTP redirect 低端口监听。',
);
assertIncludes(
content,
'GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE: \'\'',
'切换脚本必须清空 TLS 证书链路径,避免无 TLS_LISTEN 但残留 cert。',
);
assertIncludes(
content,
'GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE: \'\'',
'切换脚本必须清空 TLS 私钥路径,避免无 TLS_LISTEN 但残留 key。',
);
assertIncludes(
content,
'assertShadowEnv(checkFile);',
'切换脚本必须先用临时目标 env 复核 shadow 口径。',
);
assertIncludes(
content,
'writeEnvFileAtomically(config.envFile, nextText);',
'切换脚本通过复核后才可原子写入真实 env。',
);
assertIncludes(
content,
'--env-file 不能是符号链接',
'apply 写入真实 env 前必须拒绝符号链接目标。',
);
assertIncludes(
content,
'DRY_RUN_ENV_FILE_MODE = 0o600',
'临时复核 env 文件权限必须固定为 0600。',
);
assertIncludes(
content,
'chownSync(tempFile, currentStat.uid, currentStat.gid);',
'真实 env 原子替换必须保留原文件 owner/group。',
);
}
function assertDryRunDoesNotModifyEnv() {
const envFile = writeEnv('dry-run.env', {
GENARRATIVE_PINGORA_GATEWAY_LISTEN: '0.0.0.0:443',
GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN: '0.0.0.0:443',
GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN: '0.0.0.0:80',
GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE: '/etc/genarrative/pingora-tls/example/fullchain.pem',
GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE: '/etc/genarrative/pingora-tls/example/privkey.pem',
});
const before = readFileSync(envFile, 'utf8');
const result = runSwitch(['--env-file', envFile]);
assertStatus(result, 0, 'dry-run 应成功。');
assertEqual(
readFileSync(envFile, 'utf8'),
before,
'dry-run 不应修改真实 Pingora gateway env。',
);
assertIncludes(
result.stdout,
'当前是 dry-run',
'dry-run 输出必须明确不会写入 env。',
);
}
function assertApplyRestoresShadowAndPreservesOtherKeys() {
const envFile = writeEnv('apply.env', {
GENARRATIVE_PINGORA_GATEWAY_UPSTREAM_API: 'http://127.0.0.1:8082',
GENARRATIVE_PINGORA_GATEWAY_LISTEN: '0.0.0.0:443',
GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN: '0.0.0.0:443',
GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN: '0.0.0.0:80',
GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE: '/etc/genarrative/pingora-tls/example/fullchain.pem',
GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE: '/etc/genarrative/pingora-tls/example/privkey.pem',
GENARRATIVE_PINGORA_GATEWAY_PROTECTION_ENABLED: 'true',
});
const result = runSwitch(['--env-file', envFile, '--apply']);
const content = readFileSync(envFile, 'utf8');
assertStatus(result, 0, 'apply 应成功。');
assertIncludes(
content,
'GENARRATIVE_PINGORA_GATEWAY_LISTEN=127.0.0.1:18081',
'apply 必须恢复 shadow 高端口监听。',
);
assertIncludes(
content,
'GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN=',
'apply 必须清空 TLS 低端口监听。',
);
assertIncludes(
content,
'GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN=',
'apply 必须清空 HTTP redirect 低端口监听。',
);
assertIncludes(
content,
'GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE=',
'apply 必须清空 TLS 证书链路径。',
);
assertIncludes(
content,
'GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE=',
'apply 必须清空 TLS 私钥路径。',
);
assertIncludes(
content,
'GENARRATIVE_PINGORA_GATEWAY_UPSTREAM_API=http://127.0.0.1:8082',
'apply 不应修改其它 Pingora gateway env。',
);
assertIncludes(
content,
'GENARRATIVE_PINGORA_GATEWAY_PROTECTION_ENABLED=true',
'apply 不应修改接流保护配置。',
);
}
function assertApplyPreservesEnvFileMode() {
const envFile = writeEnv('mode.env', {
GENARRATIVE_PINGORA_GATEWAY_LISTEN: '0.0.0.0:443',
GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN: '0.0.0.0:443',
GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN: '0.0.0.0:80',
GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE: '/etc/genarrative/pingora-tls/example/fullchain.pem',
GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE: '/etc/genarrative/pingora-tls/example/privkey.pem',
});
chmodSync(envFile, 0o640);
const before = statSync(envFile);
const result = runSwitch(['--env-file', envFile, '--apply']);
const after = statSync(envFile);
assertStatus(result, 0, 'apply 保留权限 smoke 应成功。');
assertEqual(
after.mode & 0o777,
before.mode & 0o777,
'apply 原子替换必须保留 env 文件权限。',
);
}
function assertMissingManagedKeysAreAppended() {
const envFile = writeEnv('append.env', {
GENARRATIVE_PINGORA_GATEWAY_UPSTREAM_API: 'http://127.0.0.1:8082',
});
const result = runSwitch(['--env-file', envFile, '--apply']);
const content = readFileSync(envFile, 'utf8');
assertStatus(result, 0, '缺失目标键时 apply 应成功。');
assertIncludes(
content,
'GENARRATIVE_PINGORA_GATEWAY_LISTEN=127.0.0.1:18081',
'缺失 LISTEN 时必须追加 shadow 默认值。',
);
assertIncludes(
content,
'GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN=',
'缺失 TLS_LISTEN 时必须追加空值。',
);
assertIncludes(
content,
'GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN=',
'缺失 HTTP_REDIRECT_LISTEN 时必须追加空值。',
);
assertIncludes(
content,
'GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE=',
'缺失 TLS_CERT_FILE 时必须追加空值。',
);
assertIncludes(
content,
'GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE=',
'缺失 TLS_KEY_FILE 时必须追加空值。',
);
}
function assertDuplicateManagedKeysFail() {
const envFile = path.join(tmpRoot, 'duplicate.env');
writeFileSync(
envFile,
[
'GENARRATIVE_PINGORA_GATEWAY_LISTEN=0.0.0.0:443',
'GENARRATIVE_PINGORA_GATEWAY_LISTEN=127.0.0.1:18081',
'',
].join('\n'),
'utf8',
);
const result = runSwitch(['--env-file', envFile]);
assertStatus(result, 1, '重复目标键必须失败。');
assertIncludes(
result.stderr,
'pingora gateway env 中存在重复配置',
'重复目标键失败时必须说明具体原因。',
);
}
function assertRejectsRelativeAndRootEnvFile() {
const relative = runSwitch(['--env-file', 'relative.env']);
assertStatus(relative, 1, '相对 env 路径必须失败。');
assertIncludes(
relative.stderr,
'--env-file 必须是绝对路径',
'相对 env 路径失败时必须说明原因。',
);
const root = runSwitch(['--env-file', '/']);
assertStatus(root, 1, '文件系统根目录 env 路径必须失败。');
assertIncludes(
root.stderr,
'--env-file 不能是文件系统根目录',
'根目录 env 路径失败时必须说明原因。',
);
}
function assertRejectsSymlinkEnvFileBeforeWrite() {
const target = writeEnv('symlink-target.env', {
GENARRATIVE_PINGORA_GATEWAY_LISTEN: '0.0.0.0:443',
});
const link = path.join(tmpRoot, 'symlink.env');
symlinkSync(target, link);
const before = readFileSync(target, 'utf8');
const result = runSwitch(['--env-file', link, '--apply']);
assertStatus(result, 1, '符号链接 env 必须失败。');
assertIncludes(
result.stderr,
'--env-file 不能是符号链接',
'符号链接 env 失败时必须说明原因。',
);
assertEqual(
readFileSync(target, 'utf8'),
before,
'符号链接 env 被拒绝后不应写真实目标文件。',
);
}
function assertRejectsControlCharacterEnvFile() {
const result = spawnSync(
process.execPath,
['--', SWITCH_SCRIPT, '--env-file', `${tmpRoot}/bad\n.env`],
{
cwd: process.cwd(),
encoding: 'utf8',
},
);
assertStatus(result, 1, '控制字符 env 路径必须失败。');
assertIncludes(
result.stderr,
'--env-file 不能包含换行或 NUL 字符',
'控制字符 env 路径失败时必须说明原因。',
);
}
function runSwitch(args) {
return spawnSync(process.execPath, ['--', SWITCH_SCRIPT, ...args], {
cwd: process.cwd(),
encoding: 'utf8',
});
}
function writeEnv(fileName, values) {
const filePath = path.join(tmpRoot, fileName);
const lines = Object.entries(values).map(([key, value]) => `${key}=${value}`);
writeFileSync(filePath, `${lines.join('\n')}\n`, 'utf8');
return filePath;
}
function assertStatus(result, expected, message) {
if ((result.status ?? 0) !== expected) {
failures.push(
`${message} 实际退出码 ${result.status}。stdout=${result.stdout || '<empty>'} stderr=${result.stderr || '<empty>'}`,
);
}
}
function assertIncludes(value, expected, message) {
if (!String(value || '').includes(expected)) {
failures.push(`${message} 缺少 ${expected}。`);
}
}
function assertEqual(actual, expected, message) {
if (actual !== expected) {
failures.push(`${message} 实际 ${actual},预期 ${expected}。`);
}
}