Files
a-share-analysis/tools/deployment/verify-docker.mjs
T

159 lines
4.3 KiB
JavaScript

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 {
// 构建中断、镜像尚未产生时保持清理幂等。
}
}
}