refactor: dedpulicate volume schemas across packages (#864)
This commit is contained in:
parent
2ada5acd5a
commit
aa7da321ba
32 changed files with 341 additions and 298 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
import { Cloud, Folder, Server } from "lucide-react";
|
import { Cloud, Folder, Server } from "lucide-react";
|
||||||
import type { BackendType } from "~/schemas/volumes";
|
import type { BackendType } from "@zerobyte/contracts/volumes";
|
||||||
|
|
||||||
type VolumeIconProps = {
|
type VolumeIconProps = {
|
||||||
backend: BackendType;
|
backend: BackendType;
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ import {
|
||||||
smbConfigSchema,
|
smbConfigSchema,
|
||||||
volumeConfigSchema,
|
volumeConfigSchema,
|
||||||
webdavConfigSchema,
|
webdavConfigSchema,
|
||||||
} from "~/schemas/volumes";
|
} from "@zerobyte/contracts/volumes";
|
||||||
import { testConnectionMutation } from "../../../api-client/@tanstack/react-query.gen";
|
import { testConnectionMutation } from "../../../api-client/@tanstack/react-query.gen";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
|
||||||
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import type {
|
||||||
ResticStatsDto,
|
ResticStatsDto,
|
||||||
} from "@zerobyte/core/restic";
|
} from "@zerobyte/core/restic";
|
||||||
import type { BackupWebhooks } from "@zerobyte/core/backup-hooks";
|
import type { BackupWebhooks } from "@zerobyte/core/backup-hooks";
|
||||||
import type { BackendConfig, BackendStatus, BackendType } from "~/schemas/volumes";
|
import type { BackendConfig, BackendStatus, BackendType } from "@zerobyte/contracts/volumes";
|
||||||
import type { NotificationConfig, NotificationType } from "~/schemas/notifications";
|
import type { NotificationConfig, NotificationType } from "~/schemas/notifications";
|
||||||
import type { ShortId } from "~/server/utils/branded";
|
import type { ShortId } from "~/server/utils/branded";
|
||||||
import { LOCAL_AGENT_ID } from "../modules/agents/constants";
|
import { LOCAL_AGENT_ID } from "../modules/agents/constants";
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { afterEach, expect, test, vi } from "vitest";
|
||||||
import waitForExpect from "wait-for-expect";
|
import waitForExpect from "wait-for-expect";
|
||||||
import { fromPartial } from "@total-typescript/shoehorn";
|
import { fromPartial } from "@total-typescript/shoehorn";
|
||||||
import { createAgentMessage } from "@zerobyte/contracts/agent-protocol";
|
import { createAgentMessage } from "@zerobyte/contracts/agent-protocol";
|
||||||
|
import type { Volume } from "@zerobyte/contracts/volumes";
|
||||||
import { LOCAL_AGENT_ID, LOCAL_AGENT_KIND, LOCAL_AGENT_NAME } from "../constants";
|
import { LOCAL_AGENT_ID, LOCAL_AGENT_KIND, LOCAL_AGENT_NAME } from "../constants";
|
||||||
|
|
||||||
const agentsServiceMocks = vi.hoisted(() => ({
|
const agentsServiceMocks = vi.hoisted(() => ({
|
||||||
|
|
@ -36,6 +37,22 @@ const createSocket = (id: string, agentId = LOCAL_AGENT_ID) => ({
|
||||||
close: vi.fn(),
|
close: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const backupVolume = {
|
||||||
|
id: 1,
|
||||||
|
shortId: "volume-1",
|
||||||
|
name: "Volume 1",
|
||||||
|
config: { backend: "directory", path: "/tmp" },
|
||||||
|
createdAt: 0,
|
||||||
|
updatedAt: 0,
|
||||||
|
lastHealthCheck: 0,
|
||||||
|
type: "directory",
|
||||||
|
status: "mounted" as const,
|
||||||
|
lastError: null,
|
||||||
|
autoRemount: true,
|
||||||
|
agentId: LOCAL_AGENT_ID,
|
||||||
|
organizationId: "org-1",
|
||||||
|
} satisfies Volume;
|
||||||
|
|
||||||
type CapturedFetch = NonNullable<Parameters<typeof Bun.serve>[0]["fetch"]>;
|
type CapturedFetch = NonNullable<Parameters<typeof Bun.serve>[0]["fetch"]>;
|
||||||
|
|
||||||
const invokeFetch = (fetch: CapturedFetch | undefined, request: Request, srv: Parameters<CapturedFetch>[1]) => {
|
const invokeFetch = (fetch: CapturedFetch | undefined, request: Request, srv: Parameters<CapturedFetch>[1]) => {
|
||||||
|
|
@ -223,6 +240,7 @@ test("closing a replaced connection reports disconnect without marking the activ
|
||||||
scheduleId: "schedule-1",
|
scheduleId: "schedule-1",
|
||||||
organizationId: "org-1",
|
organizationId: "org-1",
|
||||||
sourcePath: "/tmp/source",
|
sourcePath: "/tmp/source",
|
||||||
|
volume: backupVolume,
|
||||||
repositoryConfig: { backend: "local" as const, path: "/tmp/repository" },
|
repositoryConfig: { backend: "local" as const, path: "/tmp/repository" },
|
||||||
options: {},
|
options: {},
|
||||||
runtime: {
|
runtime: {
|
||||||
|
|
@ -253,6 +271,7 @@ test("sendBackup is only delivered after the agent is ready", async () => {
|
||||||
scheduleId: "schedule-1",
|
scheduleId: "schedule-1",
|
||||||
organizationId: "org-1",
|
organizationId: "org-1",
|
||||||
sourcePath: "/tmp/source",
|
sourcePath: "/tmp/source",
|
||||||
|
volume: backupVolume,
|
||||||
repositoryConfig: { backend: "local" as const, path: "/tmp/repository" },
|
repositoryConfig: { backend: "local" as const, path: "/tmp/repository" },
|
||||||
options: {},
|
options: {},
|
||||||
runtime: {
|
runtime: {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { expect, test, vi } from "vitest";
|
||||||
import waitForExpect from "wait-for-expect";
|
import waitForExpect from "wait-for-expect";
|
||||||
import { fromPartial } from "@total-typescript/shoehorn";
|
import { fromPartial } from "@total-typescript/shoehorn";
|
||||||
import { createAgentMessage, type AgentMessage } from "@zerobyte/contracts/agent-protocol";
|
import { createAgentMessage, type AgentMessage } from "@zerobyte/contracts/agent-protocol";
|
||||||
|
import type { Volume } from "@zerobyte/contracts/volumes";
|
||||||
import { LOCAL_AGENT_ID, LOCAL_AGENT_KIND, LOCAL_AGENT_NAME } from "../constants";
|
import { LOCAL_AGENT_ID, LOCAL_AGENT_KIND, LOCAL_AGENT_NAME } from "../constants";
|
||||||
import { createControllerAgentSession } from "../controller/session";
|
import { createControllerAgentSession } from "../controller/session";
|
||||||
|
|
||||||
|
|
@ -51,6 +52,22 @@ const createSession = (
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const backupVolume = {
|
||||||
|
id: 1,
|
||||||
|
shortId: "volume-1",
|
||||||
|
name: "Volume 1",
|
||||||
|
config: { backend: "directory", path: "/tmp" },
|
||||||
|
createdAt: 0,
|
||||||
|
updatedAt: 0,
|
||||||
|
lastHealthCheck: 0,
|
||||||
|
type: "directory",
|
||||||
|
status: "mounted" as const,
|
||||||
|
lastError: null,
|
||||||
|
autoRemount: true,
|
||||||
|
agentId: LOCAL_AGENT_ID,
|
||||||
|
organizationId: "org-1",
|
||||||
|
} satisfies Volume;
|
||||||
|
|
||||||
test("closing the session scope interrupts the session runner", async () => {
|
test("closing the session scope interrupts the session runner", async () => {
|
||||||
const { run, closeAsync } = createSession();
|
const { run, closeAsync } = createSession();
|
||||||
const fiber = run();
|
const fiber = run();
|
||||||
|
|
@ -85,6 +102,7 @@ test("sendBackup only queues the transport message", () => {
|
||||||
scheduleId: "schedule-queued",
|
scheduleId: "schedule-queued",
|
||||||
organizationId: "org-1",
|
organizationId: "org-1",
|
||||||
sourcePath: "/tmp/source",
|
sourcePath: "/tmp/source",
|
||||||
|
volume: backupVolume,
|
||||||
repositoryConfig: {
|
repositoryConfig: {
|
||||||
backend: "local",
|
backend: "local",
|
||||||
path: "/tmp/repository",
|
path: "/tmp/repository",
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import * as spawnModule from "@zerobyte/core/node";
|
||||||
import type { SafeSpawnParams } from "@zerobyte/core/node";
|
import type { SafeSpawnParams } from "@zerobyte/core/node";
|
||||||
import { logger } from "@zerobyte/core/node";
|
import { logger } from "@zerobyte/core/node";
|
||||||
import { restic } from "~/server/core/restic";
|
import { restic } from "~/server/core/restic";
|
||||||
import { NotFoundError, BadRequestError } from "http-errors-enhanced";
|
import { NotFoundError } from "http-errors-enhanced";
|
||||||
import { fromAny } from "@total-typescript/shoehorn";
|
import { fromAny } from "@total-typescript/shoehorn";
|
||||||
import { scheduleQueries } from "../backups.queries";
|
import { scheduleQueries } from "../backups.queries";
|
||||||
import { repositoriesService } from "~/server/modules/repositories/repositories.service";
|
import { repositoriesService } from "~/server/modules/repositories/repositories.service";
|
||||||
|
|
@ -23,6 +23,7 @@ import { createAgentBackupMocks } from "~/test/helpers/agent-mock";
|
||||||
import { getScheduleByIdOrShortId } from "../helpers/backup-schedule-lookups";
|
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";
|
||||||
|
|
||||||
const setup = () => {
|
const setup = () => {
|
||||||
const resticBackupMock = vi.fn((_: SafeSpawnParams) =>
|
const resticBackupMock = vi.fn((_: SafeSpawnParams) =>
|
||||||
|
|
@ -49,31 +50,33 @@ const setup = () => {
|
||||||
vi.spyOn(agentManager, "runBackup").mockImplementation(runBackupMock);
|
vi.spyOn(agentManager, "runBackup").mockImplementation(runBackupMock);
|
||||||
vi.spyOn(agentManager, "cancelBackup").mockImplementation(cancelBackupMock);
|
vi.spyOn(agentManager, "cancelBackup").mockImplementation(cancelBackupMock);
|
||||||
vi.spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID);
|
vi.spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID);
|
||||||
const ensureHealthyVolumeMock = vi.spyOn(volumeService, "ensureHealthyVolume").mockImplementation(async (shortId) => {
|
const ensureHealthyVolumeMock = vi
|
||||||
const volume = await db.query.volumesTable.findFirst({
|
.spyOn(volumeService, "ensureHealthyVolume")
|
||||||
where: {
|
.mockImplementation(async (shortId) => {
|
||||||
AND: [{ shortId: { eq: shortId } }, { organizationId: TEST_ORG_ID }],
|
const volume = await db.query.volumesTable.findFirst({
|
||||||
},
|
where: {
|
||||||
});
|
AND: [{ shortId: { eq: shortId } }, { organizationId: TEST_ORG_ID }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!volume) {
|
if (!volume) {
|
||||||
throw new NotFoundError("Volume not found");
|
throw new NotFoundError("Volume not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (volume.status !== "mounted") {
|
||||||
|
return {
|
||||||
|
ready: false as const,
|
||||||
|
volume,
|
||||||
|
reason: "Volume is not mounted",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (volume.status !== "mounted") {
|
|
||||||
return {
|
return {
|
||||||
ready: false as const,
|
ready: true as const,
|
||||||
volume,
|
volume,
|
||||||
reason: "Volume is not mounted",
|
remounted: false,
|
||||||
};
|
};
|
||||||
}
|
});
|
||||||
|
|
||||||
return {
|
|
||||||
ready: true as const,
|
|
||||||
volume,
|
|
||||||
remounted: false,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
resticBackupMock,
|
resticBackupMock,
|
||||||
|
|
@ -88,10 +91,11 @@ const setup = () => {
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
config.flags.enableLocalAgent = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("backup execution - validation failures", () => {
|
describe("backup execution - validation failures", () => {
|
||||||
test("should fail backup when volume is not mounted", async () => {
|
test("does not fail validation when the agent runtime owns volume readiness", async () => {
|
||||||
// arrange
|
// arrange
|
||||||
const { resticBackupMock } = setup();
|
const { resticBackupMock } = setup();
|
||||||
const volume = await createTestVolume({ status: "unmounted" });
|
const volume = await createTestVolume({ status: "unmounted" });
|
||||||
|
|
@ -105,55 +109,7 @@ describe("backup execution - validation failures", () => {
|
||||||
const result = await backupsService.validateBackupExecution(schedule.id);
|
const result = await backupsService.validateBackupExecution(schedule.id);
|
||||||
|
|
||||||
// assert
|
// assert
|
||||||
expect(result.type).toBe("failure");
|
|
||||||
if (result.type === "failure") {
|
|
||||||
expect(result.error).toBeInstanceOf(BadRequestError);
|
|
||||||
expect(result.error.message).toBe("Volume is not mounted");
|
|
||||||
}
|
|
||||||
expect(resticBackupMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("runs a preflight volume health check before starting a backup", async () => {
|
|
||||||
setup();
|
|
||||||
const volume = await createTestVolume();
|
|
||||||
const repository = await createTestRepository();
|
|
||||||
const schedule = await createTestBackupSchedule({
|
|
||||||
volumeId: volume.id,
|
|
||||||
repositoryId: repository.id,
|
|
||||||
});
|
|
||||||
const ensureHealthyVolumeSpy = vi.spyOn(volumeService, "ensureHealthyVolume").mockResolvedValue({
|
|
||||||
ready: true,
|
|
||||||
volume,
|
|
||||||
remounted: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await backupsService.validateBackupExecution(schedule.id);
|
|
||||||
|
|
||||||
expect(result.type).toBe("success");
|
expect(result.type).toBe("success");
|
||||||
expect(ensureHealthyVolumeSpy).toHaveBeenCalledWith(volume.shortId);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("fails validation when the preflight health check cannot recover the volume", async () => {
|
|
||||||
const { resticBackupMock } = setup();
|
|
||||||
const volume = await createTestVolume();
|
|
||||||
const repository = await createTestRepository();
|
|
||||||
const schedule = await createTestBackupSchedule({
|
|
||||||
volumeId: volume.id,
|
|
||||||
repositoryId: repository.id,
|
|
||||||
});
|
|
||||||
vi.spyOn(volumeService, "ensureHealthyVolume").mockResolvedValue({
|
|
||||||
ready: false,
|
|
||||||
volume: { ...volume, status: "error", lastError: "stale mount" },
|
|
||||||
reason: "stale mount",
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await backupsService.validateBackupExecution(schedule.id);
|
|
||||||
|
|
||||||
expect(result.type).toBe("failure");
|
|
||||||
if (result.type === "failure") {
|
|
||||||
expect(result.error).toBeInstanceOf(BadRequestError);
|
|
||||||
expect(result.error.message).toBe("stale mount");
|
|
||||||
}
|
|
||||||
expect(resticBackupMock).not.toHaveBeenCalled();
|
expect(resticBackupMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -461,7 +417,10 @@ describe("backup execution - routing", () => {
|
||||||
|
|
||||||
await backupsService.executeBackup(schedule.id);
|
await backupsService.executeBackup(schedule.id);
|
||||||
|
|
||||||
expect(runBackupMock).toHaveBeenCalledWith("agent-remote", expect.objectContaining({ scheduleId: schedule.id }));
|
expect(runBackupMock).toHaveBeenCalledWith(
|
||||||
|
"agent-remote",
|
||||||
|
expect.objectContaining({ scheduleId: schedule.id }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -874,7 +833,9 @@ describe("retention policy - runForget", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// act & assert
|
// act & assert
|
||||||
await expect(backupsService.runForget(schedule.id, "non-existent-repo")).rejects.toThrow("Repository not found");
|
await expect(backupsService.runForget(schedule.id, "non-existent-repo")).rejects.toThrow(
|
||||||
|
"Repository not found",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import type { BackupRunPayload } from "@zerobyte/contracts/agent-protocol";
|
||||||
import { agentManager, type BackupExecutionProgress } from "../agents/agents-manager";
|
import { agentManager, type BackupExecutionProgress } from "../agents/agents-manager";
|
||||||
import { LOCAL_AGENT_ID } from "../agents/constants";
|
import { LOCAL_AGENT_ID } from "../agents/constants";
|
||||||
import { getVolumePath } from "../volumes/helpers";
|
import { getVolumePath } from "../volumes/helpers";
|
||||||
|
import { decryptVolumeConfig } from "../volumes/volume-config-secrets";
|
||||||
import { decryptRepositoryConfig } from "../repositories/repository-config-secrets";
|
import { decryptRepositoryConfig } from "../repositories/repository-config-secrets";
|
||||||
import { createBackupOptions } from "./backup.helpers";
|
import { createBackupOptions } from "./backup.helpers";
|
||||||
import { toErrorDetails } from "../../utils/errors";
|
import { toErrorDetails } from "../../utils/errors";
|
||||||
|
|
@ -43,7 +44,9 @@ const createBackupRunPayload = async ({
|
||||||
repository,
|
repository,
|
||||||
organizationId,
|
organizationId,
|
||||||
}: BackupExecutionRequest & { jobId: string }): Promise<BackupRunPayload> => {
|
}: BackupExecutionRequest & { jobId: string }): Promise<BackupRunPayload> => {
|
||||||
|
// TODO: compute the source path on the agent so backup payloads do not carry controller-local paths.
|
||||||
const sourcePath = getVolumePath(volume);
|
const sourcePath = getVolumePath(volume);
|
||||||
|
const agentVolume = { ...volume, config: await decryptVolumeConfig(volume.config) };
|
||||||
const { signal: _, ...options } = createBackupOptions(schedule, sourcePath);
|
const { signal: _, ...options } = createBackupOptions(schedule, sourcePath);
|
||||||
|
|
||||||
if (FUSE_VOLUME_BACKENDS.has(volume.type) && !options.customResticParams.includes(IGNORE_INODE_FLAG)) {
|
if (FUSE_VOLUME_BACKENDS.has(volume.type) && !options.customResticParams.includes(IGNORE_INODE_FLAG)) {
|
||||||
|
|
@ -59,6 +62,7 @@ const createBackupRunPayload = async ({
|
||||||
scheduleId: schedule.shortId,
|
scheduleId: schedule.shortId,
|
||||||
organizationId,
|
organizationId,
|
||||||
sourcePath,
|
sourcePath,
|
||||||
|
volume: agentVolume,
|
||||||
repositoryConfig,
|
repositoryConfig,
|
||||||
options: {
|
options: {
|
||||||
...options,
|
...options,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { describeRoute, resolver } from "hono-openapi";
|
import { describeRoute, resolver } from "hono-openapi";
|
||||||
import { backupWebhooksSchema } from "@zerobyte/core/backup-hooks";
|
import { backupWebhooksSchema } from "@zerobyte/core/backup-hooks";
|
||||||
import { volumeSchema } from "../volumes/volume.dto";
|
import { publicVolumeSchema } from "@zerobyte/contracts/volumes";
|
||||||
import { repositorySchema } from "../repositories/repositories.dto";
|
import { repositorySchema } from "../repositories/repositories.dto";
|
||||||
import { backupProgressEventSchema } from "~/schemas/events-dto";
|
import { backupProgressEventSchema } from "~/schemas/events-dto";
|
||||||
|
|
||||||
|
|
@ -39,7 +39,7 @@ const backupScheduleSchema = z.object({
|
||||||
nextBackupAt: z.number().nullable(),
|
nextBackupAt: z.number().nullable(),
|
||||||
createdAt: z.number(),
|
createdAt: z.number(),
|
||||||
updatedAt: z.number(),
|
updatedAt: z.number(),
|
||||||
volume: volumeSchema,
|
volume: publicVolumeSchema,
|
||||||
repository: repositorySchema,
|
repository: repositorySchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ import { scheduleQueries } from "../backups.queries";
|
||||||
import type { BackupExecutionProgress } from "../../agents/agents-manager";
|
import type { BackupExecutionProgress } from "../../agents/agents-manager";
|
||||||
import { repositoriesService } from "../../repositories/repositories.service";
|
import { repositoriesService } from "../../repositories/repositories.service";
|
||||||
import { volumeService } from "../../volumes/volume.service";
|
import { volumeService } from "../../volumes/volume.service";
|
||||||
|
import { config } from "../../../core/config";
|
||||||
|
import { LOCAL_AGENT_ID } from "../../agents/constants";
|
||||||
import { copyToMirrors, runForget } from "./backup-maintenance";
|
import { copyToMirrors, runForget } from "./backup-maintenance";
|
||||||
|
|
||||||
interface BackupContext {
|
interface BackupContext {
|
||||||
|
|
@ -40,6 +42,9 @@ type ValidationSkipped = {
|
||||||
|
|
||||||
type ValidationResult = ValidationSuccess | ValidationFailure | ValidationSkipped;
|
type ValidationResult = ValidationSuccess | ValidationFailure | ValidationSkipped;
|
||||||
|
|
||||||
|
const requiresControllerLocalVolumeReadiness = (volume: Volume) =>
|
||||||
|
volume.agentId === LOCAL_AGENT_ID && !config.flags.enableLocalAgent;
|
||||||
|
|
||||||
export function getBackupProgress(scheduleId: number): BackupProgressEventDto | undefined {
|
export function getBackupProgress(scheduleId: number): BackupProgressEventDto | undefined {
|
||||||
return cache.get<BackupProgressEventDto>(cacheKeys.backup.progress(scheduleId));
|
return cache.get<BackupProgressEventDto>(cacheKeys.backup.progress(scheduleId));
|
||||||
}
|
}
|
||||||
|
|
@ -72,6 +77,13 @@ export async function validateBackupExecution(scheduleId: number, manual = false
|
||||||
return { type: "failure", error: new NotFoundError("Repository not found"), partialContext: { schedule, volume } };
|
return { type: "failure", error: new NotFoundError("Repository not found"), partialContext: { schedule, volume } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!requiresControllerLocalVolumeReadiness(volume)) {
|
||||||
|
return {
|
||||||
|
type: "success",
|
||||||
|
context: { schedule, volume, repository, organizationId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const volumeReadiness = await volumeService.ensureHealthyVolume(volume.shortId);
|
const volumeReadiness = await volumeService.ensureHealthyVolume(volume.shortId);
|
||||||
|
|
||||||
if (!volumeReadiness.ready) {
|
if (!volumeReadiness.ready) {
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { logger } from "@zerobyte/core/node";
|
||||||
import { toMessage } from "~/server/utils/errors";
|
import { toMessage } from "~/server/utils/errors";
|
||||||
import { cryptoUtils } from "~/server/utils/crypto";
|
import { cryptoUtils } from "~/server/utils/crypto";
|
||||||
import type { RepositoryConfig } from "@zerobyte/core/restic";
|
import type { RepositoryConfig } from "@zerobyte/core/restic";
|
||||||
import type { BackendConfig } from "~/schemas/volumes";
|
import type { BackendConfig } from "@zerobyte/contracts/volumes";
|
||||||
import type { NotificationConfig } from "~/schemas/notifications";
|
import type { NotificationConfig } from "~/schemas/notifications";
|
||||||
import { RESTIC_PASS_FILE } from "~/server/core/constants";
|
import { RESTIC_PASS_FILE } from "~/server/core/constants";
|
||||||
import { symmetricDecrypt, symmetricEncrypt } from "better-auth/crypto";
|
import { symmetricDecrypt, symmetricEncrypt } from "better-auth/crypto";
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import { repositoriesTable, volumesTable } from "~/server/db/schema";
|
||||||
import { LOCAL_AGENT_ID } from "~/server/modules/agents/constants";
|
import { LOCAL_AGENT_ID } from "~/server/modules/agents/constants";
|
||||||
import { mapRepositoryConfigSecrets } from "~/server/modules/repositories/repository-config-secrets";
|
import { mapRepositoryConfigSecrets } from "~/server/modules/repositories/repository-config-secrets";
|
||||||
import { mapVolumeConfigSecrets } from "~/server/modules/volumes/volume-config-secrets";
|
import { mapVolumeConfigSecrets } from "~/server/modules/volumes/volume-config-secrets";
|
||||||
import { BACKEND_TYPES, volumeConfigSchema, type BackendConfig } from "~/schemas/volumes";
|
import { BACKEND_TYPES, volumeConfigSchema, type BackendConfig } from "@zerobyte/contracts/volumes";
|
||||||
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";
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { BackendConfig } from "~/schemas/volumes";
|
import type { BackendConfig } from "@zerobyte/contracts/volumes";
|
||||||
import { cryptoUtils, transformOptionalSecret, type SecretTransformer } from "~/server/utils/crypto";
|
import { cryptoUtils, transformOptionalSecret, type SecretTransformer } from "~/server/utils/crypto";
|
||||||
|
|
||||||
export const mapVolumeConfigSecrets = async (
|
export const mapVolumeConfigSecrets = async (
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,16 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { describeRoute, resolver } from "hono-openapi";
|
import { describeRoute, resolver } from "hono-openapi";
|
||||||
import { BACKEND_STATUS, BACKEND_TYPES, volumeConfigSchema } from "~/schemas/volumes";
|
import {
|
||||||
|
browseFilesystemResponseSchema,
|
||||||
|
listVolumeFilesResponseSchema,
|
||||||
|
publicVolumeSchema,
|
||||||
|
statfsSchema,
|
||||||
|
testVolumeConnectionResponseSchema,
|
||||||
|
volumeConfigSchema,
|
||||||
|
volumeOperationResultSchema,
|
||||||
|
} from "@zerobyte/contracts/volumes";
|
||||||
|
|
||||||
export const volumeSchema = z.object({
|
const volumeSchema = publicVolumeSchema;
|
||||||
id: z.number(),
|
|
||||||
shortId: z.string(),
|
|
||||||
provisioningId: z.string().nullable(),
|
|
||||||
name: z.string(),
|
|
||||||
type: z.enum(BACKEND_TYPES),
|
|
||||||
status: z.enum(BACKEND_STATUS),
|
|
||||||
lastError: z.string().nullable(),
|
|
||||||
createdAt: z.number(),
|
|
||||||
updatedAt: z.number(),
|
|
||||||
lastHealthCheck: z.number(),
|
|
||||||
config: volumeConfigSchema,
|
|
||||||
autoRemount: z.boolean(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const listVolumesResponse = volumeSchema.array();
|
const listVolumesResponse = volumeSchema.array();
|
||||||
export type ListVolumesDto = z.infer<typeof listVolumesResponse>;
|
export type ListVolumesDto = z.infer<typeof listVolumesResponse>;
|
||||||
|
|
@ -80,12 +75,6 @@ export const deleteVolumeDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const statfsSchema = z.object({
|
|
||||||
total: z.number(),
|
|
||||||
used: z.number(),
|
|
||||||
free: z.number(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const getVolumeResponse = z.object({
|
const getVolumeResponse = z.object({
|
||||||
volume: volumeSchema,
|
volume: volumeSchema,
|
||||||
statfs: statfsSchema,
|
statfs: statfsSchema,
|
||||||
|
|
@ -146,10 +135,7 @@ export const testConnectionBody = z.object({
|
||||||
config: volumeConfigSchema,
|
config: volumeConfigSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
const testConnectionResponse = z.object({
|
const testConnectionResponse = testVolumeConnectionResponseSchema;
|
||||||
success: z.boolean(),
|
|
||||||
message: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const testConnectionDto = describeRoute({
|
export const testConnectionDto = describeRoute({
|
||||||
description: "Test connection to backend",
|
description: "Test connection to backend",
|
||||||
|
|
@ -167,10 +153,7 @@ export const testConnectionDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const mountVolumeResponse = z.object({
|
const mountVolumeResponse = volumeOperationResultSchema;
|
||||||
error: z.string().optional(),
|
|
||||||
status: z.enum(BACKEND_STATUS),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const mountVolumeDto = describeRoute({
|
export const mountVolumeDto = describeRoute({
|
||||||
description: "Mount a volume",
|
description: "Mount a volume",
|
||||||
|
|
@ -188,10 +171,7 @@ export const mountVolumeDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const unmountVolumeResponse = z.object({
|
const unmountVolumeResponse = volumeOperationResultSchema;
|
||||||
error: z.string().optional(),
|
|
||||||
status: z.enum(BACKEND_STATUS),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const unmountVolumeDto = describeRoute({
|
export const unmountVolumeDto = describeRoute({
|
||||||
description: "Unmount a volume",
|
description: "Unmount a volume",
|
||||||
|
|
@ -209,10 +189,7 @@ export const unmountVolumeDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const healthCheckResponse = z.object({
|
const healthCheckResponse = volumeOperationResultSchema;
|
||||||
error: z.string().optional(),
|
|
||||||
status: z.enum(BACKEND_STATUS),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const healthCheckDto = describeRoute({
|
export const healthCheckDto = describeRoute({
|
||||||
description: "Perform a health check on a volume",
|
description: "Perform a health check on a volume",
|
||||||
|
|
@ -233,22 +210,7 @@ export const healthCheckDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const fileEntrySchema = z.object({
|
const listFilesResponse = listVolumeFilesResponseSchema;
|
||||||
name: z.string(),
|
|
||||||
path: z.string(),
|
|
||||||
type: z.enum(["file", "directory"]),
|
|
||||||
size: z.number().optional(),
|
|
||||||
modifiedAt: z.number().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const listFilesResponse = z.object({
|
|
||||||
files: fileEntrySchema.array(),
|
|
||||||
path: z.string(),
|
|
||||||
offset: z.number(),
|
|
||||||
limit: z.number(),
|
|
||||||
total: z.number(),
|
|
||||||
hasMore: z.boolean(),
|
|
||||||
});
|
|
||||||
export type ListFilesDto = z.infer<typeof listFilesResponse>;
|
export type ListFilesDto = z.infer<typeof listFilesResponse>;
|
||||||
|
|
||||||
export const listFilesQuery = z.object({
|
export const listFilesQuery = z.object({
|
||||||
|
|
@ -273,10 +235,7 @@ export const listFilesDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const browseFilesystemResponse = z.object({
|
const browseFilesystemResponse = browseFilesystemResponseSchema;
|
||||||
directories: fileEntrySchema.array(),
|
|
||||||
path: z.string(),
|
|
||||||
});
|
|
||||||
export type BrowseFilesystemDto = z.infer<typeof browseFilesystemResponse>;
|
export type BrowseFilesystemDto = z.infer<typeof browseFilesystemResponse>;
|
||||||
|
|
||||||
export const browseFilesystemDto = describeRoute({
|
export const browseFilesystemDto = describeRoute({
|
||||||
|
|
|
||||||
|
|
@ -13,23 +13,18 @@ import type { UpdateVolumeBody } from "./volume.dto";
|
||||||
import { logger } from "@zerobyte/core/node";
|
import { logger } from "@zerobyte/core/node";
|
||||||
import { serverEvents } from "../../core/events";
|
import { serverEvents } from "../../core/events";
|
||||||
import type { Volume } from "../../db/schema";
|
import type { Volume } from "../../db/schema";
|
||||||
import { volumeConfigSchema, type BackendConfig } from "~/schemas/volumes";
|
import { volumeConfigSchema, type BackendConfig, type Volume as AgentVolume } from "@zerobyte/contracts/volumes";
|
||||||
|
import { Effect } from "effect";
|
||||||
import { getOrganizationId } from "~/server/core/request-context";
|
import { getOrganizationId } from "~/server/core/request-context";
|
||||||
import { type ShortId } from "~/server/utils/branded";
|
import { type ShortId } from "~/server/utils/branded";
|
||||||
import { decryptVolumeConfig, encryptVolumeConfig } from "./volume-config-secrets";
|
import { decryptVolumeConfig, encryptVolumeConfig } from "./volume-config-secrets";
|
||||||
import type { VolumeCommand, VolumeCommandResult } from "@zerobyte/contracts/agent-protocol";
|
import type { VolumeCommand, VolumeCommandResult } from "@zerobyte/contracts/agent-protocol";
|
||||||
import {
|
import { createVolumeBackend, getStatFs, getVolumePath } from "../../../../apps/agent/src/volume-host";
|
||||||
createVolumeBackend,
|
|
||||||
getStatFs,
|
|
||||||
getVolumePath,
|
|
||||||
type AgentVolume,
|
|
||||||
} from "../../../../apps/agent/src/volume-host";
|
|
||||||
import {
|
import {
|
||||||
browseFilesystem as browseHostFilesystem,
|
browseFilesystem as browseHostFilesystem,
|
||||||
listVolumeFiles,
|
listVolumeFiles,
|
||||||
testVolumeConnection,
|
testVolumeConnection,
|
||||||
} from "../../../../apps/agent/src/volume-host/operations";
|
} from "../../../../apps/agent/src/volume-host/operations";
|
||||||
import { Effect } from "effect";
|
|
||||||
|
|
||||||
type EnsureHealthyVolumeResult =
|
type EnsureHealthyVolumeResult =
|
||||||
| { ready: true; volume: Volume; remounted: boolean }
|
| { ready: true; volume: Volume; remounted: boolean }
|
||||||
|
|
@ -70,6 +65,7 @@ const volumeForAgent = async (volume: Volume): Promise<Volume> => ({
|
||||||
|
|
||||||
const volumeForHost = async (volume: Volume): Promise<AgentVolume> => ({
|
const volumeForHost = async (volume: Volume): Promise<AgentVolume> => ({
|
||||||
...volume,
|
...volume,
|
||||||
|
shortId: volume.shortId,
|
||||||
config: await decryptVolumeConfig(volume.config),
|
config: await decryptVolumeConfig(volume.config),
|
||||||
provisioningId: volume.provisioningId ?? null,
|
provisioningId: volume.provisioningId ?? null,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,21 @@ test("emits backup.failed when a backup command hits a restic error", async () =
|
||||||
scheduleId: "schedule-1",
|
scheduleId: "schedule-1",
|
||||||
organizationId: "org-1",
|
organizationId: "org-1",
|
||||||
sourcePath: "/tmp/missing-source",
|
sourcePath: "/tmp/missing-source",
|
||||||
|
volume: {
|
||||||
|
id: 1,
|
||||||
|
shortId: "volume-1",
|
||||||
|
name: "Volume 1",
|
||||||
|
config: { backend: "directory", path: "/tmp" },
|
||||||
|
createdAt: 0,
|
||||||
|
updatedAt: 0,
|
||||||
|
lastHealthCheck: 0,
|
||||||
|
type: "directory",
|
||||||
|
status: "mounted",
|
||||||
|
lastError: null,
|
||||||
|
autoRemount: true,
|
||||||
|
agentId: "local",
|
||||||
|
organizationId: "org-1",
|
||||||
|
},
|
||||||
repositoryConfig: {
|
repositoryConfig: {
|
||||||
backend: "local",
|
backend: "local",
|
||||||
path: "/tmp/test-repository",
|
path: "/tmp/test-repository",
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,21 @@ const createRunPayload = (overrides: Partial<BackupRunPayload> = {}) =>
|
||||||
scheduleId: "schedule-1",
|
scheduleId: "schedule-1",
|
||||||
organizationId: "org-1",
|
organizationId: "org-1",
|
||||||
sourcePath: "/tmp/source",
|
sourcePath: "/tmp/source",
|
||||||
|
volume: {
|
||||||
|
id: 1,
|
||||||
|
shortId: "volume-1",
|
||||||
|
name: "Volume 1",
|
||||||
|
config: { backend: "directory", path: "/tmp" },
|
||||||
|
createdAt: 0,
|
||||||
|
updatedAt: 0,
|
||||||
|
lastHealthCheck: 0,
|
||||||
|
type: "directory",
|
||||||
|
status: "mounted",
|
||||||
|
lastError: null,
|
||||||
|
autoRemount: true,
|
||||||
|
agentId: "local",
|
||||||
|
organizationId: "org-1",
|
||||||
|
},
|
||||||
repositoryConfig: {
|
repositoryConfig: {
|
||||||
backend: "local",
|
backend: "local",
|
||||||
path: "/tmp/repository",
|
path: "/tmp/repository",
|
||||||
|
|
@ -325,6 +340,21 @@ test("waits for running-job registration before returning to the processor loop"
|
||||||
scheduleId: "schedule-1",
|
scheduleId: "schedule-1",
|
||||||
organizationId: "org-1",
|
organizationId: "org-1",
|
||||||
sourcePath: "/tmp/source",
|
sourcePath: "/tmp/source",
|
||||||
|
volume: {
|
||||||
|
id: 1,
|
||||||
|
shortId: "volume-1",
|
||||||
|
name: "Volume 1",
|
||||||
|
config: { backend: "directory", path: "/tmp" },
|
||||||
|
createdAt: 0,
|
||||||
|
updatedAt: 0,
|
||||||
|
lastHealthCheck: 0,
|
||||||
|
type: "directory",
|
||||||
|
status: "mounted",
|
||||||
|
lastError: null,
|
||||||
|
autoRemount: true,
|
||||||
|
agentId: "local",
|
||||||
|
organizationId: "org-1",
|
||||||
|
},
|
||||||
repositoryConfig: {
|
repositoryConfig: {
|
||||||
backend: "local",
|
backend: "local",
|
||||||
path: "/tmp/repository",
|
path: "/tmp/repository",
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,51 @@
|
||||||
import { Effect, Runtime } from "effect";
|
import { Data, Effect, Runtime } from "effect";
|
||||||
import { createAgentMessage, type BackupRunPayload } from "@zerobyte/contracts/agent-protocol";
|
import { createAgentMessage, type BackupRunPayload } from "@zerobyte/contracts/agent-protocol";
|
||||||
|
import type { Volume } from "@zerobyte/contracts/volumes";
|
||||||
import { runBackupLifecycle } from "@zerobyte/core/backup-hooks";
|
import { runBackupLifecycle } from "@zerobyte/core/backup-hooks";
|
||||||
import { logger } from "@zerobyte/core/node";
|
import { logger } from "@zerobyte/core/node";
|
||||||
import { type ResticDeps } from "@zerobyte/core/restic";
|
import { type ResticDeps } from "@zerobyte/core/restic";
|
||||||
import { createRestic } from "@zerobyte/core/restic/server";
|
import { createRestic } from "@zerobyte/core/restic/server";
|
||||||
import { toMessage } from "@zerobyte/core/utils";
|
import { toMessage } from "@zerobyte/core/utils";
|
||||||
import type { ControllerCommandContext } from "../context";
|
import type { ControllerCommandContext } from "../context";
|
||||||
|
import { createVolumeBackend } from "../volume-host";
|
||||||
|
|
||||||
|
class VolumeReadinessError extends Data.TaggedError("VolumeReadinessError")<{
|
||||||
|
readonly _tag: "VolumeReadinessError";
|
||||||
|
message: string;
|
||||||
|
}> {}
|
||||||
|
|
||||||
|
const ensureHealthyVolume = (volume: Volume) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (volume.status === "unmounted") {
|
||||||
|
return yield* new VolumeReadinessError({
|
||||||
|
message: `Volume ${volume.name} is not mounted`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const backend = createVolumeBackend(volume);
|
||||||
|
let failureReason = volume.lastError ?? "Volume health check failed";
|
||||||
|
|
||||||
|
if (volume.status !== "error") {
|
||||||
|
const health = yield* Effect.promise(() => backend.checkHealth());
|
||||||
|
if (health.status === "mounted") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
failureReason = health.error ?? failureReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!volume.autoRemount) {
|
||||||
|
return yield* new VolumeReadinessError({ message: failureReason });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`${volume.name} is not healthy. Auto-remount is enabled, attempting to remount. Reason: ${failureReason}`,
|
||||||
|
);
|
||||||
|
const remount = yield* Effect.promise(() => backend.mount());
|
||||||
|
if (remount.status !== "mounted") {
|
||||||
|
return yield* new VolumeReadinessError({ message: remount.error ?? failureReason });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export const handleBackupRunCommand = (context: ControllerCommandContext, payload: BackupRunPayload) => {
|
export const handleBackupRunCommand = (context: ControllerCommandContext, payload: BackupRunPayload) => {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
|
|
@ -57,6 +97,8 @@ export const handleBackupRunCommand = (context: ControllerCommandContext, payloa
|
||||||
const restic = createRestic(deps);
|
const restic = createRestic(deps);
|
||||||
const runtime = yield* Effect.runtime<never>();
|
const runtime = yield* Effect.runtime<never>();
|
||||||
|
|
||||||
|
yield* ensureHealthyVolume(payload.volume);
|
||||||
|
|
||||||
const backupResult = yield* runBackupLifecycle({
|
const backupResult = yield* runBackupLifecycle({
|
||||||
restic,
|
restic,
|
||||||
repositoryConfig: payload.repositoryConfig,
|
repositoryConfig: payload.repositoryConfig,
|
||||||
|
|
@ -111,7 +153,19 @@ export const handleBackupRunCommand = (context: ControllerCommandContext, payloa
|
||||||
yield* sendCancelled(backupResult.message);
|
yield* sendCancelled(backupResult.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}).pipe(Effect.ensuring(context.deleteRunningJob(payload.jobId))),
|
}).pipe(
|
||||||
|
Effect.catchAll((error) =>
|
||||||
|
context.offerOutbound(
|
||||||
|
createAgentMessage("backup.failed", {
|
||||||
|
jobId: payload.jobId,
|
||||||
|
scheduleId: payload.scheduleId,
|
||||||
|
error: error.message,
|
||||||
|
errorDetails: toMessage(error),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Effect.ensuring(context.deleteRunningJob(payload.jobId)),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}).pipe(Effect.asVoid);
|
}).pipe(Effect.asVoid);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { Effect, Data } from "effect";
|
import { Effect, Data } from "effect";
|
||||||
import { createAgentMessage, type VolumeCommand, type VolumeCommandPayload } from "@zerobyte/contracts/agent-protocol";
|
import { createAgentMessage, type VolumeCommand, type VolumeCommandPayload } from "@zerobyte/contracts/agent-protocol";
|
||||||
|
import type { BackendConfig, Volume as AgentVolume } from "@zerobyte/contracts/volumes";
|
||||||
import { toMessage } from "@zerobyte/core/utils";
|
import { toMessage } from "@zerobyte/core/utils";
|
||||||
import { createVolumeBackend, getStatFs, getVolumePath, type AgentVolume, type BackendConfig } from "../volume-host";
|
import { createVolumeBackend, getStatFs, getVolumePath } from "../volume-host";
|
||||||
import { browseFilesystem, listVolumeFiles, testVolumeConnection } from "../volume-host/operations";
|
import { browseFilesystem, listVolumeFiles, testVolumeConnection } from "../volume-host/operations";
|
||||||
import type { ControllerCommandContext } from "../context";
|
import type { ControllerCommandContext } from "../context";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
|
import type { Volume as AgentVolume } from "@zerobyte/contracts/volumes";
|
||||||
import { afterEach, expect, test } from "vitest";
|
import { afterEach, expect, test } from "vitest";
|
||||||
import { listVolumeFiles } from "../operations";
|
import { listVolumeFiles } from "../operations";
|
||||||
import type { AgentVolume } from "../types";
|
|
||||||
|
|
||||||
let tempRoot: string | undefined;
|
let tempRoot: string | undefined;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
|
import type { BackendConfig } from "@zerobyte/contracts/volumes";
|
||||||
import { logger } from "@zerobyte/core/node";
|
import { logger } from "@zerobyte/core/node";
|
||||||
import { toMessage } from "@zerobyte/core/utils";
|
import { toMessage } from "@zerobyte/core/utils";
|
||||||
import type { BackendConfig, VolumeBackend } from "../types";
|
import type { VolumeBackend } from "../types";
|
||||||
|
|
||||||
const mount = async (config: BackendConfig) => {
|
const mount = async (config: BackendConfig) => {
|
||||||
if (config.backend !== "directory") {
|
if (config.backend !== "directory") {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
|
import type { BackendConfig } from "@zerobyte/contracts/volumes";
|
||||||
import { logger } from "@zerobyte/core/node";
|
import { logger } from "@zerobyte/core/node";
|
||||||
import { toMessage } from "@zerobyte/core/utils";
|
import { toMessage } from "@zerobyte/core/utils";
|
||||||
import { OPERATION_TIMEOUT } from "../constants";
|
import { OPERATION_TIMEOUT } from "../constants";
|
||||||
import { withTimeout } from "../timeout";
|
import { withTimeout } from "../timeout";
|
||||||
import { getMountForPath } from "../fs";
|
import { getMountForPath } from "../fs";
|
||||||
import type { BackendConfig, VolumeBackend } from "../types";
|
import type { VolumeBackend } from "../types";
|
||||||
import { assertMounted, executeMount, executeUnmount } from "./utils";
|
import { assertMounted, executeMount, executeUnmount } from "./utils";
|
||||||
|
|
||||||
const checkHealth = async (mountPath: string) => {
|
const checkHealth = async (mountPath: string) => {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
|
import type { BackendConfig } from "@zerobyte/contracts/volumes";
|
||||||
import { logger, safeExec } from "@zerobyte/core/node";
|
import { logger, safeExec } from "@zerobyte/core/node";
|
||||||
import { toMessage } from "@zerobyte/core/utils";
|
import { toMessage } from "@zerobyte/core/utils";
|
||||||
import { OPERATION_TIMEOUT, RCLONE_CONFIG_FILE, RCLONE_TIMEOUT } from "../constants";
|
import { OPERATION_TIMEOUT, RCLONE_CONFIG_FILE, RCLONE_TIMEOUT } from "../constants";
|
||||||
import { withTimeout } from "../timeout";
|
import { withTimeout } from "../timeout";
|
||||||
import { getMountForPath } from "../fs";
|
import { getMountForPath } from "../fs";
|
||||||
import type { BackendConfig, VolumeBackend } from "../types";
|
import type { VolumeBackend } from "../types";
|
||||||
import { assertMounted, executeUnmount } from "./utils";
|
import { assertMounted, executeUnmount } from "./utils";
|
||||||
|
|
||||||
const checkHealth = async (mountPath: string) => {
|
const checkHealth = async (mountPath: string) => {
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,13 @@ import { createHash } from "node:crypto";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
|
import type { BackendConfig } from "@zerobyte/contracts/volumes";
|
||||||
import { FILE_MODES, logger, writeFileWithMode } from "@zerobyte/core/node";
|
import { FILE_MODES, logger, writeFileWithMode } from "@zerobyte/core/node";
|
||||||
import { toMessage } from "@zerobyte/core/utils";
|
import { toMessage } from "@zerobyte/core/utils";
|
||||||
import { OPERATION_TIMEOUT, SSH_KEYS_DIR } from "../constants";
|
import { OPERATION_TIMEOUT, SSH_KEYS_DIR } from "../constants";
|
||||||
import { getMountForPath } from "../fs";
|
import { getMountForPath } from "../fs";
|
||||||
import { withTimeout } from "../timeout";
|
import { withTimeout } from "../timeout";
|
||||||
import type { BackendConfig, VolumeBackend } from "../types";
|
import type { VolumeBackend } from "../types";
|
||||||
import { executeUnmount } from "./utils";
|
import { executeUnmount } from "./utils";
|
||||||
|
|
||||||
const getMountPathHash = (mountPath: string) => createHash("sha256").update(mountPath).digest("hex").slice(0, 16);
|
const getMountPathHash = (mountPath: string) => createHash("sha256").update(mountPath).digest("hex").slice(0, 16);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
|
import type { BackendConfig } from "@zerobyte/contracts/volumes";
|
||||||
import { logger } from "@zerobyte/core/node";
|
import { logger } from "@zerobyte/core/node";
|
||||||
import { toMessage } from "@zerobyte/core/utils";
|
import { toMessage } from "@zerobyte/core/utils";
|
||||||
import { OPERATION_TIMEOUT } from "../constants";
|
import { OPERATION_TIMEOUT } from "../constants";
|
||||||
import { withTimeout } from "../timeout";
|
import { withTimeout } from "../timeout";
|
||||||
import { getMountForPath } from "../fs";
|
import { getMountForPath } from "../fs";
|
||||||
import type { BackendConfig, VolumeBackend } from "../types";
|
import type { VolumeBackend } from "../types";
|
||||||
import { assertMounted, executeMount, executeUnmount } from "./utils";
|
import { assertMounted, executeMount, executeUnmount } from "./utils";
|
||||||
|
|
||||||
const checkHealth = async (mountPath: string) => {
|
const checkHealth = async (mountPath: string) => {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
|
import type { BackendConfig } from "@zerobyte/contracts/volumes";
|
||||||
import { logger } from "@zerobyte/core/node";
|
import { logger } from "@zerobyte/core/node";
|
||||||
import { toMessage } from "@zerobyte/core/utils";
|
import { toMessage } from "@zerobyte/core/utils";
|
||||||
import { OPERATION_TIMEOUT } from "../constants";
|
import { OPERATION_TIMEOUT } from "../constants";
|
||||||
import { withTimeout } from "../timeout";
|
import { withTimeout } from "../timeout";
|
||||||
import { getMountForPath } from "../fs";
|
import { getMountForPath } from "../fs";
|
||||||
import type { BackendConfig, VolumeBackend } from "../types";
|
import type { VolumeBackend } from "../types";
|
||||||
import { assertMounted, executeMount, executeUnmount } from "./utils";
|
import { assertMounted, executeMount, executeUnmount } from "./utils";
|
||||||
|
|
||||||
const checkHealth = async (mountPath: string) => {
|
const checkHealth = async (mountPath: string) => {
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,11 @@ import { makeSftpBackend } from "./backends/sftp";
|
||||||
import { makeSmbBackend } from "./backends/smb";
|
import { makeSmbBackend } from "./backends/smb";
|
||||||
import { makeWebdavBackend } from "./backends/webdav";
|
import { makeWebdavBackend } from "./backends/webdav";
|
||||||
import { getVolumePath } from "./paths";
|
import { getVolumePath } from "./paths";
|
||||||
import type { AgentVolume, VolumeBackend } from "./types";
|
import type { Volume as AgentVolume } from "@zerobyte/contracts/volumes";
|
||||||
|
import type { VolumeBackend } from "./types";
|
||||||
|
|
||||||
export { getStatFs, isNodeJSErrnoException } from "./fs";
|
export { getStatFs, isNodeJSErrnoException } from "./fs";
|
||||||
export { getVolumePath } from "./paths";
|
export { getVolumePath } from "./paths";
|
||||||
export type { AgentVolume, BackendConfig, VolumeBackend } from "./types";
|
|
||||||
|
|
||||||
export const createVolumeBackend = (volume: AgentVolume, mountPath = getVolumePath(volume)): VolumeBackend => {
|
export const createVolumeBackend = (volume: AgentVolume, mountPath = getVolumePath(volume)): VolumeBackend => {
|
||||||
switch (volume.config.backend) {
|
switch (volume.config.backend) {
|
||||||
|
|
@ -25,7 +25,9 @@ export const createVolumeBackend = (volume: AgentVolume, mountPath = getVolumePa
|
||||||
return makeRcloneBackend(volume.config, mountPath);
|
return makeRcloneBackend(volume.config, mountPath);
|
||||||
case "sftp":
|
case "sftp":
|
||||||
return makeSftpBackend(volume.config, mountPath);
|
return makeSftpBackend(volume.config, mountPath);
|
||||||
|
default: {
|
||||||
|
const _exhaustive: never = volume.config;
|
||||||
|
throw new Error("Unsupported backend");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("Unsupported backend");
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
|
import type { BackendConfig, Volume as AgentVolume } from "@zerobyte/contracts/volumes";
|
||||||
import { toMessage } from "@zerobyte/core/utils";
|
import { toMessage } from "@zerobyte/core/utils";
|
||||||
import { createVolumeBackend, getVolumePath, isNodeJSErrnoException } from ".";
|
|
||||||
import type { AgentVolume, BackendConfig } from "./types";
|
|
||||||
import { Data, Effect } from "effect";
|
import { Data, Effect } from "effect";
|
||||||
|
import { createVolumeBackend, getVolumePath, isNodeJSErrnoException } from ".";
|
||||||
|
|
||||||
const DEFAULT_PAGE_SIZE = 500;
|
const DEFAULT_PAGE_SIZE = 500;
|
||||||
const MAX_PAGE_SIZE = 500;
|
const MAX_PAGE_SIZE = 500;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { VOLUME_MOUNT_BASE } from "./constants";
|
import { VOLUME_MOUNT_BASE } from "./constants";
|
||||||
import type { AgentVolume } from "./types";
|
import type { Volume } from "@zerobyte/contracts/volumes";
|
||||||
|
|
||||||
export const getVolumePath = (volume: AgentVolume) => {
|
export const getVolumePath = (volume: Volume) => {
|
||||||
if (volume.config.backend === "directory") {
|
if (volume.config.backend === "directory") {
|
||||||
return volume.config.path;
|
return volume.config.path;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,67 +1,7 @@
|
||||||
export type BackendStatus = "mounted" | "unmounted" | "error";
|
import type { VolumeOperationResult } from "@zerobyte/contracts/volumes";
|
||||||
|
|
||||||
type BaseConfig = { backend: string; readOnly?: boolean };
|
|
||||||
|
|
||||||
export type BackendConfig =
|
|
||||||
| (BaseConfig & { backend: "directory"; path: string })
|
|
||||||
| (BaseConfig & { backend: "nfs"; server: string; exportPath: string; port: number; version: "3" | "4" | "4.1" })
|
|
||||||
| (BaseConfig & {
|
|
||||||
backend: "smb";
|
|
||||||
server: string;
|
|
||||||
share: string;
|
|
||||||
username?: string;
|
|
||||||
password?: string;
|
|
||||||
guest?: boolean;
|
|
||||||
vers?: "1.0" | "2.0" | "2.1" | "3.0" | "auto";
|
|
||||||
domain?: string;
|
|
||||||
port: number;
|
|
||||||
})
|
|
||||||
| (BaseConfig & {
|
|
||||||
backend: "webdav";
|
|
||||||
server: string;
|
|
||||||
path: string;
|
|
||||||
username?: string;
|
|
||||||
password?: string;
|
|
||||||
port: number;
|
|
||||||
ssl?: boolean;
|
|
||||||
})
|
|
||||||
| (BaseConfig & { backend: "rclone"; remote: string; path: string })
|
|
||||||
| (BaseConfig & {
|
|
||||||
backend: "sftp";
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
username: string;
|
|
||||||
password?: string;
|
|
||||||
privateKey?: string;
|
|
||||||
path: string;
|
|
||||||
skipHostKeyCheck?: boolean;
|
|
||||||
knownHosts?: string;
|
|
||||||
});
|
|
||||||
|
|
||||||
export type AgentVolume = {
|
|
||||||
id: number;
|
|
||||||
shortId: string;
|
|
||||||
name: string;
|
|
||||||
config: BackendConfig;
|
|
||||||
createdAt: number;
|
|
||||||
updatedAt: number;
|
|
||||||
lastHealthCheck: number;
|
|
||||||
type: string;
|
|
||||||
status: BackendStatus;
|
|
||||||
lastError: string | null;
|
|
||||||
provisioningId?: string | null;
|
|
||||||
autoRemount: boolean;
|
|
||||||
agentId: string;
|
|
||||||
organizationId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OperationResult = {
|
|
||||||
status: BackendStatus;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type VolumeBackend = {
|
export type VolumeBackend = {
|
||||||
mount: () => Promise<OperationResult>;
|
mount: () => Promise<VolumeOperationResult>;
|
||||||
unmount: () => Promise<OperationResult>;
|
unmount: () => Promise<VolumeOperationResult>;
|
||||||
checkHealth: () => Promise<OperationResult>;
|
checkHealth: () => Promise<VolumeOperationResult>;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,19 @@
|
||||||
"types": "./src/agent-protocol.ts",
|
"types": "./src/agent-protocol.ts",
|
||||||
"import": "./src/agent-protocol.ts",
|
"import": "./src/agent-protocol.ts",
|
||||||
"default": "./src/agent-protocol.ts"
|
"default": "./src/agent-protocol.ts"
|
||||||
|
},
|
||||||
|
"./volumes": {
|
||||||
|
"types": "./src/volumes.ts",
|
||||||
|
"import": "./src/volumes.ts",
|
||||||
|
"default": "./src/volumes.ts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"tsc": "tsc --noEmit"
|
"tsc": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zerobyte/core": "workspace:*"
|
"@zerobyte/core": "workspace:*",
|
||||||
|
"zod": "^4.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest"
|
"@types/bun": "latest"
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,15 @@ import {
|
||||||
resticBackupProgressSchema,
|
resticBackupProgressSchema,
|
||||||
type CompressionMode,
|
type CompressionMode,
|
||||||
} from "@zerobyte/core/restic";
|
} from "@zerobyte/core/restic";
|
||||||
|
import {
|
||||||
|
browseFilesystemResponseSchema,
|
||||||
|
listVolumeFilesResponseSchema,
|
||||||
|
statfsSchema,
|
||||||
|
testVolumeConnectionResponseSchema,
|
||||||
|
volumeConfigSchema,
|
||||||
|
volumeOperationResultSchema,
|
||||||
|
volumeSchema,
|
||||||
|
} from "./volumes";
|
||||||
|
|
||||||
const compressionModeSchema = z.enum(["off", "auto", "max"]) satisfies z.ZodType<CompressionMode>;
|
const compressionModeSchema = z.enum(["off", "auto", "max"]) satisfies z.ZodType<CompressionMode>;
|
||||||
|
|
||||||
|
|
@ -37,6 +46,7 @@ const backupRunSchema = z.object({
|
||||||
scheduleId: z.string(),
|
scheduleId: z.string(),
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
sourcePath: z.string(),
|
sourcePath: z.string(),
|
||||||
|
volume: volumeSchema,
|
||||||
repositoryConfig: repositoryConfigSchema,
|
repositoryConfig: repositoryConfigSchema,
|
||||||
options: backupExecutionOptionsSchema,
|
options: backupExecutionOptionsSchema,
|
||||||
runtime: backupRuntimeSchema,
|
runtime: backupRuntimeSchema,
|
||||||
|
|
@ -51,53 +61,6 @@ const backupCancelSchema = z.object({
|
||||||
payload: z.object({ jobId: z.string(), scheduleId: z.string() }),
|
payload: z.object({ jobId: z.string(), scheduleId: z.string() }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const backendStatusSchema = z.enum(["mounted", "unmounted", "error"]);
|
|
||||||
|
|
||||||
const volumeSchema = z.object({
|
|
||||||
id: z.number(),
|
|
||||||
shortId: z.string(),
|
|
||||||
name: z.string(),
|
|
||||||
path: z.string().nullable().optional(),
|
|
||||||
config: z.record(z.string(), z.unknown()).and(z.object({ backend: z.string() })),
|
|
||||||
createdAt: z.number(),
|
|
||||||
updatedAt: z.number(),
|
|
||||||
lastHealthCheck: z.number(),
|
|
||||||
type: z.string(),
|
|
||||||
status: backendStatusSchema,
|
|
||||||
lastError: z.string().nullable(),
|
|
||||||
provisioningId: z.string().nullable().optional(),
|
|
||||||
autoRemount: z.boolean(),
|
|
||||||
agentId: z.string(),
|
|
||||||
organizationId: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const volumeOperationResultSchema = z.object({
|
|
||||||
status: backendStatusSchema,
|
|
||||||
error: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const statfsSchema = z.object({
|
|
||||||
total: z.number().optional(),
|
|
||||||
used: z.number().optional(),
|
|
||||||
free: z.number().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const fileEntrySchema = z.object({
|
|
||||||
name: z.string(),
|
|
||||||
path: z.string(),
|
|
||||||
type: z.enum(["directory", "file"]),
|
|
||||||
size: z.number().optional(),
|
|
||||||
modifiedAt: z.number().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const directoryEntrySchema = z.object({
|
|
||||||
name: z.string(),
|
|
||||||
path: z.string(),
|
|
||||||
type: z.literal("directory"),
|
|
||||||
size: z.undefined().optional(),
|
|
||||||
modifiedAt: z.number().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const volumeCommandSchema = z.discriminatedUnion("name", [
|
const volumeCommandSchema = z.discriminatedUnion("name", [
|
||||||
z.object({ name: z.literal("volume.mount"), volume: volumeSchema }),
|
z.object({ name: z.literal("volume.mount"), volume: volumeSchema }),
|
||||||
z.object({ name: z.literal("volume.unmount"), volume: volumeSchema }),
|
z.object({ name: z.literal("volume.unmount"), volume: volumeSchema }),
|
||||||
|
|
@ -110,7 +73,7 @@ const volumeCommandSchema = z.discriminatedUnion("name", [
|
||||||
offset: z.number(),
|
offset: z.number(),
|
||||||
limit: z.number(),
|
limit: z.number(),
|
||||||
}),
|
}),
|
||||||
z.object({ name: z.literal("volume.testConnection"), backendConfig: z.record(z.string(), z.unknown()) }),
|
z.object({ name: z.literal("volume.testConnection"), backendConfig: volumeConfigSchema }),
|
||||||
z.object({ name: z.literal("filesystem.browse"), path: z.string() }),
|
z.object({ name: z.literal("filesystem.browse"), path: z.string() }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -127,25 +90,9 @@ const volumeCommandResultSchema = z.discriminatedUnion("name", [
|
||||||
z.object({ name: z.literal("volume.unmount"), result: volumeOperationResultSchema }),
|
z.object({ name: z.literal("volume.unmount"), result: volumeOperationResultSchema }),
|
||||||
z.object({ name: z.literal("volume.checkHealth"), result: volumeOperationResultSchema }),
|
z.object({ name: z.literal("volume.checkHealth"), result: volumeOperationResultSchema }),
|
||||||
z.object({ name: z.literal("volume.statfs"), result: statfsSchema }),
|
z.object({ name: z.literal("volume.statfs"), result: statfsSchema }),
|
||||||
z.object({
|
z.object({ name: z.literal("volume.listFiles"), result: listVolumeFilesResponseSchema }),
|
||||||
name: z.literal("volume.listFiles"),
|
z.object({ name: z.literal("volume.testConnection"), result: testVolumeConnectionResponseSchema }),
|
||||||
result: z.object({
|
z.object({ name: z.literal("filesystem.browse"), result: browseFilesystemResponseSchema }),
|
||||||
files: z.array(fileEntrySchema),
|
|
||||||
path: z.string(),
|
|
||||||
offset: z.number(),
|
|
||||||
limit: z.number(),
|
|
||||||
total: z.number(),
|
|
||||||
hasMore: z.boolean(),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
z.object({
|
|
||||||
name: z.literal("volume.testConnection"),
|
|
||||||
result: z.object({ success: z.boolean(), message: z.string() }),
|
|
||||||
}),
|
|
||||||
z.object({
|
|
||||||
name: z.literal("filesystem.browse"),
|
|
||||||
result: z.object({ directories: z.array(directoryEntrySchema), path: z.string() }),
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const volumeCommandResponseSchema = z.object({
|
const volumeCommandResponseSchema = z.object({
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ export const sftpConfigSchema = z.object({
|
||||||
knownHosts: z.string().optional(),
|
knownHosts: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const volumeConfigSchemaBase = z.discriminatedUnion("backend", [
|
export const volumeConfigSchema = z.discriminatedUnion("backend", [
|
||||||
nfsConfigSchema,
|
nfsConfigSchema,
|
||||||
smbConfigSchema,
|
smbConfigSchema,
|
||||||
webdavConfigSchema,
|
webdavConfigSchema,
|
||||||
|
|
@ -95,8 +95,6 @@ const volumeConfigSchemaBase = z.discriminatedUnion("backend", [
|
||||||
sftpConfigSchema,
|
sftpConfigSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const volumeConfigSchema = volumeConfigSchemaBase;
|
|
||||||
|
|
||||||
export type BackendConfig = z.infer<typeof volumeConfigSchema>;
|
export type BackendConfig = z.infer<typeof volumeConfigSchema>;
|
||||||
|
|
||||||
export const BACKEND_STATUS = {
|
export const BACKEND_STATUS = {
|
||||||
|
|
@ -106,3 +104,78 @@ export const BACKEND_STATUS = {
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type BackendStatus = keyof typeof BACKEND_STATUS;
|
export type BackendStatus = keyof typeof BACKEND_STATUS;
|
||||||
|
|
||||||
|
export const backendStatusSchema = z.enum(BACKEND_STATUS);
|
||||||
|
|
||||||
|
export const volumeSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
shortId: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
path: z.string().nullable().optional(),
|
||||||
|
config: volumeConfigSchema,
|
||||||
|
createdAt: z.number(),
|
||||||
|
updatedAt: z.number(),
|
||||||
|
lastHealthCheck: z.number(),
|
||||||
|
type: z.enum(BACKEND_TYPES),
|
||||||
|
status: backendStatusSchema,
|
||||||
|
lastError: z.string().nullable(),
|
||||||
|
provisioningId: z.string().nullable().optional(),
|
||||||
|
autoRemount: z.boolean(),
|
||||||
|
agentId: z.string(),
|
||||||
|
organizationId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Volume = z.infer<typeof volumeSchema>;
|
||||||
|
|
||||||
|
export const publicVolumeSchema = volumeSchema.omit({
|
||||||
|
agentId: true,
|
||||||
|
organizationId: true,
|
||||||
|
path: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PublicVolume = z.infer<typeof publicVolumeSchema>;
|
||||||
|
|
||||||
|
export const volumeOperationResultSchema = z.object({
|
||||||
|
status: backendStatusSchema,
|
||||||
|
error: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type VolumeOperationResult = z.infer<typeof volumeOperationResultSchema>;
|
||||||
|
|
||||||
|
export const statfsSchema = z.object({
|
||||||
|
total: z.number().optional(),
|
||||||
|
used: z.number().optional(),
|
||||||
|
free: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fileEntrySchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
path: z.string(),
|
||||||
|
type: z.enum(["directory", "file"]),
|
||||||
|
size: z.number().optional(),
|
||||||
|
modifiedAt: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const directoryEntrySchema = fileEntrySchema.extend({
|
||||||
|
type: z.literal("directory"),
|
||||||
|
size: z.undefined().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listVolumeFilesResponseSchema = z.object({
|
||||||
|
files: z.array(fileEntrySchema),
|
||||||
|
path: z.string(),
|
||||||
|
offset: z.number(),
|
||||||
|
limit: z.number(),
|
||||||
|
total: z.number(),
|
||||||
|
hasMore: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const testVolumeConnectionResponseSchema = z.object({
|
||||||
|
success: z.boolean(),
|
||||||
|
message: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const browseFilesystemResponseSchema = z.object({
|
||||||
|
directories: z.array(directoryEntrySchema),
|
||||||
|
path: z.string(),
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue