mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
feat(snapshots): list files in snapshots api
This commit is contained in:
@@ -23,6 +23,7 @@ import {
|
||||
deleteRepository,
|
||||
getRepository,
|
||||
listSnapshots,
|
||||
listSnapshotFiles,
|
||||
listBackupSchedules,
|
||||
createBackupSchedule,
|
||||
deleteBackupSchedule,
|
||||
@@ -67,6 +68,7 @@ import type {
|
||||
DeleteRepositoryResponse,
|
||||
GetRepositoryData,
|
||||
ListSnapshotsData,
|
||||
ListSnapshotFilesData,
|
||||
ListBackupSchedulesData,
|
||||
CreateBackupScheduleData,
|
||||
CreateBackupScheduleResponse,
|
||||
@@ -715,6 +717,27 @@ export const listSnapshotsOptions = (options: Options<ListSnapshotsData>) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const listSnapshotFilesQueryKey = (options: Options<ListSnapshotFilesData>) =>
|
||||
createQueryKey("listSnapshotFiles", options);
|
||||
|
||||
/**
|
||||
* List files and directories in a snapshot
|
||||
*/
|
||||
export const listSnapshotFilesOptions = (options: Options<ListSnapshotFilesData>) => {
|
||||
return queryOptions({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await listSnapshotFiles({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: listSnapshotFilesQueryKey(options),
|
||||
});
|
||||
};
|
||||
|
||||
export const listBackupSchedulesQueryKey = (options?: Options<ListBackupSchedulesData>) =>
|
||||
createQueryKey("listBackupSchedules", options);
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@ import type {
|
||||
GetRepositoryResponses,
|
||||
ListSnapshotsData,
|
||||
ListSnapshotsResponses,
|
||||
ListSnapshotFilesData,
|
||||
ListSnapshotFilesResponses,
|
||||
ListBackupSchedulesData,
|
||||
ListBackupSchedulesResponses,
|
||||
CreateBackupScheduleData,
|
||||
@@ -348,6 +350,18 @@ export const listSnapshots = <ThrowOnError extends boolean = false>(
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* List files and directories in a snapshot
|
||||
*/
|
||||
export const listSnapshotFiles = <ThrowOnError extends boolean = false>(
|
||||
options: Options<ListSnapshotFilesData, ThrowOnError>,
|
||||
) => {
|
||||
return (options.client ?? _heyApiClient).get<ListSnapshotFilesResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/repositories/{name}/snapshots/{snapshotId}/files",
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* List all backup schedules
|
||||
*/
|
||||
|
||||
@@ -788,7 +788,7 @@ export type ListSnapshotsData = {
|
||||
name: string;
|
||||
};
|
||||
query?: {
|
||||
volumeId?: number;
|
||||
volumeId?: string;
|
||||
};
|
||||
url: "/api/v1/repositories/{name}/snapshots";
|
||||
};
|
||||
@@ -810,6 +810,47 @@ export type ListSnapshotsResponses = {
|
||||
|
||||
export type ListSnapshotsResponse = ListSnapshotsResponses[keyof ListSnapshotsResponses];
|
||||
|
||||
export type ListSnapshotFilesData = {
|
||||
body?: never;
|
||||
path: {
|
||||
name: string;
|
||||
snapshotId: string;
|
||||
};
|
||||
query?: {
|
||||
path?: string;
|
||||
};
|
||||
url: "/api/v1/repositories/{name}/snapshots/{snapshotId}/files";
|
||||
};
|
||||
|
||||
export type ListSnapshotFilesResponses = {
|
||||
/**
|
||||
* List of files and directories in the snapshot
|
||||
*/
|
||||
200: {
|
||||
files: Array<{
|
||||
name: string;
|
||||
path: string;
|
||||
type: string;
|
||||
atime?: string;
|
||||
ctime?: string;
|
||||
gid?: number;
|
||||
mode?: number;
|
||||
mtime?: string;
|
||||
size?: number;
|
||||
uid?: number;
|
||||
}>;
|
||||
snapshot: {
|
||||
hostname: string;
|
||||
id: string;
|
||||
paths: Array<string>;
|
||||
short_id: string;
|
||||
time: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type ListSnapshotFilesResponse = ListSnapshotFilesResponses[keyof ListSnapshotFilesResponses];
|
||||
|
||||
export type ListBackupSchedulesData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
|
||||
@@ -23,7 +23,7 @@ export const ScheduleSummary = (props: Props) => {
|
||||
const { data: snapshots, isLoading: loadingSnapshots } = useQuery({
|
||||
...listSnapshotsOptions({
|
||||
path: { name: repository.name },
|
||||
query: { volumeId: volume.id },
|
||||
query: { volumeId: volume.id.toString() },
|
||||
}),
|
||||
refetchInterval: 10000,
|
||||
refetchOnWindowFocus: true,
|
||||
|
||||
@@ -8,10 +8,13 @@ import {
|
||||
listRepositoriesDto,
|
||||
listSnapshotsDto,
|
||||
listSnapshotsFilters,
|
||||
listSnapshotFilesDto,
|
||||
listSnapshotFilesQuery,
|
||||
type DeleteRepositoryDto,
|
||||
type GetRepositoryDto,
|
||||
type ListRepositoriesDto,
|
||||
type ListSnapshotsDto,
|
||||
type ListSnapshotFilesDto,
|
||||
} from "./repositories.dto";
|
||||
import { repositoriesService } from "./repositories.service";
|
||||
|
||||
@@ -68,4 +71,14 @@ export const repositoriesController = new Hono()
|
||||
c.header("Cache-Control", "max-age=30, stale-while-revalidate=300");
|
||||
|
||||
return c.json<ListSnapshotsDto>(response, 200);
|
||||
})
|
||||
.get("/:name/snapshots/:snapshotId/files", listSnapshotFilesDto, validator("query", listSnapshotFilesQuery), async (c) => {
|
||||
const { name, snapshotId } = c.req.param();
|
||||
const { path } = c.req.valid("query");
|
||||
|
||||
const result = await repositoriesService.listSnapshotFiles(name, snapshotId, path);
|
||||
|
||||
c.header("Cache-Control", "max-age=300, stale-while-revalidate=600");
|
||||
|
||||
return c.json<ListSnapshotFilesDto>(result, 200);
|
||||
});
|
||||
|
||||
@@ -164,3 +164,52 @@ export const listSnapshotsDto = describeRoute({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* List files in a snapshot
|
||||
*/
|
||||
export const snapshotFileNodeSchema = type({
|
||||
name: "string",
|
||||
type: "string",
|
||||
path: "string",
|
||||
uid: "number?",
|
||||
gid: "number?",
|
||||
size: "number?",
|
||||
mode: "number?",
|
||||
mtime: "string?",
|
||||
atime: "string?",
|
||||
ctime: "string?",
|
||||
});
|
||||
|
||||
export const listSnapshotFilesResponse = type({
|
||||
snapshot: type({
|
||||
id: "string",
|
||||
short_id: "string",
|
||||
time: "string",
|
||||
hostname: "string",
|
||||
paths: "string[]",
|
||||
}),
|
||||
files: snapshotFileNodeSchema.array(),
|
||||
});
|
||||
|
||||
export type ListSnapshotFilesDto = typeof listSnapshotFilesResponse.infer;
|
||||
|
||||
export const listSnapshotFilesQuery = type({
|
||||
path: "string?",
|
||||
});
|
||||
|
||||
export const listSnapshotFilesDto = describeRoute({
|
||||
description: "List files and directories in a snapshot",
|
||||
tags: ["Repositories"],
|
||||
operationId: "listSnapshotFiles",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of files and directories in the snapshot",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(listSnapshotFilesResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -134,10 +134,38 @@ const listSnapshots = async (name: string, volumeId?: number) => {
|
||||
return snapshots;
|
||||
};
|
||||
|
||||
const listSnapshotFiles = async (name: string, snapshotId: string, path?: 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.ls(repository.config, snapshotId, path);
|
||||
|
||||
if (!result.snapshot) {
|
||||
throw new NotFoundError("Snapshot not found or empty");
|
||||
}
|
||||
|
||||
return {
|
||||
snapshot: {
|
||||
id: result.snapshot.id,
|
||||
short_id: result.snapshot.short_id,
|
||||
time: result.snapshot.time,
|
||||
hostname: result.snapshot.hostname,
|
||||
paths: result.snapshot.paths,
|
||||
},
|
||||
files: result.nodes,
|
||||
};
|
||||
};
|
||||
|
||||
export const repositoriesService = {
|
||||
listRepositories,
|
||||
createRepository,
|
||||
getRepository,
|
||||
deleteRepository,
|
||||
listSnapshots,
|
||||
listSnapshotFiles,
|
||||
};
|
||||
|
||||
@@ -236,6 +236,86 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy) => {
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
const lsNodeSchema = type({
|
||||
name: "string",
|
||||
type: "string",
|
||||
path: "string",
|
||||
uid: "number?",
|
||||
gid: "number?",
|
||||
size: "number?",
|
||||
mode: "number?",
|
||||
mtime: "string?",
|
||||
atime: "string?",
|
||||
ctime: "string?",
|
||||
struct_type: "'node'",
|
||||
});
|
||||
|
||||
const lsSnapshotInfoSchema = type({
|
||||
time: "string",
|
||||
parent: "string?",
|
||||
tree: "string",
|
||||
paths: "string[]",
|
||||
hostname: "string",
|
||||
username: "string?",
|
||||
id: "string",
|
||||
short_id: "string",
|
||||
struct_type: "'snapshot'",
|
||||
message_type: "'snapshot'",
|
||||
});
|
||||
|
||||
const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config);
|
||||
|
||||
const args: string[] = ["--repo", repoUrl, "ls", snapshotId, "--json", "--long"];
|
||||
|
||||
if (path) {
|
||||
args.push(path);
|
||||
}
|
||||
|
||||
const res = await $`restic ${args}`.env(env).nothrow();
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic ls failed: ${res.stderr}`);
|
||||
throw new Error(`Restic ls failed: ${res.stderr}`);
|
||||
}
|
||||
|
||||
// The output is a stream of JSON objects, first is snapshot info, rest are file/dir nodes
|
||||
const stdout = res.text();
|
||||
const lines = stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((line) => line.trim());
|
||||
|
||||
if (lines.length === 0) {
|
||||
return { snapshot: null, nodes: [] };
|
||||
}
|
||||
|
||||
// First line is snapshot info
|
||||
const snapshotLine = JSON.parse(lines[0] ?? "{}");
|
||||
const snapshot = lsSnapshotInfoSchema(snapshotLine);
|
||||
|
||||
if (snapshot instanceof type.errors) {
|
||||
logger.error(`Restic ls snapshot info validation failed: ${snapshot}`);
|
||||
throw new Error(`Restic ls snapshot info validation failed: ${snapshot}`);
|
||||
}
|
||||
|
||||
const nodes: Array<typeof lsNodeSchema.infer> = [];
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const nodeLine = JSON.parse(lines[i] ?? "{}");
|
||||
const nodeValidation = lsNodeSchema(nodeLine);
|
||||
|
||||
if (nodeValidation instanceof type.errors) {
|
||||
logger.warn(`Skipping invalid node: ${nodeValidation}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
nodes.push(nodeValidation);
|
||||
}
|
||||
|
||||
return { snapshot, nodes };
|
||||
};
|
||||
|
||||
export const restic = {
|
||||
ensurePassfile,
|
||||
init,
|
||||
@@ -243,4 +323,5 @@ export const restic = {
|
||||
restore,
|
||||
snapshots,
|
||||
forget,
|
||||
ls,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user