bulk-delete backup snapshots, and forget a config's snapshots on delete
This commit is contained in:
14 files changed
+831
-34
No files matched your search
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { AlertTriangle, Loader2, Trash2 } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
/** How many snapshots the confirm will delete. */
|
||||
count: number;
|
||||
/** Deletion in progress (disables the button, shows a spinner). */
|
||||
busy?: boolean;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
let { open = $bindable(), count, busy = false, onConfirm }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2 text-destructive">
|
||||
<AlertTriangle class="h-5 w-5" />
|
||||
Delete {count} snapshot{count === 1 ? '' : 's'}?
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
This permanently forgets and prunes the selected snapshot{count === 1 ? '' : 's'} from
|
||||
{count === 1 ? 'its' : 'their'} backup repository. This cannot be undone.
|
||||
</p>
|
||||
<Dialog.Footer class="gap-2 sm:justify-end">
|
||||
<Button variant="outline" onclick={() => (open = false)} disabled={busy}>Cancel</Button>
|
||||
<Button variant="destructive" onclick={onConfirm} disabled={busy}>
|
||||
{#if busy}<Loader2 class="mr-1 h-4 w-4 animate-spin" />{:else}<Trash2 class="mr-1 h-4 w-4" />{/if}
|
||||
Delete
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -3425,7 +3425,7 @@
|
||||
"tags": [
|
||||
"backup"
|
||||
],
|
||||
"summary": "Delete a backup configuration and unregister its schedule",
|
||||
"summary": "Delete a backup configuration and unregister its schedule, optionally forgetting its snapshots too",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
@@ -3435,11 +3435,20 @@
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "Backup configuration id (from GET /api/backup/configs)"
|
||||
},
|
||||
{
|
||||
"name": "deleteSnapshots",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"description": "Also forget and prune this config's snapshots (default false; snapshots survive so a restore is still possible)"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Returns { success: true } once the configuration is deleted"
|
||||
"description": "Returns { success: true }, plus { snapshots: { deleted, skipped, error? } } when deleteSnapshots=true"
|
||||
},
|
||||
"409": {
|
||||
"description": "A backup is currently running for this config — stop it before deleting"
|
||||
@@ -3453,7 +3462,7 @@
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Permission (\"backups:manage\") and environment-access denials (403) and not-found (404) are produced by the shared route guards."
|
||||
"description": "Permission (\"backups:manage\") and environment-access denials (403) and not-found (404) are produced by the shared route guards. With deleteSnapshots=true the config's snapshots are forgotten and pruned (best-effort; reported in the response, never blocks the delete)."
|
||||
}
|
||||
},
|
||||
"/api/backup/configs/{id}/run": {
|
||||
@@ -4779,6 +4788,68 @@
|
||||
"description": "Job-polled so a proxy can't abort the restic dump at ~15s. The layout is redacted (stack secrets and container Config.Env/Labels stripped) before it leaves the server."
|
||||
}
|
||||
},
|
||||
"/api/backup/snapshots/batch-delete": {
|
||||
"post": {
|
||||
"operationId": "post_api_backup_snapshots_batch-delete",
|
||||
"tags": [
|
||||
"backup"
|
||||
],
|
||||
"summary": "Forget and prune several snapshots from one destination in a single restic forget --prune, with per-snapshot ownership and environment access gates",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Returns { success: true, deleted: string[], skipped: string[] } - deleted were forgotten, skipped were not owned or not accessible"
|
||||
},
|
||||
"400": {
|
||||
"description": "Missing/invalid destinationId, empty snapshotIds, or an invalid snapshot id"
|
||||
},
|
||||
"500": {
|
||||
"description": "Failed to forget the snapshots (restic error)"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Permission denial (403, \"backups:manage\") is produced by the shared requireBackups route guard. Per-snapshot ownership/environment failures are skipped (returned in `skipped`), not 403d.",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"destinationId": {
|
||||
"type": "integer"
|
||||
},
|
||||
"snapshotIds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"destinationId",
|
||||
"snapshotIds"
|
||||
]
|
||||
},
|
||||
"example": {
|
||||
"destinationId": 3,
|
||||
"snapshotIds": [
|
||||
"1a2b3c4d",
|
||||
"5e6f7a8b"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/backup/snapshots/diff": {
|
||||
"get": {
|
||||
"operationId": "get_api_backup_snapshots_diff",
|
||||
@@ -23897,6 +23968,12 @@
|
||||
},
|
||||
"useSelfhstIcons": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"stackLogOperations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -23955,7 +24032,10 @@
|
||||
"showWhatsNew": true,
|
||||
"terminalFont": "string",
|
||||
"timeFormat": "string",
|
||||
"useSelfhstIcons": true
|
||||
"useSelfhstIcons": true,
|
||||
"stackLogOperations": [
|
||||
"string"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -434,3 +434,66 @@ export function selectOwnSnapshotsFromDestination<S extends { tags?: string[] }>
|
||||
.filter((s) => snapshotMatchesConfigScope(s.tags, configIds, targetName))
|
||||
.map((s) => ({ ...s, _destinationId: dest.id, _destinationName: dest.name, _destinationRepository: dest.repository }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Group snapshots by their destination id, returning `{ destinationId, snapshotIds }`
|
||||
* batches. Bulk delete needs this because `restic forget` runs against ONE repo at a
|
||||
* time, so a selection spanning multiple destinations becomes one batch call per dest.
|
||||
* Pure so the grouping is unit-tested independently of the UI.
|
||||
*/
|
||||
export function groupSnapshotIdsByDestination<S extends { id: string; _destinationId: number }>(
|
||||
snaps: S[]
|
||||
): { destinationId: number; snapshotIds: string[] }[] {
|
||||
const byDest = new Map<number, string[]>();
|
||||
for (const s of snaps) {
|
||||
const arr = byDest.get(s._destinationId) ?? [];
|
||||
arr.push(s.id);
|
||||
byDest.set(s._destinationId, arr);
|
||||
}
|
||||
return [...byDest.entries()].map(([destinationId, snapshotIds]) => ({ destinationId, snapshotIds }));
|
||||
}
|
||||
|
||||
export interface BulkDeleteResult {
|
||||
deleted: number;
|
||||
skipped: number;
|
||||
/** true if any destination's batch call returned a non-2xx. */
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete many snapshots via the batch endpoint, grouped by destination (one
|
||||
* `restic forget --prune` per repo). Shared by every snapshot list (containers/stacks
|
||||
* backup modal, the Backups page, the Settings repo browser) so the delete logic is one
|
||||
* place. Callers own the UI (toasts, reload); this only performs the calls and tallies.
|
||||
*/
|
||||
export async function bulkDeleteSnapshots(
|
||||
targets: { id: string; _destinationId: number }[]
|
||||
): Promise<BulkDeleteResult> {
|
||||
let deleted = 0, skipped = 0, failed = false;
|
||||
for (const { destinationId, snapshotIds } of groupSnapshotIdsByDestination(targets)) {
|
||||
try {
|
||||
const res = await fetch('/api/backup/snapshots/batch-delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ destinationId, snapshotIds })
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
deleted += data.deleted?.length ?? 0;
|
||||
if (res.ok) skipped += data.skipped?.length ?? 0;
|
||||
else failed = true;
|
||||
} catch {
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
return { deleted, skipped, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Consistent toast wording for a bulk-delete result, shared by every snapshot list.
|
||||
* Returns { type, message } so each caller fires its own toast (import stays local).
|
||||
*/
|
||||
export function bulkDeleteToast(r: BulkDeleteResult): { type: 'success' | 'error'; message: string } {
|
||||
if (r.failed) return { type: 'error', message: r.deleted > 0 ? `Deleted ${r.deleted}, but some snapshots could not be deleted` : 'Snapshots could not be deleted' };
|
||||
if (r.deleted === 0 && r.skipped > 0) return { type: 'error', message: `${r.skipped} snapshot${r.skipped === 1 ? '' : 's'} could not be deleted (not owned or no access)` };
|
||||
return { type: 'success', message: `Deleted ${r.deleted} snapshot${r.deleted === 1 ? '' : 's'}${r.skipped > 0 ? ` (${r.skipped} skipped)` : ''}` };
|
||||
}
|
||||
Reference in new issue
Block a user