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

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

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",

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