dorfteich/apps/backup/src/pg.ts
Claude Fable 5 8dbff86537
All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m9s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m47s
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m10s
CD / Promote to Int (push) Successful in 9s
CI / Auth e2e pack (push) Successful in 5m25s
CI / Import/export fidelity gate (push) Successful in 45s
Add backup sidecar: nightly dump, volume archive, prune, status, restore (#83)
New apps/backup service (ADR 0015): nightly pg_dump -Fc plus one tar of the
uploads/plugins volumes as a consistent restore set on a new backups volume,
retention prune that never removes the newest complete set, atomic
status.json for the readiness/admin consumers (#85/#86), and a failure mail
sent directly via nodemailer (the api may be the broken part) with de/en
texts in the shared mails catalog. BACKUP_RUN_ONCE=1 gives the on-demand
path; deploy/backup/restore.sh automates the documented restore runbook.
The pure secret-store helpers moved to @dorfteich/shared so the sidecar
resolves the wizard-written SMTP relay exactly like the api.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 17:29:15 +02:00

55 lines
2.0 KiB
TypeScript

import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
/**
* Connection settings for the libpq CLI tools as environment variables.
* Deliberately not `--dbname=<url>`: the URL carries the password, and argv
* is world-readable inside the container (`/proc/<pid>/cmdline`) — the
* credential-handling rule is env/file only, never argv.
*/
export function pgEnvFromUrl(databaseUrl: string): Record<string, string> {
const url = new URL(databaseUrl);
const env: Record<string, string> = {
PGHOST: url.hostname,
PGDATABASE: decodeURIComponent(url.pathname.replace(/^\//, '')),
};
if (url.port) env.PGPORT = url.port;
if (url.username) env.PGUSER = decodeURIComponent(url.username);
if (url.password) env.PGPASSWORD = decodeURIComponent(url.password);
return env;
}
async function runPgTool(tool: string, args: string[], databaseUrl: string): Promise<void> {
try {
await execFileAsync(tool, args, {
env: { ...process.env, ...pgEnvFromUrl(databaseUrl) },
maxBuffer: 16 * 1024 * 1024,
});
} catch (error) {
const stderr = (error as { stderr?: string }).stderr?.trim();
throw new Error(`${tool} failed: ${stderr || (error as Error).message}`);
}
}
/** `pg_dump -Fc` of the whole database into `outFile` (ADR 0015). */
export async function pgDump(databaseUrl: string, outFile: string): Promise<void> {
await runPgTool('pg_dump', ['--format=custom', '--file', outFile], databaseUrl);
}
/**
* Restores a custom-format dump into the live database. `--clean
* --if-exists` drops recreated objects first, so the restore lands on a
* database that may still hold newer state (the runbook stops the app
* services, not the db container).
*/
export async function pgRestore(databaseUrl: string, dumpFile: string): Promise<void> {
const database = pgEnvFromUrl(databaseUrl).PGDATABASE ?? '';
await runPgTool(
'pg_restore',
['--clean', '--if-exists', '--no-owner', '--dbname', database, dumpFile],
databaseUrl,
);
}