400 lines
11 KiB
JavaScript
400 lines
11 KiB
JavaScript
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,
|
||
}
|