mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-15 20:25:23 +02:00
Merge pull request #2447 from divaltor/volume-backup
feat(volume): Add possibility to keep latest N backups for custom apps
This commit is contained in:
@@ -55,7 +55,12 @@ const formSchema = z
|
||||
cronExpression: z.string().min(1, "Cron expression is required"),
|
||||
volumeName: z.string().min(1, "Volume name is required"),
|
||||
prefix: z.string(),
|
||||
// keepLatestCount: z.coerce.number().optional(),
|
||||
keepLatestCount: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.gte(1, "Must be at least 1")
|
||||
.optional()
|
||||
.nullable(),
|
||||
turnOff: z.boolean().default(false),
|
||||
enabled: z.boolean().default(true),
|
||||
serviceType: z.enum([
|
||||
@@ -108,6 +113,7 @@ export const HandleVolumeBackups = ({
|
||||
}: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [cacheType, setCacheType] = useState<CacheType>("cache");
|
||||
const [keepLatestCountInput, setKeepLatestCountInput] = useState("");
|
||||
|
||||
const utils = api.useUtils();
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
@@ -117,7 +123,7 @@ export const HandleVolumeBackups = ({
|
||||
cronExpression: "",
|
||||
volumeName: "",
|
||||
prefix: "",
|
||||
// keepLatestCount: undefined,
|
||||
keepLatestCount: undefined,
|
||||
turnOff: false,
|
||||
enabled: true,
|
||||
serviceName: "",
|
||||
@@ -173,13 +179,19 @@ export const HandleVolumeBackups = ({
|
||||
cronExpression: volumeBackup.cronExpression,
|
||||
volumeName: volumeBackup.volumeName || "",
|
||||
prefix: volumeBackup.prefix,
|
||||
// keepLatestCount: volumeBackup.keepLatestCount || undefined,
|
||||
keepLatestCount: volumeBackup.keepLatestCount || undefined,
|
||||
turnOff: volumeBackup.turnOff,
|
||||
enabled: volumeBackup.enabled || false,
|
||||
serviceName: volumeBackup.serviceName || "",
|
||||
destinationId: volumeBackup.destinationId,
|
||||
serviceType: volumeBackup.serviceType,
|
||||
});
|
||||
setKeepLatestCountInput(
|
||||
volumeBackup.keepLatestCount !== null &&
|
||||
volumeBackup.keepLatestCount !== undefined
|
||||
? String(volumeBackup.keepLatestCount)
|
||||
: "",
|
||||
);
|
||||
}
|
||||
}, [form, volumeBackup, volumeBackupId]);
|
||||
|
||||
@@ -190,8 +202,12 @@ export const HandleVolumeBackups = ({
|
||||
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||
if (!id && !volumeBackupId) return;
|
||||
|
||||
const preparedKeepLatestCount =
|
||||
keepLatestCountInput === "" ? null : (values.keepLatestCount ?? null);
|
||||
|
||||
await mutateAsync({
|
||||
...values,
|
||||
keepLatestCount: preparedKeepLatestCount,
|
||||
destinationId: values.destinationId,
|
||||
volumeBackupId: volumeBackupId || "",
|
||||
serviceType: volumeBackupType,
|
||||
@@ -257,9 +273,8 @@ export const HandleVolumeBackups = ({
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"overflow-y-auto",
|
||||
volumeBackupType === "compose" || volumeBackupType === "application"
|
||||
? "max-h-[95vh] sm:max-w-2xl"
|
||||
? "sm:max-w-2xl"
|
||||
: " sm:max-w-lg",
|
||||
)}
|
||||
>
|
||||
@@ -600,29 +615,38 @@ export const HandleVolumeBackups = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* <FormField
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keepLatestCount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Keep Latest Count</FormLabel>
|
||||
<FormLabel>Keep Latest Backups</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="5"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(Number(e.target.value) || undefined)
|
||||
}
|
||||
type="number"
|
||||
min={1}
|
||||
autoComplete="off"
|
||||
placeholder="Leave empty to keep all"
|
||||
value={keepLatestCountInput}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
setKeepLatestCountInput(raw);
|
||||
if (raw === "") {
|
||||
field.onChange(undefined);
|
||||
} else if (/^\d+$/.test(raw)) {
|
||||
field.onChange(Number(raw));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Number of backup files to keep (optional)
|
||||
How many recent backups to keep. Empty means no cleanup.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/> */}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -2,11 +2,14 @@ import { findVolumeBackupById } from "@dokploy/server/services/volume-backups";
|
||||
import { scheduledJobs, scheduleJob } from "node-schedule";
|
||||
import {
|
||||
createDeploymentVolumeBackup,
|
||||
updateDeploymentStatus,
|
||||
} from "@dokploy/server/services/deployment";
|
||||
import {
|
||||
execAsync,
|
||||
execAsyncRemote,
|
||||
updateDeploymentStatus,
|
||||
} from "../..";
|
||||
} from "@dokploy/server/utils/process/execAsync";
|
||||
import { backupVolume } from "./backup";
|
||||
import { getS3Credentials, normalizeS3Path } from "../backups/utils";
|
||||
|
||||
export const scheduleVolumeBackup = async (volumeBackupId: string) => {
|
||||
const volumeBackup = await findVolumeBackupById(volumeBackupId);
|
||||
@@ -20,6 +23,33 @@ export const removeVolumeBackupJob = async (volumeBackupId: string) => {
|
||||
currentJob?.cancel();
|
||||
};
|
||||
|
||||
const cleanupOldVolumeBackups = async (
|
||||
volumeBackup: Awaited<ReturnType<typeof findVolumeBackupById>>,
|
||||
serverId?: string | null,
|
||||
) => {
|
||||
const { keepLatestCount, destination, prefix, volumeName } = volumeBackup;
|
||||
|
||||
if (!keepLatestCount) return;
|
||||
|
||||
try {
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
const normalizedPrefix = normalizeS3Path(prefix);
|
||||
const backupFilesPath = `:s3:${destination.bucket}/${normalizedPrefix}`;
|
||||
const listCommand = `rclone lsf ${rcloneFlags.join(" ")} --include \"${volumeName}-*.tar\" :s3:${destination.bucket}/${normalizedPrefix}`;
|
||||
const sortAndPick = `sort -r | tail -n +$((${keepLatestCount}+1)) | xargs -I{}`;
|
||||
const deleteCommand = `rclone delete ${rcloneFlags.join(" ")} ${backupFilesPath}{}`;
|
||||
const fullCommand = `${listCommand} | ${sortAndPick} ${deleteCommand}`;
|
||||
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, fullCommand);
|
||||
} else {
|
||||
await execAsync(fullCommand);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Volume backup retention error", error);
|
||||
}
|
||||
};
|
||||
|
||||
export const runVolumeBackup = async (volumeBackupId: string) => {
|
||||
const volumeBackup = await findVolumeBackupById(volumeBackupId);
|
||||
const serverId =
|
||||
@@ -40,6 +70,10 @@ export const runVolumeBackup = async (volumeBackupId: string) => {
|
||||
await execAsync(commandWithLog);
|
||||
}
|
||||
|
||||
if (volumeBackup.keepLatestCount && volumeBackup.keepLatestCount > 0) {
|
||||
await cleanupOldVolumeBackups(volumeBackup, serverId);
|
||||
}
|
||||
|
||||
await updateDeploymentStatus(deployment.deploymentId, "done");
|
||||
} catch (error) {
|
||||
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||
|
||||
Reference in New Issue
Block a user