diff --git a/apps/client/app/api-client/@tanstack/react-query.gen.ts b/apps/client/app/api-client/@tanstack/react-query.gen.ts index f27634d..7c62b8d 100644 --- a/apps/client/app/api-client/@tanstack/react-query.gen.ts +++ b/apps/client/app/api-client/@tanstack/react-query.gen.ts @@ -35,6 +35,7 @@ import { updateBackupSchedule, getBackupScheduleForVolume, runBackupNow, + getSystemInfo, } from "../sdk.gen"; import { queryOptions, type UseMutationOptions, type DefaultError } from "@tanstack/react-query"; import type { @@ -90,6 +91,7 @@ import type { GetBackupScheduleForVolumeData, RunBackupNowData, RunBackupNowResponse, + GetSystemInfoData, } from "../types.gen"; import { client as _heyApiClient } from "../client.gen"; @@ -1078,3 +1080,23 @@ export const runBackupNowMutation = ( }; return mutationOptions; }; + +export const getSystemInfoQueryKey = (options?: Options) => createQueryKey("getSystemInfo", options); + +/** + * Get system information including available capabilities + */ +export const getSystemInfoOptions = (options?: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getSystemInfo({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getSystemInfoQueryKey(options), + }); +}; diff --git a/apps/client/app/api-client/sdk.gen.ts b/apps/client/app/api-client/sdk.gen.ts index 63b5ba0..2f1c9e2 100644 --- a/apps/client/app/api-client/sdk.gen.ts +++ b/apps/client/app/api-client/sdk.gen.ts @@ -72,6 +72,8 @@ import type { GetBackupScheduleForVolumeResponses, RunBackupNowData, RunBackupNowResponses, + GetSystemInfoData, + GetSystemInfoResponses, } from "./types.gen"; import { client as _heyApiClient } from "./client.gen"; @@ -513,3 +515,15 @@ export const runBackupNow = ( ...options, }); }; + +/** + * Get system information including available capabilities + */ +export const getSystemInfo = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get({ + url: "/api/v1/system/info", + ...options, + }); +}; diff --git a/apps/client/app/api-client/types.gen.ts b/apps/client/app/api-client/types.gen.ts index c483ce8..6e61297 100644 --- a/apps/client/app/api-client/types.gen.ts +++ b/apps/client/app/api-client/types.gen.ts @@ -1418,6 +1418,26 @@ export type RunBackupNowResponses = { export type RunBackupNowResponse = RunBackupNowResponses[keyof RunBackupNowResponses]; +export type GetSystemInfoData = { + body?: never; + path?: never; + query?: never; + url: "/api/v1/system/info"; +}; + +export type GetSystemInfoResponses = { + /** + * System information with enabled capabilities + */ + 200: { + capabilities: { + docker: boolean; + }; + }; +}; + +export type GetSystemInfoResponse = GetSystemInfoResponses[keyof GetSystemInfoResponses]; + export type ClientOptions = { baseUrl: "http://192.168.2.42:4096" | (string & {}); }; diff --git a/apps/client/app/components/ui/alert.tsx b/apps/client/app/components/ui/alert.tsx new file mode 100644 index 0000000..f072966 --- /dev/null +++ b/apps/client/app/components/ui/alert.tsx @@ -0,0 +1,43 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "~/lib/utils"; + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = "Alert"; + +const AlertTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +AlertTitle.displayName = "AlertTitle"; + +const AlertDescription = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +AlertDescription.displayName = "AlertDescription"; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/apps/client/app/modules/volumes/tabs/docker.tsx b/apps/client/app/modules/volumes/tabs/docker.tsx index 8d8fd92..7cc0c20 100644 --- a/apps/client/app/modules/volumes/tabs/docker.tsx +++ b/apps/client/app/modules/volumes/tabs/docker.tsx @@ -1,11 +1,12 @@ import { useQuery } from "@tanstack/react-query"; -import { Unplug } from "lucide-react"; +import { AlertCircle, Unplug } from "lucide-react"; import * as YML from "yaml"; +import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; import { CodeBlock } from "~/components/ui/code-block"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table"; import type { Volume } from "~/lib/types"; -import { getContainersUsingVolumeOptions } from "../../../api-client/@tanstack/react-query.gen"; +import { getContainersUsingVolumeOptions, getSystemInfoOptions } from "../../../api-client/@tanstack/react-query.gen"; type Props = { volume: Volume; @@ -28,6 +29,11 @@ export const DockerTabContent = ({ volume }: Props) => { const dockerRunCommand = `docker run -v im-${volume.name}:/path/in/container nginx:latest`; + const { data: systemInfo } = useQuery({ + ...getSystemInfoOptions(), + staleTime: 60000, // Cache for 1 minute + }); + const { data: containersData, isLoading, @@ -39,6 +45,7 @@ export const DockerTabContent = ({ volume }: Props) => { }); const containers = containersData || []; + const dockerAvailable = systemInfo?.data?.capabilities?.docker ?? true; const getStateClass = (state: string) => { switch (state) { @@ -53,6 +60,28 @@ export const DockerTabContent = ({ volume }: Props) => { return (
+ {!dockerAvailable && ( +
+ + + Docker Integration Unavailable + + Docker integration features are currently disabled. To enable them, you need to mount the following paths + in your docker-compose.yml: +
    +
  • + /var/run/docker.sock:/var/run/docker.sock +
  • +
  • + /run/docker/plugins:/run/docker/plugins +
  • +
+

After adding these mounts, restart the Ironmount container.

+
+
+
+ )} + Plug-and-play Docker integration @@ -83,19 +112,31 @@ export const DockerTabContent = ({ volume }: Props) => { Containers Using This Volume - List of Docker containers mounting this volume. + + {dockerAvailable + ? "List of Docker containers mounting this volume." + : "Docker integration is unavailable - enable it to see container information."} + - {isLoading &&
Loading containers...
} - {error &&
Failed to load containers: {String(error)}
} - {!isLoading && !error && containers.length === 0 && ( + {!dockerAvailable && ( +
+ +

Docker integration is not available.

+
+ )} + {dockerAvailable && isLoading &&
Loading containers...
} + {dockerAvailable && error && ( +
Failed to load containers: {String(error)}
+ )} + {dockerAvailable && !isLoading && !error && containers.length === 0 && (

No Docker containers are currently using this volume.

)} - {!isLoading && !error && containers.length > 0 && ( + {dockerAvailable && !isLoading && !error && containers.length > 0 && (
diff --git a/apps/server/build.ts b/apps/server/build.ts index ea27e22..5d22973 100644 --- a/apps/server/build.ts +++ b/apps/server/build.ts @@ -3,11 +3,12 @@ await Bun.build({ outdir: "./dist", target: "bun", env: "disable", - // sourcemap: "linked", + sourcemap: true, minify: { whitespace: true, identifiers: true, syntax: true, + keepNames: true, }, external: ["ssh2"], }); diff --git a/apps/server/src/core/capabilities.ts b/apps/server/src/core/capabilities.ts new file mode 100644 index 0000000..1b87a42 --- /dev/null +++ b/apps/server/src/core/capabilities.ts @@ -0,0 +1,55 @@ +import * as fs from "node:fs/promises"; +import Docker from "dockerode"; +import { logger } from "../utils/logger"; + +export type SystemCapabilities = { + docker: boolean; +}; + +let capabilitiesPromise: Promise | null = null; + +/** + * Returns the current system capabilities. + * On first call, detects all capabilities and caches the promise. + * Subsequent calls return the same cached promise, ensuring detection only happens once. + */ +export async function getCapabilities(): Promise { + if (capabilitiesPromise === null) { + // Start detection and cache the promise + capabilitiesPromise = detectCapabilities(); + } + + return capabilitiesPromise; +} + +/** + * Detects which optional capabilities are available in the current environment + */ +async function detectCapabilities(): Promise { + return { + docker: await detectDocker(), + }; +} + +/** + * Checks if Docker is available by: + * 1. Checking if /var/run/docker.sock exists and is accessible + * 2. Attempting to ping the Docker daemon + */ +async function detectDocker(): Promise { + try { + await fs.access("/var/run/docker.sock"); + + const docker = new Docker(); + await docker.ping(); + + logger.info("Docker capability: enabled"); + return true; + } catch (_) { + logger.warn( + "Docker capability: disabled. " + + "To enable: mount /var/run/docker.sock and /run/docker/plugins in docker-compose.yml", + ); + return false; + } +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 8db2014..50a8386 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -4,12 +4,14 @@ import { Hono } from "hono"; import { serveStatic } from "hono/bun"; import { logger as honoLogger } from "hono/logger"; import { openAPIRouteHandler } from "hono-openapi"; +import { getCapabilities } from "./core/capabilities"; import { runDbMigrations } from "./db/db"; import { authController } from "./modules/auth/auth.controller"; import { requireAuth } from "./modules/auth/auth.middleware"; import { driverController } from "./modules/driver/driver.controller"; import { startup } from "./modules/lifecycle/startup"; import { repositoriesController } from "./modules/repositories/repositories.controller"; +import { systemController } from "./modules/system/system.controller"; import { volumeController } from "./modules/volumes/volume.controller"; import { backupScheduleController } from "./modules/backups/backups.controller"; import { handleServiceError } from "./utils/errors"; @@ -41,6 +43,7 @@ const app = new Hono() .route("/api/v1/volumes", volumeController.use(requireAuth)) .route("/api/v1/repositories", repositoriesController.use(requireAuth)) .route("/api/v1/backups", backupScheduleController.use(requireAuth)) + .route("/api/v1/system", systemController.use(requireAuth)) .get("/assets/*", serveStatic({ root: "./assets/frontend" })) .get("/images/*", serveStatic({ root: "./assets/frontend" })) .get("*", serveStatic({ path: "./assets/frontend/index.html" })); @@ -60,15 +63,26 @@ app.onError((err, c) => { return c.json({ message }, status); }); -const socketPath = "/run/docker/plugins/ironmount.sock"; - -await fs.mkdir("/run/docker/plugins", { recursive: true }); runDbMigrations(); -Bun.serve({ - unix: socketPath, - fetch: driver.fetch, -}); +const { docker } = await getCapabilities(); + +if (docker) { + const socketPath = "/run/docker/plugins/ironmount.sock"; + + try { + await fs.mkdir("/run/docker/plugins", { recursive: true }); + + Bun.serve({ + unix: socketPath, + fetch: driver.fetch, + }); + + logger.info(`Docker volume plugin server running at ${socketPath}`); + } catch (error) { + logger.error(`Failed to start Docker volume plugin server: ${error}`); + } +} Bun.serve({ port: 4096, @@ -77,6 +91,6 @@ Bun.serve({ startup(); -logger.info(`Server is running at http://localhost:4096 and unix socket at ${socketPath}`); +logger.info(`Server is running at http://localhost:4096`); export type AppType = typeof app; diff --git a/apps/server/src/modules/system/system.controller.ts b/apps/server/src/modules/system/system.controller.ts new file mode 100644 index 0000000..5100f62 --- /dev/null +++ b/apps/server/src/modules/system/system.controller.ts @@ -0,0 +1,9 @@ +import { Hono } from "hono"; +import { systemInfoDto, type SystemInfoDto } from "./system.dto"; +import { systemService } from "./system.service"; + +export const systemController = new Hono().get("/info", systemInfoDto, async (c) => { + const info = await systemService.getSystemInfo(); + + return c.json(info, 200); +}); diff --git a/apps/server/src/modules/system/system.dto.ts b/apps/server/src/modules/system/system.dto.ts new file mode 100644 index 0000000..806a3c6 --- /dev/null +++ b/apps/server/src/modules/system/system.dto.ts @@ -0,0 +1,28 @@ +import { type } from "arktype"; +import { describeRoute, resolver } from "hono-openapi"; + +export const capabilitiesSchema = type({ + docker: "boolean", +}); + +export const systemInfoResponse = type({ + capabilities: capabilitiesSchema, +}); + +export type SystemInfoDto = typeof systemInfoResponse.infer; + +export const systemInfoDto = describeRoute({ + description: "Get system information including available capabilities", + tags: ["System"], + operationId: "getSystemInfo", + responses: { + 200: { + description: "System information with enabled capabilities", + content: { + "application/json": { + schema: resolver(systemInfoResponse), + }, + }, + }, + }, +}); diff --git a/apps/server/src/modules/system/system.service.ts b/apps/server/src/modules/system/system.service.ts new file mode 100644 index 0000000..8d02e1d --- /dev/null +++ b/apps/server/src/modules/system/system.service.ts @@ -0,0 +1,11 @@ +import { getCapabilities } from "../../core/capabilities"; + +const getSystemInfo = async () => { + return { + capabilities: await getCapabilities(), + }; +}; + +export const systemService = { + getSystemInfo, +}; diff --git a/apps/server/src/modules/volumes/volume.service.ts b/apps/server/src/modules/volumes/volume.service.ts index c32452d..cfdf583 100644 --- a/apps/server/src/modules/volumes/volume.service.ts +++ b/apps/server/src/modules/volumes/volume.service.ts @@ -6,6 +6,7 @@ import Docker from "dockerode"; import { eq } from "drizzle-orm"; import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced"; import slugify from "slugify"; +import { getCapabilities } from "../../core/capabilities"; import { db } from "../../db/db"; import { volumesTable } from "../../db/schema"; import { toMessage } from "../../utils/errors"; @@ -229,26 +230,37 @@ const getContainersUsingVolume = async (name: string) => { throw new NotFoundError("Volume not found"); } - const docker = new Docker(); - const containers = await docker.listContainers({ all: true }); - - const usingContainers = []; - for (const info of containers) { - const container = docker.getContainer(info.Id); - const inspect = await container.inspect(); - const mounts = inspect.Mounts || []; - const usesVolume = mounts.some((mount) => mount.Type === "volume" && mount.Name === `im-${volume.name}`); - if (usesVolume) { - usingContainers.push({ - id: inspect.Id, - name: inspect.Name, - state: inspect.State.Status, - image: inspect.Config.Image, - }); - } + const { docker } = await getCapabilities(); + if (!docker) { + logger.debug("Docker capability not available, returning empty containers list"); + return { containers: [] }; } - return { containers: usingContainers }; + try { + const docker = new Docker(); + const containers = await docker.listContainers({ all: true }); + + const usingContainers = []; + for (const info of containers) { + const container = docker.getContainer(info.Id); + const inspect = await container.inspect(); + const mounts = inspect.Mounts || []; + const usesVolume = mounts.some((mount) => mount.Type === "volume" && mount.Name === `im-${volume.name}`); + if (usesVolume) { + usingContainers.push({ + id: inspect.Id, + name: inspect.Name, + state: inspect.State.Status, + image: inspect.Config.Image, + }); + } + } + + return { containers: usingContainers }; + } catch (error) { + logger.error(`Failed to get containers using volume: ${toMessage(error)}`); + return { containers: [] }; + } }; const listFiles = async (name: string, subPath?: string) => { diff --git a/docker-compose.yml b/docker-compose.yml index dc5f3cd..9ad69db 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,15 +6,17 @@ services: target: development container_name: ironmount restart: unless-stopped - privileged: true + cap_add: + - SYS_ADMIN environment: - NODE_ENV=development ports: - "4096:4097" volumes: - - /var/run/docker.sock:/var/run/docker.sock - - /run/docker/plugins:/run/docker/plugins - - /proc:/host/proc:ro + # - /var/run/docker.sock:/var/run/docker.sock + # - /run/docker/plugins:/run/docker/plugins + # - /proc:/host/proc:ro + - /var/lib/repositories/:/var/lib/repositories - ironmount_data:/data - ./apps/client/app:/app/apps/client/app @@ -27,15 +29,16 @@ services: target: production container_name: ironmount restart: unless-stopped - privileged: true + cap_add: + - SYS_ADMIN ports: - "4096:4096" volumes: - - /var/run/docker.sock:/var/run/docker.sock - - /run/docker/plugins:/run/docker/plugins - - /var/lib/ironmount/volumes/:/var/lib/ironmount/volumes:rslave + # - /var/run/docker.sock:/var/run/docker.sock + # - /run/docker/plugins:/run/docker/plugins + # - /var/lib/ironmount/volumes/:/var/lib/ironmount/volumes:rslave + # - /proc:/host/proc:ro - /var/lib/repositories/:/var/lib/repositories - - /proc:/host/proc:ro - ironmount_data:/data volumes: