refactor(backups): tag snapshots by backup id and run forget by grouping first by tags

This commit is contained in:
Nicolas Meienberger
2025-11-04 20:09:38 +01:00
parent ecd517341c
commit 01c2a3669c
12 changed files with 66 additions and 268 deletions

View File

@@ -32,7 +32,6 @@ import {
getBackupSchedule, getBackupSchedule,
updateBackupSchedule, updateBackupSchedule,
getBackupScheduleForVolume, getBackupScheduleForVolume,
upsertBackupSchedule,
runBackupNow, runBackupNow,
} from "../sdk.gen"; } from "../sdk.gen";
import { queryOptions, type UseMutationOptions, type DefaultError } from "@tanstack/react-query"; import { queryOptions, type UseMutationOptions, type DefaultError } from "@tanstack/react-query";
@@ -83,8 +82,6 @@ import type {
UpdateBackupScheduleData, UpdateBackupScheduleData,
UpdateBackupScheduleResponse, UpdateBackupScheduleResponse,
GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeData,
UpsertBackupScheduleData,
UpsertBackupScheduleResponse,
RunBackupNowData, RunBackupNowData,
RunBackupNowResponse, RunBackupNowResponse,
} from "../types.gen"; } from "../types.gen";
@@ -957,29 +954,6 @@ export const getBackupScheduleForVolumeOptions = (options: Options<GetBackupSche
}); });
}; };
/**
* Create or update a backup schedule for a volume
*/
export const upsertBackupScheduleMutation = (
options?: Partial<Options<UpsertBackupScheduleData>>,
): UseMutationOptions<UpsertBackupScheduleResponse, DefaultError, Options<UpsertBackupScheduleData>> => {
const mutationOptions: UseMutationOptions<
UpsertBackupScheduleResponse,
DefaultError,
Options<UpsertBackupScheduleData>
> = {
mutationFn: async (localOptions) => {
const { data } = await upsertBackupSchedule({
...options,
...localOptions,
throwOnError: true,
});
return data;
},
};
return mutationOptions;
};
export const runBackupNowQueryKey = (options: Options<RunBackupNowData>) => createQueryKey("runBackupNow", options); export const runBackupNowQueryKey = (options: Options<RunBackupNowData>) => createQueryKey("runBackupNow", options);
/** /**

View File

@@ -68,8 +68,6 @@ import type {
UpdateBackupScheduleResponses, UpdateBackupScheduleResponses,
GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeData,
GetBackupScheduleForVolumeResponses, GetBackupScheduleForVolumeResponses,
UpsertBackupScheduleData,
UpsertBackupScheduleResponses,
RunBackupNowData, RunBackupNowData,
RunBackupNowResponses, RunBackupNowResponses,
} from "./types.gen"; } from "./types.gen";
@@ -474,22 +472,6 @@ export const getBackupScheduleForVolume = <ThrowOnError extends boolean = false>
}); });
}; };
/**
* Create or update a backup schedule for a volume
*/
export const upsertBackupSchedule = <ThrowOnError extends boolean = false>(
options?: Options<UpsertBackupScheduleData, ThrowOnError>,
) => {
return (options?.client ?? _heyApiClient).put<UpsertBackupScheduleResponses, unknown, ThrowOnError>({
url: "/api/v1/backups/upsert",
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
});
};
/** /**
* Trigger a backup immediately for a schedule * Trigger a backup immediately for a schedule
*/ */

View File

