400 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const fs = require('node:fs')
const path = require('node:path')
const { spawnSync } = require('node:child_process')
const {
describeDatabaseUrl,
loadDatabaseUrl,
} = require('./diagnose-prisma-migration-baseline')
const {
checkCommandAvailable,
checkDockerAvailability,
resolvePostgresTool,
verifyBackupFile,
} = require('./preflight-database-foundation-change')
const ROOT_DIR = path.resolve(__dirname, '..')
function sanitizeFileName(value) {
return String(value || 'database')
.replace(/[^A-Za-z0-9_.-]+/g, '_')
.replace(/^_+|_+$/g, '')
|| 'database'
}
function timestampForFile(date = new Date()) {
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z')
}
function buildBackupFilePath({ databaseName, outputDir, now = new Date() }) {
const safeDatabaseName = sanitizeFileName(databaseName)
return path.resolve(outputDir, `${safeDatabaseName}-${timestampForFile(now)}.dump`)
}
function parsePostgresUrl(databaseUrl) {
const parsed = new URL(databaseUrl)
return {
host: parsed.hostname,
port: parsed.port,
databaseName: parsed.pathname.replace(/^\//, ''),
username: decodeURIComponent(parsed.username || ''),
password: decodeURIComponent(parsed.password || ''),
}
}
function buildPgDumpArgs({ databaseUrl, outputFile }) {
const parsed = parsePostgresUrl(databaseUrl)
const args = [
'--format=custom',
'--no-owner',
'--no-privileges',
'--file',
outputFile,
]
if (parsed.host) args.push('--host', parsed.host)
if (parsed.port) args.push('--port', parsed.port)
if (parsed.username) args.push('--username', parsed.username)
if (parsed.databaseName) args.push('--dbname', parsed.databaseName)
return {
args,
env: parsed.password ? { PGPASSWORD: parsed.password } : {},
}
}
function resolveDockerDatabaseHost(host) {
if (['localhost', '127.0.0.1', '::1'].includes(String(host || '').toLowerCase())) {
return process.platform === 'win32' ? 'host.docker.internal' : 'localhost'
}
return host
}
function buildDockerVolumeArg(hostDir) {
return `${path.resolve(hostDir)}:/backup`
}
function buildDockerEnvFlagArgs(envVars = {}) {
return Object.keys(envVars)
.filter((key) => envVars[key])
.flatMap((key) => ['--env', key])
}
function buildDockerPgDumpArgs({ databaseUrl, outputFile }) {
const parsed = parsePostgresUrl(databaseUrl)
const containerOutputFile = `/backup/${path.basename(outputFile)}`
const args = [
'pg_dump',
'--format=custom',
'--no-owner',
'--no-privileges',
'--file',
containerOutputFile,
]
const host = resolveDockerDatabaseHost(parsed.host)
if (host) args.push('--host', host)
if (parsed.port) args.push('--port', parsed.port)
if (parsed.username) args.push('--username', parsed.username)
if (parsed.databaseName) args.push('--dbname', parsed.databaseName)
return {
args,
env: parsed.password ? { PGPASSWORD: parsed.password } : {},
host,
containerOutputFile,
volumeArg: buildDockerVolumeArg(path.dirname(outputFile)),
}
}
function parseBackupCliArgs(argv = process.argv.slice(2), env = process.env) {
let outputDir = env.DATABASE_BACKUP_DIR || env.DB_BACKUP_DIR || path.join(ROOT_DIR, '.database-backups')
let useDocker = env.POSTGRES_BACKUP_USE_DOCKER === '1'
let allowDockerPull = env.POSTGRES_BACKUP_ALLOW_DOCKER_PULL === '1'
let dockerImage = env.POSTGRES_DOCKER_IMAGE || 'postgres:16'
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
if (arg === '--output-dir') {
outputDir = argv[index + 1] || ''
index += 1
continue
}
if (arg.startsWith('--output-dir=')) {
outputDir = arg.slice('--output-dir='.length)
continue
}
if (arg === '--use-docker') {
useDocker = true
continue
}
if (arg === '--allow-docker-pull') {
allowDockerPull = true
continue
}
if (arg === '--docker-image') {
dockerImage = argv[index + 1] || ''
index += 1
continue
}
if (arg.startsWith('--docker-image=')) {
dockerImage = arg.slice('--docker-image='.length)
continue
}
throw new Error(`未知参数:${arg}`)
}
if (!outputDir) {
throw new Error('备份输出目录不能为空。')
}
if (useDocker && !dockerImage) {
throw new Error('Docker 备份镜像不能为空。')
}
return {
allowDockerPull,
dockerImage,
outputDir: path.resolve(ROOT_DIR, outputDir),
useDocker,
}
}
function createBackup({
databaseUrl,
outputDir = path.join(ROOT_DIR, '.database-backups'),
env = process.env,
useDocker = false,
allowDockerPull = false,
dockerImage = env.POSTGRES_DOCKER_IMAGE || 'postgres:16',
}) {
const pgDump = checkCommandAvailable('pg_dump', ['--version'], env)
if (!pgDump.available) {
if (useDocker) {
return createBackupWithDocker({
allowDockerPull,
databaseUrl,
dockerImage,
env,
outputDir,
})
}
throw new Error([
'pg_dump 不可用,无法生成 PostgreSQL 标准备份。',
`当前检测入口:${pgDump.executable}(${pgDump.configuredBy})。`,
'可通过 PG_DUMP_PATH、POSTGRES_BIN_DIR 或 PG_BIN_DIR 指定 PostgreSQL client 工具目录。',
].join('\n'))
}
const database = describeDatabaseUrl(databaseUrl)
const outputFile = buildBackupFilePath({
databaseName: database.databaseName,
outputDir,
})
fs.mkdirSync(path.dirname(outputFile), { recursive: true })
const pgDumpCommand = buildPgDumpArgs({ databaseUrl, outputFile })
const pgDumpTool = resolvePostgresTool('pg_dump', env)
const result = spawnSync(pgDumpTool.executable, pgDumpCommand.args, {
cwd: ROOT_DIR,
encoding: 'utf8',
env: {
...process.env,
...pgDumpCommand.env,
},
windowsHide: true,
})
if (result.status !== 0) {
throw new Error([
'pg_dump 执行失败。',
result.stderr ? `stderr=${result.stderr.trim()}` : '',
result.error ? `error=${result.error.message}` : '',
].filter(Boolean).join('\n'))
}
const sizeBytes = fs.existsSync(outputFile) ? fs.statSync(outputFile).size : 0
if (sizeBytes <= 0) {
throw new Error('pg_dump 完成但备份文件为空,拒绝把它当作可用备份。')
}
const pgRestore = checkCommandAvailable('pg_restore', ['--version'], env)
const verification = verifyBackupFile({
usable: true,
path: outputFile,
}, {
pgRestore,
}, env)
if (!verification.ok) {
throw new Error(verification.message)
}
return {
outputFile,
sizeBytes,
database,
pgDump: {
executable: pgDumpTool.executable,
configuredBy: pgDumpTool.configuredBy,
},
backupVerification: verification,
}
}
function assertDockerBackupReady({ dockerImage, allowDockerPull, env }) {
const docker = checkDockerAvailability({ ...env, POSTGRES_DOCKER_IMAGE: dockerImage })
if (!docker.available) {
throw new Error('Docker 不可用,无法使用 Docker 兜底生成 PostgreSQL 备份。')
}
if (!docker.postgresImageAvailable && !allowDockerPull) {
throw new Error([
`Docker 镜像 ${dockerImage} 不存在。`,
'为避免隐式下载镜像,默认拒绝继续。',
'请先准备镜像,或显式传入 --allow-docker-pull。',
].join('\n'))
}
return docker
}
function createBackupWithDocker({
databaseUrl,
outputDir = path.join(ROOT_DIR, '.database-backups'),
env = process.env,
dockerImage = 'postgres:16',
allowDockerPull = false,
}) {
const docker = assertDockerBackupReady({ dockerImage, allowDockerPull, env })
const database = describeDatabaseUrl(databaseUrl)
const outputFile = buildBackupFilePath({
databaseName: database.databaseName,
outputDir,
})
fs.mkdirSync(path.dirname(outputFile), { recursive: true })
const dockerPgDump = buildDockerPgDumpArgs({ databaseUrl, outputFile })
const dockerArgs = [
'run',
'--rm',
'-v',
dockerPgDump.volumeArg,
...buildDockerEnvFlagArgs(dockerPgDump.env),
dockerImage,
...dockerPgDump.args,
]
const result = spawnSync('docker', dockerArgs, {
cwd: ROOT_DIR,
encoding: 'utf8',
env: {
...process.env,
...env,
...dockerPgDump.env,
},
windowsHide: true,
})
if (result.status !== 0) {
throw new Error([
'Docker pg_dump 执行失败。',
result.stderr ? `stderr=${result.stderr.trim()}` : '',
result.error ? `error=${result.error.message}` : '',
].filter(Boolean).join('\n'))
}
const sizeBytes = fs.existsSync(outputFile) ? fs.statSync(outputFile).size : 0
if (sizeBytes <= 0) {
throw new Error('Docker pg_dump 完成但备份文件为空,拒绝把它当作可用备份。')
}
const verifyArgs = [
'run',
'--rm',
'-v',
dockerPgDump.volumeArg,
dockerImage,
'pg_restore',
'--list',
dockerPgDump.containerOutputFile,
]
const verification = spawnSync('docker', verifyArgs, {
cwd: ROOT_DIR,
encoding: 'utf8',
env: {
...process.env,
...env,
},
windowsHide: true,
})
if (verification.status !== 0) {
throw new Error([
'Docker pg_restore --list 校验失败,不能把该备份作为迁移兜底。',
verification.stderr ? `stderr=${verification.stderr.trim()}` : '',
verification.error ? `error=${verification.error.message}` : '',
].filter(Boolean).join('\n'))
}
return {
outputFile,
sizeBytes,
database,
pgDump: {
executable: 'docker',
configuredBy: '--use-docker',
dockerImage,
databaseHost: dockerPgDump.host,
postgresImageAvailableBeforeRun: docker.postgresImageAvailable,
},
backupVerification: {
attempted: true,
ok: true,
status: verification.status,
stdoutPreview: String(verification.stdout ?? '').slice(0, 500),
stderr: String(verification.stderr ?? '').trim(),
tooling: {
executable: 'docker',
configuredBy: '--use-docker',
},
message: '备份文件可被 Docker pg_restore --list 读取。',
},
}
}
function main() {
const databaseUrl = loadDatabaseUrl()
if (!databaseUrl) {
throw new Error('DATABASE_URL 未配置,无法备份数据库。')
}
const cli = parseBackupCliArgs()
const report = createBackup({
databaseUrl,
allowDockerPull: cli.allowDockerPull,
dockerImage: cli.dockerImage,
outputDir: cli.outputDir,
useDocker: cli.useDocker,
})
console.log(JSON.stringify({
mode: 'postgres-backup',
ok: true,
outputFile: report.outputFile,
sizeBytes: report.sizeBytes,
database: report.database,
pgDump: report.pgDump,
backupVerification: report.backupVerification,
}, null, 2))
}
if (require.main === module) {
try {
main()
} catch (error) {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
}
}
module.exports = {
buildBackupFilePath,
buildDockerEnvFlagArgs,
buildDockerPgDumpArgs,
buildDockerVolumeArg,
buildPgDumpArgs,
createBackup,
createBackupWithDocker,
parsePostgresUrl,
parseBackupCliArgs,
resolveDockerDatabaseHost,
sanitizeFileName,
timestampForFile,
}