fix(GOV-009): 完成最终复审整改

This commit is contained in:
2026-08-13 18:51:14 +08:00
parent 3a8120a33a
commit cb550e3d5c
15 changed files with 197 additions and 8 deletions
+76 -2
View File
@@ -7,8 +7,10 @@ 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, api, redis];
const containers = [web, worker, scheduler, api, redis];
function docker(args, options = {}) {
return execFileSync('docker', args, {
@@ -32,6 +34,34 @@ function waitForContainerCommand(container, command, description) {
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, '.']);
@@ -48,6 +78,50 @@ try {
);
}
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,
@@ -134,7 +208,7 @@ try {
'Web 到 API 的 Nginx 代理',
);
process.stdout.write('Docker 镜像、非 root 用户、三进程入口与 Web/API 链路验证通过\n');
process.stdout.write('Docker 镜像、非 root 用户、三进程生命周期与 Web/API 链路验证通过\n');
} finally {
for (const container of containers) {
try {
@@ -0,0 +1,26 @@
import process from 'node:process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const IMMUTABLE_IMAGE_PATTERN = /^\S+@sha256:[a-f0-9]{64}$/;
/**
* Compose 变量语法只能阻断空值,无法拒绝 latest 或普通 tag;发布前必须先通过
* 该校验器,确保 Web/Server 都使用包含仓库名的内容摘要引用。
*/
export function assertImmutableImageReferences(environment) {
for (const key of ['A_SHARE_WEB_IMAGE', 'A_SHARE_SERVER_IMAGE']) {
const value = environment[key] ?? '';
if (!IMMUTABLE_IMAGE_PATTERN.test(value)) {
throw new Error(`${key} 必须使用仓库名@sha256摘要`);
}
}
}
if (
process.argv[1] &&
path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))
) {
assertImmutableImageReferences(process.env);
process.stdout.write('生产镜像引用验证通过\n');
}
@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { assertImmutableImageReferences } from './verify-image-references.mjs';
const digest = `sha256:${'a'.repeat(64)}`;
test('接受包含仓库名和 sha256 摘要的不可变镜像', () => {
assert.doesNotThrow(() =>
assertImmutableImageReferences({
A_SHARE_WEB_IMAGE: `registry.invalid/web@${digest}`,
A_SHARE_SERVER_IMAGE: `registry.invalid/server@${digest}`,
}),
);
});
for (const [name, image] of [
['latest', 'registry.invalid/web:latest'],
['普通 tag', 'registry.invalid/web:3a8120a'],
['裸摘要', digest],
['空值', ''],
]) {
test(`拒绝 Web ${name} 镜像引用`, () => {
assert.throws(
() =>
assertImmutableImageReferences({
A_SHARE_WEB_IMAGE: image,
A_SHARE_SERVER_IMAGE: `registry.invalid/server@${digest}`,
}),
/A_SHARE_WEB_IMAGE 必须使用仓库名@sha256摘要/,
);
});
}