Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 102 additions & 1 deletion apps/dokploy/__test__/utils/backups.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { normalizeS3Path } from "@dokploy/server/utils/backups/utils";
import {
normalizeS3Path,
validateFileNameFormat,
formatBackupFileName,
} from "@dokploy/server/utils/backups/utils";
import { describe, expect, test } from "vitest";

describe("normalizeS3Path", () => {
Expand Down Expand Up @@ -59,3 +63,100 @@ describe("normalizeS3Path", () => {
expect(normalizeS3Path("instance-backups")).toBe("instance-backups/");
});
});

describe("validateFileNameFormat", () => {
test("should return empty array for valid formats", () => {
expect(validateFileNameFormat("{timestamp}")).toEqual([]);
expect(validateFileNameFormat("{date}_{time}")).toEqual([]);
expect(validateFileNameFormat("{appName}-{timestamp}")).toEqual([]);
expect(validateFileNameFormat("{volumeName}-{date}-{uuid}")).toEqual([]);
});

test("should return invalid variables", () => {
expect(validateFileNameFormat("{invalid}")).toEqual(["invalid"]);
expect(validateFileNameFormat("{foo}-{bar}")).toEqual(["foo", "bar"]);
expect(validateFileNameFormat("{timestamp}-{unknown}")).toEqual(["unknown"]);
});

test("should handle formats without variables", () => {
expect(validateFileNameFormat("static-name")).toEqual([]);
expect(validateFileNameFormat("backup")).toEqual([]);
});
});

