R26-1 R26-2 R26-3: 修复 env 注入并补部署校验与 smoke
这个提交包含在:
+154
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const rootDir = path.resolve(__dirname, '..', '..');
|
||||
const configPath = path.join(rootDir, 'server/src/config.js');
|
||||
const envExamplePath = path.join(rootDir, 'deploy/trialrun/.env.example');
|
||||
const composePath = path.join(rootDir, 'deploy/trialrun/docker-compose.yml');
|
||||
|
||||
const requiredServices = ['server', 'worker', 'init'];
|
||||
const allowedOverrides = {
|
||||
server: ['NODE_ENV'],
|
||||
worker: ['NODE_ENV', 'SERVE_CLIENT_DIST', 'JOBS_ENABLED'],
|
||||
init: ['NODE_ENV'],
|
||||
};
|
||||
|
||||
function readFile(filePath) {
|
||||
return fs.readFileSync(filePath, 'utf8');
|
||||
}
|
||||
|
||||
function extractConfigEnvKeys(content) {
|
||||
const keys = new Set();
|
||||
for (const match of content.matchAll(/process\.env\.([A-Z0-9_]+)/g)) {
|
||||
keys.add(match[1]);
|
||||
}
|
||||
for (const match of content.matchAll(/requiredEnv\("([A-Z0-9_]+)"\)/g)) {
|
||||
keys.add(match[1]);
|
||||
}
|
||||
return [...keys].sort();
|
||||
}
|
||||
|
||||
function extractEnvExampleKeys(content) {
|
||||
const keys = new Set();
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const match = line.match(/^\s*#?\s*([A-Z0-9_]+)\s*=/);
|
||||
if (match) {
|
||||
keys.add(match[1]);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function extractServiceBlock(composeContent, serviceName) {
|
||||
const lines = composeContent.split(/\r?\n/);
|
||||
let capture = false;
|
||||
const block = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const serviceMatch = line.match(/^ ([A-Za-z0-9_]+):\s*$/);
|
||||
if (serviceMatch) {
|
||||
if (capture) {
|
||||
break;
|
||||
}
|
||||
capture = serviceMatch[1] === serviceName;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (capture) {
|
||||
block.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return block.length ? block.join('\n') : null;
|
||||
}
|
||||
|
||||
function extractSection(serviceBlock, sectionName) {
|
||||
const lines = serviceBlock.split(/\r?\n/);
|
||||
const sectionLines = [];
|
||||
let capture = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const sectionMatch = line.match(/^\s{4}([A-Za-z0-9_]+):\s*$/);
|
||||
if (sectionMatch) {
|
||||
if (capture) {
|
||||
break;
|
||||
}
|
||||
capture = sectionMatch[1] === sectionName;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (capture) {
|
||||
sectionLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return sectionLines;
|
||||
}
|
||||
|
||||
function extractEnvFileEntries(serviceBlock) {
|
||||
return extractSection(serviceBlock, 'env_file')
|
||||
.map((line) => line.match(/^\s{6}-\s*(.+?)\s*$/))
|
||||
.filter(Boolean)
|
||||
.map((item) => item[1]);
|
||||
}
|
||||
|
||||
function extractEnvironmentKeys(serviceBlock) {
|
||||
return extractSection(serviceBlock, 'environment')
|
||||
.map((line) => line.match(/^\s{6}([A-Z0-9_]+):/))
|
||||
.filter(Boolean)
|
||||
.map((item) => item[1]);
|
||||
}
|
||||
|
||||
function fail(message, errors) {
|
||||
console.error(`[R26][FAIL] ${message}`);
|
||||
for (const error of errors) {
|
||||
console.error(`- ${error}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const configContent = readFile(configPath);
|
||||
const envExampleContent = readFile(envExamplePath);
|
||||
const composeContent = readFile(composePath);
|
||||
|
||||
const configKeys = extractConfigEnvKeys(configContent);
|
||||
const envExampleKeys = extractEnvExampleKeys(envExampleContent);
|
||||
const errors = [];
|
||||
|
||||
for (const key of configKeys) {
|
||||
if (!envExampleKeys.has(key)) {
|
||||
errors.push(`.env.example 缺少 ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const serviceName of requiredServices) {
|
||||
const block = extractServiceBlock(composeContent, serviceName);
|
||||
if (!block) {
|
||||
errors.push(`docker-compose.yml 缺少服务 ${serviceName}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const envFiles = extractEnvFileEntries(block);
|
||||
if (!envFiles.includes('./.env')) {
|
||||
errors.push(`服务 ${serviceName} 未通过 env_file 引用 ./.env`);
|
||||
}
|
||||
|
||||
const environmentKeys = extractEnvironmentKeys(block);
|
||||
const illegalOverrides = environmentKeys.filter(
|
||||
(key) => !(allowedOverrides[serviceName] ?? []).includes(key),
|
||||
);
|
||||
|
||||
if (illegalOverrides.length) {
|
||||
errors.push(`服务 ${serviceName} 存在未授权 override: ${illegalOverrides.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
fail('部署环境契约校验失败', errors);
|
||||
}
|
||||
|
||||
console.log('[R26][PASS] 部署环境契约校验通过');
|
||||
console.log(`- config.js env 键数: ${configKeys.length}`);
|
||||
console.log(`- .env.example env 键数: ${envExampleKeys.size}`);
|
||||
console.log(`- 已检查服务: ${requiredServices.join(', ')}`);
|
||||
在新工单中引用
屏蔽一个用户