diff --git a/apps/dokploy/__test__/utils/backups.test.ts b/apps/dokploy/__test__/utils/backups.test.ts index 2c1e5decc9..58129c22a9 100644 --- a/apps/dokploy/__test__/utils/backups.test.ts +++ b/apps/dokploy/__test__/utils/backups.test.ts @@ -1,6 +1,25 @@ -import { normalizeS3Path } from "@dokploy/server/utils/backups/utils"; +import type { Destination } from "@dokploy/server/services/destination"; +import { + getRcloneBucketPath, + getS3Credentials, + normalizeS3Path, +} from "@dokploy/server/utils/backups/utils"; import { describe, expect, test } from "vitest"; +const baseDestination = { + destinationId: "dest-1", + name: "test", + provider: "AWS", + accessKey: "AKIA", + secretAccessKey: "secret", + bucket: "my-bucket", + region: "us-east-1", + endpoint: "https://s3.example.com", + additionalFlags: null, + organizationId: "org-1", + createdAt: new Date(), +} satisfies Destination; + describe("normalizeS3Path", () => { test("should handle empty and whitespace-only prefix", () => { expect(normalizeS3Path("")).toBe(""); @@ -59,3 +78,72 @@ describe("normalizeS3Path", () => { expect(normalizeS3Path("instance-backups")).toBe("instance-backups/"); }); }); + +describe("getRcloneBucketPath", () => { + test("returns S3-style remote for S3 providers", () => { + expect(getRcloneBucketPath(baseDestination)).toBe(":s3:my-bucket"); + }); + + test("uses the user-supplied connection string for Custom provider", () => { + const destination: Destination = { + ...baseDestination, + provider: "Custom", + endpoint: ":sftp,host=example.com,user=foo,pass=bar:", + bucket: "", + accessKey: "", + secretAccessKey: "", + region: "", + }; + expect(getRcloneBucketPath(destination)).toBe( + ":sftp,host=example.com,user=foo,pass=bar:", + ); + }); + + test("appends the optional path/folder for Custom provider", () => { + const destination: Destination = { + ...baseDestination, + provider: "Custom", + endpoint: ":drive,token=xyz:", + bucket: "/backups", + accessKey: "", + secretAccessKey: "", + region: "", + }; + expect(getRcloneBucketPath(destination)).toBe(":drive,token=xyz:backups"); + }); +}); + +describe("getS3Credentials", () => { + test("emits --s3-* flags for S3 providers", () => { + const flags = getS3Credentials(baseDestination); + expect(flags).toContain('--s3-access-key-id="AKIA"'); + expect(flags).toContain('--s3-secret-access-key="secret"'); + expect(flags).toContain('--s3-region="us-east-1"'); + expect(flags).toContain('--s3-endpoint="https://s3.example.com"'); + expect(flags).toContain('--s3-provider="AWS"'); + }); + + test("does NOT emit --s3-* flags for Custom provider", () => { + const destination: Destination = { + ...baseDestination, + provider: "Custom", + endpoint: ":sftp,host=example.com,user=foo,pass=bar:", + additionalFlags: ["--sftp-disable-hashcheck"], + }; + const flags = getS3Credentials(destination); + for (const flag of flags) { + expect(flag).not.toMatch(/^--s3-/); + } + expect(flags).toEqual(["--sftp-disable-hashcheck"]); + }); + + test("returns an empty array for Custom provider with no additional flags", () => { + const destination: Destination = { + ...baseDestination, + provider: "Custom", + endpoint: ":sftp,host=example.com,user=foo,pass=bar:", + additionalFlags: null, + }; + expect(getS3Credentials(destination)).toEqual([]); + }); +}); diff --git a/apps/dokploy/components/dashboard/settings/destination/constants.ts b/apps/dokploy/components/dashboard/settings/destination/constants.ts index f43e47d1a1..d50cab5895 100644 --- a/apps/dokploy/components/dashboard/settings/destination/constants.ts +++ b/apps/dokploy/components/dashboard/settings/destination/constants.ts @@ -1,7 +1,13 @@ +export const CUSTOM_PROVIDER_KEY = "Custom"; + export const S3_PROVIDERS: Array<{ key: string; name: string; }> = [ + { + key: CUSTOM_PROVIDER_KEY, + name: "Custom (rclone connection string — FTP/SFTP/Google Drive/etc.)", + }, { key: "AWS", name: "Amazon Web Services (AWS) S3", diff --git a/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx b/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx index e7ecf92b2b..0086cf5f61 100644 --- a/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx +++ b/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx @@ -39,28 +39,60 @@ import { } from "@/components/ui/select"; import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; -import { S3_PROVIDERS } from "./constants"; +import { CUSTOM_PROVIDER_KEY, S3_PROVIDERS } from "./constants"; -const addDestination = z.object({ - name: z.string().min(1, "Name is required"), - provider: z.string().min(1, "Provider is required"), - accessKeyId: z.string().min(1, "Access Key Id is required"), - secretAccessKey: z.string().min(1, "Secret Access Key is required"), - bucket: z.string().min(1, "Bucket is required"), - region: z.string(), - endpoint: z.string().min(1, "Endpoint is required"), - serverId: z.string().optional(), - additionalFlags: z - .array( - z.object({ - value: z - .string() - .min(1, "Flag cannot be empty") - .regex(ADDITIONAL_FLAG_REGEX, ADDITIONAL_FLAG_ERROR), - }), - ) - .optional(), -}); +const addDestination = z + .object({ + name: z.string().min(1, "Name is required"), + provider: z.string().min(1, "Provider is required"), + accessKeyId: z.string(), + secretAccessKey: z.string(), + bucket: z.string(), + region: z.string(), + endpoint: z.string(), + serverId: z.string().optional(), + additionalFlags: z + .array( + z.object({ + value: z + .string() + .min(1, "Flag cannot be empty") + .regex(ADDITIONAL_FLAG_REGEX, ADDITIONAL_FLAG_ERROR), + }), + ) + .optional(), + }) + .superRefine((value, ctx) => { + if (value.provider === CUSTOM_PROVIDER_KEY) { + if (!value.endpoint || value.endpoint.trim().length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["endpoint"], + message: + "Connection string is required (e.g. :sftp,host=foo,user=bar:)", + }); + } + return; + } + const required: Array<{ + key: "accessKeyId" | "secretAccessKey" | "bucket" | "endpoint"; + label: string; + }> = [ + { key: "accessKeyId", label: "Access Key Id" }, + { key: "secretAccessKey", label: "Secret Access Key" }, + { key: "bucket", label: "Bucket" }, + { key: "endpoint", label: "Endpoint" }, + ]; + for (const { key, label } of required) { + if (!value[key] || value[key].trim().length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [key], + message: `${label} is required`, + }); + } + } + }); type AddDestination = z.infer; @@ -113,6 +145,9 @@ export const HandleDestinations = ({ destinationId }: Props) => { name: "additionalFlags", }); + const provider = form.watch("provider"); + const isCustomProvider = provider === CUSTOM_PROVIDER_KEY; + useEffect(() => { if (destination) { form.reset({ @@ -162,14 +197,24 @@ export const HandleDestinations = ({ destinationId }: Props) => { }; const handleTestConnection = async (serverId?: string) => { - const result = await form.trigger([ - "provider", - "accessKeyId", - "secretAccessKey", - "bucket", - "endpoint", - "additionalFlags", - ]); + const fieldsToTrigger: Array< + | "provider" + | "accessKeyId" + | "secretAccessKey" + | "bucket" + | "endpoint" + | "additionalFlags" + > = isCustomProvider + ? ["provider", "endpoint", "additionalFlags"] + : [ + "provider", + "accessKeyId", + "secretAccessKey", + "bucket", + "endpoint", + "additionalFlags", + ]; + const result = await form.trigger(fieldsToTrigger); if (!result) { const errors = form.formState.errors; @@ -196,7 +241,9 @@ export const HandleDestinations = ({ destinationId }: Props) => { const endpoint = form.getValues("endpoint"); const region = form.getValues("region"); - const connectionString = `:s3,provider=${provider},access_key_id=${accessKey},secret_access_key=${secretKey},endpoint=${endpoint}${region ? `,region=${region}` : ""}:${bucket}`; + const connectionString = isCustomProvider + ? `${endpoint}${(bucket || "").replace(/^\/+/, "")}` + : `:s3,provider=${provider},access_key_id=${accessKey},secret_access_key=${secretKey},endpoint=${endpoint}${region ? `,region=${region}` : ""}:${bucket}`; await testConnection({ provider, @@ -312,82 +359,137 @@ export const HandleDestinations = ({ destinationId }: Props) => { }} /> - { - return ( - - Access Key Id - - - - - - ); - }} - /> - ( - -
- Secret Access Key -
- - - - -
- )} - /> - ( - -
- Bucket -
- - - - -
- )} - /> - ( - -
- Region -
- - - - -
- )} - /> - ( - - Endpoint - - - - - - )} - /> + {isCustomProvider ? ( + <> + ( + + Rclone Connection String + + + +

+ Full rclone connection string for any backend (FTP, + SFTP, Google Drive, Dropbox, OneDrive, etc.). See{" "} + + rclone docs + + . Must end with a colon. +

+ +
+ )} + /> + ( + + Path / Folder (Optional) + + + +

+ Optional folder on the remote where backups will be + stored. +

+ +
+ )} + /> + + ) : ( + <> + { + return ( + + Access Key Id + + + + + + ); + }} + /> + ( + +
+ Secret Access Key +
+ + + + +
+ )} + /> + ( + +
+ Bucket +
+ + + + +
+ )} + /> + ( + +
+ Region +
+ + + + +
+ )} + /> + ( + + Endpoint + + + + + + )} + /> + + )}
Additional Flags (Optional) diff --git a/apps/dokploy/server/api/routers/destination.ts b/apps/dokploy/server/api/routers/destination.ts index cf7395a3f7..9267200ae7 100644 --- a/apps/dokploy/server/api/routers/destination.ts +++ b/apps/dokploy/server/api/routers/destination.ts @@ -57,26 +57,38 @@ export const destinationRouter = createTRPCRouter({ additionalFlags, } = input; try { - const rcloneFlags = [ - `--s3-access-key-id="${accessKey}"`, - `--s3-secret-access-key="${secretAccessKey}"`, - `--s3-region="${region}"`, - `--s3-endpoint="${endpoint}"`, - "--s3-no-check-bucket", - "--s3-force-path-style", - "--retries 1", - "--low-level-retries 1", - "--timeout 10s", - "--contimeout 5s", - ]; - if (provider) { + const isCustom = provider === "Custom"; + const rcloneFlags = isCustom + ? [ + "--retries 1", + "--low-level-retries 1", + "--timeout 10s", + "--contimeout 5s", + ] + : [ + `--s3-access-key-id="${accessKey}"`, + `--s3-secret-access-key="${secretAccessKey}"`, + `--s3-region="${region}"`, + `--s3-endpoint="${endpoint}"`, + "--s3-no-check-bucket", + "--s3-force-path-style", + "--retries 1", + "--low-level-retries 1", + "--timeout 10s", + "--contimeout 5s", + ]; + if (!isCustom && provider) { rcloneFlags.unshift(`--s3-provider="${provider}"`); } if (additionalFlags?.length) { rcloneFlags.push(...additionalFlags); } - const rcloneDestination = `:s3:${bucket}`; - const rcloneCommand = `rclone ls ${rcloneFlags.join(" ")} "${rcloneDestination}"`; + const rcloneDestination = isCustom + ? `${endpoint || ""}${(bucket || "").replace(/^\/+/, "")}` + : `:s3:${bucket}`; + const rcloneCommand = isCustom + ? `rclone lsd ${rcloneFlags.join(" ")} "${rcloneDestination}"` + : `rclone ls ${rcloneFlags.join(" ")} "${rcloneDestination}"`; if (IS_CLOUD && !input.serverId) { throw new TRPCError({ diff --git a/packages/server/src/db/schema/destination.ts b/packages/server/src/db/schema/destination.ts index c479679fe3..b67ba642ab 100644 --- a/packages/server/src/db/schema/destination.ts +++ b/packages/server/src/db/schema/destination.ts @@ -54,6 +54,50 @@ const createSchema = createInsertSchema(destinations, { .default([]), }); +// In Custom mode the user supplies a raw rclone connection string in the +// `endpoint` field (e.g. `:sftp,host=foo,user=bar,pass=baz:`) and only the +// endpoint is required — the S3-specific fields (accessKey, secretAccessKey, +// region, bucket) are unused and may be empty. +type DestinationRefineInput = { + provider?: string | null; + endpoint?: string; + accessKey?: string; + secretAccessKey?: string; + bucket?: string; +}; + +const requireFieldsForProvider = ( + value: DestinationRefineInput, + ctx: z.RefinementCtx, +) => { + if (value.provider === "Custom") { + if (!value.endpoint || value.endpoint.trim().length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["endpoint"], + message: + "Connection string is required (e.g. :sftp,host=foo,user=bar:)", + }); + } + return; + } + for (const field of [ + "accessKey", + "secretAccessKey", + "bucket", + "endpoint", + ] as const) { + const fieldValue = value[field]; + if (!fieldValue || fieldValue.trim().length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [field], + message: `${field} is required`, + }); + } + } +}; + export const apiCreateDestination = createSchema .pick({ name: true, @@ -68,7 +112,8 @@ export const apiCreateDestination = createSchema .required() .extend({ serverId: z.string().optional(), - }); + }) + .superRefine(requireFieldsForProvider); export const apiFindOneDestination = z.object({ destinationId: z.string().min(1), @@ -95,4 +140,5 @@ export const apiUpdateDestination = createSchema .required() .extend({ serverId: z.string().optional(), - }); + }) + .superRefine(requireFieldsForProvider); diff --git a/packages/server/src/utils/backups/compose.ts b/packages/server/src/utils/backups/compose.ts index 6640590b03..7451dbf63f 100644 --- a/packages/server/src/utils/backups/compose.ts +++ b/packages/server/src/utils/backups/compose.ts @@ -11,6 +11,7 @@ import { execAsync, execAsyncRemote } from "../process/execAsync"; import { getBackupCommand, getBackupTimestamp, + getRcloneBucketPath, getS3Credentials, normalizeS3Path, } from "./utils"; @@ -35,7 +36,7 @@ export const runComposeBackup = async ( try { const rcloneFlags = getS3Credentials(destination); - const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`; + const rcloneDestination = `${getRcloneBucketPath(destination)}/${bucketDestination}`; const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`; const backupCommand = getBackupCommand( diff --git a/packages/server/src/utils/backups/index.ts b/packages/server/src/utils/backups/index.ts index 876579cb12..abc3f53e69 100644 --- a/packages/server/src/utils/backups/index.ts +++ b/packages/server/src/utils/backups/index.ts @@ -10,7 +10,12 @@ import { startLogCleanup } from "../access-log/handler"; import { cleanupAll } from "../docker/utils"; import { sendDockerCleanupNotifications } from "../notifications/docker-cleanup"; import { execAsync, execAsyncRemote } from "../process/execAsync"; -import { getS3Credentials, normalizeS3Path, scheduleBackup } from "./utils"; +import { + getRcloneBucketPath, + getS3Credentials, + normalizeS3Path, + scheduleBackup, +} from "./utils"; export const initCronJobs = async () => { console.log("Setting up cron jobs...."); @@ -133,7 +138,7 @@ export const keepLatestNBackups = async ( try { const rcloneFlags = getS3Credentials(backup.destination); const appName = getServiceAppName(backup); - const backupFilesPath = `:s3:${backup.destination.bucket}/${appName}/${normalizeS3Path(backup.prefix)}`; + const backupFilesPath = `${getRcloneBucketPath(backup.destination)}/${appName}/${normalizeS3Path(backup.prefix)}`; // --include "*.bson.gz" or "*.sql.gz" or "*.zip" ensures nothing else other than the dokploy backup files are touched by rclone const rcloneList = `rclone lsf ${rcloneFlags.join(" ")} --include "*${backup.databaseType === "web-server" ? ".zip" : ".{sql.gz,bson.gz}"}" ${backupFilesPath}`; diff --git a/packages/server/src/utils/backups/libsql.ts b/packages/server/src/utils/backups/libsql.ts index a994db8bd6..e8770d0529 100644 --- a/packages/server/src/utils/backups/libsql.ts +++ b/packages/server/src/utils/backups/libsql.ts @@ -11,6 +11,7 @@ import { execAsync, execAsyncRemote } from "../process/execAsync"; import { getBackupCommand, getBackupTimestamp, + getRcloneBucketPath, getS3Credentials, normalizeS3Path, } from "./utils"; @@ -34,7 +35,7 @@ export const runLibsqlBackup = async ( const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`; try { const rcloneFlags = getS3Credentials(destination); - const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`; + const rcloneDestination = `${getRcloneBucketPath(destination)}/${bucketDestination}`; const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`; diff --git a/packages/server/src/utils/backups/mariadb.ts b/packages/server/src/utils/backups/mariadb.ts index dea22ff189..3daafc885f 100644 --- a/packages/server/src/utils/backups/mariadb.ts +++ b/packages/server/src/utils/backups/mariadb.ts @@ -11,6 +11,7 @@ import { execAsync, execAsyncRemote } from "../process/execAsync"; import { getBackupCommand, getBackupTimestamp, + getRcloneBucketPath, getS3Credentials, normalizeS3Path, } from "./utils"; @@ -33,7 +34,7 @@ export const runMariadbBackup = async ( }); try { const rcloneFlags = getS3Credentials(destination); - const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`; + const rcloneDestination = `${getRcloneBucketPath(destination)}/${bucketDestination}`; const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`; const backupCommand = getBackupCommand( diff --git a/packages/server/src/utils/backups/mongo.ts b/packages/server/src/utils/backups/mongo.ts index cebece14f7..1e0f45eab4 100644 --- a/packages/server/src/utils/backups/mongo.ts +++ b/packages/server/src/utils/backups/mongo.ts @@ -11,6 +11,7 @@ import { execAsync, execAsyncRemote } from "../process/execAsync"; import { getBackupCommand, getBackupTimestamp, + getRcloneBucketPath, getS3Credentials, normalizeS3Path, } from "./utils"; @@ -30,7 +31,7 @@ export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => { }); try { const rcloneFlags = getS3Credentials(destination); - const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`; + const rcloneDestination = `${getRcloneBucketPath(destination)}/${bucketDestination}`; const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`; const backupCommand = getBackupCommand( diff --git a/packages/server/src/utils/backups/mysql.ts b/packages/server/src/utils/backups/mysql.ts index a72f598806..f4e8196c02 100644 --- a/packages/server/src/utils/backups/mysql.ts +++ b/packages/server/src/utils/backups/mysql.ts @@ -11,6 +11,7 @@ import { execAsync, execAsyncRemote } from "../process/execAsync"; import { getBackupCommand, getBackupTimestamp, + getRcloneBucketPath, getS3Credentials, normalizeS3Path, } from "./utils"; @@ -31,7 +32,7 @@ export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => { try { const rcloneFlags = getS3Credentials(destination); - const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`; + const rcloneDestination = `${getRcloneBucketPath(destination)}/${bucketDestination}`; const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`; diff --git a/packages/server/src/utils/backups/postgres.ts b/packages/server/src/utils/backups/postgres.ts index 30a88db2b3..11cf2935e9 100644 --- a/packages/server/src/utils/backups/postgres.ts +++ b/packages/server/src/utils/backups/postgres.ts @@ -11,6 +11,7 @@ import { execAsync, execAsyncRemote } from "../process/execAsync"; import { getBackupCommand, getBackupTimestamp, + getRcloneBucketPath, getS3Credentials, normalizeS3Path, } from "./utils"; @@ -34,7 +35,7 @@ export const runPostgresBackup = async ( const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`; try { const rcloneFlags = getS3Credentials(destination); - const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`; + const rcloneDestination = `${getRcloneBucketPath(destination)}/${bucketDestination}`; const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`; diff --git a/packages/server/src/utils/backups/utils.ts b/packages/server/src/utils/backups/utils.ts index 365ebff415..360227fd21 100644 --- a/packages/server/src/utils/backups/utils.ts +++ b/packages/server/src/utils/backups/utils.ts @@ -66,7 +66,19 @@ export const normalizeS3Path = (prefix: string) => { return normalizedPrefix ? `${normalizedPrefix}/` : ""; }; +export const isCustomProvider = (destination: Pick) => + destination.provider === "Custom"; + export const getS3Credentials = (destination: Destination) => { + // In Custom mode the user supplies a full rclone connection string in the + // `endpoint` field (e.g. `:sftp,host=foo,user=bar:`), so the S3 backend + // flags are not applicable — only forward the additional flags. + if (isCustomProvider(destination)) { + return destination.additionalFlags?.length + ? [...destination.additionalFlags] + : []; + } + const { accessKey, secretAccessKey, region, endpoint, provider } = destination; const rcloneFlags = [ @@ -89,6 +101,22 @@ export const getS3Credentials = (destination: Destination) => { return rcloneFlags; }; +// Returns the rclone path prefix up to (but not including) the trailing slash +// for the destination. For S3 it produces `:s3:my-bucket`. For the Custom +// provider it concatenates the user-supplied connection string (stored in +// `endpoint`, e.g. `:sftp,host=foo,user=bar:`) with the optional `bucket` +// field, which is treated as a path/folder prefix on the remote. +export const getRcloneBucketPath = (destination: Destination) => { + if (isCustomProvider(destination)) { + const remote = destination.endpoint || ""; + const bucket = destination.bucket + ? destination.bucket.replace(/^\/+/, "") + : ""; + return `${remote}${bucket}`; + } + return `:s3:${destination.bucket}`; +}; + export const getPostgresBackupCommand = ( database: string, databaseUser: string, diff --git a/packages/server/src/utils/backups/web-server.ts b/packages/server/src/utils/backups/web-server.ts index 712cc08090..3713f15e39 100644 --- a/packages/server/src/utils/backups/web-server.ts +++ b/packages/server/src/utils/backups/web-server.ts @@ -11,7 +11,12 @@ import { import { findDestinationById } from "@dokploy/server/services/destination"; import { sendDokployBackupNotifications } from "../notifications/dokploy-backup"; import { execAsync } from "../process/execAsync"; -import { getBackupTimestamp, getS3Credentials, normalizeS3Path } from "./utils"; +import { + getBackupTimestamp, + getRcloneBucketPath, + getS3Credentials, + normalizeS3Path, +} from "./utils"; function formatBytes(bytes?: number) { if (bytes === undefined) return "Unknown size"; @@ -41,7 +46,7 @@ export const runWebServerBackup = async (backup: BackupSchedule) => { const { BASE_PATH } = paths(); const tempDir = await mkdtemp(join(tmpdir(), "dokploy-backup-")); const backupFileName = `webserver-backup-${timestamp}.zip`; - const s3Path = `:s3:${destination.bucket}/${backup.appName}/${normalizeS3Path(backup.prefix)}${backupFileName}`; + const s3Path = `${getRcloneBucketPath(destination)}/${backup.appName}/${normalizeS3Path(backup.prefix)}${backupFileName}`; try { await execAsync(`mkdir -p ${tempDir}/filesystem`); diff --git a/packages/server/src/utils/restore/compose.ts b/packages/server/src/utils/restore/compose.ts index 20cac27fb4..2d4f27ed92 100644 --- a/packages/server/src/utils/restore/compose.ts +++ b/packages/server/src/utils/restore/compose.ts @@ -2,7 +2,7 @@ import type { apiRestoreBackup } from "@dokploy/server/db/schema"; import type { Compose } from "@dokploy/server/services/compose"; import type { Destination } from "@dokploy/server/services/destination"; import type { z } from "zod"; -import { getS3Credentials } from "../backups/utils"; +import { getRcloneBucketPath, getS3Credentials } from "../backups/utils"; import { execAsync, execAsyncRemote } from "../process/execAsync"; import { getRestoreCommand } from "./utils"; @@ -24,7 +24,7 @@ export const restoreComposeBackup = async ( const { serverId, appName, composeType } = compose; const rcloneFlags = getS3Credentials(destination); - const bucketPath = `:s3:${destination.bucket}`; + const bucketPath = getRcloneBucketPath(destination); const backupPath = `${bucketPath}/${backupInput.backupFile}`; let rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`; diff --git a/packages/server/src/utils/restore/libsql.ts b/packages/server/src/utils/restore/libsql.ts index e984826f84..4617db36d6 100644 --- a/packages/server/src/utils/restore/libsql.ts +++ b/packages/server/src/utils/restore/libsql.ts @@ -2,7 +2,11 @@ import type { apiRestoreBackup } from "@dokploy/server/db/schema"; import type { Destination } from "@dokploy/server/services/destination"; import type { Libsql } from "@dokploy/server/services/libsql"; import type { z } from "zod"; -import { getS3Credentials, getServiceContainerCommand } from "../backups/utils"; +import { + getRcloneBucketPath, + getS3Credentials, + getServiceContainerCommand, +} from "../backups/utils"; import { execAsync, execAsyncRemote } from "../process/execAsync"; export const restoreLibsqlBackup = async ( @@ -15,7 +19,7 @@ export const restoreLibsqlBackup = async ( const { appName, serverId } = libsql; const rcloneFlags = getS3Credentials(destination); - const bucketPath = `:s3:${destination.bucket}`; + const bucketPath = getRcloneBucketPath(destination); const backupPath = `${bucketPath}/${backupInput.backupFile}`; diff --git a/packages/server/src/utils/restore/mariadb.ts b/packages/server/src/utils/restore/mariadb.ts index ffbceba765..62a14bc8e4 100644 --- a/packages/server/src/utils/restore/mariadb.ts +++ b/packages/server/src/utils/restore/mariadb.ts @@ -2,7 +2,7 @@ import type { apiRestoreBackup } from "@dokploy/server/db/schema"; import type { Destination } from "@dokploy/server/services/destination"; import type { Mariadb } from "@dokploy/server/services/mariadb"; import type { z } from "zod"; -import { getS3Credentials } from "../backups/utils"; +import { getRcloneBucketPath, getS3Credentials } from "../backups/utils"; import { execAsync, execAsyncRemote } from "../process/execAsync"; import { getRestoreCommand } from "./utils"; @@ -16,7 +16,7 @@ export const restoreMariadbBackup = async ( const { appName, serverId, databaseUser, databasePassword } = mariadb; const rcloneFlags = getS3Credentials(destination); - const bucketPath = `:s3:${destination.bucket}`; + const bucketPath = getRcloneBucketPath(destination); const backupPath = `${bucketPath}/${backupInput.backupFile}`; const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`; diff --git a/packages/server/src/utils/restore/mongo.ts b/packages/server/src/utils/restore/mongo.ts index 4329a49857..2fbce2b457 100644 --- a/packages/server/src/utils/restore/mongo.ts +++ b/packages/server/src/utils/restore/mongo.ts @@ -2,7 +2,7 @@ import type { apiRestoreBackup } from "@dokploy/server/db/schema"; import type { Destination } from "@dokploy/server/services/destination"; import type { Mongo } from "@dokploy/server/services/mongo"; import type { z } from "zod"; -import { getS3Credentials } from "../backups/utils"; +import { getRcloneBucketPath, getS3Credentials } from "../backups/utils"; import { execAsync, execAsyncRemote } from "../process/execAsync"; import { getRestoreCommand } from "./utils"; @@ -16,7 +16,7 @@ export const restoreMongoBackup = async ( const { appName, databasePassword, databaseUser, serverId } = mongo; const rcloneFlags = getS3Credentials(destination); - const bucketPath = `:s3:${destination.bucket}`; + const bucketPath = getRcloneBucketPath(destination); const backupPath = `${bucketPath}/${backupInput.backupFile}`; const rcloneCommand = `rclone copy ${rcloneFlags.join(" ")} "${backupPath}"`; diff --git a/packages/server/src/utils/restore/mysql.ts b/packages/server/src/utils/restore/mysql.ts index f5187242cf..aba29a55a8 100644 --- a/packages/server/src/utils/restore/mysql.ts +++ b/packages/server/src/utils/restore/mysql.ts @@ -2,7 +2,7 @@ import type { apiRestoreBackup } from "@dokploy/server/db/schema"; import type { Destination } from "@dokploy/server/services/destination"; import type { MySql } from "@dokploy/server/services/mysql"; import type { z } from "zod"; -import { getS3Credentials } from "../backups/utils"; +import { getRcloneBucketPath, getS3Credentials } from "../backups/utils"; import { execAsync, execAsyncRemote } from "../process/execAsync"; import { getRestoreCommand } from "./utils"; @@ -16,7 +16,7 @@ export const restoreMySqlBackup = async ( const { appName, databaseRootPassword, serverId } = mysql; const rcloneFlags = getS3Credentials(destination); - const bucketPath = `:s3:${destination.bucket}`; + const bucketPath = getRcloneBucketPath(destination); const backupPath = `${bucketPath}/${backupInput.backupFile}`; const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`; diff --git a/packages/server/src/utils/restore/postgres.ts b/packages/server/src/utils/restore/postgres.ts index 19f32989f0..8df44da2b2 100644 --- a/packages/server/src/utils/restore/postgres.ts +++ b/packages/server/src/utils/restore/postgres.ts @@ -2,7 +2,7 @@ import type { apiRestoreBackup } from "@dokploy/server/db/schema"; import type { Destination } from "@dokploy/server/services/destination"; import type { Postgres } from "@dokploy/server/services/postgres"; import type { z } from "zod"; -import { getS3Credentials } from "../backups/utils"; +import { getRcloneBucketPath, getS3Credentials } from "../backups/utils"; import { execAsync, execAsyncRemote } from "../process/execAsync"; import { getRestoreCommand } from "./utils"; @@ -16,7 +16,7 @@ export const restorePostgresBackup = async ( const { appName, databaseUser, serverId } = postgres; const rcloneFlags = getS3Credentials(destination); - const bucketPath = `:s3:${destination.bucket}`; + const bucketPath = getRcloneBucketPath(destination); const backupPath = `${bucketPath}/${backupInput.backupFile}`; diff --git a/packages/server/src/utils/restore/web-server.ts b/packages/server/src/utils/restore/web-server.ts index 683a1898ae..6ca6a1f2b6 100644 --- a/packages/server/src/utils/restore/web-server.ts +++ b/packages/server/src/utils/restore/web-server.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { IS_CLOUD, paths } from "@dokploy/server/constants"; import type { Destination } from "@dokploy/server/services/destination"; -import { getS3Credentials } from "../backups/utils"; +import { getRcloneBucketPath, getS3Credentials } from "../backups/utils"; import { execAsync } from "../process/execAsync"; export const restoreWebServerBackup = async ( @@ -16,7 +16,7 @@ export const restoreWebServerBackup = async ( } try { const rcloneFlags = getS3Credentials(destination); - const bucketPath = `:s3:${destination.bucket}`; + const bucketPath = getRcloneBucketPath(destination); const backupPath = `${bucketPath}/${backupFile}`; const { BASE_PATH } = paths(); diff --git a/packages/server/src/utils/volume-backups/backup.ts b/packages/server/src/utils/volume-backups/backup.ts index 1d795a14a0..15a67a1328 100644 --- a/packages/server/src/utils/volume-backups/backup.ts +++ b/packages/server/src/utils/volume-backups/backup.ts @@ -4,6 +4,7 @@ import { findComposeById } from "@dokploy/server/services/compose"; import type { findVolumeBackupById } from "@dokploy/server/services/volume-backups"; import { getBackupTimestamp, + getRcloneBucketPath, getS3Credentials, normalizeS3Path, } from "../backups/utils"; @@ -39,7 +40,7 @@ export const backupVolume = async ( const backupFileName = `${volumeName}-${getBackupTimestamp()}.tar`; const bucketDestination = `${s3AppName}/${normalizeS3Path(prefix || "")}${backupFileName}`; const rcloneFlags = getS3Credentials(volumeBackup.destination); - const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`; + const rcloneDestination = `${getRcloneBucketPath(destination)}/${bucketDestination}`; const volumeBackupPath = path.join(VOLUME_BACKUPS_PATH, volumeBackup.appName); const rcloneCommand = `rclone copyto ${rcloneFlags.join(" ")} "${volumeBackupPath}/${backupFileName}" "${rcloneDestination}"`; diff --git a/packages/server/src/utils/volume-backups/restore.ts b/packages/server/src/utils/volume-backups/restore.ts index 6f6068cafc..4759e87171 100644 --- a/packages/server/src/utils/volume-backups/restore.ts +++ b/packages/server/src/utils/volume-backups/restore.ts @@ -3,6 +3,7 @@ import { findApplicationById, findComposeById, findDestinationById, + getRcloneBucketPath, getS3Credentials, paths, } from "../.."; @@ -19,7 +20,7 @@ export const restoreVolume = async ( const { VOLUME_BACKUPS_PATH } = paths(!!serverId); const volumeBackupPath = path.join(VOLUME_BACKUPS_PATH, volumeName); const rcloneFlags = getS3Credentials(destination); - const bucketPath = `:s3:${destination.bucket}`; + const bucketPath = getRcloneBucketPath(destination); const backupPath = `${bucketPath}/${backupFileName}`; // Command to download backup file from S3 diff --git a/packages/server/src/utils/volume-backups/utils.ts b/packages/server/src/utils/volume-backups/utils.ts index a1eb0a8f1e..ce4d32f458 100644 --- a/packages/server/src/utils/volume-backups/utils.ts +++ b/packages/server/src/utils/volume-backups/utils.ts @@ -10,7 +10,11 @@ import { execAsyncRemote, } from "@dokploy/server/utils/process/execAsync"; import { scheduledJobs, scheduleJob } from "node-schedule"; -import { getS3Credentials, normalizeS3Path } from "../backups/utils"; +import { + getRcloneBucketPath, + getS3Credentials, + normalizeS3Path, +} from "../backups/utils"; import { sendVolumeBackupNotifications } from "../notifications/volume-backup"; import { backupVolume, getVolumeServiceAppName } from "./backup"; @@ -84,7 +88,7 @@ const cleanupOldVolumeBackups = async ( try { const rcloneFlags = getS3Credentials(destination); const s3AppName = getVolumeServiceAppName(volumeBackup); - const backupFilesPath = `:s3:${destination.bucket}/${s3AppName}/${normalizeS3Path(prefix || "")}`; + const backupFilesPath = `${getRcloneBucketPath(destination)}/${s3AppName}/${normalizeS3Path(prefix || "")}`; const listCommand = `rclone lsf ${rcloneFlags.join(" ")} --include \"${volumeName}-*.tar\" ${backupFilesPath}`; const sortAndPick = `sort -r | tail -n +$((${keepLatestCount}+1)) | xargs -I{}`; const deleteCommand = `rclone delete ${rcloneFlags.join(" ")} ${backupFilesPath}{}`;