Compare commits

...

2 Commits

Author SHA1 Message Date
Mauricio Siu
2880fb9748 feat: enhance transfer service with auto-deployment and logging
Refactor the TransferService component to include automatic deployment after successful transfers for various service types (application, compose, postgres, mysql, mariadb, mongo, redis). Implement logging functionality to capture transfer progress and errors, improving user feedback during the transfer process. Update related API routers to support these enhancements, ensuring a seamless transfer and deployment experience.
2026-04-15 12:29:07 -06:00
Mauricio Siu
fcbd226796 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.
2026-04-13 22:36:22 -06:00
30 changed files with 2990 additions and 1 deletions

View File

@@ -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>
);
};

View File

@@ -0,0 +1,596 @@
import {
AlertTriangle,
ArrowRightLeft,
Loader2,
Server,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { DrawerLogs } from "@/components/shared/drawer-logs";
import type { LogLine } from "@/components/dashboard/docker/logs/utils";
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 useScanMutation = (serviceType: ServiceType) => {
const mutations = {
application: api.application.transferScan.useMutation(),
compose: api.compose.transferScan.useMutation(),
postgres: api.postgres.transferScan.useMutation(),
mysql: api.mysql.transferScan.useMutation(),
mariadb: api.mariadb.transferScan.useMutation(),
mongo: api.mongo.transferScan.useMutation(),
redis: api.redis.transferScan.useMutation(),
};
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">("select");
const [showConfirm, setShowConfirm] = useState(false);
// Drawer logs state
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
const [isTransferring, setIsTransferring] = useState(false);
const { data: servers } = api.server.all.useQuery();
const utils = api.useUtils();
const scan = useScanMutation(serviceType);
const idKey = getServiceIdKey(serviceType);
const availableServers = servers?.filter(
(s) => s.serverId !== currentServerId,
);
const selectedServer = servers?.find((s) => s.serverId === targetServerId);
// Subscription for transfer with logs
const subscriptionInput = {
[idKey]: serviceId,
targetServerId: targetServerId || "placeholder",
decisions: {},
};
const useTransferSubscription = (sType: ServiceType) => {
api.application.transferWithLogs.useSubscription(subscriptionInput as any, {
enabled: isTransferring && sType === "application",
onData: handleLogData,
onError: handleLogError,
});
api.compose.transferWithLogs.useSubscription(subscriptionInput as any, {
enabled: isTransferring && sType === "compose",
onData: handleLogData,
onError: handleLogError,
});
api.postgres.transferWithLogs.useSubscription(subscriptionInput as any, {
enabled: isTransferring && sType === "postgres",
onData: handleLogData,
onError: handleLogError,
});
api.mysql.transferWithLogs.useSubscription(subscriptionInput as any, {
enabled: isTransferring && sType === "mysql",
onData: handleLogData,
onError: handleLogError,
});
api.mariadb.transferWithLogs.useSubscription(subscriptionInput as any, {
enabled: isTransferring && sType === "mariadb",
onData: handleLogData,
onError: handleLogError,
});
api.mongo.transferWithLogs.useSubscription(subscriptionInput as any, {
enabled: isTransferring && sType === "mongo",
onData: handleLogData,
onError: handleLogError,
});
api.redis.transferWithLogs.useSubscription(subscriptionInput as any, {
enabled: isTransferring && sType === "redis",
onData: handleLogData,
onError: handleLogError,
});
};
const handleLogData = (log: string) => {
if (!isDrawerOpen) {
setIsDrawerOpen(true);
}
// Try to parse as JSON progress
try {
const progress = JSON.parse(log);
if (progress.message) {
const logLine: LogLine = {
rawTimestamp: new Date().toISOString(),
timestamp: new Date(),
message: `[${progress.phase || "transfer"}] ${progress.message}`,
};
setFilteredLogs((prev) => [...prev, logLine]);
}
return;
} catch {
// Not JSON, treat as plain text
}
const logLine: LogLine = {
rawTimestamp: new Date().toISOString(),
timestamp: new Date(),
message: log,
};
setFilteredLogs((prev) => [...prev, logLine]);
if (
log.includes("completed successfully") ||
log.includes("Deployment queued") ||
log.includes("Deployment started")
) {
setTimeout(() => {
setIsTransferring(false);
utils.invalidate();
toast.success("Transfer and deployment completed!");
}, 2000);
}
if (log.includes("Transfer failed") || log.includes("Transfer error")) {
setIsTransferring(false);
toast.error("Transfer failed");
}
};
const handleLogError = (error: unknown) => {
console.error("Transfer subscription error:", error);
setIsTransferring(false);
const logLine: LogLine = {
rawTimestamp: new Date().toISOString(),
timestamp: new Date(),
message: `Error: ${error instanceof Error ? error.message : String(error)}`,
};
setFilteredLogs((prev) => [...prev, logLine]);
};
// Register the subscription hooks (must be called unconditionally)
useTransferSubscription(serviceType);
const handleScan = async () => {
if (!targetServerId) {
toast.error("Please select a target server");
return;
}
setStep("scan");
try {
const result = await scan.mutateAsync({
[idKey]: serviceId,
targetServerId,
} as any);
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);
setFilteredLogs([]);
setIsTransferring(true);
setIsDrawerOpen(true);
// Add initial log
setFilteredLogs([
{
rawTimestamp: new Date().toISOString(),
timestamp: new Date(),
message: `Starting transfer to ${selectedServer?.name} (${selectedServer?.ipAddress})...`,
},
]);
};
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">
<span className="text-sm font-medium">Target Server</span>
<Select
value={targetServerId}
onValueChange={(value) => {
setTargetServerId(value);
setScanResult(null);
setStep("select");
}}
disabled={isTransferring}
>
<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>
)}
{scanResult.mounts.length > 0 && (
<div className="space-y-1">
<span className="text-sm text-muted-foreground">
Docker Volumes:
</span>
<div className="flex flex-wrap gap-1.5">
{scanResult.mounts.map((m) => (
<Badge
key={m.mount.mountId}
variant="outline"
className="font-mono text-xs"
>
{m.mount.volumeName ||
m.mount.hostPath ||
m.mount.mountPath}
{m.totalSize > 0 && (
<span className="ml-1 text-muted-foreground">
({formatBytes(m.totalSize)})
</span>
)}
{m.files.length > 0 && (
<span className="ml-1 text-muted-foreground">
{m.files.length} files
</span>
)}
</Badge>
))}
</div>
</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. After transfer completes, the service will be automatically deployed on the target server."
: "The service will be unavailable during transfer. After transfer completes, the service will be automatically deployed on the target server."}
</p>
</div>
{/* Transfer button */}
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => {
setStep("select");
setScanResult(null);
}}
>
Cancel
</Button>
<Button
onClick={() => setShowConfirm(true)}
disabled={isTransferring}
>
<ArrowRightLeft className="mr-2 size-4" />
Transfer to {selectedServer?.name}
</Button>
</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.
{scanResult.mounts.length > 0 &&
` ${scanResult.mounts.length} volume(s) will be transferred.`}
</p>
)}
<p className="text-yellow-600 dark:text-yellow-400 font-medium">
The service will experience downtime during this
process. After transfer, the service will be
automatically deployed on the target server.
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleTransfer}>
Confirm Transfer
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Drawer for transfer logs */}
<DrawerLogs
isOpen={isDrawerOpen}
onClose={() => {
setIsDrawerOpen(false);
if (!isTransferring) {
setFilteredLogs([]);
setStep("select");
setScanResult(null);
}
}}
filteredLogs={filteredLogs}
/>
</CardContent>
</Card>
);
};

