mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-12 19:51:00 +05:00
feat(transfer): move services between servers with their volumes, mounts and config
Adds a Transfer action on every service page that moves an application, compose or database to another server without S3: volumes, bind mounts and deployment logs are streamed through the panel (ssh2/spawn pipe), file mounts and Traefik config are recreated on the target, the source is cleaned up and the service is deployed on the target. Failures before cleanup roll back to the source server.
This commit is contained in:
parent
bda8124291
commit
9dcdb53aa1
60
apps/dokploy/__test__/utils/remote-stream.test.ts
Normal file
60
apps/dokploy/__test__/utils/remote-stream.test.ts
Normal file
@ -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");
|
||||
});
|
||||
});
|
||||
245
apps/dokploy/components/dashboard/shared/transfer-service.tsx
Normal file
245
apps/dokploy/components/dashboard/shared/transfer-service.tsx
Normal file
@ -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<typeof transferSchema>;
|
||||
|
||||
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<LogLine[]>([]);
|
||||
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<TransferForm>({
|
||||
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 (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10"
|
||||
isLoading={isTransferring}
|
||||
>
|
||||
<ArrowRightLeft className="size-4 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Transfer to another server</DialogTitle>
|
||||
<DialogDescription>
|
||||
Moves this service with its volumes, bind mounts, file mounts and
|
||||
configuration to the selected server, then deploys it there.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{targets.length === 0 ? (
|
||||
<AlertBlock type="info">
|
||||
There are no other servers available to transfer this service to.
|
||||
</AlertBlock>
|
||||
) : (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
id="hook-form-transfer-service"
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="targetServerId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Target server</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a server" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{targets.map((server) => (
|
||||
<SelectItem
|
||||
key={server.serverId}
|
||||
value={server.serverId}
|
||||
>
|
||||
{server.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="removeSourceData"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="ml-2">
|
||||
Remove volumes from the source server after the
|
||||
transfer
|
||||
</FormLabel>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<AlertBlock type="warning">
|
||||
<ul className="list-disc pl-4 space-y-1">
|
||||
<li>The service is stopped while its data is copied.</li>
|
||||
<li>
|
||||
Point your DNS records to the new server, certificates are
|
||||
issued again there.
|
||||
</li>
|
||||
<li>
|
||||
Networks that only exist on the current server are
|
||||
detached.
|
||||
</li>
|
||||
<li>
|
||||
Bind mount host paths are copied but never deleted from
|
||||
the source server.
|
||||
</li>
|
||||
</ul>
|
||||
</AlertBlock>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={() => setIsOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
{targets.length > 0 && (
|
||||
<Button
|
||||
isLoading={isTransferring}
|
||||
form="hook-form-transfer-service"
|
||||
type="submit"
|
||||
>
|
||||
Transfer
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DrawerLogs
|
||||
isOpen={isDrawerOpen}
|
||||
onClose={() => setIsDrawerOpen(false)}
|
||||
filteredLogs={logs}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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 && (
|
||||
<UpdateApplication applicationId={applicationId} />
|
||||
)}
|
||||
{permissions?.service.create && (
|
||||
<TransferService
|
||||
id={applicationId}
|
||||
type="application"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
)}
|
||||
{permissions?.service.delete && (
|
||||
<DeleteService id={applicationId} type="application" />
|
||||
)}
|
||||
|
||||
@ -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 = (
|
||||
<UpdateCompose composeId={composeId} />
|
||||
)}
|
||||
|
||||
{permissions?.service.create && (
|
||||
<TransferService
|
||||
id={composeId}
|
||||
type="compose"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
)}
|
||||
{permissions?.service.delete && (
|
||||
<DeleteService id={composeId} type="compose" />
|
||||
)}
|
||||
|
||||
@ -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 = (
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 justify-end">
|
||||
<UpdateLibsql libsqlId={libsqlId} />
|
||||
{(auth?.role === "owner" || auth?.canCreateServices) && (
|
||||
<TransferService
|
||||
id={libsqlId}
|
||||
type="libsql"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
)}
|
||||
{(auth?.role === "owner" || auth?.canDeleteServices) && (
|
||||
<DeleteService id={libsqlId} type="libsql" />
|
||||
)}
|
||||
|
||||
@ -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 && (
|
||||
<UpdateMariadb mariadbId={mariadbId} />
|
||||
)}
|
||||
{permissions?.service.create && (
|
||||
<TransferService
|
||||
id={mariadbId}
|
||||
type="mariadb"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
)}
|
||||
{permissions?.service.delete && (
|
||||
<DeleteService id={mariadbId} type="mariadb" />
|
||||
)}
|
||||
|
||||
@ -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 && (
|
||||
<UpdateMongo mongoId={mongoId} />
|
||||
)}
|
||||
{permissions?.service.create && (
|
||||
<TransferService
|
||||
id={mongoId}
|
||||
type="mongo"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
)}
|
||||
{permissions?.service.delete && (
|
||||
<DeleteService id={mongoId} type="mongo" />
|
||||
)}
|
||||
|
||||
@ -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 && (
|
||||
<UpdateMysql mysqlId={mysqlId} />
|
||||
)}
|
||||
{permissions?.service.create && (
|
||||
<TransferService
|
||||
id={mysqlId}
|
||||
type="mysql"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
)}
|
||||
{permissions?.service.delete && (
|
||||
<DeleteService id={mysqlId} type="mysql" />
|
||||
)}
|
||||
|
||||
@ -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 && (
|
||||
<UpdatePostgres postgresId={postgresId} />
|
||||
)}
|
||||
{permissions?.service.create && (
|
||||
<TransferService
|
||||
id={postgresId}
|
||||
type="postgres"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
)}
|
||||
{permissions?.service.delete && (
|
||||
<DeleteService id={postgresId} type="postgres" />
|
||||
)}
|
||||
|
||||
@ -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 && (
|
||||
<UpdateRedis redisId={redisId} />
|
||||
)}
|
||||
{permissions?.service.create && (
|
||||
<TransferService
|
||||
id={redisId}
|
||||
type="redis"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
)}
|
||||
{permissions?.service.delete && (
|
||||
<DeleteService id={redisId} type="redis" />
|
||||
)}
|
||||
|
||||
@ -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
|
||||
|
||||
87
apps/dokploy/server/api/routers/transfer.ts
Normal file
87
apps/dokploy/server/api/routers/transfer.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
}),
|
||||
});
|
||||
@ -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";
|
||||
|
||||
9
packages/server/src/db/schema/transfer.ts
Normal file
9
packages/server/src/db/schema/transfer.ts
Normal file
@ -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),
|
||||
});
|
||||
@ -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";
|
||||
|
||||
601
packages/server/src/services/transfer.ts
Normal file
601
packages/server/src/services/transfer.ts
Normal file
@ -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<typeof apiTransferService> & {
|
||||
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<ReturnType<typeof findService>>;
|
||||
type ApplicationService = Awaited<ReturnType<typeof findApplicationById>>;
|
||||
type ComposeService = Awaited<ReturnType<typeof findComposeById>>;
|
||||
|
||||
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<string>();
|
||||
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<unknown>> = [];
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
127
packages/server/src/utils/process/remoteStream.ts
Normal file
127
packages/server/src/utils/process/remoteStream.ts
Normal file
@ -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<number>;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export const openProcessStream = async (
|
||||
serverId: string | null,
|
||||
command: string,
|
||||
): Promise<ProcessStream> => {
|
||||
if (!serverId) {
|
||||
const child = spawn("sh", ["-c", command]);
|
||||
const exit = new Promise<number>((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<number>((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;
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user