feat(ops): 添加生产备份恢复脚本
- 新增 PostgreSQL 备份、fresh DB 恢复和 server_data volume 备份脚本\n- 恢复默认拒绝覆盖,必须显式 --confirm-overwrite\n- 补充 package scripts、deploy verify 校验和部署文档\n\nCo-Authored-By: GPT-5 Codex <codex@openai.com>
This commit is contained in:
181
scripts/restore-postgres.mjs
Normal file
181
scripts/restore-postgres.mjs
Normal file
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env node
|
||||
import { createReadStream, existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { spawn } from 'node:child_process';
|
||||
import {
|
||||
commandToString,
|
||||
composePrefix,
|
||||
flag,
|
||||
option,
|
||||
parseArgs,
|
||||
quotePgLiteral,
|
||||
readEnvFile,
|
||||
runCommand,
|
||||
} from './ops-utils.mjs';
|
||||
|
||||
function usage() {
|
||||
return `Usage: node scripts/restore-postgres.mjs --input <dump> --confirm-overwrite [options]
|
||||
|
||||
This script restores into a freshly recreated PostgreSQL database. It refuses to
|
||||
drop/recreate a database unless --confirm-overwrite is provided.
|
||||
|
||||
Options:
|
||||
--input <path> pg_dump custom-format dump file
|
||||
--confirm-overwrite Required safety flag for destructive restore
|
||||
--env-file <path> Compose env file (default: .env.production)
|
||||
--compose-file <path> Compose file (default: docker-compose.prod.yml)
|
||||
--service <name> Postgres service name (default: postgres)
|
||||
--user <name> Database user (default: POSTGRES_USER or postgres)
|
||||
--target-db <name> Target database (default: POSTGRES_DB or ftb_pm)
|
||||
--maintenance-db <name> Maintenance database (default: postgres)
|
||||
--dry-run Print restore commands without changing data
|
||||
--help Show this help
|
||||
`;
|
||||
}
|
||||
|
||||
function buildOptions(argv) {
|
||||
const args = parseArgs(argv);
|
||||
if (flag(args, 'help')) return { help: true };
|
||||
|
||||
const envFile = option(args, 'env-file', '.env.production');
|
||||
const env = readEnvFile(envFile, { optional: flag(args, 'dry-run') });
|
||||
const input = option(args, 'input');
|
||||
if (!input) {
|
||||
throw new Error('Missing required --input <dump>');
|
||||
}
|
||||
|
||||
const inputPath = resolve(input);
|
||||
if (!existsSync(inputPath)) {
|
||||
throw new Error(`Restore input not found: ${inputPath}`);
|
||||
}
|
||||
|
||||
if (!flag(args, 'confirm-overwrite')) {
|
||||
throw new Error('Refusing destructive restore. Re-run with --confirm-overwrite.');
|
||||
}
|
||||
|
||||
return {
|
||||
help: false,
|
||||
dryRun: flag(args, 'dry-run'),
|
||||
input: inputPath,
|
||||
envFile,
|
||||
composeFile: option(args, 'compose-file', 'docker-compose.prod.yml'),
|
||||
service: option(args, 'service', 'postgres'),
|
||||
user: option(args, 'user', env.POSTGRES_USER || 'postgres'),
|
||||
targetDb: option(args, 'target-db', env.POSTGRES_DB || 'ftb_pm'),
|
||||
maintenanceDb: option(args, 'maintenance-db', 'postgres'),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRestoreCommands(options) {
|
||||
const prefix = composePrefix(options);
|
||||
const terminateSql = [
|
||||
'SELECT pg_terminate_backend(pid)',
|
||||
'FROM pg_stat_activity',
|
||||
`WHERE datname = ${quotePgLiteral(options.targetDb)} AND pid <> pg_backend_pid();`,
|
||||
].join(' ');
|
||||
|
||||
return [
|
||||
[
|
||||
...prefix,
|
||||
'exec',
|
||||
'-T',
|
||||
options.service,
|
||||
'psql',
|
||||
'-U',
|
||||
options.user,
|
||||
'-d',
|
||||
options.maintenanceDb,
|
||||
'-v',
|
||||
'ON_ERROR_STOP=1',
|
||||
'-c',
|
||||
terminateSql,
|
||||
],
|
||||
[
|
||||
...prefix,
|
||||
'exec',
|
||||
'-T',
|
||||
options.service,
|
||||
'dropdb',
|
||||
'--if-exists',
|
||||
'-U',
|
||||
options.user,
|
||||
options.targetDb,
|
||||
],
|
||||
[
|
||||
...prefix,
|
||||
'exec',
|
||||
'-T',
|
||||
options.service,
|
||||
'createdb',
|
||||
'-U',
|
||||
options.user,
|
||||
options.targetDb,
|
||||
],
|
||||
[
|
||||
...prefix,
|
||||
'exec',
|
||||
'-T',
|
||||
options.service,
|
||||
'pg_restore',
|
||||
'-U',
|
||||
options.user,
|
||||
'-d',
|
||||
options.targetDb,
|
||||
'--no-owner',
|
||||
'--no-acl',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
function runRestore(command, input) {
|
||||
return new Promise((resolveRun, rejectRun) => {
|
||||
const child = spawn(command[0], command.slice(1), {
|
||||
cwd: process.cwd(),
|
||||
stdio: ['pipe', 'inherit', 'inherit'],
|
||||
});
|
||||
createReadStream(input).pipe(child.stdin);
|
||||
child.on('error', rejectRun);
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolveRun();
|
||||
} else {
|
||||
rejectRun(new Error(`pg_restore failed with exit code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2)) {
|
||||
const options = buildOptions(argv);
|
||||
if (options.help) {
|
||||
process.stdout.write(usage());
|
||||
return;
|
||||
}
|
||||
|
||||
const commands = buildRestoreCommands(options);
|
||||
if (options.dryRun) {
|
||||
process.stdout.write(
|
||||
`[dry-run] PostgreSQL restore would recreate database: ${options.targetDb}\n`,
|
||||
);
|
||||
for (const command of commands.slice(0, -1)) {
|
||||
process.stdout.write(`${commandToString(command)}\n`);
|
||||
}
|
||||
process.stdout.write(`${commandToString(commands.at(-1), { stdin: options.input })}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const command of commands.slice(0, -1)) {
|
||||
await runCommand(command);
|
||||
}
|
||||
await runRestore(commands.at(-1), options.input);
|
||||
process.stdout.write(`PostgreSQL restore completed into fresh database: ${options.targetDb}\n`);
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user