feat(GOV-009): 支持固定开发库结构基线
This commit is contained in:
@@ -15,12 +15,15 @@ import { fileURLToPath } from 'node:url';
|
||||
const DEVELOPMENT_DATABASE = 'a_share_analysis_dev';
|
||||
const REQUIRED_CHARSET = 'utf8mb4';
|
||||
const REQUIRED_COLLATION = 'utf8mb4_general_ci';
|
||||
const BASELINE_FORMAT_VERSION = 1;
|
||||
const TOOL_VERSION = '1.0.0';
|
||||
const BASELINE_FORMAT_VERSION = 2;
|
||||
const LEGACY_BASELINE_FORMAT_VERSION = 1;
|
||||
const TOOL_VERSION = '2.0.0';
|
||||
const LEGACY_TOOL_VERSION = '1.0.0';
|
||||
const VERSION_PATTERN = /^v\d+\.\d+\.\d+$/;
|
||||
const DEVELOPMENT_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']);
|
||||
const DEVELOPMENT_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '112.192.20.11']);
|
||||
const OBJECT_KEYS = Object.freeze(['tables', 'views', 'triggers', 'routines', 'events']);
|
||||
const CAPTURED_FILES = Object.freeze(['checksum.sha256', 'manifest.yaml', 'schema.sql']);
|
||||
const CAPTURED_EMPTY_FILES = Object.freeze(['manifest.yaml']);
|
||||
const NOT_APPLICABLE_FILES = Object.freeze(['manifest.yaml']);
|
||||
|
||||
function requireExactValue(actual, expected, message) {
|
||||
@@ -34,8 +37,9 @@ function isEnabled(value) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema 工具拥有比普通应用启动更窄的权限边界:即使连接地址看起来是本机,
|
||||
* 环境名和数据库名也必须同时命中固定开发值,避免通过参数误切到生产库。
|
||||
* Schema 工具拥有比普通应用启动更窄的权限边界:环境名、数据库名和主机必须
|
||||
* 同时命中代码内固定值。远程开发库不能通过 env 或命令参数动态扩展白名单,
|
||||
* 避免把一次临时连接授权意外变成生产数据库访问能力。
|
||||
*/
|
||||
export function assertSafeDevelopmentConfiguration(config) {
|
||||
requireExactValue(config.APP_ENV, 'development', 'Schema 基线只允许 development 环境');
|
||||
@@ -53,7 +57,7 @@ export function assertSafeDevelopmentConfiguration(config) {
|
||||
if (isEnabled(config.MYSQL_ENABLED)) {
|
||||
if (!DEVELOPMENT_HOSTS.has(config.MYSQL_HOST)) {
|
||||
throw new Error(
|
||||
`Schema 基线只允许通过开发机回环地址连接,实际 ${config.MYSQL_HOST || '(空)'};远程开发库必须先建立本地 SSH 隧道`,
|
||||
`Schema 基线只允许固定开发数据库主机白名单,实际 ${config.MYSQL_HOST || '(空)'}`,
|
||||
);
|
||||
}
|
||||
for (const key of ['MYSQL_HOST', 'MYSQL_PORT', 'MYSQL_USERNAME', 'MYSQL_PASSWORD']) {
|
||||
@@ -265,6 +269,17 @@ function sha256(content) {
|
||||
return createHash('sha256').update(content, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
function countSchemaObjects(sql) {
|
||||
const definitions = String(sql);
|
||||
return Object.freeze({
|
||||
tables: (definitions.match(/\bCREATE\s+TABLE\b/gi) ?? []).length,
|
||||
views: (definitions.match(/\b(?:CREATE\s+)?VIEW\s+`/gi) ?? []).length,
|
||||
triggers: (definitions.match(/\bTRIGGER\s+`/gi) ?? []).length,
|
||||
routines: (definitions.match(/\b(?:PROCEDURE|FUNCTION)\s+`/gi) ?? []).length,
|
||||
events: (definitions.match(/\bEVENT\s+`/gi) ?? []).length,
|
||||
});
|
||||
}
|
||||
|
||||
function buildManifest(fields) {
|
||||
// JSON 是 YAML 1.2 的严格子集;使用标准解析器生成和读取,避免手写 YAML
|
||||
// 对缩进、引号和同名嵌套字段产生歧义,同时仍保留约定的 manifest.yaml 文件名。
|
||||
@@ -292,6 +307,7 @@ function buildManifest(fields) {
|
||||
routines: fields.captured,
|
||||
events: fields.captured,
|
||||
},
|
||||
object_counts: fields.objectCounts,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
@@ -305,13 +321,14 @@ export function buildCapturedArtifacts(metadata, sql) {
|
||||
}
|
||||
const normalizedSql = normalizeSchemaSql(sql);
|
||||
if (!normalizedSql.trim()) {
|
||||
throw new Error('真实 Schema SQL 不能为空');
|
||||
return buildCapturedEmptyArtifacts(metadata);
|
||||
}
|
||||
const schemaSha256 = sha256(normalizedSql);
|
||||
const manifest = buildManifest({
|
||||
...metadata,
|
||||
status: 'captured',
|
||||
captured: true,
|
||||
objectCounts: countSchemaObjects(normalizedSql),
|
||||
schemaSha256,
|
||||
});
|
||||
return Object.freeze({
|
||||
@@ -325,6 +342,27 @@ export function buildCapturedArtifacts(metadata, sql) {
|
||||
});
|
||||
}
|
||||
|
||||
export function buildCapturedEmptyArtifacts(metadata) {
|
||||
assertMysql57Metadata(metadata);
|
||||
if (!VERSION_PATTERN.test(metadata.applicationVersion ?? '')) {
|
||||
throw new Error(`应用版本格式无效:${metadata.applicationVersion || '(空)'}`);
|
||||
}
|
||||
const schemaSha256 = sha256('');
|
||||
const objectCounts = Object.freeze(Object.fromEntries(OBJECT_KEYS.map((key) => [key, 0])));
|
||||
const manifest = buildManifest({
|
||||
...metadata,
|
||||
status: 'captured_empty',
|
||||
captured: false,
|
||||
objectCounts,
|
||||
schemaSha256,
|
||||
});
|
||||
return Object.freeze({
|
||||
status: 'captured_empty',
|
||||
schemaSha256,
|
||||
files: Object.freeze({ 'manifest.yaml': manifest }),
|
||||
});
|
||||
}
|
||||
|
||||
export function buildNotApplicableArtifacts(metadata) {
|
||||
if (!VERSION_PATTERN.test(metadata.applicationVersion ?? '')) {
|
||||
throw new Error(`应用版本格式无效:${metadata.applicationVersion || '(空)'}`);
|
||||
@@ -337,6 +375,7 @@ export function buildNotApplicableArtifacts(metadata) {
|
||||
status: 'not_applicable',
|
||||
reason: 'mysql_disabled',
|
||||
captured: false,
|
||||
objectCounts: Object.fromEntries(OBJECT_KEYS.map((key) => [key, 0])),
|
||||
});
|
||||
return Object.freeze({
|
||||
status: 'not_applicable',
|
||||
@@ -424,9 +463,13 @@ export function verifyBaselineDirectory(targetDirectory, expectedRelease, expect
|
||||
const status = manifest.status;
|
||||
const applicationVersion = manifest.application_version;
|
||||
const generatedAt = Date.parse(manifest.generated_at ?? '');
|
||||
const supportedFormat =
|
||||
(manifest.format_version === LEGACY_BASELINE_FORMAT_VERSION &&
|
||||
manifest.generator === `schema-baseline/${LEGACY_TOOL_VERSION}`) ||
|
||||
(manifest.format_version === BASELINE_FORMAT_VERSION &&
|
||||
manifest.generator === `schema-baseline/${TOOL_VERSION}`);
|
||||
if (
|
||||
manifest.format_version !== BASELINE_FORMAT_VERSION ||
|
||||
manifest.generator !== `schema-baseline/${TOOL_VERSION}` ||
|
||||
!supportedFormat ||
|
||||
!Number.isFinite(generatedAt) ||
|
||||
!/^[0-9a-f]{40}$/.test(manifest.git_commit ?? '')
|
||||
) {
|
||||
@@ -473,6 +516,29 @@ export function verifyBaselineDirectory(targetDirectory, expectedRelease, expect
|
||||
}
|
||||
return { status, applicationVersion, reason };
|
||||
}
|
||||
if (status === 'captured_empty') {
|
||||
if (manifest.format_version !== BASELINE_FORMAT_VERSION) {
|
||||
throw new Error('空库 Schema 基线只允许 format v2');
|
||||
}
|
||||
assertExactFileSet(targetDirectory, CAPTURED_EMPTY_FILES);
|
||||
assertMysql57Metadata({
|
||||
mysqlClientVersion: manifest.mysql_client_version,
|
||||
mysqldumpClientVersion: manifest.mysqldump_client_version,
|
||||
serverVersion: manifest.mysql_server_version,
|
||||
databaseCharset: manifest.charset,
|
||||
databaseCollation: manifest.collation,
|
||||
});
|
||||
const expectedEmptyChecksum = sha256('');
|
||||
if (
|
||||
manifest.schema_sha256 !== expectedEmptyChecksum ||
|
||||
Object.keys(manifest.object_counts ?? {}).sort().join(',') !==
|
||||
[...OBJECT_KEYS].sort().join(',') ||
|
||||
OBJECT_KEYS.some((key) => manifest.object_counts[key] !== 0)
|
||||
) {
|
||||
throw new Error('空库 Schema 清单必须证明所有对象计数为零');
|
||||
}
|
||||
return { status, applicationVersion, schemaSha256: expectedEmptyChecksum };
|
||||
}
|
||||
if (status !== 'captured') {
|
||||
throw new Error(`Schema 基线状态无效:${status}`);
|
||||
}
|
||||
@@ -491,6 +557,16 @@ export function verifyBaselineDirectory(targetDirectory, expectedRelease, expect
|
||||
for (const objectName of OBJECT_KEYS) {
|
||||
requireExactValue(manifest.objects?.[objectName], true, `Schema 对象范围缺少 ${objectName}`);
|
||||
}
|
||||
if (
|
||||
manifest.format_version === BASELINE_FORMAT_VERSION &&
|
||||
(Object.keys(manifest.object_counts ?? {}).sort().join(',') !==
|
||||
[...OBJECT_KEYS].sort().join(',') ||
|
||||
OBJECT_KEYS.some(
|
||||
(key) => !Number.isInteger(manifest.object_counts[key]) || manifest.object_counts[key] < 0,
|
||||
))
|
||||
) {
|
||||
throw new Error('Schema 对象计数字段无效');
|
||||
}
|
||||
const sql = readFileSync(path.join(targetDirectory, 'schema.sql'), 'utf8');
|
||||
if (!sql.trim()) {
|
||||
throw new Error('真实 Schema SQL 不能为空');
|
||||
@@ -643,14 +719,9 @@ export function getCurrentReleasedVersion(repositoryRoot) {
|
||||
* 下一版本开发开始前先从开发库生成 captured 基线。
|
||||
*/
|
||||
export function assertNotApplicableMatchesDevelopmentConfiguration(repositoryRoot, result) {
|
||||
if (result.status !== 'not_applicable') return result;
|
||||
|
||||
const config = assertSafeDevelopmentConfiguration(
|
||||
parseEnvironmentFile(readFileSync(resolveFixedDevelopmentEnv(repositoryRoot), 'utf8')),
|
||||
);
|
||||
if (config.MYSQL_ENABLED) {
|
||||
throw new Error('当前开发环境已启用 MySQL,不能继续使用 not_applicable Schema 基线');
|
||||
}
|
||||
// 已发布清单记录的是发布当时的数据库启用状态。当前开发配置只能决定下一份
|
||||
// 基线如何生成,不能反向否定或改写已发布版本的历史事实。
|
||||
void repositoryRoot;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -734,8 +805,14 @@ export function assertRemoteBranchMatches(repositoryRoot, branch, gitRunner = ru
|
||||
}
|
||||
|
||||
|
||||
function resolveFixedDevelopmentEnv(repositoryRoot) {
|
||||
const realDevelopmentEnv = path.join(repositoryRoot, 'deploy', 'env', 'development', 'api.env');
|
||||
export function resolveFixedDevelopmentEnv(repositoryRoot) {
|
||||
const realDevelopmentEnv = path.join(
|
||||
repositoryRoot,
|
||||
'deploy',
|
||||
'env',
|
||||
'development',
|
||||
'api.env.local',
|
||||
);
|
||||
if (existsSync(realDevelopmentEnv)) return realDevelopmentEnv;
|
||||
return path.join(repositoryRoot, 'deploy', 'env', 'development', 'api.env.example');
|
||||
}
|
||||
|
||||
@@ -11,11 +11,13 @@ import {
|
||||
assertCurrentReleasedBaselinePublished,
|
||||
assertSafeDevelopmentConfiguration,
|
||||
buildCapturedArtifacts,
|
||||
buildCapturedEmptyArtifacts,
|
||||
buildMysqldumpArguments,
|
||||
buildNotApplicableArtifacts,
|
||||
normalizeSchemaSql,
|
||||
persistBaselineArtifacts,
|
||||
runBaselineGeneration,
|
||||
resolveFixedDevelopmentEnv,
|
||||
assertRemoteBranchMatches,
|
||||
verifyBaselineDirectory,
|
||||
} from './schema-baseline.mjs';
|
||||
@@ -76,10 +78,37 @@ test('拒绝任何非开发环境和非固定开发库配置', () => {
|
||||
...validConfig,
|
||||
MYSQL_HOST: 'forbidden-remote.example.invalid',
|
||||
}),
|
||||
/开发机回环地址/,
|
||||
/固定开发数据库主机白名单/,
|
||||
);
|
||||
});
|
||||
|
||||
test('固定远程开发库白名单只接受指定地址', () => {
|
||||
assert.doesNotThrow(() =>
|
||||
assertSafeDevelopmentConfiguration({
|
||||
...validConfig,
|
||||
MYSQL_HOST: '112.192.20.11',
|
||||
}),
|
||||
);
|
||||
for (const host of ['112.192.20.12', 'dev-db.example.invalid', '']) {
|
||||
assert.throws(
|
||||
() => assertSafeDevelopmentConfiguration({ ...validConfig, MYSQL_HOST: host }),
|
||||
/固定开发数据库主机白名单/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('本地 Schema 配置优先于无秘密模板', () => {
|
||||
const root = makeTemporaryDirectory();
|
||||
const envDirectory = path.join(root, 'deploy', 'env', 'development');
|
||||
mkdirSync(envDirectory, { recursive: true });
|
||||
const localEnv = path.join(envDirectory, 'api.env.local');
|
||||
const templateEnv = path.join(envDirectory, 'api.env.example');
|
||||
writeFileSync(localEnv, 'APP_ENV=development\n');
|
||||
writeFileSync(templateEnv, 'APP_ENV=template\n');
|
||||
|
||||
assert.equal(resolveFixedDevelopmentEnv(root), localEnv);
|
||||
});
|
||||
|
||||
test('拒绝错误字符集、排序规则和不完整的启用配置', () => {
|
||||
assert.throws(
|
||||
() => assertSafeDevelopmentConfiguration({ ...validConfig, MYSQL_CHARSET: 'utf8' }),
|
||||
@@ -357,7 +386,7 @@ test('幂等重跑仍核对清单提交与发布标签提交', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('校验能发现 SQL 被修改且生成阶段拒绝空基线', () => {
|
||||
test('校验能发现 SQL 被修改且空结构转为 captured_empty', () => {
|
||||
const root = makeTemporaryDirectory();
|
||||
const target = path.join(root, 'v0.0.5');
|
||||
persistBaselineArtifacts(
|
||||
@@ -367,9 +396,67 @@ test('校验能发现 SQL 被修改且生成阶段拒绝空基线', () => {
|
||||
writeFileSync(path.join(target, 'schema.sql'), 'CREATE TABLE `tampered` (`id` int);\n');
|
||||
assert.throws(() => verifyBaselineDirectory(target, 'v0.0.5'), /校验值不一致/);
|
||||
|
||||
assert.throws(
|
||||
() => buildCapturedArtifacts({ ...validMetadata, applicationVersion: 'v0.0.4' }, ''),
|
||||
/不能为空/,
|
||||
assert.equal(
|
||||
buildCapturedArtifacts({ ...validMetadata, applicationVersion: 'v0.0.4' }, '').status,
|
||||
'captured_empty',
|
||||
);
|
||||
});
|
||||
|
||||
test('启用的空开发库生成 captured_empty format v2 清单', () => {
|
||||
const artifacts = buildCapturedEmptyArtifacts({
|
||||
...validMetadata,
|
||||
applicationVersion: 'v0.0.8',
|
||||
});
|
||||
const manifest = JSON.parse(artifacts.files['manifest.yaml']);
|
||||
|
||||
assert.equal(artifacts.status, 'captured_empty');
|
||||
assert.deepEqual(Object.keys(artifacts.files).sort(), ['manifest.yaml']);
|
||||
assert.equal(manifest.format_version, 2);
|
||||
assert.equal(manifest.status, 'captured_empty');
|
||||
assert.equal(manifest.schema_sha256, createHash('sha256').update('').digest('hex'));
|
||||
assert.deepEqual(manifest.object_counts, {
|
||||
tables: 0,
|
||||
views: 0,
|
||||
triggers: 0,
|
||||
routines: 0,
|
||||
events: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('启用入口把 mysqldump 空结构持久化为 captured_empty', () => {
|
||||
const root = makeTemporaryDirectory();
|
||||
const envFile = path.join(root, 'development.env');
|
||||
writeFileSync(
|
||||
envFile,
|
||||
Object.entries(validConfig)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('\n'),
|
||||
);
|
||||
|
||||
const result = runBaselineGeneration({
|
||||
repositoryRoot: root,
|
||||
envFile,
|
||||
applicationVersion: 'v0.0.8',
|
||||
gitCommit: validMetadata.gitCommit,
|
||||
generatedAt: validMetadata.generatedAt,
|
||||
executeFile(command, args) {
|
||||
if (args[0] === '--version') {
|
||||
return `${command} Ver 14.14 Distrib 5.7.44, for Linux`;
|
||||
}
|
||||
if (command === 'mysql') {
|
||||
return '5.7.44-log\tutf8mb4\tutf8mb4_general_ci\n';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.status, 'captured_empty');
|
||||
assert.deepEqual(
|
||||
readFileSync(
|
||||
path.join(root, 'database', 'schema-baselines', 'v0.0.8', 'manifest.yaml'),
|
||||
'utf8',
|
||||
).includes('captured_empty'),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -431,7 +518,7 @@ test('不适用清单拒绝未知对象键和伪造的格式元数据', () => {
|
||||
assert.throws(() => verifyBaselineDirectory(target, 'v0.0.5'), /清单格式/);
|
||||
});
|
||||
|
||||
test('不适用基线只在当前开发环境仍关闭 MySQL 时有效', () => {
|
||||
test('历史不适用基线不被当前开发配置反向否定', () => {
|
||||
const root = makeTemporaryDirectory();
|
||||
const envDirectory = path.join(root, 'deploy', 'env', 'development');
|
||||
const envFile = path.join(envDirectory, 'api.env.example');
|
||||
@@ -452,9 +539,8 @@ test('不适用基线只在当前开发环境仍关闭 MySQL 时有效', () => {
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('\n'),
|
||||
);
|
||||
assert.throws(
|
||||
assert.doesNotThrow(
|
||||
() => assertNotApplicableMatchesDevelopmentConfiguration(root, result),
|
||||
/已启用 MySQL.*not_applicable/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user