@@ -782,7 +782,7 @@ export type ListSnapshotsData = {
name: string; name: string;
}; };
query?: { query?: {
volumeId?: string; backupId?: string;
}; };
url: "/api/v1/repositories/{name}/snapshots"; url: "/api/v1/repositories/{name}/snapshots";
}; };
@@ -1181,11 +1181,11 @@ export type GetBackupScheduleResponse = GetBackupScheduleResponses[keyof GetBack
export type UpdateBackupScheduleData = { export type UpdateBackupScheduleData = {
body?: { body?: {
cronExpression?: string; cronExpression: string;
repositoryId: string;
enabled?: boolean; enabled?: boolean;
excludePatterns?: Array<string>; excludePatterns?: Array<string>;
includePatterns?: Array<string>; includePatterns?: Array<string>;
repositoryId?: string;
retentionPolicy?: { retentionPolicy?: {
keepDaily?: number; keepDaily?: number;
keepHourly?: number; keepHourly?: number;
@@ -1342,62 +1342,6 @@ export type GetBackupScheduleForVolumeResponses = {
export type GetBackupScheduleForVolumeResponse = export type GetBackupScheduleForVolumeResponse =
GetBackupScheduleForVolumeResponses[keyof GetBackupScheduleForVolumeResponses]; GetBackupScheduleForVolumeResponses[keyof GetBackupScheduleForVolumeResponses];
export type UpsertBackupScheduleData = {
body?: {
cronExpression: string;
enabled: boolean;
repositoryId: string;
volumeId: number;
excludePatterns?: Array<string>;
includePatterns?: Array<string>;
retentionPolicy?: {
keepDaily?: number;
keepHourly?: number;
keepLast?: number;
keepMonthly?: number;
keepWeekly?: number;
keepWithinDuration?: string;
keepYearly?: number;
};
tags?: Array<string>;
};
path?: never;
query?: never;
url: "/api/v1/backups/upsert";
};
export type UpsertBackupScheduleResponses = {
/**
* Backup schedule upserted successfully
*/
200: {
createdAt: number;
cronExpression: string;
enabled: boolean;
excludePatterns: Array<string> | null;
id: number;
includePatterns: Array<string> | null;
lastBackupAt: number | null;
lastBackupError: string | null;
lastBackupStatus: "error" | "success" | null;
nextBackupAt: number | null;
repositoryId: string;
retentionPolicy: {
keepDaily?: number;
keepHourly?: number;
keepLast?: number;
keepMonthly?: number;
keepWeekly?: number;
keepWithinDuration?: string;
keepYearly?: number;
} | null;
updatedAt: number;
volumeId: number;
};
};
export type UpsertBackupScheduleResponse = UpsertBackupScheduleResponses[keyof UpsertBackupScheduleResponses];
export type RunBackupNowData = { export type RunBackupNowData = {
body?: never; body?: never;
path: { path: {

View File

@@ -6,7 +6,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
import { formatSnapshotDuration } from "~/modules/repositories/tabs/snapshots"; import { formatSnapshotDuration } from "~/modules/repositories/tabs/snapshots";
type Snapshot = ListSnapshotsResponse["snapshots"][0]; type Snapshot = ListSnapshotsResponse[number];
type Props = { type Props = {
snapshots: Snapshot[]; snapshots: Snapshot[];

View File

@@ -4,11 +4,11 @@ import { redirect, useNavigate } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { import {
upsertBackupScheduleMutation,
getBackupScheduleOptions, getBackupScheduleOptions,
runBackupNowMutation, runBackupNowMutation,
deleteBackupScheduleMutation, deleteBackupScheduleMutation,
listSnapshotsOptions, listSnapshotsOptions,
updateBackupScheduleMutation,
} from "~/api-client/@tanstack/react-query.gen"; } from "~/api-client/@tanstack/react-query.gen";
import { parseError } from "~/lib/errors"; import { parseError } from "~/lib/errors";
import { getCronExpression } from "~/utils/utils"; import { getCronExpression } from "~/utils/utils";
@@ -26,7 +26,7 @@ export const clientLoader = async ({ params }: Route.LoaderArgs) => {
const snapshots = await listSnapshots({ const snapshots = await listSnapshots({
path: { name: data.repository.name }, path: { name: data.repository.name },
query: { volumeId: data.volumeId.toString() }, query: { backupId: params.id },
}); });
if (snapshots.data) return { snapshots: snapshots.data, schedule: data }; if (snapshots.data) return { snapshots: snapshots.data, schedule: data };
@@ -49,13 +49,13 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
const { data: snapshots } = useQuery({ const { data: snapshots } = useQuery({
...listSnapshotsOptions({ ...listSnapshotsOptions({
path: { name: schedule.repository.name }, path: { name: schedule.repository.name },
query: { volumeId: schedule.volumeId.toString() }, query: { backupId: schedule.id.toString() },
}), }),
initialData: loaderData.snapshots, initialData: loaderData.snapshots,
}); });
const upsertSchedule = useMutation({ const upsertSchedule = useMutation({
...upsertBackupScheduleMutation(), ...updateBackupScheduleMutation(),
onSuccess: () => { onSuccess: () => {
toast.success("Backup schedule saved successfully"); toast.success("Backup schedule saved successfully");
setIsEditMode(false); setIsEditMode(false);
@@ -106,8 +106,8 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
if (formValues.keepYearly) retentionPolicy.keepYearly = formValues.keepYearly; if (formValues.keepYearly) retentionPolicy.keepYearly = formValues.keepYearly;
upsertSchedule.mutate({ upsertSchedule.mutate({
path: { scheduleId: schedule.id.toString() },
body: { body: {
volumeId: schedule.volumeId,
repositoryId: formValues.repositoryId, repositoryId: formValues.repositoryId,
enabled: schedule.enabled, enabled: schedule.enabled,
cronExpression, cronExpression,
@@ -122,8 +122,8 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
if (!schedule) return; if (!schedule) return;
upsertSchedule.mutate({ upsertSchedule.mutate({
path: { scheduleId: schedule.id.toString() },
body: { body: {
volumeId: schedule.volumeId,
repositoryId: schedule.repositoryId, repositoryId: schedule.repositoryId,
enabled, enabled,
cronExpression: schedule.cronExpression, cronExpression: schedule.cronExpression,

View File

@@ -8,10 +8,8 @@ import {
getBackupScheduleForVolumeDto, getBackupScheduleForVolumeDto,
listBackupSchedulesDto, listBackupSchedulesDto,
runBackupNowDto, runBackupNowDto,
updateBackupScheduleBody,
updateBackupScheduleDto, updateBackupScheduleDto,
upsertBackupScheduleBody, updateBackupScheduleBody,
upsertBackupScheduleDto,
type CreateBackupScheduleDto, type CreateBackupScheduleDto,
type DeleteBackupScheduleDto, type DeleteBackupScheduleDto,
type GetBackupScheduleDto, type GetBackupScheduleDto,
@@ -19,7 +17,6 @@ import {
type ListBackupSchedulesResponseDto, type ListBackupSchedulesResponseDto,
type RunBackupNowDto, type RunBackupNowDto,
type UpdateBackupScheduleDto, type UpdateBackupScheduleDto,
type UpsertBackupScheduleDto,
} from "./backups.dto"; } from "./backups.dto";
import { backupsService } from "./backups.service"; import { backupsService } from "./backups.service";
@@ -57,13 +54,6 @@ export const backupScheduleController = new Hono()
return c.json<UpdateBackupScheduleDto>(schedule, 200); return c.json<UpdateBackupScheduleDto>(schedule, 200);
}) })
.put("/upsert", upsertBackupScheduleDto, validator("json", upsertBackupScheduleBody), async (c) => {
const body = c.req.valid("json");
const schedule = await backupsService.upsertSchedule(body);
return c.json<UpsertBackupScheduleDto>(schedule, 200);
})
.delete("/:scheduleId", deleteBackupScheduleDto, async (c) => { .delete("/:scheduleId", deleteBackupScheduleDto, async (c) => {
const scheduleId = c.req.param("scheduleId"); const scheduleId = c.req.param("scheduleId");
@@ -74,7 +64,7 @@ export const backupScheduleController = new Hono()
.post("/:scheduleId/run", runBackupNowDto, async (c) => { .post("/:scheduleId/run", runBackupNowDto, async (c) => {
const scheduleId = c.req.param("scheduleId"); const scheduleId = c.req.param("scheduleId");
backupsService.executeBackup(Number(scheduleId)).catch((error) => { backupsService.executeBackup(Number(scheduleId), true).catch((error) => {
console.error("Backup execution failed:", error); console.error("Backup execution failed:", error);
}); });

View File

@@ -143,9 +143,9 @@ export const createBackupScheduleDto = describeRoute({
* Update a backup schedule * Update a backup schedule
*/ */
export const updateBackupScheduleBody = type({ export const updateBackupScheduleBody = type({
repositoryId: "string?", repositoryId: "string",
enabled: "boolean?", enabled: "boolean?",
cronExpression: "string?", cronExpression: "string",
retentionPolicy: retentionPolicySchema.optional(), retentionPolicy: retentionPolicySchema.optional(),
excludePatterns: "string[]?", excludePatterns: "string[]?",
includePatterns: "string[]?", includePatterns: "string[]?",
@@ -174,42 +174,6 @@ export const updateBackupScheduleDto = describeRoute({
}, },
}); });
/**
* Upsert a backup schedule (create or update)
*/
export const upsertBackupScheduleBody = type({
volumeId: "number",
repositoryId: "string",
enabled: "boolean",
cronExpression: "string",
retentionPolicy: retentionPolicySchema.optional(),
excludePatterns: "string[]?",
includePatterns: "string[]?",
tags: "string[]?",
});
export type UpsertBackupScheduleBody = typeof upsertBackupScheduleBody.infer;
export const upsertBackupScheduleResponse = backupScheduleSchema.omit("volume", "repository");
export type UpsertBackupScheduleDto = typeof upsertBackupScheduleResponse.infer;
export const upsertBackupScheduleDto = describeRoute({
description: "Create or update a backup schedule for a volume",
operationId: "upsertBackupSchedule",
tags: ["Backups"],
responses: {
200: {
description: "Backup schedule upserted successfully",
content: {
"application/json": {
schema: resolver(upsertBackupScheduleResponse),
},
},
},
},
});
/** /**
* Delete a backup schedule * Delete a backup schedule
*/ */

View File

@@ -7,7 +7,7 @@ import { backupSchedulesTable, repositoriesTable, volumesTable } from "../../db/
import { restic } from "../../utils/restic"; import { restic } from "../../utils/restic";
import { logger } from "../../utils/logger"; import { logger } from "../../utils/logger";
import { getVolumePath } from "../volumes/helpers"; import { getVolumePath } from "../volumes/helpers";
import type { CreateBackupScheduleBody, UpdateBackupScheduleBody, UpsertBackupScheduleBody } from "./backups.dto"; import type { CreateBackupScheduleBody, UpdateBackupScheduleBody } from "./backups.dto";
import { toMessage } from "../../utils/errors"; import { toMessage } from "../../utils/errors";
const calculateNextRun = (cronExpression: string): number => { const calculateNextRun = (cronExpression: string): number => {
@@ -109,14 +109,12 @@ const updateSchedule = async (scheduleId: number, data: UpdateBackupScheduleBody
throw new BadRequestError("Invalid cron expression"); throw new BadRequestError("Invalid cron expression");
} }
if (data.repositoryId) { const repository = await db.query.repositoriesTable.findFirst({
const repository = await db.query.repositoriesTable.findFirst({ where: eq(repositoriesTable.id, data.repositoryId),
where: eq(repositoriesTable.id, data.repositoryId), });
});
if (!repository) { if (!repository) {
throw new NotFoundError("Repository not found"); throw new NotFoundError("Repository not found");
}
} }
const cronExpression = data.cronExpression ?? schedule.cronExpression; const cronExpression = data.cronExpression ?? schedule.cronExpression;
@@ -147,7 +145,7 @@ const deleteSchedule = async (scheduleId: number) => {
await db.delete(backupSchedulesTable).where(eq(backupSchedulesTable.id, scheduleId)); await db.delete(backupSchedulesTable).where(eq(backupSchedulesTable.id, scheduleId));
}; };
const executeBackup = async (scheduleId: number) => { const executeBackup = async (scheduleId: number, manual = false) => {
const schedule = await db.query.backupSchedulesTable.findFirst({ const schedule = await db.query.backupSchedulesTable.findFirst({
where: eq(backupSchedulesTable.id, scheduleId), where: eq(backupSchedulesTable.id, scheduleId),
}); });
@@ -156,7 +154,7 @@ const executeBackup = async (scheduleId: number) => {
throw new NotFoundError("Backup schedule not found"); throw new NotFoundError("Backup schedule not found");
} }
if (!schedule.enabled) { if (!schedule.enabled && !manual) {
logger.info(`Backup schedule ${scheduleId} is disabled. Skipping execution.`); logger.info(`Backup schedule ${scheduleId} is disabled. Skipping execution.`);
return; return;
} }
@@ -190,7 +188,9 @@ const executeBackup = async (scheduleId: number) => {
exclude?: string[]; exclude?: string[];
include?: string[]; include?: string[];
tags?: string[]; tags?: string[];
} = {}; } = {
tags: [schedule.id.toString()],
};
if (schedule.excludePatterns && schedule.excludePatterns.length > 0) { if (schedule.excludePatterns && schedule.excludePatterns.length > 0) {
backupOptions.exclude = schedule.excludePatterns; backupOptions.exclude = schedule.excludePatterns;
@@ -203,7 +203,7 @@ const executeBackup = async (scheduleId: number) => {
await restic.backup(repository.config, volumePath, backupOptions); await restic.backup(repository.config, volumePath, backupOptions);
if (schedule.retentionPolicy) { if (schedule.retentionPolicy) {
await restic.forget(repository.config, schedule.retentionPolicy); await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.id.toString() });
} }
const nextBackupAt = calculateNextRun(schedule.cronExpression); const nextBackupAt = calculateNextRun(schedule.cronExpression);
@@ -262,77 +262,6 @@ const getScheduleForVolume = async (volumeId: number) => {
return schedule ?? null; return schedule ?? null;
}; };
const upsertSchedule = async (data: UpsertBackupScheduleBody) => {
if (!cron.validate(data.cronExpression)) {
throw new BadRequestError("Invalid cron expression");
}
const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.id, data.volumeId),
});
if (!volume) {
throw new NotFoundError("Volume not found");
}
const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.id, data.repositoryId),
});
if (!repository) {
throw new NotFoundError("Repository not found");
}
const existingSchedule = await db.query.backupSchedulesTable.findFirst({
where: eq(backupSchedulesTable.volumeId, data.volumeId),
});
const nextBackupAt = calculateNextRun(data.cronExpression);
if (existingSchedule) {
const [updated] = await db
.update(backupSchedulesTable)
.set({
repositoryId: data.repositoryId,
enabled: data.enabled,
cronExpression: data.cronExpression,
retentionPolicy: data.retentionPolicy ?? null,
excludePatterns: data.excludePatterns ?? [],
includePatterns: data.includePatterns ?? [],
nextBackupAt: nextBackupAt,
updatedAt: Date.now(),
})
.where(eq(backupSchedulesTable.id, existingSchedule.id))
.returning();
if (!updated) {
throw new Error("Failed to update backup schedule");
}
return updated;
}
const [newSchedule] = await db
.insert(backupSchedulesTable)
.values({
volumeId: data.volumeId,
repositoryId: data.repositoryId,
enabled: data.enabled,
cronExpression: data.cronExpression,
retentionPolicy: data.retentionPolicy ?? null,
excludePatterns: data.excludePatterns ?? [],
includePatterns: data.includePatterns ?? [],
nextBackupAt: nextBackupAt,
})
.returning();
if (!newSchedule) {
throw new Error("Failed to create backup schedule");
}
return newSchedule;
};
export const backupsService = { export const backupsService = {
listSchedules, listSchedules,
getSchedule, getSchedule,
@@ -342,5 +271,4 @@ export const backupsService = {
executeBackup, executeBackup,
getSchedulesToExecute, getSchedulesToExecute,
getScheduleForVolume, getScheduleForVolume,
upsertSchedule,
}; };

View File

@@ -49,9 +49,9 @@ export const repositoriesController = new Hono()
}) })
.get("/:name/snapshots", listSnapshotsDto, validator("query", listSnapshotsFilters), async (c) => { .get("/:name/snapshots", listSnapshotsDto, validator("query", listSnapshotsFilters), async (c) => {
const { name } = c.req.param(); const { name } = c.req.param();
const { volumeId } = c.req.valid("query"); const { backupId } = c.req.valid("query");
const res = await repositoriesService.listSnapshots(name, Number(volumeId)); const res = await repositoriesService.listSnapshots(name, backupId);
const snapshots = res.map((snapshot) => { const snapshots = res.map((snapshot) => {
const { summary } = snapshot; const { summary } = snapshot;

View File

@@ -144,7 +144,7 @@ const listSnapshotsResponse = snapshotSchema.array();
export type ListSnapshotsDto = typeof listSnapshotsResponse.infer; export type ListSnapshotsDto = typeof listSnapshotsResponse.infer;
export const listSnapshotsFilters = type({ export const listSnapshotsFilters = type({
volumeId: "string?", backupId: "string?",
}); });
export const listSnapshotsDto = describeRoute({ export const listSnapshotsDto = describeRoute({

View File

@@ -8,7 +8,6 @@ import { repositoriesTable, volumesTable } from "../../db/schema";
import { toMessage } from "../../utils/errors"; import { toMessage } from "../../utils/errors";
import { restic } from "../../utils/restic"; import { restic } from "../../utils/restic";
import { cryptoUtils } from "../../utils/crypto"; import { cryptoUtils } from "../../utils/crypto";
import { getVolumePath } from "../volumes/helpers";
const listRepositories = async () => { const listRepositories = async () => {
const repositories = await db.query.repositoriesTable.findMany({}); const repositories = await db.query.repositoriesTable.findMany({});
@@ -106,7 +105,15 @@ const deleteRepository = async (name: string) => {
await db.delete(repositoriesTable).where(eq(repositoriesTable.name, name)); await db.delete(repositoriesTable).where(eq(repositoriesTable.name, name));
}; };
const listSnapshots = async (name: string, volumeId?: number) => { /**
* List snapshots for a given repository
* If backupId is provided, filter snapshots by that backup ID (tag)
* @param name Repository name
* @param backupId Optional backup ID to filter snapshots for a specific backup schedule
*
* @returns List of snapshots
*/
const listSnapshots = async (name: string, backupId?: string) => {
const repository = await db.query.repositoriesTable.findFirst({ const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.name, name), where: eq(repositoriesTable.name, name),
}); });
@@ -115,20 +122,12 @@ const listSnapshots = async (name: string, volumeId?: number) => {
throw new NotFoundError("Repository not found"); throw new NotFoundError("Repository not found");
} }
let snapshots = await restic.snapshots(repository.config); let snapshots = [];
if (volumeId) { if (backupId) {
const volume = await db.query.volumesTable.findFirst({ snapshots = await restic.snapshots(repository.config, { tags: [backupId.toString()] });
where: eq(volumesTable.id, volumeId), } else {
}); snapshots = await restic.snapshots(repository.config);
if (!volume) {
throw new NotFoundError("Volume not found");
}
snapshots = snapshots.filter((snapshot) => {
return snapshot.paths.some((path) => path.includes(getVolumePath(volume.name)));
});
} }
return snapshots; return snapshots;

View File

@@ -114,13 +114,19 @@ const init = async (config: RepositoryConfig) => {
const backup = async ( const backup = async (
config: RepositoryConfig, config: RepositoryConfig,
source: string, source: string,
options?: { exclude?: string[]; include?: string[] }, options?: { exclude?: string[]; include?: string[]; tags?: string[] },
) => { ) => {
const repoUrl = buildRepoUrl(config); const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config); const env = await buildEnv(config);
const args: string[] = ["--repo", repoUrl, "backup", "--one-file-system"]; const args: string[] = ["--repo", repoUrl, "backup", "--one-file-system"];
if (options?.tags && options.tags.length > 0) {
for (const tag of options.tags) {
args.push("--tag", tag);
}
}
let includeFile: string | null = null; let includeFile: string | null = null;
if (options?.include && options.include.length > 0) { if (options?.include && options.include.length > 0) {
const tmp = await fs.mkdtemp("restic-include"); const tmp = await fs.mkdtemp("restic-include");
@@ -263,11 +269,23 @@ const restore = async (
return result; return result;
}; };
const snapshots = async (config: RepositoryConfig) => { const snapshots = async (config: RepositoryConfig, options: { tags?: string[] } = {}) => {
const { tags } = options;
const repoUrl = buildRepoUrl(config); const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config); const env = await buildEnv(config);
const res = await $`restic --repo ${repoUrl} snapshots --json`.env(env).nothrow(); const args = ["--repo", repoUrl, "snapshots"];
if (tags && tags.length > 0) {
for (const tag of tags) {
args.push("--tag", tag);
}
}
args.push("--json");
const res = await $`restic ${args}`.env(env).nothrow();
if (res.exitCode !== 0) { if (res.exitCode !== 0) {
logger.error(`Restic snapshots retrieval failed: ${res.stderr}`); logger.error(`Restic snapshots retrieval failed: ${res.stderr}`);
@@ -284,11 +302,11 @@ const snapshots = async (config: RepositoryConfig) => {
return result; return result;
}; };
const forget = async (config: RepositoryConfig, options: RetentionPolicy) => { const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra: { tag: string }) => {
const repoUrl = buildRepoUrl(config); const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config); const env = await buildEnv(config);
const args: string[] = ["--repo", repoUrl, "forget"]; const args: string[] = ["--repo", repoUrl, "forget", "--group-by", "tags", "--tag", extra.tag];
if (options.keepLast) { if (options.keepLast) {
args.push("--keep-last", String(options.keepLast)); args.push("--keep-last", String(options.keepLast));
@@ -322,7 +340,6 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy) => {
throw new Error(`Restic forget failed: ${res.stderr}`); throw new Error(`Restic forget failed: ${res.stderr}`);
} }
logger.info("Restic forget completed successfully");
return { success: true }; return { success: true };
}; };