From 3734ba29252122adecb5d58aea8a40a2c897f7d9 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Tue, 23 Sep 2025 19:15:28 +0200 Subject: [PATCH] feat: mount volumes on startup --- apps/client/app/components/status-dot.tsx | 49 +++++++++++++++ apps/client/app/components/ui/tooltip.tsx | 59 +++++++++++++++++++ apps/client/app/lib/types.ts | 3 + apps/client/app/routes/details.tsx | 6 +- apps/client/app/routes/home.tsx | 6 +- apps/client/package.json | 1 + apps/server/src/core/config.ts | 6 +- apps/server/src/index.ts | 3 + apps/server/src/modules/backends/backend.ts | 11 ++-- .../src/modules/backends/nfs/nfs-backend.ts | 5 ++ apps/server/src/modules/lifecycle/startup.ts | 22 +++++++ .../src/modules/volumes/volume.controller.ts | 1 - bun.lock | 3 + docker-compose.yml | 2 +- 14 files changed, 159 insertions(+), 18 deletions(-) create mode 100644 apps/client/app/components/status-dot.tsx create mode 100644 apps/client/app/components/ui/tooltip.tsx create mode 100644 apps/client/app/lib/types.ts create mode 100644 apps/server/src/modules/lifecycle/startup.ts diff --git a/apps/client/app/components/status-dot.tsx b/apps/client/app/components/status-dot.tsx new file mode 100644 index 0000000..5da576b --- /dev/null +++ b/apps/client/app/components/status-dot.tsx @@ -0,0 +1,49 @@ +import type { VolumeStatus } from "~/lib/types"; +import { cn } from "~/lib/utils"; +import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; + +export const StatusDot = ({ status }: { status: VolumeStatus }) => { + const statusMapping = { + mounted: { + color: "bg-green-500", + colorLight: "bg-green-400", + animated: true, + }, + unmounted: { + color: "bg-gray-500", + colorLight: "bg-gray-400", + animated: false, + }, + error: { + color: "bg-red-500", + colorLight: "bg-red-400", + animated: true, + }, + unknown: { + color: "bg-yellow-500", + colorLight: "bg-yellow-400", + animated: true, + }, + }[status]; + + return ( + + + + {statusMapping.animated && ( + + )} + + + + +

{status}

+
+
+ ); +}; diff --git a/apps/client/app/components/ui/tooltip.tsx b/apps/client/app/components/ui/tooltip.tsx new file mode 100644 index 0000000..dc07e07 --- /dev/null +++ b/apps/client/app/components/ui/tooltip.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import * as TooltipPrimitive from "@radix-ui/react-tooltip" + +import { cn } from "~/lib/utils" + +function TooltipProvider({ + delayDuration = 0, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function Tooltip({ + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function TooltipTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ) +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/apps/client/app/lib/types.ts b/apps/client/app/lib/types.ts new file mode 100644 index 0000000..8958931 --- /dev/null +++ b/apps/client/app/lib/types.ts @@ -0,0 +1,3 @@ +import type { GetVolumeResponse } from "~/api-client"; + +export type VolumeStatus = GetVolumeResponse["status"]; diff --git a/apps/client/app/routes/details.tsx b/apps/client/app/routes/details.tsx index b31ba58..ea9db0e 100644 --- a/apps/client/app/routes/details.tsx +++ b/apps/client/app/routes/details.tsx @@ -17,6 +17,7 @@ import { parseError } from "~/lib/errors"; import { HealthchecksCard } from "~/modules/details/components/healthchecks-card"; import type { Route } from "./+types/details"; import { cn } from "~/lib/utils"; +import { StatusDot } from "~/components/status-dot"; export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => { const volume = await getVolume({ path: { name: params.name ?? "" } }); @@ -89,9 +90,8 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) {

Volume: {name}

- - - {data.status} + + {data.status[0].toUpperCase() + data.status.slice(1)}
diff --git a/apps/client/app/routes/home.tsx b/apps/client/app/routes/home.tsx index b037558..ee0c1c9 100644 --- a/apps/client/app/routes/home.tsx +++ b/apps/client/app/routes/home.tsx @@ -11,6 +11,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~ import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table"; import { VolumeIcon } from "~/components/volume-icon"; import type { Route } from "./+types/home"; +import { StatusDot } from "~/components/status-dot"; export function meta(_: Route.MetaArgs) { return [ @@ -101,10 +102,7 @@ export default function Home({ loaderData }: Route.ComponentProps) { - - - - + ))} diff --git a/apps/client/package.json b/apps/client/package.json index 435433d..06e9923 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -17,6 +17,7 @@ "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tooltip": "^1.2.8", "@react-router/node": "^7.7.1", "@react-router/serve": "^7.7.1", "@tanstack/react-query": "^5.84.2", diff --git a/apps/server/src/core/config.ts b/apps/server/src/core/config.ts index 7d68d32..023d737 100644 --- a/apps/server/src/core/config.ts +++ b/apps/server/src/core/config.ts @@ -2,16 +2,14 @@ import { type } from "arktype"; import "dotenv/config"; const envSchema = type({ - NODE_ENV: type - .enumerated("development", "production", "test") - .default("development"), + NODE_ENV: type.enumerated("development", "production", "test").default("development"), VOLUME_ROOT: "string", }).pipe((s) => ({ __prod__: s.NODE_ENV === "production", environment: s.NODE_ENV, dbFileName: "/data/ironmount.db", volumeRootHost: s.VOLUME_ROOT, - volumeRootContainer: "/mnt/volumes", + volumeRootContainer: "/mounts", })); const parseConfig = (env: unknown) => { diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index a2a015f..c7b5a82 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -7,6 +7,7 @@ import { runDbMigrations } from "./db/db"; import { driverController } from "./modules/driver/driver.controller"; import { volumeController } from "./modules/volumes/volume.controller"; import { logger } from "./utils/logger"; +import { startup } from "./modules/lifecycle/startup"; export const generalDescriptor = (app: Hono) => openAPISpecs(app, { @@ -57,6 +58,8 @@ const socketPath = "/run/docker/plugins/ironmount.sock"; fetch: app.fetch, }); + await startup(); + logger.info(`Server is running at http://localhost:8080 and unix socket at ${socketPath}`); })(); diff --git a/apps/server/src/modules/backends/backend.ts b/apps/server/src/modules/backends/backend.ts index 9da64be..e0d1137 100644 --- a/apps/server/src/modules/backends/backend.ts +++ b/apps/server/src/modules/backends/backend.ts @@ -2,6 +2,7 @@ import type { BackendStatus } from "@ironmount/schemas"; import type { Volume } from "../../db/schema"; import { makeDirectoryBackend } from "./directory/directory-backend"; import { makeNfsBackend } from "./nfs/nfs-backend"; +import { config } from "../../core/config"; export type VolumeBackend = { mount: () => Promise; @@ -10,17 +11,17 @@ export type VolumeBackend = { }; export const createVolumeBackend = (volume: Volume): VolumeBackend => { - const { config, path } = volume; + const path = `${config.volumeRootContainer}/${volume.name}/_data`; - switch (config.backend) { + switch (volume.config.backend) { case "nfs": { - return makeNfsBackend(config, path); + return makeNfsBackend(volume.config, path); } case "directory": { - return makeDirectoryBackend(config, path); + return makeDirectoryBackend(volume.config, path); } default: { - throw new Error(`Backend ${config.backend} not implemented`); + throw new Error(`Backend ${volume.config.backend} not implemented`); } } }; diff --git a/apps/server/src/modules/backends/nfs/nfs-backend.ts b/apps/server/src/modules/backends/nfs/nfs-backend.ts index 4f2d70d..dbdd8e9 100644 --- a/apps/server/src/modules/backends/nfs/nfs-backend.ts +++ b/apps/server/src/modules/backends/nfs/nfs-backend.ts @@ -69,6 +69,11 @@ const unmount = async (path: string) => { logger.error(`Error unmounting NFS volume: ${stderr}`); return reject(new Error(`Failed to unmount NFS volume: ${stderr}`)); } + + fs.rmdir(path).catch((rmdirError) => { + logger.error(`Failed to remove directory ${path}:`, rmdirError); + }); + logger.info(`NFS volume unmounted successfully: ${stdout}`); resolve(); }); diff --git a/apps/server/src/modules/lifecycle/startup.ts b/apps/server/src/modules/lifecycle/startup.ts new file mode 100644 index 0000000..a595993 --- /dev/null +++ b/apps/server/src/modules/lifecycle/startup.ts @@ -0,0 +1,22 @@ +import { eq } from "drizzle-orm"; +import { db } from "../../db/db"; +import { logger } from "../../utils/logger"; +import { volumesTable } from "../../db/schema"; +import { createVolumeBackend } from "../backends/backend"; + +export const startup = async () => { + logger.info("Mounting all volumes..."); + + const volumes = await db.query.volumesTable.findMany({ where: eq(volumesTable.status, "mounted") }); + + for (const volume of volumes) { + try { + const backend = createVolumeBackend(volume); + await backend.mount(); + logger.info(`Mounted volume ${volume.name} successfully`); + } catch (error) { + logger.error(`Failed to mount volume ${volume.name}:`, error); + await db.update(volumesTable).set({ status: "unmounted" }).where(eq(volumesTable.name, volume.name)); + } + } +}; diff --git a/apps/server/src/modules/volumes/volume.controller.ts b/apps/server/src/modules/volumes/volume.controller.ts index 83bc936..bc69e98 100644 --- a/apps/server/src/modules/volumes/volume.controller.ts +++ b/apps/server/src/modules/volumes/volume.controller.ts @@ -17,7 +17,6 @@ import { unmountVolumeDto, } from "./volume.dto"; import { volumeService } from "./volume.service"; -import { logger } from "../../utils/logger"; export const volumeController = new Hono() .get("/", listVolumesDto, async (c) => { diff --git a/bun.lock b/bun.lock index 5996b6f..2c613ed 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,7 @@ "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tooltip": "^1.2.8", "@react-router/node": "^7.7.1", "@react-router/serve": "^7.7.1", "@tanstack/react-query": "^5.84.2", @@ -295,6 +296,8 @@ "@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="], + "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="], + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], diff --git a/docker-compose.yml b/docker-compose.yml index 04148e3..0f50c59 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,5 +23,5 @@ services: - ./apps/server/src:/app/apps/server/src - ./data:/data - + - /home/nicolas/ironmount/tmp:/mounts:rshared