refactor: remove docker volum plugin functionnality (#137)

This commit is contained in:
Nico 2025-12-14 11:07:32 +01:00 committed by GitHub
parent 1eb247deec
commit 361311285a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 5 additions and 545 deletions

View file

@ -19,7 +19,6 @@ Zerobyte is a backup automation tool built on top of Restic that provides a web
- **Styling**: Tailwind CSS v4 + Radix UI components
- **Architecture**: Unified application structure (not a monorepo)
- **Code Quality**: Biome (formatter & linter)
- **Containerization**: Docker with multi-stage builds
## Repository Structure
@ -84,9 +83,7 @@ The server follows a modular service-oriented architecture:
**Entry Point**: `app/server/index.ts`
- Initializes servers using `react-router-hono-server`:
1. Main API server on port 4096 (REST API + serves static frontend)
2. Docker volume plugin server on Unix socket `/run/docker/plugins/zerobyte.sock` (optional, if Docker is available)
- Initializes main API server on port 4096 (REST API + serves static frontend)
**Modules** (`app/server/modules/`):
Each module follows a controller <20> service <20> database pattern:
@ -116,7 +113,7 @@ Cron-based background jobs managed by the Scheduler:
**Core** (`app/server/core/`):
- `scheduler.ts` - Job scheduling system using node-cron
- `capabilities.ts` - Detects available system features (Docker support, etc.)
- `capabilities.ts` - Detects available system features
- `constants.ts` - Application-wide constants
**Utils** (`app/server/utils/`):
@ -188,17 +185,6 @@ Zerobyte is a wrapper around Restic for backup operations. Key integration point
- Dynamically generates rclone config and passes via environment variables
- Supports backends like Dropbox, Google Drive, OneDrive, Backblaze B2, etc.
## Docker Volume Plugin
When Docker socket is available (`/var/run/docker.sock`), Zerobyte registers as a Docker volume plugin:
**Plugin Location**: `/run/docker/plugins/zerobyte.sock`
**Implementation**: `app/server/modules/driver/driver.controller.ts`
This allows other containers to mount Zerobyte volumes using Docker.
The plugin implements the Docker Volume Plugin API v1.
## Environment & Configuration
**Runtime Environment Variables**:
@ -212,7 +198,7 @@ The plugin implements the Docker Volume Plugin API v1.
**Capabilities Detection**:
On startup, the server detects available capabilities (see `core/capabilities.ts`):
- **Docker**: Requires `/var/run/docker.sock` access
- **rclone**: Requires `/root/.config/rclone` directory access
- System will gracefully degrade if capabilities are unavailable
## Common Workflows
@ -252,16 +238,3 @@ On startup, the server detects available capabilities (see `core/capabilities.ts
- **Security**: Restic password file has 0600 permissions - never expose it
- **Mounting**: Requires privileged container or CAP_SYS_ADMIN for FUSE mounts
- **API Documentation**: OpenAPI spec auto-generated at `/api/v1/openapi.json`, docs at `/api/v1/docs`
## Docker Development Setup
The `docker-compose.yml` defines two services:
- `zerobyte-dev` - Development with hot reload (uses `development` stage)
- `zerobyte-prod` - Production build (uses `production` stage)
Both mount:
- `/var/lib/zerobyte` for persistent data
- `/dev/fuse` device for FUSE mounting
- Optionally `/var/run/docker.sock` for Docker plugin functionality

View file

@ -196,97 +196,6 @@ Zerobyte allows you to easily restore your data from backups. To restore data, n
![Preview](https://github.com/nicotsx/zerobyte/blob/main/screenshots/restoring.png?raw=true)
## Propagating mounts to host
Zerobyte is capable of propagating mounted volumes from within the container to the host system. This is particularly useful when you want to access the mounted data directly from the host to use it with other applications or services.
In order to enable this feature, you need to change your bind mount `/var/lib/zerobyte` to use the `:rshared` flag. Here is an example of how to set this up in your `docker-compose.yml` file:
```diff
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.18
container_name: zerobyte
restart: unless-stopped
ports:
- "4096:4096"
devices:
- /dev/fuse:/dev/fuse
environment:
- TZ=Europe/Paris
volumes:
- /etc/localtime:/etc/localtime:ro
- - /var/lib/zerobyte:/var/lib/zerobyte
+ - /var/lib/zerobyte:/var/lib/zerobyte:rshared
```
Restart the Zerobyte container to apply the changes:
```bash
docker compose down
docker compose up -d
```
## Docker plugin
Zerobyte can also be used as a Docker volume plugin, allowing you to mount your volumes directly into other Docker containers. This enables seamless integration with your containerized applications.
In order to enable this feature, you need to run Zerobyte with several items shared from the host. Here is an example of how to set this up in your `docker-compose.yml` file:
```diff
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.18
container_name: zerobyte
restart: unless-stopped
cap_add:
- SYS_ADMIN
ports:
- "4096:4096"
devices:
- /dev/fuse:/dev/fuse
environment:
- TZ=Europe/Paris
volumes:
- /etc/localtime:/etc/localtime:ro
- - /var/lib/zerobyte:/var/lib/zerobyte
+ - /var/lib/zerobyte:/var/lib/zerobyte:rshared
+ - /run/docker/plugins:/run/docker/plugins
+ - /var/run/docker.sock:/var/run/docker.sock
```
Restart the Zerobyte container to apply the changes:
```bash
docker compose down
docker compose up -d
```
Your Zerobyte volumes will now be available as Docker volumes that you can mount into other containers using the `--volume` flag:
```bash
docker run -v zb-abc12:/path/in/container nginx:latest
```
Or using Docker Compose:
```yaml
services:
myservice:
image: nginx:latest
volumes:
- zb-abc12:/path/in/container
volumes:
zb-abc12:
external: true
```
The volume name format is `zb-<short-id>` where `<short-id>` is the unique identifier shown on the volume's Docker tab in Zerobyte. This short ID remains stable even if you rename the volume. You can verify that the volume is available by running:
```bash
docker volume ls
```
## Third-Party Software
This project includes the following third-party software components:

View file

@ -10,7 +10,7 @@ export function useSystemInfo() {
});
return {
capabilities: data?.capabilities ?? { docker: false, rclone: false },
capabilities: data?.capabilities ?? { rclone: false },
isLoading,
error,
systemInfo: data,

View file

@ -21,9 +21,6 @@ import { cn } from "~/client/lib/utils";
import type { Route } from "./+types/volume-details";
import { VolumeInfoTabContent } from "../tabs/info";
import { FilesTabContent } from "../tabs/files";
import { DockerTabContent } from "../tabs/docker";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
import { useSystemInfo } from "~/client/hooks/use-system-info";
import { getVolume } from "~/client/api-client";
import type { VolumeStatus } from "~/client/lib/types";
import {
@ -74,8 +71,6 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
initialData: loaderData,
});
const { capabilities } = useSystemInfo();
const deleteVol = useMutation({
...deleteVolumeMutation(),
onSuccess: () => {
@ -127,7 +122,6 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
}
const { volume, statfs } = data;
const dockerAvailable = capabilities.docker;
return (
<>
@ -170,16 +164,6 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
<TabsList className="mb-2">
<TabsTrigger value="info">Configuration</TabsTrigger>
<TabsTrigger value="files">Files</TabsTrigger>
<Tooltip>
<TooltipTrigger>
<TabsTrigger disabled={!dockerAvailable} value="docker">
Docker
</TabsTrigger>
</TooltipTrigger>
<TooltipContent className={cn({ hidden: dockerAvailable })}>
<p>Enable Docker support to access this tab.</p>
</TooltipContent>
</Tooltip>
</TabsList>
<TabsContent value="info">
<VolumeInfoTabContent volume={volume} statfs={statfs} />
@ -187,11 +171,6 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
<TabsContent value="files">
<FilesTabContent volume={volume} />
</TabsContent>
{dockerAvailable && (
<TabsContent value="docker">
<DockerTabContent volume={volume} />
</TabsContent>
)}
</Tabs>
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>

View file

@ -1,133 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { Unplug } from "lucide-react";
import * as YML from "yaml";
import { getContainersUsingVolumeOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { CodeBlock } from "~/client/components/ui/code-block";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
import type { Volume } from "~/client/lib/types";
type Props = {
volume: Volume;
};
export const DockerTabContent = ({ volume }: Props) => {
const yamlString = YML.stringify({
services: {
nginx: {
image: "nginx:latest",
volumes: [`zb-${volume.shortId}:/path/in/container`],
},
},
volumes: {
[`zb-${volume.shortId}`]: {
external: true,
},
},
});
const dockerRunCommand = `docker run -v zb-${volume.shortId}:/path/in/container nginx:latest`;
const {
data: containersData,
isLoading,
error,
} = useQuery({
...getContainersUsingVolumeOptions({ path: { name: volume.name } }),
refetchInterval: 10000,
refetchOnWindowFocus: true,
});
const containers = containersData || [];
const getStateClass = (state: string) => {
switch (state) {
case "running":
return "bg-green-100 text-green-800";
case "exited":
return "bg-orange-100 text-orange-800";
default:
return "bg-gray-100 text-gray-800";
}
};
return (
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<Card>
<CardHeader>
<CardTitle>Plug-and-play Docker integration</CardTitle>
<CardDescription>
This volume can be used in your Docker Compose files by referencing it as an external volume. The example
demonstrates how to mount the volume to a service (nginx in this case). Make sure to adjust the path inside
the container to fit your application's needs
</CardDescription>
</CardHeader>
<CardContent>
<div className="relative space-y-6">
<div className="space-y-4">
<div className="flex flex-col gap-4">
<CodeBlock code={yamlString} language="yaml" filename="docker-compose.yml" />
</div>
<div className="text-sm text-muted-foreground">
Alternatively, you can use the following command to run a Docker container with the volume mounted
</div>
<div className="flex flex-col gap-4">
<CodeBlock code={dockerRunCommand} filename="CLI one-liner" />
</div>
</div>
</div>
</CardContent>
</Card>
<div className="grid">
<Card>
<CardHeader>
<CardTitle>Containers Using This Volume</CardTitle>
<CardDescription>List of Docker containers mounting this volume.</CardDescription>
</CardHeader>
<CardContent className="space-y-4 text-sm h-full">
{isLoading && <div>Loading containers...</div>}
{error && <div className="text-destructive">Failed to load containers: {String(error)}</div>}
{!isLoading && !error && containers.length === 0 && (
<div className="flex flex-col items-center justify-center text-center h-full">
<Unplug className="mb-4 h-5 w-5 text-muted-foreground" />
<p className="text-muted-foreground">No Docker containers are currently using this volume.</p>
</div>
)}
{!isLoading && !error && containers.length > 0 && (
<div className="max-h-130 overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>ID</TableHead>
<TableHead>State</TableHead>
<TableHead>Image</TableHead>
</TableRow>
</TableHeader>
<TableBody className="text-sm">
{containers.map((container) => (
<TableRow key={container.id}>
<TableCell>{container.name}</TableCell>
<TableCell>{container.id.slice(0, 12)}</TableCell>
<TableCell>
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${getStateClass(container.state)}`}
>
{container.state}
</span>
</TableCell>
<TableCell>{container.image}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
};

