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[]; +}