dorfteich/apps/backup/src/runner.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

133 lines
4.5 KiB
TypeScript

import { mkdir, readdir, rename, rm, stat } from 'node:fs/promises';
import { join } from 'node:path';
import { archiveFileName, dumpFileName, expiredSets, listSets, newBackupId } from './backup-set.js';
import {
readStatus,
writeStatus,
type BackupRun,
type BackupSizes,
type BackupStatus,
} from './status.js';
/**
* One nightly run (ADR 0015): dump first, then the volume archive (files may
* be minutes newer than the dump — documented consistency model), then prune,
* then `status.json`. Artifacts are written under a `.partial` suffix and
* renamed on completion, so a crash never leaves a file that looks like a
* restorable artifact, and prune (which ignores `.partial`) never counts a
* torn set as the "newest complete" one.
*/
export interface RunnerDeps {
backupsDir: string;
retentionDays: number;
now(): Date;
/** Real implementation: pg_dump -Fc (pg.ts). */
dump(outFile: string): Promise<void>;
/** Real implementation: tar of the uploads/plugins mounts (archive.ts). */
archive(outFile: string): Promise<void>;
/** Failure alert (mail.ts); errors here are logged, never rethrown. */
onFailure(run: { backupId: string; error: string; lastSuccessAt: string | null }): Promise<void>;
log: {
info(details: object, message: string): void;
error(details: object, message: string): void;
};
}
export async function runBackup(deps: RunnerDeps): Promise<BackupStatus> {
const startedAt = deps.now();
const backupId = newBackupId(startedAt);
const previous = readStatus(deps.backupsDir);
const lastSuccess = previous?.lastSuccess ?? null;
await mkdir(deps.backupsDir, { recursive: true });
const dumpFile = join(deps.backupsDir, dumpFileName(backupId));
const archiveFile = join(deps.backupsDir, archiveFileName(backupId));
let run: BackupRun;
let success: BackupStatus['lastSuccess'] = lastSuccess;
try {
const sizes = await produceArtifacts(deps, dumpFile, archiveFile);
const finishedAt = deps.now();
run = {
backupId,
startedAt: startedAt.toISOString(),
finishedAt: finishedAt.toISOString(),
durationMs: finishedAt.getTime() - startedAt.getTime(),
outcome: 'succeeded',
sizes,
};
success = { backupId, finishedAt: run.finishedAt, sizes };
deps.log.info({ backupId, sizes }, 'backup run succeeded');
} catch (error) {
await removePartials(deps.backupsDir);
const message = error instanceof Error ? error.message : String(error);
const finishedAt = deps.now();
run = {
backupId,
startedAt: startedAt.toISOString(),
finishedAt: finishedAt.toISOString(),
durationMs: finishedAt.getTime() - startedAt.getTime(),
outcome: 'failed',
error: message,
};
deps.log.error({ backupId, error: message }, 'backup run failed');
try {
await deps.onFailure({
backupId,
error: message,
lastSuccessAt: lastSuccess?.finishedAt ?? null,
});
} catch (mailError) {
deps.log.error({ backupId, error: String(mailError) }, 'failure alert could not be sent');
}
}
await prune(deps);
const status: BackupStatus = {
schemaVersion: 1,
updatedAt: deps.now().toISOString(),
retentionDays: deps.retentionDays,
lastRun: run,
lastSuccess: success,
};
await writeStatus(deps.backupsDir, status);
return status;
}
/** Dump, then archive — each staged as `.partial` and renamed only when done. */
async function produceArtifacts(
deps: RunnerDeps,
dumpFile: string,
archiveFile: string,
): Promise<BackupSizes> {
await deps.dump(`${dumpFile}.partial`);
await rename(`${dumpFile}.partial`, dumpFile);
await deps.archive(`${archiveFile}.partial`);
await rename(`${archiveFile}.partial`, archiveFile);
return {
dumpBytes: (await stat(dumpFile)).size,
archiveBytes: (await stat(archiveFile)).size,
};
}
/** Leftover staging files: from this failed run or an earlier crash — no
* resumption path exists, so they are dead weight either way. */
async function removePartials(backupsDir: string): Promise<void> {
for (const name of await readdir(backupsDir)) {
if (name.endsWith('.partial')) await rm(join(backupsDir, name), { force: true });
}
}
async function prune(deps: RunnerDeps): Promise<void> {
const names = await readdir(deps.backupsDir);
for (const set of expiredSets(listSets(names), deps.now(), deps.retentionDays)) {
for (const file of set.files) {
await rm(join(deps.backupsDir, file), { force: true });
}
deps.log.info({ backupId: set.id }, 'pruned expired backup set');
}
}