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,
|
deleteRepository,
|
||||||
getRepository,
|
getRepository,
|
||||||
listSnapshots,
|
listSnapshots,
|
||||||
|
listSnapshotFiles,
|
||||||
listBackupSchedules,
|
listBackupSchedules,
|
||||||
createBackupSchedule,
|
createBackupSchedule,
|
||||||
deleteBackupSchedule,
|
deleteBackupSchedule,
|
||||||
@@ -67,6 +68,7 @@ import type {
|
|||||||
DeleteRepositoryResponse,
|
DeleteRepositoryResponse,
|
||||||
GetRepositoryData,
|
GetRepositoryData,
|
||||||
ListSnapshotsData,
|
ListSnapshotsData,
|
||||||
|
ListSnapshotFilesData,
|
||||||
ListBackupSchedulesData,
|
ListBackupSchedulesData,
|
||||||
CreateBackupScheduleData,
|
CreateBackupScheduleData,
|
||||||
CreateBackupScheduleResponse,
|
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>) =>
|
export const listBackupSchedulesQueryKey = (options?: Options<ListBackupSchedulesData>) =>
|
||||||
createQueryKey("listBackupSchedules", options);
|
createQueryKey("listBackupSchedules", options);
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ import type {
|
|||||||
GetRepositoryResponses,
|
GetRepositoryResponses,
|
||||||
ListSnapshotsData,
|
ListSnapshotsData,
|
||||||
ListSnapshotsResponses,
|
ListSnapshotsResponses,
|
||||||
|
ListSnapshotFilesData,
|
||||||
|
ListSnapshotFilesResponses,
|
||||||
ListBackupSchedulesData,
|
ListBackupSchedulesData,
|
||||||
ListBackupSchedulesResponses,
|
ListBackupSchedulesResponses,
|
||||||
CreateBackupScheduleData,
|
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
|
* List all backup schedules
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -788,7 +788,7 @@ export type ListSnapshotsData = {
|
|||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
query?: {
|
query?: {
|
||||||
volumeId?: number;
|
volumeId?: string;
|
||||||
};
|
};
|
||||||
url: "/api/v1/repositories/{name}/snapshots";
|
url: "/api/v1/repositories/{name}/snapshots";
|
||||||
};
|
};
|
||||||
@@ -810,6 +810,47 @@ export type ListSnapshotsResponses = {
|
|||||||
|
|
||||||
export type ListSnapshotsResponse = ListSnapshotsResponses[keyof 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 = {
|
export type ListBackupSchedulesData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const ScheduleSummary = (props: Props) => {
|
|||||||
const { data: snapshots, isLoading: loadingSnapshots } = useQuery({
|
const { data: snapshots, isLoading: loadingSnapshots } = useQuery({
|
||||||
...listSnapshotsOptions({
|
...listSnapshotsOptions({
|
||||||
path: { name: repository.name },
|
path: { name: repository.name },
|
||||||
query: { volumeId: volume.id },
|
query: { volumeId: volume.id.toString() },
|
||||||
}),
|
}),
|
||||||
refetchInterval: 10000,
|
refetchInterval: 10000,
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: true,
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ import {
|
|||||||
listRepositoriesDto,
|
listRepositoriesDto,
|
||||||
listSnapshotsDto,
|
listSnapshotsDto,
|
||||||
listSnapshotsFilters,
|
listSnapshotsFilters,
|
||||||
|
listSnapshotFilesDto,
|
||||||
|
listSnapshotFilesQuery,
|
||||||
type DeleteRepositoryDto,
|
type DeleteRepositoryDto,
|
||||||
type GetRepositoryDto,
|
type GetRepositoryDto,
|
||||||
type ListRepositoriesDto,
|
type ListRepositoriesDto,
|
||||||
type ListSnapshotsDto,
|
type ListSnapshotsDto,
|
||||||
|
type ListSnapshotFilesDto,
|
||||||
} from "./repositories.dto";
|
} from "./repositories.dto";
|
||||||
import { repositoriesService } from "./repositories.service";
|
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");
|
c.header("Cache-Control", "max-age=30, stale-while-revalidate=300");
|
||||||
|
|
||||||
return c.json<ListSnapshotsDto>(response, 200);
|
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;
|
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 = {
|
export const repositoriesService = {
|
||||||
listRepositories,
|
listRepositories,
|
||||||
createRepository,
|
createRepository,
|
||||||
getRepository,
|
getRepository,
|
||||||
deleteRepository,
|
deleteRepository,
|
||||||
listSnapshots,
|
listSnapshots,
|
||||||
|
listSnapshotFiles,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -236,6 +236,86 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy) => {
|
|||||||
return { success: true };
|
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 = {
|
export const restic = {
|
||||||
ensurePassfile,
|
ensurePassfile,
|
||||||
init,
|
init,
|
||||||
@@ -243,4 +323,5 @@ export const restic = {
|
|||||||
restore,
|
restore,
|
||||||
snapshots,
|
snapshots,
|
||||||
forget,
|
forget,
|
||||||
|
ls,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user