From fcbd22679692d54fa0676b83e645bc0d172fd049 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Mon, 13 Apr 2026 22:36:22 -0600 Subject: [PATCH] feat: implement service transfer functionality Add a new TransferService component to facilitate the transfer of various services (applications, databases, etc.) between servers. This includes updates to the ShowDatabaseAdvancedSettings component to support serverId, and integration of transfer functionality in the application, compose, mariadb, mongo, mysql, postgres, and redis routers. The transfer process includes scanning for transfer readiness and executing the transfer with appropriate permissions and error handling. --- .../show-database-advanced-settings.tsx | 11 +- .../dashboard/shared/transfer-service.tsx | 493 ++++++++++++++++++ .../services/application/[applicationId].tsx | 6 + .../services/compose/[composeId].tsx | 6 + .../services/mariadb/[mariadbId].tsx | 1 + .../services/mongo/[mongoId].tsx | 1 + .../services/mysql/[mysqlId].tsx | 1 + .../services/postgres/[postgresId].tsx | 1 + .../services/redis/[redisId].tsx | 1 + .../dokploy/server/api/routers/application.ts | 137 +++++ apps/dokploy/server/api/routers/compose.ts | 137 +++++ apps/dokploy/server/api/routers/mariadb.ts | 119 +++++ apps/dokploy/server/api/routers/mongo.ts | 119 +++++ apps/dokploy/server/api/routers/mysql.ts | 119 +++++ apps/dokploy/server/api/routers/postgres.ts | 119 +++++ apps/dokploy/server/api/routers/redis.ts | 119 +++++ packages/server/src/db/schema/application.ts | 6 + packages/server/src/db/schema/compose.ts | 6 + packages/server/src/db/schema/mariadb.ts | 6 + packages/server/src/db/schema/mongo.ts | 6 + packages/server/src/db/schema/mysql.ts | 6 + packages/server/src/db/schema/postgres.ts | 6 + packages/server/src/db/schema/redis.ts | 6 + packages/server/src/index.ts | 2 + packages/server/src/services/transfer.ts | 396 ++++++++++++++ packages/server/src/utils/transfer/index.ts | 4 + .../server/src/utils/transfer/preflight.ts | 100 ++++ packages/server/src/utils/transfer/scanner.ts | 232 +++++++++ packages/server/src/utils/transfer/sync.ts | 171 ++++++ packages/server/src/utils/transfer/types.ts | 91 ++++ 30 files changed, 2427 insertions(+), 1 deletion(-) create mode 100644 apps/dokploy/components/dashboard/shared/transfer-service.tsx create mode 100644 packages/server/src/services/transfer.ts create mode 100644 packages/server/src/utils/transfer/index.ts create mode 100644 packages/server/src/utils/transfer/preflight.ts create mode 100644 packages/server/src/utils/transfer/scanner.ts create mode 100644 packages/server/src/utils/transfer/sync.ts create mode 100644 packages/server/src/utils/transfer/types.ts diff --git a/apps/dokploy/components/dashboard/shared/show-database-advanced-settings.tsx b/apps/dokploy/components/dashboard/shared/show-database-advanced-settings.tsx index bf2089498..e8117aa29 100644 --- a/apps/dokploy/components/dashboard/shared/show-database-advanced-settings.tsx +++ b/apps/dokploy/components/dashboard/shared/show-database-advanced-settings.tsx @@ -3,13 +3,15 @@ import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes import { ShowCustomCommand } from "@/components/dashboard/postgres/advanced/show-custom-command"; import { ShowClusterSettings } from "../application/advanced/cluster/show-cluster-settings"; import { RebuildDatabase } from "./rebuild-database"; +import { TransferService } from "./transfer-service"; interface Props { id: string; type: "libsql" | "mariadb" | "mongo" | "mysql" | "postgres" | "redis"; + serverId?: string | null; } -export const ShowDatabaseAdvancedSettings = ({ id, type }: Props) => { +export const ShowDatabaseAdvancedSettings = ({ id, type, serverId }: Props) => { return (
@@ -23,6 +25,13 @@ export const ShowDatabaseAdvancedSettings = ({ id, type }: Props) => { + {type !== "libsql" && ( + + )}
); }; diff --git a/apps/dokploy/components/dashboard/shared/transfer-service.tsx b/apps/dokploy/components/dashboard/shared/transfer-service.tsx new file mode 100644 index 000000000..575b7dbd2 --- /dev/null +++ b/apps/dokploy/components/dashboard/shared/transfer-service.tsx @@ -0,0 +1,493 @@ +import { AlertTriangle, ArrowRightLeft, Loader2, Server } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { api } from "@/utils/api"; + +type ServiceType = + | "application" + | "compose" + | "postgres" + | "mysql" + | "mariadb" + | "mongo" + | "redis"; + +interface TransferServiceProps { + serviceId: string; + serviceType: ServiceType; + currentServerId: string | null; +} + +interface ScanResult { + serviceDirectory: { + files: Array<{ + path: string; + status: string; + sourceFile: { path: string; size: number; modifiedAt: number }; + targetFile?: { path: string; size: number; modifiedAt: number }; + }>; + totalSize: number; + }; + traefikConfig: { + exists: boolean; + hasConflict: boolean; + }; + mounts: Array<{ + mount: { + mountId: string; + type: string; + volumeName?: string | null; + hostPath?: string | null; + mountPath: string; + }; + files: Array<{ + path: string; + status: string; + }>; + totalSize: number; + }>; + totalTransferSize: number; + totalFiles: number; + conflicts: Array<{ + path: string; + status: string; + }>; +} + +const formatBytes = (bytes: number): string => { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`; +}; + +const useTransferMutations = (serviceType: ServiceType) => { + const appScan = api.application.transferScan.useMutation(); + const appTransfer = api.application.transfer.useMutation(); + const composeScan = api.compose.transferScan.useMutation(); + const composeTransfer = api.compose.transfer.useMutation(); + const postgresScan = api.postgres.transferScan.useMutation(); + const postgresTransfer = api.postgres.transfer.useMutation(); + const mysqlScan = api.mysql.transferScan.useMutation(); + const mysqlTransfer = api.mysql.transfer.useMutation(); + const mariadbScan = api.mariadb.transferScan.useMutation(); + const mariadbTransfer = api.mariadb.transfer.useMutation(); + const mongoScan = api.mongo.transferScan.useMutation(); + const mongoTransfer = api.mongo.transfer.useMutation(); + const redisScan = api.redis.transferScan.useMutation(); + const redisTransfer = api.redis.transfer.useMutation(); + + const mutations: Record< + ServiceType, + { + scan: { mutateAsync: (input: any) => Promise; isPending: boolean }; + transfer: { + mutateAsync: (input: any) => Promise; + isPending: boolean; + }; + } + > = { + application: { scan: appScan, transfer: appTransfer }, + compose: { scan: composeScan, transfer: composeTransfer }, + postgres: { scan: postgresScan, transfer: postgresTransfer }, + mysql: { scan: mysqlScan, transfer: mysqlTransfer }, + mariadb: { scan: mariadbScan, transfer: mariadbTransfer }, + mongo: { scan: mongoScan, transfer: mongoTransfer }, + redis: { scan: redisScan, transfer: redisTransfer }, + }; + + return mutations[serviceType]; +}; + +const getServiceIdKey = (serviceType: ServiceType): string => { + const map: Record = { + application: "applicationId", + compose: "composeId", + postgres: "postgresId", + mysql: "mysqlId", + mariadb: "mariadbId", + mongo: "mongoId", + redis: "redisId", + }; + return map[serviceType]; +}; + +export const TransferService = ({ + serviceId, + serviceType, + currentServerId, +}: TransferServiceProps) => { + const [targetServerId, setTargetServerId] = useState(""); + const [scanResult, setScanResult] = useState(null); + const [step, setStep] = useState<"select" | "scan" | "confirm" | "transfer">( + "select", + ); + const [showConfirm, setShowConfirm] = useState(false); + const [transferLogs, setTransferLogs] = useState([]); + + const { data: servers } = api.server.all.useQuery(); + const utils = api.useUtils(); + const { scan, transfer } = useTransferMutations(serviceType); + + const idKey = getServiceIdKey(serviceType); + + const availableServers = servers?.filter( + (s) => s.serverId !== currentServerId, + ); + + const selectedServer = servers?.find((s) => s.serverId === targetServerId); + + const handleScan = async () => { + if (!targetServerId) { + toast.error("Please select a target server"); + return; + } + + setStep("scan"); + try { + const result = await scan.mutateAsync({ + [idKey]: serviceId, + targetServerId, + }); + setScanResult(result as ScanResult); + setStep("confirm"); + } catch (error) { + toast.error( + `Scan failed: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + setStep("select"); + } + }; + + const handleTransfer = async () => { + setShowConfirm(false); + setStep("transfer"); + setTransferLogs([]); + + try { + await transfer.mutateAsync({ + [idKey]: serviceId, + targetServerId, + decisions: {}, + }); + + toast.success("Transfer completed successfully!"); + setTransferLogs((prev) => [...prev, "Transfer completed successfully!"]); + + await utils.invalidate(); + + setTimeout(() => { + setStep("select"); + setScanResult(null); + setTargetServerId(""); + }, 3000); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown error"; + toast.error(`Transfer failed: ${message}`); + setTransferLogs((prev) => [...prev, `Transfer failed: ${message}`]); + setStep("confirm"); + } + }; + + const isDbService = ["postgres", "mysql", "mariadb", "mongo", "redis"].includes( + serviceType, + ); + + return ( + + + + + Transfer Service + + + Transfer this {serviceType} service to a different server. Source data + is never modified or deleted. + + + + {!availableServers?.length ? ( +
+ + + No other servers available. Add a remote server first. + +
+ ) : ( + <> + {/* Step 1: Select target server */} +
+ + +
+ + {/* Scan button */} + {step === "select" && targetServerId && ( + + )} + + {/* Step 2: Scan in progress */} + {step === "scan" && ( +
+ + + Scanning source and target servers for files and + conflicts... + +
+ )} + + {/* Step 3: Scan results + confirm */} + {step === "confirm" && scanResult && ( +
+
+

Scan Results

+
+
+ + Total Files: + {" "} + + {scanResult.totalFiles} + +
+
+ + Transfer Size: + {" "} + + {formatBytes(scanResult.totalTransferSize)} + +
+
+ + Volumes/Mounts: + {" "} + + {scanResult.mounts.length} + +
+
+ + Conflicts: + {" "} + 0 + ? "destructive" + : "secondary" + } + > + {scanResult.conflicts.length} + +
+
+ {scanResult.traefikConfig.exists && ( +
+ + Traefik Config: + {" "} + Will be synced +
+ )} +
+ + {/* Conflict list */} + {scanResult.conflicts.length > 0 && ( +
+

+ File Conflicts (will be overwritten) +

+
+ {scanResult.conflicts.map((conflict) => ( +
+ + {conflict.status} + + + {conflict.path} + +
+ ))} +
+
+ )} + + {/* Warning */} +
+
+ + + Service Downtime Warning + +
+

+ {isDbService + ? "Stop the database service before transferring to avoid data corruption. The service will be unavailable until deployed on the target server." + : "The service will be unavailable during transfer. After transfer completes, deploy the service on the target server to start it."} +

+
+ + {/* Transfer button */} +
+ + +
+
+ )} + + {/* Step 4: Transfer in progress */} + {step === "transfer" && ( +
+
+ + + Transferring service... + +
+ {transferLogs.length > 0 && ( +
+ {transferLogs.map((log, i) => ( +
+ {log} +
+ ))} +
+ )} +
+ )} + + )} + + {/* Confirmation dialog */} + + + + Confirm Service Transfer + +

