dorfteich/apps/backup/src/runner.ts
Claude Fable 5 52192eb05f
All checks were successful
CD / Build and push images (push) Successful in 3m51s
CI / Lint, typecheck, test (push) Successful in 4m5s
CD / Deploy to Test (push) Successful in 11s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m11s
CD / Promote to Int (push) Successful in 12s
CI / Auth e2e pack (push) Successful in 5m52s
CI / Import/export fidelity gate (push) Successful in 47s
Backup mirror to BASEL: rsync of the sets after every successful run (#84)
The operator-level extra beside the admin-configured Nextcloud target
(#103), unblocked now that the ONE→BASEL tunnel is stable again.

- sidecar: optional mirror step (mirror.ts) driven purely by env —
  BACKUP_MIRROR_TARGET (rsync-over-ssh), BACKUP_MIRROR_SSH_KEY (private
  key on the secrets volume, never in image or repo),
  BACKUP_MIRROR_SSH_PORT. Runs after the prune of every successful run,
  so --delete aligns the remote retention with the local one (the
  newest-complete-set guarantee carries over). Only set files travel
  (db-*.dump, files-*.tar.gz); status files and bundles stay local.
  Host key pinned via accept-new into .mirror_known_hosts on the backups
  volume; fixed remote modes (dirs 750, files 640, symbolic --chmod —
  octal needs rsync ≥ 3, macOS dev machines ship 2.6.9). rsync +
  openssh-client added to the sidecar image.
- status: additive `mirror` block in status.json (outcome, transferred
  count, lastSuccessAt carried across failures) — shown on the admin
  backup card; failures alert via a new backupMirrorFailed mail (de+en)
  while the local run still counts as succeeded.
- deploy/backup-basel.md: complete BASEL-side walkthrough — dedicated
  user dorfteich-backup with a /home/ home and a bash login shell,
  explicitly avoiding the Debian backup-user (UID 34) pitfalls
  (nologin shell rejects rsync sessions, /var/backups home), key
  placement through the api container onto the secrets volume, .env
  values, on-demand verification.
- tests: rsync-arg/stats-parsing units plus an integration suite against
  the real rsync binary (local target; skips where rsync is absent) —
  transfer, idempotent re-run (0 files), retention alignment, failure
  path carrying lastSuccessAt.

Verified live against the real BASEL host from a native sidecar run:
initial transfer, host-key pinning, retention alignment after a local
prune, idempotency, and the failure path (surfaced in status.json while
the local run stayed green). BASEL side provisioned per the doc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-12 12:20:32 +02:00

181 lines
6.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 type { BackupMirrorStatus, BackupRemoteStatus } from '@dorfteich/shared';
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>;
/**
* Off-host upload of the completed set (issue #103, remote.ts). Called
* only after a successful local run; returns the new remote status, or
* undefined when no upload happened (not configured / not due) — the
* previous remote status is then carried unchanged. Upload failures are
* reported inside the returned status, never thrown: the local set exists
* and the run must count as succeeded.
*/
upload?(input: {
backupId: string;
previous: BackupRemoteStatus | undefined;
}): Promise<BackupRemoteStatus | undefined>;
/**
* rsync mirror to a private host (issue #84, mirror.ts). Runs after the
* prune so the remote retention aligns with the local one. Failures are
* reported inside the returned status, never thrown.
*/
mirror?(input: {
previous: BackupMirrorStatus | undefined;
}): Promise<BackupMirrorStatus | undefined>;
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;
let remote = previous?.remote;
let mirror = previous?.mirror;
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');
if (deps.upload) {
try {
remote = (await deps.upload({ backupId, previous: remote })) ?? remote;
} catch (uploadError) {
// Defensive: remote.ts reports failures in its return value; a throw
// here is a bug, but it must never turn a good local run into a
// failed one.
deps.log.error({ backupId, error: String(uploadError) }, 'upload hook threw unexpectedly');
}
}
} 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);
// After the prune, so `--delete` aligns the remote retention with the
// local one — including the newest-complete-set guarantee.
if (run.outcome === 'succeeded' && deps.mirror) {
try {
mirror = (await deps.mirror({ previous: mirror })) ?? mirror;
} catch (mirrorError) {
// Defensive like the upload hook: mirror.ts reports failures in its
// return value; a throw here must never fail the local run.
deps.log.error({ error: String(mirrorError) }, 'mirror hook threw unexpectedly');
}
}
const status: BackupStatus = {
schemaVersion: 1,
updatedAt: deps.now().toISOString(),
retentionDays: deps.retentionDays,
lastRun: run,
lastSuccess: success,
...(remote ? { remote } : {}),
...(mirror ? { mirror } : {}),
};
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');
}
}