dorfteich/apps/backup/src/index.ts
Claude Fable 5 394d1c811d
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m52s
CI / Build container images (pull_request) Successful in 3m54s
CI / Auth e2e pack (pull_request) Successful in 8m4s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 13s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 5m0s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m41s
CI / Import/export fidelity gate (push) Successful in 56s
#192: deploy-level backup target allowlist
BACKUP_ALLOWED_TARGETS (comma-separated destination hosts) constrains
where backups may go, enforced twice: the api rejects settings writes
and connection tests towards non-allowlisted hosts with admin-visible
error codes and resolves a non-allowlisted configured target to null,
and the sidecar enforces the same policy at the point of egress for the
WebDAV upload and the rsync mirror alike (shared policy helpers in
packages/shared/src/backup-target-policy.ts).

BREAKING: the empty default disables every remote target - backups stay
local only, the VS-NfD reference configuration (ADR 0026). Existing
deployments with a remote target must list its host or uploads and
mirror stop. The admin UI distinguishes unavailable-by-policy from
unconfigured (i18n de+en) and shows the permitted hosts.

Refs #192 (ADR 0026)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 12:17:14 +02:00

212 lines
7.4 KiB
TypeScript

import { existsSync, readFileSync } from 'node:fs';
import { BACKUP_MAINTENANCE_CHANNEL, parseSecretsFile } from '@dorfteich/shared';
import { Client } from 'pg';
import { pino } from 'pino';
import { createArchive } from './archive.js';
import { createCommandListener } from './commands.js';
import { loadBackupEnv } from './config.js';
import { sendFailureMail } from './mail.js';
import { mirrorSets, resolveMirrorConfig } from './mirror.js';
import { performRestore } from './perform-restore.js';
import { pgDump } from './pg.js';
import { fetchRemoteSet, resolveRemoteTarget, uploadDue, uploadSet } from './remote.js';
import { orchestrateRestore } from './restore-orchestrator.js';
import { runBackup } from './runner.js';
import { scheduleDaily } from './scheduler.js';
import { readBackupDbSettings } from './settings.js';
import type { RunnerDeps } from './runner.js';
import type { BackupCommand } from '@dorfteich/shared';
/**
* Sidecar entrypoint: runs the nightly backup at BACKUP_TIME (ADR 0015) and
* listens for api commands ("back up now", "restore set X", issue #103).
* `BACKUP_RUN_ONCE=1` runs a single backup and exits with the outcome as
* the exit code — the on-demand compose path.
*/
const env = loadBackupEnv();
const log = pino({ level: env.LOG_LEVEL, base: { service: 'backup' } });
function readSecrets(): Record<string, string> {
return existsSync(env.SECRETS_FILE)
? parseSecretsFile(readFileSync(env.SECRETS_FILE, 'utf8'))
: {};
}
/**
* Composes the runner dependencies for one run. Settings come fresh from
* the database (admin changes apply without a restart); the DB row wins
* over the env for local retention, the env stays authoritative until an
* admin saves the setting once. A manual trigger uploads whenever a target
* is configured; scheduled runs honor the upload schedule.
*/
async function buildRunnerDeps(trigger: 'scheduled' | 'manual'): Promise<RunnerDeps> {
const settings = await readBackupDbSettings(env.DATABASE_URL);
const target = resolveRemoteTarget(settings, readSecrets(), env.BACKUP_ALLOWED_TARGETS, log);
const mirrorConfig = resolveMirrorConfig(env, log);
return {
backupsDir: env.BACKUPS_DIR,
retentionDays: settings.localRetentionDays ?? env.BACKUP_RETENTION_DAYS,
now: () => new Date(),
dump: (outFile) => pgDump(env.DATABASE_URL, outFile),
archive: (outFile) => createArchive(outFile, [env.UPLOADS_DIR, env.PLUGINS_DIR]),
onFailure: async (run) => {
const sent = await sendFailureMail(env, run);
if (!sent)
log.warn({ backupId: run.backupId }, 'no BACKUP_MAIL_TO configured, alert not sent');
},
upload: target
? async ({ backupId, previous }) => {
const due =
trigger === 'manual' ||
uploadDue(
settings.nextcloud.uploadSchedule,
previous?.lastSuccessfulUpload?.finishedAt ?? null,
new Date(),
);
if (!due) return undefined;
return uploadSet({
env,
backupsDir: env.BACKUPS_DIR,
target,
remoteRetentionDays: settings.remoteRetentionDays,
backupId,
previous,
now: () => new Date(),
log,
});
}
: undefined,
mirror: mirrorConfig
? ({ previous }) =>
mirrorSets({
env,
config: mirrorConfig,
backupsDir: env.BACKUPS_DIR,
previous,
now: () => new Date(),
log,
})
: undefined,
log,
};
}
/**
* runBackup handles run errors itself (failed status + alert); this guard
* catches what it cannot — e.g. an unwritable backups volume, where even
* status.json fails. Logging instead of crashing keeps the container out
* of a restart loop; the missed run shows up through the freshness check.
*/
async function guardedRun(trigger: 'scheduled' | 'manual'): Promise<'succeeded' | 'failed'> {
try {
return (await runBackup(await buildRunnerDeps(trigger))).lastRun.outcome;
} catch (error) {
log.error({ error: String(error) }, 'backup run could not even record its status');
return 'failed';
}
}
if (process.env.BACKUP_RUN_ONCE === '1') {
process.exit((await guardedRun('manual')) === 'succeeded' ? 0 : 1);
}
/**
* All work — nightly runs, manual runs, restores — flows through one
* serial queue: a restore must never race a running backup, and command
* bursts must not overlap. Tasks never reject (each wraps its own errors).
*/
let queue: Promise<unknown> = Promise.resolve();
function enqueue(name: string, task: () => Promise<unknown>): void {
queue = queue.then(async () => {
try {
await task();
} catch (error) {
log.error({ task: name, error: String(error) }, 'queued task failed unexpectedly');
}
});
}
async function notifyMaintenance(event: { phase: 'enter' | 'exit' }): Promise<void> {
const client = new Client({ connectionString: env.DATABASE_URL });
try {
await client.connect();
await client.query('SELECT pg_notify($1, $2)', [
BACKUP_MAINTENANCE_CHANNEL,
JSON.stringify(event),
]);
} finally {
await client.end().catch(() => undefined);
}
}
/**
* Kills every other connection to the database right before pg_restore —
* the in-app equivalent of restore.sh stopping the app services. The api
* is already holding requests in maintenance mode and restarts afterwards;
* pools and listeners (including our own command listener) reconnect.
*/
async function terminateOtherConnections(): Promise<void> {
const client = new Client({ connectionString: env.DATABASE_URL });
try {
await client.connect();
await client.query(
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity
WHERE datname = current_database() AND pid <> pg_backend_pid()`,
);
} finally {
await client.end().catch(() => undefined);
}
}
async function handleRestore(command: Extract<BackupCommand, { kind: 'restore' }>): Promise<void> {
const settings = await readBackupDbSettings(env.DATABASE_URL);
const target = resolveRemoteTarget(settings, readSecrets(), env.BACKUP_ALLOWED_TARGETS, log);
await orchestrateRestore(
{
backupsDir: env.BACKUPS_DIR,
now: () => new Date(),
notifyMaintenance,
terminateOtherConnections,
fetchRemoteSet: async (backupId) => {
if (!target) throw new Error('no Nextcloud target configured');
await fetchRemoteSet({ backupsDir: env.BACKUPS_DIR, target, backupId, log });
},
restoreSet: (backupId) => performRestore(env, backupId, log),
log,
},
command,
);
}
log.info(
{ time: env.BACKUP_TIME, retentionDays: env.BACKUP_RETENTION_DAYS, dir: env.BACKUPS_DIR },
'backup sidecar started',
);
const schedule = scheduleDaily(
env.BACKUP_TIME,
async () => enqueue('scheduled backup', () => guardedRun('scheduled')),
log,
);
const commands = createCommandListener({
createClient: () => new Client({ connectionString: env.DATABASE_URL }),
onCommand: (command) => {
if (command.kind === 'run') {
log.info({ requestedBy: command.requestedBy }, 'manual backup requested');
enqueue('manual backup', () => guardedRun('manual'));
} else {
enqueue('restore', () => handleRestore(command));
}
},
log,
});
await commands.start();
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
process.on(signal, () => {
schedule.stop();
void commands.stop().finally(() => process.exit(0));
});
}