Files
dockhand-upstream/src/lib/server/backups/retention.ts
T
2026-07-25 08:41:01 +02:00

152 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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? }` (legacy
* flat form). Absent / all-zero / unparseable → `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; `--prune` reclaims space; `dryRun` adds `--dry-run` for the wipe check.
*
* @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');
else args.push('--prune');
// `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 ×
// dry-run + 5m × 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 (prune
// is non-fatal — the backup already succeeded — and it retries next schedule).
// So keep it short. A failed/timed-out prune 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 };
}