refactor(restic): all commands are effects

This commit is contained in:
Nicolas Meienberger 2026-05-29 20:37:54 +02:00
parent 7b5c53bb7d
commit fa8bff1ece
No known key found for this signature in database
35 changed files with 1783 additions and 876 deletions

View file

@ -10,24 +10,27 @@ import * as context from "~/server/core/request-context";
import * as resticModule from "~/server/core/restic"; import * as resticModule from "~/server/core/restic";
import * as spawnModule from "@zerobyte/core/node"; import * as spawnModule from "@zerobyte/core/node";
import type { ShortId } from "~/server/utils/branded"; import type { ShortId } from "~/server/utils/branded";
import { Effect } from "effect";
const setup = () => { const setup = () => {
vi.spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID); 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 { return {
mockSnapshots: (sourceSnapshots: unknown[], mirrorSnapshots: unknown[]) => { mockSnapshots: (sourceSnapshots: unknown[], mirrorSnapshots: unknown[]) => {
let callCount = 0; let callCount = 0;
vi.spyOn(resticModule.restic, "snapshots").mockImplementation(() => { vi.spyOn(resticModule.restic, "snapshots").mockImplementation(() => {
callCount++; callCount++;
if (callCount === 1) return Promise.resolve(sourceSnapshots as never); if (callCount === 1) return Effect.succeed(sourceSnapshots as never);
return Promise.resolve(mirrorSnapshots as never); return Effect.succeed(mirrorSnapshots as never);
}); });
}, },
mockCopy: () => { mockCopy: () => {
const copyMock = vi const copyMock = vi
.spyOn(resticModule.restic, "copy") .spyOn(resticModule.restic, "copy")
.mockImplementation(() => Promise.resolve({ success: true, output: "" })); .mockImplementation(() => Effect.succeed({ success: true, output: "" }));
return copyMock; return copyMock;
}, },
}; };
@ -190,12 +193,14 @@ describe("syncMirror", () => {
const copyMock = mockCopy(); const copyMock = mockCopy();
let releaseCopy: (() => void) | undefined; let releaseCopy: (() => void) | undefined;
const copyStarted = new Promise<void>((resolve) => { const copyStarted = new Promise<void>((resolve) => {
copyMock.mockImplementation( copyMock.mockImplementation(() =>
() => Effect.promise(
new Promise((copyResolve) => { () =>
releaseCopy = () => copyResolve({ success: true, output: "" }); new Promise((copyResolve) => {
resolve(); releaseCopy = () => copyResolve({ success: true, output: "" });
}), resolve();
}),
),
); );
}); });

View file

@ -24,13 +24,14 @@ import { getScheduleByIdOrShortId } from "../helpers/backup-schedule-lookups";
import { volumeService } from "~/server/modules/volumes/volume.service"; import { volumeService } from "~/server/modules/volumes/volume.service";
import { db } from "~/server/db/db"; import { db } from "~/server/db/db";
import { config } from "~/server/core/config"; import { config } from "~/server/core/config";
import { Effect } from "effect";
const setup = () => { const setup = () => {
const resticBackupMock = vi.fn((_: SafeSpawnParams) => const resticBackupMock = vi.fn((_: SafeSpawnParams) =>
Promise.resolve({ exitCode: 0, summary: generateBackupOutput(), error: "" }), Promise.resolve({ exitCode: 0, summary: generateBackupOutput(), error: "" }),
); );
const resticForgetMock = vi.fn(() => Promise.resolve({ success: true, data: null })); const resticForgetMock = vi.fn(() => Effect.succeed({ success: true, data: null }));
const resticCopyMock = vi.fn(() => Promise.resolve({ success: true, output: "" })); const resticCopyMock = vi.fn(() => Effect.succeed({ success: true, output: "" }));
const { runBackupMock, cancelBackupMock } = createAgentBackupMocks(resticBackupMock); const { runBackupMock, cancelBackupMock } = createAgentBackupMocks(resticBackupMock);
const refreshStatsMock = vi.fn(() => const refreshStatsMock = vi.fn(() =>
Promise.resolve({ Promise.resolve({
@ -925,12 +926,14 @@ describe("mirror operations", () => {
const originalMirror = await createTestBackupScheduleMirror(schedule.id, mirrorRepository.id); const originalMirror = await createTestBackupScheduleMirror(schedule.id, mirrorRepository.id);
resticCopyMock.mockImplementationOnce(async () => { resticCopyMock.mockImplementationOnce(() =>
await backupsService.updateMirrors(schedule.id, { Effect.promise(async () => {
mirrors: [{ repositoryId: mirrorRepository.id, enabled: true }], await backupsService.updateMirrors(schedule.id, {
}); mirrors: [{ repositoryId: mirrorRepository.id, enabled: true }],
return { success: true, output: "" }; });
}); return { success: true, output: "" };
}),
);
// act // act
await backupsService.copyToMirrors(schedule.id, sourceRepository, null); await backupsService.copyToMirrors(schedule.id, sourceRepository, null);
@ -957,7 +960,11 @@ describe("mirror operations", () => {
const mirror = await createTestBackupScheduleMirror(schedule.id, mirrorRepository.id); 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 // act
await backupsService.copyToMirrors(schedule.id, sourceRepository, null); await backupsService.copyToMirrors(schedule.id, sourceRepository, null);
@ -985,7 +992,7 @@ describe("mirror operations", () => {
await createTestBackupScheduleMirror(schedule.id, mirrorRepository.id); await createTestBackupScheduleMirror(schedule.id, mirrorRepository.id);
resticCopyMock.mockClear(); resticCopyMock.mockClear();
resticCopyMock.mockImplementation(() => Promise.resolve({ success: true, output: "" })); resticCopyMock.mockImplementation(() => Effect.succeed({ success: true, output: "" }));
// act // act
await backupsService.copyToMirrors(schedule.id, sourceRepository, schedule.retentionPolicy); await backupsService.copyToMirrors(schedule.id, sourceRepository, schedule.retentionPolicy);
@ -1053,14 +1060,16 @@ describe("mirror operations", () => {
resolveFirstCopyStarted = resolve; resolveFirstCopyStarted = resolve;
}); });
resticCopyMock.mockImplementationOnce( resticCopyMock.mockImplementationOnce(() =>
() => Effect.promise(
new Promise((resolve) => { () =>
resolveFirstCopyStarted(); new Promise((resolve) => {
releaseFirstCopy = () => resolve({ success: true, output: "" }); 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); const firstCopyPromise = backupsService.copyToMirrors(firstSchedule.id, sourceRepository, null);
await firstCopyStarted; await firstCopyStarted;

View file

@ -29,6 +29,7 @@ import { copyToMirrors, runForget, syncSnapshotsToMirror } from "./helpers/backu
import { restic } from "../../core/restic"; import { restic } from "../../core/restic";
import { mirrorQueries } from "./backups.queries"; import { mirrorQueries } from "./backups.queries";
import { toMessage } from "../../utils/errors"; import { toMessage } from "../../utils/errors";
import { Effect } from "effect";
const listSchedules = async () => { const listSchedules = async () => {
const organizationId = getOrganizationId(); const organizationId = getOrganizationId();
const schedules = await db.query.backupSchedulesTable.findMany({ const schedules = await db.query.backupSchedulesTable.findMany({
@ -73,7 +74,10 @@ const createSchedule = async (data: CreateBackupScheduleBody) => {
const repository = await db.query.repositoriesTable.findFirst({ const repository = await db.query.repositoriesTable.findFirst({
where: { 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({ const repository = await db.query.repositoriesTable.findFirst({
where: { 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 cronExpression = data.cronExpression ?? schedule.cronExpression;
const nextBackupAt = const nextBackupAt =
data.cronExpression === "" ? null : data.cronExpression ? calculateNextRun(cronExpression) : schedule.nextBackupAt; data.cronExpression === ""
? null
: data.cronExpression
? calculateNextRun(cronExpression)
: schedule.nextBackupAt;
const [updated] = await db const [updated] = await db
.update(backupSchedulesTable) .update(backupSchedulesTable)
@ -351,7 +362,12 @@ const reorderSchedules = async (scheduleShortIds: ShortId[]) => {
for (const [index, scheduleId] of scheduleIds.entries()) { for (const [index, scheduleId] of scheduleIds.entries()) {
tx.update(backupSchedulesTable) tx.update(backupSchedulesTable)
.set({ sortOrder: index, updatedAt: now }) .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(); .run();
} }
}); });
@ -498,10 +514,15 @@ const getMirrorSyncStatus = async (scheduleIdOrShortId: number | string, mirrorS
throw new NotFoundError("Mirror not found for this schedule"); throw new NotFoundError("Mirror not found for this schedule");
} }
const [sourceSnapshots, mirrorSnapshots] = await Promise.all([ const [sourceSnapshots, mirrorSnapshots] = await Effect.runPromise(
restic.snapshots(schedule.repository.config, { tags: [schedule.shortId], organizationId }), Effect.all(
restic.snapshots(mirrorRepo.config, { tags: [schedule.shortId], organizationId }), [
]); 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)); const mirrorSnapshotTimes = new Set(mirrorSnapshots.map((s) => s.time));

View file

@ -8,6 +8,7 @@ import { cache, cacheKeys } from "../../../utils/cache";
import { toMessage } from "../../../utils/errors"; import { toMessage } from "../../../utils/errors";
import { getOrganizationId } from "~/server/core/request-context"; import { getOrganizationId } from "~/server/core/request-context";
import { mirrorQueries, repositoryQueries, scheduleQueries } from "../backups.queries"; import { mirrorQueries, repositoryQueries, scheduleQueries } from "../backups.queries";
import { Effect } from "effect";
export async function runForget(scheduleId: number, repositoryId?: string, organizationIdOverride?: string) { export async function runForget(scheduleId: number, repositoryId?: string, organizationIdOverride?: string) {
const organizationId = organizationIdOverride ?? getOrganizationId(); const organizationId = organizationIdOverride ?? getOrganizationId();
@ -31,7 +32,9 @@ export async function runForget(scheduleId: number, repositoryId?: string, organ
const releaseLock = await repoMutex.acquireExclusive(repository.id, `forget:${scheduleId}`); const releaseLock = await repoMutex.acquireExclusive(repository.id, `forget:${scheduleId}`);
try { try {
await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.shortId, organizationId }); await Effect.runPromise(
restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.shortId, organizationId }),
);
cache.delByPrefix(cacheKeys.repository.all(repository.id)); cache.delByPrefix(cacheKeys.repository.all(repository.id));
} finally { } finally {
releaseLock(); releaseLock();
@ -118,11 +121,13 @@ export async function syncSnapshotsToMirror(
]); ]);
try { try {
await restic.copy(sourceRepository.config, mirrorRepository.config, { await Effect.runPromise(
tag: schedule.shortId, restic.copy(sourceRepository.config, mirrorRepository.config, {
organizationId, tag: schedule.shortId,
snapshotIds, organizationId,
}); snapshotIds,
}),
);
cache.delByPrefix(cacheKeys.repository.all(mirrorRepository.id)); cache.delByPrefix(cacheKeys.repository.all(mirrorRepository.id));
} finally { } finally {
releaseLocks(); releaseLocks();
@ -206,7 +211,12 @@ async function copyToSingleMirror(
]); ]);
try { try {
await restic.copy(sourceRepository.config, mirror.repository.config, { tag: schedule.shortId, organizationId }); await Effect.runPromise(
restic.copy(sourceRepository.config, mirror.repository.config, {
tag: schedule.shortId,
organizationId,
}),
);
cache.delByPrefix(cacheKeys.repository.all(mirror.repository.id)); cache.delByPrefix(cacheKeys.repository.all(mirror.repository.id));
} finally { } finally {
releaseLocks(); releaseLocks();

View file

@ -8,6 +8,7 @@ import { restic } from "~/server/core/restic";
import { createTestSession } from "~/test/helpers/auth"; import { createTestSession } from "~/test/helpers/auth";
import { generateShortId } from "~/server/utils/id"; import { generateShortId } from "~/server/utils/id";
import { provisionedResourcesSchema, readProvisionedResourcesFile, syncProvisionedResources } from "./provisioning"; import { provisionedResourcesSchema, readProvisionedResourcesFile, syncProvisionedResources } from "./provisioning";
import { Effect } from "effect";
describe("provisioning", () => { describe("provisioning", () => {
let session: Awaited<ReturnType<typeof createTestSession>>; let session: Awaited<ReturnType<typeof createTestSession>>;
@ -17,7 +18,7 @@ describe("provisioning", () => {
}); });
beforeEach(async () => { 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(backupSchedulesTable);
await db.delete(volumesTable); await db.delete(volumesTable);
@ -361,7 +362,7 @@ describe("provisioning", () => {
const { organizationId } = session; const { organizationId } = session;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-provisioning-")); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-provisioning-"));
const provisioningPath = path.join(tempDir, "provisioning.json"); 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); vi.spyOn(restic, "init").mockImplementation(initMock);

View file

@ -20,6 +20,7 @@ import { BACKEND_TYPES, volumeConfigSchema, type BackendConfig } from "@zerobyte
import { cryptoUtils } from "~/server/utils/crypto"; import { cryptoUtils } from "~/server/utils/crypto";
import { toMessage } from "~/server/utils/errors"; import { toMessage } from "~/server/utils/errors";
import { generateShortId } from "~/server/utils/id"; import { generateShortId } from "~/server/utils/id";
import { Effect } from "effect";
const envSecretPrefix = "env://"; const envSecretPrefix = "env://";
const fileSecretPrefix = "file://"; const fileSecretPrefix = "file://";
@ -171,9 +172,12 @@ const syncProvisionedRepositories = async (repositories: ProvisionedRepository[]
}); });
if (!repository.config.isExistingRepository) { if (!repository.config.isExistingRepository) {
const result = await restic const result = await Effect.runPromise(
.init(encryptedConfig, repository.organizationId, { timeoutMs: appConfig.serverIdleTimeout * 1000 }) restic.init(encryptedConfig, {
.catch((error) => ({ success: false, error })); organizationId: repository.organizationId,
timeoutMs: appConfig.serverIdleTimeout * 1000,
}),
).catch((error) => ({ success: false, error }));
await db await db
.update(repositoriesTable) .update(repositoriesTable)
@ -186,7 +190,9 @@ const syncProvisionedRepositories = async (repositories: ProvisionedRepository[]
.where(eq(repositoriesTable.id, id)); .where(eq(repositoriesTable.id, id));
if (result.error) { 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; continue;

View file

@ -8,6 +8,7 @@ import { generateShortId } from "~/server/utils/id";
import { createTestSession, getAuthHeaders } from "~/test/helpers/auth"; import { createTestSession, getAuthHeaders } from "~/test/helpers/auth";
import type { RepositoryConfig } from "@zerobyte/core/restic"; import type { RepositoryConfig } from "@zerobyte/core/restic";
import { restic } from "~/server/core/restic"; import { restic } from "~/server/core/restic";
import { Effect } from "effect";
const app = createApp(); const app = createApp();
@ -18,7 +19,7 @@ beforeAll(async () => {
}); });
beforeEach(() => { beforeEach(() => {
vi.spyOn(restic, "init").mockResolvedValue({ success: true, error: null }); vi.spyOn(restic, "init").mockReturnValue(Effect.succeed({ success: true, error: null }));
}); });
afterEach(() => { afterEach(() => {
@ -333,9 +334,11 @@ describe("repositories updates", () => {
const { restic } = await import("~/server/core/restic"); const { restic } = await import("~/server/core/restic");
const { ResticError } = await import("@zerobyte/core/restic"); const { ResticError } = await import("@zerobyte/core/restic");
const deleteSnapshotSpy = vi.spyOn(restic, "deleteSnapshot").mockImplementation(async () => { const deleteSnapshotSpy = vi.spyOn(restic, "deleteSnapshot").mockImplementation(() =>
throw new ResticError(1, "Fatal: unexpected HTTP response (403): 403 Forbidden"); Effect.sync(() => {
}); throw new ResticError(1, "Fatal: unexpected HTTP response (403): 403 Forbidden");
}),
);
try { try {
const res = await app.request(`/api/v1/repositories/${repository.shortId}/snapshots/snap123`, { const res = await app.request(`/api/v1/repositories/${repository.shortId}/snapshots/snap123`, {
@ -345,8 +348,9 @@ describe("repositories updates", () => {
expect(res.status).toBe(500); expect(res.status).toBe(500);
const body = await res.json(); const body = await res.json();
expect(body.message).toBe("Command failed: An error occurred while executing the command."); expect(body.message).toBe(
expect(body.details).toBe("Fatal: unexpected HTTP response (403): 403 Forbidden"); "Command failed: An error occurred while executing the command.\nFatal: unexpected HTTP response (403): 403 Forbidden",
);
} finally { } finally {
deleteSnapshotSpy.mockRestore(); deleteSnapshotSpy.mockRestore();
} }
@ -359,27 +363,34 @@ describe("repositories updates", () => {
const stream = new PassThrough(); const stream = new PassThrough();
const expectedContent = "downloaded snapshot contents"; const expectedContent = "downloaded snapshot contents";
const snapshotsSpy = vi.spyOn(restic, "snapshots").mockResolvedValue([ const snapshotsSpy = vi.spyOn(restic, "snapshots").mockReturnValue(
{ Effect.succeed([
id: "test-snapshot", {
short_id: "test-snapshot", id: "test-snapshot",
time: new Date().toISOString(), short_id: "test-snapshot",
paths: ["/mnt/project"], time: new Date().toISOString(),
hostname: "host", paths: ["/mnt/project"],
}, hostname: "host",
]); },
const dumpSpy = vi.spyOn(restic, "dump").mockResolvedValue({ ]),
stream, );
completion: Promise.resolve(), const dumpSpy = vi.spyOn(restic, "dump").mockReturnValue(
abort: vi.fn(), Effect.succeed({
}); stream: stream as never,
completion: Promise.resolve(),
abort: vi.fn(),
}),
);
try { try {
const controller = new AbortController(); const controller = new AbortController();
const response = await app.request(`/api/v1/repositories/${repository.shortId}/snapshots/test-snapshot/dump`, { const response = await app.request(
headers: session.headers, `/api/v1/repositories/${repository.shortId}/snapshots/test-snapshot/dump`,
signal: controller.signal, {
}); headers: session.headers,
signal: controller.signal,
},
);
queueMicrotask(() => { queueMicrotask(() => {
controller.abort(); controller.abort();
@ -397,20 +408,24 @@ describe("repositories updates", () => {
const repository = await createRepositoryRecord(session.organizationId); const repository = await createRepositoryRecord(session.organizationId);
const stream = new PassThrough(); const stream = new PassThrough();
const snapshotsSpy = vi.spyOn(restic, "snapshots").mockResolvedValue([ const snapshotsSpy = vi.spyOn(restic, "snapshots").mockReturnValue(
{ Effect.succeed([
id: "test-snapshot", {
short_id: "test-snapshot", id: "test-snapshot",
time: new Date().toISOString(), short_id: "test-snapshot",
paths: ["/mnt/project"], time: new Date().toISOString(),
hostname: "host", paths: ["/mnt/project"],
}, hostname: "host",
]); },
const dumpSpy = vi.spyOn(restic, "dump").mockResolvedValue({ ]),
stream, );
completion: Promise.resolve(), const dumpSpy = vi.spyOn(restic, "dump").mockReturnValue(
abort: vi.fn(), Effect.succeed({
}); stream: stream as never,
completion: Promise.resolve(),
abort: vi.fn(),
}),
);
try { try {
stream.end("downloaded snapshot contents"); stream.end("downloaded snapshot contents");

View file

@ -17,6 +17,7 @@ import { createTestBackupSchedule } from "~/test/helpers/backup";
import { cache, cacheKeys } from "~/server/utils/cache"; import { cache, cacheKeys } from "~/server/utils/cache";
import { ResticError } from "@zerobyte/core/restic/server"; import { ResticError } from "@zerobyte/core/restic/server";
import { repositoriesService } from "../repositories.service"; import { repositoriesService } from "../repositories.service";
import { Effect } from "effect";
const createTestRepository = async (organizationId: string) => { const createTestRepository = async (organizationId: string) => {
const id = randomUUID(); const id = randomUUID();
@ -44,7 +45,7 @@ beforeAll(async () => {
}); });
describe("repositoriesService.createRepository", () => { describe("repositoriesService.createRepository", () => {
const initMock = vi.fn(() => Promise.resolve({ success: true, error: null })); const initMock = vi.fn(() => Effect.succeed({ success: true, error: null }));
beforeEach(() => { beforeEach(() => {
initMock.mockClear(); initMock.mockClear();
@ -116,7 +117,7 @@ describe("repositoriesService.createRepository", () => {
const explicitPath = `${REPOSITORY_BASE}/custom-${randomUUID()}`; const explicitPath = `${REPOSITORY_BASE}/custom-${randomUUID()}`;
const config: RepositoryConfig = { backend: "local", path: explicitPath, isExistingRepository: true }; const config: RepositoryConfig = { backend: "local", path: explicitPath, isExistingRepository: true };
vi.spyOn(restic, "snapshots").mockImplementation(() => Promise.resolve([])); vi.spyOn(restic, "snapshots").mockImplementation(() => Effect.succeed([]));
// act // act
const result = await withContext({ organizationId: session.organizationId, userId: session.user.id }, () => const result = await withContext({ organizationId: session.organizationId, userId: session.user.id }, () =>
@ -174,7 +175,7 @@ describe("repositoriesService repository stats", () => {
snapshots_count: 3, 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 }, () => const refreshed = await withContext({ organizationId: session.organizationId, userId: session.user.id }, () =>
repositoriesService.refreshRepositoryStats(repository.shortId), repositoriesService.refreshRepositoryStats(repository.shortId),
@ -202,7 +203,7 @@ describe("repositoriesService.dumpSnapshot", () => {
}); });
const createDumpResult = (payload: string) => ({ const createDumpResult = (payload: string) => ({
stream: Readable.from([payload]), stream: Readable.from([payload]) as never,
completion: Promise.resolve(), completion: Promise.resolve(),
abort: () => {}, abort: () => {},
}); });
@ -242,22 +243,24 @@ describe("repositoriesService.dumpSnapshot", () => {
organizationId, organizationId,
}); });
vi.spyOn(restic, "snapshots").mockResolvedValue([ vi.spyOn(restic, "snapshots").mockReturnValue(
{ Effect.succeed([
id: snapshotId, {
short_id: snapshotId, id: snapshotId,
time: new Date().toISOString(), short_id: snapshotId,
paths: snapshotPaths ?? [basePath], time: new Date().toISOString(),
hostname: "host", paths: snapshotPaths ?? [basePath],
}, hostname: "host",
]); },
]),
);
const dumpMock = vi.fn((_config: unknown, snapshotRef: string, options: Parameters<typeof restic.dump>[2]) => { const dumpMock = vi.fn((_config: unknown, snapshotRef: string, options: Parameters<typeof restic.dump>[2]) => {
if (!options.path) { if (!options.path) {
throw new Error("Expected dump path in test"); throw new Error("Expected dump path in test");
} }
return Promise.resolve( return Effect.succeed(
createDumpResult( createDumpResult(
JSON.stringify({ JSON.stringify({
snapshotRef, snapshotRef,
@ -409,18 +412,20 @@ describe("repositoriesService.restoreSnapshot", () => {
const organizationId = session.organizationId; const organizationId = session.organizationId;
const repository = await createTestRepository(organizationId); const repository = await createTestRepository(organizationId);
vi.spyOn(restic, "snapshots").mockResolvedValue([ vi.spyOn(restic, "snapshots").mockReturnValue(
{ Effect.succeed([
id: "snapshot-restore", {
short_id: "snapshot-restore", id: "snapshot-restore",
time: new Date().toISOString(), short_id: "snapshot-restore",
paths, time: new Date().toISOString(),
hostname: "host", paths,
}, hostname: "host",
]); },
]),
);
const restoreMock = vi.fn(() => const restoreMock = vi.fn(() =>
Promise.resolve({ Effect.succeed({
message_type: "summary" as const, message_type: "summary" as const,
seconds_elapsed: 1, seconds_elapsed: 1,
percent_done: 100, percent_done: 100,
@ -569,8 +574,8 @@ describe("repositoriesService.getRetentionCategories", () => {
}); });
const forgetSpy = vi.spyOn(restic, "forget"); const forgetSpy = vi.spyOn(restic, "forget");
forgetSpy.mockResolvedValueOnce(buildForgetResponse(oldSnapshotId)); forgetSpy.mockReturnValueOnce(Effect.succeed(buildForgetResponse(oldSnapshotId)));
forgetSpy.mockResolvedValueOnce(buildForgetResponse(newSnapshotId)); forgetSpy.mockReturnValueOnce(Effect.succeed(buildForgetResponse(newSnapshotId)));
const firstCategories = await withContext({ organizationId, userId: session.user.id }, () => const firstCategories = await withContext({ organizationId, userId: session.user.id }, () =>
repositoriesService.getRetentionCategories(repository.shortId, schedule.shortId), repositoriesService.getRetentionCategories(repository.shortId, schedule.shortId),
@ -606,8 +611,8 @@ describe("repositoriesService.deleteSnapshot", () => {
snapshots_count: 1, snapshots_count: 1,
}; };
vi.spyOn(restic, "deleteSnapshot").mockResolvedValue({ success: true }); vi.spyOn(restic, "deleteSnapshot").mockReturnValue(Effect.succeed({ success: true }));
const statsSpy = vi.spyOn(restic, "stats").mockResolvedValue(expectedStats); const statsSpy = vi.spyOn(restic, "stats").mockReturnValue(Effect.succeed(expectedStats));
await withContext({ organizationId: session.organizationId, userId: session.user.id }, () => await withContext({ organizationId: session.organizationId, userId: session.user.id }, () =>
repositoriesService.deleteSnapshot(repository.shortId, "snap-1"), repositoriesService.deleteSnapshot(repository.shortId, "snap-1"),
@ -625,9 +630,9 @@ describe("repositoriesService.deleteSnapshot", () => {
test("should throw original error when restic deleteSnapshot fails", async () => { test("should throw original error when restic deleteSnapshot fails", async () => {
const repository = await createTestRepository(session.organizationId); const repository = await createTestRepository(session.organizationId);
vi.spyOn(restic, "deleteSnapshot").mockImplementation(async () => { vi.spyOn(restic, "deleteSnapshot").mockImplementation(() =>
throw new ResticError(1, "Fatal: unexpected HTTP response (403): 403 Forbidden"); Effect.fail(new ResticError(1, "Fatal: unexpected HTTP response (403): 403 Forbidden")),
}); );
await expect( await expect(
withContext({ organizationId: session.organizationId, userId: session.user.id }, () => withContext({ organizationId: session.organizationId, userId: session.user.id }, () =>

View file

@ -10,6 +10,7 @@ import { db } from "~/server/db/db";
import { repositoriesTable } from "~/server/db/schema"; import { repositoriesTable } from "~/server/db/schema";
import { repoMutex } from "~/server/core/repository-mutex"; import { repoMutex } from "~/server/core/repository-mutex";
import { serverEvents } from "~/server/core/events"; import { serverEvents } from "~/server/core/events";
import { Effect } from "effect";
class AbortError extends Error { class AbortError extends Error {
name = "AbortError"; name = "AbortError";
@ -17,7 +18,7 @@ class AbortError extends Error {
const runUnlockStep = async (config: RepositoryConfig, signal?: AbortSignal) => { const runUnlockStep = async (config: RepositoryConfig, signal?: AbortSignal) => {
const orgId = getOrganizationId(); const orgId = getOrganizationId();
const result = await restic.unlock(config, { signal, organizationId: orgId }).then( const result = await Effect.runPromise(restic.unlock(config, { signal, organizationId: orgId })).then(
(result) => ({ success: true, message: result.message, error: null }), (result) => ({ success: true, message: result.message, error: null }),
(error) => ({ success: false, message: null, error: toMessage(error) }), (error) => ({ success: false, message: null, error: toMessage(error) }),
); );
@ -32,7 +33,9 @@ const runUnlockStep = async (config: RepositoryConfig, signal?: AbortSignal) =>
const runCheckStep = async (config: RepositoryConfig, signal: AbortSignal) => { const runCheckStep = async (config: RepositoryConfig, signal: AbortSignal) => {
const orgId = getOrganizationId(); const orgId = getOrganizationId();
const result = await restic.check(config, { readData: false, signal, organizationId: orgId }).then( const result = await Effect.runPromise(
restic.check(config, { readData: false, signal, organizationId: orgId }),
).then(
(result) => result, (result) => result,
(error) => ({ success: false, output: null, error: toMessage(error), hasErrors: true }), (error) => ({ success: false, output: null, error: toMessage(error), hasErrors: true }),
); );
@ -47,7 +50,7 @@ const runCheckStep = async (config: RepositoryConfig, signal: AbortSignal) => {
const runRepairIndexStep = async (config: RepositoryConfig, signal: AbortSignal) => { const runRepairIndexStep = async (config: RepositoryConfig, signal: AbortSignal) => {
const orgId = getOrganizationId(); const orgId = getOrganizationId();
const result = await restic.repairIndex(config, { signal, organizationId: orgId }).then( const result = await Effect.runPromise(restic.repairIndex(config, { signal, organizationId: orgId })).then(
(result) => ({ success: true, output: result.output, error: null }), (result) => ({ success: true, output: result.output, error: null }),
(error) => ({ success: false, output: null, error: toMessage(error) }), (error) => ({ success: false, output: null, error: toMessage(error) }),
); );

View file

@ -0,0 +1,251 @@
import { logger, safeExec } from "@zerobyte/core/node";
import { ResticError, type RepositoryConfig } from "@zerobyte/core/restic";
import { addCommonArgs, buildEnv, buildRepoUrl, cleanupTemporaryKeys } from "@zerobyte/core/restic/server";
import type { ResticDeps } from "@zerobyte/core/restic";
import { db } from "~/server/db/db";
import { type Repository } from "~/server/db/schema";
import { toMessage } from "~/server/utils/errors";
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 physicalRepositoryKey = (config: RepositoryConfig) => {
switch (config.backend) {
case "local":
return `local:${config.path}`;
case "s3":
case "r2":
return `${config.backend}:${config.endpoint.trim().replace(/\/$/, "")}/${config.bucket}`;
case "gcs":
return `gcs:${config.bucket}`;
case "azure":
return `azure:${config.accountName}/${config.container}`;
case "rclone":
return `rclone:${config.remote}:${config.path}`;
case "rest":
return `rest:${config.url.trim().replace(/\/$/, "")}/${config.path ?? ""}`;
case "sftp":
return `sftp:${config.user}@${config.host}:${config.port ?? 22}:${config.path}`;
}
};
const getMutexRows = (repositoryIds: string[]) => {
if (repositoryIds.length === 0) {
return { locks: [], waiters: [] };
}
const locks = db.query.repositoryLocksTable.findMany().sync();
const waiters = db.query.repositoryLockWaitersTable.findMany().sync();
const idSet = new Set(repositoryIds);
return {
locks: locks.filter((lock) => idSet.has(lock.repositoryId)),
waiters: waiters.filter((waiter) => idSet.has(waiter.repositoryId)),
};
};
const findRepositoriesByConfig = (config: RepositoryConfig, organizationId: string) => {
const target = physicalRepositoryKey(config);
const repositories = db.query.repositoriesTable
.findMany({
where: { organizationId },
})
.sync();
return repositories.filter((candidate) => physicalRepositoryKey(candidate.config) === target);
};
const summarizeRepository = (repository: Repository) => ({
id: repository.id,
shortId: repository.shortId,
name: repository.name,
type: repository.type,
status: repository.status,
physicalKey: physicalRepositoryKey(repository.config),
repoUrl: buildRepoUrl(repository.config),
lastChecked: repository.lastChecked,
lastError: repository.lastError,
});
const listPhysicalDuplicates = (config: RepositoryConfig, organizationId: string) => {
const repositories = findRepositoriesByConfig(config, organizationId);
return repositories
.filter(
(candidate) =>
repositories.length > 1 || physicalRepositoryKey(candidate.config) === physicalRepositoryKey(config),
)
.map(summarizeRepository);
};
const parseLockIds = (stdout: string) => {
const ids = new Set<string>();
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 repositoryRows = findRepositoriesByConfig(repositoryConfig, organizationId);
const relatedRows = relatedRepositoryConfigs.flatMap((config) =>
findRepositoriesByConfig(config, organizationId),
);
const uniqueRows = [...repositoryRows, ...relatedRows].filter(
(candidate, index, all) => all.findIndex((other) => other.id === candidate.id) === index,
);
const duplicateRows = listPhysicalDuplicates(repositoryConfig, organizationId);
const repositoryIds = [
...new Set([...uniqueRows.map((candidate) => candidate.id), ...duplicateRows.map((row) => row.id)]),
];
const mutexRows = getMutexRows(repositoryIds);
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: {
physicalKey: physicalRepositoryKey(repositoryConfig),
repoUrl: buildRepoUrl(repositoryConfig),
matchingRows: repositoryRows.map(summarizeRepository),
},
relatedRepositories: relatedRows.map(summarizeRepository),
duplicatePhysicalRepositoryRows: duplicateRows,
mutexState: mutexRows,
});
const configsToInspect = [repositoryConfig, ...relatedRepositoryConfigs].filter(
(config, index, all) =>
all.findIndex((other) => physicalRepositoryKey(other) === physicalRepositoryKey(config)) === index,
);
for (const config of configsToInspect) {
try {
const resticLocks = await inspectResticLocks(config, organizationId, resticDeps);
logger.error("[ResticLockFailure] Restic backend lock dump", {
operation,
physicalKey: physicalRepositoryKey(config),
resticLocks,
});
} catch (diagnosticError) {
logger.error("[ResticLockFailure] Failed to inspect restic backend locks", {
operation,
physicalKey: physicalRepositoryKey(config),
error: toMessage(diagnosticError),
});
}
}
return true;
} catch (diagnosticError) {
logger.error("[ResticLockFailure] Failed to collect lock diagnostics", {
operation,
physicalKey: physicalRepositoryKey(repositoryConfig),
error: toMessage(diagnosticError),
});
return true;
}
};

View file

@ -6,6 +6,7 @@ import {
type CompressionMode, type CompressionMode,
type OverwriteMode, type OverwriteMode,
type RepositoryConfig, type RepositoryConfig,
type ResticDumpStream,
type ResticStatsDto, type ResticStatsDto,
repositoryConfigSchema, repositoryConfigSchema,
} from "@zerobyte/core/restic"; } from "@zerobyte/core/restic";
@ -32,6 +33,7 @@ import { executeDoctor } from "./helpers/doctor";
import type { ShortId } from "~/server/utils/branded"; import type { ShortId } from "~/server/utils/branded";
import { decryptRepositoryConfig, encryptRepositoryConfig } from "./repository-config-secrets"; import { decryptRepositoryConfig, encryptRepositoryConfig } from "./repository-config-secrets";
import { getScheduleByIdOrShortId } from "../backups/helpers/backup-schedule-lookups"; import { getScheduleByIdOrShortId } from "../backups/helpers/backup-schedule-lookups";
import { Effect } from "effect";
const runningDoctors = new Map<string, AbortController>(); const runningDoctors = new Map<string, AbortController>();
@ -100,16 +102,18 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
let error: string | null = null; let error: string | null = null;
if (config.isExistingRepository) { if (config.isExistingRepository) {
const result = await restic const result = await Effect.runPromise(restic.snapshots(encryptedConfig, { organizationId }))
.snapshots(encryptedConfig, { organizationId })
.then(() => ({ error: null })) .then(() => ({ error: null }))
.catch((error) => ({ error })); .catch((error) => ({ error }));
error = result.error; error = result.error;
} else { } else {
const initResult = await restic.init(encryptedConfig, organizationId, { const initResult = await Effect.runPromise(
timeoutMs: appConfig.serverIdleTimeout * 1000, restic.init(encryptedConfig, {
}); organizationId,
timeoutMs: appConfig.serverIdleTimeout * 1000,
}),
);
error = initResult.error; error = initResult.error;
} }
@ -143,7 +147,9 @@ const getRepository = async (shortId: ShortId) => {
const runAndStoreRepositoryStats = async (repository: Repository): Promise<ResticStatsDto> => { const runAndStoreRepositoryStats = async (repository: Repository): Promise<ResticStatsDto> => {
const releaseLock = await repoMutex.acquireShared(repository.id, "stats"); const releaseLock = await repoMutex.acquireShared(repository.id, "stats");
try { try {
const stats = await restic.stats(repository.config, { organizationId: repository.organizationId }); const stats = await Effect.runPromise(
restic.stats(repository.config, { organizationId: repository.organizationId }),
);
await db await db
.update(repositoriesTable) .update(repositoriesTable)
@ -219,7 +225,7 @@ const listSnapshots = async (shortId: ShortId, backupId?: ShortId) => {
} }
const cacheKey = cacheKeys.repository.snapshots(repository.id, backupId); const cacheKey = cacheKeys.repository.snapshots(repository.id, backupId);
const cached = cache.get<Awaited<ReturnType<typeof restic.snapshots>>>(cacheKey); const cached = cache.get<Effect.Effect.Success<ReturnType<typeof restic.snapshots>>>(cacheKey);
if (cached) { if (cached) {
return cached; return cached;
} }
@ -229,9 +235,11 @@ const listSnapshots = async (shortId: ShortId, backupId?: ShortId) => {
let snapshots = []; let snapshots = [];
if (backupId) { if (backupId) {
snapshots = await restic.snapshots(repository.config, { tags: [backupId], organizationId }); snapshots = await Effect.runPromise(
restic.snapshots(repository.config, { tags: [backupId], organizationId }),
);
} else { } else {
snapshots = await restic.snapshots(repository.config, { organizationId }); snapshots = await Effect.runPromise(restic.snapshots(repository.config, { organizationId }));
} }
cache.set(cacheKey, snapshots); cache.set(cacheKey, snapshots);
@ -278,7 +286,9 @@ const listSnapshotFiles = async (
const releaseLock = await repoMutex.acquireShared(repository.id, `ls:${snapshotId}`); const releaseLock = await repoMutex.acquireShared(repository.id, `ls:${snapshotId}`);
try { try {
const result = await restic.ls(repository.config, snapshotId, organizationId, path, { offset, limit }); const result = await Effect.runPromise(
restic.ls(repository.config, snapshotId, path, { organizationId, offset, limit }),
);
if (!result.snapshot) { if (!result.snapshot) {
throw new NotFoundError("Snapshot not found or empty"); throw new NotFoundError("Snapshot not found or empty");
@ -358,19 +368,21 @@ const restoreSnapshot = async (
snapshotId, snapshotId,
}); });
const result = await restic.restore(repository.config, snapshotId, target, { const result = await Effect.runPromise(
basePath, restic.restore(repository.config, snapshotId, target, {
...options, basePath,
organizationId, ...options,
onProgress: (progress) => { organizationId,
serverEvents.emit("restore:progress", { onProgress: (progress) => {
organizationId, serverEvents.emit("restore:progress", {
repositoryId: repository.shortId, organizationId,
snapshotId, repositoryId: repository.shortId,
...progress, snapshotId,
}); ...progress,
}, });
}); },
}),
);
serverEvents.emit("restore:completed", { serverEvents.emit("restore:completed", {
organizationId, organizationId,
@ -408,7 +420,7 @@ const dumpSnapshot = async (shortId: ShortId, snapshotId: string, path?: string,
} }
const releaseLock = await repoMutex.acquireShared(repository.id, `dump:${snapshotId}`); const releaseLock = await repoMutex.acquireShared(repository.id, `dump:${snapshotId}`);
let dumpStream: Awaited<ReturnType<typeof restic.dump>> | undefined = undefined; let dumpStream: ResticDumpStream | null = null;
try { try {
const snapshot = await getSnapshotDetails(repository.shortId, snapshotId); 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 Effect.runPromise(restic.dump(repository.config, preparedDump.snapshotRef, dumpOptions));
serverEvents.emit("dump:started", { serverEvents.emit("dump:started", {
organizationId, organizationId,
@ -468,12 +480,12 @@ const getSnapshotDetails = async (shortId: ShortId, snapshotId: string) => {
} }
const cacheKey = cacheKeys.repository.snapshots(repository.id); const cacheKey = cacheKeys.repository.snapshots(repository.id);
let snapshots = cache.get<Awaited<ReturnType<typeof restic.snapshots>>>(cacheKey); let snapshots = cache.get<Effect.Effect.Success<ReturnType<typeof restic.snapshots>>>(cacheKey);
if (!snapshots) { if (!snapshots) {
const releaseLock = await repoMutex.acquireShared(repository.id, `snapshot_details:${snapshotId}`); const releaseLock = await repoMutex.acquireShared(repository.id, `snapshot_details:${snapshotId}`);
try { try {
snapshots = await restic.snapshots(repository.config, { organizationId }); snapshots = await Effect.runPromise(restic.snapshots(repository.config, { organizationId }));
cache.set(cacheKey, snapshots); cache.set(cacheKey, snapshots);
} finally { } finally {
releaseLock(); releaseLock();
@ -501,7 +513,7 @@ const checkHealth = async (shortId: ShortId) => {
const releaseLock = await repoMutex.acquireExclusive(repository.id, "check"); const releaseLock = await repoMutex.acquireExclusive(repository.id, "check");
try { try {
const { hasErrors, error } = await restic.check(repository.config, { organizationId }); const { hasErrors, error } = await Effect.runPromise(restic.check(repository.config, { organizationId }));
await db await db
.update(repositoriesTable) .update(repositoriesTable)
@ -599,7 +611,7 @@ const deleteSnapshot = async (shortId: ShortId, snapshotId: string) => {
const releaseLock = await repoMutex.acquireExclusive(repository.id, `delete:${snapshotId}`); const releaseLock = await repoMutex.acquireExclusive(repository.id, `delete:${snapshotId}`);
try { try {
await restic.deleteSnapshot(repository.config, snapshotId, organizationId); await Effect.runPromise(restic.deleteSnapshot(repository.config, snapshotId, { organizationId }));
cache.delByPrefix(cacheKeys.repository.all(repository.id)); cache.delByPrefix(cacheKeys.repository.all(repository.id));
void runAndStoreRepositoryStats(repository).catch((error) => { void runAndStoreRepositoryStats(repository).catch((error) => {
logger.error( logger.error(
@ -622,7 +634,7 @@ const deleteSnapshots = async (shortId: ShortId, snapshotIds: string[]) => {
let shouldRefreshStats = false; let shouldRefreshStats = false;
const releaseLock = await repoMutex.acquireExclusive(repository.id, `delete:bulk`); const releaseLock = await repoMutex.acquireExclusive(repository.id, `delete:bulk`);
try { try {
await restic.deleteSnapshots(repository.config, snapshotIds, organizationId); await Effect.runPromise(restic.deleteSnapshots(repository.config, snapshotIds, { organizationId }));
cache.delByPrefix(cacheKeys.repository.all(repository.id)); cache.delByPrefix(cacheKeys.repository.all(repository.id));
shouldRefreshStats = true; shouldRefreshStats = true;
} finally { } finally {
@ -654,7 +666,7 @@ const tagSnapshots = async (
const releaseLock = await repoMutex.acquireExclusive(repository.id, `tag:bulk`); const releaseLock = await repoMutex.acquireExclusive(repository.id, `tag:bulk`);
try { try {
await restic.tagSnapshots(repository.config, snapshotIds, tags, organizationId); await Effect.runPromise(restic.tagSnapshots(repository.config, snapshotIds, tags, { organizationId }));
cache.delByPrefix(cacheKeys.repository.all(repository.id)); cache.delByPrefix(cacheKeys.repository.all(repository.id));
} finally { } finally {
releaseLock(); releaseLock();
@ -673,7 +685,7 @@ const refreshSnapshots = async (shortId: ShortId) => {
const releaseLock = await repoMutex.acquireShared(repository.id, "refresh"); const releaseLock = await repoMutex.acquireShared(repository.id, "refresh");
try { try {
const snapshots = await restic.snapshots(repository.config, { organizationId }); const snapshots = await Effect.runPromise(restic.snapshots(repository.config, { organizationId }));
const cacheKey = cacheKeys.repository.snapshots(repository.id); const cacheKey = cacheKeys.repository.snapshots(repository.id);
cache.set(cacheKey, snapshots); cache.set(cacheKey, snapshots);
@ -772,7 +784,7 @@ const unlockRepository = async (shortId: ShortId) => {
const releaseLock = await repoMutex.acquireExclusive(repository.id, "unlock"); const releaseLock = await repoMutex.acquireExclusive(repository.id, "unlock");
try { try {
const result = await restic.unlock(repository.config, { organizationId }); const result = await Effect.runPromise(restic.unlock(repository.config, { organizationId }));
return result; return result;
} finally { } finally {
releaseLock(); releaseLock();
@ -835,11 +847,13 @@ const getRetentionCategories = async (repositoryId: ShortId, scheduleId?: ShortI
return new Map<string, RetentionCategory[]>(); return new Map<string, RetentionCategory[]>();
} }
const dryRunResults = await restic.forget(repository.config, schedule.retentionPolicy, { const dryRunResults = await Effect.runPromise(
tag: scheduleId, restic.forget(repository.config, schedule.retentionPolicy, {
organizationId: getOrganizationId(), tag: scheduleId,
dryRun: true, organizationId: getOrganizationId(),
}); dryRun: true,
}),
);
if (!dryRunResults.data) { if (!dryRunResults.data) {
return new Map<string, RetentionCategory[]>(); return new Map<string, RetentionCategory[]>();

View file

@ -138,17 +138,19 @@ async function verifySnapshot(
fixtureRootPath: string, fixtureRootPath: string,
scenario: IntegrationScenario, scenario: IntegrationScenario,
) { ) {
const snapshots = await resticClient.snapshots(repositoryConfig, { const snapshots = await Effect.runPromise(
organizationId, resticClient.snapshots(repositoryConfig, {
tags: [uniqueBackupTag], organizationId,
}); tags: [uniqueBackupTag],
}),
);
const snapshot = snapshots.find((candidate) => candidate.id === snapshotId || candidate.short_id === snapshotId); const snapshot = snapshots.find((candidate) => candidate.id === snapshotId || candidate.short_id === snapshotId);
if (!snapshot) { if (!snapshot) {
throw new Error(`Unable to find snapshot ${snapshotId} by integration tag ${uniqueBackupTag}`); 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); await verifySnapshotEntries(fixtureRootPath, res.nodes, scenario.expectedEntries);
} }
@ -163,12 +165,14 @@ async function restoreSnapshot(
restoreOptions: IntegrationScenario["restore"], restoreOptions: IntegrationScenario["restore"],
) { ) {
await fs.mkdir(restoreTarget, { recursive: true }); await fs.mkdir(restoreTarget, { recursive: true });
await resticClient.restore(repositoryConfig, snapshotId, restoreTarget, { await Effect.runPromise(
organizationId, resticClient.restore(repositoryConfig, snapshotId, restoreTarget, {
basePath: fixtureRootPath, organizationId,
excludeXattr: restoreOptions?.excludeXattr, basePath: fixtureRootPath,
overwrite: restoreOptions?.overwrite, excludeXattr: restoreOptions?.excludeXattr,
}); overwrite: restoreOptions?.overwrite,
}),
);
} }
async function cleanupScenario(backend: VolumeBackend, restoreTarget: string, scenarioWorkspace: string) { 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 () => { await runStage(stages, "init-repository", async () => {
if (scenario.repository.isExistingRepository) return; 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) { if (!initResult.success) {
throw new Error(initResult.error ?? "restic init failed"); throw new Error(initResult.error ?? "restic init failed");
} }

View file

@ -3,6 +3,7 @@ import * as cleanupModule from "../../helpers/cleanup-temporary-keys";
import * as nodeModule from "../../../node"; import * as nodeModule from "../../../node";
import { copy } from "../copy"; import { copy } from "../copy";
import type { ResticDeps } from "../../types"; import type { ResticDeps } from "../../types";
import { Effect } from "effect";
const mockDeps: ResticDeps = { const mockDeps: ResticDeps = {
resolveSecret: async (s) => s, resolveSecret: async (s) => s,
@ -49,7 +50,14 @@ describe("copy command", () => {
test("treats flag-like snapshot IDs as positional args", async () => { test("treats flag-like snapshot IDs as positional args", async () => {
const { getArgs } = setup(); 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("--"); const separatorIndex = getArgs().indexOf("--");
expect(separatorIndex).toBeGreaterThan(-1); expect(separatorIndex).toBeGreaterThan(-1);
@ -59,7 +67,7 @@ describe("copy command", () => {
test("defaults to 'latest' when no snapshotIds are provided", async () => { test("defaults to 'latest' when no snapshotIds are provided", async () => {
const { getArgs } = setup(); 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("--"); const separatorIndex = getArgs().indexOf("--");
expect(separatorIndex).toBeGreaterThan(-1); expect(separatorIndex).toBeGreaterThan(-1);
@ -69,11 +77,13 @@ describe("copy command", () => {
test("passes multiple snapshot IDs after separator", async () => { test("passes multiple snapshot IDs after separator", async () => {
const { getArgs } = setup(); const { getArgs } = setup();
await copy( await Effect.runPromise(
sourceConfig, copy(
destConfig, sourceConfig,
{ organizationId: "org-1", snapshotIds: ["abc123", "def456", "ghi789"], tag: "daily" }, destConfig,
mockDeps, { organizationId: "org-1", snapshotIds: ["abc123", "def456", "ghi789"], tag: "daily" },
mockDeps,
),
); );
const separatorIndex = getArgs().indexOf("--"); const separatorIndex = getArgs().indexOf("--");
@ -84,7 +94,9 @@ describe("copy command", () => {
test("defaults to 'latest' when snapshotIds is empty array", async () => { test("defaults to 'latest' when snapshotIds is empty array", async () => {
const { getArgs } = setup(); 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("--"); const separatorIndex = getArgs().indexOf("--");
expect(separatorIndex).toBeGreaterThan(-1); expect(separatorIndex).toBeGreaterThan(-1);

View file

@ -3,6 +3,7 @@ import * as cleanupModule from "../../helpers/cleanup-temporary-keys";
import * as nodeModule from "../../../node"; import * as nodeModule from "../../../node";
import { deleteSnapshots } from "../delete-snapshots"; import { deleteSnapshots } from "../delete-snapshots";
import type { ResticDeps } from "../../types"; import type { ResticDeps } from "../../types";
import { Effect } from "effect";
const mockDeps: ResticDeps = { const mockDeps: ResticDeps = {
resolveSecret: async (s) => s, resolveSecret: async (s) => s,
@ -43,7 +44,7 @@ describe("deleteSnapshots command", () => {
const { getArgs } = setup(); const { getArgs } = setup();
const snapshotIds = ["--help", "--password-command=sh -c 'id'"]; 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("--"); const separatorIndex = getArgs().indexOf("--");
expect(separatorIndex).toBeGreaterThan(-1); expect(separatorIndex).toBeGreaterThan(-1);

View file

@ -4,6 +4,7 @@ import * as cleanupModule from "../../helpers/cleanup-temporary-keys";
import * as nodeModule from "../../../node"; import * as nodeModule from "../../../node";
import { dump } from "../dump"; import { dump } from "../dump";
import type { ResticDeps } from "../../types"; import type { ResticDeps } from "../../types";
import { Effect } from "effect";
const mockDeps: ResticDeps = { const mockDeps: ResticDeps = {
resolveSecret: async (s) => s, resolveSecret: async (s) => s,
@ -45,7 +46,9 @@ describe("dump command", () => {
test("treats snapshot reference as a positional arg", async () => { test("treats snapshot reference as a positional arg", async () => {
const { getArgs } = setup(); 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; await result.completion;
const separatorIndex = getArgs().indexOf("--"); const separatorIndex = getArgs().indexOf("--");

View file

@ -4,6 +4,7 @@ import * as spawnModule from "../../../node/spawn";
import { ls } from "../ls"; import { ls } from "../ls";
import type { ResticDeps } from "../../types"; import type { ResticDeps } from "../../types";
import type { SafeSpawnParams } from "../../../node/spawn"; import type { SafeSpawnParams } from "../../../node/spawn";
import { Effect } from "effect";
const mockDeps: ResticDeps = { const mockDeps: ResticDeps = {
resolveSecret: async (s) => s, resolveSecret: async (s) => s,
@ -57,7 +58,7 @@ describe("ls command", () => {
const snapshotId = "--password-command=sh -c 'id'"; const snapshotId = "--password-command=sh -c 'id'";
const path = "--help"; 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("--"); const separatorIndex = getArgs().indexOf("--");
expect(separatorIndex).toBeGreaterThan(-1); expect(separatorIndex).toBeGreaterThan(-1);

View file

@ -5,6 +5,7 @@ import { ResticError } from "../../error";
import { restore } from "../restore"; import { restore } from "../restore";
import type { ResticDeps } from "../../types"; import type { ResticDeps } from "../../types";
import type { SafeSpawnParams, SpawnResult } from "../../../node/spawn"; import type { SafeSpawnParams, SpawnResult } from "../../../node/spawn";
import { Effect } from "effect";
const mockDeps: ResticDeps = { const mockDeps: ResticDeps = {
resolveSecret: async (s) => s, 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 () => { test("uses the common ancestor as restore root and strips includes for non-root targets", async () => {
const { getRestoreArg, getOptionValues } = setup(); const { getRestoreArg, getOptionValues } = setup();
await restore( await Effect.runPromise(
config, restore(
"snapshot-456", config,
"/tmp/restore-target", "snapshot-456",
{ "/tmp/restore-target",
organizationId: "org-1", {
include: [ organizationId: "org-1",
"/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf", include: [
"/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg", "/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf",
], "/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg",
}, ],
mockDeps, },
mockDeps,
),
); );
expect(getRestoreArg()).toBe("snapshot-456:/var/lib/zerobyte/volumes/vol123/_data"); 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 () => { test("restores a selected file from its parent directory for non-root targets", async () => {
const { getRestoreArg, getOptionValues } = setup(); const { getRestoreArg, getOptionValues } = setup();
await restore( await Effect.runPromise(
config, restore(
"snapshot-single-file", config,
"/tmp/restore-target", "snapshot-single-file",
{ "/tmp/restore-target",
organizationId: "org-1", {
include: ["/var/lib/zerobyte/volumes/vol123/_data/archive/backup.20260301-233001.7z"], organizationId: "org-1",
selectedItemKind: "file", include: ["/var/lib/zerobyte/volumes/vol123/_data/archive/backup.20260301-233001.7z"],
}, selectedItemKind: "file",
mockDeps, },
mockDeps,
),
); );
expect(getRestoreArg()).toBe("snapshot-single-file:/var/lib/zerobyte/volumes/vol123/_data/archive"); 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 () => { test("treats flag-like snapshot IDs as positional restore args", async () => {
const { getArgs, getRestoreArg } = setup(); const { getArgs, getRestoreArg } = setup();
await restore( await Effect.runPromise(
config, restore(
"--help", config,
"/tmp/restore-target", "--help",
{ "/tmp/restore-target",
organizationId: "org-1", {
basePath: "/var/lib/zerobyte/volumes/vol123/_data", organizationId: "org-1",
}, basePath: "/var/lib/zerobyte/volumes/vol123/_data",
mockDeps, },
mockDeps,
),
); );
const separatorIndex = getArgs().indexOf("--"); const separatorIndex = getArgs().indexOf("--");
@ -154,12 +161,14 @@ describe("restore command", () => {
test("returns a parsed restore summary on success", async () => { test("returns a parsed restore summary on success", async () => {
setup(); setup();
const result = await restore( const result = await Effect.runPromise(
config, restore(
"snapshot-123", config,
"/tmp/restore-target", "snapshot-123",
{ organizationId: "org-1", basePath: "/var/lib/zerobyte/volumes/vol123/_data" }, "/tmp/restore-target",
mockDeps, { organizationId: "org-1", basePath: "/var/lib/zerobyte/volumes/vol123/_data" },
mockDeps,
),
); );
expect(result).toMatchObject({ expect(result).toMatchObject({
@ -172,7 +181,25 @@ describe("restore command", () => {
test("throws ResticError when the command fails", async () => { test("throws ResticError when the command fails", async () => {
setup({ spawnResult: { exitCode: 1, summary: "", error: "restore failed" } }); 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( restore(
config, config,
"snapshot-123", "snapshot-123",
@ -180,18 +207,6 @@ describe("restore command", () => {
{ organizationId: "org-1", basePath: "/var/lib/zerobyte/volumes/vol123/_data" }, { organizationId: "org-1", basePath: "/var/lib/zerobyte/volumes/vol123/_data" },
mockDeps, 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({ expect(result).toEqual({
@ -209,16 +224,18 @@ describe("restore command", () => {
const progressUpdates: unknown[] = []; const progressUpdates: unknown[] = [];
setup({ onSpawnCall: (params) => params.onStdout?.(validProgressLine) }); setup({ onSpawnCall: (params) => params.onStdout?.(validProgressLine) });
await restore( await Effect.runPromise(
config, restore(
"snapshot-123", config,
"/tmp/restore-target", "snapshot-123",
{ "/tmp/restore-target",
organizationId: "org-1", {
basePath: "/var/lib/zerobyte/volumes/vol123/_data", organizationId: "org-1",
onProgress: (progress) => progressUpdates.push(progress), basePath: "/var/lib/zerobyte/volumes/vol123/_data",
}, onProgress: (progress) => progressUpdates.push(progress),
mockDeps, },
mockDeps,
),
); );
expect(progressUpdates).toHaveLength(1); expect(progressUpdates).toHaveLength(1);
@ -238,16 +255,18 @@ describe("restore command", () => {
}, },
}); });
await restore( await Effect.runPromise(
config, restore(
"snapshot-123", config,
"/tmp/restore-target", "snapshot-123",
{ "/tmp/restore-target",
organizationId: "org-1", {
basePath: "/var/lib/zerobyte/volumes/vol123/_data", organizationId: "org-1",
onProgress: (progress) => progressUpdates.push(progress), basePath: "/var/lib/zerobyte/volumes/vol123/_data",
}, onProgress: (progress) => progressUpdates.push(progress),
mockDeps, },
mockDeps,
),
); );
expect(progressUpdates).toHaveLength(0); expect(progressUpdates).toHaveLength(0);

View file

@ -4,6 +4,7 @@ import * as nodeModule from "../../../node";
import { snapshots } from "../snapshots"; import { snapshots } from "../snapshots";
import type { SafeSpawnParams } from "../../../node"; import type { SafeSpawnParams } from "../../../node";
import type { ResticDeps } from "../../types"; import type { ResticDeps } from "../../types";
import { Effect } from "effect";
const mockDeps: ResticDeps = { const mockDeps: ResticDeps = {
resolveSecret: async (s) => s, resolveSecret: async (s) => s,
@ -57,7 +58,7 @@ describe("snapshots command", () => {
return Promise.resolve({ exitCode: 0, summary: "", error: "" }); 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); expect(result).toEqual(snapshotsOutput);
}); });

View file

@ -3,6 +3,7 @@ import * as cleanupModule from "../../helpers/cleanup-temporary-keys";
import * as nodeModule from "../../../node"; import * as nodeModule from "../../../node";
import { tagSnapshots } from "../tag-snapshots"; import { tagSnapshots } from "../tag-snapshots";
import type { ResticDeps } from "../../types"; import type { ResticDeps } from "../../types";
import { Effect } from "effect";
const mockDeps: ResticDeps = { const mockDeps: ResticDeps = {
resolveSecret: async (s) => s, resolveSecret: async (s) => s,
@ -43,7 +44,9 @@ describe("tagSnapshots command", () => {
const { getArgs } = setup(); const { getArgs } = setup();
const snapshotIds = ["--help", "--password-command=sh -c 'id'"]; 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("--"); const separatorIndex = getArgs().indexOf("--");
expect(separatorIndex).toBeGreaterThan(-1); expect(separatorIndex).toBeGreaterThan(-1);

View file

@ -1,3 +1,4 @@
import { Data, Effect } from "effect";
import { logger, safeExec } from "../../node"; import { logger, safeExec } from "../../node";
import { addCommonArgs } from "../helpers/add-common-args"; import { addCommonArgs } from "../helpers/add-common-args";
import { buildEnv } from "../helpers/build-env"; 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 { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
import type { RepositoryConfig } from "../schemas"; import type { RepositoryConfig } from "../schemas";
import type { ResticDeps } from "../types"; 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, config: RepositoryConfig,
options: { options: {
readData?: boolean; readData?: boolean;
@ -15,54 +23,68 @@ export const check = async (
}, },
deps: ResticDeps, deps: ResticDeps,
) => { ) => {
const repoUrl = buildRepoUrl(config); return Effect.tryPromise({
const env = await buildEnv(config, options.organizationId, deps); 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) { if (options.readData) {
args.push("--read-data"); args.push("--read-data");
} }
addCommonArgs(args, env, config); addCommonArgs(args, env, config);
const res = await safeExec({ const res = await safeExec({
command: "restic", command: "restic",
args, args,
env, env,
signal: options.signal, 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,
};
}; };

View file

@ -7,68 +7,89 @@ import type { RepositoryConfig } from "../schemas";
import { ResticError } from "../error"; import { ResticError } from "../error";
import { logger, safeExec } from "../../node"; import { logger, safeExec } from "../../node";
import type { ResticDeps } from "../types"; 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, sourceConfig: RepositoryConfig,
destConfig: RepositoryConfig, destConfig: RepositoryConfig,
options: { organizationId: string; tag?: string; snapshotIds?: string[] }, options: { organizationId: string; tag?: string; snapshotIds?: string[] },
deps: ResticDeps, deps: ResticDeps,
) => { ) => {
const sourceRepoUrl = buildRepoUrl(sourceConfig); return Effect.tryPromise({
const destRepoUrl = buildRepoUrl(destConfig); try: async () => {
const sourceRepoUrl = buildRepoUrl(sourceConfig);
const destRepoUrl = buildRepoUrl(destConfig);
const sourceEnv = await buildEnv(sourceConfig, options.organizationId, deps); const sourceEnv = await buildEnv(sourceConfig, options.organizationId, deps);
const destEnv = await buildEnv(destConfig, options.organizationId, deps); const destEnv = await buildEnv(destConfig, options.organizationId, deps);
const env: Record<string, string> = { const env: Record<string, string> = {
...sourceEnv, ...sourceEnv,
...destEnv, ...destEnv,
RESTIC_FROM_PASSWORD_FILE: sourceEnv.RESTIC_PASSWORD_FILE!, 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) { if (options.tag) {
args.push("--tag", 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 sourceDownloadLimit = formatBandwidthLimit(sourceConfig.downloadLimit);
const destUploadLimit = formatBandwidthLimit(destConfig.uploadLimit); const destUploadLimit = formatBandwidthLimit(destConfig.uploadLimit);
if (sourceDownloadLimit) { if (sourceDownloadLimit) {
args.push("--limit-download", sourceDownloadLimit); args.push("--limit-download", sourceDownloadLimit);
} }
if (destUploadLimit) { if (destUploadLimit) {
args.push("--limit-upload", destUploadLimit); args.push("--limit-upload", destUploadLimit);
} }
if (options.snapshotIds && options.snapshotIds.length > 0) { if (options.snapshotIds && options.snapshotIds.length > 0) {
args.push("--", ...options.snapshotIds); args.push("--", ...options.snapshotIds);
} else { } else {
args.push("--", "latest"); args.push("--", "latest");
} }
logger.info(`Copying snapshots from ${sourceRepoUrl} to ${destRepoUrl}...`); logger.info(`Copying snapshots from ${sourceRepoUrl} to ${destRepoUrl}...`);
logger.debug(`Executing: restic ${args.join(" ")}`); 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(sourceEnv, deps);
await cleanupTemporaryKeys(destEnv, deps); await cleanupTemporaryKeys(destEnv, deps);
const { stdout, stderr } = res; const { stdout, stderr } = res;
if (res.exitCode !== 0) { if (res.exitCode !== 0) {
logger.error(`Restic copy failed: ${stderr}`); logger.error(`Restic copy failed: ${stderr}`);
throw new ResticError(res.exitCode, stderr); throw new ResticError(res.exitCode, stderr);
} }
logger.info(`Restic copy completed from ${sourceRepoUrl} to ${destRepoUrl}`); logger.info(`Restic copy completed from ${sourceRepoUrl} to ${destRepoUrl}`);
return { return {
success: true, success: true,
output: stdout, output: stdout,
}; };
},
catch: (error) => {
if (error instanceof ResticError) {
return error;
}
return new ResticCopyCommandError({
cause: error,
message: toMessage(error),
});
},
});
}; };

View file

@ -1,3 +1,4 @@
import { Data, Effect } from "effect";
import { logger, safeExec } from "../../node"; import { logger, safeExec } from "../../node";
import { ResticError } from "../error"; import { ResticError } from "../error";
import { addCommonArgs } from "../helpers/add-common-args"; 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 { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
import type { RepositoryConfig } from "../schemas"; import type { RepositoryConfig } from "../schemas";
import type { ResticDeps } from "../types"; 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, config: RepositoryConfig,
snapshotIds: string[], snapshotIds: string[],
organizationId: string, options: { organizationId: string },
deps: ResticDeps, deps: ResticDeps,
) => { ) => {
const repoUrl = buildRepoUrl(config); return Effect.tryPromise({
const env = await buildEnv(config, organizationId, deps); try: async () => {
const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config, options.organizationId, deps);
if (snapshotIds.length === 0) { if (snapshotIds.length === 0) {
throw new Error("No snapshot IDs provided for deletion."); throw new Error("No snapshot IDs provided for deletion.");
} }
const args: string[] = ["--repo", repoUrl, "forget", "--prune"]; const args: string[] = ["--repo", repoUrl, "forget", "--prune"];
addCommonArgs(args, env, config); addCommonArgs(args, env, config);
args.push("--", ...snapshotIds); args.push("--", ...snapshotIds);
const res = await safeExec({ command: "restic", args, env }); const res = await safeExec({ command: "restic", args, env });
await cleanupTemporaryKeys(env, deps); await cleanupTemporaryKeys(env, deps);
if (res.exitCode !== 0) { if (res.exitCode !== 0) {
logger.error(`Restic snapshot deletion failed: ${res.stderr}`); logger.error(`Restic snapshot deletion failed: ${res.stderr}`);
throw new ResticError(res.exitCode, 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, config: RepositoryConfig,
snapshotId: string, snapshotId: string,
organizationId: string, options: { organizationId: string },
deps: ResticDeps, deps: ResticDeps,
) => { ) => {
return deleteSnapshots(config, [snapshotId], organizationId, deps); return deleteSnapshots(config, [snapshotId], options, deps);
}; };

View file

@ -1,6 +1,6 @@
import { normalizeAbsolutePath } from "../../utils/path"; import { normalizeAbsolutePath } from "../../utils/path";
import type { Readable } from "node:stream"; 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 { addCommonArgs } from "../helpers/add-common-args";
import { buildEnv } from "../helpers/build-env"; import { buildEnv } from "../helpers/build-env";
import { buildRepoUrl } from "../helpers/build-repo-url"; import { buildRepoUrl } from "../helpers/build-repo-url";
@ -8,6 +8,13 @@ import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
import type { RepositoryConfig } from "../schemas"; import type { RepositoryConfig } from "../schemas";
import { logger, safeSpawn } from "../../node"; import { logger, safeSpawn } from "../../node";
import { ResticError } from "../error"; 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 normalizeDumpPath = (pathToDump?: string): string => {
const trimmedPath = pathToDump?.trim(); const trimmedPath = pathToDump?.trim();
@ -18,7 +25,7 @@ const normalizeDumpPath = (pathToDump?: string): string => {
return normalizeAbsolutePath(trimmedPath); return normalizeAbsolutePath(trimmedPath);
}; };
export const dump = async ( export const dump = (
config: RepositoryConfig, config: RepositoryConfig,
snapshotRef: string, snapshotRef: string,
options: { options: {
@ -27,86 +34,100 @@ export const dump = async (
archive?: false; archive?: false;
}, },
deps: ResticDeps, deps: ResticDeps,
): Promise<ResticDumpStream> => { ) => {
const repoUrl = buildRepoUrl(config); return Effect.tryPromise({
const env = await buildEnv(config, options.organizationId, deps); try: async () => {
const pathToDump = normalizeDumpPath(options.path); 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) { if (options.archive !== false) {
args.push("--archive", "tar"); args.push("--archive", "tar");
} }
addCommonArgs(args, env, config, { includeJson: false }); addCommonArgs(args, env, config, { includeJson: false });
args.push("--", snapshotRef, pathToDump); args.push("--", snapshotRef, pathToDump);
logger.debug(`Executing: restic ${args.join(" ")}`); logger.debug(`Executing: restic ${args.join(" ")}`);
let didCleanup = false; let didCleanup = false;
const cleanup = async () => { const cleanup = async () => {
if (didCleanup) { if (didCleanup) {
return; 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);
} }
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<void>((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();
}
},
};
}, },
}) catch: (error) => {
.then((result) => { if (error instanceof ResticError) {
if (result.exitCode === 0) { return error;
return;
} }
const stderr = stderrTail.trim() || result.error; return new ResticDumpCommandError({
logger.error(`Restic dump failed: ${stderr}`); cause: error,
throw new ResticError(result.exitCode, stderr); message: toMessage(error),
}) });
.finally(async () => {
abortController = null;
await cleanup();
});
completion.catch(() => {});
const completionPromise = new Promise<void>((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();
}
}, },
}; });
}; };

View file

@ -7,60 +7,81 @@ import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
import type { RepositoryConfig } from "../schemas"; import type { RepositoryConfig } from "../schemas";
import { logger, safeExec } from "../../node"; import { logger, safeExec } from "../../node";
import { ResticError } from "../error"; 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, config: RepositoryConfig,
options: RetentionPolicy, options: RetentionPolicy,
extra: { tag: string; organizationId: string; dryRun?: boolean }, extra: { tag: string; organizationId: string; dryRun?: boolean },
deps: ResticDeps, deps: ResticDeps,
) => { ) => {
const repoUrl = buildRepoUrl(config); return Effect.tryPromise({
const env = await buildEnv(config, extra.organizationId, deps); 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) { if (extra.dryRun) {
args.push("--dry-run", "--no-lock"); args.push("--dry-run", "--no-lock");
} }
if (options.keepLast) { if (options.keepLast) {
args.push("--keep-last", String(options.keepLast)); args.push("--keep-last", String(options.keepLast));
} }
if (options.keepHourly) { if (options.keepHourly) {
args.push("--keep-hourly", String(options.keepHourly)); args.push("--keep-hourly", String(options.keepHourly));
} }
if (options.keepDaily) { if (options.keepDaily) {
args.push("--keep-daily", String(options.keepDaily)); args.push("--keep-daily", String(options.keepDaily));
} }
if (options.keepWeekly) { if (options.keepWeekly) {
args.push("--keep-weekly", String(options.keepWeekly)); args.push("--keep-weekly", String(options.keepWeekly));
} }
if (options.keepMonthly) { if (options.keepMonthly) {
args.push("--keep-monthly", String(options.keepMonthly)); args.push("--keep-monthly", String(options.keepMonthly));
} }
if (options.keepYearly) { if (options.keepYearly) {
args.push("--keep-yearly", String(options.keepYearly)); args.push("--keep-yearly", String(options.keepYearly));
} }
if (options.keepWithinDuration) { if (options.keepWithinDuration) {
args.push("--keep-within-duration", options.keepWithinDuration); args.push("--keep-within-duration", options.keepWithinDuration);
} }
if (!extra.dryRun) { if (!extra.dryRun) {
args.push("--prune"); args.push("--prune");
} }
addCommonArgs(args, env, config); addCommonArgs(args, env, config);
const res = await safeExec({ command: "restic", args, env }); const res = await safeExec({ command: "restic", args, env });
await cleanupTemporaryKeys(env, deps); await cleanupTemporaryKeys(env, deps);
if (res.exitCode !== 0) { if (res.exitCode !== 0) {
logger.error(`Restic forget failed: ${res.stderr}`); logger.error(`Restic forget failed: ${res.stderr}`);
throw new ResticError(res.exitCode, res.stderr); throw new ResticError(res.exitCode, res.stderr);
} }
const lines = res.stdout.split("\n").filter((line) => line.trim()); const lines = res.stdout.split("\n").filter((line) => line.trim());
const result = extra.dryRun ? safeJsonParse<ResticForgetResponse>(lines.at(-1) ?? "[]") : null; const result = extra.dryRun ? safeJsonParse<ResticForgetResponse>(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),
});
},
});
}; };

View file

@ -6,22 +6,31 @@ import { keyAdd } from "./key-add";
import type { RepositoryConfig } from "../schemas"; import type { RepositoryConfig } from "../schemas";
import { logger, safeExec } from "../../node"; import { logger, safeExec } from "../../node";
import type { ResticDeps } from "../types"; 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 ( const addDefaultKey = async (
config: RepositoryConfig, config: RepositoryConfig,
organizationId: string, options: { organizationId: string; timeoutMs?: number },
options: { timeoutMs?: number },
deps: ResticDeps, deps: ResticDeps,
) => { ) => {
if (deps?.hostname) { if (deps?.hostname) {
const keyResult = await keyAdd( const keyResult = await Effect.runPromise(
config, keyAdd(
organizationId, config,
{ {
host: deps.hostname, organizationId: options.organizationId,
timeoutMs: options?.timeoutMs, host: deps.hostname,
}, timeoutMs: options?.timeoutMs,
deps, },
deps,
),
); );
if (!keyResult.success) { if (!keyResult.success) {
@ -30,39 +39,49 @@ const addDefaultKey = async (
} }
}; };
export const init = async ( export const init = (
config: RepositoryConfig, config: RepositoryConfig,
organizationId: string, options: { organizationId: string; timeoutMs?: number },
options: { timeoutMs?: number } | undefined,
deps: ResticDeps, 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]; const args = ["init", "--repo", repoUrl];
addCommonArgs(args, env, config); addCommonArgs(args, env, config);
const res = await safeExec({ command: "restic", args, env, timeout: options?.timeoutMs ?? 60000 }); const res = await safeExec({ command: "restic", args, env, timeout: options?.timeoutMs ?? 60000 });
await cleanupTemporaryKeys(env, deps); await cleanupTemporaryKeys(env, deps);
if (res.exitCode !== 0) { if (res.exitCode !== 0) {
logger.error(`Restic init failed: ${res.stderr}`); logger.error(`Restic init failed: ${res.stderr}`);
return { success: false, error: res.stderr }; return { success: false, error: res.stderr };
} }
logger.info(`Restic repository initialized: ${repoUrl}`); logger.info(`Restic repository initialized: ${repoUrl}`);
void addDefaultKey( await addDefaultKey(
config, config,
organizationId, { organizationId: options.organizationId, timeoutMs: options?.timeoutMs },
{ deps,
timeoutMs: options?.timeoutMs, );
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),
});
},
});
}; };

View file

@ -1,3 +1,4 @@
import { Data, Effect } from "effect";
import { logger, safeExec } from "../../node"; import { logger, safeExec } from "../../node";
import { addCommonArgs } from "../helpers/add-common-args"; import { addCommonArgs } from "../helpers/add-common-args";
import { buildEnv } from "../helpers/build-env"; 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 { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
import type { RepositoryConfig } from "../schemas"; import type { RepositoryConfig } from "../schemas";
import type { ResticDeps } from "../types"; 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, config: RepositoryConfig,
organizationId: string, options: { organizationId: string; host: string; timeoutMs?: number },
options: { host: string; timeoutMs?: number },
deps: ResticDeps, 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 = [ const args = [
"key", "key",
"add", "add",
"--repo", "--repo",
repoUrl, repoUrl,
"--host", "--host",
options.host, options.host,
"--new-password-file", "--new-password-file",
env.RESTIC_PASSWORD_FILE, env.RESTIC_PASSWORD_FILE,
].filter((e) => e !== undefined); ].filter((e) => e !== undefined);
addCommonArgs(args, env, config); addCommonArgs(args, env, config);
const res = await safeExec({ command: "restic", args, env, timeout: options.timeoutMs ?? 60000 }); const res = await safeExec({ command: "restic", args, env, timeout: options.timeoutMs ?? 60000 });
await cleanupTemporaryKeys(env, deps); await cleanupTemporaryKeys(env, deps);
if (res.exitCode !== 0) { if (res.exitCode !== 0) {
logger.error(`Restic key add failed: ${res.stderr}`); logger.error(`Restic key add failed: ${res.stderr}`);
return { success: false, error: res.stderr }; return { success: false, error: res.stderr };
} }
logger.info(`Restic key added with host "${options.host}" for repository: ${repoUrl}`); logger.info(`Restic key added with host "${options.host}" for repository: ${repoUrl}`);
return { success: true, error: null }; return { success: true, error: null };
},
catch: (error) => {
if (error instanceof ResticError) {
return error;
}
return new ResticKeyAddCommandError({
cause: error,
message: toMessage(error),
});
},
});
}; };

View file

@ -7,6 +7,13 @@ import type { RepositoryConfig } from "../schemas";
import { logger, safeSpawn } from "../../node"; import { logger, safeSpawn } from "../../node";
import { ResticError } from "../error"; import { ResticError } from "../error";
import type { ResticDeps } from "../types"; 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({ const lsNodeSchema = z.object({
name: z.string(), name: z.string(),
@ -49,98 +56,111 @@ type ResticLsResult = {
}; };
}; };
export const ls = async ( export const ls = (
config: RepositoryConfig, config: RepositoryConfig,
snapshotId: string, snapshotId: string,
organizationId: string,
path: string | undefined, path: string | undefined,
options: { offset?: number; limit?: number } | undefined, options: { organizationId: string; offset?: number; limit?: number },
deps: ResticDeps, deps: ResticDeps,
): Promise<ResticLsResult> => { ) => {
const repoUrl = buildRepoUrl(config); return Effect.tryPromise({
const env = await buildEnv(config, organizationId, deps); 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); addCommonArgs(args, env, config);
args.push("--", snapshotId); args.push("--", snapshotId);
if (path) { if (path) {
args.push(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;
} }
try { let snapshot: LsSnapshotInfo | null = null;
const data = JSON.parse(trimmedLine); const nodes: LsNode[] = [];
let totalNodes = 0;
let isFirstLine = true;
let hasMore = false;
if (isFirstLine) { const offset = Math.max(options?.offset ?? 0, 0);
isFirstLine = false; const limit = Math.min(Math.max(options?.limit ?? 500, 1), 500);
const snapshotValidation = lsSnapshotInfoSchema.safeParse(data);
if (snapshotValidation.success) { logger.debug(`Running restic ls with args: ${args.join(" ")}`);
snapshot = snapshotValidation.data;
const res = await safeSpawn({
command: "restic",
args,
env,
onStdout: (line) => {
const trimmedLine = line.trim();
if (!trimmedLine) {
return;
} }
return;
}
const nodeValidation = lsNodeSchema.safeParse(data); try {
if (!nodeValidation.success) { const data = JSON.parse(trimmedLine);
logger.warn(`Skipping invalid node: ${nodeValidation.error.message}`);
return;
}
if (totalNodes >= offset && totalNodes < offset + limit) { if (isFirstLine) {
nodes.push(nodeValidation.data); isFirstLine = false;
} const snapshotValidation = lsSnapshotInfoSchema.safeParse(data);
totalNodes++; if (snapshotValidation.success) {
snapshot = snapshotValidation.data;
}
return;
}
if (totalNodes >= offset + limit + 1) { const nodeValidation = lsNodeSchema.safeParse(data);
hasMore = true; if (!nodeValidation.success) {
} logger.warn(`Skipping invalid node: ${nodeValidation.error.message}`);
} catch { return;
// Ignore JSON parse errors for non-JSON lines }
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,
},
};
}; };

View file

@ -1,3 +1,4 @@
import { Data, Effect } from "effect";
import { logger, safeExec } from "../../node"; import { logger, safeExec } from "../../node";
import { ResticError } from "../error"; import { ResticError } from "../error";
import { addCommonArgs } from "../helpers/add-common-args"; 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 { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
import type { RepositoryConfig } from "../schemas"; import type { RepositoryConfig } from "../schemas";
import type { ResticDeps } from "../types"; 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, config: RepositoryConfig,
options: { signal?: AbortSignal; organizationId: string }, options: { signal?: AbortSignal; organizationId: string },
deps: ResticDeps, deps: ResticDeps,
) => { ) => {
const repoUrl = buildRepoUrl(config); return Effect.tryPromise({
const env = await buildEnv(config, options.organizationId, deps); try: async () => {
const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config, options.organizationId, deps);
const args = ["repair", "index", "--repo", repoUrl]; const args = ["repair", "index", "--repo", repoUrl];
addCommonArgs(args, env, config); addCommonArgs(args, env, config);
const res = await safeExec({ const res = await safeExec({
command: "restic", command: "restic",
args, args,
env, env,
signal: options.signal, 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",
};
}; };

View file

@ -11,6 +11,13 @@ import { logger, safeSpawn } from "../../node";
import { ResticError } from "../error"; import { ResticError } from "../error";
import { resticRestoreOutputSchema, type ResticRestoreOutputDto } from "../restic-dto"; import { resticRestoreOutputSchema, type ResticRestoreOutputDto } from "../restic-dto";
import type { ResticDeps } from "../types"; 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({ const restoreProgressSchema = z.object({
message_type: z.enum(["status", "summary"]), message_type: z.enum(["status", "summary"]),
@ -24,7 +31,7 @@ const restoreProgressSchema = z.object({
export type RestoreProgress = z.infer<typeof restoreProgressSchema>; export type RestoreProgress = z.infer<typeof restoreProgressSchema>;
export const restore = async ( export const restore = (
config: RepositoryConfig, config: RepositoryConfig,
snapshotId: string, snapshotId: string,
target: string, target: string,
@ -42,130 +49,148 @@ export const restore = async (
}, },
deps: ResticDeps, deps: ResticDeps,
) => { ) => {
const repoUrl = buildRepoUrl(config); return Effect.tryPromise({
const env = await buildEnv(config, options.organizationId, deps); 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 includes = options.include?.length ? options.include : [options.basePath ?? "/"];
const commonAncestor = const commonAncestor =
options.selectedItemKind === "file" && includes.length === 1 options.selectedItemKind === "file" && includes.length === 1
? path.posix.dirname(includes[0] ?? "/") ? path.posix.dirname(includes[0] ?? "/")
: findCommonAncestor(includes); : findCommonAncestor(includes);
if (target !== "/") { if (target !== "/") {
restoreArg = `${snapshotId}:${commonAncestor}`; 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);
} }
} else {
const strippedIncludes = options.include.map((pattern) => path.posix.relative(commonAncestor, pattern));
const includesCoverRestoreRoot = strippedIncludes.some((pattern) => pattern === "" || pattern === ".");
if (!includesCoverRestoreRoot) { const args = ["--repo", repoUrl, "restore", "--target", target];
for (const pattern of strippedIncludes) {
if (pattern !== "" && pattern !== ".") { if (options.overwrite) {
args.push("--overwrite", options.overwrite);
}
if (options.include?.length) {
if (target === "/") {
for (const pattern of options.include) {
args.push("--include", pattern); 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) { if (options.exclude && options.exclude.length > 0) {
for (const pattern of options.exclude) { for (const pattern of options.exclude) {
args.push("--exclude", pattern); args.push("--exclude", pattern);
} }
} }
if (options.excludeXattr && options.excludeXattr.length > 0) { if (options.excludeXattr && options.excludeXattr.length > 0) {
for (const xattr of options.excludeXattr) { for (const xattr of options.excludeXattr) {
args.push("--exclude-xattr", xattr); args.push("--exclude-xattr", xattr);
} }
} }
addCommonArgs(args, env, config); addCommonArgs(args, env, config);
args.push("--", restoreArg); args.push("--", restoreArg);
const streamProgress = throttle((data: string) => { const streamProgress = throttle((data: string) => {
if (options.onProgress) { 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 { try {
const jsonData = JSON.parse(data); summaryLine = JSON.parse(lastLine ?? "{}");
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 { } 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(" ")}`); logger.debug(`Restic restore output last line: ${JSON.stringify(summaryLine)}`);
const res = await safeSpawn({ const result = resticRestoreOutputSchema.safeParse(summaryLine);
command: "restic",
args, if (!result.success) {
env, logger.warn(`Restic restore output validation failed: ${result.error.message}`);
signal: options.signal, logger.info(`Restic restore completed for snapshot ${snapshotId} to target ${target}`);
onStdout: (data) => { const fallback: ResticRestoreOutputDto = {
if (options.onProgress) { message_type: "summary" as const,
streamProgress(data); 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;
}; };

View file

@ -7,6 +7,14 @@ import { resticSnapshotSummarySchema } from "../restic-dto";
import type { RepositoryConfig } from "../schemas"; import type { RepositoryConfig } from "../schemas";
import { logger, safeSpawn } from "../../node"; import { logger, safeSpawn } from "../../node";
import type { ResticDeps } from "../types"; 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({ const snapshotInfoSchema = z.object({
gid: z.number().optional(), gid: z.number().optional(),
@ -23,49 +31,64 @@ const snapshotInfoSchema = z.object({
summary: resticSnapshotSummarySchema.optional(), summary: resticSnapshotSummarySchema.optional(),
}); });
export const snapshots = async ( export const snapshots = (
config: RepositoryConfig, config: RepositoryConfig,
options: { tags?: string[]; organizationId: string }, options: { tags?: string[]; organizationId: string },
deps: ResticDeps, deps: ResticDeps,
) => { ) => {
const { tags, organizationId } = options; return Effect.tryPromise({
try: async () => {
const { tags, organizationId } = options;
const repoUrl = buildRepoUrl(config); const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config, organizationId, deps); const env = await buildEnv(config, organizationId, deps);
const args = ["--repo", repoUrl, "snapshots"]; const args = ["--repo", repoUrl, "snapshots"];
if (tags && tags.length > 0) { if (tags && tags.length > 0) {
for (const tag of tags) { for (const tag of tags) {
args.push("--tag", tag); args.push("--tag", tag);
} }
} }
addCommonArgs(args, env, config); addCommonArgs(args, env, config);
const stdoutLines: string[] = []; const stdoutLines: string[] = [];
const res = await safeSpawn({ const res = await safeSpawn({
command: "restic", command: "restic",
args, args,
env, env,
onStdout: (line) => { onStdout: (line) => {
stdoutLines.push(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;
}; };

View file

@ -8,29 +8,50 @@ import { logger, safeExec } from "../../node";
import { ResticError } from "../error"; import { ResticError } from "../error";
import { resticStatsSchema } from "../restic-dto"; import { resticStatsSchema } from "../restic-dto";
import type { ResticDeps } from "../types"; 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) => { class ResticStatsCommandError extends Data.TaggedError("ResticStatsCommandError")<{
const repoUrl = buildRepoUrl(config); cause: unknown;
const env = await buildEnv(config, options.organizationId, deps); message: string;
}> {}
const args = ["--repo", repoUrl, "stats", "--mode", "raw-data"]; export const stats = (config: RepositoryConfig, options: { organizationId: string }, deps: ResticDeps) => {
addCommonArgs(args, env, config); 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 }); const args = ["--repo", repoUrl, "stats", "--mode", "raw-data"];
await cleanupTemporaryKeys(env, deps); addCommonArgs(args, env, config);
if (res.exitCode !== 0) { const res = await safeExec({ command: "restic", args, env });
logger.error(`Restic stats retrieval failed: ${res.stderr}`); await cleanupTemporaryKeys(env, deps);
throw new ResticError(res.exitCode, res.stderr);
}
const parsedJson = safeJsonParse<unknown>(res.stdout); if (res.exitCode !== 0) {
const result = resticStatsSchema.safeParse(parsedJson); logger.error(`Restic stats retrieval failed: ${res.stderr}`);
throw new ResticError(res.exitCode, res.stderr);
}
if (!result.success) { const parsedJson = safeJsonParse<unknown>(res.stdout);
logger.error(`Restic stats output validation failed: ${result.error.message}`); const result = resticStatsSchema.safeParse(parsedJson);
throw new Error(`Restic stats output validation failed: ${result.error.message}`);
}
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),
});
},
});
}; };

View file

@ -1,3 +1,4 @@
import { Data, Effect } from "effect";
import { logger, safeExec } from "../../node"; import { logger, safeExec } from "../../node";
import { ResticError } from "../error"; import { ResticError } from "../error";
import { addCommonArgs } from "../helpers/add-common-args"; 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 { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
import type { RepositoryConfig } from "../schemas"; import type { RepositoryConfig } from "../schemas";
import type { ResticDeps } from "../types"; 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, config: RepositoryConfig,
snapshotIds: string[], snapshotIds: string[],
tags: { add?: string[]; remove?: string[]; set?: string[] }, tags: { add?: string[]; remove?: string[]; set?: string[] },
organizationId: string, options: { organizationId: string },
deps: ResticDeps, deps: ResticDeps,
) => { ) => {
if (snapshotIds.length === 0) { return Effect.tryPromise({
throw new Error("No snapshot IDs provided for tagging."); try: async () => {
} if (snapshotIds.length === 0) {
throw new Error("No snapshot IDs provided for tagging.");
}
const repoUrl = buildRepoUrl(config); const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config, organizationId, deps); const env = await buildEnv(config, options.organizationId, deps);
const args: string[] = ["--repo", repoUrl, "tag"]; const args: string[] = ["--repo", repoUrl, "tag"];
if (tags.add) { if (tags.add) {
for (const tag of tags.add) { for (const tag of tags.add) {
args.push("--add", tag); args.push("--add", tag);
} }
} }
if (tags.remove) { if (tags.remove) {
for (const tag of tags.remove) { for (const tag of tags.remove) {
args.push("--remove", tag); args.push("--remove", tag);
} }
} }
if (tags.set) { if (tags.set) {
for (const tag of tags.set) { for (const tag of tags.set) {
args.push("--set", tag); args.push("--set", tag);
} }
} }
addCommonArgs(args, env, config); addCommonArgs(args, env, config);
args.push("--", ...snapshotIds); args.push("--", ...snapshotIds);
const res = await safeExec({ command: "restic", args, env }); const res = await safeExec({ command: "restic", args, env });
await cleanupTemporaryKeys(env, deps); await cleanupTemporaryKeys(env, deps);
if (res.exitCode !== 0) { if (res.exitCode !== 0) {
logger.error(`Restic snapshot tagging failed: ${res.stderr}`); logger.error(`Restic snapshot tagging failed: ${res.stderr}`);
throw new ResticError(res.exitCode, 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),
});
},
});
}; };

View file

@ -1,3 +1,4 @@
import { Data, Effect } from "effect";
import { logger, safeExec } from "../../node"; import { logger, safeExec } from "../../node";
import { ResticError } from "../error"; import { ResticError } from "../error";
import { addCommonArgs } from "../helpers/add-common-args"; 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 { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
import type { RepositoryConfig } from "../schemas"; import type { RepositoryConfig } from "../schemas";
import type { ResticDeps } from "../types"; 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, config: RepositoryConfig,
options: { signal?: AbortSignal; organizationId: string }, options: { signal?: AbortSignal; organizationId: string },
deps: ResticDeps, deps: ResticDeps,
) => { ) => {
const repoUrl = buildRepoUrl(config); return Effect.tryPromise({
const env = await buildEnv(config, options.organizationId, deps); try: async () => {
const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config, options.organizationId, deps);
const args = ["unlock", "--repo", repoUrl, "--remove-all"]; const args = ["unlock", "--repo", repoUrl, "--remove-all"];
addCommonArgs(args, env, config); addCommonArgs(args, env, config);
const res = await safeExec({ const res = await safeExec({
command: "restic", command: "restic",
args, args,
env, env,
signal: options.signal, 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" };
}; };

View file

@ -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<string>();
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;
}
};

View file

@ -1,3 +1,4 @@
import { Effect } from "effect";
import { backup } from "./commands/backup"; import { backup } from "./commands/backup";
import { check } from "./commands/check"; import { check } from "./commands/check";
import { copy } from "./commands/copy"; import { copy } from "./commands/copy";
@ -13,6 +14,8 @@ import { snapshots } from "./commands/snapshots";
import { stats } from "./commands/stats"; import { stats } from "./commands/stats";
import { tagSnapshots } from "./commands/tag-snapshots"; import { tagSnapshots } from "./commands/tag-snapshots";
import { unlock } from "./commands/unlock"; import { unlock } from "./commands/unlock";
import { logResticLockFailureDiagnostics } from "./lock-diagnostics";
import type { RepositoryConfig } from "./schemas";
import type { ResticDeps } from "./types"; import type { ResticDeps } from "./types";
export { addCommonArgs } from "./helpers/add-common-args"; export { addCommonArgs } from "./helpers/add-common-args";
@ -22,28 +25,80 @@ export { cleanupTemporaryKeys } from "./helpers/cleanup-temporary-keys";
export { validateCustomResticParams } from "./helpers/validate-custom-params"; export { validateCustomResticParams } from "./helpers/validate-custom-params";
export { ResticError } from "./error"; export { ResticError } from "./error";
function withDeps<Args extends unknown[], Result>( type LockDiagnosticCommandContext = {
command: (...args: [...Args, ResticDeps]) => Result, 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, 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<Args extends unknown[], Success, Failure, Requirements>(
operation: string,
command: (...args: [...Args, ResticDeps]) => Effect.Effect<Success, Failure, Requirements>,
deps: ResticDeps,
): (...args: Args) => Effect.Effect<Success, Failure, Requirements> {
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) => ({ export const createRestic = (deps: ResticDeps) => ({
init: withDeps(init, deps), init: withDeps("restic.init", init, deps),
keyAdd: withDeps(keyAdd, deps), keyAdd: withDeps("restic.keyAdd", keyAdd, deps),
backup: withDeps(backup, deps), backup: withDeps("restic.backup", backup, deps),
restore: withDeps(restore, deps), restore: withDeps("restic.restore", restore, deps),
dump: withDeps(dump, deps), dump: withDeps("restic.dump", dump, deps),
snapshots: withDeps(snapshots, deps), snapshots: withDeps("restic.snapshots", snapshots, deps),
stats: withDeps(stats, deps), stats: withDeps("restic.stats", stats, deps),
forget: withDeps(forget, deps), forget: withDeps("restic.forget", forget, deps),
deleteSnapshot: withDeps(deleteSnapshot, deps), deleteSnapshot: withDeps("restic.deleteSnapshot", deleteSnapshot, deps),
deleteSnapshots: withDeps(deleteSnapshots, deps), deleteSnapshots: withDeps("restic.deleteSnapshots", deleteSnapshots, deps),
tagSnapshots: withDeps(tagSnapshots, deps), tagSnapshots: withDeps("restic.tagSnapshots", tagSnapshots, deps),
unlock: withDeps(unlock, deps), unlock: withDeps("restic.unlock", unlock, deps),
ls: withDeps(ls, deps), ls: withDeps("restic.ls", ls, deps),
check: withDeps(check, deps), check: withDeps("restic.check", check, deps),
repairIndex: withDeps(repairIndex, deps), repairIndex: withDeps("restic.repairIndex", repairIndex, deps),
copy: withDeps(copy, deps), copy: withDeps("restic.copy", copy, deps),
}); });