diff --git a/apps/dokploy/__test__/transfer/transfer.test.ts b/apps/dokploy/__test__/transfer/transfer.test.ts new file mode 100644 index 0000000000..f3e5483161 --- /dev/null +++ b/apps/dokploy/__test__/transfer/transfer.test.ts @@ -0,0 +1,672 @@ +/** + * Tests for transfer utilities + */ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import type { FileInfo } from "@dokploy/server/utils/transfer"; +import { compareFileLists } from "@dokploy/server/utils/transfer"; + +describe("compareFileLists", () => { + const createFile = ( + path: string, + size: number, + mtime: number, + isDirectory = false, + ): FileInfo => ({ + path, + size, + mtime, + mode: isDirectory ? "755" : "644", + isDirectory, + }); + + test("should return empty array when both lists are empty", () => { + const result = compareFileLists([], []); + expect(result).toEqual([]); + }); + + test("should mark files as missing_target when only in source", () => { + const source = [createFile("/file1.txt", 100, 1000)]; + const target: FileInfo[] = []; + + const result = compareFileLists(source, target); + + expect(result).toHaveLength(1); + expect(result[0]?.status).toBe("missing_target"); + expect(result[0]?.path).toBe("/file1.txt"); + }); + + test("should mark files as missing_source when only in target", () => { + const source: FileInfo[] = []; + const target = [createFile("/file2.txt", 200, 2000)]; + + const result = compareFileLists(source, target); + + expect(result).toHaveLength(1); + expect(result[0]?.status).toBe("missing_source"); + expect(result[0]?.path).toBe("/file2.txt"); + }); + + test("should mark files as match when size and mtime are identical", () => { + const source = [createFile("/same.txt", 100, 1000)]; + const target = [createFile("/same.txt", 100, 1000)]; + + const result = compareFileLists(source, target); + + expect(result).toHaveLength(1); + expect(result[0]?.status).toBe("match"); + expect(result[0]?.targetInfo).toBeDefined(); + }); + + test("should mark files as newer_source when source mtime is greater", () => { + const source = [createFile("/updated.txt", 100, 2000)]; + const target = [createFile("/updated.txt", 100, 1000)]; + + const result = compareFileLists(source, target); + + expect(result).toHaveLength(1); + expect(result[0]?.status).toBe("newer_source"); + }); + + test("should mark files as newer_target when target mtime is greater", () => { + const source = [createFile("/old.txt", 100, 1000)]; + const target = [createFile("/old.txt", 100, 2000)]; + + const result = compareFileLists(source, target); + + expect(result).toHaveLength(1); + expect(result[0]?.status).toBe("newer_target"); + }); + + test("should mark files as conflict when same mtime but different size", () => { + const source = [createFile("/conflict.txt", 100, 1000)]; + const target = [createFile("/conflict.txt", 200, 1000)]; + + const result = compareFileLists(source, target); + + expect(result).toHaveLength(1); + expect(result[0]?.status).toBe("conflict"); + }); + + test("should handle mixed scenarios correctly", () => { + const source = [ + createFile("/only-source.txt", 100, 1000), + createFile("/match.txt", 200, 2000), + createFile("/newer-source.txt", 300, 3000), + createFile("/conflict.txt", 400, 4000), + ]; + const target = [ + createFile("/only-target.txt", 500, 5000), + createFile("/match.txt", 200, 2000), + createFile("/newer-source.txt", 300, 2000), + createFile("/conflict.txt", 450, 4000), + ]; + + const result = compareFileLists(source, target); + + expect(result).toHaveLength(5); + + const statusMap = new Map(result.map((r) => [r.path, r.status])); + expect(statusMap.get("/only-source.txt")).toBe("missing_target"); + expect(statusMap.get("/only-target.txt")).toBe("missing_source"); + expect(statusMap.get("/match.txt")).toBe("match"); + expect(statusMap.get("/newer-source.txt")).toBe("newer_source"); + expect(statusMap.get("/conflict.txt")).toBe("conflict"); + }); + + test("should handle directories correctly", () => { + const source = [createFile("/dir", 0, 1000, true)]; + const target = [createFile("/dir", 0, 1000, true)]; + + const result = compareFileLists(source, target); + + expect(result).toHaveLength(1); + expect(result[0]?.status).toBe("match"); + expect(result[0]?.isDirectory).toBe(true); + }); + + test("should include targetInfo in result when file exists on target", () => { + const source = [createFile("/file.txt", 100, 2000)]; + const target = [createFile("/file.txt", 100, 1000)]; + + const result = compareFileLists(source, target); + + expect(result[0]?.targetInfo).toBeDefined(); + expect(result[0]?.targetInfo?.mtime).toBe(1000); + }); +}); + +describe("ServiceType", () => { + // All supported service types for transfer + const SERVICE_TYPES = [ + "application", + "postgres", + "mysql", + "mariadb", + "mongo", + "redis", + "compose", + ] as const; + + // Label mapping matching the transfer-service.tsx component + const SERVICE_LABELS: Record = { + application: "Application", + compose: "Compose", + postgres: "PostgreSQL", + mysql: "MySQL", + mariadb: "MariaDB", + mongo: "MongoDB", + redis: "Redis", + }; + + test("should have all 7 service types defined", () => { + expect(SERVICE_TYPES).toHaveLength(7); + }); + + test.each(SERVICE_TYPES)("service type '%s' should have a label", (type) => { + expect(SERVICE_LABELS[type]).toBeDefined(); + expect(typeof SERVICE_LABELS[type]).toBe("string"); + expect(SERVICE_LABELS[type]?.length).toBeGreaterThan(0); + }); + + test("should include application service type", () => { + expect(SERVICE_TYPES).toContain("application"); + expect(SERVICE_LABELS.application).toBe("Application"); + }); + + test("should include compose service type", () => { + expect(SERVICE_TYPES).toContain("compose"); + expect(SERVICE_LABELS.compose).toBe("Compose"); + }); + + test("should include all database types", () => { + const dbTypes = ["postgres", "mysql", "mariadb", "mongo", "redis"]; + for (const dbType of dbTypes) { + expect(SERVICE_TYPES).toContain(dbType); + expect(SERVICE_LABELS[dbType]).toBeDefined(); + } + }); + + test("database types should have proper display names", () => { + expect(SERVICE_LABELS.postgres).toBe("PostgreSQL"); + expect(SERVICE_LABELS.mysql).toBe("MySQL"); + expect(SERVICE_LABELS.mariadb).toBe("MariaDB"); + expect(SERVICE_LABELS.mongo).toBe("MongoDB"); + expect(SERVICE_LABELS.redis).toBe("Redis"); + }); +}); + +// ============================================================================ +// Mocked Integration Tests +// ============================================================================ + +// Mock setup for execAsync +const mockExecAsync = vi.fn(); +const mockExecAsyncRemote = vi.fn(); +const mockFindServerById = vi.fn(); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: (...args: unknown[]) => mockExecAsync(...args), + execAsyncRemote: (...args: unknown[]) => mockExecAsyncRemote(...args), +})); + +vi.mock("@dokploy/server/services/server", () => ({ + findServerById: (...args: unknown[]) => mockFindServerById(...args), +})); + +import { shouldSyncFile } from "@dokploy/server/utils/transfer"; +import { + scanVolume, + scanBindMount, + syncBindFile, + syncVolumeFile, +} from "@dokploy/server/utils/transfer"; +import type { + FileCompareResult, + MergeStrategy, +} from "@dokploy/server/utils/transfer"; + +describe("shouldSyncFile", () => { + const createCompareResult = ( + status: FileCompareResult["status"], + path = "/test.txt", + ): FileCompareResult => ({ + path, + size: 100, + mtime: 1000, + mode: "644", + isDirectory: false, + status, + }); + + describe("with skip strategy", () => { + const strategy: MergeStrategy = "skip"; + + test("should not sync matching files", () => { + expect(shouldSyncFile(createCompareResult("match"), strategy)).toBe( + false, + ); + }); + + test("should sync missing_target files", () => { + expect( + shouldSyncFile(createCompareResult("missing_target"), strategy), + ).toBe(true); + }); + + test("should not sync missing_source files", () => { + expect( + shouldSyncFile(createCompareResult("missing_source"), strategy), + ).toBe(false); + }); + + test("should not sync newer_source files with skip", () => { + expect( + shouldSyncFile(createCompareResult("newer_source"), strategy), + ).toBe(false); + }); + + test("should not sync newer_target files", () => { + expect( + shouldSyncFile(createCompareResult("newer_target"), strategy), + ).toBe(false); + }); + + test("should not sync conflicts with skip", () => { + expect(shouldSyncFile(createCompareResult("conflict"), strategy)).toBe( + false, + ); + }); + }); + + describe("with overwrite strategy", () => { + const strategy: MergeStrategy = "overwrite"; + + test("should sync newer_source files", () => { + expect( + shouldSyncFile(createCompareResult("newer_source"), strategy), + ).toBe(true); + }); + + test("should sync newer_target files (source wins)", () => { + expect( + shouldSyncFile(createCompareResult("newer_target"), strategy), + ).toBe(true); + }); + + test("should sync conflicts", () => { + expect(shouldSyncFile(createCompareResult("conflict"), strategy)).toBe( + true, + ); + }); + }); + + describe("with newer strategy", () => { + const strategy: MergeStrategy = "newer"; + + test("should sync newer_source files", () => { + expect( + shouldSyncFile(createCompareResult("newer_source"), strategy), + ).toBe(true); + }); + + test("should not sync newer_target files", () => { + expect( + shouldSyncFile(createCompareResult("newer_target"), strategy), + ).toBe(false); + }); + + test("should sync conflicts (compare as conflict resolution)", () => { + expect(shouldSyncFile(createCompareResult("conflict"), strategy)).toBe( + true, + ); + }); + }); + + describe("with manual decisions", () => { + test("should honor scoped decisions to avoid cross-mount path collisions", () => { + const file = createCompareResult("newer_target", "/shared.txt"); + const decisions = { + "mount-a:/shared.txt": "overwrite" as const, + "mount-b:/shared.txt": "skip" as const, + }; + expect(shouldSyncFile(file, "skip", decisions, "mount-a")).toBe(true); + expect(shouldSyncFile(file, "overwrite", decisions, "mount-b")).toBe( + false, + ); + }); + + test("should respect manual skip decision", () => { + const file = createCompareResult("newer_source", "/override.txt"); + const decisions = { "/override.txt": "skip" as const }; + expect(shouldSyncFile(file, "overwrite", decisions)).toBe(false); + }); + + test("should respect manual overwrite decision", () => { + const file = createCompareResult("newer_target", "/force.txt"); + const decisions = { "/force.txt": "overwrite" as const }; + expect(shouldSyncFile(file, "skip", decisions)).toBe(true); + }); + + test("should use strategy when no manual decision exists", () => { + const file = createCompareResult("newer_source", "/auto.txt"); + const decisions = { "/other.txt": "skip" as const }; + expect(shouldSyncFile(file, "overwrite", decisions)).toBe(true); + }); + }); +}); + +describe("scanVolume (mocked)", () => { + beforeEach(() => { + mockExecAsync.mockReset(); + mockExecAsyncRemote.mockReset(); + }); + + test("should parse file list output correctly", async () => { + mockExecAsync.mockResolvedValue({ + stdout: + "f|/volume_data/file1.txt|100|1609459200|644\nf|/volume_data/file2.txt|200|1609459300|644\n", + }); + + const files = await scanVolume(null, "test-volume"); + + expect(files).toHaveLength(2); + expect(files[0]?.path).toBe("/file1.txt"); + expect(files[0]?.size).toBe(100); + expect(files[0]?.mtime).toBe(1609459200); + expect(files[0]?.isDirectory).toBe(false); + expect(files[1]?.path).toBe("/file2.txt"); + }); + + test("should handle directories", async () => { + mockExecAsync.mockResolvedValue({ + stdout: "d|/volume_data/subdir|0|1609459200|755\n", + }); + + const files = await scanVolume(null, "test-volume"); + + expect(files).toHaveLength(1); + expect(files[0]?.isDirectory).toBe(true); + expect(files[0]?.mode).toBe("755"); + }); + + test("should use remote exec for non-null serverId", async () => { + mockExecAsyncRemote.mockResolvedValue({ + stdout: "f|/volume_data/remote.txt|50|1609459200|644\n", + }); + + const files = await scanVolume("server-123", "remote-volume"); + + expect(mockExecAsyncRemote).toHaveBeenCalledWith( + "server-123", + expect.stringContaining("docker run"), + ); + expect(files).toHaveLength(1); + expect(files[0]?.path).toBe("/remote.txt"); + }); + + test("should handle empty volume", async () => { + mockExecAsync.mockResolvedValue({ stdout: "" }); + + const files = await scanVolume(null, "empty-volume"); + + expect(files).toHaveLength(0); + }); + + test("should call emit callback for each file", async () => { + mockExecAsync.mockResolvedValue({ + stdout: + "f|/volume_data/a.txt|10|1000|644\nf|/volume_data/b.txt|20|2000|644\n", + }); + const emit = vi.fn(); + + await scanVolume(null, "test-volume", emit); + + expect(emit).toHaveBeenCalledTimes(2); + expect(emit).toHaveBeenCalledWith( + expect.objectContaining({ path: "/a.txt" }), + ); + expect(emit).toHaveBeenCalledWith( + expect.objectContaining({ path: "/b.txt" }), + ); + }); +}); + +describe("scanBindMount (mocked)", () => { + beforeEach(() => { + mockExecAsync.mockReset(); + mockExecAsyncRemote.mockReset(); + }); + + test("should parse bind mount file list", async () => { + mockExecAsync.mockResolvedValue({ + stdout: + "f|/data/config.json|512|1609459200|644\nd|/data/logs|0|1609459100|755\n", + }); + + const files = await scanBindMount(null, "/data"); + + expect(files).toHaveLength(2); + expect(files[0]?.path).toBe("/config.json"); + expect(files[0]?.size).toBe(512); + expect(files[1]?.path).toBe("/logs"); + expect(files[1]?.isDirectory).toBe(true); + }); + + test("should use remote exec for remote server", async () => { + mockExecAsyncRemote.mockResolvedValue({ + stdout: "f|/app/data.db|1024|1609459200|644\n", + }); + + const files = await scanBindMount("server-456", "/app"); + + expect(mockExecAsyncRemote).toHaveBeenCalledWith( + "server-456", + expect.stringContaining("find"), + ); + expect(files).toHaveLength(1); + }); +}); + +describe("remote-to-remote sync helpers", () => { + beforeEach(() => { + mockExecAsync.mockReset(); + mockExecAsyncRemote.mockReset(); + mockFindServerById.mockReset(); + + mockExecAsync.mockResolvedValue({ stdout: "", stderr: "" }); + mockExecAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" }); + mockFindServerById.mockImplementation(async (serverId: string) => { + if (serverId === "source-server") { + return { + username: "root", + ipAddress: "43.161.225.99", + port: 22, + sshKey: { privateKey: "SOURCE_PRIVATE_KEY" }, + }; + } + + if (serverId === "target-server") { + return { + username: "root", + ipAddress: "101.32.14.86", + port: 22, + sshKey: { privateKey: "TARGET_PRIVATE_KEY" }, + }; + } + + throw new Error(`Unknown server id: ${serverId}`); + }); + }); + + test("syncVolumeFile should stream via Dokploy server for remote-to-remote", async () => { + await syncVolumeFile( + "source-server", + "target-server", + "source-volume", + "target-volume", + "/file.txt", + ); + + expect(mockExecAsync).toHaveBeenCalledTimes(1); + const command = mockExecAsync.mock.calls[0]?.[0]; + expect(command).toContain("43.161.225.99"); + expect(command).toContain("101.32.14.86"); + expect(command).toContain("docker run --rm -v"); + expect(command).toContain("-i "); + }); + + test("syncBindFile should stream via Dokploy server for remote-to-remote", async () => { + await syncBindFile( + "source-server", + "target-server", + "/etc/dokploy/applications/source-app", + "/etc/dokploy/applications/target-app", + "/nested/config.json", + ); + + expect(mockExecAsyncRemote).toHaveBeenCalledWith( + "target-server", + expect.stringContaining("mkdir -p"), + ); + expect(mockExecAsync).toHaveBeenCalledTimes(1); + const command = mockExecAsync.mock.calls[0]?.[0]; + expect(command).toContain("tar cf - -C"); + expect(command).toContain("tar xf - -C"); + expect(command).toContain("43.161.225.99"); + expect(command).toContain("101.32.14.86"); + expect(command).toContain("-i "); + }); +}); + +describe("remote-to-local sync helpers", () => { + beforeEach(() => { + mockExecAsync.mockReset(); + mockExecAsyncRemote.mockReset(); + mockFindServerById.mockReset(); + + mockExecAsync.mockResolvedValue({ stdout: "", stderr: "" }); + mockExecAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" }); + mockFindServerById.mockImplementation(async (serverId: string) => { + if (serverId === "source-server") { + return { + username: "root", + ipAddress: "43.161.225.99", + port: 22, + sshKey: { privateKey: "SOURCE_PRIVATE_KEY" }, + }; + } + + throw new Error(`Unknown server id: ${serverId}`); + }); + }); + + test("syncVolumeFile should stream from remote source to local target", async () => { + await syncVolumeFile( + "source-server", + null, + "source-volume", + "target-volume", + "/file.txt", + ); + + expect(mockFindServerById).toHaveBeenCalledTimes(1); + expect(mockFindServerById).toHaveBeenCalledWith("source-server"); + expect(mockExecAsyncRemote).not.toHaveBeenCalled(); + expect(mockExecAsync).toHaveBeenCalledTimes(1); + const command = mockExecAsync.mock.calls[0]?.[0]; + expect(command).toContain("43.161.225.99"); + expect(command).toContain("docker run --rm -i -v"); + }); + + test("syncBindFile should stream from remote source to local target", async () => { + await syncBindFile( + "source-server", + null, + "/etc/dokploy/applications/source-app", + "/etc/dokploy/applications/target-app", + "/nested/config.json", + ); + + expect(mockFindServerById).toHaveBeenCalledTimes(1); + expect(mockFindServerById).toHaveBeenCalledWith("source-server"); + expect(mockExecAsyncRemote).not.toHaveBeenCalled(); + expect(mockExecAsync).toHaveBeenCalledTimes(2); + expect(mockExecAsync.mock.calls[0]?.[0]).toContain("mkdir -p"); + const command = mockExecAsync.mock.calls[1]?.[0]; + expect(command).toContain("43.161.225.99"); + expect(command).toContain("tar cf - -C"); + expect(command).toContain("tar xf - -C"); + }); +}); + +describe("local-to-remote bind sync helper", () => { + beforeEach(() => { + mockExecAsync.mockReset(); + mockExecAsyncRemote.mockReset(); + mockFindServerById.mockReset(); + + mockExecAsync.mockResolvedValue({ stdout: "", stderr: "" }); + mockExecAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" }); + mockFindServerById.mockImplementation(async (serverId: string) => { + if (serverId === "target-server") { + return { + username: "root", + ipAddress: "101.32.14.86", + port: 22, + sshKey: { privateKey: "TARGET_PRIVATE_KEY" }, + }; + } + + throw new Error(`Unknown server id: ${serverId}`); + }); + }); + + test("syncBindFile should use rsync with mtime preservation", async () => { + await syncBindFile( + null, + "target-server", + "/etc/dokploy/applications/source-app", + "/etc/dokploy/applications/target-app", + "/nested/config.json", + ); + + expect(mockExecAsyncRemote).toHaveBeenCalledWith( + "target-server", + expect.stringContaining("mkdir -p"), + ); + expect(mockExecAsync).toHaveBeenCalledTimes(1); + const command = mockExecAsync.mock.calls[0]?.[0]; + expect(command).toContain("rsync -az --times"); + expect(command).not.toContain("--checksum"); + expect(command).toContain("101.32.14.86"); + }); +}); + +describe("local-to-local bind sync helper", () => { + beforeEach(() => { + mockExecAsync.mockReset(); + mockExecAsyncRemote.mockReset(); + mockFindServerById.mockReset(); + + mockExecAsync.mockResolvedValue({ stdout: "", stderr: "" }); + mockExecAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" }); + }); + + test("syncBindFile should use rsync with mtime preservation", async () => { + await syncBindFile( + null, + null, + "/etc/dokploy/applications/source-app", + "/etc/dokploy/applications/target-app", + "/nested/config.json", + ); + + expect(mockExecAsyncRemote).not.toHaveBeenCalled(); + expect(mockExecAsync).toHaveBeenCalledTimes(2); + expect(mockExecAsync.mock.calls[0]?.[0]).toContain("mkdir -p"); + + const command = mockExecAsync.mock.calls[1]?.[0]; + expect(command).toContain("rsync -az --times"); + expect(command).not.toContain("--checksum"); + }); +}); diff --git a/apps/dokploy/components/dashboard/shared/show-database-advanced-settings.tsx b/apps/dokploy/components/dashboard/shared/show-database-advanced-settings.tsx index 024ddb9680..876fc5c4e0 100644 --- a/apps/dokploy/components/dashboard/shared/show-database-advanced-settings.tsx +++ b/apps/dokploy/components/dashboard/shared/show-database-advanced-settings.tsx @@ -3,19 +3,22 @@ import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes import { ShowCustomCommand } from "@/components/dashboard/postgres/advanced/show-custom-command"; import { ShowClusterSettings } from "../application/advanced/cluster/show-cluster-settings"; import { RebuildDatabase } from "./rebuild-database"; +import { TransferService } from "./transfer-service"; interface Props { id: string; type: "postgres" | "mysql" | "mariadb" | "mongo" | "redis"; + serverId?: string | null; } -export const ShowDatabaseAdvancedSettings = ({ id, type }: Props) => { +export const ShowDatabaseAdvancedSettings = ({ id, type, serverId }: Props) => { return (
+
); diff --git a/apps/dokploy/components/dashboard/shared/transfer-service.tsx b/apps/dokploy/components/dashboard/shared/transfer-service.tsx new file mode 100644 index 0000000000..2415074e25 --- /dev/null +++ b/apps/dokploy/components/dashboard/shared/transfer-service.tsx @@ -0,0 +1,970 @@ +import { AlertTriangle, CheckCircle2, Loader2, Server } from "lucide-react"; +import { useRef, useState } from "react"; +import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { Progress } from "@/components/ui/progress"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { api } from "@/utils/api"; + +type ServiceType = + | "application" + | "compose" + | "postgres" + | "mysql" + | "mariadb" + | "mongo" + | "redis"; + +interface Props { + id: string; + type: ServiceType; + currentServerId?: string | null; +} + +type Step = "select" | "scanning" | "review" | "transferring" | "done"; + +interface ConflictFile { + path: string; + decisionKey?: string; + size: number; + mtime: number; + status: string; + hash?: string; + targetInfo?: { mtime: number; hash?: string }; +} + +interface ScanResult { + serviceDir?: { path: string; files: unknown[] }; + traefikConfig?: { + sourceExists: boolean; + targetExists: boolean; + hasConflict: boolean; + }; + volumes: Array<{ + volumeName: string; + mountPath: string; + sizeBytes: number; + files: ConflictFile[]; + }>; + binds: Array<{ + hostPath: string; + files: ConflictFile[]; + }>; + totalSizeBytes: number; + conflicts: ConflictFile[]; + hasConflicts: boolean; +} + +interface TransferProgress { + phase: string; + currentFile?: string; + processedFiles: number; + totalFiles: number; + transferredBytes: number; + totalBytes: number; + percentage: number; +} + +interface ScanProgress { + phase: string; + mount?: string; + currentFile?: string; + processedMounts: number; + totalMounts: number; + scannedFiles: number; + processedHashes: number; + totalHashes: number; +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`; +} + +function formatDate(timestamp: number): string { + if (!timestamp) return "-"; + return new Date(timestamp * 1000).toLocaleString(); +} + +function getConflictDecisionKey(conflict: ConflictFile): string { + return conflict.decisionKey || conflict.path; +} + +function getStatusBadge(status: string) { + switch (status) { + case "missing_target": + return New; + case "newer_source": + return Updated; + case "newer_target": + return ( + + Target Newer + + ); + case "conflict": + return Conflict; + case "match": + return ( + + Match + + ); + default: + return {status}; + } +} + +interface TransferInnerProps { + id: string; + type: ServiceType; + currentServerId?: string | null; + scanFn: (input: Record) => Promise; + isScanning: boolean; + scanStatusText?: string; +} + +const TransferInner = ({ + id, + type, + currentServerId, + scanFn, + isScanning, + scanStatusText, +}: TransferInnerProps) => { + const utils = api.useUtils(); + const [selectedServerId, setSelectedServerId] = useState(""); + const [step, setStep] = useState("select"); + const [scanResult, setScanResult] = useState(null); + const [decisions, setDecisions] = useState< + Record + >({}); + const [progress, setProgress] = useState(null); + const [logs, setLogs] = useState([]); + + // Shared state for controlling subscription + const [isTransferring, setIsTransferring] = useState(false); + + const { data: servers } = api.server.withSSHKey.useQuery(); + const { data: isCloud } = api.settings.isCloud.useQuery(); + + const availableServers = servers?.filter( + (server) => server.serverId !== currentServerId, + ); + const showDokployOption = !isCloud && currentServerId !== null; + const hasAvailableTargets = + (availableServers?.length ?? 0) > 0 || showDokployOption; + const hasDomainCertificateFlow = type === "application" || type === "compose"; + + const buildInput = (extra?: Record) => { + const targetServerId = + selectedServerId === "dokploy" ? null : selectedServerId; + const base: Record = { targetServerId }; + + if (type === "application") base.applicationId = id; + else if (type === "compose") base.composeId = id; + else if (type === "postgres") base.postgresId = id; + else if (type === "mysql") base.mysqlId = id; + else if (type === "mariadb") base.mariadbId = id; + else if (type === "mongo") base.mongoId = id; + else if (type === "redis") base.redisId = id; + + return { ...base, ...extra }; + }; + + // Subscription input — built for the transferWithLogs call + const subscriptionInput = buildInput({ decisions }) as never; + + // Callback for processing subscription data + const handleSubscriptionData = (log: string) => { + if (log === "Transfer completed successfully!") { + setStep("done"); + setIsTransferring(false); + toast.success("Service transferred successfully"); + utils.invalidate(); + return; + } + + if (log.startsWith("Transfer failed:")) { + setIsTransferring(false); + setStep("review"); + toast.error(log); + return; + } + + // Try to parse as progress JSON + try { + const p = JSON.parse(log) as TransferProgress; + setProgress(p); + setLogs((prev) => { + const phaseLabel = `[${p.phase}]`; + const logLine = p.currentFile + ? `${phaseLabel} ${p.currentFile}` + : phaseLabel; + + if (!p.currentFile) { + const lastLog = prev[prev.length - 1]; + if (lastLog?.startsWith(phaseLabel)) return prev; + } + + // Avoid duplicating same log + if (prev.length > 0 && prev[prev.length - 1] === logLine) return prev; + return [...prev.slice(-99), logLine]; // Keep last 100 logs + }); + } catch { + setLogs((prev) => [...prev.slice(-99), log]); + } + }; + + const handleSubscriptionError = (error: { message: string }) => { + console.error("Transfer subscription error:", error); + setIsTransferring(false); + setStep("review"); + toast.error("Transfer failed", { + description: error.message || "Subscription disconnected unexpectedly", + }); + }; + + const handleScan = async () => { + if (!selectedServerId) { + toast.error("Please select a target server"); + return; + } + try { + setStep("scanning"); + const result = (await scanFn(buildInput())) as ScanResult; + setScanResult(result); + const defaultDecisions: Record = {}; + for (const conflict of result.conflicts) { + defaultDecisions[getConflictDecisionKey(conflict)] = "overwrite"; + } + setDecisions(defaultDecisions); + setStep("review"); + } catch (error) { + toast.error("Scan failed", { + description: error instanceof Error ? error.message : "Unknown error", + }); + setStep("select"); + } + }; + + const startTransfer = () => { + setStep("transferring"); + setProgress(null); + setLogs([]); + setIsTransferring(true); + }; + + const toggleDecision = (decisionKey: string) => { + setDecisions((prev) => ({ + ...prev, + [decisionKey]: prev[decisionKey] === "overwrite" ? "skip" : "overwrite", + })); + }; + + const resetFlow = () => { + setStep("select"); + setScanResult(null); + setDecisions({}); + setSelectedServerId(""); + setIsTransferring(false); + setProgress(null); + setLogs([]); + }; + + const serviceLabels: Record = { + application: "Application", + compose: "Compose", + postgres: "PostgreSQL", + mysql: "MySQL", + mariadb: "MariaDB", + mongo: "MongoDB", + redis: "Redis", + }; + const serviceLabel = serviceLabels[type]; + + const getCurrentServerName = () => { + if (currentServerId === null || currentServerId === undefined) { + return "Dokploy (Local)"; + } + return ( + servers?.find((s) => s.serverId === currentServerId)?.name ?? + "Unknown Server" + ); + }; + + const getTargetServerName = () => { + if (selectedServerId === "dokploy") return "Dokploy (Local)"; + return ( + servers?.find((s) => s.serverId === selectedServerId)?.name ?? + "Unknown Server" + ); + }; + + const totalConflicts = scanResult?.conflicts?.length ?? 0; + + return { + subscriptionInput, + isTransferring, + handleSubscriptionData, + handleSubscriptionError, + render: hasAvailableTargets ? ( + + + + Transfer Service + + + +
+

+ Current Server:{" "} + {getCurrentServerName()} +

+ + {/* Step 1: Select server */} + {step === "select" && ( + <> +
+ + +
+ + + )} + + {/* Step 2: Scanning */} + {step === "scanning" && ( +
+ + + {scanStatusText || "Scanning files on both servers..."} + +
+ )} + + {/* Step 3: Review results */} + {step === "review" && scanResult && ( +
+
+
Target:
+
{getTargetServerName()}
+
Total size:
+
+ {formatBytes(scanResult.totalSizeBytes)} +
+
Volumes:
+
{scanResult.volumes.length}
+ {scanResult.serviceDir && ( + <> +
Service dir:
+
+ {scanResult.serviceDir.files.length} files +
+ + )} + {totalConflicts > 0 && ( + <> +
Conflicts:
+
+ {totalConflicts} +
+ + )} +
+ + {totalConflicts > 0 && ( +
+ + + + File + Size + Status + Source mtime + Target mtime + Source hash + Target hash + Action + + + + {scanResult.conflicts.map((conflict) => ( + + + {conflict.path} + + + {formatBytes(conflict.size)} + + + {getStatusBadge(conflict.status)} + + + {formatDate(conflict.mtime)} + + + {formatDate(conflict.targetInfo?.mtime ?? 0)} + + + {conflict.hash || "-"} + + + {conflict.targetInfo?.hash || "-"} + + + + + + ))} + +
+
+ )} + + {totalConflicts === 0 && ( +
+ + No conflicts detected — ready to transfer +
+ )} + +
+ + + + + + + + + + Confirm Transfer + + +
+

+ Transfer this {serviceLabel} to{" "} + + {getTargetServerName()} + + ? +

+

+ Estimated transfer size:{" "} + {formatBytes(scanResult.totalSizeBytes)} +

+ {totalConflicts > 0 && ( +

+ {totalConflicts} conflict(s) will be handled + according to your choices. +

+ )} +

+ The service will be unavailable during transfer. + Please deploy on the target server after + completion. +

+ {hasDomainCertificateFlow && ( +

+ If this service has domains, update DNS A/AAAA + records to the target server after transfer. TLS + certificates are not migrated and will be + re-issued on the target after DNS propagation. +

+ )} +
+
+
+ + Cancel + + +
+
+
+
+ )} + + {/* Step 4: Transferring with real-time logs */} + {step === "transferring" && ( +
+
+ + + {progress?.phase || "Starting transfer..."} + +
+ + + + {progress && ( +
+ + {progress.processedFiles} / {progress.totalFiles} files + + + {formatBytes(progress.transferredBytes)} /{" "} + {formatBytes(progress.totalBytes)} + + {progress.percentage}% +
+ )} + + {progress?.currentFile && ( +

+ {progress.currentFile} +

+ )} + + {logs.length > 0 && ( + +
+ {logs.map((log, i) => ( +

+ {log} +

+ ))} +
+
+ )} + +

+ Do not close this page during transfer. +

+
+ )} + + {/* Step 5: Done */} + {step === "done" && ( +
+
+ + + Transfer completed successfully! + +
+

+ The service has been transferred to {getTargetServerName()}. + You may need to deploy the service on the target server. +

+ {hasDomainCertificateFlow && ( +

+ For domain services: update DNS A/AAAA to the target, wait + for propagation, then open the HTTPS URL to trigger + certificate issuance on target Traefik. If it still fails, + redeploy the service or restart Traefik and retry. +

+ )} + +
+ )} +
+
+
+ ) : null, + }; +}; + +// Per-service wrapper components that call the correct hooks at the top level + +function ApplicationTransfer({ + id, + currentServerId, +}: { + id: string; + currentServerId?: string | null; +}) { + const scan = api.application.transferScan.useMutation(); + const inner = TransferInner({ + id, + type: "application", + currentServerId, + scanFn: (input) => scan.mutateAsync(input as never), + isScanning: scan.isLoading, + }); + + api.application.transferWithLogs.useSubscription(inner.subscriptionInput, { + enabled: inner.isTransferring, + onData: inner.handleSubscriptionData, + onError: inner.handleSubscriptionError, + }); + + return inner.render; +} + +function ComposeTransfer({ + id, + currentServerId, +}: { + id: string; + currentServerId?: string | null; +}) { + const [scanInput, setScanInput] = useState | null>( + null, + ); + const [isScanStreaming, setIsScanStreaming] = useState(false); + const [scanStatusText, setScanStatusText] = useState(); + const scanPromiseRef = useRef<{ + resolve: (value: unknown) => void; + reject: (reason: Error) => void; + } | null>(null); + const inner = TransferInner({ + id, + type: "compose", + currentServerId, + scanFn: (input) => { + if (scanPromiseRef.current) { + return Promise.reject(new Error("Scan already in progress")); + } + setScanInput(input); + setIsScanStreaming(true); + setScanStatusText("Preparing scan..."); + return new Promise((resolve, reject) => { + scanPromiseRef.current = { resolve, reject }; + }); + }, + isScanning: isScanStreaming, + scanStatusText, + }); + + api.compose.transferScanWithLogs.useSubscription( + (scanInput || { composeId: id, targetServerId: null }) as never, + { + enabled: isScanStreaming && !!scanInput, + onData: (data) => { + try { + const event = JSON.parse(data) as { + type: "scan_progress" | "scan_complete" | "scan_error"; + payload?: unknown; + }; + if (event.type === "scan_progress") { + const payload = (event.payload || {}) as ScanProgress; + const mountLabel = payload.mount ? ` (${payload.mount})` : ""; + const countsLabel = + payload.totalMounts > 0 + ? ` ${payload.processedMounts}/${payload.totalMounts}` + : ""; + const fileLabel = + payload.scannedFiles > 0 + ? ` • ${payload.scannedFiles} files` + : ""; + setScanStatusText( + `${payload.phase || "Scanning"}${mountLabel}${countsLabel}${fileLabel}`, + ); + return; + } + + if (event.type === "scan_complete") { + setIsScanStreaming(false); + setScanInput(null); + setScanStatusText(undefined); + const pending = scanPromiseRef.current; + scanPromiseRef.current = null; + pending?.resolve(event.payload); + return; + } + + if (event.type === "scan_error") { + const payload = (event.payload || {}) as { message?: string }; + const pending = scanPromiseRef.current; + scanPromiseRef.current = null; + setIsScanStreaming(false); + setScanInput(null); + setScanStatusText(undefined); + pending?.reject(new Error(payload.message || "Scan failed")); + } + } catch { + const pending = scanPromiseRef.current; + scanPromiseRef.current = null; + setIsScanStreaming(false); + setScanInput(null); + setScanStatusText(undefined); + pending?.reject(new Error("Invalid scan response")); + } + }, + onError: (error) => { + const pending = scanPromiseRef.current; + scanPromiseRef.current = null; + setIsScanStreaming(false); + setScanInput(null); + setScanStatusText(undefined); + pending?.reject( + new Error(error.message || "Scan stream disconnected unexpectedly"), + ); + }, + }, + ); + + api.compose.transferWithLogs.useSubscription(inner.subscriptionInput, { + enabled: inner.isTransferring, + onData: inner.handleSubscriptionData, + onError: inner.handleSubscriptionError, + }); + + return inner.render; +} + +function PostgresTransfer({ + id, + currentServerId, +}: { + id: string; + currentServerId?: string | null; +}) { + const scan = api.postgres.transferScan.useMutation(); + const inner = TransferInner({ + id, + type: "postgres", + currentServerId, + scanFn: (input) => scan.mutateAsync(input as never), + isScanning: scan.isLoading, + }); + + api.postgres.transferWithLogs.useSubscription(inner.subscriptionInput, { + enabled: inner.isTransferring, + onData: inner.handleSubscriptionData, + onError: inner.handleSubscriptionError, + }); + + return inner.render; +} + +function MysqlTransfer({ + id, + currentServerId, +}: { + id: string; + currentServerId?: string | null; +}) { + const scan = api.mysql.transferScan.useMutation(); + const inner = TransferInner({ + id, + type: "mysql", + currentServerId, + scanFn: (input) => scan.mutateAsync(input as never), + isScanning: scan.isLoading, + }); + + api.mysql.transferWithLogs.useSubscription(inner.subscriptionInput, { + enabled: inner.isTransferring, + onData: inner.handleSubscriptionData, + onError: inner.handleSubscriptionError, + }); + + return inner.render; +} + +function MariadbTransfer({ + id, + currentServerId, +}: { + id: string; + currentServerId?: string | null; +}) { + const scan = api.mariadb.transferScan.useMutation(); + const inner = TransferInner({ + id, + type: "mariadb", + currentServerId, + scanFn: (input) => scan.mutateAsync(input as never), + isScanning: scan.isLoading, + }); + + api.mariadb.transferWithLogs.useSubscription(inner.subscriptionInput, { + enabled: inner.isTransferring, + onData: inner.handleSubscriptionData, + onError: inner.handleSubscriptionError, + }); + + return inner.render; +} + +function MongoTransfer({ + id, + currentServerId, +}: { + id: string; + currentServerId?: string | null; +}) { + const scan = api.mongo.transferScan.useMutation(); + const inner = TransferInner({ + id, + type: "mongo", + currentServerId, + scanFn: (input) => scan.mutateAsync(input as never), + isScanning: scan.isLoading, + }); + + api.mongo.transferWithLogs.useSubscription(inner.subscriptionInput, { + enabled: inner.isTransferring, + onData: inner.handleSubscriptionData, + onError: inner.handleSubscriptionError, + }); + + return inner.render; +} + +function RedisTransfer({ + id, + currentServerId, +}: { + id: string; + currentServerId?: string | null; +}) { + const scan = api.redis.transferScan.useMutation(); + const inner = TransferInner({ + id, + type: "redis", + currentServerId, + scanFn: (input) => scan.mutateAsync(input as never), + isScanning: scan.isLoading, + }); + + api.redis.transferWithLogs.useSubscription(inner.subscriptionInput, { + enabled: inner.isTransferring, + onData: inner.handleSubscriptionData, + onError: inner.handleSubscriptionError, + }); + + return inner.render; +} + +export const TransferService = ({ id, type, currentServerId }: Props) => { + switch (type) { + case "application": + return ; + case "compose": + return ; + case "postgres": + return ; + case "mysql": + return ; + case "mariadb": + return ; + case "mongo": + return ; + case "redis": + return ; + } +}; diff --git a/apps/dokploy/components/proprietary/auth/sign-in-with-github.tsx b/apps/dokploy/components/proprietary/auth/sign-in-with-github.tsx index 988eeae050..22b0aef811 100644 --- a/apps/dokploy/components/proprietary/auth/sign-in-with-github.tsx +++ b/apps/dokploy/components/proprietary/auth/sign-in-with-github.tsx @@ -2,8 +2,8 @@ import { useState } from "react"; import { toast } from "sonner"; -import { authClient } from "@/lib/auth-client"; import { Button } from "@/components/ui/button"; +import { authClient } from "@/lib/auth-client"; export function SignInWithGithub() { const [isLoading, setIsLoading] = useState(false); diff --git a/apps/dokploy/components/proprietary/auth/sign-in-with-google.tsx b/apps/dokploy/components/proprietary/auth/sign-in-with-google.tsx index bff0e69ab8..e40d8d9b5b 100644 --- a/apps/dokploy/components/proprietary/auth/sign-in-with-google.tsx +++ b/apps/dokploy/components/proprietary/auth/sign-in-with-google.tsx @@ -2,8 +2,8 @@ import { useState } from "react"; import { toast } from "sonner"; -import { authClient } from "@/lib/auth-client"; import { Button } from "@/components/ui/button"; +import { authClient } from "@/lib/auth-client"; export function SignInWithGoogle() { const [isLoading, setIsLoading] = useState(false); diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx index 7917bd97c7..776361538b 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx @@ -33,6 +33,7 @@ import { ShowVolumeBackups } from "@/components/dashboard/application/volume-bac import { DeleteService } from "@/components/dashboard/compose/delete-service"; import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring"; import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring"; +import { TransferService } from "@/components/dashboard/shared/transfer-service"; import { DashboardLayout } from "@/components/layouts/dashboard-layout"; import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar"; import { StatusTooltip } from "@/components/shared/status-tooltip"; @@ -373,6 +374,11 @@ const Service = ( + diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx index 1d6902c59f..dfd7434f33 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx @@ -29,6 +29,7 @@ import { UpdateCompose } from "@/components/dashboard/compose/update-compose"; import { ShowBackups } from "@/components/dashboard/database/backups/show-backups"; import { ComposeFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-compose-monitoring"; import { ComposePaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-compose-monitoring"; +import { TransferService } from "@/components/dashboard/shared/transfer-service"; import { DashboardLayout } from "@/components/layouts/dashboard-layout"; import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar"; import { StatusTooltip } from "@/components/shared/status-tooltip"; @@ -367,6 +368,11 @@ const Service = ( + diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mariadb/[mariadbId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mariadb/[mariadbId].tsx index 0a1e8501de..6b906bc4c8 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mariadb/[mariadbId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mariadb/[mariadbId].tsx @@ -296,6 +296,7 @@ const Mariadb = ( diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mongo/[mongoId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mongo/[mongoId].tsx index bae83cb2b2..280752109d 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mongo/[mongoId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mongo/[mongoId].tsx @@ -296,7 +296,11 @@ const Mongo = (
- +
diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mysql/[mysqlId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mysql/[mysqlId].tsx index ba2b9d8a03..c6bd83ea10 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mysql/[mysqlId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mysql/[mysqlId].tsx @@ -280,6 +280,7 @@ const MySql = ( diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/postgres/[postgresId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/postgres/[postgresId].tsx index 1d90e3e133..e8090436f4 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/postgres/[postgresId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/postgres/[postgresId].tsx @@ -284,6 +284,7 @@ const Postgresql = ( diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/redis/[redisId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/redis/[redisId].tsx index 47eb82a742..af7a64039a 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/redis/[redisId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/redis/[redisId].tsx @@ -285,7 +285,11 @@ const Redis = (
- +
diff --git a/apps/dokploy/server/api/root.ts b/apps/dokploy/server/api/root.ts index c8b4295fe8..5792e8a276 100644 --- a/apps/dokploy/server/api/root.ts +++ b/apps/dokploy/server/api/root.ts @@ -22,12 +22,12 @@ import { mountRouter } from "./routers/mount"; import { mysqlRouter } from "./routers/mysql"; import { notificationRouter } from "./routers/notification"; import { organizationRouter } from "./routers/organization"; -import { licenseKeyRouter } from "./routers/proprietary/license-key"; -import { ssoRouter } from "./routers/proprietary/sso"; import { portRouter } from "./routers/port"; import { postgresRouter } from "./routers/postgres"; import { previewDeploymentRouter } from "./routers/preview-deployment"; import { projectRouter } from "./routers/project"; +import { licenseKeyRouter } from "./routers/proprietary/license-key"; +import { ssoRouter } from "./routers/proprietary/sso"; import { redirectsRouter } from "./routers/redirects"; import { redisRouter } from "./routers/redis"; import { registryRouter } from "./routers/registry"; diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index b494fdf367..eb942d3e31 100644 --- a/apps/dokploy/server/api/routers/application.ts +++ b/apps/dokploy/server/api/routers/application.ts @@ -3,6 +3,7 @@ import { checkServiceAccess, createApplication, deleteAllMiddlewares, + executeTransfer, findApplicationById, findEnvironmentById, findGitProviderById, @@ -17,6 +18,7 @@ import { removeMonitoringDirectory, removeService, removeTraefikConfig, + scanServiceForTransfer, startService, startServiceRemote, stopService, @@ -30,6 +32,7 @@ import { // uploadFileSchema } from "@dokploy/server"; import { TRPCError } from "@trpc/server"; +import { observable } from "@trpc/server/observable"; import { eq } from "drizzle-orm"; import { nanoid } from "nanoid"; import { z } from "zod"; @@ -54,6 +57,7 @@ import { apiSaveGithubProvider, apiSaveGitlabProvider, apiSaveGitProvider, + apiTransferApplication, apiUpdateApplication, applications, } from "@/server/db/schema"; @@ -64,6 +68,12 @@ import { myQueue, } from "@/server/queues/queueSetup"; import { cancelDeployment, deploy } from "@/server/utils/deploy"; +import { + runTransferWithDowntime, + startSourceDockerService, + stopSourceDockerService, + validateTransferTargetServer, +} from "@/server/utils/transfer"; import { uploadFileSchema } from "@/utils/schema"; export const applicationRouter = createTRPCRouter({ @@ -975,4 +985,211 @@ export const applicationRouter = createTRPCRouter({ message: "Deployment cancellation only available in cloud version", }); }), + + // Scan application for transfer — pre-flight check + transferScan: protectedProcedure + .input(apiTransferApplication) + .mutation(async ({ input, ctx }) => { + const application = await findApplicationById(input.applicationId); + + if ( + application.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to transfer this application", + }); + } + + if (ctx.user.role === "member") { + await checkServiceAccess( + ctx.user.id, + input.applicationId, + ctx.session.activeOrganizationId, + "delete", + ); + } + + const targetServerId = await validateTransferTargetServer({ + targetServerId: input.targetServerId, + sourceServerId: application.serverId, + organizationId: ctx.session.activeOrganizationId, + }); + + return scanServiceForTransfer({ + serviceId: input.applicationId, + serviceType: "application", + appName: application.appName, + sourceServerId: application.serverId, + targetServerId, + }); + }), + + // Transfer application to a different server (node) + transfer: protectedProcedure + .input( + apiTransferApplication.extend({ + decisions: z.record(z.enum(["skip", "overwrite"])).optional(), + }), + ) + .mutation(async ({ input, ctx }) => { + const application = await findApplicationById(input.applicationId); + + if ( + application.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to transfer this application", + }); + } + + if (ctx.user.role === "member") { + await checkServiceAccess( + ctx.user.id, + input.applicationId, + ctx.session.activeOrganizationId, + "delete", // Transfer requires delete-level permission + ); + } + + const targetServerId = await validateTransferTargetServer({ + targetServerId: input.targetServerId, + sourceServerId: application.serverId, + organizationId: ctx.session.activeOrganizationId, + }); + + const result = await runTransferWithDowntime({ + stopSource: async () => { + await stopSourceDockerService( + application.serverId, + application.appName, + ); + }, + startSource: async () => { + await startSourceDockerService( + application.serverId, + application.appName, + ); + }, + executeTransfer: async () => + executeTransfer( + { + serviceId: input.applicationId, + serviceType: "application", + appName: application.appName, + sourceServerId: application.serverId, + targetServerId, + }, + input.decisions || {}, + (_progress) => { + // TODO: stream progress via subscription in future + }, + ), + commitTransfer: async () => { + await db + .update(applications) + .set({ serverId: targetServerId }) + .where(eq(applications.applicationId, input.applicationId)); + }, + }); + + if (!result.success) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Transfer failed: ${result.errors.join(", ")}`, + }); + } + + return { success: true }; + }), + + transferWithLogs: protectedProcedure + .input( + apiTransferApplication.extend({ + decisions: z.record(z.enum(["skip", "overwrite"])).optional(), + }), + ) + .subscription(async ({ input, ctx }) => { + const application = await findApplicationById(input.applicationId); + + if ( + application.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to transfer this application", + }); + } + + if (ctx.user.role === "member") { + await checkServiceAccess( + ctx.user.id, + input.applicationId, + ctx.session.activeOrganizationId, + "delete", + ); + } + + const targetServerId = await validateTransferTargetServer({ + targetServerId: input.targetServerId, + sourceServerId: application.serverId, + organizationId: ctx.session.activeOrganizationId, + }); + + return observable((emit) => { + runTransferWithDowntime({ + stopSource: async () => { + await stopSourceDockerService( + application.serverId, + application.appName, + ); + }, + startSource: async () => { + await startSourceDockerService( + application.serverId, + application.appName, + ); + }, + executeTransfer: async () => + executeTransfer( + { + serviceId: input.applicationId, + serviceType: "application", + appName: application.appName, + sourceServerId: application.serverId, + targetServerId, + }, + input.decisions || {}, + (progress) => { + emit.next(JSON.stringify(progress)); + }, + ), + commitTransfer: async () => { + await db + .update(applications) + .set({ serverId: targetServerId }) + .where(eq(applications.applicationId, input.applicationId)); + }, + }) + .then((result) => { + if (result.success) { + emit.next("Transfer completed successfully!"); + } else { + const errorMessage = result.errors.join(", ") || "Unknown error"; + emit.next(`Transfer failed: ${errorMessage}`); + } + emit.complete(); + }) + .catch((error) => { + const message = + error instanceof Error ? error.message : "Unknown transfer error"; + emit.next(`Transfer failed: ${message}`); + emit.complete(); + }); + }); + }), }); diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index 9354988a8f..30aa25435a 100644 --- a/apps/dokploy/server/api/routers/compose.ts +++ b/apps/dokploy/server/api/routers/compose.ts @@ -11,6 +11,7 @@ import { deleteMount, execAsync, execAsyncRemote, + executeTransfer, findComposeById, findDomainsByComposeId, findEnvironmentById, @@ -27,6 +28,7 @@ import { removeComposeDirectory, removeDeploymentsByComposeId, removeDomainById, + scanServiceForTransfer, startCompose, stopCompose, updateCompose, @@ -39,6 +41,7 @@ import { } from "@dokploy/server/templates/github"; import { processTemplate } from "@dokploy/server/templates/processors"; import { TRPCError } from "@trpc/server"; +import { observable } from "@trpc/server/observable"; import { eq } from "drizzle-orm"; import _ from "lodash"; import { nanoid } from "nanoid"; @@ -55,6 +58,7 @@ import { apiFindCompose, apiRandomizeCompose, apiRedeployCompose, + apiTransferCompose, apiUpdateCompose, compose as composeTable, } from "@/server/db/schema"; @@ -65,6 +69,10 @@ import { myQueue, } from "@/server/queues/queueSetup"; import { cancelDeployment, deploy } from "@/server/utils/deploy"; +import { + runTransferWithDowntime, + validateTransferTargetServer, +} from "@/server/utils/transfer"; import { generatePassword } from "@/templates/utils"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; @@ -1025,4 +1033,268 @@ export const composeRouter = createTRPCRouter({ message: "Deployment cancellation only available in cloud version", }); }), + + // Scan compose for transfer — pre-flight check + transferScan: protectedProcedure + .input(apiTransferCompose) + .mutation(async ({ input, ctx }) => { + const compose = await findComposeById(input.composeId); + + if ( + compose.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to transfer this compose", + }); + } + + if (ctx.user.role === "member") { + await checkServiceAccess( + ctx.user.id, + input.composeId, + ctx.session.activeOrganizationId, + "delete", + ); + } + + const targetServerId = await validateTransferTargetServer({ + targetServerId: input.targetServerId, + sourceServerId: compose.serverId, + organizationId: ctx.session.activeOrganizationId, + }); + + return scanServiceForTransfer({ + serviceId: input.composeId, + serviceType: "compose", + appName: compose.appName, + sourceServerId: compose.serverId, + targetServerId, + }); + }), + + transferScanWithLogs: protectedProcedure + .input(apiTransferCompose) + .subscription(async ({ input, ctx }) => { + const compose = await findComposeById(input.composeId); + + if ( + compose.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to transfer this compose", + }); + } + + if (ctx.user.role === "member") { + await checkServiceAccess( + ctx.user.id, + input.composeId, + ctx.session.activeOrganizationId, + "delete", + ); + } + + const targetServerId = await validateTransferTargetServer({ + targetServerId: input.targetServerId, + sourceServerId: compose.serverId, + organizationId: ctx.session.activeOrganizationId, + }); + + return observable((emit) => { + scanServiceForTransfer( + { + serviceId: input.composeId, + serviceType: "compose", + appName: compose.appName, + sourceServerId: compose.serverId, + targetServerId, + }, + (progress) => { + emit.next( + JSON.stringify({ + type: "scan_progress", + payload: progress, + }), + ); + }, + ) + .then((result) => { + emit.next( + JSON.stringify({ + type: "scan_complete", + payload: result, + }), + ); + emit.complete(); + }) + .catch((error) => { + const message = + error instanceof Error ? error.message : "Unknown scan error"; + emit.next( + JSON.stringify({ + type: "scan_error", + payload: { message }, + }), + ); + emit.complete(); + }); + }); + }), + + // Transfer compose to a different server (node) + transfer: protectedProcedure + .input( + apiTransferCompose.extend({ + decisions: z.record(z.enum(["skip", "overwrite"])).optional(), + }), + ) + .mutation(async ({ input, ctx }) => { + const compose = await findComposeById(input.composeId); + + if ( + compose.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to transfer this compose", + }); + } + + if (ctx.user.role === "member") { + await checkServiceAccess( + ctx.user.id, + input.composeId, + ctx.session.activeOrganizationId, + "delete", + ); + } + + const targetServerId = await validateTransferTargetServer({ + targetServerId: input.targetServerId, + sourceServerId: compose.serverId, + organizationId: ctx.session.activeOrganizationId, + }); + + const result = await runTransferWithDowntime({ + stopSource: async () => { + await stopCompose(input.composeId); + }, + startSource: async () => { + await startCompose(input.composeId); + }, + executeTransfer: async () => + executeTransfer( + { + serviceId: input.composeId, + serviceType: "compose", + appName: compose.appName, + sourceServerId: compose.serverId, + targetServerId, + }, + input.decisions || {}, + (_progress) => {}, + ), + commitTransfer: async () => { + await db + .update(composeTable) + .set({ serverId: targetServerId }) + .where(eq(composeTable.composeId, input.composeId)); + }, + }); + + if (!result.success) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Transfer failed: ${result.errors.join(", ")}`, + }); + } + + return { success: true }; + }), + + transferWithLogs: protectedProcedure + .input( + apiTransferCompose.extend({ + decisions: z.record(z.enum(["skip", "overwrite"])).optional(), + }), + ) + .subscription(async ({ input, ctx }) => { + const compose = await findComposeById(input.composeId); + + if ( + compose.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to transfer this compose", + }); + } + + if (ctx.user.role === "member") { + await checkServiceAccess( + ctx.user.id, + input.composeId, + ctx.session.activeOrganizationId, + "delete", + ); + } + + const targetServerId = await validateTransferTargetServer({ + targetServerId: input.targetServerId, + sourceServerId: compose.serverId, + organizationId: ctx.session.activeOrganizationId, + }); + + return observable((emit) => { + runTransferWithDowntime({ + stopSource: async () => { + await stopCompose(input.composeId); + }, + startSource: async () => { + await startCompose(input.composeId); + }, + executeTransfer: async () => + executeTransfer( + { + serviceId: input.composeId, + serviceType: "compose", + appName: compose.appName, + sourceServerId: compose.serverId, + targetServerId, + }, + input.decisions || {}, + (progress) => { + emit.next(JSON.stringify(progress)); + }, + ), + commitTransfer: async () => { + await db + .update(composeTable) + .set({ serverId: targetServerId }) + .where(eq(composeTable.composeId, input.composeId)); + }, + }) + .then((result) => { + if (result.success) { + emit.next("Transfer completed successfully!"); + } else { + const errorMessage = result.errors.join(", ") || "Unknown error"; + emit.next(`Transfer failed: ${errorMessage}`); + } + emit.complete(); + }) + .catch((error) => { + const message = + error instanceof Error ? error.message : "Unknown transfer error"; + emit.next(`Transfer failed: ${message}`); + emit.complete(); + }); + }); + }), }); diff --git a/apps/dokploy/server/api/routers/mariadb.ts b/apps/dokploy/server/api/routers/mariadb.ts index 1f58eec1bf..bd415136f5 100644 --- a/apps/dokploy/server/api/routers/mariadb.ts +++ b/apps/dokploy/server/api/routers/mariadb.ts @@ -5,6 +5,7 @@ import { createMariadb, createMount, deployMariadb, + executeTransfer, findBackupsByDbId, findEnvironmentById, findMariadbById, @@ -13,6 +14,7 @@ import { rebuildDatabase, removeMariadbById, removeService, + scanServiceForTransfer, startService, startServiceRemote, stopService, @@ -34,10 +36,17 @@ import { apiResetMariadb, apiSaveEnvironmentVariablesMariaDB, apiSaveExternalPortMariaDB, + apiTransferMariaDB, apiUpdateMariaDB, mariadb as mariadbTable, } from "@/server/db/schema"; import { cancelJobs } from "@/server/utils/backup"; +import { + runTransferWithDowntime, + startSourceDockerService, + stopSourceDockerService, + validateTransferTargetServer, +} from "@/server/utils/transfer"; export const mariadbRouter = createTRPCRouter({ create: protectedProcedure .input(apiCreateMariaDB) @@ -446,4 +455,197 @@ export const mariadbRouter = createTRPCRouter({ await rebuildDatabase(mariadb.mariadbId, "mariadb"); return true; }), + + // Scan mariadb for transfer — pre-flight check + transferScan: protectedProcedure + .input(apiTransferMariaDB) + .mutation(async ({ input, ctx }) => { + const mariadb = await findMariadbById(input.mariadbId); + + if ( + mariadb.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to transfer this MariaDB", + }); + } + + if (ctx.user.role === "member") { + await checkServiceAccess( + ctx.user.id, + input.mariadbId, + ctx.session.activeOrganizationId, + "delete", + ); + } + + const targetServerId = await validateTransferTargetServer({ + targetServerId: input.targetServerId, + sourceServerId: mariadb.serverId, + organizationId: ctx.session.activeOrganizationId, + }); + + return scanServiceForTransfer({ + serviceId: input.mariadbId, + serviceType: "mariadb", + appName: mariadb.appName, + sourceServerId: mariadb.serverId, + targetServerId, + }); + }), + + // Transfer mariadb to a different server (node) + transfer: protectedProcedure + .input( + apiTransferMariaDB.extend({ + decisions: z.record(z.enum(["skip", "overwrite"])).optional(), + }), + ) + .mutation(async ({ input, ctx }) => { + const mariadb = await findMariadbById(input.mariadbId); + + if ( + mariadb.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to transfer this MariaDB", + }); + } + + if (ctx.user.role === "member") { + await checkServiceAccess( + ctx.user.id, + input.mariadbId, + ctx.session.activeOrganizationId, + "delete", + ); + } + + const targetServerId = await validateTransferTargetServer({ + targetServerId: input.targetServerId, + sourceServerId: mariadb.serverId, + organizationId: ctx.session.activeOrganizationId, + }); + + const result = await runTransferWithDowntime({ + stopSource: async () => { + await stopSourceDockerService(mariadb.serverId, mariadb.appName); + }, + startSource: async () => { + await startSourceDockerService(mariadb.serverId, mariadb.appName); + }, + executeTransfer: async () => + executeTransfer( + { + serviceId: input.mariadbId, + serviceType: "mariadb", + appName: mariadb.appName, + sourceServerId: mariadb.serverId, + targetServerId, + }, + input.decisions || {}, + (_progress) => {}, + ), + commitTransfer: async () => { + await db + .update(mariadbTable) + .set({ serverId: targetServerId }) + .where(eq(mariadbTable.mariadbId, input.mariadbId)); + }, + }); + + if (!result.success) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Transfer failed: ${result.errors.join(", ")}`, + }); + } + + return { success: true }; + }), + + transferWithLogs: protectedProcedure + .input( + apiTransferMariaDB.extend({ + decisions: z.record(z.enum(["skip", "overwrite"])).optional(), + }), + ) + .subscription(async ({ input, ctx }) => { + const mariadb = await findMariadbById(input.mariadbId); + + if ( + mariadb.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to transfer this MariaDB", + }); + } + + if (ctx.user.role === "member") { + await checkServiceAccess( + ctx.user.id, + input.mariadbId, + ctx.session.activeOrganizationId, + "delete", + ); + } + + const targetServerId = await validateTransferTargetServer({ + targetServerId: input.targetServerId, + sourceServerId: mariadb.serverId, + organizationId: ctx.session.activeOrganizationId, + }); + + return observable((emit) => { + runTransferWithDowntime({ + stopSource: async () => { + await stopSourceDockerService(mariadb.serverId, mariadb.appName); + }, + startSource: async () => { + await startSourceDockerService(mariadb.serverId, mariadb.appName); + }, + executeTransfer: async () => + executeTransfer( + { + serviceId: input.mariadbId, + serviceType: "mariadb", + appName: mariadb.appName, + sourceServerId: mariadb.serverId, + targetServerId, + }, + input.decisions || {}, + (progress) => { + emit.next(JSON.stringify(progress)); + }, + ), + commitTransfer: async () => { + await db + .update(mariadbTable) + .set({ serverId: targetServerId }) + .where(eq(mariadbTable.mariadbId, input.mariadbId)); + }, + }) + .then((result) => { + if (result.success) { + emit.next("Transfer completed successfully!"); + } else { + const errorMessage = result.errors.join(", ") || "Unknown error"; + emit.next(`Transfer failed: ${errorMessage}`); + } + emit.complete(); + }) + .catch((error) => { + const message = + error instanceof Error ? error.message : "Unknown transfer error"; + emit.next(`Transfer failed: ${message}`); + emit.complete(); + }); + }); + }), }); diff --git a/apps/dokploy/server/api/routers/mongo.ts b/apps/dokploy/server/api/routers/mongo.ts index 661c808db8..dbd96b9d30 100644 --- a/apps/dokploy/server/api/routers/mongo.ts +++ b/apps/dokploy/server/api/routers/mongo.ts @@ -5,6 +5,7 @@ import { createMongo, createMount, deployMongo, + executeTransfer, findBackupsByDbId, findEnvironmentById, findMongoById, @@ -13,6 +14,7 @@ import { rebuildDatabase, removeMongoById, removeService, + scanServiceForTransfer, startService, startServiceRemote, stopService, @@ -34,10 +36,17 @@ import { apiResetMongo, apiSaveEnvironmentVariablesMongo, apiSaveExternalPortMongo, + apiTransferMongo, apiUpdateMongo, mongo as mongoTable, } from "@/server/db/schema"; import { cancelJobs } from "@/server/utils/backup"; +import { + runTransferWithDowntime, + startSourceDockerService, + stopSourceDockerService, + validateTransferTargetServer, +} from "@/server/utils/transfer"; export const mongoRouter = createTRPCRouter({ create: protectedProcedure .input(apiCreateMongo) @@ -464,4 +473,197 @@ export const mongoRouter = createTRPCRouter({ return true; }), + + // Scan mongo for transfer — pre-flight check + transferScan: protectedProcedure + .input(apiTransferMongo) + .mutation(async ({ input, ctx }) => { + const mongo = await findMongoById(input.mongoId); + + if ( + mongo.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to transfer this MongoDB", + }); + } + + if (ctx.user.role === "member") { + await checkServiceAccess( + ctx.user.id, + input.mongoId, + ctx.session.activeOrganizationId, + "delete", + ); + } + + const targetServerId = await validateTransferTargetServer({ + targetServerId: input.targetServerId, + sourceServerId: mongo.serverId, + organizationId: ctx.session.activeOrganizationId, + }); + + return scanServiceForTransfer({ + serviceId: input.mongoId, + serviceType: "mongo", + appName: mongo.appName, + sourceServerId: mongo.serverId, + targetServerId, + }); + }), + + // Transfer mongo to a different server (node) + transfer: protectedProcedure + .input( + apiTransferMongo.extend({ + decisions: z.record(z.enum(["skip", "overwrite"])).optional(), + }), + ) + .mutation(async ({ input, ctx }) => { + const mongo = await findMongoById(input.mongoId); + + if ( + mongo.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to transfer this MongoDB", + }); + } + + if (ctx.user.role === "member") { + await checkServiceAccess( + ctx.user.id, + input.mongoId, + ctx.session.activeOrganizationId, + "delete", + ); + } + + const targetServerId = await validateTransferTargetServer({ + targetServerId: input.targetServerId, + sourceServerId: mongo.serverId, + organizationId: ctx.session.activeOrganizationId, + }); + + const result = await runTransferWithDowntime({ + stopSource: async () => { + await stopSourceDockerService(mongo.serverId, mongo.appName); + }, + startSource: async () => { + await startSourceDockerService(mongo.serverId, mongo.appName); + }, + executeTransfer: async () => + executeTransfer( + { + serviceId: input.mongoId, + serviceType: "mongo", + appName: mongo.appName, + sourceServerId: mongo.serverId, + targetServerId, + }, + input.decisions || {}, + (_progress) => {}, + ), + commitTransfer: async () => { + await db + .update(mongoTable) + .set({ serverId: targetServerId }) + .where(eq(mongoTable.mongoId, input.mongoId)); + }, + }); + + if (!result.success) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Transfer failed: ${result.errors.join(", ")}`, + }); + } + + return { success: true }; + }), + + transferWithLogs: protectedProcedure + .input( + apiTransferMongo.extend({ + decisions: z.record(z.enum(["skip", "overwrite"])).optional(), + }), + ) + .subscription(async ({ input, ctx }) => { + const mongo = await findMongoById(input.mongoId); + + if ( + mongo.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to transfer this MongoDB", + }); + } + + if (ctx.user.role === "member") { + await checkServiceAccess( + ctx.user.id, + input.mongoId, + ctx.session.activeOrganizationId, + "delete", + ); + } + + const targetServerId = await validateTransferTargetServer({ + targetServerId: input.targetServerId, + sourceServerId: mongo.serverId, + organizationId: ctx.session.activeOrganizationId, + }); + + return observable((emit) => { + runTransferWithDowntime({ + stopSource: async () => { + await stopSourceDockerService(mongo.serverId, mongo.appName); + }, + startSource: async () => { + await startSourceDockerService(mongo.serverId, mongo.appName); + }, + executeTransfer: async () => + executeTransfer( + { + serviceId: input.mongoId, + serviceType: "mongo", + appName: mongo.appName, + sourceServerId: mongo.serverId, + targetServerId, + }, + input.decisions || {}, + (progress) => { + emit.next(JSON.stringify(progress)); + }, + ), + commitTransfer: async () => { + await db + .update(mongoTable) + .set({ serverId: targetServerId }) + .where(eq(mongoTable.mongoId, input.mongoId)); + }, + }) + .then((result) => { + if (result.success) { + emit.next("Transfer completed successfully!"); + } else { + const errorMessage = result.errors.join(", ") || "Unknown error"; + emit.next(`Transfer failed: ${errorMessage}`); + } + emit.complete(); + }) + .catch((error) => { + const message = + error instanceof Error ? error.message : "Unknown transfer error"; + emit.next(`Transfer failed: ${message}`); + emit.complete(); + }); + }); + }), }); diff --git a/apps/dokploy/server/api/routers/mysql.ts b/apps/dokploy/server/api/routers/mysql.ts index 9af93b5560..c26c4f64cc 100644 --- a/apps/dokploy/server/api/routers/mysql.ts +++ b/apps/dokploy/server/api/routers/mysql.ts @@ -5,6 +5,7 @@ import { createMount, createMysql, deployMySql, + executeTransfer, findBackupsByDbId, findEnvironmentById, findMySqlById, @@ -13,6 +14,7 @@ import { rebuildDatabase, removeMySqlById, removeService, + scanServiceForTransfer, startService, startServiceRemote, stopService, @@ -34,10 +36,17 @@ import { apiResetMysql, apiSaveEnvironmentVariablesMySql, apiSaveExternalPortMySql, + apiTransferMySql, apiUpdateMySql, mysql as mysqlTable, } from "@/server/db/schema"; import { cancelJobs } from "@/server/utils/backup"; +import { + runTransferWithDowntime, + startSourceDockerService, + stopSourceDockerService, + validateTransferTargetServer, +} from "@/server/utils/transfer"; export const mysqlRouter = createTRPCRouter({ create: protectedProcedure @@ -459,4 +468,197 @@ export const mysqlRouter = createTRPCRouter({ return true; }), + + // Scan mysql for transfer — pre-flight check + transferScan: protectedProcedure + .input(apiTransferMySql) + .mutation(async ({ input, ctx }) => { + const mysql = await findMySqlById(input.mysqlId); + + if ( + mysql.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to transfer this MySQL", + }); + } + + if (ctx.user.role === "member") { + await checkServiceAccess( + ctx.user.id, + input.mysqlId, + ctx.session.activeOrganizationId, + "delete", + ); + } + + const targetServerId = await validateTransferTargetServer({ + targetServerId: input.targetServerId, + sourceServerId: mysql.serverId, + organizationId: ctx.session.activeOrganizationId, + }); + + return scanServiceForTransfer({ + serviceId: input.mysqlId, + serviceType: "mysql", + appName: mysql.appName, + sourceServerId: mysql.serverId, + targetServerId, + }); + }), + + // Transfer mysql to a different server (node) + transfer: protectedProcedure + .input( + apiTransferMySql.extend({ + decisions: z.record(z.enum(["skip", "overwrite"])).optional(), + }), + ) + .mutation(async ({ input, ctx }) => { + const mysql = await findMySqlById(input.mysqlId); + + if ( + mysql.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to transfer this MySQL", + }); + } + + if (ctx.user.role === "member") { + await checkServiceAccess( + ctx.user.id, + input.mysqlId, + ctx.session.activeOrganizationId, + "delete", + ); + } + + const targetServerId = await validateTransferTargetServer({ + targetServerId: input.targetServerId, + sourceServerId: mysql.serverId, + organizationId: ctx.session.activeOrganizationId, + }); + + const result = await runTransferWithDowntime({ + stopSource: async () => { + await stopSourceDockerService(mysql.serverId, mysql.appName); + }, + startSource: async () => { + await startSourceDockerService(mysql.serverId, mysql.appName); + }, + executeTransfer: async () => + executeTransfer( + { + serviceId: input.mysqlId, + serviceType: "mysql", + appName: mysql.appName, + sourceServerId: mysql.serverId, + targetServerId, + }, + input.decisions || {}, + (_progress) => {}, + ), + commitTransfer: async () => { + await db + .update(mysqlTable) + .set({ serverId: targetServerId }) + .where(eq(mysqlTable.mysqlId, input.mysqlId)); + }, + }); + + if (!result.success) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Transfer failed: ${result.errors.join(", ")}`, + }); + } + + return { success: true }; + }), + + transferWithLogs: protectedProcedure + .input( + apiTransferMySql.extend({ + decisions: z.record(z.enum(["skip", "overwrite"])).optional(), + }), + ) + .subscription(async ({ input, ctx }) => { + const mysql = await findMySqlById(input.mysqlId); + + if ( + mysql.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to transfer this MySQL", + }); + } + + if (ctx.user.role === "member") { + await checkServiceAccess( + ctx.user.id, + input.mysqlId, + ctx.session.activeOrganizationId, + "delete", + ); + } + + const targetServerId = await validateTransferTargetServer({ + targetServerId: input.targetServerId, + sourceServerId: mysql.serverId, + organizationId: ctx.session.activeOrganizationId, + }); + + return observable((emit) => { + runTransferWithDowntime({ + stopSource: async () => { + await stopSourceDockerService(mysql.serverId, mysql.appName); + }, + startSource: async () => { + await startSourceDockerService(mysql.serverId, mysql.appName); + }, + executeTransfer: async () => + executeTransfer( + { + serviceId: input.mysqlId, + serviceType: "mysql", + appName: mysql.appName, + sourceServerId: mysql.serverId, + targetServerId, + }, + input.decisions || {}, + (progress) => { + emit.next(JSON.stringify(progress)); + }, + ), + commitTransfer: async () => { + await db + .update(mysqlTable) + .set({ serverId: targetServerId }) + .where(eq(mysqlTable.mysqlId, input.mysqlId)); + }, + }) + .then((result) => { + if (result.success) { + emit.next("Transfer completed successfully!"); + } else { + const errorMessage = result.errors.join(", ") || "Unknown error"; + emit.next(`Transfer failed: ${errorMessage}`); + } + emit.complete(); + }) + .catch((error) => { + const message = + error instanceof Error ? error.message : "Unknown transfer error"; + emit.next(`Transfer failed: ${message}`); + emit.complete(); + }); + }); + }), }); diff --git a/apps/dokploy/server/api/routers/postgres.ts b/apps/dokploy/server/api/routers/postgres.ts index 97db0b878d..743efc3811 100644 --- a/apps/dokploy/server/api/routers/postgres.ts +++ b/apps/dokploy/server/api/routers/postgres.ts @@ -5,6 +5,7 @@ import { createMount, createPostgres, deployPostgres, + executeTransfer, findBackupsByDbId, findEnvironmentById, findPostgresById, @@ -14,6 +15,7 @@ import { rebuildDatabase, removePostgresById, removeService, + scanServiceForTransfer, startService, startServiceRemote, stopService, @@ -35,10 +37,17 @@ import { apiResetPostgres, apiSaveEnvironmentVariablesPostgres, apiSaveExternalPortPostgres, + apiTransferPostgres, apiUpdatePostgres, postgres as postgresTable, } from "@/server/db/schema"; import { cancelJobs } from "@/server/utils/backup"; +import { + runTransferWithDowntime, + startSourceDockerService, + stopSourceDockerService, + validateTransferTargetServer, +} from "@/server/utils/transfer"; export const postgresRouter = createTRPCRouter({ create: protectedProcedure @@ -469,4 +478,197 @@ export const postgresRouter = createTRPCRouter({ return true; }), + + // Scan postgres for transfer — pre-flight check + transferScan: protectedProcedure + .input(apiTransferPostgres) + .mutation(async ({ input, ctx }) => { + const postgres = await findPostgresById(input.postgresId); + + if ( + postgres.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to transfer this Postgres", + }); + } + + if (ctx.user.role === "member") { + await checkServiceAccess( + ctx.user.id, + input.postgresId, + ctx.session.activeOrganizationId, + "delete", + ); + } + + const targetServerId = await validateTransferTargetServer({ + targetServerId: input.targetServerId, + sourceServerId: postgres.serverId, + organizationId: ctx.session.activeOrganizationId, + }); + + return scanServiceForTransfer({ + serviceId: input.postgresId, + serviceType: "postgres", + appName: postgres.appName, + sourceServerId: postgres.serverId, + targetServerId, + }); + }), + + // Transfer postgres to a different server (node) + transfer: protectedProcedure + .input( + apiTransferPostgres.extend({ + decisions: z.record(z.enum(["skip", "overwrite"])).optional(), + }), + ) + .mutation(async ({ input, ctx }) => { + const postgres = await findPostgresById(input.postgresId); + + if ( + postgres.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to transfer this Postgres", + }); + } + + if (ctx.user.role === "member") { + await checkServiceAccess( + ctx.user.id, + input.postgresId, + ctx.session.activeOrganizationId, + "delete", + ); + } + + const targetServerId = await validateTransferTargetServer({ + targetServerId: input.targetServerId, + sourceServerId: postgres.serverId, + organizationId: ctx.session.activeOrganizationId, + }); + + const result = await runTransferWithDowntime({ + stopSource: async () => { + await stopSourceDockerService(postgres.serverId, postgres.appName); + }, + startSource: async () => { + await startSourceDockerService(postgres.serverId, postgres.appName); + }, + executeTransfer: async () => + executeTransfer( + { + serviceId: input.postgresId, + serviceType: "postgres", + appName: postgres.appName, + sourceServerId: postgres.serverId, + targetServerId, + }, + input.decisions || {}, + (_progress) => {}, + ), + commitTransfer: async () => { + await db + .update(postgresTable) + .set({ serverId: targetServerId }) + .where(eq(postgresTable.postgresId, input.postgresId)); + }, + }); + + if (!result.success) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Transfer failed: ${result.errors.join(", ")}`, + }); + } + + return { success: true }; + }), + + transferWithLogs: protectedProcedure + .input( + apiTransferPostgres.extend({ + decisions: z.record(z.enum(["skip", "overwrite"])).optional(), + }), + ) + .subscription(async ({ input, ctx }) => { + const postgres = await findPostgresById(input.postgresId); + + if ( + postgres.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to transfer this Postgres", + }); + } + + if (ctx.user.role === "member") { + await checkServiceAccess( + ctx.user.id, + input.postgresId, + ctx.session.activeOrganizationId, + "delete", + ); + } + + const targetServerId = await validateTransferTargetServer({ + targetServerId: input.targetServerId, + sourceServerId: postgres.serverId, + organizationId: ctx.session.activeOrganizationId, + }); + + return observable((emit) => { + runTransferWithDowntime({ + stopSource: async () => { + await stopSourceDockerService(postgres.serverId, postgres.appName); + }, + startSource: async () => { + await startSourceDockerService(postgres.serverId, postgres.appName); + }, + executeTransfer: async () => + executeTransfer( + { + serviceId: input.postgresId, + serviceType: "postgres", + appName: postgres.appName, + sourceServerId: postgres.serverId, + targetServerId, + }, + input.decisions || {}, + (progress) => { + emit.next(JSON.stringify(progress)); + }, + ), + commitTransfer: async () => { + await db + .update(postgresTable) + .set({ serverId: targetServerId }) + .where(eq(postgresTable.postgresId, input.postgresId)); + }, + }) + .then((result) => { + if (result.success) { + emit.next("Transfer completed successfully!"); + } else { + const errorMessage = result.errors.join(", ") || "Unknown error"; + emit.next(`Transfer failed: ${errorMessage}`); + } + emit.complete(); + }) + .catch((error) => { + const message = + error instanceof Error ? error.message : "Unknown transfer error"; + emit.next(`Transfer failed: ${message}`); + emit.complete(); + }); + }); + }), }); diff --git a/apps/dokploy/server/api/routers/redis.ts b/apps/dokploy/server/api/routers/redis.ts index a5cc7c9aac..014604bcf3 100644 --- a/apps/dokploy/server/api/routers/redis.ts +++ b/apps/dokploy/server/api/routers/redis.ts @@ -5,6 +5,7 @@ import { createMount, createRedis, deployRedis, + executeTransfer, findEnvironmentById, findProjectById, findRedisById, @@ -12,6 +13,7 @@ import { rebuildDatabase, removeRedisById, removeService, + scanServiceForTransfer, startService, startServiceRemote, stopService, @@ -34,9 +36,16 @@ import { apiResetRedis, apiSaveEnvironmentVariablesRedis, apiSaveExternalPortRedis, + apiTransferRedis, apiUpdateRedis, redis as redisTable, } from "@/server/db/schema"; +import { + runTransferWithDowntime, + startSourceDockerService, + stopSourceDockerService, + validateTransferTargetServer, +} from "@/server/utils/transfer"; export const redisRouter = createTRPCRouter({ create: protectedProcedure .input(apiCreateRedis) @@ -439,4 +448,197 @@ export const redisRouter = createTRPCRouter({ await rebuildDatabase(redis.redisId, "redis"); return true; }), + + // Scan redis for transfer — pre-flight check + transferScan: protectedProcedure + .input(apiTransferRedis) + .mutation(async ({ input, ctx }) => { + const redis = await findRedisById(input.redisId); + + if ( + redis.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to transfer this Redis", + }); + } + + if (ctx.user.role === "member") { + await checkServiceAccess( + ctx.user.id, + input.redisId, + ctx.session.activeOrganizationId, + "delete", + ); + } + + const targetServerId = await validateTransferTargetServer({ + targetServerId: input.targetServerId, + sourceServerId: redis.serverId, + organizationId: ctx.session.activeOrganizationId, + }); + + return scanServiceForTransfer({ + serviceId: input.redisId, + serviceType: "redis", + appName: redis.appName, + sourceServerId: redis.serverId, + targetServerId, + }); + }), + + // Transfer redis to a different server (node) + transfer: protectedProcedure + .input( + apiTransferRedis.extend({ + decisions: z.record(z.enum(["skip", "overwrite"])).optional(), + }), + ) + .mutation(async ({ input, ctx }) => { + const redis = await findRedisById(input.redisId); + + if ( + redis.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to transfer this Redis", + }); + } + + if (ctx.user.role === "member") { + await checkServiceAccess( + ctx.user.id, + input.redisId, + ctx.session.activeOrganizationId, + "delete", + ); + } + + const targetServerId = await validateTransferTargetServer({ + targetServerId: input.targetServerId, + sourceServerId: redis.serverId, + organizationId: ctx.session.activeOrganizationId, + }); + + const result = await runTransferWithDowntime({ + stopSource: async () => { + await stopSourceDockerService(redis.serverId, redis.appName); + }, + startSource: async () => { + await startSourceDockerService(redis.serverId, redis.appName); + }, + executeTransfer: async () => + executeTransfer( + { + serviceId: input.redisId, + serviceType: "redis", + appName: redis.appName, + sourceServerId: redis.serverId, + targetServerId, + }, + input.decisions || {}, + (_progress) => {}, + ), + commitTransfer: async () => { + await db + .update(redisTable) + .set({ serverId: targetServerId }) + .where(eq(redisTable.redisId, input.redisId)); + }, + }); + + if (!result.success) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Transfer failed: ${result.errors.join(", ")}`, + }); + } + + return { success: true }; + }), + + transferWithLogs: protectedProcedure + .input( + apiTransferRedis.extend({ + decisions: z.record(z.enum(["skip", "overwrite"])).optional(), + }), + ) + .subscription(async ({ input, ctx }) => { + const redis = await findRedisById(input.redisId); + + if ( + redis.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to transfer this Redis", + }); + } + + if (ctx.user.role === "member") { + await checkServiceAccess( + ctx.user.id, + input.redisId, + ctx.session.activeOrganizationId, + "delete", + ); + } + + const targetServerId = await validateTransferTargetServer({ + targetServerId: input.targetServerId, + sourceServerId: redis.serverId, + organizationId: ctx.session.activeOrganizationId, + }); + + return observable((emit) => { + runTransferWithDowntime({ + stopSource: async () => { + await stopSourceDockerService(redis.serverId, redis.appName); + }, + startSource: async () => { + await startSourceDockerService(redis.serverId, redis.appName); + }, + executeTransfer: async () => + executeTransfer( + { + serviceId: input.redisId, + serviceType: "redis", + appName: redis.appName, + sourceServerId: redis.serverId, + targetServerId, + }, + input.decisions || {}, + (progress) => { + emit.next(JSON.stringify(progress)); + }, + ), + commitTransfer: async () => { + await db + .update(redisTable) + .set({ serverId: targetServerId }) + .where(eq(redisTable.redisId, input.redisId)); + }, + }) + .then((result) => { + if (result.success) { + emit.next("Transfer completed successfully!"); + } else { + const errorMessage = result.errors.join(", ") || "Unknown error"; + emit.next(`Transfer failed: ${errorMessage}`); + } + emit.complete(); + }) + .catch((error) => { + const message = + error instanceof Error ? error.message : "Unknown transfer error"; + emit.next(`Transfer failed: ${message}`); + emit.complete(); + }); + }); + }), }); diff --git a/apps/dokploy/server/server.ts b/apps/dokploy/server/server.ts index dbd7c2638f..3cb48fb3c6 100644 --- a/apps/dokploy/server/server.ts +++ b/apps/dokploy/server/server.ts @@ -17,6 +17,7 @@ import { config } from "dotenv"; import next from "next"; import { migration } from "@/server/db/migration"; import packageInfo from "../package.json"; +import { setupDataTransferWebSocketServer } from "./wss/data-transfer"; import { setupDockerContainerLogsWebSocketServer } from "./wss/docker-container-logs"; import { setupDockerContainerTerminalWebSocketServer } from "./wss/docker-container-terminal"; import { setupDockerStatsMonitoringSocketServer } from "./wss/docker-stats"; @@ -53,6 +54,7 @@ void app.prepare().then(async () => { setupDockerContainerLogsWebSocketServer(server); setupDockerContainerTerminalWebSocketServer(server); setupTerminalWebSocketServer(server); + setupDataTransferWebSocketServer(server); if (!IS_CLOUD) { setupDockerStatsMonitoringSocketServer(server); } diff --git a/apps/dokploy/server/utils/transfer.ts b/apps/dokploy/server/utils/transfer.ts new file mode 100644 index 0000000000..4059814d61 --- /dev/null +++ b/apps/dokploy/server/utils/transfer.ts @@ -0,0 +1,171 @@ +import { + IS_CLOUD, + findServerById, + startService, + startServiceRemote, + stopService, + stopServiceRemote, +} from "@dokploy/server"; +import { TRPCError } from "@trpc/server"; + +interface ValidateTransferTargetServerInput { + targetServerId: string | null | undefined; + sourceServerId: string | null | undefined; + organizationId: string; +} + +interface TransferExecutionResult { + success: boolean; + errors: string[]; +} + +interface RunTransferWithDowntimeInput { + stopSource: () => Promise; + startSource: () => Promise; + executeTransfer: () => Promise; + commitTransfer: () => Promise; +} + +interface RunTransferWithDowntimeResult { + success: boolean; + errors: string[]; + sourceRestarted: boolean; +} + +const getErrorMessage = ( + error: unknown, + fallback = "Unknown error", +): string => { + if (error instanceof Error && error.message) { + return error.message; + } + return fallback; +}; + +export const validateTransferTargetServer = async ({ + targetServerId, + sourceServerId, + organizationId, +}: ValidateTransferTargetServerInput): Promise => { + const normalizeServerId = ( + serverId: string | null | undefined, + ): string | null => { + if (!serverId) { + return null; + } + const trimmedServerId = serverId.trim(); + return trimmedServerId.length > 0 ? trimmedServerId : null; + }; + + const normalizedSourceServerId = normalizeServerId(sourceServerId); + const normalizedTargetServerId = normalizeServerId(targetServerId); + + if (IS_CLOUD && !normalizedTargetServerId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You need to select a target server for transfer", + }); + } + + if (normalizedSourceServerId === normalizedTargetServerId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Source and target server must be different", + }); + } + + if (normalizedTargetServerId) { + const targetServer = await findServerById(normalizedTargetServerId); + if (targetServer.organizationId !== organizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to use the selected target server", + }); + } + } + + return normalizedTargetServerId; +}; + +export const stopSourceDockerService = async ( + sourceServerId: string | null, + appName: string, +) => { + const stopResult = sourceServerId + ? await stopServiceRemote(sourceServerId, appName) + : await stopService(appName); + + if (stopResult instanceof Error) { + throw stopResult; + } + + if (stopResult) { + throw new Error( + typeof stopResult === "string" + ? stopResult + : "Failed to stop source service", + ); + } +}; + +export const startSourceDockerService = async ( + sourceServerId: string | null, + appName: string, +) => { + if (sourceServerId) { + await startServiceRemote(sourceServerId, appName); + return; + } + + await startService(appName); +}; + +export const runTransferWithDowntime = async ({ + stopSource, + startSource, + executeTransfer, + commitTransfer, +}: RunTransferWithDowntimeInput): Promise => { + await stopSource(); + + let transferResult: TransferExecutionResult; + try { + transferResult = await executeTransfer(); + } catch (error) { + transferResult = { + success: false, + errors: [getErrorMessage(error, "Transfer execution failed")], + }; + } + + if (transferResult.success) { + try { + await commitTransfer(); + return { success: true, errors: [], sourceRestarted: false }; + } catch (error) { + transferResult = { + success: false, + errors: [ + ...transferResult.errors, + `Failed to finalize transfer: ${getErrorMessage(error)}`, + ], + }; + } + } + + let sourceRestarted = false; + try { + await startSource(); + sourceRestarted = true; + } catch (error) { + transferResult.errors.push( + `Failed to restart source service: ${getErrorMessage(error)}`, + ); + } + + return { + success: false, + errors: transferResult.errors, + sourceRestarted, + }; +}; diff --git a/apps/dokploy/server/wss/data-transfer.ts b/apps/dokploy/server/wss/data-transfer.ts new file mode 100644 index 0000000000..e0315bafb4 --- /dev/null +++ b/apps/dokploy/server/wss/data-transfer.ts @@ -0,0 +1,511 @@ +/** + * WebSocket server for data transfer progress + */ +import type http from "node:http"; +import type { Duplex } from "node:stream"; +import { + checkServiceAccess, + findApplicationById, + findComposeById, + findMariadbById, + findMongoById, + findMountsByApplicationId, + findMySqlById, + findPostgresById, + findRedisById, + validateRequest, +} from "@dokploy/server"; +import type { + FileCompareResult, + MountTransferConfig, + TransferConfig, + TransferMessage, +} from "@dokploy/server/utils/transfer"; +import { + compareFileLists, + runPreflightChecks, + scanMount, + syncMount, +} from "@dokploy/server/utils/transfer"; +import type { WebSocket } from "ws"; +import { WebSocketServer } from "ws"; +import { z } from "zod"; +import { validateTransferTargetServer } from "@/server/utils/transfer"; + +interface TransferSession { + config?: TransferConfig; + sourceFiles: Map; + targetFiles: Map; + comparisonResults: Map; + abortController?: AbortController; + isPaused: boolean; +} + +interface WebSocketAuthContext { + userId: string; + isMember: boolean; + organizationId: string; +} + +interface TransferUpgradeRequest extends http.IncomingMessage { + transferAuthContext?: WebSocketAuthContext; +} + +function rejectUpgrade( + socket: Duplex, + statusCode: number, + statusMessage: string, +) { + if (!socket.destroyed) { + socket.write( + `HTTP/1.1 ${statusCode} ${statusMessage}\r\nConnection: close\r\n\r\n`, + ); + socket.destroy(); + } +} + +const scanConfigSchema = z + .object({ + serviceId: z.string().min(1), + serviceType: z.enum([ + "application", + "postgres", + "mysql", + "mariadb", + "mongo", + "redis", + "compose", + ]), + targetServerId: z.string().trim().nullable().optional(), + mergeStrategy: z + .enum(["skip", "overwrite", "newer", "manual"]) + .default("manual"), + }) + .passthrough(); + +const wsCommandSchema = z.discriminatedUnion("action", [ + z.object({ action: z.literal("scan"), config: scanConfigSchema }), + z.object({ action: z.literal("compare") }), + z.object({ + action: z.literal("sync"), + manualDecisions: z.record(z.enum(["skip", "overwrite"])).optional(), + }), + z.object({ action: z.literal("pause") }), + z.object({ action: z.literal("resume") }), + z.object({ action: z.literal("cancel") }), +]); + +function sendMessage( + ws: WebSocket, + type: TransferMessage["type"], + payload: unknown, +) { + if (ws.readyState === ws.OPEN) { + ws.send( + JSON.stringify({ + type, + payload, + timestamp: Date.now(), + } satisfies TransferMessage), + ); + } +} + +async function getServiceScope( + serviceType: z.infer["serviceType"], + serviceId: string, +): Promise<{ sourceServerId: string | null; organizationId: string }> { + switch (serviceType) { + case "application": { + const service = await findApplicationById(serviceId); + return { + sourceServerId: service.serverId, + organizationId: service.environment.project.organizationId, + }; + } + case "compose": { + const service = await findComposeById(serviceId); + return { + sourceServerId: service.serverId, + organizationId: service.environment.project.organizationId, + }; + } + case "postgres": { + const service = await findPostgresById(serviceId); + return { + sourceServerId: service.serverId, + organizationId: service.environment.project.organizationId, + }; + } + case "mysql": { + const service = await findMySqlById(serviceId); + return { + sourceServerId: service.serverId, + organizationId: service.environment.project.organizationId, + }; + } + case "mariadb": { + const service = await findMariadbById(serviceId); + return { + sourceServerId: service.serverId, + organizationId: service.environment.project.organizationId, + }; + } + case "mongo": { + const service = await findMongoById(serviceId); + return { + sourceServerId: service.serverId, + organizationId: service.environment.project.organizationId, + }; + } + case "redis": { + const service = await findRedisById(serviceId); + return { + sourceServerId: service.serverId, + organizationId: service.environment.project.organizationId, + }; + } + default: + throw new Error(`Unsupported service type: ${serviceType}`); + } +} + +async function buildAuthorizedTransferConfig( + authContext: WebSocketAuthContext, + config: z.infer, +): Promise { + const serviceScope = await getServiceScope( + config.serviceType, + config.serviceId, + ); + + if (serviceScope.organizationId !== authContext.organizationId) { + throw new Error("You are not authorized to transfer this service"); + } + + if (authContext.isMember) { + await checkServiceAccess( + authContext.userId, + config.serviceId, + authContext.organizationId, + "delete", + ); + } + + const targetServerId = await validateTransferTargetServer({ + targetServerId: config.targetServerId, + sourceServerId: serviceScope.sourceServerId, + organizationId: authContext.organizationId, + }); + + if (!targetServerId) { + throw new Error("Target server is required for data transfer"); + } + + const mountRows = await findMountsByApplicationId( + config.serviceId, + config.serviceType, + ); + const mounts: MountTransferConfig[] = mountRows + .filter((mount) => mount.type === "volume" || mount.type === "bind") + .map((mount) => { + const sourcePath = + mount.type === "volume" ? mount.volumeName : mount.hostPath; + + if (!sourcePath) { + throw new Error(`Mount ${mount.mountId} is missing source path`); + } + + return { + mountId: mount.mountId, + mountType: mount.type as "volume" | "bind", + sourcePath, + targetPath: sourcePath, + createIfMissing: true, + updateMountConfig: false, + }; + }); + + return { + serviceId: config.serviceId, + serviceType: config.serviceType, + sourceServerId: serviceScope.sourceServerId, + targetServerId, + mergeStrategy: config.mergeStrategy, + mounts, + }; +} + +export const setupDataTransferWebSocketServer = ( + server: http.Server, +) => { + const wss = new WebSocketServer({ + noServer: true, + path: "/data-transfer", + }); + + server.on("upgrade", (req: TransferUpgradeRequest, socket, head) => { + let pathname = ""; + try { + pathname = new URL( + req.url || "", + `http://${req.headers.host || "localhost"}`, + ).pathname; + } catch { + return; + } + + if (pathname === "/data-transfer") { + void (async () => { + try { + const { user, session } = await validateRequest(req); + if (!user || !session) { + rejectUpgrade(socket, 401, "Unauthorized"); + return; + } + + req.transferAuthContext = { + userId: user.id, + isMember: user.role === "member", + organizationId: session.activeOrganizationId || "", + }; + + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit("connection", ws, req); + }); + } catch { + rejectUpgrade(socket, 500, "Internal Server Error"); + } + })(); + } + }); + + wss.on("connection", (ws, req: TransferUpgradeRequest) => { + const authContext = req.transferAuthContext; + if (!authContext) { + ws.close(4001, "Unauthorized"); + return; + } + + const transferSession: TransferSession = { + sourceFiles: new Map(), + targetFiles: new Map(), + comparisonResults: new Map(), + isPaused: false, + }; + + ws.on("message", async (data) => { + try { + const parsedData = JSON.parse(data.toString()) as unknown; + const command = wsCommandSchema.parse(parsedData); + + switch (command.action) { + case "scan": { + const secureConfig = await buildAuthorizedTransferConfig( + authContext, + command.config, + ); + await handleScan(ws, transferSession, secureConfig); + break; + } + case "compare": + await handleCompare(ws, transferSession); + break; + + case "sync": + await handleSync(ws, transferSession, command.manualDecisions); + break; + + case "pause": + transferSession.isPaused = true; + sendMessage(ws, "paused", {}); + break; + + case "resume": + transferSession.isPaused = false; + sendMessage(ws, "resumed", {}); + break; + + case "cancel": + transferSession.abortController?.abort(); + sendMessage(ws, "cancelled", {}); + break; + } + } catch (error) { + sendMessage(ws, "error", { + message: + error instanceof z.ZodError + ? error.issues.map((issue) => issue.message).join(", ") + : error instanceof Error + ? error.message + : "Unknown error", + }); + } + }); + + ws.on("close", () => { + transferSession.abortController?.abort(); + }); + }); +}; + +async function handleScan( + ws: WebSocket, + session: TransferSession, + config: TransferConfig, +) { + session.config = config; + session.sourceFiles.clear(); + session.targetFiles.clear(); + + sendMessage(ws, "scan_start", { mounts: config.mounts.length }); + + // Run preflight checks + const preflightResults = await runPreflightChecks( + config.targetServerId, + config.mounts, + (data) => { + sendMessage(ws, "scan_progress", { + phase: "preflight", + mount: data.mount, + result: data.result, + }); + }, + ); + + // Scan source mounts + for (const mount of config.mounts) { + sendMessage(ws, "scan_progress", { + phase: "scanning_source", + mount: mount.mountId, + }); + + const files = await scanMount(config.sourceServerId, mount, (file) => { + sendMessage(ws, "scan_progress", { + phase: "file_found", + mount: mount.mountId, + file: file.path, + }); + }); + + session.sourceFiles.set(mount.mountId, files as FileCompareResult[]); + } + + // Scan target mounts + for (const mount of config.mounts) { + sendMessage(ws, "scan_progress", { + phase: "scanning_target", + mount: mount.mountId, + }); + + const files = await scanMount( + config.targetServerId, + { ...mount, sourcePath: mount.targetPath }, + (file) => { + sendMessage(ws, "scan_progress", { + phase: "target_file_found", + mount: mount.mountId, + file: file.path, + }); + }, + ); + + session.targetFiles.set(mount.mountId, files as FileCompareResult[]); + } + + sendMessage(ws, "scan_complete", { + preflightResults: Object.fromEntries(preflightResults), + sourceCounts: Object.fromEntries( + Array.from(session.sourceFiles.entries()).map(([k, v]) => [k, v.length]), + ), + targetCounts: Object.fromEntries( + Array.from(session.targetFiles.entries()).map(([k, v]) => [k, v.length]), + ), + }); +} + +async function handleCompare(ws: WebSocket, session: TransferSession) { + if (!session.config) { + sendMessage(ws, "error", { message: "No scan has been performed" }); + return; + } + + sendMessage(ws, "compare_start", {}); + + session.comparisonResults.clear(); + + for (const mount of session.config.mounts) { + const sourceFiles = session.sourceFiles.get(mount.mountId) || []; + const targetFiles = session.targetFiles.get(mount.mountId) || []; + + const comparison = compareFileLists(sourceFiles, targetFiles); + session.comparisonResults.set(mount.mountId, comparison); + + sendMessage(ws, "compare_progress", { + mount: mount.mountId, + total: comparison.length, + match: comparison.filter((f) => f.status === "match").length, + missing: comparison.filter((f) => f.status === "missing_target").length, + newer: comparison.filter( + (f) => f.status === "newer_source" || f.status === "newer_target", + ).length, + conflict: comparison.filter((f) => f.status === "conflict").length, + }); + } + + // Send full comparison results + sendMessage(ws, "compare_complete", { + results: Object.fromEntries(session.comparisonResults), + }); +} + +async function handleSync( + ws: WebSocket, + session: TransferSession, + manualDecisions?: Record, +) { + if (!session.config) { + sendMessage(ws, "error", { message: "No scan has been performed" }); + return; + } + + session.abortController = new AbortController(); + const waitForResume = async () => { + while (session.isPaused) { + if (session.abortController?.signal.aborted) return; + await new Promise((resolve) => setTimeout(resolve, 150)); + } + }; + + sendMessage(ws, "sync_start", {}); + + const allErrors: string[] = []; + + for (const mount of session.config.mounts) { + const files = session.comparisonResults.get(mount.mountId) || []; + + const result = await syncMount( + mount, + files, + session.config.sourceServerId, + session.config.targetServerId, + session.config.mergeStrategy, + manualDecisions, + session.abortController.signal, + (status) => { + sendMessage(ws, "sync_progress", { + mount: mount.mountId, + ...status, + }); + }, + waitForResume, + ); + + allErrors.push(...result.errors); + } + + sendMessage(ws, "sync_complete", { + success: allErrors.length === 0, + errors: allErrors, + }); +} diff --git a/docs/transfer-migration.md b/docs/transfer-migration.md new file mode 100644 index 0000000000..436380cac2 --- /dev/null +++ b/docs/transfer-migration.md @@ -0,0 +1,441 @@ +# RFC: Service Transfer Migration Between Servers + +> **Status**: Draft — requesting maintainer feedback before finalizing PR +> **Author**: Horsley +> **Affected area**: Multi-server (remote server) service management + +## Scope + +This feature applies to **multi-server mode** — where Dokploy manages multiple remote servers and users assign services to individual servers. + +> [!NOTE] +> **Not in scope: Docker Swarm mode.** Swarm has its own built-in mechanisms for service migration (node drain / service constraints). This feature targets the non-Swarm multi-server setup specifically. + +## Problem Statement + +When users need to **move a service from one remote server to another** (e.g., hardware migration, load balancing, server decommission), the current options are: + +1. **For regular users**: Manually create a new service on the target server, re-enter all configurations (environment variables, domains, mounts, etc.), copy data files manually, then delete the old service. This is tedious, error-prone, and risks missing configurations or data. +2. **For advanced users**: SSH into both servers, manually `rsync` or `tar` the application directory and Docker volumes, then directly update `serverId` in the Dokploy database. This requires deep knowledge of Dokploy internals and is risky for database services whose volumes contain critical data. + +Neither option provides any safety guarantees — there is no conflict detection, no progress feedback, and no rollback safety. + +## Proposed Solution + +A **two-phase transfer** feature that safely migrates services between remote servers: + +1. **Scan phase**: Pre-flight analysis of source and target servers — detects files, sizes, and conflicts +2. **Execute phase**: Syncs files/volumes with real-time progress streaming, then atomically updates the `serverId` in the database + +> [!IMPORTANT] +> Source server data is **read-only** during transfer. Files are copied, never moved or deleted. The database `serverId` is updated only after a fully successful sync. + +--- + +## Architecture Overview + +```mermaid +flowchart TD + subgraph Frontend["Frontend (transfer-service.tsx)"] + UI[Multi-step UI] + end + + subgraph Routers["tRPC Routers (x7)"] + TS[transferScan — mutation] + TL[transferWithLogs — subscription] + T[transfer — mutation] + end + + subgraph Service["Transfer Service"] + SCAN[scanServiceForTransfer] + EXEC[executeTransfer] + end + + subgraph Utils["Transfer Utilities"] + SCANNER[scanner.ts] + SYNC[sync.ts] + PREFLIGHT[preflight.ts] + TYPES[types.ts] + end + + UI -->|1. Scan| TS --> SCAN + SCAN --> SCANNER + UI -->|2. Execute| TL --> EXEC + EXEC --> SYNC + EXEC --> PREFLIGHT + UI -.->|Legacy fallback| T --> EXEC +``` + +### Data Flow + +```mermaid +sequenceDiagram + participant User + participant UI as Frontend + participant WS as WebSocket (/drawer-logs) + participant Router as tRPC Router + participant Transfer as transfer.ts + participant Source as Source Server + participant Target as Target Server + + User->>UI: Select target server + UI->>Router: transferScan(serviceId, targetServerId) + Router->>Transfer: scanServiceForTransfer() + Transfer->>Source: Scan files, volumes, Traefik config + Transfer->>Target: Scan existing files for conflicts + Transfer-->>Router: ScanResult (sizes, conflicts, hashes) + Router-->>UI: Display scan results + + User->>UI: Review conflicts, set decisions + User->>UI: Confirm transfer + + UI->>WS: transferWithLogs subscription + WS->>Router: subscription handler + Router->>Transfer: executeTransfer(opts, decisions, onProgress) + + loop For each mount/volume + Transfer->>Source: Read files (rsync/tar) + Transfer->>Target: Write files + Transfer->>Router: onProgress({phase, file, %}) + Router->>WS: emit.next(JSON.stringify(progress)) + WS->>UI: Real-time progress update + end + + Transfer-->>Router: {success: true} + Router->>Router: UPDATE serverId in DB + Router->>WS: emit.next("Transfer completed successfully!") + WS->>UI: Show completion +``` + +--- + +## What Gets Transferred + +### Per Service Type + +| Service Type | App/Compose Dir | Traefik Config | Auto Data Volume | User Mounts | +|:------------|:---------------:|:--------------:|:----------------:|:-----------:| +| Application | ✅ | ✅ | — | ✅ | +| Compose | ✅ | ✅ | — | ✅ | +| PostgreSQL | — | — | ✅ `{appName}-data` | ✅ | +| MySQL | — | — | ✅ `{appName}-data` | ✅ | +| MariaDB | — | — | ✅ `{appName}-data` | ✅ | +| MongoDB | — | — | ✅ `{appName}-data` | ✅ | +| Redis | — | — | ✅ `{appName}-data` | ✅ | + +### NOT Transferred + +| Item | Reason | +|------|--------| +| TLS/SSL certificates | Each server manages its own Let's Encrypt certs | +| Docker images | Pulled from registry during deploy | +| Running containers | Only files/volumes are synced | +| Docker networks | Auto-created during deploy | +| Container logs | Ephemeral | + +### Domain & TLS Cutover Guidance (Current Behavior) + +For services that expose domains (typically Application / Compose), transfer currently moves Traefik dynamic routing config but **does not move ACME certificate storage (`acme.json`)**. + +This means TLS certs are expected to be re-issued on the target server after cutover. + +Recommended operator flow: + +1. Complete transfer and deploy on target server +2. Update domain `A`/`AAAA` records to the target server IP +3. Wait for DNS propagation (TTL is an upper bound, not a strict global switch time) +4. Open the HTTPS domain to trigger certificate issuance on target Traefik +5. If issuance fails, retry after propagation; as fallback, redeploy the service or restart Traefik + +--- + +## Service Downtime & Container State Management + +Transferring a service involves copying files and Docker volumes between servers. To ensure data consistency, the service's containers should be **stopped before transfer**. + +> [!WARNING] +> **Downtime is expected during transfer.** Users should plan for service unavailability from the moment the service is stopped until it is deployed on the target server. + +### Pre-Transfer + +| Step | Action | Reason | +|------|--------|--------| +| 1 | **Stop the service** on source server | Prevents data writes during file/volume copy, ensures consistency | +| 2 | Verify service is stopped | UI should confirm containers are not running | + +> [!CAUTION] +> **For database services (PostgreSQL, MySQL, MariaDB, MongoDB, Redis)**: Writing to the database during volume transfer may result in **corrupted or inconsistent data** on the target. Always stop the database before transfer. + +### Post-Transfer (Success) + +| Step | Action | Reason | +|------|--------|--------| +| 1 | `serverId` is updated in DB | Service is now associated with the target server | +| 2 | **User must deploy/start** the service on the target server | Containers are not automatically started after transfer | +| 3 | Verify service is running correctly | Check logs, connectivity, data integrity | +| 4 | If domains are configured, update DNS `A`/`AAAA` to target and wait for propagation | ACME HTTP challenge must reach target Traefik | +| 5 | Trigger certificate issuance by visiting HTTPS domain (retry if needed) | Certificates are not migrated; target issues certs | +| 6 | (Optional) Clean up source server | Source files remain untouched; user can remove them when ready | + +### Post-Transfer (Failure or Cancellation) + +| Step | Action | Reason | +|------|--------|--------| +| 1 | `serverId` is **NOT** updated | Service remains associated with the source server | +| 2 | **User should restart the service** on the source server | Restore availability as quickly as possible | +| 3 | Investigate failure (check logs) | Common causes: SSH connectivity, disk space, permissions | +| 4 | Retry transfer when ready | Source data is unchanged, safe to retry | + +> [!IMPORTANT] +> The UI should clearly indicate: (1) the service will experience downtime, (2) after success, a deploy is needed on the target, and (3) after failure, the user should restart the service on the source server. + +### Downtime Timeline + +```mermaid +gantt + title Service Availability During Transfer + dateFormat X + axisFormat %s + + section Source Server + Running :done, 0, 1 + Stopped for transfer :crit, 1, 3 + Stopped (data still exists) :active, 3, 4 + + section Target Server + Empty :done, 0, 2 + Receiving data :active, 2, 3 + User deploys service :crit, 3, 4 + Running :done, 4, 5 + + section Downtime + Service unavailable :crit, 1, 4 +``` + +--- + +## Sync Methods + +| Scenario | Method | Details | +|----------|--------|---------| +| Local → Remote | `rsync -az` over SSH | Efficient delta sync | +| Remote → Local | `rsync` + `tar` (by data type) | Service dir uses `rsync`; bind/volume file sync uses tar stream | +| Remote → Remote | `tar` pipeline via Dokploy server | `ssh source "tar czf -" \| ssh target "tar xzf -"` | +| Docker volume | Docker container + `tar` via SSH | Mounts volume read-only, streams via tar | + +--- + +## Conflict Detection & Resolution + +During the scan phase, files on both source and target are compared: + +| Conflict Status | Meaning | +|----------------|---------| +| `missing_target` | File only exists on source (new file) | +| `newer_source` | Source file is newer than target | +| `newer_target` | Target file is newer than source | +| `conflict` | Both modified, different content | +| `match` | Identical files, no action needed | + +For conflicts, the UI displays: +- File path +- File size +- Source vs target modification time +- Source vs target file hash (MD5) + +Users choose per-file: **Overwrite** (replace target) or **Skip** (keep target). + +--- + +## File Changes + +### New Files + +| File | Purpose | +|------|---------| +| `packages/server/src/services/transfer.ts` | Core orchestration — `scanServiceForTransfer()` and `executeTransfer()` | +| `packages/server/src/utils/transfer/scanner.ts` | File scanning for volumes (`docker run`) and bind mounts (`find`) | +| `packages/server/src/utils/transfer/sync.ts` | File-level sync with merge strategy support | +| `packages/server/src/utils/transfer/preflight.ts` | Pre-flight checks: path permissions, volume/directory creation | +| `packages/server/src/utils/transfer/types.ts` | Shared TypeScript types (`FileInfo`, `MountTransferConfig`, `TransferStatus`, etc.) | +| `packages/server/src/utils/transfer/index.ts` | Barrel export | +| `apps/dokploy/server/wss/data-transfer.ts` | Raw WebSocket server for granular transfer control (scan/compare/sync phases) | + +### Modified Files + +#### Router Endpoints (7 files) + +Each router received two new endpoints and one enhanced endpoint: + +| Router File | New: `transferScan` | New: `transferWithLogs` | Enhanced: `transfer` | +|:------------|:-------------------:|:-----------------------:|:--------------------:| +| `routers/application.ts` | ✅ | ✅ | ✅ | +| `routers/compose.ts` | ✅ | ✅ | ✅ | +| `routers/postgres.ts` | ✅ | ✅ | ✅ | +| `routers/mysql.ts` | ✅ | ✅ | ✅ | +| `routers/mariadb.ts` | ✅ | ✅ | ✅ | +| `routers/mongo.ts` | ✅ | ✅ | ✅ | +| `routers/redis.ts` | ✅ | ✅ | ✅ | + +**Endpoint details:** + +| Endpoint | Type | Purpose | +|----------|------|---------| +| `transferScan` | Mutation | Runs `scanServiceForTransfer()`, returns sizes/conflicts | +| `transferWithLogs` | Subscription | Runs `executeTransfer()` with real-time progress via `observable`, updates `serverId` on success | +| `transfer` (enhanced) | Mutation | Same as above but synchronous (no streaming), kept as fallback | + +> [!NOTE] +> The `transfer` mutation already existed in the codebase (it only updated `serverId`). We enhanced it to call `executeTransfer()` first, ensuring data is synced before the DB update. + +#### Frontend + +| File | Change | +|------|--------| +| `components/dashboard/shared/transfer-service.tsx` | Complete rewrite — multi-step flow with scan, conflict review, and real-time progress via tRPC subscription | + +#### Server Infrastructure + +| File | Change | +|------|--------| +| `packages/server/src/index.ts` | Added `transfer` service export | +| `apps/dokploy/server/server.ts` | Registered `setupDataTransferWebSocketServer` | + +### Schema Updates (this PR) + +| File | Change | +|------|--------| +| `db/schema/application.ts` | Added `apiTransferApplication` | +| `db/schema/compose.ts` | Added `apiTransferCompose` | +| `db/schema/postgres.ts` | Added `apiTransferPostgres` | +| `db/schema/mysql.ts` | Added `apiTransferMySql` | +| `db/schema/mariadb.ts` | Added `apiTransferMariaDB` | +| `db/schema/mongo.ts` | Added `apiTransferMongo` | +| `db/schema/redis.ts` | Added `apiTransferRedis` | + +--- + +## Key Implementation Details + +### `scanServiceForTransfer(opts)` → `TransferScanResult` + +1. **Application/Compose directory**: Scans source and target with `scanBindMount()`, runs `compareFileLists()` to detect diffs +2. **Traefik config**: Reads `{appName}.yml` from both servers using existing `readConfig`/`readRemoteConfig` utilities +3. **All DB mounts**: Queries `findMountsByApplicationId(serviceId, serviceType)` to discover volumes and bind mounts, scans each on both sides +4. **Conflict hashing**: For conflicting files, computes MD5 hash on both sides for service directory, bind mounts, and Docker volumes via `computeFileHash()` +5. Returns total byte count, file lists, and per-file conflict details + +### `executeTransfer(opts, decisions, onProgress)` → `{success, errors}` + +1. **Service directory sync**: `rsync` (local↔remote) or `tar` pipeline (remote↔remote) +2. **Traefik config sync**: Reads config from source, writes to target using existing Traefik utilities +3. **Pre-flight**: Creates target volumes (`docker volume create`) and directories (`mkdir -p`) +4. **Mount sync**: For each volume/bind, calls `syncMount()` from transfer utilities, respecting user decisions for conflicts +5. **Progress callback**: Reports `{phase, currentFile, processedFiles, totalFiles, transferredBytes, totalBytes, percentage}` + +### `transferWithLogs` (tRPC Subscription) + +Follows the exact pattern of the existing `deployWithLogs` across every router: + +```typescript +transferWithLogs: protectedProcedure + .input(apiTransferService.extend({ + decisions: z.record(z.enum(["skip", "overwrite"])).optional(), + })) + .subscription(async ({ input, ctx }) => { + // auth check... + return observable((emit) => { + executeTransfer(opts, decisions, (progress) => { + emit.next(JSON.stringify(progress)); + }).then(async (result) => { + if (result.success) { + await db.update(table).set({ serverId: targetServerId })... + emit.next("Transfer completed successfully!"); + } else { + emit.next(`Transfer failed: ${result.errors.join(", ")}`); + } + }); + }); + }), +``` + +### Frontend Transfer Flow + +``` +Select Server → Scan → Review Conflicts → Confirm → Transfer (Live Logs) → Done +``` + +Each service type has its own wrapper component (e.g., `ApplicationTransfer`, `PostgresTransfer`) that calls the type-specific hooks at the top level, sharing common logic via `TransferInner`. + +--- + +## Safety Guarantees + +| Guarantee | How | +|-----------|-----| +| **Source is read-only** | All sync operations copy data; never delete/modify source | +| **Atomic DB update** | `serverId` updated only after `executeTransfer()` returns `success: true` | +| **Failure is safe** | On error, source remains unchanged; target may have partial files that can be cleaned up; retry is always possible | +| **User control** | Per-file conflict resolution (overwrite/skip) | +| **Auth enforced (tRPC)** | Organization membership + member-level `checkServiceAccess(..., "delete")` on `transferScan` / `transfer` / `transferWithLogs` | +| **Raw WS auth (`/data-transfer`)** | Session/login check only (service-level authorization still pending hardening) | + +--- + +## UI User Flow + +1. Navigate to **Service → Settings → Transfer Service** +2. Select target server from dropdown (remote servers + optional `Dokploy (Local)` target) +3. Click **Scan for Transfer** — shows loading while scanning both servers +4. Review scan results: + - Total transfer size + - Volume count + - Conflict table with size, source/target mtime, hash, and overwrite/skip toggles +5. Click **Transfer** → confirmation dialog with: + - Size estimate + - ⚠️ **Downtime warning**: "Your service will be unavailable during transfer" +6. Watch real-time progress: progress bar, file count, bytes transferred, current file, scrollable log area +7. On **success**: + - ✅ "Transfer completed" message + - Prompt: "Deploy the service on the target server to start it" +8. On **failure**: + - ❌ Error message with details + - Prompt: "Restart the service on the source server to restore availability" + +--- + +## Discussion Points for Maintainer + +1. **`data-transfer.ts` WebSocket server**: Raw WS endpoint `/data-transfer` supports scan/compare/sync and pause/resume/cancel. Current UI still uses tRPC subscription path, so WS remains an advanced/internal path. + +2. **`transfer` mutation vs `transferWithLogs` subscription**: We kept both — the mutation as a synchronous fallback (e.g., for API/CLI usage) and the subscription for the UI. Is this acceptable, or should we consolidate? + +3. **Auto-stop before transfer**: Should the system automatically stop the service before starting the transfer? This reduces user error but adds complexity. Current approach: user is responsible for stopping with a clear UI warning. Alternative: auto-stop and auto-restart on failure. + +4. **Auto-deploy after transfer**: Should the system automatically deploy/start the service on the target after successful transfer? Current approach: user manually deploys. Alternative: auto-deploy, with rollback to source on failure. + +5. **Rollback**: Currently, failed transfers leave partial data on the target but don't touch the source. Should we add explicit cleanup of target on failure? + +6. **Schema ownership**: `apiTransfer*` schemas are now part of this feature scope and must stay aligned with router behavior (`targetServerId` + conflict decision payload contract). + +--- + +## Limitations + +1. **Service downtime is required** — service must be stopped on source before transfer; user deploys on target after +2. **No automatic rollback** — source is untouched, but target may have partial files on failure +3. **Network dependent** — large volumes take time on slow connections +4. **Same Dokploy instance** — both servers must be managed by the same Dokploy installation +5. **No incremental/resumable transfers** — if interrupted, the entire sync restarts +6. **Multi-server mode only** — does not apply to Docker Swarm mode (use Swarm drain instead) + +--- + +## Testing Plan + +- [x] TypeScript compilation: `pnpm --filter @dokploy/server exec tsc --noEmit` — 0 errors +- [ ] Manual: Transfer application between two servers +- [ ] Manual: Transfer database (PostgreSQL) with data volume between servers +- [ ] Manual: Transfer with file conflicts, verify overwrite/skip works +- [ ] Manual: Verify rollback safety — cancel mid-transfer, verify source unchanged +- [ ] Manual: Transfer remote→local (Dokploy server as target) diff --git a/packages/server/src/db/schema/application.ts b/packages/server/src/db/schema/application.ts index c06ee191fb..296b7c684e 100644 --- a/packages/server/src/db/schema/application.ts +++ b/packages/server/src/db/schema/application.ts @@ -526,3 +526,9 @@ export const apiUpdateApplication = createSchema applicationId: z.string().min(1), }) .omit({ serverId: true }); + +// Schema for transferring application to another server +export const apiTransferApplication = z.object({ + applicationId: z.string().min(1), + targetServerId: z.string().trim().nullable(), +}); diff --git a/packages/server/src/db/schema/compose.ts b/packages/server/src/db/schema/compose.ts index 02bd60f0bf..6edca38890 100644 --- a/packages/server/src/db/schema/compose.ts +++ b/packages/server/src/db/schema/compose.ts @@ -226,3 +226,9 @@ export const apiRandomizeCompose = createSchema suffix: z.string().optional(), composeId: z.string().min(1), }); + +// Schema for transferring compose to another server +export const apiTransferCompose = z.object({ + composeId: z.string().min(1), + targetServerId: z.string().trim().nullable(), +}); diff --git a/packages/server/src/db/schema/mariadb.ts b/packages/server/src/db/schema/mariadb.ts index 2ec9894fa2..b557682611 100644 --- a/packages/server/src/db/schema/mariadb.ts +++ b/packages/server/src/db/schema/mariadb.ts @@ -208,3 +208,9 @@ export const apiRebuildMariadb = createSchema mariadbId: true, }) .required(); + +// Schema for transferring mariadb to another server +export const apiTransferMariaDB = z.object({ + mariadbId: z.string().min(1), + targetServerId: z.string().trim().nullable(), +}); diff --git a/packages/server/src/db/schema/mongo.ts b/packages/server/src/db/schema/mongo.ts index 30a9e74039..48ed885bdf 100644 --- a/packages/server/src/db/schema/mongo.ts +++ b/packages/server/src/db/schema/mongo.ts @@ -204,3 +204,9 @@ export const apiRebuildMongo = createSchema mongoId: true, }) .required(); + +// Schema for transferring mongo to another server +export const apiTransferMongo = z.object({ + mongoId: z.string().min(1), + targetServerId: z.string().trim().nullable(), +}); diff --git a/packages/server/src/db/schema/mysql.ts b/packages/server/src/db/schema/mysql.ts index 86c8c7f0f6..707c3d1bb8 100644 --- a/packages/server/src/db/schema/mysql.ts +++ b/packages/server/src/db/schema/mysql.ts @@ -205,3 +205,9 @@ export const apiRebuildMysql = createSchema mysqlId: true, }) .required(); + +// Schema for transferring mysql to another server +export const apiTransferMySql = z.object({ + mysqlId: z.string().min(1), + targetServerId: z.string().trim().nullable(), +}); diff --git a/packages/server/src/db/schema/postgres.ts b/packages/server/src/db/schema/postgres.ts index 9cd5fee299..d4529f6e97 100644 --- a/packages/server/src/db/schema/postgres.ts +++ b/packages/server/src/db/schema/postgres.ts @@ -198,3 +198,9 @@ export const apiRebuildPostgres = createSchema postgresId: true, }) .required(); + +// Schema for transferring postgres to another server +export const apiTransferPostgres = z.object({ + postgresId: z.string().min(1), + targetServerId: z.string().trim().nullable(), +}); diff --git a/packages/server/src/db/schema/redis.ts b/packages/server/src/db/schema/redis.ts index dba8d98261..018b221c7b 100644 --- a/packages/server/src/db/schema/redis.ts +++ b/packages/server/src/db/schema/redis.ts @@ -184,3 +184,9 @@ export const apiRebuildRedis = createSchema redisId: true, }) .required(); + +// Schema for transferring redis to another server +export const apiTransferRedis = z.object({ + redisId: z.string().min(1), + targetServerId: z.string().trim().nullable(), +}); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 7bfc5553bd..fda18d6b16 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -41,6 +41,7 @@ export * from "./services/security"; export * from "./services/server"; export * from "./services/settings"; export * from "./services/ssh-key"; +export * from "./services/transfer"; export * from "./services/user"; export * from "./services/volume-backups"; export * from "./services/web-server-settings"; diff --git a/packages/server/src/services/mount.ts b/packages/server/src/services/mount.ts index f08a32312e..7eee79162f 100644 --- a/packages/server/src/services/mount.ts +++ b/packages/server/src/services/mount.ts @@ -262,6 +262,9 @@ export const findMountsByApplicationId = async ( case "redis": sqlChunks.push(eq(mounts.redisId, serviceId)); break; + case "compose": + sqlChunks.push(eq(mounts.composeId, serviceId)); + break; default: throw new Error(`Unknown service type: ${serviceType}`); } diff --git a/packages/server/src/services/transfer.ts b/packages/server/src/services/transfer.ts new file mode 100644 index 0000000000..07e5d4171e --- /dev/null +++ b/packages/server/src/services/transfer.ts @@ -0,0 +1,1169 @@ +/** + * Transfer Service — orchestrates scanning and syncing for service migration + */ +import path from "node:path"; +import { paths } from "@dokploy/server/constants"; +import { findComposeById } from "@dokploy/server/services/compose"; +import { findMountsByApplicationId } from "@dokploy/server/services/mount"; +import { + loadDockerCompose, + loadDockerComposeRemote, +} from "@dokploy/server/utils/docker/domain"; +import type { ComposeSpecification } from "@dokploy/server/utils/docker/types"; +import { + readConfig, + readRemoteConfig, + writeConfig, + writeConfigRemote, +} from "@dokploy/server/utils/traefik/application"; +import { execAsync, execAsyncRemote } from "../utils/process/execAsync"; +import { shellEscape } from "../utils/process/ssh"; +import { + createDirectoryOnTarget, + createVolumeOnTarget, +} from "../utils/transfer/preflight"; +import { + compareFileLists, + computeFileHash, + scanBindMount, + scanVolume, +} from "../utils/transfer/scanner"; +import { + buildDecisionKey, + shouldSyncFile, + syncMount, + syncVolumeArchive, +} from "../utils/transfer/sync"; +import type { + FileCompareResult, + MountTransferConfig, + ServiceType, +} from "../utils/transfer/types"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface TransferOptions { + serviceId: string; + serviceType: ServiceType; + appName: string; + sourceServerId: string | null; // null = local/main server + targetServerId: string | null; // null = local/main server +} + +export interface VolumeScanResult { + volumeName: string; + mountPath: string; + sizeBytes: number; + files: FileCompareResult[]; +} + +export interface BindScanResult { + hostPath: string; + files: FileCompareResult[]; +} + +export interface TransferScanResult { + serviceDir?: { + path: string; + files: FileCompareResult[]; + }; + traefikConfig?: { + sourceExists: boolean; + targetExists: boolean; + hasConflict: boolean; + sourceContent?: string; + targetContent?: string; + }; + volumes: VolumeScanResult[]; + binds: BindScanResult[]; + totalSizeBytes: number; + conflicts: FileCompareResult[]; + hasConflicts: boolean; +} + +export interface TransferProgress { + phase: string; + currentFile?: string; + processedFiles: number; + totalFiles: number; + transferredBytes: number; + totalBytes: number; + percentage: number; +} + +export interface TransferScanProgress { + phase: string; + mount?: string; + currentFile?: string; + processedMounts: number; + totalMounts: number; + scannedFiles: number; + processedHashes: number; + totalHashes: number; +} + +interface ResolvedMountTransferConfig extends MountTransferConfig { + mountPath?: string; + composeProjectName?: string; + composeVolumeKey?: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Get the service directory path (only for application/compose) + */ +function getServiceDirPath( + serviceType: ServiceType, + appName: string, +): string | null { + const { APPLICATIONS_PATH, COMPOSE_PATH } = paths(true); + if (serviceType === "application") { + return `${APPLICATIONS_PATH}/${appName}`; + } + if (serviceType === "compose") { + return `${COMPOSE_PATH}/${appName}`; + } + return null; +} + +function getServiceDirMountId( + serviceType: ServiceType, + serviceId: string, +): string { + return `service-dir:${serviceType}:${serviceId}`; +} + +/** + * Check if a service type has Traefik config + */ +function hasTraefikConfig(serviceType: ServiceType): boolean { + return serviceType === "application" || serviceType === "compose"; +} + +/** + * Build MountTransferConfig from DB mounts + */ +function buildMountConfigs( + dbMounts: Awaited>, +): ResolvedMountTransferConfig[] { + return dbMounts + .filter((m) => m.type === "volume" || m.type === "bind") + .map((m) => ({ + mountId: m.mountId, + mountType: m.type as "volume" | "bind", + sourcePath: m.type === "volume" ? m.volumeName || "" : m.hostPath || "", + targetPath: m.type === "volume" ? m.volumeName || "" : m.hostPath || "", + createIfMissing: true, + updateMountConfig: false, + mountPath: m.mountPath, + })); +} + +function getMountDedupeKey(mount: MountTransferConfig): string { + return `${mount.mountType}:${mount.sourcePath}`; +} + +function hasUnresolvedVariable(value: string): boolean { + return value.includes("${") || value.startsWith("$"); +} + +function isLikelyBindSource(source: string): boolean { + return ( + source.startsWith("/") || + source.startsWith("./") || + source.startsWith("../") || + source === "." || + source === ".." || + source.startsWith("~/") + ); +} + +function isPathInside(pathToCheck: string, parentPath: string): boolean { + const normalizedPath = path.resolve(pathToCheck); + const normalizedParent = path.resolve(parentPath); + return ( + normalizedPath === normalizedParent || + normalizedPath.startsWith(`${normalizedParent}${path.sep}`) + ); +} + +function parseComposeVolumeString(volume: string): { + source?: string; + target?: string; +} { + const parts = volume.split(":"); + if (parts.length < 2) { + // Anonymous volume (`/var/lib/data`) has no host/source to transfer. + return { target: parts[0] }; + } + const source = parts[0]; + const target = parts[1]; + return { source, target }; +} + +interface ResolvedComposeVolume { + volumeName: string; + volumeKey: string; + external: boolean; +} + +function resolveComposeVolume( + source: string, + appName: string, + composeSpec: ComposeSpecification, +): ResolvedComposeVolume | null { + if (hasUnresolvedVariable(source) || isLikelyBindSource(source)) { + return null; + } + + // Handle cases like "db-data/subdir:/target" by using the root volume key. + const [volumeKey] = source.split("/"); + if (!volumeKey) return null; + + const volumeDef = composeSpec.volumes?.[volumeKey]; + if (!volumeDef || typeof volumeDef !== "object") { + return { + volumeName: `${appName}_${volumeKey}`, + volumeKey, + external: false, + }; + } + + if (volumeDef.external) { + if ( + typeof volumeDef.external === "object" && + typeof volumeDef.external.name === "string" && + volumeDef.external.name.length > 0 + ) { + return { + volumeName: volumeDef.external.name, + volumeKey, + external: true, + }; + } + return { + volumeName: volumeKey, + volumeKey, + external: true, + }; + } + + if (typeof volumeDef.name === "string" && volumeDef.name.length > 0) { + return { + volumeName: volumeDef.name, + volumeKey, + external: false, + }; + } + + return { + volumeName: `${appName}_${volumeKey}`, + volumeKey, + external: false, + }; +} + +function addResolvedMount( + list: ResolvedMountTransferConfig[], + seen: Set, + mount: ResolvedMountTransferConfig, +) { + const dedupeKey = getMountDedupeKey(mount); + if (seen.has(dedupeKey)) return; + seen.add(dedupeKey); + list.push(mount); +} + +function getComposeManagedVolumeLabels( + mount: ResolvedMountTransferConfig, +): Record | undefined { + if ( + mount.mountType !== "volume" || + !mount.composeProjectName || + !mount.composeVolumeKey + ) { + return undefined; + } + + return { + "com.docker.compose.project": mount.composeProjectName, + "com.docker.compose.volume": mount.composeVolumeKey, + }; +} + +function extractComposeSpecMounts( + composeSpec: ComposeSpecification, + appName: string, + serviceDirPath: string, + composeBaseDir: string, + seen: Set, +): ResolvedMountTransferConfig[] { + const mounts: ResolvedMountTransferConfig[] = []; + const services = composeSpec.services || {}; + + for (const service of Object.values(services)) { + const serviceVolumes = service?.volumes; + if (!serviceVolumes || !Array.isArray(serviceVolumes)) continue; + + for (const rawVolume of serviceVolumes) { + if (typeof rawVolume === "string") { + const parsed = parseComposeVolumeString(rawVolume); + const source = parsed.source; + const target = parsed.target; + if (!source || !target) continue; + + if (hasUnresolvedVariable(source)) continue; + + if (isLikelyBindSource(source)) { + if (source.startsWith("~/")) continue; + const bindPath = path.isAbsolute(source) + ? source + : path.resolve(composeBaseDir, source); + if (isPathInside(bindPath, serviceDirPath)) continue; + addResolvedMount(mounts, seen, { + mountId: `compose-spec:bind:${bindPath}`, + mountType: "bind", + sourcePath: bindPath, + targetPath: bindPath, + createIfMissing: true, + updateMountConfig: false, + mountPath: target, + }); + continue; + } + + const resolvedVolume = resolveComposeVolume( + source, + appName, + composeSpec, + ); + if (!resolvedVolume) continue; + addResolvedMount(mounts, seen, { + mountId: `compose-spec:volume:${resolvedVolume.volumeName}`, + mountType: "volume", + sourcePath: resolvedVolume.volumeName, + targetPath: resolvedVolume.volumeName, + createIfMissing: true, + updateMountConfig: false, + mountPath: target, + composeProjectName: resolvedVolume.external ? undefined : appName, + composeVolumeKey: resolvedVolume.external + ? undefined + : resolvedVolume.volumeKey, + }); + continue; + } + + if (!rawVolume || typeof rawVolume !== "object") continue; + + const mountType = + typeof rawVolume.type === "string" ? rawVolume.type : "volume"; + const source = + typeof rawVolume.source === "string" ? rawVolume.source : undefined; + const target = + typeof rawVolume.target === "string" ? rawVolume.target : undefined; + + if (!source || !target || hasUnresolvedVariable(source)) continue; + + if (mountType === "bind") { + if (source.startsWith("~/")) continue; + const bindPath = path.isAbsolute(source) + ? source + : path.resolve(composeBaseDir, source); + if (isPathInside(bindPath, serviceDirPath)) continue; + addResolvedMount(mounts, seen, { + mountId: `compose-spec:bind:${bindPath}`, + mountType: "bind", + sourcePath: bindPath, + targetPath: bindPath, + createIfMissing: true, + updateMountConfig: false, + mountPath: target, + }); + continue; + } + + if (mountType !== "volume") continue; + const resolvedVolume = resolveComposeVolume(source, appName, composeSpec); + if (!resolvedVolume) continue; + addResolvedMount(mounts, seen, { + mountId: `compose-spec:volume:${resolvedVolume.volumeName}`, + mountType: "volume", + sourcePath: resolvedVolume.volumeName, + targetPath: resolvedVolume.volumeName, + createIfMissing: true, + updateMountConfig: false, + mountPath: target, + composeProjectName: resolvedVolume.external ? undefined : appName, + composeVolumeKey: resolvedVolume.external + ? undefined + : resolvedVolume.volumeKey, + }); + } + } + + return mounts; +} + +async function getMountConfigsForTransfer( + opts: TransferOptions, +): Promise { + const dbMounts = await findMountsByApplicationId( + opts.serviceId, + opts.serviceType, + ); + const mountConfigs = buildMountConfigs(dbMounts); + const seen = new Set(mountConfigs.map((mount) => getMountDedupeKey(mount))); + + if (opts.serviceType !== "compose") { + return mountConfigs; + } + + try { + const compose = await findComposeById(opts.serviceId); + const composeSpec = compose.serverId + ? await loadDockerComposeRemote(compose) + : await loadDockerCompose(compose); + if (!composeSpec) return mountConfigs; + + const { COMPOSE_PATH } = paths(!!compose.serverId); + const composeRelativePath = + compose.sourceType === "raw" + ? "docker-compose.yml" + : compose.composePath || "docker-compose.yml"; + const composeBaseDir = path.dirname( + path.join(COMPOSE_PATH, compose.appName, "code", composeRelativePath), + ); + const serviceDirPath = + getServiceDirPath("compose", compose.appName) || + path.join(COMPOSE_PATH, compose.appName); + + const composeSpecMounts = extractComposeSpecMounts( + composeSpec, + compose.appName, + serviceDirPath, + composeBaseDir, + seen, + ); + + const mountsByDedupeKey = new Map( + mountConfigs.map((mount) => [getMountDedupeKey(mount), mount]), + ); + + for (const composeSpecMount of composeSpecMounts) { + const dedupeKey = getMountDedupeKey(composeSpecMount); + const existingMount = mountsByDedupeKey.get(dedupeKey); + if (!existingMount) { + mountConfigs.push(composeSpecMount); + mountsByDedupeKey.set(dedupeKey, composeSpecMount); + continue; + } + + // Preserve mount metadata from compose file when DB mounts overlap. + if ( + !existingMount.composeProjectName && + composeSpecMount.composeProjectName + ) { + existingMount.composeProjectName = composeSpecMount.composeProjectName; + } + if ( + !existingMount.composeVolumeKey && + composeSpecMount.composeVolumeKey + ) { + existingMount.composeVolumeKey = composeSpecMount.composeVolumeKey; + } + } + + return mountConfigs; + } catch (error) { + console.warn( + `Failed to resolve Compose-defined mounts for transfer: ${ + error instanceof Error ? error.message : "unknown error" + }`, + ); + return mountConfigs; + } +} + +/** + * Calculate volume size in bytes + */ +async function getVolumeSize( + serverId: string | null, + volumeName: string, +): Promise { + const command = `docker run --rm -v ${shellEscape(`${volumeName}:/volume_data:ro`)} alpine sh -c 'du -sb /volume_data 2>/dev/null | cut -f1'`; + try { + const { stdout } = serverId + ? await execAsyncRemote(serverId, command) + : await execAsync(command); + const trimmed = stdout.trim(); + if (!trimmed) { + return null; + } + const parsed = Number.parseInt(trimmed, 10); + if (Number.isNaN(parsed)) { + return null; + } + return parsed; + } catch { + return null; + } +} + +/** + * Sync Traefik config for a service + */ +async function syncTraefikConfig( + sourceServerId: string | null, + targetServerId: string | null, + appName: string, + onProgress?: (log: string) => void, +): Promise { + onProgress?.(`Syncing Traefik config: ${appName}.yml`); + + // Read source config + let config: string | null = null; + if (sourceServerId) { + config = await readRemoteConfig(sourceServerId, appName); + } else { + config = readConfig(appName); + } + + if (!config) { + onProgress?.("No Traefik config found on source, skipping"); + return; + } + + // Write to target + if (targetServerId) { + await writeConfigRemote(targetServerId, appName, config); + } else { + writeConfig(appName, config); + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Phase 1: Scan — detect files, sizes, and conflicts + */ +export async function scanServiceForTransfer( + opts: TransferOptions, + onProgress?: (progress: TransferScanProgress) => void, +): Promise { + const result: TransferScanResult = { + volumes: [], + binds: [], + totalSizeBytes: 0, + conflicts: [], + hasConflicts: false, + }; + const mountConfigs = await getMountConfigsForTransfer(opts); + const dirPath = getServiceDirPath(opts.serviceType, opts.appName); + const totalMounts = mountConfigs.length * 2 + (dirPath ? 2 : 0); + let processedMounts = 0; + let scannedFiles = 0; + let processedHashes = 0; + let totalHashes = 0; + const scanEmitInterval = 200; + const emitScanProgress = ( + phase: string, + mount?: string, + currentFile?: string, + force = false, + ) => { + if (!onProgress) return; + if (!force && !currentFile && !phase.includes("complete")) { + return; + } + onProgress({ + phase, + mount, + currentFile, + processedMounts, + totalMounts, + scannedFiles, + processedHashes, + totalHashes, + }); + }; + const makeFileScanner = + (phase: string, mount: string) => (file: { path: string }) => { + scannedFiles++; + if (onProgress && scannedFiles % scanEmitInterval === 0) { + emitScanProgress(phase, mount, file.path, true); + } + }; + emitScanProgress("Preparing scan", undefined, undefined, true); + + // 1. Scan service directory (application/compose only) + if (dirPath) { + emitScanProgress("Scanning source mount", dirPath, undefined, true); + const sourceFiles = await scanBindMount( + opts.sourceServerId, + dirPath, + makeFileScanner("Scanning source mount", dirPath), + ); + processedMounts++; + emitScanProgress("Scanned source mount", dirPath, undefined, true); + emitScanProgress("Scanning target mount", dirPath, undefined, true); + const targetFiles = await scanBindMount( + opts.targetServerId, + dirPath, + makeFileScanner("Scanning target mount", dirPath), + ); + processedMounts++; + emitScanProgress("Scanned target mount", dirPath, undefined, true); + const serviceDirMountId = getServiceDirMountId( + opts.serviceType, + opts.serviceId, + ); + const compared = compareFileLists(sourceFiles, targetFiles).map((file) => ({ + ...file, + decisionKey: buildDecisionKey(serviceDirMountId, file.path), + })); + + result.serviceDir = { path: dirPath, files: compared }; + result.totalSizeBytes += sourceFiles.reduce((s, f) => s + f.size, 0); + } + + // 2. Check Traefik config (application/compose only) + if (hasTraefikConfig(opts.serviceType)) { + let sourceConfig: string | null = null; + let targetConfig: string | null = null; + + if (opts.sourceServerId) { + sourceConfig = await readRemoteConfig(opts.sourceServerId, opts.appName); + } else { + sourceConfig = readConfig(opts.appName); + } + + if (opts.targetServerId) { + targetConfig = await readRemoteConfig(opts.targetServerId, opts.appName); + } else { + targetConfig = readConfig(opts.appName); + } + + result.traefikConfig = { + sourceExists: !!sourceConfig, + targetExists: !!targetConfig, + hasConflict: + !!sourceConfig && !!targetConfig && sourceConfig !== targetConfig, + sourceContent: sourceConfig || undefined, + targetContent: targetConfig || undefined, + }; + } + + // 3. Scan all mounts (volumes + binds) + for (const mount of mountConfigs) { + const mountLabel = `${mount.mountType}:${mount.sourcePath}`; + if (mount.mountType === "volume") { + const sizeBytes = await getVolumeSize( + opts.sourceServerId, + mount.sourcePath, + ); + emitScanProgress("Scanning source mount", mountLabel, undefined, true); + const sourceFiles = await scanVolume( + opts.sourceServerId, + mount.sourcePath, + makeFileScanner("Scanning source mount", mountLabel), + ); + processedMounts++; + emitScanProgress("Scanned source mount", mountLabel, undefined, true); + emitScanProgress("Scanning target mount", mountLabel, undefined, true); + const targetFiles = await scanVolume( + opts.targetServerId, + mount.targetPath, + makeFileScanner("Scanning target mount", mountLabel), + ); + processedMounts++; + emitScanProgress("Scanned target mount", mountLabel, undefined, true); + const compared = compareFileLists(sourceFiles, targetFiles).map( + (file) => ({ + ...file, + decisionKey: buildDecisionKey(mount.mountId, file.path), + }), + ); + + result.volumes.push({ + volumeName: mount.sourcePath, + mountPath: mount.mountPath || "", + sizeBytes: sizeBytes ?? 0, + files: compared, + }); + result.totalSizeBytes += sizeBytes ?? 0; + } else { + const sourceFiles = await scanBindMount( + opts.sourceServerId, + mount.sourcePath, + makeFileScanner("Scanning source mount", mountLabel), + ); + processedMounts++; + emitScanProgress("Scanned source mount", mountLabel, undefined, true); + emitScanProgress("Scanning target mount", mountLabel, undefined, true); + const targetFiles = await scanBindMount( + opts.targetServerId, + mount.targetPath, + makeFileScanner("Scanning target mount", mountLabel), + ); + processedMounts++; + emitScanProgress("Scanned target mount", mountLabel, undefined, true); + const compared = compareFileLists(sourceFiles, targetFiles).map( + (file) => ({ + ...file, + decisionKey: buildDecisionKey(mount.mountId, file.path), + }), + ); + + result.binds.push({ + hostPath: mount.sourcePath, + files: compared, + }); + result.totalSizeBytes += sourceFiles.reduce((s, f) => s + f.size, 0); + } + } + + // 4. Collect all files requiring user attention + const allFiles: FileCompareResult[] = []; + if (result.serviceDir) allFiles.push(...result.serviceDir.files); + for (const v of result.volumes) allFiles.push(...v.files); + for (const b of result.binds) allFiles.push(...b.files); + + result.conflicts = allFiles.filter( + (f) => + f.status === "conflict" || + f.status === "newer_target" || + f.status === "newer_source", + ); + totalHashes = result.conflicts.filter( + (conflict) => + !conflict.isDirectory && + (conflict.status === "newer_source" || + conflict.status === "newer_target"), + ).length; + if (totalHashes > 0) { + emitScanProgress("Hashing changed files", undefined, undefined, true); + } + + // Hash verification is only needed for mtime-based deltas. + // For same-mtime "conflict" entries, size already differs so hashing is redundant. + for (let i = 0; i < result.conflicts.length; i++) { + const conflict = result.conflicts[i]; + if (!conflict) continue; + if ( + conflict.status !== "newer_source" && + conflict.status !== "newer_target" + ) { + continue; + } + if (conflict.isDirectory) continue; + processedHashes++; + if (processedHashes % 20 === 0 || processedHashes === totalHashes) { + emitScanProgress("Hashing changed files", undefined, conflict.path, true); + } + + // Determine which mount this belongs to + let hashApplied = false; + for (const v of result.volumes) { + const idx = v.files.indexOf(conflict); + if (idx >= 0) { + const sourceHash = await computeFileHash( + opts.sourceServerId, + v.volumeName, + conflict.path, + true, + ); + const targetHash = await computeFileHash( + opts.targetServerId, + v.volumeName, + conflict.path, + true, + ); + conflict.hash = sourceHash || undefined; + conflict.targetInfo = conflict.targetInfo + ? { ...conflict.targetInfo, hash: targetHash || undefined } + : undefined; + if (sourceHash && targetHash && sourceHash === targetHash) { + conflict.status = "match"; + } + hashApplied = true; + break; + } + } + + if (hashApplied) continue; + + for (const b of result.binds) { + const idx = b.files.indexOf(conflict); + if (idx >= 0) { + const sourceHash = await computeFileHash( + opts.sourceServerId, + b.hostPath, + conflict.path, + false, + ); + const targetHash = await computeFileHash( + opts.targetServerId, + b.hostPath, + conflict.path, + false, + ); + conflict.hash = sourceHash || undefined; + conflict.targetInfo = conflict.targetInfo + ? { ...conflict.targetInfo, hash: targetHash || undefined } + : undefined; + if (sourceHash && targetHash && sourceHash === targetHash) { + conflict.status = "match"; + } + hashApplied = true; + break; + } + } + + if (hashApplied || !result.serviceDir) continue; + + const idx = result.serviceDir.files.indexOf(conflict); + if (idx >= 0) { + const sourceHash = await computeFileHash( + opts.sourceServerId, + result.serviceDir.path, + conflict.path, + false, + ); + const targetHash = await computeFileHash( + opts.targetServerId, + result.serviceDir.path, + conflict.path, + false, + ); + conflict.hash = sourceHash || undefined; + conflict.targetInfo = conflict.targetInfo + ? { ...conflict.targetInfo, hash: targetHash || undefined } + : undefined; + if (sourceHash && targetHash && sourceHash === targetHash) { + conflict.status = "match"; + } + } + } + + // Re-evaluate after hash verification: equal-content mtime deltas become matches. + result.conflicts = allFiles.filter( + (f) => + f.status === "conflict" || + f.status === "newer_target" || + f.status === "newer_source", + ); + + result.hasConflicts = result.conflicts.length > 0; + emitScanProgress("Scan complete", undefined, undefined, true); + + return result; +} + +/** + * Phase 2: Execute — sync files/volumes with progress, then return + */ +export async function executeTransfer( + opts: TransferOptions, + decisions: Record, + onProgress: (progress: TransferProgress) => void, +): Promise<{ success: boolean; errors: string[] }> { + type DiffPlannedSync = { + mode: "diff"; + mount: ResolvedMountTransferConfig; + compared: FileCompareResult[]; + filesToSyncCount: number; + totalBytes: number; + phaseLabel: string; + }; + + type ArchivePlannedSync = { + mode: "archive"; + mount: ResolvedMountTransferConfig; + filesToSyncCount: number; + totalBytes: number; + phaseLabel: string; + }; + + type PlannedSync = DiffPlannedSync | ArchivePlannedSync; + + const errors: string[] = []; + let totalBytes = 0; + let transferredBytes = 0; + let totalFiles = 0; + let processedFiles = 0; + + // Helper to emit progress + const emit = (phase: string, currentFile?: string) => { + onProgress({ + phase, + currentFile, + processedFiles, + totalFiles, + transferredBytes, + totalBytes, + percentage: + totalBytes > 0 ? Math.round((transferredBytes / totalBytes) * 100) : 0, + }); + }; + + // Ensure we have a valid target + const targetServerId = opts.targetServerId; + + const createDiffPlan = async ( + mount: ResolvedMountTransferConfig, + phaseLabel: string, + decisionScope?: string, + ): Promise => { + const sourceFiles = + mount.mountType === "volume" + ? await scanVolume(opts.sourceServerId, mount.sourcePath) + : await scanBindMount(opts.sourceServerId, mount.sourcePath); + const targetFiles = + mount.mountType === "volume" + ? await scanVolume(targetServerId, mount.targetPath) + : await scanBindMount(targetServerId, mount.targetPath); + const compared = compareFileLists(sourceFiles, targetFiles).map((file) => ({ + ...file, + decisionKey: buildDecisionKey(decisionScope || mount.mountId, file.path), + })); + const filesToSync = compared.filter((file) => + shouldSyncFile(file, "manual", decisions, decisionScope || mount.mountId), + ); + return { + mode: "diff", + mount, + compared, + filesToSyncCount: filesToSync.length, + totalBytes: filesToSync.reduce((sum, file) => sum + file.size, 0), + phaseLabel, + }; + }; + + try { + emit("Preparing transfer plan"); + + // 1. Build sync plans and totals before copying files. + let serviceDirPlan: DiffPlannedSync | null = null; + const dirPath = getServiceDirPath(opts.serviceType, opts.appName); + const mountConfigs = await getMountConfigsForTransfer(opts); + const planningTotal = (dirPath ? 1 : 0) + mountConfigs.length; + let planningStep = 0; + const decisionEntries = Object.entries(decisions); + const hasSkipDecisionForScope = (scope: string) => + decisionEntries.some( + ([key, decision]) => key.startsWith(`${scope}:`) && decision === "skip", + ); + + const emitPlanning = (detail: string) => { + planningStep++; + emit( + `Preparing transfer plan (${planningStep}/${planningTotal})`, + detail, + ); + }; + + if (dirPath) { + const serviceDirMount: ResolvedMountTransferConfig = { + mountId: getServiceDirMountId(opts.serviceType, opts.serviceId), + mountType: "bind", + sourcePath: dirPath, + targetPath: dirPath, + createIfMissing: true, + updateMountConfig: false, + }; + const phaseLabel = `Syncing service directory: ${dirPath}`; + emitPlanning(`Analyzing bind: ${dirPath}`); + serviceDirPlan = await createDiffPlan( + serviceDirMount, + phaseLabel, + serviceDirMount.mountId, + ); + totalFiles += serviceDirPlan.filesToSyncCount; + totalBytes += serviceDirPlan.totalBytes; + } + + // 2. Build plans for mounts (volumes & binds) + const mountPlans: PlannedSync[] = []; + for (const mount of mountConfigs) { + if ( + mount.mountType === "volume" && + !hasSkipDecisionForScope(mount.mountId) + ) { + emitPlanning(`Analyzing volume: ${mount.sourcePath} (archive mode)`); + const sizeBytes = await getVolumeSize( + opts.sourceServerId, + mount.sourcePath, + ); + const shouldSyncArchive = sizeBytes === null || sizeBytes > 0; + const archivePlan: ArchivePlannedSync = { + mode: "archive", + mount, + filesToSyncCount: shouldSyncArchive ? 1 : 0, + totalBytes: sizeBytes ?? 0, + phaseLabel: `Syncing ${mount.mountType}: ${mount.sourcePath}`, + }; + mountPlans.push(archivePlan); + totalFiles += archivePlan.filesToSyncCount; + totalBytes += archivePlan.totalBytes; + continue; + } + + emitPlanning(`Analyzing ${mount.mountType}: ${mount.sourcePath}`); + const diffPlan = await createDiffPlan( + mount, + `Syncing ${mount.mountType}: ${mount.sourcePath}`, + ); + mountPlans.push(diffPlan); + totalFiles += diffPlan.filesToSyncCount; + totalBytes += diffPlan.totalBytes; + } + + // 3. Sync service directory (application/compose) + if (serviceDirPlan && dirPath) { + emit(serviceDirPlan.phaseLabel); + await createDirectoryOnTarget(targetServerId, dirPath); + + const abortController = new AbortController(); + let mountProcessedFiles = 0; + let mountTransferredBytes = 0; + const serviceDirResult = await syncMount( + serviceDirPlan.mount, + serviceDirPlan.compared, + opts.sourceServerId, + targetServerId, + "manual", + decisions, + abortController.signal, + (status) => { + if (status.processedFiles !== undefined) { + const delta = status.processedFiles - mountProcessedFiles; + mountProcessedFiles = status.processedFiles; + if (delta > 0) { + processedFiles += delta; + } + } + if (status.transferredBytes !== undefined) { + const delta = status.transferredBytes - mountTransferredBytes; + mountTransferredBytes = status.transferredBytes; + if (delta > 0) { + transferredBytes += delta; + } + } + emit(serviceDirPlan.phaseLabel, status.currentFile || undefined); + }, + ); + + if (!serviceDirResult.success) { + errors.push(...serviceDirResult.errors); + } + } + + // 4. Sync Traefik config (application/compose) + if (hasTraefikConfig(opts.serviceType)) { + emit("Syncing Traefik configuration"); + await syncTraefikConfig( + opts.sourceServerId, + targetServerId, + opts.appName, + (log) => emit("Syncing Traefik configuration", log), + ); + } + + // Pre-flight: create volumes/directories on target + if (targetServerId) { + for (const { mount } of mountPlans) { + if (mount.mountType === "volume") { + await createVolumeOnTarget(targetServerId, mount.targetPath, { + labels: getComposeManagedVolumeLabels(mount), + }); + } else { + await createDirectoryOnTarget(targetServerId, mount.targetPath); + } + } + } + + // Sync each mount + for (const plan of mountPlans) { + const { mount, phaseLabel } = plan; + emit(phaseLabel); + + if (plan.mode === "archive") { + if (plan.filesToSyncCount === 0) { + continue; + } + try { + emit(phaseLabel, "[archive stream]"); + await syncVolumeArchive( + opts.sourceServerId, + targetServerId, + mount.sourcePath, + mount.targetPath, + ); + processedFiles += plan.filesToSyncCount; + transferredBytes += plan.totalBytes; + emit(phaseLabel); + } catch (error) { + errors.push( + `Failed to sync volume ${mount.sourcePath}: ${ + error instanceof Error ? error.message : "Unknown error" + }`, + ); + } + continue; + } + + const abortController = new AbortController(); + let mountProcessedFiles = 0; + let mountTransferredBytes = 0; + + const mountResult = await syncMount( + mount, + plan.compared, + opts.sourceServerId, + targetServerId, + "manual", + decisions, + abortController.signal, + (status) => { + if (status.processedFiles !== undefined) { + const delta = status.processedFiles - mountProcessedFiles; + mountProcessedFiles = status.processedFiles; + if (delta > 0) { + processedFiles += delta; + } + } + if (status.transferredBytes !== undefined) { + const delta = status.transferredBytes - mountTransferredBytes; + mountTransferredBytes = status.transferredBytes; + if (delta > 0) { + transferredBytes += delta; + } + } + emit(phaseLabel, status.currentFile || undefined); + }, + ); + + if (!mountResult.success) { + errors.push(...mountResult.errors); + } + } + + emit("Transfer complete"); + } catch (error) { + const msg = + error instanceof Error ? error.message : "Unknown transfer error"; + errors.push(msg); + emit("Transfer failed"); + } + + return { success: errors.length === 0, errors }; +} diff --git a/packages/server/src/utils/process/ssh.ts b/packages/server/src/utils/process/ssh.ts new file mode 100644 index 0000000000..8ce5664b58 --- /dev/null +++ b/packages/server/src/utils/process/ssh.ts @@ -0,0 +1,86 @@ +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +interface SSHServerConnection { + ipAddress: string; + port: number; + username: string; +} + +const SSH_BASE_OPTIONS = + "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o IdentitiesOnly=yes"; + +/** + * Escape a value for safe interpolation in shell commands. + */ +export const shellEscape = (value: string): string => { + return `'${value.replaceAll("'", `'\"'\"'`)}'`; +}; + +/** + * Ensure a server private key exists. + */ +export const getServerPrivateKey = ( + privateKey: string | undefined, + serverLabel: string, +): string => { + if (!privateKey) { + throw new Error(`${serverLabel} has no SSH private key configured`); + } + return privateKey; +}; + +/** + * Create a temporary private key file and return its cleanup callback. + */ +export const createTemporaryPrivateKeyFile = async ( + privateKey: string, + prefix = "dokploy-transfer-key-", +): Promise<{ keyPath: string; cleanup: () => Promise }> => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), prefix)); + const keyPath = path.join(tempDir, "id_rsa"); + + await writeFile(keyPath, privateKey, { mode: 0o600 }); + await chmod(keyPath, 0o600); + + return { + keyPath, + cleanup: async () => { + await rm(tempDir, { recursive: true, force: true }); + }, + }; +}; + +/** + * Build a raw SSH command without a remote command payload. + */ +export const buildSshCommand = ( + server: SSHServerConnection, + privateKeyPath: string, +): string => { + return `ssh ${SSH_BASE_OPTIONS} -i ${shellEscape(privateKeyPath)} -p ${server.port} ${shellEscape( + `${server.username}@${server.ipAddress}`, + )}`; +}; + +/** + * Build an SSH command that executes a remote shell command. + */ +export const buildSshExecCommand = ( + server: SSHServerConnection, + privateKeyPath: string, + remoteCommand: string, +): string => { + return `${buildSshCommand(server, privateKeyPath)} ${shellEscape(remoteCommand)}`; +}; + +/** + * Build the SSH transport string consumed by rsync -e. + */ +export const buildRsyncSshTransport = ( + port: number, + privateKeyPath: string, +): string => { + return `ssh ${SSH_BASE_OPTIONS} -i ${shellEscape(privateKeyPath)} -p ${port}`; +}; diff --git a/packages/server/src/utils/transfer/index.ts b/packages/server/src/utils/transfer/index.ts new file mode 100644 index 0000000000..5923e9bce4 --- /dev/null +++ b/packages/server/src/utils/transfer/index.ts @@ -0,0 +1,8 @@ +/** + * Transfer utilities index + */ + +export * from "./preflight"; +export * from "./scanner"; +export * from "./sync"; +export * from "./types"; diff --git a/packages/server/src/utils/transfer/preflight.ts b/packages/server/src/utils/transfer/preflight.ts new file mode 100644 index 0000000000..d413436f7b --- /dev/null +++ b/packages/server/src/utils/transfer/preflight.ts @@ -0,0 +1,198 @@ +/** + * Pre-flight checks for transfer target server + */ +import { findServerById } from "@dokploy/server/services/server"; +import { execAsync, execAsyncRemote } from "../process/execAsync"; +import { shellEscape } from "../process/ssh"; +import type { MountTransferConfig, PreflightCheckResult } from "./types"; + +export interface VolumeCreateOptions { + labels?: Record; +} + +function buildVolumeLabelArgs(labels?: Record): string { + if (!labels) return ""; + + return Object.entries(labels) + .filter(([, value]) => value.length > 0) + .map(([key, value]) => `--label ${shellEscape(`${key}=${value}`)}`) + .join(" "); +} + +/** + * Check if a path exists and is writable on the target server + */ +export async function checkPathPermissions( + serverId: string | null, + path: string, +): Promise { + const escapedPath = shellEscape(path); + const command = ` + TARGET_PATH=${escapedPath} + if [ -e "$TARGET_PATH" ]; then + EXISTS="true" + if [ -w "$TARGET_PATH" ]; then + WRITABLE="true" + else + WRITABLE="false" + fi + else + EXISTS="false" + # Check if parent is writable (can create) + PARENT=$(dirname "$TARGET_PATH") + if [ -w "$PARENT" ]; then + WRITABLE="true" + else + WRITABLE="false" + fi + fi + SPACE=$(df -B1 "$TARGET_PATH" 2>/dev/null | tail -1 | awk '{print $4}' || echo "0") + echo "$EXISTS|$WRITABLE|$SPACE" + `; + + try { + const { stdout } = serverId + ? await execAsyncRemote(serverId, command) + : await execAsync(command); + + const parts = stdout.trim().split("|"); + const exists = parts[0] || "false"; + const writable = parts[1] || "false"; + const space = parts[2] || "0"; + return { + path, + exists: exists === "true", + writable: writable === "true", + spaceAvailable: Number.parseInt(space, 10) || 0, + }; + } catch (error) { + return { + path, + exists: false, + writable: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } +} + +/** + * Check if Docker volume exists on target server + */ +export async function checkVolumeExists( + serverId: string | null, + volumeName: string, +): Promise { + const command = `docker volume inspect ${shellEscape(volumeName)} > /dev/null 2>&1 && echo "exists" || echo "missing"`; + + try { + const { stdout } = serverId + ? await execAsyncRemote(serverId, command) + : await execAsync(command); + return stdout.trim() === "exists"; + } catch { + return false; + } +} + +/** + * Create directory on target server + */ +export async function createDirectoryOnTarget( + serverId: string | null, + path: string, +): Promise<{ success: boolean; error?: string }> { + const command = `mkdir -p ${shellEscape(path)}`; + + try { + if (serverId) { + await execAsyncRemote(serverId, command); + } else { + await execAsync(command); + } + return { success: true }; + } catch (error) { + return { + success: false, + error: + error instanceof Error ? error.message : "Failed to create directory", + }; + } +} + +/** + * Create Docker volume on target server + */ +export async function createVolumeOnTarget( + serverId: string | null, + volumeName: string, + options?: VolumeCreateOptions, +): Promise<{ success: boolean; error?: string }> { + const labelArgs = buildVolumeLabelArgs(options?.labels); + const command = ["docker volume create", labelArgs, shellEscape(volumeName)] + .filter(Boolean) + .join(" "); + + try { + if (serverId) { + await execAsyncRemote(serverId, command); + } else { + await execAsync(command); + } + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Failed to create volume", + }; + } +} + +/** + * Run pre-flight checks for all mounts + */ +export async function runPreflightChecks( + targetServerId: string, + mounts: MountTransferConfig[], + emit?: (data: { mount: string; result: PreflightCheckResult }) => void, +): Promise> { + const results = new Map(); + + for (const mount of mounts) { + let result: PreflightCheckResult; + + if (mount.mountType === "volume") { + const exists = await checkVolumeExists(targetServerId, mount.targetPath); + result = { + path: mount.targetPath, + exists, + writable: true, // Volumes are always writable if they exist + }; + } else { + result = await checkPathPermissions(targetServerId, mount.targetPath); + } + + results.set(mount.mountId, result); + + if (emit) { + emit({ mount: mount.mountId, result }); + } + } + + return results; +} + +/** + * Get SSH connection info for establishing direct transfer + */ +export async function getServerSSHInfo(serverId: string) { + const server = await findServerById(serverId); + if (!server.sshKeyId) { + throw new Error("Target server has no SSH key configured"); + } + return { + host: server.ipAddress, + port: server.port, + username: server.username, + privateKey: server.sshKey?.privateKey, + }; +} diff --git a/packages/server/src/utils/transfer/scanner.ts b/packages/server/src/utils/transfer/scanner.ts new file mode 100644 index 0000000000..adfe3fe0ec --- /dev/null +++ b/packages/server/src/utils/transfer/scanner.ts @@ -0,0 +1,258 @@ +/** + * File scanning and comparison utilities for transfer + */ +import { execAsync, execAsyncRemote } from "../process/execAsync"; +import { shellEscape } from "../process/ssh"; +import type { + FileCompareResult, + FileCompareStatus, + FileInfo, + MountTransferConfig, +} from "./types"; + +/** + * Scan files in a Docker volume + */ +export async function scanVolume( + serverId: string | null, + volumeName: string, + emit?: (file: FileInfo) => void, +): Promise { + // Use a temporary container to list files in the volume + const volumeMount = shellEscape(`${volumeName}:/volume_data:ro`); + const command = ` + docker run --rm -v ${volumeMount} alpine sh -c ' + find /volume_data -type f -o -type d | while read f; do + if [ -f "$f" ]; then + STAT=$(stat -c "%s|%Y|%a" "$f" 2>/dev/null || echo "0|0|644") + echo "f|$f|$STAT" + elif [ -d "$f" ]; then + STAT=$(stat -c "0|%Y|%a" "$f" 2>/dev/null || echo "0|0|755") + echo "d|$f|$STAT" + fi + done + ' + `; + + const files: FileInfo[] = []; + + try { + const { stdout } = serverId + ? await execAsyncRemote(serverId, command) + : await execAsync(command); + + for (const line of stdout.trim().split("\n")) { + if (!line) continue; + const parts = line.split("|"); + const type = parts[0] || "f"; + const fullPath = parts[1] || ""; + const size = parts[2] || "0"; + const mtime = parts[3] || "0"; + const mode = parts[4] || (type === "d" ? "755" : "644"); + const path = fullPath.replace("/volume_data", ""); + if (!path) continue; + + const fileInfo: FileInfo = { + path, + size: Number.parseInt(size, 10) || 0, + mtime: Number.parseInt(mtime, 10) || 0, + mode, + isDirectory: type === "d", + }; + + files.push(fileInfo); + if (emit) emit(fileInfo); + } + } catch (error) { + console.error(`Error scanning volume ${volumeName}:`, error); + } + + return files; +} + +/** + * Scan files in a bind mount path + */ +export async function scanBindMount( + serverId: string | null, + hostPath: string, + emit?: (file: FileInfo) => void, +): Promise { + const escapedHostPath = shellEscape(hostPath); + const command = ` + find ${escapedHostPath} -type f -o -type d 2>/dev/null | while read f; do + if [ -f "$f" ]; then + STAT=$(stat -c "%s|%Y|%a" "$f" 2>/dev/null || echo "0|0|644") + echo "f|$f|$STAT" + elif [ -d "$f" ]; then + STAT=$(stat -c "0|%Y|%a" "$f" 2>/dev/null || echo "0|0|755") + echo "d|$f|$STAT" + fi + done + `; + + const files: FileInfo[] = []; + + try { + const { stdout } = serverId + ? await execAsyncRemote(serverId, command) + : await execAsync(command); + + for (const line of stdout.trim().split("\n")) { + if (!line) continue; + const parts = line.split("|"); + const type = parts[0] || "f"; + const fullPath = parts[1] || ""; + const size = parts[2] || "0"; + const mtime = parts[3] || "0"; + const mode = parts[4] || (type === "d" ? "755" : "644"); + const path = fullPath.replace(hostPath, ""); + if (!path) continue; + + const fileInfo: FileInfo = { + path, + size: Number.parseInt(size, 10) || 0, + mtime: Number.parseInt(mtime, 10) || 0, + mode, + isDirectory: type === "d", + }; + + files.push(fileInfo); + if (emit) emit(fileInfo); + } + } catch (error) { + console.error(`Error scanning bind mount ${hostPath}:`, error); + } + + return files; +} + +/** + * Scan files based on mount type + */ +export async function scanMount( + serverId: string | null, + mount: MountTransferConfig, + emit?: (file: FileInfo) => void, +): Promise { + if (mount.mountType === "volume") { + return scanVolume(serverId, mount.sourcePath, emit); + } + return scanBindMount(serverId, mount.sourcePath, emit); +} + +/** + * Compare two file lists and determine sync actions + */ +export function compareFileLists( + sourceFiles: FileInfo[], + targetFiles: FileInfo[], +): FileCompareResult[] { + const targetMap = new Map(); + for (const file of targetFiles) { + targetMap.set(file.path, file); + } + + const results: FileCompareResult[] = []; + const seenPaths = new Set(); + + // Check source files against target + for (const source of sourceFiles) { + seenPaths.add(source.path); + const target = targetMap.get(source.path); + + let status: FileCompareStatus; + if (!target) { + status = "missing_target"; + } else if (source.size === target.size && source.mtime === target.mtime) { + status = "match"; + } else if (source.mtime > target.mtime) { + status = "newer_source"; + } else if (source.mtime < target.mtime) { + status = "newer_target"; + } else { + // Same mtime but different size = conflict + status = "conflict"; + } + + results.push({ + ...source, + status, + targetInfo: target, + }); + } + + // Check for files only on target + for (const target of targetFiles) { + if (!seenPaths.has(target.path)) { + results.push({ + ...target, + status: "missing_source", + targetInfo: target, + }); + } + } + + return results; +} + +/** + * Compute MD5 hash for a file (for conflict resolution) + */ +export async function computeFileHash( + serverId: string | null, + basePath: string, + relativePath: string, + isVolume: boolean, +): Promise { + let command: string; + + if (isVolume) { + command = `docker run --rm -v ${shellEscape(`${basePath}:/volume_data:ro`)} alpine md5sum ${shellEscape(`/volume_data${relativePath}`)} 2>/dev/null | cut -d' ' -f1`; + } else { + command = `md5sum ${shellEscape(`${basePath}${relativePath}`)} 2>/dev/null | cut -d' ' -f1`; + } + + try { + const { stdout } = serverId + ? await execAsyncRemote(serverId, command) + : await execAsync(command); + return stdout.trim() || null; + } catch { + return null; + } +} + +/** + * Compare files with optional hash verification + */ +export async function compareFilesWithHash( + sourceServerId: string | null, + targetServerId: string, + mount: MountTransferConfig, + file: FileCompareResult, +): Promise { + if (file.status !== "conflict") { + return file; + } + + const isVolume = mount.mountType === "volume"; + const sourceHash = await computeFileHash( + sourceServerId, + mount.sourcePath, + file.path, + isVolume, + ); + const targetHash = await computeFileHash( + targetServerId, + mount.targetPath, + file.path, + isVolume, + ); + + if (sourceHash && targetHash && sourceHash === targetHash) { + return { ...file, status: "match", hash: sourceHash }; + } + + return { ...file, hash: sourceHash || undefined }; +} diff --git a/packages/server/src/utils/transfer/sync.ts b/packages/server/src/utils/transfer/sync.ts new file mode 100644 index 0000000000..d44f82cc65 --- /dev/null +++ b/packages/server/src/utils/transfer/sync.ts @@ -0,0 +1,553 @@ +/** + * Data sync utilities for transfer (READ-ONLY on source) + */ +import { findServerById } from "@dokploy/server/services/server"; +import { execAsync, execAsyncRemote } from "../process/execAsync"; +import { + buildRsyncSshTransport, + buildSshExecCommand, + createTemporaryPrivateKeyFile, + getServerPrivateKey, + shellEscape, +} from "../process/ssh"; +import type { + FileCompareResult, + MergeStrategy, + MountTransferConfig, + TransferStatus, +} from "./types"; + +async function runTransferPipe( + sourceServerId: string | null, + targetServerId: string | null, + sourceCommand: string, + targetCommand: string, +): Promise { + if (sourceServerId && targetServerId) { + // Remote -> Remote sync via Dokploy server stream + const sourceServer = await findServerById(sourceServerId); + const targetServer = await findServerById(targetServerId); + const sourcePrivateKey = getServerPrivateKey( + sourceServer.sshKey?.privateKey, + "Source server", + ); + const targetPrivateKey = getServerPrivateKey( + targetServer.sshKey?.privateKey, + "Target server", + ); + const sourceKey = await createTemporaryPrivateKeyFile(sourcePrivateKey); + const targetKey = await createTemporaryPrivateKeyFile(targetPrivateKey); + + try { + const fullCommand = `${buildSshExecCommand( + sourceServer, + sourceKey.keyPath, + sourceCommand, + )} | ${buildSshExecCommand(targetServer, targetKey.keyPath, targetCommand)}`; + await execAsync(fullCommand); + } finally { + await sourceKey.cleanup(); + await targetKey.cleanup(); + } + return; + } + + if (sourceServerId && !targetServerId) { + // Remote -> Local + const sourceServer = await findServerById(sourceServerId); + const sourcePrivateKey = getServerPrivateKey( + sourceServer.sshKey?.privateKey, + "Source server", + ); + const sourceKey = await createTemporaryPrivateKeyFile(sourcePrivateKey); + + try { + const fullCommand = `${buildSshExecCommand( + sourceServer, + sourceKey.keyPath, + sourceCommand, + )} | ${targetCommand}`; + await execAsync(fullCommand); + } finally { + await sourceKey.cleanup(); + } + return; + } + + if (!sourceServerId && targetServerId) { + // Local -> Remote + const targetServer = await findServerById(targetServerId); + const targetPrivateKey = getServerPrivateKey( + targetServer.sshKey?.privateKey, + "Target server", + ); + const targetKey = await createTemporaryPrivateKeyFile(targetPrivateKey); + + try { + const pipedCommand = `${sourceCommand} | ${buildSshExecCommand( + targetServer, + targetKey.keyPath, + targetCommand, + )}`; + await execAsync(pipedCommand); + } finally { + await targetKey.cleanup(); + } + return; + } + + // Local -> Local + await execAsync(`${sourceCommand} | ${targetCommand}`); +} + +/** + * Build a scoped key for manual conflict decisions. + */ +export function buildDecisionKey(scope: string, filePath: string): string { + return `${scope}:${filePath}`; +} + +/** + * Determine if a file should be synced based on merge strategy + */ +export function shouldSyncFile( + file: FileCompareResult, + strategy: MergeStrategy, + manualDecisions?: Record, + decisionScope?: string, +): boolean { + // Directories are created implicitly by tar/rsync when syncing files. + // Treat them as non-syncable to avoid false conflict handling on directory mtimes. + if (file.isDirectory) { + return false; + } + + // Check manual decision first + if (manualDecisions) { + const scopedKey = + decisionScope && buildDecisionKey(decisionScope, file.path); + const manualDecision = + (file.decisionKey && manualDecisions[file.decisionKey]) || + (scopedKey ? manualDecisions[scopedKey] : undefined) || + manualDecisions[file.path]; + if (manualDecision) { + return manualDecision === "overwrite"; + } + } + + switch (file.status) { + case "match": + return false; // Already identical + + case "missing_target": + return true; // Always copy missing files + + case "missing_source": + return false; // Never touch files only on target + + case "newer_source": + return strategy !== "skip"; + + case "newer_target": + return strategy === "overwrite"; + + case "conflict": + return strategy === "overwrite" || strategy === "newer"; + + default: + return false; + } +} + +/** + * Sync a single file from source to target (volume) + */ +export async function syncVolumeFile( + sourceServerId: string | null, + targetServerId: string | null, + sourceVolume: string, + targetVolume: string, + filePath: string, + emit?: (log: string) => void, +): Promise { + emit?.(`Syncing ${filePath}...`); + + // Use docker to stream a compressed tar archive through SSH/local shell. + // SOURCE: docker run -v vol:/data alpine tar czf - file + // TARGET: docker run -i -v vol:/data alpine tar xzf - + const sourceCommand = `docker run --rm -v ${shellEscape( + `${sourceVolume}:/volume_data:ro`, + )} alpine tar czf - -C /volume_data ${shellEscape(`.${filePath}`)}`; + const targetCommand = `docker run --rm -i -v ${shellEscape( + `${targetVolume}:/volume_data`, + )} alpine tar xzf - -C /volume_data`; + + await runTransferPipe( + sourceServerId, + targetServerId, + sourceCommand, + targetCommand, + ); +} + +/** + * Sync many files from a source volume to a target volume in a single tar stream. + * This is significantly faster than syncing file-by-file for large volumes. + */ +export async function syncVolumeFilesBatch( + sourceServerId: string | null, + targetServerId: string | null, + sourceVolume: string, + targetVolume: string, + filePaths: string[], +): Promise { + if (filePaths.length === 0) return; + + const relativePaths = filePaths.map((filePath) => `.${filePath}`); + const encodedFileList = Buffer.from( + relativePaths.join("\n"), + "utf8", + ).toString("base64"); + + const sourceCommand = `docker run --rm -v ${shellEscape( + `${sourceVolume}:/volume_data:ro`, + )} alpine sh -c ${shellEscape( + `echo ${shellEscape(encodedFileList)} | base64 -d > /tmp/files.txt && tar czf - -C /volume_data -T /tmp/files.txt`, + )}`; + const targetCommand = `docker run --rm -i -v ${shellEscape( + `${targetVolume}:/volume_data`, + )} alpine tar xzf - -C /volume_data`; + + await runTransferPipe( + sourceServerId, + targetServerId, + sourceCommand, + targetCommand, + ); +} + +/** + * Sync a whole volume using a single compressed tar stream. + */ +export async function syncVolumeArchive( + sourceServerId: string | null, + targetServerId: string | null, + sourceVolume: string, + targetVolume: string, +): Promise { + const sourceCommand = `docker run --rm -v ${shellEscape( + `${sourceVolume}:/volume_data:ro`, + )} alpine tar czf - -C /volume_data .`; + const targetCommand = `docker run --rm -i -v ${shellEscape( + `${targetVolume}:/volume_data`, + )} alpine tar xzf - -C /volume_data`; + + await runTransferPipe( + sourceServerId, + targetServerId, + sourceCommand, + targetCommand, + ); +} + +/** + * Sync a single file from source to target (bind mount) + */ +export async function syncBindFile( + sourceServerId: string | null, + targetServerId: string | null, + sourcePath: string, + targetPath: string, + filePath: string, + emit?: (log: string) => void, +): Promise { + emit?.(`Syncing ${filePath}...`); + + const sourceFullPath = `${sourcePath}${filePath}`; + const targetFullPath = `${targetPath}${filePath}`; + + // Ensure target directory exists + const targetDir = + targetFullPath.substring(0, targetFullPath.lastIndexOf("/")) || "/"; + if (targetServerId) { + await execAsyncRemote(targetServerId, `mkdir -p ${shellEscape(targetDir)}`); + } else { + await execAsync(`mkdir -p ${shellEscape(targetDir)}`); + } + + if (sourceServerId && targetServerId) { + // Remote -> Remote bind sync via Dokploy server stream + const sourceServer = await findServerById(sourceServerId); + const targetServer = await findServerById(targetServerId); + const sourcePrivateKey = getServerPrivateKey( + sourceServer.sshKey?.privateKey, + "Source server", + ); + const targetPrivateKey = getServerPrivateKey( + targetServer.sshKey?.privateKey, + "Target server", + ); + const sourceKey = await createTemporaryPrivateKeyFile(sourcePrivateKey); + const targetKey = await createTemporaryPrivateKeyFile(targetPrivateKey); + + try { + const sourceCommand = `tar cf - -C ${shellEscape(sourcePath)} ${shellEscape( + `.${filePath}`, + )}`; + const targetCommand = `mkdir -p ${shellEscape( + targetDir, + )} && tar xf - -C ${shellEscape(targetPath)}`; + const pipedCommand = `${buildSshExecCommand( + sourceServer, + sourceKey.keyPath, + sourceCommand, + )} | ${buildSshExecCommand(targetServer, targetKey.keyPath, targetCommand)}`; + await execAsync(pipedCommand); + } finally { + await sourceKey.cleanup(); + await targetKey.cleanup(); + } + return; + } + + if (sourceServerId && !targetServerId) { + // Remote -> Local + const sourceServer = await findServerById(sourceServerId); + const sourcePrivateKey = getServerPrivateKey( + sourceServer.sshKey?.privateKey, + "Source server", + ); + const sourceKey = await createTemporaryPrivateKeyFile(sourcePrivateKey); + + try { + const sourceCommand = `tar cf - -C ${shellEscape(sourcePath)} ${shellEscape( + `.${filePath}`, + )}`; + const targetCommand = `mkdir -p ${shellEscape( + targetDir, + )} && tar xf - -C ${shellEscape(targetPath)}`; + const pipedCommand = `${buildSshExecCommand( + sourceServer, + sourceKey.keyPath, + sourceCommand, + )} | ${targetCommand}`; + await execAsync(pipedCommand); + } finally { + await sourceKey.cleanup(); + } + return; + } + + if (!sourceServerId && targetServerId) { + // Local -> Remote via rsync + const targetServer = await findServerById(targetServerId); + const targetPrivateKey = getServerPrivateKey( + targetServer.sshKey?.privateKey, + "Target server", + ); + const targetKey = await createTemporaryPrivateKeyFile(targetPrivateKey); + const rsyncSshTransport = buildRsyncSshTransport( + targetServer.port, + targetKey.keyPath, + ); + try { + // Keep source mtimes on target because scan/compare relies on mtime semantics. + const rsyncCommand = `rsync -az --times -e ${shellEscape( + rsyncSshTransport, + )} ${shellEscape(sourceFullPath)} ${shellEscape( + `${targetServer.username}@${targetServer.ipAddress}:${targetFullPath}`, + )}`; + await execAsync(rsyncCommand); + } finally { + await targetKey.cleanup(); + } + return; + } + + // Local -> Local + await execAsync( + `rsync -az --times ${shellEscape(sourceFullPath)} ${shellEscape(targetFullPath)}`, + ); +} + +/** + * Sync all files for a mount with progress updates + */ +export async function syncMount( + mount: MountTransferConfig, + files: FileCompareResult[], + sourceServerId: string | null, + targetServerId: string | null, + strategy: MergeStrategy, + manualDecisions: Record | undefined, + signal: AbortSignal, + emit: (status: Partial) => void, + waitForResume?: () => Promise, +): Promise<{ success: boolean; errors: string[] }> { + const filesToSync = files.filter((f) => + shouldSyncFile(f, strategy, manualDecisions, mount.mountId), + ); + + const errors: string[] = []; + let processedFiles = 0; + let transferredBytes = 0; + + emit({ + state: "syncing", + totalFiles: filesToSync.length, + processedFiles: 0, + totalBytes: filesToSync.reduce((sum, f) => sum + f.size, 0), + transferredBytes: 0, + }); + + if (mount.mountType === "volume") { + const directoryCount = filesToSync.filter( + (file) => file.isDirectory, + ).length; + if (directoryCount > 0) { + processedFiles += directoryCount; + emit({ processedFiles }); + } + + const filesToCopy = filesToSync.filter((file) => !file.isDirectory); + const batchSize = 500; + for (let i = 0; i < filesToCopy.length; i += batchSize) { + if (waitForResume) { + await waitForResume(); + } + + if (signal.aborted) { + emit({ state: "cancelled" }); + return { success: false, errors: ["Transfer cancelled by user"] }; + } + + const batch = filesToCopy.slice(i, i + batchSize); + const batchPaths = batch.map((file) => file.path); + const batchBytes = batch.reduce((sum, file) => sum + file.size, 0); + + try { + emit({ currentFile: batch[0]?.path }); + await syncVolumeFilesBatch( + sourceServerId, + targetServerId, + mount.sourcePath, + mount.targetPath, + batchPaths, + ); + + transferredBytes += batchBytes; + processedFiles += batch.length; + emit({ + processedFiles, + transferredBytes, + }); + } catch (error) { + // Batch mode is an optimization, so we gracefully fall back to per-file sync. + for (const file of batch) { + if (waitForResume) { + await waitForResume(); + } + + if (signal.aborted) { + emit({ state: "cancelled" }); + return { success: false, errors: ["Transfer cancelled by user"] }; + } + + try { + emit({ currentFile: file.path }); + await syncVolumeFile( + sourceServerId, + targetServerId, + mount.sourcePath, + mount.targetPath, + file.path, + ); + + transferredBytes += file.size; + processedFiles++; + + emit({ + processedFiles, + transferredBytes, + }); + } catch (fileError) { + const errorMsg = `Failed to sync ${file.path}: ${ + fileError instanceof Error ? fileError.message : "Unknown error" + }`; + errors.push(errorMsg); + } + } + + if (error instanceof Error) { + console.warn( + `Volume batch sync failed for ${mount.sourcePath}, falling back to per-file mode: ${error.message}`, + ); + } + } + } + + emit({ + state: errors.length > 0 ? "error" : "completed", + processedFiles, + transferredBytes, + errors, + }); + + return { + success: errors.length === 0, + errors, + }; + } + + for (const file of filesToSync) { + if (waitForResume) { + await waitForResume(); + } + + if (signal.aborted) { + emit({ state: "cancelled" }); + return { success: false, errors: ["Transfer cancelled by user"] }; + } + + // Skip directories (they're created automatically) + if (file.isDirectory) { + processedFiles++; + continue; + } + + try { + emit({ currentFile: file.path }); + + await syncBindFile( + sourceServerId, + targetServerId, + mount.sourcePath, + mount.targetPath, + file.path, + ); + + transferredBytes += file.size; + processedFiles++; + + emit({ + processedFiles, + transferredBytes, + }); + } catch (error) { + const errorMsg = `Failed to sync ${file.path}: ${error instanceof Error ? error.message : "Unknown error"}`; + errors.push(errorMsg); + } + } + + emit({ + state: errors.length > 0 ? "error" : "completed", + processedFiles, + transferredBytes, + errors, + }); + + return { + success: errors.length === 0, + errors, + }; +} diff --git a/packages/server/src/utils/transfer/types.ts b/packages/server/src/utils/transfer/types.ts new file mode 100644 index 0000000000..517a920e19 --- /dev/null +++ b/packages/server/src/utils/transfer/types.ts @@ -0,0 +1,140 @@ +/** + * Transfer types for service migration between nodes + */ + +// Service types matching the mount schema +export type ServiceType = + | "application" + | "postgres" + | "mysql" + | "mariadb" + | "mongo" + | "redis" + | "compose"; + +// File information for comparison +export interface FileInfo { + path: string; + size: number; + mtime: number; // Unix timestamp + mode: string; // Permission string like "0644" + hash?: string; // MD5 or SHA256, computed on demand + isDirectory: boolean; +} + +// Comparison result for a single file +export type FileCompareStatus = + | "match" // Identical on both + | "missing_target" // Only on source + | "missing_source" // Only on target + | "newer_source" // Source is newer + | "newer_target" // Target is newer + | "conflict"; // Different but same mtime + +export interface FileCompareResult extends FileInfo { + status: FileCompareStatus; + targetInfo?: FileInfo; + decisionKey?: string; // Scoped key for manual conflict decisions +} + +// Merge strategies for handling existing files +export type MergeStrategy = + | "skip" // Keep target, copy missing only + | "overwrite" // Source always wins + | "newer" // Compare mtime, newer wins + | "manual"; // User decides per file + +// Mount transfer configuration +export interface MountTransferConfig { + mountId: string; + mountType: "volume" | "bind" | "file"; + sourcePath: string; // Volume name or host path + targetPath: string; // Can be different for bind mounts + createIfMissing: boolean; + updateMountConfig: boolean; // Update DB after transfer +} + +// Pre-flight check result +export interface PreflightCheckResult { + path: string; + exists: boolean; + writable: boolean; + error?: string; + spaceAvailable?: number; // Bytes +} + +// Overall transfer status +export type TransferState = + | "idle" + | "scanning" + | "comparing" + | "ready" + | "syncing" + | "paused" + | "completed" + | "error" + | "cancelled"; + +// Real-time transfer status for WebSocket updates +export interface TransferStatus { + state: TransferState; + totalFiles: number; + processedFiles: number; + totalBytes: number; + transferredBytes: number; + currentFile?: string; + speed?: number; // Bytes per second + eta?: number; // Seconds remaining + errors: string[]; +} + +// Transfer job for a single mount +export interface MountTransferJob { + config: MountTransferConfig; + files: FileCompareResult[]; + status: "pending" | "scanning" | "syncing" | "done" | "error"; + error?: string; +} + +// Full transfer configuration +export interface TransferConfig { + serviceId: string; + serviceType: ServiceType; + sourceServerId: string | null; // null for local/main server + targetServerId: string; + mergeStrategy: MergeStrategy; + mounts: MountTransferConfig[]; + manualDecisions?: Record; // decisionKey -> decision +} + +// WebSocket message types +export type TransferMessageType = + | "scan_start" + | "scan_progress" + | "scan_complete" + | "compare_start" + | "compare_progress" + | "compare_complete" + | "sync_start" + | "sync_progress" + | "sync_file" + | "sync_complete" + | "error" + | "paused" + | "resumed" + | "cancelled"; + +export interface TransferMessage { + type: TransferMessageType; + payload: unknown; + timestamp: number; +} + +// WebSocket command from client +export type TransferCommand = + | { action: "scan"; config: TransferConfig } + | { action: "compare" } + | { action: "sync"; manualDecisions?: Record } + | { action: "pause" } + | { action: "resume" } + | { action: "cancel" };