diff --git a/src/lib/components/backup/DestinationPicker.svelte b/src/lib/components/backup/DestinationPicker.svelte
index c08f12c..b1082b6 100644
--- a/src/lib/components/backup/DestinationPicker.svelte
+++ b/src/lib/components/backup/DestinationPicker.svelte
@@ -11,20 +11,22 @@
* local-on-remote disabling.
*/
import * as Select from '$lib/components/ui/select';
- import { getRepoTypeIcon, isLocalRepo } from '$lib/utils/backup';
+ import { getRepoTypeIcon, localRepoNeedsSameHost } from '$lib/utils/backup';
interface Destination {
id: number;
name: string;
repository: string;
+ hostPath?: string | null;
}
interface Props {
destinations: Destination[];
/** Bound to the selected destination id. 0 = nothing selected. */
value: number;
- /** Set to true on a remote environment to disable local-path destinations. */
- disableLocalForRemoteEnv?: boolean;
+ /** The target environment. A local-path destination is disabled for a
+ * remote env unless it declares a hostPath. Omit for a local/socket env. */
+ env?: { connectionType?: string | null; host?: string | null } | null;
/** CSS class for the trigger. Defaults to full width. */
triggerClass?: string;
placeholder?: string;
@@ -33,7 +35,7 @@
let {
destinations,
value = $bindable(),
- disableLocalForRemoteEnv = false,
+ env,
triggerClass = 'h-9 w-full',
placeholder = 'Select repository...'
}: Props = $props();
@@ -60,23 +62,16 @@
{#each destinations as dest}
{@const DIcon = getRepoTypeIcon(dest.repository)}
- {@const localOnRemote = disableLocalForRemoteEnv && isLocalRepo(dest.repository)}
- {#if localOnRemote}
-
-
-
- {dest.name}
- — local
-
-
- {:else}
-
-
-
- {dest.name}
-
-
- {/if}
+ {@const localOnRemote = localRepoNeedsSameHost(dest, env)}
+
+
+
+ {dest.name}
+ {#if localOnRemote}
+ needs same host
+ {/if}
+
+
{/each}
diff --git a/src/lib/server/backups/index.ts b/src/lib/server/backups/index.ts
index 32105da..963833b 100644
--- a/src/lib/server/backups/index.ts
+++ b/src/lib/server/backups/index.ts
@@ -25,7 +25,7 @@ import { instanceTagFilter, parseSnapshots, retentionTagFilter } from './models'
import { buildSwapRecoveryScript } from './swap';
import { parseOptionsJson, fireWebhook, parseResticDiff, type SnapshotDiff } from './helpers';
import { getHostname } from '../license';
-import { getBackupConfig, getBackupConfigs, getBackupDestination, updateBackupConfig, updateBackupDestination, decryptBackupDestination, getEnvironment } from '../db';
+import { getBackupConfig, getBackupConfigs, getBackupDestination, updateBackupConfig, updateBackupDestination, decryptBackupDestination } from '../db';
import { getInstanceId } from './identity';
import { inspectContainer } from '../docker';
import { sendEventNotification } from '../notifications';
@@ -175,6 +175,18 @@ async function collectMetadata(
console.warn(`[Backup] Stack directory for "${targetName}" exceeded the backup cap (${MAX_FILES} files / ${Math.round(MAX_TOTAL_BYTES / 1024 / 1024)} MB) — only ${count} file(s) / ${bytes} byte(s) captured. The snapshot is INCOMPLETE for redeploy; reduce the stack dir or exclude large sibling files.`);
}
} catch { /* directory walk best-effort — compose/.env already captured */ }
+ // Carry the stack's secret env vars IN the snapshot so a restore reproduces a
+ // WORKING stack — secrets and all — even after the source stack (and its DB
+ // rows) are gone. Stored as their at-rest ciphertext (enc:v1:...), encrypted
+ // under THIS instance's key: a restore on the same instance decrypts them
+ // transparently; on a fresh instance the operator must carry over the same
+ // encryption key (.encryption_key / ENCRYPTION_KEY) or they stay unreadable.
+ // Values are NEVER stored plaintext (getStackSecretCiphertexts guarantees it).
+ try {
+ const { getStackSecretCiphertexts } = await import('../db');
+ const secrets = await getStackSecretCiphertexts(targetName, envId ?? null);
+ if (secrets.length > 0) metadata.secrets = secrets;
+ } catch { /* best-effort — a lookup failure just omits secrets from the snapshot */ }
// Rewrite metadata.json with hasStackFiles set.
files[0] = { path: 'metadata.json', contentBase64: Buffer.from(JSON.stringify(metadata, null, 2)).toString('base64') };
}
@@ -224,6 +236,89 @@ function backupPorts(destination: any, onProgress?: (status: string, message: st
// =============================================================================
// RestorePorts — the real implementation, bound to one destination + access ctx
// =============================================================================
+/**
+ * Extract a snapshot's captured stack files (/metadata/stacks//stackfiles/) into
+ * `targetPath` under the stacks root, with a path-traversal guard, a data-loss guard
+ * (an empty extraction never wipes the live dir), and an atomic staging-swap so a
+ * mid-copy failure can't leave the dir half-written. Returns false (no-op) when the
+ * snapshot has no stackfiles. Shared by the writeLocalStackFiles port and the restore
+ * redeploy path (which materialises into the CANONICAL stack dir). `destination` is
+ * already resolved by the caller.
+ */
+async function materialiseStackFiles(destination: any, snapId: string, stackName: string, targetPath: string, overwrite: boolean): Promise {
+ const { mkdtempSync, mkdirSync, rmSync, existsSync, cpSync, readdirSync, renameSync } = await import('fs');
+ const { tmpdir } = await import('os');
+ const { join, dirname, resolve, sep } = await import('path');
+ const { randomUUID } = await import('crypto');
+ const { getStacksDir } = await import('../stacks');
+ // Use `restic restore --include` (not dump→tar): restore writes a
+ // DETERMINISTIC path under the target — /metadata/stacks//
+ // stackfiles/ — so there's no tar-prefix guessing.
+ const tmp = mkdtempSync(join(tmpdir(), 'dh-stackfiles-'));
+ console.log(`[Backup] materialiseStackFiles: "${stackName}" snapshot=${snapId} → extract to ${tmp}, then swap into ${targetPath} (overwrite=${overwrite})`);
+ try {
+ const include = `/metadata/stacks/${stackName}/stackfiles`;
+ const run = await restic.runLocal(destination, ['restore', snapId, '--target', tmp, '--include', include], 'data');
+ if (run.exitCode !== 0) {
+ console.log(`[Backup] materialiseStackFiles: restic restore failed for "${stackName}": ${run.stderr.trim() || `exit ${run.exitCode}`}`);
+ return false;
+ }
+ const extractedDir = join(tmp, 'metadata', 'stacks', stackName, 'stackfiles');
+ // DATA-LOSS guard: only proceed if extraction actually produced files.
+ const extracted = existsSync(extractedDir) ? readdirSync(extractedDir) : [];
+ if (extracted.length === 0) {
+ console.log(`[Backup] materialiseStackFiles: no stackfiles extracted for "${stackName}" (snapshot may predate full-dir capture) — leaving ${targetPath} untouched`);
+ return false;
+ }
+
+ // Path-traversal guard: the resolved target MUST stay under the stacks root.
+ const resolvedTarget = resolve(targetPath);
+ const stacksRoot = resolve(getStacksDir());
+ if (resolvedTarget !== stacksRoot && !resolvedTarget.startsWith(stacksRoot + sep)) {
+ console.log(`[Backup] materialiseStackFiles: refusing target "${resolvedTarget}" outside the stacks root "${stacksRoot}"`);
+ return false;
+ }
+
+ mkdirSync(resolvedTarget, { recursive: true });
+
+ // Atomic staging-swap: build the desired contents in a sibling staging dir
+ // FIRST, then swap it into place with a same-parent rename. On OVERWRITE the
+ // staging holds ONLY the restored files; on merge it starts as a copy of live.
+ const parent = dirname(resolvedTarget);
+ const staging = mkdtempSync(join(parent, '.dockhand-stack-new-'));
+ const oldAside = join(parent, `.dockhand-stack-old-${randomUUID()}`);
+ try {
+ if (!overwrite) {
+ for (const existing of readdirSync(resolvedTarget)) {
+ cpSync(join(resolvedTarget, existing), join(staging, existing), { recursive: true, force: true });
+ }
+ }
+ for (const entry of extracted) {
+ cpSync(join(extractedDir, entry), join(staging, entry), { recursive: true, force: true });
+ }
+ renameSync(resolvedTarget, oldAside);
+ try {
+ renameSync(staging, resolvedTarget);
+ } catch (swapErr) {
+ try { renameSync(oldAside, resolvedTarget); } catch { /* best effort */ }
+ throw swapErr;
+ }
+ rmSync(oldAside, { recursive: true, force: true });
+ console.log(`[Backup] materialiseStackFiles: wrote ${extracted.length} entries to ${resolvedTarget} (${overwrite ? 'replaced' : 'merged'})`);
+ return true;
+ } catch (copyErr) {
+ try { rmSync(staging, { recursive: true, force: true }); } catch { /* best effort */ }
+ if (existsSync(oldAside) && !existsSync(resolvedTarget)) {
+ try { renameSync(oldAside, resolvedTarget); } catch { /* best effort */ }
+ }
+ try { rmSync(oldAside, { recursive: true, force: true }); } catch { /* best effort */ }
+ throw copyErr;
+ }
+ } finally {
+ rmSync(tmp, { recursive: true, force: true });
+ }
+}
+
function restorePorts(destination: any, access: { isEnterprise: boolean; canAccessEnvironment: (id: number) => Promise }, onProgress?: (status: string, message: string) => void): RestorePorts {
const run = boundRunner(destination);
return {
@@ -313,11 +408,14 @@ function restorePorts(destination: any, access: { isEnterprise: boolean; canAcce
// sibling configs referenced by relative paths resolve, and the file keeps its
// real name (e.g. immich.yaml). The backup always captures the full dir plus the
// recorded compose filename, so there is no normalized-compose.yaml fallback.
- redeployStack: async (name, envId, destId, snapId) => {
- const { redeployStackFromDir } = await import('../stacks');
- const { mkdtempSync, rmSync, existsSync, readdirSync } = await import('fs');
- const { tmpdir } = await import('os');
+ redeployStack: async (name, envId, destId, snapId, restoreSecrets) => {
+ const { redeployStackFromDir, getStackDir } = await import('../stacks');
+ const { existsSync } = await import('fs');
const { join } = await import('path');
+ const { upsertStackSource } = await import('../db');
+
+ const log = (msg: string) => console.log(`[Restore:redeploy ${name}] ${msg}`);
+ log(`start: env=${envId ?? 'local'} destination=${destId} snapshot=${snapId}`);
const meta = await getSnapshotMetadata(destId, snapId);
const composeFileName = meta && typeof meta.composeFileName === 'string' ? meta.composeFileName : '';
@@ -325,21 +423,57 @@ function restorePorts(destination: any, access: { isEnterprise: boolean; canAcce
throw new Error('snapshot has no recorded compose filename; cannot redeploy this stack');
}
- const destination = await loadDest(destId);
- const tmp = mkdtempSync(join(tmpdir(), 'dh-redeploy-'));
- try {
- const include = `/metadata/stacks/${name}/stackfiles`;
- const run = await restic.runLocal(destination, ['restore', snapId, '--target', tmp, '--include', include], 'data');
- const extractedDir = join(tmp, 'metadata', 'stacks', name, 'stackfiles');
- const extractedNames = run.exitCode === 0 && existsSync(extractedDir) ? readdirSync(extractedDir) : [];
- if (!extractedNames.includes(composeFileName)) {
- throw new Error(`snapshot stackfiles/ is missing the compose file "${composeFileName}"; cannot redeploy`);
+ // Restore the stack's secrets FROM THE SNAPSHOT (opt-in, default on). They are
+ // stored as at-rest ciphertext encrypted under this instance's key; writing
+ // them into the TARGET env's DB before redeploy lets redeployStackFromDir's
+ // normal secret injection pick them up. setStackEnvVars re-runs encrypt(),
+ // which is a pass-through for an already-encrypted value, so the ciphertext is
+ // stored verbatim (no double-encryption). On a different instance without the
+ // matching key these values won't decrypt — the restore UI warns about that.
+ if (restoreSecrets && Array.isArray(meta?.secrets) && meta.secrets.length > 0) {
+ const { setStackEnvVars } = await import('../db');
+ const secrets = meta.secrets
+ .filter((s: any) => s && typeof s.key === 'string' && typeof s.value === 'string')
+ .map((s: any) => ({ key: s.key, value: s.value, isSecret: true }));
+ if (secrets.length > 0) {
+ await setStackEnvVars(name, envId ?? null, secrets);
+ log(`restored ${secrets.length} secret(s) from snapshot → env ${envId ?? 'local'}`);
}
- const r = await redeployStackFromDir(name, extractedDir, composeFileName, envId ?? undefined);
- if (!r.success) throw new Error(r.error || 'docker compose up failed');
- } finally {
- try { rmSync(tmp, { recursive: true, force: true }); } catch { /* best effort */ }
}
+
+ // Materialise the snapshot's stack files into the CANONICAL stack dir
+ // (stacks///) — where every managed stack lives — and
+ // bring the stack up FROM THERE, so after a restore (even one that deleted
+ // the stack completely) it is a normal managed stack on disk, not a
+ // throwaway /tmp copy (#1329). writeLocalStackFiles carries the path-
+ // traversal + data-loss + atomic-swap guards; overwrite=true because an
+ // in-place restore is destructive by design.
+ const stackDir = await getStackDir(name, envId ?? undefined);
+ const destination = await loadDest(destId);
+ log(`materialising snapshot stackfiles → canonical dir: ${stackDir} (compose: ${composeFileName})`);
+ const wrote = await materialiseStackFiles(destination, snapId, name, stackDir, true);
+ if (!wrote || !existsSync(join(stackDir, composeFileName))) {
+ throw new Error(`could not materialise stack files for "${name}" into ${stackDir}; cannot redeploy`);
+ }
+ log(`materialised OK → ${stackDir}`);
+
+ log(`redeploying (docker compose up) from: ${stackDir}`);
+ const r = await redeployStackFromDir(name, stackDir, composeFileName, envId ?? undefined);
+ if (!r.success) throw new Error(r.error || 'docker compose up failed');
+ log(`redeploy OK`);
+
+ // Register the restored stack as managed (internal) so the UI can
+ // edit/redeploy it — a complete-delete-then-restore must come back managed.
+ const composePath = join(stackDir, composeFileName);
+ const envPath = existsSync(join(stackDir, '.env')) ? join(stackDir, '.env') : null;
+ await upsertStackSource({
+ stackName: name,
+ environmentId: envId ?? null,
+ sourceType: 'internal',
+ composePath,
+ envPath
+ });
+ log(`registered as managed (internal): composePath=${composePath} envPath=${envPath ?? '(none)'}`);
},
readSnapshotMetadata: (destId, snapId) => getSnapshotMetadata(destId, snapId),
// Materialise the snapshot's captured stack files into Dockhand's LOCAL data
@@ -347,88 +481,8 @@ function restorePorts(destination: any, access: { isEnterprise: boolean; canAcce
// files were backed up under /metadata/stacks//stackfiles/; dump that
// subtree as a tar and extract it flat into targetPath.
writeLocalStackFiles: async (destId, snapId, stackName, targetPath, overwrite) => {
- const { mkdtempSync, mkdirSync, rmSync, existsSync, cpSync, readdirSync, renameSync } = await import('fs');
- const { tmpdir } = await import('os');
- const { join, dirname, resolve, sep } = await import('path');
- const { randomUUID } = await import('crypto');
- const { getStacksDir } = await import('../stacks');
- // Use `restic restore --include` (not dump→tar): restore writes a
- // DETERMINISTIC path under the target — /metadata/stacks//
- // stackfiles/ — so there's no tar-prefix guessing. Mirrors the proven
- // approach the previous engine used (commit 5edcfa07 et al.).
const destination = await loadDest(destId);
- const tmp = mkdtempSync(join(tmpdir(), 'dh-stackfiles-'));
- try {
- const include = `/metadata/stacks/${stackName}/stackfiles`;
- const run = await restic.runLocal(destination, ['restore', snapId, '--target', tmp, '--include', include], 'data');
- if (run.exitCode !== 0) {
- console.log(`[Backup] writeLocalStackFiles: restic restore failed for "${stackName}": ${run.stderr.trim() || `exit ${run.exitCode}`}`);
- return false;
- }
- const extractedDir = join(tmp, 'metadata', 'stacks', stackName, 'stackfiles');
- // DATA-LOSS guard: only proceed if extraction actually produced files.
- // A snapshot that predates full-dir capture, or a corrupt path, yields
- // nothing — replacing the live dir with an empty one would be data loss.
- const extracted = existsSync(extractedDir) ? readdirSync(extractedDir) : [];
- if (extracted.length === 0) {
- console.log(`[Backup] writeLocalStackFiles: no stackfiles extracted for "${stackName}" (snapshot may predate full-dir capture) — leaving ${targetPath} untouched`);
- return false;
- }
-
- // Path-traversal guard: the resolved target MUST stay under the stacks
- // root before any mkdir/rename/rm — a "../.." name must never let a
- // restore wipe an arbitrary directory.
- const resolvedTarget = resolve(targetPath);
- const stacksRoot = resolve(getStacksDir());
- if (resolvedTarget !== stacksRoot && !resolvedTarget.startsWith(stacksRoot + sep)) {
- console.log(`[Backup] writeLocalStackFiles: refusing target "${resolvedTarget}" outside the stacks root "${stacksRoot}"`);
- return false;
- }
-
- mkdirSync(resolvedTarget, { recursive: true });
-
- // Atomic staging-swap (audit #3 / HIGH #2): build the desired contents
- // in a sibling staging dir FIRST, then swap it into place with a
- // same-parent rename, so a mid-copy failure can never leave the live
- // stack dir half-wiped. On OVERWRITE the staging holds ONLY the restored
- // files (stale files cleared); on merge it starts as a copy of the live
- // dir so existing user files are preserved.
- const parent = dirname(resolvedTarget);
- const staging = mkdtempSync(join(parent, '.dockhand-stack-new-'));
- const oldAside = join(parent, `.dockhand-stack-old-${randomUUID()}`);
- try {
- if (!overwrite) {
- for (const existing of readdirSync(resolvedTarget)) {
- cpSync(join(resolvedTarget, existing), join(staging, existing), { recursive: true, force: true });
- }
- }
- for (const entry of extracted) {
- cpSync(join(extractedDir, entry), join(staging, entry), { recursive: true, force: true });
- }
- // Copy fully succeeded — same-parent swap: live aside, staging in.
- renameSync(resolvedTarget, oldAside);
- try {
- renameSync(staging, resolvedTarget);
- } catch (swapErr) {
- try { renameSync(oldAside, resolvedTarget); } catch { /* best effort */ }
- throw swapErr;
- }
- rmSync(oldAside, { recursive: true, force: true });
- console.log(`[Backup] writeLocalStackFiles: wrote ${extracted.length} entries to ${resolvedTarget} (${overwrite ? 'replaced' : 'merged'})`);
- return true;
- } catch (copyErr) {
- // Fail closed: clean up staging/aside, restore the live dir if the
- // swap left it moved, and rethrow so the restore reports FAILED.
- try { rmSync(staging, { recursive: true, force: true }); } catch { /* best effort */ }
- if (existsSync(oldAside) && !existsSync(resolvedTarget)) {
- try { renameSync(oldAside, resolvedTarget); } catch { /* best effort */ }
- }
- try { rmSync(oldAside, { recursive: true, force: true }); } catch { /* best effort */ }
- throw copyErr;
- }
- } finally {
- rmSync(tmp, { recursive: true, force: true });
- }
+ return materialiseStackFiles(destination, snapId, stackName, targetPath, overwrite);
},
// --- clone (cross-env restore) -----------------------------------------------
volumeExists: async (name, envId) => {
@@ -499,7 +553,7 @@ export async function previewSnapshot(
destinationId: number,
snapshotId: string,
access: { isEnterprise: boolean; canAccessEnvironment: (id: number) => Promise } = { isEnterprise: false, canAccessEnvironment: async () => true },
-): Promise<{ snapshotId: string; volumes: string[]; volumeTypes: Record; volumeSources: Record; backupTime: string | null; sourceEnvironmentId: number | null; hasMetadata: boolean; hasStackFiles: boolean }> {
+): Promise<{ snapshotId: string; volumes: string[]; volumeTypes: Record; volumeSources: Record; backupTime: string | null; sourceEnvironmentId: number | null; hasMetadata: boolean; hasStackFiles: boolean; sourceSecretKeys: string[] }> {
const destination = await getBackupDestination(destinationId);
if (!destination) throw new Error('backup destination not found');
const instanceId = await getInstanceId();
@@ -518,6 +572,13 @@ export async function previewSnapshot(
const volumeSources: Record = {};
let backupTime: string | null = null;
let sourceEnvironmentId: number | null = null;
+ // Names (never values) of the secret env vars carried IN the snapshot for this stack.
+ // The restore UI lists these under the "restore secrets from this backup" toggle so
+ // the operator sees exactly which secrets come back. The VALUES live in the snapshot
+ // as ciphertext (never exposed here) and are written to the DB by redeployStack.
+ let sourceSecretKeys: string[] = [];
+ let mdType: string | null = null;
+ let mdTargetName: string | null = null;
if (hasMetadata) {
const dump = await restic.runLocal(destination, ['dump', '--no-lock', snapshotId, '/metadata/metadata.json']);
if (dump.exitCode === 0) {
@@ -525,6 +586,13 @@ export async function previewSnapshot(
const md = JSON.parse(dump.stdout);
backupTime = md?.backupTime ?? null;
sourceEnvironmentId = typeof md?.environmentId === 'number' ? md.environmentId : null;
+ mdType = typeof md?.type === 'string' ? md.type : null;
+ mdTargetName = typeof md?.targetName === 'string' ? md.targetName : null;
+ if (Array.isArray(md?.secrets)) {
+ sourceSecretKeys = md.secrets
+ .map((s: any) => s?.key)
+ .filter((k: unknown): k is string => typeof k === 'string');
+ }
for (const v of md?.volumes ?? []) {
// Key by v.key — that's what listSnapshotVolumes returns (the /volumes/
// segment) and what the restore UI indexes on. For a bind, key !== name
@@ -537,7 +605,7 @@ export async function previewSnapshot(
} catch { /* leave types empty on malformed metadata */ }
}
}
- return { snapshotId, volumes, volumeTypes, volumeSources, backupTime, sourceEnvironmentId, hasMetadata, hasStackFiles };
+ return { snapshotId, volumes, volumeTypes, volumeSources, backupTime, sourceEnvironmentId, hasMetadata, hasStackFiles, sourceSecretKeys };
}
// --- snapshot reads (routes already apply the env guard; we add instance own.) ---
@@ -702,25 +770,25 @@ export type RepoTask = 'unlock' | 'check' | 'prune' | 'stats' | 'repair-index' |
/** Run a repo-maintenance task. Returns { success } for the route + activity log. */
export interface RepoStats { totalSize: number; totalFiles: number; snapshots: number }
-export async function runRepoTask(destinationId: number, task: RepoTask, opts?: { maxUnused?: string; dataSubset?: string }): Promise<{ success: boolean; output?: string; error?: string; stats?: RepoStats }> {
+export async function runRepoTask(destinationId: number, task: RepoTask, opts?: { maxUnused?: string; dataSubset?: string; staleOnly?: boolean }): Promise<{ success: boolean; output?: string; error?: string; stats?: RepoStats }> {
const destination = await loadDest(destinationId);
if (task === 'repair-index' || task === 'repair-snapshots') {
const sub = task === 'repair-index' ? 'index' : 'snapshots';
- // repair takes the repo lock — serialize against a concurrent backup to the same repo.
- const run = await serializeByRepo(destinationId, () => restic.runLocal(destination, ['repair', sub], 'data'));
+ // repair takes the repo lock — serialize against a concurrent backup to the same
+ // repo, and --retry-lock so a legitimately-running cross-instance backup doesn't
+ // make repair fail REPO_LOCKED (parity with prune/forget).
+ const run = await serializeByRepo(destinationId, () => restic.runLocal(destination, ['repair', sub, '--retry-lock', '5m'], 'data'));
logRepoOp(destination.name, `repair ${sub}`, run.exitCode === 0, { output: run.stdout, error: run.stderr });
return run.exitCode === 0 ? { success: true, output: run.stdout.trim() } : { success: false, error: run.stderr.trim() || 'repair failed' };
}
// prune/check hold the repo lock; run them serialized on the destination so a
// scheduled maintenance pass can't collide with a backup writing to the same repo.
- // unlock is ALSO serialized: it now uses `--remove-all` (see unlockRepository), so
- // running it unserialized could wipe the LIVE lock of a concurrent backup/restore on
- // the same repo in THIS instance. Serializing means the only locks it clears are
- // orphans (dead owners) — exactly its documented job. stats (--no-lock) is read-safe
- // and stays unserialized.
+ // unlock: the EXPLICIT user action uses `--remove-all`; an AUTOMATIC caller passes
+ // staleOnly (plain unlock) so it never wipes a live foreign lock on a shared repo.
+ // stats (--no-lock) is read-safe and stays unserialized.
const r = task === 'prune' ? await serializeByRepo(destinationId, () => pruneRepository(reader(), destination, opts?.maxUnused))
: task === 'check' ? await serializeByRepo(destinationId, () => checkRepository(reader(), destination, opts?.dataSubset))
- : task === 'unlock' ? await serializeByRepo(destinationId, () => unlockRepository(reader(), destination))
+ : task === 'unlock' ? await serializeByRepo(destinationId, () => unlockRepository(reader(), destination, !opts?.staleOnly))
: await repoStats(reader(), destination);
logRepoOp(destination.name, task, r.ok, r.ok ? { output: r.output } : { error: r.error });
if (!r.ok) return { success: false, error: r.error };
@@ -924,18 +992,6 @@ async function runBackupRegistered(configId: number, triggeredBy: 'cron' | 'manu
/** Run a restore. The route layer has already validated the request + auth and
* supplies the caller's access context for the ownership guard. */
-/** A filesystem repo (absolute or relative path) rather than a remote backend. */
-function isLocalRepo(repository: string): boolean {
- return repository.startsWith('/') || repository.startsWith('./');
-}
-/** An environment whose Docker daemon is NOT Dockhand's own host. */
-function isRemoteEnv(connectionType?: string | null, host?: string | null): boolean {
- if (!connectionType) return false;
- if (connectionType === 'hawser-standard' || connectionType === 'hawser-edge') return true;
- if (connectionType === 'direct' && !!host) return true;
- return false;
-}
-
export async function runRestore(
job: RestoreJob,
access: { isEnterprise: boolean; canAccessEnvironment: (id: number) => Promise } = { isEnterprise: false, canAccessEnvironment: async () => true },
@@ -944,18 +1000,9 @@ export async function runRestore(
const destination = await getBackupDestination(job.destinationId);
if (!destination) return { status: 'error', code: 'VALIDATION', error: 'backup destination not found' };
- // A local filesystem repository lives on Dockhand's own host and is bind-mounted
- // into the restore helper. That only works when the helper runs on the same host
- // (socket env); on a remote environment the repo path doesn't exist, so the
- // restore would fail deep in restic. Reject it up front with a clear message —
- // mirrors the same guard on the backup-config path.
- if (job.environmentId != null && isLocalRepo(destination.repository)) {
- const env = await getEnvironment(job.environmentId);
- if (env && isRemoteEnv(env.connectionType, env.host)) {
- return { status: 'error', code: 'VALIDATION', error: `Local repository "${destination.name}" cannot restore to remote environment "${env.name}" — the repository path only exists on Dockhand's host. Use an S3 or REST destination for cross-host restores.` };
- }
- }
-
+ // A local-path repo is allowed on any env: the restore helper (on the target
+ // daemon) fails loud via the localRepoGuard if the repo isn't visible there
+ // (wrong-host mount), so no silent bad restore — see restic-script.ts.
return new RestoreService(restorePorts(destination, access, onProgress)).run(job, 'manual');
}
diff --git a/src/lib/server/backups/repo.ts b/src/lib/server/backups/repo.ts
index 6cb034a..4a74f58 100644
--- a/src/lib/server/backups/repo.ts
+++ b/src/lib/server/backups/repo.ts
@@ -84,19 +84,24 @@ export async function pruneRepository(restic: ResticLocal, destination: any, max
return toResult(run);
}
-/** Remove stale repository locks left by a crashed operation. */
-export async function unlockRepository(restic: ResticLocal, destination: any): Promise {
- // `--remove-all`, not a plain `unlock`. This is an EXPLICIT, user-initiated action
- // ("Unlock" in the UI / `task:unlock`), whose documented purpose is to "remove stale
- // locks left behind when a previous restic operation crashed or was killed" and is
- // "safe if no other operation is currently running against the repo". A plain
- // `restic unlock` only removes locks it can prove stale, and it CANNOT prove that for
- // a lock whose hostname belongs to a now-gone helper CONTAINER (restic:
- // `if hn != l.Hostname { return false }`, then a 30-min age-out) — i.e. exactly the
- // orphaned lock this button exists to clear. So the plain form silently failed to do
- // what the UI/manual promise. `--remove-all` makes it actually clear those locks.
- // Only reachable via a deliberate user/API call, never automatic retention.
- const run = await restic.runLocal(destination, ['unlock', '--remove-all']);
+/**
+ * Remove repository locks left by a crashed operation.
+ *
+ * `removeAll` (default true) uses `restic unlock --remove-all` — clears EVERY lock,
+ * including one it cannot prove stale (an orphan whose helper-container hostname restic
+ * won't age out for 30 min). That is what the EXPLICIT user "Unlock" button needs, and
+ * it is safe only when no other op runs against the repo — the operator's call.
+ *
+ * `removeAll = false` is a plain `restic unlock`: it reaps ONLY locks restic can prove
+ * stale and LEAVES a live foreign lock intact. AUTOMATIC callers (scheduled maintenance
+ * auto-unlock) MUST use this — the per-repo serializer is per-instance and cannot see a
+ * separate instance's live lock on a shared repo, so a blind --remove-all there would
+ * silently wipe another instance's in-flight backup lock (same hazard backup-service
+ * deliberately avoids). #1313-adjacent shared-repo data-safety fix.
+ */
+export async function unlockRepository(restic: ResticLocal, destination: any, removeAll = true): Promise {
+ const args = removeAll ? ['unlock', '--remove-all'] : ['unlock'];
+ const run = await restic.runLocal(destination, args);
return toResult(run);
}
diff --git a/src/lib/server/backups/restic-script.ts b/src/lib/server/backups/restic-script.ts
index 621d8fa..1987caf 100644
--- a/src/lib/server/backups/restic-script.ts
+++ b/src/lib/server/backups/restic-script.ts
@@ -1,15 +1,8 @@
/**
- * backups/restic-script.ts — pure helpers for building the shell command a
- * restic helper container runs, and reading its exit code back. No I/O, no DB,
- * no docker — so these are unit-testable in isolation (the Restic class in
- * ./restic that uses them is the side-effecting edge).
- *
- * Why an exit marker instead of relying on the container's exit code: restic's
- * `backup` exit code 3 ("could not read all source files") is a partial-but-
- * valid outcome we must be able to see as a warning rather than a hard failure.
- * The helper script captures restic's real exit code and prints it as its final
- * stdout line; the caller reads it back. The container's own shell always exits
- * 0 so the docker layer doesn't treat a mere restic non-zero as a helper crash.
+ * Pure builders for the restic helper container's shell command + reading its
+ * exit code back. The exit marker preserves restic's real code (esp. exit 3 =
+ * partial backup, a warning not a failure) since the helper's shell always
+ * exits 0 so docker doesn't treat a restic non-zero as a crash.
*/
/** The marker line the helper prints carrying restic's real exit code. */
@@ -26,26 +19,47 @@ export function resticCommand(args: string[]): string {
}
/**
- * Wrap a command so it runs, captures its exit code, and prints the exit marker
- * as the FINAL stdout line — regardless of success or failure. The command runs
- * WITHOUT `set -e` so a restic non-zero (e.g. exit 3) doesn't abort before the
- * marker is printed.
- *
- * CONSTRAINT: `command` must RETURN its status (which restic and `sh -c '...'`
- * do), not terminate the shell with a bare `exit N` — a bare exit ends the whole
- * script before the marker line runs, and the caller would then read `undefined`
- * (treated as an unknown-outcome failure). When composing a multi-step session
- * script, wrap a step whose failure must be caught in a subshell `( ... )` or
- * `sh -c '...'` rather than letting `set -e` / `exit` end the wrapper.
+ * Append the exit marker after `command` so the caller reads its real code.
+ * GOTCHA: `command` must RETURN its status, not `exit N` - a bare exit ends the
+ * script before the marker prints, and the caller reads `undefined` (= failure).
+ * Wrap a step that might `exit`/`set -e` in a subshell `( ... )`.
*/
export function finishScript(command: string): string {
return `${command}; __rc=$?; echo "${EXIT_MARKER}\${__rc}"`;
}
+/**
+ * Fail loud if a bind-mounted local repo isn't visible on the target daemon's
+ * host: Docker would auto-create the missing path empty, silently backing up to
+ * the wrong host. '' for non-local repos (they never bind a path).
+ */
+export function localRepoGuard(repository: string): string {
+ if (!(repository.startsWith('/') || repository.startsWith('./'))) 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.`;
+ return `test -f "$RESTIC_REPOSITORY/config" || { echo ${shellQuote(msg)} >&2; exit 1; }; `;
+}
+
+/**
+ * The helper runs as root (it must read root-owned source volumes), so it writes
+ * the repo files as root. The main Dockhand process reads the repo as its own
+ * (su-exec'd) uid, so for a LOCAL-path repo it couldn't read root-owned files
+ * ("snapshot not found"). Re-own the repo to the main process's uid:gid after the
+ * op so subsequent local reads work. Best-effort (`|| true`) and appended AFTER the
+ * exit marker, so it never changes restic's real exit code. '' for non-local repos
+ * (no bind, nothing to chown) and when ownerSpec is empty.
+ * Note: this only re-owns the repo FILES; the ownership of the BACKED-UP data lives
+ * in snapshot metadata and is untouched (restore fidelity is preserved).
+ */
+export function localRepoChown(repository: string, ownerSpec: string): string {
+ if (!ownerSpec) return '';
+ if (!(repository.startsWith('/') || repository.startsWith('./'))) return '';
+ return `; chown -R ${ownerSpec} "$RESTIC_REPOSITORY" 2>/dev/null || true`;
+}
+
/**
* Read the restic exit code from a helper's stdout (the EXIT_MARKER line, read
* from the end so trailing output wins). Returns undefined if the marker is
- * absent — meaning the script did not run to completion (killed / crashed),
+ * absent - meaning the script did not run to completion (killed / crashed),
* i.e. an unknown outcome the caller must treat as failure.
*/
export function readExitMarker(stdout: string): number | undefined {
@@ -60,6 +74,35 @@ export function readExitMarker(stdout: string): number | undefined {
return undefined;
}
+/**
+ * Classify a spawned restic process's `close` event into the fail-closed exit/stderr
+ * contract, independent of how stdout was captured (string or Buffer). A timeout kill
+ * gives `code === null` + a signal, which maps to `exitCode: undefined` (unknown
+ * outcome = failure for the caller), and the killing signal is appended to stderr so
+ * it is visible in diagnostics.
+ */
+export function classifyProcClose(
+ code: number | null,
+ signal: NodeJS.Signals | null,
+ stderr: string
+): { exitCode: number | undefined; stderr: string } {
+ const exitCode = code === null ? undefined : code;
+ const stderrOut = signal ? `${stderr}\n(process killed by ${signal})` : stderr;
+ return { exitCode, stderr: stderrOut };
+}
+
+/**
+ * Classify a spawn `error` (restic missing / not spawnable): a real failure surfaced
+ * as data with `exitCode: undefined` (never thrown), the error message appended to
+ * stderr so the caller sees why.
+ */
+export function classifyProcError(
+ err: Error,
+ stderr: string
+): { exitCode: undefined; stderr: string } {
+ return { exitCode: undefined, stderr: `${stderr}\n${err.message}` };
+}
+
/**
* Build the KEY=value env array for a restic helper container: repo + password
* + a progress-rate hint, plus the cloud credentials that survive the allowlist.
@@ -81,8 +124,8 @@ export function buildHelperEnv(
}
/**
- * Build the bind list for a restic helper: the caller's volume binds, plus — for
- * a local (filesystem) repository only — the repo path bind so restic can reach
+ * Build the bind list for a restic helper: the caller's volume binds, plus - for
+ * a local (filesystem) repository only - the repo path bind so restic can reach
* it. `resolveLocalRepoHostPath` maps the in-container repo path to a host path.
*/
export function buildHelperBinds(
diff --git a/src/lib/server/backups/restic.ts b/src/lib/server/backups/restic.ts
index 60da602..09da8a6 100644
--- a/src/lib/server/backups/restic.ts
+++ b/src/lib/server/backups/restic.ts
@@ -37,7 +37,7 @@ import {
} from './security';
import { TIMEOUTS, type ResticRun, type TimeoutTier } from './models';
import { withTimeout } from './helpers';
-import { resticCommand, finishScript, readExitMarker, buildHelperEnv, buildHelperBinds } from './restic-script';
+import { resticCommand, finishScript, readExitMarker, buildHelperEnv, buildHelperBinds, localRepoGuard, localRepoChown, classifyProcClose, classifyProcError } from './restic-script';
import { translateContainerPathViaMount } from '../host-path';
/** The label key stamped on every helper container so a reaper only removes
@@ -119,15 +119,13 @@ export class Restic {
proc.stdout.on('data', (d) => { stdout += d; });
proc.stderr.on('data', (d) => { stderr += d; });
proc.on('close', (code, signal) => {
- // A timeout kill gives code === null + a signal → exit is undefined
- // (unknown outcome), which the caller treats as failure.
- const exitCode = code === null ? undefined : code;
- const stderrOut = signal ? `${stderr}\n(process killed by ${signal})` : stderr;
+ const { exitCode, stderr: stderrOut } = classifyProcClose(code, signal, stderr);
done({ exitCode, stdout, stderr: stderrOut });
});
proc.on('error', (err) => {
// restic missing / not spawnable — a real failure, surfaced as data.
- done({ exitCode: undefined, stdout, stderr: `${stderr}\n${err.message}` });
+ const { exitCode, stderr: stderrOut } = classifyProcError(err, stderr);
+ done({ exitCode, stdout, stderr: stderrOut });
});
});
}
@@ -163,12 +161,12 @@ export class Restic {
proc.stdout.on('data', (d: Buffer) => { chunks.push(Buffer.from(d)); });
proc.stderr.on('data', (d) => { stderr += d; });
proc.on('close', (code, signal) => {
- const exitCode = code === null ? undefined : code;
- const stderrOut = signal ? `${stderr}\n(process killed by ${signal})` : stderr;
+ const { exitCode, stderr: stderrOut } = classifyProcClose(code, signal, stderr);
done({ exitCode, stdout: Buffer.concat(chunks), stderr: stderrOut });
});
proc.on('error', (err) => {
- done({ exitCode: undefined, stdout: Buffer.concat(chunks), stderr: `${stderr}\n${err.message}` });
+ const { exitCode, stderr: stderrOut } = classifyProcError(err, stderr);
+ done({ exitCode, stdout: Buffer.concat(chunks), stderr: stderrOut });
});
});
}
@@ -194,9 +192,18 @@ export class Restic {
// code. A caller-supplied script (e.g. the in-place restore swap) may use
// `set -e`, so capture its exit in a subshell — the marker line must still
// print on success, otherwise a clean run reads back as undefined (failure).
+ // The local-repo guard runs FIRST (inside the marked command, so its exit 1
+ // surfaces via the marker): a local repo whose `config` isn't visible on the
+ // target daemon's host is a wrong-host mount — fail loud, not silently empty.
+ const guard = localRepoGuard(destination.repository);
+ // The helper runs as root; re-own a local repo to the main process's uid:gid
+ // after the op so the main process (su-exec'd to that uid) can read it back.
+ // Appended AFTER the exit marker so it never changes restic's exit code.
+ const ownerSpec = `${process.getuid?.() ?? 0}:${process.getgid?.() ?? 0}`;
+ const chown = localRepoChown(destination.repository, ownerSpec);
const cmd = spec.script
- ? ['sh', '-c', finishScript(`( ${spec.script} )`)]
- : ['sh', '-c', finishScript(resticCommand([...spec.args, ...sanitizeResticFlags(destination.flags)]))];
+ ? ['sh', '-c', finishScript(`( ${guard}${spec.script} )`) + chown]
+ : ['sh', '-c', finishScript(`${guard}${resticCommand([...spec.args, ...sanitizeResticFlags(destination.flags)])}`) + chown];
// Large metadata/stack files go into the container via put-archive (docker
// cp), NOT the Cmd, so they can't blow ARG_MAX. Build the tar once; stream
diff --git a/src/lib/server/scheduler/tasks/repo-maintenance.ts b/src/lib/server/scheduler/tasks/repo-maintenance.ts
index c91c2a4..4fd5f30 100644
--- a/src/lib/server/scheduler/tasks/repo-maintenance.ts
+++ b/src/lib/server/scheduler/tasks/repo-maintenance.ts
@@ -39,7 +39,7 @@ async function runMaintenanceTask(
const policies = parsePoliciesJson(dest.policies); // (audit #26/#37) safe-degrade + log on malformed
if (policies.autoUnlock) {
log('Auto-unlocking repository...');
- await runRepoTask(destinationId, 'unlock');
+ await runRepoTask(destinationId, 'unlock', { staleOnly: true }); // plain unlock: never wipe a live foreign lock on a shared repo
}
log(`Running ${task}...`);
@@ -122,7 +122,7 @@ export async function runRepoVerify(
const policies = parsePoliciesJson(dest.policies); // (audit #26/#37) safe-degrade + log on malformed
if (policies.autoUnlock) {
log('Auto-unlocking repository...');
- await runRepoTask(destinationId, 'unlock');
+ await runRepoTask(destinationId, 'unlock', { staleOnly: true }); // plain unlock: never wipe a live foreign lock on a shared repo
}
log(`Verifying ${dataSubset} of data...`);
diff --git a/src/lib/shared/repo-predicates.ts b/src/lib/shared/repo-predicates.ts
index 3e1c4f4..aef2f53 100644
--- a/src/lib/shared/repo-predicates.ts
+++ b/src/lib/shared/repo-predicates.ts
@@ -14,9 +14,23 @@ export function isLocalRepo(repository: string): boolean {
}
/** An environment reachable over the network (hawser or direct-with-host). */
-export function isRemoteEnvironment(env?: { connectionType?: string | null; host?: string | null }): boolean {
+export function isRemoteEnvironment(env?: { connectionType?: string | null; host?: string | null } | null): boolean {
if (!env) return false;
if (env.connectionType === 'hawser-standard' || env.connectionType === 'hawser-edge') return true;
if (env.connectionType === 'direct' && !!env.host) return true;
return false;
}
+
+/**
+ * Advisory (NOT a block): a local-path repo on a remote/direct env only works
+ * when that env's Docker daemon shares Dockhand's host (e.g. a co-located
+ * socket-proxy). The backup/restore helper runs on the target daemon and fails
+ * loud if the repo isn't visible there (see restic-script.ts localRepoGuard), so
+ * this is only a UI hint — the operation itself is allowed and self-checks.
+ */
+export function localRepoNeedsSameHost(
+ dest: { repository: string },
+ env?: { connectionType?: string | null; host?: string | null } | null
+): boolean {
+ return isLocalRepo(dest.repository) && isRemoteEnvironment(env);
+}
diff --git a/src/lib/utils/backup.ts b/src/lib/utils/backup.ts
index de39caa..be6a448 100644
--- a/src/lib/utils/backup.ts
+++ b/src/lib/utils/backup.ts
@@ -63,7 +63,7 @@ export function getRepoTypeLabel(repository: string): string {
// Repository/environment predicates live in the lucide-free shared module so
// server routes can import them without pulling in this file's icon deps (#37).
-export { isLocalRepo, isRemoteEnvironment } from '$lib/shared/repo-predicates';
+export { isLocalRepo, isRemoteEnvironment, localRepoNeedsSameHost } from '$lib/shared/repo-predicates';
export {
normalizeMounts,
normalizeStackMounts,
diff --git a/src/routes/api/backup/configs/+server.ts b/src/routes/api/backup/configs/+server.ts
index 098354c..3a973e7 100644
--- a/src/routes/api/backup/configs/+server.ts
+++ b/src/routes/api/backup/configs/+server.ts
@@ -4,26 +4,12 @@ import { authorize } from '$lib/server/authorize';
import { auditBackup } from '$lib/server/audit';
import {
getBackupConfigs,
- createBackupConfig,
- getBackupDestination,
- getEnvironment
+ createBackupConfig
} from '$lib/server/db';
import { registerSchedule, isValidCron } from '$lib/server/scheduler';
import { assertStackBackupable } from '$lib/server/backups';
import { validateRetention, retentionToStore } from '$lib/server/backups/helpers';
-// Inlined to avoid pulling lucide-svelte / svelte runtime into the server bundle
-// via $lib/utils/backup. Keep in sync with that module if the predicates change.
-function isLocalRepo(repository: string): boolean {
- return repository.startsWith('/') || repository.startsWith('./');
-}
-function isRemoteEnv(connectionType?: string | null, host?: string | null): boolean {
- if (!connectionType) return false;
- if (connectionType === 'hawser-standard' || connectionType === 'hawser-edge') return true;
- if (connectionType === 'direct' && !!host) return true;
- return false;
-}
-
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'view')) {
@@ -92,20 +78,8 @@ export const POST: RequestHandler = async (event) => {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
- // Refuse local repo + remote env: the restic helper runs on the REMOTE
- // daemon, which has no access to Dockhand's filesystem. The backup would
- // silently write to a wrong path on the remote host.
- if (body.environmentId) {
- const [dest, env] = await Promise.all([
- getBackupDestination(body.destinationId),
- getEnvironment(body.environmentId)
- ]);
- if (dest && env && isRemoteEnv(env.connectionType, env.host) && isLocalRepo(dest.repository)) {
- return json({
- error: `Local repository "${dest.name}" cannot back up containers on remote environment "${env.name}". Use S3, REST, or another non-local backend.`
- }, { status: 400 });
- }
- }
+ // A local-path repo is allowed on any env; if the target daemon can't see the
+ // repo (wrong-host mount) the backup fails loud via the helper's localRepoGuard.
// External stacks (no known compose path) can't be backed up usefully —
// see assertStackBackupable for the why.
diff --git a/src/routes/api/backup/configs/[id]/+server.ts b/src/routes/api/backup/configs/[id]/+server.ts
index a9456fd..aabb53c 100644
--- a/src/routes/api/backup/configs/[id]/+server.ts
+++ b/src/routes/api/backup/configs/[id]/+server.ts
@@ -4,15 +4,12 @@ import { authorize } from '$lib/server/authorize';
import { auditBackup } from '$lib/server/audit';
import {
updateBackupConfig,
- deleteBackupConfig,
- getBackupDestination,
- getEnvironment
+ deleteBackupConfig
} from '$lib/server/db';
import { registerSchedule, unregisterSchedule, isValidCron } from '$lib/server/scheduler';
import { isBackupRunning } from '$lib/server/backups';
import { validateRetention, retentionToStore, resolveEnabledOnScheduleChange } from '$lib/server/backups/helpers';
import { requireBackups, loadConfigGateEnv } from '$lib/server/backups/route-guards';
-import { isLocalRepo, isRemoteEnvironment } from '$lib/shared/repo-predicates';
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
@@ -59,21 +56,8 @@ export const PUT: RequestHandler = async (event) => {
return json({ error: 'Cannot change the destination while a backup is running for this config' }, { status: 409 });
}
- // Refuse local repo + remote env (same rule as POST). Effective destination
- // is the body's value if supplied, otherwise the existing config's.
- const effectiveDestId = body.destinationId ?? existing.destinationId;
- const effectiveEnvId = existing.environmentId; // env is fixed at creation
- if (effectiveEnvId) {
- const [dest, env] = await Promise.all([
- getBackupDestination(effectiveDestId),
- getEnvironment(effectiveEnvId)
- ]);
- if (dest && env && isRemoteEnvironment(env) && isLocalRepo(dest.repository)) {
- return json({
- error: `Local repository "${dest.name}" cannot back up containers on remote environment "${env.name}". Use S3, REST, or another non-local backend.`
- }, { status: 400 });
- }
- }
+ // A local-path repo is allowed on any env; a wrong-host mount fails loud via
+ // the helper's localRepoGuard at run time.
// Auto-enable a config that transitions from manual (no schedule) to scheduled
// (a real cron) — otherwise a paused run-once config that the user edits to add a
diff --git a/src/routes/api/backup/destinations/[id]/+server.ts b/src/routes/api/backup/destinations/[id]/+server.ts
index 1495ea3..3ed9d84 100644
--- a/src/routes/api/backup/destinations/[id]/+server.ts
+++ b/src/routes/api/backup/destinations/[id]/+server.ts
@@ -7,25 +7,12 @@ import {
updateBackupDestination,
deleteBackupDestination,
decryptBackupDestination,
- getBackupConfigs,
- getEnvironment
+ getBackupConfigs
} from '$lib/server/db';
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
import { validatePolicySchedules, validateRepositoryForSave, validateFlags } from '$lib/server/backups/helpers';
import { destinationHasRunningBackup } from '$lib/server/backups';
-// Inlined predicates (kept in sync with the config routes) — see
-// configs/+server.ts. A local repo cannot back up a remote environment.
-function isLocalRepo(repository: string): boolean {
- return repository.startsWith('/') || repository.startsWith('./');
-}
-function isRemoteEnv(connectionType?: string | null, host?: string | null): boolean {
- if (!connectionType) return false;
- if (connectionType === 'hawser-standard' || connectionType === 'hawser-edge') return true;
- if (connectionType === 'direct' && !!host) return true;
- return false;
-}
-
/**
* Single-destination response shape. envVars (decrypted cloud credentials) are
* ONLY included for callers with backups:manage — the edit form that pre-fills
@@ -98,23 +85,8 @@ export const PUT: RequestHandler = async (event) => {
const flagError = validateFlags(body.flags);
if (flagError) return json({ error: flagError }, { status: 400 });
- // (audit #8) Changing the repository to a LOCAL path bypasses the config-route
- // guard: refuse if any config on this destination targets a remote environment
- // (the restic helper runs on the remote daemon, which can't see Dockhand's fs).
- const effectiveRepository = body.repository !== undefined ? body.repository : existing.repository;
- if (body.repository !== undefined && isLocalRepo(effectiveRepository)) {
- const configs = await getBackupConfigs();
- const envIds = [...new Set(
- configs.filter((c: any) => c.destinationId === id && c.environmentId).map((c: any) => c.environmentId as number)
- )];
- const envs = await Promise.all(envIds.map((eid) => getEnvironment(eid)));
- const remote = envs.find((env) => env && isRemoteEnv(env.connectionType, env.host));
- if (remote) {
- return json({
- error: `Local repository cannot back up containers on remote environment "${remote.name}". Use S3, REST, or another non-local backend.`
- }, { status: 400 });
- }
- }
+ // A local-path repo is allowed on any env; a wrong-host mount fails loud via
+ // the helper's localRepoGuard at backup/restore time.
try {
const updated = await updateBackupDestination(id, {
diff --git a/src/routes/backups/CreateBackupModal.svelte b/src/routes/backups/CreateBackupModal.svelte
index 18162c1..c4a79a7 100644
--- a/src/routes/backups/CreateBackupModal.svelte
+++ b/src/routes/backups/CreateBackupModal.svelte
@@ -10,7 +10,7 @@
import CronEditor from '$lib/components/cron-editor.svelte';
import { Package, Box, Layers, Search, Loader2, CheckCircle2, ArrowBigRight, Settings, Clock, Play } from 'lucide-svelte';
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
- import { getRepoTypeIcon, formatCron, runBackupAction, isRemoteEnvironment, type BackupFormState } from '$lib/utils/backup';
+ import { getRepoTypeIcon, formatCron, runBackupAction, type BackupFormState } from '$lib/utils/backup';
import { toast } from 'svelte-sonner';
import VolumePicker from '$lib/components/backup/VolumePicker.svelte';
import DestinationPicker from '$lib/components/backup/DestinationPicker.svelte';
@@ -27,7 +27,7 @@
let { open = $bindable(), onCreated, onRun }: Props = $props();
interface Environment { id: number; name: string; icon?: string; connectionType?: string; host?: string | null; }
- interface Destination { id: number; name: string; repository: string; }
+ interface Destination { id: number; name: string; repository: string; hostPath?: string | null; }
interface ContainerItem { name: string; type: 'container' | 'stack'; envId: number; envName: string; envIcon?: string; volumes: VolumeInfo[]; }
let step = $state<1 | 2 | 3>(1);
@@ -176,11 +176,9 @@
const getDestIcon = getRepoTypeIcon;
const selectedDest = $derived(destinations.find(d => d.id === selectedDestId));
- // Used by the DestinationPicker to disable local-path destinations when
- // the chosen environment is remote (helper container on the remote host
- // can't read Dockhand's local filesystem).
+ // Passed to DestinationPicker so it can hint that a local-path repo needs the
+ // env's daemon co-located with Dockhand (advisory only, not a block).
const selectedEnv = $derived(environments.find((e) => e.id === selectedItem?.envId));
- const isSelectedEnvRemote = $derived(isRemoteEnvironment(selectedEnv));
{ if (!isOpen && saving) return; open = isOpen; }}>
@@ -306,7 +304,7 @@
diff --git a/src/routes/containers/BackupPanel.svelte b/src/routes/containers/BackupPanel.svelte
index 4d0ef3a..3dac67d 100644
--- a/src/routes/containers/BackupPanel.svelte
+++ b/src/routes/containers/BackupPanel.svelte
@@ -22,7 +22,7 @@
import { formatDateTime } from '$lib/stores/settings';
import { watchJob } from '$lib/utils/sse-fetch';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
- import { getRepoTypeIcon, isLocalRepo, isRemoteEnvironment, parseRetention, parseOptions, retentionSummary as getRetentionSummary, formatCron, runBackupAction, classifyJobResult, tagLogLine, fetchBackupExecutions, type BackupAction, type BackupFormState } from '$lib/utils/backup';
+ import { getRepoTypeIcon, parseRetention, parseOptions, retentionSummary as getRetentionSummary, formatCron, runBackupAction, classifyJobResult, tagLogLine, fetchBackupExecutions, type BackupAction, type BackupFormState } from '$lib/utils/backup';
interface Props {
containerName: string;
@@ -37,7 +37,7 @@
let { containerName, volumes, type = 'container', environmentId, onConfigSaved, autoShowRunForm = false, onTally }: Props = $props();
- interface Destination { id: number; name: string; repository: string; }
+ interface Destination { id: number; name: string; repository: string; hostPath?: string | null; }
interface BackupConfig {
id: number; destinationId: number; enabled: boolean; schedule: string;
retention: string | null; options?: string | null; stopBeforeBackup: boolean; selectedVolumes: string[] | null;
@@ -154,7 +154,9 @@
const progressEnv = $derived($environments.find((e) => e.id === envId));
const envId = $derived(environmentId ?? $currentEnvironment?.id ?? null);
- const isCurrentEnvRemote = $derived(() => isRemoteEnvironment($currentEnvironment));
+ // The full env carries connectionType/host (currentEnvironment does not), needed
+ // for the local-repo guard.
+ const fullEnv = $derived($environments.find((e) => e.id === envId));
async function fetchDestinations() {
try {
@@ -327,11 +329,8 @@
*/
async function submitForm(action: BackupAction) {
if (!editDestinationId) { toast.error('Select a backup repository'); return; }
- const selectedDest = destinations.find(d => d.id === editDestinationId);
- if (selectedDest && isCurrentEnvRemote() && isLocalRepo(selectedDest.repository)) {
- toast.error('Local repositories cannot be used with remote environments');
- return;
- }
+ // A local repo on a non-co-located env is allowed here; it fails loud at run
+ // time via the helper's localRepoGuard rather than being blocked up front.
// Cron only matters when the schedule will be persisted. run-once
// clears it server-side so the field value is irrelevant.
if ((action === 'save' || action === 'save-run') && editScheduleInvalid) {
@@ -596,7 +595,7 @@
diff --git a/src/routes/settings/backups/DestinationModal.svelte b/src/routes/settings/backups/DestinationModal.svelte
index c1a3216..f227fe9 100644
--- a/src/routes/settings/backups/DestinationModal.svelte
+++ b/src/routes/settings/backups/DestinationModal.svelte
@@ -6,7 +6,7 @@
import { TogglePill } from '$lib/components/ui/toggle-pill';
import * as Tooltip from '$lib/components/ui/tooltip';
import { Input } from '$lib/components/ui/input';
- import { Plus, Check, RefreshCw, Wifi, Database, HardDrive, Dices, Copy, ChevronDown, BarChart3, Loader2, Clock, PackageCheck, FolderCheck, Unlock, CircleHelp, AlertTriangle } from 'lucide-svelte';
+ import { Plus, Check, RefreshCw, Wifi, Database, HardDrive, Dices, Copy, BarChart3, Loader2, Clock, PackageCheck, FolderCheck, Unlock, CircleHelp, AlertTriangle } from 'lucide-svelte';
import cronstrue from 'cronstrue';
import { formatBytes } from '$lib/utils/format';
import CronEditor from '$lib/components/cron-editor.svelte';
@@ -20,8 +20,10 @@
id: number;
name: string;
repository: string;
+ hostPath?: string | null;
envVars?: Record;
flags?: string;
+ policies?: string | null;
lastTestStatus?: string | null;
createdAt: string;
updatedAt: string;
@@ -122,7 +124,7 @@
fields: [
{ key: 'bucket', label: 'Bucket', placeholder: 'my-backup-bucket' },
{ key: 'path', label: 'Path (optional)', placeholder: 'dockhand' },
- { key: 'accountId', label: 'Account ID', placeholder: '', envKey: 'B2_ACCOUNT_ID' },
+ { key: 'accountId', label: 'Key ID', placeholder: '', envKey: 'B2_ACCOUNT_ID' },
{ key: 'accountKey', label: 'Application key', placeholder: '', secret: true, envKey: 'B2_ACCOUNT_KEY' }
],
buildRepo: (f) => `b2:${f.bucket}:${f.path || ''}`.replace(/:$/, ''),
@@ -191,6 +193,11 @@
let policyAutoUnlock = $state(true);
let testing = $state(false);
let initializing = $state(false);
+ // Set when a Test on a NEW (unsaved) destination reports the repo reachable but
+ // not yet initialised. It flips the create button to "Save and init" so the user
+ // can save + init in one click. Reset whenever the repo fields change (a stale
+ // verdict must not drive the button after the user edits the target).
+ let needsInit = $state(false);
const selectedBackend = $derived(backendTypes.find(b => b.value === formBackendType) ?? backendTypes[0]);
const repoFields = $derived(selectedBackend.fields.filter(f => !f.envKey));
@@ -305,8 +312,12 @@
});
const data = await res.json();
if (data.success && data.needsInit) {
+ // Only the create flow gets the "Save and init" button; editing an
+ // existing destination already has its own "Init repo" action.
+ needsInit = !isEditing;
toast.info(data.message || 'Connection works, but repository needs initialization');
} else {
+ needsInit = false;
toast[res.ok && data.success ? 'success' : 'error'](data.success ? 'Connection test successful' : (data.error || 'Connection test failed'));
}
} catch { toast.error('Connection test failed'); } finally { testing = false; }
@@ -342,6 +353,12 @@
currentRepo; // track
repoConflictName = null;
});
+ // Any change to the target (repo string OR credentials OR password) invalidates a
+ // prior Test verdict, so the "Save and init" state must not linger.
+ $effect(() => {
+ currentRepo; formFields; formPassword; // track
+ needsInit = false;
+ });
async function save() {
if (!formName.trim()) { formError = 'Name is required'; return; }
@@ -389,7 +406,10 @@
const res = await fetch(isEditing ? `/api/backup/destinations/${destination!.id}` : '/api/backup/destinations', {
method: isEditing ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
});
- if (res.ok) { open = false; onSaved(); toast.success(isEditing ? 'Destination updated' : 'Destination created'); }
+ // Creating a destination auto-initialises its repository server-side, so a
+ // create over a reachable-but-uninitialised repo (needsInit) needs no extra
+ // call here — the button just tells the user that up front ("Save and init").
+ if (res.ok) { open = false; onSaved(); toast.success(needsInit ? 'Destination created and repository initialized' : isEditing ? 'Destination updated' : 'Destination created'); }
else { const data = await res.json(); formError = data.error || 'Failed'; }
} catch { formError = 'Failed'; } finally { formSaving = false; }
}
@@ -408,7 +428,7 @@
{#if selectedBackend.value === 'local'}
- Local path repositories can only be used to back up and restore containers running on the same Docker host as Dockhand. For remote environments or cross-env restore, use S3, REST, or another remote backend.
+ Local path repositories work on the local Docker host, or a co-located socket-proxy on that same host. For remote hosts, use S3, REST, or another remote backend.
{/if}
@@ -508,6 +528,7 @@
+
Repository policies
@@ -657,7 +678,7 @@
{/if}
-
+
+ {:else if needsInit}
+ Connection OK — repository will be initialized on save.
{/if}
diff --git a/src/routes/settings/environments/EnvironmentBackupsTab.svelte b/src/routes/settings/environments/EnvironmentBackupsTab.svelte
index ad54065..d16cf82 100644
--- a/src/routes/settings/environments/EnvironmentBackupsTab.svelte
+++ b/src/routes/settings/environments/EnvironmentBackupsTab.svelte
@@ -5,7 +5,7 @@
import * as Select from '$lib/components/ui/select';
import CronEditor from '$lib/components/cron-editor.svelte';
import { Box, Layers, Loader2, Plus, ChevronDown, ChevronRight, ArrowRight, Zap, GitBranch } from 'lucide-svelte';
- import { formatCron, getRepoTypeIcon, isLocalRepo, isRemoteEnvironment } from '$lib/utils/backup';
+ import { formatCron, getRepoTypeIcon } from '$lib/utils/backup';
import { standaloneContainers, volumesForStack, type BackupItem } from '$lib/utils/mounts';
import BackupPanel from '../../containers/BackupPanel.svelte';
@@ -36,8 +36,9 @@
let batchDestId = $state(0);
let batchSchedule = $state('0 2 * * *');
let batchSaving = $state(false);
- const isRemote = $derived(isRemoteEnvironment({ connectionType, host }));
- const usableDestinations = $derived(isRemote ? destList.filter(d => !isLocalRepo(d.repository)) : destList);
+ // All destinations are selectable; a local repo on a non-co-located env fails
+ // loud at run time (helper localRepoGuard), not hidden here.
+ const usableDestinations = $derived(destList);
// External stacks can't be backed up — exclude them from the unconfigured count
// and from "Schedule all" so the number reflects only backup-able targets. The
// list mixes stacks and standalone containers, and "Schedule all" configs BOTH,