fix: use host bind mount for /data/repos so tool containers can access mounted repos

The API container used a named Docker volume (repo_data:/data/repos) for
storing repositories. When creating tool instances with direct mount mode,
the API told Docker to bind-mount /data/repos/<repo>:/workspace into the
tool container. But the Docker daemon resolves bind-mount paths on the HOST
filesystem, not inside the API container. Since the host had no /data/repos
(the repos only existed inside the named volume), tool containers mounted
empty directories.

Changed both compose files to use a host bind mount (/data/repos:/data/repos)
instead of a named volume. This ensures:
- The API container and tool containers both see the same /data/repos path
- Bind mounts from /data/repos into tool containers work correctly

For existing installations: repos previously stored in the repo_data named
volume should be copied to /data/repos on the host before restarting the
stack.

Quality gates: compose file syntax valid
This commit is contained in:
2026-05-30 15:46:02 +02:00
parent b7396d58d2
commit d2b6bba15c
3 changed files with 167 additions and 147 deletions
+165 -143
View File
@@ -1,165 +1,187 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { import {
stopInstance, stopInstance,
deleteInstance, deleteInstance,
startInstance, startInstance,
recreateInstanceTunnel, recreateInstanceTunnel,
} from "../api/sessions"; } from "../api/sessions";
import type { Session } from "../api/sessions"; import type { Session } from "../api/sessions";
interface UseInstanceActionsOptions { interface UseInstanceActionsOptions {
onRefresh: () => Promise<void>; onRefresh: () => Promise<void>;
} }
interface UseInstanceActionsReturn { interface UseInstanceActionsReturn {
loadingSessionId: string | null; loadingSessionId: string | null;
dirtyDeleteSession: Session | null; dirtyDeleteSession: Session | null;
dirtyDeleteFiles: string[]; dirtyDeleteFiles: string[];
handleOpen: (session: Session) => void; handleOpen: (session: Session) => void;
handleStart: (session: Session) => Promise<void>; handleStart: (session: Session) => Promise<void>;
handleStop: (session: Session) => Promise<void>; handleStop: (session: Session) => Promise<void>;
handleDelete: (session: Session) => Promise<void>; handleDelete: (session: Session) => Promise<void>;
handleForceDelete: (session: Session) => Promise<void>; handleForceDelete: (session: Session) => Promise<void>;
handleRecreateTunnel: (session: Session) => Promise<void>; handleRecreateTunnel: (session: Session) => Promise<void>;
clearDirtyDelete: () => void; clearDirtyDelete: () => void;
} }
export function useInstanceActions( export function useInstanceActions(
options: UseInstanceActionsOptions options: UseInstanceActionsOptions,
): UseInstanceActionsReturn { ): UseInstanceActionsReturn {
const { onRefresh } = options; const { onRefresh } = options;
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null); const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(null); const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]); null,
);
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
const handleOpen = useCallback((session: Session) => { const handleOpen = useCallback((session: Session) => {
if (session.url) { if (session.url) {
window.open(session.url, "_blank", "noopener,noreferrer"); window.open(session.url, "_blank", "noopener,noreferrer");
return; return;
} }
if (session.tool_type_interfaces?.includes("terminal")) { if (session.tool_type_interfaces?.includes("terminal")) {
window.location.href = `/instances/${session.id}/terminal`; window.location.href = `/instances/${session.id}/terminal`;
return; return;
} }
window.location.href = `/projects/${session.project_id}`; window.location.href = `/projects/${session.project_id}`;
}, []); }, []);
const handleStart = useCallback( const handleStart = useCallback(
async (session: Session) => { async (session: Session) => {
if (loadingSessionId === session.id) return; if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id); setLoadingSessionId(session.id);
try { try {
await startInstance(session.project_id, session.repository_id, session.id); await startInstance(
await onRefresh(); session.project_id,
} catch { session.repository_id,
// ignore session.id,
} finally { );
setLoadingSessionId(null); await onRefresh();
} } catch {
}, // ignore
[loadingSessionId, onRefresh] } finally {
); setLoadingSessionId(null);
}
},
[loadingSessionId, onRefresh],
);
const handleStop = useCallback( const handleStop = useCallback(
async (session: Session) => { async (session: Session) => {
if (loadingSessionId === session.id) return; if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id); setLoadingSessionId(session.id);
try { try {
await stopInstance(session.project_id, session.repository_id, session.id); await stopInstance(
await onRefresh(); session.project_id,
} catch { session.repository_id,
// ignore session.id,
} finally { );
setLoadingSessionId(null); await onRefresh();
} } catch {
}, // ignore
[loadingSessionId, onRefresh] } finally {
); setLoadingSessionId(null);
}
},
[loadingSessionId, onRefresh],
);
const handleDelete = useCallback( const handleDelete = useCallback(
async (session: Session) => { async (session: Session) => {
if (loadingSessionId === session.id) return; if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id); setLoadingSessionId(session.id);
try { try {
await deleteInstance(session.project_id, session.repository_id, session.id); await deleteInstance(
setDirtyDeleteSession(null); session.project_id,
setDirtyDeleteFiles([]); session.repository_id,
await onRefresh(); session.id,
} catch (error) { );
const axiosError = error as { setDirtyDeleteSession(null);
response?: { status?: number; data?: { detail?: { changed_files?: string[] } } }; setDirtyDeleteFiles([]);
}; await onRefresh();
if (axiosError.response?.status === 409) { } catch (error) {
const detail = axiosError.response.data?.detail; const axiosError = error as {
if (detail?.changed_files) { response?: {
setDirtyDeleteSession(session); status?: number;
setDirtyDeleteFiles(detail.changed_files); data?: { detail?: { changed_files?: string[] } };
return; };
} };
} if (axiosError.response?.status === 409) {
} finally { const detail = axiosError.response.data?.detail;
setLoadingSessionId(null); if (detail?.changed_files) {
} setDirtyDeleteSession(session);
}, setDirtyDeleteFiles(detail.changed_files);
[loadingSessionId, onRefresh] return;
); }
}
} finally {
setLoadingSessionId(null);
}
},
[loadingSessionId, onRefresh],
);
const handleForceDelete = useCallback( const handleForceDelete = useCallback(
async (session: Session) => { async (session: Session) => {
if (loadingSessionId === session.id) return; if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id); setLoadingSessionId(session.id);
try { try {
await deleteInstance(session.project_id, session.repository_id, session.id, true); await deleteInstance(
setDirtyDeleteSession(null); session.project_id,
setDirtyDeleteFiles([]); session.repository_id,
await onRefresh(); session.id,
} catch { true,
// ignore );
} finally { setDirtyDeleteSession(null);
setLoadingSessionId(null); setDirtyDeleteFiles([]);
} await onRefresh();
}, } catch {
[loadingSessionId, onRefresh] // ignore
); } finally {
setLoadingSessionId(null);
}
},
[loadingSessionId, onRefresh],
);
const handleRecreateTunnel = useCallback( const handleRecreateTunnel = useCallback(
async (session: Session) => { async (session: Session) => {
if (loadingSessionId === session.id) return; if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id); setLoadingSessionId(session.id);
try { try {
await recreateInstanceTunnel( await recreateInstanceTunnel(
session.project_id, session.project_id,
session.repository_id, session.repository_id,
session.id session.id,
); );
await onRefresh(); await onRefresh();
} catch (err) { } catch (err) {
const message = const message =
(err as { response?: { data?: { detail?: string } } })?.response?.data (err as { response?: { data?: { detail?: string } } })?.response?.data
?.detail || "Failed to recreate tunnel"; ?.detail || "Failed to recreate tunnel";
alert(message); alert(message);
} finally { } finally {
setLoadingSessionId(null); setLoadingSessionId(null);
} }
}, },
[loadingSessionId, onRefresh] [loadingSessionId, onRefresh],
); );
const clearDirtyDelete = useCallback(() => { const clearDirtyDelete = useCallback(() => {
setDirtyDeleteSession(null); setDirtyDeleteSession(null);
setDirtyDeleteFiles([]); setDirtyDeleteFiles([]);
}, []); }, []);
return { return {
loadingSessionId, loadingSessionId,
dirtyDeleteSession, dirtyDeleteSession,
dirtyDeleteFiles, dirtyDeleteFiles,
handleOpen, handleOpen,
handleStart, handleStart,
handleStop, handleStop,
handleDelete, handleDelete,
handleForceDelete, handleForceDelete,
handleRecreateTunnel, handleRecreateTunnel,
clearDirtyDelete, clearDirtyDelete,
}; };
} }
+1 -2
View File
@@ -92,7 +92,7 @@ services:
AUTHENTIK_AUTHORIZE_URL: ${AUTHENTIK_AUTHORIZE_URL:-} AUTHENTIK_AUTHORIZE_URL: ${AUTHENTIK_AUTHORIZE_URL:-}
AUTHENTIK_TOKEN_URL: ${AUTHENTIK_TOKEN_URL:-} AUTHENTIK_TOKEN_URL: ${AUTHENTIK_TOKEN_URL:-}
volumes: volumes:
- repo_data:/data/repos - /data/repos:/data/repos
- /data/instances:/data/instances - /data/instances:/data/instances
- avatar_uploads:/app/uploads - avatar_uploads:/app/uploads
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
@@ -116,7 +116,6 @@ services:
volumes: volumes:
postgres_data: postgres_data:
redis_data: redis_data:
repo_data:
avatar_uploads: avatar_uploads:
networks: networks:
+1 -2
View File
@@ -57,7 +57,7 @@ services:
REPO_BASE_PATH: /data/repos REPO_BASE_PATH: /data/repos
INSTANCE_BASE_PATH: /data/instances INSTANCE_BASE_PATH: /data/instances
volumes: volumes:
- repo_data:/data/repos - /data/repos:/data/repos
- /data/instances:/data/instances - /data/instances:/data/instances
ports: ports:
- "8000:8000" - "8000:8000"
@@ -91,7 +91,6 @@ services:
volumes: volumes:
postgres_data: postgres_data:
redis_data: redis_data:
repo_data:
networks: networks:
backend: backend: