mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-15 20:25:23 +02:00
feat: enhance transfer service with auto-deployment and logging
Refactor the TransferService component to include automatic deployment after successful transfers for various service types (application, compose, postgres, mysql, mariadb, mongo, redis). Implement logging functionality to capture transfer progress and errors, improving user feedback during the transfer process. Update related API routers to support these enhancements, ensuring a seamless transfer and deployment experience.
This commit is contained in:
@@ -1,6 +1,13 @@
|
|||||||
import { AlertTriangle, ArrowRightLeft, Loader2, Server } from "lucide-react";
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
ArrowRightLeft,
|
||||||
|
Loader2,
|
||||||
|
Server,
|
||||||
|
} from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { DrawerLogs } from "@/components/shared/drawer-logs";
|
||||||
|
import type { LogLine } from "@/components/dashboard/docker/logs/utils";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -90,41 +97,16 @@ const formatBytes = (bytes: number): string => {
|
|||||||
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
|
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const useTransferMutations = (serviceType: ServiceType) => {
|
const useScanMutation = (serviceType: ServiceType) => {
|
||||||
const appScan = api.application.transferScan.useMutation();
|
const mutations = {
|
||||||
const appTransfer = api.application.transfer.useMutation();
|
application: api.application.transferScan.useMutation(),
|
||||||
const composeScan = api.compose.transferScan.useMutation();
|
compose: api.compose.transferScan.useMutation(),
|
||||||
const composeTransfer = api.compose.transfer.useMutation();
|
postgres: api.postgres.transferScan.useMutation(),
|
||||||
const postgresScan = api.postgres.transferScan.useMutation();
|
mysql: api.mysql.transferScan.useMutation(),
|
||||||
const postgresTransfer = api.postgres.transfer.useMutation();
|
mariadb: api.mariadb.transferScan.useMutation(),
|
||||||
const mysqlScan = api.mysql.transferScan.useMutation();
|
mongo: api.mongo.transferScan.useMutation(),
|
||||||
const mysqlTransfer = api.mysql.transfer.useMutation();
|
redis: api.redis.transferScan.useMutation(),
|
||||||
const mariadbScan = api.mariadb.transferScan.useMutation();
|
|
||||||
const mariadbTransfer = api.mariadb.transfer.useMutation();
|
|
||||||
const mongoScan = api.mongo.transferScan.useMutation();
|
|
||||||
const mongoTransfer = api.mongo.transfer.useMutation();
|
|
||||||
const redisScan = api.redis.transferScan.useMutation();
|
|
||||||
const redisTransfer = api.redis.transfer.useMutation();
|
|
||||||
|
|
||||||
const mutations: Record<
|
|
||||||
ServiceType,
|
|
||||||
{
|
|
||||||
scan: { mutateAsync: (input: any) => Promise<any>; isPending: boolean };
|
|
||||||
transfer: {
|
|
||||||
mutateAsync: (input: any) => Promise<any>;
|
|
||||||
isPending: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
> = {
|
|
||||||
application: { scan: appScan, transfer: appTransfer },
|
|
||||||
compose: { scan: composeScan, transfer: composeTransfer },
|
|
||||||
postgres: { scan: postgresScan, transfer: postgresTransfer },
|
|
||||||
mysql: { scan: mysqlScan, transfer: mysqlTransfer },
|
|
||||||
mariadb: { scan: mariadbScan, transfer: mariadbTransfer },
|
|
||||||
mongo: { scan: mongoScan, transfer: mongoTransfer },
|
|
||||||
redis: { scan: redisScan, transfer: redisTransfer },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return mutations[serviceType];
|
return mutations[serviceType];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -148,15 +130,17 @@ export const TransferService = ({
|
|||||||
}: TransferServiceProps) => {
|
}: TransferServiceProps) => {
|
||||||
const [targetServerId, setTargetServerId] = useState<string>("");
|
const [targetServerId, setTargetServerId] = useState<string>("");
|
||||||
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
|
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
|
||||||
const [step, setStep] = useState<"select" | "scan" | "confirm" | "transfer">(
|
const [step, setStep] = useState<"select" | "scan" | "confirm">("select");
|
||||||
"select",
|
|
||||||
);
|
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
const [transferLogs, setTransferLogs] = useState<string[]>([]);
|
|
||||||
|
// Drawer logs state
|
||||||
|
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||||
|
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
||||||
|
const [isTransferring, setIsTransferring] = useState(false);
|
||||||
|
|
||||||
const { data: servers } = api.server.all.useQuery();
|
const { data: servers } = api.server.all.useQuery();
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { scan, transfer } = useTransferMutations(serviceType);
|
const scan = useScanMutation(serviceType);
|
||||||
|
|
||||||
const idKey = getServiceIdKey(serviceType);
|
const idKey = getServiceIdKey(serviceType);
|
||||||
|
|
||||||
@@ -166,6 +150,111 @@ export const TransferService = ({
|
|||||||
|
|
||||||
const selectedServer = servers?.find((s) => s.serverId === targetServerId);
|
const selectedServer = servers?.find((s) => s.serverId === targetServerId);
|
||||||
|
|
||||||
|
// Subscription for transfer with logs
|
||||||
|
const subscriptionInput = {
|
||||||
|
[idKey]: serviceId,
|
||||||
|
targetServerId: targetServerId || "placeholder",
|
||||||
|
decisions: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const useTransferSubscription = (sType: ServiceType) => {
|
||||||
|
api.application.transferWithLogs.useSubscription(subscriptionInput as any, {
|
||||||
|
enabled: isTransferring && sType === "application",
|
||||||
|
onData: handleLogData,
|
||||||
|
onError: handleLogError,
|
||||||
|
});
|
||||||
|
api.compose.transferWithLogs.useSubscription(subscriptionInput as any, {
|
||||||
|
enabled: isTransferring && sType === "compose",
|
||||||
|
onData: handleLogData,
|
||||||
|
onError: handleLogError,
|
||||||
|
});
|
||||||
|
api.postgres.transferWithLogs.useSubscription(subscriptionInput as any, {
|
||||||
|
enabled: isTransferring && sType === "postgres",
|
||||||
|
onData: handleLogData,
|
||||||
|
onError: handleLogError,
|
||||||
|
});
|
||||||
|
api.mysql.transferWithLogs.useSubscription(subscriptionInput as any, {
|
||||||
|
enabled: isTransferring && sType === "mysql",
|
||||||
|
onData: handleLogData,
|
||||||
|
onError: handleLogError,
|
||||||
|
});
|
||||||
|
api.mariadb.transferWithLogs.useSubscription(subscriptionInput as any, {
|
||||||
|
enabled: isTransferring && sType === "mariadb",
|
||||||
|
onData: handleLogData,
|
||||||
|
onError: handleLogError,
|
||||||
|
});
|
||||||
|
api.mongo.transferWithLogs.useSubscription(subscriptionInput as any, {
|
||||||
|
enabled: isTransferring && sType === "mongo",
|
||||||
|
onData: handleLogData,
|
||||||
|
onError: handleLogError,
|
||||||
|
});
|
||||||
|
api.redis.transferWithLogs.useSubscription(subscriptionInput as any, {
|
||||||
|
enabled: isTransferring && sType === "redis",
|
||||||
|
onData: handleLogData,
|
||||||
|
onError: handleLogError,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogData = (log: string) => {
|
||||||
|
if (!isDrawerOpen) {
|
||||||
|
setIsDrawerOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse as JSON progress
|
||||||
|
try {
|
||||||
|
const progress = JSON.parse(log);
|
||||||
|
if (progress.message) {
|
||||||
|
const logLine: LogLine = {
|
||||||
|
rawTimestamp: new Date().toISOString(),
|
||||||
|
timestamp: new Date(),
|
||||||
|
message: `[${progress.phase || "transfer"}] ${progress.message}`,
|
||||||
|
};
|
||||||
|
setFilteredLogs((prev) => [...prev, logLine]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// Not JSON, treat as plain text
|
||||||
|
}
|
||||||
|
|
||||||
|
const logLine: LogLine = {
|
||||||
|
rawTimestamp: new Date().toISOString(),
|
||||||
|
timestamp: new Date(),
|
||||||
|
message: log,
|
||||||
|
};
|
||||||
|
setFilteredLogs((prev) => [...prev, logLine]);
|
||||||
|
|
||||||
|
if (
|
||||||
|
log.includes("completed successfully") ||
|
||||||
|
log.includes("Deployment queued") ||
|
||||||
|
log.includes("Deployment started")
|
||||||
|
) {
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsTransferring(false);
|
||||||
|
utils.invalidate();
|
||||||
|
toast.success("Transfer and deployment completed!");
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (log.includes("Transfer failed") || log.includes("Transfer error")) {
|
||||||
|
setIsTransferring(false);
|
||||||
|
toast.error("Transfer failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogError = (error: unknown) => {
|
||||||
|
console.error("Transfer subscription error:", error);
|
||||||
|
setIsTransferring(false);
|
||||||
|
const logLine: LogLine = {
|
||||||
|
rawTimestamp: new Date().toISOString(),
|
||||||
|
timestamp: new Date(),
|
||||||
|
message: `Error: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
};
|
||||||
|
setFilteredLogs((prev) => [...prev, logLine]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register the subscription hooks (must be called unconditionally)
|
||||||
|
useTransferSubscription(serviceType);
|
||||||
|
|
||||||
const handleScan = async () => {
|
const handleScan = async () => {
|
||||||
if (!targetServerId) {
|
if (!targetServerId) {
|
||||||
toast.error("Please select a target server");
|
toast.error("Please select a target server");
|
||||||
@@ -177,7 +266,7 @@ export const TransferService = ({
|
|||||||
const result = await scan.mutateAsync({
|
const result = await scan.mutateAsync({
|
||||||
[idKey]: serviceId,
|
[idKey]: serviceId,
|
||||||
targetServerId,
|
targetServerId,
|
||||||
});
|
} as any);
|
||||||
setScanResult(result as ScanResult);
|
setScanResult(result as ScanResult);
|
||||||
setStep("confirm");
|
setStep("confirm");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -190,38 +279,27 @@ export const TransferService = ({
|
|||||||
|
|
||||||
const handleTransfer = async () => {
|
const handleTransfer = async () => {
|
||||||
setShowConfirm(false);
|
setShowConfirm(false);
|
||||||
setStep("transfer");
|
setFilteredLogs([]);
|
||||||
setTransferLogs([]);
|
setIsTransferring(true);
|
||||||
|
setIsDrawerOpen(true);
|
||||||
|
|
||||||
try {
|
// Add initial log
|
||||||
await transfer.mutateAsync({
|
setFilteredLogs([
|
||||||
[idKey]: serviceId,
|
{
|
||||||
targetServerId,
|
rawTimestamp: new Date().toISOString(),
|
||||||
decisions: {},
|
timestamp: new Date(),
|
||||||
});
|
message: `Starting transfer to ${selectedServer?.name} (${selectedServer?.ipAddress})...`,
|
||||||
|
},
|
||||||
toast.success("Transfer completed successfully!");
|
]);
|
||||||
setTransferLogs((prev) => [...prev, "Transfer completed successfully!"]);
|
|
||||||
|
|
||||||
await utils.invalidate();
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
setStep("select");
|
|
||||||
setScanResult(null);
|
|
||||||
setTargetServerId("");
|
|
||||||
}, 3000);
|
|
||||||
} catch (error) {
|
|
||||||
const message =
|
|
||||||
error instanceof Error ? error.message : "Unknown error";
|
|
||||||
toast.error(`Transfer failed: ${message}`);
|
|
||||||
setTransferLogs((prev) => [...prev, `Transfer failed: ${message}`]);
|
|
||||||
setStep("confirm");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isDbService = ["postgres", "mysql", "mariadb", "mongo", "redis"].includes(
|
const isDbService = [
|
||||||
serviceType,
|
"postgres",
|
||||||
);
|
"mysql",
|
||||||
|
"mariadb",
|
||||||
|
"mongo",
|
||||||
|
"redis",
|
||||||
|
].includes(serviceType);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
@@ -247,7 +325,7 @@ export const TransferService = ({
|
|||||||
<>
|
<>
|
||||||
{/* Step 1: Select target server */}
|
{/* Step 1: Select target server */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Target Server</label>
|
<span className="text-sm font-medium">Target Server</span>
|
||||||
<Select
|
<Select
|
||||||
value={targetServerId}
|
value={targetServerId}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
@@ -255,7 +333,7 @@ export const TransferService = ({
|
|||||||
setScanResult(null);
|
setScanResult(null);
|
||||||
setStep("select");
|
setStep("select");
|
||||||
}}
|
}}
|
||||||
disabled={step === "transfer"}
|
disabled={isTransferring}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select target server" />
|
<SelectValue placeholder="Select target server" />
|
||||||
@@ -365,6 +443,36 @@ export const TransferService = ({
|
|||||||
<Badge variant="outline">Will be synced</Badge>
|
<Badge variant="outline">Will be synced</Badge>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{scanResult.mounts.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Docker Volumes:
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{scanResult.mounts.map((m) => (
|
||||||
|
<Badge
|
||||||
|
key={m.mount.mountId}
|
||||||
|
variant="outline"
|
||||||
|
className="font-mono text-xs"
|
||||||
|
>
|
||||||
|
{m.mount.volumeName ||
|
||||||
|
m.mount.hostPath ||
|
||||||
|
m.mount.mountPath}
|
||||||
|
{m.totalSize > 0 && (
|
||||||
|
<span className="ml-1 text-muted-foreground">
|
||||||
|
({formatBytes(m.totalSize)})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{m.files.length > 0 && (
|
||||||
|
<span className="ml-1 text-muted-foreground">
|
||||||
|
{m.files.length} files
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Conflict list */}
|
{/* Conflict list */}
|
||||||
@@ -379,7 +487,10 @@ export const TransferService = ({
|
|||||||
key={conflict.path}
|
key={conflict.path}
|
||||||
className="text-xs font-mono flex items-center gap-2"
|
className="text-xs font-mono flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<Badge variant="outline" className="text-[10px]">
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px]"
|
||||||
|
>
|
||||||
{conflict.status}
|
{conflict.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
@@ -401,8 +512,8 @@ export const TransferService = ({
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{isDbService
|
{isDbService
|
||||||
? "Stop the database service before transferring to avoid data corruption. The service will be unavailable until deployed on the target server."
|
? "Stop the database service before transferring to avoid data corruption. After transfer completes, the service will be automatically deployed on the target server."
|
||||||
: "The service will be unavailable during transfer. After transfer completes, deploy the service on the target server to start it."}
|
: "The service will be unavailable during transfer. After transfer completes, the service will be automatically deployed on the target server."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -419,7 +530,7 @@ export const TransferService = ({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setShowConfirm(true)}
|
onClick={() => setShowConfirm(true)}
|
||||||
disabled={transfer.isPending}
|
disabled={isTransferring}
|
||||||
>
|
>
|
||||||
<ArrowRightLeft className="mr-2 size-4" />
|
<ArrowRightLeft className="mr-2 size-4" />
|
||||||
Transfer to {selectedServer?.name}
|
Transfer to {selectedServer?.name}
|
||||||
@@ -427,30 +538,6 @@ export const TransferService = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 4: Transfer in progress */}
|
|
||||||
{step === "transfer" && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
Transferring service...
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{transferLogs.length > 0 && (
|
|
||||||
<div className="rounded-lg border bg-muted/50 p-3 max-h-60 overflow-y-auto">
|
|
||||||
{transferLogs.map((log, i) => (
|
|
||||||
<div
|
|
||||||
key={`${log}-${i}`}
|
|
||||||
className="text-xs font-mono text-muted-foreground"
|
|
||||||
>
|
|
||||||
{log}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -470,12 +557,14 @@ export const TransferService = ({
|
|||||||
{scanResult.totalFiles} files (
|
{scanResult.totalFiles} files (
|
||||||
{formatBytes(scanResult.totalTransferSize)}) will be
|
{formatBytes(scanResult.totalTransferSize)}) will be
|
||||||
copied.
|
copied.
|
||||||
|
{scanResult.mounts.length > 0 &&
|
||||||
|
` ${scanResult.mounts.length} volume(s) will be transferred.`}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-yellow-600 dark:text-yellow-400 font-medium">
|
<p className="text-yellow-600 dark:text-yellow-400 font-medium">
|
||||||
The service will experience downtime during this
|
The service will experience downtime during this
|
||||||
process. After transfer, you must deploy the service on
|
process. After transfer, the service will be
|
||||||
the target server.
|
automatically deployed on the target server.
|
||||||
</p>
|
</p>
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
@@ -487,6 +576,20 @@ export const TransferService = ({
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* Drawer for transfer logs */}
|
||||||
|
<DrawerLogs
|
||||||
|
isOpen={isDrawerOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsDrawerOpen(false);
|
||||||
|
if (!isTransferring) {
|
||||||
|
setFilteredLogs([]);
|
||||||
|
setStep("select");
|
||||||
|
setScanResult(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
filteredLogs={filteredLogs}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1206,7 +1206,29 @@ export const applicationRouter = createTRPCRouter({
|
|||||||
.update(applications)
|
.update(applications)
|
||||||
.set({ serverId: input.targetServerId })
|
.set({ serverId: input.targetServerId })
|
||||||
.where(eq(applications.applicationId, input.applicationId));
|
.where(eq(applications.applicationId, input.applicationId));
|
||||||
queue.push("Transfer completed successfully!");
|
queue.push("Transfer completed! Starting deployment on target server...");
|
||||||
|
|
||||||
|
// Auto-deploy on target server
|
||||||
|
const jobData: DeploymentJob = {
|
||||||
|
applicationId: input.applicationId,
|
||||||
|
titleLog: "Transfer deployment",
|
||||||
|
type: "deploy",
|
||||||
|
applicationType: "application",
|
||||||
|
descriptionLog: "Auto-deploy after transfer to new server",
|
||||||
|
server: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
jobData.serverId = input.targetServerId;
|
||||||
|
deploy(jobData).catch(() => {});
|
||||||
|
} else {
|
||||||
|
await myQueue.add("deployments", jobData, {
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
queue.push("Deployment queued successfully!");
|
||||||
} else {
|
} else {
|
||||||
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
|
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
|
||||||
}
|
}
|
||||||
@@ -1272,6 +1294,26 @@ export const applicationRouter = createTRPCRouter({
|
|||||||
.set({ serverId: input.targetServerId })
|
.set({ serverId: input.targetServerId })
|
||||||
.where(eq(applications.applicationId, input.applicationId));
|
.where(eq(applications.applicationId, input.applicationId));
|
||||||
|
|
||||||
|
// Auto-deploy on target server
|
||||||
|
const jobData: DeploymentJob = {
|
||||||
|
applicationId: input.applicationId,
|
||||||
|
titleLog: "Transfer deployment",
|
||||||
|
type: "deploy",
|
||||||
|
applicationType: "application",
|
||||||
|
descriptionLog: "Auto-deploy after transfer to new server",
|
||||||
|
server: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
jobData.serverId = input.targetServerId;
|
||||||
|
deploy(jobData).catch(() => {});
|
||||||
|
} else {
|
||||||
|
await myQueue.add("deployments", jobData, {
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1240,7 +1240,28 @@ export const composeRouter = createTRPCRouter({
|
|||||||
.update(composeTable)
|
.update(composeTable)
|
||||||
.set({ serverId: input.targetServerId })
|
.set({ serverId: input.targetServerId })
|
||||||
.where(eq(composeTable.composeId, input.composeId));
|
.where(eq(composeTable.composeId, input.composeId));
|
||||||
queue.push("Transfer completed successfully!");
|
queue.push("Transfer completed! Starting deployment on target server...");
|
||||||
|
|
||||||
|
const jobData: DeploymentJob = {
|
||||||
|
composeId: input.composeId,
|
||||||
|
titleLog: "Transfer deployment",
|
||||||
|
type: "deploy",
|
||||||
|
applicationType: "compose",
|
||||||
|
descriptionLog: "Auto-deploy after transfer to new server",
|
||||||
|
server: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
jobData.serverId = input.targetServerId;
|
||||||
|
deploy(jobData).catch(() => {});
|
||||||
|
} else {
|
||||||
|
await myQueue.add("deployments", jobData, {
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
queue.push("Deployment queued successfully!");
|
||||||
} else {
|
} else {
|
||||||
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
|
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
|
||||||
}
|
}
|
||||||
@@ -1306,6 +1327,26 @@ export const composeRouter = createTRPCRouter({
|
|||||||
.set({ serverId: input.targetServerId })
|
.set({ serverId: input.targetServerId })
|
||||||
.where(eq(composeTable.composeId, input.composeId));
|
.where(eq(composeTable.composeId, input.composeId));
|
||||||
|
|
||||||
|
// Auto-deploy on target server
|
||||||
|
const jobData: DeploymentJob = {
|
||||||
|
composeId: input.composeId,
|
||||||
|
titleLog: "Transfer deployment",
|
||||||
|
type: "deploy",
|
||||||
|
applicationType: "compose",
|
||||||
|
descriptionLog: "Auto-deploy after transfer to new server",
|
||||||
|
server: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
jobData.serverId = input.targetServerId;
|
||||||
|
deploy(jobData).catch(() => {});
|
||||||
|
} else {
|
||||||
|
await myQueue.add("deployments", jobData, {
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -690,7 +690,9 @@ export const mariadbRouter = createTRPCRouter({
|
|||||||
.update(mariadbTable)
|
.update(mariadbTable)
|
||||||
.set({ serverId: input.targetServerId })
|
.set({ serverId: input.targetServerId })
|
||||||
.where(eq(mariadbTable.mariadbId, input.mariadbId));
|
.where(eq(mariadbTable.mariadbId, input.mariadbId));
|
||||||
queue.push("Transfer completed successfully!");
|
queue.push("Transfer completed! Starting deployment on target server...");
|
||||||
|
await deployMariadb(input.mariadbId).catch(() => {});
|
||||||
|
queue.push("Deployment started!");
|
||||||
} else {
|
} else {
|
||||||
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
|
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
|
||||||
}
|
}
|
||||||
@@ -743,6 +745,9 @@ export const mariadbRouter = createTRPCRouter({
|
|||||||
.update(mariadbTable)
|
.update(mariadbTable)
|
||||||
.set({ serverId: input.targetServerId })
|
.set({ serverId: input.targetServerId })
|
||||||
.where(eq(mariadbTable.mariadbId, input.mariadbId));
|
.where(eq(mariadbTable.mariadbId, input.mariadbId));
|
||||||
|
|
||||||
|
await deployMariadb(input.mariadbId).catch(() => {});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -701,7 +701,9 @@ export const mongoRouter = createTRPCRouter({
|
|||||||
.update(mongoTable)
|
.update(mongoTable)
|
||||||
.set({ serverId: input.targetServerId })
|
.set({ serverId: input.targetServerId })
|
||||||
.where(eq(mongoTable.mongoId, input.mongoId));
|
.where(eq(mongoTable.mongoId, input.mongoId));
|
||||||
queue.push("Transfer completed successfully!");
|
queue.push("Transfer completed! Starting deployment on target server...");
|
||||||
|
await deployMongo(input.mongoId).catch(() => {});
|
||||||
|
queue.push("Deployment started!");
|
||||||
} else {
|
} else {
|
||||||
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
|
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
|
||||||
}
|
}
|
||||||
@@ -754,6 +756,9 @@ export const mongoRouter = createTRPCRouter({
|
|||||||
.update(mongoTable)
|
.update(mongoTable)
|
||||||
.set({ serverId: input.targetServerId })
|
.set({ serverId: input.targetServerId })
|
||||||
.where(eq(mongoTable.mongoId, input.mongoId));
|
.where(eq(mongoTable.mongoId, input.mongoId));
|
||||||
|
|
||||||
|
await deployMongo(input.mongoId).catch(() => {});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -704,7 +704,9 @@ export const mysqlRouter = createTRPCRouter({
|
|||||||
.update(mysqlTable)
|
.update(mysqlTable)
|
||||||
.set({ serverId: input.targetServerId })
|
.set({ serverId: input.targetServerId })
|
||||||
.where(eq(mysqlTable.mysqlId, input.mysqlId));
|
.where(eq(mysqlTable.mysqlId, input.mysqlId));
|
||||||
queue.push("Transfer completed successfully!");
|
queue.push("Transfer completed! Starting deployment on target server...");
|
||||||
|
await deployMySql(input.mysqlId).catch(() => {});
|
||||||
|
queue.push("Deployment started!");
|
||||||
} else {
|
} else {
|
||||||
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
|
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
|
||||||
}
|
}
|
||||||
@@ -757,6 +759,9 @@ export const mysqlRouter = createTRPCRouter({
|
|||||||
.update(mysqlTable)
|
.update(mysqlTable)
|
||||||
.set({ serverId: input.targetServerId })
|
.set({ serverId: input.targetServerId })
|
||||||
.where(eq(mysqlTable.mysqlId, input.mysqlId));
|
.where(eq(mysqlTable.mysqlId, input.mysqlId));
|
||||||
|
|
||||||
|
await deployMySql(input.mysqlId).catch(() => {});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -714,7 +714,9 @@ export const postgresRouter = createTRPCRouter({
|
|||||||
.update(postgresTable)
|
.update(postgresTable)
|
||||||
.set({ serverId: input.targetServerId })
|
.set({ serverId: input.targetServerId })
|
||||||
.where(eq(postgresTable.postgresId, input.postgresId));
|
.where(eq(postgresTable.postgresId, input.postgresId));
|
||||||
queue.push("Transfer completed successfully!");
|
queue.push("Transfer completed! Starting deployment on target server...");
|
||||||
|
await deployPostgres(input.postgresId).catch(() => {});
|
||||||
|
queue.push("Deployment started!");
|
||||||
} else {
|
} else {
|
||||||
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
|
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
|
||||||
}
|
}
|
||||||
@@ -767,6 +769,9 @@ export const postgresRouter = createTRPCRouter({
|
|||||||
.update(postgresTable)
|
.update(postgresTable)
|
||||||
.set({ serverId: input.targetServerId })
|
.set({ serverId: input.targetServerId })
|
||||||
.where(eq(postgresTable.postgresId, input.postgresId));
|
.where(eq(postgresTable.postgresId, input.postgresId));
|
||||||
|
|
||||||
|
await deployPostgres(input.postgresId).catch(() => {});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -687,7 +687,9 @@ export const redisRouter = createTRPCRouter({
|
|||||||
.update(redisTable)
|
.update(redisTable)
|
||||||
.set({ serverId: input.targetServerId })
|
.set({ serverId: input.targetServerId })
|
||||||
.where(eq(redisTable.redisId, input.redisId));
|
.where(eq(redisTable.redisId, input.redisId));
|
||||||
queue.push("Transfer completed successfully!");
|
queue.push("Transfer completed! Starting deployment on target server...");
|
||||||
|
await deployRedis(input.redisId).catch(() => {});
|
||||||
|
queue.push("Deployment started!");
|
||||||
} else {
|
} else {
|
||||||
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
|
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
|
||||||
}
|
}
|
||||||
@@ -740,6 +742,9 @@ export const redisRouter = createTRPCRouter({
|
|||||||
.update(redisTable)
|
.update(redisTable)
|
||||||
.set({ serverId: input.targetServerId })
|
.set({ serverId: input.targetServerId })
|
||||||
.where(eq(redisTable.redisId, input.redisId));
|
.where(eq(redisTable.redisId, input.redisId));
|
||||||
|
|
||||||
|
await deployRedis(input.redisId).catch(() => {});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import path from "node:path";
|
|||||||
import { findMountsByApplicationId } from "./mount";
|
import { findMountsByApplicationId } from "./mount";
|
||||||
import {
|
import {
|
||||||
compareFileLists,
|
compareFileLists,
|
||||||
|
getDirectorySize,
|
||||||
|
getVolumeSize,
|
||||||
|
listComposeVolumes,
|
||||||
|
listVolumesByPrefix,
|
||||||
scanDirectory,
|
scanDirectory,
|
||||||
|
scanDockerVolume,
|
||||||
scanMount,
|
scanMount,
|
||||||
} from "../utils/transfer/scanner";
|
} from "../utils/transfer/scanner";
|
||||||
import { runPreflightChecks } from "../utils/transfer/preflight";
|
import { runPreflightChecks } from "../utils/transfer/preflight";
|
||||||
@@ -57,6 +62,42 @@ const getAutoDataVolumeName = (
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover all Docker volumes for a service.
|
||||||
|
* For compose: uses Docker labels + prefix matching.
|
||||||
|
* For databases: uses the auto {appName}-data convention.
|
||||||
|
* For applications: uses user-defined mounts only.
|
||||||
|
*/
|
||||||
|
const discoverServiceVolumes = async (
|
||||||
|
serverId: string | null,
|
||||||
|
serviceType: ServiceType,
|
||||||
|
appName: string,
|
||||||
|
): Promise<string[]> => {
|
||||||
|
const volumes: Set<string> = new Set();
|
||||||
|
|
||||||
|
if (serviceType === "compose") {
|
||||||
|
// Get volumes by compose project label
|
||||||
|
const labelVolumes = await listComposeVolumes(serverId, appName);
|
||||||
|
for (const v of labelVolumes) {
|
||||||
|
volumes.add(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also try prefix matching (compose uses {projectName}_{volumeName} pattern)
|
||||||
|
const prefixVolumes = await listVolumesByPrefix(serverId, `${appName}_`);
|
||||||
|
for (const v of prefixVolumes) {
|
||||||
|
volumes.add(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto data volume for databases
|
||||||
|
const autoVolume = getAutoDataVolumeName(serviceType, appName);
|
||||||
|
if (autoVolume) {
|
||||||
|
volumes.add(autoVolume);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(volumes);
|
||||||
|
};
|
||||||
|
|
||||||
export const scanServiceForTransfer = async (
|
export const scanServiceForTransfer = async (
|
||||||
opts: TransferOptions,
|
opts: TransferOptions,
|
||||||
): Promise<TransferScanResult> => {
|
): Promise<TransferScanResult> => {
|
||||||
@@ -80,94 +121,70 @@ export const scanServiceForTransfer = async (
|
|||||||
);
|
);
|
||||||
const targetPath = getServiceBasePath(serviceType, appName, true);
|
const targetPath = getServiceBasePath(serviceType, appName, true);
|
||||||
|
|
||||||
try {
|
const sourceFiles = await scanDirectory(sourceServerId, sourcePath);
|
||||||
const sourceFiles = await scanDirectory(sourceServerId, sourcePath);
|
const targetFiles = await scanDirectory(targetServerId, targetPath);
|
||||||
const targetFiles = await scanDirectory(targetServerId, targetPath);
|
const dirSize = await getDirectorySize(sourceServerId, sourcePath);
|
||||||
|
|
||||||
const fileConflicts = await compareFileLists(
|
const fileConflicts = compareFileLists(sourceFiles, targetFiles);
|
||||||
sourceFiles,
|
|
||||||
targetFiles,
|
|
||||||
sourceServerId,
|
|
||||||
targetServerId,
|
|
||||||
sourcePath,
|
|
||||||
);
|
|
||||||
|
|
||||||
result.serviceDirectory = {
|
result.serviceDirectory = {
|
||||||
files: fileConflicts,
|
files: fileConflicts,
|
||||||
totalSize: sourceFiles.reduce((sum, f) => sum + f.size, 0),
|
totalSize: dirSize || sourceFiles.reduce((sum, f) => sum + f.size, 0),
|
||||||
};
|
};
|
||||||
} catch {
|
|
||||||
// Directory may not exist yet, that's ok
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Check Traefik config
|
// 2. Check Traefik config
|
||||||
if (serviceType === "application" || serviceType === "compose") {
|
if (serviceType === "application" || serviceType === "compose") {
|
||||||
const configPath = "/etc/dokploy/traefik/dynamic";
|
const { DYNAMIC_TRAEFIK_PATH } = paths(!!sourceServerId);
|
||||||
const configFile = `${configPath}/${appName}.yml`;
|
const configFile = `${appName}.yml`;
|
||||||
|
const sourceConfigFiles = await scanDirectory(
|
||||||
|
sourceServerId,
|
||||||
|
DYNAMIC_TRAEFIK_PATH,
|
||||||
|
);
|
||||||
|
const hasSourceConfig = sourceConfigFiles.some(
|
||||||
|
(f) => f.path === configFile,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
if (hasSourceConfig) {
|
||||||
const sourceFiles = await scanDirectory(sourceServerId, configPath);
|
result.traefikConfig.exists = true;
|
||||||
const sourceConfig = sourceFiles.find(
|
const { DYNAMIC_TRAEFIK_PATH: targetTraefikPath } = paths(true);
|
||||||
(f) => f.path === `${appName}.yml`,
|
const targetConfigFiles = await scanDirectory(
|
||||||
);
|
|
||||||
if (sourceConfig) {
|
|
||||||
result.traefikConfig.exists = true;
|
|
||||||
const targetFiles = await scanDirectory(targetServerId, configPath);
|
|
||||||
const targetConfig = targetFiles.find(
|
|
||||||
(f) => f.path === `${appName}.yml`,
|
|
||||||
);
|
|
||||||
if (targetConfig) {
|
|
||||||
result.traefikConfig.hasConflict = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Config may not exist
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Scan auto data volume for databases
|
|
||||||
const autoVolume = getAutoDataVolumeName(serviceType, appName);
|
|
||||||
if (autoVolume) {
|
|
||||||
try {
|
|
||||||
const sourceFiles = await scanMount(sourceServerId, {
|
|
||||||
mountId: "auto",
|
|
||||||
type: "volume",
|
|
||||||
volumeName: autoVolume,
|
|
||||||
mountPath: "/data",
|
|
||||||
});
|
|
||||||
const targetFiles = await scanMount(targetServerId, {
|
|
||||||
mountId: "auto",
|
|
||||||
type: "volume",
|
|
||||||
volumeName: autoVolume,
|
|
||||||
mountPath: "/data",
|
|
||||||
});
|
|
||||||
|
|
||||||
const fileConflicts = await compareFileLists(
|
|
||||||
sourceFiles,
|
|
||||||
targetFiles,
|
|
||||||
sourceServerId,
|
|
||||||
targetServerId,
|
targetServerId,
|
||||||
undefined,
|
targetTraefikPath,
|
||||||
autoVolume,
|
);
|
||||||
|
result.traefikConfig.hasConflict = targetConfigFiles.some(
|
||||||
|
(f) => f.path === configFile,
|
||||||
);
|
);
|
||||||
|
|
||||||
result.mounts.push({
|
|
||||||
mount: {
|
|
||||||
mountId: "auto",
|
|
||||||
type: "volume",
|
|
||||||
volumeName: autoVolume,
|
|
||||||
mountPath: "/data",
|
|
||||||
},
|
|
||||||
files: fileConflicts,
|
|
||||||
totalSize: sourceFiles.reduce((sum, f) => sum + f.size, 0),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Volume may not exist
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Scan user-defined mounts
|
// 3. Discover and scan ALL Docker volumes for the service
|
||||||
|
const discoveredVolumes = await discoverServiceVolumes(
|
||||||
|
sourceServerId,
|
||||||
|
serviceType,
|
||||||
|
appName,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const volumeName of discoveredVolumes) {
|
||||||
|
const sourceFiles = await scanDockerVolume(sourceServerId, volumeName);
|
||||||
|
const targetFiles = await scanDockerVolume(targetServerId, volumeName);
|
||||||
|
const volSize = await getVolumeSize(sourceServerId, volumeName);
|
||||||
|
|
||||||
|
const fileConflicts = compareFileLists(sourceFiles, targetFiles);
|
||||||
|
|
||||||
|
result.mounts.push({
|
||||||
|
mount: {
|
||||||
|
mountId: `docker-${volumeName}`,
|
||||||
|
type: "volume",
|
||||||
|
volumeName,
|
||||||
|
mountPath: "/data",
|
||||||
|
},
|
||||||
|
files: fileConflicts,
|
||||||
|
totalSize: volSize || sourceFiles.reduce((sum, f) => sum + f.size, 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Scan user-defined mounts from Dokploy DB
|
||||||
const serviceTypeForMount = serviceType as
|
const serviceTypeForMount = serviceType as
|
||||||
| "application"
|
| "application"
|
||||||
| "postgres"
|
| "postgres"
|
||||||
@@ -176,49 +193,51 @@ export const scanServiceForTransfer = async (
|
|||||||
| "mongo"
|
| "mongo"
|
||||||
| "redis"
|
| "redis"
|
||||||
| "compose";
|
| "compose";
|
||||||
try {
|
|
||||||
const userMounts = await findMountsByApplicationId(
|
|
||||||
opts.serviceId,
|
|
||||||
serviceTypeForMount,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const mount of userMounts) {
|
const userMounts = await findMountsByApplicationId(
|
||||||
const mountConfig: MountTransferConfig = {
|
opts.serviceId,
|
||||||
mountId: mount.mountId,
|
serviceTypeForMount,
|
||||||
type: mount.type,
|
);
|
||||||
hostPath: mount.hostPath,
|
|
||||||
volumeName: mount.volumeName,
|
|
||||||
mountPath: mount.mountPath,
|
|
||||||
content: mount.content,
|
|
||||||
filePath: mount.filePath,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (mount.type === "file") continue; // File mounts are DB-stored
|
for (const mount of userMounts) {
|
||||||
|
if (mount.type === "file") continue;
|
||||||
|
|
||||||
try {
|
// Skip if already discovered as Docker volume
|
||||||
const sourceFiles = await scanMount(sourceServerId, mountConfig);
|
if (
|
||||||
const targetFiles = await scanMount(targetServerId, mountConfig);
|
mount.type === "volume" &&
|
||||||
|
mount.volumeName &&
|
||||||
const fileConflicts = await compareFileLists(
|
discoveredVolumes.includes(mount.volumeName)
|
||||||
sourceFiles,
|
) {
|
||||||
targetFiles,
|
continue;
|
||||||
sourceServerId,
|
|
||||||
targetServerId,
|
|
||||||
mount.type === "bind" ? mount.hostPath || undefined : undefined,
|
|
||||||
mount.type === "volume" ? mount.volumeName || undefined : undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
result.mounts.push({
|
|
||||||
mount: mountConfig,
|
|
||||||
files: fileConflicts,
|
|
||||||
totalSize: sourceFiles.reduce((sum, f) => sum + f.size, 0),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Individual mount scan failure shouldn't stop entire scan
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// No mounts found
|
const mountConfig: MountTransferConfig = {
|
||||||
|
mountId: mount.mountId,
|
||||||
|
type: mount.type,
|
||||||
|
hostPath: mount.hostPath,
|
||||||
|
volumeName: mount.volumeName,
|
||||||
|
mountPath: mount.mountPath,
|
||||||
|
content: mount.content,
|
||||||
|
filePath: mount.filePath,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sourceFiles = await scanMount(sourceServerId, mountConfig);
|
||||||
|
const targetFiles = await scanMount(targetServerId, mountConfig);
|
||||||
|
|
||||||
|
let mountSize = 0;
|
||||||
|
if (mount.type === "volume" && mount.volumeName) {
|
||||||
|
mountSize = await getVolumeSize(sourceServerId, mount.volumeName);
|
||||||
|
} else if (mount.type === "bind" && mount.hostPath) {
|
||||||
|
mountSize = await getDirectorySize(sourceServerId, mount.hostPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileConflicts = compareFileLists(sourceFiles, targetFiles);
|
||||||
|
|
||||||
|
result.mounts.push({
|
||||||
|
mount: mountConfig,
|
||||||
|
files: fileConflicts,
|
||||||
|
totalSize: mountSize || sourceFiles.reduce((sum, f) => sum + f.size, 0),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate totals
|
// Calculate totals
|
||||||
@@ -233,11 +252,7 @@ export const scanServiceForTransfer = async (
|
|||||||
result.conflicts = [
|
result.conflicts = [
|
||||||
...result.serviceDirectory.files,
|
...result.serviceDirectory.files,
|
||||||
...result.mounts.flatMap((m) => m.files),
|
...result.mounts.flatMap((m) => m.files),
|
||||||
].filter(
|
].filter((f) => f.status !== "match" && f.status !== "missing_target");
|
||||||
(f) =>
|
|
||||||
f.status !== "match" &&
|
|
||||||
f.status !== "missing_target",
|
|
||||||
);
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
@@ -249,58 +264,90 @@ export const executeTransfer = async (
|
|||||||
): Promise<TransferResult> => {
|
): Promise<TransferResult> => {
|
||||||
const { serviceType, appName, sourceServerId, targetServerId } = opts;
|
const { serviceType, appName, sourceServerId, targetServerId } = opts;
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
let processedFiles = 0;
|
const processedFiles = 0;
|
||||||
let transferredBytes = 0;
|
const transferredBytes = 0;
|
||||||
|
|
||||||
const scan = await scanServiceForTransfer(opts);
|
|
||||||
const totalFiles = scan.totalFiles;
|
|
||||||
const totalBytes = scan.totalTransferSize;
|
|
||||||
|
|
||||||
const reportProgress = (
|
const reportProgress = (
|
||||||
phase: TransferProgress["phase"],
|
phase: TransferProgress["phase"],
|
||||||
message?: string,
|
message?: string,
|
||||||
currentFile?: string,
|
currentFile?: string,
|
||||||
) => {
|
) => {
|
||||||
if (processedFiles > 0) {
|
onProgress?.({
|
||||||
const percentage = totalFiles > 0 ? Math.round((processedFiles / totalFiles) * 100) : 0;
|
phase,
|
||||||
onProgress?.({
|
currentFile,
|
||||||
phase,
|
processedFiles,
|
||||||
currentFile,
|
totalFiles: 0,
|
||||||
processedFiles,
|
transferredBytes,
|
||||||
totalFiles,
|
totalBytes: 0,
|
||||||
transferredBytes,
|
percentage: 0,
|
||||||
totalBytes,
|
message,
|
||||||
percentage,
|
});
|
||||||
message,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
onProgress?.({
|
|
||||||
phase,
|
|
||||||
currentFile,
|
|
||||||
processedFiles,
|
|
||||||
totalFiles,
|
|
||||||
transferredBytes,
|
|
||||||
totalBytes,
|
|
||||||
percentage: 0,
|
|
||||||
message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Phase 1: Preflight checks
|
// Phase 1: Preflight
|
||||||
reportProgress("preparing", "Running preflight checks...");
|
reportProgress("preparing", "Running preflight checks...");
|
||||||
|
|
||||||
const mountConfigs: MountTransferConfig[] = scan.mounts.map(
|
// Discover all volumes
|
||||||
(m) => m.mount,
|
const discoveredVolumes = await discoverServiceVolumes(
|
||||||
|
sourceServerId,
|
||||||
|
serviceType,
|
||||||
|
appName,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// User-defined mounts
|
||||||
|
const mountConfigs: MountTransferConfig[] = [];
|
||||||
|
const serviceTypeForMount = serviceType as
|
||||||
|
| "application"
|
||||||
|
| "postgres"
|
||||||
|
| "mysql"
|
||||||
|
| "mariadb"
|
||||||
|
| "mongo"
|
||||||
|
| "redis"
|
||||||
|
| "compose";
|
||||||
|
|
||||||
|
const userMounts = await findMountsByApplicationId(
|
||||||
|
opts.serviceId,
|
||||||
|
serviceTypeForMount,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const mount of userMounts) {
|
||||||
|
if (mount.type === "file") continue;
|
||||||
|
if (
|
||||||
|
mount.type === "volume" &&
|
||||||
|
mount.volumeName &&
|
||||||
|
discoveredVolumes.includes(mount.volumeName)
|
||||||
|
) {
|
||||||
|
continue; // Will be handled as discovered volume
|
||||||
|
}
|
||||||
|
mountConfigs.push({
|
||||||
|
mountId: mount.mountId,
|
||||||
|
type: mount.type,
|
||||||
|
hostPath: mount.hostPath,
|
||||||
|
volumeName: mount.volumeName,
|
||||||
|
mountPath: mount.mountPath,
|
||||||
|
content: mount.content,
|
||||||
|
filePath: mount.filePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const allVolumeConfigs: MountTransferConfig[] = [
|
||||||
|
...discoveredVolumes.map((v) => ({
|
||||||
|
mountId: `docker-${v}`,
|
||||||
|
type: "volume" as const,
|
||||||
|
volumeName: v,
|
||||||
|
mountPath: "/data",
|
||||||
|
})),
|
||||||
|
...mountConfigs,
|
||||||
|
];
|
||||||
|
|
||||||
const targetBasePath = getServiceBasePath(serviceType, appName, true);
|
const targetBasePath = getServiceBasePath(serviceType, appName, true);
|
||||||
|
|
||||||
const preflight = await runPreflightChecks(
|
const preflight = await runPreflightChecks(
|
||||||
targetServerId,
|
targetServerId,
|
||||||
targetBasePath,
|
targetBasePath,
|
||||||
totalBytes,
|
0,
|
||||||
mountConfigs,
|
allVolumeConfigs,
|
||||||
(msg) => reportProgress("preparing", msg),
|
(msg) => reportProgress("preparing", msg),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -326,18 +373,16 @@ export const executeTransfer = async (
|
|||||||
targetBasePath,
|
targetBasePath,
|
||||||
(msg) => reportProgress("syncing_directory", msg),
|
(msg) => reportProgress("syncing_directory", msg),
|
||||||
);
|
);
|
||||||
processedFiles += scan.serviceDirectory.files.length;
|
|
||||||
transferredBytes += scan.serviceDirectory.totalSize;
|
|
||||||
reportProgress("syncing_directory", "Service directory synced");
|
reportProgress("syncing_directory", "Service directory synced");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errors.push(
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
`Failed to sync service directory: ${error instanceof Error ? error.message : String(error)}`,
|
errors.push(`Failed to sync service directory: ${msg}`);
|
||||||
);
|
reportProgress("syncing_directory", `Error: ${msg}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 3: Sync Traefik config
|
// Phase 3: Sync Traefik config
|
||||||
if (scan.traefikConfig.exists) {
|
if (serviceType === "application" || serviceType === "compose") {
|
||||||
reportProgress("syncing_traefik", "Syncing Traefik configuration...");
|
reportProgress("syncing_traefik", "Syncing Traefik configuration...");
|
||||||
try {
|
try {
|
||||||
await syncTraefikConfig(
|
await syncTraefikConfig(
|
||||||
@@ -346,43 +391,58 @@ export const executeTransfer = async (
|
|||||||
appName,
|
appName,
|
||||||
(msg) => reportProgress("syncing_traefik", msg),
|
(msg) => reportProgress("syncing_traefik", msg),
|
||||||
);
|
);
|
||||||
reportProgress("syncing_traefik", "Traefik config synced");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errors.push(
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
`Failed to sync Traefik config: ${error instanceof Error ? error.message : String(error)}`,
|
errors.push(`Failed to sync Traefik config: ${msg}`);
|
||||||
);
|
reportProgress("syncing_traefik", `Error: ${msg}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 4: Sync mounts
|
// Phase 4: Sync all discovered Docker volumes
|
||||||
reportProgress("syncing_mounts", "Syncing mounts and volumes...");
|
reportProgress("syncing_mounts", "Syncing Docker volumes...");
|
||||||
for (const mountScan of scan.mounts) {
|
|
||||||
|
for (const volumeName of discoveredVolumes) {
|
||||||
|
reportProgress("syncing_mounts", `Syncing volume: ${volumeName}`);
|
||||||
|
try {
|
||||||
|
await syncDockerVolume(
|
||||||
|
sourceServerId,
|
||||||
|
targetServerId,
|
||||||
|
volumeName,
|
||||||
|
(msg) => reportProgress("syncing_mounts", msg),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
|
errors.push(`Failed to sync volume ${volumeName}: ${msg}`);
|
||||||
|
reportProgress("syncing_mounts", `Error: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 5: Sync user-defined mounts (bind mounts, etc.)
|
||||||
|
for (const mountConfig of mountConfigs) {
|
||||||
const mountLabel =
|
const mountLabel =
|
||||||
mountScan.mount.volumeName ||
|
mountConfig.volumeName || mountConfig.hostPath || mountConfig.mountPath;
|
||||||
mountScan.mount.hostPath ||
|
reportProgress("syncing_mounts", `Syncing: ${mountLabel}`);
|
||||||
mountScan.mount.mountPath;
|
|
||||||
reportProgress("syncing_mounts", `Syncing: ${mountLabel}`, mountLabel);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await syncMount(
|
await syncMount(
|
||||||
sourceServerId,
|
sourceServerId,
|
||||||
targetServerId,
|
targetServerId,
|
||||||
mountScan.mount,
|
mountConfig,
|
||||||
decisions,
|
decisions,
|
||||||
(msg) => reportProgress("syncing_mounts", msg),
|
(msg) => reportProgress("syncing_mounts", msg),
|
||||||
);
|
);
|
||||||
processedFiles += mountScan.files.length;
|
|
||||||
transferredBytes += mountScan.totalSize;
|
|
||||||
reportProgress("syncing_mounts", `Completed: ${mountLabel}`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errors.push(
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
`Failed to sync mount ${mountLabel}: ${error instanceof Error ? error.message : String(error)}`,
|
errors.push(`Failed to sync mount ${mountLabel}: ${msg}`);
|
||||||
);
|
reportProgress("syncing_mounts", `Error: ${msg}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
reportProgress("failed", `Transfer completed with errors: ${errors.join(", ")}`);
|
reportProgress(
|
||||||
|
"failed",
|
||||||
|
`Transfer completed with errors: ${errors.join(", ")}`,
|
||||||
|
);
|
||||||
return { success: false, errors };
|
return { success: false, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,103 +6,215 @@ import type {
|
|||||||
MountTransferConfig,
|
MountTransferConfig,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
|
const execOnServer = async (
|
||||||
|
serverId: string | null,
|
||||||
|
command: string,
|
||||||
|
): Promise<{ stdout: string; stderr: string }> => {
|
||||||
|
if (serverId) {
|
||||||
|
return execAsyncRemote(serverId, command);
|
||||||
|
}
|
||||||
|
return execAsync(command);
|
||||||
|
};
|
||||||
|
|
||||||
export const scanDirectory = async (
|
export const scanDirectory = async (
|
||||||
serverId: string | null,
|
serverId: string | null,
|
||||||
dirPath: string,
|
dirPath: string,
|
||||||
): Promise<FileInfo[]> => {
|
): Promise<FileInfo[]> => {
|
||||||
const command = `find ${dirPath} -type f -exec stat --format='%n|%s|%Y' {} + 2>/dev/null || true`;
|
// Check if directory exists first
|
||||||
|
try {
|
||||||
let stdout: string;
|
const { stdout: exists } = await execOnServer(
|
||||||
if (serverId) {
|
serverId,
|
||||||
const result = await execAsyncRemote(serverId, command);
|
`test -d "${dirPath}" && echo "yes" || echo "no"`,
|
||||||
stdout = result.stdout;
|
);
|
||||||
} else {
|
if (exists.trim() !== "yes") {
|
||||||
const result = await execAsync(command);
|
return [];
|
||||||
stdout = result.stdout;
|
}
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!stdout.trim()) return [];
|
// Use find + stat -c (POSIX-compatible on Linux)
|
||||||
|
// stat -c works on GNU coreutils (Debian, Ubuntu, etc.)
|
||||||
|
const command = `find "${dirPath}" -type f -printf '%p|%s|%T@\\n' 2>/dev/null`;
|
||||||
|
|
||||||
return stdout
|
try {
|
||||||
.trim()
|
const { stdout } = await execOnServer(serverId, command);
|
||||||
.split("\n")
|
if (!stdout.trim()) return [];
|
||||||
.filter(Boolean)
|
|
||||||
.map((line) => {
|
return stdout
|
||||||
const [filePath, size, modifiedAt] = line.split("|");
|
.trim()
|
||||||
return {
|
.split("\n")
|
||||||
path: filePath!.replace(dirPath, "").replace(/^\//, ""),
|
.filter(Boolean)
|
||||||
size: Number.parseInt(size || "0", 10),
|
.map((line) => {
|
||||||
modifiedAt: Number.parseInt(modifiedAt || "0", 10),
|
const parts = line.split("|");
|
||||||
};
|
const filePath = parts[0] || "";
|
||||||
});
|
const size = parts[1] || "0";
|
||||||
|
const modifiedAt = parts[2] || "0";
|
||||||
|
return {
|
||||||
|
path: filePath.replace(dirPath, "").replace(/^\//, ""),
|
||||||
|
size: Number.parseInt(size, 10),
|
||||||
|
modifiedAt: Math.floor(Number.parseFloat(modifiedAt)),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((f) => f.path);
|
||||||
|
} catch {
|
||||||
|
// Fallback: try simpler ls-based approach
|
||||||
|
try {
|
||||||
|
const { stdout } = await execOnServer(
|
||||||
|
serverId,
|
||||||
|
`find "${dirPath}" -type f 2>/dev/null`,
|
||||||
|
);
|
||||||
|
if (!stdout.trim()) return [];
|
||||||
|
|
||||||
|
return stdout
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((filePath) => ({
|
||||||
|
path: filePath.replace(dirPath, "").replace(/^\//, ""),
|
||||||
|
size: 0,
|
||||||
|
modifiedAt: 0,
|
||||||
|
}))
|
||||||
|
.filter((f) => f.path);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const scanDockerVolume = async (
|
export const scanDockerVolume = async (
|
||||||
serverId: string | null,
|
serverId: string | null,
|
||||||
volumeName: string,
|
volumeName: string,
|
||||||
): Promise<FileInfo[]> => {
|
): Promise<FileInfo[]> => {
|
||||||
const command = `docker run --rm -v ${volumeName}:/volume alpine find /volume -type f -exec stat -c '%n|%s|%Y' {} + 2>/dev/null || true`;
|
// First check if volume exists
|
||||||
|
try {
|
||||||
let stdout: string;
|
const { stdout: exists } = await execOnServer(
|
||||||
if (serverId) {
|
serverId,
|
||||||
const result = await execAsyncRemote(serverId, command);
|
`docker volume inspect "${volumeName}" >/dev/null 2>&1 && echo "yes" || echo "no"`,
|
||||||
stdout = result.stdout;
|
);
|
||||||
} else {
|
if (exists.trim() !== "yes") {
|
||||||
const result = await execAsync(command);
|
return [];
|
||||||
stdout = result.stdout;
|
}
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!stdout.trim()) return [];
|
// Use busybox/alpine stat format (-c '%n|%s|%Y')
|
||||||
|
const command = `docker run --rm -v "${volumeName}":/volume:ro alpine sh -c 'find /volume -type f -exec stat -c "%n|%s|%Y" {} + 2>/dev/null || find /volume -type f 2>/dev/null'`;
|
||||||
|
|
||||||
return stdout
|
try {
|
||||||
.trim()
|
const { stdout } = await execOnServer(serverId, command);
|
||||||
.split("\n")
|
if (!stdout.trim()) return [];
|
||||||
.filter(Boolean)
|
|
||||||
.map((line) => {
|
return stdout
|
||||||
const [filePath, size, modifiedAt] = line.split("|");
|
.trim()
|
||||||
return {
|
.split("\n")
|
||||||
path: (filePath || "").replace("/volume/", ""),
|
.filter(Boolean)
|
||||||
size: Number.parseInt(size || "0", 10),
|
.map((line) => {
|
||||||
modifiedAt: Number.parseInt(modifiedAt || "0", 10),
|
const parts = line.split("|");
|
||||||
};
|
if (parts.length >= 3) {
|
||||||
});
|
return {
|
||||||
|
path: (parts[0] || "").replace(/^\/volume\/?/, ""),
|
||||||
|
size: Number.parseInt(parts[1] || "0", 10),
|
||||||
|
modifiedAt: Number.parseInt(parts[2] || "0", 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Fallback: just file path
|
||||||
|
return {
|
||||||
|
path: line.replace(/^\/volume\/?/, ""),
|
||||||
|
size: 0,
|
||||||
|
modifiedAt: 0,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((f) => f.path);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDirectorySize = async (
|
||||||
|
serverId: string | null,
|
||||||
|
dirPath: string,
|
||||||
|
): Promise<number> => {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execOnServer(
|
||||||
|
serverId,
|
||||||
|
`du -sb "${dirPath}" 2>/dev/null | awk '{print $1}'`,
|
||||||
|
);
|
||||||
|
return Number.parseInt(stdout.trim(), 10) || 0;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getVolumeSize = async (
|
||||||
|
serverId: string | null,
|
||||||
|
volumeName: string,
|
||||||
|
): Promise<number> => {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execOnServer(
|
||||||
|
serverId,
|
||||||
|
`docker run --rm -v "${volumeName}":/volume:ro alpine du -sb /volume 2>/dev/null | awk '{print $1}'`,
|
||||||
|
);
|
||||||
|
return Number.parseInt(stdout.trim(), 10) || 0;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all Docker volumes belonging to a compose project.
|
||||||
|
* Docker compose automatically labels volumes with com.docker.compose.project
|
||||||
|
*/
|
||||||
|
export const listComposeVolumes = async (
|
||||||
|
serverId: string | null,
|
||||||
|
projectName: string,
|
||||||
|
): Promise<string[]> => {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execOnServer(
|
||||||
|
serverId,
|
||||||
|
`docker volume ls --filter "label=com.docker.compose.project=${projectName}" --format "{{.Name}}" 2>/dev/null`,
|
||||||
|
);
|
||||||
|
if (!stdout.trim()) return [];
|
||||||
|
return stdout.trim().split("\n").filter(Boolean);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all Docker volumes that match a prefix pattern (appName_*).
|
||||||
|
* Fallback for when compose labels are not available.
|
||||||
|
*/
|
||||||
|
export const listVolumesByPrefix = async (
|
||||||
|
serverId: string | null,
|
||||||
|
prefix: string,
|
||||||
|
): Promise<string[]> => {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execOnServer(
|
||||||
|
serverId,
|
||||||
|
`docker volume ls --format "{{.Name}}" 2>/dev/null | grep "^${prefix}" || true`,
|
||||||
|
);
|
||||||
|
if (!stdout.trim()) return [];
|
||||||
|
return stdout.trim().split("\n").filter(Boolean);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const computeFileHash = async (
|
export const computeFileHash = async (
|
||||||
serverId: string | null,
|
serverId: string | null,
|
||||||
filePath: string,
|
filePath: string,
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
const command = `md5sum "${filePath}" | awk '{print $1}'`;
|
try {
|
||||||
|
const { stdout } = await execOnServer(
|
||||||
let stdout: string;
|
serverId,
|
||||||
if (serverId) {
|
`md5sum "${filePath}" 2>/dev/null | awk '{print $1}'`,
|
||||||
const result = await execAsyncRemote(serverId, command);
|
);
|
||||||
stdout = result.stdout;
|
return stdout.trim();
|
||||||
} else {
|
} catch {
|
||||||
const result = await execAsync(command);
|
return "";
|
||||||
stdout = result.stdout;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return stdout.trim();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const computeVolumeFileHash = async (
|
|
||||||
serverId: string | null,
|
|
||||||
volumeName: string,
|
|
||||||
filePath: string,
|
|
||||||
): Promise<string> => {
|
|
||||||
const command = `docker run --rm -v ${volumeName}:/volume alpine md5sum "/volume/${filePath}" | awk '{print $1}'`;
|
|
||||||
|
|
||||||
let stdout: string;
|
|
||||||
if (serverId) {
|
|
||||||
const result = await execAsyncRemote(serverId, command);
|
|
||||||
stdout = result.stdout;
|
|
||||||
} else {
|
|
||||||
const result = await execAsync(command);
|
|
||||||
stdout = result.stdout;
|
|
||||||
}
|
|
||||||
|
|
||||||
return stdout.trim();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const scanMount = async (
|
export const scanMount = async (
|
||||||
@@ -115,20 +227,13 @@ export const scanMount = async (
|
|||||||
if (mount.type === "bind" && mount.hostPath) {
|
if (mount.type === "bind" && mount.hostPath) {
|
||||||
return scanDirectory(serverId, mount.hostPath);
|
return scanDirectory(serverId, mount.hostPath);
|
||||||
}
|
}
|
||||||
if (mount.type === "file") {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return [];
|
return [];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const compareFileLists = async (
|
export const compareFileLists = (
|
||||||
sourceFiles: FileInfo[],
|
sourceFiles: FileInfo[],
|
||||||
targetFiles: FileInfo[],
|
targetFiles: FileInfo[],
|
||||||
sourceServerId: string | null,
|
): FileConflict[] => {
|
||||||
targetServerId: string,
|
|
||||||
basePath?: string,
|
|
||||||
volumeName?: string,
|
|
||||||
): Promise<FileConflict[]> => {
|
|
||||||
const targetMap = new Map<string, FileInfo>();
|
const targetMap = new Map<string, FileInfo>();
|
||||||
for (const f of targetFiles) {
|
for (const f of targetFiles) {
|
||||||
targetMap.set(f.path, f);
|
targetMap.set(f.path, f);
|
||||||
@@ -161,44 +266,7 @@ export const compareFileLists = async (
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let sourceHash: string;
|
// Different size or time = conflict
|
||||||
let targetHash: string;
|
|
||||||
|
|
||||||
if (volumeName) {
|
|
||||||
sourceHash = await computeVolumeFileHash(
|
|
||||||
sourceServerId,
|
|
||||||
volumeName,
|
|
||||||
sourceFile.path,
|
|
||||||
);
|
|
||||||
targetHash = await computeVolumeFileHash(
|
|
||||||
targetServerId,
|
|
||||||
volumeName,
|
|
||||||
targetFile.path,
|
|
||||||
);
|
|
||||||
} else if (basePath) {
|
|
||||||
sourceHash = await computeFileHash(
|
|
||||||
sourceServerId,
|
|
||||||
`${basePath}/${sourceFile.path}`,
|
|
||||||
);
|
|
||||||
targetHash = await computeFileHash(
|
|
||||||
targetServerId,
|
|
||||||
`${basePath}/${targetFile.path}`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
sourceHash = "";
|
|
||||||
targetHash = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sourceHash && targetHash && sourceHash === targetHash) {
|
|
||||||
conflicts.push({
|
|
||||||
path: sourceFile.path,
|
|
||||||
status: "match",
|
|
||||||
sourceFile: { ...sourceFile, hash: sourceHash },
|
|
||||||
targetFile: { ...targetFile, hash: targetHash },
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let status: ConflictStatus;
|
let status: ConflictStatus;
|
||||||
if (sourceFile.modifiedAt > targetFile.modifiedAt) {
|
if (sourceFile.modifiedAt > targetFile.modifiedAt) {
|
||||||
status = "newer_source";
|
status = "newer_source";
|
||||||
@@ -211,14 +279,14 @@ export const compareFileLists = async (
|
|||||||
conflicts.push({
|
conflicts.push({
|
||||||
path: sourceFile.path,
|
path: sourceFile.path,
|
||||||
status,
|
status,
|
||||||
sourceFile: { ...sourceFile, hash: sourceHash || undefined },
|
sourceFile,
|
||||||
targetFile: { ...targetFile, hash: targetHash || undefined },
|
targetFile,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Files only on target
|
||||||
for (const targetFile of targetFiles) {
|
for (const targetFile of targetFiles) {
|
||||||
const exists = sourceFiles.some((sf) => sf.path === targetFile.path);
|
if (!sourceFiles.some((sf) => sf.path === targetFile.path)) {
|
||||||
if (!exists) {
|
|
||||||
conflicts.push({
|
conflicts.push({
|
||||||
path: targetFile.path,
|
path: targetFile.path,
|
||||||
status: "newer_target",
|
status: "newer_target",
|
||||||
|
|||||||
@@ -1,17 +1,193 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { findServerById } from "../../services/server";
|
||||||
|
import { Client } from "ssh2";
|
||||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||||
import type { ConflictDecision, MountTransferConfig } from "./types";
|
import type { ConflictDecision, MountTransferConfig } from "./types";
|
||||||
|
|
||||||
const execOnServer = async (
|
const execOnServer = async (
|
||||||
serverId: string | null,
|
serverId: string | null,
|
||||||
command: string,
|
command: string,
|
||||||
onData?: (data: string) => void,
|
|
||||||
): Promise<{ stdout: string; stderr: string }> => {
|
): Promise<{ stdout: string; stderr: string }> => {
|
||||||
if (serverId) {
|
if (serverId) {
|
||||||
return execAsyncRemote(serverId, command, onData);
|
return execAsyncRemote(serverId, command);
|
||||||
}
|
}
|
||||||
return execAsync(command);
|
return execAsync(command);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a direct SSH connection to a server.
|
||||||
|
* Used for streaming binary data (tar pipes) that can't go through execAsyncRemote.
|
||||||
|
*/
|
||||||
|
const getSSHConnection = async (
|
||||||
|
serverId: string,
|
||||||
|
): Promise<{ conn: Client }> => {
|
||||||
|
const server = await findServerById(serverId);
|
||||||
|
if (!server.sshKeyId) {
|
||||||
|
throw new Error(`No SSH key configured for server ${server.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const conn = new Client();
|
||||||
|
conn
|
||||||
|
.on("ready", () => {
|
||||||
|
resolve({ conn });
|
||||||
|
})
|
||||||
|
.on("error", (err) => {
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`SSH connection failed to ${server.name} (${server.ipAddress}): ${err.message}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.connect({
|
||||||
|
host: server.ipAddress,
|
||||||
|
port: server.port,
|
||||||
|
username: server.username,
|
||||||
|
privateKey: server.sshKey?.privateKey,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pipe a tar stream from source SSH connection to target SSH connection.
|
||||||
|
*/
|
||||||
|
const pipeSSH = (
|
||||||
|
sourceConn: Client,
|
||||||
|
targetConn: Client,
|
||||||
|
sourceCmd: string,
|
||||||
|
targetCmd: string,
|
||||||
|
onLog?: (message: string) => void,
|
||||||
|
): Promise<void> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
sourceConn.exec(sourceCmd, (err, sourceStream) => {
|
||||||
|
if (err) return reject(new Error(`Source exec failed: ${err.message}`));
|
||||||
|
|
||||||
|
targetConn.exec(targetCmd, (err2, targetStream) => {
|
||||||
|
if (err2)
|
||||||
|
return reject(new Error(`Target exec failed: ${err2.message}`));
|
||||||
|
|
||||||
|
let totalBytes = 0;
|
||||||
|
|
||||||
|
sourceStream.on("data", (chunk: Buffer) => {
|
||||||
|
totalBytes += chunk.length;
|
||||||
|
targetStream.write(chunk);
|
||||||
|
});
|
||||||
|
|
||||||
|
sourceStream.on("end", () => {
|
||||||
|
targetStream.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
targetStream.on("close", () => {
|
||||||
|
onLog?.(
|
||||||
|
`Transferred ${(totalBytes / 1024 / 1024).toFixed(2)} MB`,
|
||||||
|
);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
sourceStream.on("error", (e: Error) =>
|
||||||
|
reject(new Error(`Source stream error: ${e.message}`)),
|
||||||
|
);
|
||||||
|
targetStream.on("error", (e: Error) =>
|
||||||
|
reject(new Error(`Target stream error: ${e.message}`)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream data from local tar command into a remote SSH command.
|
||||||
|
*/
|
||||||
|
const pipeLocalToRemote = (
|
||||||
|
targetConn: Client,
|
||||||
|
localCmd: string,
|
||||||
|
localArgs: string[],
|
||||||
|
remoteCmd: string,
|
||||||
|
onLog?: (message: string) => void,
|
||||||
|
): Promise<void> => {
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const localProcess = spawn(localCmd, localArgs, {
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
targetConn.exec(remoteCmd, (err, targetStream) => {
|
||||||
|
if (err) {
|
||||||
|
localProcess.kill();
|
||||||
|
return reject(new Error(`Remote exec failed: ${err.message}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalBytes = 0;
|
||||||
|
|
||||||
|
localProcess.stdout.on("data", (chunk: Buffer) => {
|
||||||
|
totalBytes += chunk.length;
|
||||||
|
targetStream.write(chunk);
|
||||||
|
});
|
||||||
|
|
||||||
|
localProcess.stdout.on("end", () => {
|
||||||
|
targetStream.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
targetStream.on("close", () => {
|
||||||
|
onLog?.(
|
||||||
|
`Transferred ${(totalBytes / 1024 / 1024).toFixed(2)} MB`,
|
||||||
|
);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
localProcess.on("error", (e) => reject(e));
|
||||||
|
targetStream.on("error", (e: Error) => reject(e));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream data from a remote SSH command into a local tar command.
|
||||||
|
*/
|
||||||
|
const pipeRemoteToLocal = (
|
||||||
|
sourceConn: Client,
|
||||||
|
remoteCmd: string,
|
||||||
|
localCmd: string,
|
||||||
|
localArgs: string[],
|
||||||
|
onLog?: (message: string) => void,
|
||||||
|
): Promise<void> => {
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const localProcess = spawn(localCmd, localArgs, {
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
sourceConn.exec(remoteCmd, (err, sourceStream) => {
|
||||||
|
if (err) {
|
||||||
|
localProcess.kill();
|
||||||
|
return reject(new Error(`Remote exec failed: ${err.message}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalBytes = 0;
|
||||||
|
|
||||||
|
sourceStream.on("data", (chunk: Buffer) => {
|
||||||
|
totalBytes += chunk.length;
|
||||||
|
localProcess.stdin.write(chunk);
|
||||||
|
});
|
||||||
|
|
||||||
|
sourceStream.on("end", () => {
|
||||||
|
localProcess.stdin.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
localProcess.on("close", (code: number) => {
|
||||||
|
onLog?.(
|
||||||
|
`Transferred ${(totalBytes / 1024 / 1024).toFixed(2)} MB`,
|
||||||
|
);
|
||||||
|
if (code === 0) resolve();
|
||||||
|
else reject(new Error(`Local process exited with code ${code}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
sourceStream.on("error", (e: Error) => reject(e));
|
||||||
|
localProcess.on("error", (e) => reject(e));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const syncDirectory = async (
|
export const syncDirectory = async (
|
||||||
sourceServerId: string | null,
|
sourceServerId: string | null,
|
||||||
targetServerId: string,
|
targetServerId: string,
|
||||||
@@ -21,47 +197,59 @@ export const syncDirectory = async (
|
|||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
onLog?.(`Syncing directory: ${sourcePath} → ${targetPath}`);
|
onLog?.(`Syncing directory: ${sourcePath} → ${targetPath}`);
|
||||||
|
|
||||||
|
// Ensure target directory exists
|
||||||
await execOnServer(targetServerId, `mkdir -p "${targetPath}"`);
|
await execOnServer(targetServerId, `mkdir -p "${targetPath}"`);
|
||||||
|
|
||||||
if (!sourceServerId && targetServerId) {
|
if (sourceServerId && targetServerId) {
|
||||||
// Local → Remote: use rsync over SSH
|
// Remote → Remote: pipe tar directly between SSH connections
|
||||||
const { stdout: sshKeyInfo } = await execAsyncRemote(
|
onLog?.("Using direct SSH pipe for remote-to-remote transfer...");
|
||||||
targetServerId,
|
const [source, target] = await Promise.all([
|
||||||
"echo connected",
|
getSSHConnection(sourceServerId),
|
||||||
);
|
getSSHConnection(targetServerId),
|
||||||
// Tar from local, pipe to remote via SSH
|
]);
|
||||||
await execAsync(
|
try {
|
||||||
`tar czf - -C "${sourcePath}" . 2>/dev/null | ssh -o StrictHostKeyChecking=no -i /tmp/transfer_key_${targetServerId} "tar xzf - -C ${targetPath}"`,
|
await pipeSSH(
|
||||||
).catch(async () => {
|
source.conn,
|
||||||
// Fallback: read from local, write to remote via tar through dokploy
|
target.conn,
|
||||||
const { stdout: tarData } = await execAsync(
|
`tar czf - -C "${sourcePath}" . 2>/dev/null`,
|
||||||
`tar czf - -C "${sourcePath}" . | base64`,
|
`tar xzf - -C "${targetPath}"`,
|
||||||
|
onLog,
|
||||||
);
|
);
|
||||||
await execAsyncRemote(
|
} finally {
|
||||||
targetServerId,
|
source.conn.end();
|
||||||
`echo "${tarData}" | base64 -d | tar xzf - -C "${targetPath}"`,
|
target.conn.end();
|
||||||
|
}
|
||||||
|
} else if (!sourceServerId && targetServerId) {
|
||||||
|
// Local → Remote
|
||||||
|
onLog?.("Transferring from local to remote...");
|
||||||
|
const { conn } = await getSSHConnection(targetServerId);
|
||||||
|
try {
|
||||||
|
await pipeLocalToRemote(
|
||||||
|
conn,
|
||||||
|
"tar",
|
||||||
|
["czf", "-", "-C", sourcePath, "."],
|
||||||
|
`tar xzf - -C "${targetPath}"`,
|
||||||
|
onLog,
|
||||||
);
|
);
|
||||||
});
|
} finally {
|
||||||
} else if (sourceServerId && targetServerId) {
|
conn.end();
|
||||||
// Remote → Remote: tar pipeline through Dokploy server
|
}
|
||||||
onLog?.("Using tar pipeline for remote-to-remote transfer...");
|
|
||||||
const { stdout: tarData } = await execAsyncRemote(
|
|
||||||
sourceServerId,
|
|
||||||
`tar czf - -C "${sourcePath}" . | base64`,
|
|
||||||
);
|
|
||||||
await execAsyncRemote(
|
|
||||||
targetServerId,
|
|
||||||
`echo "${tarData}" | base64 -d | tar xzf - -C "${targetPath}"`,
|
|
||||||
);
|
|
||||||
} else if (sourceServerId && !targetServerId) {
|
} else if (sourceServerId && !targetServerId) {
|
||||||
// Remote → Local
|
// Remote → Local
|
||||||
const { stdout: tarData } = await execAsyncRemote(
|
onLog?.("Transferring from remote to local...");
|
||||||
sourceServerId,
|
await execAsync(`mkdir -p "${targetPath}"`);
|
||||||
`tar czf - -C "${sourcePath}" . | base64`,
|
const { conn } = await getSSHConnection(sourceServerId);
|
||||||
);
|
try {
|
||||||
await execAsync(
|
await pipeRemoteToLocal(
|
||||||
`echo "${tarData}" | base64 -d | tar xzf - -C "${targetPath}"`,
|
conn,
|
||||||
);
|
`tar czf - -C "${sourcePath}" . 2>/dev/null`,
|
||||||
|
"tar",
|
||||||
|
["xzf", "-", "-C", targetPath],
|
||||||
|
onLog,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
conn.end();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onLog?.(`Directory synced successfully: ${targetPath}`);
|
onLog?.(`Directory synced successfully: ${targetPath}`);
|
||||||
@@ -75,27 +263,68 @@ export const syncDockerVolume = async (
|
|||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
onLog?.(`Syncing Docker volume: ${volumeName}`);
|
onLog?.(`Syncing Docker volume: ${volumeName}`);
|
||||||
|
|
||||||
|
// Ensure volume exists on target
|
||||||
await execOnServer(
|
await execOnServer(
|
||||||
targetServerId,
|
targetServerId,
|
||||||
`docker volume inspect ${volumeName} > /dev/null 2>&1 || docker volume create ${volumeName}`,
|
`docker volume inspect "${volumeName}" > /dev/null 2>&1 || docker volume create "${volumeName}"`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Export volume from source as tar
|
const srcTarCmd = `docker run --rm -v "${volumeName}":/volume:ro alpine tar czf - -C /volume . 2>/dev/null`;
|
||||||
const exportCommand = `docker run --rm -v ${volumeName}:/volume alpine tar czf - -C /volume . | base64`;
|
const dstTarCmd = `docker run --rm -i -v "${volumeName}":/volume alpine tar xzf - -C /volume`;
|
||||||
let tarData: string;
|
|
||||||
|
|
||||||
if (sourceServerId) {
|
if (sourceServerId && targetServerId) {
|
||||||
const result = await execAsyncRemote(sourceServerId, exportCommand);
|
// Remote → Remote
|
||||||
tarData = result.stdout;
|
onLog?.("Using direct SSH pipe for volume transfer...");
|
||||||
} else {
|
const [source, target] = await Promise.all([
|
||||||
const result = await execAsync(exportCommand);
|
getSSHConnection(sourceServerId),
|
||||||
tarData = result.stdout;
|
getSSHConnection(targetServerId),
|
||||||
|
]);
|
||||||
|
try {
|
||||||
|
await pipeSSH(source.conn, target.conn, srcTarCmd, dstTarCmd, onLog);
|
||||||
|
} finally {
|
||||||
|
source.conn.end();
|
||||||
|
target.conn.end();
|
||||||
|
}
|
||||||
|
} else if (!sourceServerId && targetServerId) {
|
||||||
|
// Local → Remote
|
||||||
|
onLog?.("Transferring volume from local to remote...");
|
||||||
|
const { conn } = await getSSHConnection(targetServerId);
|
||||||
|
try {
|
||||||
|
await pipeLocalToRemote(
|
||||||
|
conn,
|
||||||
|
"docker",
|
||||||
|
[
|
||||||
|
"run", "--rm",
|
||||||
|
"-v", `${volumeName}:/volume:ro`,
|
||||||
|
"alpine", "tar", "czf", "-", "-C", "/volume", ".",
|
||||||
|
],
|
||||||
|
dstTarCmd,
|
||||||
|
onLog,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
conn.end();
|
||||||
|
}
|
||||||
|
} else if (sourceServerId && !targetServerId) {
|
||||||
|
// Remote → Local
|
||||||
|
onLog?.("Transferring volume from remote to local...");
|
||||||
|
const { conn } = await getSSHConnection(sourceServerId);
|
||||||
|
try {
|
||||||
|
await pipeRemoteToLocal(
|
||||||
|
conn,
|
||||||
|
srcTarCmd,
|
||||||
|
"docker",
|
||||||
|
[
|
||||||
|
"run", "--rm", "-i",
|
||||||
|
"-v", `${volumeName}:/volume`,
|
||||||
|
"alpine", "tar", "xzf", "-", "-C", "/volume",
|
||||||
|
],
|
||||||
|
onLog,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
conn.end();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import volume on target
|
|
||||||
const importCommand = `echo "${tarData}" | base64 -d | docker run --rm -i -v ${volumeName}:/volume alpine tar xzf - -C /volume`;
|
|
||||||
|
|
||||||
await execOnServer(targetServerId, importCommand);
|
|
||||||
onLog?.(`Volume synced successfully: ${volumeName}`);
|
onLog?.(`Volume synced successfully: ${volumeName}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -122,9 +351,6 @@ export const syncMount = async (
|
|||||||
onLog,
|
onLog,
|
||||||
);
|
);
|
||||||
} else if (mount.type === "file" && mount.content) {
|
} else if (mount.type === "file" && mount.content) {
|
||||||
onLog?.(`Syncing file mount: ${mount.mountPath}`);
|
|
||||||
// File mounts are stored in the database, they get created during deploy
|
|
||||||
// No file transfer needed, the content is in the DB
|
|
||||||
onLog?.("File mount will be recreated from database content during deploy");
|
onLog?.("File mount will be recreated from database content during deploy");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -141,30 +367,28 @@ export const syncTraefikConfig = async (
|
|||||||
const configFile = `${configPath}/${appName}.yml`;
|
const configFile = `${configPath}/${appName}.yml`;
|
||||||
|
|
||||||
let configContent: string;
|
let configContent: string;
|
||||||
if (sourceServerId) {
|
try {
|
||||||
const { stdout } = await execAsyncRemote(
|
const { stdout } = await execOnServer(
|
||||||
sourceServerId,
|
sourceServerId,
|
||||||
`cat "${configFile}" 2>/dev/null || echo ""`,
|
`cat "${configFile}" 2>/dev/null`,
|
||||||
);
|
|
||||||
configContent = stdout;
|
|
||||||
} else {
|
|
||||||
const { stdout } = await execAsync(
|
|
||||||
`cat "${configFile}" 2>/dev/null || echo ""`,
|
|
||||||
);
|
);
|
||||||
configContent = stdout;
|
configContent = stdout;
|
||||||
|
} catch {
|
||||||
|
onLog?.("No Traefik config found on source, skipping");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!configContent.trim()) {
|
if (!configContent.trim()) {
|
||||||
onLog?.("No Traefik config found on source, skipping");
|
onLog?.("Empty Traefik config on source, skipping");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await execOnServer(targetServerId, `mkdir -p "${configPath}"`);
|
await execOnServer(targetServerId, `mkdir -p "${configPath}"`);
|
||||||
|
|
||||||
const escapedContent = configContent.replace(/'/g, "'\\''");
|
const b64 = Buffer.from(configContent).toString("base64");
|
||||||
await execOnServer(
|
await execOnServer(
|
||||||
targetServerId,
|
targetServerId,
|
||||||
`echo '${escapedContent}' > "${configFile}"`,
|
`echo "${b64}" | base64 -d > "${configFile}"`,
|
||||||
);
|
);
|
||||||
|
|
||||||
onLog?.("Traefik config synced successfully");
|
onLog?.("Traefik config synced successfully");
|
||||||
|
|||||||
Reference in New Issue
Block a user