Files
Genarrative/scripts/ops/pingora-cutover-evidence-verify.mjs
T
kdletters a7711d2dc3 引入Pingora独立网关正式化链路
新增 pingora-gateway 独立二进制 crate,覆盖路由、静态资源、压缩、接流保护、TLS 直连和访问日志能力。

新增 Nginx canary、realpath canary、direct preflight、direct live、direct enable 和 rollback 脚本。

新增 Pingora 切流证据包、命令证据、manifest 验真、根目录总审计和 release readiness 聚合门禁。

完善 API release、Jenkins、systemd、health patrol、生产部署和发布包自包含校验。

更新 Pingora 试点文档、Nginx README 与 Hermes 共享记忆。
2026-06-17 20:16:51 +08:00

342 lines
10 KiB
JavaScript

#!/usr/bin/env node
import { createHash } from 'node:crypto';
import { constants as fsConstants } from 'node:fs';
import { access, lstat, readdir, readFile, stat } from 'node:fs/promises';
import path from 'node:path';
const config = parseArgs(process.argv.slice(2));
const manifestPath = await resolveManifestPath(config);
const bundleDir = path.dirname(manifestPath);
await validateBundleDir(bundleDir);
const manifest = await readManifest(manifestPath);
validateManifestSchemaVersion(manifest);
const { verifications, registeredFileNames } =
await verifyManifestFiles(bundleDir, manifest);
const extraFiles = config.allowExtraFiles
? []
: await findExtraEvidenceEntries(bundleDir, registeredFileNames);
const summaryVerification = config.requireSummaryOk
? verifySummaryOk(manifest)
: null;
const failed = verifications.filter((item) => item.status !== 'OK');
const failedCount =
failed.length +
extraFiles.length +
(summaryVerification && summaryVerification.status !== 'OK' ? 1 : 0);
console.log(
`${JSON.stringify(
{
ok: failedCount === 0,
manifestPath,
bundleDir,
checkedCount: verifications.length,
failedCount,
allowExtraFiles: config.allowExtraFiles,
requireSummaryOk: config.requireSummaryOk,
summary: summaryVerification,
files: verifications,
extraFiles,
},
null,
2,
)}\n`,
);
if (failedCount > 0) {
process.exit(1);
}
function parseArgs(argv) {
const result = {
manifestPath: '',
bundleDir: '',
allowExtraFiles: false,
requireSummaryOk: 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 '--manifest':
result.manifestPath = requireValue(argv, ++index, arg);
break;
case '--bundle-dir':
result.bundleDir = requireValue(argv, ++index, arg);
break;
case '--allow-extra-files':
result.allowExtraFiles = true;
break;
case '--require-summary-ok':
result.requireSummaryOk = true;
break;
default:
throw new Error(`未知参数: ${arg}`);
}
}
if (result.manifestPath && result.bundleDir) {
throw new Error('--manifest 和 --bundle-dir 只能二选一。');
}
if (!result.manifestPath && !result.bundleDir) {
throw new Error('必须提供 --manifest 或 --bundle-dir。');
}
for (const [label, value] of [
['--manifest', result.manifestPath],
['--bundle-dir', result.bundleDir],
]) {
if (!value) {
continue;
}
if (!path.isAbsolute(value)) {
throw new Error(`${label} 必须是绝对路径。`);
}
if (isFilesystemRootPath(value)) {
throw new Error(`${label} 不能是文件系统根目录。`);
}
validateNoControlCharacters(value, label);
}
return result;
}
function printUsage() {
console.log(`Usage:
node scripts/ops/pingora-cutover-evidence-verify.mjs --manifest <path/to/manifest.json>
node scripts/ops/pingora-cutover-evidence-verify.mjs --bundle-dir <path>
Options:
--allow-extra-files 允许证据目录中存在 manifest.files 未登记的额外条目;默认拒绝,正式切换归档不应使用。
--require-summary-ok 要求 manifest.summary.status 必须是 OK;正式切换 runbook 的即时验真步骤必须使用。
该脚本只读校验 Pingora 直连切换证据目录中的 schemaVersion=1 manifest.files 元数据,确认每个已登记文件的 path / sizeBytes / sha256 与实际文件一致,并默认拒绝证据目录中混入未登记文件、目录或符号链接;null 文件条目会跳过。正式切换 runbook 还会用 --require-summary-ok 把 manifest.summary.status 非 OK 的证据作为失败处理。脚本不会修改证据目录、不会 reload systemd、不会访问 Nginx 或 Pingora。
`);
}
function requireValue(argv, index, flag) {
const value = argv[index];
if (value === undefined || value.startsWith('--')) {
throw new Error(`${flag} 缺少参数值`);
}
return value;
}
async function resolveManifestPath(config) {
if (config.manifestPath) {
return path.resolve(config.manifestPath);
}
return path.join(path.resolve(config.bundleDir), 'manifest.json');
}
async function validateBundleDir(bundleDir) {
const resolved = path.resolve(bundleDir);
if (isFilesystemRootPath(resolved)) {
throw new Error('证据目录不能是文件系统根目录。');
}
const stats = await lstat(resolved);
if (stats.isSymbolicLink()) {
throw new Error(`证据目录不能是符号链接: ${resolved}`);
}
if (!stats.isDirectory()) {
throw new Error(`证据目录必须是目录: ${resolved}`);
}
await access(resolved, fsConstants.R_OK | fsConstants.X_OK);
}
async function readManifest(manifestPath) {
const stats = await lstat(manifestPath);
if (stats.isSymbolicLink()) {
throw new Error(`manifest 不能是符号链接: ${manifestPath}`);
}
if (!stats.isFile()) {
throw new Error(`manifest 必须是文件: ${manifestPath}`);
}
let content;
try {
content = await readFile(manifestPath, 'utf8');
} catch (error) {
throw new Error(`读取 manifest 失败: ${error.message}`);
}
try {
return JSON.parse(content);
} catch (error) {
throw new Error(`manifest 不是合法 JSON: ${error.message}`);
}
}
function validateManifestSchemaVersion(manifest) {
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
throw new Error('manifest 顶层必须是 JSON object。');
}
if (manifest.schemaVersion !== 1) {
throw new Error('manifest.schemaVersion 必须是 1。');
}
}
async function verifyManifestFiles(bundleDir, manifest) {
if (!manifest.files || typeof manifest.files !== 'object') {
throw new Error('manifest 缺少 files object。');
}
const verifications = [];
const registeredFileNames = new Set(['manifest.json']);
for (const [key, value] of Object.entries(manifest.files)) {
if (value === null) {
continue;
}
if (typeof value === 'string') {
if (key === 'manifest' && value === 'manifest.json') {
continue;
}
throw new Error(
`manifest.files.${key} 必须使用 { path, sizeBytes, sha256 } 元数据对象。`,
);
}
validateMetadata(key, value);
registeredFileNames.add(value.path);
verifications.push(await verifyMetadata(bundleDir, key, value));
}
return { verifications, registeredFileNames };
}
function verifySummaryOk(manifest) {
const status = manifest?.summary?.status;
if (status === 'OK') {
return {
status: 'OK',
expectedStatus: 'OK',
actualStatus: status,
reason: null,
};
}
return {
status: 'MISMATCH',
expectedStatus: 'OK',
actualStatus: status ?? null,
reason: 'manifest.summary.status is not OK',
};
}
async function findExtraEvidenceEntries(bundleDir, registeredFileNames) {
const entries = await readdir(bundleDir, { withFileTypes: true });
const extras = [];
for (const entry of entries) {
if (registeredFileNames.has(entry.name)) {
continue;
}
extras.push({
path: entry.name,
status: 'MISMATCH',
reason: entry.isSymbolicLink()
? 'extra symlink not registered in manifest.files'
: entry.isDirectory()
? 'extra directory not registered in manifest.files'
: 'extra file not registered in manifest.files',
});
}
return extras.sort((left, right) => left.path.localeCompare(right.path));
}
function validateMetadata(key, value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`manifest.files.${key} 必须是元数据对象或 null。`);
}
if (typeof value.path !== 'string' || value.path.length === 0) {
throw new Error(`manifest.files.${key}.path 必须是非空字符串。`);
}
if (
/[\0\r\n]/u.test(value.path) ||
path.isAbsolute(value.path) ||
value.path.includes('/') ||
value.path.includes('\\') ||
value.path === '.' ||
value.path === '..' ||
value.path.includes('..')
) {
throw new Error(
`manifest.files.${key}.path 只能是证据目录内的安全文件名: ${value.path}`,
);
}
if (!Number.isSafeInteger(value.sizeBytes) || value.sizeBytes < 0) {
throw new Error(`manifest.files.${key}.sizeBytes 必须是非负安全整数。`);
}
if (typeof value.sha256 !== 'string' || !/^[0-9a-f]{64}$/u.test(value.sha256)) {
throw new Error(`manifest.files.${key}.sha256 必须是 64 位小写十六进制。`);
}
}
async function verifyMetadata(bundleDir, key, metadata) {
const filePath = path.join(bundleDir, metadata.path);
const expectedPath = path.resolve(bundleDir, metadata.path);
if (path.dirname(expectedPath) !== path.resolve(bundleDir)) {
throw new Error(`manifest.files.${key}.path 超出证据目录: ${metadata.path}`);
}
let stats;
let content;
try {
const linkStats = await lstat(filePath);
if (linkStats.isSymbolicLink()) {
return {
key,
path: metadata.path,
status: 'MISMATCH',
reason: 'file is symlink',
};
}
if (!linkStats.isFile()) {
return {
key,
path: metadata.path,
status: 'MISMATCH',
reason: 'not a regular file',
};
}
[stats, content] = await Promise.all([stat(filePath), readFile(filePath)]);
} catch (error) {
return {
key,
path: metadata.path,
status: 'MISMATCH',
reason: error?.code === 'ENOENT' ? 'missing file' : error.message,
};
}
const actualSha256 = createHash('sha256').update(content).digest('hex');
const sizeMatches = stats.size === metadata.sizeBytes;
const sha256Matches = actualSha256 === metadata.sha256;
return {
key,
path: metadata.path,
status: sizeMatches && sha256Matches ? 'OK' : 'MISMATCH',
expectedSizeBytes: metadata.sizeBytes,
actualSizeBytes: stats.size,
expectedSha256: metadata.sha256,
actualSha256,
reason:
sizeMatches && sha256Matches
? null
: [
...(sizeMatches ? [] : ['size mismatch']),
...(sha256Matches ? [] : ['sha256 mismatch']),
].join(', '),
};
}
function isFilesystemRootPath(value) {
const resolved = path.resolve(String(value));
return resolved === path.parse(resolved).root;
}
function validateNoControlCharacters(value, label) {
if (/[\0\r\n]/u.test(String(value))) {
throw new Error(`${label} 不能包含换行或 NUL 字符。`);
}
}