local-path backup destinations validate the configured path
This commit is contained in:
6 files changed
+229
-3
No files matched your search
@@ -20,6 +20,7 @@ import { initRepository as initRepoCore, testRepository as testRepoCore, checkRe
|
||||
import { parseRetention, buildForgetArgs, checkWouldWipe } from './retention';
|
||||
import { buildSnapshotLayout, serializeLayout, parseSnapshotLayout, type SnapshotLayout, type SnapshotStack, type SnapshotSecret } from './snapshot-layout';
|
||||
import { instanceTagFilter, parseSnapshots, retentionTagFilter, BackupError } from './models';
|
||||
import { localRepoIssueFor } from './local-repo-path';
|
||||
import { parseOptionsJson, buildJobOptions, parseSelectedVolumes, parseBackupFlags, sanitizeRestoreFlags, fireWebhook, parseResticDiff, type SnapshotDiff } from './helpers';
|
||||
import { getHostname } from '../license';
|
||||
import { getBackupConfig, getBackupConfigs, getBackupDestination, updateBackupConfig, updateBackupDestination, decryptBackupDestination } from '../db';
|
||||
@@ -1321,8 +1322,25 @@ export async function forgetSnapshot(destinationId: number, snapshotId: string):
|
||||
|
||||
// --- repository maintenance ---
|
||||
|
||||
/**
|
||||
* Reject a local-path repo whose path isn't under any Dockhand bind mount (#1506):
|
||||
* restic would write it into the container's ephemeral filesystem while the helper
|
||||
* looks for it on the host, so both "succeed" yet never share one repo. Returns an
|
||||
* error string to surface, or null when the path is fine (bare-metal, or under a bind).
|
||||
*/
|
||||
async function localRepoPathIssue(destination: { repository: string }): Promise<string | null> {
|
||||
const { isLocalRepo } = await import('./models');
|
||||
const { getCachedContainerMounts } = await import('../host-path');
|
||||
return localRepoIssueFor(destination.repository, isLocalRepo(destination.repository), getCachedContainerMounts());
|
||||
}
|
||||
|
||||
export async function initRepository(destinationId: number): Promise<void> {
|
||||
const destination = await loadDest(destinationId);
|
||||
const pathIssue = await localRepoPathIssue(destination);
|
||||
if (pathIssue) {
|
||||
logRepoOp(destination.name, 'init', false, { error: pathIssue });
|
||||
throw new Error(pathIssue);
|
||||
}
|
||||
const r = await initRepoCore(reader(), destination);
|
||||
logRepoOp(destination.name, 'init', r.ok, r.ok ? { output: r.output } : { error: r.error });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
@@ -1330,6 +1348,11 @@ export async function initRepository(destinationId: number): Promise<void> {
|
||||
|
||||
export async function testRepository(destinationId: number): Promise<{ ok: boolean; needsInit?: boolean; error?: string }> {
|
||||
const destination = await loadDest(destinationId);
|
||||
const pathIssue = await localRepoPathIssue(destination);
|
||||
if (pathIssue) {
|
||||
logRepoOp(destination.name, 'test', false, { error: pathIssue });
|
||||
return { ok: false, error: pathIssue };
|
||||
}
|
||||
const r = await testRepoCore(reader(), destination);
|
||||
logRepoOp(destination.name, 'test', r.ok, r.ok ? undefined : { error: r.error });
|
||||
if (r.ok) return { ok: true };
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Pure validation for a LOCAL-path backup repository, shared by init/test (which run
|
||||
* restic in the Dockhand container) and the backup/restore helper (a sibling container
|
||||
* that bind-mounts the repo by its HOST path).
|
||||
*
|
||||
* The trap this guards (#1506): when Dockhand runs in a container, the "Path" field is
|
||||
* the path AS THE DOCKHAND CONTAINER SEES IT. If the user types the HOST path instead
|
||||
* (e.g. /opt/backups) while Dockhand only has it bind-mounted at /app/local-backups,
|
||||
* `restic init` writes the repo into the container's own ephemeral filesystem (it
|
||||
* vanishes on restart) and the helper - which mounts the host path - later finds an
|
||||
* empty dir. Both sides "work" in isolation but never agree on one location.
|
||||
*
|
||||
* A local repo is only usable when its container path is UNDER one of Dockhand's bind
|
||||
* mounts, so the same bytes are reachable from the host (where the helper mounts them).
|
||||
*
|
||||
* No I/O here: callers pass the cached mount table so this stays unit-testable.
|
||||
*/
|
||||
|
||||
export interface ContainerMount {
|
||||
source: string; // host path
|
||||
destination: string; // container path
|
||||
}
|
||||
|
||||
export type LocalRepoPathVerdict =
|
||||
| { ok: true; hostPath: string } // path is under a bind mount; helper mounts hostPath
|
||||
| { ok: true; hostPath: null; reason: 'no-mounts' } // bare-metal: container path IS the host path
|
||||
| { ok: false; reason: 'not-under-bind'; mountHints: string[] };
|
||||
|
||||
function isUnder(child: string, parent: string): boolean {
|
||||
return child === parent || child.startsWith(parent.replace(/\/+$/, '') + '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a local repo container path is a usable bind-mounted location.
|
||||
* - No mounts known (bare metal, or detection not run): allow; the path is already a
|
||||
* host path (there is no container/host split to get wrong).
|
||||
* - Mounts known and the path is under one: allow, returning the translated host path.
|
||||
* - Mounts known and the path is NOT under any: reject - this is the #1506 host-path
|
||||
* mistake that would write to the container's ephemeral filesystem.
|
||||
*/
|
||||
export function classifyLocalRepoPath(
|
||||
repoPath: string,
|
||||
mounts: ContainerMount[]
|
||||
): LocalRepoPathVerdict {
|
||||
if (!mounts || mounts.length === 0) {
|
||||
return { ok: true, hostPath: null, reason: 'no-mounts' };
|
||||
}
|
||||
// Longest destination first so the most specific mount wins.
|
||||
const sorted = [...mounts].sort((a, b) => b.destination.length - a.destination.length);
|
||||
for (const m of sorted) {
|
||||
if (isUnder(repoPath, m.destination)) {
|
||||
const rel = repoPath.slice(m.destination.replace(/\/+$/, '').length);
|
||||
return { ok: true, hostPath: m.source.replace(/\/+$/, '') + rel };
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'not-under-bind',
|
||||
mountHints: sorted.map((m) => m.destination)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The init/test decision as a pure function: returns an error string to surface, or
|
||||
* null when the repo is fine. `isLocal` is injected (the caller passes isLocalRepo) so
|
||||
* both the remote-exemption gate and the local-path verdict are unit-testable without
|
||||
* touching the mount cache or the models module.
|
||||
* - Remote repo (isLocal false): always null - never reject a working s3/rest/b2/sftp.
|
||||
* - Local repo under a bind (or bare metal): null.
|
||||
* - Local repo not under any bind: the #1506 host-path error.
|
||||
*/
|
||||
export function localRepoIssueFor(
|
||||
repository: string,
|
||||
isLocal: boolean,
|
||||
mounts: ContainerMount[]
|
||||
): string | null {
|
||||
if (!isLocal) return null;
|
||||
const verdict = classifyLocalRepoPath(repository, mounts);
|
||||
if (verdict.ok) return null;
|
||||
return localRepoPathError(repository, verdict.mountHints);
|
||||
}
|
||||
|
||||
/** Human-readable error for a rejected local repo path (surfaced at init/test). */
|
||||
export function localRepoPathError(repoPath: string, mountHints: string[]): string {
|
||||
const hints = mountHints.length
|
||||
? ` Use a path under one of Dockhand's bind mounts instead: ${mountHints.join(', ')}.`
|
||||
: '';
|
||||
return (
|
||||
`Local path "${repoPath}" is not inside any of Dockhand's bind mounts, so the ` +
|
||||
`repository would be written to the container's own filesystem and lost on restart, ` +
|
||||
`and the backup helper on the host would not find it.${hints} ` +
|
||||
`Enter the path as Dockhand sees it (the container side of your volume mount), not the host path.`
|
||||
);
|
||||
}
|
||||
@@ -76,7 +76,7 @@ export function tlsCertPreamble(): string {
|
||||
*/
|
||||
export function localRepoGuard(repository: string): string {
|
||||
if (!isLocalRepo(repository)) return '';
|
||||
const msg = `restic repository not found at $RESTIC_REPOSITORY on this environment's Docker host. A local-path repository only works when the environment's Docker daemon runs on the same host as Dockhand (e.g. a co-located socket-proxy). For a remote host, use an S3 or REST destination.`;
|
||||
const msg = `restic repository not found at $RESTIC_REPOSITORY on this environment's Docker host. The repo directory is empty or missing there. Two common causes: (1) the destination Path is the host path instead of the path Dockhand sees inside its container - enter the container-side path of your backup volume mount, then re-init; (2) the environment's Docker daemon is on a different host than Dockhand - a local path can't reach it, so use an S3 or REST destination instead.`;
|
||||
return `test -f "$RESTIC_REPOSITORY/config" || { echo ${shellQuote(msg)} >&2; exit 1; }; `;
|
||||
}
|
||||
|
||||
|
||||
@@ -417,6 +417,15 @@ export function translateContainerPathViaMount(containerPath: string): string |
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The container mounts cached at startup (empty when Dockhand runs on bare metal,
|
||||
* or before detectHostDataDir has run). Callers use this to tell "Dockhand is
|
||||
* containerized and this path is not under any bind" from "no mount info at all".
|
||||
*/
|
||||
export function getCachedContainerMounts(): Array<{ source: string; destination: string }> {
|
||||
return cachedMounts ? [...cachedMounts] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the host path for the Docker socket mount.
|
||||
* This is needed for sibling containers (e.g., scanners) that need socket access.
|
||||
|
||||
Reference in new issue
Block a user