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.
This commit is contained in:
Mauricio Siu
2026-04-15 12:29:07 -06:00
parent fcbd226796
commit 2880fb9748
11 changed files with 1042 additions and 479 deletions
@@ -1,6 +1,13 @@
import { AlertTriangle, ArrowRightLeft, Loader2, Server } from "lucide-react";
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,
@@ -90,41 +97,16 @@ const formatBytes = (bytes: number): string => {
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 },
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];
};
@@ -148,15 +130,17 @@ export const TransferService = ({
}: TransferServiceProps) => {
const [targetServerId, setTargetServerId] = useState<string>("");
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
const [step, setStep] = useState<"select" | "scan" | "confirm" | "transfer">(
"select",
);
const [step, setStep] = useState<"select" | "scan" | "confirm">("select");
const [showConfirm, setShowConfirm] = useState(false);
const [transferLogs, setTransferLogs] = useState<string[]>([]);
// 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, transfer } = useTransferMutations(serviceType);
const scan = useScanMutation(serviceType);
const idKey = getServiceIdKey(serviceType);
@@ -166,6 +150,111 @@ export const TransferService = ({
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");
@@ -177,7 +266,7 @@ export const TransferService = ({
const result = await scan.mutateAsync({
[idKey]: serviceId,
targetServerId,
});
} as any);
setScanResult(result as ScanResult);
setStep("confirm");
} catch (error) {
@@ -190,38 +279,27 @@ export const TransferService = ({
const handleTransfer = async () => {
setShowConfirm(false);
setStep("transfer");
setTransferLogs([]);
setFilteredLogs([]);
setIsTransferring(true);
setIsDrawerOpen(true);
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");
}
// 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,
);
const isDbService = [
"postgres",
"mysql",
"mariadb",
"mongo",
"redis",
].includes(serviceType);
return (
<Card className="bg-background">
@@ -247,7 +325,7 @@ export const TransferService = ({
<>
{/* Step 1: Select target server */}
<div className="space-y-2">
<label className="text-sm font-medium">Target Server</label>
<span className="text-sm font-medium">Target Server</span>
<Select
value={targetServerId}
onValueChange={(value) => {
@@ -255,7 +333,7 @@ export const TransferService = ({
setScanResult(null);
setStep("select");
}}
disabled={step === "transfer"}
disabled={isTransferring}
>
<SelectTrigger>
<SelectValue placeholder="Select target server" />
@@ -365,6 +443,36 @@ export const TransferService = ({
<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 */}
@@ -379,7 +487,10 @@ export const TransferService = ({
key={conflict.path}
className="text-xs font-mono flex items-center gap-2"
>
<Badge variant="outline" className="text-[10px]">
<Badge
variant="outline"
className="text-[10px]"
>
{conflict.status}
</Badge>
<span className="truncate">
@@ -401,8 +512,8 @@ export const TransferService = ({
</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."}
? "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>
@@ -419,7 +530,7 @@ export const TransferService = ({
</Button>
<Button
onClick={() => setShowConfirm(true)}
disabled={transfer.isPending}
disabled={isTransferring}
>
<ArrowRightLeft className="mr-2 size-4" />
Transfer to {selectedServer?.name}
@@ -427,30 +538,6 @@ export const TransferService = ({
</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>
)}
</>
)}
@@ -470,12 +557,14 @@ export const TransferService = ({
{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, you must deploy the service on
the target server.
process. After transfer, the service will be
automatically deployed on the target server.
</p>
</AlertDialogDescription>
</AlertDialogHeader>
@@ -487,6 +576,20 @@ export const TransferService = ({
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Drawer for transfer logs */}
<DrawerLogs
isOpen={isDrawerOpen}
onClose={() => {
setIsDrawerOpen(false);
if (!isTransferring) {
setFilteredLogs([]);
setStep("select");
setScanResult(null);
}
}}
filteredLogs={filteredLogs}
/>
</CardContent>
</Card>
);
+43 -1
View File
@@ -1206,7 +1206,29 @@ export const applicationRouter = createTRPCRouter({
.update(applications)
.set({ serverId: input.targetServerId })
.where(eq(applications.applicationId, input.applicationId));
queue.push("Transfer completed successfully!");
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(", ")}`);
}
@@ -1272,6 +1294,26 @@ export const applicationRouter = createTRPCRouter({
.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 };
}),
});
+42 -1
View File
@@ -1240,7 +1240,28 @@ export const composeRouter = createTRPCRouter({
.update(composeTable)
.set({ serverId: input.targetServerId })
.where(eq(composeTable.composeId, input.composeId));
queue.push("Transfer completed successfully!");
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(", ")}`);
}
@@ -1306,6 +1327,26 @@ export const composeRouter = createTRPCRouter({
.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 };
}),
});
+6 -1
View File
@@ -690,7 +690,9 @@ export const mariadbRouter = createTRPCRouter({
.update(mariadbTable)
.set({ serverId: input.targetServerId })
.where(eq(mariadbTable.mariadbId, input.mariadbId));
queue.push("Transfer completed successfully!");
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(", ")}`);
}
@@ -743,6 +745,9 @@ export const mariadbRouter = createTRPCRouter({
.update(mariadbTable)
.set({ serverId: input.targetServerId })
.where(eq(mariadbTable.mariadbId, input.mariadbId));
await deployMariadb(input.mariadbId).catch(() => {});
return { success: true };
}),
});
+6 -1
View File
@@ -701,7 +701,9 @@ export const mongoRouter = createTRPCRouter({
.update(mongoTable)
.set({ serverId: input.targetServerId })
.where(eq(mongoTable.mongoId, input.mongoId));
queue.push("Transfer completed successfully!");
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(", ")}`);
}
@@ -754,6 +756,9 @@ export const mongoRouter = createTRPCRouter({
.update(mongoTable)
.set({ serverId: input.targetServerId })
.where(eq(mongoTable.mongoId, input.mongoId));
await deployMongo(input.mongoId).catch(() => {});
return { success: true };
}),
});
+6 -1
View File
@@ -704,7 +704,9 @@ export const mysqlRouter = createTRPCRouter({
.update(mysqlTable)
.set({ serverId: input.targetServerId })
.where(eq(mysqlTable.mysqlId, input.mysqlId));
queue.push("Transfer completed successfully!");
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(", ")}`);
}
@@ -757,6 +759,9 @@ export const mysqlRouter = createTRPCRouter({
.update(mysqlTable)
.set({ serverId: input.targetServerId })
.where(eq(mysqlTable.mysqlId, input.mysqlId));
await deployMySql(input.mysqlId).catch(() => {});
return { success: true };
}),
});
+6 -1
View File
@@ -714,7 +714,9 @@ export const postgresRouter = createTRPCRouter({
.update(postgresTable)
.set({ serverId: input.targetServerId })
.where(eq(postgresTable.postgresId, input.postgresId));
queue.push("Transfer completed successfully!");
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(", ")}`);
}
@@ -767,6 +769,9 @@ export const postgresRouter = createTRPCRouter({
.update(postgresTable)
.set({ serverId: input.targetServerId })
.where(eq(postgresTable.postgresId, input.postgresId));
await deployPostgres(input.postgresId).catch(() => {});
return { success: true };
}),
});
+6 -1
View File
@@ -687,7 +687,9 @@ export const redisRouter = createTRPCRouter({
.update(redisTable)
.set({ serverId: input.targetServerId })
.where(eq(redisTable.redisId, input.redisId));
queue.push("Transfer completed successfully!");
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(", ")}`);
}
@@ -740,6 +742,9 @@ export const redisRouter = createTRPCRouter({
.update(redisTable)
.set({ serverId: input.targetServerId })
.where(eq(redisTable.redisId, input.redisId));
await deployRedis(input.redisId).catch(() => {});
return { success: true };
}),
});
+240 -180
View File
@@ -3,7 +3,12 @@ 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";
@@ -57,6 +62,42 @@ const getAutoDataVolumeName = (
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> => {
@@ -80,94 +121,70 @@ export const scanServiceForTransfer = async (
);
const targetPath = getServiceBasePath(serviceType, appName, true);
try {
const sourceFiles = await scanDirectory(sourceServerId, sourcePath);
const targetFiles = await scanDirectory(targetServerId, targetPath);
const sourceFiles = await scanDirectory(sourceServerId, sourcePath);
const targetFiles = await scanDirectory(targetServerId, targetPath);
const dirSize = await getDirectorySize(sourceServerId, sourcePath);
const fileConflicts = await compareFileLists(
sourceFiles,
targetFiles,
sourceServerId,
targetServerId,
sourcePath,
);
const fileConflicts = compareFileLists(sourceFiles, targetFiles);
result.serviceDirectory = {
files: fileConflicts,
totalSize: sourceFiles.reduce((sum, f) => sum + f.size, 0),
};
} catch {
// Directory may not exist yet, that's ok
}
result.serviceDirectory = {
files: fileConflicts,
totalSize: dirSize || sourceFiles.reduce((sum, f) => sum + f.size, 0),
};
}
// 2. Check Traefik config
if (serviceType === "application" || serviceType === "compose") {
const configPath = "/etc/dokploy/traefik/dynamic";
const configFile = `${configPath}/${appName}.yml`;
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,
);
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,
if (hasSourceConfig) {
result.traefikConfig.exists = true;
const { DYNAMIC_TRAEFIK_PATH: targetTraefikPath } = paths(true);
const targetConfigFiles = await scanDirectory(
targetServerId,
undefined,
autoVolume,
targetTraefikPath,
);
result.traefikConfig.hasConflict = targetConfigFiles.some(
(f) => f.path === configFile,
);
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
// 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"
@@ -176,49 +193,51 @@ export const scanServiceForTransfer = async (
| "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,
};
const userMounts = await findMountsByApplicationId(
opts.serviceId,
serviceTypeForMount,
);
if (mount.type === "file") continue; // File mounts are DB-stored
for (const mount of userMounts) {
if (mount.type === "file") continue;
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
}
// Skip if already discovered as Docker volume
if (
mount.type === "volume" &&
mount.volumeName &&
discoveredVolumes.includes(mount.volumeName)
) {
continue;
}
} catch {
// No mounts found
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
@@ -233,11 +252,7 @@ export const scanServiceForTransfer = async (
result.conflicts = [
...result.serviceDirectory.files,
...result.mounts.flatMap((m) => m.files),
].filter(
(f) =>
f.status !== "match" &&
f.status !== "missing_target",
);
].filter((f) => f.status !== "match" && f.status !== "missing_target");
return result;
};
@@ -249,58 +264,90 @@ export const executeTransfer = async (
): 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 processedFiles = 0;
const transferredBytes = 0;
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,
});
}
onProgress?.({
phase,
currentFile,
processedFiles,
totalFiles: 0,
transferredBytes,
totalBytes: 0,
percentage: 0,
message,
});
};
try {
// Phase 1: Preflight checks
// Phase 1: Preflight
reportProgress("preparing", "Running preflight checks...");
const mountConfigs: MountTransferConfig[] = scan.mounts.map(
(m) => m.mount,
// 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,
totalBytes,
mountConfigs,
0,
allVolumeConfigs,
(msg) => reportProgress("preparing", msg),
);
@@ -326,18 +373,16 @@ export const executeTransfer = async (
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)}`,
);
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 (scan.traefikConfig.exists) {
if (serviceType === "application" || serviceType === "compose") {
reportProgress("syncing_traefik", "Syncing Traefik configuration...");
try {
await syncTraefikConfig(
@@ -346,43 +391,58 @@ export const executeTransfer = async (
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)}`,
);
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 mounts
reportProgress("syncing_mounts", "Syncing mounts and volumes...");
for (const mountScan of scan.mounts) {
// 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 =
mountScan.mount.volumeName ||
mountScan.mount.hostPath ||
mountScan.mount.mountPath;
reportProgress("syncing_mounts", `Syncing: ${mountLabel}`, mountLabel);
mountConfig.volumeName || mountConfig.hostPath || mountConfig.mountPath;
reportProgress("syncing_mounts", `Syncing: ${mountLabel}`);
try {
await syncMount(
sourceServerId,
targetServerId,
mountScan.mount,
mountConfig,
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)}`,
);
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(", ")}`);
reportProgress(
"failed",
`Transfer completed with errors: ${errors.join(", ")}`,
);
return { success: false, errors };
}
+193 -125
View File
@@ -6,103 +6,215 @@ 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 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;
// 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 [];
}
if (!stdout.trim()) 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`;
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),
};
});
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[]> => {
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;
// 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 [];
}
if (!stdout.trim()) 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'`;
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),
};
});
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> => {
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;
try {
const { stdout } = await execOnServer(
serverId,
`md5sum "${filePath}" 2>/dev/null | awk '{print $1}'`,
);
return stdout.trim();
} catch {
return "";
}
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 (
@@ -115,20 +227,13 @@ export const scanMount = async (
if (mount.type === "bind" && mount.hostPath) {
return scanDirectory(serverId, mount.hostPath);
}
if (mount.type === "file") {
return [];
}
return [];
};
export const compareFileLists = async (
export const compareFileLists = (
sourceFiles: FileInfo[],
targetFiles: FileInfo[],
sourceServerId: string | null,
targetServerId: string,
basePath?: string,
volumeName?: string,
): Promise<FileConflict[]> => {
): FileConflict[] => {
const targetMap = new Map<string, FileInfo>();
for (const f of targetFiles) {
targetMap.set(f.path, f);
@@ -161,44 +266,7 @@ export const compareFileLists = async (
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;
}
// Different size or time = conflict
let status: ConflictStatus;
if (sourceFile.modifiedAt > targetFile.modifiedAt) {
status = "newer_source";
@@ -211,14 +279,14 @@ export const compareFileLists = async (
conflicts.push({
path: sourceFile.path,
status,
sourceFile: { ...sourceFile, hash: sourceHash || undefined },
targetFile: { ...targetFile, hash: targetHash || undefined },
sourceFile,
targetFile,
});
}
// Files only on target
for (const targetFile of targetFiles) {
const exists = sourceFiles.some((sf) => sf.path === targetFile.path);
if (!exists) {
if (!sourceFiles.some((sf) => sf.path === targetFile.path)) {
conflicts.push({
path: targetFile.path,
status: "newer_target",
+289 -65
View File
@@ -1,17 +1,193 @@
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,
onData?: (data: string) => void,
): Promise<{ stdout: string; stderr: string }> => {
if (serverId) {
return execAsyncRemote(serverId, command, onData);
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,
@@ -21,47 +197,59 @@ export const syncDirectory = async (
): Promise<void> => {
onLog?.(`Syncing directory: ${sourcePath}${targetPath}`);
// Ensure target directory exists
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`,
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,
);
await execAsyncRemote(
targetServerId,
`echo "${tarData}" | base64 -d | tar xzf - -C "${targetPath}"`,
} 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,
);
});
} 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}"`,
);
} finally {
conn.end();
}
} 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?.("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}`);
@@ -75,27 +263,68 @@ export const syncDockerVolume = async (
): 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}`,
`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;
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) {
const result = await execAsyncRemote(sourceServerId, exportCommand);
tarData = result.stdout;
} else {
const result = await execAsync(exportCommand);
tarData = result.stdout;
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();
}
}
// 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}`);
};
@@ -122,9 +351,6 @@ export const syncMount = async (
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");
}
};
@@ -141,30 +367,28 @@ export const syncTraefikConfig = async (
const configFile = `${configPath}/${appName}.yml`;
let configContent: string;
if (sourceServerId) {
const { stdout } = await execAsyncRemote(
try {
const { stdout } = await execOnServer(
sourceServerId,
`cat "${configFile}" 2>/dev/null || echo ""`,
);
configContent = stdout;
} else {
const { stdout } = await execAsync(
`cat "${configFile}" 2>/dev/null || echo ""`,
`cat "${configFile}" 2>/dev/null`,
);
configContent = stdout;
} catch {
onLog?.("No Traefik config found on source, skipping");
return;
}
if (!configContent.trim()) {
onLog?.("No Traefik config found on source, skipping");
onLog?.("Empty Traefik config on source, skipping");
return;
}
await execOnServer(targetServerId, `mkdir -p "${configPath}"`);
const escapedContent = configContent.replace(/'/g, "'\\''");
const b64 = Buffer.from(configContent).toString("base64");
await execOnServer(
targetServerId,
`echo '${escapedContent}' > "${configFile}"`,
`echo "${b64}" | base64 -d > "${configFile}"`,
);
onLog?.("Traefik config synced successfully");