import { execFileSync } from 'node:child_process'; import process from 'node:process'; const suffix = `${process.pid}`; const webImage = `a-share-analysis-web:verify-${suffix}`; const serverImage = `a-share-analysis-server:verify-${suffix}`; const network = `a-share-analysis-verify-${suffix}`; const redis = `a-share-analysis-verify-redis-${suffix}`; const api = `a-share-analysis-verify-api-${suffix}`; const web = `a-share-analysis-verify-web-${suffix}`; const containers = [web, api, redis]; function docker(args, options = {}) { return execFileSync('docker', args, { cwd: process.cwd(), encoding: 'utf8', stdio: options.capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', }); } function waitForContainerCommand(container, command, description) { let lastError; for (let attempt = 0; attempt < 30; attempt += 1) { try { docker(['exec', container, ...command], { capture: true }); return; } catch (error) { lastError = error; Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200); } } throw new Error(`${description} 未在 6 秒内就绪:${String(lastError?.stderr ?? '')}`); } try { docker(['build', '--file', 'web/Dockerfile', '--tag', webImage, '.']); docker(['build', '--file', 'server/Dockerfile', '--tag', serverImage, '.']); const serverUser = docker(['image', 'inspect', serverImage, '--format', '{{.Config.User}}'], { capture: true, }).trim(); if (serverUser !== 'node') throw new Error(`Server 镜像必须使用 node 用户,实际 ${serverUser}`); for (const entry of ['api', 'scheduler', 'worker']) { docker( ['run', '--rm', '--entrypoint', 'test', serverImage, '-f', `/app/dist/apps/${entry}/main.js`], { capture: true }, ); } docker(['network', 'create', network], { capture: true }); docker(['run', '--detach', '--name', redis, '--network', network, 'redis:7.4.8-alpine3.21'], { capture: true, }); docker( [ 'run', '--detach', '--name', api, '--network', network, '--network-alias', 'api', '--env', 'APP_ENV=production', '--env', 'LOG_LEVEL=error', '--env', 'API_HOST=0.0.0.0', '--env', 'API_PORT=3000', '--env', `REDIS_HOST=${redis}`, '--env', 'REDIS_PORT=6379', '--env', 'REDIS_USERNAME=', '--env', 'REDIS_PASSWORD=', '--env', 'REDIS_DATABASE=0', '--env', 'REDIS_TLS=false', '--env', 'MYSQL_ENABLED=false', '--env', 'MYSQL_HOST=127.0.0.1', '--env', 'MYSQL_PORT=3306', '--env', 'MYSQL_DATABASE=a_share_analysis_dev', '--env', 'MYSQL_USERNAME=', '--env', 'MYSQL_PASSWORD=', '--env', 'MYSQL_CHARSET=utf8mb4', '--env', 'MYSQL_COLLATION=utf8mb4_general_ci', '--env', 'MYSQL_TIMEZONE=+08:00', '--env', 'AI_ENABLED=false', '--env', 'AI_BASE_URL=', '--env', 'AI_API_KEY=', '--env', 'AI_MODEL=', '--env', 'AI_TIMEOUT_MS=30000', serverImage, ], { capture: true }, ); docker(['run', '--detach', '--name', web, '--network', network, webImage], { capture: true, }); waitForContainerCommand( api, [ 'node', '-e', "fetch('http://127.0.0.1:3000/api/v1/health').then(r=>{if(r.status!==200)process.exit(1)})", ], 'API 健康检查', ); waitForContainerCommand(web, ['wget', '-q', '-O', '/dev/null', 'http://127.0.0.1/'], 'Web 首页'); waitForContainerCommand( web, ['wget', '-q', '-O', '/dev/null', 'http://127.0.0.1/api/v1/health'], 'Web 到 API 的 Nginx 代理', ); process.stdout.write('Docker 镜像、非 root 用户、三进程入口与 Web/API 链路验证通过\n'); } finally { for (const container of containers) { try { docker(['rm', '--force', container], { capture: true }); } catch { // 容器未创建时保持清理幂等。 } } try { docker(['network', 'rm', network], { capture: true }); } catch { // 网络未创建或已自动移除时无需报错。 } for (const image of [webImage, serverImage]) { try { docker(['image', 'rm', '--force', image], { capture: true }); } catch { // 构建中断、镜像尚未产生时保持清理幂等。 } } }