diff --git a/apps/dokploy/__test__/utils/remote-stream.test.ts b/apps/dokploy/__test__/utils/remote-stream.test.ts new file mode 100644 index 000000000..f442dfb44 --- /dev/null +++ b/apps/dokploy/__test__/utils/remote-stream.test.ts @@ -0,0 +1,60 @@ +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@dokploy/server/services/server", () => ({ + findServerById: vi.fn(), +})); + +import { pipeBetweenServers } from "@dokploy/server/utils/process/remoteStream"; + +describe("pipeBetweenServers", () => { + it("delivers a short source stream that ends before the target is ready", async () => { + const bytes = await pipeBetweenServers({ + source: { serverId: null, command: "printf 'hello world'" }, + target: { serverId: null, command: "sleep 0.3; cat > /dev/null" }, + }); + expect(bytes).toBe(11); + }); + + it("pipes multi-megabyte data unchanged", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pipe-")); + const source = path.join(dir, "src.bin"); + const target = path.join(dir, "dst.bin"); + const size = 3 * 1024 * 1024; + const progress: number[] = []; + + const bytes = await pipeBetweenServers({ + source: { + serverId: null, + command: `head -c ${size} /dev/urandom | tee '${source}'`, + }, + target: { serverId: null, command: `cat > '${target}'` }, + onProgress: (transferred) => progress.push(transferred), + }); + + expect(bytes).toBe(size); + expect(progress.at(-1)).toBe(size); + expect(await fs.readFile(target)).toEqual(await fs.readFile(source)); + await fs.rm(dir, { recursive: true, force: true }); + }); + + it("reports a target failure with its stderr", async () => { + await expect( + pipeBetweenServers({ + source: { serverId: null, command: "printf x" }, + target: { serverId: null, command: "echo boom >&2; exit 3" }, + }), + ).rejects.toThrow("target exited with code 3: boom"); + }); + + it("reports a source failure", async () => { + await expect( + pipeBetweenServers({ + source: { serverId: null, command: "exit 2" }, + target: { serverId: null, command: "cat > /dev/null" }, + }), + ).rejects.toThrow("source exited with code 2"); + }); +}); 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..6251beec8 --- /dev/null +++ b/apps/dokploy/components/dashboard/shared/transfer-service.tsx @@ -0,0 +1,245 @@ +import type { ServiceType } from "@dokploy/server/db/schema"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { ArrowRightLeft } from "lucide-react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { DrawerLogs } from "@/components/shared/drawer-logs"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { api } from "@/utils/api"; +import { type LogLine, parseLogs } from "../docker/logs/utils"; + +const LOCAL_SERVER = "dokploy"; + +const transferSchema = z.object({ + targetServerId: z.string().min(1, { message: "Select a target server" }), + removeSourceData: z.boolean(), +}); + +type TransferForm = z.infer; + +interface Props { + id: string; + type: ServiceType; + serverId?: string | null; +} + +export const TransferService = ({ id, type, serverId }: Props) => { + const utils = api.useUtils(); + const { data: isCloud } = api.settings.isCloud.useQuery(); + const { data: servers } = api.server.withSSHKey.useQuery(); + const [isOpen, setIsOpen] = useState(false); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const [isTransferring, setIsTransferring] = useState(false); + const [logs, setLogs] = useState([]); + const [request, setRequest] = useState<{ + targetServerId: string | null; + removeSourceData: boolean; + } | null>(null); + + const targets = [ + ...(!isCloud && serverId + ? [{ serverId: LOCAL_SERVER, name: "Dokploy Server" }] + : []), + ...(servers ?? []).filter((server) => server.serverId !== serverId), + ]; + + const form = useForm({ + defaultValues: { targetServerId: "", removeSourceData: false }, + resolver: zodResolver(transferSchema), + }); + + api.transfer.start.useSubscription( + { + serviceType: type, + serviceId: id, + targetServerId: request?.targetServerId ?? null, + removeSourceData: request?.removeSourceData ?? false, + }, + { + enabled: isTransferring && request !== null, + onData(line) { + setLogs((prev) => [...prev, ...parseLogs(line)]); + if (line.startsWith("Transfer completed")) { + setIsTransferring(false); + toast.success("Service transferred successfully"); + utils.invalidate(); + } else if (line.startsWith("Transfer failed")) { + setIsTransferring(false); + toast.error("Transfer failed, check the logs"); + utils.invalidate(); + } + }, + onError(error) { + setIsTransferring(false); + toast.error(error.message); + }, + }, + ); + + const onSubmit = (values: TransferForm) => { + setRequest({ + targetServerId: + values.targetServerId === LOCAL_SERVER ? null : values.targetServerId, + removeSourceData: values.removeSourceData, + }); + setLogs([]); + setIsDrawerOpen(true); + setIsTransferring(true); + setIsOpen(false); + }; + + return ( + <> + + + + + + + Transfer to another server + + Moves this service with its volumes, bind mounts, file mounts and + configuration to the selected server, then deploys it there. + + + {targets.length === 0 ? ( + + There are no other servers available to transfer this service to. + + ) : ( +
+ + ( + + Target server + + + + )} + /> + ( + +
+ + + + + Remove volumes from the source server after the + transfer + +
+ +
+ )} + /> + +
    +
  • The service is stopped while its data is copied.
  • +
  • + Point your DNS records to the new server, certificates are + issued again there. +
  • +
  • + Networks that only exist on the current server are + detached. +
  • +
  • + Bind mount host paths are copied but never deleted from + the source server. +
  • +
