From 37c34fdadc23c00e46004978ca9cf4e1842a8bdf Mon Sep 17 00:00:00 2001 From: Vlad Vladov Date: Sat, 23 Aug 2025 12:32:17 +0300 Subject: [PATCH] feat(volume): Add possibility to keep latest N backups for custom containers --- .../volume-backups/handle-volume-backups.tsx | 49 ++++++++++++++----- .../server/src/utils/volume-backups/utils.ts | 40 ++++++++++++--- 2 files changed, 71 insertions(+), 18 deletions(-) diff --git a/apps/dokploy/components/dashboard/application/volume-backups/handle-volume-backups.tsx b/apps/dokploy/components/dashboard/application/volume-backups/handle-volume-backups.tsx index 29f8f6e15..09ba71839 100644 --- a/apps/dokploy/components/dashboard/application/volume-backups/handle-volume-backups.tsx +++ b/apps/dokploy/components/dashboard/application/volume-backups/handle-volume-backups.tsx @@ -55,7 +55,13 @@ 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 +114,7 @@ export const HandleVolumeBackups = ({ }: Props) => { const [isOpen, setIsOpen] = useState(false); const [cacheType, setCacheType] = useState("cache"); + const [keepLatestCountInput, setKeepLatestCountInput] = useState(""); const utils = api.useUtils(); const form = useForm>({ @@ -117,7 +124,7 @@ export const HandleVolumeBackups = ({ cronExpression: "", volumeName: "", prefix: "", - // keepLatestCount: undefined, + keepLatestCount: undefined, turnOff: false, enabled: true, serviceName: "", @@ -173,13 +180,18 @@ 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) => { if (!id && !volumeBackupId) return; + const preparedKeepLatestCount = + keepLatestCountInput === "" ? null : values.keepLatestCount ?? null; + await mutateAsync({ ...values, + keepLatestCount: preparedKeepLatestCount, destinationId: values.destinationId, volumeBackupId: volumeBackupId || "", serviceType: volumeBackupType, @@ -600,29 +616,38 @@ export const HandleVolumeBackups = ({ )} /> - {/* ( - Keep Latest Count + Keep Latest Backups - 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)); + } + }} /> - Number of backup files to keep (optional) + How many recent backups to keep. Empty means no cleanup. )} - /> */} + /> { const volumeBackup = await findVolumeBackupById(volumeBackupId); @@ -20,6 +17,33 @@ export const removeVolumeBackupJob = async (volumeBackupId: string) => { currentJob?.cancel(); }; +const cleanupOldVolumeBackups = async ( + volumeBackup: Awaited>, + 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 +64,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");