snapshots from another Dockhand instance sharing the repo now appear as restorable orphans

This commit is contained in:
jarek committed 2026-08-09 10:31:27 +02:00
1 parent 5adac5e19e
commit 3d1b383ad7
4 files changed
+110 -40

No files matched your search

+12 -6
View File
@@ -1,15 +1,15 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { requireBackups } from '$lib/server/backups/route-guards';
import { getBackupConfig, getBackupDestinations } from '$lib/server/db';
import { listSnapshots } from '$lib/server/backups';
import { filterSnapshotsByEnvAccess } from '$lib/server/backups/route-guards';
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const denied = await requireBackups(auth, 'view');
if (denied) return denied;
const configIdParam = url.searchParams.get('configId');
const destIdParam = url.searchParams.get('destinationId');
@@ -94,16 +94,22 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
if (isNaN(destinationId)) return json({ error: 'Invalid destinationId' }, { status: 400 });
try {
// No target filter — return all snapshots in this destination.
// No target filter — return all snapshots in this destination (incl. snapshots
// from OTHER Dockhand instances sharing/copied into the repo). The response carries
// this install's instance id so the client can tell own snapshots from foreign
// orphans without relying on configid (which collides across instances - #1351).
const { getInstanceId } = await import('$lib/server/backups/identity');
const thisInstanceId = await getInstanceId();
const snapshots = await listSnapshots(destinationId);
// (HIGH #8) On enterprise, a non-admin must not see snapshots belonging to
// environments they can't access — the ids/paths alone are disclosure. Drop
// any snapshot whose owning env (from its dockhand:env tag) isn't accessible.
const headers = { 'X-Dockhand-Instance': thisInstanceId };
if (auth.isEnterprise) {
const filtered = await filterSnapshotsByEnvAccess(auth, snapshots);
return json(filtered);
return json(filtered, { headers });
}
return json(snapshots);
return json(snapshots, { headers });
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return json({ error: errorMsg }, { status: 500 });
+33 -5
View File
@@ -10,6 +10,7 @@
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { Input } from '$lib/components/ui/input';
import { SnapshotLoadProgress } from '$lib/components/ui/snapshot-load-progress';
import * as Select from '$lib/components/ui/select';
import * as Tooltip from '$lib/components/ui/tooltip';
import { DataGrid } from '$lib/components/data-grid';
@@ -106,6 +107,12 @@
// Snapshot counts loaded async per destination, keyed by targetName:destId
let snapshotCounts = $state<Map<string, number>>(new Map());
let snapshotCountsLoading = $state(true);
// Progress of the per-destination snapshot count sweep: how many destinations have
// responded out of the total. Drives an "X / N" indicator so a slow/unreachable
// repo (backend restic timeout is 5 min) shows visible progress instead of an
// indeterminate spinner. done === total means the sweep finished.
let snapshotCountsDone = $state(0);
let snapshotCountsTotal = $state(0);
// Orphan targets: found in repos but no config exists
interface OrphanTarget { key: string; targetName: string; type: string; destinationId: number; destinationName?: string; destinationRepository?: string; snapshotCount: number; latestSnapshot?: string; }
let orphanTargets = $state<OrphanTarget[]>([]);
@@ -169,6 +176,8 @@
let restoreSnapshotId = $state('');
let restoreContainerName = $state('');
let restoreEnvId = $state<number | undefined>(undefined);
let restoreDestName = $state('');
let restoreDestRepo = $state('');
// Snapshot diff
let showDiff = $state(false);
@@ -313,6 +322,8 @@
async function loadSnapshotCounts() {
snapshotCountsLoading = true;
snapshotCountsDone = 0;
snapshotCountsTotal = destinations.length;
// Live config ids per destination — a snapshot's dockhand:configid tag matching
// one of these belongs to a shown config; anything else is orphaned.
const liveConfigIds = new Set(configs.map(c => c.id));
@@ -322,10 +333,19 @@
// (snapshots whose config was deleted). The right-column count must match the
// expanded per-config list, so it's keyed by config, NOT by target name — two
// configs backing the same container to the same repo have DIFFERENT counts.
//
// Each destination updates snapshotCounts as soon as IT responds, and the
// "Loading" indicator clears after the FIRST one finishes - so a single slow or
// unreachable repo can't hold the whole list hostage for its 30s timeout. The
// rest keep filling in counts progressively behind the scenes.
await Promise.allSettled(destinations.map(async (dest) => {
try {
const res = await fetch(`/api/backup/snapshots?destinationId=${dest.id}`);
if (!res.ok) return;
// This install id: a snapshot belongs to a LOCAL config only when THIS instance wrote it.
// configid collides across instances (a fresh install reuses 1,2,3...), so a foreign
// snapshot with configid=1 must be an orphan, not mis-bucketed into local config #1 (#1351).
const thisInstance = res.headers.get("X-Dockhand-Instance") || "";
const d = await res.json();
const snaps: any[] = d.snapshots ?? d;
if (!Array.isArray(snaps)) return;
@@ -338,8 +358,9 @@
const perConfig = new Map<number, number>();
const orphanByName = new Map<string, any[]>();
for (const snap of snaps) {
const cid = Number(tagVal(snap, 'dockhand:configid='));
if (Number.isInteger(cid) && liveConfigIds.has(cid)) {
const cid = Number(tagVal(snap, "dockhand:configid="));
const isOwn = tagVal(snap, "dockhand:instance=") === thisInstance;
if (isOwn && Number.isInteger(cid) && liveConfigIds.has(cid)) {
perConfig.set(cid, (perConfig.get(cid) || 0) + 1);
} else {
const name = tagVal(snap, 'dockhand:name=') || 'unknown';
@@ -371,9 +392,10 @@
}
snapshotCounts = newCounts;
orphanTargets = newOrphans;
} catch {}
} catch {} finally { snapshotCountsDone += 1; }
}));
// Backstop: if there were zero destinations, nothing cleared it above.
snapshotCountsLoading = false;
}
@@ -622,6 +644,8 @@
restoreSnapshotId = snapshot.id;
restoreContainerName = config.targetName;
restoreEnvId = config.environmentId ?? undefined;
restoreDestName = snapshot._destinationName ?? config.destinationName ?? '';
restoreDestRepo = snapshot._destinationRepository ?? config.destinationRepository ?? '';
showRestore = true;
}
@@ -651,11 +675,13 @@
<div class="flex-1 min-h-0 flex flex-col gap-3 overflow-hidden">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3 min-h-8">
<PageHeader title="Backups" icon={Archive} count={filteredConfigs.length}>
{#if loading || snapshotCountsLoading}
{#if loading}
<span class="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 class="w-3.5 h-3.5 animate-spin" />
{loading ? 'Loading…' : 'Loading snapshots…'}
Loading…
</span>
{:else if snapshotCountsLoading}
<SnapshotLoadProgress done={snapshotCountsDone} total={snapshotCountsTotal} />
{/if}
</PageHeader>
<div class="flex flex-wrap items-center gap-2">
@@ -1045,6 +1071,8 @@
destinationId={restoreDestId}
snapshotId={restoreSnapshotId}
containerName={restoreContainerName}
destinationName={restoreDestName}
destinationRepository={restoreDestRepo}
environmentId={restoreEnvId}
onDone={fetchData}
/>
+26 -9
View File
@@ -41,6 +41,10 @@
}
let loading = $state(true);
// Progress of the per-destination snapshot sweep (X of N repos) - a slow/unreachable
// repo can take up to the backend restic timeout (5 min), so show it advancing.
let loadDone = $state(0);
let loadTotal = $state(0);
let snapshots = $state<Snapshot[]>([]);
let deletingSnapshot = $state<string | null>(null);
let confirmDeleteSnapshot = $state<string | null>(null);
@@ -55,6 +59,8 @@
let showRestore = $state(false);
let restoreDestId = $state(0);
let restoreSnapshotId = $state('');
let restoreDestName = $state('');
let restoreDestRepo = $state('');
// Diff (click one to select, another to compare)
let showDiff = $state(false);
let diffDestId = $state(0);
@@ -97,22 +103,29 @@
const destinations: { id: number; name: string; repository: string }[] = destRes.ok ? await destRes.json() : [];
const nameTag = `dockhand:name=${targetName}`;
const all: Snapshot[] = [];
// Query every destination and keep only THIS target's snapshots (by tag).
loadDone = 0;
loadTotal = destinations.length;
snapshots = [];
// Query every destination and keep only THIS target's snapshots (by tag). Show each
// repo's results the moment it returns instead of blocking on the slowest repo - with
// many (or one unreachable) repos the list fills in live rather than after a long wait.
await Promise.all(destinations.map(async (dest) => {
try {
const res = await fetch(`/api/backup/snapshots?destinationId=${dest.id}`);
if (!res.ok) return;
const data = await res.json();
const snaps: any[] = data.snapshots ?? data;
for (const s of snaps) {
if ((s.tags || []).some((t: string) => t === nameTag)) {
all.push({ ...s, _destinationId: dest.id, _destinationName: dest.name, _destinationRepository: dest.repository });
}
const mine = snaps
.filter((s) => (s.tags || []).some((t: string) => t === nameTag))
.map((s) => ({ ...s, _destinationId: dest.id, _destinationName: dest.name, _destinationRepository: dest.repository }));
if (mine.length) {
all.push(...mine);
all.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime());
snapshots = [...all];
onCount?.(all.length);
}
} catch { /* skip an unreachable destination */ }
} catch { /* skip an unreachable destination */ } finally { loadDone += 1; }
}));
all.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime());
snapshots = all;
onCount?.(all.length);
// Per-snapshot stats from schedule executions (recorded at backup time).
@@ -151,6 +164,8 @@
function openRestore(s: Snapshot) {
restoreDestId = s._destinationId;
restoreSnapshotId = s.id;
restoreDestName = s._destinationName;
restoreDestRepo = s._destinationRepository;
showRestore = true;
}
function toggleDiff(s: Snapshot) {
@@ -226,7 +241,7 @@
<!-- First load only: a quiet spinner, no skeleton. On a refresh where rows already
exist we fall through and keep the real table visible (the header refresh icon
already signals progress). Matches the History tab's empty/loading treatment. -->
<LoadingState class="min-h-[50vh]" label="Loading snapshots..." />
<LoadingState class="min-h-[50vh]" label={loadTotal > 1 ? `Loading snapshots… ${loadDone} of ${loadTotal} repos` : 'Loading snapshots…'} />
{:else if snapshots.length === 0}
<div class="flex min-h-[60vh] flex-col items-center justify-center py-10 text-center">
<Archive class="mb-3 h-10 w-10 text-muted-foreground/40" />
@@ -333,6 +348,8 @@
destinationId={restoreDestId}
snapshotId={restoreSnapshotId}
containerName={targetName}
destinationName={restoreDestName}
destinationRepository={restoreDestRepo}
{environmentId}
onDone={loadSnapshots}
/>