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
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
181 lines
6.5 KiB
TypeScript
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');
|
|
}
|
|
}
|