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,