scope stack backup snapshots to their own environment

This commit is contained in:
Jarek Krochmalski committed 2026-09-09 09:37:27 +05:30
1 parent e94b77ddb7
commit 51bc928b39
3 files changed
+137 -5

No files matched your search

+46
View File
@@ -388,3 +388,49 @@ export async function fetchBackupExecutions(
}));
return computeExecutionTally(all);
}
/**
* Does a snapshot (by its restic tags) belong to the stack/container Backups view for a
* given set of ENV-SCOPED config ids? A same-name stack on another environment writes to
* the same shared repo and carries the same `dockhand:name=<target>` tag, so filtering by
* name alone mixed snapshots (and "latest") across environments (#1546). `configIds` are
* already env-scoped by the caller and `dockhand:configid` uniquely identifies a
* (target, env) config, so we match on that. When no config ids are known (should not
* happen once a target has any backup config) fall back to the name tag rather than hide
* everything. Pure + unit-testable.
*/
export function snapshotMatchesConfigScope(
tags: string[] | undefined,
configIds: number[],
targetName: string
): boolean {
const t = tags ?? [];
if (configIds.length > 0) {
for (const tag of t) {
if (tag.startsWith('dockhand:configid=')) {
const n = Number(tag.slice('dockhand:configid='.length));
if (Number.isInteger(n) && configIds.includes(n)) return true;
}
}
return false;
}
return t.includes(`dockhand:name=${targetName}`);
}
/**
* Pick THIS target's snapshots from one destination's raw restic list and stamp each with
* the destination it came from - the exact filter+map the Backups panel applies per
* destination. Extracted so the wired call site (not just snapshotMatchesConfigScope in
* isolation) is covered by a test: a regression back to a name-only filter changes this
* helper's output and fails the suite (#1546).
*/
export function selectOwnSnapshotsFromDestination<S extends { tags?: string[] }>(
snaps: S[],
dest: { id: number; name: string; repository: string },
configIds: number[],
targetName: string
): (S & { _destinationId: number; _destinationName: string; _destinationRepository: string })[] {
return snaps
.filter((s) => snapshotMatchesConfigScope(s.tags, configIds, targetName))
.map((s) => ({ ...s, _destinationId: dest.id, _destinationName: dest.name, _destinationRepository: dest.repository }));
}
+7 -5
View File
@@ -7,7 +7,7 @@
import { formatDateTime, formatRelativeTime } from '$lib/stores/settings';
import { formatBytes } from '$lib/utils/format';
import { readJobResponse } from '$lib/utils/sse-fetch';
import { getRepoTypeIcon } from '$lib/utils/backup';
import { getRepoTypeIcon, selectOwnSnapshotsFromDestination } from '$lib/utils/backup';
import SnapshotBrowser from './SnapshotBrowser.svelte';
import RestoreModal from './RestoreModal.svelte';
import SnapshotDiffModal from '../backups/SnapshotDiffModal.svelte';
@@ -102,7 +102,11 @@
try {
const destRes = await fetch('/api/backup/destinations');
const destinations: { id: number; name: string; repository: string }[] = destRes.ok ? await destRes.json() : [];
const nameTag = `dockhand:name=${targetName}`;
// Scope to THIS env's configs, not just the name: same-name stacks on different
// environments write to a shared repo and all carry dockhand:name=<stack>, so a
// name-only filter mixed their snapshots (and "latest") across envs (#1546).
// configIds is env-scoped (BackupPanel fetches configs with ?env=), so a snapshot's
// own dockhand:configid tag uniquely identifies (stack, env). See snapshotMatchesConfigScope.
const all: Snapshot[] = [];
loadDone = 0;
loadTotal = destinations.length;
@@ -119,9 +123,7 @@
const data = await readJobResponse(res);
if (data?.error) return;
const snaps: any[] = data.snapshots ?? data;
const mine = snaps
.filter((s) => (s.tags || []).some((t: string) => t === nameTag))
.map((s) => ({ ...s, _destinationId: dest.id, _destinationName: dest.name, _destinationRepository: dest.repository }));
const mine = selectOwnSnapshotsFromDestination(snaps, dest, configIds, targetName);
if (mine.length) {
all.push(...mine);
all.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime());