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