From 5055994bd30fcae43509afc9463912d24792233c Mon Sep 17 00:00:00 2001 From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com> Date: Mon, 28 Apr 2025 02:17:42 -0600 Subject: [PATCH] Enhance RestoreBackup component to support compose backups by adding a database type selection and metadata handling. Update related API routes and schemas to accommodate new backup types, ensuring flexibility for various database configurations. Modify UI components to allow dynamic input for service names and database credentials based on the selected database type. --- .../database/backups/restore-backup.tsx | 378 +++++++++++++++++- .../database/backups/show-backups.tsx | 1 + .../database/backups/update-backup.tsx | 1 - apps/dokploy/server/api/routers/backup.ts | 143 ++++--- apps/dokploy/server/api/routers/compose.ts | 4 +- packages/server/src/db/schema/backups.ts | 2 +- packages/server/src/utils/backups/compose.ts | 25 +- packages/server/src/utils/builders/compose.ts | 6 +- packages/server/src/utils/docker/domain.ts | 57 +-- packages/server/src/utils/restore/compose.ts | 99 +++++ packages/server/src/utils/restore/index.ts | 1 + 11 files changed, 585 insertions(+), 132 deletions(-) create mode 100644 packages/server/src/utils/restore/compose.ts diff --git a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx index 243201e16..d7323e5b8 100644 --- a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx +++ b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx @@ -32,25 +32,76 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; import { zodResolver } from "@hookform/resolvers/zod"; import copy from "copy-to-clipboard"; import { debounce } from "lodash"; -import { CheckIcon, ChevronsUpDown, Copy, RotateCcw } from "lucide-react"; +import { + CheckIcon, + ChevronsUpDown, + Copy, + RotateCcw, + RefreshCw, + DatabaseZap, +} from "lucide-react"; import { useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; import type { ServiceType } from "../../application/advanced/show-resources"; import { type LogLine, parseLogs } from "../../docker/logs/utils"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; interface Props { id: string; databaseType: Exclude | "web-server"; serverId?: string | null; + backupType?: "database" | "compose"; } +const getMetadataSchema = ( + backupType: "database" | "compose", + databaseType: string, +) => { + if (backupType !== "compose") return z.object({}).optional(); + + const schemas = { + postgres: z.object({ + databaseUser: z.string().min(1, "Database user is required"), + }), + mariadb: z.object({ + databaseUser: z.string().min(1, "Database user is required"), + databasePassword: z.string().min(1, "Database password is required"), + }), + mongo: z.object({ + databaseUser: z.string().min(1, "Database user is required"), + databasePassword: z.string().min(1, "Database password is required"), + }), + mysql: z.object({ + databaseRootPassword: z.string().min(1, "Root password is required"), + }), + "web-server": z.object({}), + }; + + return z.object({ + [databaseType]: schemas[databaseType as keyof typeof schemas], + serviceName: z.string().min(1, "Service name is required"), + }); +}; + const RestoreBackupSchema = z.object({ destinationId: z .string({ @@ -73,10 +124,16 @@ const RestoreBackupSchema = z.object({ .min(1, { message: "Database name is required", }), + databaseType: z + .string({ + required_error: "Please select a database type", + }) + .min(1, { + message: "Database type is required", + }), + metadata: z.object({}).optional(), }); -type RestoreBackup = z.infer; - const formatBytes = (bytes: number): string => { if (bytes === 0) return "0 Bytes"; const k = 1024; @@ -85,24 +142,41 @@ const formatBytes = (bytes: number): string => { return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`; }; -export const RestoreBackup = ({ id, databaseType, serverId }: Props) => { +export const RestoreBackup = ({ + id, + databaseType, + serverId, + backupType = "database", +}: Props) => { const [isOpen, setIsOpen] = useState(false); const [search, setSearch] = useState(""); const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); + const [selectedDatabaseType, setSelectedDatabaseType] = useState( + backupType === "compose" ? "" : databaseType, + ); const { data: destinations = [] } = api.destination.all.useQuery(); - const form = useForm({ + const schema = RestoreBackupSchema.extend({ + metadata: getMetadataSchema(backupType, selectedDatabaseType), + }); + + const form = useForm>({ defaultValues: { destinationId: "", backupFile: "", databaseName: databaseType === "web-server" ? "dokploy" : "", + databaseType: backupType === "compose" ? "" : databaseType, + metadata: {}, }, - resolver: zodResolver(RestoreBackupSchema), + resolver: zodResolver(schema), }); const destionationId = form.watch("destinationId"); + const metadata = form.watch("metadata"); + // console.log({ metadata }); + const debouncedSetSearch = debounce((value: string) => { setDebouncedSearchTerm(value); }, 350); @@ -127,16 +201,15 @@ export const RestoreBackup = ({ id, databaseType, serverId }: Props) => { const [filteredLogs, setFilteredLogs] = useState([]); const [isDeploying, setIsDeploying] = useState(false); - // const { mutateAsync: restore, isLoading: isRestoring } = - // api.backup.restoreBackup.useMutation(); - api.backup.restoreBackupWithLogs.useSubscription( { databaseId: id, - databaseType, + databaseType: form.watch("databaseType"), databaseName: form.watch("databaseName"), backupFile: form.watch("backupFile"), destinationId: form.watch("destinationId"), + backupType: backupType, + metadata: metadata, }, { enabled: isDeploying, @@ -158,10 +231,32 @@ export const RestoreBackup = ({ id, databaseType, serverId }: Props) => { }, ); - const onSubmit = async (_data: RestoreBackup) => { + const onSubmit = async (data: z.infer) => { + if (backupType === "compose" && !data.databaseType) { + toast.error("Please select a database type"); + return; + } + console.log({ data }); setIsDeploying(true); }; + const [cacheType, setCacheType] = useState<"fetch" | "cache">("cache"); + const { + data: services = [], + isLoading: isLoadingServices, + refetch: refetchServices, + } = api.compose.loadServices.useQuery( + { + composeId: id, + type: cacheType, + }, + { + retry: false, + refetchOnWindowFocus: false, + enabled: backupType === "compose", + }, + ); + return ( @@ -170,7 +265,7 @@ export const RestoreBackup = ({ id, databaseType, serverId }: Props) => { Restore Backup - + @@ -373,25 +468,270 @@ export const RestoreBackup = ({ id, databaseType, serverId }: Props) => { control={form.control} name="databaseName" render={({ field }) => ( - + Database Name - + )} /> + + {backupType === "compose" && ( + <> + ( + + Database Type + + + + )} + /> + + ( + + Service Name +
+ + + + + + + +

+ Fetch: Will clone the repository and load the + services +

+
+
+
+ + + + + + +

+ Cache: If you previously deployed this compose, + it will read the services from the last + deployment/fetch from the repository +

+
+
+
+
+ + +
+ )} + /> + + {selectedDatabaseType === "postgres" && ( + ( + + Database User + + + + + + )} + /> + )} + + {selectedDatabaseType === "mariadb" && ( + <> + ( + + Database User + + + + + + )} + /> + ( + + Database Password + + + + + + )} + /> + + )} + + {selectedDatabaseType === "mongo" && ( + <> + ( + + Database User + + + + + + )} + /> + ( + + Database Password + + + + + + )} + /> + + )} + + {selectedDatabaseType === "mysql" && ( + ( + + Root Password + + + + + + )} + /> + )} + + )} + diff --git a/apps/dokploy/components/dashboard/database/backups/show-backups.tsx b/apps/dokploy/components/dashboard/database/backups/show-backups.tsx index e219c3e30..062e7be7d 100644 --- a/apps/dokploy/components/dashboard/database/backups/show-backups.tsx +++ b/apps/dokploy/components/dashboard/database/backups/show-backups.tsx @@ -110,6 +110,7 @@ export const ShowBackups = ({ diff --git a/apps/dokploy/components/dashboard/database/backups/update-backup.tsx b/apps/dokploy/components/dashboard/database/backups/update-backup.tsx index a57cf01d9..8a12c535c 100644 --- a/apps/dokploy/components/dashboard/database/backups/update-backup.tsx +++ b/apps/dokploy/components/dashboard/database/backups/update-backup.tsx @@ -192,7 +192,6 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => { form.reset( { ...currentValues, - metadata: {}, }, { keepDefaultValues: true }, ); diff --git a/apps/dokploy/server/api/routers/backup.ts b/apps/dokploy/server/api/routers/backup.ts index 50368d164..32d011d67 100644 --- a/apps/dokploy/server/api/routers/backup.ts +++ b/apps/dokploy/server/api/routers/backup.ts @@ -11,6 +11,7 @@ import { createBackup, findBackupById, findComposeByBackupId, + findComposeById, findMariadbByBackupId, findMariadbById, findMongoByBackupId, @@ -42,6 +43,7 @@ import { execAsyncRemote, } from "@dokploy/server/utils/process/execAsync"; import { + restoreComposeBackup, restoreMariadbBackup, restoreMongoBackup, restoreMySqlBackup, @@ -129,6 +131,7 @@ export const backupRouter = createTRPCRouter({ .input(apiUpdateBackup) .mutation(async ({ input }) => { try { + console.log(input); await updateBackupById(input.backupId, input); const backup = await findBackupById(input.backupId); @@ -374,78 +377,96 @@ export const backupRouter = createTRPCRouter({ "mongo", "web-server", ]), + backupType: z.enum(["database", "compose"]), databaseName: z.string().min(1), backupFile: z.string().min(1), destinationId: z.string().min(1), + metadata: z.any(), }), ) .subscription(async ({ input }) => { const destination = await findDestinationById(input.destinationId); - if (input.databaseType === "postgres") { - const postgres = await findPostgresById(input.databaseId); + if (input.backupType === "database") { + if (input.databaseType === "postgres") { + const postgres = await findPostgresById(input.databaseId); - return observable((emit) => { - restorePostgresBackup( - postgres, - destination, - input.databaseName, - input.backupFile, - (log) => { - emit.next(log); - }, - ); - }); - } - if (input.databaseType === "mysql") { - const mysql = await findMySqlById(input.databaseId); - return observable((emit) => { - restoreMySqlBackup( - mysql, - destination, - input.databaseName, - input.backupFile, - (log) => { - emit.next(log); - }, - ); - }); - } - if (input.databaseType === "mariadb") { - const mariadb = await findMariadbById(input.databaseId); - return observable((emit) => { - restoreMariadbBackup( - mariadb, - destination, - input.databaseName, - input.backupFile, - (log) => { - emit.next(log); - }, - ); - }); - } - if (input.databaseType === "mongo") { - const mongo = await findMongoById(input.databaseId); - return observable((emit) => { - restoreMongoBackup( - mongo, - destination, - input.databaseName, - input.backupFile, - (log) => { - emit.next(log); - }, - ); - }); - } - if (input.databaseType === "web-server") { - return observable((emit) => { - restoreWebServerBackup(destination, input.backupFile, (log) => { - emit.next(log); + return observable((emit) => { + restorePostgresBackup( + postgres, + destination, + input.databaseName, + input.backupFile, + (log) => { + emit.next(log); + }, + ); }); + } + if (input.databaseType === "mysql") { + const mysql = await findMySqlById(input.databaseId); + return observable((emit) => { + restoreMySqlBackup( + mysql, + destination, + input.databaseName, + input.backupFile, + (log) => { + emit.next(log); + }, + ); + }); + } + if (input.databaseType === "mariadb") { + const mariadb = await findMariadbById(input.databaseId); + return observable((emit) => { + restoreMariadbBackup( + mariadb, + destination, + input.databaseName, + input.backupFile, + (log) => { + emit.next(log); + }, + ); + }); + } + if (input.databaseType === "mongo") { + const mongo = await findMongoById(input.databaseId); + return observable((emit) => { + restoreMongoBackup( + mongo, + destination, + input.databaseName, + input.backupFile, + (log) => { + emit.next(log); + }, + ); + }); + } + if (input.databaseType === "web-server") { + return observable((emit) => { + restoreWebServerBackup(destination, input.backupFile, (log) => { + emit.next(log); + }); + }); + } + } + if (input.backupType === "compose") { + const compose = await findComposeById(input.databaseId); + return observable((emit) => { + restoreComposeBackup( + compose, + destination, + input.databaseName, + input.backupFile, + input.metadata, + (log) => { + emit.next(log); + }, + ); }); } - return true; }), }); diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index ad9f2086b..c4f9b3177 100644 --- a/apps/dokploy/server/api/routers/compose.ts +++ b/apps/dokploy/server/api/routers/compose.ts @@ -27,6 +27,7 @@ import { createMount, deleteMount, findComposeById, + findDomainsByComposeId, findProjectById, findServerById, findUserById, @@ -267,7 +268,8 @@ export const composeRouter = createTRPCRouter({ message: "You are not authorized to get this compose", }); } - const composeFile = await addDomainToCompose(compose); + const domains = await findDomainsByComposeId(input.composeId); + const composeFile = await addDomainToCompose(compose, domains); return dump(composeFile, { lineWidth: 1000, }); diff --git a/packages/server/src/db/schema/backups.ts b/packages/server/src/db/schema/backups.ts index 6dca5caab..67d8698c7 100644 --- a/packages/server/src/db/schema/backups.ts +++ b/packages/server/src/db/schema/backups.ts @@ -137,7 +137,7 @@ const createSchema = createInsertSchema(backups, { mysqlId: z.string().optional(), mongoId: z.string().optional(), userId: z.string().optional(), - metadata: z.object({}).optional(), + metadata: z.any().optional(), }); export const apiCreateBackup = createSchema.pick({ diff --git a/packages/server/src/utils/backups/compose.ts b/packages/server/src/utils/backups/compose.ts index 06d0465ff..85b9669eb 100644 --- a/packages/server/src/utils/backups/compose.ts +++ b/packages/server/src/utils/backups/compose.ts @@ -21,7 +21,7 @@ export const runComposeBackup = async ( const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`; const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`; - const command = `docker ps --filter "status=running" --filter "label=dokploy.backup.id=${backup.backupId}" --format "{{.ID}}" | head -n 1`; + const command = getFindContainerCommand(compose, backup.serviceName || ""); if (compose.serverId) { const { stdout } = await execAsyncRemote(compose.serverId, command); if (!stdout) { @@ -88,3 +88,26 @@ export const runComposeBackup = async ( throw error; } }; + +export const getFindContainerCommand = ( + compose: Compose, + serviceName: string, +) => { + const { appName, composeType } = compose; + const labels = + composeType === "stack" + ? { + namespace: `label=com.docker.stack.namespace=${appName}`, + service: `label=com.docker.swarm.service.name=${appName}_${serviceName}`, + } + : { + project: `label=com.docker.compose.project=${appName}`, + service: `label=com.docker.compose.service=${serviceName}`, + }; + + const command = `docker ps --filter "status=running" \ + --filter "${Object.values(labels).join('" --filter "')}" \ + --format "{{.ID}}" | head -n 1`; + + return command.trim(); +}; diff --git a/packages/server/src/utils/builders/compose.ts b/packages/server/src/utils/builders/compose.ts index 47d4c71e6..19e7d1529 100644 --- a/packages/server/src/utils/builders/compose.ts +++ b/packages/server/src/utils/builders/compose.ts @@ -22,15 +22,15 @@ import { spawnAsync } from "../process/spawnAsync"; export type ComposeNested = InferResultType< "compose", - { project: true; mounts: true; domains: true; backups: true } + { project: true; mounts: true; domains: true } >; export const buildCompose = async (compose: ComposeNested, logPath: string) => { const writeStream = createWriteStream(logPath, { flags: "a" }); - const { sourceType, appName, mounts, composeType } = compose; + const { sourceType, appName, mounts, composeType, domains } = compose; try { const { COMPOSE_PATH } = paths(); const command = createCommand(compose); - await writeDomainsToCompose(compose); + await writeDomainsToCompose(compose, domains); createEnvFile(compose); if (compose.isolatedDeployment) { diff --git a/packages/server/src/utils/docker/domain.ts b/packages/server/src/utils/docker/domain.ts index 8d3143640..4f0083979 100644 --- a/packages/server/src/utils/docker/domain.ts +++ b/packages/server/src/utils/docker/domain.ts @@ -38,8 +38,6 @@ import type { PropertiesNetworks, } from "./types"; import { encodeBase64 } from "./utils"; -import type { Backup } from "@dokploy/server/services/backup"; -import { createBackupLabels } from "./backup"; export const cloneCompose = async (compose: Compose) => { if (compose.sourceType === "github") { @@ -134,13 +132,13 @@ export const readComposeFile = async (compose: Compose) => { }; export const writeDomainsToCompose = async ( - compose: Compose & { domains: Domain[]; backups: Backup[] }, + compose: Compose, + domains: Domain[], ) => { - const { domains, backups } = compose; - if (!domains.length && !backups.length) { + if (!domains.length) { return; } - const composeConverted = await addDomainToCompose(compose); + const composeConverted = await addDomainToCompose(compose, domains); const path = getComposePath(compose); const composeString = dump(composeConverted, { lineWidth: 1000 }); @@ -152,7 +150,7 @@ export const writeDomainsToCompose = async ( }; export const writeDomainsToComposeRemote = async ( - compose: Compose & { domains: Domain[]; backups: Backup[] }, + compose: Compose, domains: Domain[], logPath: string, ) => { @@ -161,7 +159,7 @@ export const writeDomainsToComposeRemote = async ( } try { - const composeConverted = await addDomainToCompose(compose); + const composeConverted = await addDomainToCompose(compose, domains); const path = getComposePath(compose); if (!composeConverted) { @@ -182,20 +180,22 @@ exit 1; `; } }; +// (node:59875) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 SIGTERM listeners added to [process]. Use emitter.setMaxListeners() to increase limit export const addDomainToCompose = async ( - compose: Compose & { domains: Domain[]; backups: Backup[] }, + compose: Compose, + domains: Domain[], ) => { - const { appName, domains, backups } = compose; + const { appName } = compose; let result: ComposeSpecification | null; if (compose.serverId) { - result = await loadDockerComposeRemote(compose); + result = await loadDockerComposeRemote(compose); // aca hay que ir al servidor e ir a traer el compose file al servidor } else { result = await loadDockerCompose(compose); } - if (!result || (domains.length === 0 && backups.length === 0)) { + if (!result || domains.length === 0) { return null; } @@ -210,7 +210,6 @@ export const addDomainToCompose = async ( result = randomized; } - // Add domains to the compose for (const domain of domains) { const { serviceName, https } = domain; if (!serviceName) { @@ -265,38 +264,6 @@ export const addDomainToCompose = async ( } } - // Add backups to the compose - for (const backup of backups) { - const { backupId, serviceName, enabled } = backup; - - if (!enabled) { - continue; - } - - if (!serviceName) { - throw new Error( - "Service name not found, please check the backups to use a valid service name", - ); - } - - if (!result?.services?.[serviceName]) { - throw new Error(`The service ${serviceName} not found in the compose`); - } - - const backupLabels = createBackupLabels(backupId); - - if (!result.services[serviceName].labels) { - result.services[serviceName].labels = []; - } - - result.services[serviceName].labels = [ - ...(Array.isArray(result.services[serviceName].labels) - ? result.services[serviceName].labels - : []), - ...backupLabels, - ]; - } - // Add dokploy-network to the root of the compose file if (!compose.isolatedDeployment) { result.networks = addDokployNetworkToRoot(result.networks); diff --git a/packages/server/src/utils/restore/compose.ts b/packages/server/src/utils/restore/compose.ts new file mode 100644 index 000000000..f9deb52bd --- /dev/null +++ b/packages/server/src/utils/restore/compose.ts @@ -0,0 +1,99 @@ +import type { Destination } from "@dokploy/server/services/destination"; +import type { Compose } from "@dokploy/server/services/compose"; +import { getS3Credentials } from "../backups/utils"; +import { execAsync, execAsyncRemote } from "../process/execAsync"; +import type { Backup } from "@dokploy/server/services/backup"; +import { getFindContainerCommand } from "../backups/compose"; + +export const restoreComposeBackup = async ( + compose: Compose, + destination: Destination, + database: string, + backupFile: string, + metadata: Backup["metadata"] & { serviceName: string }, + emit: (log: string) => void, +) => { + try { + console.log({ metadata }); + const { serverId } = compose; + + const rcloneFlags = getS3Credentials(destination); + const bucketPath = `:s3:${destination.bucket}`; + const backupPath = `${bucketPath}/${backupFile}`; + + const command = getFindContainerCommand(compose, metadata.serviceName); + + console.log("command", command); + let containerId = ""; + if (serverId) { + const { stdout, stderr } = await execAsyncRemote(serverId, command); + emit(stdout); + emit(stderr); + containerId = stdout.trim(); + } else { + const { stdout, stderr } = await execAsync(command); + console.log("stdout", stdout); + console.log("stderr", stderr); + emit(stdout); + emit(stderr); + containerId = stdout.trim(); + } + let restoreCommand = ""; + + if (metadata.postgres) { + restoreCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip | docker exec -i ${containerId} pg_restore -U ${metadata.postgres.databaseUser} -d ${database} --clean --if-exists`; + } else if (metadata.mariadb) { + restoreCommand = ` + rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip | docker exec -i ${containerId} mariadb -u ${metadata.mariadb.databaseUser} -p${metadata.mariadb.databasePassword} ${database} + `; + } else if (metadata.mysql) { + restoreCommand = ` + rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip | docker exec -i ${containerId} mysql -u root -p${metadata.mysql.databaseRootPassword} ${database} + `; + } else if (metadata.mongo) { + const tempDir = "/tmp/dokploy-restore"; + const fileName = backupFile.split("/").pop() || "backup.dump.gz"; + const decompressedName = fileName.replace(".gz", ""); + restoreCommand = `\ + rm -rf ${tempDir} && \ + mkdir -p ${tempDir} && \ + rclone copy ${rcloneFlags.join(" ")} "${backupPath}" ${tempDir} && \ + cd ${tempDir} && \ + gunzip -f "${fileName}" && \ + docker exec -i ${containerId} mongorestore --username ${metadata.mongo.databaseUser} --password ${metadata.mongo.databasePassword} --authenticationDatabase admin --db ${database} --archive < "${decompressedName}" && \ + rm -rf ${tempDir}`; + } + + emit("Starting restore..."); + emit(`Backup path: ${backupPath}`); + + emit(`Executing command: ${restoreCommand}`); + + if (serverId) { + const { stdout, stderr } = await execAsyncRemote( + serverId, + restoreCommand, + ); + emit(stdout); + emit(stderr); + } else { + const { stdout, stderr } = await execAsync(restoreCommand); + console.log("stdout", stdout); + console.log("stderr", stderr); + emit(stdout); + emit(stderr); + } + + emit("Restore completed successfully!"); + } catch (error) { + console.error(error); + emit( + `Error: ${ + error instanceof Error ? error.message : "Error restoring mongo backup" + }`, + ); + throw new Error( + error instanceof Error ? error.message : "Error restoring mongo backup", + ); + } +}; diff --git a/packages/server/src/utils/restore/index.ts b/packages/server/src/utils/restore/index.ts index 972e1d1d7..615e53d35 100644 --- a/packages/server/src/utils/restore/index.ts +++ b/packages/server/src/utils/restore/index.ts @@ -3,3 +3,4 @@ export { restoreMySqlBackup } from "./mysql"; export { restoreMariadbBackup } from "./mariadb"; export { restoreMongoBackup } from "./mongo"; export { restoreWebServerBackup } from "./web-server"; +export { restoreComposeBackup } from "./compose";