View File

@@ -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>
)}

View File

@@ -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>
)}

View File

@@ -303,6 +303,7 @@ const Mariadb = (
<ShowDatabaseAdvancedSettings
id={mariadbId}
type="mariadb"
serverId={data?.serverId}
/>
</div>
</TabsContent>

View File

@@ -307,6 +307,7 @@ const Mongo = (
<ShowDatabaseAdvancedSettings
id={mongoId}
type="mongo"
serverId={data?.serverId}
/>
</div>
</TabsContent>

View File

@@ -284,6 +284,7 @@ const MySql = (
<ShowDatabaseAdvancedSettings
id={mysqlId}
type="mysql"
serverId={data?.serverId}
/>
</div>
</TabsContent>

View File

@@ -292,6 +292,7 @@ const Postgresql = (
<ShowDatabaseAdvancedSettings
id={postgresId}
type="postgres"
serverId={data?.serverId}
/>
</div>
</TabsContent>

View File

@@ -296,6 +296,7 @@ const Redis = (
<ShowDatabaseAdvancedSettings
id={redisId}
type="redis"
serverId={data?.serverId}
/>
</div>
</TabsContent>

View File

@@ -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,180 @@ 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! Starting deployment on target server...");
// Auto-deploy on target server
const jobData: DeploymentJob = {
applicationId: input.applicationId,
titleLog: "Transfer deployment",
type: "deploy",
applicationType: "application",
descriptionLog: "Auto-deploy after transfer to new server",
server: true,
};
if (IS_CLOUD) {
jobData.serverId = input.targetServerId;
deploy(jobData).catch(() => {});
} else {
await myQueue.add("deployments", jobData, {
removeOnComplete: true,
removeOnFail: true,
});
}
queue.push("Deployment queued 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));
// Auto-deploy on target server
const jobData: DeploymentJob = {
applicationId: input.applicationId,
titleLog: "Transfer deployment",
type: "deploy",
applicationType: "application",
descriptionLog: "Auto-deploy after transfer to new server",
server: true,
};
if (IS_CLOUD) {
jobData.serverId = input.targetServerId;
deploy(jobData).catch(() => {});
} else {
await myQueue.add("deployments", jobData, {
removeOnComplete: true,
removeOnFail: true,
});
}
return { success: true };
}),
});

View File

@@ -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,179 @@ 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! Starting deployment on target server...");
const jobData: DeploymentJob = {
composeId: input.composeId,
titleLog: "Transfer deployment",
type: "deploy",
applicationType: "compose",
descriptionLog: "Auto-deploy after transfer to new server",
server: true,
};
if (IS_CLOUD) {
jobData.serverId = input.targetServerId;
deploy(jobData).catch(() => {});
} else {
await myQueue.add("deployments", jobData, {
removeOnComplete: true,
removeOnFail: true,
});
}
queue.push("Deployment queued 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));
// Auto-deploy on target server
const jobData: DeploymentJob = {
composeId: input.composeId,
titleLog: "Transfer deployment",
type: "deploy",
applicationType: "compose",
descriptionLog: "Auto-deploy after transfer to new server",
server: true,
};
if (IS_CLOUD) {
jobData.serverId = input.targetServerId;
deploy(jobData).catch(() => {});
} else {
await myQueue.add("deployments", jobData, {
removeOnComplete: true,
removeOnFail: true,
});
}
return { success: true };
}),
});

