233 lines
6.8 KiB
JavaScript
233 lines
6.8 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 scheduler = `a-share-analysis-verify-scheduler-${suffix}`;
|
|
const worker = `a-share-analysis-verify-worker-${suffix}`;
|
|
const web = `a-share-analysis-verify-web-${suffix}`;
|
|
const containers = [web, worker, scheduler, 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 ?? '')}`);
|
|
}
|
|
|
|
function verifyLongRunningProcess(container, entry, environment) {
|
|
const environmentArguments = environment.flatMap(([key, value]) => ['--env', `${key}=${value}`]);
|
|
docker(
|
|
[
|
|
'run',
|
|
'--detach',
|
|
'--name',
|
|
container,
|
|
...environmentArguments,
|
|
serverImage,
|
|
'node',
|
|
`/app/dist/apps/${entry}/main.js`,
|
|
],
|
|
{ capture: true },
|
|
);
|
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500);
|
|
const running = docker(['inspect', container, '--format', '{{.State.Running}}'], {
|
|
capture: true,
|
|
}).trim();
|
|
if (running !== 'true') throw new Error(`${entry} 容器未保持运行`);
|
|
|
|
docker(['stop', '--time', '5', container], { capture: true });
|
|
const exitCode = docker(['inspect', container, '--format', '{{.State.ExitCode}}'], {
|
|
capture: true,
|
|
}).trim();
|
|
if (exitCode !== '0') throw new Error(`${entry} 容器未优雅退出,退出码 ${exitCode}`);
|
|
}
|
|
|
|
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 },
|
|
);
|
|
}
|
|
|
|
const sharedProcessEnvironment = [
|
|
['APP_ENV', 'production'],
|
|
['LOG_LEVEL', 'error'],
|
|
['REDIS_HOST', '127.0.0.1'],
|
|
['REDIS_PORT', '6379'],
|
|
['REDIS_USERNAME', ''],
|
|
['REDIS_PASSWORD', ''],
|
|
['REDIS_DATABASE', '1'],
|
|
['REDIS_TLS', 'false'],
|
|
['ROCKETMQ_ENABLED', 'false'],
|
|
['ROCKETMQ_NAMESRV_ADDR', '127.0.0.1:9876'],
|
|
['ROCKETMQ_ENDPOINT', '127.0.0.1:8081'],
|
|
['ROCKETMQ_TOPIC_PREFIX', 'a-share-analysis'],
|
|
];
|
|
verifyLongRunningProcess(scheduler, 'scheduler', [
|
|
...sharedProcessEnvironment,
|
|
['SCHEDULER_TIMEZONE', 'Asia/Shanghai'],
|
|
['SCHEDULER_ENABLED', 'true'],
|
|
['ROCKETMQ_SEND_TIMEOUT_MS', '3000'],
|
|
]);
|
|
verifyLongRunningProcess(worker, 'worker', [
|
|
...sharedProcessEnvironment,
|
|
['MARKET_DATA_MODE', 'live'],
|
|
['MARKET_DATA_PROVIDER', 'deterministic-fixture'],
|
|
['MARKET_DATA_BASE_URL', ''],
|
|
['MARKET_DATA_API_KEY', ''],
|
|
['MYSQL_ENABLED', 'false'],
|
|
['MYSQL_HOST', '127.0.0.1'],
|
|
['MYSQL_PORT', '3306'],
|
|
['MYSQL_DATABASE', 'a_share_analysis_dev'],
|
|
['MYSQL_USERNAME', ''],
|
|
['MYSQL_PASSWORD', ''],
|
|
['MYSQL_CHARSET', 'utf8mb4'],
|
|
['MYSQL_COLLATION', 'utf8mb4_general_ci'],
|
|
['MYSQL_TIMEZONE', '+08:00'],
|
|
['AI_ENABLED', 'false'],
|
|
['AI_BASE_URL', ''],
|
|
['AI_API_KEY', ''],
|
|
['AI_MODEL', ''],
|
|
['AI_TIMEOUT_MS', '30000'],
|
|
['ROCKETMQ_CONSUMER_GROUP', 'a-share-analysis-worker'],
|
|
['ROCKETMQ_CONSUMER_CONCURRENCY', '4'],
|
|
]);
|
|
|
|
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 {
|
|
// 构建中断、镜像尚未产生时保持清理幂等。
|
|
}
|
|
}
|
|
}
|