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:
2026-07-08 16:10:15 +08:00
parent 27cc1badc7
commit 2161970543
9 changed files with 697 additions and 1 deletions

139
scripts/backup-postgres.mjs Normal file
View File

@@ -0,0 +1,139 @@
#!/usr/bin/env node
import { createWriteStream, unlinkSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { spawn } from 'node:child_process';
import {
commandToString,
composePrefix,
ensureParentDir,
flag,
option,
parseArgs,
readEnvFile,
timestampSlug,
} from './ops-utils.mjs';
function usage() {
return `Usage: node scripts/backup-postgres.mjs [options]
Options:
--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)
--db <name> Database name (default: POSTGRES_DB or ftb_pm)
--backup-dir <path> Directory for generated backups (default: backups/postgres)
--output <path> Exact output dump path
--dry-run Print the pg_dump command without writing a file
--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 db = option(args, 'db', env.POSTGRES_DB || 'ftb_pm');
const user = option(args, 'user', env.POSTGRES_USER || 'postgres');
const backupDir = option(args, 'backup-dir', 'backups/postgres');
const output = option(
args,
'output',
join(backupDir, `${db}-postgres-${timestampSlug()}.dump`),
);
return {
help: false,
dryRun: flag(args, 'dry-run'),
envFile,
composeFile: option(args, 'compose-file', 'docker-compose.prod.yml'),
service: option(args, 'service', 'postgres'),
db,
user,
output: resolve(output),
};
}
export function buildPgDumpCommand(options) {
return [
...composePrefix(options),
'exec',
'-T',
options.service,
'pg_dump',
'-U',
options.user,
'-d',
options.db,
'--format=custom',
'--no-owner',
'--no-acl',
];
}
async function writeBackup(command, output) {
ensureParentDir(output);
await new Promise((resolveWrite, rejectWrite) => {
const file = createWriteStream(output, { flags: 'wx' });
const child = spawn(command[0], command.slice(1), {
cwd: process.cwd(),
stdio: ['ignore', 'pipe', 'inherit'],
});
let settled = false;
const rejectOnce = (error) => {
if (settled) return;
settled = true;
try {
unlinkSync(output);
} catch {
// Best effort cleanup of a partial dump.
}
rejectWrite(error);
};
child.stdout.pipe(file);
child.on('error', rejectOnce);
file.on('error', rejectOnce);
child.on('close', (code) => {
file.end(() => {
if (settled) return;
settled = true;
if (code === 0) {
resolveWrite();
} else {
rejectOnce(new Error(`pg_dump 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 command = buildPgDumpCommand(options);
if (options.dryRun) {
process.stdout.write(`[dry-run] PostgreSQL backup would write: ${options.output}\n`);
process.stdout.write(`${commandToString(command, { stdout: options.output })}\n`);
return;
}
await writeBackup(command, options.output);
process.stdout.write(`PostgreSQL backup written: ${options.output}\n`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
process.stderr.write(`${error.message}\n`);
process.exit(1);
});
}