mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-14 11:06:15 +05:00
feat(backups): add optional custom name for backup files
This commit is contained in:
parent
0124613516
commit
623ed062bb
@ -1,5 +1,22 @@
|
||||
import { normalizeS3Path } from "@dokploy/server/utils/backups/utils";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Destination } from "@dokploy/server/services/destination";
|
||||
import { keepLatestNBackups } from "@dokploy/server/utils/backups";
|
||||
import {
|
||||
getBackupFileName,
|
||||
normalizeS3Path,
|
||||
sanitizeBackupCustomName,
|
||||
} from "@dokploy/server/utils/backups/utils";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const { findDestinationByIdMock } = vi.hoisted(() => ({
|
||||
findDestinationByIdMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/destination", () => ({
|
||||
findDestinationById: findDestinationByIdMock,
|
||||
}));
|
||||
|
||||
describe("normalizeS3Path", () => {
|
||||
test("should handle empty and whitespace-only prefix", () => {
|
||||
@ -59,3 +76,238 @@ describe("normalizeS3Path", () => {
|
||||
expect(normalizeS3Path("instance-backups")).toBe("instance-backups/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeBackupCustomName", () => {
|
||||
test("should return an empty string for empty, null or undefined input", () => {
|
||||
expect(sanitizeBackupCustomName("")).toBe("");
|
||||
expect(sanitizeBackupCustomName(null)).toBe("");
|
||||
expect(sanitizeBackupCustomName(undefined)).toBe("");
|
||||
});
|
||||
|
||||
test("should trim whitespace", () => {
|
||||
expect(sanitizeBackupCustomName(" my-backup ")).toBe("my-backup");
|
||||
});
|
||||
|
||||
test("should preserve already-safe characters", () => {
|
||||
expect(sanitizeBackupCustomName("my-backup_v1.2")).toBe("my-backup_v1.2");
|
||||
});
|
||||
|
||||
test("should replace unsafe characters with a hyphen", () => {
|
||||
expect(sanitizeBackupCustomName("my backup")).toBe("my-backup");
|
||||
expect(sanitizeBackupCustomName("my/backup")).toBe("my-backup");
|
||||
expect(sanitizeBackupCustomName("my@backup!")).toBe("my-backup");
|
||||
expect(sanitizeBackupCustomName("café ☕")).toBe("caf");
|
||||
});
|
||||
|
||||
test("should collapse consecutive unsafe characters into a single hyphen", () => {
|
||||
expect(sanitizeBackupCustomName("my backup")).toBe("my-backup");
|
||||
expect(sanitizeBackupCustomName("my///backup")).toBe("my-backup");
|
||||
});
|
||||
|
||||
test("should strip leading and trailing hyphens produced by sanitization", () => {
|
||||
expect(sanitizeBackupCustomName("/my-backup/")).toBe("my-backup");
|
||||
expect(sanitizeBackupCustomName("!!!")).toBe("");
|
||||
expect(sanitizeBackupCustomName(" ")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBackupFileName", () => {
|
||||
beforeEach(() => {
|
||||
// Freeze time so the generated timestamp segment is deterministic.
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(Date.UTC(2026, 7, 4, 3, 27, 47, 369)));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const expectedTimestamp = "2026-08-04T03-27-47-369Z";
|
||||
|
||||
test("should return only the timestamp when there is no custom name or fixed prefix", () => {
|
||||
expect(getBackupFileName(undefined, "sql.gz")).toBe(
|
||||
`${expectedTimestamp}.sql.gz`,
|
||||
);
|
||||
expect(getBackupFileName(null, "sql.gz")).toBe(
|
||||
`${expectedTimestamp}.sql.gz`,
|
||||
);
|
||||
expect(getBackupFileName("", "sql.gz")).toBe(`${expectedTimestamp}.sql.gz`);
|
||||
});
|
||||
|
||||
test("should prepend the sanitized custom name to the timestamp", () => {
|
||||
expect(getBackupFileName("my-backup", "sql.gz")).toBe(
|
||||
`my-backup-${expectedTimestamp}.sql.gz`,
|
||||
);
|
||||
expect(getBackupFileName("My Backup!", "bson.gz")).toBe(
|
||||
`My-Backup-${expectedTimestamp}.bson.gz`,
|
||||
);
|
||||
});
|
||||
|
||||
test("should fall back to the timestamp-only name when the custom name sanitizes to empty", () => {
|
||||
expect(getBackupFileName("!!!", "sql.gz")).toBe(
|
||||
`${expectedTimestamp}.sql.gz`,
|
||||
);
|
||||
});
|
||||
|
||||
test("should use the fixed prefix alone when there is no custom name (web-server default)", () => {
|
||||
expect(getBackupFileName(undefined, "zip", "webserver-backup")).toBe(
|
||||
`webserver-backup-${expectedTimestamp}.zip`,
|
||||
);
|
||||
expect(getBackupFileName("", "zip", "webserver-backup")).toBe(
|
||||
`webserver-backup-${expectedTimestamp}.zip`,
|
||||
);
|
||||
});
|
||||
|
||||
test("should combine the fixed prefix and the custom name when both are present", () => {
|
||||
expect(getBackupFileName("dokploy-local", "zip", "webserver-backup")).toBe(
|
||||
`webserver-backup-dokploy-local-${expectedTimestamp}.zip`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("keepLatestNBackups", () => {
|
||||
// Drives the real, unmodified keepLatestNBackups: no shell fragment is
|
||||
// re-typed here, so this can't silently drift from what index.ts runs.
|
||||
// The only two externals stubbed are the DB lookup (findDestinationById)
|
||||
// and the `rclone` binary itself, via a fake executable put on PATH; the
|
||||
// rest of the pipeline (execAsync, sort/tail/cut/xargs) runs for real.
|
||||
let stubDir: string;
|
||||
let lsfFixtureFile: string;
|
||||
let deletedFile: string;
|
||||
let originalPath: string | undefined;
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
const fakeDestination: Destination = {
|
||||
destinationId: "dest-1",
|
||||
name: "test-destination",
|
||||
provider: null,
|
||||
accessKey: "AK",
|
||||
secretAccessKey: "SK",
|
||||
bucket: "test-bucket",
|
||||
region: "us-east-1",
|
||||
endpoint: "https://s3.example.com",
|
||||
additionalFlags: null,
|
||||
organizationId: "org-1",
|
||||
serverId: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
} as unknown as Destination;
|
||||
|
||||
const runKeepLatestNBackups = async (
|
||||
keepLatestCount: number,
|
||||
lsfLines: string[],
|
||||
) => {
|
||||
writeFileSync(lsfFixtureFile, `${lsfLines.join("\n")}\n`);
|
||||
findDestinationByIdMock.mockResolvedValue(fakeDestination);
|
||||
|
||||
await keepLatestNBackups({
|
||||
backupId: "backup-1",
|
||||
destinationId: "dest-1",
|
||||
prefix: "/",
|
||||
appName: "test-app",
|
||||
databaseType: "postgres",
|
||||
keepLatestCount,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: minimal fixture, not the full drizzle relation shape
|
||||
} as any);
|
||||
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
|
||||
try {
|
||||
return readFileSync(deletedFile, "utf-8").trim().split("\n").filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
stubDir = mkdtempSync(join(tmpdir(), "rclone-stub-"));
|
||||
lsfFixtureFile = join(stubDir, "lsf-fixture.txt");
|
||||
deletedFile = join(stubDir, "deleted.txt");
|
||||
|
||||
// A fake `rclone`, close enough to the real CLI to catch a regression
|
||||
// in how index.ts calls it:
|
||||
// - "lsf" only emits the "<modtime>;<path>" fixture when invoked with
|
||||
// `--format tp` (the real flag rclone needs to produce that shape) —
|
||||
// without it, it emits bare paths, just like the real binary would.
|
||||
// - "delete" records the exact path it was asked to remove instead of
|
||||
// touching real S3.
|
||||
writeFileSync(
|
||||
join(stubDir, "rclone"),
|
||||
[
|
||||
"#!/bin/bash",
|
||||
'if [ "$1" = "lsf" ]; then',
|
||||
' if printf "%s\\n" "$@" | grep -qx "tp"; then',
|
||||
` cat "${lsfFixtureFile}"`,
|
||||
" else",
|
||||
` cut -d';' -f2- "${lsfFixtureFile}"`,
|
||||
" fi",
|
||||
'elif [ "$1" = "delete" ]; then',
|
||||
` echo "\${@: -1}" >> "${deletedFile}"`,
|
||||
"fi",
|
||||
].join("\n"),
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
|
||||
originalPath = process.env.PATH;
|
||||
process.env.PATH = `${stubDir}:${originalPath ?? ""}`;
|
||||
findDestinationByIdMock.mockReset();
|
||||
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.PATH = originalPath;
|
||||
consoleErrorSpy.mockRestore();
|
||||
rmSync(stubDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Deliberately mixes a plain timestamp filename with a customName-prefixed
|
||||
// one so filename-lexicographic order ("2..." < "dokploy-local-...")
|
||||
// disagrees with chronological order — the exact scenario a customName
|
||||
// introduces for a pre-existing schedule once it's edited to add/remove/
|
||||
// change the custom name.
|
||||
const lsfLines = [
|
||||
"2026-08-04 03:27:00;2026-08-04T03-27-00-000Z.sql.gz",
|
||||
"2026-08-04 03:28:00;dokploy-local-2026-08-04T03-28-00-000Z.sql.gz",
|
||||
"2026-08-04 03:29:00;2026-08-04T03-29-00-000Z.sql.gz",
|
||||
"2026-08-04 03:30:00;dokploy-local-2026-08-04T03-30-00-000Z.sql.gz",
|
||||
];
|
||||
|
||||
test("deletes the chronologically oldest backups, not the lexicographically last ones", async () => {
|
||||
const deleted = await runKeepLatestNBackups(2, lsfLines);
|
||||
const deletedFilenames = deleted.map(
|
||||
(path) => path.split("/").at(-1),
|
||||
);
|
||||
|
||||
// A naive filename-only sort would have kept the two files whose names
|
||||
// sort last ("dokploy-local-...-03-30" and "dokploy-local-...-03-28")
|
||||
// and deleted "...03-27" and "...03-29" instead — deleting the
|
||||
// second-*newest* backup. The real, modtime-based pipeline must
|
||||
// instead delete exactly the two oldest by time, with a well-formed
|
||||
// path (no stray modtime/separator leaking through).
|
||||
expect(deletedFilenames.sort()).toEqual(
|
||||
[
|
||||
"2026-08-04T03-27-00-000Z.sql.gz",
|
||||
"dokploy-local-2026-08-04T03-28-00-000Z.sql.gz",
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
test("deletes nothing when keepLatestCount covers the whole list", async () => {
|
||||
const deleted = await runKeepLatestNBackups(4, lsfLines);
|
||||
expect(deleted).toEqual([]);
|
||||
});
|
||||
|
||||
test("does nothing (and never calls rclone) when keepLatestCount is 0", async () => {
|
||||
await keepLatestNBackups({
|
||||
backupId: "backup-1",
|
||||
destinationId: "dest-1",
|
||||
prefix: "/",
|
||||
appName: "test-app",
|
||||
databaseType: "postgres",
|
||||
keepLatestCount: 0,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: minimal fixture, not the full drizzle relation shape
|
||||
} as any);
|
||||
|
||||
expect(findDestinationByIdMock).not.toHaveBeenCalled();
|
||||
expect(() => readFileSync(deletedFile, "utf-8")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@ -78,6 +78,15 @@ const Schema = z
|
||||
destinationId: z.string().min(1, "Destination required"),
|
||||
schedule: z.string().min(1, "Schedule (Cron) required"),
|
||||
prefix: z.string().min(1, "Prefix required"),
|
||||
customName: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(100, "Custom name must be 100 characters or less")
|
||||
.regex(
|
||||
/^[a-zA-Z0-9._-]*$/,
|
||||
"Only letters, numbers, dots, hyphens and underscores are allowed",
|
||||
)
|
||||
.optional(),
|
||||
enabled: z.boolean(),
|
||||
includeEncryptionKey: z.boolean(),
|
||||
database: z.string().min(1, "Database required"),
|
||||
@ -226,6 +235,7 @@ export const HandleBackup = ({
|
||||
enabled: true,
|
||||
includeEncryptionKey: true,
|
||||
prefix: "/",
|
||||
customName: "",
|
||||
schedule: "",
|
||||
keepLatestCount: undefined,
|
||||
serviceName: null,
|
||||
@ -266,6 +276,7 @@ export const HandleBackup = ({
|
||||
enabled: backup?.enabled ?? true,
|
||||
includeEncryptionKey: backup?.includeEncryptionKey ?? true,
|
||||
prefix: backup?.prefix ?? "/",
|
||||
customName: backup?.customName ?? "",
|
||||
schedule: backup?.schedule ?? "",
|
||||
keepLatestCount: backup?.keepLatestCount ?? undefined,
|
||||
serviceName: backup?.serviceName ?? null,
|
||||
@ -310,6 +321,7 @@ export const HandleBackup = ({
|
||||
await createBackup({
|
||||
destinationId: data.destinationId,
|
||||
prefix: data.prefix,
|
||||
customName: data.customName ?? "",
|
||||
schedule: data.schedule,
|
||||
enabled: data.enabled,
|
||||
includeEncryptionKey: data.includeEncryptionKey,
|
||||
@ -625,6 +637,28 @@ export const HandleBackup = ({
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customName"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Custom File Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={"my-backup"} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Optional. Prepended to the automatically generated
|
||||
timestamp, e.g.
|
||||
"my-backup-2026-08-03T20-58-22-123Z.sql.gz". Only
|
||||
letters, numbers, dots, hyphens and underscores are
|
||||
allowed.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keepLatestCount"
|
||||
|
||||
@ -404,11 +404,16 @@ export const RestoreBackup = ({
|
||||
name="backupFile"
|
||||
render={({ field }) => (
|
||||
<FormItem className="">
|
||||
<FormLabel className="flex items-center justify-between">
|
||||
Search Backup Files
|
||||
<FormLabel className="flex items-center justify-between gap-2">
|
||||
<span className="shrink-0">Search Backup Files</span>
|
||||
{field.value && (
|
||||
<Badge variant="outline" className="truncate">
|
||||
{field.value}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="min-w-0 max-w-[70%]"
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
{field.value}
|
||||
</span>
|
||||
<Copy
|
||||
className="ml-2 size-4 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
@ -431,14 +436,17 @@ export const RestoreBackup = ({
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-left flex-1 w-52">
|
||||
<span className="min-w-0 flex-1 truncate text-left">
|
||||
{field.value || "Search and select a backup file"}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search backup files..."
|
||||
@ -460,7 +468,7 @@ export const RestoreBackup = ({
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-64">
|
||||
<CommandGroup className="w-96">
|
||||
<CommandGroup className="w-full">
|
||||
{files?.map((file) => (
|
||||
<CommandItem
|
||||
value={file.Path}
|
||||
@ -477,14 +485,14 @@ export const RestoreBackup = ({
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<div className="flex w-full justify-between">
|
||||
<span className="font-medium">
|
||||
<div className="flex w-full items-start justify-between gap-2">
|
||||
<span className="min-w-0 flex-1 break-all font-medium">
|
||||
{file.Path}
|
||||
</span>
|
||||
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
"mt-0.5 h-4 w-4 shrink-0",
|
||||
file.Path === field.value
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
|
||||
@ -270,6 +270,17 @@ export const ShowBackups = ({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{backup.customName && (
|
||||
<div className="min-w-[150px]">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
Custom Name
|
||||
</span>
|
||||
<p className="font-medium text-sm mt-0.5">
|
||||
{backup.customName}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-w-[100px]">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
Keep Latest
|
||||
|
||||
1
apps/dokploy/drizzle/0180_brainy_machine_man.sql
Normal file
1
apps/dokploy/drizzle/0180_brainy_machine_man.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TABLE "backup" ADD COLUMN "customName" text;
|
||||
8926
apps/dokploy/drizzle/meta/0180_snapshot.json
Normal file
8926
apps/dokploy/drizzle/meta/0180_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -1261,6 +1261,13 @@
|
||||
"when": 1785800152143,
|
||||
"tag": "0179_foamy_marauders",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 180,
|
||||
"version": "7",
|
||||
"when": 1785876666983,
|
||||
"tag": "0180_brainy_machine_man",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -45,6 +45,7 @@ export const backups = pgTable("backup", {
|
||||
enabled: boolean("enabled"),
|
||||
database: text("database").notNull(),
|
||||
prefix: text("prefix").notNull(),
|
||||
customName: text("customName"),
|
||||
serviceName: text("serviceName"),
|
||||
destinationId: text("destinationId")
|
||||
.notNull()
|
||||
@ -139,11 +140,22 @@ export const backupsRelations = relations(backups, ({ one, many }) => ({
|
||||
deployments: many(deployments),
|
||||
}));
|
||||
|
||||
const customNameSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.max(100, "Custom name must be 100 characters or less")
|
||||
.regex(
|
||||
/^[a-zA-Z0-9._-]*$/,
|
||||
"Only letters, numbers, dots, hyphens and underscores are allowed",
|
||||
)
|
||||
.optional();
|
||||
|
||||
const createSchema = createInsertSchema(backups, {
|
||||
backupId: z.string(),
|
||||
destinationId: z.string(),
|
||||
enabled: z.boolean().optional(),
|
||||
prefix: z.string().min(1),
|
||||
customName: customNameSchema,
|
||||
database: z.string().min(1),
|
||||
schedule: z.string(),
|
||||
keepLatestCount: z.number().optional(),
|
||||
@ -169,6 +181,7 @@ export const apiCreateBackup = createSchema.pick({
|
||||
schedule: true,
|
||||
enabled: true,
|
||||
prefix: true,
|
||||
customName: true,
|
||||
destinationId: true,
|
||||
keepLatestCount: true,
|
||||
database: true,
|
||||
@ -212,6 +225,7 @@ export const apiUpdateBackup = createSchema
|
||||
.required()
|
||||
.extend({
|
||||
includeEncryptionKey: z.boolean().optional(),
|
||||
customName: customNameSchema,
|
||||
});
|
||||
|
||||
export const apiRestoreBackup = z.object({
|
||||
|
||||
@ -11,7 +11,7 @@ import { sendDatabaseBackupNotifications } from "../notifications/database-backu
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
import {
|
||||
getBackupCommand,
|
||||
getBackupTimestamp,
|
||||
getBackupFileName,
|
||||
getS3Credentials,
|
||||
normalizeS3Path,
|
||||
} from "./utils";
|
||||
@ -25,7 +25,10 @@ export const runComposeBackup = async (
|
||||
const project = await findProjectById(environment.projectId);
|
||||
const { prefix, databaseType, serviceName } = backup;
|
||||
const destination = await findDestinationById(backup.destinationId);
|
||||
const backupFileName = `${getBackupTimestamp()}.${databaseType === "mongo" ? "bson" : "sql"}.gz`;
|
||||
const backupFileName = getBackupFileName(
|
||||
backup.customName,
|
||||
databaseType === "mongo" ? "bson.gz" : "sql.gz",
|
||||
);
|
||||
const s3AppName = serviceName ? `${appName}_${serviceName}` : appName;
|
||||
const bucketDestination = `${s3AppName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
const deployment = await createDeploymentBackup({
|
||||
|
||||
@ -139,9 +139,10 @@ export const keepLatestNBackups = async (
|
||||
const backupFilesPath = `:s3:${destination.bucket}/${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}`;
|
||||
// --format "tp" prefixes each line with the real modification time so sorting reflects actual backup age instead of filename order
|
||||
const rcloneList = `rclone lsf ${rcloneFlags.join(" ")} --include "*${backup.databaseType === "web-server" ? ".zip" : ".{sql.gz,bson.gz}"}" --format "tp" ${backupFilesPath}`;
|
||||
// when we pipe the above command with this one, we only get the list of files we want to delete
|
||||
const sortAndPickUnwantedBackups = `sort -r | tail -n +$((${backup.keepLatestCount}+1)) | xargs -I{}`;
|
||||
const sortAndPickUnwantedBackups = `sort -r | tail -n +$((${backup.keepLatestCount}+1)) | cut -d';' -f2- | xargs -I{}`;
|
||||
// this command deletes the files
|
||||
// to test the deletion before actually deleting we can add --dry-run before ${backupFilesPath}{}
|
||||
const rcloneDelete = `rclone delete ${rcloneFlags.join(" ")} ${backupFilesPath}{}`;
|
||||
|
||||
@ -11,7 +11,7 @@ import { sendDatabaseBackupNotifications } from "../notifications/database-backu
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
import {
|
||||
getBackupCommand,
|
||||
getBackupTimestamp,
|
||||
getBackupFileName,
|
||||
getS3Credentials,
|
||||
normalizeS3Path,
|
||||
} from "./utils";
|
||||
@ -31,7 +31,7 @@ export const runLibsqlBackup = async (
|
||||
});
|
||||
const { prefix } = backup;
|
||||
const destination = await findDestinationById(backup.destinationId);
|
||||
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
|
||||
const backupFileName = getBackupFileName(backup.customName, "sql.gz");
|
||||
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
try {
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
|
||||
@ -11,7 +11,7 @@ import { sendDatabaseBackupNotifications } from "../notifications/database-backu
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
import {
|
||||
getBackupCommand,
|
||||
getBackupTimestamp,
|
||||
getBackupFileName,
|
||||
getS3Credentials,
|
||||
normalizeS3Path,
|
||||
} from "./utils";
|
||||
@ -25,7 +25,7 @@ export const runMariadbBackup = async (
|
||||
const project = await findProjectById(environment.projectId);
|
||||
const { prefix } = backup;
|
||||
const destination = await findDestinationById(backup.destinationId);
|
||||
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
|
||||
const backupFileName = getBackupFileName(backup.customName, "sql.gz");
|
||||
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
const deployment = await createDeploymentBackup({
|
||||
backupId: backup.backupId,
|
||||
|
||||
@ -11,7 +11,7 @@ import { sendDatabaseBackupNotifications } from "../notifications/database-backu
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
import {
|
||||
getBackupCommand,
|
||||
getBackupTimestamp,
|
||||
getBackupFileName,
|
||||
getS3Credentials,
|
||||
normalizeS3Path,
|
||||
} from "./utils";
|
||||
@ -22,7 +22,7 @@ export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => {
|
||||
const project = await findProjectById(environment.projectId);
|
||||
const { prefix } = backup;
|
||||
const destination = await findDestinationById(backup.destinationId);
|
||||
const backupFileName = `${getBackupTimestamp()}.bson.gz`;
|
||||
const backupFileName = getBackupFileName(backup.customName, "bson.gz");
|
||||
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
const deployment = await createDeploymentBackup({
|
||||
backupId: backup.backupId,
|
||||
|
||||
@ -11,7 +11,7 @@ import { sendDatabaseBackupNotifications } from "../notifications/database-backu
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
import {
|
||||
getBackupCommand,
|
||||
getBackupTimestamp,
|
||||
getBackupFileName,
|
||||
getS3Credentials,
|
||||
normalizeS3Path,
|
||||
} from "./utils";
|
||||
@ -22,7 +22,7 @@ export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => {
|
||||
const project = await findProjectById(environment.projectId);
|
||||
const { prefix } = backup;
|
||||
const destination = await findDestinationById(backup.destinationId);
|
||||
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
|
||||
const backupFileName = getBackupFileName(backup.customName, "sql.gz");
|
||||
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
const deployment = await createDeploymentBackup({
|
||||
backupId: backup.backupId,
|
||||
|
||||
@ -11,7 +11,7 @@ import { sendDatabaseBackupNotifications } from "../notifications/database-backu
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
import {
|
||||
getBackupCommand,
|
||||
getBackupTimestamp,
|
||||
getBackupFileName,
|
||||
getS3Credentials,
|
||||
normalizeS3Path,
|
||||
} from "./utils";
|
||||
@ -31,7 +31,7 @@ export const runPostgresBackup = async (
|
||||
});
|
||||
const { prefix } = backup;
|
||||
const destination = await findDestinationById(backup.destinationId);
|
||||
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
|
||||
const backupFileName = getBackupFileName(backup.customName, "sql.gz");
|
||||
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
try {
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
|
||||
@ -61,6 +61,32 @@ export const removeScheduleBackup = (backupId: string) => {
|
||||
export const getBackupTimestamp = () =>
|
||||
new Date().toISOString().replace(/[:.]/g, "-");
|
||||
|
||||
const UNSAFE_CUSTOM_NAME_CHARS = /[^a-zA-Z0-9._-]+/g;
|
||||
|
||||
export const sanitizeBackupCustomName = (
|
||||
customName?: string | null,
|
||||
): string => {
|
||||
if (!customName) return "";
|
||||
return customName
|
||||
.trim()
|
||||
.replace(UNSAFE_CUSTOM_NAME_CHARS, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
};
|
||||
|
||||
export const getBackupFileName = (
|
||||
customName: string | null | undefined,
|
||||
extension: string,
|
||||
fixedPrefix?: string,
|
||||
): string => {
|
||||
const timestamp = getBackupTimestamp();
|
||||
const parts = [fixedPrefix, sanitizeBackupCustomName(customName)].filter(
|
||||
Boolean,
|
||||
);
|
||||
return parts.length
|
||||
? `${parts.join("-")}-${timestamp}.${extension}`
|
||||
: `${timestamp}.${extension}`;
|
||||
};
|
||||
|
||||
export const normalizeS3Path = (prefix: string) => {
|
||||
// Trim whitespace and remove leading/trailing slashes
|
||||
const normalizedPrefix = prefix.trim().replace(/^\/+|\/+$/g, "");
|
||||
|
||||
@ -16,7 +16,7 @@ import { findDestinationById } from "@dokploy/server/services/destination";
|
||||
import { sendDokployBackupNotifications } from "../notifications/dokploy-backup";
|
||||
import { execAsync } from "../process/execAsync";
|
||||
import { redactRcloneCredentials } from "./redact";
|
||||
import { getBackupTimestamp, getS3Credentials, normalizeS3Path } from "./utils";
|
||||
import { getBackupFileName, getS3Credentials, normalizeS3Path } from "./utils";
|
||||
|
||||
function formatBytes(bytes?: number) {
|
||||
if (bytes === undefined) return "Unknown size";
|
||||
@ -42,10 +42,13 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
|
||||
try {
|
||||
const destination = await findDestinationById(backup.destinationId);
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
const timestamp = getBackupTimestamp();
|
||||
const { BASE_PATH } = paths();
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "dokploy-backup-"));
|
||||
const backupFileName = `webserver-backup-${timestamp}.zip`;
|
||||
const backupFileName = getBackupFileName(
|
||||
backup.customName,
|
||||
"zip",
|
||||
"webserver-backup",
|
||||
);
|
||||
const s3Path = `:s3:${destination.bucket}/${backup.appName}/${normalizeS3Path(backup.prefix)}${backupFileName}`;
|
||||
|
||||
try {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user