build(GOV-009): 对齐应用部署与运行入口

This commit is contained in:
2026-08-13 17:56:37 +08:00
parent 4665336a63
commit c771a98d4d
16 changed files with 311 additions and 75 deletions
+96 -1
View File
@@ -1,10 +1,15 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
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 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');
@@ -53,6 +58,18 @@ test('模板不包含敏感值且 Web 只暴露 VITE 变量', async () => {
}
});
test('Web API 根地址不重复 OpenAPI 自带的 api 路径', async () => {
const development = parseEnvTemplate(
await readRepoFile('deploy/env/development/web.env.example'),
);
const production = parseEnvTemplate(
await readRepoFile('deploy/env/production/web.env.example'),
);
assert.equal(development.get('VITE_API_BASE_URL'), 'http://127.0.0.1:3000');
assert.equal(production.get('VITE_API_BASE_URL'), '');
});
test('生产模板禁止模拟数据并保持 MySQL 5.7 与 RocketMQ 配置契约', async () => {
const rocketMqTemplates = new Map();
for (const environment of ['development', 'production']) {
@@ -149,3 +166,81 @@ test('部署文档覆盖本地、Compose 生产和 Ubuntu 源码生产', async (
assert.match(source, /pnpm 10/);
assert.match(source, /不负责安装/);
});
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,
});
for (const processName of ['api', 'scheduler', 'worker']) {
const unit = await readRepoFile(`deploy/systemd/a-share-${processName}.service.example`);
const execStart = unit.match(
/^ExecStart=\/usr\/bin\/node \/opt\/a-share-analysis\/current\/(.+)$/m,
)?.[1];
assert.ok(execStart, `${processName} 缺少 Node 构建入口`);
await access(new URL(execStart, repoRoot));
}
});
test('Docker 构建上下文排除本地配置与派生产物', async () => {
const dockerIgnore = await readRepoFile('.dockerignore');
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}`,
);
}
});