mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-19 07:01:35 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29498b72fe | |||
| cf63d73623 | |||
| 06a7559e8a | |||
| 79557cb971 |
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user