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