backup volume picker shows every bind mount when two containers share a container path
This commit is contained in:
4 files changed
+146
-31
No files matched your search
@@ -72,9 +72,9 @@
|
||||
{/if}
|
||||
{#if vt}
|
||||
{#if vt.kind === 'bind'}
|
||||
<span class="mt-[2px] inline-flex w-12 shrink-0 items-center justify-center gap-1 rounded-full bg-amber-500/15 px-1.5 text-[9px] font-semibold uppercase tracking-wide text-amber-500"><Folder class="h-2.5 w-2.5" />bind</span>
|
||||
<span class="mt-[2px] inline-flex w-14 shrink-0 items-center justify-center gap-1 whitespace-nowrap rounded-full bg-amber-500/15 px-1.5 text-[9px] font-semibold uppercase tracking-wide text-amber-500"><Folder class="h-2.5 w-2.5 shrink-0" />bind</span>
|
||||
{:else}
|
||||
<span class="mt-[2px] inline-flex w-12 shrink-0 items-center justify-center gap-1 rounded-full bg-sky-500/15 px-1.5 text-[9px] font-semibold uppercase tracking-wide text-sky-500"><HardDrive class="h-2.5 w-2.5" />vol</span>
|
||||
<span class="mt-[2px] inline-flex w-14 shrink-0 items-center justify-center gap-1 whitespace-nowrap rounded-full bg-sky-500/15 px-1.5 text-[9px] font-semibold uppercase tracking-wide text-sky-500"><HardDrive class="h-2.5 w-2.5 shrink-0" />vol</span>
|
||||
{/if}
|
||||
<span class={isError ? 'text-red-400' : ''}>{vt.rest}</span>
|
||||
{:else}
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
import MountTypeBadge from '$lib/components/MountTypeBadge.svelte';
|
||||
|
||||
interface VolumeInfo {
|
||||
/** Restic identity; selection keys on this so two binds sharing a destination don't collide. */
|
||||
key: string;
|
||||
name: string;
|
||||
mountPoint: string;
|
||||
mountType?: 'volume' | 'bind';
|
||||
@@ -47,11 +49,11 @@
|
||||
const backupable = $derived(volumes.filter((v) => !v.unbackupable));
|
||||
const bindMounts = $derived(backupable.filter((v) => v.mountType === 'bind'));
|
||||
|
||||
function toggleVolume(name: string) {
|
||||
if (selectedVolumes.includes(name)) {
|
||||
selectedVolumes = selectedVolumes.filter((v) => v !== name);
|
||||
function toggleVolume(key: string) {
|
||||
if (selectedVolumes.includes(key)) {
|
||||
selectedVolumes = selectedVolumes.filter((v) => v !== key);
|
||||
} else {
|
||||
selectedVolumes = [...selectedVolumes, name];
|
||||
selectedVolumes = [...selectedVolumes, key];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -60,7 +62,7 @@
|
||||
<!-- One bordered section: the "all volumes" toggle heads the list it controls,
|
||||
so the switch clearly belongs to the volumes below it. -->
|
||||
<div class="border rounded-md overflow-hidden">
|
||||
<div class="flex items-center justify-between gap-4 px-3 py-2 bg-muted/30 border-b">
|
||||
<div class="flex items-center gap-3 px-3 py-2 bg-muted/30 border-b">
|
||||
<Label class="text-xs">Backup all volumes ({backupable.length})</Label>
|
||||
<TogglePill bind:checked={allVolumes} onLabel="Yes" offLabel="No" />
|
||||
</div>
|
||||
@@ -77,12 +79,12 @@
|
||||
|
||||
<!-- List always shown: informational when "all" is on, selectable when off. -->
|
||||
<div class="divide-y max-h-40 overflow-y-auto">
|
||||
{#each volumes as vol}
|
||||
{#each volumes as vol (vol.key)}
|
||||
<label class="flex items-center gap-2 px-3 py-1.5 text-xs" class:cursor-pointer={!allVolumes && !vol.unbackupable} class:opacity-50={vol.unbackupable}>
|
||||
<Checkbox
|
||||
checked={!vol.unbackupable && (allVolumes || selectedVolumes.includes(vol.name))}
|
||||
checked={!vol.unbackupable && (allVolumes || selectedVolumes.includes(vol.key))}
|
||||
disabled={allVolumes || vol.unbackupable}
|
||||
onCheckedChange={() => !vol.unbackupable && toggleVolume(vol.name)}
|
||||
onCheckedChange={() => !vol.unbackupable && toggleVolume(vol.key)}
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
<MountTypeBadge type={vol.mountType} size="sm" />
|
||||
|
||||
+60
-21
@@ -5,13 +5,17 @@
|
||||
*/
|
||||
|
||||
import { isUnbackupableBindSource } from './unbackupable-mounts';
|
||||
import { volumeDedupKey, volumeStorageKey } from './volume-identity';
|
||||
|
||||
export interface VolumeInfo {
|
||||
/** The volume's identity: the restic `/volumes/<key>` key, computed the same way capture does
|
||||
* (utils/volume-identity). Selection and dedup key on this, not on `name` - two binds sharing
|
||||
* a container destination have the same `name` but different `key` (#1373). */
|
||||
key: string;
|
||||
name: string;
|
||||
mountPoint: string;
|
||||
mountType: 'volume' | 'bind';
|
||||
/** Bind mounts only: the host source path, for display (name is the container
|
||||
* destination so it matches the backup engine's filter). */
|
||||
/** Bind mounts only: the host source path, shown as the picker's disambiguator. */
|
||||
source?: string;
|
||||
/** Bind mounts only: true when the host source is a socket or host system path
|
||||
* the backup engine refuses to capture. Shown in the picker but not selectable,
|
||||
@@ -41,13 +45,21 @@ export function mountTypeFromHostPath(hostPath: string): 'volume' | 'bind' {
|
||||
}
|
||||
|
||||
/**
|
||||
* A parsed bind entry (`hostPath:containerPath`) -> pickable VolumeInfo. `name` MUST
|
||||
* match what the backup engine discovers (named volume -> its name/hostPath, bind ->
|
||||
* its container destination), else the selectedVolumes filter drops it silently.
|
||||
* A parsed bind entry (`hostPath:containerPath`) -> pickable VolumeInfo. `name` is the display
|
||||
* value (container destination for a bind); `key` is the restic identity from volume-identity.
|
||||
* `taken` threads the key allocator so a caller merging several sources can't hand out a
|
||||
* duplicate key.
|
||||
*/
|
||||
export function volumeInfoFromBind(bind: { hostPath: string; containerPath: string }): VolumeInfo {
|
||||
export function volumeInfoFromBind(bind: { hostPath: string; containerPath: string }, taken: Set<string> = new Set()): VolumeInfo {
|
||||
const mountType = mountTypeFromHostPath(bind.hostPath);
|
||||
const key = volumeStorageKey(
|
||||
mountType === 'bind'
|
||||
? { type: 'bind', source: bind.hostPath, destination: bind.containerPath }
|
||||
: { type: 'volume', source: bind.hostPath, destination: bind.containerPath, name: bind.hostPath },
|
||||
taken
|
||||
);
|
||||
return {
|
||||
key,
|
||||
name: mountType === 'bind' ? bind.containerPath : bind.hostPath,
|
||||
mountPoint: bind.containerPath,
|
||||
mountType,
|
||||
@@ -57,13 +69,12 @@ export function volumeInfoFromBind(bind: { hostPath: string; containerPath: stri
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a container's raw Docker mounts into the pickable volume list.
|
||||
* Keeps only `volume` and `bind` mounts (skips tmpfs/npipe/etc.). `name` MUST
|
||||
* equal what the backup engine discovers (named volume -> its name, bind -> its
|
||||
* container destination), else the selectedVolumes filter drops it silently; the
|
||||
* host source is kept separately in `source` for display only.
|
||||
* Normalize a container's raw Docker mounts into the pickable volume list. Keeps only `volume`
|
||||
* and `bind` mounts (skips tmpfs/npipe/etc.). `key` is the restic identity (volume-identity),
|
||||
* allocated against `taken` so a stack merge across containers can share one allocator; `name`
|
||||
* is display only (bind destination). Pass a shared `taken` when merging multiple containers.
|
||||
*/
|
||||
export function normalizeMounts(mounts: RawMount[] | null | undefined): VolumeInfo[] {
|
||||
export function normalizeMounts(mounts: RawMount[] | null | undefined, taken: Set<string> = new Set()): VolumeInfo[] {
|
||||
if (!Array.isArray(mounts)) return [];
|
||||
const out: VolumeInfo[] = [];
|
||||
for (const m of mounts) {
|
||||
@@ -72,35 +83,63 @@ export function normalizeMounts(mounts: RawMount[] | null | undefined): VolumeIn
|
||||
const destination = m.destination || m.Destination || '';
|
||||
const source = m.source || m.Source || '';
|
||||
if (type === 'bind') {
|
||||
out.push({ name: destination || source, mountPoint: destination, mountType: 'bind', source: source || undefined, unbackupable: isUnbackupableBindSource(source) || undefined });
|
||||
const key = volumeStorageKey({ type: 'bind', source, destination }, taken);
|
||||
out.push({ key, name: destination || source, mountPoint: destination, mountType: 'bind', source: source || undefined, unbackupable: isUnbackupableBindSource(source) || undefined });
|
||||
} else {
|
||||
const name = m.name || m.Name || source || destination || '';
|
||||
out.push({ name, mountPoint: destination, mountType: 'volume' });
|
||||
const key = volumeStorageKey({ type: 'volume', source: name, destination, name }, taken);
|
||||
out.push({ key, name, mountPoint: destination, mountType: 'volume' });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the mounts of every container in a compose project into one deduped
|
||||
* volume list (by display name). Used for stack-level backup pickers.
|
||||
* Merge the mounts of every container in a compose project into one volume list, deduped by
|
||||
* volume IDENTITY (not display name) so two containers binding different host paths to the same
|
||||
* container path both survive (#1373). Keys are allocated in ONE pass with a shared `taken` set,
|
||||
* mirroring the backend discoverVolumesFromMounts so the two can't hand out divergent keys.
|
||||
*/
|
||||
export function normalizeStackMounts(
|
||||
containers: Array<{ mounts?: RawMount[]; Mounts?: RawMount[] }>
|
||||
): VolumeInfo[] {
|
||||
const seen = new Set<string>();
|
||||
const taken = new Set<string>();
|
||||
const out: VolumeInfo[] = [];
|
||||
for (const c of containers) {
|
||||
for (const v of normalizeMounts(c.mounts || c.Mounts)) {
|
||||
if (v.name && !seen.has(v.name)) {
|
||||
seen.add(v.name);
|
||||
out.push(v);
|
||||
}
|
||||
for (const m of c.mounts || c.Mounts || []) {
|
||||
const type = m.type || m.Type;
|
||||
if (type !== 'volume' && type !== 'bind') continue;
|
||||
const destination = m.destination || m.Destination || '';
|
||||
const source = m.source || m.Source || '';
|
||||
const name = type === 'volume' ? (m.name || m.Name || source || destination || '') : (destination || source);
|
||||
const dedup = volumeDedupKey(type === 'volume' ? { type, source: name, destination, name } : { type: 'bind', source, destination });
|
||||
if (seen.has(dedup)) continue;
|
||||
seen.add(dedup);
|
||||
const [v] = normalizeMounts([m], taken);
|
||||
if (v) out.push(v);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a saved config's stored selection to the current volume list's keys, so the picker checks
|
||||
* the right boxes. A stored entry may be a key or a name; match by key first, then by unique name.
|
||||
* A name matching several volumes (two binds sharing a destination) is ambiguous and dropped - the
|
||||
* user re-picks it. (#1373)
|
||||
*/
|
||||
export function reconcileSelectedVolumeKeys(volumes: Array<{ key: string; name: string }>, stored: string[]): string[] {
|
||||
const byKey = new Set(volumes.map((v) => v.key));
|
||||
const out: string[] = [];
|
||||
for (const sel of stored) {
|
||||
if (byKey.has(sel)) { out.push(sel); continue; }
|
||||
const named = volumes.filter((v) => v.name === sel);
|
||||
if (named.length === 1) out.push(named[0].key);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The compose-project label that groups containers into a stack. */
|
||||
const COMPOSE_PROJECT_LABEL = 'com.docker.compose.project';
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// Shared volume identity for the backup feature: ONE place that decides how a container mount
|
||||
// becomes (a) the identity two mounts share when they are the same volume (dedup key) and (b) the
|
||||
// restic `/volumes/<key>` storage key. Server discovery (discovery-core.ts) and the client picker
|
||||
// (utils/mounts.ts) both import this so they compute identity the same way. Dependency-free so
|
||||
// it's safe in the browser bundle. (#1373)
|
||||
|
||||
export interface MountIdentity {
|
||||
type: 'volume' | 'bind';
|
||||
/** volume name (for a volume) or absolute host path (for a bind). */
|
||||
source: string;
|
||||
/** container path the mount is mounted at. */
|
||||
destination: string;
|
||||
/** volume name (volumes only). */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The identity two mounts share when they are the SAME volume, so a shared volume mounted by
|
||||
* several containers is captured once. A named volume is identified by its name; a bind by its
|
||||
* (source, destination) pair - two binds to the same destination from DIFFERENT sources are
|
||||
* DIFFERENT volumes and must both survive (that is #1373). Mirrors discovery's original
|
||||
* `seenVolumeNames` + `pairKey` (`Source\nDestination`) exactly.
|
||||
*/
|
||||
export function volumeDedupKey(m: MountIdentity): string {
|
||||
if (m.type === 'volume') return `vol:${m.name ?? m.source}`;
|
||||
return `bind:${m.source}\n${m.destination}`;
|
||||
}
|
||||
|
||||
/** Slug an arbitrary path segment into the `/volumes/` key charset. */
|
||||
function slug(s: string): string {
|
||||
return s.replace(/^\/+/, '').replace(/[^A-Za-z0-9._-]/g, '_');
|
||||
}
|
||||
|
||||
/** Segments of a host path that carry no identity on their own - dropping them keeps the
|
||||
* meaningful tail (e.g. `.../gitea/data` -> `gitea_data`, not `data`). */
|
||||
const GENERIC_SEGMENTS = new Set(['', 'data', 'config', 'conf', 'mnt', 'srv', 'var', 'opt', 'home', 'volumes', 'appdata']);
|
||||
|
||||
/** The last 1-2 meaningful segments of a bind's host source, so two binds to the same container
|
||||
* path get distinguishable keys. `/mnt/cache/appdata/gitea/data` -> `gitea_data`. */
|
||||
function sourceTail(source: string): string {
|
||||
const parts = source.split('/').filter(Boolean);
|
||||
if (parts.length === 0) return 'bind';
|
||||
const last = parts[parts.length - 1];
|
||||
const prev = parts[parts.length - 2];
|
||||
// If the last segment is generic (data/config/...), qualify it with its parent dir.
|
||||
if (prev && GENERIC_SEGMENTS.has(last.toLowerCase())) return slug(`${prev}_${last}`);
|
||||
return slug(last);
|
||||
}
|
||||
|
||||
/**
|
||||
* The restic `/volumes/<key>` storage key for a mount, collision-suffixed against `taken` so two
|
||||
* mounts never share a key (restic would overwrite one with the other). Named volumes key on
|
||||
* their name (stable, restore knows it). Binds key on `<destination-slug>__<source-tail>` so two
|
||||
* containers binding different host paths to the same container path get distinct, readable keys
|
||||
* (#1373: `/data` from gitea vs gitea-runner -> `data__gitea_data` / `data__gitea-runner_data`).
|
||||
*/
|
||||
export function volumeStorageKey(m: MountIdentity, taken: Set<string> = new Set()): string {
|
||||
if (m.type === 'volume') return safeKey(m.name ?? m.source, taken);
|
||||
const base = `${slug(m.destination) || 'bind'}__${sourceTail(m.source)}`;
|
||||
return safeKey(base, taken);
|
||||
}
|
||||
|
||||
/** Make a base string into a safe `/volumes/` key. Non-safe chars become `_`; a collision gets a
|
||||
* short numeric suffix so two mounts never map to the same key. Moved here from discovery-core so
|
||||
* capture and the UI share ONE key allocator; discovery-core re-exports it for existing callers. */
|
||||
export function safeKey(base: string, taken: Set<string> = new Set()): string {
|
||||
let key = base.replace(/^\//, '').replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 100) || 'bind';
|
||||
if (!taken.has(key)) { taken.add(key); return key; }
|
||||
let i = 2;
|
||||
while (taken.has(`${key}_${i}`)) i++;
|
||||
const out = `${key}_${i}`;
|
||||
taken.add(out);
|
||||
return out;
|
||||
}
|
||||
Reference in new issue
Block a user