Compare commits

...

4 Commits

Author SHA1 Message Date
Narciso 29498b72fe fix(backups): avoid blocked download tab and fix file list scroll 2026-10-07 17:10:31 -04:00
Narciso cf63d73623 feat(backups): download backup files with presigned urls 2026-10-07 10:09:00 -04:00
Narciso 06a7559e8a Merge remote-tracking branch 'origin/canary' into feat/download-backups 2026-10-07 08:49:34 -04:00
Narciso 79557cb971 feat: allow download backups 2026-09-01 13:38:07 -04:00
13 changed files with 661 additions and 32 deletions
@@ -0,0 +1,271 @@
import { ExecError } from "@dokploy/server/utils/process/execAsync";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
findBackupById: vi.fn(),
findDestinationById: vi.fn(),
findServerById: vi.fn(),
findMemberByUserId: vi.fn(),
checkServicePermissionAndAccess: vi.fn(),
execAsync: vi.fn(),
execAsyncRemote: vi.fn(),
}));
vi.mock("@dokploy/server/services/backup", () => ({
findBackupById: mocks.findBackupById,
}));
vi.mock("@dokploy/server/services/destination", () => ({
findDestinationById: mocks.findDestinationById,
}));
vi.mock("@dokploy/server/services/server", () => ({
findServerById: mocks.findServerById,
}));
vi.mock("@dokploy/server/services/permission", () => ({
findMemberByUserId: mocks.findMemberByUserId,
checkServicePermissionAndAccess: mocks.checkServicePermissionAndAccess,
}));
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => ({
...(await importOriginal<object>()),
execAsync: mocks.execAsync,
execAsyncRemote: mocks.execAsyncRemote,
}));
const ctx = {
user: { id: "user-1" },
session: { activeOrganizationId: "org-1" },
};
const baseBackup = {
backupId: "backup-1",
destinationId: "dest-1",
databaseType: "postgres",
prefix: "db",
appName: "web",
postgresId: "pg-1",
postgres: { appName: "pg-app", serverId: null },
};
const destination = {
destinationId: "dest-1",
organizationId: "org-1",
bucket: "bucket",
accessKey: "ak",
secretAccessKey: "sk",
region: "us-east-1",
endpoint: "http://s3.local",
provider: "Minio",
additionalFlags: [],
};
const loadModule = async (isCloud: boolean) => {
vi.resetModules();
vi.doMock("@dokploy/server/constants", async (importOriginal) => ({
...(await importOriginal<object>()),
IS_CLOUD: isCloud,
}));
return await import("@dokploy/server/utils/backups/files");
};
beforeEach(() => {
vi.clearAllMocks();
mocks.findBackupById.mockResolvedValue(baseBackup);
mocks.findDestinationById.mockResolvedValue(destination);
mocks.findServerById.mockResolvedValue({
serverId: "server-1",
organizationId: "org-1",
});
mocks.findMemberByUserId.mockResolvedValue({ role: "owner" });
mocks.checkServicePermissionAndAccess.mockResolvedValue(undefined);
});
describe("getBackupServerId", () => {
it("returns the serverId of the backed up service", async () => {
const { getBackupServerId } = await loadModule(false);
const backup = baseBackup as Parameters<typeof getBackupServerId>[0];
expect(getBackupServerId(backup)).toBeNull();
for (const type of [
"postgres",
"mysql",
"mariadb",
"mongo",
"libsql",
"compose",
]) {
expect(
getBackupServerId({ ...backup, [type]: { serverId: `srv-${type}` } }),
).toBe(`srv-${type}`);
}
});
});
describe("resolveBackupAccess", () => {
it("rejects backups without a server on cloud", async () => {
const { resolveBackupAccess } = await loadModule(true);
await expect(resolveBackupAccess(ctx, "backup-1")).rejects.toMatchObject({
code: "NOT_FOUND",
message: "Server not found",
});
});
it("rejects a destination from another organization", async () => {
const { resolveBackupAccess } = await loadModule(false);
mocks.findDestinationById.mockResolvedValue({
...destination,
organizationId: "org-2",
});
await expect(resolveBackupAccess(ctx, "backup-1")).rejects.toMatchObject({
code: "UNAUTHORIZED",
});
expect(mocks.checkServicePermissionAndAccess).not.toHaveBeenCalled();
});
it("rejects a server from another organization", async () => {
const { resolveBackupAccess } = await loadModule(false);
mocks.findBackupById.mockResolvedValue({
...baseBackup,
postgres: { appName: "pg-app", serverId: "server-1" },
});
mocks.findServerById.mockResolvedValue({
serverId: "server-1",
organizationId: "org-2",
});
await expect(resolveBackupAccess(ctx, "backup-1")).rejects.toMatchObject({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
});
it("only lets owners and admins reach web-server backups", async () => {
const { resolveBackupAccess } = await loadModule(false);
mocks.findBackupById.mockResolvedValue({
...baseBackup,
databaseType: "web-server",
postgresId: null,
postgres: null,
});
mocks.findMemberByUserId.mockResolvedValue({ role: "member" });
await expect(resolveBackupAccess(ctx, "backup-1")).rejects.toMatchObject({
code: "UNAUTHORIZED",
});
mocks.findMemberByUserId.mockResolvedValue({ role: "admin" });
await expect(resolveBackupAccess(ctx, "backup-1")).resolves.toMatchObject({
serverId: null,
path: ":s3:bucket/web/db/",
});
});
});
describe("listBackupFilesByBackupId", () => {
it("lists on the service server sorted by ModTime", async () => {
const { listBackupFilesByBackupId } = await loadModule(false);
mocks.findBackupById.mockResolvedValue({
...baseBackup,
postgres: { appName: "pg-app", serverId: "server-1" },
});
mocks.execAsyncRemote.mockResolvedValue({
stdout: JSON.stringify([
{ Name: "first.sql.gz", ModTime: "2026-01-01T00:00:00Z" },
{ Name: "third.sql.gz", ModTime: "2026-01-01T04:00:00+03:00" },
{ Name: "second.sql.gz", ModTime: "2026-01-01T00:30:00Z" },
]),
stderr: "",
});
const files = await listBackupFilesByBackupId(ctx, "backup-1");
expect(files.map((file) => file.Name)).toEqual([
"third.sql.gz",
"second.sql.gz",
"first.sql.gz",
]);
expect(mocks.checkServicePermissionAndAccess).toHaveBeenCalledWith(
ctx,
"pg-1",
{ backup: ["read"] },
);
const [serverId, command] = mocks.execAsyncRemote.mock.calls[0] ?? [];
expect(serverId).toBe("server-1");
expect(command.replace(/\\/g, "")).toContain(
"--use-server-modtime --include *.{sql.gz,bson.gz} :s3:bucket/pg-app/db/",
);
expect(mocks.execAsync).not.toHaveBeenCalled();
});
it("returns an empty list when the folder does not exist", async () => {
const { listBackupFilesByBackupId } = await loadModule(false);
mocks.execAsync.mockRejectedValue(
new ExecError("Command execution failed", {
command: "rclone",
stderr: "ERROR : directory not found",
}),
);
await expect(listBackupFilesByBackupId(ctx, "backup-1")).resolves.toEqual(
[],
);
});
});
describe("getBackupDownloadUrl", () => {
it.each(["../x.sql.gz", "a/b.sql.gz", ".hidden.sql.gz", "x.txt"])(
"rejects %s",
async (fileName) => {
const { getBackupDownloadUrl } = await loadModule(false);
await expect(
getBackupDownloadUrl(ctx, "backup-1", fileName),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(mocks.execAsync).not.toHaveBeenCalled();
},
);
it("returns the presigned url from rclone link", async () => {
const { getBackupDownloadUrl } = await loadModule(false);
mocks.execAsync.mockResolvedValue({
stdout: "https://s3.local/bucket/pg-app/db/x.sql.gz?X-Amz-Signature=1\n",
stderr: "",
});
await expect(
getBackupDownloadUrl(ctx, "backup-1", "x.sql.gz"),
).resolves.toEqual({
url: "https://s3.local/bucket/pg-app/db/x.sql.gz?X-Amz-Signature=1",
});
const [command] = mocks.execAsync.mock.calls[0] ?? [];
expect(command).toContain("rclone link --expire 15m");
expect(command.replace(/\\/g, "")).toContain(
":s3:bucket/pg-app/db/x.sql.gz",
);
});
it("maps object not found to NOT_FOUND", async () => {
const { getBackupDownloadUrl } = await loadModule(false);
mocks.execAsync.mockRejectedValue(
new ExecError("Command execution failed", {
command: "rclone",
stderr: "ERROR : object not found",
}),
);
await expect(
getBackupDownloadUrl(ctx, "backup-1", "x.sql.gz"),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("never logs the command on other failures", async () => {
const { getBackupDownloadUrl } = await loadModule(false);
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => {});
mocks.execAsync.mockRejectedValue(
new ExecError(
"Command failed: rclone link --s3-secret-access-key=SUPERSECRET",
{
command: "rclone link --s3-secret-access-key=SUPERSECRET",
stderr: "ERROR : AccessDenied",
},
),
);
await expect(
getBackupDownloadUrl(ctx, "backup-1", "x.sql.gz"),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(JSON.stringify(consoleError.mock.calls)).not.toContain(
"SUPERSECRET",
);
consoleError.mockRestore();
});
});
@@ -0,0 +1,146 @@
import { DatabaseBackup, Download, FolderOpen, Loader2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { formatBytes } from "./restore-backup";
interface Props {
backupId: string;
}
export const ShowBackupFiles = ({ backupId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const {
data: files,
isLoading,
isError,
error,
} = api.backup.listBackupFilesByBackupId.useQuery(
{ backupId },
{ enabled: isOpen },
);
const download = api.backup.getDownloadUrl.useMutation({
onSuccess: ({ url }) => window.location.assign(url),
onError: (error) => toast.error(error.message),
});
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<FolderOpen className="size-4" />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>Backup Files</TooltipContent>
</Tooltip>
</TooltipProvider>
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Backup Files</DialogTitle>
<DialogDescription>
Files stored in the destination for this backup.
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error.message}</AlertBlock>}
{isLoading && (
<div className="flex items-center justify-center py-16">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)}
{!isLoading && !isError && files?.length === 0 && (
<div className="flex flex-col items-center justify-center gap-3 py-16">
<DatabaseBackup className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
No backup files found yet
</span>
</div>
)}
{!isError && !!files?.length && (
<ScrollArea className="h-[60vh]">
<Table>
<TableHeader>
<TableRow>
<TableHead>File</TableHead>
<TableHead>Date</TableHead>
<TableHead>Size</TableHead>
<TableHead className="w-16" />
</TableRow>
</TableHeader>
<TableBody>
{files.map((file) => (
<TableRow key={file.Name}>
<TableCell className="font-medium break-all">
{file.Name}
</TableCell>
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
{file.ModTime
? new Date(file.ModTime).toLocaleString()
: "—"}
</TableCell>
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
{formatBytes(file.Size)}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
className="size-8"
disabled={download.isPending}
onClick={() =>
download.mutate({ backupId, fileName: file.Name })
}
>
{download.isPending &&
download.variables?.fileName === file.Name ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Download className="size-4" />
)}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
)}
</DialogContent>
</Dialog>
);
};
@@ -30,6 +30,7 @@ import type { ServiceType } from "../../application/advanced/show-resources";
import { ShowDeploymentsModal } from "../../application/deployments/show-deployments-modal";
import { HandleBackup } from "./handle-backup";
import { RestoreBackup } from "./restore-backup";
import { ShowBackupFiles } from "./show-backup-files";
interface Props {
id: string;
@@ -325,6 +326,7 @@ export const ShowBackups = ({
</Tooltip>
</TooltipProvider>
<ShowBackupFiles backupId={backup.backupId} />
<HandleBackup
backupType={backup.backupType}
backupId={backup.backupId}
@@ -5,6 +5,7 @@ import type { ColumnDef } from "@tanstack/react-table";
import { format } from "date-fns";
import {
ArrowUpDown,
Download,
FileJson,
LogIn,
LogOut,
@@ -77,6 +78,11 @@ const ACTION_CONFIG: Record<
className:
"bg-slate-500/10 text-slate-600 dark:text-slate-400 border-slate-500/20",
},
download: {
label: "Downloaded",
icon: Download,
className: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
},
};
const RESOURCE_LABELS: Record<string, string> = {
@@ -53,6 +53,7 @@ const ACTION_OPTIONS = [
{ value: "redeploy", label: "Redeployed" },
{ value: "login", label: "Login" },
{ value: "logout", label: "Logout" },
{ value: "download", label: "Downloaded" },
];
const RESOURCE_OPTIONS = [
@@ -83,7 +84,8 @@ type AuditAction =
| "cancel"
| "redeploy"
| "login"
| "logout";
| "logout"
| "download";
type AuditResourceType =
| "project"
| "service"
+24 -12
View File
@@ -15,8 +15,11 @@ import {
findPostgresByBackupId,
findPostgresById,
findServerById,
getBackupDownloadUrl,
IS_CLOUD,
keepLatestNBackups,
listBackupFilesByBackupId,
type RcloneFile,
removeBackupById,
removeScheduleBackup,
runLibsqlBackup,
@@ -67,18 +70,6 @@ import {
} from "@/server/db/schema";
import { removeJob, schedule, updateJob } from "@/server/utils/backup";
interface RcloneFile {
Path: string;
Name: string;
Size: number;
IsDir: boolean;
Tier?: string;
Hashes?: {
MD5?: string;
SHA1?: string;
};
}
export const backupRouter = createTRPCRouter({
create: protectedProcedure
.input(apiCreateBackup)
@@ -563,6 +554,27 @@ export const backupRouter = createTRPCRouter({
}
}),
listBackupFilesByBackupId: withPermission("backup", "read")
.input(z.object({ backupId: z.string() }))
.query(({ input, ctx }) => listBackupFilesByBackupId(ctx, input.backupId)),
getDownloadUrl: withPermission("backup", "read")
.input(z.object({ backupId: z.string(), fileName: z.string().min(1) }))
.mutation(async ({ input, ctx }) => {
const result = await getBackupDownloadUrl(
ctx,
input.backupId,
input.fileName,
);
await audit(ctx, {
action: "download",
resourceType: "backup",
resourceId: input.backupId,
metadata: { file: input.fileName },
});
return result;
}),
restoreBackupWithLogs: protectedProcedure
.meta({
openapi: {
@@ -31,6 +31,7 @@ export const auditLogRouter = createTRPCRouter({
"redeploy",
"login",
"logout",
"download",
])
.optional(),
resourceType: z
@@ -54,6 +54,7 @@ export type AuditAction =
| "login"
| "logout"
| "restore"
| "download"
| "run"
| "start"
| "stop"
+1
View File
@@ -79,6 +79,7 @@ export {
export * from "./utils/access-log/types";
export * from "./utils/access-log/utils";
export * from "./utils/backups/compose";
export * from "./utils/backups/files";
export * from "./utils/backups/index";
export * from "./utils/backups/libsql";
export * from "./utils/backups/mariadb";
+179
View File
@@ -0,0 +1,179 @@
import { IS_CLOUD } from "@dokploy/server/constants";
import {
type BackupSchedule,
findBackupById,
} from "@dokploy/server/services/backup";
import { findDestinationById } from "@dokploy/server/services/destination";
import {
checkServicePermissionAndAccess,
findMemberByUserId,
type PermissionCtx,
} from "@dokploy/server/services/permission";
import { findServerById } from "@dokploy/server/services/server";
import { TRPCError } from "@trpc/server";
import { quote } from "shell-quote";
import { ExecError, execAsync, execAsyncRemote } from "../process/execAsync";
import { redactRcloneCredentials } from "./redact";
import { getBackupFolder, getS3Credentials } from "./utils";
export interface RcloneFile {
Path: string;
Name: string;
Size: number;
IsDir: boolean;
ModTime?: string;
Tier?: string;
Hashes?: {
MD5?: string;
SHA1?: string;
};
}
const BACKUP_FILE_NAME = /^[^/.][^/]*\.(sql\.gz|bson\.gz|zip)$/;
export const getBackupServerId = (backup: BackupSchedule) =>
backup.postgres?.serverId ??
backup.mysql?.serverId ??
backup.mariadb?.serverId ??
backup.mongo?.serverId ??
backup.libsql?.serverId ??
backup.compose?.serverId ??
null;
export const resolveBackupAccess = async (
ctx: PermissionCtx,
backupId: string,
) => {
const backup = await findBackupById(backupId);
const destination = await findDestinationById(backup.destinationId);
if (destination.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this destination.",
});
}
const serviceId =
backup.postgresId ||
backup.mysqlId ||
backup.mariadbId ||
backup.mongoId ||
backup.libsqlId ||
backup.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, { backup: ["read"] });
} else {
const member = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (member.role !== "owner" && member.role !== "admin") {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this backup.",
});
}
}
const serverId = getBackupServerId(backup);
if (serverId) {
const server = await findServerById(serverId);
if (server.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
} else if (IS_CLOUD) {
throw new TRPCError({ code: "NOT_FOUND", message: "Server not found" });
}
return {
backup,
destination,
serverId,
path: `:s3:${destination.bucket}/${getBackupFolder(backup)}`,
};
};
const runRclone = (command: string, serverId: string | null) =>
serverId
? execAsyncRemote(serverId, command)
: execAsync(command, { maxBuffer: 32 * 1024 * 1024 });
const getErrorDetail = (error: unknown) =>
error instanceof ExecError
? error.stderr || (error.serverId ? error.message : "")
: String(error);
const modTime = (file: RcloneFile) => Date.parse(file.ModTime ?? "") || 0;
export const listBackupFilesByBackupId = async (
ctx: PermissionCtx,
backupId: string,
): Promise<RcloneFile[]> => {
const { backup, destination, serverId, path } = await resolveBackupAccess(
ctx,
backupId,
);
const include =
backup.databaseType === "web-server" ? "*.zip" : "*.{sql.gz,bson.gz}";
const command = `rclone lsjson ${getS3Credentials(destination).join(" ")} --files-only --no-mimetype --use-server-modtime --include ${quote([include])} ${quote([path])}`;
try {
const { stdout } = await runRclone(command, serverId);
const files = JSON.parse(stdout) as RcloneFile[];
return files.sort((a, b) => modTime(b) - modTime(a));
} catch (error) {
const detail = getErrorDetail(error);
if (/directory not found|object not found/i.test(detail)) {
return [];
}
console.error("rclone lsjson failed:", redactRcloneCredentials(detail));
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error listing backup files",
});
}
};
export const getBackupDownloadUrl = async (
ctx: PermissionCtx,
backupId: string,
fileName: string,
) => {
if (!BACKUP_FILE_NAME.test(fileName)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid backup file name",
});
}
const { destination, serverId, path } = await resolveBackupAccess(
ctx,
backupId,
);
const command = `rclone link --expire 15m ${getS3Credentials(destination).join(" ")} ${quote([`${path}${fileName}`])}`;
try {
const { stdout } = await runRclone(command, serverId);
const url = stdout.trim().split("\n").pop();
if (!url) {
throw new Error("rclone link returned no URL");
}
return { url };
} catch (error) {
const detail = getErrorDetail(error);
if (/object not found/i.test(detail)) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Backup file not found",
});
}
console.error("rclone link failed:", redactRcloneCredentials(detail));
throw new TRPCError({
code: "BAD_REQUEST",
message: "Could not generate the download link",
});
}
};
+2 -18
View File
@@ -12,7 +12,7 @@ import { cleanupAll } from "../docker/utils";
import { sendDockerCleanupNotifications } from "../notifications/docker-cleanup";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import { redactRcloneCredentials } from "./redact";
import { getS3Credentials, normalizeS3Path, scheduleBackup } from "./utils";
import { getBackupFolder, getS3Credentials, scheduleBackup } from "./utils";
export const initCronJobs = async () => {
console.log("Setting up cron jobs....");
@@ -109,21 +109,6 @@ export const initCronJobs = async () => {
}
};
const getServiceAppName = (backup: BackupSchedule): string => {
if (backup.compose?.appName) {
return backup.serviceName
? `${backup.compose.appName}_${backup.serviceName}`
: backup.compose.appName;
}
const serviceAppName =
backup.postgres?.appName ||
backup.mysql?.appName ||
backup.mariadb?.appName ||
backup.mongo?.appName ||
backup.libsql?.appName;
return serviceAppName || backup.appName;
};
export const keepLatestNBackups = async (
backup: BackupSchedule,
serverId?: string | null,
@@ -135,8 +120,7 @@ export const keepLatestNBackups = async (
try {
const destination = await findDestinationById(backup.destinationId);
const rcloneFlags = getS3Credentials(destination);
const appName = getServiceAppName(backup);
const backupFilesPath = `:s3:${destination.bucket}/${appName}/${normalizeS3Path(backup.prefix)}`;
const backupFilesPath = `:s3:${destination.bucket}/${getBackupFolder(backup)}`;
// --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}`;
@@ -303,3 +303,22 @@ export const getBackupCommand = (
echo "Backup done ✅" >> ${logPath};
`;
};
const getServiceAppName = (backup: BackupSchedule) => {
if (backup.compose?.appName) {
return backup.serviceName
? `${backup.compose.appName}_${backup.serviceName}`
: backup.compose.appName;
}
return (
backup.postgres?.appName ||
backup.mysql?.appName ||
backup.mariadb?.appName ||
backup.mongo?.appName ||
backup.libsql?.appName ||
backup.appName
);
};
export const getBackupFolder = (backup: BackupSchedule) =>
`${getServiceAppName(backup)}/${normalizeS3Path(backup.prefix)}`;
@@ -25,7 +25,12 @@ const execAsyncBase = util.promisify(exec);
export const execAsync = async (
command: string,
options?: { cwd?: string; env?: NodeJS.ProcessEnv; shell?: string },
options?: {
cwd?: string;
env?: NodeJS.ProcessEnv;
shell?: string;
maxBuffer?: number;
},
): Promise<{ stdout: string; stderr: string }> => {
try {
const result = await execAsyncBase(command, options);