建立生产密钥恢复合同(08-01-production-secret-recovery-env-externalization)
这个提交包含在:
@@ -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(', ')}`);
|
||||
|
||||
在新工单中引用
屏蔽一个用户