View File

@@ -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,125 @@ 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! Starting deployment on target server...");
await deployMariadb(input.mariadbId).catch(() => {});
queue.push("Deployment started!");
} 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));
await deployMariadb(input.mariadbId).catch(() => {});
return { success: true };
}),
});

View File

@@ -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,125 @@ 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! Starting deployment on target server...");
await deployMongo(input.mongoId).catch(() => {});
queue.push("Deployment started!");
} 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));
await deployMongo(input.mongoId).catch(() => {});
return { success: true };
}),
});

View File

@@ -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,125 @@ 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! Starting deployment on target server...");
await deployMySql(input.mysqlId).catch(() => {});
queue.push("Deployment started!");
} 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));
await deployMySql(input.mysqlId).catch(() => {});
return { success: true };
}),
});

View File

@@ -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,125 @@ 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! Starting deployment on target server...");
await deployPostgres(input.postgresId).catch(() => {});
queue.push("Deployment started!");
} 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));
await deployPostgres(input.postgresId).catch(() => {});
return { success: true };
}),
});

View File

@@ -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,125 @@ 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! Starting deployment on target server...");
await deployRedis(input.redisId).catch(() => {});
queue.push("Deployment started!");
} 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));
await deployRedis(input.redisId).catch(() => {});
return { success: true };
}),
});

View File

@@ -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(),
});

View File

@@ -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(),
});

View File

@@ -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(),
});

View File

@@ -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(),
});

View File

@@ -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(),
});

View File

@@ -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(),
});

View File

@@ -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(),
});

View File

@@ -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";

View File

