From aa7da321ba5d58153d65e6de5f5cb4ce025dd741 Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Sat, 9 May 2026 15:36:25 +0200 Subject: [PATCH] refactor: dedpulicate volume schemas across packages (#864) --- app/client/components/volume-icon.tsx | 2 +- .../volumes/components/create-volume-form.tsx | 2 +- app/server/db/schema.ts | 2 +- .../__tests__/controller-runtime.test.ts | 19 ++++ .../modules/agents/__tests__/session.test.ts | 18 +++ .../backups.service.execution.test.ts | 105 ++++++------------ app/server/modules/backups/backup-executor.ts | 4 + app/server/modules/backups/backups.dto.ts | 4 +- .../backups/helpers/backup-lifecycle.ts | 12 ++ .../00002-isolate-restic-passwords.ts | 2 +- .../modules/provisioning/provisioning.ts | 2 +- .../modules/volumes/volume-config-secrets.ts | 2 +- app/server/modules/volumes/volume.dto.ts | 73 +++--------- app/server/modules/volumes/volume.service.ts | 12 +- .../src/__tests__/controller-session.test.ts | 15 +++ apps/agent/src/commands/backup-run.test.ts | 30 +++++ apps/agent/src/commands/backup-run.ts | 58 +++++++++- apps/agent/src/commands/volume.ts | 3 +- .../volume-host/__tests__/operations.test.ts | 2 +- .../src/volume-host/backends/directory.ts | 3 +- apps/agent/src/volume-host/backends/nfs.ts | 3 +- apps/agent/src/volume-host/backends/rclone.ts | 3 +- apps/agent/src/volume-host/backends/sftp.ts | 3 +- apps/agent/src/volume-host/backends/smb.ts | 3 +- apps/agent/src/volume-host/backends/webdav.ts | 3 +- apps/agent/src/volume-host/index.ts | 10 +- apps/agent/src/volume-host/operations.ts | 4 +- apps/agent/src/volume-host/paths.ts | 4 +- apps/agent/src/volume-host/types.ts | 68 +----------- packages/contracts/package.json | 8 +- packages/contracts/src/agent-protocol.ts | 81 +++----------- .../contracts/src}/volumes.ts | 79 ++++++++++++- 32 files changed, 341 insertions(+), 298 deletions(-) rename {app/schemas => packages/contracts/src}/volumes.ts (60%) diff --git a/app/client/components/volume-icon.tsx b/app/client/components/volume-icon.tsx index fa2a5513..e7316892 100644 --- a/app/client/components/volume-icon.tsx +++ b/app/client/components/volume-icon.tsx @@ -1,5 +1,5 @@ import { Cloud, Folder, Server } from "lucide-react"; -import type { BackendType } from "~/schemas/volumes"; +import type { BackendType } from "@zerobyte/contracts/volumes"; type VolumeIconProps = { backend: BackendType; diff --git a/app/client/modules/volumes/components/create-volume-form.tsx b/app/client/modules/volumes/components/create-volume-form.tsx index 7f61f146..367c6b81 100644 --- a/app/client/modules/volumes/components/create-volume-form.tsx +++ b/app/client/modules/volumes/components/create-volume-form.tsx @@ -25,7 +25,7 @@ import { smbConfigSchema, volumeConfigSchema, webdavConfigSchema, -} from "~/schemas/volumes"; +} from "@zerobyte/contracts/volumes"; import { testConnectionMutation } from "../../../api-client/@tanstack/react-query.gen"; import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip"; import { useSystemInfo } from "~/client/hooks/use-system-info"; diff --git a/app/server/db/schema.ts b/app/server/db/schema.ts index fb347da1..009e6f8f 100644 --- a/app/server/db/schema.ts +++ b/app/server/db/schema.ts @@ -10,7 +10,7 @@ import type { ResticStatsDto, } from "@zerobyte/core/restic"; 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 { ShortId } from "~/server/utils/branded"; import { LOCAL_AGENT_ID } from "../modules/agents/constants"; diff --git a/app/server/modules/agents/__tests__/controller-runtime.test.ts b/app/server/modules/agents/__tests__/controller-runtime.test.ts index 66523716..4656ecb2 100644 --- a/app/server/modules/agents/__tests__/controller-runtime.test.ts +++ b/app/server/modules/agents/__tests__/controller-runtime.test.ts @@ -3,6 +3,7 @@ import { afterEach, expect, test, vi } from "vitest"; import waitForExpect from "wait-for-expect"; import { fromPartial } from "@total-typescript/shoehorn"; 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"; const agentsServiceMocks = vi.hoisted(() => ({ @@ -36,6 +37,22 @@ const createSocket = (id: string, agentId = LOCAL_AGENT_ID) => ({ 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[0]["fetch"]>; const invokeFetch = (fetch: CapturedFetch | undefined, request: Request, srv: Parameters[1]) => { @@ -223,6 +240,7 @@ test("closing a replaced connection reports disconnect without marking the activ scheduleId: "schedule-1", organizationId: "org-1", sourcePath: "/tmp/source", + volume: backupVolume, repositoryConfig: { backend: "local" as const, path: "/tmp/repository" }, options: {}, runtime: { @@ -253,6 +271,7 @@ test("sendBackup is only delivered after the agent is ready", async () => { scheduleId: "schedule-1", organizationId: "org-1", sourcePath: "/tmp/source", + volume: backupVolume, repositoryConfig: { backend: "local" as const, path: "/tmp/repository" }, options: {}, runtime: { diff --git a/app/server/modules/agents/__tests__/session.test.ts b/app/server/modules/agents/__tests__/session.test.ts index 5c1766da..d1da32b5 100644 --- a/app/server/modules/agents/__tests__/session.test.ts +++ b/app/server/modules/agents/__tests__/session.test.ts @@ -3,6 +3,7 @@ import { expect, test, vi } from "vitest"; import waitForExpect from "wait-for-expect"; import { fromPartial } from "@total-typescript/shoehorn"; 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 { 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 () => { const { run, closeAsync } = createSession(); const fiber = run(); @@ -85,6 +102,7 @@ test("sendBackup only queues the transport message", () => { scheduleId: "schedule-queued", organizationId: "org-1", sourcePath: "/tmp/source", + volume: backupVolume, repositoryConfig: { backend: "local", path: "/tmp/repository", diff --git a/app/server/modules/backups/__tests__/backups.service.execution.test.ts b/app/server/modules/backups/__tests__/backups.service.execution.test.ts index 3ed84306..6e1ad0a5 100644 --- a/app/server/modules/backups/__tests__/backups.service.execution.test.ts +++ b/app/server/modules/backups/__tests__/backups.service.execution.test.ts @@ -12,7 +12,7 @@ import * as spawnModule from "@zerobyte/core/node"; import type { SafeSpawnParams } from "@zerobyte/core/node"; import { logger } from "@zerobyte/core/node"; 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 { scheduleQueries } from "../backups.queries"; 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 { volumeService } from "~/server/modules/volumes/volume.service"; import { db } from "~/server/db/db"; +import { config } from "~/server/core/config"; const setup = () => { const resticBackupMock = vi.fn((_: SafeSpawnParams) => @@ -49,31 +50,33 @@ const setup = () => { vi.spyOn(agentManager, "runBackup").mockImplementation(runBackupMock); vi.spyOn(agentManager, "cancelBackup").mockImplementation(cancelBackupMock); vi.spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID); - const ensureHealthyVolumeMock = vi.spyOn(volumeService, "ensureHealthyVolume").mockImplementation(async (shortId) => { - const volume = await db.query.volumesTable.findFirst({ - where: { - AND: [{ shortId: { eq: shortId } }, { organizationId: TEST_ORG_ID }], - }, - }); + const ensureHealthyVolumeMock = vi + .spyOn(volumeService, "ensureHealthyVolume") + .mockImplementation(async (shortId) => { + const volume = await db.query.volumesTable.findFirst({ + where: { + AND: [{ shortId: { eq: shortId } }, { organizationId: TEST_ORG_ID }], + }, + }); - if (!volume) { - throw new NotFoundError("Volume not found"); - } + if (!volume) { + 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 { - ready: false as const, + ready: true as const, volume, - reason: "Volume is not mounted", + remounted: false, }; - } - - return { - ready: true as const, - volume, - remounted: false, - }; - }); + }); return { resticBackupMock, @@ -88,10 +91,11 @@ const setup = () => { afterEach(() => { vi.restoreAllMocks(); + config.flags.enableLocalAgent = true; }); 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 const { resticBackupMock } = setup(); const volume = await createTestVolume({ status: "unmounted" }); @@ -105,55 +109,7 @@ describe("backup execution - validation failures", () => { const result = await backupsService.validateBackupExecution(schedule.id); // 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(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(); }); @@ -461,7 +417,10 @@ describe("backup execution - routing", () => { 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 - 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", + ); }); }); diff --git a/app/server/modules/backups/backup-executor.ts b/app/server/modules/backups/backup-executor.ts index 39e18d6e..88132467 100644 --- a/app/server/modules/backups/backup-executor.ts +++ b/app/server/modules/backups/backup-executor.ts @@ -7,6 +7,7 @@ import type { BackupRunPayload } from "@zerobyte/contracts/agent-protocol"; import { agentManager, type BackupExecutionProgress } from "../agents/agents-manager"; import { LOCAL_AGENT_ID } from "../agents/constants"; import { getVolumePath } from "../volumes/helpers"; +import { decryptVolumeConfig } from "../volumes/volume-config-secrets"; import { decryptRepositoryConfig } from "../repositories/repository-config-secrets"; import { createBackupOptions } from "./backup.helpers"; import { toErrorDetails } from "../../utils/errors"; @@ -43,7 +44,9 @@ const createBackupRunPayload = async ({ repository, organizationId, }: BackupExecutionRequest & { jobId: string }): Promise => { + // TODO: compute the source path on the agent so backup payloads do not carry controller-local paths. const sourcePath = getVolumePath(volume); + const agentVolume = { ...volume, config: await decryptVolumeConfig(volume.config) }; const { signal: _, ...options } = createBackupOptions(schedule, sourcePath); if (FUSE_VOLUME_BACKENDS.has(volume.type) && !options.customResticParams.includes(IGNORE_INODE_FLAG)) { @@ -59,6 +62,7 @@ const createBackupRunPayload = async ({ scheduleId: schedule.shortId, organizationId, sourcePath, + volume: agentVolume, repositoryConfig, options: { ...options, diff --git a/app/server/modules/backups/backups.dto.ts b/app/server/modules/backups/backups.dto.ts index c785b130..92437eaf 100644 --- a/app/server/modules/backups/backups.dto.ts +++ b/app/server/modules/backups/backups.dto.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { describeRoute, resolver } from "hono-openapi"; 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 { backupProgressEventSchema } from "~/schemas/events-dto"; @@ -39,7 +39,7 @@ const backupScheduleSchema = z.object({ nextBackupAt: z.number().nullable(), createdAt: z.number(), updatedAt: z.number(), - volume: volumeSchema, + volume: publicVolumeSchema, repository: repositorySchema, }); diff --git a/app/server/modules/backups/helpers/backup-lifecycle.ts b/app/server/modules/backups/helpers/backup-lifecycle.ts index 141ce142..f75b17a7 100644 --- a/app/server/modules/backups/helpers/backup-lifecycle.ts +++ b/app/server/modules/backups/helpers/backup-lifecycle.ts @@ -13,6 +13,8 @@ import { scheduleQueries } from "../backups.queries"; import type { BackupExecutionProgress } from "../../agents/agents-manager"; import { repositoriesService } from "../../repositories/repositories.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"; interface BackupContext { @@ -40,6 +42,9 @@ type 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 { return cache.get(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 } }; } + if (!requiresControllerLocalVolumeReadiness(volume)) { + return { + type: "success", + context: { schedule, volume, repository, organizationId }, + }; + } + const volumeReadiness = await volumeService.ensureHealthyVolume(volume.shortId); if (!volumeReadiness.ready) { diff --git a/app/server/modules/lifecycle/migrations/00002-isolate-restic-passwords.ts b/app/server/modules/lifecycle/migrations/00002-isolate-restic-passwords.ts index 742c34e2..942005e0 100644 --- a/app/server/modules/lifecycle/migrations/00002-isolate-restic-passwords.ts +++ b/app/server/modules/lifecycle/migrations/00002-isolate-restic-passwords.ts @@ -13,7 +13,7 @@ import { logger } from "@zerobyte/core/node"; import { toMessage } from "~/server/utils/errors"; import { cryptoUtils } from "~/server/utils/crypto"; 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 { RESTIC_PASS_FILE } from "~/server/core/constants"; import { symmetricDecrypt, symmetricEncrypt } from "better-auth/crypto"; diff --git a/app/server/modules/provisioning/provisioning.ts b/app/server/modules/provisioning/provisioning.ts index ad93e876..e9a7bff0 100644 --- a/app/server/modules/provisioning/provisioning.ts +++ b/app/server/modules/provisioning/provisioning.ts @@ -16,7 +16,7 @@ import { repositoriesTable, volumesTable } from "~/server/db/schema"; import { LOCAL_AGENT_ID } from "~/server/modules/agents/constants"; import { mapRepositoryConfigSecrets } from "~/server/modules/repositories/repository-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 { toMessage } from "~/server/utils/errors"; import { generateShortId } from "~/server/utils/id"; diff --git a/app/server/modules/volumes/volume-config-secrets.ts b/app/server/modules/volumes/volume-config-secrets.ts index 1bda9efe..a803285e 100644 --- a/app/server/modules/volumes/volume-config-secrets.ts +++ b/app/server/modules/volumes/volume-config-secrets.ts @@ -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"; export const mapVolumeConfigSecrets = async ( diff --git a/app/server/modules/volumes/volume.dto.ts b/app/server/modules/volumes/volume.dto.ts index d669c8cf..cf6a6889 100644 --- a/app/server/modules/volumes/volume.dto.ts +++ b/app/server/modules/volumes/volume.dto.ts @@ -1,21 +1,16 @@ import { z } from "zod"; 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({ - 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 volumeSchema = publicVolumeSchema; const listVolumesResponse = volumeSchema.array(); export type ListVolumesDto = z.infer; @@ -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({ volume: volumeSchema, statfs: statfsSchema, @@ -146,10 +135,7 @@ export const testConnectionBody = z.object({ config: volumeConfigSchema, }); -const testConnectionResponse = z.object({ - success: z.boolean(), - message: z.string(), -}); +const testConnectionResponse = testVolumeConnectionResponseSchema; export const testConnectionDto = describeRoute({ description: "Test connection to backend", @@ -167,10 +153,7 @@ export const testConnectionDto = describeRoute({ }, }); -const mountVolumeResponse = z.object({ - error: z.string().optional(), - status: z.enum(BACKEND_STATUS), -}); +const mountVolumeResponse = volumeOperationResultSchema; export const mountVolumeDto = describeRoute({ description: "Mount a volume", @@ -188,10 +171,7 @@ export const mountVolumeDto = describeRoute({ }, }); -const unmountVolumeResponse = z.object({ - error: z.string().optional(), - status: z.enum(BACKEND_STATUS), -}); +const unmountVolumeResponse = volumeOperationResultSchema; export const unmountVolumeDto = describeRoute({ description: "Unmount a volume", @@ -209,10 +189,7 @@ export const unmountVolumeDto = describeRoute({ }, }); -const healthCheckResponse = z.object({ - error: z.string().optional(), - status: z.enum(BACKEND_STATUS), -}); +const healthCheckResponse = volumeOperationResultSchema; export const healthCheckDto = describeRoute({ description: "Perform a health check on a volume", @@ -233,22 +210,7 @@ export const healthCheckDto = describeRoute({ }, }); -const fileEntrySchema = z.object({ - 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(), -}); +const listFilesResponse = listVolumeFilesResponseSchema; export type ListFilesDto = z.infer; export const listFilesQuery = z.object({ @@ -273,10 +235,7 @@ export const listFilesDto = describeRoute({ }, }); -const browseFilesystemResponse = z.object({ - directories: fileEntrySchema.array(), - path: z.string(), -}); +const browseFilesystemResponse = browseFilesystemResponseSchema; export type BrowseFilesystemDto = z.infer; export const browseFilesystemDto = describeRoute({ diff --git a/app/server/modules/volumes/volume.service.ts b/app/server/modules/volumes/volume.service.ts index 570bc3d3..6e7b9c2b 100644 --- a/app/server/modules/volumes/volume.service.ts +++ b/app/server/modules/volumes/volume.service.ts @@ -13,23 +13,18 @@ import type { UpdateVolumeBody } from "./volume.dto"; import { logger } from "@zerobyte/core/node"; import { serverEvents } from "../../core/events"; 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 { type ShortId } from "~/server/utils/branded"; import { decryptVolumeConfig, encryptVolumeConfig } from "./volume-config-secrets"; import type { VolumeCommand, VolumeCommandResult } from "@zerobyte/contracts/agent-protocol"; -import { - createVolumeBackend, - getStatFs, - getVolumePath, - type AgentVolume, -} from "../../../../apps/agent/src/volume-host"; +import { createVolumeBackend, getStatFs, getVolumePath } from "../../../../apps/agent/src/volume-host"; import { browseFilesystem as browseHostFilesystem, listVolumeFiles, testVolumeConnection, } from "../../../../apps/agent/src/volume-host/operations"; -import { Effect } from "effect"; type EnsureHealthyVolumeResult = | { ready: true; volume: Volume; remounted: boolean } @@ -70,6 +65,7 @@ const volumeForAgent = async (volume: Volume): Promise => ({ const volumeForHost = async (volume: Volume): Promise => ({ ...volume, + shortId: volume.shortId, config: await decryptVolumeConfig(volume.config), provisioningId: volume.provisioningId ?? null, }); diff --git a/apps/agent/src/__tests__/controller-session.test.ts b/apps/agent/src/__tests__/controller-session.test.ts index 3745c1c7..cdccd437 100644 --- a/apps/agent/src/__tests__/controller-session.test.ts +++ b/apps/agent/src/__tests__/controller-session.test.ts @@ -34,6 +34,21 @@ test("emits backup.failed when a backup command hits a restic error", async () = scheduleId: "schedule-1", organizationId: "org-1", 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: { backend: "local", path: "/tmp/test-repository", diff --git a/apps/agent/src/commands/backup-run.test.ts b/apps/agent/src/commands/backup-run.test.ts index faa88755..6361dee2 100644 --- a/apps/agent/src/commands/backup-run.test.ts +++ b/apps/agent/src/commands/backup-run.test.ts @@ -40,6 +40,21 @@ const createRunPayload = (overrides: Partial = {}) => scheduleId: "schedule-1", organizationId: "org-1", 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: { backend: "local", path: "/tmp/repository", @@ -325,6 +340,21 @@ test("waits for running-job registration before returning to the processor loop" scheduleId: "schedule-1", organizationId: "org-1", 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: { backend: "local", path: "/tmp/repository", diff --git a/apps/agent/src/commands/backup-run.ts b/apps/agent/src/commands/backup-run.ts index 500ef2a3..ff3dd3ff 100644 --- a/apps/agent/src/commands/backup-run.ts +++ b/apps/agent/src/commands/backup-run.ts @@ -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 type { Volume } from "@zerobyte/contracts/volumes"; import { runBackupLifecycle } from "@zerobyte/core/backup-hooks"; import { logger } from "@zerobyte/core/node"; import { type ResticDeps } from "@zerobyte/core/restic"; import { createRestic } from "@zerobyte/core/restic/server"; import { toMessage } from "@zerobyte/core/utils"; 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) => { return Effect.gen(function* () { @@ -57,6 +97,8 @@ export const handleBackupRunCommand = (context: ControllerCommandContext, payloa const restic = createRestic(deps); const runtime = yield* Effect.runtime(); + yield* ensureHealthyVolume(payload.volume); + const backupResult = yield* runBackupLifecycle({ restic, repositoryConfig: payload.repositoryConfig, @@ -111,7 +153,19 @@ export const handleBackupRunCommand = (context: ControllerCommandContext, payloa yield* sendCancelled(backupResult.message); 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); }; diff --git a/apps/agent/src/commands/volume.ts b/apps/agent/src/commands/volume.ts index 55b384dc..bcfa959c 100644 --- a/apps/agent/src/commands/volume.ts +++ b/apps/agent/src/commands/volume.ts @@ -1,7 +1,8 @@ import { Effect, Data } from "effect"; 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 { 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 type { ControllerCommandContext } from "../context"; diff --git a/apps/agent/src/volume-host/__tests__/operations.test.ts b/apps/agent/src/volume-host/__tests__/operations.test.ts index b0e60dbd..d5dd756e 100644 --- a/apps/agent/src/volume-host/__tests__/operations.test.ts +++ b/apps/agent/src/volume-host/__tests__/operations.test.ts @@ -1,9 +1,9 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import type { Volume as AgentVolume } from "@zerobyte/contracts/volumes"; import { afterEach, expect, test } from "vitest"; import { listVolumeFiles } from "../operations"; -import type { AgentVolume } from "../types"; let tempRoot: string | undefined; diff --git a/apps/agent/src/volume-host/backends/directory.ts b/apps/agent/src/volume-host/backends/directory.ts index 67151268..df25b2c7 100644 --- a/apps/agent/src/volume-host/backends/directory.ts +++ b/apps/agent/src/volume-host/backends/directory.ts @@ -1,7 +1,8 @@ import * as fs from "node:fs/promises"; +import type { BackendConfig } from "@zerobyte/contracts/volumes"; import { logger } from "@zerobyte/core/node"; import { toMessage } from "@zerobyte/core/utils"; -import type { BackendConfig, VolumeBackend } from "../types"; +import type { VolumeBackend } from "../types"; const mount = async (config: BackendConfig) => { if (config.backend !== "directory") { diff --git a/apps/agent/src/volume-host/backends/nfs.ts b/apps/agent/src/volume-host/backends/nfs.ts index 803e9a45..87d3d94e 100644 --- a/apps/agent/src/volume-host/backends/nfs.ts +++ b/apps/agent/src/volume-host/backends/nfs.ts @@ -1,11 +1,12 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; +import type { BackendConfig } from "@zerobyte/contracts/volumes"; import { logger } from "@zerobyte/core/node"; import { toMessage } from "@zerobyte/core/utils"; import { OPERATION_TIMEOUT } from "../constants"; import { withTimeout } from "../timeout"; import { getMountForPath } from "../fs"; -import type { BackendConfig, VolumeBackend } from "../types"; +import type { VolumeBackend } from "../types"; import { assertMounted, executeMount, executeUnmount } from "./utils"; const checkHealth = async (mountPath: string) => { diff --git a/apps/agent/src/volume-host/backends/rclone.ts b/apps/agent/src/volume-host/backends/rclone.ts index 011f2cf4..522a3c49 100644 --- a/apps/agent/src/volume-host/backends/rclone.ts +++ b/apps/agent/src/volume-host/backends/rclone.ts @@ -1,11 +1,12 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; +import type { BackendConfig } from "@zerobyte/contracts/volumes"; import { logger, safeExec } from "@zerobyte/core/node"; import { toMessage } from "@zerobyte/core/utils"; import { OPERATION_TIMEOUT, RCLONE_CONFIG_FILE, RCLONE_TIMEOUT } from "../constants"; import { withTimeout } from "../timeout"; import { getMountForPath } from "../fs"; -import type { BackendConfig, VolumeBackend } from "../types"; +import type { VolumeBackend } from "../types"; import { assertMounted, executeUnmount } from "./utils"; const checkHealth = async (mountPath: string) => { diff --git a/apps/agent/src/volume-host/backends/sftp.ts b/apps/agent/src/volume-host/backends/sftp.ts index 021ea77f..7940c67c 100644 --- a/apps/agent/src/volume-host/backends/sftp.ts +++ b/apps/agent/src/volume-host/backends/sftp.ts @@ -3,12 +3,13 @@ import { createHash } from "node:crypto"; import * as os from "node:os"; import * as path from "node:path"; import { spawn } from "node:child_process"; +import type { BackendConfig } from "@zerobyte/contracts/volumes"; import { FILE_MODES, logger, writeFileWithMode } from "@zerobyte/core/node"; import { toMessage } from "@zerobyte/core/utils"; import { OPERATION_TIMEOUT, SSH_KEYS_DIR } from "../constants"; import { getMountForPath } from "../fs"; import { withTimeout } from "../timeout"; -import type { BackendConfig, VolumeBackend } from "../types"; +import type { VolumeBackend } from "../types"; import { executeUnmount } from "./utils"; const getMountPathHash = (mountPath: string) => createHash("sha256").update(mountPath).digest("hex").slice(0, 16); diff --git a/apps/agent/src/volume-host/backends/smb.ts b/apps/agent/src/volume-host/backends/smb.ts index 767d27d5..3d7ff5da 100644 --- a/apps/agent/src/volume-host/backends/smb.ts +++ b/apps/agent/src/volume-host/backends/smb.ts @@ -1,11 +1,12 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; +import type { BackendConfig } from "@zerobyte/contracts/volumes"; import { logger } from "@zerobyte/core/node"; import { toMessage } from "@zerobyte/core/utils"; import { OPERATION_TIMEOUT } from "../constants"; import { withTimeout } from "../timeout"; import { getMountForPath } from "../fs"; -import type { BackendConfig, VolumeBackend } from "../types"; +import type { VolumeBackend } from "../types"; import { assertMounted, executeMount, executeUnmount } from "./utils"; const checkHealth = async (mountPath: string) => { diff --git a/apps/agent/src/volume-host/backends/webdav.ts b/apps/agent/src/volume-host/backends/webdav.ts index dd3548e8..9e66b84b 100644 --- a/apps/agent/src/volume-host/backends/webdav.ts +++ b/apps/agent/src/volume-host/backends/webdav.ts @@ -1,11 +1,12 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; +import type { BackendConfig } from "@zerobyte/contracts/volumes"; import { logger } from "@zerobyte/core/node"; import { toMessage } from "@zerobyte/core/utils"; import { OPERATION_TIMEOUT } from "../constants"; import { withTimeout } from "../timeout"; import { getMountForPath } from "../fs"; -import type { BackendConfig, VolumeBackend } from "../types"; +import type { VolumeBackend } from "../types"; import { assertMounted, executeMount, executeUnmount } from "./utils"; const checkHealth = async (mountPath: string) => { diff --git a/apps/agent/src/volume-host/index.ts b/apps/agent/src/volume-host/index.ts index d704326e..9f268878 100644 --- a/apps/agent/src/volume-host/index.ts +++ b/apps/agent/src/volume-host/index.ts @@ -5,11 +5,11 @@ import { makeSftpBackend } from "./backends/sftp"; import { makeSmbBackend } from "./backends/smb"; import { makeWebdavBackend } from "./backends/webdav"; 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 { getVolumePath } from "./paths"; -export type { AgentVolume, BackendConfig, VolumeBackend } from "./types"; export const createVolumeBackend = (volume: AgentVolume, mountPath = getVolumePath(volume)): VolumeBackend => { switch (volume.config.backend) { @@ -25,7 +25,9 @@ export const createVolumeBackend = (volume: AgentVolume, mountPath = getVolumePa return makeRcloneBackend(volume.config, mountPath); case "sftp": return makeSftpBackend(volume.config, mountPath); + default: { + const _exhaustive: never = volume.config; + throw new Error("Unsupported backend"); + } } - - throw new Error("Unsupported backend"); }; diff --git a/apps/agent/src/volume-host/operations.ts b/apps/agent/src/volume-host/operations.ts index e2ad0f7e..80c66189 100644 --- a/apps/agent/src/volume-host/operations.ts +++ b/apps/agent/src/volume-host/operations.ts @@ -1,10 +1,10 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import type { BackendConfig, Volume as AgentVolume } from "@zerobyte/contracts/volumes"; import { toMessage } from "@zerobyte/core/utils"; -import { createVolumeBackend, getVolumePath, isNodeJSErrnoException } from "."; -import type { AgentVolume, BackendConfig } from "./types"; import { Data, Effect } from "effect"; +import { createVolumeBackend, getVolumePath, isNodeJSErrnoException } from "."; const DEFAULT_PAGE_SIZE = 500; const MAX_PAGE_SIZE = 500; diff --git a/apps/agent/src/volume-host/paths.ts b/apps/agent/src/volume-host/paths.ts index f5a1b59e..d2598108 100644 --- a/apps/agent/src/volume-host/paths.ts +++ b/apps/agent/src/volume-host/paths.ts @@ -1,7 +1,7 @@ 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") { return volume.config.path; } diff --git a/apps/agent/src/volume-host/types.ts b/apps/agent/src/volume-host/types.ts index 72b32826..676b0e96 100644 --- a/apps/agent/src/volume-host/types.ts +++ b/apps/agent/src/volume-host/types.ts @@ -1,67 +1,7 @@ -export type BackendStatus = "mounted" | "unmounted" | "error"; - -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; -}; +import type { VolumeOperationResult } from "@zerobyte/contracts/volumes"; export type VolumeBackend = { - mount: () => Promise; - unmount: () => Promise; - checkHealth: () => Promise; + mount: () => Promise; + unmount: () => Promise; + checkHealth: () => Promise; }; diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 8cf8dd23..af58ba90 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -7,13 +7,19 @@ "types": "./src/agent-protocol.ts", "import": "./src/agent-protocol.ts", "default": "./src/agent-protocol.ts" + }, + "./volumes": { + "types": "./src/volumes.ts", + "import": "./src/volumes.ts", + "default": "./src/volumes.ts" } }, "scripts": { "tsc": "tsc --noEmit" }, "dependencies": { - "@zerobyte/core": "workspace:*" + "@zerobyte/core": "workspace:*", + "zod": "^4.0.1" }, "devDependencies": { "@types/bun": "latest" diff --git a/packages/contracts/src/agent-protocol.ts b/packages/contracts/src/agent-protocol.ts index 2be20a16..80a8c81e 100644 --- a/packages/contracts/src/agent-protocol.ts +++ b/packages/contracts/src/agent-protocol.ts @@ -7,6 +7,15 @@ import { resticBackupProgressSchema, type CompressionMode, } from "@zerobyte/core/restic"; +import { + browseFilesystemResponseSchema, + listVolumeFilesResponseSchema, + statfsSchema, + testVolumeConnectionResponseSchema, + volumeConfigSchema, + volumeOperationResultSchema, + volumeSchema, +} from "./volumes"; const compressionModeSchema = z.enum(["off", "auto", "max"]) satisfies z.ZodType; @@ -37,6 +46,7 @@ const backupRunSchema = z.object({ scheduleId: z.string(), organizationId: z.string(), sourcePath: z.string(), + volume: volumeSchema, repositoryConfig: repositoryConfigSchema, options: backupExecutionOptionsSchema, runtime: backupRuntimeSchema, @@ -51,53 +61,6 @@ const backupCancelSchema = z.object({ 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", [ z.object({ name: z.literal("volume.mount"), volume: volumeSchema }), z.object({ name: z.literal("volume.unmount"), volume: volumeSchema }), @@ -110,7 +73,7 @@ const volumeCommandSchema = z.discriminatedUnion("name", [ offset: 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() }), ]); @@ -127,25 +90,9 @@ const volumeCommandResultSchema = z.discriminatedUnion("name", [ z.object({ name: z.literal("volume.unmount"), 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.listFiles"), - result: z.object({ - 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() }), - }), + z.object({ name: z.literal("volume.listFiles"), result: listVolumeFilesResponseSchema }), + z.object({ name: z.literal("volume.testConnection"), result: testVolumeConnectionResponseSchema }), + z.object({ name: z.literal("filesystem.browse"), result: browseFilesystemResponseSchema }), ]); const volumeCommandResponseSchema = z.object({ diff --git a/app/schemas/volumes.ts b/packages/contracts/src/volumes.ts similarity index 60% rename from app/schemas/volumes.ts rename to packages/contracts/src/volumes.ts index 3e829f98..1dd69660 100644 --- a/app/schemas/volumes.ts +++ b/packages/contracts/src/volumes.ts @@ -86,7 +86,7 @@ export const sftpConfigSchema = z.object({ knownHosts: z.string().optional(), }); -const volumeConfigSchemaBase = z.discriminatedUnion("backend", [ +export const volumeConfigSchema = z.discriminatedUnion("backend", [ nfsConfigSchema, smbConfigSchema, webdavConfigSchema, @@ -95,8 +95,6 @@ const volumeConfigSchemaBase = z.discriminatedUnion("backend", [ sftpConfigSchema, ]); -export const volumeConfigSchema = volumeConfigSchemaBase; - export type BackendConfig = z.infer; export const BACKEND_STATUS = { @@ -106,3 +104,78 @@ export const BACKEND_STATUS = { } as const; 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; + +export const publicVolumeSchema = volumeSchema.omit({ + agentId: true, + organizationId: true, + path: true, +}); + +export type PublicVolume = z.infer; + +export const volumeOperationResultSchema = z.object({ + status: backendStatusSchema, + error: z.string().optional(), +}); + +export type VolumeOperationResult = z.infer; + +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(), +});