feat: implement service transfer functionality

Add a new TransferService component to facilitate the transfer of various services (applications, databases, etc.) between servers. This includes updates to the ShowDatabaseAdvancedSettings component to support serverId, and integration of transfer functionality in the application, compose, mariadb, mongo, mysql, postgres, and redis routers. The transfer process includes scanning for transfer readiness and executing the transfer with appropriate permissions and error handling.
This commit is contained in:
Mauricio Siu
2026-04-13 22:36:22 -06:00
parent ddff8b9de7
commit fcbd226796
30 changed files with 2427 additions and 1 deletions

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,396 @@
import { paths } from "@dokploy/server/constants";
import path from "node:path";
import { findMountsByApplicationId } from "./mount";
import {
compareFileLists,
scanDirectory,
scanMount,
} from "../utils/transfer/scanner";
import { runPreflightChecks } from "../utils/transfer/preflight";
import {
syncDirectory,
syncDockerVolume,
syncMount,
syncTraefikConfig,
} from "../utils/transfer/sync";
import type {
ConflictDecision,
MountTransferConfig,
ServiceType,
TransferOptions,
TransferProgress,
TransferResult,
TransferScanResult,
} from "../utils/transfer/types";
const getServiceBasePath = (
serviceType: ServiceType,
appName: string,
isRemote: boolean,
): string => {
if (serviceType === "compose") {
const { COMPOSE_PATH } = paths(isRemote);
return path.join(COMPOSE_PATH, appName);
}
const { APPLICATIONS_PATH } = paths(isRemote);
return path.join(APPLICATIONS_PATH, appName);
};
const hasServiceDirectory = (serviceType: ServiceType): boolean => {
return serviceType === "application" || serviceType === "compose";
};
const getAutoDataVolumeName = (
serviceType: ServiceType,
appName: string,
): string | null => {
const dbTypes: ServiceType[] = [
"postgres",
"mysql",
"mariadb",
"mongo",
"redis",
];
if (dbTypes.includes(serviceType)) {
return `${appName}-data`;
}
return null;
};
export const scanServiceForTransfer = async (
opts: TransferOptions,
): Promise<TransferScanResult> => {
const { serviceType, appName, sourceServerId, targetServerId } = opts;
const result: TransferScanResult = {
serviceDirectory: { files: [], totalSize: 0 },
traefikConfig: { exists: false, hasConflict: false },
mounts: [],
totalTransferSize: 0,
totalFiles: 0,
conflicts: [],
};
// 1. Scan service directory (application/compose only)
if (hasServiceDirectory(serviceType)) {
const sourcePath = getServiceBasePath(
serviceType,
appName,
!!sourceServerId,
);
const targetPath = getServiceBasePath(serviceType, appName, true);
try {
const sourceFiles = await scanDirectory(sourceServerId, sourcePath);
const targetFiles = await scanDirectory(targetServerId, targetPath);
const fileConflicts = await compareFileLists(
sourceFiles,
targetFiles,
sourceServerId,
targetServerId,
sourcePath,
);
result.serviceDirectory = {
files: fileConflicts,
totalSize: sourceFiles.reduce((sum, f) => sum + f.size, 0),
};
} catch {
// Directory may not exist yet, that's ok
}
}
// 2. Check Traefik config
if (serviceType === "application" || serviceType === "compose") {
const configPath = "/etc/dokploy/traefik/dynamic";
const configFile = `${configPath}/${appName}.yml`;
try {
const sourceFiles = await scanDirectory(sourceServerId, configPath);
const sourceConfig = sourceFiles.find(
(f) => f.path === `${appName}.yml`,
);
if (sourceConfig) {
result.traefikConfig.exists = true;
const targetFiles = await scanDirectory(targetServerId, configPath);
const targetConfig = targetFiles.find(
(f) => f.path === `${appName}.yml`,
);
if (targetConfig) {
result.traefikConfig.hasConflict = true;
}
}
} catch {
// Config may not exist
}
}
// 3. Scan auto data volume for databases
const autoVolume = getAutoDataVolumeName(serviceType, appName);
if (autoVolume) {
try {
const sourceFiles = await scanMount(sourceServerId, {
mountId: "auto",
type: "volume",
volumeName: autoVolume,
mountPath: "/data",
});
const targetFiles = await scanMount(targetServerId, {
mountId: "auto",
type: "volume",
volumeName: autoVolume,
mountPath: "/data",
});
const fileConflicts = await compareFileLists(
sourceFiles,
targetFiles,
sourceServerId,
targetServerId,
undefined,
autoVolume,
);
result.mounts.push({
mount: {
mountId: "auto",
type: "volume",
volumeName: autoVolume,
mountPath: "/data",
},
files: fileConflicts,
totalSize: sourceFiles.reduce((sum, f) => sum + f.size, 0),
});
} catch {
// Volume may not exist
}
}
// 4. Scan user-defined mounts
const serviceTypeForMount = serviceType as
| "application"
| "postgres"
| "mysql"
| "mariadb"
| "mongo"
| "redis"
| "compose";
try {
const userMounts = await findMountsByApplicationId(
opts.serviceId,
serviceTypeForMount,
);
for (const mount of userMounts) {
const mountConfig: MountTransferConfig = {
mountId: mount.mountId,
type: mount.type,
hostPath: mount.hostPath,
volumeName: mount.volumeName,
mountPath: mount.mountPath,
content: mount.content,
filePath: mount.filePath,
};
if (mount.type === "file") continue; // File mounts are DB-stored
try {
const sourceFiles = await scanMount(sourceServerId, mountConfig);
const targetFiles = await scanMount(targetServerId, mountConfig);
const fileConflicts = await compareFileLists(
sourceFiles,
targetFiles,
sourceServerId,
targetServerId,
mount.type === "bind" ? mount.hostPath || undefined : undefined,
mount.type === "volume" ? mount.volumeName || undefined : undefined,
);
result.mounts.push({
mount: mountConfig,
files: fileConflicts,
totalSize: sourceFiles.reduce((sum, f) => sum + f.size, 0),
});
} catch {
// Individual mount scan failure shouldn't stop entire scan
}
}
} catch {
// No mounts found
}
// Calculate totals
result.totalTransferSize =
result.serviceDirectory.totalSize +
result.mounts.reduce((sum, m) => sum + m.totalSize, 0);
result.totalFiles =
result.serviceDirectory.files.length +
result.mounts.reduce((sum, m) => sum + m.files.length, 0);
result.conflicts = [
...result.serviceDirectory.files,
...result.mounts.flatMap((m) => m.files),
].filter(
(f) =>
f.status !== "match" &&
f.status !== "missing_target",
);
return result;
};
export const executeTransfer = async (
opts: TransferOptions,
decisions: Record<string, ConflictDecision>,
onProgress?: (progress: TransferProgress) => void,
): Promise<TransferResult> => {
const { serviceType, appName, sourceServerId, targetServerId } = opts;
const errors: string[] = [];
let processedFiles = 0;
let transferredBytes = 0;
const scan = await scanServiceForTransfer(opts);
const totalFiles = scan.totalFiles;
const totalBytes = scan.totalTransferSize;
const reportProgress = (
phase: TransferProgress["phase"],
message?: string,
currentFile?: string,
) => {
if (processedFiles > 0) {
const percentage = totalFiles > 0 ? Math.round((processedFiles / totalFiles) * 100) : 0;
onProgress?.({
phase,
currentFile,
processedFiles,
totalFiles,
transferredBytes,
totalBytes,
percentage,
message,
});
} else {
onProgress?.({
phase,
currentFile,
processedFiles,
totalFiles,
transferredBytes,
totalBytes,
percentage: 0,
message,
});
}
};
try {
// Phase 1: Preflight checks
reportProgress("preparing", "Running preflight checks...");
const mountConfigs: MountTransferConfig[] = scan.mounts.map(
(m) => m.mount,
);
const targetBasePath = getServiceBasePath(serviceType, appName, true);
const preflight = await runPreflightChecks(
targetServerId,
targetBasePath,
totalBytes,
mountConfigs,
(msg) => reportProgress("preparing", msg),
);
if (!preflight.passed) {
return { success: false, errors: preflight.errors };
}
// Phase 2: Sync service directory
if (hasServiceDirectory(serviceType)) {
reportProgress("syncing_directory", "Syncing service directory...");
const sourcePath = getServiceBasePath(
serviceType,
appName,
!!sourceServerId,
);
try {
await syncDirectory(
sourceServerId,
targetServerId,
sourcePath,
targetBasePath,
(msg) => reportProgress("syncing_directory", msg),
);
processedFiles += scan.serviceDirectory.files.length;
transferredBytes += scan.serviceDirectory.totalSize;
reportProgress("syncing_directory", "Service directory synced");
} catch (error) {
errors.push(
`Failed to sync service directory: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
// Phase 3: Sync Traefik config
if (scan.traefikConfig.exists) {
reportProgress("syncing_traefik", "Syncing Traefik configuration...");
try {
await syncTraefikConfig(
sourceServerId,
targetServerId,
appName,
(msg) => reportProgress("syncing_traefik", msg),
);
reportProgress("syncing_traefik", "Traefik config synced");
} catch (error) {
errors.push(
`Failed to sync Traefik config: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
// Phase 4: Sync mounts
reportProgress("syncing_mounts", "Syncing mounts and volumes...");
for (const mountScan of scan.mounts) {
const mountLabel =
mountScan.mount.volumeName ||
mountScan.mount.hostPath ||
mountScan.mount.mountPath;
reportProgress("syncing_mounts", `Syncing: ${mountLabel}`, mountLabel);
try {
await syncMount(
sourceServerId,
targetServerId,
mountScan.mount,
decisions,
(msg) => reportProgress("syncing_mounts", msg),
);
processedFiles += mountScan.files.length;
transferredBytes += mountScan.totalSize;
reportProgress("syncing_mounts", `Completed: ${mountLabel}`);
} catch (error) {
errors.push(
`Failed to sync mount ${mountLabel}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
if (errors.length > 0) {
reportProgress("failed", `Transfer completed with errors: ${errors.join(", ")}`);
return { success: false, errors };
}
reportProgress("completed", "Transfer completed successfully!");
return { success: true, errors: [] };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
reportProgress("failed", `Transfer failed: ${message}`);
return { success: false, errors: [message] };
}
};

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,232 @@
import { execAsync, execAsyncRemote } from "../process/execAsync";
import type {
ConflictStatus,
FileConflict,
FileInfo,
MountTransferConfig,
} from "./types";
export const scanDirectory = async (
serverId: string | null,
dirPath: string,
): Promise<FileInfo[]> => {
const command = `find ${dirPath} -type f -exec stat --format='%n|%s|%Y' {} + 2>/dev/null || true`;
let stdout: string;
if (serverId) {
const result = await execAsyncRemote(serverId, command);
stdout = result.stdout;
} else {
const result = await execAsync(command);
stdout = result.stdout;
}
if (!stdout.trim()) return [];
return stdout
.trim()
.split("\n")
.filter(Boolean)
.map((line) => {
const [filePath, size, modifiedAt] = line.split("|");
return {
path: filePath!.replace(dirPath, "").replace(/^\//, ""),
size: Number.parseInt(size || "0", 10),
modifiedAt: Number.parseInt(modifiedAt || "0", 10),
};
});
};
export const scanDockerVolume = async (
serverId: string | null,
volumeName: string,
): Promise<FileInfo[]> => {
const command = `docker run --rm -v ${volumeName}:/volume alpine find /volume -type f -exec stat -c '%n|%s|%Y' {} + 2>/dev/null || true`;
let stdout: string;
if (serverId) {
const result = await execAsyncRemote(serverId, command);
stdout = result.stdout;
} else {
const result = await execAsync(command);
stdout = result.stdout;
}
if (!stdout.trim()) return [];
return stdout
.trim()
.split("\n")
.filter(Boolean)
.map((line) => {
const [filePath, size, modifiedAt] = line.split("|");
return {
path: (filePath || "").replace("/volume/", ""),
size: Number.parseInt(size || "0", 10),
modifiedAt: Number.parseInt(modifiedAt || "0", 10),
};
});
};
export const computeFileHash = async (
serverId: string | null,
filePath: string,
): Promise<string> => {
const command = `md5sum "${filePath}" | awk '{print $1}'`;
let stdout: string;
if (serverId) {
const result = await execAsyncRemote(serverId, command);
stdout = result.stdout;
} else {
const result = await execAsync(command);
stdout = result.stdout;
}
return stdout.trim();
};
export const computeVolumeFileHash = async (
serverId: string | null,
volumeName: string,
filePath: string,
): Promise<string> => {
const command = `docker run --rm -v ${volumeName}:/volume alpine md5sum "/volume/${filePath}" | awk '{print $1}'`;
let stdout: string;
if (serverId) {
const result = await execAsyncRemote(serverId, command);
stdout = result.stdout;
} else {
const result = await execAsync(command);
stdout = result.stdout;
}
return stdout.trim();
};
export const scanMount = async (
serverId: string | null,
mount: MountTransferConfig,
): Promise<FileInfo[]> => {
if (mount.type === "volume" && mount.volumeName) {
return scanDockerVolume(serverId, mount.volumeName);
}
if (mount.type === "bind" && mount.hostPath) {
return scanDirectory(serverId, mount.hostPath);
}
if (mount.type === "file") {
return [];
}
return [];
};
export const compareFileLists = async (
sourceFiles: FileInfo[],
targetFiles: FileInfo[],
sourceServerId: string | null,
targetServerId: string,
basePath?: string,
volumeName?: string,
): Promise<FileConflict[]> => {
const targetMap = new Map<string, FileInfo>();
for (const f of targetFiles) {
targetMap.set(f.path, f);
}
const conflicts: FileConflict[] = [];
for (const sourceFile of sourceFiles) {
const targetFile = targetMap.get(sourceFile.path);
if (!targetFile) {
conflicts.push({
path: sourceFile.path,
status: "missing_target",
sourceFile,
});
continue;
}
if (
sourceFile.size === targetFile.size &&
sourceFile.modifiedAt === targetFile.modifiedAt
) {
conflicts.push({
path: sourceFile.path,
status: "match",
sourceFile,
targetFile,
});
continue;
}
let sourceHash: string;
let targetHash: string;
if (volumeName) {
sourceHash = await computeVolumeFileHash(
sourceServerId,
volumeName,
sourceFile.path,
);
targetHash = await computeVolumeFileHash(
targetServerId,
volumeName,
targetFile.path,
);
} else if (basePath) {
sourceHash = await computeFileHash(
sourceServerId,
`${basePath}/${sourceFile.path}`,
);
targetHash = await computeFileHash(
targetServerId,
`${basePath}/${targetFile.path}`,
);
} else {
sourceHash = "";
targetHash = "";
}
if (sourceHash && targetHash && sourceHash === targetHash) {
conflicts.push({
path: sourceFile.path,
status: "match",
sourceFile: { ...sourceFile, hash: sourceHash },
targetFile: { ...targetFile, hash: targetHash },
});
continue;
}
let status: ConflictStatus;
if (sourceFile.modifiedAt > targetFile.modifiedAt) {
status = "newer_source";
} else if (targetFile.modifiedAt > sourceFile.modifiedAt) {
status = "newer_target";
} else {
status = "conflict";
}
conflicts.push({
path: sourceFile.path,
status,
sourceFile: { ...sourceFile, hash: sourceHash || undefined },
targetFile: { ...targetFile, hash: targetHash || undefined },
});
}
for (const targetFile of targetFiles) {
const exists = sourceFiles.some((sf) => sf.path === targetFile.path);
if (!exists) {
conflicts.push({
path: targetFile.path,
status: "newer_target",
sourceFile: { path: targetFile.path, size: 0, modifiedAt: 0 },
targetFile,
});
}
}
return conflicts;
};

View File

@@ -0,0 +1,171 @@
import { execAsync, execAsyncRemote } from "../process/execAsync";
import type { ConflictDecision, MountTransferConfig } from "./types";
const execOnServer = async (
serverId: string | null,
command: string,
onData?: (data: string) => void,
): Promise<{ stdout: string; stderr: string }> => {
if (serverId) {
return execAsyncRemote(serverId, command, onData);
}
return execAsync(command);
};
export const syncDirectory = async (
sourceServerId: string | null,
targetServerId: string,
sourcePath: string,
targetPath: string,
onLog?: (message: string) => void,
): Promise<void> => {
onLog?.(`Syncing directory: ${sourcePath}${targetPath}`);
await execOnServer(targetServerId, `mkdir -p "${targetPath}"`);
if (!sourceServerId && targetServerId) {
// Local → Remote: use rsync over SSH
const { stdout: sshKeyInfo } = await execAsyncRemote(
targetServerId,
"echo connected",
);
// Tar from local, pipe to remote via SSH
await execAsync(
`tar czf - -C "${sourcePath}" . 2>/dev/null | ssh -o StrictHostKeyChecking=no -i /tmp/transfer_key_${targetServerId} "tar xzf - -C ${targetPath}"`,
).catch(async () => {
// Fallback: read from local, write to remote via tar through dokploy
const { stdout: tarData } = await execAsync(
`tar czf - -C "${sourcePath}" . | base64`,
);
await execAsyncRemote(
targetServerId,
`echo "${tarData}" | base64 -d | tar xzf - -C "${targetPath}"`,
);
});
} else if (sourceServerId && targetServerId) {
// Remote → Remote: tar pipeline through Dokploy server
onLog?.("Using tar pipeline for remote-to-remote transfer...");
const { stdout: tarData } = await execAsyncRemote(
sourceServerId,
`tar czf - -C "${sourcePath}" . | base64`,
);
await execAsyncRemote(
targetServerId,
`echo "${tarData}" | base64 -d | tar xzf - -C "${targetPath}"`,
);
} else if (sourceServerId && !targetServerId) {
// Remote → Local
const { stdout: tarData } = await execAsyncRemote(
sourceServerId,
`tar czf - -C "${sourcePath}" . | base64`,
);
await execAsync(
`echo "${tarData}" | base64 -d | tar xzf - -C "${targetPath}"`,
);
}
onLog?.(`Directory synced successfully: ${targetPath}`);
};
export const syncDockerVolume = async (
sourceServerId: string | null,
targetServerId: string,
volumeName: string,
onLog?: (message: string) => void,
): Promise<void> => {
onLog?.(`Syncing Docker volume: ${volumeName}`);
await execOnServer(
targetServerId,
`docker volume inspect ${volumeName} > /dev/null 2>&1 || docker volume create ${volumeName}`,
);
// Export volume from source as tar
const exportCommand = `docker run --rm -v ${volumeName}:/volume alpine tar czf - -C /volume . | base64`;
let tarData: string;
if (sourceServerId) {
const result = await execAsyncRemote(sourceServerId, exportCommand);
tarData = result.stdout;
} else {
const result = await execAsync(exportCommand);
tarData = result.stdout;
}
// Import volume on target
const importCommand = `echo "${tarData}" | base64 -d | docker run --rm -i -v ${volumeName}:/volume alpine tar xzf - -C /volume`;
await execOnServer(targetServerId, importCommand);
onLog?.(`Volume synced successfully: ${volumeName}`);
};
export const syncMount = async (
sourceServerId: string | null,
targetServerId: string,
mount: MountTransferConfig,
_decisions: Record<string, ConflictDecision>,
onLog?: (message: string) => void,
): Promise<void> => {
if (mount.type === "volume" && mount.volumeName) {
await syncDockerVolume(
sourceServerId,
targetServerId,
mount.volumeName,
onLog,
);
} else if (mount.type === "bind" && mount.hostPath) {
await syncDirectory(
sourceServerId,
targetServerId,
mount.hostPath,
mount.hostPath,
onLog,
);
} else if (mount.type === "file" && mount.content) {
onLog?.(`Syncing file mount: ${mount.mountPath}`);
// File mounts are stored in the database, they get created during deploy
// No file transfer needed, the content is in the DB
onLog?.("File mount will be recreated from database content during deploy");
}
};
export const syncTraefikConfig = async (
sourceServerId: string | null,
targetServerId: string,
appName: string,
onLog?: (message: string) => void,
): Promise<void> => {
onLog?.(`Syncing Traefik config for: ${appName}`);
const configPath = "/etc/dokploy/traefik/dynamic";
const configFile = `${configPath}/${appName}.yml`;
let configContent: string;
if (sourceServerId) {
const { stdout } = await execAsyncRemote(
sourceServerId,
`cat "${configFile}" 2>/dev/null || echo ""`,
);
configContent = stdout;
} else {
const { stdout } = await execAsync(
`cat "${configFile}" 2>/dev/null || echo ""`,
);
configContent = stdout;
}
if (!configContent.trim()) {
onLog?.("No Traefik config found on source, skipping");
return;
}
await execOnServer(targetServerId, `mkdir -p "${configPath}"`);
const escapedContent = configContent.replace(/'/g, "'\\''");
await execOnServer(
targetServerId,
`echo '${escapedContent}' > "${configFile}"`,
);
onLog?.("Traefik config synced successfully");
};

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