exclude docker sockets and host system paths from volume backups
This commit is contained in:
4 files changed
+76
-9
No files matched your search
@@ -20,6 +20,8 @@
|
||||
mountPoint: string;
|
||||
mountType?: 'volume' | 'bind';
|
||||
source?: string;
|
||||
/** Socket / host system bind the engine won't capture: listed but not selectable. */
|
||||
unbackupable?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -40,7 +42,10 @@
|
||||
showBindWarning = true
|
||||
}: Props = $props();
|
||||
|
||||
const bindMounts = $derived(volumes.filter((v) => v.mountType === 'bind'));
|
||||
// Unbackupable binds (docker.sock, host system paths) are listed for context but
|
||||
// never count as backup targets and can't be selected. The engine skips them too.
|
||||
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)) {
|
||||
@@ -56,7 +61,7 @@
|
||||
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">
|
||||
<Label class="text-xs">Backup all volumes ({volumes.length})</Label>
|
||||
<Label class="text-xs">Backup all volumes ({backupable.length})</Label>
|
||||
<TogglePill bind:checked={allVolumes} onLabel="Yes" offLabel="No" />
|
||||
</div>
|
||||
|
||||
@@ -73,11 +78,11 @@
|
||||
<!-- 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}
|
||||
<label class="flex items-center gap-2 px-3 py-1.5 text-xs" class:cursor-pointer={!allVolumes}>
|
||||
<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={allVolumes || selectedVolumes.includes(vol.name)}
|
||||
disabled={allVolumes}
|
||||
onCheckedChange={() => toggleVolume(vol.name)}
|
||||
checked={!vol.unbackupable && (allVolumes || selectedVolumes.includes(vol.name))}
|
||||
disabled={allVolumes || vol.unbackupable}
|
||||
onCheckedChange={() => !vol.unbackupable && toggleVolume(vol.name)}
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
<MountTypeBadge type={vol.mountType} size="sm" />
|
||||
@@ -85,7 +90,9 @@
|
||||
{#if vol.source && vol.source !== vol.name}
|
||||
<span class="text-muted-foreground font-mono truncate">from {vol.source}</span>
|
||||
{/if}
|
||||
{#if vol.mountPoint}
|
||||
{#if vol.unbackupable}
|
||||
<span class="text-muted-foreground ml-auto shrink-0 italic">socket / system path - not backed up</span>
|
||||
{:else if vol.mountPoint}
|
||||
<span class="text-muted-foreground ml-auto truncate">{vol.mountPoint}</span>
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
* quietly omits data is exactly the failure mode we refuse.
|
||||
*/
|
||||
|
||||
import { isUnbackupableBindSource } from '../../utils/unbackupable-mounts';
|
||||
|
||||
/** A single mount as returned by a container inspect (the fields we use). */
|
||||
export interface Mount {
|
||||
Type: string;
|
||||
@@ -102,6 +104,14 @@ export function discoverVolumesFromMounts(containers: Array<{ name: string; moun
|
||||
source: mount.Name,
|
||||
});
|
||||
} else if (mount.Type === 'bind' && mount.Source && mount.Destination) {
|
||||
if (isUnbackupableBindSource(mount.Source)) {
|
||||
skipped.push({
|
||||
type: 'bind',
|
||||
destination: mount.Destination,
|
||||
reason: `${mount.Source} is a socket or host system path, not backed up`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const pairKey = `${mount.Source}\n${mount.Destination}`;
|
||||
if (seenBindPairs.has(pairKey)) continue;
|
||||
seenBindPairs.add(pairKey);
|
||||
|
||||
+10
-2
@@ -4,6 +4,8 @@
|
||||
* unit-testable under `bun test`.
|
||||
*/
|
||||
|
||||
import { isUnbackupableBindSource } from './unbackupable-mounts';
|
||||
|
||||
export interface VolumeInfo {
|
||||
name: string;
|
||||
mountPoint: string;
|
||||
@@ -11,6 +13,10 @@ export interface VolumeInfo {
|
||||
/** Bind mounts only: the host source path, for display (name is the container
|
||||
* destination so it matches the backup engine's filter). */
|
||||
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,
|
||||
* and excluded from "backup all" — mirrors the engine's isUnbackupableBindSource. */
|
||||
unbackupable?: boolean;
|
||||
}
|
||||
|
||||
/** A raw Docker mount as returned by the containers API (list or inspect).
|
||||
@@ -44,7 +50,9 @@ export function volumeInfoFromBind(bind: { hostPath: string; containerPath: stri
|
||||
return {
|
||||
name: mountType === 'bind' ? bind.containerPath : bind.hostPath,
|
||||
mountPoint: bind.containerPath,
|
||||
mountType
|
||||
mountType,
|
||||
source: mountType === 'bind' ? bind.hostPath : undefined,
|
||||
unbackupable: mountType === 'bind' ? isUnbackupableBindSource(bind.hostPath) || undefined : undefined
|
||||
};
|
||||
}
|
||||
|
||||
@@ -64,7 +72,7 @@ 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 });
|
||||
out.push({ 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' });
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Bind mounts that must never be backed up: Docker/containerd sockets and host
|
||||
* system paths that are infrastructure, not data. Backing up a socket is
|
||||
* meaningless (it's a device node, not files) and a "backup all" that captures
|
||||
* /proc or /var/run is pointless and slow.
|
||||
*
|
||||
* Dependency-free so BOTH the backup engine (discovery-core.ts, which enumerates
|
||||
* mounts itself for "backup all") and the UI picker (utils/mounts.ts) can share
|
||||
* the exact same rule with no drift.
|
||||
*
|
||||
* Matches on the HOST SOURCE path of a bind mount, not the container destination
|
||||
* (the source is what identifies "this is the host's docker socket", regardless
|
||||
* of where a container maps it).
|
||||
*/
|
||||
|
||||
/** Exact host source paths (or their children) that are never backed up. */
|
||||
const UNBACKUPABLE_PREFIXES = [
|
||||
'/proc',
|
||||
'/sys',
|
||||
'/dev',
|
||||
'/var/run',
|
||||
'/run',
|
||||
];
|
||||
|
||||
/** Exact socket files, matched anywhere, in case they live outside the prefixes
|
||||
* above (e.g. rootless docker under /var/run/user/<uid>, already covered by
|
||||
* /var/run, but a socket bound from an unusual path still gets caught here). */
|
||||
const UNBACKUPABLE_SOCKET_BASENAMES = ['docker.sock', 'containerd.sock'];
|
||||
|
||||
/** True if a bind mount's host SOURCE is infrastructure that must not be backed
|
||||
* up (a socket or a host system path). Only applies to bind mounts — named
|
||||
* volumes are always data. */
|
||||
export function isUnbackupableBindSource(hostSource: string | undefined | null): boolean {
|
||||
if (!hostSource || !hostSource.startsWith('/')) return false;
|
||||
const path = hostSource.replace(/\/+$/, ''); // drop trailing slash
|
||||
const base = path.slice(path.lastIndexOf('/') + 1);
|
||||
if (UNBACKUPABLE_SOCKET_BASENAMES.includes(base)) return true;
|
||||
for (const prefix of UNBACKUPABLE_PREFIXES) {
|
||||
if (path === prefix || path.startsWith(prefix + '/')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
Reference in new issue
Block a user