#!/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 requiredFragment of [ 'validate_data_path POSTGRES_DATA_DIR "$POSTGRES_DATA_DIR"', 'validate_data_path STORAGE_HOST_DIR "$STORAGE_HOST_DIR"', 'DEPLOY_DATA_MODE" != "existing"', 'DEPLOY_DATA_MODE" != "initialize"', '"$POSTGRES_DATA_DIR/PG_VERSION"', ]) { if (!deployScriptContent.includes(requiredFragment)) { errors.push(`deploy.sh 缺少正式部署数据目录护栏: ${requiredFragment}`); } } 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(', ')}`);