294 行
9.3 KiB
JavaScript
可执行文件
294 行
9.3 KiB
JavaScript
可执行文件
#!/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.resolve(
|
|
process.env.ENV_CONTRACT_ENV_EXAMPLE_PATH || path.join(rootDir, 'deploy/trialrun/.env.example'),
|
|
);
|
|
const composePath = path.resolve(
|
|
process.env.ENV_CONTRACT_COMPOSE_PATH || path.join(rootDir, 'deploy/trialrun/docker-compose.yml'),
|
|
);
|
|
const runbookPath = path.join(rootDir, 'deploy/trialrun/runbook.md');
|
|
const checklistPath = path.join(rootDir, 'deploy/trialrun/checklist.md');
|
|
const deployScriptPath = path.resolve(
|
|
process.env.ENV_CONTRACT_DEPLOY_SCRIPT_PATH || path.join(rootDir, 'deploy/trialrun/deploy.sh'),
|
|
);
|
|
const timerScriptPath = path.resolve(
|
|
process.env.ENV_CONTRACT_TIMER_SCRIPT_PATH
|
|
|| path.join(rootDir, 'deploy/trialrun/install-warch3-ops-timer.sh'),
|
|
);
|
|
const nginxPaths = [
|
|
path.join(rootDir, 'deploy/trialrun/nginx-hyb-oa.conf'),
|
|
path.join(rootDir, 'deploy/trialrun/nginx.conf.example'),
|
|
];
|
|
|
|
const requiredServices = ['server', 'worker', 'init'];
|
|
const deploymentInjectedEnvKeys = new Set(['BUILD_SHA']);
|
|
const allowedOverrides = {
|
|
server: ['NODE_ENV', 'BUILD_SHA'],
|
|
worker: ['NODE_ENV', 'BUILD_SHA', 'SERVE_CLIENT_DIST', 'JOBS_ENABLED'],
|
|
init: ['NODE_ENV', 'BUILD_SHA'],
|
|
};
|
|
const requiredRuntimeOverrides = {
|
|
server: ['BUILD_SHA'],
|
|
worker: ['BUILD_SHA'],
|
|
init: ['BUILD_SHA'],
|
|
};
|
|
const requiredBuildArgs = {
|
|
server: ['BUILD_SHA', 'VITE_BUILD_SHA'],
|
|
worker: ['BUILD_SHA', 'VITE_BUILD_SHA'],
|
|
init: ['BUILD_SHA', 'VITE_BUILD_SHA'],
|
|
};
|
|
|
|
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 runbookContent = readFile(runbookPath);
|
|
const checklistContent = readFile(checklistPath);
|
|
const deployScriptContent = readFile(deployScriptPath);
|
|
const timerScriptContent = readFile(timerScriptPath);
|
|
const nginxContents = nginxPaths.map((filePath) => ({
|
|
filePath,
|
|
content: readFile(filePath),
|
|
}));
|
|
|
|
const configKeys = extractConfigEnvKeys(configContent);
|
|
const envExampleKeys = extractEnvExampleKeys(envExampleContent);
|
|
const errors = [];
|
|
|
|
for (const key of configKeys) {
|
|
if (!deploymentInjectedEnvKeys.has(key) && !envExampleKeys.has(key)) {
|
|
errors.push(`.env.example 缺少 ${key}`);
|
|
}
|
|
}
|
|
for (const key of deploymentInjectedEnvKeys) {
|
|
if (envExampleKeys.has(key)) {
|
|
errors.push(`.env.example 不应持久化部署注入键 ${key}`);
|
|
}
|
|
}
|
|
|
|
for (const key of ['APP_HOST_PORT', 'POSTGRES_DATA_DIR', 'STORAGE_HOST_DIR']) {
|
|
if (!envExampleKeys.has(key)) {
|
|
errors.push(`.env.example 缺少 Compose 宿主契约键 ${key}`);
|
|
}
|
|
}
|
|
if (envExampleKeys.has('COMPOSE_PROJECT_NAME')) {
|
|
errors.push('.env.example 不应持久化 COMPOSE_PROJECT_NAME,调用方必须通过 -p 显式选择项目');
|
|
}
|
|
|
|
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),
|
|
);
|
|
const missingRuntimeOverrides = (requiredRuntimeOverrides[serviceName] ?? []).filter(
|
|
(key) => !environmentKeys.includes(key),
|
|
);
|
|
const missingBuildArgs = (requiredBuildArgs[serviceName] ?? []).filter(
|
|
(key) => !new RegExp(`^\\s{8}${key}:`, 'm').test(block),
|
|
);
|
|
|
|
if (illegalOverrides.length) {
|
|
errors.push(`服务 ${serviceName} 存在未授权 override: ${illegalOverrides.join(', ')}`);
|
|
}
|
|
if (missingRuntimeOverrides.length) {
|
|
errors.push(`服务 ${serviceName} 缺少运行时 override: ${missingRuntimeOverrides.join(', ')}`);
|
|
}
|
|
if (missingBuildArgs.length) {
|
|
errors.push(`服务 ${serviceName} 缺少构建参数: ${missingBuildArgs.join(', ')}`);
|
|
}
|
|
}
|
|
|
|
const serverBlock = extractServiceBlock(composeContent, 'server');
|
|
if (!serverBlock || !/^\s{6}-\s*["']127\.0\.0\.1:\$\{APP_HOST_PORT:-3000\}:3000["']\s*$/m.test(serverBlock)) {
|
|
errors.push('服务 server 必须通过 APP_HOST_PORT 将容器 3000 端口只绑定到宿主回环地址');
|
|
}
|
|
|
|
const dbBlock = extractServiceBlock(composeContent, 'db');
|
|
if (!dbBlock) {
|
|
errors.push('docker-compose.yml 缺少服务 db');
|
|
} else {
|
|
if (/^\s{4}ports:\s*$/m.test(dbBlock)) {
|
|
errors.push('服务 db 不得向宿主发布端口');
|
|
}
|
|
if (!/\$\{POSTGRES_DATA_DIR:-\.\/data\/postgres\}:\/var\/lib\/postgresql\/data/.test(dbBlock)) {
|
|
errors.push('服务 db 必须通过 POSTGRES_DATA_DIR 参数化宿主数据目录');
|
|
}
|
|
}
|
|
|
|
if (/^\s+container_name:\s*/m.test(composeContent)) {
|
|
errors.push('docker-compose.yml 不得固定 container_name,容器身份必须由 Compose project 隔离');
|
|
}
|
|
|
|
for (const serviceName of ['server', 'worker', 'init']) {
|
|
const block = extractServiceBlock(composeContent, serviceName);
|
|
if (block && !/\$\{STORAGE_HOST_DIR:-\.\/data\/storage\}:\$\{STORAGE_DIR\}/.test(block)) {
|
|
errors.push(`服务 ${serviceName} 必须通过 STORAGE_HOST_DIR 参数化宿主附件目录`);
|
|
}
|
|
}
|
|
|
|
for (const [name, content] of [
|
|
['deploy.sh', deployScriptContent],
|
|
['install-warch3-ops-timer.sh', timerScriptContent],
|
|
]) {
|
|
if (!content.includes('-p "$COMPOSE_PROJECT_NAME"')) {
|
|
errors.push(`${name} 必须向 Compose 显式传入独立 project name`);
|
|
}
|
|
}
|
|
|
|
for (const { filePath, content } of nginxContents) {
|
|
const relativePath = path.relative(rootDir, filePath);
|
|
if (content.includes('$proxy_add_x_forwarded_for')) {
|
|
errors.push(`${relativePath} 不得追加客户端提供的 X-Forwarded-For`);
|
|
}
|
|
|
|
const forwardedForDirectives = [
|
|
...content.matchAll(/proxy_set_header\s+X-Forwarded-For\s+([^;]+);/g),
|
|
];
|
|
if (
|
|
forwardedForDirectives.length === 0 ||
|
|
forwardedForDirectives.some((match) => match[1].trim() !== '$remote_addr')
|
|
) {
|
|
errors.push(`${relativePath} 必须用 $remote_addr 覆盖 X-Forwarded-For`);
|
|
}
|
|
}
|
|
|
|
const authChallengeEndpoints = [
|
|
'/api/auth/forgot-password',
|
|
'/api/auth/reset-password',
|
|
'/api/auth/email-login/code',
|
|
'/api/auth/email-login/verify',
|
|
];
|
|
for (const [name, content] of [
|
|
['runbook.md', runbookContent],
|
|
['checklist.md', checklistContent],
|
|
]) {
|
|
if (!content.includes('EXTERNAL_GATED')) {
|
|
errors.push(`${name} 必须标记认证 challenge 边缘操作为 EXTERNAL_GATED`);
|
|
}
|
|
for (const endpoint of authChallengeEndpoints) {
|
|
if (!content.includes(endpoint)) {
|
|
errors.push(`${name} 缺少认证 challenge 边缘关闭端点 ${endpoint}`);
|
|
}
|
|
}
|
|
if (!/脚本不会自动(?:修改|改)边缘/.test(content)) {
|
|
errors.push(`${name} 必须声明部署脚本不会自动修改边缘配置`);
|
|
}
|
|
}
|
|
|
|
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(', ')}`);
|