@@ -0,0 +1,456 @@
import { paths } from "@dokploy/server/constants";
import path from "node:path";
import { findMountsByApplicationId } from "./mount";
import {
compareFileLists,
getDirectorySize,
getVolumeSize,
listComposeVolumes,
listVolumesByPrefix,
scanDirectory,
scanDockerVolume,
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;
};
/**
* Discover all Docker volumes for a service.
* For compose: uses Docker labels + prefix matching.
* For databases: uses the auto {appName}-data convention.
* For applications: uses user-defined mounts only.
*/
const discoverServiceVolumes = async (
serverId: string | null,
serviceType: ServiceType,
appName: string,
): Promise<string[]> => {
const volumes: Set<string> = new Set();
if (serviceType === "compose") {
// Get volumes by compose project label
const labelVolumes = await listComposeVolumes(serverId, appName);
for (const v of labelVolumes) {
volumes.add(v);
}
// Also try prefix matching (compose uses {projectName}_{volumeName} pattern)
const prefixVolumes = await listVolumesByPrefix(serverId, `${appName}_`);
for (const v of prefixVolumes) {
volumes.add(v);
}
}
// Auto data volume for databases
const autoVolume = getAutoDataVolumeName(serviceType, appName);
if (autoVolume) {
volumes.add(autoVolume);
}
return Array.from(volumes);
};
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);
const sourceFiles = await scanDirectory(sourceServerId, sourcePath);
const targetFiles = await scanDirectory(targetServerId, targetPath);
const dirSize = await getDirectorySize(sourceServerId, sourcePath);
const fileConflicts = compareFileLists(sourceFiles, targetFiles);
result.serviceDirectory = {
files: fileConflicts,
totalSize: dirSize || sourceFiles.reduce((sum, f) => sum + f.size, 0),
};
}
// 2. Check Traefik config
if (serviceType === "application" || serviceType === "compose") {
const { DYNAMIC_TRAEFIK_PATH } = paths(!!sourceServerId);
const configFile = `${appName}.yml`;
const sourceConfigFiles = await scanDirectory(
sourceServerId,
DYNAMIC_TRAEFIK_PATH,
);
const hasSourceConfig = sourceConfigFiles.some(
(f) => f.path === configFile,
);
if (hasSourceConfig) {
result.traefikConfig.exists = true;
const { DYNAMIC_TRAEFIK_PATH: targetTraefikPath } = paths(true);
const targetConfigFiles = await scanDirectory(
targetServerId,
targetTraefikPath,
);
result.traefikConfig.hasConflict = targetConfigFiles.some(
(f) => f.path === configFile,
);
}
}
// 3. Discover and scan ALL Docker volumes for the service
const discoveredVolumes = await discoverServiceVolumes(
sourceServerId,
serviceType,
appName,
);
for (const volumeName of discoveredVolumes) {
const sourceFiles = await scanDockerVolume(sourceServerId, volumeName);
const targetFiles = await scanDockerVolume(targetServerId, volumeName);
const volSize = await getVolumeSize(sourceServerId, volumeName);
const fileConflicts = compareFileLists(sourceFiles, targetFiles);
result.mounts.push({
mount: {
mountId: `docker-${volumeName}`,
type: "volume",
volumeName,
mountPath: "/data",
},
files: fileConflicts,
totalSize: volSize || sourceFiles.reduce((sum, f) => sum + f.size, 0),
});
}
// 4. Scan user-defined mounts from Dokploy DB
const serviceTypeForMount = serviceType as
| "application"
| "postgres"
| "mysql"
| "mariadb"
| "mongo"
| "redis"
| "compose";
const userMounts = await findMountsByApplicationId(
opts.serviceId,
serviceTypeForMount,
);
for (const mount of userMounts) {
if (mount.type === "file") continue;
// Skip if already discovered as Docker volume
if (
mount.type === "volume" &&
mount.volumeName &&
discoveredVolumes.includes(mount.volumeName)
) {
continue;
}
const mountConfig: MountTransferConfig = {
mountId: mount.mountId,
type: mount.type,
hostPath: mount.hostPath,
volumeName: mount.volumeName,
mountPath: mount.mountPath,
content: mount.content,
filePath: mount.filePath,
};
const sourceFiles = await scanMount(sourceServerId, mountConfig);
const targetFiles = await scanMount(targetServerId, mountConfig);
let mountSize = 0;
if (mount.type === "volume" && mount.volumeName) {
mountSize = await getVolumeSize(sourceServerId, mount.volumeName);
} else if (mount.type === "bind" && mount.hostPath) {
mountSize = await getDirectorySize(sourceServerId, mount.hostPath);
}
const fileConflicts = compareFileLists(sourceFiles, targetFiles);
result.mounts.push({
mount: mountConfig,
files: fileConflicts,
totalSize: mountSize || sourceFiles.reduce((sum, f) => sum + f.size, 0),
});
}
// 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[] = [];
const processedFiles = 0;
const transferredBytes = 0;
const reportProgress = (
phase: TransferProgress["phase"],
message?: string,
currentFile?: string,
) => {
onProgress?.({
phase,
currentFile,
processedFiles,
totalFiles: 0,
transferredBytes,
totalBytes: 0,
percentage: 0,
message,
});
};
try {
// Phase 1: Preflight
reportProgress("preparing", "Running preflight checks...");
// Discover all volumes
const discoveredVolumes = await discoverServiceVolumes(
sourceServerId,
serviceType,
appName,
);
// User-defined mounts
const mountConfigs: MountTransferConfig[] = [];
const serviceTypeForMount = serviceType as
| "application"
| "postgres"
| "mysql"
| "mariadb"
| "mongo"
| "redis"
| "compose";
const userMounts = await findMountsByApplicationId(
opts.serviceId,
serviceTypeForMount,
);
for (const mount of userMounts) {
if (mount.type === "file") continue;
if (
mount.type === "volume" &&
mount.volumeName &&
discoveredVolumes.includes(mount.volumeName)
) {
continue; // Will be handled as discovered volume
}
mountConfigs.push({
mountId: mount.mountId,
type: mount.type,
hostPath: mount.hostPath,
volumeName: mount.volumeName,
mountPath: mount.mountPath,
content: mount.content,
filePath: mount.filePath,
});
}
const allVolumeConfigs: MountTransferConfig[] = [
...discoveredVolumes.map((v) => ({
mountId: `docker-${v}`,
type: "volume" as const,
volumeName: v,
mountPath: "/data",
})),
...mountConfigs,
];
const targetBasePath = getServiceBasePath(serviceType, appName, true);
const preflight = await runPreflightChecks(
targetServerId,
targetBasePath,
0,
allVolumeConfigs,
(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),
);
reportProgress("syncing_directory", "Service directory synced");
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
errors.push(`Failed to sync service directory: ${msg}`);
reportProgress("syncing_directory", `Error: ${msg}`);
}
}
// Phase 3: Sync Traefik config
if (serviceType === "application" || serviceType === "compose") {
reportProgress("syncing_traefik", "Syncing Traefik configuration...");
try {
await syncTraefikConfig(
sourceServerId,
targetServerId,
appName,
(msg) => reportProgress("syncing_traefik", msg),
);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
errors.push(`Failed to sync Traefik config: ${msg}`);
reportProgress("syncing_traefik", `Error: ${msg}`);
}
}
// Phase 4: Sync all discovered Docker volumes
reportProgress("syncing_mounts", "Syncing Docker volumes...");
for (const volumeName of discoveredVolumes) {
reportProgress("syncing_mounts", `Syncing volume: ${volumeName}`);
try {
await syncDockerVolume(
sourceServerId,
targetServerId,
volumeName,
(msg) => reportProgress("syncing_mounts", msg),
);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
errors.push(`Failed to sync volume ${volumeName}: ${msg}`);
reportProgress("syncing_mounts", `Error: ${msg}`);
}
}
// Phase 5: Sync user-defined mounts (bind mounts, etc.)
for (const mountConfig of mountConfigs) {
const mountLabel =
mountConfig.volumeName || mountConfig.hostPath || mountConfig.mountPath;
reportProgress("syncing_mounts", `Syncing: ${mountLabel}`);
try {
await syncMount(
sourceServerId,
targetServerId,
mountConfig,
decisions,
(msg) => reportProgress("syncing_mounts", msg),
);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
errors.push(`Failed to sync mount ${mountLabel}: ${msg}`);
reportProgress("syncing_mounts", `Error: ${msg}`);
}
}
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] };
}
};

