diff --git a/src/lib/server/git.ts b/src/lib/server/git.ts index 72b11c7..e6dc7d3 100644 --- a/src/lib/server/git.ts +++ b/src/lib/server/git.ts @@ -838,7 +838,7 @@ export function deleteRepositoryFiles(repoId: number): void { // === Git Stack Functions === -async function getStackRepoPath(stackId: number, stackName?: string, environmentId?: number | null): Promise { +export async function getStackRepoPath(stackId: number, stackName?: string, environmentId?: number | null): Promise { if (stackName && environmentId) { // Use old path if it already exists (backward compat), otherwise use name-based path const oldPath = join(GIT_REPOS_DIR, `stack-${stackId}`); diff --git a/src/lib/server/stack-delete-guard.ts b/src/lib/server/stack-delete-guard.ts new file mode 100644 index 0000000..65f561a --- /dev/null +++ b/src/lib/server/stack-delete-guard.ts @@ -0,0 +1,27 @@ +/** + * Pure path guard for stack-directory deletion (#675). Import-light (only node:path), so + * it's unit-testable without pulling the DB/docker imports of stacks.ts. + * + * THE BUG (#675): a stack ADOPTED at the stacks ROOT (compose at + * $DATA_DIR/stacks/docker-compose.yml) is auto-named "stacks". On delete, its custom dir + * is the stacks ROOT and its basename ("stacks") equals the stack name ("stacks"). A plain + * `startsWith(stacksRoot)` guard passed both checks, so rmSync wiped the ENTIRE stacks + * folder — every other stack. The fix is a STRICT-subdir check (`startsWith(stacksRoot + + * sep)`): resolve() strips trailing separators, so the root has none and fails, while a + * real nested stack dir ($DATA_DIR/stacks//) is a strict subdir and passes. + */ +import { resolve, basename, sep as pathSep } from 'node:path'; + +/** + * True when `customDir` is a directory Dockhand may delete for `stackName`: a STRICT + * subdirectory of `stacksDir` (never the root itself) whose basename matches the stack + * name. Path-only — the caller still checks existsSync before deleting. + */ +export function isDeletableStackDir(customDir: string, stacksDir: string, stackName: string): boolean { + const resolvedCustomDir = resolve(customDir); + const resolvedStacksDir = resolve(stacksDir); + return ( + resolvedCustomDir.startsWith(resolvedStacksDir + pathSep) && + basename(resolvedCustomDir) === stackName + ); +} diff --git a/src/routes/api/stacks/[name]/+server.ts b/src/routes/api/stacks/[name]/+server.ts index 29f3740..9f48acc 100644 --- a/src/routes/api/stacks/[name]/+server.ts +++ b/src/routes/api/stacks/[name]/+server.ts @@ -10,6 +10,9 @@ export const DELETE: RequestHandler = async (event) => { const force = url.searchParams.get('force') === 'true'; const volumes = url.searchParams.get('volumes') === 'true'; + // files defaults to true (backward-compatible: old callers with no param still delete + // files). The delete modal passes files=false for "Remove stack" (keep files on disk). + const files = url.searchParams.get('files') !== 'false'; const envId = url.searchParams.get('env'); const envIdNum = envId ? parseInt(envId) : undefined; @@ -25,10 +28,10 @@ export const DELETE: RequestHandler = async (event) => { try { const stackName = decodeURIComponent(params.name); - const result = await removeStack(stackName, envIdNum, force, volumes); + const result = await removeStack(stackName, envIdNum, force, volumes, files); // Audit log - await auditStack(event, 'delete', stackName, envIdNum, { force, volumes }); + await auditStack(event, 'delete', stackName, envIdNum, { force, volumes, files }); if (!result.success) { return json({ success: false, error: result.error }, { status: 400 }); diff --git a/src/routes/api/stacks/[name]/delete-preview/+server.ts b/src/routes/api/stacks/[name]/delete-preview/+server.ts new file mode 100644 index 0000000..7143f92 --- /dev/null +++ b/src/routes/api/stacks/[name]/delete-preview/+server.ts @@ -0,0 +1,31 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { computeStackDeletionPaths } from '$lib/server/stacks'; +import { authorize } from '$lib/server/authorize'; + +// GET /api/stacks/[name]/delete-preview?env=N +// Returns the on-disk directories a delete-with-files would remove, so the confirm modal +// shows the user EXACTLY what the backend will delete (same source of truth — no drift). +export const GET: RequestHandler = async ({ params, url, cookies }) => { + const auth = await authorize(cookies); + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + if (auth.authEnabled && !(await auth.can('stacks', 'remove', envIdNum))) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + const { name } = params; + try { + const { stackDir, gitDir, sourceType, namedVolumes } = await computeStackDeletionPaths(name, envIdNum); + return json({ + stackName: name, + sourceType, // internal | git | external | null + stackDir, // absolute path, or null if Dockhand won't delete files + gitDir, // absolute path of the cloned git repo dir, or null + namedVolumes, // compose-managed named volumes removed by "also delete volumes" + canDeleteFiles: !!(stackDir || gitDir), + }); + } catch (error: any) { + return json({ error: error.message || 'Failed to compute deletion preview' }, { status: 500 }); + } +}; diff --git a/src/routes/stacks/+page.svelte b/src/routes/stacks/+page.svelte index 3626dfc..22c743b 100644 --- a/src/routes/stacks/+page.svelte +++ b/src/routes/stacks/+page.svelte @@ -25,6 +25,7 @@ import BatchOperationModal from '$lib/components/BatchOperationModal.svelte'; import type { ComposeStackInfo, ContainerStats } from '$lib/types'; import StackModal from './StackModal.svelte'; + import DeleteStackModal from './DeleteStackModal.svelte'; import GitSourceBadge from './GitSourceBadge.svelte'; import GitStackModal from './GitStackModal.svelte'; import ImportStackModal from './ImportStackModal.svelte'; @@ -477,10 +478,12 @@ } // Confirmation popover state - let confirmDeleteName = $state(null); let confirmStopName = $state(null); let confirmDownName = $state(null); - let deleteVolumes = $state(false); + // Delete-stack modal state (replaces the old confirm popover — shows the exact dirs + // that will be removed and offers "remove stack" vs "remove stack + files"). + let showDeleteModal = $state(false); + let deleteStackName = $state(''); // Stack operation loading state let stackActionLoading = $state(null); @@ -1063,12 +1066,10 @@ } } - async function removeStack(name: string) { + async function removeStack(name: string, opts: { deleteFiles: boolean; deleteVolumes: boolean }) { operationError = null; - const withVolumes = deleteVolumes; - deleteVolumes = false; try { - const params = `force=true${withVolumes ? '&volumes=true' : ''}`; + const params = `force=true${opts.deleteVolumes ? '&volumes=true' : ''}${opts.deleteFiles ? '' : '&files=false'}`; const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}?${params}`, envId), { method: 'DELETE' }); if (!response.ok) { const data = await response.json(); @@ -1076,7 +1077,8 @@ showErrorDialog(`Failed to remove ${name}`, errorMsg); return; } - toast.success(`Removed ${name}${withVolumes ? ' (volumes deleted)' : ''}`); + const bits = [opts.deleteFiles ? 'files deleted' : 'files kept', ...(opts.deleteVolumes ? ['volumes deleted'] : [])]; + toast.success(`Removed ${name} (${bits.join(', ')})`); await fetchStacks(); } catch (error) { console.error('Failed to remove stack:', error); @@ -2126,25 +2128,14 @@ {/if} {#if $canAccess('stacks', 'remove')} - removeStack(stack.name)} - onOpenChange={(open) => { confirmDeleteName = open ? stack.name : null; if (!open) deleteVolumes = false; }} + onclick={(e) => { e.stopPropagation(); deleteStackName = stack.name; showDeleteModal = true; }} + class="p-0.5 rounded hover:bg-muted transition-colors cursor-pointer" > - {#snippet extraContent()} - - {/snippet} - {#snippet children({ open })} - - {/snippet} - + + {/if} {/if} @@ -2662,6 +2653,13 @@ onAdopted={fetchStacks} /> + removeStack(deleteStackName, opts)} +/> + + import * as Dialog from '$lib/components/ui/dialog'; + import { Button } from '$lib/components/ui/button'; + import { Checkbox } from '$lib/components/ui/checkbox'; + import { Trash2, Folder, Database, Loader2, ArrowRight } from 'lucide-svelte'; + import GitGenericIcon from '$lib/components/icons/GitGenericIcon.svelte'; + import { appendEnvParam } from '$lib/stores/environment'; + + // The parent owns the fetch; onConfirm receives what the user chose to remove. + let { + open = $bindable(false), + stackName = '', + envId = null as number | null, + onConfirm, + }: { + open?: boolean; + stackName?: string; + envId?: number | null; + onConfirm: (opts: { deleteFiles: boolean; deleteVolumes: boolean }) => void | Promise; + } = $props(); + + interface Preview { + sourceType: string | null; + stackDir: string | null; + gitDir: string | null; + namedVolumes: string[]; + canDeleteFiles: boolean; + } + + let preview = $state(null); + let loading = $state(false); + let busy = $state(false); + + // What to remove — the user ticks each. Containers are ALWAYS removed (the point of the + // operation), so they aren't a checkbox. Files default ON (the common intent), volumes + // default OFF (destructive, unrecoverable). + let removeFiles = $state(true); + let removeVolumes = $state(false); + + const hasFiles = $derived(!!(preview?.stackDir || preview?.gitDir)); + const hasVolumes = $derived((preview?.namedVolumes?.length ?? 0) > 0); + + $effect(() => { + if (open && stackName) { + loading = true; + preview = null; + removeFiles = true; + removeVolumes = false; + fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/delete-preview`, envId)) + .then((r) => (r.ok ? r.json() : null)) + .then((d) => { preview = d; }) + .catch(() => { preview = null; }) + .finally(() => { loading = false; }); + } + }); + + // Fixed set of outcome lines — ALWAYS shown (so the box never resizes as you toggle), + // each flipping between a red "deleted" and a green "kept" state. `delete: null` = no + // choice (containers are always removed). `kind` picks the icon for the "kept" state. + const willHappen = $derived.by(() => { + const lines: { delete: boolean | null; kind: 'container' | 'files' | 'volumes'; text: string }[] = [ + { delete: null, kind: 'container', text: 'Stop and remove the stack’s containers' }, + ]; + if (hasFiles) { + lines.push(removeFiles + ? { delete: true, kind: 'files', text: 'Delete the stack files on disk' } + : { delete: false, kind: 'files', text: 'Keep the stack files on disk' }); + } + if (hasVolumes) { + const n = preview!.namedVolumes.length; + lines.push(removeVolumes + ? { delete: true, kind: 'volumes', text: `Delete ${n} named volume(s) — data is unrecoverable` } + : { delete: false, kind: 'volumes', text: `Keep ${n} named volume(s)` }); + } + return lines; + }); + + async function run() { + busy = true; + try { + await onConfirm({ deleteFiles: hasFiles && removeFiles, deleteVolumes: hasVolumes && removeVolumes }); + open = false; + } finally { + busy = false; + } + } + + + + + + + + Remove stack "{stackName}" + + + Choose what to remove. The stack's containers are always stopped and removed. + + + + {#if loading} +
+ Checking what can be removed… +
+ {:else if preview} +
+ {#if hasFiles} + + {/if} + + {#if hasVolumes} + + {/if} + + {#if !preview.canDeleteFiles && !hasVolumes} +

+ This stack has no files or named volumes Dockhand manages — only the stack + record and its containers will be removed. +

+ {/if} +
+ + +
+
+ What will happen +
+
    + {#each willHappen as w} +
  • + {#if w.delete === true} + + {w.text} + {:else if w.delete === false} + + {#if w.kind === 'files'} + + {:else} + + {/if} + {w.text} + {:else} + + {w.text} + {/if} +
  • + {/each} +
+
+ {/if} + +
+ + +
+
+