View file

@ -1,9 +1,7 @@
import * as fs from "node:fs/promises";
import Docker from "dockerode";
import { logger } from "../utils/logger";
export type SystemCapabilities = {
docker: boolean;
rclone: boolean;
};
@ -28,44 +26,10 @@ export async function getCapabilities(): Promise<SystemCapabilities> {
*/
async function detectCapabilities(): Promise<SystemCapabilities> {
return {
docker: await detectDocker(),
rclone: await detectRclone(),
};
}
export const parseDockerHost = (dockerHost?: string) => {
const match = dockerHost?.match(/^(ssh|http|https):\/\/([^:]+)(?::(\d+))?$/);
if (match) {
const protocol = match[1] as "ssh" | "http" | "https";
const host = match[2];
const port = match[3] ? parseInt(match[3], 10) : undefined;
return { protocol, host, port };
}
return {};
};
/**
* 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<boolean> {
try {
const docker = new Docker(parseDockerHost(process.env.DOCKER_HOST));
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;
}
}
/**
* Checks if rclone is available by:
* 1. Checking if /root/.config/rclone directory exists and is accessible

View file

@ -3,6 +3,5 @@ export const VOLUME_MOUNT_BASE = "/var/lib/zerobyte/volumes";
export const REPOSITORY_BASE = "/var/lib/zerobyte/repositories";
export const DATABASE_URL = "/var/lib/zerobyte/data/ironmount.db";
export const RESTIC_PASS_FILE = "/var/lib/zerobyte/data/restic.pass";
export const SOCKET_PATH = "/run/docker/plugins/zerobyte.sock";
export const REQUIRED_MIGRATIONS = ["v0.14.0"];

View file

@ -1,14 +1,11 @@
import { createHonoServer } from "react-router-hono-server/bun";
import * as fs from "node:fs/promises";
import { Scalar } from "@scalar/hono-api-reference";
import { Hono } from "hono";
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 { migrateToShortIds } from "./modules/lifecycle/migration";
import { repositoriesController } from "./modules/repositories/repositories.controller";
@ -20,7 +17,7 @@ import { notificationsController } from "./modules/notifications/notifications.c
import { handleServiceError } from "./utils/errors";
import { logger } from "./utils/logger";
import { shutdown } from "./modules/lifecycle/shutdown";
import { REQUIRED_MIGRATIONS, SOCKET_PATH } from "./core/constants";
import { REQUIRED_MIGRATIONS } from "./core/constants";
import { validateRequiredMigrations } from "./modules/lifecycle/checkpoint";
export const generalDescriptor = (app: Hono) =>
@ -41,7 +38,6 @@ export const scalarDescriptor = Scalar({
url: "/api/v1/openapi.json",
});
const driver = new Hono().use(honoLogger()).route("/", driverController);
const app = new Hono()
.use(honoLogger())
.get("healthcheck", (c) => c.json({ status: "ok" }))
@ -73,23 +69,6 @@ runDbMigrations();
await migrateToShortIds();
await validateRequiredMigrations(REQUIRED_MIGRATIONS);
const { docker } = await getCapabilities();
if (docker) {
try {
await fs.mkdir("/run/docker/plugins", { recursive: true });
Bun.serve({
unix: SOCKET_PATH,
fetch: driver.fetch,
});
logger.info(`Docker volume plugin server running at ${SOCKET_PATH}`);
} catch (error) {
logger.error(`Failed to start Docker volume plugin server: ${error}`);
}
}
startup();
logger.info(`Server is running at http://localhost:4096`);

View file

@ -1,114 +0,0 @@
import { Hono } from "hono";
import { volumeService } from "../volumes/volume.service";
import { getVolumePath } from "../volumes/helpers";
import { eq } from "drizzle-orm";
import { db } from "../../db/db";
import { volumesTable } from "../../db/schema";
export const driverController = new Hono()
.post("/VolumeDriver.Capabilities", (c) => {
return c.json({
Capabilities: {
Scope: "global",
},
});
})
.post("/Plugin.Activate", (c) => {
return c.json({
Implements: ["VolumeDriver"],
});
})
.post("/VolumeDriver.Create", (_) => {
throw new Error("Volume creation is not supported via the driver");
})
.post("/VolumeDriver.Remove", (c) => {
return c.json({
Err: "",
});
})
.post("/VolumeDriver.Mount", async (c) => {
const body = await c.req.json();
if (!body.Name) {
return c.json({ Err: "Volume name is required" }, 400);
}
const shortId = body.Name.replace(/^zb-/, "");
const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.shortId, shortId),
});
if (!volume) {
return c.json({ Err: `Volume with shortId ${shortId} not found` }, 404);
}
return c.json({
Mountpoint: getVolumePath(volume),
});
})
.post("/VolumeDriver.Unmount", (c) => {
return c.json({
Err: "",
});
})
.post("/VolumeDriver.Path", async (c) => {
const body = await c.req.json();
if (!body.Name) {
return c.json({ Err: "Volume name is required" }, 400);
}
const shortId = body.Name.replace(/^zb-/, "");
const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.shortId, shortId),
});
if (!volume) {
return c.json({ Err: `Volume with shortId ${shortId} not found` }, 404);
}
return c.json({
Mountpoint: getVolumePath(volume),
});
})
.post("/VolumeDriver.Get", async (c) => {
const body = await c.req.json();
if (!body.Name) {
return c.json({ Err: "Volume name is required" }, 400);
}
const shortId = body.Name.replace(/^zb-/, "");
const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.shortId, shortId),
});
if (!volume) {
return c.json({ Err: `Volume with shortId ${shortId} not found` }, 404);
}
return c.json({
Volume: {
Name: `zb-${volume.shortId}`,
Mountpoint: getVolumePath(volume),
Status: {},
},
Err: "",
});
})
.post("/VolumeDriver.List", async (c) => {
const volumes = await volumeService.listVolumes();
const res = volumes.map((volume) => ({
Name: `zb-${volume.shortId}`,
Mountpoint: getVolumePath(volume),
Status: {},
}));
return c.json({
Volumes: res,
});
});

View file

@ -3,18 +3,11 @@ import { eq, or } from "drizzle-orm";
import { db } from "../../db/db";
import { volumesTable } from "../../db/schema";
import { logger } from "../../utils/logger";
import { SOCKET_PATH } from "../../core/constants";
import { createVolumeBackend } from "../backends/backend";
export const shutdown = async () => {
await Scheduler.stop();
await Bun.file(SOCKET_PATH)
.delete()
.catch(() => {
// Ignore errors if the socket file does not exist
});
const volumes = await db.query.volumesTable.findMany({
where: or(eq(volumesTable.status, "mounted")),
});

View file

@ -2,7 +2,6 @@ import { type } from "arktype";
import { describeRoute, resolver } from "hono-openapi";
export const capabilitiesSchema = type({
docker: "boolean",
rclone: "boolean",
});

View file

@ -4,7 +4,6 @@ import {
createVolumeBody,
createVolumeDto,
deleteVolumeDto,
getContainersDto,
getVolumeDto,
healthCheckDto,
type ListVolumesDto,
@ -18,7 +17,6 @@ import {
updateVolumeDto,
type CreateVolumeDto,
type GetVolumeDto,
type ListContainersDto,
type UpdateVolumeDto,
type ListFilesDto,
browseFilesystemDto,
@ -74,12 +72,6 @@ export const volumeController = new Hono()
return c.json<GetVolumeDto>(response, 200);
})
.get("/:name/containers", getContainersDto, async (c) => {
const { name } = c.req.param();
const { containers } = await volumeService.getContainersUsingVolume(name);
return c.json<ListContainersDto>(containers, 200);
})
.put("/:name", updateVolumeDto, validator("json", updateVolumeBody), async (c) => {
const { name } = c.req.param();
const body = c.req.valid("json");

View file

@ -262,38 +262,6 @@ export const healthCheckDto = describeRoute({
},
});
/**
* Get containers using a volume
*/
const containerSchema = type({
id: "string",
name: "string",
state: "string",
image: "string",
});
export const listContainersResponse = containerSchema.array();
export type ListContainersDto = typeof listContainersResponse.infer;
export const getContainersDto = describeRoute({
description: "Get containers using a volume by name",
operationId: "getContainersUsingVolume",
tags: ["Volumes"],
responses: {
200: {
description: "List of containers using the volume",
content: {
"application/json": {
schema: resolver(listContainersResponse),
},
},
},
404: {
description: "Volume not found",
},
},
});
/**
* List files in a volume
*/

View file

@ -1,11 +1,9 @@
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import Docker from "dockerode";
import { and, eq, ne } from "drizzle-orm";
import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
import slugify from "slugify";
import { getCapabilities, parseDockerHost } from "../../core/capabilities";
import { db } from "../../db/db";
import { volumesTable } from "../../db/schema";
import { cryptoUtils } from "../../utils/crypto";
@ -282,49 +280,6 @@ const checkHealth = async (name: string) => {
return { status, error };
};
const getContainersUsingVolume = async (name: string) => {
const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.name, name),
});
if (!volume) {
throw new NotFoundError("Volume not found");
}
const { docker } = await getCapabilities();
if (!docker) {
logger.debug("Docker capability not available, returning empty containers list");
return { containers: [] };
}
try {
const docker = new Docker(parseDockerHost(process.env.DOCKER_HOST));
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 === `zb-${volume.shortId}`);
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) => {
const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.name, name),
@ -443,7 +398,6 @@ export const volumeService = {
testConnection,
unmountVolume,
checkHealth,
getContainersUsingVolume,
listFiles,
browseFilesystem,
};

View file

@ -41,7 +41,6 @@
"cron-parser": "^5.4.0",
"date-fns": "^4.1.0",
"dither-plugin": "^1.1.1",
"dockerode": "^4.0.9",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.44.7",
"es-toolkit": "^1.42.0",
@ -72,7 +71,6 @@
"@tailwindcss/vite": "^4.1.17",
"@tanstack/react-query-devtools": "^5.91.1",
"@types/bun": "^1.3.3",
"@types/dockerode": "^3.3.47",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",