157 lines
6.7 KiB
TypeScript
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 };
|
|
}
|