a7711d2dc3
新增 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 共享记忆。
342 lines
10 KiB
JavaScript
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 字符。`);
|
|
}
|
|
}
|