a stack restored to new locations always keeps its compose file and stays internal

This commit is contained in:
jarek committed 2026-08-09 10:31:27 +02:00
1 parent bd06b340c5
commit 5adac5e19e
5 files changed
+667 -174

No files matched your search

+65 -47
View File
@@ -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 `<live>/.dockhand-restore-new` (staging), then the swap
* commits. `liveRoots` are the `/volumes/<name>` 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 <live>` plus `--include /volumes/<name>` writes to
* `<live>/volumes/<name>`, so we first restore to a scratch target then move the
* restored subtree into `.dockhand-restore-new` — but simpler and equivalent:
* restore with `--target <live>/.dockhand-restore-new` and strip the leading
* `/volumes/<name>` 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 `<live>/.dockhand-restore-new`, then the swap commits.
* `liveRoots` are the `/volumes/<name>` 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 `<live>` is a FILE,
// not a dir. The dir path below mkdir's a staging dir INSIDE <live> — impossible for
// a file (`mkdir <file>/...: 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/<name>`, so its parent
// is always `/volumes` and the sibling temp lives there (never inside the file).
const parts = include.replace(/^\/+/, '').split('/'); // ['volumes', '<name>']
const parent = '/' + parts.slice(0, -1).join('/'); // '/volumes'
const baseName = parts[parts.length - 1]; // '<name>'
const fileStagingDir = `${parent}/.dockhand-restore-file-${baseName}`;
const liveQ = shellQuote(live);
// restic restores the file include as <target><include> = <fileStagingDir>/volumes/<name>.
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 <live>, 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 <staging><include> (it recreates the include's full path
// under the target). Flatten so the swap sees the volume contents directly
// at the staging root: move <staging><include>/* up to <staging>/, 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/<name>`, 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` = <live>/.dockhand-restore-new/volumes — the `volumes`
// rung INSIDE our own staging dir, NEVER <live>/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: `<live>` 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/<name>/*` 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 <mount>/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/<name>/*` 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,
+187 -94
View File
@@ -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<ResticRun>;
/** Resolve the live bind for a volume by its name (name → `<src>:/volumes/<key>:rw`). */
resolveVolumeBind(destinationId: number, snapshotId: string, envId: number | null | undefined, volumeName: string):
/** Resolve the live bind for a volume from ALREADY-READ metadata (name → `<src>:/volumes/<key>: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<Array<{ key: string; type: 'bind' | 'volume'; target: string; include: string; bind: string }>>;
acquireLiveTarget(key: string): (() => void) | null;
serializeDestination<T>(destinationId: number, fn: () => Promise<T>): Promise<T>;
openOperation(entityName: string, environmentId: number | null, triggeredBy: 'manual' | 'cron', onProgress?: (s: string, m: string) => void):
Promise<OperationHandle>;
notify(event: 'restore_success' | 'restore_failed', payload: Record<string, unknown>, envId: number | null | undefined): Promise<void>;
/** Record a durable swap intent before the swap, cleared after. */
recordSwapIntent(key: string, data: { snapshotId: string; includes: string[]; binds: string[]; envId: number | null }): Promise<void>;
clearSwapIntent(key: string): Promise<void>;
/** 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<void>;
clearStopIntent(key: string): Promise<void>;
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<void>;
/** Read the snapshot's stored restore metadata (metadata.json), or null. */
readSnapshotMetadata(destinationId: number, snapshotId: string): Promise<any | null>;
/** Write the snapshot's captured stack files (/metadata/stacks/<name>/stackfiles/)
* into Dockhand's LOCAL data dir at stacks/<envName>/<stackName>/ so Dockhand can
/** Read the snapshot's stored restore metadata (typed SnapshotLayout), or null. */
readSnapshotMetadata(destinationId: number, snapshotId: string): Promise<SnapshotLayout | null>;
/** Write the snapshot's captured stack files (/volumes/__dockhand_stackdir__) into
* Dockhand's LOCAL data dir at stacks/<envName>/<stackName>/ 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<boolean>;
* 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<boolean>;
/** Resolve the canonical LOCAL managed stack dir (stacks/<envName>/<stackName>/) 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<string>;
// --- 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<RestoreResult> {
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/<name>), 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<RestoreResult> {
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/<env>/<name>, 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<RestoreResult> {
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<RestoreResult> {
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<void>) | null }>;
stop: (op: OperationHandle) => Promise<{ restart: (() => Promise<void>) | null }>;
postAction: (op: OperationHandle) => Promise<PostRestoreOutcome>;
},
): Promise<RestoreResult> {
@@ -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<void>) | 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}...`);
+128
View File
@@ -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/<key>`) and `bind` (`<target>:/volumes/<key>: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/<key> 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 };
}
@@ -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);
+266 -29
View File
@@ -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 <icon> <name>" 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<string[]>([]);
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<string[]>([]);
// 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<TargetPreview | null>(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<typeof setTimeout> | 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. -->
<Dialog.Content class="max-w-5xl flex flex-col overflow-hidden {restoreStatus === 'idle' ? 'top-[4vh] translate-y-0 h-[88vh]' : 'top-[6vh] translate-y-0 h-[85vh]'}">
<Dialog.Header class="shrink-0">
<Dialog.Title class="flex flex-wrap items-center gap-x-2 gap-y-1">
<RotateCcw class="h-4 w-4" /> Restore
{#if !loading}{#if targetIsStack}<Layers class="h-4 w-4 text-muted-foreground" />{:else}<Box class="h-4 w-4 text-muted-foreground" />{/if}{/if}
<span>{containerName}</span>
{#if targetEnv}
<span class="text-muted-foreground">{mode === 'in-place' ? 'on' : 'to'}</span>
<span class="flex items-center gap-1 font-medium text-amber-500"><EnvironmentIcon icon={targetEnv.icon || 'globe'} envId={targetEnv.id} class="h-3.5 w-3.5" />{targetEnvName}</span>
{/if}
<Badge variant="outline" class="font-mono text-[10px]">{snapshotId.slice(0, 8)}</Badge>
{#if backupTime}<span class="flex items-center gap-1 text-xs font-normal text-muted-foreground"><Clock class="h-3 w-3" />{formatDateTime(backupTime)}</span>{/if}
<Dialog.Title>
<SnapshotHeader
icon={RotateCcw}
verb="Restore"
name={containerName}
nameType={targetIsStack ? 'stack' : 'container'}
{destinationName}
{destinationRepository}
{sourceEnv}
{sourceEnvName}
{snapshotId}
snapshotTime={backupTime}
>
{#snippet trailing()}
{#if targetEnv}
<span class="text-muted-foreground">{mode === 'in-place' ? 'on' : 'to'}</span>
<span class="flex items-center gap-1 font-medium text-foreground"><EnvironmentIcon icon={targetEnv.icon || 'globe'} envId={targetEnv.id} class="h-4 w-4" />{targetEnvName}</span>
{/if}
{/snippet}
</SnapshotHeader>
</Dialog.Title>
<Dialog.Description class="sr-only">Restore snapshot {snapshotId.slice(0, 8)} for {containerName}.</Dialog.Description>
</Dialog.Header>
@@ -552,8 +684,8 @@
</div>
{#if volumes.length === 0}
<p class="text-sm text-muted-foreground">This snapshot has no volumes — restore recreates the {targetIsStack ? 'stack' : 'container'} from its saved config.</p>
{#if showSecretRestore}
<div class="rounded border p-2">{@render secretRestoreBlock()}</div>
{#if showSecretRestore || (mode === 'new-location' && targetIsStack && hasStackFiles)}
<div class="rounded border p-2">{@render secretRestoreBlock()}{@render stackRestoreOptions()}</div>
{/if}
{:else if mode === 'new-location'}
<!-- Clone mode: each selected volume maps to an editable DESTINATION on
@@ -599,25 +731,35 @@
</div>
{/each}
{@render secretRestoreBlock()}
{@render stackRestoreOptions()}
</div>
{:else}
<div class="space-y-1.5 rounded border p-2">
{#each volumes as vol}
<label class="flex cursor-pointer items-center gap-2 text-sm">
<Checkbox bind:checked={vol.selected} />
<label class="flex cursor-pointer items-start gap-2 text-sm">
<Checkbox bind:checked={vol.selected} class="mt-0.5" />
{#if vol.type === 'bind'}
<Folder class="h-3.5 w-3.5 shrink-0 text-amber-500" />
<span class="w-11 shrink-0 rounded-full bg-amber-500/15 px-1.5 py-0.5 text-center text-[10px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400">bind</span>
<Folder class="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
<span class="mt-0.5 w-11 shrink-0 rounded-full bg-amber-500/15 px-1.5 py-0.5 text-center text-[10px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400">bind</span>
{:else}
<HardDrive class="h-3.5 w-3.5 shrink-0 text-sky-500" />
<span class="w-11 shrink-0 rounded-full bg-sky-500/15 px-1.5 py-0.5 text-center text-[10px] font-semibold uppercase tracking-wide text-sky-600 dark:text-sky-400">vol</span>
<HardDrive class="mt-0.5 h-3.5 w-3.5 shrink-0 text-sky-500" />
<span class="mt-0.5 w-11 shrink-0 rounded-full bg-sky-500/15 px-1.5 py-0.5 text-center text-[10px] font-semibold uppercase tracking-wide text-sky-600 dark:text-sky-400">vol</span>
{/if}
<span class="truncate font-mono">{vol.name}</span>
<span class="min-w-0">
<span class="block truncate font-mono">{vol.name}</span>
{#if vol.type === 'bind' && vol.origDest && vol.origDest !== vol.name}
<!-- The real host path that gets overwritten in place. -->
<span class="block break-all font-mono text-xs text-muted-foreground">{vol.origDest}</span>
{/if}
</span>
</label>
{/each}
{@render secretRestoreBlock()}
{@render stackRestoreOptions()}
</div>
{/if}
{@render hostTargetsBlock()}
</div>
</div>
@@ -680,10 +822,66 @@
</div>
</div>
<!-- What will happen: a live prose recap driven by the form. Env names amber,
<!-- What will happen: a live prose recap driven by the form. Env names use
the foreground colour (amber is reserved for warnings/destructive here),
each prefixed by the env's icon (same as the modal header). -->
<!-- The EXACT host targets the restore writes to, resolved server-side by the same
function the restore uses, plus whether each already holds data. Gives the user
certainty about what lands where before committing. -->
{#snippet hostDataBadge(kind: ProbeKind)}
{#if kind === 'has-data'}
<span class="inline-flex shrink-0 items-center gap-1 rounded-full bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400"><AlertTriangle class="h-3 w-3" />has data</span>
{:else if kind === 'empty'}
<span class="shrink-0 rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">empty</span>
{:else if kind === 'missing'}
<span class="shrink-0 rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">new</span>
{/if}
{/snippet}
<!-- VOL / BIND kind pill + icon, used on BOTH sides of "<source> -> <target>" so a
volume-to-volume (or path-to-path) restore reads consistently, not "VOL -> named volume". -->
{#snippet kindBadge(kind: 'volume' | 'bind')}
{#if kind === 'bind'}
<Folder class="h-3 w-3 shrink-0 text-amber-500" />
<span class="w-9 shrink-0 rounded-full bg-amber-500/15 px-1 py-0.5 text-center text-[9px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400">bind</span>
{:else}
<HardDrive class="h-3 w-3 shrink-0 text-sky-500" />
<span class="w-9 shrink-0 rounded-full bg-sky-500/15 px-1 py-0.5 text-center text-[9px] font-semibold uppercase tracking-wide text-sky-600 dark:text-sky-400">vol</span>
{/if}
{/snippet}
<!-- Only the FAILURE states live here now (they block the restore and must be seen even
when the "What will happen" recap can't render). The per-target paths + has-data
badges + overwrite ack moved INTO the recap so there's one place, not two. -->
{#snippet hostTargetsBlock()}
{#if helperError}
<div class="flex items-start gap-2 rounded-md border border-destructive/50 bg-destructive/10 p-2.5 text-xs">
<AlertTriangle class="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<div class="min-w-0">
<div class="font-medium text-destructive">The backup helper can't run on {targetEnvName || 'this environment'}</div>
<div class="mt-0.5 break-all text-muted-foreground">{helperError}</div>
<div class="mt-1 text-muted-foreground">A restore can't run until this is fixed - the same helper writes the restored data.</div>
</div>
</div>
{:else if targetPreviewError && !targetPreviewLoading}
<div class="flex items-start gap-2 rounded-md border border-destructive/50 bg-destructive/10 p-2.5 text-xs">
<AlertTriangle class="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<div class="min-w-0">
<div class="font-medium text-destructive">Couldn't check the target paths</div>
<div class="mt-0.5 break-all text-muted-foreground">{targetPreviewError}</div>
</div>
</div>
{:else if targetPreview && targetPreview.unresolved.length > 0}
<div class="space-y-1 rounded-md border border-destructive/50 bg-destructive/10 p-2.5 text-xs">
{#each targetPreview.unresolved as u}
<div class="flex items-start gap-2 text-destructive">
<Ban class="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span class="min-w-0"><span class="font-mono">{u.key}</span> - {u.reason}</span>
</div>
{/each}
</div>
{/if}
{/snippet}
{#snippet envChip(env: { id: number; icon?: string | null } | undefined, name: string)}
<span class="inline-flex items-center gap-1 align-middle font-medium text-amber-500">{#if env}<EnvironmentIcon icon={env.icon || 'globe'} envId={env.id} class="h-3.5 w-3.5" />{/if}{name || '…'}</span>
<span class="inline-flex -translate-y-[0.12em] items-center gap-1 align-middle font-medium text-foreground">{#if env}<EnvironmentIcon icon={env.icon || 'globe'} envId={env.id} class="h-3 w-3" />{/if}{name || '…'}</span>
{/snippet}
<!-- Secrets restored from the snapshot — rendered INSIDE the "what gets
restored" volume box so it reads as one coherent list. Ciphertext is
@@ -721,6 +919,27 @@
</div>
{/if}
{/snippet}
<!-- New-location STACK restore options: skip the captured compose/.env (data
only), and/or adopt the restored stack into Dockhand afterwards. Stack +
new-location only. -->
{#snippet stackRestoreOptions()}
{#if mode === 'new-location' && targetIsStack && hasStackFiles}
<!-- Top border/padding only when something (the secret block) sits above it;
otherwise this is the first content in the box and needs no divider. -->
<div class="space-y-2 {showSecretRestore ? 'border-t pt-2 mt-1.5' : ''}">
<label class="flex cursor-pointer items-start gap-2 text-sm">
<Checkbox bind:checked={skipStackFiles} class="mt-0.5" />
<FileX class="h-4 w-4 shrink-0 translate-y-0.5 text-muted-foreground" />
<span>
Restore volume data only (skip stack files)
<span class="block text-xs text-muted-foreground">
Leave out the captured compose and config - restore just the volume data. Otherwise the stack files are restored and registered in Dockhand so you can edit and redeploy the stack.
</span>
</span>
</label>
</div>
{/if}
{/snippet}
<div class="mt-3 rounded-md border border-l-[3px] p-3 text-sm {mode === 'in-place' ? 'border-l-destructive bg-destructive/5' : 'border-l-primary bg-primary/5'}">
<div class="mb-1.5 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{#if mode === 'in-place'}<AlertTriangle class="h-3.5 w-3.5 text-destructive" />{:else}<Info class="h-3.5 w-3.5 text-primary" />{/if}
@@ -733,11 +952,17 @@
{#if selectedRows.length > 0}
<ul class="mt-1.5 space-y-1">
{#each selectedRows as v}
{@const t = targetPreview?.volumes.find((x) => x.key === v.name)}
<li class="flex items-center gap-2 text-xs">
{#if v.type === 'bind'}<Folder class="h-3 w-3 shrink-0 text-amber-500" />{:else}<HardDrive class="h-3 w-3 shrink-0 text-sky-500" />{/if}
{#if v.type === 'bind'}<span class="w-9 shrink-0 rounded-full bg-amber-500/15 px-1 py-0.5 text-center text-[9px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400">bind</span>{:else}<span class="w-9 shrink-0 rounded-full bg-sky-500/15 px-1 py-0.5 text-center text-[9px] font-semibold uppercase tracking-wide text-sky-600 dark:text-sky-400">vol</span>{/if}
<span class="font-mono">{v.name}</span>
<span class="text-muted-foreground">wiped &amp; replaced</span>
{#if v.type === 'bind' && (t?.target ?? v.origDest)}
<span class="text-muted-foreground">&rarr; host path</span>
<span class="min-w-0 break-all font-mono text-muted-foreground">{t?.target ?? v.origDest}</span>
{:else}
<span class="text-muted-foreground">wiped &amp; replaced</span>
{/if}
</li>
{/each}
</ul>
@@ -754,15 +979,27 @@
{#if selectedRows.length > 0}
<ul class="mt-1.5 space-y-1">
{#each selectedRows as v}
{@const probe = probeByKey.get(v.name)}
<li class="flex items-center gap-2 text-xs">
{#if v.type === 'bind'}<Folder class="h-3 w-3 shrink-0 text-amber-500" />{:else}<HardDrive class="h-3 w-3 shrink-0 text-sky-500" />{/if}
{#if v.type === 'bind'}<span class="w-9 shrink-0 rounded-full bg-amber-500/15 px-1 py-0.5 text-center text-[9px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400">bind</span>{:else}<span class="w-9 shrink-0 rounded-full bg-sky-500/15 px-1 py-0.5 text-center text-[9px] font-semibold uppercase tracking-wide text-sky-600 dark:text-sky-400">vol</span>{/if}
{@render kindBadge(v.type)}
<span class="font-mono">{v.name}</span>
<span class="text-muted-foreground">→ {v.destKind === 'path' ? 'host path' : 'named volume'}</span>
<span class="shrink-0 text-muted-foreground">&rarr;</span>
{@render kindBadge(v.destKind === 'path' ? 'bind' : 'volume')}
<span class="font-mono {(v.conflict || v.pathInvalid) ? 'text-destructive' : ''}">{v.dest.trim() || '…'}</span>
<!-- Per-row data probe: a small spinner pill while checking, then a badge. -->
{#if v.dest.trim()}
{#if probe}{@render hostDataBadge(probe)}
{:else if targetPreviewLoading}<span class="inline-flex shrink-0 items-center gap-1 rounded-full border border-border bg-background px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"><Loader2 class="h-2.5 w-2.5 animate-spin" />checking on{#if targetEnv}<EnvironmentIcon icon={targetEnv.icon || 'globe'} envId={targetEnv.id} class="h-3 w-3" />{/if}{targetEnvName || 'host'}</span>{/if}
{/if}
</li>
{/each}
</ul>
{#if targetsWithData > 0}
<label class="mt-2 flex cursor-pointer items-start gap-2 text-xs text-amber-600 dark:text-amber-400">
<Checkbox bind:checked={overwriteAck} class="mt-0.5" />
<span>I understand existing data at {targetsWithData === 1 ? 'this location' : `these ${targetsWithData} locations`} will be overwritten.</span>
</label>
{/if}
{/if}
{#if postRestore !== 'none'}
<p class="mt-1.5 leading-relaxed">Then Dockhand will <b>{postRestoreLabel.toLowerCase()}</b> on {@render envChip(targetEnv, targetEnvName)}{#if sourceEnvName && sourceEnvName !== targetEnvName}. Nothing on {@render envChip(sourceEnv, sourceEnvName)} is touched{/if}.</p>