warn before saving a stack whose compose path won't survive a recreate

This commit is contained in:
Jarek Krochmalski committed 2026-09-06 17:03:47 +05:30
1 parent 63850bf5ae
commit 44223389d8
4 files changed
+325 -16

No files matched your search

+69 -4
View File
@@ -216,6 +216,14 @@ export function resetHostDetection(): void {
cachedOwnExtraHosts = null;
}
/** Test-only: seed the container-mount table so mount-dependent path translation
* (translateContainerPathViaMount / rewriteComposeVolumePaths) can be unit-tested. */
export function __setCachedMountsForTest(
mounts: Array<{ source: string; destination: string }> | null
): void {
cachedMounts = mounts;
}
/**
* Get the host path for our DATA_DIR mount by inspecting our own container
*/
@@ -426,6 +434,58 @@ export function getCachedContainerMounts(): Array<{ source: string; destination:
return cachedMounts ? [...cachedMounts] : [];
}
/**
* True when a container mount destination STRICTLY DEEPER than DATA_DIR covers
* `containerPath` (e.g. DATA_DIR=/app/data with a separate bind at /app/data/stacks).
* In that topology translateToHostPath() is WRONG - it resolves the path against
* DATA_DIR's host root (the outer volume), but the files physically live under the
* more-specific bind. translateContainerPathViaMount() has the right answer, so the
* caller must prefer it. Pure over the passed-in mount list for unit testing (#1533).
*/
export function pathOverriddenBySubMount(
containerPath: string,
dataDir: string,
mounts: Array<{ source: string; destination: string }>
): boolean {
const norm = (p: string) => p.replace(/\/+$/, '');
const dd = norm(dataDir);
const cp = norm(containerPath);
return mounts.some((m) => {
const dest = norm(m.destination);
// Deeper than DATA_DIR...
if (!(dest.startsWith(dd + '/') && dest.length > dd.length)) return false;
// ...and it covers the path.
return cp === dest || cp.startsWith(dest + '/');
});
}
/**
* Warn when a custom stack compose path won't survive a Dockhand recreate (#1524).
* A path Dockhand writes to that is NOT under any of its container mounts lands on the
* container's throwaway layer - it looks saved, then vanishes when the container is
* recreated (an update/restart), leaving the stack's compose "blank". Returns a
* human-readable warning, or null when the path is fine.
*
* Pure over the passed-in mount list so it unit-tests without a live container. Only
* fires when Dockhand is containerized (mounts non-empty) and the path is absolute and
* not under any mount destination; bare-metal (no mounts) and default in-DATA_DIR paths
* return null.
*/
export function unpersistedComposePathWarning(
composePath: string | null | undefined,
mounts: Array<{ source: string; destination: string }>
): string | null {
if (!composePath || mounts.length === 0) return null;
// Only a path Dockhand itself resolves inside its container can be orphaned; a
// relative path is resolved against DATA_DIR elsewhere, so only guard absolute paths.
if (!composePath.startsWith('/')) return null;
const underMount = [...mounts]
.sort((a, b) => b.destination.length - a.destination.length)
.some((m) => composePath === m.destination || composePath.startsWith(m.destination + '/'));
if (underMount) return null;
return `The compose file path "${composePath}" is not inside any of Dockhand's mounted volumes, so it is written inside the Dockhand container and will be LOST when the container is recreated (an update or restart). Use a path under a mounted volume (e.g. the default location, or add a bind mount for this path to Dockhand's own compose), then re-save.`;
}
/**
* Get the host path for the Docker socket mount.
* This is needed for sibling containers (e.g., scanners) that need socket access.
@@ -529,14 +589,19 @@ export function rewriteComposeVolumePaths(composeContent: string, workingDir: st
const absoluteContainerPath = resolve(workingDir, relativeSrc);
const absoluteHostPath = translateContainerPathViaMount(absoluteContainerPath);
if (absoluteHostPath) {
if (absoluteHostPath && absoluteHostPath !== absoluteContainerPath) {
const newLine = `${prefix}${absoluteHostPath}${destPart}`;
modifiedLines.push(newLine);
changes.push(` ${relativeSrc} -> ${absoluteHostPath}`);
} else {
// Can't translate — leave line unchanged. Compose will resolve
// it relative to its cwd; if that's wrong the deploy fails
// loudly, which is better than producing a misleading host path.
// Either untranslatable, OR a 1:1 mount where the host path equals the
// container path (#1514): rewriting `./x` to the identical absolute path
// is pointless and forces stdin deploy (`-f -`), which loses the real
// com.docker.compose.project.config_files label. Leave the relative bind
// so the deploy can pass the compose file by path; compose resolves `./x`
// against the file's dir to the same host location. If it was simply
// untranslatable, compose still resolves it against its cwd - a wrong path
// there fails the deploy loudly, better than a misleading host path.
modifiedLines.push(line);
}
} else {
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { getStackSource } from '$lib/server/db';
import { findStackDir } from '$lib/server/stacks';
import { getCachedContainerMounts, unpersistedComposePathWarning } from '$lib/server/host-path';
import { existsSync, readdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
@@ -15,7 +16,7 @@ import { join, dirname } from 'node:path';
* query: env:integer Environment ID the stack belongs to (from GET /api/environments)
* body: {newComposePath:string!}
* body-example: {"newComposePath":"/opt/stacks/web/compose.yaml"}
* resp-200: {hasChanges:boolean!, oldDir:string, newDir:string, fileCount:integer!, currentComposePath:string}
* resp-200: {hasChanges:boolean!, oldDir:string, newDir:string, fileCount:integer!, currentComposePath:string, persistenceWarning:string}
* resp-200-example: {"hasChanges":true,"oldDir":"/opt/stacks/old","newDir":"/opt/stacks/web","fileCount":3,"currentComposePath":"/opt/stacks/old/compose.yaml"}
* resp-403: Permission denied (requires stacks:edit)
* resp-500: Failed to check path changes
@@ -25,7 +26,10 @@ export const POST: RequestHandler = async ({ params, request, url, cookies }) =>
const { name } = params;
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
if (auth.authEnabled && !(await auth.can('stacks', 'edit', envIdNum))) {
// edit OR create: this pre-flight (files-to-move + persistence warning) is used by both
// the relocate/save flows (edit) and the create flow (a not-yet-existing stack), and it
// reveals nothing beyond the caller's own proposed path + Dockhand's mount layout.
if (auth.authEnabled && !(await auth.can('stacks', 'edit', envIdNum)) && !(await auth.can('stacks', 'create', envIdNum))) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const envAccessDenied = await auth.requireEnvAccess(envIdNum ?? null);
@@ -78,12 +82,17 @@ export const POST: RequestHandler = async ({ params, request, url, cookies }) =>
}
}
// Warn if the chosen path won't survive a Dockhand recreate because it is not
// under any of Dockhand's mounted volumes (#1524).
const persistenceWarning = unpersistedComposePathWarning(newComposePath, getCachedContainerMounts());
return json({
hasChanges,
oldDir: currentDir,
newDir,
fileCount,
currentComposePath
currentComposePath,
persistenceWarning
});
} catch (error: any) {
console.error(`Error checking path change for stack ${name}:`, error);
+131 -9
View File
@@ -136,6 +136,11 @@
const selectedProviderName = $derived(
secretProviders.find((p) => p.id === formSecretProviderId)?.name ?? null
);
// Whether a provider is currently bound AND still exists (a deleted provider leaves
// formSecretProviderId pointing at a gone id) - drives the historical banner (#1522).
const selectedProviderBound = $derived(
formSecretProviderId != null && secretProviders.some((p) => p.id === formSecretProviderId)
);
// Live probe of the bound provider: key NAMES currently present (bulk + resolved
// inline refs). Drives the editor's green IN VAULT marker. Empty when no provider
// is bound or the probe failed; probeError holds the reason on failure.
@@ -454,6 +459,43 @@
let changeLocationFileCount = $state(0);
let changeLocationOldDir = $state<string | null>(null);
let movingLocation = $state(false);
// Persistence warning: the chosen compose path is not under a Dockhand mount, so it
// would vanish on a container recreate (#1524). Shown as a confirm before committing.
let showPersistenceWarn = $state(false);
let persistenceWarnText = $state('');
let pendingHasFilesToMove = $state(false);
// Continuation run when the user accepts the persistence warning ("Use it anyway").
// Lets create / save / relocate share one dialog: each stashes what to do next.
let persistenceWarnProceed: (() => void) | null = null;
/**
* Pre-flight a compose path: if it is not under a Dockhand mount (would be lost on
* recreate, #1524), show the warning dialog and defer `proceed` until the user accepts;
* otherwise run `proceed` immediately. On any check failure, proceed (never block on the
* guard itself). Only meaningful for an absolute custom path.
*/
async function guardComposePersistence(composePath: string, proceed: () => void) {
const envId = $currentEnvironment?.id ?? null;
const probeName = mode === 'edit' ? stackName : (newStackName.trim() || 'new-stack');
try {
const res = await fetch(
appendEnvParam(`/api/stacks/${encodeURIComponent(probeName)}/check-path-change`, envId),
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ newComposePath: composePath }) }
);
if (res.ok) {
const data = await res.json();
if (data.persistenceWarning) {
persistenceWarnText = data.persistenceWarning;
persistenceWarnProceed = proceed;
showPersistenceWarn = true;
return;
}
}
} catch (e) {
console.warn('Persistence pre-flight failed:', e);
}
proceed();
}
async function handleChangeLocation(selectedDir: string, _name: string) {
showFileBrowser = false;
@@ -482,13 +524,22 @@
if (response.ok) {
const data = await response.json();
if (data.hasChanges && data.oldDir && data.fileCount > 0) {
// Show confirmation dialog
pendingNewLocation = newDir;
pendingNewComposePath = newComposePath;
pendingNewEnvPath = newEnvPath;
changeLocationOldDir = data.oldDir;
changeLocationFileCount = data.fileCount;
// Stash the target so a follow-up confirm (persistence and/or move) can act on it.
pendingNewLocation = newDir;
pendingNewComposePath = newComposePath;
pendingNewEnvPath = newEnvPath;
changeLocationOldDir = data.oldDir ?? null;
changeLocationFileCount = data.fileCount ?? 0;
pendingHasFilesToMove = !!(data.hasChanges && data.oldDir && data.fileCount > 0);
// A non-persisted path is the more serious warning - confirm it FIRST; on
// confirm we fall through to the move dialog (if any) or commit (#1524).
if (data.persistenceWarning) {
persistenceWarnText = data.persistenceWarning;
showPersistenceWarn = true;
return;
}
if (pendingHasFilesToMove) {
showChangeLocationConfirm = true;
return;
}
@@ -503,6 +554,41 @@
isDirty = true;
}
// "Use it anyway". A continuation (create/save) runs first; otherwise this is the
// change-location flow, so continue to the move dialog or commit the new path.
function confirmPersistenceWarn() {
showPersistenceWarn = false;
if (persistenceWarnProceed) {
const go = persistenceWarnProceed;
persistenceWarnProceed = null;
go();
return;
}
if (pendingHasFilesToMove) {
showChangeLocationConfirm = true;
return;
}
if (pendingNewComposePath) workingComposePath = pendingNewComposePath;
if (pendingNewEnvPath !== null) workingEnvPath = pendingNewEnvPath;
isDirty = true;
clearPendingLocation();
}
function cancelPersistenceWarn() {
showPersistenceWarn = false;
persistenceWarnProceed = null;
clearPendingLocation();
}
function clearPendingLocation() {
pendingNewLocation = null;
pendingNewComposePath = null;
pendingNewEnvPath = null;
changeLocationOldDir = null;
changeLocationFileCount = 0;
pendingHasFilesToMove = false;
}
function cancelChangeLocation() {
showChangeLocationConfirm = false;
pendingNewLocation = null;
@@ -1247,7 +1333,7 @@
composeContent = newContent;
}
async function handleCreate(start: boolean = false) {
async function handleCreate(start: boolean = false, persistenceAcked = false) {
errors = {};
let hasErrors = false;
@@ -1267,6 +1353,11 @@
if (hasErrors) return;
// Warn if the chosen compose path won't survive a recreate (#1524) - before creating.
if (!persistenceAcked && workingComposePath.trim()) {
return guardComposePersistence(workingComposePath.trim(), () => handleCreate(start, true));
}
const envId = $currentEnvironment?.id ?? null;
// Check if stack already exists
@@ -1364,7 +1455,7 @@
}
}
async function handleSave(restart = false, moveFromDir: string | null | undefined = undefined) {
async function handleSave(restart = false, moveFromDir: string | null | undefined = undefined, persistenceAcked = false) {
errors = {};
// Validate compose content (unless file location is needed and we have a path)
@@ -1399,6 +1490,13 @@
);
if (checkResponse.ok) {
const checkData = await checkResponse.json();
// Non-persisted path is the more serious warning - surface it first (#1524).
if (checkData.persistenceWarning && !persistenceAcked) {
persistenceWarnText = checkData.persistenceWarning;
persistenceWarnProceed = () => handleSave(restart, moveFromDir, true);
showPersistenceWarn = true;
return;
}
if (checkData.hasChanges && checkData.oldDir && checkData.fileCount > 0) {
// Show confirmation dialog
pathChangeOldDir = checkData.oldDir;
@@ -2160,6 +2258,7 @@
injectedSecretKeys={mode === 'edit' ? injectedSecretKeys : []}
providerType={selectedProviderType}
providerName={selectedProviderName}
providerBound={selectedProviderBound}
{probeError}
{providerKeySet}
{readonly}
@@ -2353,6 +2452,29 @@
</Dialog.Content>
</Dialog.Root>
<!-- Persistence warning: chosen compose path is not under a Dockhand mount (#1524) -->
<Dialog.Root bind:open={showPersistenceWarn}>
<Dialog.Content class="max-w-lg">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<TriangleAlert class="w-5 h-5 text-amber-500 shrink-0" />
This location isn't persisted
</Dialog.Title>
</Dialog.Header>
<p class="text-sm text-muted-foreground mt-1">
{persistenceWarnText}
</p>
<div class="flex justify-end gap-1.5 mt-4">
<Button variant="default" size="sm" onclick={cancelPersistenceWarn}>
Pick another location
</Button>
<Button variant="outline" size="sm" onclick={confirmPersistenceWarn}>
Use it anyway
</Button>
</div>
</Dialog.Content>
</Dialog.Root>
<!-- Change location confirmation dialog -->
<Dialog.Root bind:open={showChangeLocationConfirm}>
<Dialog.Content class="max-w-lg">
+113
View File
@@ -0,0 +1,113 @@
/**
* #1524: warn when a custom stack compose path is not under any Dockhand container
* mount, so it would be written to the container's throwaway layer and lost on recreate.
*/
import { describe, test, expect } from 'bun:test';
import { unpersistedComposePathWarning, pathOverriddenBySubMount, translateContainerPathViaMount, __setCachedMountsForTest } from '../src/lib/server/host-path';
const MOUNTS = [
{ source: 'dockhand_data', destination: '/app/data' },
{ source: '/opt/stacks', destination: '/opt/stacks' },
];
describe('unpersistedComposePathWarning', () => {
test('warns for a path outside every mount', () => {
const w = unpersistedComposePathWarning('/home/docker/minecraft/compose.yaml', MOUNTS);
expect(w).toContain('/home/docker/minecraft/compose.yaml');
expect(w).toContain('recreated');
});
test('no warning for a path under the data volume (default location)', () => {
expect(unpersistedComposePathWarning('/app/data/stacks/Production/web/compose.yaml', MOUNTS)).toBeNull();
});
test('no warning for a path under a user-added bind mount', () => {
expect(unpersistedComposePathWarning('/opt/stacks/web/compose.yaml', MOUNTS)).toBeNull();
});
test('a path exactly equal to a mount destination is under it', () => {
expect(unpersistedComposePathWarning('/opt/stacks', MOUNTS)).toBeNull();
});
test('a prefix that is not a path boundary does NOT count as under the mount', () => {
// /app/database is not under /app/data even though the string starts with it.
expect(unpersistedComposePathWarning('/app/database/compose.yaml', MOUNTS)).not.toBeNull();
});
test('bare metal (no mounts) never warns', () => {
expect(unpersistedComposePathWarning('/home/docker/x/compose.yaml', [])).toBeNull();
});
test('a relative path is not guarded (resolved against DATA_DIR elsewhere)', () => {
expect(unpersistedComposePathWarning('compose.yaml', MOUNTS)).toBeNull();
expect(unpersistedComposePathWarning('./sub/compose.yaml', MOUNTS)).toBeNull();
});
test('null / empty path never warns', () => {
expect(unpersistedComposePathWarning(null, MOUNTS)).toBeNull();
expect(unpersistedComposePathWarning('', MOUNTS)).toBeNull();
expect(unpersistedComposePathWarning(undefined, MOUNTS)).toBeNull();
});
});
describe('pathOverriddenBySubMount (#1533)', () => {
// The exact #1533 topology: /app/data on a named volume, /app/data/stacks on a
// separate bind. A stack under /app/data/stacks lives on the bind, not the volume,
// so the DATA_DIR translation is wrong and must yield to the mount translation.
const SUBMOUNT = [
{ source: '/home/docker/docker/volumes/dockhand_data/_data', destination: '/app/data' },
{ source: '/home/docker/compose', destination: '/app/data/stacks' },
];
test('true when a deeper bind overrides the stack subpath', () => {
expect(pathOverriddenBySubMount('/app/data/stacks/adguard', '/app/data', SUBMOUNT)).toBe(true);
});
test('true when the path equals the sub-mount destination', () => {
expect(pathOverriddenBySubMount('/app/data/stacks', '/app/data', SUBMOUNT)).toBe(true);
});
test('false for a DATA_DIR path NOT covered by the sub-mount', () => {
// /app/data/db is under DATA_DIR but not under the /app/data/stacks bind.
expect(pathOverriddenBySubMount('/app/data/db', '/app/data', SUBMOUNT)).toBe(false);
});
test('false when the only mount IS DATA_DIR itself (normal single-volume setup)', () => {
const single = [{ source: 'dockhand_data', destination: '/app/data' }];
expect(pathOverriddenBySubMount('/app/data/stacks/adguard', '/app/data', single)).toBe(false);
});
test('false with no mounts (bare metal)', () => {
expect(pathOverriddenBySubMount('/app/data/stacks/adguard', '/app/data', [])).toBe(false);
});
test('path-boundary safe: /app/database is not under /app/data', () => {
const m = [{ source: '/x', destination: '/app/database' }];
expect(pathOverriddenBySubMount('/app/data/stacks/adguard', '/app/data', m)).toBe(false);
});
test('trailing slashes on DATA_DIR / destinations are normalized', () => {
const m = [{ source: '/home/docker/compose', destination: '/app/data/stacks/' }];
expect(pathOverriddenBySubMount('/app/data/stacks/adguard', '/app/data/', m)).toBe(true);
});
// The load-bearing invariant of the #1533 fix: when we NULL dataDirHostPath because a
// sub-mount overrides, mountHostPath (translateContainerPathViaMount) MUST resolve non-null
// - otherwise we'd regress a wrong-but-found path into a hard-fail (unknown).
test('overridden => translateContainerPathViaMount resolves non-null (no hard-fail regression)', () => {
const mounts = [
{ source: '/home/docker/docker/volumes/dockhand_data/_data', destination: '/app/data' },
{ source: '/home/docker/compose', destination: '/app/data/stacks' },
];
const path = '/app/data/stacks/adguard';
try {
__setCachedMountsForTest(mounts);
expect(pathOverriddenBySubMount(path, '/app/data', mounts)).toBe(true);
// the very folder the backup helper will bind-mount:
expect(translateContainerPathViaMount(path)).toBe('/home/docker/compose/adguard');
} finally {
__setCachedMountsForTest(null);
}
});
});