bulk-delete backup snapshots, and forget a config's snapshots on delete

This commit is contained in:
Jarek Krochmalski committed 2026-09-13 14:50:02 +02:00
1 parent 0ca3b86f82
commit cd811d5117
14 files changed
+831 -34

No files matched your search

+70 -1
View File
@@ -15,7 +15,7 @@ import { RestoreService, type RestorePorts, type RestoreJob } from './restore-se
import { openOperation } from './operations';
import { LiveTargetLocks, DestinationSerializer } from './locks';
import { resolveTargets, discoverVolumes, stopForBackup } from './docker';
import { guardSnapshotAccess, listSnapshots as listSnapshotsCore, resolveSnapshotEnvId as resolveSnapshotEnvIdCore, filterSnapshotsByAccessibleEnv } from './snapshots';
import { guardSnapshotAccess, selectOwnedForForget, listSnapshots as listSnapshotsCore, resolveSnapshotEnvId as resolveSnapshotEnvIdCore, filterSnapshotsByAccessibleEnv } from './snapshots';
import { initRepository as initRepoCore, testRepository as testRepoCore, checkRepository, pruneRepository, unlockRepository, repoStats, rotateDestinationPassword as rotateCore } from './repo';
import { parseRetention, buildForgetArgs, checkWouldWipe } from './retention';
import { buildSnapshotLayout, serializeLayout, parseSnapshotLayout, type SnapshotLayout, type SnapshotStack, type SnapshotSecret } from './snapshot-layout';
@@ -1334,6 +1334,75 @@ export async function forgetSnapshot(destinationId: number, snapshotId: string):
return { ok: true };
}
export type ForgetSnapshotsResult = {
/** Snapshot ids that were forgotten (owned + passed to restic). */
deleted: string[];
/** Ids we refused: not owned by this instance or not env-accessible (never sent to restic). */
skipped: string[];
error?: string;
};
/** Per-id vetting for forgetSnapshots. `access` carries the enterprise env gate so the
* ONE ownership listing per id also covers env access - no separate per-id env round-trip
* at the route. `preVetted` skips the per-id guard when the caller has already proven
* instance ownership + env access for every id via the SAME instance-scoped restic listing
* (e.g. a `--tag instance,configid` config listing), which is a strictly stronger proof. */
export type ForgetSnapshotsOptions = {
access?: { isEnterprise: boolean; canAccessEnvironment: (envId: number) => Promise<boolean> };
preVetted?: boolean;
};
const FULL_ACCESS = { isEnterprise: false, canAccessEnvironment: async () => true };
/**
* Forget MANY snapshots from ONE destination in a single `restic forget ... --prune`
* (prunes once, not per-id). Every id is instance-ownership- and (enterprise) env-access-
* checked first via ONE restic listing per id; ids that fail are skipped (never passed to
* restic) so a bulk delete can't reach another instance's or another env's snapshots
* sharing the repo. Pass `preVetted` only when the ids came from an instance-scoped
* listing that already proved ownership + access. All owned ids share the one lock.
*/
export async function forgetSnapshots(
destinationId: number,
snapshotIds: string[],
options: ForgetSnapshotsOptions = {},
): Promise<ForgetSnapshotsResult> {
const destination = await loadDest(destinationId);
const instanceId = await getInstanceId();
const access = options.access ?? FULL_ACCESS;
const { owned, skipped } = await selectOwnedForForget(snapshotIds, {
preVetted: options.preVetted,
guard: (id) => guardSnapshotAccess(reader(), destination, instanceId, id, access),
});
if (owned.length === 0) return { deleted: [], skipped };
const run = await serializeByRepo(destinationId, () =>
restic.runLocal(destination, ['forget', ...owned, '--prune', '--retry-lock', '5m'], 'data'),
);
logRepoOp(destination.name, `forget ${owned.length} snapshot(s)`, run.exitCode === 0, { output: run.stdout, error: run.stderr });
if (run.exitCode !== 0) return { deleted: [], skipped, error: run.stderr.trim() || 'forget failed' };
return { deleted: owned, skipped };
}
/**
* Forget every snapshot belonging to a backup config (matched by its `dockhand:configid`
* tag) from its destination, in a single restic forget --prune. Used when a config is
* deleted WITH "also delete snapshots". Best-effort: returns the tally; a restic error is
* reported, never thrown, so it can't fail the config deletion that already happened.
* The listing is `--tag instance,configid` scoped, a stronger ownership proof than the
* per-id guard, so the ids are forgotten preVetted (no redundant per-id re-listing).
* CALLER CONTRACT: `preVetted` suppresses the per-snapshot env guard, so the caller MUST
* gate on the config's environment first. The DELETE config route does (loadConfigGateEnv),
* and every id here shares that already-gated config's env - keep this true for any new caller.
*/
export async function forgetSnapshotsForConfig(configId: number, destinationId: number): Promise<ForgetSnapshotsResult> {
const snaps = await listSnapshots(destinationId, configId).catch(() => []);
const ids = snaps.map((s) => s.id);
if (ids.length === 0) return { deleted: [], skipped: [] };
return forgetSnapshots(destinationId, ids, { preVetted: true });
}
// --- repository maintenance ---
/**
+30
View File
@@ -159,6 +159,36 @@ export async function filterSnapshotsByAccessibleEnv<T extends { tags: string[]
return kept;
}
/**
* Split a bulk-delete's ids into the ones to forget vs. the ones to skip.
*
* `preVetted` is the contract behind the bulk/config-delete perf path: the caller has
* ALREADY proven instance ownership + env access for every id via an instance-scoped
* restic listing (the batch route's `resolveSnapshotEnvId`, or the config listing's
* `--tag instance,configid`), which is a strictly stronger proof than the per-id guard.
* So preVetted forwards the ids as-is and NEVER calls `guard`. Without it, each id is
* guarded here and a failing one is skipped (never forgotten) - the only path that lets
* a not-owned / not-accessible id reach restic is a caller wrongly asserting preVetted.
* Pure over the injected `guard` so the branch is unit-testable without a repo.
*/
export async function selectOwnedForForget(
snapshotIds: string[],
opts: { preVetted?: boolean; guard: (id: string) => Promise<void> },
): Promise<{ owned: string[]; skipped: string[] }> {
if (opts.preVetted) return { owned: snapshotIds, skipped: [] };
const owned: string[] = [];
const skipped: string[] = [];
for (const id of snapshotIds) {
try {
await opts.guard(id);
owned.push(id);
} catch {
skipped.push(id);
}
}
return { owned, skipped };
}
/**
* List this installation's snapshots in a repository (optionally scoped to one
* config). Enterprise callers only see snapshots whose env they can access.