feat(backup): full DR bundle export + admin-configurable offsite destinations
Backend-agnostic disaster-recovery backup engine that runs on the current storage backend (no storage cutover required): - Full-bundle export: db.dump (pg_dump custom) + every storage blob + manifest.json with per-object SHA-256, streamed as a tar. Entry points: admin UI download, GET /api/v1/admin/backup/export, scripts/create-full-backup.ts. - Admin-configurable push destinations (backup_destinations table, migration 0091): SFTP/SSH, S3-compatible (reuses the minio client), and mounted path/NAS behind one transport interface (test/push/prune). Secrets AES-GCM at rest; API returns only *IsSet markers. - Opt-in per-destination AES-256 bundle encryption (scrypt KDF, streamed) + scripts/decrypt-backup.ts for restore. - Wired the previously-dead database-backup cron to runScheduledBackupPush (push to enabled destinations, prune to retention, alert super-admins on failure). Tests: 1608 unit/integration pass; tsc + lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
48
scripts/create-full-backup.ts
Normal file
48
scripts/create-full-backup.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Produce a full disaster-recovery bundle (db.dump + every blob + manifest.json)
|
||||
* to a local file. Same code path as the admin "Download full backup" button
|
||||
* (`createFullBackupTar`), minus the HTTP layer — for headless/ops use and for
|
||||
* rehearsing the restore runbook (docs/backup-restore-runbook.md).
|
||||
*
|
||||
* pnpm tsx scripts/create-full-backup.ts [outfile.tar]
|
||||
*
|
||||
* Defaults the output name to ./pn-crm-backup-<timestamp>.tar in the CWD.
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
|
||||
import { copyFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createFullBackupTar } from '@/lib/services/backup-export.service';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const { tarPath, filename, manifest, cleanup } = await createFullBackupTar();
|
||||
try {
|
||||
const dest = path.resolve(process.argv[2] ?? filename);
|
||||
await copyFile(tarPath, dest);
|
||||
logger.info(
|
||||
{
|
||||
dest,
|
||||
storageBackend: manifest.storageBackend,
|
||||
dbDumpBytes: manifest.database.sizeBytes,
|
||||
blobs: manifest.counts.blobs,
|
||||
blobBytes: manifest.counts.blobBytes,
|
||||
skipped: manifest.counts.skipped,
|
||||
},
|
||||
'Full backup written',
|
||||
);
|
||||
if (manifest.skipped.length) {
|
||||
logger.warn({ skipped: manifest.skipped }, 'Some referenced blobs were missing in storage');
|
||||
}
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.stdout.write('', () => process.exit(0)))
|
||||
.catch((err) => {
|
||||
logger.error({ err }, 'Full backup failed');
|
||||
process.stderr.write('', () => process.exit(1));
|
||||
});
|
||||
Reference in New Issue
Block a user