From 2161970543b477960e878b0111b27b8567faea48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=80=82?= Date: Wed, 8 Jul 2026 16:10:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(ops):=20=E6=B7=BB=E5=8A=A0=E7=94=9F?= =?UTF-8?q?=E4=BA=A7=E5=A4=87=E4=BB=BD=E6=81=A2=E5=A4=8D=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 PostgreSQL 备份、fresh DB 恢复和 server_data volume 备份脚本\n- 恢复默认拒绝覆盖,必须显式 --confirm-overwrite\n- 补充 package scripts、deploy verify 校验和部署文档\n\nCo-Authored-By: GPT-5 Codex --- .gitignore | 1 + docs/deployment.md | 58 ++++++++- package.json | 4 + scripts/backup-postgres.mjs | 139 ++++++++++++++++++++ scripts/backup-server-data.mjs | 99 +++++++++++++++ scripts/ops-scripts.test.mjs | 85 +++++++++++++ scripts/ops-utils.mjs | 116 +++++++++++++++++ scripts/restore-postgres.mjs | 181 +++++++++++++++++++++++++++ scripts/verify-production-deploy.mjs | 15 +++ 9 files changed, 697 insertions(+), 1 deletion(-) create mode 100644 scripts/backup-postgres.mjs create mode 100644 scripts/backup-server-data.mjs create mode 100644 scripts/ops-scripts.test.mjs create mode 100644 scripts/ops-utils.mjs create mode 100644 scripts/restore-postgres.mjs diff --git a/.gitignore b/.gitignore index 1eb6fa2..6793171 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ next-env.d.ts *.tsbuildinfo apps/server/data/ .worktrees/ +backups/ diff --git a/docs/deployment.md b/docs/deployment.md index 0b75669..830ac87 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -203,6 +203,61 @@ pnpm deploy:check-runtime http://localhost/api/v1/health/version ftb_pm_backup.sql +pnpm backup:postgres -- --env-file .env.production +pnpm backup:server-data -- --env-file .env.production ``` ## 常见排查 diff --git a/package.json b/package.json index 0cd5de7..0663a3e 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,12 @@ "lint": "turbo lint", "type-check": "turbo type-check", "test": "turbo test", + "ops:test": "node --test scripts/*.test.mjs", "deploy:verify": "node scripts/verify-production-deploy.mjs", "deploy:check-runtime": "node scripts/check-runtime-version.mjs", + "backup:postgres": "node scripts/backup-postgres.mjs", + "restore:postgres": "node scripts/restore-postgres.mjs", + "backup:server-data": "node scripts/backup-server-data.mjs", "deploy:local:build": "docker compose --env-file .env.local-server -f docker-compose.local.yml build", "deploy:local:up": "docker compose --env-file .env.local-server -f docker-compose.local.yml up -d", "deploy:local:down": "docker compose --env-file .env.local-server -f docker-compose.local.yml down", diff --git a/scripts/backup-postgres.mjs b/scripts/backup-postgres.mjs new file mode 100644 index 0000000..08d134e --- /dev/null +++ b/scripts/backup-postgres.mjs @@ -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 Compose env file (default: .env.production) + --compose-file Compose file (default: docker-compose.prod.yml) + --service Postgres service name (default: postgres) + --user Database user (default: POSTGRES_USER or postgres) + --db Database name (default: POSTGRES_DB or ftb_pm) + --backup-dir Directory for generated backups (default: backups/postgres) + --output 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); + }); +} + diff --git a/scripts/backup-server-data.mjs b/scripts/backup-server-data.mjs new file mode 100644 index 0000000..8e5bfc9 --- /dev/null +++ b/scripts/backup-server-data.mjs @@ -0,0 +1,99 @@ +#!/usr/bin/env node +import { basename, dirname, join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { + commandToString, + ensureParentDir, + flag, + option, + parseArgs, + readEnvFile, + runCommand, + timestampSlug, +} from './ops-utils.mjs'; + +function usage() { + return `Usage: node scripts/backup-server-data.mjs [options] + +Backs up the server_data Docker volume that stores runtime AI provider config. + +Options: + --env-file Compose env file used to derive COMPOSE_PROJECT_NAME + (default: .env.production) + --volume Explicit Docker volume name + --backup-dir Directory for generated backups (default: backups/server-data) + --output Exact output .tgz path + --image Utility image (default: alpine:3.20) + --dry-run Print the docker run 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 projectName = env.COMPOSE_PROJECT_NAME || 'ftb_pm'; + const volume = option(args, 'volume', `${projectName}_server_data`); + const backupDir = option(args, 'backup-dir', 'backups/server-data'); + const output = option( + args, + 'output', + join(backupDir, `${volume}-${timestampSlug()}.tgz`), + ); + + return { + help: false, + dryRun: flag(args, 'dry-run'), + image: option(args, 'image', 'alpine:3.20'), + volume, + output: resolve(output), + }; +} + +export function buildServerDataBackupCommand(options) { + const outputDir = dirname(options.output); + const outputName = basename(options.output); + return [ + 'docker', + 'run', + '--rm', + '-v', + `${options.volume}:/data:ro`, + '-v', + `${outputDir}:/backup`, + options.image, + 'sh', + '-lc', + `tar -czf /backup/${outputName} -C /data .`, + ]; +} + +export async function main(argv = process.argv.slice(2)) { + const options = buildOptions(argv); + if (options.help) { + process.stdout.write(usage()); + return; + } + + const command = buildServerDataBackupCommand(options); + if (options.dryRun) { + process.stdout.write(`[dry-run] server_data backup would write: ${options.output}\n`); + process.stdout.write(`${commandToString(command)}\n`); + return; + } + + ensureParentDir(options.output); + await runCommand(command); + process.stdout.write(`server_data 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); + }); +} + diff --git a/scripts/ops-scripts.test.mjs b/scripts/ops-scripts.test.mjs new file mode 100644 index 0000000..e057418 --- /dev/null +++ b/scripts/ops-scripts.test.mjs @@ -0,0 +1,85 @@ +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, writeFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +const root = resolve(import.meta.dirname, '..'); + +function runScript(script, args) { + return spawnSync(process.execPath, [join(root, script), ...args], { + cwd: root, + encoding: 'utf8', + }); +} + +describe('production ops scripts', () => { + it('prints a pg_dump command in dry-run mode without writing the target file', () => { + const dir = mkdtempSync(join(tmpdir(), 'ftb-pg-backup-')); + const output = join(dir, 'backup.dump'); + + const result = runScript('scripts/backup-postgres.mjs', [ + '--dry-run', + '--env-file', + '.env.production.example', + '--output', + output, + ]); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /docker compose/); + assert.match(result.stdout, /pg_dump/); + assert.match(result.stdout, /backup\.dump/); + assert.equal(existsSync(output), false); + }); + + it('refuses restore by default unless --confirm-overwrite is supplied', () => { + const dir = mkdtempSync(join(tmpdir(), 'ftb-pg-restore-')); + const input = join(dir, 'backup.dump'); + writeFileSync(input, 'not-a-real-dump'); + + const result = runScript('scripts/restore-postgres.mjs', [ + '--env-file', + '.env.production.example', + '--input', + input, + ]); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /--confirm-overwrite/); + }); + + it('prints fresh database restore steps in dry-run mode after explicit overwrite confirmation', () => { + const dir = mkdtempSync(join(tmpdir(), 'ftb-pg-restore-dry-')); + const input = join(dir, 'backup.dump'); + writeFileSync(input, 'not-a-real-dump'); + + const result = runScript('scripts/restore-postgres.mjs', [ + '--dry-run', + '--confirm-overwrite', + '--env-file', + '.env.production.example', + '--input', + input, + ]); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /dropdb/); + assert.match(result.stdout, /createdb/); + assert.match(result.stdout, /pg_restore/); + }); + + it('prints a server_data volume tar backup command in dry-run mode', () => { + const result = runScript('scripts/backup-server-data.mjs', [ + '--dry-run', + '--env-file', + '.env.production.example', + ]); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /docker run/); + assert.match(result.stdout, /ftb_pm_server_data/); + assert.match(result.stdout, /tar -czf/); + }); +}); diff --git a/scripts/ops-utils.mjs b/scripts/ops-utils.mjs new file mode 100644 index 0000000..2d01f02 --- /dev/null +++ b/scripts/ops-utils.mjs @@ -0,0 +1,116 @@ +import { existsSync, mkdirSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { spawn } from 'node:child_process'; + +export function parseArgs(argv) { + const parsed = { flags: new Set(), values: new Map(), positionals: [] }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--') continue; + if (!arg.startsWith('--')) { + parsed.positionals.push(arg); + continue; + } + + const eq = arg.indexOf('='); + if (eq !== -1) { + parsed.values.set(arg.slice(2, eq), arg.slice(eq + 1)); + continue; + } + + const key = arg.slice(2); + const next = argv[index + 1]; + if (next && !next.startsWith('--')) { + parsed.values.set(key, next); + index += 1; + } else { + parsed.flags.add(key); + } + } + + return parsed; +} + +export function option(args, name, fallback = undefined) { + return args.values.has(name) ? args.values.get(name) : fallback; +} + +export function flag(args, name) { + return args.flags.has(name); +} + +export function readEnvFile(filePath, { optional = false } = {}) { + if (!existsSync(filePath)) { + if (optional) return {}; + throw new Error(`Env file not found: ${filePath}`); + } + + const env = {}; + const content = readFileSync(filePath, 'utf8'); + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const eq = line.indexOf('='); + if (eq === -1) continue; + const key = line.slice(0, eq).trim(); + let value = line.slice(eq + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + env[key] = value; + } + return env; +} + +export function ensureParentDir(filePath) { + mkdirSync(dirname(filePath), { recursive: true }); +} + +export function timestampSlug(date = new Date()) { + return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z'); +} + +export function shellQuote(value) { + if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) return value; + return `'${value.replace(/'/g, "'\\''")}'`; +} + +export function commandToString(command, { stdin, stdout } = {}) { + const rendered = command.map((part) => shellQuote(part)).join(' '); + const withStdin = stdin ? `${rendered} < ${shellQuote(stdin)}` : rendered; + return stdout ? `${withStdin} > ${shellQuote(stdout)}` : withStdin; +} + +export function composePrefix({ envFile, composeFile }) { + return ['docker', 'compose', '--env-file', envFile, '-f', composeFile]; +} + +export function resolvePath(path) { + return resolve(process.cwd(), path); +} + +export function runCommand(command, options = {}) { + return new Promise((resolveRun, rejectRun) => { + const child = spawn(command[0], command.slice(1), { + stdio: options.stdio ?? 'inherit', + cwd: options.cwd ?? process.cwd(), + }); + + child.on('error', rejectRun); + child.on('close', (code) => { + if (code === 0) { + resolveRun(); + } else { + rejectRun(new Error(`Command failed (${code}): ${commandToString(command)}`)); + } + }); + }); +} + +export function quotePgLiteral(value) { + return `'${value.replace(/'/g, "''")}'`; +} diff --git a/scripts/restore-postgres.mjs b/scripts/restore-postgres.mjs new file mode 100644 index 0000000..0322c9e --- /dev/null +++ b/scripts/restore-postgres.mjs @@ -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 --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 pg_dump custom-format dump file + --confirm-overwrite Required safety flag for destructive restore + --env-file Compose env file (default: .env.production) + --compose-file Compose file (default: docker-compose.prod.yml) + --service Postgres service name (default: postgres) + --user Database user (default: POSTGRES_USER or postgres) + --target-db Target database (default: POSTGRES_DB or ftb_pm) + --maintenance-db 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 '); + } + + 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); + }); +} + diff --git a/scripts/verify-production-deploy.mjs b/scripts/verify-production-deploy.mjs index bb238d3..510311e 100644 --- a/scripts/verify-production-deploy.mjs +++ b/scripts/verify-production-deploy.mjs @@ -66,6 +66,18 @@ const checks = [ file: 'scripts/check-runtime-version.mjs', snippets: ['Runtime version verified', '/api/v1/health/version', 'expectedVersion'], }, + { + file: 'scripts/backup-postgres.mjs', + snippets: ['pg_dump', '--format=custom', '--dry-run'], + }, + { + file: 'scripts/restore-postgres.mjs', + snippets: ['--confirm-overwrite', 'dropdb', 'pg_restore'], + }, + { + file: 'scripts/backup-server-data.mjs', + snippets: ['server_data', 'tar -czf', '--dry-run'], + }, { file: 'docker-compose.local.yml', snippets: [ @@ -118,6 +130,9 @@ const checks = [ 'docker-compose.local.yml', '本地服务器部署', 'pnpm deploy:verify', + 'pnpm backup:postgres', + 'pnpm restore:postgres', + 'pnpm backup:server-data', 'pnpm db:migrate', 'NEXT_PUBLIC_API_URL', 'Nginx',