+ You are about to transfer this {serviceType} to{" "} + {selectedServer?.name} ( + {selectedServer?.ipAddress}). +

+ {scanResult && ( +

+ {scanResult.totalFiles} files ( + {formatBytes(scanResult.totalTransferSize)}) will be + copied. +

+ )} +

+ The service will experience downtime during this + process. After transfer, you must deploy the service on + the target server. +

+
+
+ + Cancel + + Confirm Transfer + + +
+
+
+
+ ); +}; diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx index be4518c7b..591700d9e 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx @@ -18,6 +18,7 @@ import { ShowPorts } from "@/components/dashboard/application/advanced/ports/sho import { ShowRedirects } from "@/components/dashboard/application/advanced/redirects/show-redirects"; import { ShowSecurity } from "@/components/dashboard/application/advanced/security/show-security"; import { ShowBuildServer } from "@/components/dashboard/application/advanced/show-build-server"; +import { TransferService } from "@/components/dashboard/shared/transfer-service"; import { ShowResources } from "@/components/dashboard/application/advanced/show-resources"; import { ShowTraefikConfig } from "@/components/dashboard/application/advanced/traefik/show-traefik-config"; import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes"; @@ -419,6 +420,11 @@ const Service = ( + )} diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx index ba575706d..8261136d1 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx @@ -22,6 +22,7 @@ import { ShowSchedules } from "@/components/dashboard/application/schedules/show import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups"; import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command"; import { IsolatedDeploymentTab } from "@/components/dashboard/compose/advanced/add-isolation"; +import { TransferService } from "@/components/dashboard/shared/transfer-service"; import { ShowComposeContainers } from "@/components/dashboard/compose/containers/show-compose-containers"; import { DeleteService } from "@/components/dashboard/compose/delete-service"; import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show"; @@ -423,6 +424,11 @@ const Service = ( + )} diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mariadb/[mariadbId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mariadb/[mariadbId].tsx index b54804279..f43d4ff47 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mariadb/[mariadbId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mariadb/[mariadbId].tsx @@ -303,6 +303,7 @@ const Mariadb = ( diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mongo/[mongoId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mongo/[mongoId].tsx index 402718625..f5383c7f1 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mongo/[mongoId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mongo/[mongoId].tsx @@ -307,6 +307,7 @@ const Mongo = ( diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mysql/[mysqlId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mysql/[mysqlId].tsx index 55e1ee6e0..d3c0541b5 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mysql/[mysqlId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mysql/[mysqlId].tsx @@ -284,6 +284,7 @@ const MySql = ( diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/postgres/[postgresId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/postgres/[postgresId].tsx index f73ce35cb..b287b0d05 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/postgres/[postgresId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/postgres/[postgresId].tsx @@ -292,6 +292,7 @@ const Postgresql = ( diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/redis/[redisId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/redis/[redisId].tsx index 8847ed891..37fc252dd 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/redis/[redisId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/redis/[redisId].tsx @@ -296,6 +296,7 @@ const Redis = ( diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index 228c98656..34701d9a6 100644 --- a/apps/dokploy/server/api/routers/application.ts +++ b/apps/dokploy/server/api/routers/application.ts @@ -28,6 +28,8 @@ import { updateDeploymentStatus, writeConfig, writeConfigRemote, + scanServiceForTransfer, + executeTransfer, } from "@dokploy/server"; import { db } from "@dokploy/server/db"; import { @@ -62,6 +64,7 @@ import { apiSaveGithubProvider, apiSaveGitlabProvider, apiSaveGitProvider, + apiTransferApplication, apiUpdateApplication, applications, environments, @@ -1137,4 +1140,138 @@ export const applicationRouter = createTRPCRouter({ application.serverId, ); }), + + transferScan: protectedProcedure + .input(apiTransferApplication.pick({ applicationId: true, targetServerId: true })) + .mutation(async ({ input, ctx }) => { + const application = await findApplicationById(input.applicationId); + await checkServicePermissionAndAccess(ctx, input.applicationId, { + service: ["delete"], + }); + if ( + application.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this application", + }); + } + + return await scanServiceForTransfer({ + serviceId: input.applicationId, + serviceType: "application", + appName: application.appName, + sourceServerId: application.serverId, + targetServerId: input.targetServerId, + }); + }), + + transferWithLogs: protectedProcedure + .input(apiTransferApplication) + .subscription(async function* ({ input, ctx, signal }) { + const application = await findApplicationById(input.applicationId); + await checkServicePermissionAndAccess(ctx, input.applicationId, { + service: ["delete"], + }); + if ( + application.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this application", + }); + } + + const queue: string[] = []; + let done = false; + + executeTransfer( + { + serviceId: input.applicationId, + serviceType: "application", + appName: application.appName, + sourceServerId: application.serverId, + targetServerId: input.targetServerId, + }, + input.decisions || {}, + (progress) => { + queue.push(JSON.stringify(progress)); + }, + ) + .then(async (result) => { + if (result.success) { + await db + .update(applications) + .set({ serverId: input.targetServerId }) + .where(eq(applications.applicationId, input.applicationId)); + queue.push("Transfer completed successfully!"); + } else { + queue.push(`Transfer failed: ${result.errors.join(", ")}`); + } + }) + .catch((error) => { + queue.push( + `Transfer error: ${error instanceof Error ? error.message : String(error)}`, + ); + }) + .finally(() => { + done = true; + }); + + while (!done || queue.length > 0) { + if (queue.length > 0) { + yield queue.shift()!; + } else { + await new Promise((r) => setTimeout(r, 50)); + } + if (signal?.aborted) { + return; + } + } + }), + + transfer: protectedProcedure + .input(apiTransferApplication) + .mutation(async ({ input, ctx }) => { + const application = await findApplicationById(input.applicationId); + await checkServicePermissionAndAccess(ctx, input.applicationId, { + service: ["delete"], + }); + if ( + application.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this application", + }); + } + + const result = await executeTransfer( + { + serviceId: input.applicationId, + serviceType: "application", + appName: application.appName, + sourceServerId: application.serverId, + targetServerId: input.targetServerId, + }, + input.decisions || {}, + ); + + if (!result.success) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Transfer failed: ${result.errors.join(", ")}`, + }); + } + + await db + .update(applications) + .set({ serverId: input.targetServerId }) + .where(eq(applications.applicationId, input.applicationId)); + + return { success: true }; + }), }); diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index d395bdffc..1384a225f 100644 --- a/apps/dokploy/server/api/routers/compose.ts +++ b/apps/dokploy/server/api/routers/compose.ts @@ -32,6 +32,8 @@ import { stopCompose, updateCompose, updateDeploymentStatus, + scanServiceForTransfer, + executeTransfer, } from "@dokploy/server"; import { db } from "@dokploy/server/db"; import { @@ -63,6 +65,7 @@ import { apiRandomizeCompose, apiRedeployCompose, apiSaveEnvironmentVariablesCompose, + apiTransferCompose, apiUpdateCompose, compose as composeTable, environments, @@ -1171,4 +1174,138 @@ export const composeRouter = createTRPCRouter({ true, ); }), + + transferScan: protectedProcedure + .input(apiTransferCompose.pick({ composeId: true, targetServerId: true })) + .mutation(async ({ input, ctx }) => { + const compose = await findComposeById(input.composeId); + await checkServicePermissionAndAccess(ctx, input.composeId, { + service: ["delete"], + }); + if ( + compose.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this compose", + }); + } + + return await scanServiceForTransfer({ + serviceId: input.composeId, + serviceType: "compose", + appName: compose.appName, + sourceServerId: compose.serverId, + targetServerId: input.targetServerId, + }); + }), + + transferWithLogs: protectedProcedure + .input(apiTransferCompose) + .subscription(async function* ({ input, ctx, signal }) { + const compose = await findComposeById(input.composeId); + await checkServicePermissionAndAccess(ctx, input.composeId, { + service: ["delete"], + }); + if ( + compose.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this compose", + }); + } + + const queue: string[] = []; + let done = false; + + executeTransfer( + { + serviceId: input.composeId, + serviceType: "compose", + appName: compose.appName, + sourceServerId: compose.serverId, + targetServerId: input.targetServerId, + }, + input.decisions || {}, + (progress) => { + queue.push(JSON.stringify(progress)); + }, + ) + .then(async (result) => { + if (result.success) { + await db + .update(composeTable) + .set({ serverId: input.targetServerId }) + .where(eq(composeTable.composeId, input.composeId)); + queue.push("Transfer completed successfully!"); + } else { + queue.push(`Transfer failed: ${result.errors.join(", ")}`); + } + }) + .catch((error) => { + queue.push( + `Transfer error: ${error instanceof Error ? error.message : String(error)}`, + ); + }) + .finally(() => { + done = true; + }); + + while (!done || queue.length > 0) { + if (queue.length > 0) { + yield queue.shift()!; + } else { + await new Promise((r) => setTimeout(r, 50)); + } + if (signal?.aborted) { + return; + } + } + }), + + transfer: protectedProcedure + .input(apiTransferCompose) + .mutation(async ({ input, ctx }) => { + const compose = await findComposeById(input.composeId); + await checkServicePermissionAndAccess(ctx, input.composeId, { + service: ["delete"], + }); + if ( + compose.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this compose", + }); + } + + const result = await executeTransfer( + { + serviceId: input.composeId, + serviceType: "compose", + appName: compose.appName, + sourceServerId: compose.serverId, + targetServerId: input.targetServerId, + }, + input.decisions || {}, + ); + + if (!result.success) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Transfer failed: ${result.errors.join(", ")}`, + }); + } + + await db + .update(composeTable) + .set({ serverId: input.targetServerId }) + .where(eq(composeTable.composeId, input.composeId)); + + return { success: true }; + }), }); diff --git a/apps/dokploy/server/api/routers/mariadb.ts b/apps/dokploy/server/api/routers/mariadb.ts index a58a33a9c..e0d3a2b80 100644 --- a/apps/dokploy/server/api/routers/mariadb.ts +++ b/apps/dokploy/server/api/routers/mariadb.ts @@ -21,6 +21,8 @@ import { stopService, stopServiceRemote, updateMariadbById, + scanServiceForTransfer, + executeTransfer, } from "@dokploy/server"; import { db } from "@dokploy/server/db"; import { @@ -44,6 +46,7 @@ import { apiResetMariadb, apiSaveEnvironmentVariablesMariaDB, apiSaveExternalPortMariaDB, + apiTransferMariadb, apiUpdateMariaDB, DATABASE_PASSWORD_MESSAGE, DATABASE_PASSWORD_REGEX, @@ -626,4 +629,120 @@ export const mariadbRouter = createTRPCRouter({ mariadb.serverId, ); }), + + transferScan: protectedProcedure + .input(apiTransferMariadb.pick({ mariadbId: true, targetServerId: true })) + .mutation(async ({ input, ctx }) => { + const mariadb = await findMariadbById(input.mariadbId); + await checkServicePermissionAndAccess(ctx, input.mariadbId, { + service: ["delete"], + }); + if ( + mariadb.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this MariaDB", + }); + } + return await scanServiceForTransfer({ + serviceId: input.mariadbId, + serviceType: "mariadb", + appName: mariadb.appName, + sourceServerId: mariadb.serverId, + targetServerId: input.targetServerId, + }); + }), + + transferWithLogs: protectedProcedure + .input(apiTransferMariadb) + .subscription(async function* ({ input, ctx, signal }) { + const mariadb = await findMariadbById(input.mariadbId); + await checkServicePermissionAndAccess(ctx, input.mariadbId, { + service: ["delete"], + }); + if ( + mariadb.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this MariaDB", + }); + } + const queue: string[] = []; + let done = false; + executeTransfer( + { + serviceId: input.mariadbId, + serviceType: "mariadb", + appName: mariadb.appName, + sourceServerId: mariadb.serverId, + targetServerId: input.targetServerId, + }, + input.decisions || {}, + (progress) => { queue.push(JSON.stringify(progress)); }, + ) + .then(async (result) => { + if (result.success) { + await db + .update(mariadbTable) + .set({ serverId: input.targetServerId }) + .where(eq(mariadbTable.mariadbId, input.mariadbId)); + queue.push("Transfer completed successfully!"); + } else { + queue.push(`Transfer failed: ${result.errors.join(", ")}`); + } + }) + .catch((error) => { + queue.push(`Transfer error: ${error instanceof Error ? error.message : String(error)}`); + }) + .finally(() => { done = true; }); + + while (!done || queue.length > 0) { + if (queue.length > 0) { yield queue.shift()!; } + else { await new Promise((r) => setTimeout(r, 50)); } + if (signal?.aborted) { return; } + } + }), + + transfer: protectedProcedure + .input(apiTransferMariadb) + .mutation(async ({ input, ctx }) => { + const mariadb = await findMariadbById(input.mariadbId); + await checkServicePermissionAndAccess(ctx, input.mariadbId, { + service: ["delete"], + }); + if ( + mariadb.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this MariaDB", + }); + } + const result = await executeTransfer( + { + serviceId: input.mariadbId, + serviceType: "mariadb", + appName: mariadb.appName, + sourceServerId: mariadb.serverId, + targetServerId: input.targetServerId, + }, + input.decisions || {}, + ); + if (!result.success) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Transfer failed: ${result.errors.join(", ")}`, + }); + } + await db + .update(mariadbTable) + .set({ serverId: input.targetServerId }) + .where(eq(mariadbTable.mariadbId, input.mariadbId)); + return { success: true }; + }), }); diff --git a/apps/dokploy/server/api/routers/mongo.ts b/apps/dokploy/server/api/routers/mongo.ts index 222917b9f..11cc115f3 100644 --- a/apps/dokploy/server/api/routers/mongo.ts +++ b/apps/dokploy/server/api/routers/mongo.ts @@ -21,6 +21,8 @@ import { stopService, stopServiceRemote, updateMongoById, + scanServiceForTransfer, + executeTransfer, } from "@dokploy/server"; import { db } from "@dokploy/server/db"; import { @@ -43,6 +45,7 @@ import { apiResetMongo, apiSaveEnvironmentVariablesMongo, apiSaveExternalPortMongo, + apiTransferMongo, apiUpdateMongo, DATABASE_PASSWORD_MESSAGE, DATABASE_PASSWORD_REGEX, @@ -637,4 +640,120 @@ export const mongoRouter = createTRPCRouter({ mongo.serverId, ); }), + + transferScan: protectedProcedure + .input(apiTransferMongo.pick({ mongoId: true, targetServerId: true })) + .mutation(async ({ input, ctx }) => { + const mongo = await findMongoById(input.mongoId); + await checkServicePermissionAndAccess(ctx, input.mongoId, { + service: ["delete"], + }); + if ( + mongo.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this MongoDB", + }); + } + return await scanServiceForTransfer({ + serviceId: input.mongoId, + serviceType: "mongo", + appName: mongo.appName, + sourceServerId: mongo.serverId, + targetServerId: input.targetServerId, + }); + }), + + transferWithLogs: protectedProcedure + .input(apiTransferMongo) + .subscription(async function* ({ input, ctx, signal }) { + const mongo = await findMongoById(input.mongoId); + await checkServicePermissionAndAccess(ctx, input.mongoId, { + service: ["delete"], + }); + if ( + mongo.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this MongoDB", + }); + } + const queue: string[] = []; + let done = false; + executeTransfer( + { + serviceId: input.mongoId, + serviceType: "mongo", + appName: mongo.appName, + sourceServerId: mongo.serverId, + targetServerId: input.targetServerId, + }, + input.decisions || {}, + (progress) => { queue.push(JSON.stringify(progress)); }, + ) + .then(async (result) => { + if (result.success) { + await db + .update(mongoTable) + .set({ serverId: input.targetServerId }) + .where(eq(mongoTable.mongoId, input.mongoId)); + queue.push("Transfer completed successfully!"); + } else { + queue.push(`Transfer failed: ${result.errors.join(", ")}`); + } + }) + .catch((error) => { + queue.push(`Transfer error: ${error instanceof Error ? error.message : String(error)}`); + }) + .finally(() => { done = true; }); + + while (!done || queue.length > 0) { + if (queue.length > 0) { yield queue.shift()!; } + else { await new Promise((r) => setTimeout(r, 50)); } + if (signal?.aborted) { return; } + } + }), + + transfer: protectedProcedure + .input(apiTransferMongo) + .mutation(async ({ input, ctx }) => { + const mongo = await findMongoById(input.mongoId); + await checkServicePermissionAndAccess(ctx, input.mongoId, { + service: ["delete"], + }); + if ( + mongo.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this MongoDB", + }); + } + const result = await executeTransfer( + { + serviceId: input.mongoId, + serviceType: "mongo", + appName: mongo.appName, + sourceServerId: mongo.serverId, + targetServerId: input.targetServerId, + }, + input.decisions || {}, + ); + if (!result.success) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Transfer failed: ${result.errors.join(", ")}`, + }); + } + await db + .update(mongoTable) + .set({ serverId: input.targetServerId }) + .where(eq(mongoTable.mongoId, input.mongoId)); + return { success: true }; + }), }); diff --git a/apps/dokploy/server/api/routers/mysql.ts b/apps/dokploy/server/api/routers/mysql.ts index 263fa53f0..40b519f56 100644 --- a/apps/dokploy/server/api/routers/mysql.ts +++ b/apps/dokploy/server/api/routers/mysql.ts @@ -21,6 +21,8 @@ import { stopServiceRemote, updateMySqlById, getAccessibleServerIds, + scanServiceForTransfer, + executeTransfer, } from "@dokploy/server"; import { db } from "@dokploy/server/db"; import { @@ -43,6 +45,7 @@ import { apiResetMysql, apiSaveEnvironmentVariablesMySql, apiSaveExternalPortMySql, + apiTransferMysql, apiUpdateMySql, DATABASE_PASSWORD_MESSAGE, DATABASE_PASSWORD_REGEX, @@ -640,4 +643,120 @@ export const mysqlRouter = createTRPCRouter({ mysql.serverId, ); }), + + transferScan: protectedProcedure + .input(apiTransferMysql.pick({ mysqlId: true, targetServerId: true })) + .mutation(async ({ input, ctx }) => { + const mysql = await findMySqlById(input.mysqlId); + await checkServicePermissionAndAccess(ctx, input.mysqlId, { + service: ["delete"], + }); + if ( + mysql.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this MySQL", + }); + } + return await scanServiceForTransfer({ + serviceId: input.mysqlId, + serviceType: "mysql", + appName: mysql.appName, + sourceServerId: mysql.serverId, + targetServerId: input.targetServerId, + }); + }), + + transferWithLogs: protectedProcedure + .input(apiTransferMysql) + .subscription(async function* ({ input, ctx, signal }) { + const mysql = await findMySqlById(input.mysqlId); + await checkServicePermissionAndAccess(ctx, input.mysqlId, { + service: ["delete"], + }); + if ( + mysql.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this MySQL", + }); + } + const queue: string[] = []; + let done = false; + executeTransfer( + { + serviceId: input.mysqlId, + serviceType: "mysql", + appName: mysql.appName, + sourceServerId: mysql.serverId, + targetServerId: input.targetServerId, + }, + input.decisions || {}, + (progress) => { queue.push(JSON.stringify(progress)); }, + ) + .then(async (result) => { + if (result.success) { + await db + .update(mysqlTable) + .set({ serverId: input.targetServerId }) + .where(eq(mysqlTable.mysqlId, input.mysqlId)); + queue.push("Transfer completed successfully!"); + } else { + queue.push(`Transfer failed: ${result.errors.join(", ")}`); + } + }) + .catch((error) => { + queue.push(`Transfer error: ${error instanceof Error ? error.message : String(error)}`); + }) + .finally(() => { done = true; }); + + while (!done || queue.length > 0) { + if (queue.length > 0) { yield queue.shift()!; } + else { await new Promise((r) => setTimeout(r, 50)); } + if (signal?.aborted) { return; } + } + }), + + transfer: protectedProcedure + .input(apiTransferMysql) + .mutation(async ({ input, ctx }) => { + const mysql = await findMySqlById(input.mysqlId); + await checkServicePermissionAndAccess(ctx, input.mysqlId, { + service: ["delete"], + }); + if ( + mysql.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this MySQL", + }); + } + const result = await executeTransfer( + { + serviceId: input.mysqlId, + serviceType: "mysql", + appName: mysql.appName, + sourceServerId: mysql.serverId, + targetServerId: input.targetServerId, + }, + input.decisions || {}, + ); + if (!result.success) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Transfer failed: ${result.errors.join(", ")}`, + }); + } + await db + .update(mysqlTable) + .set({ serverId: input.targetServerId }) + .where(eq(mysqlTable.mysqlId, input.mysqlId)); + return { success: true }; + }), }); diff --git a/apps/dokploy/server/api/routers/postgres.ts b/apps/dokploy/server/api/routers/postgres.ts index 33d8fd3f4..d7cf295dd 100644 --- a/apps/dokploy/server/api/routers/postgres.ts +++ b/apps/dokploy/server/api/routers/postgres.ts @@ -22,6 +22,8 @@ import { stopServiceRemote, updatePostgresById, getAccessibleServerIds, + scanServiceForTransfer, + executeTransfer, } from "@dokploy/server"; import { db } from "@dokploy/server/db"; import { @@ -44,6 +46,7 @@ import { apiResetPostgres, apiSaveEnvironmentVariablesPostgres, apiSaveExternalPortPostgres, + apiTransferPostgres, apiUpdatePostgres, DATABASE_PASSWORD_MESSAGE, DATABASE_PASSWORD_REGEX, @@ -650,4 +653,120 @@ export const postgresRouter = createTRPCRouter({ postgres.serverId, ); }), + + transferScan: protectedProcedure + .input(apiTransferPostgres.pick({ postgresId: true, targetServerId: true })) + .mutation(async ({ input, ctx }) => { + const postgres = await findPostgresById(input.postgresId); + await checkServicePermissionAndAccess(ctx, input.postgresId, { + service: ["delete"], + }); + if ( + postgres.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this Postgres", + }); + } + return await scanServiceForTransfer({ + serviceId: input.postgresId, + serviceType: "postgres", + appName: postgres.appName, + sourceServerId: postgres.serverId, + targetServerId: input.targetServerId, + }); + }), + + transferWithLogs: protectedProcedure + .input(apiTransferPostgres) + .subscription(async function* ({ input, ctx, signal }) { + const postgres = await findPostgresById(input.postgresId); + await checkServicePermissionAndAccess(ctx, input.postgresId, { + service: ["delete"], + }); + if ( + postgres.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this Postgres", + }); + } + const queue: string[] = []; + let done = false; + executeTransfer( + { + serviceId: input.postgresId, + serviceType: "postgres", + appName: postgres.appName, + sourceServerId: postgres.serverId, + targetServerId: input.targetServerId, + }, + input.decisions || {}, + (progress) => { queue.push(JSON.stringify(progress)); }, + ) + .then(async (result) => { + if (result.success) { + await db + .update(postgresTable) + .set({ serverId: input.targetServerId }) + .where(eq(postgresTable.postgresId, input.postgresId)); + queue.push("Transfer completed successfully!"); + } else { + queue.push(`Transfer failed: ${result.errors.join(", ")}`); + } + }) + .catch((error) => { + queue.push(`Transfer error: ${error instanceof Error ? error.message : String(error)}`); + }) + .finally(() => { done = true; }); + + while (!done || queue.length > 0) { + if (queue.length > 0) { yield queue.shift()!; } + else { await new Promise((r) => setTimeout(r, 50)); } + if (signal?.aborted) { return; } + } + }), + + transfer: protectedProcedure + .input(apiTransferPostgres) + .mutation(async ({ input, ctx }) => { + const postgres = await findPostgresById(input.postgresId); + await checkServicePermissionAndAccess(ctx, input.postgresId, { + service: ["delete"], + }); + if ( + postgres.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this Postgres", + }); + } + const result = await executeTransfer( + { + serviceId: input.postgresId, + serviceType: "postgres", + appName: postgres.appName, + sourceServerId: postgres.serverId, + targetServerId: input.targetServerId, + }, + input.decisions || {}, + ); + if (!result.success) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Transfer failed: ${result.errors.join(", ")}`, + }); + } + await db + .update(postgresTable) + .set({ serverId: input.targetServerId }) + .where(eq(postgresTable.postgresId, input.postgresId)); + return { success: true }; + }), }); diff --git a/apps/dokploy/server/api/routers/redis.ts b/apps/dokploy/server/api/routers/redis.ts index a1e912e0b..0635a8225 100644 --- a/apps/dokploy/server/api/routers/redis.ts +++ b/apps/dokploy/server/api/routers/redis.ts @@ -20,6 +20,8 @@ import { stopServiceRemote, updateRedisById, getAccessibleServerIds, + scanServiceForTransfer, + executeTransfer, } from "@dokploy/server"; import { db } from "@dokploy/server/db"; import { @@ -42,6 +44,7 @@ import { apiResetRedis, apiSaveEnvironmentVariablesRedis, apiSaveExternalPortRedis, + apiTransferRedis, apiUpdateRedis, DATABASE_PASSWORD_MESSAGE, DATABASE_PASSWORD_REGEX, @@ -623,4 +626,120 @@ export const redisRouter = createTRPCRouter({ redis.serverId, ); }), + + transferScan: protectedProcedure + .input(apiTransferRedis.pick({ redisId: true, targetServerId: true })) + .mutation(async ({ input, ctx }) => { + const redis = await findRedisById(input.redisId); + await checkServicePermissionAndAccess(ctx, input.redisId, { + service: ["delete"], + }); + if ( + redis.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this Redis", + }); + } + return await scanServiceForTransfer({ + serviceId: input.redisId, + serviceType: "redis", + appName: redis.appName, + sourceServerId: redis.serverId, + targetServerId: input.targetServerId, + }); + }), + + transferWithLogs: protectedProcedure + .input(apiTransferRedis) + .subscription(async function* ({ input, ctx, signal }) { + const redis = await findRedisById(input.redisId); + await checkServicePermissionAndAccess(ctx, input.redisId, { + service: ["delete"], + }); + if ( + redis.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this Redis", + }); + } + const queue: string[] = []; + let done = false; + executeTransfer( + { + serviceId: input.redisId, + serviceType: "redis", + appName: redis.appName, + sourceServerId: redis.serverId, + targetServerId: input.targetServerId, + }, + input.decisions || {}, + (progress) => { queue.push(JSON.stringify(progress)); }, + ) + .then(async (result) => { + if (result.success) { + await db + .update(redisTable) + .set({ serverId: input.targetServerId }) + .where(eq(redisTable.redisId, input.redisId)); + queue.push("Transfer completed successfully!"); + } else { + queue.push(`Transfer failed: ${result.errors.join(", ")}`); + } + }) + .catch((error) => { + queue.push(`Transfer error: ${error instanceof Error ? error.message : String(error)}`); + }) + .finally(() => { done = true; }); + + while (!done || queue.length > 0) { + if (queue.length > 0) { yield queue.shift()!; } + else { await new Promise((r) => setTimeout(r, 50)); } + if (signal?.aborted) { return; } + } + }), + + transfer: protectedProcedure + .input(apiTransferRedis) + .mutation(async ({ input, ctx }) => { + const redis = await findRedisById(input.redisId); + await checkServicePermissionAndAccess(ctx, input.redisId, { + service: ["delete"], + }); + if ( + redis.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this Redis", + }); + } + const result = await executeTransfer( + { + serviceId: input.redisId, + serviceType: "redis", + appName: redis.appName, + sourceServerId: redis.serverId, + targetServerId: input.targetServerId, + }, + input.decisions || {}, + ); + if (!result.success) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Transfer failed: ${result.errors.join(", ")}`, + }); + } + await db + .update(redisTable) + .set({ serverId: input.targetServerId }) + .where(eq(redisTable.redisId, input.redisId)); + return { success: true }; + }), }); diff --git a/packages/server/src/db/schema/application.ts b/packages/server/src/db/schema/application.ts index a7067f63f..0eeae0fcb 100644 --- a/packages/server/src/db/schema/application.ts +++ b/packages/server/src/db/schema/application.ts @@ -534,3 +534,9 @@ export const apiUpdateApplication = createSchema applicationId: z.string().min(1), }) .omit({ serverId: true }); + +export const apiTransferApplication = z.object({ + applicationId: z.string().min(1), + targetServerId: z.string().min(1), + decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(), +}); diff --git a/packages/server/src/db/schema/compose.ts b/packages/server/src/db/schema/compose.ts index 7803cb0a7..34ae31bb6 100644 --- a/packages/server/src/db/schema/compose.ts +++ b/packages/server/src/db/schema/compose.ts @@ -240,3 +240,9 @@ export const apiRandomizeCompose = createSchema suffix: z.string().optional(), composeId: z.string().min(1), }); + +export const apiTransferCompose = z.object({ + composeId: z.string().min(1), + targetServerId: z.string().min(1), + decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(), +}); diff --git a/packages/server/src/db/schema/mariadb.ts b/packages/server/src/db/schema/mariadb.ts index ce3f3e2d2..666d432a8 100644 --- a/packages/server/src/db/schema/mariadb.ts +++ b/packages/server/src/db/schema/mariadb.ts @@ -213,3 +213,9 @@ export const apiRebuildMariadb = createSchema mariadbId: true, }) .required(); + +export const apiTransferMariadb = z.object({ + mariadbId: z.string().min(1), + targetServerId: z.string().min(1), + decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(), +}); diff --git a/packages/server/src/db/schema/mongo.ts b/packages/server/src/db/schema/mongo.ts index a5910f197..82a57caa9 100644 --- a/packages/server/src/db/schema/mongo.ts +++ b/packages/server/src/db/schema/mongo.ts @@ -210,3 +210,9 @@ export const apiRebuildMongo = createSchema mongoId: true, }) .required(); + +export const apiTransferMongo = z.object({ + mongoId: z.string().min(1), + targetServerId: z.string().min(1), + decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(), +}); diff --git a/packages/server/src/db/schema/mysql.ts b/packages/server/src/db/schema/mysql.ts index a9a5d072f..259d8046e 100644 --- a/packages/server/src/db/schema/mysql.ts +++ b/packages/server/src/db/schema/mysql.ts @@ -210,3 +210,9 @@ export const apiRebuildMysql = createSchema mysqlId: true, }) .required(); + +export const apiTransferMysql = z.object({ + mysqlId: z.string().min(1), + targetServerId: z.string().min(1), + decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(), +}); diff --git a/packages/server/src/db/schema/postgres.ts b/packages/server/src/db/schema/postgres.ts index 954b15d4a..2b997eb24 100644 --- a/packages/server/src/db/schema/postgres.ts +++ b/packages/server/src/db/schema/postgres.ts @@ -204,3 +204,9 @@ export const apiRebuildPostgres = createSchema postgresId: true, }) .required(); + +export const apiTransferPostgres = z.object({ + postgresId: z.string().min(1), + targetServerId: z.string().min(1), + decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(), +}); diff --git a/packages/server/src/db/schema/redis.ts b/packages/server/src/db/schema/redis.ts index c07074d0c..0a8926938 100644 --- a/packages/server/src/db/schema/redis.ts +++ b/packages/server/src/db/schema/redis.ts @@ -187,3 +187,9 @@ export const apiRebuildRedis = createSchema redisId: true, }) .required(); + +export const apiTransferRedis = z.object({ + redisId: z.string().min(1), + targetServerId: z.string().min(1), + decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(), +}); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index e6fd0ba59..fcc3f80a3 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -47,6 +47,7 @@ export * from "./services/server"; export * from "./services/settings"; export * from "./services/ssh-key"; export * from "./services/user"; +export * from "./services/transfer"; export * from "./services/volume-backups"; export * from "./services/web-server-settings"; export * from "./setup/config-paths"; @@ -131,6 +132,7 @@ export * from "./utils/traefik/redirect"; export * from "./utils/traefik/security"; export * from "./utils/traefik/types"; export * from "./utils/traefik/web-server"; +export * from "./utils/transfer/index"; export * from "./utils/volume-backups/index"; export * from "./utils/watch-paths/should-deploy"; export * from "./wss/utils"; diff --git a/packages/server/src/services/transfer.ts b/packages/server/src/services/transfer.ts new file mode 100644 index 000000000..2a0ae03b9 --- /dev/null +++ b/packages/server/src/services/transfer.ts @@ -0,0 +1,396 @@ +import { paths } from "@dokploy/server/constants"; +import path from "node:path"; +import { findMountsByApplicationId } from "./mount"; +import { + compareFileLists, + scanDirectory, + scanMount, +} from "../utils/transfer/scanner"; +import { runPreflightChecks } from "../utils/transfer/preflight"; +import { + syncDirectory, + syncDockerVolume, + syncMount, + syncTraefikConfig, +} from "../utils/transfer/sync"; +import type { + ConflictDecision, + MountTransferConfig, + ServiceType, + TransferOptions, + TransferProgress, + TransferResult, + TransferScanResult, +} from "../utils/transfer/types"; + +const getServiceBasePath = ( + serviceType: ServiceType, + appName: string, + isRemote: boolean, +): string => { + if (serviceType === "compose") { + const { COMPOSE_PATH } = paths(isRemote); + return path.join(COMPOSE_PATH, appName); + } + const { APPLICATIONS_PATH } = paths(isRemote); + return path.join(APPLICATIONS_PATH, appName); +}; + +const hasServiceDirectory = (serviceType: ServiceType): boolean => { + return serviceType === "application" || serviceType === "compose"; +}; + +const getAutoDataVolumeName = ( + serviceType: ServiceType, + appName: string, +): string | null => { + const dbTypes: ServiceType[] = [ + "postgres", + "mysql", + "mariadb", + "mongo", + "redis", + ]; + if (dbTypes.includes(serviceType)) { + return `${appName}-data`; + } + return null; +}; + +export const scanServiceForTransfer = async ( + opts: TransferOptions, +): Promise => { + const { serviceType, appName, sourceServerId, targetServerId } = opts; + + const result: TransferScanResult = { + serviceDirectory: { files: [], totalSize: 0 }, + traefikConfig: { exists: false, hasConflict: false }, + mounts: [], + totalTransferSize: 0, + totalFiles: 0, + conflicts: [], + }; + + // 1. Scan service directory (application/compose only) + if (hasServiceDirectory(serviceType)) { + const sourcePath = getServiceBasePath( + serviceType, + appName, + !!sourceServerId, + ); + const targetPath = getServiceBasePath(serviceType, appName, true); + + try { + const sourceFiles = await scanDirectory(sourceServerId, sourcePath); + const targetFiles = await scanDirectory(targetServerId, targetPath); + + const fileConflicts = await compareFileLists( + sourceFiles, + targetFiles, + sourceServerId, + targetServerId, + sourcePath, + ); + + result.serviceDirectory = { + files: fileConflicts, + totalSize: sourceFiles.reduce((sum, f) => sum + f.size, 0), + }; + } catch { + // Directory may not exist yet, that's ok + } + } + + // 2. Check Traefik config + if (serviceType === "application" || serviceType === "compose") { + const configPath = "/etc/dokploy/traefik/dynamic"; + const configFile = `${configPath}/${appName}.yml`; + + try { + const sourceFiles = await scanDirectory(sourceServerId, configPath); + const sourceConfig = sourceFiles.find( + (f) => f.path === `${appName}.yml`, + ); + if (sourceConfig) { + result.traefikConfig.exists = true; + const targetFiles = await scanDirectory(targetServerId, configPath); + const targetConfig = targetFiles.find( + (f) => f.path === `${appName}.yml`, + ); + if (targetConfig) { + result.traefikConfig.hasConflict = true; + } + } + } catch { + // Config may not exist + } + } + + // 3. Scan auto data volume for databases + const autoVolume = getAutoDataVolumeName(serviceType, appName); + if (autoVolume) { + try { + const sourceFiles = await scanMount(sourceServerId, { + mountId: "auto", + type: "volume", + volumeName: autoVolume, + mountPath: "/data", + }); + const targetFiles = await scanMount(targetServerId, { + mountId: "auto", + type: "volume", + volumeName: autoVolume, + mountPath: "/data", + }); + + const fileConflicts = await compareFileLists( + sourceFiles, + targetFiles, + sourceServerId, + targetServerId, + undefined, + autoVolume, + ); + + result.mounts.push({ + mount: { + mountId: "auto", + type: "volume", + volumeName: autoVolume, + mountPath: "/data", + }, + files: fileConflicts, + totalSize: sourceFiles.reduce((sum, f) => sum + f.size, 0), + }); + } catch { + // Volume may not exist + } + } + + // 4. Scan user-defined mounts + const serviceTypeForMount = serviceType as + | "application" + | "postgres" + | "mysql" + | "mariadb" + | "mongo" + | "redis" + | "compose"; + try { + const userMounts = await findMountsByApplicationId( + opts.serviceId, + serviceTypeForMount, + ); + + for (const mount of userMounts) { + const mountConfig: MountTransferConfig = { + mountId: mount.mountId, + type: mount.type, + hostPath: mount.hostPath, + volumeName: mount.volumeName, + mountPath: mount.mountPath, + content: mount.content, + filePath: mount.filePath, + }; + + if (mount.type === "file") continue; // File mounts are DB-stored + + try { + const sourceFiles = await scanMount(sourceServerId, mountConfig); + const targetFiles = await scanMount(targetServerId, mountConfig); + + const fileConflicts = await compareFileLists( + sourceFiles, + targetFiles, + sourceServerId, + targetServerId, + mount.type === "bind" ? mount.hostPath || undefined : undefined, + mount.type === "volume" ? mount.volumeName || undefined : undefined, + ); + + result.mounts.push({ + mount: mountConfig, + files: fileConflicts, + totalSize: sourceFiles.reduce((sum, f) => sum + f.size, 0), + }); + } catch { + // Individual mount scan failure shouldn't stop entire scan + } + } + } catch { + // No mounts found + } + + // Calculate totals + result.totalTransferSize = + result.serviceDirectory.totalSize + + result.mounts.reduce((sum, m) => sum + m.totalSize, 0); + + result.totalFiles = + result.serviceDirectory.files.length + + result.mounts.reduce((sum, m) => sum + m.files.length, 0); + + result.conflicts = [ + ...result.serviceDirectory.files, + ...result.mounts.flatMap((m) => m.files), + ].filter( + (f) => + f.status !== "match" && + f.status !== "missing_target", + ); + + return result; +}; + +export const executeTransfer = async ( + opts: TransferOptions, + decisions: Record, + onProgress?: (progress: TransferProgress) => void, +): Promise => { + const { serviceType, appName, sourceServerId, targetServerId } = opts; + const errors: string[] = []; + let processedFiles = 0; + let transferredBytes = 0; + + const scan = await scanServiceForTransfer(opts); + const totalFiles = scan.totalFiles; + const totalBytes = scan.totalTransferSize; + + const reportProgress = ( + phase: TransferProgress["phase"], + message?: string, + currentFile?: string, + ) => { + if (processedFiles > 0) { + const percentage = totalFiles > 0 ? Math.round((processedFiles / totalFiles) * 100) : 0; + onProgress?.({ + phase, + currentFile, + processedFiles, + totalFiles, + transferredBytes, + totalBytes, + percentage, + message, + }); + } else { + onProgress?.({ + phase, + currentFile, + processedFiles, + totalFiles, + transferredBytes, + totalBytes, + percentage: 0, + message, + }); + } + }; + + try { + // Phase 1: Preflight checks + reportProgress("preparing", "Running preflight checks..."); + + const mountConfigs: MountTransferConfig[] = scan.mounts.map( + (m) => m.mount, + ); + const targetBasePath = getServiceBasePath(serviceType, appName, true); + + const preflight = await runPreflightChecks( + targetServerId, + targetBasePath, + totalBytes, + mountConfigs, + (msg) => reportProgress("preparing", msg), + ); + + if (!preflight.passed) { + return { success: false, errors: preflight.errors }; + } + + // Phase 2: Sync service directory + if (hasServiceDirectory(serviceType)) { + reportProgress("syncing_directory", "Syncing service directory..."); + + const sourcePath = getServiceBasePath( + serviceType, + appName, + !!sourceServerId, + ); + + try { + await syncDirectory( + sourceServerId, + targetServerId, + sourcePath, + targetBasePath, + (msg) => reportProgress("syncing_directory", msg), + ); + processedFiles += scan.serviceDirectory.files.length; + transferredBytes += scan.serviceDirectory.totalSize; + reportProgress("syncing_directory", "Service directory synced"); + } catch (error) { + errors.push( + `Failed to sync service directory: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + // Phase 3: Sync Traefik config + if (scan.traefikConfig.exists) { + reportProgress("syncing_traefik", "Syncing Traefik configuration..."); + try { + await syncTraefikConfig( + sourceServerId, + targetServerId, + appName, + (msg) => reportProgress("syncing_traefik", msg), + ); + reportProgress("syncing_traefik", "Traefik config synced"); + } catch (error) { + errors.push( + `Failed to sync Traefik config: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + // Phase 4: Sync mounts + reportProgress("syncing_mounts", "Syncing mounts and volumes..."); + for (const mountScan of scan.mounts) { + const mountLabel = + mountScan.mount.volumeName || + mountScan.mount.hostPath || + mountScan.mount.mountPath; + reportProgress("syncing_mounts", `Syncing: ${mountLabel}`, mountLabel); + + try { + await syncMount( + sourceServerId, + targetServerId, + mountScan.mount, + decisions, + (msg) => reportProgress("syncing_mounts", msg), + ); + processedFiles += mountScan.files.length; + transferredBytes += mountScan.totalSize; + reportProgress("syncing_mounts", `Completed: ${mountLabel}`); + } catch (error) { + errors.push( + `Failed to sync mount ${mountLabel}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + if (errors.length > 0) { + reportProgress("failed", `Transfer completed with errors: ${errors.join(", ")}`); + return { success: false, errors }; + } + + reportProgress("completed", "Transfer completed successfully!"); + return { success: true, errors: [] }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + reportProgress("failed", `Transfer failed: ${message}`); + return { success: false, errors: [message] }; + } +}; diff --git a/packages/server/src/utils/transfer/index.ts b/packages/server/src/utils/transfer/index.ts new file mode 100644 index 000000000..d560bd05d --- /dev/null +++ b/packages/server/src/utils/transfer/index.ts @@ -0,0 +1,4 @@ +export * from "./types"; +export * from "./scanner"; +export * from "./sync"; +export * from "./preflight"; diff --git a/packages/server/src/utils/transfer/preflight.ts b/packages/server/src/utils/transfer/preflight.ts new file mode 100644 index 000000000..0c6156e3a --- /dev/null +++ b/packages/server/src/utils/transfer/preflight.ts @@ -0,0 +1,100 @@ +import { execAsync, execAsyncRemote } from "../process/execAsync"; +import type { MountTransferConfig } from "./types"; + +const execOnServer = async ( + serverId: string | null, + command: string, +): Promise<{ stdout: string; stderr: string }> => { + if (serverId) { + return execAsyncRemote(serverId, command); + } + return execAsync(command); +}; + +export const ensureDirectoryExists = async ( + serverId: string | null, + dirPath: string, +): Promise => { + await execOnServer(serverId, `mkdir -p "${dirPath}"`); +}; + +export const ensureVolumeExists = async ( + serverId: string | null, + volumeName: string, +): Promise => { + await execOnServer( + serverId, + `docker volume inspect ${volumeName} > /dev/null 2>&1 || docker volume create ${volumeName}`, + ); +}; + +export const checkDiskSpace = async ( + serverId: string | null, + path: string, +): Promise => { + const { stdout } = await execOnServer( + serverId, + `df -B1 "${path}" | tail -1 | awk '{print $4}'`, + ); + return Number.parseInt(stdout.trim(), 10); +}; + +export const runPreflightChecks = async ( + targetServerId: string, + targetBasePath: string, + requiredBytes: number, + mounts: MountTransferConfig[], + onLog?: (message: string) => void, +): Promise<{ passed: boolean; errors: string[] }> => { + const errors: string[] = []; + + onLog?.("Checking disk space on target server..."); + try { + const availableBytes = await checkDiskSpace(targetServerId, "/"); + if (availableBytes < requiredBytes * 1.2) { + errors.push( + `Insufficient disk space on target server. Required: ${formatBytes(requiredBytes)}, Available: ${formatBytes(availableBytes)}`, + ); + } + } catch { + errors.push("Failed to check disk space on target server"); + } + + onLog?.("Ensuring target directories exist..."); + try { + await ensureDirectoryExists(targetServerId, targetBasePath); + } catch { + errors.push(`Failed to create directory: ${targetBasePath}`); + } + + for (const mount of mounts) { + if (mount.type === "volume" && mount.volumeName) { + onLog?.(`Ensuring volume exists: ${mount.volumeName}`); + try { + await ensureVolumeExists(targetServerId, mount.volumeName); + } catch { + errors.push(`Failed to create volume: ${mount.volumeName}`); + } + } else if (mount.type === "bind" && mount.hostPath) { + onLog?.(`Ensuring bind mount path exists: ${mount.hostPath}`); + try { + await ensureDirectoryExists(targetServerId, mount.hostPath); + } catch { + errors.push(`Failed to create directory: ${mount.hostPath}`); + } + } + } + + return { + passed: errors.length === 0, + errors, + }; +}; + +const formatBytes = (bytes: number): string => { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`; +}; diff --git a/packages/server/src/utils/transfer/scanner.ts b/packages/server/src/utils/transfer/scanner.ts new file mode 100644 index 000000000..6595eadfc --- /dev/null +++ b/packages/server/src/utils/transfer/scanner.ts @@ -0,0 +1,232 @@ +import { execAsync, execAsyncRemote } from "../process/execAsync"; +import type { + ConflictStatus, + FileConflict, + FileInfo, + MountTransferConfig, +} from "./types"; + +export const scanDirectory = async ( + serverId: string | null, + dirPath: string, +): Promise => { + const command = `find ${dirPath} -type f -exec stat --format='%n|%s|%Y' {} + 2>/dev/null || true`; + + let stdout: string; + if (serverId) { + const result = await execAsyncRemote(serverId, command); + stdout = result.stdout; + } else { + const result = await execAsync(command); + stdout = result.stdout; + } + + if (!stdout.trim()) return []; + + return stdout + .trim() + .split("\n") + .filter(Boolean) + .map((line) => { + const [filePath, size, modifiedAt] = line.split("|"); + return { + path: filePath!.replace(dirPath, "").replace(/^\//, ""), + size: Number.parseInt(size || "0", 10), + modifiedAt: Number.parseInt(modifiedAt || "0", 10), + }; + }); +}; + +export const scanDockerVolume = async ( + serverId: string | null, + volumeName: string, +): Promise => { + const command = `docker run --rm -v ${volumeName}:/volume alpine find /volume -type f -exec stat -c '%n|%s|%Y' {} + 2>/dev/null || true`; + + let stdout: string; + if (serverId) { + const result = await execAsyncRemote(serverId, command); + stdout = result.stdout; + } else { + const result = await execAsync(command); + stdout = result.stdout; + } + + if (!stdout.trim()) return []; + + return stdout + .trim() + .split("\n") + .filter(Boolean) + .map((line) => { + const [filePath, size, modifiedAt] = line.split("|"); + return { + path: (filePath || "").replace("/volume/", ""), + size: Number.parseInt(size || "0", 10), + modifiedAt: Number.parseInt(modifiedAt || "0", 10), + }; + }); +}; + +export const computeFileHash = async ( + serverId: string | null, + filePath: string, +): Promise => { + const command = `md5sum "${filePath}" | awk '{print $1}'`; + + let stdout: string; + if (serverId) { + const result = await execAsyncRemote(serverId, command); + stdout = result.stdout; + } else { + const result = await execAsync(command); + stdout = result.stdout; + } + + return stdout.trim(); +}; + +export const computeVolumeFileHash = async ( + serverId: string | null, + volumeName: string, + filePath: string, +): Promise => { + const command = `docker run --rm -v ${volumeName}:/volume alpine md5sum "/volume/${filePath}" | awk '{print $1}'`; + + let stdout: string; + if (serverId) { + const result = await execAsyncRemote(serverId, command); + stdout = result.stdout; + } else { + const result = await execAsync(command); + stdout = result.stdout; + } + + return stdout.trim(); +}; + +export const scanMount = async ( + serverId: string | null, + mount: MountTransferConfig, +): Promise => { + if (mount.type === "volume" && mount.volumeName) { + return scanDockerVolume(serverId, mount.volumeName); + } + if (mount.type === "bind" && mount.hostPath) { + return scanDirectory(serverId, mount.hostPath); + } + if (mount.type === "file") { + return []; + } + return []; +}; + +export const compareFileLists = async ( + sourceFiles: FileInfo[], + targetFiles: FileInfo[], + sourceServerId: string | null, + targetServerId: string, + basePath?: string, + volumeName?: string, +): Promise => { + const targetMap = new Map(); + for (const f of targetFiles) { + targetMap.set(f.path, f); + } + + const conflicts: FileConflict[] = []; + + for (const sourceFile of sourceFiles) { + const targetFile = targetMap.get(sourceFile.path); + + if (!targetFile) { + conflicts.push({ + path: sourceFile.path, + status: "missing_target", + sourceFile, + }); + continue; + } + + if ( + sourceFile.size === targetFile.size && + sourceFile.modifiedAt === targetFile.modifiedAt + ) { + conflicts.push({ + path: sourceFile.path, + status: "match", + sourceFile, + targetFile, + }); + continue; + } + + let sourceHash: string; + let targetHash: string; + + if (volumeName) { + sourceHash = await computeVolumeFileHash( + sourceServerId, + volumeName, + sourceFile.path, + ); + targetHash = await computeVolumeFileHash( + targetServerId, + volumeName, + targetFile.path, + ); + } else if (basePath) { + sourceHash = await computeFileHash( + sourceServerId, + `${basePath}/${sourceFile.path}`, + ); + targetHash = await computeFileHash( + targetServerId, + `${basePath}/${targetFile.path}`, + ); + } else { + sourceHash = ""; + targetHash = ""; + } + + if (sourceHash && targetHash && sourceHash === targetHash) { + conflicts.push({ + path: sourceFile.path, + status: "match", + sourceFile: { ...sourceFile, hash: sourceHash }, + targetFile: { ...targetFile, hash: targetHash }, + }); + continue; + } + + let status: ConflictStatus; + if (sourceFile.modifiedAt > targetFile.modifiedAt) { + status = "newer_source"; + } else if (targetFile.modifiedAt > sourceFile.modifiedAt) { + status = "newer_target"; + } else { + status = "conflict"; + } + + conflicts.push({ + path: sourceFile.path, + status, + sourceFile: { ...sourceFile, hash: sourceHash || undefined }, + targetFile: { ...targetFile, hash: targetHash || undefined }, + }); + } + + for (const targetFile of targetFiles) { + const exists = sourceFiles.some((sf) => sf.path === targetFile.path); + if (!exists) { + conflicts.push({ + path: targetFile.path, + status: "newer_target", + sourceFile: { path: targetFile.path, size: 0, modifiedAt: 0 }, + targetFile, + }); + } + } + + return conflicts; +}; diff --git a/packages/server/src/utils/transfer/sync.ts b/packages/server/src/utils/transfer/sync.ts new file mode 100644 index 000000000..0bdd8e733 --- /dev/null +++ b/packages/server/src/utils/transfer/sync.ts @@ -0,0 +1,171 @@ +import { execAsync, execAsyncRemote } from "../process/execAsync"; +import type { ConflictDecision, MountTransferConfig } from "./types"; + +const execOnServer = async ( + serverId: string | null, + command: string, + onData?: (data: string) => void, +): Promise<{ stdout: string; stderr: string }> => { + if (serverId) { + return execAsyncRemote(serverId, command, onData); + } + return execAsync(command); +}; + +export const syncDirectory = async ( + sourceServerId: string | null, + targetServerId: string, + sourcePath: string, + targetPath: string, + onLog?: (message: string) => void, +): Promise => { + onLog?.(`Syncing directory: ${sourcePath} → ${targetPath}`); + + await execOnServer(targetServerId, `mkdir -p "${targetPath}"`); + + if (!sourceServerId && targetServerId) { + // Local → Remote: use rsync over SSH + const { stdout: sshKeyInfo } = await execAsyncRemote( + targetServerId, + "echo connected", + ); + // Tar from local, pipe to remote via SSH + await execAsync( + `tar czf - -C "${sourcePath}" . 2>/dev/null | ssh -o StrictHostKeyChecking=no -i /tmp/transfer_key_${targetServerId} "tar xzf - -C ${targetPath}"`, + ).catch(async () => { + // Fallback: read from local, write to remote via tar through dokploy + const { stdout: tarData } = await execAsync( + `tar czf - -C "${sourcePath}" . | base64`, + ); + await execAsyncRemote( + targetServerId, + `echo "${tarData}" | base64 -d | tar xzf - -C "${targetPath}"`, + ); + }); + } else if (sourceServerId && targetServerId) { + // Remote → Remote: tar pipeline through Dokploy server + onLog?.("Using tar pipeline for remote-to-remote transfer..."); + const { stdout: tarData } = await execAsyncRemote( + sourceServerId, + `tar czf - -C "${sourcePath}" . | base64`, + ); + await execAsyncRemote( + targetServerId, + `echo "${tarData}" | base64 -d | tar xzf - -C "${targetPath}"`, + ); + } else if (sourceServerId && !targetServerId) { + // Remote → Local + const { stdout: tarData } = await execAsyncRemote( + sourceServerId, + `tar czf - -C "${sourcePath}" . | base64`, + ); + await execAsync( + `echo "${tarData}" | base64 -d | tar xzf - -C "${targetPath}"`, + ); + } + + onLog?.(`Directory synced successfully: ${targetPath}`); +}; + +export const syncDockerVolume = async ( + sourceServerId: string | null, + targetServerId: string, + volumeName: string, + onLog?: (message: string) => void, +): Promise => { + onLog?.(`Syncing Docker volume: ${volumeName}`); + + await execOnServer( + targetServerId, + `docker volume inspect ${volumeName} > /dev/null 2>&1 || docker volume create ${volumeName}`, + ); + + // Export volume from source as tar + const exportCommand = `docker run --rm -v ${volumeName}:/volume alpine tar czf - -C /volume . | base64`; + let tarData: string; + + if (sourceServerId) { + const result = await execAsyncRemote(sourceServerId, exportCommand); + tarData = result.stdout; + } else { + const result = await execAsync(exportCommand); + tarData = result.stdout; + } + + // Import volume on target + const importCommand = `echo "${tarData}" | base64 -d | docker run --rm -i -v ${volumeName}:/volume alpine tar xzf - -C /volume`; + + await execOnServer(targetServerId, importCommand); + onLog?.(`Volume synced successfully: ${volumeName}`); +}; + +export const syncMount = async ( + sourceServerId: string | null, + targetServerId: string, + mount: MountTransferConfig, + _decisions: Record, + onLog?: (message: string) => void, +): Promise => { + if (mount.type === "volume" && mount.volumeName) { + await syncDockerVolume( + sourceServerId, + targetServerId, + mount.volumeName, + onLog, + ); + } else if (mount.type === "bind" && mount.hostPath) { + await syncDirectory( + sourceServerId, + targetServerId, + mount.hostPath, + mount.hostPath, + onLog, + ); + } else if (mount.type === "file" && mount.content) { + onLog?.(`Syncing file mount: ${mount.mountPath}`); + // File mounts are stored in the database, they get created during deploy + // No file transfer needed, the content is in the DB + onLog?.("File mount will be recreated from database content during deploy"); + } +}; + +export const syncTraefikConfig = async ( + sourceServerId: string | null, + targetServerId: string, + appName: string, + onLog?: (message: string) => void, +): Promise => { + onLog?.(`Syncing Traefik config for: ${appName}`); + + const configPath = "/etc/dokploy/traefik/dynamic"; + const configFile = `${configPath}/${appName}.yml`; + + let configContent: string; + if (sourceServerId) { + const { stdout } = await execAsyncRemote( + sourceServerId, + `cat "${configFile}" 2>/dev/null || echo ""`, + ); + configContent = stdout; + } else { + const { stdout } = await execAsync( + `cat "${configFile}" 2>/dev/null || echo ""`, + ); + configContent = stdout; + } + + if (!configContent.trim()) { + onLog?.("No Traefik config found on source, skipping"); + return; + } + + await execOnServer(targetServerId, `mkdir -p "${configPath}"`); + + const escapedContent = configContent.replace(/'/g, "'\\''"); + await execOnServer( + targetServerId, + `echo '${escapedContent}' > "${configFile}"`, + ); + + onLog?.("Traefik config synced successfully"); +}; diff --git a/packages/server/src/utils/transfer/types.ts b/packages/server/src/utils/transfer/types.ts new file mode 100644 index 000000000..4d63e68a2 --- /dev/null +++ b/packages/server/src/utils/transfer/types.ts @@ -0,0 +1,91 @@ +export type ServiceType = + | "application" + | "compose" + | "postgres" + | "mysql" + | "mariadb" + | "mongo" + | "redis"; + +export interface FileInfo { + path: string; + size: number; + modifiedAt: number; + hash?: string; +} + +export type ConflictStatus = + | "missing_target" + | "newer_source" + | "newer_target" + | "conflict" + | "match"; + +export interface FileConflict { + path: string; + status: ConflictStatus; + sourceFile: FileInfo; + targetFile?: FileInfo; +} + +export interface MountTransferConfig { + mountId: string; + type: "bind" | "volume" | "file"; + hostPath?: string | null; + volumeName?: string | null; + mountPath: string; + content?: string | null; + filePath?: string | null; +} + +export interface TransferScanResult { + serviceDirectory: { + files: FileConflict[]; + totalSize: number; + }; + traefikConfig: { + exists: boolean; + hasConflict: boolean; + }; + mounts: Array<{ + mount: MountTransferConfig; + files: FileConflict[]; + totalSize: number; + }>; + totalTransferSize: number; + totalFiles: number; + conflicts: FileConflict[]; +} + +export type ConflictDecision = "skip" | "overwrite"; + +export interface TransferProgress { + phase: + | "preparing" + | "syncing_directory" + | "syncing_traefik" + | "syncing_mounts" + | "updating_database" + | "completed" + | "failed"; + currentFile?: string; + processedFiles: number; + totalFiles: number; + transferredBytes: number; + totalBytes: number; + percentage: number; + message?: string; +} + +export interface TransferOptions { + serviceId: string; + serviceType: ServiceType; + appName: string; + sourceServerId: string | null; + targetServerId: string; +} + +export interface TransferResult { + success: boolean; + errors: string[]; +}