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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -15,3 +15,4 @@ next-env.d.ts
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
apps/server/data/
|
apps/server/data/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
backups/
|
||||||
|
|||||||
@@ -203,6 +203,61 @@ pnpm deploy:check-runtime http://localhost/api/v1/health/version <expected-commi
|
|||||||
|
|
||||||
浏览器不再作为业务数据主存储。清浏览器缓存不会删除产品、项目、版本、任务、测试用例、Bug 等业务数据。
|
浏览器不再作为业务数据主存储。清浏览器缓存不会删除产品、项目、版本、任务、测试用例、Bug 等业务数据。
|
||||||
|
|
||||||
|
## 备份与恢复
|
||||||
|
|
||||||
|
备份分两类:PostgreSQL 业务数据和 `server_data` volume 中的运行时配置。真实备份文件默认写到 `backups/`,该目录已加入 `.gitignore`,不要把 dump 或 tar 包提交到仓库。
|
||||||
|
|
||||||
|
先演练命令,不写文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm backup:postgres -- --dry-run --env-file .env.production
|
||||||
|
pnpm backup:server-data -- --dry-run --env-file .env.production
|
||||||
|
```
|
||||||
|
|
||||||
|
执行正式备份:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm backup:postgres -- --env-file .env.production
|
||||||
|
pnpm backup:server-data -- --env-file .env.production
|
||||||
|
```
|
||||||
|
|
||||||
|
PostgreSQL 备份使用 `pg_dump --format=custom --no-owner --no-acl`,便于跨环境恢复。`server_data` 备份使用只读 volume mount 和 `tar -czf`,覆盖 AI Provider 配置等后端运行时文件。
|
||||||
|
|
||||||
|
恢复数据库必须显式确认覆盖。默认命令会拒绝执行,防止误删现有库:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm restore:postgres -- --env-file .env.production --input backups/postgres/ftb_pm-postgres-20260708T120000Z.dump
|
||||||
|
```
|
||||||
|
|
||||||
|
确认要把目标数据库重建为 fresh DB 后,再运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm restore:postgres -- \
|
||||||
|
--env-file .env.production \
|
||||||
|
--input backups/postgres/ftb_pm-postgres-20260708T120000Z.dump \
|
||||||
|
--confirm-overwrite
|
||||||
|
```
|
||||||
|
|
||||||
|
恢复前建议先 dry-run 看清将执行的 `psql/dropdb/createdb/pg_restore` 步骤:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm restore:postgres -- \
|
||||||
|
--dry-run \
|
||||||
|
--confirm-overwrite \
|
||||||
|
--env-file .env.production \
|
||||||
|
--input backups/postgres/ftb_pm-postgres-20260708T120000Z.dump
|
||||||
|
```
|
||||||
|
|
||||||
|
如果要恢复到临时库做校验,不覆盖当前生产库:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm restore:postgres -- \
|
||||||
|
--env-file .env.production \
|
||||||
|
--input backups/postgres/ftb_pm-postgres-20260708T120000Z.dump \
|
||||||
|
--target-db ftb_pm_restore_check \
|
||||||
|
--confirm-overwrite
|
||||||
|
```
|
||||||
|
|
||||||
## 升级流程
|
## 升级流程
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -216,7 +271,8 @@ docker compose --env-file .env.production -f docker-compose.prod.yml exec server
|
|||||||
升级前建议先备份数据库:
|
升级前建议先备份数据库:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --env-file .env.production -f docker-compose.prod.yml exec postgres pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" > ftb_pm_backup.sql
|
pnpm backup:postgres -- --env-file .env.production
|
||||||
|
pnpm backup:server-data -- --env-file .env.production
|
||||||
```
|
```
|
||||||
|
|
||||||
## 常见排查
|
## 常见排查
|
||||||
|
|||||||
@@ -7,8 +7,12 @@
|
|||||||
"lint": "turbo lint",
|
"lint": "turbo lint",
|
||||||
"type-check": "turbo type-check",
|
"type-check": "turbo type-check",
|
||||||
"test": "turbo test",
|
"test": "turbo test",
|
||||||
|
"ops:test": "node --test scripts/*.test.mjs",
|
||||||
"deploy:verify": "node scripts/verify-production-deploy.mjs",
|
"deploy:verify": "node scripts/verify-production-deploy.mjs",
|
||||||
"deploy:check-runtime": "node scripts/check-runtime-version.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: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: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",
|
"deploy:local:down": "docker compose --env-file .env.local-server -f docker-compose.local.yml down",
|
||||||
|
|||||||
139
scripts/backup-postgres.mjs
Normal file
139
scripts/backup-postgres.mjs
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
99
scripts/backup-server-data.mjs
Normal file
99
scripts/backup-server-data.mjs
Normal file
@@ -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 <path> Compose env file used to derive COMPOSE_PROJECT_NAME
|
||||||
|
(default: .env.production)
|
||||||
|
--volume <name> Explicit Docker volume name
|
||||||
|
--backup-dir <path> Directory for generated backups (default: backups/server-data)
|
||||||
|
--output <path> Exact output .tgz path
|
||||||
|
--image <name> 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
85
scripts/ops-scripts.test.mjs
Normal file
85
scripts/ops-scripts.test.mjs
Normal file
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
116
scripts/ops-utils.mjs
Normal file
116
scripts/ops-utils.mjs
Normal file
@@ -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, "''")}'`;
|
||||||
|
}
|
||||||
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -66,6 +66,18 @@ const checks = [
|
|||||||
file: 'scripts/check-runtime-version.mjs',
|
file: 'scripts/check-runtime-version.mjs',
|
||||||
snippets: ['Runtime version verified', '/api/v1/health/version', 'expectedVersion'],
|
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',
|
file: 'docker-compose.local.yml',
|
||||||
snippets: [
|
snippets: [
|
||||||
@@ -118,6 +130,9 @@ const checks = [
|
|||||||
'docker-compose.local.yml',
|
'docker-compose.local.yml',
|
||||||
'本地服务器部署',
|
'本地服务器部署',
|
||||||
'pnpm deploy:verify',
|
'pnpm deploy:verify',
|
||||||
|
'pnpm backup:postgres',
|
||||||
|
'pnpm restore:postgres',
|
||||||
|
'pnpm backup:server-data',
|
||||||
'pnpm db:migrate',
|
'pnpm db:migrate',
|
||||||
'NEXT_PUBLIC_API_URL',
|
'NEXT_PUBLIC_API_URL',
|
||||||
'Nginx',
|
'Nginx',
|
||||||
|
|||||||
Reference in New Issue
Block a user