Files
dockhand-upstream/src/lib/server/backups/retention.ts
T

157 lines
6.7 KiB
TypeScript

/**
* 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<string, unknown>): RetentionPolicy {
let obj: Record<string, unknown>;
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=<uuid>,dockhand:configid=<id>`
*/
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 };
}