diff --git a/src/lib/server/backups/restore-script.ts b/src/lib/server/backups/restore-script.ts index 7e50ee5..51f71da 100644 --- a/src/lib/server/backups/restore-script.ts +++ b/src/lib/server/backups/restore-script.ts @@ -17,18 +17,11 @@ import { shellQuote } from './restic-script'; import { buildInPlaceRestoreScript, SWAP_NEW } from './swap'; /** - * Build the in-place restore script. For each live volume root, restic restores - * that include into `/.dockhand-restore-new` (staging), then the swap - * commits. `liveRoots` are the `/volumes/` bind destinations being - * restored, already validated as safe paths. - * - * We run one restic per volume so each lands in its own staging dir. restic's - * `--target ` plus `--include /volumes/` writes to - * `/volumes/`, so we first restore to a scratch target then move the - * restored subtree into `.dockhand-restore-new` — but simpler and equivalent: - * restore with `--target /.dockhand-restore-new` and strip the leading - * `/volumes/` via restic's path handling. To keep the shell simple and - * robust we restore into the staging dir and let the swap move top-level entries. + * Build the in-place restore script. One restic per volume so each lands in its + * own staging dir `/.dockhand-restore-new`, then the swap commits. + * `liveRoots` are the `/volumes/` bind destinations being restored, already + * validated as safe paths. restic restores into the staging dir and the swap moves + * top-level entries into place. */ export function buildInPlaceRestore( snapshotId: string, @@ -39,41 +32,67 @@ export function buildInPlaceRestore( // subshell per restore so a non-zero restic aborts the whole script (set -e in // buildInPlaceRestoreScript) before any swap runs. const restores = liveRoots.map((live) => { + const include = live; // e.g. /volumes/data (a dir) or /volumes/etc_app_cfg.yaml (a FILE bind) + const say = (m: string) => `echo ${shellQuote(`[dockhand] ${m}`)} >&2`; + + // A single-FILE bind (`./cfg.yaml:/x`) mounts file-on-file, so `` is a FILE, + // not a dir. The dir path below mkdir's a staging dir INSIDE — impossible for + // a file (`mkdir /...: not a directory`). So branch at RUNTIME (file vs dir is + // only known in the helper): a file swaps atomically via a sibling temp + rename; a + // dir uses the staging-dir swap. include is always `/volumes/`, so its parent + // is always `/volumes` and the sibling temp lives there (never inside the file). + const parts = include.replace(/^\/+/, '').split('/'); // ['volumes', ''] + const parent = '/' + parts.slice(0, -1).join('/'); // '/volumes' + const baseName = parts[parts.length - 1]; // '' + const fileStagingDir = `${parent}/.dockhand-restore-file-${baseName}`; + const liveQ = shellQuote(live); + // restic restores the file include as = /volumes/. + const fileNested = shellQuote(`${fileStagingDir}${include}`); + const fileStaging = shellQuote(fileStagingDir); + const fileRestic = `restic ${['restore', '--json', snapshotId, '--target', fileStagingDir, '--include', include, ...flags].map(shellQuote).join(' ')}`; + + // --- DIR path (unchanged): staging dir inside , flatten, then swapOne commits. const staging = `${live}/${SWAP_NEW}`; - const include = live; // e.g. /volumes/data - const args = [ - 'restore', '--json', snapshotId, - '--target', staging, - '--include', include, - ...flags, - ]; - // restic writes (it recreates the include's full path - // under the target). Flatten so the swap sees the volume contents directly - // at the staging root: move /* up to /, then - // remove the recreated scaffold (the first path component under staging). - const restic = `restic ${args.map(shellQuote).join(' ')}`; - const nested = shellQuote(`${staging}${include}`); const stagingQ = shellQuote(staging); - // include is always `/volumes/`, so the scaffold under staging is its - // first path component, e.g. `volumes`. + const dirRestic = `restic ${['restore', '--json', snapshotId, '--target', staging, '--include', include, ...flags].map(shellQuote).join(' ')}`; + const nested = shellQuote(`${staging}${include}`); const firstComponent = include.replace(/^\/+/, '').split('/')[0]; const scaffold = shellQuote(`${staging}/${firstComponent}`); - const flatten = + const dirFlatten = `( if [ -d ${nested} ]; then cd ${nested}; ` + `for e in * .[!.]* ..?*; do [ -e "$e" ] || [ -L "$e" ] || continue; mv -- "$e" ${stagingQ}/; done; ` + `fi )`; - // Narrate FS operations to stderr (streamed live to the restore log). - const say = (m: string) => `echo ${shellQuote(`[dockhand] ${m}`)} >&2`; - // SAFE: `scaffold` = /.dockhand-restore-new/volumes — the `volumes` - // rung INSIDE our own staging dir, NEVER /volumes. So this rm can't - // touch a user's own `volumes/` directory at the live root (that's the bug - // the CLONE path had before it moved to a staging dir — see buildCloneRestore). + return [ - `${say(`Restoring ${include} into staging dir ${SWAP_NEW}`)}`, - restic, - flatten, - `rm -rf ${scaffold}`, - ].join('; '); + `if [ -d ${liveQ} ]; then`, + // directory volume: existing staging-dir restore; swapOne (below) commits it. + // Clear the staging dir FIRST (like the file/clone paths) so a marker-less leftover from + // a previously-killed restore can't merge stale files into this one. Safe: recovery has + // already rolled back any real (marker-bearing) interrupted swap; a marker-less staging + // dir is always disposable partial junk. restic restore has no --delete, so without this + // wipe a retry would keep files that aren't in this snapshot and commit them as "restored". + ` ${say(`Restoring ${include} into staging dir ${SWAP_NEW}`)};`, + ` rm -rf ${stagingQ};`, + ` ${dirRestic};`, + ` ${dirFlatten};`, + ` rm -rf ${scaffold};`, + `else`, + // single-file bind: `` is a FILE bind-mount (file-on-file), so it is an + // active mount point — it can be WRITTEN THROUGH but NOT renamed/replaced + // (`mv` over it -> "Device or resource busy"). So restore into a sibling temp + // dir, then overwrite the live file's CONTENT in place via `cat` (writes through + // the mount). Not atomic like a rename, but the only option for a file mount. + ` ${say(`Restoring file ${include} (overwriting through the mount)`)};`, + ` rm -rf ${fileStaging};`, + ` ${fileRestic};`, + // GUARD before the redirect: `cat X > live` truncates `live` BEFORE cat runs, so a + // missing/empty restored file (restic exit 0 but nothing extracted) would zero the + // live user file and then fail - destroying data. Only overwrite once the restored + // file is confirmed present; otherwise abort with the live file untouched. + ` if [ -f ${fileNested} ]; then cat ${fileNested} > ${liveQ}; else echo "restore: extracted file ${fileNested} missing - refusing to truncate live file" >&2; rm -rf ${fileStaging}; exit 1; fi;`, + ` rm -rf ${fileStaging};`, + `fi`, + ].join(' '); }).join('; '); return buildInPlaceRestoreScript(liveRoots, restores); @@ -108,14 +127,13 @@ export function buildCloneRestore( const restores = mountRoots.map((root) => { const include = root; // e.g. /volumes/data — mounted at the helper root // DATA SAFETY: restic recreates the include path under `--target`, so we must - // flatten `.../volumes//*` up to the mount root. We do that via an - // ISOLATED staging dir INSIDE the mount (mirroring the in-place path), NOT by - // writing straight into the mount and `rm -rf`-ing a `volumes` scaffold there. - // The old approach did `rm -rf /volumes`, which would delete a user's - // OWN `volumes/` sub-directory if the bind destination happened to contain one. - // Restic runs WITHOUT --delete, so files already at the mount are preserved; - // the restored files are moved in ON TOP (overwriting only name collisions), - // then the staging dir — created by us, never the user — is removed. + // flatten `.../volumes//*` up to the mount root. Do that via an ISOLATED + // staging dir INSIDE the mount (mirroring the in-place path), NOT by writing + // straight into the mount and `rm -rf`-ing a `volumes` scaffold there - that + // would delete a user's OWN `volumes/` sub-directory if the bind destination + // happened to contain one. Restic runs WITHOUT --delete, so files already at + // the mount are preserved; the restored files are moved in ON TOP (overwriting + // only name collisions), then the staging dir - ours, never the user's - is removed. const staging = `${root}/${staging_name}`; const args = [ 'restore', '--json', snapshotId, diff --git a/src/lib/server/backups/restore-service.ts b/src/lib/server/backups/restore-service.ts index 1e393d8..00ec5b2 100644 --- a/src/lib/server/backups/restore-service.ts +++ b/src/lib/server/backups/restore-service.ts @@ -10,15 +10,13 @@ * then a phase-marked swap commits it — never deleting the * original until the replacement is in place. * - * Safety properties enforced here (each has a behavioural test): + * Safety properties enforced here: * - the caller must OWN the snapshot (instance + environment) before any read; - * - an in-place restore requires explicit confirmation (the validator already - * rejects an unconfirmed one; the service refuses again, defence in depth); + * - an in-place restore requires explicit confirmation (validator + service, defence in depth); * - the requested volumes must exist in the snapshot before anything live is * touched (so a typo can't wipe a volume for a restore that then fails); - * - a failed/interrupted swap leaves live data intact (proven in swap tests); - * - two operations on the same live target are rejected; ops on one repo - * serialize; + * - a failed/interrupted swap leaves live data intact; + * - two operations on the same live target are rejected; ops on one repo serialize; * - the restore never records success on an unknown outcome (undefined exit). */ import { @@ -29,11 +27,11 @@ import { type ResticRun, } from './models'; import { buildInPlaceRestore, buildNewLocationRestore, buildCloneRestore } from './restore-script'; -import { assertSafeVolumeInclude } from './security'; +import { assertSafeVolumeInclude, assertSafeRestoreTarget } from './security'; import { liveTargetKey } from './locks'; import { cleanErrorMsg } from './helpers'; -import { stopIntentKey as stopIntentKeyFor } from './stop-recovery-core'; -import type { OperationHandle, StopIntent } from './backup-service'; +import type { SnapshotLayout } from './snapshot-layout'; +import type { OperationHandle } from './backup-service'; import { formatResticLines } from './backup-service'; import { EXIT_MARKER } from './restic-script'; @@ -60,22 +58,21 @@ export interface RestorePorts { /** Run the restore session container. */ runInHelper(spec: { script?: string; args?: string[]; binds: string[]; envId: number | null | undefined; name: string; onStderr?: (line: string) => void }): Promise; - /** Resolve the live bind for a volume by its name (name → `:/volumes/:rw`). */ - resolveVolumeBind(destinationId: number, snapshotId: string, envId: number | null | undefined, volumeName: string): + /** Resolve the live bind for a volume from ALREADY-READ metadata (name → `:/volumes/:rw`). + * Takes the metadata the caller already loaded so the swap resolves from one read - no second + * dump that could transiently fail and slug-bind a bind into an orphan volume. */ + resolveVolumeBind(metadata: SnapshotLayout | null, envId: number | null | undefined, volumeName: string): Promise<{ bind: string; include: string }>; + /** Resolve clone destinations through the shared resolver (the SAME one the dialog preview + * uses), so the previewed path and the populated path are one computation. Returns per-volume + * bind material; the caller still does the side effects (volumeExists / createTargetVolume). */ + resolveCloneTargets(job: RestoreJob, volumes: string[]): + Promise>; acquireLiveTarget(key: string): (() => void) | null; serializeDestination(destinationId: number, fn: () => Promise): Promise; openOperation(entityName: string, environmentId: number | null, triggeredBy: 'manual' | 'cron', onProgress?: (s: string, m: string) => void): Promise; notify(event: 'restore_success' | 'restore_failed', payload: Record, envId: number | null | undefined): Promise; - /** Record a durable swap intent before the swap, cleared after. */ - recordSwapIntent(key: string, data: { snapshotId: string; includes: string[]; binds: string[]; envId: number | null }): Promise; - clearSwapIntent(key: string): Promise; - /** Record a durable "this container was stopped for restore" intent BEFORE the - * stop, so a process death before the restart leaves a row a fresh startup can - * replay to bring the container back up. Cleared once the restart succeeds. */ - recordStopIntent(key: string, intent: StopIntent): Promise; - clearStopIntent(key: string): Promise; helperName(snapshotId: string): string; // --- post-restore actions (in-place only) ------------------------------------ @@ -94,13 +91,17 @@ export interface RestorePorts { * restoreSecrets (opt-in, default on): write the stack's secrets carried in the * snapshot into the target env's DB before redeploy so the stack comes up complete. */ redeployStack(name: string, envId: number | null | undefined, destinationId: number, snapshotId: string, restoreSecrets?: boolean): Promise; - /** Read the snapshot's stored restore metadata (metadata.json), or null. */ - readSnapshotMetadata(destinationId: number, snapshotId: string): Promise; - /** Write the snapshot's captured stack files (/metadata/stacks//stackfiles/) - * into Dockhand's LOCAL data dir at stacks/// so Dockhand can + /** Read the snapshot's stored restore metadata (typed SnapshotLayout), or null. */ + readSnapshotMetadata(destinationId: number, snapshotId: string): Promise; + /** Write the snapshot's captured stack files (/volumes/__dockhand_stackdir__) into + * Dockhand's LOCAL data dir at stacks/// so Dockhand can * manage/redeploy the restored stack, even when the volume data restored to a - * remote env. No-op (returns false) if the snapshot has no stackfiles/. */ - writeLocalStackFiles(destinationId: number, snapshotId: string, stackName: string, targetPath: string, overwrite: boolean): Promise; + * remote env. No-op (returns false) if the snapshot has no stack files. */ + writeLocalStackFiles(destinationId: number, snapshotId: string, stackName: string, targetPath: string, overwrite: boolean, envId?: number | null): Promise; + /** Resolve the canonical LOCAL managed stack dir (stacks///) for a + * target - where a clone's compose/.env is materialised so the restored stack is a normal + * managed (internal) stack the UI can edit + redeploy. */ + stackDirFor(stackName: string, envId: number | null | undefined): Promise; // --- clone (cross-env restore) ----------------------------------------------- /** Does a NAMED VOLUME with this name already exist on the target env? Used by @@ -153,6 +154,12 @@ export interface RestoreJob { * false = bring the stack up without secrets (the operator re-enters them). Only * meaningful for a stack redeploy. */ restoreSecrets?: boolean; + /** New-location STACK restore only. When true, the snapshot's captured stack dir + * (/volumes/__dockhand_stackdir__: compose/.env/config) is NOT restored - only the + * volume data lands at targetPath. A data-only restore for when the operator already + * has the compose and just wants the data back. Default false (stack files included and + * materialised into Dockhand's managed stack dir). */ + skipStackFiles?: boolean; } export class RestoreService { @@ -174,6 +181,19 @@ export class RestoreService { async run(job: RestoreJob, triggeredBy: 'manual' | 'cron' = 'manual'): Promise { const envId = job.environmentId; + // Two tiers, both stamped with wall-clock + {env,target,snapshot} so a line lines up + // with the shard test log by time and target. log() is always on (started/failed); + // diag() carries the internal mode/type micro-trace behind BACKUP_DIAG=1 (CI-only). + const key = `${job.targetName ?? job.targetPath ?? '?'} snap=${job.snapshotId.slice(0, 8)} env=${envId}`; + const t0 = Date.now(); + const log = (msg: string) => console.log(`[restore] ${new Date().toISOString()} ${key} | ${msg}`); + const diagOn = process.env.BACKUP_DIAG === '1'; + const diag = (phase: string, extra?: string) => { + if (!diagOn) return; + console.log(`[restore] ${new Date().toISOString()} ${key} | ${phase} @${Date.now() - t0}ms${extra ? ' | ' + extra : ''}`); + }; + log(`started (${job.mode}, ${job.targetType})`); + diag('START', `post=${job.postRestore ?? 'none'}`); try { // --- ownership: caller must own the snapshot (instance + env) --- await this.ports.guardSnapshotAccess(job.destinationId, job.snapshotId); @@ -246,6 +266,7 @@ export class RestoreService { : await this.runInPlaceContainer(job, triggeredBy, requested); } catch (err) { const { code, message } = errorInfo(err); + log(`failed after ${((Date.now() - t0) / 1000).toFixed(1)}s: ${code} ${message}`); return { status: 'error', code, error: message }; } } @@ -259,31 +280,35 @@ export class RestoreService { return this.runNewLocationTo(job, triggeredBy, volumes, includes); } - /** Non-destructive STACK restore into a fresh directory: the volume data PLUS - * the snapshot's stored compose/.env files (under /metadata/stacks/), so - * the operator gets a complete, redeployable stack directory. Additionally, the - * captured stack files are written to Dockhand's LOCAL data dir so Dockhand can - * manage the restored stack even when the volume data restores to a remote env. */ + /** Non-destructive STACK restore into a fresh directory. Restores the volume data and, + * unless skipStackFiles is set, the snapshot's stored compose/.env (under + * /volumes/__dockhand_stackdir__). Dockhand ALWAYS materialises the stack files into its own + * managed stack dir and registers the stack, so it can manage/redeploy it - this is + * load-bearing: for a REMOTE env the compose exists only on the remote host otherwise, so + * Dockhand would have nothing to manage. `skipStackFiles` opts out entirely (data-only). */ private async runNewLocationStack(job: RestoreJob, triggeredBy: 'manual' | 'cron', volumes: string[]): Promise { const includes = volumes.map((v) => `/volumes/${v}`); - if (job.targetName) includes.push(`/metadata/stacks/${job.targetName}`); + const includeStackFiles = job.targetName && !job.skipStackFiles; + if (includeStackFiles) { + // The captured stack dir is always at /volumes/__dockhand_stackdir__ + // (listSnapshotVolumes filters it out of `volumes`, so add it explicitly here so + // the new-location restore also lands the compose/.env for a redeployable dir). + const { STACKDIR_VOLUME_KEY } = await import('./stackdir-plan'); + includes.push(`/volumes/${STACKDIR_VOLUME_KEY}`); + } const result = await this.runNewLocationTo(job, triggeredBy, volumes, includes); - // After the volume restore, materialise the stack files on Dockhand itself at - // the same targetPath, so Dockhand can manage/redeploy the restored stack. - if (result.status !== 'error' && job.targetName && job.targetPath) { + // Materialise the stack files into the CANONICAL managed stack dir on Dockhand + // (stackDirFor -> $DATA_DIR/stacks//, NOT job.targetPath which is the host path + // where the volume DATA landed), and register the stack as managed. Always (unless + // skipStackFiles): Dockhand needs the compose locally to edit/redeploy, especially for a + // remote env. Best-effort - the volume restore already succeeded. + if (result.status !== 'error' && includeStackFiles && job.targetName) { try { - // Materialising stack files reproduces the snapshot's point-in-time - // stack dir, so it always REPLACES (clears stale files). This is NOT - // keyed on confirmOverwrite — that flag is the destructive-intent guard - // for overwriting live VOLUME data (in-place), a different concept from - // how the managed compose/.env dir is rewritten. `mergeStackFiles: true` - // on the job is the explicit opt-out (kept for parity with the old - // engine's overwriteExisting=false), otherwise we replace. const overwrite = job.mergeStackFiles !== true; - await this.ports.writeLocalStackFiles(job.destinationId, job.snapshotId, job.targetName, job.targetPath, overwrite); + const stackTargetPath = await this.ports.stackDirFor(job.targetName, job.environmentId); + await this.ports.writeLocalStackFiles(job.destinationId, job.snapshotId, job.targetName, stackTargetPath, overwrite, job.environmentId); } catch (e) { - // the volume restore already succeeded; local stack files are best-effort - console.log(`[Restore] writeLocalStackFiles threw for "${job.targetName}": ${e instanceof Error ? e.message : String(e)}`); + console.log(`[restore] ${new Date().toISOString()} materialise stack files threw for "${job.targetName}": ${e instanceof Error ? e.message : String(e)}`); } } return result; @@ -293,6 +318,10 @@ export class RestoreService { private async runNewLocationTo(job: RestoreJob, triggeredBy: 'manual' | 'cron', volumes: string[], includes: string[]): Promise { const op = await this.ports.openOperation(`Restore ${job.snapshotId.slice(0, 8)} → ${job.targetPath}`, job.environmentId, triggeredBy); try { + // Defense-in-depth: the helper bind-mounts targetPath rw as root. Refuse a protected + // system dir here even if the request bypassed API validation. (validate.ts blocks it + // at the edge; this guarantees the contract at the last point before the mount.) + assertSafeRestoreTarget(job.targetPath!); includes.forEach(assertSafeVolumeInclude); const args = buildNewLocationRestore(job.snapshotId, job.targetPath!, includes, job.restoreFlags ?? []); op.progress('restoring', `Restoring ${volumes.length} volume(s) to ${job.targetPath}...`); @@ -320,11 +349,31 @@ export class RestoreService { (op) => this.applyPostRestore(job.postRestore ?? 'recreate', job, op)); } - /** CLONE a STACK to the target env: populate the mapped destinations, then - * redeploy from the snapshot's stored compose files. */ + /** CLONE a STACK to the target env: populate the mapped destinations, then either + * redeploy from the snapshot's stored compose files, or (postRestore 'none') just + * materialise the compose/.env dir locally so the user has a stack to edit + redeploy + * by hand. Redeploy materialises the dir itself (redeployStack); 'none' would otherwise + * leave NO compose dir at all - the UI says "update the compose file... then redeploy", + * so the dir must exist. */ private runCloneStack(job: RestoreJob, triggeredBy: 'manual' | 'cron', volumes: string[]): Promise { - return this.runClonePopulate(job, triggeredBy, volumes, - (op) => this.applyPostRestore(job.postRestore === 'none' ? 'none' : 'redeploy', job, op)); + return this.runClonePopulate(job, triggeredBy, volumes, async (op) => { + if (job.postRestore === 'none') { + // Bring nothing up, but materialise the managed stack dir so it's editable + + // redeployable. Best-effort (matches runNewLocationStack): the volume clone + // already succeeded, so a stackfile write failure must not fail the restore. + if (job.targetName) { + try { + const overwrite = job.mergeStackFiles !== true; + const stackTargetPath = await this.ports.stackDirFor(job.targetName, job.environmentId); + await this.ports.writeLocalStackFiles(job.destinationId, job.snapshotId, job.targetName, stackTargetPath, overwrite, job.environmentId); + } catch (e) { + console.log(`[restore] ${new Date().toISOString()} clone 'none' writeLocalStackFiles threw for "${job.targetName}": ${e instanceof Error ? e.message : String(e)}`); + } + } + return this.applyPostRestore('none', job, op); + } + return this.applyPostRestore('redeploy', job, op); + }); } /** @@ -362,20 +411,28 @@ export class RestoreService { if (mapped.length > 0) { const binds: string[] = []; const mountRoots: string[] = []; + // The target PATHS + bind strings come from the shared resolver (same as the dialog + // preview), so preview and restore can't diverge. The loop only does the SIDE + // EFFECTS (collision check / volume creation), not bind construction. + const resolved = await this.ports.resolveCloneTargets(job, mapped); + const byKey = new Map(resolved.map((r) => [r.key, r])); for (const v of mapped) { - const d = destMap.get(v)!; - const include = `/volumes/${v}`; - assertSafeVolumeInclude(include); - if (d.kind === 'volume') { - if (await this.ports.volumeExists(d.target, env)) { + const r = byKey.get(v)!; + assertSafeVolumeInclude(r.include); + // Last line of defense (independent of validate.ts): a host-path clone target + // is bind-mounted rw as root, so refuse a protected system dir even if a + // crafted request reached the service directly. + if (r.type === 'bind') assertSafeRestoreTarget(r.target); + if (r.type === 'volume') { + if (await this.ports.volumeExists(r.target, env)) { throw new BackupError('VALIDATION', - `volume "${d.target}" already exists on the target environment; remove it or choose another destination before cloning`); + `volume "${r.target}" already exists on the target environment; remove it or choose another destination before cloning`); } - await this.ports.createTargetVolume(d.target, env); + await this.ports.createTargetVolume(r.target, env); } // named volume (now created) or absolute host path — bind rw into the helper. - binds.push(`${d.target}:${include}:rw`); - mountRoots.push(include); + binds.push(r.bind); + mountRoots.push(r.include); } const script = buildCloneRestore(job.snapshotId, mountRoots, job.restoreFlags ?? []); op.progress('restoring', `Populating ${mapped.length} volume(s) on the target environment...`); @@ -387,6 +444,14 @@ export class RestoreService { } // --- loose-files fallback for any un-mapped volumes (extract to targetPath) --- + // FAIL-CLOSED: un-mapped volumes need a targetPath to land in. Without one they + // would be silently dropped while the restore still reports success and the + // redeploy brings the target up with EMPTY volumes (data loss). Refuse instead. + if (loose.length > 0 && !job.targetPath) { + throw new BackupError('VALIDATION', + `Clone restore has ${loose.length} volume(s) with no destination mapping and no targetPath ` + + `to extract them to (${loose.join(', ')}). Map every volume or provide a targetPath.`); + } if (loose.length > 0 && job.targetPath) { const includes = loose.map((v) => `/volumes/${v}`); includes.forEach(assertSafeVolumeInclude); @@ -428,18 +493,13 @@ export class RestoreService { return this.runInPlaceSwap(job, triggeredBy, volumes, { // Stop the one container (a stop FAILURE aborts before any volume is touched). stop: async (op) => { - if (!job.targetName) return { intentKey: null, restart: null }; + if (!job.targetName) return { restart: null }; const { containers } = await this.ports.resolveTargets(job.targetName, job.environmentId); const running = containers.filter((c) => c.state === 'running'); - if (running.length === 0) return { intentKey: null, restart: null }; + if (running.length === 0) return { restart: null }; op.progress('stopping', `Stopping ${job.targetName}...`); - const intentKey = stopIntentKeyFor(job.environmentId, 'container', job.targetName); - await this.ports.recordStopIntent(intentKey, { - type: 'container', targetName: job.targetName, envId: job.environmentId, - containers: running.map((c) => ({ id: c.id, name: c.name })), - }); const { restart } = await this.ports.stopBeforeRestore(running, job.environmentId); - return { intentKey, restart }; + return { restart }; }, // After the swap, run the caller's post-restore action (start/recreate/none). postAction: (op) => this.applyPostRestore(job.postRestore ?? 'start', job, op), @@ -454,18 +514,13 @@ export class RestoreService { // Stop the whole stack via native compose stop; a stop FAILURE aborts // before any volume is touched. stop: async (op) => { - if (!job.targetName) return { intentKey: null, restart: null }; + if (!job.targetName) return { restart: null }; const { containers } = await this.ports.resolveTargets(job.targetName, job.environmentId, 'stack'); const running = containers.filter((c) => c.state === 'running'); - if (running.length === 0) return { intentKey: null, restart: null }; + if (running.length === 0) return { restart: null }; op.progress('stopping', `Stopping stack ${job.targetName}...`); - const intentKey = stopIntentKeyFor(job.environmentId, 'stack', job.targetName); - await this.ports.recordStopIntent(intentKey, { - type: 'stack', targetName: job.targetName, envId: job.environmentId, - containers: running.map((c) => ({ id: c.id, name: c.name })), - }); const { restart } = await this.ports.stopStackBeforeRestore(job.targetName, job.environmentId); - return { intentKey, restart }; + return { restart }; }, // A stack is brought back by redeploying from its stored compose files. postAction: (op) => this.applyPostRestore('redeploy', job, op), @@ -485,7 +540,7 @@ export class RestoreService { triggeredBy: 'manual' | 'cron', volumes: string[], opts: { - stop: (op: OperationHandle) => Promise<{ intentKey: string | null; restart: (() => Promise) | null }>; + stop: (op: OperationHandle) => Promise<{ restart: (() => Promise) | null }>; postAction: (op: OperationHandle) => Promise; }, ): Promise { @@ -494,18 +549,57 @@ export class RestoreService { } const env = job.environmentId; + // DATA-SAFETY: a BIND volume's live bind is the SOURCE env's absolute host path + // (resolveBindFromMetadata returns entry.source, e.g. /srv/appA/data). An in-place + // swap writes to that exact path on `env`'s daemon. If `env` is NOT the snapshot's + // source env, we'd destructively swap into an unrelated /srv/appA/data on the WRONG + // host. Named volumes are safe (they resolve to a name on the local daemon), so only + // bind volumes are gated. Cross-env restores must go through the clone path. + // Detect binds from the metadata directly (fail-closed: if metadata is unreadable we + // can't prove the swap is safe, so we refuse rather than trust an empty volumeTypes). + // Read the metadata ONCE here and reuse it to resolve the binds below. A second per-volume + // read opens a TOCTOU window: a transient dump failure returns null and slug-binds a bind + // into an orphan named volume (data loss). + const meta = await this.ports.readSnapshotMetadata(job.destinationId, job.snapshotId).catch(() => null); + { + const requested = new Set(volumes); + const bindVols = (meta?.volumes ?? []) + .filter((v) => v.type === 'bind' && requested.has(v.key ?? v.name)) + .map((v) => v.key ?? v.name); + // If we requested volumes but couldn't read metadata to classify them, we cannot + // prove none are binds -> fail-closed. + const metaUnreadable = volumes.length > 0 && meta === null; + if (bindVols.length > 0 || metaUnreadable) { + const sourceEnv = meta?.environmentId; + const targetIsSource = meta !== null && (sourceEnv ?? null) === (env ?? null); + if (!targetIsSource) { + const which = bindVols.length > 0 ? ` (${bindVols.join(', ')})` : ''; + return { + status: 'error', + code: 'VALIDATION', + error: `in-place restore of a bind-mounted volume${which} is only allowed on the snapshot's source environment; use "restore to new location" to move it across environments`, + }; + } + } + } + // Past the gate, `meta` is proven non-null whenever we requested volumes (metaUnreadable + // would have returned above), so the resolver never sees null for a real bind -> no + // slug-bind. Resolve every volume from this ONE metadata read. + // Resolve each volume's live bind + include (rw — the swap writes to it). const binds: string[] = []; const liveRoots: string[] = []; for (const v of volumes) { - const { bind, include } = await this.ports.resolveVolumeBind(job.destinationId, job.snapshotId, env, v); + const { bind, include } = await this.ports.resolveVolumeBind(meta, env, v); assertSafeVolumeInclude(include); binds.push(bind); liveRoots.push(include); } - // Lock the live targets (reject a concurrent op on this data). - const key = liveTargetKey(env, binds); + // Lock the live targets (reject a concurrent op on this data). The fallback identity + // matches backup-service's `${type}:${targetName}` so a backup and a restore of the + // SAME config-only target still collide, while disjoint config-only targets don't. + const key = liveTargetKey(env, binds, `${job.targetType ?? 'container'}:${job.targetName}`); const release = this.ports.acquireLiveTarget(key); if (!release) return { status: 'skipped', reason: `Another backup or restore is already running for "${job.targetName ?? key}".` }; @@ -513,19 +607,17 @@ export class RestoreService { return await this.ports.serializeDestination(job.destinationId, async () => { const op = await this.ports.openOperation(`Restore ${job.snapshotId.slice(0, 8)} → ${job.targetName ?? 'volumes'}`, env, triggeredBy); let restart: (() => Promise) | null = null; - let stopIntentKey: string | null = null; - const swapKey = `swap:${env ?? 'local'}:${job.snapshotId}:${job.targetName ?? key}`; try { // Stop the target first; a stop FAILURE aborts before any volume is touched. - ({ intentKey: stopIntentKey, restart } = await opts.stop(op)); + ({ restart } = await opts.stop(op)); // Metadata-only snapshot (no volumes): there's nothing to swap — go // straight to the post-restore action (recreate/redeploy from the - // stored config). Otherwise run the phase-marked volume swap. + // stored config). Otherwise run the phase-marked volume swap. The swap + // is atomic per volume (staging + rename) and self-recovers on the next + // restore via the on-disk phase marker, so a crash mid-swap leaves the + // live data either fully-old or fully-new, never half-swapped. if (liveRoots.length > 0) { - // Durable swap intent BEFORE the swap, so a process death mid-swap is recoverable. - await this.ports.recordSwapIntent(swapKey, { snapshotId: job.snapshotId, includes: liveRoots, binds, envId: env }); - const script = buildInPlaceRestore(job.snapshotId, liveRoots, job.restoreFlags ?? []); op.progress('restoring', `Restoring ${volumes.length} volume(s) in place...`); this.emitVolumeList(op, volumes); @@ -533,8 +625,6 @@ export class RestoreService { onStderr: (line) => { for (const l of formatResticLines(line)) op.progress('progress', l.startsWith('[dockhand]') ? l : `[restic] ${l}`); } }); this.emitResticStdout(op, run.stdout); if (!resticOk(run)) throw new BackupError('RESTIC', run.stderr.trim() || 'restore failed', { exitCode: run.exitCode }); - - await this.ports.clearSwapIntent(swapKey); } else { op.progress('restoring', `Config-only snapshot — no volume data to restore for ${job.targetName ?? 'target'}.`); } @@ -545,8 +635,6 @@ export class RestoreService { const outcome = await opts.postAction(op); if (outcome.ok) { - // The post-restore action resolved the target's state — drop the intent. - if (stopIntentKey) { try { await this.ports.clearStopIntent(stopIntentKey); } catch { /* non-fatal */ } } await op.close({ kind: 'ok' }, { snapshotId: job.snapshotId, volumes, postRestore: outcome }); await this.notifyOk(job, volumes); return { status: 'success', executionId: op.id, restoredVolumes: volumes, postRestore: outcome }; @@ -557,12 +645,17 @@ export class RestoreService { await this.notifyOk(job, volumes); return { status: 'warning', executionId: op.id, restoredVolumes: volumes, postRestore: outcome }; } catch (err) { - // Restart what we stopped; the swap-intent stays for startup recovery if the swap was interrupted. - // On a clean restart, drop the durable stop intent; if the restart itself - // throws, KEEP it so a later startup retries the restart. + // Restart whatever we stopped for the restore. If the restart itself + // fails, SURFACE it: there is no boot-time recovery, so the operator + // must know the target is still stopped and start it themselves. if (restart) { - try { await restart(); if (stopIntentKey) await this.ports.clearStopIntent(stopIntentKey).catch(() => {}); } - catch { /* restart failed — leave the stop intent for startup retry */ } + try { + await restart(); + } catch { + const msg = `Restore failed and ${job.targetName ?? 'the target'} could not be restarted — it is STILL STOPPED. Start it manually.`; + op.progress('warning', msg); + try { await this.ports.notify('restore_failed', { snapshotId: job.snapshotId, kind: 'restore', errorCode: 'STOPPED_RESTART_FAILED', message: msg }, job.environmentId); } catch { /* non-fatal */ } + } } return await this.failOp(op, job, err); } @@ -619,7 +712,7 @@ export class RestoreService { case 'redeploy': { const meta = await this.ports.readSnapshotMetadata(job.destinationId, job.snapshotId); - if (!meta || meta.hasStackFiles !== true) { + if (!meta || meta.stack === undefined) { return { action, ok: false, error: 'no stack files stored in this snapshot; cannot redeploy' }; } op.progress('redeploying', `Redeploying stack ${name}...`); diff --git a/src/lib/server/backups/restore-targets.ts b/src/lib/server/backups/restore-targets.ts new file mode 100644 index 0000000..9d554b2 --- /dev/null +++ b/src/lib/server/backups/restore-targets.ts @@ -0,0 +1,128 @@ +/** + * Single source of truth for WHERE a restore lands on disk. Both the restore preview and the + * actual restore paths derive their target locations from this pure function, so the paths shown + * to the user are EXACTLY the paths restic writes to - they cannot diverge because they are the + * same computation. + * + * Pure and import-light (only restore-core, which is itself pure) so it is unit-testable directly: + * no docker, db, or fs. The caller loads the snapshot metadata and resolves stackDir once and + * passes them in. + * + * This module computes targets in PARALLEL to the write path (runClonePopulate / runInPlaceSwap / + * writeLocalStackFiles) - it does not replace their bind construction. Identity is enforced by + * tests asserting resolveRestoreTargets(...).target === the source those paths actually bind. + */ +import { resolveBindFromMetadata, volumeBind, volumeInclude, type SnapshotMetadata } from './restore-core'; +import { isReservedVolumeKey } from './stackdir-plan'; + +/** A single volume's resolved destination. For a bind, `target` is the absolute host path restic + * writes to; for a named volume, `target` is the volume name docker resolves on the target host. + * `include` (`/volumes/`) and `bind` (`:/volumes/:rw`) are the exact strings the + * restore write path feeds to restic - the resolver is the ONE place they are built, so the dialog + * preview and the actual restore can't diverge (both consume this). */ +export interface ResolvedVolumeTarget { + key: string; + type: 'bind' | 'volume'; + target: string; + include: string; + bind: string; + origin: 'clone' | 'in-place-metadata' | 'loose-files'; +} + +/** The rw bind + include for a resolved target - uses the shared builder (restore-core) so the + * mount-point contract lives in one place. */ +function bindMaterial(target: string, key: string): { include: string; bind: string } { + return { include: volumeInclude(key), bind: volumeBind(target, key, 'rw') }; +} + +/** Where the stack's captured compose/.env/config files land (the canonical managed stack dir). */ +export interface ResolvedStackFiles { + targetDir: string; + willWrite: boolean; + /** true = the managed dir is replaced (stale files cleared); false = merge (mergeStackFiles). */ + overwrite: boolean; +} + +export interface ResolvedRestoreTargets { + volumes: ResolvedVolumeTarget[]; + stackFiles: ResolvedStackFiles | null; + /** Volumes whose target could not be resolved (fail-closed cases surfaced as data, not thrown, + * so the preview can render a warning row instead of the whole call blowing up). */ + unresolved: Array<{ key: string; reason: string }>; +} + +/** The subset of a RestoreJob this resolver needs. Kept structural so the module stays import-light + * (no dependency on restore-service's full RestoreJob type). */ +export interface RestoreTargetInput { + mode: 'in-place' | 'new-location'; + targetType?: 'container' | 'stack'; + targetName?: string | null; + targetPath?: string | null; + volumeDestinations?: Array<{ volume: string; kind: 'volume' | 'path'; target: string }>; + skipStackFiles?: boolean; + mergeStackFiles?: boolean; +} + +/** + * Resolve the final on-disk targets for a restore. `volumes` is the requested volume-key list + * (reserved keys like the stack-dir synthetic volume are ignored - they are handled via stackFiles). + * `metadata` and `stackDir` are pre-loaded by the caller. + */ +export function resolveRestoreTargets(input: { + job: RestoreTargetInput; + volumes: string[]; + metadata: SnapshotMetadata | null; + stackDir: string | null; +}): ResolvedRestoreTargets { + const { job, metadata, stackDir } = input; + const volumes: ResolvedVolumeTarget[] = []; + const unresolved: Array<{ key: string; reason: string }> = []; + + // The stack-dir synthetic volume is not a user volume; it rides stackFiles, not this list. + const requested = input.volumes.filter((k) => !isReservedVolumeKey(k)); + + if (job.mode === 'new-location') { + // CLONE: destinations are used VERBATIM (mirrors runClonePopulate). A path -> absolute host + // path bound as-is; a volume -> a named volume created on the target env. Volumes with no + // destination fall back to loose-files extraction under targetPath. + const destByVolume = new Map((job.volumeDestinations ?? []).map((d) => [d.volume, d])); + for (const key of requested) { + const d = destByVolume.get(key); + if (d) { + // The write path binds `${d.target}:${include}:rw` verbatim (restore-service.ts). + const { include, bind } = bindMaterial(d.target, key); + volumes.push({ key, type: d.kind === 'path' ? 'bind' : 'volume', target: d.target, include, bind, origin: 'clone' }); + } else if (job.targetPath) { + // Loose-files fallback: the write path extracts to targetPath with the bind + // `${targetPath}:${targetPath}` (NOT the /volumes/ template) - mirror that shape. + volumes.push({ key, type: 'bind', target: job.targetPath, include: `/volumes/${key}`, bind: `${job.targetPath}:${job.targetPath}`, origin: 'loose-files' }); + } else { + unresolved.push({ key, reason: 'no destination mapping and no target path to extract to' }); + } + } + } else { + // IN-PLACE: re-resolve each volume from the snapshot metadata - the SAME call the swap uses, + // so the shown path equals the written path. resolveBindFromMetadata returns the exact + // bind/include the swap consumes; take them 1:1. A bind whose absolute source is + // unrecoverable throws; catch it into unresolved (the swap keeps throwing). + for (const key of requested) { + try { + const vb = resolveBindFromMetadata(metadata, key); + volumes.push({ key, type: vb.type, target: vb.source, include: vb.include, bind: vb.bind, origin: 'in-place-metadata' }); + } catch (e) { + unresolved.push({ key, reason: e instanceof Error ? e.message : String(e) }); + } + } + } + + // Stack files land in the managed stack dir. Written only for a stack restore that isn't + // opting out (skipStackFiles) and has a target name + resolved dir. Mirrors the guard in + // runNewLocationStack / runCloneStack. + let stackFiles: ResolvedStackFiles | null = null; + if (job.targetType === 'stack' && stackDir) { + const willWrite = !!job.targetName && !job.skipStackFiles; + stackFiles = { targetDir: stackDir, willWrite, overwrite: job.mergeStackFiles !== true }; + } + + return { volumes, stackFiles, unresolved }; +} diff --git a/src/routes/api/backup/restore/preview/+server.ts b/src/routes/api/backup/restore/preview/+server.ts index 65eb0e2..297f874 100644 --- a/src/routes/api/backup/restore/preview/+server.ts +++ b/src/routes/api/backup/restore/preview/+server.ts @@ -1,15 +1,15 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { authorize } from '$lib/server/authorize'; -import { previewSnapshot } from '$lib/server/backups'; +import { requireBackups } from '$lib/server/backups/route-guards'; +import { previewSnapshot, previewRestoreTargets } from '$lib/server/backups'; import { validateSnapshotId } from '$lib/server/docker-validation'; import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards'; export const POST: RequestHandler = async ({ request, cookies }) => { const auth = await authorize(cookies); - if (auth.authEnabled && !await auth.can('backups', 'manage')) { - return json({ error: 'Permission denied' }, { status: 403 }); - } + const rbacDenied = await requireBackups(auth, 'manage'); + if (rbacDenied) return rbacDenied; const body = await request.json(); @@ -29,6 +29,23 @@ export const POST: RequestHandler = async ({ request, cookies }) => { try { const access = { isEnterprise: auth.isEnterprise, canAccessEnvironment: (id: number) => auth.canAccessEnvironment(id) }; const preview = await previewSnapshot(body.destinationId, body.snapshotId, access); + // When the caller supplies a restore mode, ALSO resolve the exact on-disk targets and probe + // them on the target host (the same computation the real restore uses). Absent `mode` keeps + // the metadata-only response the initial modal load and existing callers rely on. + if (body.mode === 'in-place' || body.mode === 'new-location') { + const targets = await previewRestoreTargets(body.destinationId, body.snapshotId, { + mode: body.mode, + environmentId: body.environmentId ?? null, + targetType: body.targetType, + targetName: body.targetName ?? null, + targetPath: body.targetPath ?? null, + volumeDestinations: body.volumeDestinations, + skipStackFiles: body.skipStackFiles, + mergeStackFiles: body.mergeStackFiles, + volumes: body.volumes, + }, access); + return json({ ...preview, targets }); + } return json(preview); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); diff --git a/src/routes/containers/RestoreModal.svelte b/src/routes/containers/RestoreModal.svelte index a1ad579..39124b8 100644 --- a/src/routes/containers/RestoreModal.svelte +++ b/src/routes/containers/RestoreModal.svelte @@ -6,15 +6,16 @@ import { Checkbox } from '$lib/components/ui/checkbox'; import { Label } from '$lib/components/ui/label'; import { Badge } from '$lib/components/ui/badge'; - import { RotateCcw, AlertTriangle, Loader2, HardDrive, Folder, Clock, Play, CheckCircle2, XCircle, Server, PackagePlus, Ban, Rocket, Box, Layers, HelpCircle, Info, KeyRound } from 'lucide-svelte'; + import { RotateCcw, AlertTriangle, Loader2, HardDrive, Folder, Clock, Play, CheckCircle2, XCircle, Server, PackagePlus, Ban, Rocket, Box, Layers, HelpCircle, Info, KeyRound, FileX } from 'lucide-svelte'; import * as Tooltip from '$lib/components/ui/tooltip'; import { toast } from 'svelte-sonner'; import { watchJob } from '$lib/utils/sse-fetch'; import { environments } from '$lib/stores/environment'; import { formatDateTime } from '$lib/stores/settings'; import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte'; + import SnapshotHeader from '$lib/components/backup/SnapshotHeader.svelte'; import LogConsole from '$lib/components/LogConsole.svelte'; - import { tagLogLine, classifyJobResult } from '$lib/utils/backup'; + import { tagLogLine, classifyJobResult, getRepoTypeIcon } from '$lib/utils/backup'; interface Props { open: boolean; @@ -22,12 +23,17 @@ snapshotId: string; containerName: string; environmentId?: number; + /** Source repository (name + repo URL) this snapshot is being restored FROM. + * Shown in the header as "from " so it's clear which destination + * the restore reads. Optional: the header just omits it when not supplied. */ + destinationName?: string; + destinationRepository?: string; /** Refresh callback fired once the restore succeeds — the caller reloads its * snapshot list. This modal is ALWAYS self-contained (form → log → result in * one dialog); it never delegates its progress UI to a parent. */ onDone?: () => void; } - let { open = $bindable(), destinationId, snapshotId, containerName, environmentId, onDone }: Props = $props(); + let { open = $bindable(), destinationId, snapshotId, containerName, environmentId, destinationName, destinationRepository, onDone }: Props = $props(); // The two restore modes, framed by DESTINATION intent (not safe-vs-destructive): // - 'new-location' ("Restore to environment"): pick a target env and, per @@ -95,6 +101,10 @@ // reproduce a working stack. Off = bring it up without secrets (re-enter by hand). let sourceSecretKeys = $state([]); let restoreSecrets = $state(true); + // New-location STACK restore option. skipStackFiles: restore only the volume DATA, not the + // captured compose/.env (the operator already has the compose). When NOT set, the stack files + // are restored AND materialised into Dockhand's managed stack dir so it can edit/redeploy. + let skipStackFiles = $state(false); // Volume names that already exist on the chosen target env (for conflict marks). let existingTargetVolumes = $state([]); // Target-NAME collision: a container/stack of the restore's name already exists @@ -130,6 +140,41 @@ const stepRingClass = 'relative z-10 border-primary/40 bg-background text-primary'; const stepLineClass = 'bg-primary/25'; + // --- Exact target-path preview + host-data probe ----------------------------------------- + // The EXACT on-disk targets the restore will write, resolved server-side by the SAME function + // the real restore uses (never client-side path math), plus whether each already holds data on + // the target host. Re-fetched (debounced) whenever the destination or target env changes. + type ProbeKind = 'has-data' | 'empty' | 'missing' | 'helper-failed'; + interface TargetPreview { + volumes: Array<{ key: string; type: 'bind' | 'volume'; target: string; origin: string; hasData: ProbeKind }>; + stackFiles: { targetDir: string; willWrite: boolean; hasData: ProbeKind } | null; + unresolved: Array<{ key: string; reason: string }>; + helperOk: boolean; + helperError?: string; + } + let targetPreview = $state(null); + let targetPreviewLoading = $state(false); + // Set when the preview request itself fails (non-ok / thrown) so we surface a visible + // error instead of a silent empty section - a failed probe used to render as nothing. + let targetPreviewError = $state(''); + // The user's acknowledgement that existing data at the resolved targets will be overwritten. + let overwriteAck = $state(false); + // Monotonic request id so a slow response for an old form state can't overwrite a newer one. + let targetPreviewSeq = 0; + let targetPreviewTimer: ReturnType | null = null; + + // A resolved target already holds data on the host (in-place is always destructive to it; + // a clone path/volume may be pre-populated). Drives the overwrite acknowledgement gate. + const targetsWithData = $derived( + (targetPreview?.volumes ?? []).filter((v) => v.hasData === 'has-data').length + + (targetPreview?.stackFiles?.hasData === 'has-data' ? 1 : 0) + ); + const helperError = $derived(targetPreview && targetPreview.helperOk === false ? (targetPreview.helperError || 'the backup helper container could not run on the target environment') : ''); + // Probe result per volume key (has-data / empty / missing), for the per-row badges in the + // "What will happen" recap. Null while the probe hasn't returned for that row yet -> the row + // shows its own spinner. + const probeByKey = $derived(new Map((targetPreview?.volumes ?? []).map((v) => [v.key, v.hasData]))); + const selectedRows = $derived(volumes.filter((v) => v.selected)); // A selected volume redirected to a NEW name or type (vs the snapshot's original). // The recreate/redeploy uses the STORED config/compose, which still references the @@ -149,9 +194,24 @@ const canRun = $derived( // A target environment MUST be chosen. effectiveEnvId != null && + // Don't let a restore fire while the target-path probe is still running: it may come back + // with "has data" (an overwrite the user must acknowledge first), so running now would + // bypass that gate. Wait for the preview to resolve. + !targetPreviewLoading && + !targetPreviewError && + // The probe helper must be able to run on the target - if it can't, neither the probe + // nor the restore can, so block up front rather than fail mid-restore. + !helperError && (mode === 'in-place' + // In-place: confirmOverwrite IS the overwrite acknowledgement (its copy says + // "replaces the live volume data"). The new-location-only overwriteAck checkbox + // is never rendered here, so DON'T gate on it — that deadlocked the button whenever + // the live target already held data (targetsWithData > 0), i.e. every real in-place. ? confirmOverwrite : ( + // New-location: when any resolved target already holds data, the user must + // tick the separate overwrite acknowledgement. + (targetsWithData === 0 || overwriteAck) && // Every selected volume needs a non-empty destination; none may have a // blocking issue (collision / invalid host path). selectedRows.every((v) => v.dest.trim().length > 0) && @@ -178,10 +238,13 @@ // Reset stack-ness so a stale value from the previous open can't flash the // wrong type icon while the new snapshot is still being read. hasStackFiles = false; + skipStackFiles = false; restoreStatus = 'idle'; restoreLogs = []; restoreWarning = ''; error = ''; + targetPreview = null; + overwriteAck = false; if (envList.length === 0) environments.refresh(); void loadPreview(); } @@ -326,6 +389,63 @@ } } + async function loadTargetPreview() { + const envId = effectiveEnvId; + if (!open || !snapshotId || envId == null) { targetPreview = null; return; } + const seq = ++targetPreviewSeq; + targetPreviewLoading = true; + try { + const volumeDestinations = mode === 'new-location' + ? selectedRows.map((v) => ({ volume: v.name, kind: v.destKind, target: v.dest.trim() })) + : undefined; + const res = await fetch('/api/backup/restore/preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + destinationId, snapshotId, mode, environmentId: envId, + targetType: targetIsStack ? 'stack' : 'container', + targetName: containerName, targetPath, + volumeDestinations, skipStackFiles, + volumes: selectedRows.map((v) => v.name), + }) + }); + if (seq !== targetPreviewSeq) return; // a newer request superseded this one + if (!res.ok) { + targetPreview = null; + const msg = await res.json().then((d) => d?.error).catch(() => null); + targetPreviewError = msg || 'Could not check the target paths on the environment.'; + return; + } + const data = await res.json(); + targetPreview = data.targets ?? null; + } catch { + if (seq === targetPreviewSeq) { targetPreview = null; targetPreviewError = 'Could not reach the server to check the target paths.'; } + } finally { + if (seq === targetPreviewSeq) targetPreviewLoading = false; + } + } + + // Debounced re-probe on any change that moves where the restore lands. Reading these makes the + // effect track them; the 350ms debounce collapses a burst of keystrokes into one request. + $effect(() => { + // touch the reactive inputs so the effect re-runs when they change + void [mode, effectiveEnvId, skipStackFiles, targetIsStack, + selectedRows.map((v) => `${v.name}:${v.selected}:${v.destKind}:${v.dest}`).join('|')]; + if (!open || !snapshotId) return; + // Clear the stale preview IMMEDIATELY (bump seq so any in-flight probe for the old target is + // discarded on return) so a warning/path for the previous env can't linger while the new + // probe runs - especially when the old env's helper was timing out (a slow pull). + targetPreviewSeq++; + targetPreview = null; + targetPreviewError = ''; + if (targetPreviewTimer) clearTimeout(targetPreviewTimer); + // No target env picked yet (new-location before you choose one) -> nothing to probe. Clear + // the loading state and don't schedule a probe that has nowhere to mount. + if (effectiveEnvId == null) { targetPreviewLoading = false; return; } + targetPreviewLoading = true; + targetPreviewTimer = setTimeout(() => { void loadTargetPreview(); }, 350); + }); + async function executeRestore() { if (!canRun) return; restoring = true; @@ -360,7 +480,9 @@ // postRestore is sent for BOTH modes now (clone brings the target up). postRestore, targetPath: mode === 'new-location' ? targetPath.trim() : undefined, - volumeDestinations + // New-location STACK restore options (server ignores them otherwise). + skipStackFiles: mode === 'new-location' && hasStackFiles ? skipStackFiles : undefined, + volumeDestinations }) }); if (!res.ok) { @@ -430,16 +552,26 @@ box sits glued to the top of the screen. --> - - Restore - {#if !loading}{#if targetIsStack}{:else}{/if}{/if} - {containerName} - {#if targetEnv} - {mode === 'in-place' ? 'on' : 'to'} - {targetEnvName} - {/if} - {snapshotId.slice(0, 8)} - {#if backupTime}{formatDateTime(backupTime)}{/if} + + + {#snippet trailing()} + {#if targetEnv} + {mode === 'in-place' ? 'on' : 'to'} + {targetEnvName} + {/if} + {/snippet} + Restore snapshot {snapshotId.slice(0, 8)} for {containerName}. @@ -552,8 +684,8 @@ {#if volumes.length === 0}

This snapshot has no volumes — restore recreates the {targetIsStack ? 'stack' : 'container'} from its saved config.

- {#if showSecretRestore} -
{@render secretRestoreBlock()}
+ {#if showSecretRestore || (mode === 'new-location' && targetIsStack && hasStackFiles)} +
{@render secretRestoreBlock()}{@render stackRestoreOptions()}
{/if} {:else if mode === 'new-location'} + {vol.origDest} + {/if} + {/each} {@render secretRestoreBlock()} + {@render stackRestoreOptions()} {/if} + + {@render hostTargetsBlock()} @@ -680,10 +822,66 @@ - + + {#snippet hostDataBadge(kind: ProbeKind)} + {#if kind === 'has-data'} + has data + {:else if kind === 'empty'} + empty + {:else if kind === 'missing'} + new + {/if} + {/snippet} + + {#snippet kindBadge(kind: 'volume' | 'bind')} + {#if kind === 'bind'} + + bind + {:else} + + vol + {/if} + {/snippet} + + {#snippet hostTargetsBlock()} + {#if helperError} +
+ +
+
The backup helper can't run on {targetEnvName || 'this environment'}
+
{helperError}
+
A restore can't run until this is fixed - the same helper writes the restored data.
+
+
+ {:else if targetPreviewError && !targetPreviewLoading} +
+ +
+
Couldn't check the target paths
+
{targetPreviewError}
+
+
+ {:else if targetPreview && targetPreview.unresolved.length > 0} +
+ {#each targetPreview.unresolved as u} +
+ + {u.key} - {u.reason} +
+ {/each} +
+ {/if} + {/snippet} {#snippet envChip(env: { id: number; icon?: string | null } | undefined, name: string)} - {#if env}{/if}{name || '…'} + {#if env}{/if}{name || '…'} {/snippet} + {#snippet stackRestoreOptions()} + {#if mode === 'new-location' && targetIsStack && hasStackFiles} + +
+ +
+ {/if} + {/snippet}
{#if mode === 'in-place'}{:else}{/if} @@ -733,11 +952,17 @@ {#if selectedRows.length > 0}
    {#each selectedRows as v} + {@const t = targetPreview?.volumes.find((x) => x.key === v.name)}
  • {#if v.type === 'bind'}{:else}{/if} {#if v.type === 'bind'}bind{:else}vol{/if} {v.name} - wiped & replaced + {#if v.type === 'bind' && (t?.target ?? v.origDest)} + → host path + {t?.target ?? v.origDest} + {:else} + wiped & replaced + {/if}
  • {/each}
@@ -754,15 +979,27 @@ {#if selectedRows.length > 0}
    {#each selectedRows as v} + {@const probe = probeByKey.get(v.name)}
  • - {#if v.type === 'bind'}{:else}{/if} - {#if v.type === 'bind'}bind{:else}vol{/if} + {@render kindBadge(v.type)} {v.name} - → {v.destKind === 'path' ? 'host path' : 'named volume'} + → + {@render kindBadge(v.destKind === 'path' ? 'bind' : 'volume')} {v.dest.trim() || '…'} + + {#if v.dest.trim()} + {#if probe}{@render hostDataBadge(probe)} + {:else if targetPreviewLoading}checking on{#if targetEnv}{/if}{targetEnvName || 'host'}{/if} + {/if}
  • {/each}
+ {#if targetsWithData > 0} + + {/if} {/if} {#if postRestore !== 'none'}

Then Dockhand will {postRestoreLabel.toLowerCase()} on {@render envChip(targetEnv, targetEnvName)}{#if sourceEnvName && sourceEnvName !== targetEnvName}. Nothing on {@render envChip(sourceEnv, sourceEnvName)} is touched{/if}.