mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
feat(snapshot): backend restore api
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
|||||||
getRepository,
|
getRepository,
|
||||||
listSnapshots,
|
listSnapshots,
|
||||||
listSnapshotFiles,
|
listSnapshotFiles,
|
||||||
|
restoreSnapshot,
|
||||||
listBackupSchedules,
|
listBackupSchedules,
|
||||||
createBackupSchedule,
|
createBackupSchedule,
|
||||||
deleteBackupSchedule,
|
deleteBackupSchedule,
|
||||||
@@ -69,6 +70,8 @@ import type {
|
|||||||
GetRepositoryData,
|
GetRepositoryData,
|
||||||
ListSnapshotsData,
|
ListSnapshotsData,
|
||||||
ListSnapshotFilesData,
|
ListSnapshotFilesData,
|
||||||
|
RestoreSnapshotData,
|
||||||
|
RestoreSnapshotResponse,
|
||||||
ListBackupSchedulesData,
|
ListBackupSchedulesData,
|
||||||
CreateBackupScheduleData,
|
CreateBackupScheduleData,
|
||||||
CreateBackupScheduleResponse,
|
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>) =>
|
export const listBackupSchedulesQueryKey = (options?: Options<ListBackupSchedulesData>) =>
|
||||||
createQueryKey("listBackupSchedules", options);
|
createQueryKey("listBackupSchedules", options);
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ import type {
|
|||||||
ListSnapshotsResponses,
|
ListSnapshotsResponses,
|
||||||
ListSnapshotFilesData,
|
ListSnapshotFilesData,
|
||||||
ListSnapshotFilesResponses,
|
ListSnapshotFilesResponses,
|
||||||
|
RestoreSnapshotData,
|
||||||
|
RestoreSnapshotResponses,
|
||||||
ListBackupSchedulesData,
|
ListBackupSchedulesData,
|
||||||
ListBackupSchedulesResponses,
|
ListBackupSchedulesResponses,
|
||||||
CreateBackupScheduleData,
|
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
|
* List all backup schedules
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -851,6 +851,36 @@ export type ListSnapshotFilesResponses = {
|
|||||||
|
|
||||||
export type ListSnapshotFilesResponse = ListSnapshotFilesResponses[keyof 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 = {
|
export type ListBackupSchedulesData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
|
|||||||
@@ -6,15 +6,19 @@ import {
|
|||||||
deleteRepositoryDto,
|
deleteRepositoryDto,
|
||||||
getRepositoryDto,
|
getRepositoryDto,
|
||||||
listRepositoriesDto,
|
listRepositoriesDto,
|
||||||
listSnapshotsDto,
|
|
||||||
listSnapshotsFilters,
|
|
||||||
listSnapshotFilesDto,
|
listSnapshotFilesDto,
|
||||||
listSnapshotFilesQuery,
|
listSnapshotFilesQuery,
|
||||||
|
listSnapshotsDto,
|
||||||
|
listSnapshotsFilters,
|
||||||
|
restoreSnapshotBody,
|
||||||
|
restoreSnapshotDto,
|
||||||
|
type CreateRepositoryDto,
|
||||||
type DeleteRepositoryDto,
|
type DeleteRepositoryDto,
|
||||||
type GetRepositoryDto,
|
type GetRepositoryDto,
|
||||||
type ListRepositoriesDto,
|
type ListRepositoriesDto,
|
||||||
type ListSnapshotsDto,
|
|
||||||
type ListSnapshotFilesDto,
|
type ListSnapshotFilesDto,
|
||||||
|
type ListSnapshotsDto,
|
||||||
|
type RestoreSnapshotDto,
|
||||||
} from "./repositories.dto";
|
} from "./repositories.dto";
|
||||||
import { repositoriesService } from "./repositories.service";
|
import { repositoriesService } from "./repositories.service";
|
||||||
|
|
||||||
@@ -86,4 +90,12 @@ export const repositoriesController = new Hono()
|
|||||||
|
|
||||||
return c.json<ListSnapshotFilesDto>(result, 200);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 = {
|
export const repositoriesService = {
|
||||||
listRepositories,
|
listRepositories,
|
||||||
createRepository,
|
createRepository,
|
||||||
@@ -168,4 +197,5 @@ export const repositoriesService = {
|
|||||||
deleteRepository,
|
deleteRepository,
|
||||||
listSnapshots,
|
listSnapshots,
|
||||||
listSnapshotFiles,
|
listSnapshotFiles,
|
||||||
|
restoreSnapshot,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -159,18 +159,90 @@ const backup = async (
|
|||||||
return result;
|
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 repoUrl = buildRepoUrl(config);
|
||||||
const env = await buildEnv(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) {
|
if (res.exitCode !== 0) {
|
||||||
logger.error(`Restic restore failed: ${res.stderr}`);
|
logger.error(`Restic restore failed: ${res.stderr}`);
|
||||||
throw new Error(`Restic restore failed: ${res.stderr}`);
|
throw new Error(`Restic restore failed: ${res.stderr}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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}`);
|
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) => {
|
const snapshots = async (config: RepositoryConfig) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user