mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-15 20:25:23 +02:00
feat: implement service transfer functionality
Add a new TransferService component to facilitate the transfer of various services (applications, databases, etc.) between servers. This includes updates to the ShowDatabaseAdvancedSettings component to support serverId, and integration of transfer functionality in the application, compose, mariadb, mongo, mysql, postgres, and redis routers. The transfer process includes scanning for transfer readiness and executing the transfer with appropriate permissions and error handling.
This commit is contained in:
@@ -3,13 +3,15 @@ import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes
|
||||
import { ShowCustomCommand } from "@/components/dashboard/postgres/advanced/show-custom-command";
|
||||
import { ShowClusterSettings } from "../application/advanced/cluster/show-cluster-settings";
|
||||
import { RebuildDatabase } from "./rebuild-database";
|
||||
import { TransferService } from "./transfer-service";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
type: "libsql" | "mariadb" | "mongo" | "mysql" | "postgres" | "redis";
|
||||
serverId?: string | null;
|
||||
}
|
||||
|
||||
export const ShowDatabaseAdvancedSettings = ({ id, type }: Props) => {
|
||||
export const ShowDatabaseAdvancedSettings = ({ id, type, serverId }: Props) => {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-5">
|
||||
<ShowCustomCommand id={id} type={type} />
|
||||
@@ -23,6 +25,13 @@ export const ShowDatabaseAdvancedSettings = ({ id, type }: Props) => {
|
||||
<ShowVolumes id={id} type={type} />
|
||||
<ShowResources id={id} type={type} />
|
||||
<RebuildDatabase id={id} type={type} />
|
||||
{type !== "libsql" && (
|
||||
<TransferService
|
||||
serviceId={id}
|
||||
serviceType={type}
|
||||
currentServerId={serverId ?? null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
493
apps/dokploy/components/dashboard/shared/transfer-service.tsx
Normal file
493
apps/dokploy/components/dashboard/shared/transfer-service.tsx
Normal file
@@ -0,0 +1,493 @@
|
||||
import { AlertTriangle, ArrowRightLeft, Loader2, Server } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
type ServiceType =
|
||||
| "application"
|
||||
| "compose"
|
||||
| "postgres"
|
||||
| "mysql"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "redis";
|
||||
|
||||
interface TransferServiceProps {
|
||||
serviceId: string;
|
||||
serviceType: ServiceType;
|
||||
currentServerId: string | null;
|
||||
}
|
||||
|
||||
interface ScanResult {
|
||||
serviceDirectory: {
|
||||
files: Array<{
|
||||
path: string;
|
||||
status: string;
|
||||
sourceFile: { path: string; size: number; modifiedAt: number };
|
||||
targetFile?: { path: string; size: number; modifiedAt: number };
|
||||
}>;
|
||||
totalSize: number;
|
||||
};
|
||||
traefikConfig: {
|
||||
exists: boolean;
|
||||
hasConflict: boolean;
|
||||
};
|
||||
mounts: Array<{
|
||||
mount: {
|
||||
mountId: string;
|
||||
type: string;
|
||||
volumeName?: string | null;
|
||||
hostPath?: string | null;
|
||||
mountPath: string;
|
||||
};
|
||||
files: Array<{
|
||||
path: string;
|
||||
status: string;
|
||||
}>;
|
||||
totalSize: number;
|
||||
}>;
|
||||
totalTransferSize: number;
|
||||
totalFiles: number;
|
||||
conflicts: Array<{
|
||||
path: string;
|
||||
status: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const useTransferMutations = (serviceType: ServiceType) => {
|
||||
const appScan = api.application.transferScan.useMutation();
|
||||
const appTransfer = api.application.transfer.useMutation();
|
||||
const composeScan = api.compose.transferScan.useMutation();
|
||||
const composeTransfer = api.compose.transfer.useMutation();
|
||||
const postgresScan = api.postgres.transferScan.useMutation();
|
||||
const postgresTransfer = api.postgres.transfer.useMutation();
|
||||
const mysqlScan = api.mysql.transferScan.useMutation();
|
||||
const mysqlTransfer = api.mysql.transfer.useMutation();
|
||||
const mariadbScan = api.mariadb.transferScan.useMutation();
|
||||
const mariadbTransfer = api.mariadb.transfer.useMutation();
|
||||
const mongoScan = api.mongo.transferScan.useMutation();
|
||||
const mongoTransfer = api.mongo.transfer.useMutation();
|
||||
const redisScan = api.redis.transferScan.useMutation();
|
||||
const redisTransfer = api.redis.transfer.useMutation();
|
||||
|
||||
const mutations: Record<
|
||||
ServiceType,
|
||||
{
|
||||
scan: { mutateAsync: (input: any) => Promise<any>; isPending: boolean };
|
||||
transfer: {
|
||||
mutateAsync: (input: any) => Promise<any>;
|
||||
isPending: boolean;
|
||||
};
|
||||
}
|
||||
> = {
|
||||
application: { scan: appScan, transfer: appTransfer },
|
||||
compose: { scan: composeScan, transfer: composeTransfer },
|
||||
postgres: { scan: postgresScan, transfer: postgresTransfer },
|
||||
mysql: { scan: mysqlScan, transfer: mysqlTransfer },
|
||||
mariadb: { scan: mariadbScan, transfer: mariadbTransfer },
|
||||
mongo: { scan: mongoScan, transfer: mongoTransfer },
|
||||
redis: { scan: redisScan, transfer: redisTransfer },
|
||||
};
|
||||
|
||||
return mutations[serviceType];
|
||||
};
|
||||
|
||||
const getServiceIdKey = (serviceType: ServiceType): string => {
|
||||
const map: Record<ServiceType, string> = {
|
||||
application: "applicationId",
|
||||
compose: "composeId",
|
||||
postgres: "postgresId",
|
||||
mysql: "mysqlId",
|
||||
mariadb: "mariadbId",
|
||||
mongo: "mongoId",
|
||||
redis: "redisId",
|
||||
};
|
||||
return map[serviceType];
|
||||
};
|
||||
|
||||
export const TransferService = ({
|
||||
serviceId,
|
||||
serviceType,
|
||||
currentServerId,
|
||||
}: TransferServiceProps) => {
|
||||
const [targetServerId, setTargetServerId] = useState<string>("");
|
||||
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
|
||||
const [step, setStep] = useState<"select" | "scan" | "confirm" | "transfer">(
|
||||
"select",
|
||||
);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const [transferLogs, setTransferLogs] = useState<string[]>([]);
|
||||
|
||||
const { data: servers } = api.server.all.useQuery();
|
||||
const utils = api.useUtils();
|
||||
const { scan, transfer } = useTransferMutations(serviceType);
|
||||
|
||||
const idKey = getServiceIdKey(serviceType);
|
||||
|
||||
const availableServers = servers?.filter(
|
||||
(s) => s.serverId !== currentServerId,
|
||||
);
|
||||
|
||||
const selectedServer = servers?.find((s) => s.serverId === targetServerId);
|
||||
|
||||
const handleScan = async () => {
|
||||
if (!targetServerId) {
|
||||
toast.error("Please select a target server");
|
||||
return;
|
||||
}
|
||||
|
||||
setStep("scan");
|
||||
try {
|
||||
const result = await scan.mutateAsync({
|
||||
[idKey]: serviceId,
|
||||
targetServerId,
|
||||
});
|
||||
setScanResult(result as ScanResult);
|
||||
setStep("confirm");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`Scan failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
setStep("select");
|
||||
}
|
||||
};
|
||||
|
||||
const handleTransfer = async () => {
|
||||
setShowConfirm(false);
|
||||
setStep("transfer");
|
||||
setTransferLogs([]);
|
||||
|
||||
try {
|
||||
await transfer.mutateAsync({
|
||||
[idKey]: serviceId,
|
||||
targetServerId,
|
||||
decisions: {},
|
||||
});
|
||||
|
||||
toast.success("Transfer completed successfully!");
|
||||
setTransferLogs((prev) => [...prev, "Transfer completed successfully!"]);
|
||||
|
||||
await utils.invalidate();
|
||||
|
||||
setTimeout(() => {
|
||||
setStep("select");
|
||||
setScanResult(null);
|
||||
setTargetServerId("");
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
toast.error(`Transfer failed: ${message}`);
|
||||
setTransferLogs((prev) => [...prev, `Transfer failed: ${message}`]);
|
||||
setStep("confirm");
|
||||
}
|
||||
};
|
||||
|
||||
const isDbService = ["postgres", "mysql", "mariadb", "mongo", "redis"].includes(
|
||||
serviceType,
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<ArrowRightLeft className="size-5" />
|
||||
Transfer Service
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Transfer this {serviceType} service to a different server. Source data
|
||||
is never modified or deleted.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!availableServers?.length ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Server className="size-4" />
|
||||
<span>
|
||||
No other servers available. Add a remote server first.
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Step 1: Select target server */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Target Server</label>
|
||||
<Select
|
||||
value={targetServerId}
|
||||
onValueChange={(value) => {
|
||||
setTargetServerId(value);
|
||||
setScanResult(null);
|
||||
setStep("select");
|
||||
}}
|
||||
disabled={step === "transfer"}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select target server" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{availableServers.map((server) => (
|
||||
<SelectItem
|
||||
key={server.serverId}
|
||||
value={server.serverId}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{server.name}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{server.ipAddress}
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>
|
||||
Servers ({availableServers.length})
|
||||
</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Scan button */}
|
||||
{step === "select" && targetServerId && (
|
||||
<Button
|
||||
onClick={handleScan}
|
||||
disabled={scan.isPending}
|
||||
variant="outline"
|
||||
>
|
||||
{scan.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
Scanning...
|
||||
</>
|
||||
) : (
|
||||
"Scan for Transfer"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Step 2: Scan in progress */}
|
||||
{step === "scan" && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>
|
||||
Scanning source and target servers for files and
|
||||
conflicts...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Scan results + confirm */}
|
||||
{step === "confirm" && scanResult && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<h4 className="font-medium">Scan Results</h4>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
Total Files:
|
||||
</span>{" "}
|
||||
<span className="font-medium">
|
||||
{scanResult.totalFiles}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
Transfer Size:
|
||||
</span>{" "}
|
||||
<span className="font-medium">
|
||||
{formatBytes(scanResult.totalTransferSize)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
Volumes/Mounts:
|
||||
</span>{" "}
|
||||
<span className="font-medium">
|
||||
{scanResult.mounts.length}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
Conflicts:
|
||||
</span>{" "}
|
||||
<Badge
|
||||
variant={
|
||||
scanResult.conflicts.length > 0
|
||||
? "destructive"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
{scanResult.conflicts.length}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{scanResult.traefikConfig.exists && (
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
Traefik Config:
|
||||
</span>{" "}
|
||||
<Badge variant="outline">Will be synced</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Conflict list */}
|
||||
{scanResult.conflicts.length > 0 && (
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<h4 className="font-medium text-sm">
|
||||
File Conflicts (will be overwritten)
|
||||
</h4>
|
||||
<div className="max-h-40 overflow-y-auto space-y-1">
|
||||
{scanResult.conflicts.map((conflict) => (
|
||||
<div
|
||||
key={conflict.path}
|
||||
className="text-xs font-mono flex items-center gap-2"
|
||||
>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{conflict.status}
|
||||
</Badge>
|
||||
<span className="truncate">
|
||||
{conflict.path}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning */}
|
||||
<div className="rounded-lg border border-yellow-500/50 bg-yellow-500/10 p-4 space-y-2">
|
||||
<div className="flex items-center gap-2 text-yellow-600 dark:text-yellow-400">
|
||||
<AlertTriangle className="size-4" />
|
||||
<span className="font-medium text-sm">
|
||||
Service Downtime Warning
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isDbService
|
||||
? "Stop the database service before transferring to avoid data corruption. The service will be unavailable until deployed on the target server."
|
||||
: "The service will be unavailable during transfer. After transfer completes, deploy the service on the target server to start it."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Transfer button */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setStep("select");
|
||||
setScanResult(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowConfirm(true)}
|
||||
disabled={transfer.isPending}
|
||||
>
|
||||
<ArrowRightLeft className="mr-2 size-4" />
|
||||
Transfer to {selectedServer?.name}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Transfer in progress */}
|
||||
{step === "transfer" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span className="text-sm font-medium">
|
||||
Transferring service...
|
||||
</span>
|
||||
</div>
|
||||
{transferLogs.length > 0 && (
|
||||
<div className="rounded-lg border bg-muted/50 p-3 max-h-60 overflow-y-auto">
|
||||
{transferLogs.map((log, i) => (
|
||||
<div
|
||||
key={`${log}-${i}`}
|
||||
className="text-xs font-mono text-muted-foreground"
|
||||
>
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Confirmation dialog */}
|
||||
<AlertDialog open={showConfirm} onOpenChange={setShowConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirm Service Transfer</AlertDialogTitle>
|
||||
<AlertDialogDescription className="space-y-2">
|
||||
<p>
|
||||
You are about to transfer this {serviceType} to{" "}
|
||||
<strong>{selectedServer?.name}</strong> (
|
||||
{selectedServer?.ipAddress}).
|
||||
</p>
|
||||
{scanResult && (
|
||||
<p>
|
||||
{scanResult.totalFiles} files (
|
||||
{formatBytes(scanResult.totalTransferSize)}) will be
|
||||
copied.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-yellow-600 dark:text-yellow-400 font-medium">
|
||||
The service will experience downtime during this
|
||||
process. After transfer, you must deploy the service on
|
||||
the target server.
|
||||
</p>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleTransfer}>
|
||||
Confirm Transfer
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -18,6 +18,7 @@ import { ShowPorts } from "@/components/dashboard/application/advanced/ports/sho
|
||||
import { ShowRedirects } from "@/components/dashboard/application/advanced/redirects/show-redirects";
|
||||
import { ShowSecurity } from "@/components/dashboard/application/advanced/security/show-security";
|
||||
import { ShowBuildServer } from "@/components/dashboard/application/advanced/show-build-server";
|
||||
import { TransferService } from "@/components/dashboard/shared/transfer-service";
|
||||
import { ShowResources } from "@/components/dashboard/application/advanced/show-resources";
|
||||
import { ShowTraefikConfig } from "@/components/dashboard/application/advanced/traefik/show-traefik-config";
|
||||
import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes";
|
||||
@@ -419,6 +420,11 @@ const Service = (
|
||||
<ShowSecurity applicationId={applicationId} />
|
||||
<ShowPorts applicationId={applicationId} />
|
||||
<ShowTraefikConfig applicationId={applicationId} />
|
||||
<TransferService
|
||||
serviceId={applicationId}
|
||||
serviceType="application"
|
||||
currentServerId={data?.serverId ?? null}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { ShowSchedules } from "@/components/dashboard/application/schedules/show
|
||||
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
|
||||
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
|
||||
import { IsolatedDeploymentTab } from "@/components/dashboard/compose/advanced/add-isolation";
|
||||
import { TransferService } from "@/components/dashboard/shared/transfer-service";
|
||||
import { ShowComposeContainers } from "@/components/dashboard/compose/containers/show-compose-containers";
|
||||
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||
import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show";
|
||||
@@ -423,6 +424,11 @@ const Service = (
|
||||
<ShowVolumes id={composeId} type="compose" />
|
||||
<ShowImport composeId={composeId} />
|
||||
<IsolatedDeploymentTab composeId={composeId} />
|
||||
<TransferService
|
||||
serviceId={composeId}
|
||||
serviceType="compose"
|
||||
currentServerId={data?.serverId ?? null}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
@@ -303,6 +303,7 @@ const Mariadb = (
|
||||
<ShowDatabaseAdvancedSettings
|
||||
id={mariadbId}
|
||||
type="mariadb"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -307,6 +307,7 @@ const Mongo = (
|
||||
<ShowDatabaseAdvancedSettings
|
||||
id={mongoId}
|
||||
type="mongo"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -284,6 +284,7 @@ const MySql = (
|
||||
<ShowDatabaseAdvancedSettings
|
||||
id={mysqlId}
|
||||
type="mysql"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -292,6 +292,7 @@ const Postgresql = (
|
||||
<ShowDatabaseAdvancedSettings
|
||||
id={postgresId}
|
||||
type="postgres"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -296,6 +296,7 @@ const Redis = (
|
||||
<ShowDatabaseAdvancedSettings
|
||||
id={redisId}
|
||||
type="redis"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
updateDeploymentStatus,
|
||||
writeConfig,
|
||||
writeConfigRemote,
|
||||
scanServiceForTransfer,
|
||||
executeTransfer,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
@@ -62,6 +64,7 @@ import {
|
||||
apiSaveGithubProvider,
|
||||
apiSaveGitlabProvider,
|
||||
apiSaveGitProvider,
|
||||
apiTransferApplication,
|
||||
apiUpdateApplication,
|
||||
applications,
|
||||
environments,
|
||||
@@ -1137,4 +1140,138 @@ export const applicationRouter = createTRPCRouter({
|
||||
application.serverId,
|
||||
);
|
||||
}),
|
||||
|
||||
transferScan: protectedProcedure
|
||||
.input(apiTransferApplication.pick({ applicationId: true, targetServerId: true }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const application = await findApplicationById(input.applicationId);
|
||||
await checkServicePermissionAndAccess(ctx, input.applicationId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
application.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this application",
|
||||
});
|
||||
}
|
||||
|
||||
return await scanServiceForTransfer({
|
||||
serviceId: input.applicationId,
|
||||
serviceType: "application",
|
||||
appName: application.appName,
|
||||
sourceServerId: application.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
});
|
||||
}),
|
||||
|
||||
transferWithLogs: protectedProcedure
|
||||
.input(apiTransferApplication)
|
||||
.subscription(async function* ({ input, ctx, signal }) {
|
||||
const application = await findApplicationById(input.applicationId);
|
||||
await checkServicePermissionAndAccess(ctx, input.applicationId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
application.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this application",
|
||||
});
|
||||
}
|
||||
|
||||
const queue: string[] = [];
|
||||
let done = false;
|
||||
|
||||
executeTransfer(
|
||||
{
|
||||
serviceId: input.applicationId,
|
||||
serviceType: "application",
|
||||
appName: application.appName,
|
||||
sourceServerId: application.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
},
|
||||
input.decisions || {},
|
||||
(progress) => {
|
||||
queue.push(JSON.stringify(progress));
|
||||
},
|
||||
)
|
||||
.then(async (result) => {
|
||||
if (result.success) {
|
||||
await db
|
||||
.update(applications)
|
||||
.set({ serverId: input.targetServerId })
|
||||
.where(eq(applications.applicationId, input.applicationId));
|
||||
queue.push("Transfer completed successfully!");
|
||||
} else {
|
||||
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
queue.push(
|
||||
`Transfer error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
done = true;
|
||||
});
|
||||
|
||||
while (!done || queue.length > 0) {
|
||||
if (queue.length > 0) {
|
||||
yield queue.shift()!;
|
||||
} else {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
transfer: protectedProcedure
|
||||
.input(apiTransferApplication)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const application = await findApplicationById(input.applicationId);
|
||||
await checkServicePermissionAndAccess(ctx, input.applicationId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
application.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this application",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await executeTransfer(
|
||||
{
|
||||
serviceId: input.applicationId,
|
||||
serviceType: "application",
|
||||
appName: application.appName,
|
||||
sourceServerId: application.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
},
|
||||
input.decisions || {},
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Transfer failed: ${result.errors.join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
await db
|
||||
.update(applications)
|
||||
.set({ serverId: input.targetServerId })
|
||||
.where(eq(applications.applicationId, input.applicationId));
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
stopCompose,
|
||||
updateCompose,
|
||||
updateDeploymentStatus,
|
||||
scanServiceForTransfer,
|
||||
executeTransfer,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
@@ -63,6 +65,7 @@ import {
|
||||
apiRandomizeCompose,
|
||||
apiRedeployCompose,
|
||||
apiSaveEnvironmentVariablesCompose,
|
||||
apiTransferCompose,
|
||||
apiUpdateCompose,
|
||||
compose as composeTable,
|
||||
environments,
|
||||
@@ -1171,4 +1174,138 @@ export const composeRouter = createTRPCRouter({
|
||||
true,
|
||||
);
|
||||
}),
|
||||
|
||||
transferScan: protectedProcedure
|
||||
.input(apiTransferCompose.pick({ composeId: true, targetServerId: true }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const compose = await findComposeById(input.composeId);
|
||||
await checkServicePermissionAndAccess(ctx, input.composeId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
compose.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this compose",
|
||||
});
|
||||
}
|
||||
|
||||
return await scanServiceForTransfer({
|
||||
serviceId: input.composeId,
|
||||
serviceType: "compose",
|
||||
appName: compose.appName,
|
||||
sourceServerId: compose.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
});
|
||||
}),
|
||||
|
||||
transferWithLogs: protectedProcedure
|
||||
.input(apiTransferCompose)
|
||||
.subscription(async function* ({ input, ctx, signal }) {
|
||||
const compose = await findComposeById(input.composeId);
|
||||
await checkServicePermissionAndAccess(ctx, input.composeId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
compose.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this compose",
|
||||
});
|
||||
}
|
||||
|
||||
const queue: string[] = [];
|
||||
let done = false;
|
||||
|
||||
executeTransfer(
|
||||
{
|
||||
serviceId: input.composeId,
|
||||
serviceType: "compose",
|
||||
appName: compose.appName,
|
||||
sourceServerId: compose.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
},
|
||||
input.decisions || {},
|
||||
(progress) => {
|
||||
queue.push(JSON.stringify(progress));
|
||||
},
|
||||
)
|
||||
.then(async (result) => {
|
||||
if (result.success) {
|
||||
await db
|
||||
.update(composeTable)
|
||||
.set({ serverId: input.targetServerId })
|
||||
.where(eq(composeTable.composeId, input.composeId));
|
||||
queue.push("Transfer completed successfully!");
|
||||
} else {
|
||||
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
queue.push(
|
||||
`Transfer error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
done = true;
|
||||
});
|
||||
|
||||
while (!done || queue.length > 0) {
|
||||
if (queue.length > 0) {
|
||||
yield queue.shift()!;
|
||||
} else {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
transfer: protectedProcedure
|
||||
.input(apiTransferCompose)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const compose = await findComposeById(input.composeId);
|
||||
await checkServicePermissionAndAccess(ctx, input.composeId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
compose.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this compose",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await executeTransfer(
|
||||
{
|
||||
serviceId: input.composeId,
|
||||
serviceType: "compose",
|
||||
appName: compose.appName,
|
||||
sourceServerId: compose.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
},
|
||||
input.decisions || {},
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Transfer failed: ${result.errors.join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
await db
|
||||
.update(composeTable)
|
||||
.set({ serverId: input.targetServerId })
|
||||
.where(eq(composeTable.composeId, input.composeId));
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
stopService,
|
||||
stopServiceRemote,
|
||||
updateMariadbById,
|
||||
scanServiceForTransfer,
|
||||
executeTransfer,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
@@ -44,6 +46,7 @@ import {
|
||||
apiResetMariadb,
|
||||
apiSaveEnvironmentVariablesMariaDB,
|
||||
apiSaveExternalPortMariaDB,
|
||||
apiTransferMariadb,
|
||||
apiUpdateMariaDB,
|
||||
DATABASE_PASSWORD_MESSAGE,
|
||||
DATABASE_PASSWORD_REGEX,
|
||||
@@ -626,4 +629,120 @@ export const mariadbRouter = createTRPCRouter({
|
||||
mariadb.serverId,
|
||||
);
|
||||
}),
|
||||
|
||||
transferScan: protectedProcedure
|
||||
.input(apiTransferMariadb.pick({ mariadbId: true, targetServerId: true }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const mariadb = await findMariadbById(input.mariadbId);
|
||||
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
mariadb.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this MariaDB",
|
||||
});
|
||||
}
|
||||
return await scanServiceForTransfer({
|
||||
serviceId: input.mariadbId,
|
||||
serviceType: "mariadb",
|
||||
appName: mariadb.appName,
|
||||
sourceServerId: mariadb.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
});
|
||||
}),
|
||||
|
||||
transferWithLogs: protectedProcedure
|
||||
.input(apiTransferMariadb)
|
||||
.subscription(async function* ({ input, ctx, signal }) {
|
||||
const mariadb = await findMariadbById(input.mariadbId);
|
||||
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
mariadb.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this MariaDB",
|
||||
});
|
||||
}
|
||||
const queue: string[] = [];
|
||||
let done = false;
|
||||
executeTransfer(
|
||||
{
|
||||
serviceId: input.mariadbId,
|
||||
serviceType: "mariadb",
|
||||
appName: mariadb.appName,
|
||||
sourceServerId: mariadb.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
},
|
||||
input.decisions || {},
|
||||
(progress) => { queue.push(JSON.stringify(progress)); },
|
||||
)
|
||||
.then(async (result) => {
|
||||
if (result.success) {
|
||||
await db
|
||||
.update(mariadbTable)
|
||||
.set({ serverId: input.targetServerId })
|
||||
.where(eq(mariadbTable.mariadbId, input.mariadbId));
|
||||
queue.push("Transfer completed successfully!");
|
||||
} else {
|
||||
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
queue.push(`Transfer error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
})
|
||||
.finally(() => { done = true; });
|
||||
|
||||
while (!done || queue.length > 0) {
|
||||
if (queue.length > 0) { yield queue.shift()!; }
|
||||
else { await new Promise((r) => setTimeout(r, 50)); }
|
||||
if (signal?.aborted) { return; }
|
||||
}
|
||||
}),
|
||||
|
||||
transfer: protectedProcedure
|
||||
.input(apiTransferMariadb)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const mariadb = await findMariadbById(input.mariadbId);
|
||||
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
mariadb.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this MariaDB",
|
||||
});
|
||||
}
|
||||
const result = await executeTransfer(
|
||||
{
|
||||
serviceId: input.mariadbId,
|
||||
serviceType: "mariadb",
|
||||
appName: mariadb.appName,
|
||||
sourceServerId: mariadb.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
},
|
||||
input.decisions || {},
|
||||
);
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Transfer failed: ${result.errors.join(", ")}`,
|
||||
});
|
||||
}
|
||||
await db
|
||||
.update(mariadbTable)
|
||||
.set({ serverId: input.targetServerId })
|
||||
.where(eq(mariadbTable.mariadbId, input.mariadbId));
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
stopService,
|
||||
stopServiceRemote,
|
||||
updateMongoById,
|
||||
scanServiceForTransfer,
|
||||
executeTransfer,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
@@ -43,6 +45,7 @@ import {
|
||||
apiResetMongo,
|
||||
apiSaveEnvironmentVariablesMongo,
|
||||
apiSaveExternalPortMongo,
|
||||
apiTransferMongo,
|
||||
apiUpdateMongo,
|
||||
DATABASE_PASSWORD_MESSAGE,
|
||||
DATABASE_PASSWORD_REGEX,
|
||||
@@ -637,4 +640,120 @@ export const mongoRouter = createTRPCRouter({
|
||||
mongo.serverId,
|
||||
);
|
||||
}),
|
||||
|
||||
transferScan: protectedProcedure
|
||||
.input(apiTransferMongo.pick({ mongoId: true, targetServerId: true }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const mongo = await findMongoById(input.mongoId);
|
||||
await checkServicePermissionAndAccess(ctx, input.mongoId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
mongo.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this MongoDB",
|
||||
});
|
||||
}
|
||||
return await scanServiceForTransfer({
|
||||
serviceId: input.mongoId,
|
||||
serviceType: "mongo",
|
||||
appName: mongo.appName,
|
||||
sourceServerId: mongo.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
});
|
||||
}),
|
||||
|
||||
transferWithLogs: protectedProcedure
|
||||
.input(apiTransferMongo)
|
||||
.subscription(async function* ({ input, ctx, signal }) {
|
||||
const mongo = await findMongoById(input.mongoId);
|
||||
await checkServicePermissionAndAccess(ctx, input.mongoId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
mongo.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this MongoDB",
|
||||
});
|
||||
}
|
||||
const queue: string[] = [];
|
||||
let done = false;
|
||||
executeTransfer(
|
||||
{
|
||||
serviceId: input.mongoId,
|
||||
serviceType: "mongo",
|
||||
appName: mongo.appName,
|
||||
sourceServerId: mongo.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
},
|
||||
input.decisions || {},
|
||||
(progress) => { queue.push(JSON.stringify(progress)); },
|
||||
)
|
||||
.then(async (result) => {
|
||||
if (result.success) {
|
||||
await db
|
||||
.update(mongoTable)
|
||||
.set({ serverId: input.targetServerId })
|
||||
.where(eq(mongoTable.mongoId, input.mongoId));
|
||||
queue.push("Transfer completed successfully!");
|
||||
} else {
|
||||
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
queue.push(`Transfer error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
})
|
||||
.finally(() => { done = true; });
|
||||
|
||||
while (!done || queue.length > 0) {
|
||||
if (queue.length > 0) { yield queue.shift()!; }
|
||||
else { await new Promise((r) => setTimeout(r, 50)); }
|
||||
if (signal?.aborted) { return; }
|
||||
}
|
||||
}),
|
||||
|
||||
transfer: protectedProcedure
|
||||
.input(apiTransferMongo)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const mongo = await findMongoById(input.mongoId);
|
||||
await checkServicePermissionAndAccess(ctx, input.mongoId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
mongo.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this MongoDB",
|
||||
});
|
||||
}
|
||||
const result = await executeTransfer(
|
||||
{
|
||||
serviceId: input.mongoId,
|
||||
serviceType: "mongo",
|
||||
appName: mongo.appName,
|
||||
sourceServerId: mongo.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
},
|
||||
input.decisions || {},
|
||||
);
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Transfer failed: ${result.errors.join(", ")}`,
|
||||
});
|
||||
}
|
||||
await db
|
||||
.update(mongoTable)
|
||||
.set({ serverId: input.targetServerId })
|
||||
.where(eq(mongoTable.mongoId, input.mongoId));
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
stopServiceRemote,
|
||||
updateMySqlById,
|
||||
getAccessibleServerIds,
|
||||
scanServiceForTransfer,
|
||||
executeTransfer,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
@@ -43,6 +45,7 @@ import {
|
||||
apiResetMysql,
|
||||
apiSaveEnvironmentVariablesMySql,
|
||||
apiSaveExternalPortMySql,
|
||||
apiTransferMysql,
|
||||
apiUpdateMySql,
|
||||
DATABASE_PASSWORD_MESSAGE,
|
||||
DATABASE_PASSWORD_REGEX,
|
||||
@@ -640,4 +643,120 @@ export const mysqlRouter = createTRPCRouter({
|
||||
mysql.serverId,
|
||||
);
|
||||
}),
|
||||
|
||||
transferScan: protectedProcedure
|
||||
.input(apiTransferMysql.pick({ mysqlId: true, targetServerId: true }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const mysql = await findMySqlById(input.mysqlId);
|
||||
await checkServicePermissionAndAccess(ctx, input.mysqlId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
mysql.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this MySQL",
|
||||
});
|
||||
}
|
||||
return await scanServiceForTransfer({
|
||||
serviceId: input.mysqlId,
|
||||
serviceType: "mysql",
|
||||
appName: mysql.appName,
|
||||
sourceServerId: mysql.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
});
|
||||
}),
|
||||
|
||||
transferWithLogs: protectedProcedure
|
||||
.input(apiTransferMysql)
|
||||
.subscription(async function* ({ input, ctx, signal }) {
|
||||
const mysql = await findMySqlById(input.mysqlId);
|
||||
await checkServicePermissionAndAccess(ctx, input.mysqlId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
mysql.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this MySQL",
|
||||
});
|
||||
}
|
||||
const queue: string[] = [];
|
||||
let done = false;
|
||||
executeTransfer(
|
||||
{
|
||||
serviceId: input.mysqlId,
|
||||
serviceType: "mysql",
|
||||
appName: mysql.appName,
|
||||
sourceServerId: mysql.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
},
|
||||
input.decisions || {},
|
||||
(progress) => { queue.push(JSON.stringify(progress)); },
|
||||
)
|
||||
.then(async (result) => {
|
||||
if (result.success) {
|
||||
await db
|
||||
.update(mysqlTable)
|
||||
.set({ serverId: input.targetServerId })
|
||||
.where(eq(mysqlTable.mysqlId, input.mysqlId));
|
||||
queue.push("Transfer completed successfully!");
|
||||
} else {
|
||||
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
queue.push(`Transfer error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
})
|
||||
.finally(() => { done = true; });
|
||||
|
||||
while (!done || queue.length > 0) {
|
||||
if (queue.length > 0) { yield queue.shift()!; }
|
||||
else { await new Promise((r) => setTimeout(r, 50)); }
|
||||
if (signal?.aborted) { return; }
|
||||
}
|
||||
}),
|
||||
|
||||
transfer: protectedProcedure
|
||||
.input(apiTransferMysql)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const mysql = await findMySqlById(input.mysqlId);
|
||||
await checkServicePermissionAndAccess(ctx, input.mysqlId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
mysql.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this MySQL",
|
||||
});
|
||||
}
|
||||
const result = await executeTransfer(
|
||||
{
|
||||
serviceId: input.mysqlId,
|
||||
serviceType: "mysql",
|
||||
appName: mysql.appName,
|
||||
sourceServerId: mysql.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
},
|
||||
input.decisions || {},
|
||||
);
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Transfer failed: ${result.errors.join(", ")}`,
|
||||
});
|
||||
}
|
||||
await db
|
||||
.update(mysqlTable)
|
||||
.set({ serverId: input.targetServerId })
|
||||
.where(eq(mysqlTable.mysqlId, input.mysqlId));
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
stopServiceRemote,
|
||||
updatePostgresById,
|
||||
getAccessibleServerIds,
|
||||
scanServiceForTransfer,
|
||||
executeTransfer,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
@@ -44,6 +46,7 @@ import {
|
||||
apiResetPostgres,
|
||||
apiSaveEnvironmentVariablesPostgres,
|
||||
apiSaveExternalPortPostgres,
|
||||
apiTransferPostgres,
|
||||
apiUpdatePostgres,
|
||||
DATABASE_PASSWORD_MESSAGE,
|
||||
DATABASE_PASSWORD_REGEX,
|
||||
@@ -650,4 +653,120 @@ export const postgresRouter = createTRPCRouter({
|
||||
postgres.serverId,
|
||||
);
|
||||
}),
|
||||
|
||||
transferScan: protectedProcedure
|
||||
.input(apiTransferPostgres.pick({ postgresId: true, targetServerId: true }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const postgres = await findPostgresById(input.postgresId);
|
||||
await checkServicePermissionAndAccess(ctx, input.postgresId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
postgres.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this Postgres",
|
||||
});
|
||||
}
|
||||
return await scanServiceForTransfer({
|
||||
serviceId: input.postgresId,
|
||||
serviceType: "postgres",
|
||||
appName: postgres.appName,
|
||||
sourceServerId: postgres.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
});
|
||||
}),
|
||||
|
||||
transferWithLogs: protectedProcedure
|
||||
.input(apiTransferPostgres)
|
||||
.subscription(async function* ({ input, ctx, signal }) {
|
||||
const postgres = await findPostgresById(input.postgresId);
|
||||
await checkServicePermissionAndAccess(ctx, input.postgresId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
postgres.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this Postgres",
|
||||
});
|
||||
}
|
||||
const queue: string[] = [];
|
||||
let done = false;
|
||||
executeTransfer(
|
||||
{
|
||||
serviceId: input.postgresId,
|
||||
serviceType: "postgres",
|
||||
appName: postgres.appName,
|
||||
sourceServerId: postgres.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
},
|
||||
input.decisions || {},
|
||||
(progress) => { queue.push(JSON.stringify(progress)); },
|
||||
)
|
||||
.then(async (result) => {
|
||||
if (result.success) {
|
||||
await db
|
||||
.update(postgresTable)
|
||||
.set({ serverId: input.targetServerId })
|
||||
.where(eq(postgresTable.postgresId, input.postgresId));
|
||||
queue.push("Transfer completed successfully!");
|
||||
} else {
|
||||
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
queue.push(`Transfer error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
})
|
||||
.finally(() => { done = true; });
|
||||
|
||||
while (!done || queue.length > 0) {
|
||||
if (queue.length > 0) { yield queue.shift()!; }
|
||||
else { await new Promise((r) => setTimeout(r, 50)); }
|
||||
if (signal?.aborted) { return; }
|
||||
}
|
||||
}),
|
||||
|
||||
transfer: protectedProcedure
|
||||
.input(apiTransferPostgres)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const postgres = await findPostgresById(input.postgresId);
|
||||
await checkServicePermissionAndAccess(ctx, input.postgresId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
postgres.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this Postgres",
|
||||
});
|
||||
}
|
||||
const result = await executeTransfer(
|
||||
{
|
||||
serviceId: input.postgresId,
|
||||
serviceType: "postgres",
|
||||
appName: postgres.appName,
|
||||
sourceServerId: postgres.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
},
|
||||
input.decisions || {},
|
||||
);
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Transfer failed: ${result.errors.join(", ")}`,
|
||||
});
|
||||
}
|
||||
await db
|
||||
.update(postgresTable)
|
||||
.set({ serverId: input.targetServerId })
|
||||
.where(eq(postgresTable.postgresId, input.postgresId));
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
stopServiceRemote,
|
||||
updateRedisById,
|
||||
getAccessibleServerIds,
|
||||
scanServiceForTransfer,
|
||||
executeTransfer,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
@@ -42,6 +44,7 @@ import {
|
||||
apiResetRedis,
|
||||
apiSaveEnvironmentVariablesRedis,
|
||||
apiSaveExternalPortRedis,
|
||||
apiTransferRedis,
|
||||
apiUpdateRedis,
|
||||
DATABASE_PASSWORD_MESSAGE,
|
||||
DATABASE_PASSWORD_REGEX,
|
||||
@@ -623,4 +626,120 @@ export const redisRouter = createTRPCRouter({
|
||||
redis.serverId,
|
||||
);
|
||||
}),
|
||||
|
||||
transferScan: protectedProcedure
|
||||
.input(apiTransferRedis.pick({ redisId: true, targetServerId: true }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const redis = await findRedisById(input.redisId);
|
||||
await checkServicePermissionAndAccess(ctx, input.redisId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
redis.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this Redis",
|
||||
});
|
||||
}
|
||||
return await scanServiceForTransfer({
|
||||
serviceId: input.redisId,
|
||||
serviceType: "redis",
|
||||
appName: redis.appName,
|
||||
sourceServerId: redis.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
});
|
||||
}),
|
||||
|
||||
transferWithLogs: protectedProcedure
|
||||
.input(apiTransferRedis)
|
||||
.subscription(async function* ({ input, ctx, signal }) {
|
||||
const redis = await findRedisById(input.redisId);
|
||||
await checkServicePermissionAndAccess(ctx, input.redisId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
redis.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this Redis",
|
||||
});
|
||||
}
|
||||
const queue: string[] = [];
|
||||
let done = false;
|
||||
executeTransfer(
|
||||
{
|
||||
serviceId: input.redisId,
|
||||
serviceType: "redis",
|
||||
appName: redis.appName,
|
||||
sourceServerId: redis.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
},
|
||||
input.decisions || {},
|
||||
(progress) => { queue.push(JSON.stringify(progress)); },
|
||||
)
|
||||
.then(async (result) => {
|
||||
if (result.success) {
|
||||
await db
|
||||
.update(redisTable)
|
||||
.set({ serverId: input.targetServerId })
|
||||
.where(eq(redisTable.redisId, input.redisId));
|
||||
queue.push("Transfer completed successfully!");
|
||||
} else {
|
||||
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
queue.push(`Transfer error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
})
|
||||
.finally(() => { done = true; });
|
||||
|
||||
while (!done || queue.length > 0) {
|
||||
if (queue.length > 0) { yield queue.shift()!; }
|
||||
else { await new Promise((r) => setTimeout(r, 50)); }
|
||||
if (signal?.aborted) { return; }
|
||||
}
|
||||
}),
|
||||
|
||||
transfer: protectedProcedure
|
||||
.input(apiTransferRedis)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const redis = await findRedisById(input.redisId);
|
||||
await checkServicePermissionAndAccess(ctx, input.redisId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
redis.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this Redis",
|
||||
});
|
||||
}
|
||||
const result = await executeTransfer(
|
||||
{
|
||||
serviceId: input.redisId,
|
||||
serviceType: "redis",
|
||||
appName: redis.appName,
|
||||
sourceServerId: redis.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
},
|
||||
input.decisions || {},
|
||||
);
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Transfer failed: ${result.errors.join(", ")}`,
|
||||
});
|
||||
}
|
||||
await db
|
||||
.update(redisTable)
|
||||
.set({ serverId: input.targetServerId })
|
||||
.where(eq(redisTable.redisId, input.redisId));
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -534,3 +534,9 @@ export const apiUpdateApplication = createSchema
|
||||
applicationId: z.string().min(1),
|
||||
})
|
||||
.omit({ serverId: true });
|
||||
|
||||
export const apiTransferApplication = z.object({
|
||||
applicationId: z.string().min(1),
|
||||
targetServerId: z.string().min(1),
|
||||
decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
|
||||
});
|
||||
|
||||
@@ -240,3 +240,9 @@ export const apiRandomizeCompose = createSchema
|
||||
suffix: z.string().optional(),
|
||||
composeId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiTransferCompose = z.object({
|
||||
composeId: z.string().min(1),
|
||||
targetServerId: z.string().min(1),
|
||||
decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
|
||||
});
|
||||
|
||||
@@ -213,3 +213,9 @@ export const apiRebuildMariadb = createSchema
|
||||
mariadbId: true,
|
||||
})
|
||||
.required();
|
||||
|
||||
export const apiTransferMariadb = z.object({
|
||||
mariadbId: z.string().min(1),
|
||||
targetServerId: z.string().min(1),
|
||||
decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
|
||||
});
|
||||
|
||||
@@ -210,3 +210,9 @@ export const apiRebuildMongo = createSchema
|
||||
mongoId: true,
|
||||
})
|
||||
.required();
|
||||
|
||||
export const apiTransferMongo = z.object({
|
||||
mongoId: z.string().min(1),
|
||||
targetServerId: z.string().min(1),
|
||||
decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
|
||||
});
|
||||
|
||||
@@ -210,3 +210,9 @@ export const apiRebuildMysql = createSchema
|
||||
mysqlId: true,
|
||||
})
|
||||
.required();
|
||||
|
||||
export const apiTransferMysql = z.object({
|
||||
mysqlId: z.string().min(1),
|
||||
targetServerId: z.string().min(1),
|
||||
decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
|
||||
});
|
||||
|
||||
@@ -204,3 +204,9 @@ export const apiRebuildPostgres = createSchema
|
||||
postgresId: true,
|
||||
})
|
||||
.required();
|
||||
|
||||
export const apiTransferPostgres = z.object({
|
||||
postgresId: z.string().min(1),
|
||||
targetServerId: z.string().min(1),
|
||||
decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
|
||||
});
|
||||
|
||||
@@ -187,3 +187,9 @@ export const apiRebuildRedis = createSchema
|
||||
redisId: true,
|
||||
})
|
||||
.required();
|
||||
|
||||
export const apiTransferRedis = z.object({
|
||||
redisId: z.string().min(1),
|
||||
targetServerId: z.string().min(1),
|
||||
decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
|
||||
});
|
||||
|
||||
@@ -47,6 +47,7 @@ export * from "./services/server";
|
||||
export * from "./services/settings";
|
||||
export * from "./services/ssh-key";
|
||||
export * from "./services/user";
|
||||
export * from "./services/transfer";
|
||||
export * from "./services/volume-backups";
|
||||
export * from "./services/web-server-settings";
|
||||
export * from "./setup/config-paths";
|
||||
@@ -131,6 +132,7 @@ export * from "./utils/traefik/redirect";
|
||||
export * from "./utils/traefik/security";
|
||||
export * from "./utils/traefik/types";
|
||||
export * from "./utils/traefik/web-server";
|
||||
export * from "./utils/transfer/index";
|
||||
export * from "./utils/volume-backups/index";
|
||||
export * from "./utils/watch-paths/should-deploy";
|
||||
export * from "./wss/utils";
|
||||
|
||||
396
packages/server/src/services/transfer.ts
Normal file
396
packages/server/src/services/transfer.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
import { paths } from "@dokploy/server/constants";
|
||||
import path from "node:path";
|
||||
import { findMountsByApplicationId } from "./mount";
|
||||
import {
|
||||
compareFileLists,
|
||||
scanDirectory,
|
||||
scanMount,
|
||||
} from "../utils/transfer/scanner";
|
||||
import { runPreflightChecks } from "../utils/transfer/preflight";
|
||||
import {
|
||||
syncDirectory,
|
||||
syncDockerVolume,
|
||||
syncMount,
|
||||
syncTraefikConfig,
|
||||
} from "../utils/transfer/sync";
|
||||
import type {
|
||||
ConflictDecision,
|
||||
MountTransferConfig,
|
||||
ServiceType,
|
||||
TransferOptions,
|
||||
TransferProgress,
|
||||
TransferResult,
|
||||
TransferScanResult,
|
||||
} from "../utils/transfer/types";
|
||||
|
||||
const getServiceBasePath = (
|
||||
serviceType: ServiceType,
|
||||
appName: string,
|
||||
isRemote: boolean,
|
||||
): string => {
|
||||
if (serviceType === "compose") {
|
||||
const { COMPOSE_PATH } = paths(isRemote);
|
||||
return path.join(COMPOSE_PATH, appName);
|
||||
}
|
||||
const { APPLICATIONS_PATH } = paths(isRemote);
|
||||
return path.join(APPLICATIONS_PATH, appName);
|
||||
};
|
||||
|
||||
const hasServiceDirectory = (serviceType: ServiceType): boolean => {
|
||||
return serviceType === "application" || serviceType === "compose";
|
||||
};
|
||||
|
||||
const getAutoDataVolumeName = (
|
||||
serviceType: ServiceType,
|
||||
appName: string,
|
||||
): string | null => {
|
||||
const dbTypes: ServiceType[] = [
|
||||
"postgres",
|
||||
"mysql",
|
||||
"mariadb",
|
||||
"mongo",
|
||||
"redis",
|
||||
];
|
||||
if (dbTypes.includes(serviceType)) {
|
||||
return `${appName}-data`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const scanServiceForTransfer = async (
|
||||
opts: TransferOptions,
|
||||
): Promise<TransferScanResult> => {
|
||||
const { serviceType, appName, sourceServerId, targetServerId } = opts;
|
||||
|
||||
const result: TransferScanResult = {
|
||||
serviceDirectory: { files: [], totalSize: 0 },
|
||||
traefikConfig: { exists: false, hasConflict: false },
|
||||
mounts: [],
|
||||
totalTransferSize: 0,
|
||||
totalFiles: 0,
|
||||
conflicts: [],
|
||||
};
|
||||
|
||||
// 1. Scan service directory (application/compose only)
|
||||
if (hasServiceDirectory(serviceType)) {
|
||||
const sourcePath = getServiceBasePath(
|
||||
serviceType,
|
||||
appName,
|
||||
!!sourceServerId,
|
||||
);
|
||||
const targetPath = getServiceBasePath(serviceType, appName, true);
|
||||
|
||||
try {
|
||||
const sourceFiles = await scanDirectory(sourceServerId, sourcePath);
|
||||
const targetFiles = await scanDirectory(targetServerId, targetPath);
|
||||
|
||||
const fileConflicts = await compareFileLists(
|
||||
sourceFiles,
|
||||
targetFiles,
|
||||
sourceServerId,
|
||||
targetServerId,
|
||||
sourcePath,
|
||||
);
|
||||
|
||||
result.serviceDirectory = {
|
||||
files: fileConflicts,
|
||||
totalSize: sourceFiles.reduce((sum, f) => sum + f.size, 0),
|
||||
};
|
||||
} catch {
|
||||
// Directory may not exist yet, that's ok
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check Traefik config
|
||||
if (serviceType === "application" || serviceType === "compose") {
|
||||
const configPath = "/etc/dokploy/traefik/dynamic";
|
||||
const configFile = `${configPath}/${appName}.yml`;
|
||||
|
||||
try {
|
||||
const sourceFiles = await scanDirectory(sourceServerId, configPath);
|
||||
const sourceConfig = sourceFiles.find(
|
||||
(f) => f.path === `${appName}.yml`,
|
||||
);
|
||||
if (sourceConfig) {
|
||||
result.traefikConfig.exists = true;
|
||||
const targetFiles = await scanDirectory(targetServerId, configPath);
|
||||
const targetConfig = targetFiles.find(
|
||||
(f) => f.path === `${appName}.yml`,
|
||||
);
|
||||
if (targetConfig) {
|
||||
result.traefikConfig.hasConflict = true;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Config may not exist
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Scan auto data volume for databases
|
||||
const autoVolume = getAutoDataVolumeName(serviceType, appName);
|
||||
if (autoVolume) {
|
||||
try {
|
||||
const sourceFiles = await scanMount(sourceServerId, {
|
||||
mountId: "auto",
|
||||
type: "volume",
|
||||
volumeName: autoVolume,
|
||||
mountPath: "/data",
|
||||
});
|
||||
const targetFiles = await scanMount(targetServerId, {
|
||||
mountId: "auto",
|
||||
type: "volume",
|
||||
volumeName: autoVolume,
|
||||
mountPath: "/data",
|
||||
});
|
||||
|
||||
const fileConflicts = await compareFileLists(
|
||||
sourceFiles,
|
||||
targetFiles,
|
||||
sourceServerId,
|
||||
targetServerId,
|
||||
undefined,
|
||||
autoVolume,
|
||||
);
|
||||
|
||||
result.mounts.push({
|
||||
mount: {
|
||||
mountId: "auto",
|
||||
type: "volume",
|
||||
volumeName: autoVolume,
|
||||
mountPath: "/data",
|
||||
},
|
||||
files: fileConflicts,
|
||||
totalSize: sourceFiles.reduce((sum, f) => sum + f.size, 0),
|
||||
});
|
||||
} catch {
|
||||
// Volume may not exist
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Scan user-defined mounts
|
||||
const serviceTypeForMount = serviceType as
|
||||
| "application"
|
||||
| "postgres"
|
||||
| "mysql"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "redis"
|
||||
| "compose";
|
||||
try {
|
||||
const userMounts = await findMountsByApplicationId(
|
||||
opts.serviceId,
|
||||
serviceTypeForMount,
|
||||
);
|
||||
|
||||
for (const mount of userMounts) {
|
||||
const mountConfig: MountTransferConfig = {
|
||||
mountId: mount.mountId,
|
||||
type: mount.type,
|
||||
hostPath: mount.hostPath,
|
||||
volumeName: mount.volumeName,
|
||||
mountPath: mount.mountPath,
|
||||
content: mount.content,
|
||||
filePath: mount.filePath,
|
||||
};
|
||||
|
||||
if (mount.type === "file") continue; // File mounts are DB-stored
|
||||
|
||||
try {
|
||||
const sourceFiles = await scanMount(sourceServerId, mountConfig);
|
||||
const targetFiles = await scanMount(targetServerId, mountConfig);
|
||||
|
||||
const fileConflicts = await compareFileLists(
|
||||
sourceFiles,
|
||||
targetFiles,
|
||||
sourceServerId,
|
||||
targetServerId,
|
||||
mount.type === "bind" ? mount.hostPath || undefined : undefined,
|
||||
mount.type === "volume" ? mount.volumeName || undefined : undefined,
|
||||
);
|
||||
|
||||
result.mounts.push({
|
||||
mount: mountConfig,
|
||||
files: fileConflicts,
|
||||
totalSize: sourceFiles.reduce((sum, f) => sum + f.size, 0),
|
||||
});
|
||||
} catch {
|
||||
// Individual mount scan failure shouldn't stop entire scan
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// No mounts found
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
result.totalTransferSize =
|
||||
result.serviceDirectory.totalSize +
|
||||
result.mounts.reduce((sum, m) => sum + m.totalSize, 0);
|
||||
|
||||
result.totalFiles =
|
||||
result.serviceDirectory.files.length +
|
||||
result.mounts.reduce((sum, m) => sum + m.files.length, 0);
|
||||
|
||||
result.conflicts = [
|
||||
...result.serviceDirectory.files,
|
||||
...result.mounts.flatMap((m) => m.files),
|
||||
].filter(
|
||||
(f) =>
|
||||
f.status !== "match" &&
|
||||
f.status !== "missing_target",
|
||||
);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const executeTransfer = async (
|
||||
opts: TransferOptions,
|
||||
decisions: Record<string, ConflictDecision>,
|
||||
onProgress?: (progress: TransferProgress) => void,
|
||||
): Promise<TransferResult> => {
|
||||
const { serviceType, appName, sourceServerId, targetServerId } = opts;
|
||||
const errors: string[] = [];
|
||||
let processedFiles = 0;
|
||||
let transferredBytes = 0;
|
||||
|
||||
const scan = await scanServiceForTransfer(opts);
|
||||
const totalFiles = scan.totalFiles;
|
||||
const totalBytes = scan.totalTransferSize;
|
||||
|
||||
const reportProgress = (
|
||||
phase: TransferProgress["phase"],
|
||||
message?: string,
|
||||
currentFile?: string,
|
||||
) => {
|
||||
if (processedFiles > 0) {
|
||||
const percentage = totalFiles > 0 ? Math.round((processedFiles / totalFiles) * 100) : 0;
|
||||
onProgress?.({
|
||||
phase,
|
||||
currentFile,
|
||||
processedFiles,
|
||||
totalFiles,
|
||||
transferredBytes,
|
||||
totalBytes,
|
||||
percentage,
|
||||
message,
|
||||
});
|
||||
} else {
|
||||
onProgress?.({
|
||||
phase,
|
||||
currentFile,
|
||||
processedFiles,
|
||||
totalFiles,
|
||||
transferredBytes,
|
||||
totalBytes,
|
||||
percentage: 0,
|
||||
message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Phase 1: Preflight checks
|
||||
reportProgress("preparing", "Running preflight checks...");
|
||||
|
||||
const mountConfigs: MountTransferConfig[] = scan.mounts.map(
|
||||
(m) => m.mount,
|
||||
);
|
||||
const targetBasePath = getServiceBasePath(serviceType, appName, true);
|
||||
|
||||
const preflight = await runPreflightChecks(
|
||||
targetServerId,
|
||||
targetBasePath,
|
||||
totalBytes,
|
||||
mountConfigs,
|
||||
(msg) => reportProgress("preparing", msg),
|
||||
);
|
||||
|
||||
if (!preflight.passed) {
|
||||
return { success: false, errors: preflight.errors };
|
||||
}
|
||||
|
||||
// Phase 2: Sync service directory
|
||||
if (hasServiceDirectory(serviceType)) {
|
||||
reportProgress("syncing_directory", "Syncing service directory...");
|
||||
|
||||
const sourcePath = getServiceBasePath(
|
||||
serviceType,
|
||||
appName,
|
||||
!!sourceServerId,
|
||||
);
|
||||
|
||||
try {
|
||||
await syncDirectory(
|
||||
sourceServerId,
|
||||
targetServerId,
|
||||
sourcePath,
|
||||
targetBasePath,
|
||||
(msg) => reportProgress("syncing_directory", msg),
|
||||
);
|
||||
processedFiles += scan.serviceDirectory.files.length;
|
||||
transferredBytes += scan.serviceDirectory.totalSize;
|
||||
reportProgress("syncing_directory", "Service directory synced");
|
||||
} catch (error) {
|
||||
errors.push(
|
||||
`Failed to sync service directory: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Sync Traefik config
|
||||
if (scan.traefikConfig.exists) {
|
||||
reportProgress("syncing_traefik", "Syncing Traefik configuration...");
|
||||
try {
|
||||
await syncTraefikConfig(
|
||||
sourceServerId,
|
||||
targetServerId,
|
||||
appName,
|
||||
(msg) => reportProgress("syncing_traefik", msg),
|
||||
);
|
||||
reportProgress("syncing_traefik", "Traefik config synced");
|
||||
} catch (error) {
|
||||
errors.push(
|
||||
`Failed to sync Traefik config: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 4: Sync mounts
|
||||
reportProgress("syncing_mounts", "Syncing mounts and volumes...");
|
||||
for (const mountScan of scan.mounts) {
|
||||
const mountLabel =
|
||||
mountScan.mount.volumeName ||
|
||||
mountScan.mount.hostPath ||
|
||||
mountScan.mount.mountPath;
|
||||
reportProgress("syncing_mounts", `Syncing: ${mountLabel}`, mountLabel);
|
||||
|
||||
try {
|
||||
await syncMount(
|
||||
sourceServerId,
|
||||
targetServerId,
|
||||
mountScan.mount,
|
||||
decisions,
|
||||
(msg) => reportProgress("syncing_mounts", msg),
|
||||
);
|
||||
processedFiles += mountScan.files.length;
|
||||
transferredBytes += mountScan.totalSize;
|
||||
reportProgress("syncing_mounts", `Completed: ${mountLabel}`);
|
||||
} catch (error) {
|
||||
errors.push(
|
||||
`Failed to sync mount ${mountLabel}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
reportProgress("failed", `Transfer completed with errors: ${errors.join(", ")}`);
|
||||
return { success: false, errors };
|
||||
}
|
||||
|
||||
reportProgress("completed", "Transfer completed successfully!");
|
||||
return { success: true, errors: [] };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
reportProgress("failed", `Transfer failed: ${message}`);
|
||||
return { success: false, errors: [message] };
|
||||
}
|
||||
};
|
||||
4
packages/server/src/utils/transfer/index.ts
Normal file
4
packages/server/src/utils/transfer/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./types";
|
||||
export * from "./scanner";
|
||||
export * from "./sync";
|
||||
export * from "./preflight";
|
||||
100
packages/server/src/utils/transfer/preflight.ts
Normal file
100
packages/server/src/utils/transfer/preflight.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
import type { MountTransferConfig } from "./types";
|
||||
|
||||
const execOnServer = async (
|
||||
serverId: string | null,
|
||||
command: string,
|
||||
): Promise<{ stdout: string; stderr: string }> => {
|
||||
if (serverId) {
|
||||
return execAsyncRemote(serverId, command);
|
||||
}
|
||||
return execAsync(command);
|
||||
};
|
||||
|
||||
export const ensureDirectoryExists = async (
|
||||
serverId: string | null,
|
||||
dirPath: string,
|
||||
): Promise<void> => {
|
||||
await execOnServer(serverId, `mkdir -p "${dirPath}"`);
|
||||
};
|
||||
|
||||
export const ensureVolumeExists = async (
|
||||
serverId: string | null,
|
||||
volumeName: string,
|
||||
): Promise<void> => {
|
||||
await execOnServer(
|
||||
serverId,
|
||||
`docker volume inspect ${volumeName} > /dev/null 2>&1 || docker volume create ${volumeName}`,
|
||||
);
|
||||
};
|
||||
|
||||
export const checkDiskSpace = async (
|
||||
serverId: string | null,
|
||||
path: string,
|
||||
): Promise<number> => {
|
||||
const { stdout } = await execOnServer(
|
||||
serverId,
|
||||
`df -B1 "${path}" | tail -1 | awk '{print $4}'`,
|
||||
);
|
||||
return Number.parseInt(stdout.trim(), 10);
|
||||
};
|
||||
|
||||
export const runPreflightChecks = async (
|
||||
targetServerId: string,
|
||||
targetBasePath: string,
|
||||
requiredBytes: number,
|
||||
mounts: MountTransferConfig[],
|
||||
onLog?: (message: string) => void,
|
||||
): Promise<{ passed: boolean; errors: string[] }> => {
|
||||
const errors: string[] = [];
|
||||
|
||||
onLog?.("Checking disk space on target server...");
|
||||
try {
|
||||
const availableBytes = await checkDiskSpace(targetServerId, "/");
|
||||
if (availableBytes < requiredBytes * 1.2) {
|
||||
errors.push(
|
||||
`Insufficient disk space on target server. Required: ${formatBytes(requiredBytes)}, Available: ${formatBytes(availableBytes)}`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
errors.push("Failed to check disk space on target server");
|
||||
}
|
||||
|
||||
onLog?.("Ensuring target directories exist...");
|
||||
try {
|
||||
await ensureDirectoryExists(targetServerId, targetBasePath);
|
||||
} catch {
|
||||
errors.push(`Failed to create directory: ${targetBasePath}`);
|
||||
}
|
||||
|
||||
for (const mount of mounts) {
|
||||
if (mount.type === "volume" && mount.volumeName) {
|
||||
onLog?.(`Ensuring volume exists: ${mount.volumeName}`);
|
||||
try {
|
||||
await ensureVolumeExists(targetServerId, mount.volumeName);
|
||||
} catch {
|
||||
errors.push(`Failed to create volume: ${mount.volumeName}`);
|
||||
}
|
||||
} else if (mount.type === "bind" && mount.hostPath) {
|
||||
onLog?.(`Ensuring bind mount path exists: ${mount.hostPath}`);
|
||||
try {
|
||||
await ensureDirectoryExists(targetServerId, mount.hostPath);
|
||||
} catch {
|
||||
errors.push(`Failed to create directory: ${mount.hostPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
passed: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
|
||||
};
|
||||
232
packages/server/src/utils/transfer/scanner.ts
Normal file
232
packages/server/src/utils/transfer/scanner.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
import type {
|
||||
ConflictStatus,
|
||||
FileConflict,
|
||||
FileInfo,
|
||||
MountTransferConfig,
|
||||
} from "./types";
|
||||
|
||||
export const scanDirectory = async (
|
||||
serverId: string | null,
|
||||
dirPath: string,
|
||||
): Promise<FileInfo[]> => {
|
||||
const command = `find ${dirPath} -type f -exec stat --format='%n|%s|%Y' {} + 2>/dev/null || true`;
|
||||
|
||||
let stdout: string;
|
||||
if (serverId) {
|
||||
const result = await execAsyncRemote(serverId, command);
|
||||
stdout = result.stdout;
|
||||
} else {
|
||||
const result = await execAsync(command);
|
||||
stdout = result.stdout;
|
||||
}
|
||||
|
||||
if (!stdout.trim()) return [];
|
||||
|
||||
return stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [filePath, size, modifiedAt] = line.split("|");
|
||||
return {
|
||||
path: filePath!.replace(dirPath, "").replace(/^\//, ""),
|
||||
size: Number.parseInt(size || "0", 10),
|
||||
modifiedAt: Number.parseInt(modifiedAt || "0", 10),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const scanDockerVolume = async (
|
||||
serverId: string | null,
|
||||
volumeName: string,
|
||||
): Promise<FileInfo[]> => {
|
||||
const command = `docker run --rm -v ${volumeName}:/volume alpine find /volume -type f -exec stat -c '%n|%s|%Y' {} + 2>/dev/null || true`;
|
||||
|
||||
let stdout: string;
|
||||
if (serverId) {
|
||||
const result = await execAsyncRemote(serverId, command);
|
||||
stdout = result.stdout;
|
||||
} else {
|
||||
const result = await execAsync(command);
|
||||
stdout = result.stdout;
|
||||
}
|
||||
|
||||
if (!stdout.trim()) return [];
|
||||
|
||||
return stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [filePath, size, modifiedAt] = line.split("|");
|
||||
return {
|
||||
path: (filePath || "").replace("/volume/", ""),
|
||||
size: Number.parseInt(size || "0", 10),
|
||||
modifiedAt: Number.parseInt(modifiedAt || "0", 10),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const computeFileHash = async (
|
||||
serverId: string | null,
|
||||
filePath: string,
|
||||
): Promise<string> => {
|
||||
const command = `md5sum "${filePath}" | awk '{print $1}'`;
|
||||
|
||||
let stdout: string;
|
||||
if (serverId) {
|
||||
const result = await execAsyncRemote(serverId, command);
|
||||
stdout = result.stdout;
|
||||
} else {
|
||||
const result = await execAsync(command);
|
||||
stdout = result.stdout;
|
||||
}
|
||||
|
||||
return stdout.trim();
|
||||
};
|
||||
|
||||
export const computeVolumeFileHash = async (
|
||||
serverId: string | null,
|
||||
volumeName: string,
|
||||
filePath: string,
|
||||
): Promise<string> => {
|
||||
const command = `docker run --rm -v ${volumeName}:/volume alpine md5sum "/volume/${filePath}" | awk '{print $1}'`;
|
||||
|
||||
let stdout: string;
|
||||
if (serverId) {
|
||||
const result = await execAsyncRemote(serverId, command);
|
||||
stdout = result.stdout;
|
||||
} else {
|
||||
const result = await execAsync(command);
|
||||
stdout = result.stdout;
|
||||
}
|
||||
|
||||
return stdout.trim();
|
||||
};
|
||||
|
||||
export const scanMount = async (
|
||||
serverId: string | null,
|
||||
mount: MountTransferConfig,
|
||||
): Promise<FileInfo[]> => {
|
||||
if (mount.type === "volume" && mount.volumeName) {
|
||||
return scanDockerVolume(serverId, mount.volumeName);
|
||||
}
|
||||
if (mount.type === "bind" && mount.hostPath) {
|
||||
return scanDirectory(serverId, mount.hostPath);
|
||||
}
|
||||
if (mount.type === "file") {
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
export const compareFileLists = async (
|
||||
sourceFiles: FileInfo[],
|
||||
targetFiles: FileInfo[],
|
||||
sourceServerId: string | null,
|
||||
targetServerId: string,
|
||||
basePath?: string,
|
||||
volumeName?: string,
|
||||
): Promise<FileConflict[]> => {
|
||||
const targetMap = new Map<string, FileInfo>();
|
||||
for (const f of targetFiles) {
|
||||
targetMap.set(f.path, f);
|
||||
}
|
||||
|
||||
const conflicts: FileConflict[] = [];
|
||||
|
||||
for (const sourceFile of sourceFiles) {
|
||||
const targetFile = targetMap.get(sourceFile.path);
|
||||
|
||||
if (!targetFile) {
|
||||
conflicts.push({
|
||||
path: sourceFile.path,
|
||||
status: "missing_target",
|
||||
sourceFile,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
sourceFile.size === targetFile.size &&
|
||||
sourceFile.modifiedAt === targetFile.modifiedAt
|
||||
) {
|
||||
conflicts.push({
|
||||
path: sourceFile.path,
|
||||
status: "match",
|
||||
sourceFile,
|
||||
targetFile,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let sourceHash: string;
|
||||
let targetHash: string;
|
||||
|
||||
if (volumeName) {
|
||||
sourceHash = await computeVolumeFileHash(
|
||||
sourceServerId,
|
||||
volumeName,
|
||||
sourceFile.path,
|
||||
);
|
||||
targetHash = await computeVolumeFileHash(
|
||||
targetServerId,
|
||||
volumeName,
|
||||
targetFile.path,
|
||||
);
|
||||
} else if (basePath) {
|
||||
sourceHash = await computeFileHash(
|
||||
sourceServerId,
|
||||
`${basePath}/${sourceFile.path}`,
|
||||
);
|
||||
targetHash = await computeFileHash(
|
||||
targetServerId,
|
||||
`${basePath}/${targetFile.path}`,
|
||||
);
|
||||
} else {
|
||||
sourceHash = "";
|
||||
targetHash = "";
|
||||
}
|
||||
|
||||
if (sourceHash && targetHash && sourceHash === targetHash) {
|
||||
conflicts.push({
|
||||
path: sourceFile.path,
|
||||
status: "match",
|
||||
sourceFile: { ...sourceFile, hash: sourceHash },
|
||||
targetFile: { ...targetFile, hash: targetHash },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let status: ConflictStatus;
|
||||
if (sourceFile.modifiedAt > targetFile.modifiedAt) {
|
||||
status = "newer_source";
|
||||
} else if (targetFile.modifiedAt > sourceFile.modifiedAt) {
|
||||
status = "newer_target";
|
||||
} else {
|
||||
status = "conflict";
|
||||
}
|
||||
|
||||
conflicts.push({
|
||||
path: sourceFile.path,
|
||||
status,
|
||||
sourceFile: { ...sourceFile, hash: sourceHash || undefined },
|
||||
targetFile: { ...targetFile, hash: targetHash || undefined },
|
||||
});
|
||||
}
|
||||
|
||||
for (const targetFile of targetFiles) {
|
||||
const exists = sourceFiles.some((sf) => sf.path === targetFile.path);
|
||||
if (!exists) {
|
||||
conflicts.push({
|
||||
path: targetFile.path,
|
||||
status: "newer_target",
|
||||
sourceFile: { path: targetFile.path, size: 0, modifiedAt: 0 },
|
||||
targetFile,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return conflicts;
|
||||
};
|
||||
171
packages/server/src/utils/transfer/sync.ts
Normal file
171
packages/server/src/utils/transfer/sync.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
import type { ConflictDecision, MountTransferConfig } from "./types";
|
||||
|
||||
const execOnServer = async (
|
||||
serverId: string | null,
|
||||
command: string,
|
||||
onData?: (data: string) => void,
|
||||
): Promise<{ stdout: string; stderr: string }> => {
|
||||
if (serverId) {
|
||||
return execAsyncRemote(serverId, command, onData);
|
||||
}
|
||||
return execAsync(command);
|
||||
};
|
||||
|
||||
export const syncDirectory = async (
|
||||
sourceServerId: string | null,
|
||||
targetServerId: string,
|
||||
sourcePath: string,
|
||||
targetPath: string,
|
||||
onLog?: (message: string) => void,
|
||||
): Promise<void> => {
|
||||
onLog?.(`Syncing directory: ${sourcePath} → ${targetPath}`);
|
||||
|
||||
await execOnServer(targetServerId, `mkdir -p "${targetPath}"`);
|
||||
|
||||
if (!sourceServerId && targetServerId) {
|
||||
// Local → Remote: use rsync over SSH
|
||||
const { stdout: sshKeyInfo } = await execAsyncRemote(
|
||||
targetServerId,
|
||||
"echo connected",
|
||||
);
|
||||
// Tar from local, pipe to remote via SSH
|
||||
await execAsync(
|
||||
`tar czf - -C "${sourcePath}" . 2>/dev/null | ssh -o StrictHostKeyChecking=no -i /tmp/transfer_key_${targetServerId} "tar xzf - -C ${targetPath}"`,
|
||||
).catch(async () => {
|
||||
// Fallback: read from local, write to remote via tar through dokploy
|
||||
const { stdout: tarData } = await execAsync(
|
||||
`tar czf - -C "${sourcePath}" . | base64`,
|
||||
);
|
||||
await execAsyncRemote(
|
||||
targetServerId,
|
||||
`echo "${tarData}" | base64 -d | tar xzf - -C "${targetPath}"`,
|
||||
);
|
||||
});
|
||||
} else if (sourceServerId && targetServerId) {
|
||||
// Remote → Remote: tar pipeline through Dokploy server
|
||||
onLog?.("Using tar pipeline for remote-to-remote transfer...");
|
||||
const { stdout: tarData } = await execAsyncRemote(
|
||||
sourceServerId,
|
||||
`tar czf - -C "${sourcePath}" . | base64`,
|
||||
);
|
||||
await execAsyncRemote(
|
||||
targetServerId,
|
||||
`echo "${tarData}" | base64 -d | tar xzf - -C "${targetPath}"`,
|
||||
);
|
||||
} else if (sourceServerId && !targetServerId) {
|
||||
// Remote → Local
|
||||
const { stdout: tarData } = await execAsyncRemote(
|
||||
sourceServerId,
|
||||
`tar czf - -C "${sourcePath}" . | base64`,
|
||||
);
|
||||
await execAsync(
|
||||
`echo "${tarData}" | base64 -d | tar xzf - -C "${targetPath}"`,
|
||||
);
|
||||
}
|
||||
|
||||
onLog?.(`Directory synced successfully: ${targetPath}`);
|
||||
};
|
||||
|
||||
export const syncDockerVolume = async (
|
||||
sourceServerId: string | null,
|
||||
targetServerId: string,
|
||||
volumeName: string,
|
||||
onLog?: (message: string) => void,
|
||||
): Promise<void> => {
|
||||
onLog?.(`Syncing Docker volume: ${volumeName}`);
|
||||
|
||||
await execOnServer(
|
||||
targetServerId,
|
||||
`docker volume inspect ${volumeName} > /dev/null 2>&1 || docker volume create ${volumeName}`,
|
||||
);
|
||||
|
||||
// Export volume from source as tar
|
||||
const exportCommand = `docker run --rm -v ${volumeName}:/volume alpine tar czf - -C /volume . | base64`;
|
||||
let tarData: string;
|
||||
|
||||
if (sourceServerId) {
|
||||
const result = await execAsyncRemote(sourceServerId, exportCommand);
|
||||
tarData = result.stdout;
|
||||
} else {
|
||||
const result = await execAsync(exportCommand);
|
||||
tarData = result.stdout;
|
||||
}
|
||||
|
||||
// Import volume on target
|
||||
const importCommand = `echo "${tarData}" | base64 -d | docker run --rm -i -v ${volumeName}:/volume alpine tar xzf - -C /volume`;
|
||||
|
||||
await execOnServer(targetServerId, importCommand);
|
||||
onLog?.(`Volume synced successfully: ${volumeName}`);
|
||||
};
|
||||
|
||||
export const syncMount = async (
|
||||
sourceServerId: string | null,
|
||||
targetServerId: string,
|
||||
mount: MountTransferConfig,
|
||||
_decisions: Record<string, ConflictDecision>,
|
||||
onLog?: (message: string) => void,
|
||||
): Promise<void> => {
|
||||
if (mount.type === "volume" && mount.volumeName) {
|
||||
await syncDockerVolume(
|
||||
sourceServerId,
|
||||
targetServerId,
|
||||
mount.volumeName,
|
||||
onLog,
|
||||
);
|
||||
} else if (mount.type === "bind" && mount.hostPath) {
|
||||
await syncDirectory(
|
||||
sourceServerId,
|
||||
targetServerId,
|
||||
mount.hostPath,
|
||||
mount.hostPath,
|
||||
onLog,
|
||||
);
|
||||
} else if (mount.type === "file" && mount.content) {
|
||||
onLog?.(`Syncing file mount: ${mount.mountPath}`);
|
||||
// File mounts are stored in the database, they get created during deploy
|
||||
// No file transfer needed, the content is in the DB
|
||||
onLog?.("File mount will be recreated from database content during deploy");
|
||||
}
|
||||
};
|
||||
|
||||
export const syncTraefikConfig = async (
|
||||
sourceServerId: string | null,
|
||||
targetServerId: string,
|
||||
appName: string,
|
||||
onLog?: (message: string) => void,
|
||||
): Promise<void> => {
|
||||
onLog?.(`Syncing Traefik config for: ${appName}`);
|
||||
|
||||
const configPath = "/etc/dokploy/traefik/dynamic";
|
||||
const configFile = `${configPath}/${appName}.yml`;
|
||||
|
||||
let configContent: string;
|
||||
if (sourceServerId) {
|
||||
const { stdout } = await execAsyncRemote(
|
||||
sourceServerId,
|
||||
`cat "${configFile}" 2>/dev/null || echo ""`,
|
||||
);
|
||||
configContent = stdout;
|
||||
} else {
|
||||
const { stdout } = await execAsync(
|
||||
`cat "${configFile}" 2>/dev/null || echo ""`,
|
||||
);
|
||||
configContent = stdout;
|
||||
}
|
||||
|
||||
if (!configContent.trim()) {
|
||||
onLog?.("No Traefik config found on source, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
await execOnServer(targetServerId, `mkdir -p "${configPath}"`);
|
||||
|
||||
const escapedContent = configContent.replace(/'/g, "'\\''");
|
||||
await execOnServer(
|
||||
targetServerId,
|
||||
`echo '${escapedContent}' > "${configFile}"`,
|
||||
);
|
||||
|
||||
onLog?.("Traefik config synced successfully");
|
||||
};
|
||||
91
packages/server/src/utils/transfer/types.ts
Normal file
91
packages/server/src/utils/transfer/types.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
export type ServiceType =
|
||||
| "application"
|
||||
| "compose"
|
||||
| "postgres"
|
||||
| "mysql"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "redis";
|
||||
|
||||
export interface FileInfo {
|
||||
path: string;
|
||||
size: number;
|
||||
modifiedAt: number;
|
||||
hash?: string;
|
||||
}
|
||||
|
||||
export type ConflictStatus =
|
||||
| "missing_target"
|
||||
| "newer_source"
|
||||
| "newer_target"
|
||||
| "conflict"
|
||||
| "match";
|
||||
|
||||
export interface FileConflict {
|
||||
path: string;
|
||||
status: ConflictStatus;
|
||||
sourceFile: FileInfo;
|
||||
targetFile?: FileInfo;
|
||||
}
|
||||
|
||||
export interface MountTransferConfig {
|
||||
mountId: string;
|
||||
type: "bind" | "volume" | "file";
|
||||
hostPath?: string | null;
|
||||
volumeName?: string | null;
|
||||
mountPath: string;
|
||||
content?: string | null;
|
||||
filePath?: string | null;
|
||||
}
|
||||
|
||||
export interface TransferScanResult {
|
||||
serviceDirectory: {
|
||||
files: FileConflict[];
|
||||
totalSize: number;
|
||||
};
|
||||
traefikConfig: {
|
||||
exists: boolean;
|
||||
hasConflict: boolean;
|
||||
};
|
||||
mounts: Array<{
|
||||
mount: MountTransferConfig;
|
||||
files: FileConflict[];
|
||||
totalSize: number;
|
||||
}>;
|
||||
totalTransferSize: number;
|
||||
totalFiles: number;
|
||||
conflicts: FileConflict[];
|
||||
}
|
||||
|
||||
export type ConflictDecision = "skip" | "overwrite";
|
||||
|
||||
export interface TransferProgress {
|
||||
phase:
|
||||
| "preparing"
|
||||
| "syncing_directory"
|
||||
| "syncing_traefik"
|
||||
| "syncing_mounts"
|
||||
| "updating_database"
|
||||
| "completed"
|
||||
| "failed";
|
||||
currentFile?: string;
|
||||
processedFiles: number;
|
||||
totalFiles: number;
|
||||
transferredBytes: number;
|
||||
totalBytes: number;
|
||||
percentage: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface TransferOptions {
|
||||
serviceId: string;
|
||||
serviceType: ServiceType;
|
||||
appName: string;
|
||||
sourceServerId: string | null;
|
||||
targetServerId: string;
|
||||
}
|
||||
|
||||
export interface TransferResult {
|
||||
success: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
Reference in New Issue
Block a user