refactor(GOV-009): 彻底切换为仅源码部署

This commit is contained in:
2026-08-13 21:12:12 +08:00
parent ab48f06150
commit 47d0e0d248
35 changed files with 480 additions and 712 deletions
-232
View File
@@ -1,232 +0,0 @@
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 {
// 构建中断、镜像尚未产生时保持清理幂等。
}
}
}
@@ -1,26 +0,0 @@
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');
}
@@ -1,33 +0,0 @@
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摘要/,
);
});
}
@@ -0,0 +1,140 @@
import { access, readFile } 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'],
]);
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}`);
}
}
}
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');
}
@@ -0,0 +1,100 @@
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { verifySourceDeployment } from './verify-source-deployment.mjs';
const processNames = ['api', 'scheduler', 'worker'];
function systemdUnit(processName, { user = 'a-share' } = {}) {
const displayName =
processName === 'api' ? 'API' : `${processName[0].toUpperCase()}${processName.slice(1)}`;
return `[Unit]
Description=A Share Analysis ${displayName}
[Service]
Type=simple
User=${user}
Group=a-share
WorkingDirectory=/opt/a-share-analysis/current
EnvironmentFile=/etc/a-share-analysis/${processName}.env
ExecStartPre=/usr/bin/test -r /etc/a-share-analysis/${processName}.env
ExecStart=/usr/bin/node /opt/a-share-analysis/current/server/dist/apps/${processName}/main.js
[Install]
WantedBy=multi-user.target
`;
}
async function createSourceReleaseFixture({
omit,
apiUser = 'a-share',
invalidNginx = false,
} = {}) {
const root = await mkdtemp(path.join(tmpdir(), 'a-share-source-release-'));
const files = new Map([
['web/dist/index.html', '<!doctype html><title>A Share Analysis</title>\n'],
['server/dist/apps/api/main.js', 'export {};\n'],
['server/dist/apps/scheduler/main.js', 'export {};\n'],
['server/dist/apps/worker/main.js', 'export {};\n'],
...processNames.map((processName) => [
`deploy/systemd/a-share-${processName}.service.example`,
systemdUnit(processName, { user: processName === 'api' ? apiUser : 'a-share' }),
]),
[
'deploy/nginx/a-share-analysis.conf.example',
invalidNginx
? 'server { root /srv/invalid; }\n'
: `server {
root /opt/a-share-analysis/current/web/dist;
location /api/ {
proxy_pass http://127.0.0.1:3000;
}
}
`,
],
]);
for (const [relativePath, content] of files) {
if (relativePath === omit) continue;
const absolutePath = path.join(root, relativePath);
await mkdir(path.dirname(absolutePath), { recursive: true });
await writeFile(absolutePath, content);
}
return root;
}
test('完整源码发布夹具通过', async () => {
const root = await createSourceReleaseFixture();
await assert.doesNotReject(() => verifySourceDeployment(root));
});
test('任一 Docker 活动文件会阻断源码发布', async () => {
const root = await createSourceReleaseFixture();
await writeFile(path.join(root, 'Dockerfile'), 'FROM node:22\n');
await assert.rejects(() => verifySourceDeployment(root), /禁止存在 Docker 活动文件.*Dockerfile/);
});
test('Server 构建入口缺失会阻断源码发布', async () => {
const root = await createSourceReleaseFixture({ omit: 'server/dist/apps/worker/main.js' });
await assert.rejects(() => verifySourceDeployment(root), /Worker 构建入口不存在/);
});
test('systemd 配置不是最小权限时会阻断源码发布', async () => {
const root = await createSourceReleaseFixture({ apiUser: 'root' });
await assert.rejects(() => verifySourceDeployment(root), /API systemd 必须使用 User=a-share/);
});
test('Nginx 未代理 API 或未指向 Web dist 时会阻断源码发布', async () => {
const root = await createSourceReleaseFixture({ invalidNginx: true });
await assert.rejects(() => verifySourceDeployment(root), /Nginx/);
});