feat(snapshot): backend restore api

This commit is contained in:
Nicolas Meienberger
2025-10-31 21:15:43 +01:00
parent 5846c1ff86
commit c7db88fb56
7 changed files with 251 additions and 7 deletions

View File

@@ -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<ListSnapshotFilesData>
});
};
export const restoreSnapshotQueryKey = (options: Options<RestoreSnapshotData>) =>
createQueryKey("restoreSnapshot", options);
/**
* Restore a snapshot to a target path on the filesystem
*/
export const restoreSnapshotOptions = (options: Options<RestoreSnapshotData>) => {
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<Options<RestoreSnapshotData>>,
): UseMutationOptions<RestoreSnapshotResponse, DefaultError, Options<RestoreSnapshotData>> => {
const mutationOptions: UseMutationOptions<RestoreSnapshotResponse, DefaultError, Options<RestoreSnapshotData>> = {
mutationFn: async (localOptions) => {
const { data } = await restoreSnapshot({
...options,
...localOptions,
throwOnError: true,
});
return data;
},
};
return mutationOptions;
};
export const listBackupSchedulesQueryKey = (options?: Options<ListBackupSchedulesData>) =>
createQueryKey("listBackupSchedules", options);

View File

@@ -52,6 +52,8 @@ import type {
ListSnapshotsResponses,
ListSnapshotFilesData,
ListSnapshotFilesResponses,
RestoreSnapshotData,
RestoreSnapshotResponses,
ListBackupSchedulesData,
ListBackupSchedulesResponses,
CreateBackupScheduleData,
@@ -362,6 +364,22 @@ export const listSnapshotFiles = <ThrowOnError extends boolean = false>(
});
};
/**
* Restore a snapshot to a target path on the filesystem
*/
export const restoreSnapshot = <ThrowOnError extends boolean = false>(
options: Options<RestoreSnapshotData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).post<RestoreSnapshotResponses, unknown, ThrowOnError>({
url: "/api/v1/repositories/{name}/restore",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
};
/**
* List all backup schedules
*/

View File

@@ -851,6 +851,36 @@ export type ListSnapshotFilesResponses = {
export type ListSnapshotFilesResponse = ListSnapshotFilesResponses[keyof ListSnapshotFilesResponses];
export type RestoreSnapshotData = {
body?: {
snapshotId: string;
targetPath: string;
exclude?: Array<string>;
include?: Array<string>;
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;

View File

@@ -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<ListSnapshotFilesDto>(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<RestoreSnapshotDto>(result, 200);
});

View File

@@ -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),
},
},
},
},
});

View File

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

View File

@@ -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) => {