refactor(restic): auto try to unlock and remove stale locks (#926)
This commit is contained in:
parent
d4436b0cdc
commit
0a2c6bca0c
25 changed files with 415 additions and 56 deletions
|
|
@ -377,6 +377,47 @@ describe("backup execution - validation failures", () => {
|
||||||
expect(updatedSchedule.lastBackupStatus).toBe("error");
|
expect(updatedSchedule.lastBackupStatus).toBe("error");
|
||||||
expect(updatedSchedule.lastBackupError).toBe("Local backup agent is not connected");
|
expect(updatedSchedule.lastBackupError).toBe("Local backup agent is not connected");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("removes stale locks and retries once when the local backup fallback hits a restic lock", async () => {
|
||||||
|
const { resticBackupMock, runBackupMock } = setup();
|
||||||
|
config.flags.enableLocalAgent = false;
|
||||||
|
const safeExecMock = vi.spyOn(spawnModule, "safeExec").mockResolvedValue({
|
||||||
|
exitCode: 0,
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
timedOut: false,
|
||||||
|
});
|
||||||
|
const volume = await createTestVolume();
|
||||||
|
const repository = await createTestRepository();
|
||||||
|
const schedule = await createTestBackupSchedule({
|
||||||
|
volumeId: volume.id,
|
||||||
|
repositoryId: repository.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
runBackupMock.mockResolvedValueOnce({
|
||||||
|
status: "unavailable",
|
||||||
|
error: new Error("Local backup agent is not connected"),
|
||||||
|
});
|
||||||
|
resticBackupMock
|
||||||
|
.mockImplementationOnce((params: SafeSpawnParams) => {
|
||||||
|
params.onStderr?.("unable to create lock in backend: repository is already locked");
|
||||||
|
return Promise.resolve({
|
||||||
|
exitCode: 11,
|
||||||
|
summary: "",
|
||||||
|
error: "unable to create lock in backend: repository is already locked",
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.mockImplementationOnce(() => Promise.resolve({ exitCode: 0, summary: generateBackupOutput(), error: "" }));
|
||||||
|
|
||||||
|
await backupsService.executeBackup(schedule.id);
|
||||||
|
|
||||||
|
const updatedSchedule = await getScheduleByIdOrShortId(schedule.id);
|
||||||
|
expect(updatedSchedule.lastBackupStatus).toBe("success");
|
||||||
|
expect(resticBackupMock).toHaveBeenCalledTimes(2);
|
||||||
|
const unlockCalls = safeExecMock.mock.calls.filter(([params]) => params.args?.includes("unlock"));
|
||||||
|
expect(unlockCalls).toHaveLength(1);
|
||||||
|
expect(unlockCalls[0]?.[0].args).not.toContain("--remove-all");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("backup execution - routing", () => {
|
describe("backup execution - routing", () => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { HttpError } from "http-errors-enhanced";
|
import { HttpError } from "http-errors-enhanced";
|
||||||
import { sanitizeSensitiveData } from "@zerobyte/core/node";
|
import { sanitizeSensitiveData } from "@zerobyte/core/node";
|
||||||
import { ResticError } from "@zerobyte/core/restic";
|
import { isResticError } from "@zerobyte/core/restic";
|
||||||
import { toErrorDetails as getErrorDetails, toMessage as getMessage } from "@zerobyte/core/utils";
|
import { toErrorDetails as getErrorDetails, toMessage as getMessage } from "@zerobyte/core/utils";
|
||||||
import { Cause, Effect, Exit, Option } from "effect";
|
import { Cause, Effect, Exit, Option } from "effect";
|
||||||
|
|
||||||
|
|
@ -31,7 +31,7 @@ export const handleServiceError = (error: unknown) => {
|
||||||
return { message: sanitizeSensitiveData(error.message), status: error.statusCode };
|
return { message: sanitizeSensitiveData(error.message), status: error.statusCode };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof ResticError) {
|
if (isResticError(error)) {
|
||||||
return {
|
return {
|
||||||
message: sanitizeSensitiveData(error.summary),
|
message: sanitizeSensitiveData(error.summary),
|
||||||
details: error.details ? sanitizeSensitiveData(error.details) : undefined,
|
details: error.details ? sanitizeSensitiveData(error.details) : undefined,
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ Repository status is stored metadata inside Zerobyte. It is not a continuous liv
|
||||||
| Action | What it does | What it does **not** do |
|
| Action | What it does | What it does **not** do |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `Run doctor` | Runs `unlock`, then `check`, then `repair-index` if restic says it is needed. Saves step output into the Doctor Report. | It does **not** run a full `check --read-data` scrub, it does **not** prune unused data, and it does **not** refresh the stats card. |
|
| `Run doctor` | Runs `unlock`, then `check`, then `repair-index` if restic says it is needed. Saves step output into the Doctor Report. | It does **not** run a full `check --read-data` scrub, it does **not** prune unused data, and it does **not** refresh the stats card. |
|
||||||
| `Unlock` | Runs `restic unlock --remove-all` to clear stale repository locks. | It does **not** validate repository integrity and it does **not** change the stored health status by itself. |
|
| `Unlock` | Runs `restic unlock` to clear stale repository locks. | It does **not** validate repository integrity and it does **not** change the stored health status by itself. |
|
||||||
| Stats refresh button | Runs `restic stats --mode raw-data` and updates stored size and snapshot metrics. | It does **not** change repository health status, **Last Checked**, or the Doctor Report. |
|
| Stats refresh button | Runs `restic stats --mode raw-data` and updates stored size and snapshot metrics. | It does **not** change repository health status, **Last Checked**, or the Doctor Report. |
|
||||||
|
|
||||||
## Run doctor in detail
|
## Run doctor in detail
|
||||||
|
|
|
||||||
169
packages/core/src/restic/__tests__/server-lock-recovery.test.ts
Normal file
169
packages/core/src/restic/__tests__/server-lock-recovery.test.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
import { Effect } from "effect";
|
||||||
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import * as nodeModule from "../../node";
|
||||||
|
import * as cleanupModule from "../helpers/cleanup-temporary-keys";
|
||||||
|
import { createRestic } from "../server";
|
||||||
|
import type { ResticDeps } from "../types";
|
||||||
|
|
||||||
|
const mockDeps: ResticDeps = {
|
||||||
|
resolveSecret: async (s) => s,
|
||||||
|
getOrganizationResticPassword: async () => "org-restic-password",
|
||||||
|
resticCacheDir: "/tmp/restic-cache",
|
||||||
|
resticPassFile: "/tmp/restic.pass",
|
||||||
|
defaultExcludes: ["/tmp/restic.pass", "/var/lib/zerobyte/repositories"],
|
||||||
|
rcloneConfigFile: "/root/.config/rclone/rclone.conf",
|
||||||
|
};
|
||||||
|
|
||||||
|
const repositoryConfig = {
|
||||||
|
backend: "local" as const,
|
||||||
|
path: "/tmp/restic-repo",
|
||||||
|
isExistingRepository: true,
|
||||||
|
customPassword: "custom-password",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sourceRepositoryConfig = {
|
||||||
|
backend: "local" as const,
|
||||||
|
path: "/tmp/source-restic-repo",
|
||||||
|
isExistingRepository: true,
|
||||||
|
customPassword: "source-password",
|
||||||
|
};
|
||||||
|
|
||||||
|
const destinationRepositoryConfig = {
|
||||||
|
backend: "local" as const,
|
||||||
|
path: "/tmp/destination-restic-repo",
|
||||||
|
isExistingRepository: true,
|
||||||
|
customPassword: "destination-password",
|
||||||
|
};
|
||||||
|
|
||||||
|
const backupSummary = JSON.stringify({
|
||||||
|
message_type: "summary",
|
||||||
|
files_new: 10,
|
||||||
|
files_changed: 5,
|
||||||
|
files_unmodified: 85,
|
||||||
|
dirs_new: 2,
|
||||||
|
dirs_changed: 1,
|
||||||
|
dirs_unmodified: 17,
|
||||||
|
data_blobs: 20,
|
||||||
|
tree_blobs: 5,
|
||||||
|
data_added: 1_048_576,
|
||||||
|
total_files_processed: 100,
|
||||||
|
total_bytes_processed: 2_097_152,
|
||||||
|
total_duration: 12.34,
|
||||||
|
snapshot_id: "abcd1234",
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("restic command lock recovery", () => {
|
||||||
|
test("runs stale-only unlock and retries once after a lock failure", async () => {
|
||||||
|
vi.spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
|
||||||
|
const safeExecMock = vi.spyOn(nodeModule, "safeExec").mockResolvedValue({
|
||||||
|
exitCode: 0,
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
timedOut: false,
|
||||||
|
});
|
||||||
|
const safeSpawnMock = vi
|
||||||
|
.spyOn(nodeModule, "safeSpawn")
|
||||||
|
.mockImplementationOnce(async ({ onStderr }) => {
|
||||||
|
onStderr?.("unable to create lock in backend: repository is already locked");
|
||||||
|
return {
|
||||||
|
exitCode: 11,
|
||||||
|
summary: "",
|
||||||
|
error: "unable to create lock in backend: repository is already locked",
|
||||||
|
stderr: "",
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
exitCode: 0,
|
||||||
|
summary: backupSummary,
|
||||||
|
error: "",
|
||||||
|
stderr: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const restic = createRestic(mockDeps);
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
restic.backup(repositoryConfig, "/tmp/source", { organizationId: "org-1" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
expect(safeSpawnMock).toHaveBeenCalledTimes(2);
|
||||||
|
const unlockCalls = safeExecMock.mock.calls.filter(([params]) => params.args?.includes("unlock"));
|
||||||
|
expect(unlockCalls).toHaveLength(1);
|
||||||
|
expect(unlockCalls[0]?.[0].args).not.toContain("--remove-all");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("propagates the retry error when the retry is still locked", async () => {
|
||||||
|
vi.spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
|
||||||
|
const safeExecMock = vi.spyOn(nodeModule, "safeExec").mockResolvedValue({
|
||||||
|
exitCode: 0,
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
timedOut: false,
|
||||||
|
});
|
||||||
|
vi.spyOn(nodeModule, "safeSpawn").mockImplementation(async ({ onStderr }) => {
|
||||||
|
onStderr?.("unable to create lock in backend: repository is already locked");
|
||||||
|
return {
|
||||||
|
exitCode: 11,
|
||||||
|
summary: "",
|
||||||
|
error: "unable to create lock in backend: repository is already locked",
|
||||||
|
stderr: "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const restic = createRestic(mockDeps);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
Effect.runPromise(restic.backup(repositoryConfig, "/tmp/source", { organizationId: "org-1" })),
|
||||||
|
).rejects.toThrow("unable to create lock in backend: repository is already locked");
|
||||||
|
const unlockCalls = safeExecMock.mock.calls.filter(([params]) => params.args?.includes("unlock"));
|
||||||
|
expect(unlockCalls).toHaveLength(1);
|
||||||
|
expect(unlockCalls[0]?.[0].args).not.toContain("--remove-all");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runs stale-only unlock for both repositories before retrying copy", async () => {
|
||||||
|
vi.spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
|
||||||
|
let copyCalls = 0;
|
||||||
|
const safeExecMock = vi.spyOn(nodeModule, "safeExec").mockImplementation(async ({ args }) => {
|
||||||
|
if (args?.includes("copy")) {
|
||||||
|
copyCalls += 1;
|
||||||
|
return copyCalls === 1
|
||||||
|
? {
|
||||||
|
exitCode: 11,
|
||||||
|
stdout: "",
|
||||||
|
stderr: "unable to create lock in backend: repository is already locked",
|
||||||
|
timedOut: false,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
exitCode: 0,
|
||||||
|
stdout: "copied",
|
||||||
|
stderr: "",
|
||||||
|
timedOut: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
exitCode: 0,
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
timedOut: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const restic = createRestic(mockDeps);
|
||||||
|
await Effect.runPromise(
|
||||||
|
restic.copy(sourceRepositoryConfig, destinationRepositoryConfig, {
|
||||||
|
organizationId: "org-1",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const unlockCalls = safeExecMock.mock.calls.filter(([params]) => params.args?.includes("unlock"));
|
||||||
|
expect(copyCalls).toBe(2);
|
||||||
|
expect(unlockCalls).toHaveLength(2);
|
||||||
|
for (const [params] of unlockCalls) {
|
||||||
|
expect(params.args).not.toContain("--remove-all");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
73
packages/core/src/restic/commands/__tests__/unlock.test.ts
Normal file
73
packages/core/src/restic/commands/__tests__/unlock.test.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { Effect } from "effect";
|
||||||
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import * as nodeModule from "../../../node";
|
||||||
|
import { ResticLockError } from "../../error";
|
||||||
|
import * as cleanupModule from "../../helpers/cleanup-temporary-keys";
|
||||||
|
import type { ResticDeps } from "../../types";
|
||||||
|
import { unlock } from "../unlock";
|
||||||
|
|
||||||
|
const mockDeps: ResticDeps = {
|
||||||
|
resolveSecret: async (s) => s,
|
||||||
|
getOrganizationResticPassword: async () => "org-restic-password",
|
||||||
|
resticCacheDir: "/tmp/restic-cache",
|
||||||
|
resticPassFile: "/tmp/restic.pass",
|
||||||
|
defaultExcludes: ["/tmp/restic.pass", "/var/lib/zerobyte/repositories"],
|
||||||
|
rcloneConfigFile: "/root/.config/rclone/rclone.conf",
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
backend: "local" as const,
|
||||||
|
path: "/tmp/restic-repo",
|
||||||
|
isExistingRepository: true,
|
||||||
|
customPassword: "custom-password",
|
||||||
|
};
|
||||||
|
|
||||||
|
const setup = () => {
|
||||||
|
let capturedArgs: string[] = [];
|
||||||
|
|
||||||
|
vi.spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
|
||||||
|
vi.spyOn(nodeModule, "safeExec").mockImplementation(async ({ args }) => {
|
||||||
|
capturedArgs = args ?? [];
|
||||||
|
return { exitCode: 0, stdout: "", stderr: "", timedOut: false };
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
getArgs: () => capturedArgs,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unlock command", () => {
|
||||||
|
test("removes only stale locks by default", async () => {
|
||||||
|
const { getArgs } = setup();
|
||||||
|
|
||||||
|
await Effect.runPromise(unlock(config, { organizationId: "org-1" }, mockDeps));
|
||||||
|
|
||||||
|
expect(getArgs()).toContain("unlock");
|
||||||
|
expect(getArgs()).not.toContain("--remove-all");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can opt in to removing all locks", async () => {
|
||||||
|
const { getArgs } = setup();
|
||||||
|
|
||||||
|
await Effect.runPromise(unlock(config, { organizationId: "org-1", removeAll: true }, mockDeps));
|
||||||
|
|
||||||
|
expect(getArgs()).toContain("--remove-all");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns a typed lock error for restic lock failures", async () => {
|
||||||
|
setup();
|
||||||
|
vi.spyOn(nodeModule, "safeExec").mockResolvedValueOnce({
|
||||||
|
exitCode: 11,
|
||||||
|
stdout: "",
|
||||||
|
stderr: "unable to create lock in backend: repository is already locked",
|
||||||
|
timedOut: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = await Effect.runPromise(Effect.flip(unlock(config, { organizationId: "org-1" }, mockDeps)));
|
||||||
|
expect(error).toBeInstanceOf(ResticLockError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -10,7 +10,7 @@ import { buildEnv } from "../helpers/build-env";
|
||||||
import { buildRepoUrl } from "../helpers/build-repo-url";
|
import { buildRepoUrl } from "../helpers/build-repo-url";
|
||||||
import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
|
import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
|
||||||
import { validateCustomResticParams } from "../helpers/validate-custom-params";
|
import { validateCustomResticParams } from "../helpers/validate-custom-params";
|
||||||
import { ResticError } from "../error";
|
import { createResticError, isResticError } from "../error";
|
||||||
import { logger, safeSpawn } from "../../node";
|
import { logger, safeSpawn } from "../../node";
|
||||||
import type { ResticDeps } from "../types";
|
import type { ResticDeps } from "../types";
|
||||||
import { toMessage } from "../../utils";
|
import { toMessage } from "../../utils";
|
||||||
|
|
@ -190,7 +190,7 @@ export const backup = (
|
||||||
logger.error(`Restic backup failed: ${res.error}`);
|
logger.error(`Restic backup failed: ${res.error}`);
|
||||||
logger.error(`Command executed: restic ${args.join(" ")}`);
|
logger.error(`Command executed: restic ${args.join(" ")}`);
|
||||||
|
|
||||||
throw new ResticError(res.exitCode, stderrLines.join("\n") || res.stderr || res.error);
|
throw createResticError(res.exitCode, stderrLines.join("\n") || res.stderr || res.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastLine = res.summary.trim();
|
const lastLine = res.summary.trim();
|
||||||
|
|
@ -221,7 +221,7 @@ export const backup = (
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
catch: (error) => {
|
catch: (error) => {
|
||||||
if (error instanceof ResticError) {
|
if (isResticError(error)) {
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ 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 { isResticError } from "../error";
|
||||||
import { toMessage } from "../../utils";
|
import { toMessage } from "../../utils";
|
||||||
|
|
||||||
class ResticCheckCommandError extends Data.TaggedError("ResticCheckCommandError")<{
|
class ResticCheckCommandError extends Data.TaggedError("ResticCheckCommandError")<{
|
||||||
|
|
@ -77,7 +77,7 @@ export const check = (
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
catch: (error) => {
|
catch: (error) => {
|
||||||
if (error instanceof ResticError) {
|
if (isResticError(error)) {
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { buildEnv } from "../helpers/build-env";
|
||||||
import { buildRepoUrl } from "../helpers/build-repo-url";
|
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 { ResticError } from "../error";
|
import { createResticError, isResticError } 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 { Data, Effect } from "effect";
|
||||||
|
|
@ -72,7 +72,7 @@ export const copy = (
|
||||||
|
|
||||||
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 createResticError(res.exitCode, stderr);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Restic copy completed from ${sourceRepoUrl} to ${destRepoUrl}`);
|
logger.info(`Restic copy completed from ${sourceRepoUrl} to ${destRepoUrl}`);
|
||||||
|
|
@ -82,7 +82,7 @@ export const copy = (
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
catch: (error) => {
|
catch: (error) => {
|
||||||
if (error instanceof ResticError) {
|
if (isResticError(error)) {
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Data, Effect } from "effect";
|
import { Data, Effect } from "effect";
|
||||||
import { logger, safeExec } from "../../node";
|
import { logger, safeExec } from "../../node";
|
||||||
import { ResticError } from "../error";
|
import { createResticError, isResticError } from "../error";
|
||||||
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";
|
||||||
|
|
@ -38,13 +38,13 @@ export const deleteSnapshots = (
|
||||||
|
|
||||||
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 createResticError(res.exitCode, res.stderr);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
catch: (error) => {
|
catch: (error) => {
|
||||||
if (error instanceof ResticError) {
|
if (isResticError(error)) {
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ 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 { logger, safeSpawn } from "../../node";
|
import { logger, safeSpawn } from "../../node";
|
||||||
import { ResticError } from "../error";
|
import { createResticError, isResticError } from "../error";
|
||||||
import { Data, Effect } from "effect";
|
import { Data, Effect } from "effect";
|
||||||
import { toMessage } from "../../utils";
|
import { toMessage } from "../../utils";
|
||||||
|
|
||||||
|
|
@ -94,7 +94,7 @@ export const dump = (
|
||||||
|
|
||||||
const stderr = stderrTail.trim() || result.error;
|
const stderr = stderrTail.trim() || result.error;
|
||||||
logger.error(`Restic dump failed: ${stderr}`);
|
logger.error(`Restic dump failed: ${stderr}`);
|
||||||
throw new ResticError(result.exitCode, stderr);
|
throw createResticError(result.exitCode, stderr);
|
||||||
})
|
})
|
||||||
.finally(async () => {
|
.finally(async () => {
|
||||||
abortController = null;
|
abortController = null;
|
||||||
|
|
@ -120,7 +120,7 @@ export const dump = (
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
catch: (error) => {
|
catch: (error) => {
|
||||||
if (error instanceof ResticError) {
|
if (isResticError(error)) {
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ 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 { logger, safeExec } from "../../node";
|
import { logger, safeExec } from "../../node";
|
||||||
import { ResticError } from "../error";
|
import { createResticError, isResticError } from "../error";
|
||||||
import { toMessage } from "../../utils";
|
import { toMessage } from "../../utils";
|
||||||
import { Data, Effect } from "effect";
|
import { Data, Effect } from "effect";
|
||||||
|
|
||||||
|
|
@ -65,7 +65,7 @@ export const forget = (
|
||||||
|
|
||||||
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 createResticError(res.exitCode, res.stderr);
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = res.stdout.split("\n").filter((line) => line.trim());
|
const lines = res.stdout.split("\n").filter((line) => line.trim());
|
||||||
|
|
@ -74,7 +74,7 @@ export const forget = (
|
||||||
return { success: true, data: result };
|
return { success: true, data: result };
|
||||||
},
|
},
|
||||||
catch: (error) => {
|
catch: (error) => {
|
||||||
if (error instanceof ResticError) {
|
if (isResticError(error)) {
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ 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 { Data, Effect } from "effect";
|
||||||
import { ResticError } from "../error";
|
import { isResticError } from "../error";
|
||||||
import { toMessage } from "../../utils";
|
import { toMessage } from "../../utils";
|
||||||
|
|
||||||
class ResticInitCommandError extends Data.TaggedError("ResticInitCommandError")<{
|
class ResticInitCommandError extends Data.TaggedError("ResticInitCommandError")<{
|
||||||
|
|
@ -70,7 +70,7 @@ export const init = (
|
||||||
return { success: true, error: null };
|
return { success: true, error: null };
|
||||||
},
|
},
|
||||||
catch: (error) => {
|
catch: (error) => {
|
||||||
if (error instanceof ResticError) {
|
if (isResticError(error)) {
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ 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 { toMessage } from "../../utils";
|
||||||
import { ResticError } from "../error";
|
import { isResticError } from "../error";
|
||||||
|
|
||||||
class ResticKeyAddCommandError extends Data.TaggedError("ResticKeyAddCommandError")<{
|
class ResticKeyAddCommandError extends Data.TaggedError("ResticKeyAddCommandError")<{
|
||||||
cause: unknown;
|
cause: unknown;
|
||||||
|
|
@ -52,7 +52,7 @@ export const keyAdd = (
|
||||||
return { success: true, error: null };
|
return { success: true, error: null };
|
||||||
},
|
},
|
||||||
catch: (error) => {
|
catch: (error) => {
|
||||||
if (error instanceof ResticError) {
|
if (isResticError(error)) {
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ 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 { logger, safeSpawn } from "../../node";
|
import { logger, safeSpawn } from "../../node";
|
||||||
import { ResticError } from "../error";
|
import { createResticError, isResticError } from "../error";
|
||||||
import type { ResticDeps } from "../types";
|
import type { ResticDeps } from "../types";
|
||||||
import { Data, Effect } from "effect";
|
import { Data, Effect } from "effect";
|
||||||
import { toMessage } from "../../utils";
|
import { toMessage } from "../../utils";
|
||||||
|
|
@ -134,7 +134,7 @@ export const ls = (
|
||||||
|
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode !== 0) {
|
||||||
logger.error(`Restic ls failed: ${res.error}`);
|
logger.error(`Restic ls failed: ${res.error}`);
|
||||||
throw new ResticError(res.exitCode, res.stderr || res.error);
|
throw createResticError(res.exitCode, res.stderr || res.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (totalNodes > offset + limit) {
|
if (totalNodes > offset + limit) {
|
||||||
|
|
@ -153,7 +153,7 @@ export const ls = (
|
||||||
} as ResticLsResult;
|
} as ResticLsResult;
|
||||||
},
|
},
|
||||||
catch: (error) => {
|
catch: (error) => {
|
||||||
if (error instanceof ResticError) {
|
if (isResticError(error)) {
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Data, Effect } from "effect";
|
import { Data, Effect } from "effect";
|
||||||
import { logger, safeExec } from "../../node";
|
import { logger, safeExec } from "../../node";
|
||||||
import { ResticError } from "../error";
|
import { createResticError, isResticError } from "../error";
|
||||||
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";
|
||||||
|
|
@ -44,7 +44,7 @@ export const repairIndex = (
|
||||||
|
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode !== 0) {
|
||||||
logger.error(`Restic repair index failed: ${stderr}`);
|
logger.error(`Restic repair index failed: ${stderr}`);
|
||||||
throw new ResticError(res.exitCode, stderr);
|
throw createResticError(res.exitCode, stderr);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Restic repair index completed for repository: ${repoUrl}`);
|
logger.info(`Restic repair index completed for repository: ${repoUrl}`);
|
||||||
|
|
@ -55,7 +55,7 @@ export const repairIndex = (
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
catch: (error) => {
|
catch: (error) => {
|
||||||
if (error instanceof ResticError) {
|
if (isResticError(error)) {
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { buildRepoUrl } from "../helpers/build-repo-url";
|
||||||
import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
|
import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
|
||||||
import { type RepositoryConfig, type OverwriteMode } from "../schemas";
|
import { type RepositoryConfig, type OverwriteMode } from "../schemas";
|
||||||
import { logger, safeSpawn } from "../../node";
|
import { logger, safeSpawn } from "../../node";
|
||||||
import { ResticError } from "../error";
|
import { createResticError, isResticError } 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 { Data, Effect } from "effect";
|
||||||
|
|
@ -147,7 +147,7 @@ export const restore = (
|
||||||
|
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode !== 0) {
|
||||||
logger.error(`Restic restore failed: ${res.error}`);
|
logger.error(`Restic restore failed: ${res.error}`);
|
||||||
throw new ResticError(res.exitCode, res.stderr || res.error);
|
throw createResticError(res.exitCode, res.stderr || res.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastLine = res.summary.trim();
|
const lastLine = res.summary.trim();
|
||||||
|
|
@ -183,7 +183,7 @@ export const restore = (
|
||||||
return result.data;
|
return result.data;
|
||||||
},
|
},
|
||||||
catch: (error) => {
|
catch: (error) => {
|
||||||
if (error instanceof ResticError) {
|
if (isResticError(error)) {
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { logger, safeSpawn } from "../../node";
|
||||||
import type { ResticDeps } from "../types";
|
import type { ResticDeps } from "../types";
|
||||||
import { Data, Effect } from "effect";
|
import { Data, Effect } from "effect";
|
||||||
import { toMessage } from "../../utils";
|
import { toMessage } from "../../utils";
|
||||||
import { ResticError } from "../error";
|
import { isResticError } from "../error";
|
||||||
|
|
||||||
class ResticSnapshotsCommandError extends Data.TaggedError("ResticSnapshotsCommandError")<{
|
class ResticSnapshotsCommandError extends Data.TaggedError("ResticSnapshotsCommandError")<{
|
||||||
cause: unknown;
|
cause: unknown;
|
||||||
|
|
@ -81,7 +81,7 @@ export const snapshots = (
|
||||||
},
|
},
|
||||||
|
|
||||||
catch: (error) => {
|
catch: (error) => {
|
||||||
if (error instanceof ResticError) {
|
if (isResticError(error)) {
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ 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 { logger, safeExec } from "../../node";
|
import { logger, safeExec } from "../../node";
|
||||||
import { ResticError } from "../error";
|
import { createResticError, isResticError } 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 { Data, Effect } from "effect";
|
||||||
|
|
@ -30,7 +30,7 @@ export const stats = (config: RepositoryConfig, options: { organizationId: strin
|
||||||
|
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode !== 0) {
|
||||||
logger.error(`Restic stats retrieval failed: ${res.stderr}`);
|
logger.error(`Restic stats retrieval failed: ${res.stderr}`);
|
||||||
throw new ResticError(res.exitCode, res.stderr);
|
throw createResticError(res.exitCode, res.stderr);
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedJson = safeJsonParse<unknown>(res.stdout);
|
const parsedJson = safeJsonParse<unknown>(res.stdout);
|
||||||
|
|
@ -44,7 +44,7 @@ export const stats = (config: RepositoryConfig, options: { organizationId: strin
|
||||||
return result.data;
|
return result.data;
|
||||||
},
|
},
|
||||||
catch: (error) => {
|
catch: (error) => {
|
||||||
if (error instanceof ResticError) {
|
if (isResticError(error)) {
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Data, Effect } from "effect";
|
import { Data, Effect } from "effect";
|
||||||
import { logger, safeExec } from "../../node";
|
import { logger, safeExec } from "../../node";
|
||||||
import { ResticError } from "../error";
|
import { createResticError, isResticError } from "../error";
|
||||||
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";
|
||||||
|
|
@ -58,14 +58,14 @@ export const tagSnapshots = (
|
||||||
|
|
||||||
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 createResticError(res.exitCode, res.stderr);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
catch: (error) => {
|
catch: (error) => {
|
||||||
if (error instanceof ResticError) {
|
if (isResticError(error)) {
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Data, Effect } from "effect";
|
import { Data, Effect } from "effect";
|
||||||
import { logger, safeExec } from "../../node";
|
import { logger, safeExec } from "../../node";
|
||||||
import { ResticError } from "../error";
|
import { createResticError, isResticError } from "../error";
|
||||||
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";
|
||||||
|
|
@ -16,7 +16,7 @@ class ResticUnlockCommandError extends Data.TaggedError("ResticUnlockCommandErro
|
||||||
|
|
||||||
export const unlock = (
|
export const unlock = (
|
||||||
config: RepositoryConfig,
|
config: RepositoryConfig,
|
||||||
options: { signal?: AbortSignal; organizationId: string },
|
options: { signal?: AbortSignal; organizationId: string; removeAll?: boolean },
|
||||||
deps: ResticDeps,
|
deps: ResticDeps,
|
||||||
) => {
|
) => {
|
||||||
return Effect.tryPromise({
|
return Effect.tryPromise({
|
||||||
|
|
@ -24,7 +24,10 @@ export const unlock = (
|
||||||
const repoUrl = buildRepoUrl(config);
|
const repoUrl = buildRepoUrl(config);
|
||||||
const env = await buildEnv(config, options.organizationId, deps);
|
const env = await buildEnv(config, options.organizationId, deps);
|
||||||
|
|
||||||
const args = ["unlock", "--repo", repoUrl, "--remove-all"];
|
const args = ["unlock", "--repo", repoUrl];
|
||||||
|
if (options.removeAll) {
|
||||||
|
args.push("--remove-all");
|
||||||
|
}
|
||||||
addCommonArgs(args, env, config);
|
addCommonArgs(args, env, config);
|
||||||
|
|
||||||
const res = await safeExec({
|
const res = await safeExec({
|
||||||
|
|
@ -42,14 +45,14 @@ export const unlock = (
|
||||||
|
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode !== 0) {
|
||||||
logger.error(`Restic unlock failed: ${res.stderr}`);
|
logger.error(`Restic unlock failed: ${res.stderr}`);
|
||||||
throw new ResticError(res.exitCode, res.stderr);
|
throw createResticError(res.exitCode, res.stderr);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Restic unlock succeeded for repository: ${repoUrl}`);
|
logger.info(`Restic unlock succeeded for repository: ${repoUrl}`);
|
||||||
return { success: true, message: "Repository unlocked successfully" };
|
return { success: true, message: "Repository unlocked successfully" };
|
||||||
},
|
},
|
||||||
catch: (error) => {
|
catch: (error) => {
|
||||||
if (error instanceof ResticError) {
|
if (isResticError(error)) {
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { Data } from "effect";
|
||||||
|
|
||||||
const resticErrorCodes: Record<number, string> = {
|
const resticErrorCodes: Record<number, string> = {
|
||||||
1: "Command failed: An error occurred while executing the command.",
|
1: "Command failed: An error occurred while executing the command.",
|
||||||
2: "Go runtime error: A runtime error occurred in the Go program.",
|
2: "Go runtime error: A runtime error occurred in the Go program.",
|
||||||
|
|
@ -23,3 +25,34 @@ export class ResticError extends Error {
|
||||||
this.name = "ResticError";
|
this.name = "ResticError";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ResticLockError extends Data.TaggedError("ResticLockError")<{
|
||||||
|
code: number;
|
||||||
|
summary: string;
|
||||||
|
details: string;
|
||||||
|
message: string;
|
||||||
|
}> {
|
||||||
|
constructor(details: string) {
|
||||||
|
const summary = resticErrorCodes[11]!;
|
||||||
|
super({
|
||||||
|
code: 11,
|
||||||
|
summary,
|
||||||
|
details,
|
||||||
|
message: details ? `${summary}\n${details}` : summary,
|
||||||
|
});
|
||||||
|
this.name = "ResticLockError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AnyResticError = ResticError | ResticLockError;
|
||||||
|
|
||||||
|
export const isResticError = (error: unknown): error is AnyResticError =>
|
||||||
|
error instanceof ResticError || error instanceof ResticLockError;
|
||||||
|
|
||||||
|
export const createResticError = (code: number, details: string) => {
|
||||||
|
if (code === 11) {
|
||||||
|
return new ResticLockError(details);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ResticError(code, details);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
export * from "./schemas";
|
export * from "./schemas";
|
||||||
export * from "./restic-dto";
|
export * from "./restic-dto";
|
||||||
export { ResticError } from "./error";
|
export { isResticError, ResticError, ResticLockError } from "./error";
|
||||||
|
|
||||||
export type { RestoreProgress } from "./commands/restore";
|
export type { RestoreProgress } from "./commands/restore";
|
||||||
export type {
|
export type {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { logger, safeExec } from "../node";
|
import { logger, safeExec } from "../node";
|
||||||
import { toMessage } from "../utils";
|
import { toMessage } from "../utils";
|
||||||
import { ResticError } from "./error";
|
import { ResticLockError } from "./error";
|
||||||
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";
|
||||||
|
|
@ -26,7 +26,7 @@ const LOCK_ERROR_PATTERNS = [
|
||||||
];
|
];
|
||||||
|
|
||||||
export const isResticLockFailure = (error: unknown) => {
|
export const isResticLockFailure = (error: unknown) => {
|
||||||
if (error instanceof ResticError && error.code === 11) {
|
if (error instanceof ResticLockError) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Effect } from "effect";
|
import { Effect } from "effect";
|
||||||
|
import { logger } from "../node";
|
||||||
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";
|
||||||
|
|
@ -14,6 +15,7 @@ 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 { ResticLockError } from "./error";
|
||||||
import { logResticLockFailureDiagnostics } from "./lock-diagnostics";
|
import { logResticLockFailureDiagnostics } from "./lock-diagnostics";
|
||||||
import type { RepositoryConfig } from "./schemas";
|
import type { RepositoryConfig } from "./schemas";
|
||||||
import type { ResticDeps } from "./types";
|
import type { ResticDeps } from "./types";
|
||||||
|
|
@ -24,7 +26,7 @@ export { buildRepoUrl } from "./helpers/build-repo-url";
|
||||||
export { cleanupTemporaryKeys } from "./helpers/cleanup-temporary-keys";
|
export { cleanupTemporaryKeys } from "./helpers/cleanup-temporary-keys";
|
||||||
export { validateCustomResticParams } from "./helpers/validate-custom-params";
|
export { validateCustomResticParams } from "./helpers/validate-custom-params";
|
||||||
export { isResticLockFailure, logResticLockFailureDiagnostics } from "./lock-diagnostics";
|
export { isResticLockFailure, logResticLockFailureDiagnostics } from "./lock-diagnostics";
|
||||||
export { ResticError } from "./error";
|
export { isResticError, ResticError, ResticLockError } from "./error";
|
||||||
|
|
||||||
type LockDiagnosticCommandContext = {
|
type LockDiagnosticCommandContext = {
|
||||||
repositoryConfig: RepositoryConfig;
|
repositoryConfig: RepositoryConfig;
|
||||||
|
|
@ -34,6 +36,12 @@ type LockDiagnosticCommandContext = {
|
||||||
|
|
||||||
type ResticCommandOptions = { organizationId: string };
|
type ResticCommandOptions = { organizationId: string };
|
||||||
type ResticCommandResult = { error?: unknown };
|
type ResticCommandResult = { error?: unknown };
|
||||||
|
type ResticCommandFailure<Failure> = Failure | ResticLockError;
|
||||||
|
type RunResticCommand<Success, Failure, Requirements> = () => Effect.Effect<
|
||||||
|
Success,
|
||||||
|
ResticCommandFailure<Failure>,
|
||||||
|
Requirements
|
||||||
|
>;
|
||||||
|
|
||||||
const getCommandContext = (operation: string, args: unknown[]): LockDiagnosticCommandContext => {
|
const getCommandContext = (operation: string, args: unknown[]): LockDiagnosticCommandContext => {
|
||||||
const firstRepositoryConfig = args[0] as RepositoryConfig;
|
const firstRepositoryConfig = args[0] as RepositoryConfig;
|
||||||
|
|
@ -68,15 +76,47 @@ const logLockFailure = async (
|
||||||
relatedRepositoryConfigs: context.relatedRepositoryConfigs,
|
relatedRepositoryConfigs: context.relatedRepositoryConfigs,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const unlockStaleLocks = (context: LockDiagnosticCommandContext, deps: ResticDeps): Effect.Effect<void, Error> =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
for (const repositoryConfig of [context.repositoryConfig, ...(context.relatedRepositoryConfigs ?? [])]) {
|
||||||
|
yield* unlock(repositoryConfig, { organizationId: context.organizationId }, deps);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const recoverFromLockFailure = <Success, Failure, Requirements>(
|
||||||
|
error: ResticLockError,
|
||||||
|
operation: string,
|
||||||
|
context: LockDiagnosticCommandContext,
|
||||||
|
deps: ResticDeps,
|
||||||
|
runCommand: RunResticCommand<Success, Failure, Requirements>,
|
||||||
|
): Effect.Effect<Success, ResticCommandFailure<Failure> | Error, Requirements> =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* logger.effect.warn("Restic lock failure detected. Removing stale locks and retrying once.");
|
||||||
|
yield* Effect.promise(() => logLockFailure(error, operation, context, deps));
|
||||||
|
yield* unlockStaleLocks(context, deps);
|
||||||
|
|
||||||
|
const retryResult = yield* runCommand();
|
||||||
|
yield* logger.effect.info("Restic lock failure recovered by removing stale locks and retrying once.");
|
||||||
|
|
||||||
|
return retryResult;
|
||||||
|
});
|
||||||
|
|
||||||
function withDeps<Args extends unknown[], Success, Failure, Requirements>(
|
function withDeps<Args extends unknown[], Success, Failure, Requirements>(
|
||||||
operation: string,
|
operation: string,
|
||||||
command: (...args: [...Args, ResticDeps]) => Effect.Effect<Success, Failure, Requirements>,
|
command: (...args: [...Args, ResticDeps]) => Effect.Effect<Success, ResticCommandFailure<Failure>, Requirements>,
|
||||||
deps: ResticDeps,
|
deps: ResticDeps,
|
||||||
): (...args: Args) => Effect.Effect<Success, Failure, Requirements> {
|
): (...args: Args) => Effect.Effect<Success, ResticCommandFailure<Failure> | Error, Requirements> {
|
||||||
return (...args: Args) => {
|
return (...args: Args) => {
|
||||||
const context = getCommandContext(operation, args);
|
const context = getCommandContext(operation, args);
|
||||||
return command(...args, deps).pipe(
|
const runCommand = () => command(...args, deps);
|
||||||
Effect.tapError((error) => Effect.promise(() => logLockFailure(error, operation, context, deps))),
|
|
||||||
|
const commandEffect = runCommand().pipe(
|
||||||
|
Effect.catchTag("ResticLockError", (error) =>
|
||||||
|
recoverFromLockFailure(error as ResticLockError, operation, context, deps, runCommand),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return commandEffect.pipe(
|
||||||
Effect.tap((result) => {
|
Effect.tap((result) => {
|
||||||
const { error } = result as ResticCommandResult;
|
const { error } = result as ResticCommandResult;
|
||||||
return error ? Effect.promise(() => logLockFailure(error, operation, context, deps)) : Effect.void;
|
return error ? Effect.promise(() => logLockFailure(error, operation, context, deps)) : Effect.void;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { ResticError } from "../restic/error.js";
|
import { isResticError } from "../restic/error.js";
|
||||||
|
|
||||||
export const toMessage = (error: unknown) => {
|
export const toMessage = (error: unknown) => {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
|
|
@ -9,7 +9,7 @@ export const toMessage = (error: unknown) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const toErrorDetails = (error: unknown) => {
|
export const toErrorDetails = (error: unknown) => {
|
||||||
if (error instanceof ResticError) {
|
if (isResticError(error)) {
|
||||||
return error.details || error.summary;
|
return error.details || error.summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue