refactor(GOV-009): 彻底切换为仅源码部署
This commit is contained in:
@@ -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/);
|
||||
});
|
||||
@@ -1,15 +1,10 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { access, mkdtemp, readFile, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { access, readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const repoRoot = new URL('../../', import.meta.url);
|
||||
const processNames = ['api', 'scheduler', 'worker', 'web'];
|
||||
const sensitiveKeyPattern = /(PASSWORD|API_KEY|TOKEN|SECRET)$/;
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
async function readRepoFile(relativePath) {
|
||||
return readFile(new URL(relativePath, repoRoot), 'utf8');
|
||||
@@ -115,13 +110,21 @@ test('生产模板禁止模拟数据并保持 MySQL 5.7 与 RocketMQ 配置契
|
||||
}
|
||||
});
|
||||
|
||||
test('本地 Compose 仅启动 Redis 并提供健康检查', async () => {
|
||||
const compose = await readRepoFile('deploy/compose/development.yml');
|
||||
|
||||
assert.match(compose, /services:\s*\n\s+redis:/);
|
||||
assert.match(compose, /healthcheck:/);
|
||||
assert.match(compose, /127\.0\.0\.1:/);
|
||||
assert.doesNotMatch(compose, /^\s{2}(mysql|rocketmq|api|worker|scheduler|web):/m);
|
||||
test('活动仓库不再包含 Docker 运行构建或部署入口', async () => {
|
||||
for (const relativePath of [
|
||||
'.dockerignore',
|
||||
'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',
|
||||
]) {
|
||||
await assert.rejects(access(new URL(relativePath, repoRoot)), { code: 'ENOENT' });
|
||||
}
|
||||
});
|
||||
|
||||
test('活动架构与部署文档只采用 RocketMQ 5.x', async () => {
|
||||
@@ -130,7 +133,6 @@ test('活动架构与部署文档只采用 RocketMQ 5.x', async () => {
|
||||
'docs/architecture/system-architecture.md',
|
||||
'docs/governance/code-development-standards.md',
|
||||
'docs/deployment/local-development.md',
|
||||
'docs/deployment/docker-compose-production.md',
|
||||
'docs/deployment/ubuntu-source-production.md',
|
||||
]) {
|
||||
const content = await readRepoFile(relativePath);
|
||||
@@ -153,73 +155,27 @@ test('生产源码部署使用独立 EnvironmentFile 并提供 Nginx 代理', as
|
||||
assert.match(nginx, /\/opt\/a-share-analysis\/current\/web\/dist/);
|
||||
});
|
||||
|
||||
test('部署文档覆盖本地、Compose 生产和 Ubuntu 源码生产', async () => {
|
||||
test('部署文档要求本机外部 Redis 和唯一 Ubuntu 源码生产路径', async () => {
|
||||
const local = await readRepoFile('docs/deployment/local-development.md');
|
||||
const compose = await readRepoFile('docs/deployment/docker-compose-production.md');
|
||||
const source = await readRepoFile('docs/deployment/ubuntu-source-production.md');
|
||||
|
||||
assert.match(local, /Redis/);
|
||||
assert.match(compose, /Docker Compose/);
|
||||
assert.match(local, /redis-cli -h 127\.0\.0\.1 -p 6379 ping/);
|
||||
assert.match(local, /外部服务|开发机/);
|
||||
assert.doesNotMatch(local, /docker compose/i);
|
||||
assert.match(source, /Ubuntu 22\.04 LTS/);
|
||||
assert.match(source, /Ubuntu 24\.04 LTS/);
|
||||
assert.match(source, /Node\.js 22 LTS/);
|
||||
assert.match(source, /pnpm 10/);
|
||||
assert.match(source, /Node\.js 22\.x/);
|
||||
assert.match(source, /pnpm 10\.25\.0/);
|
||||
assert.match(source, /pnpm verify:release/);
|
||||
assert.match(source, /\/opt\/a-share-analysis\/releases\//);
|
||||
assert.match(source, /\/opt\/a-share-analysis\/current/);
|
||||
assert.match(source, /不负责安装/);
|
||||
for (const document of [local, compose, source]) {
|
||||
for (const document of [local, source]) {
|
||||
assert.match(document, /MySQL 5\.7\.42/);
|
||||
}
|
||||
});
|
||||
|
||||
test('生产 Compose 只接受显式镜像并解析为真实健康检查', async () => {
|
||||
const composePath = new URL('deploy/compose/production.yml.example', repoRoot).pathname;
|
||||
const temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'a-share-compose-'));
|
||||
const envFiles = {};
|
||||
for (const processName of processNames) {
|
||||
const envPath = path.join(temporaryDirectory, `${processName}.env`);
|
||||
await writeFile(envPath, 'APP_ENV=production\n');
|
||||
envFiles[processName] = envPath;
|
||||
}
|
||||
|
||||
await assert.rejects(
|
||||
execFileAsync('docker', ['compose', '-f', composePath, 'config'], {
|
||||
env: { ...process.env },
|
||||
}),
|
||||
/必须提供不可变 (?:Web|Server) 镜像引用/,
|
||||
);
|
||||
|
||||
const imageDigest = `sha256:${'a'.repeat(64)}`;
|
||||
const { stdout } = await execFileAsync(
|
||||
'docker',
|
||||
['compose', '-f', composePath, 'config', '--format', 'json'],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
A_SHARE_WEB_IMAGE: `registry.invalid/web@${imageDigest}`,
|
||||
A_SHARE_SERVER_IMAGE: `registry.invalid/server@${imageDigest}`,
|
||||
A_SHARE_WEB_ENV_FILE: envFiles.web,
|
||||
A_SHARE_API_ENV_FILE: envFiles.api,
|
||||
A_SHARE_SCHEDULER_ENV_FILE: envFiles.scheduler,
|
||||
A_SHARE_WORKER_ENV_FILE: envFiles.worker,
|
||||
},
|
||||
},
|
||||
);
|
||||
const config = JSON.parse(stdout);
|
||||
|
||||
assert.equal(config.services.web.image, `registry.invalid/web@${imageDigest}`);
|
||||
assert.equal(config.services.api.image, `registry.invalid/server@${imageDigest}`);
|
||||
assert.deepEqual(config.services.api.command, ['node', 'dist/apps/api/main.js']);
|
||||
assert.ok(config.services.web.healthcheck?.test);
|
||||
assert.ok(config.services.api.healthcheck?.test);
|
||||
});
|
||||
|
||||
test('容器构建文件存在且 systemd 启动入口对应真实构建产物', async () => {
|
||||
await access(new URL('web/Dockerfile', repoRoot));
|
||||
await access(new URL('web/nginx.conf', repoRoot));
|
||||
await access(new URL('server/Dockerfile', repoRoot));
|
||||
await execFileAsync('pnpm', ['--filter', '@a-share-analysis/server', 'build'], {
|
||||
cwd: repoRoot.pathname,
|
||||
});
|
||||
|
||||
test('systemd 启动入口对应源码构建产物', async () => {
|
||||
for (const processName of ['api', 'scheduler', 'worker']) {
|
||||
const unit = await readRepoFile(`deploy/systemd/a-share-${processName}.service.example`);
|
||||
const execStart = unit.match(
|
||||
@@ -230,20 +186,11 @@ test('容器构建文件存在且 systemd 启动入口对应真实构建产物',
|
||||
}
|
||||
});
|
||||
|
||||
test('Docker 构建上下文排除本地配置与派生产物', async () => {
|
||||
const dockerIgnore = await readRepoFile('.dockerignore');
|
||||
test('根级发布命令只保留源码发布门禁', async () => {
|
||||
const rootPackage = JSON.parse(await readRepoFile('package.json'));
|
||||
|
||||
for (const requiredPattern of [
|
||||
'deploy/env/**/*.local',
|
||||
'.git',
|
||||
'.gitnexus',
|
||||
'node_modules',
|
||||
'web/dist',
|
||||
'server/dist',
|
||||
]) {
|
||||
assert.ok(
|
||||
dockerIgnore.split(/\r?\n/).includes(requiredPattern),
|
||||
`.dockerignore 缺少 ${requiredPattern}`,
|
||||
);
|
||||
}
|
||||
assert.equal(rootPackage.scripts['verify:source'], 'node tools/deployment/verify-source-deployment.mjs');
|
||||
assert.equal(rootPackage.scripts['verify:release'], 'pnpm verify && pnpm verify:source');
|
||||
assert.equal(rootPackage.scripts['verify:docker'], undefined);
|
||||
assert.equal(rootPackage.scripts['check:images'], undefined);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user