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:
Mauricio Siu
2025-08-24 00:44:27 -06:00
committed by GitHub
2 changed files with 74 additions and 16 deletions

View File

@@ -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}

View File

@@ -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");