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 { 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 { 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 = Promise.resolve(); function enqueue(name: string, task: () => Promise): 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 { 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 { 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): Promise { 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)); }); }