diff --git a/apps/client/app/api-client/@tanstack/react-query.gen.ts b/apps/client/app/api-client/@tanstack/react-query.gen.ts index f673a5e..b17380d 100644 --- a/apps/client/app/api-client/@tanstack/react-query.gen.ts +++ b/apps/client/app/api-client/@tanstack/react-query.gen.ts @@ -24,6 +24,7 @@ import { getRepository, listSnapshots, listSnapshotFiles, + restoreSnapshot, listBackupSchedules, createBackupSchedule, deleteBackupSchedule, @@ -69,6 +70,8 @@ import type { GetRepositoryData, ListSnapshotsData, ListSnapshotFilesData, + RestoreSnapshotData, + RestoreSnapshotResponse, ListBackupSchedulesData, CreateBackupScheduleData, CreateBackupScheduleResponse, @@ -738,6 +741,46 @@ export const listSnapshotFilesOptions = (options: Options }); }; +export const restoreSnapshotQueryKey = (options: Options) => + createQueryKey("restoreSnapshot", options); + +/** + * Restore a snapshot to a target path on the filesystem + */ +export const restoreSnapshotOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await restoreSnapshot({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: restoreSnapshotQueryKey(options), + }); +}; + +/** + * Restore a snapshot to a target path on the filesystem + */ +export const restoreSnapshotMutation = ( + options?: Partial>, +): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await restoreSnapshot({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + export const listBackupSchedulesQueryKey = (options?: Options) => createQueryKey("listBackupSchedules", options); diff --git a/apps/client/app/api-client/sdk.gen.ts b/apps/client/app/api-client/sdk.gen.ts index b05636e..c83fb68 100644 --- a/apps/client/app/api-client/sdk.gen.ts +++ b/apps/client/app/api-client/sdk.gen.ts @@ -52,6 +52,8 @@ import type { ListSnapshotsResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, + RestoreSnapshotData, + RestoreSnapshotResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, CreateBackupScheduleData, @@ -362,6 +364,22 @@ export const listSnapshotFiles = ( }); }; +/** + * Restore a snapshot to a target path on the filesystem + */ +export const restoreSnapshot = ( + options: Options, +) => { + return (options.client ?? _heyApiClient).post({ + url: "/api/v1/repositories/{name}/restore", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }); +}; + /** * List all backup schedules */ diff --git a/apps/client/app/api-client/types.gen.ts b/apps/client/app/api-client/types.gen.ts index 0f98277..eae4d41 100644 --- a/apps/client/app/api-client/types.gen.ts +++ b/apps/client/app/api-client/types.gen.ts @@ -851,6 +851,36 @@ export type ListSnapshotFilesResponses = { export type ListSnapshotFilesResponse = ListSnapshotFilesResponses[keyof ListSnapshotFilesResponses]; +export type RestoreSnapshotData = { + body?: { + snapshotId: string; + targetPath: string; + exclude?: Array; + include?: Array; + path?: string; + }; + path: { + name: string; + }; + query?: never; + url: "/api/v1/repositories/{name}/restore"; +}; + +export type RestoreSnapshotResponses = { + /** + * Snapshot restored successfully + */ + 200: { + filesRestored: number; + filesUpdated: number; + message: string; + success: boolean; + totalBytes: number; + }; +}; + +export type RestoreSnapshotResponse = RestoreSnapshotResponses[keyof RestoreSnapshotResponses]; + export type ListBackupSchedulesData = { body?: never; path?: never; diff --git a/apps/server/src/modules/repositories/repositories.controller.ts b/apps/server/src/modules/repositories/repositories.controller.ts index f08ec5c..de572f2 100644 --- a/apps/server/src/modules/repositories/repositories.controller.ts +++ b/apps/server/src/modules/repositories/repositories.controller.ts @@ -6,15 +6,19 @@ import { deleteRepositoryDto, getRepositoryDto, listRepositoriesDto, - listSnapshotsDto, - listSnapshotsFilters, listSnapshotFilesDto, listSnapshotFilesQuery, + listSnapshotsDto, + listSnapshotsFilters, + restoreSnapshotBody, + restoreSnapshotDto, + type CreateRepositoryDto, type DeleteRepositoryDto, type GetRepositoryDto, type ListRepositoriesDto, - type ListSnapshotsDto, type ListSnapshotFilesDto, + type ListSnapshotsDto, + type RestoreSnapshotDto, } from "./repositories.dto"; import { repositoriesService } from "./repositories.service"; @@ -86,4 +90,12 @@ export const repositoriesController = new Hono() return c.json(result, 200); }, - ); + ) + .post("/:name/restore", restoreSnapshotDto, validator("json", restoreSnapshotBody), async (c) => { + const { name } = c.req.param(); + const { snapshotId, targetPath, path, include, exclude } = c.req.valid("json"); + + const result = await repositoriesService.restoreSnapshot(name, snapshotId, targetPath, { path, include, exclude }); + + return c.json(result, 200); + }); diff --git a/apps/server/src/modules/repositories/repositories.dto.ts b/apps/server/src/modules/repositories/repositories.dto.ts index 77764de..a11972b 100644 --- a/apps/server/src/modules/repositories/repositories.dto.ts +++ b/apps/server/src/modules/repositories/repositories.dto.ts @@ -213,3 +213,42 @@ export const listSnapshotFilesDto = describeRoute({ }, }, }); + +/** + * Restore a snapshot + */ +export const restoreSnapshotBody = type({ + snapshotId: "string", + targetPath: "string", + path: "string?", + include: "string[]?", + exclude: "string[]?", +}); + +export type RestoreSnapshotBody = typeof restoreSnapshotBody.infer; + +export const restoreSnapshotResponse = type({ + success: "boolean", + message: "string", + filesRestored: "number", + filesUpdated: "number", + totalBytes: "number", +}); + +export type RestoreSnapshotDto = typeof restoreSnapshotResponse.infer; + +export const restoreSnapshotDto = describeRoute({ + description: "Restore a snapshot to a target path on the filesystem", + tags: ["Repositories"], + operationId: "restoreSnapshot", + responses: { + 200: { + description: "Snapshot restored successfully", + content: { + "application/json": { + schema: resolver(restoreSnapshotResponse), + }, + }, + }, + }, +}); diff --git a/apps/server/src/modules/repositories/repositories.service.ts b/apps/server/src/modules/repositories/repositories.service.ts index f950087..b729588 100644 --- a/apps/server/src/modules/repositories/repositories.service.ts +++ b/apps/server/src/modules/repositories/repositories.service.ts @@ -161,6 +161,35 @@ const listSnapshotFiles = async (name: string, snapshotId: string, path?: string }; }; +const restoreSnapshot = async ( + name: string, + snapshotId: string, + targetPath: string, + options?: { + path?: string; + include?: string[]; + exclude?: string[]; + }, +) => { + const repository = await db.query.repositoriesTable.findFirst({ + where: eq(repositoriesTable.name, name), + }); + + if (!repository) { + throw new NotFoundError("Repository not found"); + } + + const result = await restic.restore(repository.config, snapshotId, targetPath, options); + + return { + success: true, + message: "Snapshot restored successfully", + filesRestored: result.files_restored, + filesUpdated: result.files_updated, + totalBytes: result.total_bytes, + }; +}; + export const repositoriesService = { listRepositories, createRepository, @@ -168,4 +197,5 @@ export const repositoriesService = { deleteRepository, listSnapshots, listSnapshotFiles, + restoreSnapshot, }; diff --git a/apps/server/src/utils/restic.ts b/apps/server/src/utils/restic.ts index 1c28362..ca16693 100644 --- a/apps/server/src/utils/restic.ts +++ b/apps/server/src/utils/restic.ts @@ -159,18 +159,90 @@ const backup = async ( return result; }; -const restore = async (config: RepositoryConfig, snapshotId: string, target: string) => { +const restoreOutputSchema = type({ + message_type: "'summary'", + files_restored: "number", + files_updated: "number", + files_unchanged: "number", + total_bytes: "number", + total_errors: "number?", +}); + +const restore = async ( + config: RepositoryConfig, + snapshotId: string, + target: string, + options?: { + include?: string[]; + exclude?: string[]; + path?: string; + }, +) => { const repoUrl = buildRepoUrl(config); const env = await buildEnv(config); - const res = await $`restic --repo ${repoUrl} restore ${snapshotId} --target ${target} --json`.env(env).nothrow(); + const args: string[] = ["--repo", repoUrl, "restore", snapshotId, "--target", target]; + + if (options?.path) { + args[args.length - 4] = `${snapshotId}:${options.path}`; + } + + if (options?.include && options.include.length > 0) { + for (const pattern of options.include) { + args.push("--include", pattern); + } + } + + if (options?.exclude && options.exclude.length > 0) { + for (const pattern of options.exclude) { + args.push("--exclude", pattern); + } + } + + args.push("--json"); + + const res = await $`restic ${args}`.env(env).nothrow(); if (res.exitCode !== 0) { logger.error(`Restic restore failed: ${res.stderr}`); throw new Error(`Restic restore failed: ${res.stderr}`); } - logger.info(`Restic restore completed for snapshot ${snapshotId} to target ${target}`); + const stdout = res.text(); + const outputLines = stdout.trim().split("\n"); + const lastLine = outputLines[outputLines.length - 1]; + + if (!lastLine) { + logger.info(`Restic restore completed for snapshot ${snapshotId} to target ${target}`); + return { + message_type: "summary" as const, + files_restored: 0, + files_updated: 0, + files_unchanged: 0, + total_bytes: 0, + }; + } + + const resSummary = JSON.parse(lastLine); + const result = restoreOutputSchema(resSummary); + + if (result instanceof type.errors) { + logger.warn(`Restic restore output validation failed: ${result}`); + logger.info(`Restic restore completed for snapshot ${snapshotId} to target ${target}`); + return { + message_type: "summary" as const, + files_restored: 0, + files_updated: 0, + files_unchanged: 0, + total_bytes: 0, + }; + } + + logger.info( + `Restic restore completed for snapshot ${snapshotId} to target ${target}: ${result.files_restored} restored, ${result.files_updated} updated`, + ); + + return result; }; const snapshots = async (config: RepositoryConfig) => {