mirror of
https://github.com/andwati/dokploy.git
synced 2026-11-11 22:45:18 +00:00
feat(volume): Add possibility to keep latest N backups for custom containers
This commit is contained in:
+37
-12
@@ -55,7 +55,13 @@ const formSchema = z
|
|||||||
cronExpression: z.string().min(1, "Cron expression is required"),
|
cronExpression: z.string().min(1, "Cron expression is required"),
|
||||||
volumeName: z.string().min(1, "Volume name is required"),
|
volumeName: z.string().min(1, "Volume name is required"),
|
||||||
prefix: z.string(),
|
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),
|
turnOff: z.boolean().default(false),
|
||||||
enabled: z.boolean().default(true),
|
enabled: z.boolean().default(true),
|
||||||
serviceType: z.enum([
|
serviceType: z.enum([
|
||||||
@@ -108,6 +114,7 @@ export const HandleVolumeBackups = ({
|
|||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [cacheType, setCacheType] = useState<CacheType>("cache");
|
const [cacheType, setCacheType] = useState<CacheType>("cache");
|
||||||
|
const [keepLatestCountInput, setKeepLatestCountInput] = useState("");
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
@@ -117,7 +124,7 @@ export const HandleVolumeBackups = ({
|
|||||||
cronExpression: "",
|
cronExpression: "",
|
||||||
volumeName: "",
|
volumeName: "",
|
||||||
prefix: "",
|
prefix: "",
|
||||||
// keepLatestCount: undefined,
|
keepLatestCount: undefined,
|
||||||
turnOff: false,
|
turnOff: false,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
serviceName: "",
|
serviceName: "",
|
||||||
@@ -173,13 +180,18 @@ export const HandleVolumeBackups = ({
|
|||||||
cronExpression: volumeBackup.cronExpression,
|
cronExpression: volumeBackup.cronExpression,
|
||||||
volumeName: volumeBackup.volumeName || "",
|
volumeName: volumeBackup.volumeName || "",
|
||||||
prefix: volumeBackup.prefix,
|
prefix: volumeBackup.prefix,
|
||||||
// keepLatestCount: volumeBackup.keepLatestCount || undefined,
|
keepLatestCount: volumeBackup.keepLatestCount || undefined,
|
||||||
turnOff: volumeBackup.turnOff,
|
turnOff: volumeBackup.turnOff,
|
||||||
enabled: volumeBackup.enabled || false,
|
enabled: volumeBackup.enabled || false,
|
||||||
serviceName: volumeBackup.serviceName || "",
|
serviceName: volumeBackup.serviceName || "",
|
||||||
destinationId: volumeBackup.destinationId,
|
destinationId: volumeBackup.destinationId,
|
||||||
serviceType: volumeBackup.serviceType,
|
serviceType: volumeBackup.serviceType,
|
||||||
});
|
});
|
||||||
|
setKeepLatestCountInput(
|
||||||
|
volumeBackup.keepLatestCount !== null && volumeBackup.keepLatestCount !== undefined
|
||||||
|
? String(volumeBackup.keepLatestCount)
|
||||||
|
: "",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [form, volumeBackup, volumeBackupId]);
|
}, [form, volumeBackup, volumeBackupId]);
|
||||||
|
|
||||||
@@ -190,8 +202,12 @@ export const HandleVolumeBackups = ({
|
|||||||
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||||
if (!id && !volumeBackupId) return;
|
if (!id && !volumeBackupId) return;
|
||||||
|
|
||||||
|
const preparedKeepLatestCount =
|
||||||
|
keepLatestCountInput === "" ? null : values.keepLatestCount ?? null;
|
||||||
|
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
...values,
|
...values,
|
||||||
|
keepLatestCount: preparedKeepLatestCount,
|
||||||
destinationId: values.destinationId,
|
destinationId: values.destinationId,
|
||||||
volumeBackupId: volumeBackupId || "",
|
volumeBackupId: volumeBackupId || "",
|
||||||
serviceType: volumeBackupType,
|
serviceType: volumeBackupType,
|
||||||
@@ -600,29 +616,38 @@ export const HandleVolumeBackups = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* <FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="keepLatestCount"
|
name="keepLatestCount"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Keep Latest Count</FormLabel>
|
<FormLabel>Keep Latest Backups</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
|
||||||
placeholder="5"
|
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(e) =>
|
type="number"
|
||||||
field.onChange(Number(e.target.value) || undefined)
|
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>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Number of backup files to keep (optional)
|
How many recent backups to keep. Empty means no cleanup.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/> */}
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import { findVolumeBackupById } from "@dokploy/server/services/volume-backups";
|
import { findVolumeBackupById } from "@dokploy/server/services/volume-backups";
|
||||||
import { scheduledJobs, scheduleJob } from "node-schedule";
|
import { scheduledJobs, scheduleJob } from "node-schedule";
|
||||||
import {
|
import { createDeploymentVolumeBackup, updateDeploymentStatus } from "@dokploy/server/services/deployment";
|
||||||
createDeploymentVolumeBackup,
|
import { execAsync, execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
|
||||||
execAsync,
|
|
||||||
execAsyncRemote,
|
|
||||||
updateDeploymentStatus,
|
|
||||||
} from "../..";
|
|
||||||
import { backupVolume } from "./backup";
|
import { backupVolume } from "./backup";
|
||||||
|
import { getS3Credentials, normalizeS3Path } from "../backups/utils";
|
||||||
|
|
||||||
export const scheduleVolumeBackup = async (volumeBackupId: string) => {
|
export const scheduleVolumeBackup = async (volumeBackupId: string) => {
|
||||||
const volumeBackup = await findVolumeBackupById(volumeBackupId);
|
const volumeBackup = await findVolumeBackupById(volumeBackupId);
|
||||||
@@ -20,6 +17,33 @@ export const removeVolumeBackupJob = async (volumeBackupId: string) => {
|
|||||||
currentJob?.cancel();
|
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) => {
|
export const runVolumeBackup = async (volumeBackupId: string) => {
|
||||||
const volumeBackup = await findVolumeBackupById(volumeBackupId);
|
const volumeBackup = await findVolumeBackupById(volumeBackupId);
|
||||||
const serverId =
|
const serverId =
|
||||||
@@ -40,6 +64,10 @@ export const runVolumeBackup = async (volumeBackupId: string) => {
|
|||||||
await execAsync(commandWithLog);
|
await execAsync(commandWithLog);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (volumeBackup.keepLatestCount && volumeBackup.keepLatestCount > 0) {
|
||||||
|
await cleanupOldVolumeBackups(volumeBackup, serverId);
|
||||||
|
}
|
||||||
|
|
||||||
await updateDeploymentStatus(deployment.deploymentId, "done");
|
await updateDeploymentStatus(deployment.deploymentId, "done");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await updateDeploymentStatus(deployment.deploymentId, "error");
|
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||||
|
|||||||
Reference in New Issue
Block a user