+
+ + + )} + + + {targets.length > 0 && ( + + )} + +
+
+ setIsDrawerOpen(false)} + filteredLogs={logs} + /> + + ); +}; 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 af9b8cd04..cf04a998f 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 @@ -36,6 +36,7 @@ import { DeleteService } from "@/components/dashboard/compose/delete-service"; import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring"; import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring"; import { AssignNetworks } from "@/components/dashboard/networks/assign-networks"; +import { TransferService } from "@/components/dashboard/shared/transfer-service"; import { DashboardLayout } from "@/components/layouts/dashboard-layout"; import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb"; import { StatusTooltip } from "@/components/shared/status-tooltip"; @@ -194,6 +195,13 @@ const Service = ( {permissions?.service.create && ( )} + {permissions?.service.create && ( + + )} {permissions?.service.delete && ( )} 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 fcd408618..c9d1a4378 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 @@ -33,6 +33,7 @@ import { ShowBackups } from "@/components/dashboard/database/backups/show-backup import { ComposeFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-compose-monitoring"; import { ComposePaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-compose-monitoring"; import { AssignComposeNetworks } from "@/components/dashboard/networks/assign-compose-networks"; +import { TransferService } from "@/components/dashboard/shared/transfer-service"; import { DashboardLayout } from "@/components/layouts/dashboard-layout"; import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb"; import { StatusTooltip } from "@/components/shared/status-tooltip"; @@ -184,6 +185,13 @@ const Service = ( )} + {permissions?.service.create && ( + + )} {permissions?.service.delete && ( )} diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/libsql/[libsqlId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/libsql/[libsqlId].tsx index 9996d4fb1..362e754a0 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/libsql/[libsqlId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/libsql/[libsqlId].tsx @@ -23,6 +23,7 @@ import { UpdateLibsql } from "@/components/dashboard/libsql/update-libsql"; import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring"; import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring"; import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings"; +import { TransferService } from "@/components/dashboard/shared/transfer-service"; import { LibsqlIcon } from "@/components/icons/data-tools-icons"; import { DashboardLayout } from "@/components/layouts/dashboard-layout"; import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb"; @@ -145,6 +146,13 @@ const Libsql = (
+ {(auth?.role === "owner" || auth?.canCreateServices) && ( + + )} {(auth?.role === "owner" || auth?.canDeleteServices) && ( )} 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 ddbb47a86..5dc531e83 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 @@ -23,6 +23,7 @@ import { UpdateMariadb } from "@/components/dashboard/mariadb/update-mariadb"; import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring"; import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring"; import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings"; +import { TransferService } from "@/components/dashboard/shared/transfer-service"; import { MariadbIcon } from "@/components/icons/data-tools-icons"; import { DashboardLayout } from "@/components/layouts/dashboard-layout"; import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb"; @@ -159,6 +160,13 @@ const Mariadb = ( {permissions?.service.create && ( )} + {permissions?.service.create && ( + + )} {permissions?.service.delete && ( )} 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 851500fd5..b94984b69 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 @@ -23,6 +23,7 @@ import { UpdateMongo } from "@/components/dashboard/mongo/update-mongo"; import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring"; import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring"; import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings"; +import { TransferService } from "@/components/dashboard/shared/transfer-service"; import { MongodbIcon } from "@/components/icons/data-tools-icons"; import { DashboardLayout } from "@/components/layouts/dashboard-layout"; import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb"; @@ -159,6 +160,13 @@ const Mongo = ( {permissions?.service.create && ( )} + {permissions?.service.create && ( + + )} {permissions?.service.delete && ( )} 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 2c689e602..840062c71 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 @@ -23,6 +23,7 @@ import { ShowGeneralMysql } from "@/components/dashboard/mysql/general/show-gene import { ShowInternalMysqlCredentials } from "@/components/dashboard/mysql/general/show-internal-mysql-credentials"; import { UpdateMysql } from "@/components/dashboard/mysql/update-mysql"; import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings"; +import { TransferService } from "@/components/dashboard/shared/transfer-service"; import { MysqlIcon } from "@/components/icons/data-tools-icons"; import { DashboardLayout } from "@/components/layouts/dashboard-layout"; import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb"; @@ -159,6 +160,13 @@ const MySql = ( {permissions?.service.create && ( )} + {permissions?.service.create && ( + + )} {permissions?.service.delete && ( )} 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 13c50fe61..9cd1a9538 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 @@ -23,6 +23,7 @@ import { ShowGeneralPostgres } from "@/components/dashboard/postgres/general/sho import { ShowInternalPostgresCredentials } from "@/components/dashboard/postgres/general/show-internal-postgres-credentials"; import { UpdatePostgres } from "@/components/dashboard/postgres/update-postgres"; import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings"; +import { TransferService } from "@/components/dashboard/shared/transfer-service"; import { PostgresqlIcon } from "@/components/icons/data-tools-icons"; import { DashboardLayout } from "@/components/layouts/dashboard-layout"; import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb"; @@ -158,6 +159,13 @@ const Postgresql = ( {permissions?.service.create && ( )} + {permissions?.service.create && ( + + )} {permissions?.service.delete && ( )} 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 bc131fc84..fed0b5dc8 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 @@ -22,6 +22,7 @@ import { ShowGeneralRedis } from "@/components/dashboard/redis/general/show-gene import { ShowInternalRedisCredentials } from "@/components/dashboard/redis/general/show-internal-redis-credentials"; import { UpdateRedis } from "@/components/dashboard/redis/update-redis"; import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings"; +import { TransferService } from "@/components/dashboard/shared/transfer-service"; import { RedisIcon } from "@/components/icons/data-tools-icons"; import { DashboardLayout } from "@/components/layouts/dashboard-layout"; import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb"; @@ -158,6 +159,13 @@ const Redis = ( {permissions?.service.create && ( )} + {permissions?.service.create && ( + + )} {permissions?.service.delete && ( )} diff --git a/apps/dokploy/server/api/root.ts b/apps/dokploy/server/api/root.ts index ecd67439f..64d61d3fb 100644 --- a/apps/dokploy/server/api/root.ts +++ b/apps/dokploy/server/api/root.ts @@ -53,6 +53,7 @@ import { sshRouter } from "./routers/ssh-key"; import { stripeRouter } from "./routers/stripe"; import { swarmRouter } from "./routers/swarm"; import { tagRouter } from "./routers/tag"; +import { transferRouter } from "./routers/transfer"; import { userRouter } from "./routers/user"; import { vaultProviderRouter } from "./routers/vault-provider"; import { volumeBackupsRouter } from "./routers/volume-backups"; @@ -120,6 +121,7 @@ export const appRouter = createTRPCRouter({ tag: tagRouter, patch: patchRouter, overview: overviewRouter, + transfer: transferRouter, }); // export type definition of API diff --git a/apps/dokploy/server/api/routers/transfer.ts b/apps/dokploy/server/api/routers/transfer.ts new file mode 100644 index 000000000..dabd6d2ff --- /dev/null +++ b/apps/dokploy/server/api/routers/transfer.ts @@ -0,0 +1,87 @@ +import { + getAccessibleServerIds, + IS_CLOUD, + transferService, +} from "@dokploy/server"; +import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission"; +import { TRPCError } from "@trpc/server"; +import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; +import { audit } from "@/server/api/utils/audit"; +import { apiTransferService } from "@/server/db/schema"; + +export const transferRouter = createTRPCRouter({ + start: protectedProcedure + .meta({ + openapi: { + path: "/transfer/start", + method: "POST", + override: true, + enabled: false, + }, + }) + .input(apiTransferService) + .subscription(async function* ({ input, ctx, signal }) { + await checkServicePermissionAndAccess(ctx, input.serviceId, { + service: ["create"], + deployment: ["create"], + }); + if (input.targetServerId) { + const accessibleIds = await getAccessibleServerIds(ctx.session); + if (!accessibleIds.has(input.targetServerId)) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You don't have access to the target server", + }); + } + } else if (IS_CLOUD) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "The Dokploy server is not available as a target", + }); + } + + const queue: string[] = []; + let done = false; + const log = (line: string) => queue.push(line); + + const run = async () => { + const result = await transferService( + { + ...input, + organizationId: ctx.session.activeOrganizationId, + }, + log, + ); + await audit(ctx, { + action: "move", + resourceType: "service", + resourceId: input.serviceId, + resourceName: result.appName, + metadata: { targetServerId: input.targetServerId }, + }); + }; + + run() + .then(() => log("Transfer completed successfully!")) + .catch((error) => { + log( + `Transfer failed ❌ ${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; + } + } + }), +}); diff --git a/packages/server/src/db/schema/index.ts b/packages/server/src/db/schema/index.ts index fea834201..f17a12473 100644 --- a/packages/server/src/db/schema/index.ts +++ b/packages/server/src/db/schema/index.ts @@ -41,6 +41,7 @@ export * from "./shared"; export * from "./ssh-key"; export * from "./sso"; export * from "./tag"; +export * from "./transfer"; export * from "./user"; export * from "./utils"; export * from "./vault-provider"; diff --git a/packages/server/src/db/schema/transfer.ts b/packages/server/src/db/schema/transfer.ts new file mode 100644 index 000000000..e326a4219 --- /dev/null +++ b/packages/server/src/db/schema/transfer.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; +import { serviceType } from "./mount"; + +export const apiTransferService = z.object({ + serviceType: z.enum(serviceType.enumValues), + serviceId: z.string().min(1), + targetServerId: z.string().min(1).nullable(), + removeSourceData: z.boolean().default(false), +}); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 766ccdd6f..cd7bef41f 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -54,6 +54,7 @@ export * from "./services/server"; export * from "./services/server-health"; export * from "./services/settings"; export * from "./services/ssh-key"; +export * from "./services/transfer"; export * from "./services/user"; export * from "./services/vault-provider"; export * from "./services/volume-backups"; @@ -120,6 +121,7 @@ export * from "./utils/notifications/dokploy-restart"; export * from "./utils/notifications/server-threshold"; export * from "./utils/notifications/utils"; export * from "./utils/process/execAsync"; +export * from "./utils/process/remoteStream"; export * from "./utils/process/spawnAsync"; export * from "./utils/providers/bitbucket"; export * from "./utils/providers/docker"; diff --git a/packages/server/src/services/transfer.ts b/packages/server/src/services/transfer.ts new file mode 100644 index 000000000..8466fe32c --- /dev/null +++ b/packages/server/src/services/transfer.ts @@ -0,0 +1,601 @@ +import path from "node:path"; +import { paths } from "@dokploy/server/constants"; +import { db } from "@dokploy/server/db"; +import { + type apiTransferService, + deployments, + network, + type ServiceType, +} from "@dokploy/server/db/schema"; +import { removeService } from "@dokploy/server/utils/docker/utils"; +import { + removeDirectoryCode, + removeMonitoringDirectory, +} from "@dokploy/server/utils/filesystem/directory"; +import { + execAsync, + execAsyncRemote, +} from "@dokploy/server/utils/process/execAsync"; +import { pipeBetweenServers } from "@dokploy/server/utils/process/remoteStream"; +import { + readConfig, + readRemoteConfig, + removeTraefikConfig, + writeConfig, + writeConfigRemote, +} from "@dokploy/server/utils/traefik/application"; +import { manageDomain } from "@dokploy/server/utils/traefik/domain"; +import { removeForwardAuthMiddleware } from "@dokploy/server/utils/traefik/forward-auth"; +import { + deleteAllMiddlewares, + removePathMiddlewares, +} from "@dokploy/server/utils/traefik/middleware"; +import { createRedirectMiddleware } from "@dokploy/server/utils/traefik/redirect"; +import { createSecurityMiddleware } from "@dokploy/server/utils/traefik/security"; +import { TRPCError } from "@trpc/server"; +import { eq, inArray, sql } from "drizzle-orm"; +import { quote } from "shell-quote"; +import type { z } from "zod"; +import { + deployApplication, + findApplicationById, + updateApplication, +} from "./application"; +import { + deployCompose, + findComposeById, + removeCompose, + startCompose, + stopCompose, + updateCompose, +} from "./compose"; +import { deployLibsql, findLibsqlById, updateLibsqlById } from "./libsql"; +import { deployMariadb, findMariadbById, updateMariadbById } from "./mariadb"; +import { deployMongo, findMongoById, updateMongoById } from "./mongo"; +import { createFileMount } from "./mount"; +import { deployMySql, findMySqlById, updateMySqlById } from "./mysql"; +import { + deployPostgres, + findPostgresById, + updatePostgresById, +} from "./postgres"; +import { deployRedis, findRedisById, updateRedisById } from "./redis"; +import { findServerById } from "./server"; + +export type TransferServiceInput = z.infer & { + organizationId: string; +}; + +type Logger = (line: string) => void; +type ServiceStatus = "idle" | "running" | "done" | "error"; + +const findService = async (serviceType: ServiceType, serviceId: string) => { + switch (serviceType) { + case "application": + return await findApplicationById(serviceId); + case "compose": + return await findComposeById(serviceId); + case "postgres": + return await findPostgresById(serviceId); + case "mysql": + return await findMySqlById(serviceId); + case "mariadb": + return await findMariadbById(serviceId); + case "mongo": + return await findMongoById(serviceId); + case "redis": + return await findRedisById(serviceId); + case "libsql": + return await findLibsqlById(serviceId); + } +}; + +type TransferableService = Awaited>; +type ApplicationService = Awaited>; +type ComposeService = Awaited>; + +const isApplication = ( + service: TransferableService, +): service is ApplicationService => "applicationId" in service; + +const isCompose = (service: TransferableService): service is ComposeService => + "composeId" in service; + +const updateServer = async ( + serviceType: ServiceType, + serviceId: string, + data: { serverId: string | null; networkIds?: string[] | null }, +) => { + switch (serviceType) { + case "application": + return await updateApplication(serviceId, data); + case "compose": + return await updateCompose(serviceId, { serverId: data.serverId }); + case "postgres": + return await updatePostgresById(serviceId, data); + case "mysql": + return await updateMySqlById(serviceId, data); + case "mariadb": + return await updateMariadbById(serviceId, data); + case "mongo": + return await updateMongoById(serviceId, data); + case "redis": + return await updateRedisById(serviceId, data); + case "libsql": + return await updateLibsqlById(serviceId, data); + } +}; + +const updateStatus = async ( + serviceType: ServiceType, + serviceId: string, + status: ServiceStatus, +) => { + switch (serviceType) { + case "application": + return await updateApplication(serviceId, { applicationStatus: status }); + case "compose": + return await updateCompose(serviceId, { composeStatus: status }); + case "postgres": + return await updatePostgresById(serviceId, { + applicationStatus: status, + }); + case "mysql": + return await updateMySqlById(serviceId, { applicationStatus: status }); + case "mariadb": + return await updateMariadbById(serviceId, { applicationStatus: status }); + case "mongo": + return await updateMongoById(serviceId, { applicationStatus: status }); + case "redis": + return await updateRedisById(serviceId, { applicationStatus: status }); + case "libsql": + return await updateLibsqlById(serviceId, { applicationStatus: status }); + } +}; + +const q = (value: string) => quote([value]); + +const runOn = (serverId: string | null, command: string) => + serverId ? execAsyncRemote(serverId, command) : execAsync(command); + +const errorMessage = (error: unknown) => + error instanceof Error ? error.message : String(error); + +const directoryExists = async (serverId: string | null, directory: string) => { + const { stdout } = await runOn( + serverId, + `[ -d ${q(directory)} ] && echo yes || echo no`, + ); + return stdout.trim() === "yes"; +}; + +const formatBytes = (bytes: number) => { + const mib = bytes / (1024 * 1024); + if (mib < 1) return `${(bytes / 1024).toFixed(1)} KiB`; + if (mib < 1024) return `${mib.toFixed(1)} MiB`; + return `${(mib / 1024).toFixed(2)} GiB`; +}; + +export const volumeExportCommand = (volume: string) => + `docker run --rm -v ${q(volume)}:/data:ro alpine tar -czf - --numeric-owner -C /data .`; + +export const volumeImportCommand = (volume: string) => + `docker volume create ${q(volume)} >/dev/null && docker run --rm -i -v ${q(volume)}:/data alpine tar -xzf - --numeric-owner -C /data`; + +export const directoryExportCommand = (directory: string) => + `COPYFILE_DISABLE=1 tar -czf - --numeric-owner -C ${q(directory)} .`; + +export const directoryImportCommand = (directory: string) => + `mkdir -p ${q(directory)} && tar -xzf - --numeric-owner -C ${q(directory)}`; + +const PROGRESS_STEP = 64 * 1024 * 1024; + +const copyStream = async ( + label: string, + source: { serverId: string | null; command: string }, + target: { serverId: string | null; command: string }, + log: Logger, +) => { + let reported = 0; + const bytes = await pipeBetweenServers({ + source, + target, + onProgress: (transferred) => { + if (transferred - reported >= PROGRESS_STEP) { + reported = transferred; + log(` ${label}: ${formatBytes(transferred)} transferred`); + } + }, + }); + log(` ${label}: done (${formatBytes(bytes)})`); +}; + +const listVolumes = async (service: TransferableService) => { + const names = new Set(); + for (const mount of service.mounts) { + if (mount.type === "volume" && mount.volumeName) { + names.add(mount.volumeName); + } + } + if (isCompose(service)) { + const project = q(service.appName); + const { stdout } = await runOn( + service.serverId ?? null, + `docker volume ls -q --filter label=com.docker.compose.project=${project}; docker volume ls -q --filter label=com.docker.stack.namespace=${project}`, + ); + for (const line of stdout.split("\n")) { + const name = line.trim(); + if (name) names.add(name); + } + } + return [...names]; +}; + +const listBindMounts = (service: TransferableService) => [ + ...new Set( + service.mounts + .filter((mount) => mount.type === "bind" && mount.hostPath) + .map((mount) => mount.hostPath as string), + ), +]; + +const stopOnSource = async (service: TransferableService, log: Logger) => { + if (isCompose(service)) { + try { + await stopCompose(service.composeId); + } catch (error) { + log(` Could not stop compose: ${errorMessage(error)}`); + } + return; + } + await runOn( + service.serverId ?? null, + `docker service scale ${q(service.appName)}=0 >/dev/null 2>&1 || true`, + ); +}; + +const startOnSource = async (service: TransferableService, log: Logger) => { + if (isCompose(service)) { + if (service.composeType === "stack") { + log( + " Stack was removed on the source server, run Deploy to bring it back", + ); + return; + } + try { + await startCompose(service.composeId); + } catch (error) { + log(` Could not start compose: ${errorMessage(error)}`); + } + return; + } + const replicas = isApplication(service) ? service.replicas : 1; + await runOn( + service.serverId ?? null, + `docker service scale ${q(service.appName)}=${replicas} >/dev/null 2>&1 || true`, + ); +}; + +const resolveNetworkIds = async ( + networkIds: string[] | null | undefined, + targetServerId: string | null, +) => { + if (!networkIds || networkIds.length === 0) { + return { kept: [] as string[], dropped: [] as string[] }; + } + const rows = await db.query.network.findMany({ + where: inArray(network.networkId, networkIds), + columns: { networkId: true, name: true, serverId: true }, + }); + return { + kept: rows + .filter((row) => (row.serverId ?? null) === targetServerId) + .map((row) => row.networkId), + dropped: rows + .filter((row) => (row.serverId ?? null) !== targetServerId) + .map((row) => row.name), + }; +}; + +const moveTraefikConfig = async ( + source: ApplicationService, + moved: ApplicationService, + log: Logger, +) => { + const config = source.serverId + ? await readRemoteConfig(source.serverId, source.appName) + : readConfig(source.appName); + if (config) { + if (moved.serverId) { + await writeConfigRemote(moved.serverId, moved.appName, config); + } else { + writeConfig(moved.appName, config); + } + } + for (const domain of moved.domains) { + await manageDomain(moved, domain); + } + for (const security of moved.security) { + await createSecurityMiddleware(moved, security); + } + for (const redirect of moved.redirects) { + await createRedirectMiddleware(moved, redirect); + } + log(` Traefik configuration recreated (${moved.domains.length} domains)`); +}; + +const removeSourceTraefikConfig = async (source: ApplicationService) => { + for (const domain of source.domains) { + await removePathMiddlewares(source, domain.uniqueConfigKey); + await removeForwardAuthMiddleware(source, domain.uniqueConfigKey); + } + await deleteAllMiddlewares(source); + await removeTraefikConfig(source.appName, source.serverId); +}; + +const moveDeploymentLogs = async ( + service: ApplicationService | ComposeService, + targetServerId: string | null, + log: Logger, +) => { + const sourceServerId = service.serverId ?? null; + const from = path.join(paths(!!sourceServerId).LOGS_PATH, service.appName); + const to = path.join(paths(!!targetServerId).LOGS_PATH, service.appName); + if (!(await directoryExists(sourceServerId, from))) return; + + log("Copying deployment logs"); + await copyStream( + "deployment logs", + { serverId: sourceServerId, command: directoryExportCommand(from) }, + { serverId: targetServerId, command: directoryImportCommand(to) }, + log, + ); + if (from !== to) { + await db + .update(deployments) + .set({ logPath: sql`replace(${deployments.logPath}, ${from}, ${to})` }) + .where( + isCompose(service) + ? eq(deployments.composeId, service.composeId) + : eq(deployments.applicationId, service.applicationId), + ); + } + await runOn(sourceServerId, `rm -rf ${q(from)}`); +}; + +const cleanupSource = async ( + service: TransferableService, + volumes: string[], + removeSourceData: boolean, + log: Logger, +) => { + const serverId = service.serverId ?? null; + const steps: Array<() => Promise> = []; + + if (isApplication(service)) { + steps.push( + () => removeSourceTraefikConfig(service), + () => removeService(service.appName, serverId), + () => removeDirectoryCode(service.appName, serverId), + () => removeMonitoringDirectory(service.appName, serverId), + ); + } else if (isCompose(service)) { + steps.push(() => removeCompose(service, removeSourceData)); + } else { + steps.push( + () => removeService(service.appName, serverId), + () => removeDirectoryCode(service.appName, serverId), + ); + } + if (removeSourceData && volumes.length > 0) { + steps.push(() => + runOn( + serverId, + `docker volume rm ${volumes.map(q).join(" ")} >/dev/null 2>&1 || true`, + ), + ); + } + + for (const step of steps) { + try { + await step(); + } catch (error) { + log(` Warning: ${errorMessage(error)}`); + } + } + if (!removeSourceData && volumes.length > 0) { + log(` Volumes kept on the source server: ${volumes.join(", ")}`); + } +}; + +const deployOnTarget = async ( + serviceType: ServiceType, + serviceId: string, + targetName: string, + log: Logger, +) => { + log(`Deploying on ${targetName}`); + const deployment = { + titleLog: "Deploy after transfer", + descriptionLog: `Transferred to ${targetName}`, + }; + switch (serviceType) { + case "application": + log("Follow the Deployments tab for the build log"); + await updateStatus(serviceType, serviceId, "running"); + return await deployApplication({ + applicationId: serviceId, + ...deployment, + }); + case "compose": + log("Follow the Deployments tab for the build log"); + await updateStatus(serviceType, serviceId, "running"); + return await deployCompose({ composeId: serviceId, ...deployment }); + case "postgres": + return await deployPostgres(serviceId, log); + case "mysql": + return await deployMySql(serviceId, log); + case "mariadb": + return await deployMariadb(serviceId, log); + case "mongo": + return await deployMongo(serviceId, log); + case "redis": + return await deployRedis(serviceId, log); + case "libsql": + return await deployLibsql(serviceId, log); + } +}; + +export const transferService = async ( + input: TransferServiceInput, + log: Logger, +) => { + const { serviceType, serviceId, targetServerId, removeSourceData } = input; + const service = await findService(serviceType, serviceId); + + if (service.environment.project.organizationId !== input.organizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to transfer this service", + }); + } + const sourceServerId = service.serverId ?? null; + if (sourceServerId === targetServerId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "The service is already on the selected server", + }); + } + const targetServer = targetServerId + ? await findServerById(targetServerId) + : null; + if (targetServer) { + if (targetServer.organizationId !== input.organizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to use the target server", + }); + } + if ( + targetServer.serverStatus !== "active" || + targetServer.serverType !== "deploy" + ) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "The target server is not available for deployments", + }); + } + } + + const sourceName = service.server?.name ?? "Dokploy Server"; + const targetName = targetServer?.name ?? "Dokploy Server"; + const originalNetworkIds = + "networkIds" in service ? service.networkIds : undefined; + let switched = false; + + log(`Transferring ${service.appName} from ${sourceName} to ${targetName}`); + try { + log(`Stopping service on ${sourceName}`); + await stopOnSource(service, log); + await updateStatus(serviceType, serviceId, "running"); + + const volumes = await listVolumes(service); + const bindMounts = listBindMounts(service); + log( + `Found ${volumes.length} volume(s) and ${bindMounts.length} bind mount(s)`, + ); + for (const volume of volumes) { + log(`Copying volume ${volume}`); + await copyStream( + volume, + { serverId: sourceServerId, command: volumeExportCommand(volume) }, + { serverId: targetServerId, command: volumeImportCommand(volume) }, + log, + ); + } + for (const hostPath of bindMounts) { + if (!(await directoryExists(sourceServerId, hostPath))) { + log(`Skipping bind mount ${hostPath}: not found on ${sourceName}`); + continue; + } + log(`Copying bind mount ${hostPath}`); + await copyStream( + hostPath, + { + serverId: sourceServerId, + command: directoryExportCommand(hostPath), + }, + { + serverId: targetServerId, + command: directoryImportCommand(hostPath), + }, + log, + ); + } + + const networks = await resolveNetworkIds( + originalNetworkIds, + targetServerId, + ); + if (networks.dropped.length > 0) { + log( + `Detaching networks not available on ${targetName}: ${networks.dropped.join(", ")}`, + ); + } + await updateServer(serviceType, serviceId, { + serverId: targetServerId, + networkIds: originalNetworkIds === undefined ? undefined : networks.kept, + }); + switched = true; + const moved = await findService(serviceType, serviceId); + + const fileMounts = moved.mounts.filter((mount) => mount.type === "file"); + if (fileMounts.length > 0) { + log(`Recreating ${fileMounts.length} file mount(s) on ${targetName}`); + for (const mount of fileMounts) { + await createFileMount(mount.mountId); + } + } + if (isApplication(service) && isApplication(moved)) { + log(`Recreating Traefik configuration on ${targetName}`); + await moveTraefikConfig(service, moved, log); + } + if (isApplication(service) || isCompose(service)) { + await moveDeploymentLogs(service, targetServerId, log); + } + + log(`Cleaning up ${sourceName}`); + await cleanupSource(service, volumes, removeSourceData, log); + await updateStatus(serviceType, serviceId, "idle"); + } catch (error) { + if (switched) { + await updateServer(serviceType, serviceId, { + serverId: sourceServerId, + networkIds: originalNetworkIds, + }); + } + log(`Data already copied to ${targetName} was left in place`); + log(`Restoring service on ${sourceName}`); + await startOnSource(service, log); + await updateStatus(serviceType, serviceId, "error"); + throw error; + } + + try { + await deployOnTarget(serviceType, serviceId, targetName, log); + } catch (error) { + await updateStatus(serviceType, serviceId, "error"); + log( + `Data was transferred but the deployment on ${targetName} failed, run Deploy to retry`, + ); + throw error; + } + + return { + serviceType, + serviceId, + appName: service.appName, + targetServerId, + targetName, + }; +}; diff --git a/packages/server/src/utils/process/remoteStream.ts b/packages/server/src/utils/process/remoteStream.ts new file mode 100644 index 000000000..5195cc4b1 --- /dev/null +++ b/packages/server/src/utils/process/remoteStream.ts @@ -0,0 +1,127 @@ +import { spawn } from "node:child_process"; +import { findServerById } from "@dokploy/server/services/server"; +import { Client } from "ssh2"; + +export interface ProcessStream { + stdin: NodeJS.WritableStream; + stdout: NodeJS.ReadableStream; + stderr: NodeJS.ReadableStream; + exit: Promise; + close: () => void; +} + +export const openProcessStream = async ( + serverId: string | null, + command: string, +): Promise => { + if (!serverId) { + const child = spawn("sh", ["-c", command]); + const exit = new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", (code) => resolve(code ?? 1)); + }); + return { + stdin: child.stdin, + stdout: child.stdout, + stderr: child.stderr, + exit, + close: () => child.kill(), + }; + } + + const server = await findServerById(serverId); + if (!server.sshKeyId) throw new Error("No SSH key available for this server"); + + return new Promise((resolve, reject) => { + const conn = new Client(); + conn + .once("ready", () => { + conn.exec(command, (err, stream) => { + if (err) { + conn.end(); + reject(err); + return; + } + let exitCode = 1; + stream.once("exit", (code: number | null) => { + exitCode = code ?? 1; + }); + const exit = new Promise((resolveExit) => { + stream.once("close", () => { + conn.end(); + resolveExit(exitCode); + }); + }); + resolve({ + stdin: stream, + stdout: stream, + stderr: stream.stderr, + exit, + close: () => { + stream.close(); + conn.end(); + }, + }); + }); + }) + .once("error", (err) => { + conn.end(); + reject(err); + }) + .connect({ + host: server.ipAddress, + port: server.port, + username: server.username, + privateKey: server.sshKey?.privateKey, + readyTimeout: 30_000, + keepaliveInterval: 15_000, + }); + }); +}; + +const collectOutput = (stream: NodeJS.ReadableStream) => { + const chunks: string[] = []; + stream.on("data", (chunk) => chunks.push(chunk.toString())); + return () => chunks.join("").trim(); +}; + +export const pipeBetweenServers = async ({ + source, + target, + onProgress, +}: { + source: { serverId: string | null; command: string }; + target: { serverId: string | null; command: string }; + onProgress?: (bytes: number) => void; +}) => { + const to = await openProcessStream(target.serverId, target.command); + const targetError = collectOutput(to.stderr); + to.stdout.resume(); + to.stdin.on("error", () => {}); + + const from = await openProcessStream(source.serverId, source.command); + const sourceError = collectOutput(from.stderr); + let bytes = 0; + from.stdout.on("data", (chunk: Buffer) => { + bytes += chunk.length; + onProgress?.(bytes); + }); + from.stdout.pipe(to.stdin); + + const targetExit = to.exit.then((code) => { + if (code !== 0) from.close(); + return code; + }); + + const [sourceCode, targetCode] = await Promise.all([from.exit, targetExit]); + const failures = [ + sourceCode !== 0 && + `source exited with code ${sourceCode}: ${sourceError()}`, + targetCode !== 0 && + `target exited with code ${targetCode}: ${targetError()}`, + ].filter(Boolean); + if (failures.length > 0) { + throw new Error(failures.join("\n")); + } + return bytes; +};