323 lines
14 KiB
JavaScript
323 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
||||
|
|
|
|||
|
|
import { createHash } from 'node:crypto'
|
|||
|
|
import { mkdir, open, readFile, readdir, stat, writeFile } from 'node:fs/promises'
|
|||
|
|
import { spawnSync } from 'node:child_process'
|
|||
|
|
import path from 'node:path'
|
|||
|
|
import process from 'node:process'
|
|||
|
|
import { fileURLToPath } from 'node:url'
|
|||
|
|
|
|||
|
|
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url))
|
|||
|
|
const SQL_ROOT = path.resolve(SCRIPT_DIR, '..')
|
|||
|
|
const SCOPES_PATH = path.join(SQL_ROOT, 'migration-scopes.json')
|
|||
|
|
const MIGRATION_NAME = /^V(\d+)__([a-z0-9][a-z0-9_-]*)\.sql$/
|
|||
|
|
|
|||
|
|
function fail(message) {
|
|||
|
|
throw new Error(message)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function parseArgs(argv) {
|
|||
|
|
const [command = 'help', ...rest] = argv
|
|||
|
|
const args = { command, _: [] }
|
|||
|
|
for (let index = 0; index < rest.length; index += 1) {
|
|||
|
|
const item = rest[index]
|
|||
|
|
if (!item.startsWith('--')) {
|
|||
|
|
args._.push(item)
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
const equals = item.indexOf('=')
|
|||
|
|
if (equals >= 0) {
|
|||
|
|
args[item.slice(2, equals)] = item.slice(equals + 1)
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
const key = item.slice(2)
|
|||
|
|
const next = rest[index + 1]
|
|||
|
|
if (next && !next.startsWith('--')) {
|
|||
|
|
args[key] = next
|
|||
|
|
index += 1
|
|||
|
|
} else {
|
|||
|
|
args[key] = true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return args
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function absoluteFrom(root, value) {
|
|||
|
|
return path.isAbsolute(value) ? value : path.resolve(root, value)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadScopes(scopesPath = SCOPES_PATH) {
|
|||
|
|
const scopes = JSON.parse(await readFile(scopesPath, 'utf8'))
|
|||
|
|
if (scopes.schemaVersion !== 1 || !scopes.regions || typeof scopes.regions !== 'object') {
|
|||
|
|
fail('migration-scopes.json 必须使用 schemaVersion=1 并提供 regions')
|
|||
|
|
}
|
|||
|
|
const seenSchemas = new Set()
|
|||
|
|
for (const [region, config] of Object.entries(scopes.regions)) {
|
|||
|
|
if (!/^[A-Z][A-Z0-9_]*$/.test(region)) fail(`区域名非法:${region}`)
|
|||
|
|
if (!Array.isArray(config.schemas) || config.schemas.length === 0) fail(`${region} 没有 schema 清单`)
|
|||
|
|
const orders = new Set()
|
|||
|
|
for (const schema of config.schemas) {
|
|||
|
|
if (!/^[a-z][a-z0-9_]*$/.test(schema.name)) fail(`schema 名称非法:${schema.name}`)
|
|||
|
|
if (seenSchemas.has(schema.name)) fail(`schema 重复登记:${schema.name}`)
|
|||
|
|
if (!Number.isInteger(schema.order) || schema.order < 1 || orders.has(schema.order)) fail(`${region} 的 schema order 非法或重复:${schema.name}`)
|
|||
|
|
if (!Array.isArray(schema.services) || schema.services.length === 0) fail(`${schema.name} 没有所属服务`)
|
|||
|
|
seenSchemas.add(schema.name)
|
|||
|
|
orders.add(schema.order)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return scopes
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function regionConfig(scopes, region) {
|
|||
|
|
const value = scopes.regions[region]
|
|||
|
|
if (!value) fail(`未知区域:${region};允许值:${Object.keys(scopes.regions).join(', ')}`)
|
|||
|
|
return value
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function schemaConfig(scopes, region, schemaName) {
|
|||
|
|
const config = regionConfig(scopes, region)
|
|||
|
|
const schema = config.schemas.find((item) => item.name === schemaName)
|
|||
|
|
if (!schema) fail(`${region} 未登记 schema:${schemaName}`)
|
|||
|
|
return schema
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function listMigrations(sqlRoot, region, schemaName) {
|
|||
|
|
const directory = path.join(sqlRoot, 'migrations', region.toLowerCase(), schemaName)
|
|||
|
|
let entries = []
|
|||
|
|
try {
|
|||
|
|
entries = await readdir(directory, { withFileTypes: true })
|
|||
|
|
} catch (error) {
|
|||
|
|
if (error.code === 'ENOENT') return []
|
|||
|
|
throw error
|
|||
|
|
}
|
|||
|
|
const migrations = []
|
|||
|
|
for (const entry of entries) {
|
|||
|
|
if (!entry.isFile() || entry.name.startsWith('.')) continue
|
|||
|
|
const match = entry.name.match(MIGRATION_NAME)
|
|||
|
|
if (!match) fail(`${path.relative(sqlRoot, path.join(directory, entry.name))} 文件名不符合 V<integer>__description.sql`)
|
|||
|
|
const version = Number(match[1])
|
|||
|
|
if (!Number.isSafeInteger(version) || version < 1) fail(`迁移版本非法:${entry.name}`)
|
|||
|
|
migrations.push({
|
|||
|
|
version,
|
|||
|
|
description: match[2],
|
|||
|
|
fileName: entry.name,
|
|||
|
|
relativePath: path.relative(sqlRoot, path.join(directory, entry.name)).replaceAll(path.sep, '/'),
|
|||
|
|
absolutePath: path.join(directory, entry.name),
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
migrations.sort((left, right) => left.version - right.version || left.fileName.localeCompare(right.fileName))
|
|||
|
|
for (let index = 1; index < migrations.length; index += 1) {
|
|||
|
|
if (migrations[index - 1].version === migrations[index].version) fail(`${region}/${schemaName} 版本重复:${migrations[index].version}`)
|
|||
|
|
}
|
|||
|
|
return migrations
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function sha256(filePath) {
|
|||
|
|
return createHash('sha256').update(await readFile(filePath)).digest('hex')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function selectedServices(value) {
|
|||
|
|
if (!value || value === 'ALL') return null
|
|||
|
|
const services = value.split(',').map((item) => item.trim()).filter(Boolean)
|
|||
|
|
if (services.length === 0) fail('--services 不能为空')
|
|||
|
|
return [...new Set(services)]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function selectSchemas(scopes, region, services) {
|
|||
|
|
const config = regionConfig(scopes, region)
|
|||
|
|
const selected = services === null
|
|||
|
|
? config.schemas
|
|||
|
|
: config.schemas.filter((schema) => schema.services.some((service) => services.includes(service)))
|
|||
|
|
return [...selected].sort((left, right) => left.order - right.order)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function buildPlan({ sqlRoot = SQL_ROOT, scopes, regions, services, commit = process.env.GIT_COMMIT ?? null }) {
|
|||
|
|
const knownServices = new Set(scopes.services ?? Object.values(scopes.regions).flatMap((config) => config.schemas.flatMap((schema) => schema.services)))
|
|||
|
|
const unknownServices = (services ?? []).filter((service) => !knownServices.has(service))
|
|||
|
|
if (unknownServices.length) fail(`范围映射中未登记服务:${unknownServices.join(', ')}`)
|
|||
|
|
const plan = {
|
|||
|
|
schemaVersion: 1,
|
|||
|
|
sourceCommit: commit,
|
|||
|
|
services: services ?? ['ALL'],
|
|||
|
|
regions: [],
|
|||
|
|
}
|
|||
|
|
for (const region of regions) {
|
|||
|
|
const config = regionConfig(scopes, region)
|
|||
|
|
const schemas = []
|
|||
|
|
for (const schema of selectSchemas(scopes, region, services)) {
|
|||
|
|
const migrations = await listMigrations(sqlRoot, region, schema.name)
|
|||
|
|
schemas.push({
|
|||
|
|
schema: schema.name,
|
|||
|
|
services: schema.services,
|
|||
|
|
order: schema.order,
|
|||
|
|
migrationDirectory: `migrations/${region.toLowerCase()}/${schema.name}`,
|
|||
|
|
baseline: config.baseline,
|
|||
|
|
target: migrations.length ? String(migrations.at(-1).version) : '0.0.0',
|
|||
|
|
migrations: await Promise.all(migrations.map(async (migration) => ({
|
|||
|
|
version: String(migration.version),
|
|||
|
|
description: migration.description,
|
|||
|
|
path: migration.relativePath,
|
|||
|
|
sha256: await sha256(migration.absolutePath),
|
|||
|
|
}))),
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
plan.regions.push({ region, schemas })
|
|||
|
|
}
|
|||
|
|
return plan
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function createMigration({ sqlRoot = SQL_ROOT, scopes, region, schemaName, description }) {
|
|||
|
|
const schema = schemaConfig(scopes, region, schemaName)
|
|||
|
|
const directory = path.join(sqlRoot, 'migrations', region.toLowerCase(), schema.name)
|
|||
|
|
await mkdir(directory, { recursive: true })
|
|||
|
|
const normalizedDescription = description.trim()
|
|||
|
|
if (!normalizedDescription) fail('必须填写变更说明')
|
|||
|
|
const safeDescription = normalizedDescription.toLowerCase().replaceAll(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'change'
|
|||
|
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|||
|
|
const migrations = await listMigrations(sqlRoot, region, schema.name)
|
|||
|
|
const version = (migrations.at(-1)?.version ?? 0) + 1
|
|||
|
|
const fileName = `V${version}__${safeDescription}.sql`
|
|||
|
|
const filePath = path.join(directory, fileName)
|
|||
|
|
try {
|
|||
|
|
const handle = await open(filePath, 'wx')
|
|||
|
|
await handle.writeFile([
|
|||
|
|
`-- Region: ${region}`,
|
|||
|
|
`-- Schema: ${schema.name}`,
|
|||
|
|
`-- Purpose: ${description.trim()}`,
|
|||
|
|
'-- Preconditions:',
|
|||
|
|
'-- Postconditions:',
|
|||
|
|
'',
|
|||
|
|
].join('\n'), 'utf8')
|
|||
|
|
await handle.close()
|
|||
|
|
return { region, schema: schema.name, version, fileName, path: path.relative(sqlRoot, filePath).replaceAll(path.sep, '/') }
|
|||
|
|
} catch (error) {
|
|||
|
|
if (error.code !== 'EEXIST') throw error
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
fail(`${region}/${schema.name} 并发生成迁移失败:重试次数已耗尽`)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function checkProtected(sqlRoot, protectedPath) {
|
|||
|
|
if (!protectedPath) return []
|
|||
|
|
const document = JSON.parse(await readFile(protectedPath, 'utf8'))
|
|||
|
|
const entries = document.files ?? document.migrations ?? []
|
|||
|
|
if (!Array.isArray(entries)) fail('保护清单的 files/migrations 必须是数组')
|
|||
|
|
const errors = []
|
|||
|
|
for (const entry of entries) {
|
|||
|
|
if (!['success', 'frozen'].includes(entry.status)) continue
|
|||
|
|
if (!entry.path || !/^[a-f0-9]{64}$/.test(entry.sha256 ?? '')) fail('保护清单条目必须包含 path 和 64 位 sha256')
|
|||
|
|
const current = await sha256(absoluteFrom(sqlRoot, entry.path))
|
|||
|
|
if (current !== entry.sha256) errors.push(`受保护迁移已修改:${entry.path}`)
|
|||
|
|
}
|
|||
|
|
return errors
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function checkSqlFiles(sqlRoot, scopes, regions) {
|
|||
|
|
const errors = []
|
|||
|
|
const schemasByName = new Map()
|
|||
|
|
for (const region of regions) {
|
|||
|
|
for (const schema of regionConfig(scopes, region).schemas) schemasByName.set(schema.name, { region, schema })
|
|||
|
|
}
|
|||
|
|
for (const [schemaName, { region }] of schemasByName) {
|
|||
|
|
const migrations = await listMigrations(sqlRoot, region, schemaName)
|
|||
|
|
for (const migration of migrations) {
|
|||
|
|
const sql = await readFile(migration.absolutePath, 'utf8')
|
|||
|
|
for (const candidate of schemasByName.keys()) {
|
|||
|
|
if (candidate !== schemaName && new RegExp(`(?:\\b|\`)${candidate}(?:\\b|\`)`, 'i').test(sql)) {
|
|||
|
|
errors.push(`${migration.relativePath} 引用了其他 schema:${candidate}`)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (/\b(?:DROP\s+DATABASE|CREATE\s+DATABASE|USE\s+)/i.test(sql)) errors.push(`${migration.relativePath} 不得包含跨库控制语句`)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return errors
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function check({ sqlRoot = SQL_ROOT, scopes, regions = Object.keys(scopes.regions), protectedPath }) {
|
|||
|
|
const errors = await checkSqlFiles(sqlRoot, scopes, regions)
|
|||
|
|
errors.push(...await checkProtected(sqlRoot, protectedPath))
|
|||
|
|
if (errors.length) fail(errors.join('\n'))
|
|||
|
|
return { ok: true, regions, checkedAt: new Date().toISOString() }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function runManifest({ sqlRoot = SQL_ROOT, manifestPath, dryRun = false }) {
|
|||
|
|
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
|||
|
|
if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.regions)) fail('发布清单格式不受支持')
|
|||
|
|
const commands = []
|
|||
|
|
for (const region of manifest.regions) {
|
|||
|
|
for (const schema of region.schemas ?? []) {
|
|||
|
|
if (schema.target === '0.0.0' && (schema.migrations ?? []).length === 0) continue
|
|||
|
|
const location = path.join(sqlRoot, schema.migrationDirectory)
|
|||
|
|
await stat(location).catch(() => fail(`迁移目录不存在:${schema.migrationDirectory}`))
|
|||
|
|
commands.push({ region: region.region, schema: schema.schema, target: schema.target, location })
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (dryRun) return { mode: 'dry-run', commands }
|
|||
|
|
if (process.env.DB_MIGRATION_ALLOW !== '1') fail('实际数据库执行被保护:先设置 DB_MIGRATION_ALLOW=1,并显式提供测试环境凭据')
|
|||
|
|
if (!process.env.FLYWAY_BIN) fail('实际数据库执行需要 FLYWAY_BIN,阶段 B 只支持 --dry-run')
|
|||
|
|
const results = []
|
|||
|
|
for (const command of commands) {
|
|||
|
|
const result = spawnSync(process.env.FLYWAY_BIN, [
|
|||
|
|
'-locations=filesystem:' + command.location,
|
|||
|
|
'-schemas=' + command.schema,
|
|||
|
|
'-defaultSchema=' + command.schema,
|
|||
|
|
'-table=flyway_schema_history',
|
|||
|
|
'-baselineOnMigrate=false',
|
|||
|
|
'-cleanDisabled=true',
|
|||
|
|
'-outOfOrder=false',
|
|||
|
|
'-validateOnMigrate=true',
|
|||
|
|
'-target=' + command.target,
|
|||
|
|
'migrate',
|
|||
|
|
], { stdio: 'inherit', env: process.env })
|
|||
|
|
if (result.status !== 0) fail(`${command.region}/${command.schema} Flyway 执行失败,退出码 ${result.status}`)
|
|||
|
|
results.push(command)
|
|||
|
|
}
|
|||
|
|
return { mode: 'execute', commands: results }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function usage() {
|
|||
|
|
console.log(`用法:\n node deploy/sql/scripts/db-migrations.mjs new --region CN --schema baiyue_auth --description add_login_audit\n node deploy/sql/scripts/db-migrations.mjs check [--protected <manifest>]\n node deploy/sql/scripts/db-migrations.mjs plan --services auth,iam --regions CN,OVERSEAS --out <file>\n node deploy/sql/scripts/db-migrations.mjs run --manifest <file> --dry-run`)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function main(argv = process.argv.slice(2)) {
|
|||
|
|
const args = parseArgs(argv)
|
|||
|
|
const scopesPath = args.scopes ? absoluteFrom(process.cwd(), args.scopes) : SCOPES_PATH
|
|||
|
|
const scopes = await loadScopes(scopesPath)
|
|||
|
|
const sqlRoot = path.dirname(scopesPath)
|
|||
|
|
if (args.command === 'help') return usage()
|
|||
|
|
if (args.command === 'new') {
|
|||
|
|
const result = await createMigration({ sqlRoot, scopes, region: String(args.region ?? ''), schemaName: String(args.schema ?? ''), description: String(args.description ?? '') })
|
|||
|
|
console.log(JSON.stringify(result, null, 2))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if (args.command === 'check') {
|
|||
|
|
const result = await check({ sqlRoot, scopes, protectedPath: args.protected ? absoluteFrom(process.cwd(), args.protected) : undefined })
|
|||
|
|
console.log(JSON.stringify(result, null, 2))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if (args.command === 'plan') {
|
|||
|
|
const regions = String(args.regions ?? Object.keys(scopes.regions).join(',')).split(',').map((item) => item.trim()).filter(Boolean)
|
|||
|
|
const plan = await buildPlan({ sqlRoot, scopes, regions, services: selectedServices(args.services) })
|
|||
|
|
const output = JSON.stringify(plan, null, 2) + '\n'
|
|||
|
|
if (args.out) await writeFile(absoluteFrom(process.cwd(), args.out), output, 'utf8')
|
|||
|
|
else process.stdout.write(output)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if (args.command === 'run') {
|
|||
|
|
const result = await runManifest({ sqlRoot, manifestPath: absoluteFrom(process.cwd(), String(args.manifest ?? '')), dryRun: Boolean(args['dry-run']) })
|
|||
|
|
console.log(JSON.stringify(result, null, 2))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
usage()
|
|||
|
|
fail(`未知命令:${args.command}`)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export { buildPlan, check, createMigration, listMigrations as discoverMigrations, loadScopes, parseArgs, runManifest }
|
|||
|
|
|
|||
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|||
|
|
main().catch((error) => {
|
|||
|
|
console.error(`ERROR: ${error.message}`)
|
|||
|
|
process.exitCode = 1
|
|||
|
|
})
|
|||
|
|
}
|