e3258f6f1d
新增 Pingora shadow env 回切脚本与对应检查。 补齐直连证据包时间线和 cutoverRunId 审计门禁。 支持 Gitea Host 透传并更新直连多域名文档。 修复百分号编码静态图标路径并补 smoke 覆盖。 更新生产发布与运维护栏对 Pingora 发布包的校验。
353 lines
11 KiB
JavaScript
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}。`);
|
|
}
|
|
}
|