diff --git a/apps/dokploy/components/dashboard/docker/files/files-explorer-modal.tsx b/apps/dokploy/components/dashboard/docker/files/files-explorer-modal.tsx new file mode 100644 index 000000000..6bf843da6 --- /dev/null +++ b/apps/dokploy/components/dashboard/docker/files/files-explorer-modal.tsx @@ -0,0 +1,401 @@ +import { + ChevronRight, + Download, + File, + Folder, + FolderOpen, + Loader2, + RefreshCw, + Trash2, +} from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { CodeEditor } from "@/components/shared/code-editor"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { api } from "@/utils/api"; + +type Props = { + serverId?: string; + children?: React.ReactNode; + asDropdownItem?: boolean; +} & ( + | { containerId: string; volumeName?: undefined } + | { volumeName: string; containerId?: undefined } +); + +const joinPath = (base: string, name: string) => + base === "/" ? `/${name}` : `${base}/${name}`; + +const decodeBase64 = (base64: string) => { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +}; + +export const FilesExplorerModal = ({ + containerId, + volumeName, + serverId, + children, + asDropdownItem = true, +}: Props) => { + const [open, setOpen] = useState(false); + const [path, setPath] = useState("/"); + const [selectedFile, setSelectedFile] = useState(null); + const [editorContent, setEditorContent] = useState(""); + + const isVolume = !!volumeName; + const utils = api.useUtils(); + + const containerEntries = api.docker.listContainerFiles.useQuery( + { containerId: containerId ?? "", path, serverId }, + { enabled: open && !isVolume, retry: false }, + ); + const volumeEntries = api.dockerVolume.listVolumeFiles.useQuery( + { volumeName: volumeName ?? "", path, serverId }, + { enabled: open && isVolume, retry: false }, + ); + const { + data: entries, + isLoading: isLoadingEntries, + error: entriesError, + refetch: refetchEntries, + isRefetching, + } = isVolume ? volumeEntries : containerEntries; + + const containerFile = api.docker.readContainerFile.useQuery( + { containerId: containerId ?? "", path: selectedFile ?? "/", serverId }, + { enabled: open && !isVolume && !!selectedFile, retry: false }, + ); + const volumeFile = api.dockerVolume.readVolumeFile.useQuery( + { volumeName: volumeName ?? "", path: selectedFile ?? "/", serverId }, + { enabled: open && isVolume && !!selectedFile, retry: false }, + ); + const { + data: file, + isLoading: isLoadingFile, + error: fileError, + refetch: refetchFile, + } = isVolume ? volumeFile : containerFile; + + const fileBytes = useMemo( + () => (file ? decodeBase64(file.content) : null), + [file], + ); + const isBinary = useMemo( + () => !!fileBytes?.some((byte) => byte === 0), + [fileBytes], + ); + + useEffect(() => { + if (fileBytes && !isBinary) { + setEditorContent(new TextDecoder().decode(fileBytes)); + } + }, [fileBytes, isBinary]); + + const writeContainerFile = api.docker.writeContainerFile.useMutation(); + const writeVolumeFile = api.dockerVolume.writeVolumeFile.useMutation(); + const deleteContainerFile = api.docker.deleteContainerFile.useMutation(); + const deleteVolumeFile = api.dockerVolume.deleteVolumeFile.useMutation(); + + const isSaving = writeContainerFile.isPending || writeVolumeFile.isPending; + const isDeleting = + deleteContainerFile.isPending || deleteVolumeFile.isPending; + + const saveFile = async () => { + if (!selectedFile) return; + try { + if (isVolume) { + await writeVolumeFile.mutateAsync({ + volumeName, + path: selectedFile, + content: editorContent, + serverId, + }); + } else { + await writeContainerFile.mutateAsync({ + containerId: containerId ?? "", + path: selectedFile, + content: editorContent, + serverId, + }); + } + toast.success("File saved"); + refetchFile(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to save file", + ); + } + }; + + const deleteEntry = async (entryPath: string) => { + try { + if (isVolume) { + await deleteVolumeFile.mutateAsync({ + volumeName, + path: entryPath, + serverId, + }); + await utils.dockerVolume.listVolumeFiles.invalidate(); + } else { + await deleteContainerFile.mutateAsync({ + containerId: containerId ?? "", + path: entryPath, + serverId, + }); + await utils.docker.listContainerFiles.invalidate(); + } + toast.success("Deleted"); + if (selectedFile === entryPath) { + setSelectedFile(null); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to delete"); + } + }; + + const breadcrumbs = path.split("/").filter(Boolean); + + const navigate = (nextPath: string) => { + setPath(nextPath); + setSelectedFile(null); + }; + + const handleOpenChange = (value: boolean) => { + setOpen(value); + if (!value) { + setPath("/"); + setSelectedFile(null); + setEditorContent(""); + } + }; + + const downloadFile = () => { + if (!fileBytes || !selectedFile) return; + const blob = new Blob([fileBytes]); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = selectedFile.split("/").pop() ?? "file"; + anchor.click(); + URL.revokeObjectURL(url); + }; + + return ( + + + {asDropdownItem ? ( + e.preventDefault()} + > + {children} + + ) : ( + children + )} + + + + + + {isVolume ? "Volume Files" : "Container Files"} + + + {isVolume + ? `Browse and edit files inside the "${volumeName}" volume` + : "Browse and edit files inside the container's filesystem"} + + + +
+ + {breadcrumbs.map((segment, index) => ( + + + {index < breadcrumbs.length - 1 && ( + + )} + + ))} + +
+ +
+
+ {isLoadingEntries ? ( +
+ Loading... + +
+ ) : entriesError ? ( +
+ {entriesError.message} +
+ ) : ( +
+ {path !== "/" && ( + + )} + {entries?.length === 0 && ( + + Empty directory + + )} + {entries?.map((entry) => { + const entryPath = joinPath(path, entry.name); + return ( +
+ + deleteEntry(entryPath)} + > + + +
+ ); + })} +
+ )} +
+ +
+ {!selectedFile ? ( +
+ Select a file to view or edit it +
+ ) : isLoadingFile ? ( +
+ Loading... + +
+ ) : fileError ? ( + {fileError.message} + ) : ( + <> +
+ + {selectedFile} + +
+ + +
+
+ {file?.truncated ? ( + + This file is larger than 512KB. Showing a truncated preview; + editing is disabled. Use Download to get the truncated + content or the terminal for full access. + + ) : isBinary ? ( +
+ Binary file — use Download instead +
+ ) : ( +
+ setEditorContent(value)} + wrapperClassName="h-full font-mono" + /> +
+ )} + + )} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/docker/show/columns.tsx b/apps/dokploy/components/dashboard/docker/show/columns.tsx index 4b108344e..3a105aefa 100644 --- a/apps/dokploy/components/dashboard/docker/show/columns.tsx +++ b/apps/dokploy/components/dashboard/docker/show/columns.tsx @@ -12,6 +12,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { ShowContainerConfig } from "../config/show-container-config"; +import { FilesExplorerModal } from "../files/files-explorer-modal"; import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs"; import { ShowContainerMounts } from "../mounts/show-container-mounts"; import { ShowContainerNetworks } from "../networks/show-container-networks"; @@ -173,6 +174,12 @@ export const columns: ColumnDef[] = [ > Terminal + + Browse Files + ["dockerVolume"]["getVolumes"][number]; + +interface Props { + serverId?: string; +} + +const SIZE_UNITS: Record = { + b: 1, + kb: 1e3, + mb: 1e6, + gb: 1e9, + tb: 1e12, +}; + +const parseSize = (size?: string) => { + if (!size) return -1; + const match = /^([\d.]+)\s*([a-zA-Z]+)$/.exec(size.trim()); + if (!match?.[1] || !match[2]) return -1; + return Number(match[1]) * (SIZE_UNITS[match[2].toLowerCase()] ?? 1); +}; + +const SortableHeader = ({ + column, + title, +}: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + title: string; +}) => ( + +); + +const ShowVolumeConfig = ({ + volumeName, + serverId, +}: { + volumeName: string; + serverId?: string; +}) => { + const [open, setOpen] = useState(false); + const { data, isLoading, error } = api.dockerVolume.getVolumeConfig.useQuery( + { volumeName, serverId }, + { enabled: open }, + ); + + return ( + + + + + + + Volume Config + + docker volume inspect output for "{volumeName}" + + + {error ? ( + {error.message} + ) : isLoading ? ( +
+ Loading... + +
+ ) : ( +
+ +
+								
+							
+
+
+ )} +
+
+ ); +}; + +export const ShowVolumes = ({ serverId }: Props) => { + const utils = api.useUtils(); + const [sorting, setSorting] = useState([ + { id: "Name", desc: false }, + ]); + const [globalFilter, setGlobalFilter] = useState(""); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + + const { data: volumes, isLoading } = api.dockerVolume.getVolumes.useQuery({ + serverId, + }); + const { data: sizes, isLoading: isLoadingSizes } = + api.dockerVolume.getVolumesSize.useQuery({ serverId }); + + const sizeByName = useMemo( + () => new Map((sizes ?? []).map((entry) => [entry.name, entry.size])), + [sizes], + ); + const { mutateAsync: removeVolume } = + api.dockerVolume.removeVolume.useMutation(); + + const filteredData = useMemo(() => { + const list = volumes ?? []; + if (!globalFilter.trim()) { + return list; + } + const query = globalFilter.toLowerCase(); + return list.filter((volume) => volume.Name.toLowerCase().includes(query)); + }, [volumes, globalFilter]); + + const columns = useMemo[]>( + () => [ + { + accessorKey: "Name", + header: ({ column }) => , + cell: ({ row }) => ( +
+ {row.original.Name} +
+ ), + }, + { + accessorKey: "Driver", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.original.Driver} + ), + }, + { + accessorKey: "Scope", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.original.Scope} + ), + }, + { + id: "Size", + header: ({ column }) => , + sortingFn: (a, b) => + parseSize(sizeByName.get(a.original.Name) ?? undefined) - + parseSize(sizeByName.get(b.original.Name) ?? undefined), + cell: ({ row }) => + isLoadingSizes ? ( + + ) : ( + + {sizeByName.get(row.original.Name) ?? "-"} + + ), + }, + { + accessorKey: "Mountpoint", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.original.Mountpoint} +
+ ), + }, + { + id: "actions", + enableSorting: false, + header: () =>
Actions
, + cell: ({ row }) => ( +
+ + + + + { + try { + await removeVolume({ + volumeName: row.original.Name, + serverId, + }); + toast.success("Volume deleted"); + await utils.dockerVolume.getVolumes.invalidate(); + } catch (error) { + toast.error("Error deleting volume", { + description: + error instanceof Error ? error.message : "Unknown error", + }); + } + }} + > + + +
+ ), + }, + ], + [serverId, removeVolume, utils, sizeByName, isLoadingSizes], + ); + + const table = useReactTable({ + data: filteredData, + columns, + state: { + sorting, + pagination, + }, + onSortingChange: setSorting, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + return ( +
+ +
+ + + + Volumes + + + Manage the Docker volumes of the selected server. + + + + + {isLoading ? ( +
+ Loading... + +
+ ) : !volumes?.length ? ( +
+
+ +
+
+

No volumes found

+

+ Docker volumes created on this server will appear here. +

+
+
+ ) : ( + <> +
+ setGlobalFilter(e.target.value)} + className="max-w-xs" + /> +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No volumes match your filters. + + + )} + +
+
+ {table.getPageCount() > 1 && ( +
+ + Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} + +
+ + +
+
+ )} + + )} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/pages/dashboard/docker.tsx b/apps/dokploy/pages/dashboard/docker.tsx index 11605d963..a8c1eef1e 100644 --- a/apps/dokploy/pages/dashboard/docker.tsx +++ b/apps/dokploy/pages/dashboard/docker.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/router"; import type { ReactElement } from "react"; import superjson from "superjson"; import { ShowContainers } from "@/components/dashboard/docker/show/show-containers"; +import { ShowVolumes } from "@/components/dashboard/docker/volumes/show-volumes"; import { ShowNetworks } from "@/components/dashboard/networks/show-networks"; import { ShowSwarmContainers } from "@/components/dashboard/swarm/containers/show-swarm-containers"; import SwarmMonitorCard from "@/components/dashboard/swarm/monitoring-card"; @@ -44,6 +45,7 @@ const Dashboard = () => { Containers Swarm + Volumes {!isCloud && Networks} @@ -67,6 +69,9 @@ const Dashboard = () => { + + + {!isCloud && ( diff --git a/apps/dokploy/server/api/root.ts b/apps/dokploy/server/api/root.ts index 4e0a73ed6..23c7f00f7 100644 --- a/apps/dokploy/server/api/root.ts +++ b/apps/dokploy/server/api/root.ts @@ -10,6 +10,7 @@ import { composeRouter } from "./routers/compose"; import { deploymentRouter } from "./routers/deployment"; import { destinationRouter } from "./routers/destination"; import { dockerRouter } from "./routers/docker"; +import { dockerVolumeRouter } from "./routers/docker-volume"; import { domainRouter } from "./routers/domain"; import { environmentRouter } from "./routers/environment"; import { gitProviderRouter } from "./routers/git-provider"; @@ -68,6 +69,7 @@ export const appRouter = createTRPCRouter({ deployment: deploymentRouter, destination: destinationRouter, docker: dockerRouter, + dockerVolume: dockerVolumeRouter, domain: domainRouter, gitea: giteaRouter, gitProvider: gitProviderRouter, diff --git a/apps/dokploy/server/api/routers/docker-volume.ts b/apps/dokploy/server/api/routers/docker-volume.ts new file mode 100644 index 000000000..643dff5d4 --- /dev/null +++ b/apps/dokploy/server/api/routers/docker-volume.ts @@ -0,0 +1,215 @@ +import { + deleteVolumeFile, + findServerById, + getVolumeConfig, + getVolumes, + getVolumesSize, + listVolumeFiles, + readVolumeFile, + removeVolume, + writeVolumeFile, +} from "@dokploy/server"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { audit } from "@/server/api/utils/audit"; +import { createTRPCRouter, withPermission } from "../trpc"; + +export const volumeNameRegex = /^[a-zA-Z0-9.\-_]+$/; + +const volumePathSchema = z + .string() + .min(1) + .max(4096) + .refine( + (path) => path.startsWith("/") && !path.includes("\0"), + "Path must be absolute.", + ); + +export const dockerVolumeRouter = createTRPCRouter({ + getVolumes: withPermission("docker", "read") + .input( + z.object({ + serverId: z.string().optional(), + }), + ) + .query(async ({ input, ctx }) => { + if (input.serverId) { + const server = await findServerById(input.serverId); + if (server.organizationId !== ctx.session?.activeOrganizationId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + } + return await getVolumes(input.serverId); + }), + + getVolumesSize: withPermission("docker", "read") + .input( + z.object({ + serverId: z.string().optional(), + }), + ) + .query(async ({ input, ctx }) => { + if (input.serverId) { + const server = await findServerById(input.serverId); + if (server.organizationId !== ctx.session?.activeOrganizationId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + } + return await getVolumesSize(input.serverId); + }), + + listVolumeFiles: withPermission("docker", "read") + .input( + z.object({ + volumeName: z + .string() + .min(1) + .regex(volumeNameRegex, "Invalid volume name."), + path: volumePathSchema, + serverId: z.string().optional(), + }), + ) + .query(async ({ input, ctx }) => { + if (input.serverId) { + const server = await findServerById(input.serverId); + if (server.organizationId !== ctx.session?.activeOrganizationId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + } + return await listVolumeFiles( + input.volumeName, + input.path, + input.serverId, + ); + }), + + readVolumeFile: withPermission("docker", "read") + .input( + z.object({ + volumeName: z + .string() + .min(1) + .regex(volumeNameRegex, "Invalid volume name."), + path: volumePathSchema, + serverId: z.string().optional(), + }), + ) + .query(async ({ input, ctx }) => { + if (input.serverId) { + const server = await findServerById(input.serverId); + if (server.organizationId !== ctx.session?.activeOrganizationId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + } + return await readVolumeFile(input.volumeName, input.path, input.serverId); + }), + + writeVolumeFile: withPermission("docker", "read") + .input( + z.object({ + volumeName: z + .string() + .min(1) + .regex(volumeNameRegex, "Invalid volume name."), + path: volumePathSchema, + content: z.string(), + serverId: z.string().optional(), + }), + ) + .mutation(async ({ input, ctx }) => { + if (input.serverId) { + const server = await findServerById(input.serverId); + if (server.organizationId !== ctx.session?.activeOrganizationId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + } + await writeVolumeFile( + input.volumeName, + input.path, + input.content, + input.serverId, + ); + await audit(ctx, { + action: "update", + resourceType: "docker", + resourceId: input.volumeName, + resourceName: `${input.volumeName}:${input.path}`, + }); + }), + + deleteVolumeFile: withPermission("docker", "read") + .input( + z.object({ + volumeName: z + .string() + .min(1) + .regex(volumeNameRegex, "Invalid volume name."), + path: volumePathSchema.refine( + (path) => path !== "/", + "Cannot delete the volume root.", + ), + serverId: z.string().optional(), + }), + ) + .mutation(async ({ input, ctx }) => { + if (input.serverId) { + const server = await findServerById(input.serverId); + if (server.organizationId !== ctx.session?.activeOrganizationId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + } + await deleteVolumeFile(input.volumeName, input.path, input.serverId); + await audit(ctx, { + action: "delete", + resourceType: "docker", + resourceId: input.volumeName, + resourceName: `${input.volumeName}:${input.path}`, + }); + }), + + getVolumeConfig: withPermission("docker", "read") + .input( + z.object({ + volumeName: z + .string() + .min(1) + .regex(volumeNameRegex, "Invalid volume name."), + serverId: z.string().optional(), + }), + ) + .query(async ({ input, ctx }) => { + if (input.serverId) { + const server = await findServerById(input.serverId); + if (server.organizationId !== ctx.session?.activeOrganizationId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + } + return await getVolumeConfig(input.volumeName, input.serverId); + }), + + removeVolume: withPermission("docker", "read") + .input( + z.object({ + volumeName: z + .string() + .min(1) + .regex(volumeNameRegex, "Invalid volume name."), + serverId: z.string().optional(), + }), + ) + .mutation(async ({ input, ctx }) => { + if (input.serverId) { + const server = await findServerById(input.serverId); + if (server.organizationId !== ctx.session?.activeOrganizationId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + } + await removeVolume(input.volumeName, input.serverId); + await audit(ctx, { + action: "delete", + resourceType: "docker", + resourceId: input.volumeName, + resourceName: input.volumeName, + }); + }), +}); diff --git a/apps/dokploy/server/api/routers/docker.ts b/apps/dokploy/server/api/routers/docker.ts index 838ab83a0..cc7fbc4c0 100644 --- a/apps/dokploy/server/api/routers/docker.ts +++ b/apps/dokploy/server/api/routers/docker.ts @@ -4,6 +4,7 @@ import { containerRestart, containerStart, containerStop, + deleteContainerFile, findServerById, getConfig, getContainers, @@ -11,7 +12,10 @@ import { getContainersByAppNameMatch, getServiceContainersByAppName, getStackContainersByAppName, + listContainerFiles, + readContainerFile, uploadFileToContainer, + writeContainerFile, } from "@dokploy/server"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; @@ -21,6 +25,15 @@ import { createTRPCRouter, withPermission } from "../trpc"; export const containerIdRegex = /^[a-zA-Z0-9.\-_]+$/; +const containerPathSchema = z + .string() + .min(1) + .max(4096) + .refine( + (path) => path.startsWith("/") && !path.includes("\0"), + "Path must be absolute.", + ); + export const dockerRouter = createTRPCRouter({ getContainers: withPermission("docker", "read") .input( @@ -298,4 +311,117 @@ export const dockerRouter = createTRPCRouter({ return { success: true, message: "File uploaded successfully" }; }), + + listContainerFiles: withPermission("docker", "read") + .input( + z.object({ + containerId: z + .string() + .min(1) + .regex(containerIdRegex, "Invalid container id."), + path: containerPathSchema, + serverId: z.string().optional(), + }), + ) + .query(async ({ input, ctx }) => { + if (input.serverId) { + const server = await findServerById(input.serverId); + if (server.organizationId !== ctx.session?.activeOrganizationId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + } + return await listContainerFiles( + input.containerId, + input.path, + input.serverId, + ); + }), + + readContainerFile: withPermission("docker", "read") + .input( + z.object({ + containerId: z + .string() + .min(1) + .regex(containerIdRegex, "Invalid container id."), + path: containerPathSchema, + serverId: z.string().optional(), + }), + ) + .query(async ({ input, ctx }) => { + if (input.serverId) { + const server = await findServerById(input.serverId); + if (server.organizationId !== ctx.session?.activeOrganizationId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + } + return await readContainerFile( + input.containerId, + input.path, + input.serverId, + ); + }), + + writeContainerFile: withPermission("docker", "read") + .input( + z.object({ + containerId: z + .string() + .min(1) + .regex(containerIdRegex, "Invalid container id."), + path: containerPathSchema, + content: z.string(), + serverId: z.string().optional(), + }), + ) + .mutation(async ({ input, ctx }) => { + if (input.serverId) { + const server = await findServerById(input.serverId); + if (server.organizationId !== ctx.session?.activeOrganizationId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + } + await writeContainerFile( + input.containerId, + input.path, + input.content, + input.serverId, + ); + await audit(ctx, { + action: "update", + resourceType: "docker", + resourceId: input.containerId, + resourceName: `${input.containerId}:${input.path}`, + }); + }), + + deleteContainerFile: withPermission("docker", "read") + .input( + z.object({ + containerId: z + .string() + .min(1) + .regex(containerIdRegex, "Invalid container id."), + path: containerPathSchema.refine( + (path) => path !== "/", + "Cannot delete the container root.", + ), + serverId: z.string().optional(), + }), + ) + .mutation(async ({ input, ctx }) => { + if (input.serverId) { + const server = await findServerById(input.serverId); + if (server.organizationId !== ctx.session?.activeOrganizationId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + } + await deleteContainerFile(input.containerId, input.path, input.serverId); + await audit(ctx, { + action: "delete", + resourceType: "docker", + resourceId: input.containerId, + resourceName: `${input.containerId}:${input.path}`, + }); + }), }); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index e81ebba2a..d5a45a685 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -17,6 +17,7 @@ export * from "./services/compose"; export * from "./services/deployment"; export * from "./services/destination"; export * from "./services/docker"; +export * from "./services/docker-volume"; export * from "./services/domain"; export * from "./services/environment"; export * from "./services/git-provider"; diff --git a/packages/server/src/services/docker-volume.ts b/packages/server/src/services/docker-volume.ts new file mode 100644 index 000000000..1fbe12376 --- /dev/null +++ b/packages/server/src/services/docker-volume.ts @@ -0,0 +1,161 @@ +import { + execAsync, + execAsyncRemote, +} from "@dokploy/server/utils/process/execAsync"; +import { quote } from "shell-quote"; +import { CONTAINER_FILE_SIZE_LIMIT } from "./docker"; + +export interface DockerVolume { + Name: string; + Driver: string; + Scope: string; + Mountpoint: string; + Labels: string; + Availability?: string; + Group?: string; + Links?: string; + Size?: string; + Status?: string; +} + +const VOLUME_MOUNT = "/__volume"; + +export const getVolumes = async (serverId?: string) => { + try { + const command = "docker volume ls --format '{{json .}}'"; + const { stdout } = serverId + ? await execAsyncRemote(serverId, command) + : await execAsync(command); + + return stdout + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as DockerVolume); + } catch (error) { + console.error(error); + return []; + } +}; + +export const getVolumesSize = async (serverId?: string) => { + try { + const command = "docker system df -v --format '{{json .}}'"; + const { stdout } = serverId + ? await execAsyncRemote(serverId, command) + : await execAsync(command); + + const diskUsage = JSON.parse(stdout.trim()); + return ((diskUsage?.Volumes ?? []) as Partial[]) + .filter((volume) => volume?.Name) + .map((volume) => ({ + name: volume.Name as string, + size: volume.Size ?? null, + })); + } catch (error) { + console.error(error); + return []; + } +}; + +export const getVolumeConfig = async ( + volumeName: string, + serverId?: string, +) => { + const command = `docker volume inspect ${quote([String(volumeName ?? "")])}`; + const { stdout } = serverId + ? await execAsyncRemote(serverId, command) + : await execAsync(command); + + return JSON.parse(stdout.trim())[0]; +}; + +export const removeVolume = async (volumeName: string, serverId?: string) => { + const command = `docker volume rm ${quote([String(volumeName ?? "")])}`; + if (serverId) { + await execAsyncRemote(serverId, command); + } else { + await execAsync(command); + } +}; + +export const listVolumeFiles = async ( + volumeName: string, + path: string, + serverId?: string, +) => { + const command = `docker run --rm -v ${quote([`${volumeName}:${VOLUME_MOUNT}:ro`])} busybox ls -1Ap ${quote([`${VOLUME_MOUNT}${path}`])}`; + const { stdout } = serverId + ? await execAsyncRemote(serverId, command) + : await execAsync(command); + + return stdout + .split("\n") + .filter(Boolean) + .map((entry) => ({ + name: entry.endsWith("/") ? entry.slice(0, -1) : entry, + isDirectory: entry.endsWith("/"), + })) + .sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); +}; + +export const readVolumeFile = async ( + volumeName: string, + filePath: string, + serverId?: string, +) => { + const command = `docker run --rm -v ${quote([`${volumeName}:${VOLUME_MOUNT}:ro`])} busybox cat ${quote([`${VOLUME_MOUNT}${filePath}`])} | head -c ${CONTAINER_FILE_SIZE_LIMIT + 1} | base64 | tr -d '\\n'`; + const { stdout, stderr } = serverId + ? await execAsyncRemote(serverId, command) + : await execAsync(command); + + if (stderr && !stdout) { + throw new Error(stderr); + } + + const buffer = Buffer.from(stdout.trim(), "base64"); + return { + content: buffer.subarray(0, CONTAINER_FILE_SIZE_LIMIT).toString("base64"), + truncated: buffer.byteLength > CONTAINER_FILE_SIZE_LIMIT, + }; +}; + +export const writeVolumeFile = async ( + volumeName: string, + filePath: string, + content: string, + serverId?: string, +) => { + const base64Content = Buffer.from(content, "utf8").toString("base64"); + if (base64Content.length > CONTAINER_FILE_SIZE_LIMIT * 2) { + throw new Error("File is too large to save from the editor (max 512KB)"); + } + + const innerCommand = `printf '%s' ${base64Content} | base64 -d > ${quote([`${VOLUME_MOUNT}${filePath}`])}`; + const command = `docker run --rm -v ${quote([`${volumeName}:${VOLUME_MOUNT}`])} busybox sh -c ${quote([innerCommand])}`; + + if (serverId) { + await execAsyncRemote(serverId, command); + } else { + await execAsync(command); + } +}; + +export const deleteVolumeFile = async ( + volumeName: string, + path: string, + serverId?: string, +) => { + const command = `docker run --rm -v ${quote([`${volumeName}:${VOLUME_MOUNT}`])} busybox rm -rf ${quote([`${VOLUME_MOUNT}${path}`])}`; + + if (serverId) { + await execAsyncRemote(serverId, command); + } else { + await execAsync(command); + } +}; diff --git a/packages/server/src/services/docker.ts b/packages/server/src/services/docker.ts index e95a84392..9f9cf9100 100644 --- a/packages/server/src/services/docker.ts +++ b/packages/server/src/services/docker.ts @@ -1,4 +1,5 @@ import { + ExecError, execAsync, execAsyncRemote, } from "@dokploy/server/utils/process/execAsync"; @@ -760,3 +761,113 @@ export const uploadFileToContainer = async ( ); } }; + +export const CONTAINER_FILE_SIZE_LIMIT = 512 * 1024; + +const NO_SHELL_UTILITIES_ERROR = + "This container image has no shell utilities and its filesystem is not accessible from the Dokploy host."; + +const isMissingBinaryError = (error: unknown) => + error instanceof ExecError && + (error.exitCode === 126 || + error.exitCode === 127 || + !!error.stderr?.includes("executable file not found")); + +export const listContainerFiles = async ( + containerId: string, + path: string, + serverId?: string, +) => { + const command = `docker exec ${quote([containerId])} ls -1Ap ${quote([path])}`; + let stdout: string; + try { + ({ stdout } = serverId + ? await execAsyncRemote(serverId, command) + : await execAsync(command)); + } catch (error) { + if (isMissingBinaryError(error)) { + throw new Error(NO_SHELL_UTILITIES_ERROR); + } + throw error; + } + + return stdout + .split("\n") + .filter(Boolean) + .map((entry) => ({ + name: entry.endsWith("/") ? entry.slice(0, -1) : entry, + isDirectory: entry.endsWith("/"), + })) + .sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); +}; + +export const readContainerFile = async ( + containerId: string, + filePath: string, + serverId?: string, +) => { + const command = `docker exec ${quote([containerId])} cat ${quote([filePath])} | head -c ${CONTAINER_FILE_SIZE_LIMIT + 1} | base64 | tr -d '\\n'`; + const { stdout, stderr } = serverId + ? await execAsyncRemote(serverId, command) + : await execAsync(command); + + if (stderr && !stdout) { + if (stderr.includes("executable file not found")) { + throw new Error(NO_SHELL_UTILITIES_ERROR); + } + throw new Error(stderr); + } + + const buffer = Buffer.from(stdout.trim(), "base64"); + return { + content: buffer.subarray(0, CONTAINER_FILE_SIZE_LIMIT).toString("base64"), + truncated: buffer.byteLength > CONTAINER_FILE_SIZE_LIMIT, + }; +}; + +export const writeContainerFile = async ( + containerId: string, + filePath: string, + content: string, + serverId?: string, +) => { + const base64Content = Buffer.from(content, "utf8").toString("base64"); + if (base64Content.length > CONTAINER_FILE_SIZE_LIMIT * 2) { + throw new Error("File is too large to save from the editor (max 512KB)"); + } + + const tempPath = `/tmp/dokploy-edit-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const command = `printf '%s' ${quote([base64Content])} | base64 -d > ${quote([tempPath])} && docker cp ${quote([tempPath])} ${quote([`${containerId}:${filePath}`])}; status=$?; rm -f ${quote([tempPath])}; exit $status`; + + if (serverId) { + await execAsyncRemote(serverId, command); + } else { + await execAsync(command); + } +}; + +export const deleteContainerFile = async ( + containerId: string, + path: string, + serverId?: string, +) => { + const command = `docker exec ${quote([containerId])} rm -rf ${quote([path])}`; + + try { + if (serverId) { + await execAsyncRemote(serverId, command); + } else { + await execAsync(command); + } + } catch (error) { + if (isMissingBinaryError(error)) { + throw new Error(NO_SHELL_UTILITIES_ERROR); + } + throw error; + } +};