restore reproduces the stack as an internal stack with its secrets
This commit is contained in:
7 files changed
+181
-34
No files matched your search
@@ -90,8 +90,10 @@ export interface RestorePorts {
|
|||||||
/** Recreate a container AS-IS from the snapshot's stored inspect config; throws
|
/** Recreate a container AS-IS from the snapshot's stored inspect config; throws
|
||||||
* with a clear message if that metadata is missing/insufficient. */
|
* with a clear message if that metadata is missing/insufficient. */
|
||||||
recreateContainerFromMetadata(name: string, envId: number | null | undefined, snapshotMetaContainer: unknown): Promise<void>;
|
recreateContainerFromMetadata(name: string, envId: number | null | undefined, snapshotMetaContainer: unknown): Promise<void>;
|
||||||
/** Redeploy a stack from the snapshot's stored compose files; throws if absent. */
|
/** Redeploy a stack from the snapshot's stored compose files; throws if absent.
|
||||||
redeployStack(name: string, envId: number | null | undefined, destinationId: number, snapshotId: string): Promise<void>;
|
* 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. */
|
/** Read the snapshot's stored restore metadata (metadata.json), or null. */
|
||||||
readSnapshotMetadata(destinationId: number, snapshotId: string): Promise<any | null>;
|
readSnapshotMetadata(destinationId: number, snapshotId: string): Promise<any | null>;
|
||||||
/** Write the snapshot's captured stack files (/metadata/stacks/<name>/stackfiles/)
|
/** Write the snapshot's captured stack files (/metadata/stacks/<name>/stackfiles/)
|
||||||
@@ -147,6 +149,10 @@ export interface RestoreJob {
|
|||||||
* Presence of ANY entry (or a recreate/redeploy postRestore) selects the clone
|
* Presence of ANY entry (or a recreate/redeploy postRestore) selects the clone
|
||||||
* path. */
|
* path. */
|
||||||
volumeDestinations?: Array<{ volume: string; kind: 'volume' | 'path'; target: string }>;
|
volumeDestinations?: Array<{ volume: string; kind: 'volume' | 'path'; target: string }>;
|
||||||
|
/** Whether to restore the stack's secrets carried in the snapshot (default true).
|
||||||
|
* false = bring the stack up without secrets (the operator re-enters them). Only
|
||||||
|
* meaningful for a stack redeploy. */
|
||||||
|
restoreSecrets?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RestoreService {
|
export class RestoreService {
|
||||||
@@ -617,7 +623,7 @@ export class RestoreService {
|
|||||||
return { action, ok: false, error: 'no stack files stored in this snapshot; cannot redeploy' };
|
return { action, ok: false, error: 'no stack files stored in this snapshot; cannot redeploy' };
|
||||||
}
|
}
|
||||||
op.progress('redeploying', `Redeploying stack ${name}...`);
|
op.progress('redeploying', `Redeploying stack ${name}...`);
|
||||||
await this.ports.redeployStack(name, envId, job.destinationId, job.snapshotId);
|
await this.ports.redeployStack(name, envId, job.destinationId, job.snapshotId, job.restoreSecrets ?? true);
|
||||||
return { action, ok: true };
|
return { action, ok: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-1
@@ -81,7 +81,7 @@ import {
|
|||||||
} from './db/drizzle.js';
|
} from './db/drizzle.js';
|
||||||
|
|
||||||
import type { AllGridPreferences, GridId, GridColumnPreferences } from '$lib/types';
|
import type { AllGridPreferences, GridId, GridColumnPreferences } from '$lib/types';
|
||||||
import { encrypt, decrypt, decryptStrict } from './encryption.js';
|
import { encrypt, decrypt, decryptStrict, isEncrypted } from './encryption.js';
|
||||||
import { parseEnvInterpolation } from './env-interpolation';
|
import { parseEnvInterpolation } from './env-interpolation';
|
||||||
import { invalidateVulnerabilitiesCache } from './vulnerabilities-cache';
|
import { invalidateVulnerabilitiesCache } from './vulnerabilities-cache';
|
||||||
|
|
||||||
@@ -4999,6 +4999,40 @@ export async function getSecretEnvVarsAsRecord(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a stack's SECRET env vars as their RAW at-rest ciphertext (enc:v1:...), keyed
|
||||||
|
* by name. Unlike the *AsRecord helpers this does NOT decrypt: it returns the exact
|
||||||
|
* bytes stored in the DB. Used by backup to carry secrets in a snapshot still
|
||||||
|
* encrypted under this instance's key — never plaintext. A value that is somehow
|
||||||
|
* stored plaintext (legacy) is encrypted here so a snapshot never holds a bare secret.
|
||||||
|
*/
|
||||||
|
export async function getStackSecretCiphertexts(
|
||||||
|
stackName: string,
|
||||||
|
environmentId?: number | null
|
||||||
|
): Promise<Array<{ key: string; value: string }>> {
|
||||||
|
let rows;
|
||||||
|
if (environmentId === undefined) {
|
||||||
|
rows = await db.select().from(stackEnvironmentVariables)
|
||||||
|
.where(eq(stackEnvironmentVariables.stackName, stackName));
|
||||||
|
} else if (environmentId === null) {
|
||||||
|
rows = await db.select().from(stackEnvironmentVariables)
|
||||||
|
.where(and(
|
||||||
|
eq(stackEnvironmentVariables.stackName, stackName),
|
||||||
|
isNull(stackEnvironmentVariables.environmentId)
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
rows = await db.select().from(stackEnvironmentVariables)
|
||||||
|
.where(and(
|
||||||
|
eq(stackEnvironmentVariables.stackName, stackName),
|
||||||
|
eq(stackEnvironmentVariables.environmentId, environmentId)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
.filter((r: typeof rows[number]) => r.isSecret && r.value)
|
||||||
|
.map((r: typeof rows[number]) => ({ key: r.key, value: isEncrypted(r.value) ? r.value : (encrypt(r.value) ?? '') }))
|
||||||
|
.filter((r: { key: string; value: string }) => r.value);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get only NON-SECRET environment variables as a key-value record.
|
* Get only NON-SECRET environment variables as a key-value record.
|
||||||
* Used for .env file operations where secrets should be excluded.
|
* Used for .env file operations where secrets should be excluded.
|
||||||
|
|||||||
@@ -2126,6 +2126,11 @@ export async function redeployStackFromDir(
|
|||||||
const envPath = join(stackDir, '.env');
|
const envPath = join(stackDir, '.env');
|
||||||
const hasEnv = existsSync(envPath);
|
const hasEnv = existsSync(envPath);
|
||||||
const envVars = hasEnv ? parseEnvFileContent(readFileSync(envPath, 'utf-8'), stackName) : undefined;
|
const envVars = hasEnv ? parseEnvFileContent(readFileSync(envPath, 'utf-8'), stackName) : undefined;
|
||||||
|
// Secret env vars are stored encrypted in the DB and deliberately never written
|
||||||
|
// to the snapshot's .env, so the extracted dir has no copy of them. Load them
|
||||||
|
// from the DB and inject them like every other compose path does (#1329) —
|
||||||
|
// otherwise a restored stack comes up with its secrets interpolating to "".
|
||||||
|
const secretVars = await getSecretEnvVarsAsRecord(stackName, envId);
|
||||||
// For Hawser, ship the entire tree (compose + include:d files + sidecars + .env).
|
// For Hawser, ship the entire tree (compose + include:d files + sidecars + .env).
|
||||||
const stackFiles = await readDirFilesAsMap(stackDir);
|
const stackFiles = await readDirFilesAsMap(stackDir);
|
||||||
return await executeComposeCommand(
|
return await executeComposeCommand(
|
||||||
@@ -2139,7 +2144,8 @@ export async function redeployStackFromDir(
|
|||||||
stackFiles
|
stackFiles
|
||||||
},
|
},
|
||||||
composeContent,
|
composeContent,
|
||||||
envVars
|
envVars,
|
||||||
|
secretVars
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2180,8 +2186,8 @@ export async function startStack(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Git stacks need useOverrideFile to write .env.dockhand with DB overrides.
|
// Git stacks need useOverrideFile to write .env.dockhand with DB overrides.
|
||||||
// sourceType is plumbed through from requireComposeFile (which already looked
|
// sourceType is plumbed through from requireComposeFile (which already looked it up
|
||||||
// it up via getStackComposeFile/getStackSource) to avoid a redundant DB lookup.
|
// via getStackComposeFile/getStackSource) to avoid a redundant DB lookup.
|
||||||
const isGitStack = result.sourceType === 'git';
|
const isGitStack = result.sourceType === 'git';
|
||||||
|
|
||||||
const opts: ComposeCommandOptions = { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath, useOverrideFile: isGitStack };
|
const opts: ComposeCommandOptions = { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath, useOverrideFile: isGitStack };
|
||||||
@@ -2220,7 +2226,9 @@ export async function stopStack(
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Git stacks need useOverrideFile to write .env.dockhand with DB overrides.
|
// Git stacks need useOverrideFile so `.env.dockhand` (the panel vars) is passed via
|
||||||
|
// --env-file; otherwise `docker compose stop` re-interpolates ${VAR:?} in the compose
|
||||||
|
// file with the panel vars missing and errors (#1313). Matches startStack/deployStack.
|
||||||
// sourceType is plumbed through from requireComposeFile to avoid a redundant DB lookup.
|
// sourceType is plumbed through from requireComposeFile to avoid a redundant DB lookup.
|
||||||
const isGitStack = result.sourceType === 'git';
|
const isGitStack = result.sourceType === 'git';
|
||||||
|
|
||||||
@@ -2301,7 +2309,7 @@ export async function downStack(
|
|||||||
return withContainerFallback(stackName, envId, 'stop');
|
return withContainerFallback(stackName, envId, 'stop');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Git stacks need useOverrideFile to write .env.dockhand with DB overrides.
|
// useOverrideFile for git stacks — same reason as stopStack (#1313).
|
||||||
// sourceType is plumbed through from requireComposeFile to avoid a redundant DB lookup.
|
// sourceType is plumbed through from requireComposeFile to avoid a redundant DB lookup.
|
||||||
const isGitStack = result.sourceType === 'git';
|
const isGitStack = result.sourceType === 'git';
|
||||||
|
|
||||||
@@ -2333,6 +2341,11 @@ export async function removeStack(
|
|||||||
// Get compose file (may not exist for external stacks)
|
// Get compose file (may not exist for external stacks)
|
||||||
const composeResult = await getStackComposeFile(stackName, envId);
|
const composeResult = await getStackComposeFile(stackName, envId);
|
||||||
|
|
||||||
|
// useOverrideFile for git stacks — same reason as stopStack (#1313).
|
||||||
|
// sourceType is plumbed through from getStackComposeFile (which already looked it
|
||||||
|
// up via getStackSource) to avoid a redundant DB lookup.
|
||||||
|
const isGitStack = composeResult.sourceType === 'git';
|
||||||
|
|
||||||
// Get stack containers BEFORE removing them (for cleanup later)
|
// Get stack containers BEFORE removing them (for cleanup later)
|
||||||
const stackContainers = await getStackContainers(stackName, envId);
|
const stackContainers = await getStackContainers(stackName, envId);
|
||||||
|
|
||||||
@@ -2367,6 +2380,7 @@ export async function removeStack(
|
|||||||
workingDir: composeResult.stackDir,
|
workingDir: composeResult.stackDir,
|
||||||
composePath: composeResult.composePath ?? undefined,
|
composePath: composeResult.composePath ?? undefined,
|
||||||
envPath: composeResult.envPath ?? undefined,
|
envPath: composeResult.envPath ?? undefined,
|
||||||
|
useOverrideFile: isGitStack,
|
||||||
// Full stack removal: the Hawser agent cleans its stack dir (#1162)
|
// Full stack removal: the Hawser agent cleans its stack dir (#1162)
|
||||||
removeFiles: true,
|
removeFiles: true,
|
||||||
filesToDelete: removalFiles
|
filesToDelete: removalFiles
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
const postRestore = ['start', 'recreate', 'redeploy', 'none'].includes(body.postRestore) ? body.postRestore : undefined;
|
const postRestore = ['start', 'recreate', 'redeploy', 'none'].includes(body.postRestore) ? body.postRestore : undefined;
|
||||||
// New-location clone: per-volume destinations on the target env.
|
// New-location clone: per-volume destinations on the target env.
|
||||||
const volumeDestinations = Array.isArray(body.volumeDestinations) ? body.volumeDestinations : undefined;
|
const volumeDestinations = Array.isArray(body.volumeDestinations) ? body.volumeDestinations : undefined;
|
||||||
|
// Stack restore: reproduce the stack's secrets carried in the snapshot (default on).
|
||||||
|
// The client sends false to bring the stack up without them. Only a stack redeploy
|
||||||
|
// consumes this; container restores ignore it.
|
||||||
|
const restoreSecrets = body.restoreSecrets !== false;
|
||||||
|
|
||||||
// Authorization gates run BEFORE request validation, so an unauthorized caller
|
// Authorization gates run BEFORE request validation, so an unauthorized caller
|
||||||
// never learns anything about the request shape.
|
// never learns anything about the request shape.
|
||||||
@@ -71,6 +75,7 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
targetName: body.targetName ?? null,
|
targetName: body.targetName ?? null,
|
||||||
postRestore,
|
postRestore,
|
||||||
volumeDestinations,
|
volumeDestinations,
|
||||||
|
restoreSecrets,
|
||||||
},
|
},
|
||||||
access,
|
access,
|
||||||
// Stream progress to the client (the restore modal's log). Without this the
|
// Stream progress to the client (the restore modal's log). Without this the
|
||||||
|
|||||||
@@ -28,7 +28,15 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
|||||||
try {
|
try {
|
||||||
const metadata = await getSnapshotMetadata(destinationId, snapshotId);
|
const metadata = await getSnapshotMetadata(destinationId, snapshotId);
|
||||||
if (!metadata) return json({ error: 'No metadata available' }, { status: 404 });
|
if (!metadata) return json({ error: 'No metadata available' }, { status: 404 });
|
||||||
return json(metadata);
|
// Never expose the stored secret VALUES (ciphertext) to the client — replace the
|
||||||
|
// `secrets` array with `secretKeys` (names only) so the UI can list them.
|
||||||
|
const { secrets, ...safe } = metadata as Record<string, unknown>;
|
||||||
|
if (Array.isArray(secrets)) {
|
||||||
|
(safe as Record<string, unknown>).secretKeys = secrets
|
||||||
|
.map((s: any) => s?.key)
|
||||||
|
.filter((k: unknown): k is string => typeof k === 'string');
|
||||||
|
}
|
||||||
|
return json(safe);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const msg = error instanceof Error ? error.message : String(error);
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
return json({ error: msg }, { status: 500 });
|
return json({ error: msg }, { status: 500 });
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
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 } from 'lucide-svelte';
|
import { RotateCcw, AlertTriangle, Loader2, HardDrive, Folder, Clock, Play, CheckCircle2, XCircle, Server, PackagePlus, Ban, Rocket, Box, Layers, HelpCircle, Info, KeyRound } from 'lucide-svelte';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { watchJob } from '$lib/utils/sse-fetch';
|
import { watchJob } from '$lib/utils/sse-fetch';
|
||||||
@@ -89,6 +89,12 @@
|
|||||||
// in-place still knows which env holds the live target (otherwise the button stays
|
// in-place still knows which env holds the live target (otherwise the button stays
|
||||||
// disabled and the header shows "on " with no name).
|
// disabled and the header shows "on " with no name).
|
||||||
let sourceEnvId = $state<number | undefined>(undefined);
|
let sourceEnvId = $state<number | undefined>(undefined);
|
||||||
|
// Names (never values) of the secrets carried IN this snapshot. The values are
|
||||||
|
// stored encrypted under this instance's key; restoring writes them back into the
|
||||||
|
// target env's DB so the stack comes up complete. Default ON — a restore is meant to
|
||||||
|
// reproduce a working stack. Off = bring it up without secrets (re-enter by hand).
|
||||||
|
let sourceSecretKeys = $state<string[]>([]);
|
||||||
|
let restoreSecrets = $state(true);
|
||||||
// Volume names that already exist on the chosen target env (for conflict marks).
|
// Volume names that already exist on the chosen target env (for conflict marks).
|
||||||
let existingTargetVolumes = $state<string[]>([]);
|
let existingTargetVolumes = $state<string[]>([]);
|
||||||
// Target-NAME collision: a container/stack of the restore's name already exists
|
// Target-NAME collision: a container/stack of the restore's name already exists
|
||||||
@@ -113,6 +119,9 @@
|
|||||||
// The snapshot's source env (for the "from <env>" phrasing in the summary).
|
// The snapshot's source env (for the "from <env>" phrasing in the summary).
|
||||||
const sourceEnv = $derived(envList.find((e) => e.id === sourceEnvId));
|
const sourceEnv = $derived(envList.find((e) => e.id === sourceEnvId));
|
||||||
const sourceEnvName = $derived(sourceEnv?.name ?? '');
|
const sourceEnvName = $derived(sourceEnv?.name ?? '');
|
||||||
|
// Offer the "restore secrets from this backup" toggle whenever this is a stack
|
||||||
|
// snapshot that actually carries secrets — same UI for in-place and cross-env.
|
||||||
|
const showSecretRestore = $derived(targetIsStack && sourceSecretKeys.length > 0);
|
||||||
const postRestoreLabel = $derived(postRestoreOptions.find((o) => o.value === postRestore)?.label ?? '');
|
const postRestoreLabel = $derived(postRestoreOptions.find((o) => o.value === postRestore)?.label ?? '');
|
||||||
// Step-rail styling: always neutral (primary). Only the What-will-happen box
|
// Step-rail styling: always neutral (primary). Only the What-will-happen box
|
||||||
// turns red on the destructive (in-place) path — the rail/numbers stay calm.
|
// turns red on the destructive (in-place) path — the rail/numbers stay calm.
|
||||||
@@ -309,6 +318,7 @@
|
|||||||
// unselected on purpose — the user picks it.
|
// unselected on purpose — the user picks it.
|
||||||
if (sourceEnvId == null) sourceEnvId = data.sourceEnvironmentId;
|
if (sourceEnvId == null) sourceEnvId = data.sourceEnvironmentId;
|
||||||
}
|
}
|
||||||
|
sourceSecretKeys = Array.isArray(data.sourceSecretKeys) ? data.sourceSecretKeys : [];
|
||||||
} catch {
|
} catch {
|
||||||
error = 'Failed to read the snapshot';
|
error = 'Failed to read the snapshot';
|
||||||
} finally {
|
} finally {
|
||||||
@@ -342,6 +352,10 @@
|
|||||||
volumes: selectedRows.map((v) => v.name),
|
volumes: selectedRows.map((v) => v.name),
|
||||||
environmentId: effectiveEnvId,
|
environmentId: effectiveEnvId,
|
||||||
targetName: containerName,
|
targetName: containerName,
|
||||||
|
// Restore the stack's secrets carried in the snapshot. Only send false
|
||||||
|
// when the toggle is shown and the user turned it off; otherwise the
|
||||||
|
// server default (restore them) applies.
|
||||||
|
restoreSecrets: showSecretRestore ? restoreSecrets : undefined,
|
||||||
confirmOverwrite: mode === 'in-place' ? confirmOverwrite : undefined,
|
confirmOverwrite: mode === 'in-place' ? confirmOverwrite : undefined,
|
||||||
// postRestore is sent for BOTH modes now (clone brings the target up).
|
// postRestore is sent for BOTH modes now (clone brings the target up).
|
||||||
postRestore,
|
postRestore,
|
||||||
@@ -538,6 +552,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if volumes.length === 0}
|
{#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>
|
<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}
|
||||||
{:else if mode === 'new-location'}
|
{:else if mode === 'new-location'}
|
||||||
<!-- Clone mode: each selected volume maps to an editable DESTINATION on
|
<!-- Clone mode: each selected volume maps to an editable DESTINATION on
|
||||||
the target env. Default is the original location (1:1 clone). -->
|
the target env. Default is the original location (1:1 clone). -->
|
||||||
@@ -581,6 +598,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
{@render secretRestoreBlock()}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-1.5 rounded border p-2">
|
<div class="space-y-1.5 rounded border p-2">
|
||||||
@@ -597,6 +615,7 @@
|
|||||||
<span class="truncate font-mono">{vol.name}</span>
|
<span class="truncate font-mono">{vol.name}</span>
|
||||||
</label>
|
</label>
|
||||||
{/each}
|
{/each}
|
||||||
|
{@render secretRestoreBlock()}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -666,6 +685,42 @@
|
|||||||
{#snippet envChip(env: { id: number; icon?: string | null } | undefined, name: string)}
|
{#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 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>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
<!-- Secrets restored from the snapshot — rendered INSIDE the "what gets
|
||||||
|
restored" volume box so it reads as one coherent list. Ciphertext is
|
||||||
|
encrypted under this instance's key; restoring writes it to the target. -->
|
||||||
|
{#snippet secretRestoreBlock()}
|
||||||
|
{#if showSecretRestore}
|
||||||
|
<div class="space-y-2 border-t pt-2 {volumes.length > 0 ? 'mt-1.5' : ''}">
|
||||||
|
<label class="flex cursor-pointer items-start gap-2 text-sm">
|
||||||
|
<Checkbox bind:checked={restoreSecrets} class="mt-0.5" />
|
||||||
|
<KeyRound class="h-4 w-4 shrink-0 translate-y-0.5 text-amber-500" />
|
||||||
|
<span>
|
||||||
|
Restore {sourceSecretKeys.length} secret{sourceSecretKeys.length === 1 ? '' : 's'} from this backup.
|
||||||
|
<span class="block text-xs text-muted-foreground">
|
||||||
|
Turn off to bring the stack up without secrets and set them by hand.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="flex flex-wrap gap-1 pl-6">
|
||||||
|
{#each sourceSecretKeys as key}
|
||||||
|
<code class="rounded bg-muted px-1.5 py-0.5 text-xs">{key}</code>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if restoreSecrets}
|
||||||
|
<div class="flex items-start gap-1.5 pl-6 text-xs text-amber-600 dark:text-amber-500">
|
||||||
|
<AlertTriangle class="h-3.5 w-3.5 shrink-0 translate-y-0.5" />
|
||||||
|
<span>
|
||||||
|
Secrets are encrypted with this Dockhand instance's key. Restoring on a
|
||||||
|
different instance requires the same encryption key
|
||||||
|
(<code class="rounded bg-muted px-1 py-0.5">.encryption_key</code> /
|
||||||
|
<code class="rounded bg-muted px-1 py-0.5">ENCRYPTION_KEY</code>), or they
|
||||||
|
stay unreadable.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</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="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">
|
<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}
|
{#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}
|
||||||
@@ -714,6 +769,15 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<p class="mt-1.5 leading-relaxed">The {targetIsStack ? 'stack' : 'container'} is <b>not started</b> — the data lands on {@render envChip(targetEnv, targetEnvName)} and you bring it up yourself.</p>
|
<p class="mt-1.5 leading-relaxed">The {targetIsStack ? 'stack' : 'container'} is <b>not started</b> — the data lands on {@render envChip(targetEnv, targetEnvName)} and you bring it up yourself.</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if showSecretRestore}
|
||||||
|
<p class="mt-1.5 leading-relaxed">
|
||||||
|
{#if restoreSecrets}
|
||||||
|
Its <b>{sourceSecretKeys.length} secret{sourceSecretKeys.length === 1 ? '' : 's'}</b> will be restored from the backup.
|
||||||
|
{:else}
|
||||||
|
Its <b>{sourceSecretKeys.length} secret{sourceSecretKeys.length === 1 ? '' : 's'}</b> will <b>not</b> be restored — set {sourceSecretKeys.length === 1 ? 'it' : 'them'} by hand afterwards.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
{#if hasRemap}
|
{#if hasRemap}
|
||||||
<!-- The recreate/redeploy uses the snapshot's STORED config/compose, which
|
<!-- The recreate/redeploy uses the snapshot's STORED config/compose, which
|
||||||
still references the ORIGINAL mounts — it won't follow a redirected
|
still references the ORIGINAL mounts — it won't follow a redirected
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
import * as Dialog from '$lib/components/ui/dialog';
|
import * as Dialog from '$lib/components/ui/dialog';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import {
|
import {
|
||||||
FolderOpen, Box, Layers, Network, Loader2, HardDrive, Tag, Variable,
|
FolderOpen, Box, Layers, Network, HardDrive, Tag, Variable,
|
||||||
Plug, HeartPulse, ScrollText, ShieldAlert, Server, Globe, Cpu, FileText
|
Plug, HeartPulse, ScrollText, ShieldAlert, Server, Globe, Cpu, FileText, KeyRound
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
|
import { LoadingState } from '$lib/components/ui/loading-state';
|
||||||
import MountTypeBadge from '$lib/components/MountTypeBadge.svelte';
|
import MountTypeBadge from '$lib/components/MountTypeBadge.svelte';
|
||||||
import { getRepoTypeIcon } from '$lib/utils/backup';
|
import { getRepoTypeIcon } from '$lib/utils/backup';
|
||||||
import { formatBytes } from '$lib/utils/format';
|
import { formatBytes } from '$lib/utils/format';
|
||||||
import { formatDateTime } from '$lib/stores/settings';
|
import { formatDateTime, formatRelativeTime } from '$lib/stores/settings';
|
||||||
import { environments } from '$lib/stores/environment';
|
import { environments } from '$lib/stores/environment';
|
||||||
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
|
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
|
||||||
import FileBrowserPanel from './FileBrowserPanel.svelte';
|
import FileBrowserPanel from './FileBrowserPanel.svelte';
|
||||||
@@ -102,7 +103,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog.Root bind:open>
|
<Dialog.Root bind:open>
|
||||||
<Dialog.Content class="max-w-4xl h-[90vh] sm:h-[80vh] flex flex-col overflow-hidden">
|
<Dialog.Content class="max-w-6xl h-[90vh] sm:h-[85vh] flex flex-col overflow-hidden">
|
||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
<Dialog.Title class="flex items-center gap-2 flex-wrap">
|
<Dialog.Title class="flex items-center gap-2 flex-wrap">
|
||||||
<FolderOpen class="w-5 h-5" />
|
<FolderOpen class="w-5 h-5" />
|
||||||
@@ -129,7 +130,7 @@
|
|||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
<Dialog.Description class="flex items-center gap-2 text-xs text-muted-foreground">
|
<Dialog.Description class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
{#if snapshotId}<Badge variant="outline" class="font-mono text-[10px]">{String(snapshotId).slice(0, 8)}</Badge>{/if}
|
{#if snapshotId}<Badge variant="outline" class="font-mono text-[10px]">{String(snapshotId).slice(0, 8)}</Badge>{/if}
|
||||||
{#if snapshotTime}{formatDateTime(snapshotTime)}{/if}
|
{#if snapshotTime}{formatDateTime(snapshotTime)} <span class="opacity-60">({formatRelativeTime(snapshotTime)})</span>{/if}
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
|
|
||||||
@@ -169,7 +170,7 @@
|
|||||||
{#if activeTab === 'metadata'}
|
{#if activeTab === 'metadata'}
|
||||||
<div class="flex-1 overflow-y-auto py-3">
|
<div class="flex-1 overflow-y-auto py-3">
|
||||||
{#if metadataLoading}
|
{#if metadataLoading}
|
||||||
<div class="flex items-center justify-center py-8"><Loader2 class="w-5 h-5 animate-spin text-muted-foreground" /></div>
|
<LoadingState class="h-full" label="Loading metadata..." />
|
||||||
{:else if metadataError}
|
{:else if metadataError}
|
||||||
<p class="text-sm text-muted-foreground p-4">{metadataError}</p>
|
<p class="text-sm text-muted-foreground p-4">{metadataError}</p>
|
||||||
{:else if metadata}
|
{:else if metadata}
|
||||||
@@ -186,14 +187,14 @@
|
|||||||
<dd>{formatDateTime(metadata.backupTime)}</dd>
|
<dd>{formatDateTime(metadata.backupTime)}</dd>
|
||||||
{#if metadata.environmentId != null}
|
{#if metadata.environmentId != null}
|
||||||
<dt class="text-muted-foreground">Environment ID</dt>
|
<dt class="text-muted-foreground">Environment ID</dt>
|
||||||
<dd class="font-mono text-xs">{metadata.environmentId}</dd>
|
<dd class="font-mono">{metadata.environmentId}</dd>
|
||||||
{/if}
|
{/if}
|
||||||
<dt class="text-muted-foreground">Volumes</dt>
|
<dt class="text-muted-foreground">Volumes</dt>
|
||||||
<dd>{metadata.volumes?.length ?? 0}</dd>
|
<dd>{metadata.volumes?.length ?? 0}</dd>
|
||||||
{#if metadata.type === 'stack'}
|
{#if metadata.type === 'stack'}
|
||||||
{#if metadata.composeFileName}
|
{#if metadata.composeFileName}
|
||||||
<dt class="text-muted-foreground">Compose file</dt>
|
<dt class="text-muted-foreground">Compose file</dt>
|
||||||
<dd class="font-mono text-xs">{metadata.composeFileName}</dd>
|
<dd class="font-mono">{metadata.composeFileName}</dd>
|
||||||
{/if}
|
{/if}
|
||||||
<dt class="text-muted-foreground">Stack files</dt>
|
<dt class="text-muted-foreground">Stack files</dt>
|
||||||
<dd>{metadata.hasStackFiles ? (metadata.stackFilesTruncated ? 'captured (truncated)' : 'captured') : 'not captured'}</dd>
|
<dd>{metadata.hasStackFiles ? (metadata.stackFilesTruncated ? 'captured (truncated)' : 'captured') : 'not captured'}</dd>
|
||||||
@@ -248,6 +249,21 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Secrets carried in the snapshot (KEY NAMES only — the values are
|
||||||
|
stored encrypted and never exposed here). Restored to the target
|
||||||
|
DB on restore unless the user opts out. -->
|
||||||
|
{#if metadata.type === 'stack' && metadata.secretKeys?.length}
|
||||||
|
<section class="space-y-2 border-t pt-4">
|
||||||
|
<h4 class="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-1.5"><KeyRound class="w-3.5 h-3.5" />Secrets ({metadata.secretKeys.length})</h4>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{#each metadata.secretKeys as key}
|
||||||
|
<code class="rounded bg-muted px-1.5 py-0.5 text-xs">{key}</code>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground">Stored encrypted with this instance's key. Restored to the stack on restore (values never shown).</p>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Container config (docker inspect, PascalCase, singular). Only for
|
<!-- Container config (docker inspect, PascalCase, singular). Only for
|
||||||
container targets — stacks have no metadata.container. -->
|
container targets — stacks have no metadata.container. -->
|
||||||
{#if containerInspect?.Config}
|
{#if containerInspect?.Config}
|
||||||
@@ -258,50 +274,50 @@
|
|||||||
</h4>
|
</h4>
|
||||||
<dl class="grid grid-cols-[130px_1fr] gap-y-1.5 text-sm">
|
<dl class="grid grid-cols-[130px_1fr] gap-y-1.5 text-sm">
|
||||||
<dt class="text-muted-foreground">Image</dt>
|
<dt class="text-muted-foreground">Image</dt>
|
||||||
<dd class="font-mono text-xs break-all">{containerInspect.Config.Image || '—'}</dd>
|
<dd class="font-mono break-all">{containerInspect.Config.Image || '—'}</dd>
|
||||||
{#if containerInspect.Id}
|
{#if containerInspect.Id}
|
||||||
<dt class="text-muted-foreground">Container ID</dt>
|
<dt class="text-muted-foreground">Container ID</dt>
|
||||||
<dd class="font-mono text-xs">{containerInspect.Id.slice(0, 12)}</dd>
|
<dd class="font-mono">{containerInspect.Id.slice(0, 12)}</dd>
|
||||||
{/if}
|
{/if}
|
||||||
{#if containerInspect.State?.Status}
|
{#if containerInspect.State?.Status}
|
||||||
<dt class="text-muted-foreground">State at backup</dt>
|
<dt class="text-muted-foreground">State at backup</dt>
|
||||||
<dd class="text-xs">{containerInspect.State.Status}</dd>
|
<dd>{containerInspect.State.Status}</dd>
|
||||||
{/if}
|
{/if}
|
||||||
{#if containerInspect.Created}
|
{#if containerInspect.Created}
|
||||||
<dt class="text-muted-foreground">Created</dt>
|
<dt class="text-muted-foreground">Created</dt>
|
||||||
<dd class="text-xs">{formatDateTime(containerInspect.Created)}</dd>
|
<dd>{formatDateTime(containerInspect.Created)}</dd>
|
||||||
{/if}
|
{/if}
|
||||||
{#if containerInspect.Config.Cmd?.length}
|
{#if containerInspect.Config.Cmd?.length}
|
||||||
<dt class="text-muted-foreground">Command</dt>
|
<dt class="text-muted-foreground">Command</dt>
|
||||||
<dd class="font-mono text-xs break-all">{containerInspect.Config.Cmd.join(' ')}</dd>
|
<dd class="font-mono break-all">{containerInspect.Config.Cmd.join(' ')}</dd>
|
||||||
{/if}
|
{/if}
|
||||||
{#if containerInspect.Config.Entrypoint?.length}
|
{#if containerInspect.Config.Entrypoint?.length}
|
||||||
<dt class="text-muted-foreground">Entrypoint</dt>
|
<dt class="text-muted-foreground">Entrypoint</dt>
|
||||||
<dd class="font-mono text-xs break-all">{containerInspect.Config.Entrypoint.join(' ')}</dd>
|
<dd class="font-mono break-all">{containerInspect.Config.Entrypoint.join(' ')}</dd>
|
||||||
{/if}
|
{/if}
|
||||||
{#if containerInspect.Config.WorkingDir}
|
{#if containerInspect.Config.WorkingDir}
|
||||||
<dt class="text-muted-foreground">Working dir</dt>
|
<dt class="text-muted-foreground">Working dir</dt>
|
||||||
<dd class="font-mono text-xs break-all">{containerInspect.Config.WorkingDir}</dd>
|
<dd class="font-mono break-all">{containerInspect.Config.WorkingDir}</dd>
|
||||||
{/if}
|
{/if}
|
||||||
{#if containerInspect.Config.User}
|
{#if containerInspect.Config.User}
|
||||||
<dt class="text-muted-foreground">User</dt>
|
<dt class="text-muted-foreground">User</dt>
|
||||||
<dd class="font-mono text-xs">{containerInspect.Config.User}</dd>
|
<dd class="font-mono">{containerInspect.Config.User}</dd>
|
||||||
{/if}
|
{/if}
|
||||||
{#if containerInspect.Config.ExposedPorts}
|
{#if containerInspect.Config.ExposedPorts}
|
||||||
<dt class="text-muted-foreground">Exposed ports</dt>
|
<dt class="text-muted-foreground">Exposed ports</dt>
|
||||||
<dd class="text-xs break-all">{Object.keys(containerInspect.Config.ExposedPorts).join(', ')}</dd>
|
<dd class="break-all">{Object.keys(containerInspect.Config.ExposedPorts).join(', ')}</dd>
|
||||||
{/if}
|
{/if}
|
||||||
{#if containerInspect.HostConfig?.RestartPolicy?.Name}
|
{#if containerInspect.HostConfig?.RestartPolicy?.Name}
|
||||||
<dt class="text-muted-foreground">Restart policy</dt>
|
<dt class="text-muted-foreground">Restart policy</dt>
|
||||||
<dd class="text-xs">{containerInspect.HostConfig.RestartPolicy.Name}{containerInspect.HostConfig.RestartPolicy.MaximumRetryCount ? ` (max ${containerInspect.HostConfig.RestartPolicy.MaximumRetryCount})` : ''}</dd>
|
<dd>{containerInspect.HostConfig.RestartPolicy.Name}{containerInspect.HostConfig.RestartPolicy.MaximumRetryCount ? ` (max ${containerInspect.HostConfig.RestartPolicy.MaximumRetryCount})` : ''}</dd>
|
||||||
{/if}
|
{/if}
|
||||||
{#if containerInspect.HostConfig?.NetworkMode}
|
{#if containerInspect.HostConfig?.NetworkMode}
|
||||||
<dt class="text-muted-foreground">Network mode</dt>
|
<dt class="text-muted-foreground">Network mode</dt>
|
||||||
<dd class="text-xs break-all">{containerInspect.HostConfig.NetworkMode}</dd>
|
<dd class="break-all">{containerInspect.HostConfig.NetworkMode}</dd>
|
||||||
{/if}
|
{/if}
|
||||||
{#if containerInspect.HostConfig?.Privileged}
|
{#if containerInspect.HostConfig?.Privileged}
|
||||||
<dt class="text-muted-foreground">Privileged</dt>
|
<dt class="text-muted-foreground">Privileged</dt>
|
||||||
<dd class="text-xs">yes</dd>
|
<dd>yes</dd>
|
||||||
{/if}
|
{/if}
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
@@ -347,15 +363,15 @@
|
|||||||
<dl class="grid grid-cols-[130px_1fr] gap-y-1 text-sm">
|
<dl class="grid grid-cols-[130px_1fr] gap-y-1 text-sm">
|
||||||
{#if containerInspect.HostConfig?.LogConfig?.Type}
|
{#if containerInspect.HostConfig?.LogConfig?.Type}
|
||||||
<dt class="text-muted-foreground flex items-center gap-1.5"><ScrollText class="w-3.5 h-3.5" />Log driver</dt>
|
<dt class="text-muted-foreground flex items-center gap-1.5"><ScrollText class="w-3.5 h-3.5" />Log driver</dt>
|
||||||
<dd class="font-mono text-xs">{containerInspect.HostConfig.LogConfig.Type}</dd>
|
<dd class="font-mono">{containerInspect.HostConfig.LogConfig.Type}</dd>
|
||||||
{/if}
|
{/if}
|
||||||
{#if containerInspect.Config?.StopSignal}
|
{#if containerInspect.Config?.StopSignal}
|
||||||
<dt class="text-muted-foreground">Stop signal</dt>
|
<dt class="text-muted-foreground">Stop signal</dt>
|
||||||
<dd class="font-mono text-xs">{containerInspect.Config.StopSignal}</dd>
|
<dd class="font-mono">{containerInspect.Config.StopSignal}</dd>
|
||||||
{/if}
|
{/if}
|
||||||
{#if containerInspect.Platform}
|
{#if containerInspect.Platform}
|
||||||
<dt class="text-muted-foreground flex items-center gap-1.5"><Cpu class="w-3.5 h-3.5" />Platform</dt>
|
<dt class="text-muted-foreground flex items-center gap-1.5"><Cpu class="w-3.5 h-3.5" />Platform</dt>
|
||||||
<dd class="font-mono text-xs">{containerInspect.Platform}</dd>
|
<dd class="font-mono">{containerInspect.Platform}</dd>
|
||||||
{/if}
|
{/if}
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user