describe("formatBackupFileName", () => {
test("should replace timestamp variable", () => {
const result = formatBackupFileName("{timestamp}", {});
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
});

test("should replace date variable", () => {
const result = formatBackupFileName("{date}", {});
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});

test("should replace time variable with safe characters", () => {
const result = formatBackupFileName("{time}", {});
expect(result).toMatch(/^\d{2}-\d{2}-\d{2}$/);
});

test("should replace context variables", () => {
const result = formatBackupFileName("{appName}-{volumeName}", {
appName: "my-app",
volumeName: "my-volume",
});
expect(result).toBe("my-app-my-volume");
});

test("should handle missing context variables as empty string", () => {
const result = formatBackupFileName("{appName}-{volumeName}", {});
expect(result).toBe("-");
});

test("should preserve unknown variables as-is", () => {
const result = formatBackupFileName("{unknown}", {});
expect(result).toBe("{unknown}");
});

test("should handle mixed static and variable content", () => {
const result = formatBackupFileName("backup-{appName}-v1", {
appName: "test",
});
expect(result).toBe("backup-test-v1");
});

test("should generate unique uuid values", () => {
const result1 = formatBackupFileName("{uuid}", {});
const result2 = formatBackupFileName("{uuid}", {});
expect(result1).not.toBe(result2);
expect(result1).toMatch(/^[0-9a-f-]{36}$/);
});

test("should generate 8-char shortUuid", () => {
const result = formatBackupFileName("{shortUuid}", {});
expect(result).toMatch(/^[0-9a-f]{8}$/);
});

test("should replace year, month, day variables", () => {
const result = formatBackupFileName("{year}-{month}-{day}", {});
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});

test("should replace hour and minute variables", () => {
const result = formatBackupFileName("{hour}-{minute}", {});
expect(result).toMatch(/^\d{2}-\d{2}$/);
});

test("should replace epoch variable", () => {
const result = formatBackupFileName("{epoch}", {});
expect(result).toMatch(/^\d+$/);
expect(Number(result)).toBeGreaterThan(1700000000);
});

test("should replace databaseType variable", () => {
const result = formatBackupFileName("{databaseType}", {
databaseType: "postgres",
});
expect(result).toBe("postgres");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,24 @@ import { api } from "@/utils/api";
import type { CacheType } from "../domains/handle-domain";
import { ScheduleFormField } from "../schedules/handle-schedules";

const validateFileNameFormat = (format: string | undefined): { valid: boolean; errors: string[] } => {
if (!format || !format.trim()) return { valid: true, errors: [] };

const usedVars = [...format.matchAll(/\{(\w+)\}/g)].map((m) => m[1]).filter(Boolean);
const validVars = ["timestamp", "date", "time", "year", "month", "day", "hour", "minute", "epoch", "appName", "volumeName", "databaseType", "uuid", "shortUuid"];
const invalidVars = usedVars.filter(v => !validVars.includes(v));

if (invalidVars.length > 0) {
return { valid: false, errors: [`Invalid format variables: ${invalidVars.join(", ")}`] };
}

if (!format.includes("{")) {
return { valid: false, errors: ["FileNameFormat must contain at least one valid placeholder like {timestamp}"] };
}

return { valid: true, errors: [] };
};

const formSchema = z
.object({
name: z.string().min(1, "Name is required"),
Expand All @@ -55,6 +73,7 @@ const formSchema = z
"Invalid volume name. Use letters, numbers, '._-' and start with a letter/number.",
),
prefix: z.string(),
fileNameFormat: z.string().optional(),
keepLatestCount: z.coerce
.number()
.int()
Expand Down Expand Up @@ -123,6 +142,7 @@ export const HandleVolumeBackups = ({
cronExpression: "",
volumeName: "",
prefix: "",
fileNameFormat: "",
keepLatestCount: undefined,
turnOff: false,
enabled: true,
Expand Down Expand Up @@ -179,6 +199,7 @@ export const HandleVolumeBackups = ({
cronExpression: volumeBackup.cronExpression,
volumeName: volumeBackup.volumeName || "",
prefix: volumeBackup.prefix,
fileNameFormat: volumeBackup.fileNameFormat || "",
keepLatestCount: volumeBackup.keepLatestCount || undefined,
turnOff: volumeBackup.turnOff,
enabled: volumeBackup.enabled || false,
Expand Down Expand Up @@ -560,6 +581,30 @@ export const HandleVolumeBackups = ({
)}
/>

<FormField
control={form.control}
name="fileNameFormat"
render={({ field }) => {
const validation = validateFileNameFormat(field.value);
return (
<FormItem>
<FormLabel>File Name Format</FormLabel>
<FormControl>
<Input
placeholder={"{volumeName}-{timestamp}"}
{...field}
/>
</FormControl>
<FormDescription>
Format for backup file name. Variables:{" "}
{"{timestamp}, {date}, {time}, {volumeName}, {appName}, {uuid}"}
</FormDescription>
<FormMessage>{validation.errors[0] || ""}</FormMessage>
</FormItem>
);
}}
/>

<FormField
control={form.control}
name="keepLatestCount"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,31 @@ type CacheType = "cache" | "fetch";

type DatabaseType = "postgres" | "mariadb" | "mysql" | "mongo" | "web-server";

const validateFileNameFormat = (format: string): { valid: boolean; errors: string[] } => {
if (!format || !format.trim()) return { valid: true, errors: [] }; // Empty is allowed

const usedVars = [...format.matchAll(/\{(\w+)\}/g)].map((m) => m[1]).filter(Boolean);
const validVars = ["timestamp", "date", "time", "year", "month", "day", "hour", "minute", "epoch", "appName", "volumeName", "databaseType", "uuid", "shortUuid"];
const invalidVars = usedVars.filter(v => !validVars.includes(v));

if (invalidVars.length > 0) {
return { valid: false, errors: [`Invalid format variables: ${invalidVars.join(", ")}`] };
}

// Check that format contains at least one variable
if (!format.includes("{")) {
return { valid: false, errors: ["FileNameFormat must contain at least one valid placeholder like {timestamp}"] };
}

return { valid: true, errors: [] };
};

const Schema = z
.object({
destinationId: z.string().min(1, "Destination required"),
schedule: z.string().min(1, "Schedule (Cron) required"),
prefix: z.string().min(1, "Prefix required"),
fileNameFormat: z.string().optional(),
enabled: z.boolean(),
database: z.string().min(1, "Database required"),
keepLatestCount: z.coerce.number().optional(),
Expand Down Expand Up @@ -213,6 +233,7 @@ export const HandleBackup = ({
destinationId: "",
enabled: true,
prefix: "/",
fileNameFormat: "",
schedule: "",
keepLatestCount: undefined,
serviceName: null,
Expand Down Expand Up @@ -250,6 +271,7 @@ export const HandleBackup = ({
destinationId: backup?.destinationId ?? "",
enabled: backup?.enabled ?? true,
prefix: backup?.prefix ?? "/",
fileNameFormat: backup?.fileNameFormat ?? "",
schedule: backup?.schedule ?? "",
keepLatestCount: backup?.keepLatestCount ?? undefined,
serviceName: backup?.serviceName ?? null,
Expand Down Expand Up @@ -290,6 +312,7 @@ export const HandleBackup = ({
await createBackup({
destinationId: data.destinationId,
prefix: data.prefix,
fileNameFormat: data.fileNameFormat,
schedule: data.schedule,
enabled: data.enabled,
database: data.database,
Expand Down Expand Up @@ -601,6 +624,26 @@ export const HandleBackup = ({
);
}}
/>
<FormField
control={form.control}
name="fileNameFormat"
render={({ field }) => {
const validation = validateFileNameFormat(field.value);
return (
<FormItem>
<FormLabel>File Name Format</FormLabel>
<FormControl>
<Input placeholder="{timestamp}" {...field} />
</FormControl>
<FormDescription>
Format for backup file name. Variables:{" "}
{"{timestamp}, {date}, {time}, {appName}, {databaseType}, {uuid}"}
</FormDescription>
<FormMessage>{validation.errors[0] || ""}</FormMessage>
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="keepLatestCount"
Expand Down
2 changes: 2 additions & 0 deletions apps/dokploy/drizzle/0153_illegal_quentin_quire.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE "backup" ADD COLUMN "fileNameFormat" text DEFAULT '{timestamp}';--> statement-breakpoint
ALTER TABLE "volume_backup" ADD COLUMN "fileNameFormat" text DEFAULT '{volumeName}-{timestamp}';
Loading