From d4436b0cdc9442dc6739bd8395341ed3500fe31d Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Sat, 30 May 2026 10:10:54 +0200 Subject: [PATCH] refactor(restic): all commands return effects (#924) * refactor(restic): all commands are effects * fix(restic): preserve effect failure errors * chore: pr feedbacks --- .../__tests__/backups.mirror-sync.test.ts | 31 ++- .../backups.service.execution.test.ts | 43 ++-- app/server/modules/backups/backup-executor.ts | 5 +- app/server/modules/backups/backups.service.ts | 39 ++- .../backups/helpers/backup-maintenance.ts | 25 +- .../modules/provisioning/provisioning.test.ts | 5 +- .../modules/provisioning/provisioning.ts | 15 +- .../__tests__/repositories.controller.test.ts | 94 ++++--- .../__tests__/repositories.service.test.ts | 67 ++--- .../modules/repositories/helpers/doctor.ts | 10 +- .../repositories/repositories.service.ts | 92 ++++--- app/server/utils/errors.ts | 11 + app/test/backend-integration/runner.ts | 28 +- .../restic/commands/__tests__/copy.test.ts | 28 +- .../__tests__/delete-snapshots.test.ts | 21 +- .../restic/commands/__tests__/dump.test.ts | 5 +- .../src/restic/commands/__tests__/ls.test.ts | 3 +- .../restic/commands/__tests__/restore.test.ts | 159 +++++++----- .../commands/__tests__/snapshots.test.ts | 3 +- .../commands/__tests__/tag-snapshots.test.ts | 5 +- packages/core/src/restic/commands/check.ts | 114 +++++---- packages/core/src/restic/commands/copy.ts | 107 ++++---- .../src/restic/commands/delete-snapshots.ts | 61 +++-- packages/core/src/restic/commands/dump.ts | 167 ++++++------ packages/core/src/restic/commands/forget.ts | 103 +++++--- packages/core/src/restic/commands/init.ts | 81 +++--- packages/core/src/restic/commands/key-add.ts | 71 ++++-- packages/core/src/restic/commands/ls.ts | 174 +++++++------ .../core/src/restic/commands/repair-index.ts | 81 +++--- packages/core/src/restic/commands/restore.ts | 239 ++++++++++-------- .../core/src/restic/commands/snapshots.ts | 91 ++++--- packages/core/src/restic/commands/stats.ts | 57 +++-- .../core/src/restic/commands/tag-snapshots.ts | 86 ++++--- packages/core/src/restic/commands/unlock.ts | 69 +++-- packages/core/src/restic/lock-diagnostics.ts | 169 +++++++++++++ packages/core/src/restic/server.ts | 96 +++++-- 36 files changed, 1567 insertions(+), 888 deletions(-) create mode 100644 packages/core/src/restic/lock-diagnostics.ts diff --git a/app/server/modules/backups/__tests__/backups.mirror-sync.test.ts b/app/server/modules/backups/__tests__/backups.mirror-sync.test.ts index c81cf4fd..d5c01fdf 100644 --- a/app/server/modules/backups/__tests__/backups.mirror-sync.test.ts +++ b/app/server/modules/backups/__tests__/backups.mirror-sync.test.ts @@ -10,24 +10,27 @@ import * as context from "~/server/core/request-context"; import * as resticModule from "~/server/core/restic"; import * as spawnModule from "@zerobyte/core/node"; import type { ShortId } from "~/server/utils/branded"; +import { Effect } from "effect"; const setup = () => { vi.spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID); - vi.spyOn(spawnModule, "safeSpawn").mockImplementation(() => Promise.resolve({ exitCode: 0, summary: "", error: "" })); + vi.spyOn(spawnModule, "safeSpawn").mockImplementation(() => + Promise.resolve({ exitCode: 0, summary: "", error: "" }), + ); return { mockSnapshots: (sourceSnapshots: unknown[], mirrorSnapshots: unknown[]) => { let callCount = 0; vi.spyOn(resticModule.restic, "snapshots").mockImplementation(() => { callCount++; - if (callCount === 1) return Promise.resolve(sourceSnapshots as never); - return Promise.resolve(mirrorSnapshots as never); + if (callCount === 1) return Effect.succeed(sourceSnapshots as never); + return Effect.succeed(mirrorSnapshots as never); }); }, mockCopy: () => { const copyMock = vi .spyOn(resticModule.restic, "copy") - .mockImplementation(() => Promise.resolve({ success: true, output: "" })); + .mockImplementation(() => Effect.succeed({ success: true, output: "" })); return copyMock; }, }; @@ -190,12 +193,14 @@ describe("syncMirror", () => { const copyMock = mockCopy(); let releaseCopy: (() => void) | undefined; const copyStarted = new Promise((resolve) => { - copyMock.mockImplementation( - () => - new Promise((copyResolve) => { - releaseCopy = () => copyResolve({ success: true, output: "" }); - resolve(); - }), + copyMock.mockImplementation(() => + Effect.promise( + () => + new Promise((copyResolve) => { + releaseCopy = () => copyResolve({ success: true, output: "" }); + resolve(); + }), + ), ); }); @@ -208,9 +213,7 @@ describe("syncMirror", () => { }); await createTestBackupScheduleMirror(schedule.id, mirrorRepository.id); - await expect( - backupsService.syncMirror(schedule.shortId, mirrorRepository.shortId as ShortId, ["snap1"]), - ).resolves.toEqual({ success: true }); + const firstSync = backupsService.syncMirror(schedule.shortId, mirrorRepository.shortId as ShortId, ["snap1"]); await copyStarted; @@ -225,6 +228,8 @@ describe("syncMirror", () => { releaseCopy?.(); + await expect(firstSync).resolves.toEqual({ success: true }); + await waitForExpect(async () => { const mirrors = await backupsService.getMirrors(schedule.shortId); expect(mirrors[0]?.lastCopyStatus).toBe("success"); diff --git a/app/server/modules/backups/__tests__/backups.service.execution.test.ts b/app/server/modules/backups/__tests__/backups.service.execution.test.ts index a1fb65f8..b8582c39 100644 --- a/app/server/modules/backups/__tests__/backups.service.execution.test.ts +++ b/app/server/modules/backups/__tests__/backups.service.execution.test.ts @@ -24,13 +24,14 @@ import { getScheduleByIdOrShortId } from "../helpers/backup-schedule-lookups"; import { volumeService } from "~/server/modules/volumes/volume.service"; import { db } from "~/server/db/db"; import { config } from "~/server/core/config"; +import { Effect } from "effect"; const setup = () => { const resticBackupMock = vi.fn((_: SafeSpawnParams) => Promise.resolve({ exitCode: 0, summary: generateBackupOutput(), error: "" }), ); - const resticForgetMock = vi.fn(() => Promise.resolve({ success: true, data: null })); - const resticCopyMock = vi.fn(() => Promise.resolve({ success: true, output: "" })); + const resticForgetMock = vi.fn(() => Effect.succeed({ success: true, data: null })); + const resticCopyMock = vi.fn(() => Effect.succeed({ success: true, output: "" })); const { runBackupMock, cancelBackupMock } = createAgentBackupMocks(resticBackupMock); const refreshStatsMock = vi.fn(() => Promise.resolve({ @@ -925,12 +926,14 @@ describe("mirror operations", () => { const originalMirror = await createTestBackupScheduleMirror(schedule.id, mirrorRepository.id); - resticCopyMock.mockImplementationOnce(async () => { - await backupsService.updateMirrors(schedule.id, { - mirrors: [{ repositoryId: mirrorRepository.id, enabled: true }], - }); - return { success: true, output: "" }; - }); + resticCopyMock.mockImplementationOnce(() => + Effect.promise(async () => { + await backupsService.updateMirrors(schedule.id, { + mirrors: [{ repositoryId: mirrorRepository.id, enabled: true }], + }); + return { success: true, output: "" }; + }), + ); // act await backupsService.copyToMirrors(schedule.id, sourceRepository, null); @@ -957,7 +960,11 @@ describe("mirror operations", () => { const mirror = await createTestBackupScheduleMirror(schedule.id, mirrorRepository.id); - resticCopyMock.mockImplementationOnce(() => Promise.reject(new Error("Copy failed"))); + resticCopyMock.mockImplementationOnce(() => + Effect.sync(() => { + throw new Error("Copy failed"); + }), + ); // act await backupsService.copyToMirrors(schedule.id, sourceRepository, null); @@ -985,7 +992,7 @@ describe("mirror operations", () => { await createTestBackupScheduleMirror(schedule.id, mirrorRepository.id); resticCopyMock.mockClear(); - resticCopyMock.mockImplementation(() => Promise.resolve({ success: true, output: "" })); + resticCopyMock.mockImplementation(() => Effect.succeed({ success: true, output: "" })); // act await backupsService.copyToMirrors(schedule.id, sourceRepository, schedule.retentionPolicy); @@ -1053,14 +1060,16 @@ describe("mirror operations", () => { resolveFirstCopyStarted = resolve; }); - resticCopyMock.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveFirstCopyStarted(); - releaseFirstCopy = () => resolve({ success: true, output: "" }); - }), + resticCopyMock.mockImplementationOnce(() => + Effect.promise( + () => + new Promise((resolve) => { + resolveFirstCopyStarted(); + releaseFirstCopy = () => resolve({ success: true, output: "" }); + }), + ), ); - resticCopyMock.mockImplementation(() => Promise.resolve({ success: true, output: "" })); + resticCopyMock.mockImplementation(() => Effect.succeed({ success: true, output: "" })); const firstCopyPromise = backupsService.copyToMirrors(firstSchedule.id, sourceRepository, null); await firstCopyStarted; diff --git a/app/server/modules/backups/backup-executor.ts b/app/server/modules/backups/backup-executor.ts index 1332c96d..6c7ef75c 100644 --- a/app/server/modules/backups/backup-executor.ts +++ b/app/server/modules/backups/backup-executor.ts @@ -1,4 +1,3 @@ -import { Effect } from "effect"; import { runBackupLifecycle } from "@zerobyte/core/backup-hooks"; import type { BackupSchedule, Volume, Repository } from "../../db/schema"; import { config } from "../../core/config"; @@ -10,7 +9,7 @@ import { getVolumePath } from "../volumes/helpers"; import { decryptVolumeConfig } from "../volumes/volume-config-secrets"; import { decryptRepositoryConfig } from "../repositories/repository-config-secrets"; import { createBackupOptions } from "./backup.helpers"; -import { toErrorDetails } from "../../utils/errors"; +import { runEffectPromise, toErrorDetails } from "../../utils/errors"; import { BadRequestError } from "http-errors-enhanced"; const FUSE_VOLUME_BACKENDS = new Set(["rclone", "sftp", "webdav"]); @@ -90,7 +89,7 @@ const executeBackupWithoutAgent = async ( compressionMode: payload.options.compressionMode, }; - return Effect.runPromise( + return runEffectPromise( runBackupLifecycle({ restic, repositoryConfig: payload.repositoryConfig, diff --git a/app/server/modules/backups/backups.service.ts b/app/server/modules/backups/backups.service.ts index f3015971..a27fb711 100644 --- a/app/server/modules/backups/backups.service.ts +++ b/app/server/modules/backups/backups.service.ts @@ -28,7 +28,8 @@ import { getScheduleByIdOrShortId } from "./helpers/backup-schedule-lookups"; import { copyToMirrors, runForget, syncSnapshotsToMirror } from "./helpers/backup-maintenance"; import { restic } from "../../core/restic"; import { mirrorQueries } from "./backups.queries"; -import { toMessage } from "../../utils/errors"; +import { runEffectPromise, toMessage } from "../../utils/errors"; +import { Effect } from "effect"; const listSchedules = async () => { const organizationId = getOrganizationId(); const schedules = await db.query.backupSchedulesTable.findMany({ @@ -73,7 +74,10 @@ const createSchedule = async (data: CreateBackupScheduleBody) => { const repository = await db.query.repositoriesTable.findFirst({ where: { - AND: [{ OR: [{ id: data.repositoryId }, { shortId: { eq: asShortId(data.repositoryId) } }] }, { organizationId }], + AND: [ + { OR: [{ id: data.repositoryId }, { shortId: { eq: asShortId(data.repositoryId) } }] }, + { organizationId }, + ], }, }); @@ -149,7 +153,10 @@ const updateSchedule = async (scheduleIdOrShortId: number | string, data: Update const repository = await db.query.repositoriesTable.findFirst({ where: { - AND: [{ OR: [{ id: data.repositoryId }, { shortId: { eq: asShortId(data.repositoryId) } }] }, { organizationId }], + AND: [ + { OR: [{ id: data.repositoryId }, { shortId: { eq: asShortId(data.repositoryId) } }] }, + { organizationId }, + ], }, }); @@ -159,7 +166,11 @@ const updateSchedule = async (scheduleIdOrShortId: number | string, data: Update const cronExpression = data.cronExpression ?? schedule.cronExpression; const nextBackupAt = - data.cronExpression === "" ? null : data.cronExpression ? calculateNextRun(cronExpression) : schedule.nextBackupAt; + data.cronExpression === "" + ? null + : data.cronExpression + ? calculateNextRun(cronExpression) + : schedule.nextBackupAt; const [updated] = await db .update(backupSchedulesTable) @@ -351,7 +362,12 @@ const reorderSchedules = async (scheduleShortIds: ShortId[]) => { for (const [index, scheduleId] of scheduleIds.entries()) { tx.update(backupSchedulesTable) .set({ sortOrder: index, updatedAt: now }) - .where(and(eq(backupSchedulesTable.id, scheduleId), eq(backupSchedulesTable.organizationId, organizationId))) + .where( + and( + eq(backupSchedulesTable.id, scheduleId), + eq(backupSchedulesTable.organizationId, organizationId), + ), + ) .run(); } }); @@ -498,10 +514,15 @@ const getMirrorSyncStatus = async (scheduleIdOrShortId: number | string, mirrorS throw new NotFoundError("Mirror not found for this schedule"); } - const [sourceSnapshots, mirrorSnapshots] = await Promise.all([ - restic.snapshots(schedule.repository.config, { tags: [schedule.shortId], organizationId }), - restic.snapshots(mirrorRepo.config, { tags: [schedule.shortId], organizationId }), - ]); + const [sourceSnapshots, mirrorSnapshots] = await runEffectPromise( + Effect.all( + [ + restic.snapshots(schedule.repository.config, { tags: [schedule.shortId], organizationId }), + restic.snapshots(mirrorRepo.config, { tags: [schedule.shortId], organizationId }), + ], + { concurrency: "unbounded" }, + ), + ); const mirrorSnapshotTimes = new Set(mirrorSnapshots.map((s) => s.time)); diff --git a/app/server/modules/backups/helpers/backup-maintenance.ts b/app/server/modules/backups/helpers/backup-maintenance.ts index 0b4ccd6f..1416cc9b 100644 --- a/app/server/modules/backups/helpers/backup-maintenance.ts +++ b/app/server/modules/backups/helpers/backup-maintenance.ts @@ -5,7 +5,7 @@ import { restic } from "../../../core/restic"; import { repoMutex } from "../../../core/repository-mutex"; import { serverEvents } from "../../../core/events"; import { cache, cacheKeys } from "../../../utils/cache"; -import { toMessage } from "../../../utils/errors"; +import { runEffectPromise, toMessage } from "../../../utils/errors"; import { getOrganizationId } from "~/server/core/request-context"; import { mirrorQueries, repositoryQueries, scheduleQueries } from "../backups.queries"; @@ -31,7 +31,9 @@ export async function runForget(scheduleId: number, repositoryId?: string, organ const releaseLock = await repoMutex.acquireExclusive(repository.id, `forget:${scheduleId}`); try { - await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.shortId, organizationId }); + await runEffectPromise( + restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.shortId, organizationId }), + ); cache.delByPrefix(cacheKeys.repository.all(repository.id)); } finally { releaseLock(); @@ -118,11 +120,13 @@ export async function syncSnapshotsToMirror( ]); try { - await restic.copy(sourceRepository.config, mirrorRepository.config, { - tag: schedule.shortId, - organizationId, - snapshotIds, - }); + await runEffectPromise( + restic.copy(sourceRepository.config, mirrorRepository.config, { + tag: schedule.shortId, + organizationId, + snapshotIds, + }), + ); cache.delByPrefix(cacheKeys.repository.all(mirrorRepository.id)); } finally { releaseLocks(); @@ -206,7 +210,12 @@ async function copyToSingleMirror( ]); try { - await restic.copy(sourceRepository.config, mirror.repository.config, { tag: schedule.shortId, organizationId }); + await runEffectPromise( + restic.copy(sourceRepository.config, mirror.repository.config, { + tag: schedule.shortId, + organizationId, + }), + ); cache.delByPrefix(cacheKeys.repository.all(mirror.repository.id)); } finally { releaseLocks(); diff --git a/app/server/modules/provisioning/provisioning.test.ts b/app/server/modules/provisioning/provisioning.test.ts index 06b9a4ca..df8f8843 100644 --- a/app/server/modules/provisioning/provisioning.test.ts +++ b/app/server/modules/provisioning/provisioning.test.ts @@ -8,6 +8,7 @@ import { restic } from "~/server/core/restic"; import { createTestSession } from "~/test/helpers/auth"; import { generateShortId } from "~/server/utils/id"; import { provisionedResourcesSchema, readProvisionedResourcesFile, syncProvisionedResources } from "./provisioning"; +import { Effect } from "effect"; describe("provisioning", () => { let session: Awaited>; @@ -17,7 +18,7 @@ describe("provisioning", () => { }); beforeEach(async () => { - vi.spyOn(restic, "init").mockResolvedValue({ success: true, error: null }); + vi.spyOn(restic, "init").mockReturnValue(Effect.succeed({ success: true, error: null })); await db.delete(backupSchedulesTable); await db.delete(volumesTable); @@ -361,7 +362,7 @@ describe("provisioning", () => { const { organizationId } = session; const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-provisioning-")); const provisioningPath = path.join(tempDir, "provisioning.json"); - const initMock = vi.fn(() => Promise.resolve({ success: true, error: null })); + const initMock = vi.fn(() => Effect.succeed({ success: true, error: null })); vi.spyOn(restic, "init").mockImplementation(initMock); diff --git a/app/server/modules/provisioning/provisioning.ts b/app/server/modules/provisioning/provisioning.ts index e9a7bff0..a801d943 100644 --- a/app/server/modules/provisioning/provisioning.ts +++ b/app/server/modules/provisioning/provisioning.ts @@ -18,7 +18,7 @@ import { mapRepositoryConfigSecrets } from "~/server/modules/repositories/reposi import { mapVolumeConfigSecrets } from "~/server/modules/volumes/volume-config-secrets"; import { BACKEND_TYPES, volumeConfigSchema, type BackendConfig } from "@zerobyte/contracts/volumes"; import { cryptoUtils } from "~/server/utils/crypto"; -import { toMessage } from "~/server/utils/errors"; +import { runEffectPromise, toMessage } from "~/server/utils/errors"; import { generateShortId } from "~/server/utils/id"; const envSecretPrefix = "env://"; @@ -171,9 +171,12 @@ const syncProvisionedRepositories = async (repositories: ProvisionedRepository[] }); if (!repository.config.isExistingRepository) { - const result = await restic - .init(encryptedConfig, repository.organizationId, { timeoutMs: appConfig.serverIdleTimeout * 1000 }) - .catch((error) => ({ success: false, error })); + const result = await runEffectPromise( + restic.init(encryptedConfig, { + organizationId: repository.organizationId, + timeoutMs: appConfig.serverIdleTimeout * 1000, + }), + ).catch((error) => ({ success: false, error })); await db .update(repositoriesTable) @@ -186,7 +189,9 @@ const syncProvisionedRepositories = async (repositories: ProvisionedRepository[] .where(eq(repositoriesTable.id, id)); if (result.error) { - logger.error(`Provisioned repository ${repository.name} failed to initialize: ${toMessage(result.error)}`); + logger.error( + `Provisioned repository ${repository.name} failed to initialize: ${toMessage(result.error)}`, + ); } } continue; diff --git a/app/server/modules/repositories/__tests__/repositories.controller.test.ts b/app/server/modules/repositories/__tests__/repositories.controller.test.ts index f65cdb07..7de1322a 100644 --- a/app/server/modules/repositories/__tests__/repositories.controller.test.ts +++ b/app/server/modules/repositories/__tests__/repositories.controller.test.ts @@ -8,6 +8,7 @@ import { generateShortId } from "~/server/utils/id"; import { createTestSession, getAuthHeaders } from "~/test/helpers/auth"; import type { RepositoryConfig } from "@zerobyte/core/restic"; import { restic } from "~/server/core/restic"; +import { Effect } from "effect"; const app = createApp(); @@ -18,7 +19,7 @@ beforeAll(async () => { }); beforeEach(() => { - vi.spyOn(restic, "init").mockResolvedValue({ success: true, error: null }); + vi.spyOn(restic, "init").mockReturnValue(Effect.succeed({ success: true, error: null })); }); afterEach(() => { @@ -327,15 +328,17 @@ describe("repositories updates", () => { }); describe("delete snapshot", () => { - test("should return 500 when restic deleteSnapshot throws ResticError", async () => { + test("should return ResticError details when restic deleteSnapshot fails", async () => { const repository = await createRepositoryRecord(session.organizationId); const { restic } = await import("~/server/core/restic"); const { ResticError } = await import("@zerobyte/core/restic"); - const deleteSnapshotSpy = vi.spyOn(restic, "deleteSnapshot").mockImplementation(async () => { - throw new ResticError(1, "Fatal: unexpected HTTP response (403): 403 Forbidden"); - }); + const deleteSnapshotSpy = vi + .spyOn(restic, "deleteSnapshot") + .mockImplementation(() => + Effect.fail(new ResticError(1, "Fatal: unexpected HTTP response (403): 403 Forbidden")), + ); try { const res = await app.request(`/api/v1/repositories/${repository.shortId}/snapshots/snap123`, { @@ -345,8 +348,10 @@ describe("repositories updates", () => { expect(res.status).toBe(500); const body = await res.json(); - expect(body.message).toBe("Command failed: An error occurred while executing the command."); - expect(body.details).toBe("Fatal: unexpected HTTP response (403): 403 Forbidden"); + expect(body).toEqual({ + message: "Command failed: An error occurred while executing the command.", + details: "Fatal: unexpected HTTP response (403): 403 Forbidden", + }); } finally { deleteSnapshotSpy.mockRestore(); } @@ -359,27 +364,34 @@ describe("repositories updates", () => { const stream = new PassThrough(); const expectedContent = "downloaded snapshot contents"; - const snapshotsSpy = vi.spyOn(restic, "snapshots").mockResolvedValue([ - { - id: "test-snapshot", - short_id: "test-snapshot", - time: new Date().toISOString(), - paths: ["/mnt/project"], - hostname: "host", - }, - ]); - const dumpSpy = vi.spyOn(restic, "dump").mockResolvedValue({ - stream, - completion: Promise.resolve(), - abort: vi.fn(), - }); + const snapshotsSpy = vi.spyOn(restic, "snapshots").mockReturnValue( + Effect.succeed([ + { + id: "test-snapshot", + short_id: "test-snapshot", + time: new Date().toISOString(), + paths: ["/mnt/project"], + hostname: "host", + }, + ]), + ); + const dumpSpy = vi.spyOn(restic, "dump").mockReturnValue( + Effect.succeed({ + stream: stream as never, + completion: Promise.resolve(), + abort: vi.fn(), + }), + ); try { const controller = new AbortController(); - const response = await app.request(`/api/v1/repositories/${repository.shortId}/snapshots/test-snapshot/dump`, { - headers: session.headers, - signal: controller.signal, - }); + const response = await app.request( + `/api/v1/repositories/${repository.shortId}/snapshots/test-snapshot/dump`, + { + headers: session.headers, + signal: controller.signal, + }, + ); queueMicrotask(() => { controller.abort(); @@ -397,20 +409,24 @@ describe("repositories updates", () => { const repository = await createRepositoryRecord(session.organizationId); const stream = new PassThrough(); - const snapshotsSpy = vi.spyOn(restic, "snapshots").mockResolvedValue([ - { - id: "test-snapshot", - short_id: "test-snapshot", - time: new Date().toISOString(), - paths: ["/mnt/project"], - hostname: "host", - }, - ]); - const dumpSpy = vi.spyOn(restic, "dump").mockResolvedValue({ - stream, - completion: Promise.resolve(), - abort: vi.fn(), - }); + const snapshotsSpy = vi.spyOn(restic, "snapshots").mockReturnValue( + Effect.succeed([ + { + id: "test-snapshot", + short_id: "test-snapshot", + time: new Date().toISOString(), + paths: ["/mnt/project"], + hostname: "host", + }, + ]), + ); + const dumpSpy = vi.spyOn(restic, "dump").mockReturnValue( + Effect.succeed({ + stream: stream as never, + completion: Promise.resolve(), + abort: vi.fn(), + }), + ); try { stream.end("downloaded snapshot contents"); diff --git a/app/server/modules/repositories/__tests__/repositories.service.test.ts b/app/server/modules/repositories/__tests__/repositories.service.test.ts index d3c67996..14ab2450 100644 --- a/app/server/modules/repositories/__tests__/repositories.service.test.ts +++ b/app/server/modules/repositories/__tests__/repositories.service.test.ts @@ -17,6 +17,7 @@ import { createTestBackupSchedule } from "~/test/helpers/backup"; import { cache, cacheKeys } from "~/server/utils/cache"; import { ResticError } from "@zerobyte/core/restic/server"; import { repositoriesService } from "../repositories.service"; +import { Effect } from "effect"; const createTestRepository = async (organizationId: string) => { const id = randomUUID(); @@ -44,7 +45,7 @@ beforeAll(async () => { }); describe("repositoriesService.createRepository", () => { - const initMock = vi.fn(() => Promise.resolve({ success: true, error: null })); + const initMock = vi.fn(() => Effect.succeed({ success: true, error: null })); beforeEach(() => { initMock.mockClear(); @@ -116,7 +117,7 @@ describe("repositoriesService.createRepository", () => { const explicitPath = `${REPOSITORY_BASE}/custom-${randomUUID()}`; const config: RepositoryConfig = { backend: "local", path: explicitPath, isExistingRepository: true }; - vi.spyOn(restic, "snapshots").mockImplementation(() => Promise.resolve([])); + vi.spyOn(restic, "snapshots").mockImplementation(() => Effect.succeed([])); // act const result = await withContext({ organizationId: session.organizationId, userId: session.user.id }, () => @@ -174,7 +175,7 @@ describe("repositoriesService repository stats", () => { snapshots_count: 3, }; - const statsSpy = vi.spyOn(restic, "stats").mockResolvedValue(expectedStats); + const statsSpy = vi.spyOn(restic, "stats").mockReturnValue(Effect.succeed(expectedStats)); const refreshed = await withContext({ organizationId: session.organizationId, userId: session.user.id }, () => repositoriesService.refreshRepositoryStats(repository.shortId), @@ -202,7 +203,7 @@ describe("repositoriesService.dumpSnapshot", () => { }); const createDumpResult = (payload: string) => ({ - stream: Readable.from([payload]), + stream: Readable.from([payload]) as never, completion: Promise.resolve(), abort: () => {}, }); @@ -242,22 +243,24 @@ describe("repositoriesService.dumpSnapshot", () => { organizationId, }); - vi.spyOn(restic, "snapshots").mockResolvedValue([ - { - id: snapshotId, - short_id: snapshotId, - time: new Date().toISOString(), - paths: snapshotPaths ?? [basePath], - hostname: "host", - }, - ]); + vi.spyOn(restic, "snapshots").mockReturnValue( + Effect.succeed([ + { + id: snapshotId, + short_id: snapshotId, + time: new Date().toISOString(), + paths: snapshotPaths ?? [basePath], + hostname: "host", + }, + ]), + ); const dumpMock = vi.fn((_config: unknown, snapshotRef: string, options: Parameters[2]) => { if (!options.path) { throw new Error("Expected dump path in test"); } - return Promise.resolve( + return Effect.succeed( createDumpResult( JSON.stringify({ snapshotRef, @@ -409,18 +412,20 @@ describe("repositoriesService.restoreSnapshot", () => { const organizationId = session.organizationId; const repository = await createTestRepository(organizationId); - vi.spyOn(restic, "snapshots").mockResolvedValue([ - { - id: "snapshot-restore", - short_id: "snapshot-restore", - time: new Date().toISOString(), - paths, - hostname: "host", - }, - ]); + vi.spyOn(restic, "snapshots").mockReturnValue( + Effect.succeed([ + { + id: "snapshot-restore", + short_id: "snapshot-restore", + time: new Date().toISOString(), + paths, + hostname: "host", + }, + ]), + ); const restoreMock = vi.fn(() => - Promise.resolve({ + Effect.succeed({ message_type: "summary" as const, seconds_elapsed: 1, percent_done: 100, @@ -569,8 +574,8 @@ describe("repositoriesService.getRetentionCategories", () => { }); const forgetSpy = vi.spyOn(restic, "forget"); - forgetSpy.mockResolvedValueOnce(buildForgetResponse(oldSnapshotId)); - forgetSpy.mockResolvedValueOnce(buildForgetResponse(newSnapshotId)); + forgetSpy.mockReturnValueOnce(Effect.succeed(buildForgetResponse(oldSnapshotId))); + forgetSpy.mockReturnValueOnce(Effect.succeed(buildForgetResponse(newSnapshotId))); const firstCategories = await withContext({ organizationId, userId: session.user.id }, () => repositoriesService.getRetentionCategories(repository.shortId, schedule.shortId), @@ -606,8 +611,8 @@ describe("repositoriesService.deleteSnapshot", () => { snapshots_count: 1, }; - vi.spyOn(restic, "deleteSnapshot").mockResolvedValue({ success: true }); - const statsSpy = vi.spyOn(restic, "stats").mockResolvedValue(expectedStats); + vi.spyOn(restic, "deleteSnapshot").mockReturnValue(Effect.succeed({ success: true })); + const statsSpy = vi.spyOn(restic, "stats").mockReturnValue(Effect.succeed(expectedStats)); await withContext({ organizationId: session.organizationId, userId: session.user.id }, () => repositoriesService.deleteSnapshot(repository.shortId, "snap-1"), @@ -625,9 +630,9 @@ describe("repositoriesService.deleteSnapshot", () => { test("should throw original error when restic deleteSnapshot fails", async () => { const repository = await createTestRepository(session.organizationId); - vi.spyOn(restic, "deleteSnapshot").mockImplementation(async () => { - throw new ResticError(1, "Fatal: unexpected HTTP response (403): 403 Forbidden"); - }); + vi.spyOn(restic, "deleteSnapshot").mockImplementation(() => + Effect.fail(new ResticError(1, "Fatal: unexpected HTTP response (403): 403 Forbidden")), + ); await expect( withContext({ organizationId: session.organizationId, userId: session.user.id }, () => diff --git a/app/server/modules/repositories/helpers/doctor.ts b/app/server/modules/repositories/helpers/doctor.ts index c3659dac..3fe56d8a 100644 --- a/app/server/modules/repositories/helpers/doctor.ts +++ b/app/server/modules/repositories/helpers/doctor.ts @@ -3,7 +3,7 @@ import { type DoctorStep, type DoctorResult, type RepositoryConfig } from "@zero import { z } from "zod"; import { getOrganizationId } from "~/server/core/request-context"; import { restic } from "~/server/core/restic"; -import { toMessage } from "~/server/utils/errors"; +import { runEffectPromise, toMessage } from "~/server/utils/errors"; import { safeJsonParse } from "@zerobyte/core/utils"; import { logger } from "@zerobyte/core/node"; import { db } from "~/server/db/db"; @@ -17,7 +17,7 @@ class AbortError extends Error { const runUnlockStep = async (config: RepositoryConfig, signal?: AbortSignal) => { const orgId = getOrganizationId(); - const result = await restic.unlock(config, { signal, organizationId: orgId }).then( + const result = await runEffectPromise(restic.unlock(config, { signal, organizationId: orgId })).then( (result) => ({ success: true, message: result.message, error: null }), (error) => ({ success: false, message: null, error: toMessage(error) }), ); @@ -32,7 +32,9 @@ const runUnlockStep = async (config: RepositoryConfig, signal?: AbortSignal) => const runCheckStep = async (config: RepositoryConfig, signal: AbortSignal) => { const orgId = getOrganizationId(); - const result = await restic.check(config, { readData: false, signal, organizationId: orgId }).then( + const result = await runEffectPromise( + restic.check(config, { readData: false, signal, organizationId: orgId }), + ).then( (result) => result, (error) => ({ success: false, output: null, error: toMessage(error), hasErrors: true }), ); @@ -47,7 +49,7 @@ const runCheckStep = async (config: RepositoryConfig, signal: AbortSignal) => { const runRepairIndexStep = async (config: RepositoryConfig, signal: AbortSignal) => { const orgId = getOrganizationId(); - const result = await restic.repairIndex(config, { signal, organizationId: orgId }).then( + const result = await runEffectPromise(restic.repairIndex(config, { signal, organizationId: orgId })).then( (result) => ({ success: true, output: result.output, error: null }), (error) => ({ success: false, output: null, error: toMessage(error) }), ); diff --git a/app/server/modules/repositories/repositories.service.ts b/app/server/modules/repositories/repositories.service.ts index 1b5b42ca..61eeaeb4 100644 --- a/app/server/modules/repositories/repositories.service.ts +++ b/app/server/modules/repositories/repositories.service.ts @@ -6,6 +6,7 @@ import { type CompressionMode, type OverwriteMode, type RepositoryConfig, + type ResticDumpStream, type ResticStatsDto, repositoryConfigSchema, } from "@zerobyte/core/restic"; @@ -20,7 +21,7 @@ import { repoMutex } from "../../core/repository-mutex"; import { db } from "../../db/db"; import { repositoriesTable, type Repository } from "../../db/schema"; import { cache, cacheKeys } from "../../utils/cache"; -import { toMessage } from "../../utils/errors"; +import { runEffectPromise, toMessage } from "../../utils/errors"; import { generateShortId } from "../../utils/id"; import { addCommonArgs, buildEnv, buildRepoUrl, cleanupTemporaryKeys } from "@zerobyte/core/restic/server"; import { restic, resticDeps } from "../../core/restic"; @@ -32,6 +33,7 @@ import { executeDoctor } from "./helpers/doctor"; import type { ShortId } from "~/server/utils/branded"; import { decryptRepositoryConfig, encryptRepositoryConfig } from "./repository-config-secrets"; import { getScheduleByIdOrShortId } from "../backups/helpers/backup-schedule-lookups"; +import { Effect } from "effect"; const runningDoctors = new Map(); @@ -100,16 +102,18 @@ const createRepository = async (name: string, config: RepositoryConfig, compress let error: string | null = null; if (config.isExistingRepository) { - const result = await restic - .snapshots(encryptedConfig, { organizationId }) + const result = await runEffectPromise(restic.snapshots(encryptedConfig, { organizationId })) .then(() => ({ error: null })) .catch((error) => ({ error })); error = result.error; } else { - const initResult = await restic.init(encryptedConfig, organizationId, { - timeoutMs: appConfig.serverIdleTimeout * 1000, - }); + const initResult = await runEffectPromise( + restic.init(encryptedConfig, { + organizationId, + timeoutMs: appConfig.serverIdleTimeout * 1000, + }), + ); error = initResult.error; } @@ -143,7 +147,9 @@ const getRepository = async (shortId: ShortId) => { const runAndStoreRepositoryStats = async (repository: Repository): Promise => { const releaseLock = await repoMutex.acquireShared(repository.id, "stats"); try { - const stats = await restic.stats(repository.config, { organizationId: repository.organizationId }); + const stats = await runEffectPromise( + restic.stats(repository.config, { organizationId: repository.organizationId }), + ); await db .update(repositoriesTable) @@ -219,7 +225,7 @@ const listSnapshots = async (shortId: ShortId, backupId?: ShortId) => { } const cacheKey = cacheKeys.repository.snapshots(repository.id, backupId); - const cached = cache.get>>(cacheKey); + const cached = cache.get>>(cacheKey); if (cached) { return cached; } @@ -229,9 +235,11 @@ const listSnapshots = async (shortId: ShortId, backupId?: ShortId) => { let snapshots = []; if (backupId) { - snapshots = await restic.snapshots(repository.config, { tags: [backupId], organizationId }); + snapshots = await runEffectPromise( + restic.snapshots(repository.config, { tags: [backupId], organizationId }), + ); } else { - snapshots = await restic.snapshots(repository.config, { organizationId }); + snapshots = await runEffectPromise(restic.snapshots(repository.config, { organizationId })); } cache.set(cacheKey, snapshots); @@ -278,7 +286,9 @@ const listSnapshotFiles = async ( const releaseLock = await repoMutex.acquireShared(repository.id, `ls:${snapshotId}`); try { - const result = await restic.ls(repository.config, snapshotId, organizationId, path, { offset, limit }); + const result = await runEffectPromise( + restic.ls(repository.config, snapshotId, path, { organizationId, offset, limit }), + ); if (!result.snapshot) { throw new NotFoundError("Snapshot not found or empty"); @@ -358,19 +368,21 @@ const restoreSnapshot = async ( snapshotId, }); - const result = await restic.restore(repository.config, snapshotId, target, { - basePath, - ...options, - organizationId, - onProgress: (progress) => { - serverEvents.emit("restore:progress", { - organizationId, - repositoryId: repository.shortId, - snapshotId, - ...progress, - }); - }, - }); + const result = await runEffectPromise( + restic.restore(repository.config, snapshotId, target, { + basePath, + ...options, + organizationId, + onProgress: (progress) => { + serverEvents.emit("restore:progress", { + organizationId, + repositoryId: repository.shortId, + snapshotId, + ...progress, + }); + }, + }), + ); serverEvents.emit("restore:completed", { organizationId, @@ -408,7 +420,7 @@ const dumpSnapshot = async (shortId: ShortId, snapshotId: string, path?: string, } const releaseLock = await repoMutex.acquireShared(repository.id, `dump:${snapshotId}`); - let dumpStream: Awaited> | undefined = undefined; + let dumpStream: ResticDumpStream | null = null; try { const snapshot = await getSnapshotDetails(repository.shortId, snapshotId); @@ -436,7 +448,7 @@ const dumpSnapshot = async (shortId: ShortId, snapshotId: string, path?: string, } } - dumpStream = await restic.dump(repository.config, preparedDump.snapshotRef, dumpOptions); + dumpStream = await runEffectPromise(restic.dump(repository.config, preparedDump.snapshotRef, dumpOptions)); serverEvents.emit("dump:started", { organizationId, @@ -468,12 +480,12 @@ const getSnapshotDetails = async (shortId: ShortId, snapshotId: string) => { } const cacheKey = cacheKeys.repository.snapshots(repository.id); - let snapshots = cache.get>>(cacheKey); + let snapshots = cache.get>>(cacheKey); if (!snapshots) { const releaseLock = await repoMutex.acquireShared(repository.id, `snapshot_details:${snapshotId}`); try { - snapshots = await restic.snapshots(repository.config, { organizationId }); + snapshots = await runEffectPromise(restic.snapshots(repository.config, { organizationId })); cache.set(cacheKey, snapshots); } finally { releaseLock(); @@ -501,7 +513,7 @@ const checkHealth = async (shortId: ShortId) => { const releaseLock = await repoMutex.acquireExclusive(repository.id, "check"); try { - const { hasErrors, error } = await restic.check(repository.config, { organizationId }); + const { hasErrors, error } = await runEffectPromise(restic.check(repository.config, { organizationId })); await db .update(repositoriesTable) @@ -599,7 +611,7 @@ const deleteSnapshot = async (shortId: ShortId, snapshotId: string) => { const releaseLock = await repoMutex.acquireExclusive(repository.id, `delete:${snapshotId}`); try { - await restic.deleteSnapshot(repository.config, snapshotId, organizationId); + await runEffectPromise(restic.deleteSnapshot(repository.config, snapshotId, { organizationId })); cache.delByPrefix(cacheKeys.repository.all(repository.id)); void runAndStoreRepositoryStats(repository).catch((error) => { logger.error( @@ -622,7 +634,7 @@ const deleteSnapshots = async (shortId: ShortId, snapshotIds: string[]) => { let shouldRefreshStats = false; const releaseLock = await repoMutex.acquireExclusive(repository.id, `delete:bulk`); try { - await restic.deleteSnapshots(repository.config, snapshotIds, organizationId); + await runEffectPromise(restic.deleteSnapshots(repository.config, snapshotIds, { organizationId })); cache.delByPrefix(cacheKeys.repository.all(repository.id)); shouldRefreshStats = true; } finally { @@ -654,7 +666,7 @@ const tagSnapshots = async ( const releaseLock = await repoMutex.acquireExclusive(repository.id, `tag:bulk`); try { - await restic.tagSnapshots(repository.config, snapshotIds, tags, organizationId); + await runEffectPromise(restic.tagSnapshots(repository.config, snapshotIds, tags, { organizationId })); cache.delByPrefix(cacheKeys.repository.all(repository.id)); } finally { releaseLock(); @@ -673,7 +685,7 @@ const refreshSnapshots = async (shortId: ShortId) => { const releaseLock = await repoMutex.acquireShared(repository.id, "refresh"); try { - const snapshots = await restic.snapshots(repository.config, { organizationId }); + const snapshots = await runEffectPromise(restic.snapshots(repository.config, { organizationId })); const cacheKey = cacheKeys.repository.snapshots(repository.id); cache.set(cacheKey, snapshots); @@ -772,7 +784,7 @@ const unlockRepository = async (shortId: ShortId) => { const releaseLock = await repoMutex.acquireExclusive(repository.id, "unlock"); try { - const result = await restic.unlock(repository.config, { organizationId }); + const result = await runEffectPromise(restic.unlock(repository.config, { organizationId })); return result; } finally { releaseLock(); @@ -835,11 +847,13 @@ const getRetentionCategories = async (repositoryId: ShortId, scheduleId?: ShortI return new Map(); } - const dryRunResults = await restic.forget(repository.config, schedule.retentionPolicy, { - tag: scheduleId, - organizationId: getOrganizationId(), - dryRun: true, - }); + const dryRunResults = await runEffectPromise( + restic.forget(repository.config, schedule.retentionPolicy, { + tag: scheduleId, + organizationId: getOrganizationId(), + dryRun: true, + }), + ); if (!dryRunResults.data) { return new Map(); diff --git a/app/server/utils/errors.ts b/app/server/utils/errors.ts index 03d6742e..a424ea7a 100644 --- a/app/server/utils/errors.ts +++ b/app/server/utils/errors.ts @@ -2,6 +2,7 @@ import { HttpError } from "http-errors-enhanced"; import { sanitizeSensitiveData } from "@zerobyte/core/node"; import { ResticError } from "@zerobyte/core/restic"; import { toErrorDetails as getErrorDetails, toMessage as getMessage } from "@zerobyte/core/utils"; +import { Cause, Effect, Exit, Option } from "effect"; const formatAllowedHostsMessage = (message: string) => { const referencesAllowedHosts = /\ballowed\s+hosts?\b|\ballowedHosts\b/i.test(message); @@ -48,3 +49,13 @@ export const toMessage = (err: unknown): string => { export const toErrorDetails = (err: unknown): string => { return sanitizeSensitiveData(getErrorDetails(err)); }; + +export const runEffectPromise = (effect: Effect.Effect) => { + return Effect.runPromiseExit(effect).then((exit) => { + if (Exit.isSuccess(exit)) { + return exit.value; + } + + throw Option.getOrUndefined(Cause.failureOption(exit.cause)) ?? Cause.squash(exit.cause); + }); +}; diff --git a/app/test/backend-integration/runner.ts b/app/test/backend-integration/runner.ts index 6a2a14ef..84b07530 100644 --- a/app/test/backend-integration/runner.ts +++ b/app/test/backend-integration/runner.ts @@ -138,17 +138,19 @@ async function verifySnapshot( fixtureRootPath: string, scenario: IntegrationScenario, ) { - const snapshots = await resticClient.snapshots(repositoryConfig, { - organizationId, - tags: [uniqueBackupTag], - }); + const snapshots = await Effect.runPromise( + resticClient.snapshots(repositoryConfig, { + organizationId, + tags: [uniqueBackupTag], + }), + ); const snapshot = snapshots.find((candidate) => candidate.id === snapshotId || candidate.short_id === snapshotId); if (!snapshot) { throw new Error(`Unable to find snapshot ${snapshotId} by integration tag ${uniqueBackupTag}`); } - const res = await resticClient.ls(repositoryConfig, snapshotId, organizationId, undefined, {}); + const res = await Effect.runPromise(resticClient.ls(repositoryConfig, snapshotId, undefined, { organizationId })); await verifySnapshotEntries(fixtureRootPath, res.nodes, scenario.expectedEntries); } @@ -163,12 +165,14 @@ async function restoreSnapshot( restoreOptions: IntegrationScenario["restore"], ) { await fs.mkdir(restoreTarget, { recursive: true }); - await resticClient.restore(repositoryConfig, snapshotId, restoreTarget, { - organizationId, - basePath: fixtureRootPath, - excludeXattr: restoreOptions?.excludeXattr, - overwrite: restoreOptions?.overwrite, - }); + await Effect.runPromise( + resticClient.restore(repositoryConfig, snapshotId, restoreTarget, { + organizationId, + basePath: fixtureRootPath, + excludeXattr: restoreOptions?.excludeXattr, + overwrite: restoreOptions?.overwrite, + }), + ); } async function cleanupScenario(backend: VolumeBackend, restoreTarget: string, scenarioWorkspace: string) { @@ -226,7 +230,7 @@ async function runScenario(scenario: IntegrationScenario, runId: string): Promis await runStage(stages, "init-repository", async () => { if (scenario.repository.isExistingRepository) return; - const initResult = await resticClient.init(scenario.repository, organizationId, undefined); + const initResult = await Effect.runPromise(resticClient.init(scenario.repository, { organizationId })); if (!initResult.success) { throw new Error(initResult.error ?? "restic init failed"); } diff --git a/packages/core/src/restic/commands/__tests__/copy.test.ts b/packages/core/src/restic/commands/__tests__/copy.test.ts index 54d984d2..0c21392f 100644 --- a/packages/core/src/restic/commands/__tests__/copy.test.ts +++ b/packages/core/src/restic/commands/__tests__/copy.test.ts @@ -3,6 +3,7 @@ import * as cleanupModule from "../../helpers/cleanup-temporary-keys"; import * as nodeModule from "../../../node"; import { copy } from "../copy"; import type { ResticDeps } from "../../types"; +import { Effect } from "effect"; const mockDeps: ResticDeps = { resolveSecret: async (s) => s, @@ -49,7 +50,14 @@ describe("copy command", () => { test("treats flag-like snapshot IDs as positional args", async () => { const { getArgs } = setup(); - await copy(sourceConfig, destConfig, { organizationId: "org-1", snapshotIds: ["--help"], tag: "daily" }, mockDeps); + await Effect.runPromise( + copy( + sourceConfig, + destConfig, + { organizationId: "org-1", snapshotIds: ["--help"], tag: "daily" }, + mockDeps, + ), + ); const separatorIndex = getArgs().indexOf("--"); expect(separatorIndex).toBeGreaterThan(-1); @@ -59,7 +67,7 @@ describe("copy command", () => { test("defaults to 'latest' when no snapshotIds are provided", async () => { const { getArgs } = setup(); - await copy(sourceConfig, destConfig, { organizationId: "org-1", tag: "daily" }, mockDeps); + await Effect.runPromise(copy(sourceConfig, destConfig, { organizationId: "org-1", tag: "daily" }, mockDeps)); const separatorIndex = getArgs().indexOf("--"); expect(separatorIndex).toBeGreaterThan(-1); @@ -69,11 +77,13 @@ describe("copy command", () => { test("passes multiple snapshot IDs after separator", async () => { const { getArgs } = setup(); - await copy( - sourceConfig, - destConfig, - { organizationId: "org-1", snapshotIds: ["abc123", "def456", "ghi789"], tag: "daily" }, - mockDeps, + await Effect.runPromise( + copy( + sourceConfig, + destConfig, + { organizationId: "org-1", snapshotIds: ["abc123", "def456", "ghi789"], tag: "daily" }, + mockDeps, + ), ); const separatorIndex = getArgs().indexOf("--"); @@ -84,7 +94,9 @@ describe("copy command", () => { test("defaults to 'latest' when snapshotIds is empty array", async () => { const { getArgs } = setup(); - await copy(sourceConfig, destConfig, { organizationId: "org-1", snapshotIds: [], tag: "daily" }, mockDeps); + await Effect.runPromise( + copy(sourceConfig, destConfig, { organizationId: "org-1", snapshotIds: [], tag: "daily" }, mockDeps), + ); const separatorIndex = getArgs().indexOf("--"); expect(separatorIndex).toBeGreaterThan(-1); diff --git a/packages/core/src/restic/commands/__tests__/delete-snapshots.test.ts b/packages/core/src/restic/commands/__tests__/delete-snapshots.test.ts index 245f8424..1b35680a 100644 --- a/packages/core/src/restic/commands/__tests__/delete-snapshots.test.ts +++ b/packages/core/src/restic/commands/__tests__/delete-snapshots.test.ts @@ -3,6 +3,7 @@ import * as cleanupModule from "../../helpers/cleanup-temporary-keys"; import * as nodeModule from "../../../node"; import { deleteSnapshots } from "../delete-snapshots"; import type { ResticDeps } from "../../types"; +import { Effect } from "effect"; const mockDeps: ResticDeps = { resolveSecret: async (s) => s, @@ -43,10 +44,28 @@ describe("deleteSnapshots command", () => { const { getArgs } = setup(); const snapshotIds = ["--help", "--password-command=sh -c 'id'"]; - await deleteSnapshots(config, snapshotIds, "org-1", mockDeps); + await Effect.runPromise(deleteSnapshots(config, snapshotIds, { organizationId: "org-1" }, mockDeps)); const separatorIndex = getArgs().indexOf("--"); expect(separatorIndex).toBeGreaterThan(-1); expect(getArgs().slice(separatorIndex + 1)).toEqual(snapshotIds); }); + + test("rejects empty snapshot IDs before building restic env", async () => { + setup(); + const deps = { + ...mockDeps, + resolveSecret: vi.fn(mockDeps.resolveSecret), + }; + + await expect( + Effect.runPromise(deleteSnapshots(config, [], { organizationId: "org-1" }, deps)), + ).rejects.toMatchObject({ + message: "No snapshot IDs provided for deletion.", + }); + + expect(deps.resolveSecret).not.toHaveBeenCalled(); + expect(nodeModule.safeExec).not.toHaveBeenCalled(); + expect(cleanupModule.cleanupTemporaryKeys).not.toHaveBeenCalled(); + }); }); diff --git a/packages/core/src/restic/commands/__tests__/dump.test.ts b/packages/core/src/restic/commands/__tests__/dump.test.ts index ab329071..b0ba140e 100644 --- a/packages/core/src/restic/commands/__tests__/dump.test.ts +++ b/packages/core/src/restic/commands/__tests__/dump.test.ts @@ -4,6 +4,7 @@ import * as cleanupModule from "../../helpers/cleanup-temporary-keys"; import * as nodeModule from "../../../node"; import { dump } from "../dump"; import type { ResticDeps } from "../../types"; +import { Effect } from "effect"; const mockDeps: ResticDeps = { resolveSecret: async (s) => s, @@ -45,7 +46,9 @@ describe("dump command", () => { test("treats snapshot reference as a positional arg", async () => { const { getArgs } = setup(); - const result = await dump(config, "--help", { organizationId: "org-1", path: "folder/file.txt" }, mockDeps); + const result = await Effect.runPromise( + dump(config, "--help", { organizationId: "org-1", path: "folder/file.txt" }, mockDeps), + ); await result.completion; const separatorIndex = getArgs().indexOf("--"); diff --git a/packages/core/src/restic/commands/__tests__/ls.test.ts b/packages/core/src/restic/commands/__tests__/ls.test.ts index f06c9c69..150ce7c8 100644 --- a/packages/core/src/restic/commands/__tests__/ls.test.ts +++ b/packages/core/src/restic/commands/__tests__/ls.test.ts @@ -4,6 +4,7 @@ import * as spawnModule from "../../../node/spawn"; import { ls } from "../ls"; import type { ResticDeps } from "../../types"; import type { SafeSpawnParams } from "../../../node/spawn"; +import { Effect } from "effect"; const mockDeps: ResticDeps = { resolveSecret: async (s) => s, @@ -57,7 +58,7 @@ describe("ls command", () => { const snapshotId = "--password-command=sh -c 'id'"; const path = "--help"; - await ls(config, snapshotId, "org-1", path, undefined, mockDeps); + await Effect.runPromise(ls(config, snapshotId, path, { organizationId: "org-1" }, mockDeps)); const separatorIndex = getArgs().indexOf("--"); expect(separatorIndex).toBeGreaterThan(-1); diff --git a/packages/core/src/restic/commands/__tests__/restore.test.ts b/packages/core/src/restic/commands/__tests__/restore.test.ts index d97da538..a043a923 100644 --- a/packages/core/src/restic/commands/__tests__/restore.test.ts +++ b/packages/core/src/restic/commands/__tests__/restore.test.ts @@ -5,6 +5,7 @@ import { ResticError } from "../../error"; import { restore } from "../restore"; import type { ResticDeps } from "../../types"; import type { SafeSpawnParams, SpawnResult } from "../../../node/spawn"; +import { Effect } from "effect"; const mockDeps: ResticDeps = { resolveSecret: async (s) => s, @@ -93,18 +94,20 @@ describe("restore command", () => { test("uses the common ancestor as restore root and strips includes for non-root targets", async () => { const { getRestoreArg, getOptionValues } = setup(); - await 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", - ], - }, - mockDeps, + await Effect.runPromise( + 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", + ], + }, + mockDeps, + ), ); expect(getRestoreArg()).toBe("snapshot-456:/var/lib/zerobyte/volumes/vol123/_data"); @@ -114,16 +117,18 @@ describe("restore command", () => { test("restores a selected file from its parent directory for non-root targets", async () => { const { getRestoreArg, getOptionValues } = setup(); - await restore( - config, - "snapshot-single-file", - "/tmp/restore-target", - { - organizationId: "org-1", - include: ["/var/lib/zerobyte/volumes/vol123/_data/archive/backup.20260301-233001.7z"], - selectedItemKind: "file", - }, - mockDeps, + await Effect.runPromise( + restore( + config, + "snapshot-single-file", + "/tmp/restore-target", + { + organizationId: "org-1", + include: ["/var/lib/zerobyte/volumes/vol123/_data/archive/backup.20260301-233001.7z"], + selectedItemKind: "file", + }, + mockDeps, + ), ); expect(getRestoreArg()).toBe("snapshot-single-file:/var/lib/zerobyte/volumes/vol123/_data/archive"); @@ -133,15 +138,17 @@ describe("restore command", () => { test("treats flag-like snapshot IDs as positional restore args", async () => { const { getArgs, getRestoreArg } = setup(); - await restore( - config, - "--help", - "/tmp/restore-target", - { - organizationId: "org-1", - basePath: "/var/lib/zerobyte/volumes/vol123/_data", - }, - mockDeps, + await Effect.runPromise( + restore( + config, + "--help", + "/tmp/restore-target", + { + organizationId: "org-1", + basePath: "/var/lib/zerobyte/volumes/vol123/_data", + }, + mockDeps, + ), ); const separatorIndex = getArgs().indexOf("--"); @@ -154,12 +161,14 @@ describe("restore command", () => { test("returns a parsed restore summary on success", async () => { setup(); - const result = await restore( - config, - "snapshot-123", - "/tmp/restore-target", - { organizationId: "org-1", basePath: "/var/lib/zerobyte/volumes/vol123/_data" }, - mockDeps, + const result = await Effect.runPromise( + restore( + config, + "snapshot-123", + "/tmp/restore-target", + { organizationId: "org-1", basePath: "/var/lib/zerobyte/volumes/vol123/_data" }, + mockDeps, + ), ); expect(result).toMatchObject({ @@ -172,7 +181,25 @@ describe("restore command", () => { test("throws ResticError when the command fails", async () => { setup({ spawnResult: { exitCode: 1, summary: "", error: "restore failed" } }); - await expect( + const error = await Effect.runPromise( + Effect.flip( + restore( + config, + "snapshot-123", + "/tmp/restore-target", + { organizationId: "org-1", basePath: "/var/lib/zerobyte/volumes/vol123/_data" }, + mockDeps, + ), + ), + ); + + expect(error).toBeInstanceOf(ResticError); + }); + + test("falls back to an empty summary when restic output cannot be parsed", async () => { + setup({ spawnResult: { summary: "not-json" } }); + + const result = await Effect.runPromise( restore( config, "snapshot-123", @@ -180,18 +207,6 @@ describe("restore command", () => { { organizationId: "org-1", basePath: "/var/lib/zerobyte/volumes/vol123/_data" }, mockDeps, ), - ).rejects.toBeInstanceOf(ResticError); - }); - - test("falls back to an empty summary when restic output cannot be parsed", async () => { - setup({ spawnResult: { summary: "not-json" } }); - - const result = await restore( - config, - "snapshot-123", - "/tmp/restore-target", - { organizationId: "org-1", basePath: "/var/lib/zerobyte/volumes/vol123/_data" }, - mockDeps, ); expect(result).toEqual({ @@ -209,16 +224,18 @@ describe("restore command", () => { const progressUpdates: unknown[] = []; setup({ onSpawnCall: (params) => params.onStdout?.(validProgressLine) }); - await restore( - config, - "snapshot-123", - "/tmp/restore-target", - { - organizationId: "org-1", - basePath: "/var/lib/zerobyte/volumes/vol123/_data", - onProgress: (progress) => progressUpdates.push(progress), - }, - mockDeps, + await Effect.runPromise( + restore( + config, + "snapshot-123", + "/tmp/restore-target", + { + organizationId: "org-1", + basePath: "/var/lib/zerobyte/volumes/vol123/_data", + onProgress: (progress) => progressUpdates.push(progress), + }, + mockDeps, + ), ); expect(progressUpdates).toHaveLength(1); @@ -238,16 +255,18 @@ describe("restore command", () => { }, }); - await restore( - config, - "snapshot-123", - "/tmp/restore-target", - { - organizationId: "org-1", - basePath: "/var/lib/zerobyte/volumes/vol123/_data", - onProgress: (progress) => progressUpdates.push(progress), - }, - mockDeps, + await Effect.runPromise( + restore( + config, + "snapshot-123", + "/tmp/restore-target", + { + organizationId: "org-1", + basePath: "/var/lib/zerobyte/volumes/vol123/_data", + onProgress: (progress) => progressUpdates.push(progress), + }, + mockDeps, + ), ); expect(progressUpdates).toHaveLength(0); diff --git a/packages/core/src/restic/commands/__tests__/snapshots.test.ts b/packages/core/src/restic/commands/__tests__/snapshots.test.ts index 6619a98e..6475cba5 100644 --- a/packages/core/src/restic/commands/__tests__/snapshots.test.ts +++ b/packages/core/src/restic/commands/__tests__/snapshots.test.ts @@ -4,6 +4,7 @@ import * as nodeModule from "../../../node"; import { snapshots } from "../snapshots"; import type { SafeSpawnParams } from "../../../node"; import type { ResticDeps } from "../../types"; +import { Effect } from "effect"; const mockDeps: ResticDeps = { resolveSecret: async (s) => s, @@ -57,7 +58,7 @@ describe("snapshots command", () => { return Promise.resolve({ exitCode: 0, summary: "", error: "" }); }); - const result = await snapshots(config, { organizationId: "org-1" }, mockDeps); + const result = await Effect.runPromise(snapshots(config, { organizationId: "org-1" }, mockDeps)); expect(result).toEqual(snapshotsOutput); }); diff --git a/packages/core/src/restic/commands/__tests__/tag-snapshots.test.ts b/packages/core/src/restic/commands/__tests__/tag-snapshots.test.ts index e028ec61..64a682be 100644 --- a/packages/core/src/restic/commands/__tests__/tag-snapshots.test.ts +++ b/packages/core/src/restic/commands/__tests__/tag-snapshots.test.ts @@ -3,6 +3,7 @@ import * as cleanupModule from "../../helpers/cleanup-temporary-keys"; import * as nodeModule from "../../../node"; import { tagSnapshots } from "../tag-snapshots"; import type { ResticDeps } from "../../types"; +import { Effect } from "effect"; const mockDeps: ResticDeps = { resolveSecret: async (s) => s, @@ -43,7 +44,9 @@ describe("tagSnapshots command", () => { const { getArgs } = setup(); const snapshotIds = ["--help", "--password-command=sh -c 'id'"]; - await tagSnapshots(config, snapshotIds, { add: ["keep"] }, "org-1", mockDeps); + await Effect.runPromise( + tagSnapshots(config, snapshotIds, { add: ["keep"] }, { organizationId: "org-1" }, mockDeps), + ); const separatorIndex = getArgs().indexOf("--"); expect(separatorIndex).toBeGreaterThan(-1); diff --git a/packages/core/src/restic/commands/check.ts b/packages/core/src/restic/commands/check.ts index 36280a1c..48743da1 100644 --- a/packages/core/src/restic/commands/check.ts +++ b/packages/core/src/restic/commands/check.ts @@ -1,3 +1,4 @@ +import { Data, Effect } from "effect"; import { logger, safeExec } from "../../node"; import { addCommonArgs } from "../helpers/add-common-args"; import { buildEnv } from "../helpers/build-env"; @@ -5,8 +6,15 @@ import { buildRepoUrl } from "../helpers/build-repo-url"; import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; import type { RepositoryConfig } from "../schemas"; import type { ResticDeps } from "../types"; +import { ResticError } from "../error"; +import { toMessage } from "../../utils"; -export const check = async ( +class ResticCheckCommandError extends Data.TaggedError("ResticCheckCommandError")<{ + cause: unknown; + message: string; +}> {} + +export const check = ( config: RepositoryConfig, options: { readData?: boolean; @@ -15,54 +23,68 @@ export const check = async ( }, deps: ResticDeps, ) => { - const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config, options.organizationId, deps); + return Effect.tryPromise({ + try: async () => { + const repoUrl = buildRepoUrl(config); + const env = await buildEnv(config, options.organizationId, deps); - const args: string[] = ["--repo", repoUrl, "check"]; + const args: string[] = ["--repo", repoUrl, "check"]; - if (options.readData) { - args.push("--read-data"); - } + if (options.readData) { + args.push("--read-data"); + } - addCommonArgs(args, env, config); + addCommonArgs(args, env, config); - const res = await safeExec({ - command: "restic", - args, - env, - signal: options.signal, + const res = await safeExec({ + command: "restic", + args, + env, + signal: options.signal, + }); + await cleanupTemporaryKeys(env, deps); + + if (options.signal?.aborted) { + logger.warn("Restic check was aborted by signal."); + return { + success: false, + hasErrors: true, + output: "", + error: "Operation aborted", + }; + } + + const { stdout, stderr } = res; + + if (res.exitCode !== 0) { + logger.error(`Restic check failed: ${stderr}`); + return { + success: false, + hasErrors: true, + output: stdout, + error: stderr, + }; + } + + const hasErrors = stdout.includes("Fatal"); + + logger.info(`Restic check completed for repository: ${repoUrl}`); + return { + success: !hasErrors, + hasErrors, + output: stdout, + error: hasErrors ? "Repository contains errors" : null, + }; + }, + catch: (error) => { + if (error instanceof ResticError) { + return error; + } + + return new ResticCheckCommandError({ + cause: error, + message: toMessage(error), + }); + }, }); - await cleanupTemporaryKeys(env, deps); - - if (options.signal?.aborted) { - logger.warn("Restic check was aborted by signal."); - return { - success: false, - hasErrors: true, - output: "", - error: "Operation aborted", - }; - } - - const { stdout, stderr } = res; - - if (res.exitCode !== 0) { - logger.error(`Restic check failed: ${stderr}`); - return { - success: false, - hasErrors: true, - output: stdout, - error: stderr, - }; - } - - const hasErrors = stdout.includes("Fatal"); - - logger.info(`Restic check completed for repository: ${repoUrl}`); - return { - success: !hasErrors, - hasErrors, - output: stdout, - error: hasErrors ? "Repository contains errors" : null, - }; }; diff --git a/packages/core/src/restic/commands/copy.ts b/packages/core/src/restic/commands/copy.ts index 66f22302..03cc739f 100644 --- a/packages/core/src/restic/commands/copy.ts +++ b/packages/core/src/restic/commands/copy.ts @@ -7,68 +7,89 @@ import type { RepositoryConfig } from "../schemas"; import { ResticError } from "../error"; import { logger, safeExec } from "../../node"; import type { ResticDeps } from "../types"; +import { Data, Effect } from "effect"; +import { toMessage } from "../../utils"; -export const copy = async ( +class ResticCopyCommandError extends Data.TaggedError("ResticCopyCommandError")<{ + cause: unknown; + message: string; +}> {} + +export const copy = ( sourceConfig: RepositoryConfig, destConfig: RepositoryConfig, options: { organizationId: string; tag?: string; snapshotIds?: string[] }, deps: ResticDeps, ) => { - const sourceRepoUrl = buildRepoUrl(sourceConfig); - const destRepoUrl = buildRepoUrl(destConfig); + return Effect.tryPromise({ + try: async () => { + const sourceRepoUrl = buildRepoUrl(sourceConfig); + const destRepoUrl = buildRepoUrl(destConfig); - const sourceEnv = await buildEnv(sourceConfig, options.organizationId, deps); - const destEnv = await buildEnv(destConfig, options.organizationId, deps); + const sourceEnv = await buildEnv(sourceConfig, options.organizationId, deps); + const destEnv = await buildEnv(destConfig, options.organizationId, deps); - const env: Record = { - ...sourceEnv, - ...destEnv, - RESTIC_FROM_PASSWORD_FILE: sourceEnv.RESTIC_PASSWORD_FILE!, - }; + const env: Record = { + ...sourceEnv, + ...destEnv, + RESTIC_FROM_PASSWORD_FILE: sourceEnv.RESTIC_PASSWORD_FILE!, + }; - const args: string[] = ["--repo", destRepoUrl, "copy", "--from-repo", sourceRepoUrl]; + const args: string[] = ["--repo", destRepoUrl, "copy", "--from-repo", sourceRepoUrl]; - if (options.tag) { - args.push("--tag", options.tag); - } + if (options.tag) { + args.push("--tag", options.tag); + } - addCommonArgs(args, env, destConfig, { skipBandwidth: true }); + addCommonArgs(args, env, destConfig, { skipBandwidth: true }); - const sourceDownloadLimit = formatBandwidthLimit(sourceConfig.downloadLimit); - const destUploadLimit = formatBandwidthLimit(destConfig.uploadLimit); + const sourceDownloadLimit = formatBandwidthLimit(sourceConfig.downloadLimit); + const destUploadLimit = formatBandwidthLimit(destConfig.uploadLimit); - if (sourceDownloadLimit) { - args.push("--limit-download", sourceDownloadLimit); - } + if (sourceDownloadLimit) { + args.push("--limit-download", sourceDownloadLimit); + } - if (destUploadLimit) { - args.push("--limit-upload", destUploadLimit); - } + if (destUploadLimit) { + args.push("--limit-upload", destUploadLimit); + } - if (options.snapshotIds && options.snapshotIds.length > 0) { - args.push("--", ...options.snapshotIds); - } else { - args.push("--", "latest"); - } + if (options.snapshotIds && options.snapshotIds.length > 0) { + args.push("--", ...options.snapshotIds); + } else { + args.push("--", "latest"); + } - logger.info(`Copying snapshots from ${sourceRepoUrl} to ${destRepoUrl}...`); - logger.debug(`Executing: restic ${args.join(" ")}`); + logger.info(`Copying snapshots from ${sourceRepoUrl} to ${destRepoUrl}...`); + logger.debug(`Executing: restic ${args.join(" ")}`); - const res = await safeExec({ command: "restic", args, env }); + const res = await safeExec({ command: "restic", args, env }); - await cleanupTemporaryKeys(sourceEnv, deps); - await cleanupTemporaryKeys(destEnv, deps); + await cleanupTemporaryKeys(sourceEnv, deps); + await cleanupTemporaryKeys(destEnv, deps); - const { stdout, stderr } = res; + const { stdout, stderr } = res; - if (res.exitCode !== 0) { - logger.error(`Restic copy failed: ${stderr}`); - throw new ResticError(res.exitCode, stderr); - } + if (res.exitCode !== 0) { + logger.error(`Restic copy failed: ${stderr}`); + throw new ResticError(res.exitCode, stderr); + } - logger.info(`Restic copy completed from ${sourceRepoUrl} to ${destRepoUrl}`); - return { - success: true, - output: stdout, - }; + logger.info(`Restic copy completed from ${sourceRepoUrl} to ${destRepoUrl}`); + return { + success: true, + output: stdout, + }; + }, + catch: (error) => { + if (error instanceof ResticError) { + return error; + } + + return new ResticCopyCommandError({ + cause: error, + message: toMessage(error), + }); + }, + }); }; diff --git a/packages/core/src/restic/commands/delete-snapshots.ts b/packages/core/src/restic/commands/delete-snapshots.ts index bf52b04c..d6a521b7 100644 --- a/packages/core/src/restic/commands/delete-snapshots.ts +++ b/packages/core/src/restic/commands/delete-snapshots.ts @@ -1,3 +1,4 @@ +import { Data, Effect } from "effect"; import { logger, safeExec } from "../../node"; import { ResticError } from "../error"; import { addCommonArgs } from "../helpers/add-common-args"; @@ -6,40 +7,60 @@ import { buildRepoUrl } from "../helpers/build-repo-url"; import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; import type { RepositoryConfig } from "../schemas"; import type { ResticDeps } from "../types"; +import { toMessage } from "../../utils"; -export const deleteSnapshots = async ( +class ResticDeleteSnapshotCommandError extends Data.TaggedError("ResticDeleteSnapshotCommandError")<{ + cause: unknown; + message: string; +}> {} + +export const deleteSnapshots = ( config: RepositoryConfig, snapshotIds: string[], - organizationId: string, + options: { organizationId: string }, deps: ResticDeps, ) => { - const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config, organizationId, deps); + return Effect.tryPromise({ + try: async () => { + if (snapshotIds.length === 0) { + throw new Error("No snapshot IDs provided for deletion."); + } - if (snapshotIds.length === 0) { - throw new Error("No snapshot IDs provided for deletion."); - } + const repoUrl = buildRepoUrl(config); + const env = await buildEnv(config, options.organizationId, deps); - const args: string[] = ["--repo", repoUrl, "forget", "--prune"]; - addCommonArgs(args, env, config); - args.push("--", ...snapshotIds); + const args: string[] = ["--repo", repoUrl, "forget", "--prune"]; + addCommonArgs(args, env, config); + args.push("--", ...snapshotIds); - const res = await safeExec({ command: "restic", args, env }); - await cleanupTemporaryKeys(env, deps); + const res = await safeExec({ command: "restic", args, env }); + await cleanupTemporaryKeys(env, deps); - if (res.exitCode !== 0) { - logger.error(`Restic snapshot deletion failed: ${res.stderr}`); - throw new ResticError(res.exitCode, res.stderr); - } + if (res.exitCode !== 0) { + logger.error(`Restic snapshot deletion failed: ${res.stderr}`); + throw new ResticError(res.exitCode, res.stderr); + } - return { success: true }; + return { success: true }; + }, + catch: (error) => { + if (error instanceof ResticError) { + return error; + } + + return new ResticDeleteSnapshotCommandError({ + cause: error, + message: toMessage(error), + }); + }, + }); }; -export const deleteSnapshot = async ( +export const deleteSnapshot = ( config: RepositoryConfig, snapshotId: string, - organizationId: string, + options: { organizationId: string }, deps: ResticDeps, ) => { - return deleteSnapshots(config, [snapshotId], organizationId, deps); + return deleteSnapshots(config, [snapshotId], options, deps); }; diff --git a/packages/core/src/restic/commands/dump.ts b/packages/core/src/restic/commands/dump.ts index 34104581..99406362 100644 --- a/packages/core/src/restic/commands/dump.ts +++ b/packages/core/src/restic/commands/dump.ts @@ -1,6 +1,6 @@ import { normalizeAbsolutePath } from "../../utils/path"; import type { Readable } from "node:stream"; -import type { ResticDeps, ResticDumpStream } from "../types"; +import type { ResticDeps } from "../types"; import { addCommonArgs } from "../helpers/add-common-args"; import { buildEnv } from "../helpers/build-env"; import { buildRepoUrl } from "../helpers/build-repo-url"; @@ -8,6 +8,13 @@ import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; import type { RepositoryConfig } from "../schemas"; import { logger, safeSpawn } from "../../node"; import { ResticError } from "../error"; +import { Data, Effect } from "effect"; +import { toMessage } from "../../utils"; + +class ResticDumpCommandError extends Data.TaggedError("ResticDumpCommandError")<{ + cause: unknown; + message: string; +}> {} const normalizeDumpPath = (pathToDump?: string): string => { const trimmedPath = pathToDump?.trim(); @@ -18,7 +25,7 @@ const normalizeDumpPath = (pathToDump?: string): string => { return normalizeAbsolutePath(trimmedPath); }; -export const dump = async ( +export const dump = ( config: RepositoryConfig, snapshotRef: string, options: { @@ -27,86 +34,100 @@ export const dump = async ( archive?: false; }, deps: ResticDeps, -): Promise => { - const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config, options.organizationId, deps); - const pathToDump = normalizeDumpPath(options.path); +) => { + return Effect.tryPromise({ + try: async () => { + const repoUrl = buildRepoUrl(config); + const env = await buildEnv(config, options.organizationId, deps); + const pathToDump = normalizeDumpPath(options.path); - const args: string[] = ["--repo", repoUrl, "dump"]; + const args: string[] = ["--repo", repoUrl, "dump"]; - if (options.archive !== false) { - args.push("--archive", "tar"); - } + if (options.archive !== false) { + args.push("--archive", "tar"); + } - addCommonArgs(args, env, config, { includeJson: false }); - args.push("--", snapshotRef, pathToDump); + addCommonArgs(args, env, config, { includeJson: false }); + args.push("--", snapshotRef, pathToDump); - logger.debug(`Executing: restic ${args.join(" ")}`); + logger.debug(`Executing: restic ${args.join(" ")}`); - let didCleanup = false; - const cleanup = async () => { - if (didCleanup) { - return; - } - - didCleanup = true; - await cleanupTemporaryKeys(env, deps); - }; - - let stream: Readable | null = null; - let abortController: AbortController | null = new AbortController(); - - const maxStderrChars = 64 * 1024; - let stderrTail = ""; - - const completion = safeSpawn({ - command: "restic", - args, - env, - signal: abortController.signal, - stdoutMode: "raw", - onSpawn: (child) => { - stream = child.stdout; - }, - onStderr: (line) => { - const chunk = line.trim(); - if (chunk) { - stderrTail += `${line}\n`; - if (stderrTail.length > maxStderrChars) { - stderrTail = stderrTail.slice(-maxStderrChars); + let didCleanup = false; + const cleanup = async () => { + if (didCleanup) { + return; } + + didCleanup = true; + await cleanupTemporaryKeys(env, deps); + }; + + let stream: Readable | null = null; + let abortController: AbortController | null = new AbortController(); + + const maxStderrChars = 64 * 1024; + let stderrTail = ""; + + const completion = safeSpawn({ + command: "restic", + args, + env, + signal: abortController.signal, + stdoutMode: "raw", + onSpawn: (child) => { + stream = child.stdout; + }, + onStderr: (line) => { + const chunk = line.trim(); + if (chunk) { + stderrTail += `${line}\n`; + if (stderrTail.length > maxStderrChars) { + stderrTail = stderrTail.slice(-maxStderrChars); + } + } + }, + }) + .then((result) => { + if (result.exitCode === 0) { + return; + } + + const stderr = stderrTail.trim() || result.error; + logger.error(`Restic dump failed: ${stderr}`); + throw new ResticError(result.exitCode, stderr); + }) + .finally(async () => { + abortController = null; + await cleanup(); + }); + + completion.catch(() => {}); + const completionPromise = new Promise((resolve, reject) => completion.then(resolve, reject)); + + if (!stream) { + await cleanup(); + throw new Error("Failed to initialize restic dump stream"); } + + return { + stream, + completion: completionPromise, + abort: () => { + if (abortController) { + abortController.abort(); + } + }, + }; }, - }) - .then((result) => { - if (result.exitCode === 0) { - return; + catch: (error) => { + if (error instanceof ResticError) { + return error; } - const stderr = stderrTail.trim() || result.error; - logger.error(`Restic dump failed: ${stderr}`); - throw new ResticError(result.exitCode, stderr); - }) - .finally(async () => { - abortController = null; - await cleanup(); - }); - - completion.catch(() => {}); - const completionPromise = new Promise((resolve, reject) => completion.then(resolve, reject)); - - if (!stream) { - await cleanup(); - throw new Error("Failed to initialize restic dump stream"); - } - - return { - stream, - completion: completionPromise, - abort: () => { - if (abortController) { - abortController.abort(); - } + return new ResticDumpCommandError({ + cause: error, + message: toMessage(error), + }); }, - }; + }); }; diff --git a/packages/core/src/restic/commands/forget.ts b/packages/core/src/restic/commands/forget.ts index c907c7f6..72c9c0eb 100644 --- a/packages/core/src/restic/commands/forget.ts +++ b/packages/core/src/restic/commands/forget.ts @@ -7,60 +7,81 @@ import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; import type { RepositoryConfig } from "../schemas"; import { logger, safeExec } from "../../node"; import { ResticError } from "../error"; +import { toMessage } from "../../utils"; +import { Data, Effect } from "effect"; -export const forget = async ( +class ResticForgetCommandError extends Data.TaggedError("ResticForgetCommandError")<{ + cause: unknown; + message: string; +}> {} + +export const forget = ( config: RepositoryConfig, options: RetentionPolicy, extra: { tag: string; organizationId: string; dryRun?: boolean }, deps: ResticDeps, ) => { - const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config, extra.organizationId, deps); + return Effect.tryPromise({ + try: async () => { + const repoUrl = buildRepoUrl(config); + const env = await buildEnv(config, extra.organizationId, deps); - const args: string[] = ["--repo", repoUrl, "forget", "--group-by", "tags", "--tag", extra.tag]; + const args: string[] = ["--repo", repoUrl, "forget", "--group-by", "tags", "--tag", extra.tag]; - if (extra.dryRun) { - args.push("--dry-run", "--no-lock"); - } + if (extra.dryRun) { + args.push("--dry-run", "--no-lock"); + } - if (options.keepLast) { - args.push("--keep-last", String(options.keepLast)); - } - if (options.keepHourly) { - args.push("--keep-hourly", String(options.keepHourly)); - } - if (options.keepDaily) { - args.push("--keep-daily", String(options.keepDaily)); - } - if (options.keepWeekly) { - args.push("--keep-weekly", String(options.keepWeekly)); - } - if (options.keepMonthly) { - args.push("--keep-monthly", String(options.keepMonthly)); - } - if (options.keepYearly) { - args.push("--keep-yearly", String(options.keepYearly)); - } - if (options.keepWithinDuration) { - args.push("--keep-within-duration", options.keepWithinDuration); - } + if (options.keepLast) { + args.push("--keep-last", String(options.keepLast)); + } + if (options.keepHourly) { + args.push("--keep-hourly", String(options.keepHourly)); + } + if (options.keepDaily) { + args.push("--keep-daily", String(options.keepDaily)); + } + if (options.keepWeekly) { + args.push("--keep-weekly", String(options.keepWeekly)); + } + if (options.keepMonthly) { + args.push("--keep-monthly", String(options.keepMonthly)); + } + if (options.keepYearly) { + args.push("--keep-yearly", String(options.keepYearly)); + } + if (options.keepWithinDuration) { + args.push("--keep-within-duration", options.keepWithinDuration); + } - if (!extra.dryRun) { - args.push("--prune"); - } + if (!extra.dryRun) { + args.push("--prune"); + } - addCommonArgs(args, env, config); + addCommonArgs(args, env, config); - const res = await safeExec({ command: "restic", args, env }); - await cleanupTemporaryKeys(env, deps); + const res = await safeExec({ command: "restic", args, env }); + await cleanupTemporaryKeys(env, deps); - if (res.exitCode !== 0) { - logger.error(`Restic forget failed: ${res.stderr}`); - throw new ResticError(res.exitCode, res.stderr); - } + if (res.exitCode !== 0) { + logger.error(`Restic forget failed: ${res.stderr}`); + throw new ResticError(res.exitCode, res.stderr); + } - const lines = res.stdout.split("\n").filter((line) => line.trim()); - const result = extra.dryRun ? safeJsonParse(lines.at(-1) ?? "[]") : null; + const lines = res.stdout.split("\n").filter((line) => line.trim()); + const result = extra.dryRun ? safeJsonParse(lines.at(-1) ?? "[]") : null; - return { success: true, data: result }; + return { success: true, data: result }; + }, + catch: (error) => { + if (error instanceof ResticError) { + return error; + } + + return new ResticForgetCommandError({ + cause: error, + message: toMessage(error), + }); + }, + }); }; diff --git a/packages/core/src/restic/commands/init.ts b/packages/core/src/restic/commands/init.ts index bfe82665..58ffbb9b 100644 --- a/packages/core/src/restic/commands/init.ts +++ b/packages/core/src/restic/commands/init.ts @@ -6,22 +6,31 @@ import { keyAdd } from "./key-add"; import type { RepositoryConfig } from "../schemas"; import { logger, safeExec } from "../../node"; import type { ResticDeps } from "../types"; +import { Data, Effect } from "effect"; +import { ResticError } from "../error"; +import { toMessage } from "../../utils"; + +class ResticInitCommandError extends Data.TaggedError("ResticInitCommandError")<{ + cause: unknown; + message: string; +}> {} const addDefaultKey = async ( config: RepositoryConfig, - organizationId: string, - options: { timeoutMs?: number }, + options: { organizationId: string; timeoutMs?: number }, deps: ResticDeps, ) => { if (deps?.hostname) { - const keyResult = await keyAdd( - config, - organizationId, - { - host: deps.hostname, - timeoutMs: options?.timeoutMs, - }, - deps, + const keyResult = await Effect.runPromise( + keyAdd( + config, + { + organizationId: options.organizationId, + host: deps.hostname, + timeoutMs: options?.timeoutMs, + }, + deps, + ), ); if (!keyResult.success) { @@ -30,39 +39,45 @@ const addDefaultKey = async ( } }; -export const init = async ( +export const init = ( config: RepositoryConfig, - organizationId: string, - options: { timeoutMs?: number } | undefined, + options: { organizationId: string; timeoutMs?: number }, deps: ResticDeps, ) => { - const repoUrl = buildRepoUrl(config); + return Effect.tryPromise({ + try: async () => { + const repoUrl = buildRepoUrl(config); - logger.info(`Initializing restic repository at ${repoUrl}...`); + logger.info(`Initializing restic repository at ${repoUrl}...`); - const env = await buildEnv(config, organizationId, deps); + const env = await buildEnv(config, options.organizationId, deps); - const args = ["init", "--repo", repoUrl]; - addCommonArgs(args, env, config); + const args = ["init", "--repo", repoUrl]; + addCommonArgs(args, env, config); - const res = await safeExec({ command: "restic", args, env, timeout: options?.timeoutMs ?? 60000 }); - await cleanupTemporaryKeys(env, deps); + const res = await safeExec({ command: "restic", args, env, timeout: options?.timeoutMs ?? 60000 }); + await cleanupTemporaryKeys(env, deps); - if (res.exitCode !== 0) { - logger.error(`Restic init failed: ${res.stderr}`); - return { success: false, error: res.stderr }; - } + if (res.exitCode !== 0) { + logger.error(`Restic init failed: ${res.stderr}`); + return { success: false, error: res.stderr }; + } - logger.info(`Restic repository initialized: ${repoUrl}`); + logger.info(`Restic repository initialized: ${repoUrl}`); - void addDefaultKey( - config, - organizationId, - { - timeoutMs: options?.timeoutMs, + void addDefaultKey(config, { organizationId: options.organizationId, timeoutMs: options?.timeoutMs }, deps); + + return { success: true, error: null }; }, - deps, - ); + catch: (error) => { + if (error instanceof ResticError) { + return error; + } - return { success: true, error: null }; + return new ResticInitCommandError({ + cause: error, + message: toMessage(error), + }); + }, + }); }; diff --git a/packages/core/src/restic/commands/key-add.ts b/packages/core/src/restic/commands/key-add.ts index 81be7016..e4b80796 100644 --- a/packages/core/src/restic/commands/key-add.ts +++ b/packages/core/src/restic/commands/key-add.ts @@ -1,3 +1,4 @@ +import { Data, Effect } from "effect"; import { logger, safeExec } from "../../node"; import { addCommonArgs } from "../helpers/add-common-args"; import { buildEnv } from "../helpers/build-env"; @@ -5,40 +6,60 @@ import { buildRepoUrl } from "../helpers/build-repo-url"; import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; import type { RepositoryConfig } from "../schemas"; import type { ResticDeps } from "../types"; +import { toMessage } from "../../utils"; +import { ResticError } from "../error"; -export const keyAdd = async ( +class ResticKeyAddCommandError extends Data.TaggedError("ResticKeyAddCommandError")<{ + cause: unknown; + message: string; +}> {} + +export const keyAdd = ( config: RepositoryConfig, - organizationId: string, - options: { host: string; timeoutMs?: number }, + options: { organizationId: string; host: string; timeoutMs?: number }, deps: ResticDeps, ) => { - const repoUrl = buildRepoUrl(config); + return Effect.tryPromise({ + try: async () => { + const repoUrl = buildRepoUrl(config); - logger.info(`Adding restic key with host "${options.host}" for repository at ${repoUrl}...`); + logger.info(`Adding restic key with host "${options.host}" for repository at ${repoUrl}...`); - const env = await buildEnv(config, organizationId, deps); + const env = await buildEnv(config, options.organizationId, deps); - const args = [ - "key", - "add", - "--repo", - repoUrl, - "--host", - options.host, - "--new-password-file", - env.RESTIC_PASSWORD_FILE, - ].filter((e) => e !== undefined); + const args = [ + "key", + "add", + "--repo", + repoUrl, + "--host", + options.host, + "--new-password-file", + env.RESTIC_PASSWORD_FILE, + ].filter((e) => e !== undefined); - addCommonArgs(args, env, config); + addCommonArgs(args, env, config); - const res = await safeExec({ command: "restic", args, env, timeout: options.timeoutMs ?? 60000 }); - await cleanupTemporaryKeys(env, deps); + const res = await safeExec({ command: "restic", args, env, timeout: options.timeoutMs ?? 60000 }); + await cleanupTemporaryKeys(env, deps); - if (res.exitCode !== 0) { - logger.error(`Restic key add failed: ${res.stderr}`); - return { success: false, error: res.stderr }; - } + if (res.exitCode !== 0) { + logger.error(`Restic key add failed: ${res.stderr}`); + return { success: false, error: res.stderr }; + } - logger.info(`Restic key added with host "${options.host}" for repository: ${repoUrl}`); - return { success: true, error: null }; + logger.info(`Restic key added with host "${options.host}" for repository: ${repoUrl}`); + return { success: true, error: null }; + }, + catch: (error) => { + if (error instanceof ResticError) { + return error; + } + + return new ResticKeyAddCommandError({ + cause: error, + message: toMessage(error), + }); + }, + }); }; diff --git a/packages/core/src/restic/commands/ls.ts b/packages/core/src/restic/commands/ls.ts index 07d18a99..68a4bd52 100644 --- a/packages/core/src/restic/commands/ls.ts +++ b/packages/core/src/restic/commands/ls.ts @@ -7,6 +7,13 @@ import type { RepositoryConfig } from "../schemas"; import { logger, safeSpawn } from "../../node"; import { ResticError } from "../error"; import type { ResticDeps } from "../types"; +import { Data, Effect } from "effect"; +import { toMessage } from "../../utils"; + +class ResticLsCommandError extends Data.TaggedError("ResticLsCommandError")<{ + cause: unknown; + message: string; +}> {} const lsNodeSchema = z.object({ name: z.string(), @@ -49,98 +56,111 @@ type ResticLsResult = { }; }; -export const ls = async ( +export const ls = ( config: RepositoryConfig, snapshotId: string, - organizationId: string, path: string | undefined, - options: { offset?: number; limit?: number } | undefined, + options: { organizationId: string; offset?: number; limit?: number }, deps: ResticDeps, -): Promise => { - const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config, organizationId, deps); +) => { + return Effect.tryPromise({ + try: async () => { + const repoUrl = buildRepoUrl(config); + const env = await buildEnv(config, options.organizationId, deps); - const args: string[] = ["--repo", repoUrl, "ls", "--long"]; + const args: string[] = ["--repo", repoUrl, "ls", "--long"]; - addCommonArgs(args, env, config); - args.push("--", snapshotId); + addCommonArgs(args, env, config); + args.push("--", snapshotId); - if (path) { - args.push(path); - } - - let snapshot: LsSnapshotInfo | null = null; - const nodes: LsNode[] = []; - let totalNodes = 0; - let isFirstLine = true; - let hasMore = false; - - const offset = Math.max(options?.offset ?? 0, 0); - const limit = Math.min(Math.max(options?.limit ?? 500, 1), 500); - - logger.debug(`Running restic ls with args: ${args.join(" ")}`); - - const res = await safeSpawn({ - command: "restic", - args, - env, - onStdout: (line) => { - const trimmedLine = line.trim(); - if (!trimmedLine) { - return; + if (path) { + args.push(path); } - try { - const data = JSON.parse(trimmedLine); + let snapshot: LsSnapshotInfo | null = null; + const nodes: LsNode[] = []; + let totalNodes = 0; + let isFirstLine = true; + let hasMore = false; - if (isFirstLine) { - isFirstLine = false; - const snapshotValidation = lsSnapshotInfoSchema.safeParse(data); - if (snapshotValidation.success) { - snapshot = snapshotValidation.data; + const offset = Math.max(options?.offset ?? 0, 0); + const limit = Math.min(Math.max(options?.limit ?? 500, 1), 500); + + logger.debug(`Running restic ls with args: ${args.join(" ")}`); + + const res = await safeSpawn({ + command: "restic", + args, + env, + onStdout: (line) => { + const trimmedLine = line.trim(); + if (!trimmedLine) { + return; } - return; - } - const nodeValidation = lsNodeSchema.safeParse(data); - if (!nodeValidation.success) { - logger.warn(`Skipping invalid node: ${nodeValidation.error.message}`); - return; - } + try { + const data = JSON.parse(trimmedLine); - if (totalNodes >= offset && totalNodes < offset + limit) { - nodes.push(nodeValidation.data); - } - totalNodes++; + if (isFirstLine) { + isFirstLine = false; + const snapshotValidation = lsSnapshotInfoSchema.safeParse(data); + if (snapshotValidation.success) { + snapshot = snapshotValidation.data; + } + return; + } - if (totalNodes >= offset + limit + 1) { - hasMore = true; - } - } catch { - // Ignore JSON parse errors for non-JSON lines + const nodeValidation = lsNodeSchema.safeParse(data); + if (!nodeValidation.success) { + logger.warn(`Skipping invalid node: ${nodeValidation.error.message}`); + return; + } + + if (totalNodes >= offset && totalNodes < offset + limit) { + nodes.push(nodeValidation.data); + } + totalNodes++; + + if (totalNodes >= offset + limit + 1) { + hasMore = true; + } + } catch { + // Ignore JSON parse errors for non-JSON lines + } + }, + }); + + await cleanupTemporaryKeys(env, deps); + + if (res.exitCode !== 0) { + logger.error(`Restic ls failed: ${res.error}`); + throw new ResticError(res.exitCode, res.stderr || res.error); } + + if (totalNodes > offset + limit) { + hasMore = true; + } + + return { + snapshot, + nodes, + pagination: { + offset, + limit, + total: totalNodes, + hasMore, + }, + } as ResticLsResult; + }, + catch: (error) => { + if (error instanceof ResticError) { + return error; + } + + return new ResticLsCommandError({ + cause: error, + message: toMessage(error), + }); }, }); - - await cleanupTemporaryKeys(env, deps); - - if (res.exitCode !== 0) { - logger.error(`Restic ls failed: ${res.error}`); - throw new ResticError(res.exitCode, res.stderr || res.error); - } - - if (totalNodes > offset + limit) { - hasMore = true; - } - - return { - snapshot, - nodes, - pagination: { - offset, - limit, - total: totalNodes, - hasMore, - }, - }; }; diff --git a/packages/core/src/restic/commands/repair-index.ts b/packages/core/src/restic/commands/repair-index.ts index cdefea0e..6d1906db 100644 --- a/packages/core/src/restic/commands/repair-index.ts +++ b/packages/core/src/restic/commands/repair-index.ts @@ -1,3 +1,4 @@ +import { Data, Effect } from "effect"; import { logger, safeExec } from "../../node"; import { ResticError } from "../error"; import { addCommonArgs } from "../helpers/add-common-args"; @@ -6,42 +7,62 @@ import { buildRepoUrl } from "../helpers/build-repo-url"; import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; import type { RepositoryConfig } from "../schemas"; import type { ResticDeps } from "../types"; +import { toMessage } from "../../utils"; -export const repairIndex = async ( +class ResticRepairIndexCommandError extends Data.TaggedError("ResticRepairIndexCommandError")<{ + cause: unknown; + message: string; +}> {} + +export const repairIndex = ( config: RepositoryConfig, options: { signal?: AbortSignal; organizationId: string }, deps: ResticDeps, ) => { - const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config, options.organizationId, deps); + return Effect.tryPromise({ + try: async () => { + const repoUrl = buildRepoUrl(config); + const env = await buildEnv(config, options.organizationId, deps); - const args = ["repair", "index", "--repo", repoUrl]; - addCommonArgs(args, env, config); + const args = ["repair", "index", "--repo", repoUrl]; + addCommonArgs(args, env, config); - const res = await safeExec({ - command: "restic", - args, - env, - signal: options.signal, + const res = await safeExec({ + command: "restic", + args, + env, + signal: options.signal, + }); + await cleanupTemporaryKeys(env, deps); + + if (options.signal?.aborted) { + logger.warn("Restic repair index was aborted by signal."); + return { success: false, message: "Operation aborted", output: "" }; + } + + const { stdout, stderr } = res; + + if (res.exitCode !== 0) { + logger.error(`Restic repair index failed: ${stderr}`); + throw new ResticError(res.exitCode, stderr); + } + + logger.info(`Restic repair index completed for repository: ${repoUrl}`); + return { + success: true, + output: stdout, + message: "Index repaired successfully", + }; + }, + catch: (error) => { + if (error instanceof ResticError) { + return error; + } + + return new ResticRepairIndexCommandError({ + cause: error, + message: toMessage(error), + }); + }, }); - await cleanupTemporaryKeys(env, deps); - - if (options.signal?.aborted) { - logger.warn("Restic repair index was aborted by signal."); - return { success: false, message: "Operation aborted", output: "" }; - } - - const { stdout, stderr } = res; - - if (res.exitCode !== 0) { - logger.error(`Restic repair index failed: ${stderr}`); - throw new ResticError(res.exitCode, stderr); - } - - logger.info(`Restic repair index completed for repository: ${repoUrl}`); - return { - success: true, - output: stdout, - message: "Index repaired successfully", - }; }; diff --git a/packages/core/src/restic/commands/restore.ts b/packages/core/src/restic/commands/restore.ts index 2f298e49..01553382 100644 --- a/packages/core/src/restic/commands/restore.ts +++ b/packages/core/src/restic/commands/restore.ts @@ -11,6 +11,13 @@ import { logger, safeSpawn } from "../../node"; import { ResticError } from "../error"; import { resticRestoreOutputSchema, type ResticRestoreOutputDto } from "../restic-dto"; import type { ResticDeps } from "../types"; +import { Data, Effect } from "effect"; +import { toMessage } from "../../utils"; + +class ResticRestoreCommandError extends Data.TaggedError("ResticRestoreCommandError")<{ + cause: unknown; + message: string; +}> {} const restoreProgressSchema = z.object({ message_type: z.enum(["status", "summary"]), @@ -24,7 +31,7 @@ const restoreProgressSchema = z.object({ export type RestoreProgress = z.infer; -export const restore = async ( +export const restore = ( config: RepositoryConfig, snapshotId: string, target: string, @@ -42,130 +49,148 @@ export const restore = async ( }, deps: ResticDeps, ) => { - const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config, options.organizationId, deps); + return Effect.tryPromise({ + try: async () => { + const repoUrl = buildRepoUrl(config); + const env = await buildEnv(config, options.organizationId, deps); - let restoreArg = snapshotId; + let restoreArg = snapshotId; - const includes = options.include?.length ? options.include : [options.basePath ?? "/"]; - const commonAncestor = - options.selectedItemKind === "file" && includes.length === 1 - ? path.posix.dirname(includes[0] ?? "/") - : findCommonAncestor(includes); + const includes = options.include?.length ? options.include : [options.basePath ?? "/"]; + const commonAncestor = + options.selectedItemKind === "file" && includes.length === 1 + ? path.posix.dirname(includes[0] ?? "/") + : findCommonAncestor(includes); - if (target !== "/") { - restoreArg = `${snapshotId}:${commonAncestor}`; - } - - const args = ["--repo", repoUrl, "restore", "--target", target]; - - if (options.overwrite) { - args.push("--overwrite", options.overwrite); - } - - if (options.include?.length) { - if (target === "/") { - for (const pattern of options.include) { - args.push("--include", pattern); + if (target !== "/") { + restoreArg = `${snapshotId}:${commonAncestor}`; } - } else { - const strippedIncludes = options.include.map((pattern) => path.posix.relative(commonAncestor, pattern)); - const includesCoverRestoreRoot = strippedIncludes.some((pattern) => pattern === "" || pattern === "."); - if (!includesCoverRestoreRoot) { - for (const pattern of strippedIncludes) { - if (pattern !== "" && pattern !== ".") { + const args = ["--repo", repoUrl, "restore", "--target", target]; + + if (options.overwrite) { + args.push("--overwrite", options.overwrite); + } + + if (options.include?.length) { + if (target === "/") { + for (const pattern of options.include) { args.push("--include", pattern); } + } else { + const strippedIncludes = options.include.map((pattern) => + path.posix.relative(commonAncestor, pattern), + ); + const includesCoverRestoreRoot = strippedIncludes.some( + (pattern) => pattern === "" || pattern === ".", + ); + + if (!includesCoverRestoreRoot) { + for (const pattern of strippedIncludes) { + if (pattern !== "" && pattern !== ".") { + args.push("--include", pattern); + } + } + } } } - } - } - if (options.exclude && options.exclude.length > 0) { - for (const pattern of options.exclude) { - args.push("--exclude", pattern); - } - } + if (options.exclude && options.exclude.length > 0) { + for (const pattern of options.exclude) { + args.push("--exclude", pattern); + } + } - if (options.excludeXattr && options.excludeXattr.length > 0) { - for (const xattr of options.excludeXattr) { - args.push("--exclude-xattr", xattr); - } - } + if (options.excludeXattr && options.excludeXattr.length > 0) { + for (const xattr of options.excludeXattr) { + args.push("--exclude-xattr", xattr); + } + } - addCommonArgs(args, env, config); - args.push("--", restoreArg); + addCommonArgs(args, env, config); + args.push("--", restoreArg); - const streamProgress = throttle((data: string) => { - if (options.onProgress) { + const streamProgress = throttle((data: string) => { + if (options.onProgress) { + try { + const jsonData = JSON.parse(data); + if (jsonData.message_type !== "status") { + return; + } + + const progress = restoreProgressSchema.safeParse(jsonData); + if (progress.success) { + options.onProgress(progress.data); + } else { + logger.error(progress.error.message); + } + } catch { + // Ignore JSON parse errors for non-JSON lines + } + } + }, 1000); + + logger.debug(`Executing: restic ${args.join(" ")}`); + const res = await safeSpawn({ + command: "restic", + args, + env, + signal: options.signal, + onStdout: (data) => { + if (options.onProgress) { + streamProgress(data); + } + }, + }); + + await cleanupTemporaryKeys(env, deps); + + if (res.exitCode !== 0) { + logger.error(`Restic restore failed: ${res.error}`); + throw new ResticError(res.exitCode, res.stderr || res.error); + } + + const lastLine = res.summary.trim(); + let summaryLine: unknown = {}; try { - const jsonData = JSON.parse(data); - if (jsonData.message_type !== "status") { - return; - } - - const progress = restoreProgressSchema.safeParse(jsonData); - if (progress.success) { - options.onProgress(progress.data); - } else { - logger.error(progress.error.message); - } + summaryLine = JSON.parse(lastLine ?? "{}"); } catch { - // Ignore JSON parse errors for non-JSON lines + logger.warn("Failed to parse restic restore output JSON summary.", lastLine); + summaryLine = {}; } - } - }, 1000); - logger.debug(`Executing: restic ${args.join(" ")}`); - const res = await safeSpawn({ - command: "restic", - args, - env, - signal: options.signal, - onStdout: (data) => { - if (options.onProgress) { - streamProgress(data); + logger.debug(`Restic restore output last line: ${JSON.stringify(summaryLine)}`); + const result = resticRestoreOutputSchema.safeParse(summaryLine); + + if (!result.success) { + logger.warn(`Restic restore output validation failed: ${result.error.message}`); + logger.info(`Restic restore completed for snapshot ${snapshotId} to target ${target}`); + const fallback: ResticRestoreOutputDto = { + message_type: "summary" as const, + total_files: 0, + files_restored: 0, + files_skipped: 0, + bytes_skipped: 0, + }; + + return fallback; } + + logger.info( + `Restic restore completed for snapshot ${snapshotId} to target ${target}: ${result.data.files_restored} restored, ${result.data.files_skipped} skipped`, + ); + + return result.data; + }, + catch: (error) => { + if (error instanceof ResticError) { + return error; + } + + return new ResticRestoreCommandError({ + cause: error, + message: toMessage(error), + }); }, }); - - await cleanupTemporaryKeys(env, deps); - - if (res.exitCode !== 0) { - logger.error(`Restic restore failed: ${res.error}`); - throw new ResticError(res.exitCode, res.stderr || res.error); - } - - const lastLine = res.summary.trim(); - let summaryLine: unknown = {}; - try { - summaryLine = JSON.parse(lastLine ?? "{}"); - } catch { - logger.warn("Failed to parse restic restore output JSON summary.", lastLine); - summaryLine = {}; - } - - logger.debug(`Restic restore output last line: ${JSON.stringify(summaryLine)}`); - const result = resticRestoreOutputSchema.safeParse(summaryLine); - - if (!result.success) { - logger.warn(`Restic restore output validation failed: ${result.error.message}`); - logger.info(`Restic restore completed for snapshot ${snapshotId} to target ${target}`); - const fallback: ResticRestoreOutputDto = { - message_type: "summary" as const, - total_files: 0, - files_restored: 0, - files_skipped: 0, - bytes_skipped: 0, - }; - - return fallback; - } - - logger.info( - `Restic restore completed for snapshot ${snapshotId} to target ${target}: ${result.data.files_restored} restored, ${result.data.files_skipped} skipped`, - ); - - return result.data; }; diff --git a/packages/core/src/restic/commands/snapshots.ts b/packages/core/src/restic/commands/snapshots.ts index cd093706..2f14e80d 100644 --- a/packages/core/src/restic/commands/snapshots.ts +++ b/packages/core/src/restic/commands/snapshots.ts @@ -7,6 +7,14 @@ import { resticSnapshotSummarySchema } from "../restic-dto"; import type { RepositoryConfig } from "../schemas"; import { logger, safeSpawn } from "../../node"; import type { ResticDeps } from "../types"; +import { Data, Effect } from "effect"; +import { toMessage } from "../../utils"; +import { ResticError } from "../error"; + +class ResticSnapshotsCommandError extends Data.TaggedError("ResticSnapshotsCommandError")<{ + cause: unknown; + message: string; +}> {} const snapshotInfoSchema = z.object({ gid: z.number().optional(), @@ -23,49 +31,64 @@ const snapshotInfoSchema = z.object({ summary: resticSnapshotSummarySchema.optional(), }); -export const snapshots = async ( +export const snapshots = ( config: RepositoryConfig, options: { tags?: string[]; organizationId: string }, deps: ResticDeps, ) => { - const { tags, organizationId } = options; + return Effect.tryPromise({ + try: async () => { + const { tags, organizationId } = options; - const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config, organizationId, deps); + const repoUrl = buildRepoUrl(config); + const env = await buildEnv(config, organizationId, deps); - const args = ["--repo", repoUrl, "snapshots"]; + const args = ["--repo", repoUrl, "snapshots"]; - if (tags && tags.length > 0) { - for (const tag of tags) { - args.push("--tag", tag); - } - } + if (tags && tags.length > 0) { + for (const tag of tags) { + args.push("--tag", tag); + } + } - addCommonArgs(args, env, config); + addCommonArgs(args, env, config); - const stdoutLines: string[] = []; - const res = await safeSpawn({ - command: "restic", - args, - env, - onStdout: (line) => { - stdoutLines.push(line); + const stdoutLines: string[] = []; + const res = await safeSpawn({ + command: "restic", + args, + env, + onStdout: (line) => { + stdoutLines.push(line); + }, + }); + await cleanupTemporaryKeys(env, deps); + + if (res.exitCode !== 0) { + const errorMessage = res.stderr || res.error; + logger.error(`Restic snapshots retrieval failed: ${errorMessage}`); + throw new Error(`Restic snapshots retrieval failed: ${errorMessage}`); + } + + const result = snapshotInfoSchema.array().safeParse(JSON.parse(stdoutLines.join("\n"))); + + if (!result.success) { + logger.error(`Restic snapshots output validation failed: ${result.error.message}`); + throw new Error(`Restic snapshots output validation failed: ${result.error.message}`); + } + + return result.data; + }, + + catch: (error) => { + if (error instanceof ResticError) { + return error; + } + + return new ResticSnapshotsCommandError({ + cause: error, + message: toMessage(error), + }); }, }); - await cleanupTemporaryKeys(env, deps); - - if (res.exitCode !== 0) { - const errorMessage = res.stderr || res.error; - logger.error(`Restic snapshots retrieval failed: ${errorMessage}`); - throw new Error(`Restic snapshots retrieval failed: ${errorMessage}`); - } - - const result = snapshotInfoSchema.array().safeParse(JSON.parse(stdoutLines.join("\n"))); - - if (!result.success) { - logger.error(`Restic snapshots output validation failed: ${result.error.message}`); - throw new Error(`Restic snapshots output validation failed: ${result.error.message}`); - } - - return result.data; }; diff --git a/packages/core/src/restic/commands/stats.ts b/packages/core/src/restic/commands/stats.ts index f1f8750c..c49178fc 100644 --- a/packages/core/src/restic/commands/stats.ts +++ b/packages/core/src/restic/commands/stats.ts @@ -8,29 +8,50 @@ import { logger, safeExec } from "../../node"; import { ResticError } from "../error"; import { resticStatsSchema } from "../restic-dto"; import type { ResticDeps } from "../types"; +import { Data, Effect } from "effect"; +import { toMessage } from "../../utils"; -export const stats = async (config: RepositoryConfig, options: { organizationId: string }, deps: ResticDeps) => { - const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config, options.organizationId, deps); +class ResticStatsCommandError extends Data.TaggedError("ResticStatsCommandError")<{ + cause: unknown; + message: string; +}> {} - const args = ["--repo", repoUrl, "stats", "--mode", "raw-data"]; - addCommonArgs(args, env, config); +export const stats = (config: RepositoryConfig, options: { organizationId: string }, deps: ResticDeps) => { + return Effect.tryPromise({ + try: async () => { + const repoUrl = buildRepoUrl(config); + const env = await buildEnv(config, options.organizationId, deps); - const res = await safeExec({ command: "restic", args, env }); - await cleanupTemporaryKeys(env, deps); + const args = ["--repo", repoUrl, "stats", "--mode", "raw-data"]; + addCommonArgs(args, env, config); - if (res.exitCode !== 0) { - logger.error(`Restic stats retrieval failed: ${res.stderr}`); - throw new ResticError(res.exitCode, res.stderr); - } + const res = await safeExec({ command: "restic", args, env }); + await cleanupTemporaryKeys(env, deps); - const parsedJson = safeJsonParse(res.stdout); - const result = resticStatsSchema.safeParse(parsedJson); + if (res.exitCode !== 0) { + logger.error(`Restic stats retrieval failed: ${res.stderr}`); + throw new ResticError(res.exitCode, res.stderr); + } - if (!result.success) { - logger.error(`Restic stats output validation failed: ${result.error.message}`); - throw new Error(`Restic stats output validation failed: ${result.error.message}`); - } + const parsedJson = safeJsonParse(res.stdout); + const result = resticStatsSchema.safeParse(parsedJson); - return result.data; + if (!result.success) { + logger.error(`Restic stats output validation failed: ${result.error.message}`); + throw new Error(`Restic stats output validation failed: ${result.error.message}`); + } + + return result.data; + }, + catch: (error) => { + if (error instanceof ResticError) { + return error; + } + + return new ResticStatsCommandError({ + cause: error, + message: toMessage(error), + }); + }, + }); }; diff --git a/packages/core/src/restic/commands/tag-snapshots.ts b/packages/core/src/restic/commands/tag-snapshots.ts index a2c661f1..fdf1b9ff 100644 --- a/packages/core/src/restic/commands/tag-snapshots.ts +++ b/packages/core/src/restic/commands/tag-snapshots.ts @@ -1,3 +1,4 @@ +import { Data, Effect } from "effect"; import { logger, safeExec } from "../../node"; import { ResticError } from "../error"; import { addCommonArgs } from "../helpers/add-common-args"; @@ -6,51 +7,72 @@ import { buildRepoUrl } from "../helpers/build-repo-url"; import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; import type { RepositoryConfig } from "../schemas"; import type { ResticDeps } from "../types"; +import { toMessage } from "../../utils"; -export const tagSnapshots = async ( +class ResticTagSnapshotsCommandError extends Data.TaggedError("ResticTagSnapshotsCommandError")<{ + cause: unknown; + message: string; +}> {} + +export const tagSnapshots = ( config: RepositoryConfig, snapshotIds: string[], tags: { add?: string[]; remove?: string[]; set?: string[] }, - organizationId: string, + options: { organizationId: string }, deps: ResticDeps, ) => { - if (snapshotIds.length === 0) { - throw new Error("No snapshot IDs provided for tagging."); - } + return Effect.tryPromise({ + try: async () => { + if (snapshotIds.length === 0) { + throw new Error("No snapshot IDs provided for tagging."); + } - const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config, organizationId, deps); + const repoUrl = buildRepoUrl(config); + const env = await buildEnv(config, options.organizationId, deps); - const args: string[] = ["--repo", repoUrl, "tag"]; + const args: string[] = ["--repo", repoUrl, "tag"]; - if (tags.add) { - for (const tag of tags.add) { - args.push("--add", tag); - } - } + if (tags.add) { + for (const tag of tags.add) { + args.push("--add", tag); + } + } - if (tags.remove) { - for (const tag of tags.remove) { - args.push("--remove", tag); - } - } + if (tags.remove) { + for (const tag of tags.remove) { + args.push("--remove", tag); + } + } - if (tags.set) { - for (const tag of tags.set) { - args.push("--set", tag); - } - } + if (tags.set) { + for (const tag of tags.set) { + args.push("--set", tag); + } + } - addCommonArgs(args, env, config); - args.push("--", ...snapshotIds); + addCommonArgs(args, env, config); + args.push("--", ...snapshotIds); - const res = await safeExec({ command: "restic", args, env }); - await cleanupTemporaryKeys(env, deps); + const res = await safeExec({ command: "restic", args, env }); + await cleanupTemporaryKeys(env, deps); - if (res.exitCode !== 0) { - logger.error(`Restic snapshot tagging failed: ${res.stderr}`); - throw new ResticError(res.exitCode, res.stderr); - } + if (res.exitCode !== 0) { + logger.error(`Restic snapshot tagging failed: ${res.stderr}`); + throw new ResticError(res.exitCode, res.stderr); + } - return { success: true }; + return { success: true }; + }, + + catch: (error) => { + if (error instanceof ResticError) { + return error; + } + + return new ResticTagSnapshotsCommandError({ + cause: error, + message: toMessage(error), + }); + }, + }); }; diff --git a/packages/core/src/restic/commands/unlock.ts b/packages/core/src/restic/commands/unlock.ts index 54d54b9f..f8afa3ec 100644 --- a/packages/core/src/restic/commands/unlock.ts +++ b/packages/core/src/restic/commands/unlock.ts @@ -1,3 +1,4 @@ +import { Data, Effect } from "effect"; import { logger, safeExec } from "../../node"; import { ResticError } from "../error"; import { addCommonArgs } from "../helpers/add-common-args"; @@ -6,36 +7,56 @@ import { buildRepoUrl } from "../helpers/build-repo-url"; import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; import type { RepositoryConfig } from "../schemas"; import type { ResticDeps } from "../types"; +import { toMessage } from "../../utils"; -export const unlock = async ( +class ResticUnlockCommandError extends Data.TaggedError("ResticUnlockCommandError")<{ + cause: unknown; + message: string; +}> {} + +export const unlock = ( config: RepositoryConfig, options: { signal?: AbortSignal; organizationId: string }, deps: ResticDeps, ) => { - const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config, options.organizationId, deps); + return Effect.tryPromise({ + try: async () => { + const repoUrl = buildRepoUrl(config); + const env = await buildEnv(config, options.organizationId, deps); - const args = ["unlock", "--repo", repoUrl, "--remove-all"]; - addCommonArgs(args, env, config); + const args = ["unlock", "--repo", repoUrl, "--remove-all"]; + addCommonArgs(args, env, config); - const res = await safeExec({ - command: "restic", - args, - env, - signal: options.signal, + const res = await safeExec({ + command: "restic", + args, + env, + signal: options.signal, + }); + await cleanupTemporaryKeys(env, deps); + + if (options.signal?.aborted) { + logger.warn("Restic unlock was aborted by signal."); + return { success: false, message: "Operation aborted" }; + } + + if (res.exitCode !== 0) { + logger.error(`Restic unlock failed: ${res.stderr}`); + throw new ResticError(res.exitCode, res.stderr); + } + + logger.info(`Restic unlock succeeded for repository: ${repoUrl}`); + return { success: true, message: "Repository unlocked successfully" }; + }, + catch: (error) => { + if (error instanceof ResticError) { + return error; + } + + return new ResticUnlockCommandError({ + cause: error, + message: toMessage(error), + }); + }, }); - await cleanupTemporaryKeys(env, deps); - - if (options.signal?.aborted) { - logger.warn("Restic unlock was aborted by signal."); - return { success: false, message: "Operation aborted" }; - } - - if (res.exitCode !== 0) { - logger.error(`Restic unlock failed: ${res.stderr}`); - throw new ResticError(res.exitCode, res.stderr); - } - - logger.info(`Restic unlock succeeded for repository: ${repoUrl}`); - return { success: true, message: "Repository unlocked successfully" }; }; diff --git a/packages/core/src/restic/lock-diagnostics.ts b/packages/core/src/restic/lock-diagnostics.ts new file mode 100644 index 00000000..2880edd6 --- /dev/null +++ b/packages/core/src/restic/lock-diagnostics.ts @@ -0,0 +1,169 @@ +import { logger, safeExec } from "../node"; +import { toMessage } from "../utils"; +import { ResticError } from "./error"; +import { addCommonArgs } from "./helpers/add-common-args"; +import { buildEnv } from "./helpers/build-env"; +import { buildRepoUrl } from "./helpers/build-repo-url"; +import { cleanupTemporaryKeys } from "./helpers/cleanup-temporary-keys"; +import type { RepositoryConfig } from "./schemas"; +import type { ResticDeps } from "./types"; + +type ResticLockDiagnosticContext = { + error: unknown; + operation: string; + repositoryConfig: RepositoryConfig; + organizationId: string; + resticDeps: ResticDeps; + relatedRepositoryConfigs?: RepositoryConfig[]; +}; + +const LOCK_ERROR_PATTERNS = [ + /unable to create lock in backend/i, + /repository is already locked/i, + /failed to lock repository/i, + /"code"\s*:\s*11/i, + /\bexit_error\b.*\b11\b/i, +]; + +export const isResticLockFailure = (error: unknown) => { + if (error instanceof ResticError && error.code === 11) { + return true; + } + + const message = toMessage(error); + return LOCK_ERROR_PATTERNS.some((pattern) => pattern.test(message)); +}; + +const parseLockIds = (stdout: string) => { + const ids = new Set(); + + for (const line of stdout.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + + const jsonId = trimmed.match(/"id"\s*:\s*"([^"]+)"/)?.[1]; + if (jsonId) { + ids.add(jsonId); + continue; + } + + const hexId = trimmed.match(/[a-f0-9]{64}/i)?.[0]; + if (hexId) { + ids.add(hexId); + } + } + + return [...ids].slice(0, 20); +}; + +const inspectResticLocks = async (config: RepositoryConfig, organizationId: string, deps: ResticDeps) => { + const repoUrl = buildRepoUrl(config); + const env = await buildEnv(config, organizationId, deps); + const baseArgs = ["--repo", repoUrl]; + addCommonArgs(baseArgs, env, config); + + try { + const listResult = await safeExec({ + command: "restic", + args: [...baseArgs, "list", "locks"], + env, + timeout: 15_000, + }); + + const lockIds = listResult.exitCode === 0 ? parseLockIds(listResult.stdout) : []; + const lockDetails = []; + + for (const lockId of lockIds) { + const catResult = await safeExec({ + command: "restic", + args: [...baseArgs, "cat", "lock", lockId], + env, + timeout: 15_000, + }); + + lockDetails.push({ + lockId, + exitCode: catResult.exitCode, + stdout: catResult.stdout, + stderr: catResult.stderr, + timedOut: catResult.timedOut, + }); + } + + return { + repoUrl, + list: { + exitCode: listResult.exitCode, + stdout: listResult.stdout, + stderr: listResult.stderr, + timedOut: listResult.timedOut, + }, + lockIds, + lockDetails, + }; + } finally { + await cleanupTemporaryKeys(env, deps); + } +}; + +export const logResticLockFailureDiagnostics = async ({ + error, + operation, + repositoryConfig, + organizationId, + resticDeps, + relatedRepositoryConfigs = [], +}: ResticLockDiagnosticContext) => { + if (!isResticLockFailure(error)) { + return false; + } + + try { + const configsByRepoUrl = new Map( + [repositoryConfig, ...relatedRepositoryConfigs].map((config) => [buildRepoUrl(config), config]), + ); + const configsToInspect = [...configsByRepoUrl.entries()]; + + logger.error("[ResticLockFailure] Restic repository lock failure detected", { + operation, + error: toMessage(error), + process: { + pid: process.pid, + hostname: process.env.HOSTNAME, + nodeEnv: process.env.NODE_ENV, + }, + repository: { + repoUrl: buildRepoUrl(repositoryConfig), + }, + relatedRepositories: relatedRepositoryConfigs.map((config) => ({ + repoUrl: buildRepoUrl(config), + })), + }); + + for (const [repoUrl, config] of configsToInspect) { + try { + const resticLocks = await inspectResticLocks(config, organizationId, resticDeps); + logger.error("[ResticLockFailure] Restic backend lock dump", { + operation, + repoUrl, + resticLocks, + }); + } catch (diagnosticError) { + logger.error("[ResticLockFailure] Failed to inspect restic backend locks", { + operation, + repoUrl, + error: toMessage(diagnosticError), + }); + } + } + + return true; + } catch (diagnosticError) { + logger.error("[ResticLockFailure] Failed to collect lock diagnostics", { + operation, + repoUrl: buildRepoUrl(repositoryConfig), + error: toMessage(diagnosticError), + }); + return true; + } +}; diff --git a/packages/core/src/restic/server.ts b/packages/core/src/restic/server.ts index 88cb7841..1f307e52 100644 --- a/packages/core/src/restic/server.ts +++ b/packages/core/src/restic/server.ts @@ -1,3 +1,4 @@ +import { Effect } from "effect"; import { backup } from "./commands/backup"; import { check } from "./commands/check"; import { copy } from "./commands/copy"; @@ -13,6 +14,8 @@ import { snapshots } from "./commands/snapshots"; import { stats } from "./commands/stats"; import { tagSnapshots } from "./commands/tag-snapshots"; import { unlock } from "./commands/unlock"; +import { logResticLockFailureDiagnostics } from "./lock-diagnostics"; +import type { RepositoryConfig } from "./schemas"; import type { ResticDeps } from "./types"; export { addCommonArgs } from "./helpers/add-common-args"; @@ -20,30 +23,83 @@ export { buildEnv } from "./helpers/build-env"; export { buildRepoUrl } from "./helpers/build-repo-url"; export { cleanupTemporaryKeys } from "./helpers/cleanup-temporary-keys"; export { validateCustomResticParams } from "./helpers/validate-custom-params"; +export { isResticLockFailure, logResticLockFailureDiagnostics } from "./lock-diagnostics"; export { ResticError } from "./error"; -function withDeps( - command: (...args: [...Args, ResticDeps]) => Result, +type LockDiagnosticCommandContext = { + repositoryConfig: RepositoryConfig; + organizationId: string; + relatedRepositoryConfigs?: RepositoryConfig[]; +}; + +type ResticCommandOptions = { organizationId: string }; +type ResticCommandResult = { error?: unknown }; + +const getCommandContext = (operation: string, args: unknown[]): LockDiagnosticCommandContext => { + const firstRepositoryConfig = args[0] as RepositoryConfig; + const options = args.at(-1) as ResticCommandOptions; + + if (operation === "restic.copy") { + return { + repositoryConfig: args[1] as RepositoryConfig, + organizationId: options.organizationId, + relatedRepositoryConfigs: [firstRepositoryConfig], + }; + } + + return { + repositoryConfig: firstRepositoryConfig, + organizationId: options.organizationId, + }; +}; + +const logLockFailure = async ( + error: unknown, + operation: string, + context: LockDiagnosticCommandContext, deps: ResticDeps, -): (...args: Args) => Result { - return (...args: Args) => command(...args, deps); +) => + logResticLockFailureDiagnostics({ + error, + operation, + repositoryConfig: context.repositoryConfig, + organizationId: context.organizationId, + resticDeps: deps, + relatedRepositoryConfigs: context.relatedRepositoryConfigs, + }); + +function withDeps( + operation: string, + command: (...args: [...Args, ResticDeps]) => Effect.Effect, + deps: ResticDeps, +): (...args: Args) => Effect.Effect { + return (...args: Args) => { + const context = getCommandContext(operation, args); + return command(...args, deps).pipe( + Effect.tapError((error) => Effect.promise(() => logLockFailure(error, operation, context, deps))), + Effect.tap((result) => { + const { error } = result as ResticCommandResult; + return error ? Effect.promise(() => logLockFailure(error, operation, context, deps)) : Effect.void; + }), + ); + }; } export const createRestic = (deps: ResticDeps) => ({ - init: withDeps(init, deps), - keyAdd: withDeps(keyAdd, deps), - backup: withDeps(backup, deps), - restore: withDeps(restore, deps), - dump: withDeps(dump, deps), - snapshots: withDeps(snapshots, deps), - stats: withDeps(stats, deps), - forget: withDeps(forget, deps), - deleteSnapshot: withDeps(deleteSnapshot, deps), - deleteSnapshots: withDeps(deleteSnapshots, deps), - tagSnapshots: withDeps(tagSnapshots, deps), - unlock: withDeps(unlock, deps), - ls: withDeps(ls, deps), - check: withDeps(check, deps), - repairIndex: withDeps(repairIndex, deps), - copy: withDeps(copy, deps), + init: withDeps("restic.init", init, deps), + keyAdd: withDeps("restic.keyAdd", keyAdd, deps), + backup: withDeps("restic.backup", backup, deps), + restore: withDeps("restic.restore", restore, deps), + dump: withDeps("restic.dump", dump, deps), + snapshots: withDeps("restic.snapshots", snapshots, deps), + stats: withDeps("restic.stats", stats, deps), + forget: withDeps("restic.forget", forget, deps), + deleteSnapshot: withDeps("restic.deleteSnapshot", deleteSnapshot, deps), + deleteSnapshots: withDeps("restic.deleteSnapshots", deleteSnapshots, deps), + tagSnapshots: withDeps("restic.tagSnapshots", tagSnapshots, deps), + unlock: withDeps("restic.unlock", unlock, deps), + ls: withDeps("restic.ls", ls, deps), + check: withDeps("restic.check", check, deps), + repairIndex: withDeps("restic.repairIndex", repairIndex, deps), + copy: withDeps("restic.copy", copy, deps), });