/** * backups/retention.ts - pure retention planning. * * Retention decides which snapshots restic keeps and which it forgets. Two * safety properties matter and both live here as pure functions: * * 1. SAFE DEFAULT: retention is OFF unless explicitly configured. A brand-new * backup config keeps every snapshot forever until the user opts in, so a * misconfiguration can never silently start deleting backups. * * 2. NEVER WIPE EVERYTHING: before a real prune, restic is run once with * `--dry-run` and the plan is parsed. If the configured policy would delete * EVERY snapshot in the group (keep = 0), the prune is refused. A policy * that keeps nothing is always a mistake, not an intent. * * The policy is a discriminated union so contradictory settings are * unrepresentable - you cannot ask for "keep last 5" and "keep 6 monthly" at * once; you pick one mode. */ /** * Retention policy. `none` is the safe default (keep everything). `keep-last` * keeps the N most recent snapshots. `time` keeps the most recent within each * time bucket (restic's --keep-daily/weekly/monthly/yearly). */ export type RetentionPolicy = | { mode: 'none' } | { mode: 'keep-last'; last: number } | { mode: 'time'; daily?: number; weekly?: number; monthly?: number; yearly?: number }; /** The safe default: keep everything. */ export const NO_RETENTION: RetentionPolicy = { mode: 'none' }; function positiveInt(v: unknown): number | undefined { const n = typeof v === 'number' ? v : Number(v); return Number.isInteger(n) && n > 0 ? n : undefined; } /** * Parse a stored retention JSON blob into a RetentionPolicy. The stored shape is * `{ keepLast?, keepDaily?, keepWeekly?, keepMonthly?, keepYearly? }`. Absent / * all-zero / unparseable resolves to `none` (the safe default), so a broken config * never prunes. `keepLast` takes precedence over time buckets if both are somehow * present (the UI only sets one). */ export function parseRetention(stored: string | null | undefined | Record): RetentionPolicy { let obj: Record; if (stored == null) return NO_RETENTION; if (typeof stored === 'string') { if (!stored.trim()) return NO_RETENTION; try { obj = JSON.parse(stored); } catch { return NO_RETENTION; } } else { obj = stored; } if (!obj || typeof obj !== 'object') return NO_RETENTION; const last = positiveInt(obj.keepLast); if (last !== undefined) return { mode: 'keep-last', last }; const daily = positiveInt(obj.keepDaily); const weekly = positiveInt(obj.keepWeekly); const monthly = positiveInt(obj.keepMonthly); const yearly = positiveInt(obj.keepYearly); if (daily || weekly || monthly || yearly) { return { mode: 'time', daily, weekly, monthly, yearly }; } return NO_RETENTION; } /** True if this policy would ever prune anything (i.e. it's not `none`). */ export function retentionActive(policy: RetentionPolicy): boolean { return policy.mode !== 'none'; } /** * Build the restic `forget` args for a policy, scoped to a single config's * snapshots by tag. Returns null for `none` (nothing to do). The tag filter uses * the STABLE per-config identity so no other config's snapshots enter the pool. * `--group-by ''` because helper hostnames are random and would otherwise split * the group; `dryRun` adds `--dry-run` for the wipe check. * * NO `--prune` here: post-backup retention only removes snapshot references (fast, * metadata-only). `--prune` walks and repacks the whole repo (minutes to hours on a * large or slow-backend repo), so it belongs in the destination's own prune schedule * (default monthly), not inline on every backup. Same split as restic front-ends * (backrest, zerobyte). * * @param configTagFilter e.g. `dockhand:instance=,dockhand:configid=` */ export function buildForgetArgs( policy: RetentionPolicy, configTagFilter: string, opts: { dryRun?: boolean; json?: boolean } = {} ): string[] | null { if (policy.mode === 'none') return null; const args = ['forget']; if (opts.json) args.push('--json'); if (opts.dryRun) args.push('--dry-run'); // `forget` needs an EXCLUSIVE repo lock; --retry-lock is how long it waits for a // held lock before giving up. This runs inside Dockhand's per-repo serializer, so // no OTHER Dockhand op in THIS instance holds the lock - the only lock that can be // present is either (a) an ORPHAN from a backup helper that Dockhand force-removed // mid-lock (its hostname is the dead container id, so restic won't age it out as // stale for 30min), or (b) a live lock from a SEPARATE Dockhand instance sharing // the repo. In both cases a LONG wait is wrong: for (a) we'd block ~10min (5m x // dry-run + 5m x real) on a dead owner - the actual cause of the 300s CI backup // timeouts; for (b) a short wait just means retention is skipped this run (forget // is non-fatal - the backup already succeeded - and it retries next schedule). // So keep it short. A failed/timed-out forget becomes a WARNING, never fails the // backup, and never touches another instance's lock. Overridable for slow links. const retryLock = process.env.RESTIC_FORGET_RETRY_LOCK || '1m'; args.push('--retry-lock', retryLock, '--group-by', '', '--tag', configTagFilter); if (policy.mode === 'keep-last') { args.push('--keep-last', String(policy.last)); } else { if (policy.daily) args.push('--keep-daily', String(policy.daily)); if (policy.weekly) args.push('--keep-weekly', String(policy.weekly)); if (policy.monthly) args.push('--keep-monthly', String(policy.monthly)); if (policy.yearly) args.push('--keep-yearly', String(policy.yearly)); } return args; } /** * Parse a `restic forget --dry-run --json` result to decide whether the prune * would delete EVERY snapshot in the group. `restic forget --json` emits an * array of group documents each with `keep[]` and `remove[]`. */ export interface WipeCheck { wouldWipe: boolean; keep: number; remove: number; total: number; } export function checkWouldWipe(dryRunStdout: string): WipeCheck { const empty: WipeCheck = { wouldWipe: false, keep: 0, remove: 0, total: 0 }; const trimmed = dryRunStdout.trim(); if (!trimmed) return empty; let parsed: unknown; try { parsed = JSON.parse(trimmed); } catch { return empty; } if (!Array.isArray(parsed)) return empty; let keep = 0; let remove = 0; for (const group of parsed) { if (!group || typeof group !== 'object') continue; const g = group as { keep?: unknown[]; remove?: unknown[] }; if (Array.isArray(g.keep)) keep += g.keep.length; if (Array.isArray(g.remove)) remove += g.remove.length; } const total = keep + remove; // A policy that keeps NOTHING while removing everything is always a mistake. return { wouldWipe: total > 0 && keep === 0 && remove > 0, keep, remove, total }; }