建立生产密钥恢复合同(08-01-production-secret-recovery-env-externalization)

这个提交包含在:
2026-08-01 19:18:54 +08:00
父节点 07de198b51
当前提交 ca24479a44
共修改 11 个文件,包含 727 行新增和 8 行删除
+99
查看文件
@@ -8,6 +8,10 @@ const configPath = path.join(rootDir, 'server/src/config.js');
const envExamplePath = path.resolve(
process.env.ENV_CONTRACT_ENV_EXAMPLE_PATH || path.join(rootDir, 'deploy/trialrun/.env.example'),
);
const recoveryClassificationPath = path.resolve(
process.env.ENV_CONTRACT_RECOVERY_CLASSIFICATION_PATH
|| path.join(rootDir, 'deploy/trialrun/env-recovery-classification.json'),
);
const composePath = path.resolve(
process.env.ENV_CONTRACT_COMPOSE_PATH || path.join(rootDir, 'deploy/trialrun/docker-compose.yml'),
);
@@ -57,6 +61,13 @@ const requiredBuildArgs = {
worker: ['BUILD_SHA', 'VITE_BUILD_SHA'],
init: ['BUILD_SHA', 'VITE_BUILD_SHA'],
};
const recoveryCategories = [
'template_rebuild',
'external_reissue',
'direct_rotate',
'downtime_reset',
'preserve_original',
];
function readFile(filePath) {
return fs.readFileSync(filePath, 'utf8');
@@ -84,6 +95,86 @@ function extractEnvExampleKeys(content) {
return keys;
}
function validateRecoveryClassification(content, envKeys) {
const validationErrors = [];
let parsed;
try {
parsed = JSON.parse(content);
} catch {
return { errors: ['环境恢复分类清单必须是有效 JSON'], keyCount: 0 };
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return { errors: ['环境恢复分类清单顶层必须是对象'], keyCount: 0 };
}
const topLevelKeys = Object.keys(parsed).sort();
if (topLevelKeys.join(',') !== 'categories,version') {
validationErrors.push('环境恢复分类清单顶层只允许 version 和 categories');
}
if (parsed.version !== 1) {
validationErrors.push('环境恢复分类清单 version 必须为 1');
}
if (!parsed.categories || typeof parsed.categories !== 'object' || Array.isArray(parsed.categories)) {
return { errors: [...validationErrors, '环境恢复分类清单 categories 必须是对象'], keyCount: 0 };
}
const actualCategories = Object.keys(parsed.categories).sort();
const expectedCategories = [...recoveryCategories].sort();
for (const category of expectedCategories) {
if (!actualCategories.includes(category)) {
validationErrors.push(`环境恢复分类清单缺少类别 ${category}`);
}
}
for (const category of actualCategories) {
if (!expectedCategories.includes(category)) {
validationErrors.push(`环境恢复分类清单存在未知类别 ${category}`);
}
}
const occurrences = new Map();
for (const category of expectedCategories) {
const entries = parsed.categories[category];
if (!Array.isArray(entries)) {
validationErrors.push(`环境恢复类别 ${category} 必须是键名数组`);
continue;
}
for (const entry of entries) {
if (typeof entry !== 'string' || !/^[A-Z][A-Z0-9_]*$/.test(entry)) {
validationErrors.push(`环境恢复类别 ${category} 含非法键名或疑似值`);
continue;
}
const categories = occurrences.get(entry) ?? [];
categories.push(category);
occurrences.set(entry, categories);
}
}
for (const [key, categories] of occurrences) {
if (categories.length !== 1) {
validationErrors.push(`环境恢复键 ${key} 重复分类: ${categories.join(', ')}`);
}
if (!envKeys.has(key)) {
validationErrors.push(`环境恢复分类包含 .env.example 外键 ${key}`);
}
}
for (const key of envKeys) {
if (!occurrences.has(key)) {
validationErrors.push(`环境恢复分类缺少 ${key}`);
}
}
const preserveOriginal = parsed.categories.preserve_original;
if (!Array.isArray(preserveOriginal) || !preserveOriginal.includes('FIELD_ENCRYPTION_KEY')) {
validationErrors.push('FIELD_ENCRYPTION_KEY 必须归入 preserve_original');
}
const downtimeReset = parsed.categories.downtime_reset;
if (!Array.isArray(downtimeReset) || !downtimeReset.includes('POSTGRES_PASSWORD')) {
validationErrors.push('POSTGRES_PASSWORD 必须归入 downtime_reset');
}
return { errors: validationErrors, keyCount: occurrences.size };
}
function extractServiceBlock(composeContent, serviceName) {
const lines = composeContent.split(/\r?\n/);
let capture = false;
@@ -154,6 +245,7 @@ function fail(message, errors) {
const configContent = readFile(configPath);
const envExampleContent = readFile(envExamplePath);
const recoveryClassificationContent = readFile(recoveryClassificationPath);
const composeContent = readFile(composePath);
const runbookContent = readFile(runbookPath);
const checklistContent = readFile(checklistPath);
@@ -177,6 +269,12 @@ const configKeys = extractConfigEnvKeys(configContent);
const envExampleKeys = extractEnvExampleKeys(envExampleContent);
const errors = [];
const recoveryClassification = validateRecoveryClassification(
recoveryClassificationContent,
envExampleKeys,
);
errors.push(...recoveryClassification.errors);
for (const key of configKeys) {
if (!deploymentInjectedEnvKeys.has(key) && !envExampleKeys.has(key)) {
errors.push(`.env.example 缺少 ${key}`);
@@ -538,4 +636,5 @@ if (errors.length) {
console.log('[R26][PASS] 部署环境契约校验通过');
console.log(`- config.js env 键数: ${configKeys.length}`);
console.log(`- .env.example env 键数: ${envExampleKeys.size}`);
console.log(`- 环境恢复分类键数: ${recoveryClassification.keyCount}`);
console.log(`- 已检查服务: ${requiredServices.join(', ')}`);