import { access, readFile, readdir } from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; const defaultRepositoryRoot = path.resolve(import.meta.dirname, '../..'); export const FORBIDDEN_DOCKER_PATHS = Object.freeze([ '.dockerignore', 'Dockerfile', 'web/Dockerfile', 'web/nginx.conf', 'server/Dockerfile', 'deploy/compose/development.yml', 'deploy/compose/production.yml.example', 'docs/deployment/docker-compose-production.md', 'tools/deployment/verify-docker.mjs', 'tools/deployment/verify-image-references.mjs', 'tools/deployment/verify-image-references.test.mjs', ]); const buildOutputs = Object.freeze([ ['Web', 'web/dist/index.html'], ['API', 'server/dist/apps/api/main.js'], ['Scheduler', 'server/dist/apps/scheduler/main.js'], ['Worker', 'server/dist/apps/worker/main.js'], ]); const serverProcesses = Object.freeze([ ['api', 'API'], ['scheduler', 'Scheduler'], ['worker', 'Worker'], ]); const ignoredScanDirectories = new Set([ '.git', '.gitnexus', '.local', 'dist', 'node_modules', 'playwright-report', 'test-results', ]); function resolveRoot(repositoryRoot) { return repositoryRoot instanceof URL ? fileURLToPath(repositoryRoot) : path.resolve(repositoryRoot); } async function pathExists(absolutePath) { try { await access(absolutePath); return true; } catch (error) { if (error?.code === 'ENOENT') return false; throw error; } } async function assertDockerArtifactsAbsent(repositoryRoot) { for (const relativePath of FORBIDDEN_DOCKER_PATHS) { if (await pathExists(path.join(repositoryRoot, relativePath))) { throw new Error(`禁止存在 Docker 活动文件:${relativePath}`); } } const pendingDirectories = [repositoryRoot]; while (pendingDirectories.length > 0) { const currentDirectory = pendingDirectories.pop(); const entries = await readdir(currentDirectory, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory() && ignoredScanDirectories.has(entry.name)) continue; const absolutePath = path.join(currentDirectory, entry.name); if (entry.isDirectory()) { pendingDirectories.push(absolutePath); continue; } if (!entry.isFile()) continue; const relativePath = path.relative(repositoryRoot, absolutePath).split(path.sep).join('/'); const segments = relativePath.split('/'); const fileName = segments.at(-1); const isDockerfile = /^Dockerfile(?:\..+)?$/i.test(fileName); const isDockerIgnore = fileName === '.dockerignore'; const isDockerCompose = /^docker-compose.*\.ya?ml$/i.test(fileName); const isComposeYaml = segments.includes('compose') && /\.ya?ml$/i.test(fileName); if (isDockerfile || isDockerIgnore || isDockerCompose || isComposeYaml) { throw new Error(`禁止存在 Docker 活动文件:${relativePath}`); } } } } async function assertBuildOutputsPresent(repositoryRoot) { for (const [displayName, relativePath] of buildOutputs) { if (!(await pathExists(path.join(repositoryRoot, relativePath)))) { throw new Error(`${displayName} 构建入口不存在:${relativePath}`); } } } function assertUnitValue(unit, displayName, key, expectedValue) { const pattern = new RegExp( `^${key}=${expectedValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm', ); if (!pattern.test(unit)) { throw new Error(`${displayName} systemd 必须使用 ${key}=${expectedValue}`); } } async function assertSystemdUnits(repositoryRoot) { for (const [processName, displayName] of serverProcesses) { const relativePath = `deploy/systemd/a-share-${processName}.service.example`; let unit; try { unit = await readFile(path.join(repositoryRoot, relativePath), 'utf8'); } catch (error) { if (error?.code === 'ENOENT') throw new Error(`${displayName} systemd 配置不存在:${relativePath}`); throw error; } const environmentFile = `/etc/a-share-analysis/${processName}.env`; assertUnitValue(unit, displayName, 'User', 'a-share'); assertUnitValue(unit, displayName, 'Group', 'a-share'); assertUnitValue(unit, displayName, 'WorkingDirectory', '/opt/a-share-analysis/current'); assertUnitValue(unit, displayName, 'EnvironmentFile', environmentFile); assertUnitValue(unit, displayName, 'ExecStartPre', `/usr/bin/test -r ${environmentFile}`); assertUnitValue( unit, displayName, 'ExecStart', `/usr/bin/node /opt/a-share-analysis/current/server/dist/apps/${processName}/main.js`, ); } } async function assertNginxConfig(repositoryRoot) { const relativePath = 'deploy/nginx/a-share-analysis.conf.example'; let nginx; try { nginx = await readFile(path.join(repositoryRoot, relativePath), 'utf8'); } catch (error) { if (error?.code === 'ENOENT') throw new Error(`Nginx 配置不存在:${relativePath}`); throw error; } for (const [description, pattern] of [ ['Web dist 根目录', /\broot\s+\/opt\/a-share-analysis\/current\/web\/dist\s*;/], ['API 代理 location', /location\s+\/api\/\s*\{/], ['API 代理目标', /proxy_pass\s+http:\/\/127\.0\.0\.1:3000\s*;/], ]) { if (!pattern.test(nginx)) throw new Error(`Nginx 缺少${description}`); } } /** * 源码发布门禁只验证应用产物和进程配置,不连接或改变任何外部基础设施。 */ export async function verifySourceDeployment(repositoryRoot = defaultRepositoryRoot) { const resolvedRoot = resolveRoot(repositoryRoot); await assertDockerArtifactsAbsent(resolvedRoot); await assertBuildOutputsPresent(resolvedRoot); await assertSystemdUnits(resolvedRoot); await assertNginxConfig(resolvedRoot); } if ( process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)) ) { await verifySourceDeployment(); process.stdout.write('源码发布产物与 systemd/Nginx 配置验证通过\n'); }