View File

@@ -0,0 +1,4 @@
export * from "./types";
export * from "./scanner";
export * from "./sync";
export * from "./preflight";

View 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]}`;
};

View File

@@ -0,0 +1,300 @@
import { execAsync, execAsyncRemote } from "../process/execAsync";
import type {
ConflictStatus,
FileConflict,
FileInfo,
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 scanDirectory = async (
serverId: string | null,
dirPath: string,
): Promise<FileInfo[]> => {
// Check if directory exists first
try {
const { stdout: exists } = await execOnServer(
serverId,
`test -d "${dirPath}" && echo "yes" || echo "no"`,
);
if (exists.trim() !== "yes") {
return [];
}
} catch {
return [];
}
// Use find + stat -c (POSIX-compatible on Linux)
// stat -c works on GNU coreutils (Debian, Ubuntu, etc.)
const command = `find "${dirPath}" -type f -printf '%p|%s|%T@\\n' 2>/dev/null`;
try {
const { stdout } = await execOnServer(serverId, command);
if (!stdout.trim()) return [];
return stdout
.trim()
.split("\n")
.filter(Boolean)
.map((line) => {
const parts = line.split("|");
const filePath = parts[0] || "";
const size = parts[1] || "0";
const modifiedAt = parts[2] || "0";
return {
path: filePath.replace(dirPath, "").replace(/^\//, ""),
size: Number.parseInt(size, 10),
modifiedAt: Math.floor(Number.parseFloat(modifiedAt)),
};
})
.filter((f) => f.path);
} catch {
// Fallback: try simpler ls-based approach
try {
const { stdout } = await execOnServer(
serverId,
`find "${dirPath}" -type f 2>/dev/null`,
);
if (!stdout.trim()) return [];
return stdout
.trim()
.split("\n")
.filter(Boolean)
.map((filePath) => ({
path: filePath.replace(dirPath, "").replace(/^\//, ""),
size: 0,
modifiedAt: 0,
}))
.filter((f) => f.path);
} catch {
return [];
}
}
};
export const scanDockerVolume = async (
serverId: string | null,
volumeName: string,
): Promise<FileInfo[]> => {
// First check if volume exists
try {
const { stdout: exists } = await execOnServer(
serverId,
`docker volume inspect "${volumeName}" >/dev/null 2>&1 && echo "yes" || echo "no"`,
);
if (exists.trim() !== "yes") {
return [];
}
} catch {
return [];
}
// Use busybox/alpine stat format (-c '%n|%s|%Y')
const command = `docker run --rm -v "${volumeName}":/volume:ro alpine sh -c 'find /volume -type f -exec stat -c "%n|%s|%Y" {} + 2>/dev/null || find /volume -type f 2>/dev/null'`;
try {
const { stdout } = await execOnServer(serverId, command);
if (!stdout.trim()) return [];
return stdout
.trim()
.split("\n")
.filter(Boolean)
.map((line) => {
const parts = line.split("|");
if (parts.length >= 3) {
return {
path: (parts[0] || "").replace(/^\/volume\/?/, ""),
size: Number.parseInt(parts[1] || "0", 10),
modifiedAt: Number.parseInt(parts[2] || "0", 10),
};
}
// Fallback: just file path
return {
path: line.replace(/^\/volume\/?/, ""),
size: 0,
modifiedAt: 0,
};
})
.filter((f) => f.path);
} catch {
return [];
}
};
export const getDirectorySize = async (
serverId: string | null,
dirPath: string,
): Promise<number> => {
try {
const { stdout } = await execOnServer(
serverId,
`du -sb "${dirPath}" 2>/dev/null | awk '{print $1}'`,
);
return Number.parseInt(stdout.trim(), 10) || 0;
} catch {
return 0;
}
};
export const getVolumeSize = async (
serverId: string | null,
volumeName: string,
): Promise<number> => {
try {
const { stdout } = await execOnServer(
serverId,
`docker run --rm -v "${volumeName}":/volume:ro alpine du -sb /volume 2>/dev/null | awk '{print $1}'`,
);
return Number.parseInt(stdout.trim(), 10) || 0;
} catch {
return 0;
}
};
/**
* List all Docker volumes belonging to a compose project.
* Docker compose automatically labels volumes with com.docker.compose.project
*/
export const listComposeVolumes = async (
serverId: string | null,
projectName: string,
): Promise<string[]> => {
try {
const { stdout } = await execOnServer(
serverId,
`docker volume ls --filter "label=com.docker.compose.project=${projectName}" --format "{{.Name}}" 2>/dev/null`,
);
if (!stdout.trim()) return [];
return stdout.trim().split("\n").filter(Boolean);
} catch {
return [];
}
};
/**
* List all Docker volumes that match a prefix pattern (appName_*).
* Fallback for when compose labels are not available.
*/
export const listVolumesByPrefix = async (
serverId: string | null,
prefix: string,
): Promise<string[]> => {
try {
const { stdout } = await execOnServer(
serverId,
`docker volume ls --format "{{.Name}}" 2>/dev/null | grep "^${prefix}" || true`,
);
if (!stdout.trim()) return [];
return stdout.trim().split("\n").filter(Boolean);
} catch {
return [];
}
};
export const computeFileHash = async (
serverId: string | null,
filePath: string,
): Promise<string> => {
try {
const { stdout } = await execOnServer(
serverId,
`md5sum "${filePath}" 2>/dev/null | awk '{print $1}'`,
);
return stdout.trim();
} catch {
return "";
}
};
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);
}
return [];
};
export const compareFileLists = (
sourceFiles: FileInfo[],
targetFiles: FileInfo[],
): 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;
}
// Different size or time = conflict
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,
targetFile,
});
}
// Files only on target
for (const targetFile of targetFiles) {
if (!sourceFiles.some((sf) => sf.path === targetFile.path)) {
conflicts.push({
path: targetFile.path,
status: "newer_target",
sourceFile: { path: targetFile.path, size: 0, modifiedAt: 0 },
targetFile,
});
}
}
return conflicts;
};

View File

@@ -0,0 +1,395 @@
import { spawn } from "node:child_process";
import { findServerById } from "../../services/server";
import { Client } from "ssh2";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import type { ConflictDecision, 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);
};
/**
* Get a direct SSH connection to a server.
* Used for streaming binary data (tar pipes) that can't go through execAsyncRemote.
*/
const getSSHConnection = async (
serverId: string,
): Promise<{ conn: Client }> => {
const server = await findServerById(serverId);
if (!server.sshKeyId) {
throw new Error(`No SSH key configured for server ${server.name}`);
}
return new Promise((resolve, reject) => {
const conn = new Client();
conn
.on("ready", () => {
resolve({ conn });
})
.on("error", (err) => {
reject(
new Error(
`SSH connection failed to ${server.name} (${server.ipAddress}): ${err.message}`,
),
);
})
.connect({
host: server.ipAddress,
port: server.port,
username: server.username,
privateKey: server.sshKey?.privateKey,
});
});
};
/**
* Pipe a tar stream from source SSH connection to target SSH connection.
*/
const pipeSSH = (
sourceConn: Client,
targetConn: Client,
sourceCmd: string,
targetCmd: string,
onLog?: (message: string) => void,
): Promise<void> => {
return new Promise((resolve, reject) => {
sourceConn.exec(sourceCmd, (err, sourceStream) => {
if (err) return reject(new Error(`Source exec failed: ${err.message}`));
targetConn.exec(targetCmd, (err2, targetStream) => {
if (err2)
return reject(new Error(`Target exec failed: ${err2.message}`));
let totalBytes = 0;
sourceStream.on("data", (chunk: Buffer) => {
totalBytes += chunk.length;
targetStream.write(chunk);
});
sourceStream.on("end", () => {
targetStream.end();
});
targetStream.on("close", () => {
onLog?.(
`Transferred ${(totalBytes / 1024 / 1024).toFixed(2)} MB`,
);
resolve();
});
sourceStream.on("error", (e: Error) =>
reject(new Error(`Source stream error: ${e.message}`)),
);
targetStream.on("error", (e: Error) =>
reject(new Error(`Target stream error: ${e.message}`)),
);
});
});
});
};
/**
* Stream data from local tar command into a remote SSH command.
*/
const pipeLocalToRemote = (
targetConn: Client,
localCmd: string,
localArgs: string[],
remoteCmd: string,
onLog?: (message: string) => void,
): Promise<void> => {
return new Promise((resolve, reject) => {
const localProcess = spawn(localCmd, localArgs, {
stdio: ["ignore", "pipe", "pipe"],
});
targetConn.exec(remoteCmd, (err, targetStream) => {
if (err) {
localProcess.kill();
return reject(new Error(`Remote exec failed: ${err.message}`));
}
let totalBytes = 0;
localProcess.stdout.on("data", (chunk: Buffer) => {
totalBytes += chunk.length;
targetStream.write(chunk);
});
localProcess.stdout.on("end", () => {
targetStream.end();
});
targetStream.on("close", () => {
onLog?.(
`Transferred ${(totalBytes / 1024 / 1024).toFixed(2)} MB`,
);
resolve();
});
localProcess.on("error", (e) => reject(e));
targetStream.on("error", (e: Error) => reject(e));
});
});
};
/**
* Stream data from a remote SSH command into a local tar command.
*/
const pipeRemoteToLocal = (
sourceConn: Client,
remoteCmd: string,
localCmd: string,
localArgs: string[],
onLog?: (message: string) => void,
): Promise<void> => {
return new Promise((resolve, reject) => {
const localProcess = spawn(localCmd, localArgs, {
stdio: ["pipe", "pipe", "pipe"],
});
sourceConn.exec(remoteCmd, (err, sourceStream) => {
if (err) {
localProcess.kill();
return reject(new Error(`Remote exec failed: ${err.message}`));
}
let totalBytes = 0;
sourceStream.on("data", (chunk: Buffer) => {
totalBytes += chunk.length;
localProcess.stdin.write(chunk);
});
sourceStream.on("end", () => {
localProcess.stdin.end();
});
localProcess.on("close", (code: number) => {
onLog?.(
`Transferred ${(totalBytes / 1024 / 1024).toFixed(2)} MB`,
);
if (code === 0) resolve();
else reject(new Error(`Local process exited with code ${code}`));
});
sourceStream.on("error", (e: Error) => reject(e));
localProcess.on("error", (e) => reject(e));
});
});
};
export const syncDirectory = async (
sourceServerId: string | null,
targetServerId: string,
sourcePath: string,
targetPath: string,
onLog?: (message: string) => void,
): Promise<void> => {
onLog?.(`Syncing directory: ${sourcePath}${targetPath}`);
// Ensure target directory exists
await execOnServer(targetServerId, `mkdir -p "${targetPath}"`);
if (sourceServerId && targetServerId) {
// Remote → Remote: pipe tar directly between SSH connections
onLog?.("Using direct SSH pipe for remote-to-remote transfer...");
const [source, target] = await Promise.all([
getSSHConnection(sourceServerId),
getSSHConnection(targetServerId),
]);
try {
await pipeSSH(
source.conn,
target.conn,
`tar czf - -C "${sourcePath}" . 2>/dev/null`,
`tar xzf - -C "${targetPath}"`,
onLog,
);
} finally {
source.conn.end();
target.conn.end();
}
} else if (!sourceServerId && targetServerId) {
// Local → Remote
onLog?.("Transferring from local to remote...");
const { conn } = await getSSHConnection(targetServerId);
try {
await pipeLocalToRemote(
conn,
"tar",
["czf", "-", "-C", sourcePath, "."],
`tar xzf - -C "${targetPath}"`,
onLog,
);
} finally {
conn.end();
}
} else if (sourceServerId && !targetServerId) {
// Remote → Local
onLog?.("Transferring from remote to local...");
await execAsync(`mkdir -p "${targetPath}"`);
const { conn } = await getSSHConnection(sourceServerId);
try {
await pipeRemoteToLocal(
conn,
`tar czf - -C "${sourcePath}" . 2>/dev/null`,
"tar",
["xzf", "-", "-C", targetPath],
onLog,
);
} finally {
conn.end();
}
}
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}`);
// Ensure volume exists on target
await execOnServer(
targetServerId,
`docker volume inspect "${volumeName}" > /dev/null 2>&1 || docker volume create "${volumeName}"`,
);
const srcTarCmd = `docker run --rm -v "${volumeName}":/volume:ro alpine tar czf - -C /volume . 2>/dev/null`;
const dstTarCmd = `docker run --rm -i -v "${volumeName}":/volume alpine tar xzf - -C /volume`;
if (sourceServerId && targetServerId) {
// Remote → Remote
onLog?.("Using direct SSH pipe for volume transfer...");
const [source, target] = await Promise.all([
getSSHConnection(sourceServerId),
getSSHConnection(targetServerId),
]);
try {
await pipeSSH(source.conn, target.conn, srcTarCmd, dstTarCmd, onLog);
} finally {
source.conn.end();
target.conn.end();
}
} else if (!sourceServerId && targetServerId) {
// Local → Remote
onLog?.("Transferring volume from local to remote...");
const { conn } = await getSSHConnection(targetServerId);
try {
await pipeLocalToRemote(
conn,
"docker",
[
"run", "--rm",
"-v", `${volumeName}:/volume:ro`,
"alpine", "tar", "czf", "-", "-C", "/volume", ".",
],
dstTarCmd,
onLog,
);
} finally {
conn.end();
}
} else if (sourceServerId && !targetServerId) {
// Remote → Local
onLog?.("Transferring volume from remote to local...");
const { conn } = await getSSHConnection(sourceServerId);
try {
await pipeRemoteToLocal(
conn,
srcTarCmd,
"docker",
[
"run", "--rm", "-i",
"-v", `${volumeName}:/volume`,
"alpine", "tar", "xzf", "-", "-C", "/volume",
],
onLog,
);
} finally {
conn.end();
}
}
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?.("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;
try {
const { stdout } = await execOnServer(
sourceServerId,
`cat "${configFile}" 2>/dev/null`,
);
configContent = stdout;
} catch {
onLog?.("No Traefik config found on source, skipping");
return;
}
if (!configContent.trim()) {
onLog?.("Empty Traefik config on source, skipping");
return;
}
await execOnServer(targetServerId, `mkdir -p "${configPath}"`);
const b64 = Buffer.from(configContent).toString("base64");
await execOnServer(
targetServerId,
`echo "${b64}" | base64 -d > "${configFile}"`,
);
onLog?.("Traefik config synced successfully");
};

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