mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-12 01:15:23 +02:00
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:
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user