From 8fcd446926cecb9485ede72f85fd72a9a9319156 Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:45:19 +0100 Subject: [PATCH] refactor: snapshot strip out base path (#542) * refactor: strip out volume path in snapshot list / restore chore: lint issue * test: backups new include patterns --- app/client/components/restore-form.tsx | 9 +- app/client/lib/volume-path.test.ts | 28 ++++ app/client/lib/volume-path.ts | 11 ++ .../components/snapshot-file-browser.tsx | 85 ++++++++--- .../repositories/routes/restore-snapshot.tsx | 8 +- .../repositories/routes/snapshot-details.tsx | 42 +++--- .../backups/$backupId/$snapshotId.restore.tsx | 16 ++- .../$repositoryId/$snapshotId/index.tsx | 20 +-- .../$repositoryId/$snapshotId/restore.tsx | 21 ++- .../__tests__/backups.controller.test.ts | 23 +++ .../backups/__tests__/backups.service.test.ts | 42 ++++++ .../modules/backups/backups.controller.ts | 2 +- app/server/modules/backups/backups.service.ts | 21 +++ .../repositories/repositories.service.ts | 5 + app/server/utils/common-ancestor.test.ts | 34 +++++ app/server/utils/restic.test.ts | 134 +++++++++++++++++- app/server/utils/restic.ts | 34 ++++- app/utils/common-ancestor.ts | 8 +- 18 files changed, 462 insertions(+), 81 deletions(-) create mode 100644 app/client/lib/volume-path.test.ts create mode 100644 app/client/lib/volume-path.ts create mode 100644 app/server/utils/common-ancestor.test.ts diff --git a/app/client/components/restore-form.tsx b/app/client/components/restore-form.tsx index 49e9f9b4..63f5970b 100644 --- a/app/client/components/restore-form.tsx +++ b/app/client/components/restore-form.tsx @@ -21,25 +21,24 @@ import { RestoreProgress } from "~/client/components/restore-progress"; import { restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { type RestoreCompletedEvent, useServerEvents } from "~/client/hooks/use-server-events"; import { OVERWRITE_MODES, type OverwriteMode } from "~/schemas/restic"; -import type { Repository, Snapshot } from "~/client/lib/types"; +import type { Repository } from "~/client/lib/types"; import { handleRepositoryError } from "~/client/lib/errors"; import { useNavigate } from "@tanstack/react-router"; -import { findCommonAncestor } from "~/utils/common-ancestor"; type RestoreLocation = "original" | "custom"; interface RestoreFormProps { - snapshot: Snapshot; repository: Repository; snapshotId: string; returnPath: string; + basePath?: string; } -export function RestoreForm({ snapshot, repository, snapshotId, returnPath }: RestoreFormProps) { +export function RestoreForm({ repository, snapshotId, returnPath, basePath }: RestoreFormProps) { const navigate = useNavigate(); const { addEventListener } = useServerEvents(); - const volumeBasePath = findCommonAncestor(snapshot.paths); + const volumeBasePath = basePath ?? "/"; const [restoreLocation, setRestoreLocation] = useState("original"); const [customTargetPath, setCustomTargetPath] = useState(""); diff --git a/app/client/lib/volume-path.test.ts b/app/client/lib/volume-path.test.ts new file mode 100644 index 00000000..a0369ca8 --- /dev/null +++ b/app/client/lib/volume-path.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; +import { getVolumeMountPath } from "./volume-path"; +import { fromAny } from "@total-typescript/shoehorn"; + +describe("getVolumeMountPath", () => { + test("returns the configured path for directory volumes", () => { + const volume = { + shortId: "abc123", + config: { + backend: "directory", + path: "/mnt/data/projects", + }, + }; + + expect(getVolumeMountPath(fromAny(volume))).toBe("/mnt/data/projects"); + }); + + test("returns the mounted data path for non-directory volumes", () => { + const volume = { + shortId: "vol789", + config: { + backend: "nfs", + }, + }; + + expect(getVolumeMountPath(fromAny(volume))).toBe("/var/lib/zerobyte/volumes/vol789/_data"); + }); +}); diff --git a/app/client/lib/volume-path.ts b/app/client/lib/volume-path.ts new file mode 100644 index 00000000..bcb56fe6 --- /dev/null +++ b/app/client/lib/volume-path.ts @@ -0,0 +1,11 @@ +import type { Volume } from "./types"; + +const VOLUME_MOUNT_BASE = "/var/lib/zerobyte/volumes"; + +export const getVolumeMountPath = (volume: Volume): string => { + if (volume.config.backend === "directory") { + return volume.config.path; + } + + return `${VOLUME_MOUNT_BASE}/${volume.shortId}/_data`; +}; diff --git a/app/client/modules/backups/components/snapshot-file-browser.tsx b/app/client/modules/backups/components/snapshot-file-browser.tsx index 078a126d..8bf81ba7 100644 --- a/app/client/modules/backups/components/snapshot-file-browser.tsx +++ b/app/client/modules/backups/components/snapshot-file-browser.tsx @@ -1,4 +1,5 @@ import { RotateCcw, Trash2 } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Button, buttonVariants } from "~/client/components/ui/button"; import type { Snapshot } from "~/client/lib/types"; @@ -6,20 +7,63 @@ import { formatDateTime } from "~/client/lib/datetime"; import { cn } from "~/client/lib/utils"; import { Link } from "@tanstack/react-router"; import { SnapshotTreeBrowser } from "~/client/components/file-browsers/snapshot-tree-browser"; -import { findCommonAncestor } from "~/utils/common-ancestor"; +import { getBackupScheduleOptions } from "~/client/api-client/@tanstack/react-query.gen"; +import { getVolumeMountPath } from "~/client/lib/volume-path"; interface Props { snapshot: Snapshot; repositoryId: string; backupId?: string; + basePath?: string; onDeleteSnapshot?: (snapshotId: string) => void; isDeletingSnapshot?: boolean; } -export const SnapshotFileBrowser = (props: Props) => { - const { snapshot, repositoryId, backupId, onDeleteSnapshot, isDeletingSnapshot } = props; +const treeProps = { + pageSize: 500, + className: "flex flex-1 min-h-0 flex-col", + treeContainerClassName: "overflow-auto flex-1 min-h-0 border border-border rounded-md bg-card m-4", + treeClassName: "px-2 py-2", + emptyMessage: "No files in this snapshot", + stateClassName: "flex-1 min-h-0", +} as const; - const volumeBasePath = findCommonAncestor(snapshot.paths); +interface ScheduleAwareTreeBrowserProps { + scheduleShortId: string; + repositoryId: string; + snapshotId: string; +} + +const ScheduleAwareTreeBrowser = ({ scheduleShortId, repositoryId, snapshotId }: ScheduleAwareTreeBrowserProps) => { + const { data: schedule, isPending } = useQuery({ + ...getBackupScheduleOptions({ path: { scheduleId: scheduleShortId } }), + retry: false, + }); + + if (isPending) { + return ; + } + + return ( + + ); +}; + +const TreeBrowserFallback = () => ( +
+

Loading volume info...

+
+); + +export const SnapshotFileBrowser = (props: Props) => { + const { snapshot, repositoryId, backupId, basePath, onDeleteSnapshot, isDeletingSnapshot } = props; + + const scheduleShortId = !basePath ? backupId || snapshot.tags?.[0] : undefined; return (
@@ -65,18 +109,27 @@ export const SnapshotFileBrowser = (props: Props) => {
- + {basePath ? ( + + ) : scheduleShortId ? ( + + ) : ( + + )} diff --git a/app/client/modules/repositories/routes/restore-snapshot.tsx b/app/client/modules/repositories/routes/restore-snapshot.tsx index 8bad0ec0..2459aa21 100644 --- a/app/client/modules/repositories/routes/restore-snapshot.tsx +++ b/app/client/modules/repositories/routes/restore-snapshot.tsx @@ -1,15 +1,15 @@ import { RestoreForm } from "~/client/components/restore-form"; -import type { Repository, Snapshot } from "~/client/lib/types"; +import type { Repository } from "~/client/lib/types"; type Props = { - snapshot: Snapshot; repository: Repository; snapshotId: string; returnPath: string; + basePath?: string; }; export function RestoreSnapshotPage(props: Props) { - const { snapshot, returnPath, snapshotId, repository } = props; + const { returnPath, snapshotId, repository, basePath } = props; - return ; + return ; } diff --git a/app/client/modules/repositories/routes/snapshot-details.tsx b/app/client/modules/repositories/routes/snapshot-details.tsx index c423f852..4ebd2fd4 100644 --- a/app/client/modules/repositories/routes/snapshot-details.tsx +++ b/app/client/modules/repositories/routes/snapshot-details.tsx @@ -12,6 +12,7 @@ import { BackupSummaryCard } from "~/client/components/backup-summary-card"; import { useState } from "react"; import { Database } from "lucide-react"; import { Link, useParams } from "@tanstack/react-router"; +import { getVolumeMountPath } from "~/client/lib/volume-path"; export const SnapshotError = () => { const { repositoryId } = useParams({ from: "/(dashboard)/repositories/$repositoryId/$snapshotId/" }); @@ -33,23 +34,20 @@ export const SnapshotError = () => { ); }; -const FilebrowserFallback = ({ repositoryId, snapshotId }: { repositoryId: string; snapshotId: string }) => { - return ( - - ); -}; +const SnapshotFileBrowserSkeleton = () => ( +
+ + + File Browser + + +
+

Loading snapshot...

+
+
+
+
+); export function SnapshotDetailsPage({ repositoryId, snapshotId }: { repositoryId: string; snapshotId: string }) { const [showAllPaths, setShowAllPaths] = useState(false); @@ -65,7 +63,7 @@ export function SnapshotDetailsPage({ repositoryId, snapshotId }: { repositoryId const { data, error } = useQuery({ ...getSnapshotDetailsOptions({ path: { id: repositoryId, snapshotId: snapshotId } }), }); - const backupSchedule = schedules?.find((s) => data?.tags.includes(s.shortId)); + const backupSchedule = schedules?.find((s) => data?.tags?.includes(s.shortId)); if (error) { return ( @@ -91,9 +89,13 @@ export function SnapshotDetailsPage({ repositoryId, snapshotId }: { repositoryId {data ? ( - + ) : ( - + )} {data && ( diff --git a/app/routes/(dashboard)/backups/$backupId/$snapshotId.restore.tsx b/app/routes/(dashboard)/backups/$backupId/$snapshotId.restore.tsx index 0103e57b..dd01f8ae 100644 --- a/app/routes/(dashboard)/backups/$backupId/$snapshotId.restore.tsx +++ b/app/routes/(dashboard)/backups/$backupId/$snapshotId.restore.tsx @@ -2,6 +2,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { getBackupSchedule } from "~/client/api-client"; import { getRepositoryOptions, getSnapshotDetailsOptions } from "~/client/api-client/@tanstack/react-query.gen"; import { RestoreSnapshotPage } from "~/client/modules/repositories/routes/restore-snapshot"; +import { getVolumeMountPath } from "~/client/lib/volume-path"; export const Route = createFileRoute("/(dashboard)/backups/$backupId/$snapshotId/restore")({ component: RouteComponent, @@ -17,9 +18,14 @@ export const Route = createFileRoute("/(dashboard)/backups/$backupId/$snapshotId ...getSnapshotDetailsOptions({ path: { id: schedule.data?.repositoryId, snapshotId: params.snapshotId } }), }), context.queryClient.ensureQueryData({ ...getRepositoryOptions({ path: { id: schedule.data?.repositoryId } }) }), - ]) + ]); - return { snapshot, repository, schedule: schedule.data }; + return { + snapshot, + repository, + schedule: schedule.data, + basePath: getVolumeMountPath(schedule.data.volume), + }; }, head: ({ params }) => ({ meta: [ @@ -42,14 +48,14 @@ export const Route = createFileRoute("/(dashboard)/backups/$backupId/$snapshotId function RouteComponent() { const { backupId, snapshotId } = Route.useParams(); - const { snapshot, repository } = Route.useLoaderData(); + const { repository, basePath } = Route.useLoaderData(); return ( - ) + ); } diff --git a/app/routes/(dashboard)/repositories/$repositoryId/$snapshotId/index.tsx b/app/routes/(dashboard)/repositories/$repositoryId/$snapshotId/index.tsx index be3a7130..4681e522 100644 --- a/app/routes/(dashboard)/repositories/$repositoryId/$snapshotId/index.tsx +++ b/app/routes/(dashboard)/repositories/$repositoryId/$snapshotId/index.tsx @@ -1,9 +1,5 @@ import { createFileRoute } from "@tanstack/react-router"; -import { - getRepositoryOptions, - getSnapshotDetailsOptions, - listSnapshotFilesOptions, -} from "~/client/api-client/@tanstack/react-query.gen"; +import { getRepositoryOptions } from "~/client/api-client/@tanstack/react-query.gen"; import { SnapshotDetailsPage } from "~/client/modules/repositories/routes/snapshot-details"; export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$snapshotId/")({ @@ -12,19 +8,7 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$s loader: async ({ params, context }) => { const res = await context.queryClient.ensureQueryData({ ...getRepositoryOptions({ path: { id: params.repositoryId } }), - }) - - void context.queryClient.prefetchQuery({ - ...getSnapshotDetailsOptions({ - path: { id: params.repositoryId, snapshotId: params.snapshotId }, - }), - }) - void context.queryClient.prefetchQuery({ - ...listSnapshotFilesOptions({ - path: { id: params.repositoryId, snapshotId: params.snapshotId }, - query: { path: "/" }, - }), - }) + }); return res; }, diff --git a/app/routes/(dashboard)/repositories/$repositoryId/$snapshotId/restore.tsx b/app/routes/(dashboard)/repositories/$repositoryId/$snapshotId/restore.tsx index 9c1ff381..eceaca38 100644 --- a/app/routes/(dashboard)/repositories/$repositoryId/$snapshotId/restore.tsx +++ b/app/routes/(dashboard)/repositories/$repositoryId/$snapshotId/restore.tsx @@ -1,6 +1,8 @@ import { createFileRoute } from "@tanstack/react-router"; +import { getBackupSchedule } from "~/client/api-client"; import { getRepositoryOptions, getSnapshotDetailsOptions } from "~/client/api-client/@tanstack/react-query.gen"; import { RestoreSnapshotPage } from "~/client/modules/repositories/routes/restore-snapshot"; +import { getVolumeMountPath } from "~/client/lib/volume-path"; export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$snapshotId/restore")({ component: RouteComponent, @@ -11,9 +13,18 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$s ...getSnapshotDetailsOptions({ path: { id: params.repositoryId, snapshotId: params.snapshotId } }), }), context.queryClient.ensureQueryData({ ...getRepositoryOptions({ path: { id: params.repositoryId } }) }), - ]) + ]); - return { snapshot, repository }; + let basePath: string | undefined; + const scheduleShortId = snapshot.tags?.[0]; + if (scheduleShortId) { + const scheduleRes = await getBackupSchedule({ path: { scheduleId: scheduleShortId } }); + if (scheduleRes.data) { + basePath = getVolumeMountPath(scheduleRes.data.volume); + } + } + + return { snapshot, repository, basePath }; }, staticData: { breadcrumb: (match) => [ @@ -36,14 +47,14 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$s function RouteComponent() { const { repositoryId, snapshotId } = Route.useParams(); - const { snapshot, repository } = Route.useLoaderData(); + const { repository, basePath } = Route.useLoaderData(); return ( - ) + ); } diff --git a/app/server/modules/backups/__tests__/backups.controller.test.ts b/app/server/modules/backups/__tests__/backups.controller.test.ts index 068ff681..26eb068a 100644 --- a/app/server/modules/backups/__tests__/backups.controller.test.ts +++ b/app/server/modules/backups/__tests__/backups.controller.test.ts @@ -1,6 +1,9 @@ import { test, describe, expect } from "bun:test"; import { createApp } from "~/server/app"; import { createTestSession, getAuthHeaders } from "~/test/helpers/auth"; +import { createTestVolume } from "~/test/helpers/volume"; +import { createTestRepository } from "~/test/helpers/repository"; +import { createTestBackupSchedule } from "~/test/helpers/backup"; const app = createApp(); @@ -77,6 +80,26 @@ describe("backups security", () => { }); describe("input validation", () => { + test("should return a schedule when queried by short id", async () => { + const { token, organizationId } = await createTestSession(); + const volume = await createTestVolume({ organizationId }); + const repository = await createTestRepository({ organizationId }); + const schedule = await createTestBackupSchedule({ + organizationId, + volumeId: volume.id, + repositoryId: repository.id, + }); + + const res = await app.request(`/api/v1/backups/${schedule.shortId}`, { + headers: getAuthHeaders(token), + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.id).toBe(schedule.id); + expect(body.shortId).toBe(schedule.shortId); + }); + test("should return 404 for malformed schedule ID", async () => { const { token } = await createTestSession(); const res = await app.request("/api/v1/backups/not-a-number", { diff --git a/app/server/modules/backups/__tests__/backups.service.test.ts b/app/server/modules/backups/__tests__/backups.service.test.ts index 3dbbbde7..ebeaf169 100644 --- a/app/server/modules/backups/__tests__/backups.service.test.ts +++ b/app/server/modules/backups/__tests__/backups.service.test.ts @@ -187,6 +187,48 @@ describe("getSchedulesToExecute", () => { }); }); +describe("getScheduleByIdOrShortId", () => { + test("should resolve a schedule by numeric id string", async () => { + const volume = await createTestVolume(); + const repository = await createTestRepository(); + const schedule = await createTestBackupSchedule({ + volumeId: volume.id, + repositoryId: repository.id, + }); + + const found = await backupsService.getScheduleByIdOrShortId(String(schedule.id)); + + expect(found.id).toBe(schedule.id); + expect(found.shortId).toBe(schedule.shortId); + }); + + test("should resolve a schedule by short id", async () => { + const volume = await createTestVolume(); + const repository = await createTestRepository(); + const schedule = await createTestBackupSchedule({ + volumeId: volume.id, + repositoryId: repository.id, + }); + + const found = await backupsService.getScheduleByIdOrShortId(schedule.shortId); + + expect(found.id).toBe(schedule.id); + expect(found.shortId).toBe(schedule.shortId); + }); + + test("should not return schedules from another organization", async () => { + const otherOrgId = faker.string.uuid(); + const schedule = await createTestBackupSchedule({ + organizationId: otherOrgId, + }); + + await expect(backupsService.getScheduleByIdOrShortId(schedule.shortId)).rejects.toThrow( + "Backup schedule not found", + ); + await expect(backupsService.getScheduleByIdOrShortId(schedule.id)).rejects.toThrow("Backup schedule not found"); + }); +}); + describe("listSchedules", () => { test("should ignore schedules with missing relations", async () => { const healthyVolume = await createTestVolume(); diff --git a/app/server/modules/backups/backups.controller.ts b/app/server/modules/backups/backups.controller.ts index 84473978..873acd0b 100644 --- a/app/server/modules/backups/backups.controller.ts +++ b/app/server/modules/backups/backups.controller.ts @@ -54,7 +54,7 @@ export const backupScheduleController = new Hono() }) .get("/:scheduleId", getBackupScheduleDto, async (c) => { const scheduleId = c.req.param("scheduleId"); - const schedule = await backupsService.getScheduleById(Number(scheduleId)); + const schedule = await backupsService.getScheduleByIdOrShortId(scheduleId); return c.json(schedule, 200); }) diff --git a/app/server/modules/backups/backups.service.ts b/app/server/modules/backups/backups.service.ts index d6c7f272..c13b2b3c 100644 --- a/app/server/modules/backups/backups.service.ts +++ b/app/server/modules/backups/backups.service.ts @@ -56,6 +56,26 @@ const getScheduleByShortId = async (shortId: string) => { return schedule; }; +const getScheduleByIdOrShortId = async (idOrShortId: string | number) => { + const organizationId = getOrganizationId(); + const schedule = await db.query.backupSchedulesTable.findFirst({ + where: { + AND: [{ OR: [{ id: Number(idOrShortId) }, { shortId: String(idOrShortId) }] }, { organizationId }], + }, + with: { volume: true, repository: true }, + }); + + if (!schedule) { + throw new NotFoundError("Backup schedule not found"); + } + + if (!schedule.volume || !schedule.repository) { + throw new NotFoundError("Backup schedule not found"); + } + + return schedule; +}; + const createSchedule = async (data: CreateBackupScheduleBody) => { const organizationId = getOrganizationId(); if (!cron.validate(data.cronExpression)) { @@ -375,6 +395,7 @@ const cleanupOrphanedSchedules = async () => { export const backupsService = { listSchedules, getScheduleById, + getScheduleByIdOrShortId, createSchedule, updateSchedule, deleteSchedule, diff --git a/app/server/modules/repositories/repositories.service.ts b/app/server/modules/repositories/repositories.service.ts index d7acf173..64a70a7f 100644 --- a/app/server/modules/repositories/repositories.service.ts +++ b/app/server/modules/repositories/repositories.service.ts @@ -25,6 +25,7 @@ import { backupsService } from "../backups/backups.service"; import type { UpdateRepositoryBody } from "./repositories.dto"; import { executeDoctor } from "./doctor"; import { REPOSITORY_BASE } from "~/server/core/constants"; +import { findCommonAncestor } from "~/utils/common-ancestor"; const runningDoctors = new Map(); @@ -336,6 +337,9 @@ const restoreSnapshot = async ( const target = options?.targetPath || "/"; + const { paths } = await getSnapshotDetails(repository.id, snapshotId); + const basePath = findCommonAncestor(paths); + const releaseLock = await repoMutex.acquireShared(repository.id, `restore:${snapshotId}`); try { serverEvents.emit("restore:started", { @@ -345,6 +349,7 @@ const restoreSnapshot = async ( }); const result = await restic.restore(repository.config, snapshotId, target, { + basePath, ...options, organizationId, onProgress: (progress) => { diff --git a/app/server/utils/common-ancestor.test.ts b/app/server/utils/common-ancestor.test.ts new file mode 100644 index 00000000..9178cb9b --- /dev/null +++ b/app/server/utils/common-ancestor.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { findCommonAncestor } from "~/utils/common-ancestor"; + +describe("findCommonAncestor", () => { + test("returns root for empty path lists", () => { + expect(findCommonAncestor([])).toBe("/"); + }); + + test("returns the original path for single-item lists", () => { + expect(findCommonAncestor(["/var/lib/zerobyte/volumes/vol123/_data"])).toBe( + "/var/lib/zerobyte/volumes/vol123/_data", + ); + }); + + test("returns the deepest shared ancestor for multiple absolute paths", () => { + expect( + findCommonAncestor([ + "/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf", + "/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg", + "/var/lib/zerobyte/volumes/vol123/_data/Music/track.mp3", + ]), + ).toBe("/var/lib/zerobyte/volumes/vol123/_data"); + }); + + test("returns root when absolute paths only share the filesystem root", () => { + expect(findCommonAncestor(["/etc/hosts", "/usr/local/bin"])).toBe("/"); + }); + + test("throws when any path is relative", () => { + expect(() => findCommonAncestor(["/var/lib/zerobyte", "relative/path"])).toThrow( + 'Path "relative/path" is not absolute.', + ); + }); +}); diff --git a/app/server/utils/restic.test.ts b/app/server/utils/restic.test.ts index afb1364c..d099f53d 100644 --- a/app/server/utils/restic.test.ts +++ b/app/server/utils/restic.test.ts @@ -1,5 +1,71 @@ -import { describe, expect, test } from "bun:test"; -import { buildRepoUrl } from "./restic"; +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; +import * as spawnModule from "./spawn"; +import { buildRepoUrl, restic } from "./restic"; + +const successfulRestoreSummary = JSON.stringify({ + message_type: "summary", + files_restored: 1, + files_skipped: 0, + bytes_skipped: 0, +}); + +let lastSafeSpawnArgs: string[] = []; + +const safeSpawnMock = mock((params: Parameters[0]) => { + lastSafeSpawnArgs = params.args; + + return Promise.resolve({ + exitCode: 0, + summary: successfulRestoreSummary, + error: "", + }); +}); + +const getRestoreArg = (args: string[]): string => { + const restoreIndex = args.indexOf("restore"); + if (restoreIndex < 0) { + throw new Error("Expected restore command in restic arguments"); + } + + const restoreArg = args[restoreIndex + 1]; + if (!restoreArg) { + throw new Error("Expected restore argument after restore command"); + } + + return restoreArg; +}; + +const getOptionValues = (args: string[], option: string): string[] => { + const values: string[] = []; + for (let i = 0; i < args.length - 1; i++) { + if (args[i] === option) { + const value = args[i + 1]; + if (value) { + values.push(value); + } + } + } + + return values; +}; + +const getLastSafeSpawnArgs = (): string[] => { + if (lastSafeSpawnArgs.length === 0) { + throw new Error("Expected safeSpawn to be called"); + } + + return lastSafeSpawnArgs; +}; + +beforeEach(() => { + safeSpawnMock.mockClear(); + lastSafeSpawnArgs = []; + spyOn(spawnModule, "safeSpawn").mockImplementation(safeSpawnMock); +}); + +afterEach(() => { + mock.restore(); +}); describe("buildRepoUrl", () => { describe("S3 backend", () => { @@ -104,3 +170,67 @@ describe("buildRepoUrl", () => { }); }); }); + +describe("restore", () => { + const config = { + backend: "local" as const, + path: "/tmp/restic-repo", + isExistingRepository: true, + customPassword: "custom-password", + }; + + test("keeps snapshot restore arg and absolute include paths when target is root", async () => { + await restic.restore(config, "snapshot-123", "/", { + organizationId: "org-1", + include: [ + "/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf", + "/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg", + ], + }); + + const args = getLastSafeSpawnArgs(); + expect(getRestoreArg(args)).toBe("snapshot-123"); + expect(getOptionValues(args, "--include")).toEqual([ + "/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf", + "/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg", + ]); + }); + + test("restores from common ancestor and strips include paths for non-root targets", async () => { + await restic.restore(config, "snapshot-456", "/tmp/restore-target", { + organizationId: "org-1", + include: [ + "/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf", + "/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg", + ], + }); + + const args = getLastSafeSpawnArgs(); + expect(getRestoreArg(args)).toBe("snapshot-456:/var/lib/zerobyte/volumes/vol123/_data"); + expect(getOptionValues(args, "--include")).toEqual(["Documents/report.pdf", "Photos/summer.jpg"]); + }); + + test("uses base path for non-root restore when includes are omitted", async () => { + await restic.restore(config, "snapshot-789", "/tmp/restore-target", { + organizationId: "org-1", + basePath: "/var/lib/zerobyte/volumes/vol123/_data", + }); + + const args = getLastSafeSpawnArgs(); + expect(getRestoreArg(args)).toBe("snapshot-789:/var/lib/zerobyte/volumes/vol123/_data"); + expect(getOptionValues(args, "--include")).toEqual([]); + }); + + test("does not pass an empty include when include equals restore root", async () => { + await restic.restore(config, "snapshot-7202d8cc", "/Users/nicolas/Documents/restore", { + organizationId: "org-1", + include: ["/Users/nicolas/Developer/zerobyte/tmp/deep/test/files"], + overwrite: "always", + }); + + const args = getLastSafeSpawnArgs(); + expect(getRestoreArg(args)).toBe("snapshot-7202d8cc:/Users/nicolas/Developer/zerobyte/tmp/deep/test/files"); + expect(getOptionValues(args, "--include")).toEqual([]); + expect(args).not.toContain(""); + }); +}); diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts index 16a0de0f..49029b9f 100644 --- a/app/server/utils/restic.ts +++ b/app/server/utils/restic.ts @@ -23,6 +23,7 @@ import { ResticError } from "./errors"; import { safeJsonParse } from "./json"; import { logger } from "./logger"; import { exec, safeSpawn } from "./spawn"; +import { findCommonAncestor } from "~/utils/common-ancestor"; const snapshotInfoSchema = type({ gid: "number?", @@ -48,7 +49,10 @@ export const buildRepoUrl = (config: RepositoryConfig): string => { return `s3:${endpoint}/${config.bucket}`; } case "r2": { - const endpoint = config.endpoint.trim().replace(/^https?:\/\//, "").replace(/\/$/, ""); + const endpoint = config.endpoint + .trim() + .replace(/^https?:\/\//, "") + .replace(/\/$/, ""); return `s3:${endpoint}/${config.bucket}`; } case "gcs": @@ -399,6 +403,7 @@ const restore = async ( snapshotId: string, target: string, options: { + basePath?: string; organizationId: string; include?: string[]; exclude?: string[]; @@ -412,15 +417,36 @@ const restore = async ( const repoUrl = buildRepoUrl(config); const env = await buildEnv(config, options.organizationId); - const args: string[] = ["--repo", repoUrl, "restore", snapshotId, "--target", target]; + let restoreArg = snapshotId; + + const includes = options.include?.length ? options.include : [options.basePath ?? "/"]; + const commonAncestor = findCommonAncestor(includes); + if (target !== "/") { + restoreArg = `${snapshotId}:${commonAncestor}`; + } + + const args = ["--repo", repoUrl, "restore", restoreArg, "--target", target]; if (options?.overwrite) { args.push("--overwrite", options.overwrite); } if (options?.include?.length) { - for (const pattern of options.include) { - args.push("--include", pattern); + if (target === "/") { + for (const pattern of options.include) { + args.push("--include", pattern); + } + } else { + const strippedIncludes = options.include.map((pattern) => path.relative(commonAncestor, pattern)); + const includesCoverRestoreRoot = strippedIncludes.some((pattern) => pattern === "" || pattern === "."); + + if (!includesCoverRestoreRoot) { + for (const pattern of strippedIncludes) { + if (pattern !== "" && pattern !== ".") { + args.push("--include", pattern); + } + } + } } } diff --git a/app/utils/common-ancestor.ts b/app/utils/common-ancestor.ts index 5263c5de..0f966db6 100644 --- a/app/utils/common-ancestor.ts +++ b/app/utils/common-ancestor.ts @@ -1,6 +1,12 @@ export const findCommonAncestor = (paths: string[]): string => { + for (const p of paths) { + if (!p.startsWith("/")) { + throw new Error(`Path "${p}" is not absolute.`); + } + } + if (paths.length === 0) return "/"; - if (paths.length === 1) return paths[0]; + if (paths.length === 1) return paths[0] || "/"; const splitPaths = paths.map((path) => path.split("/").filter(Boolean)); const minLength = Math.min(...splitPaths.map((parts) => parts.length));