feat: rclone volume backend (#123)

This commit is contained in:
Nico 2025-12-10 20:20:49 +01:00 committed by GitHub
parent 4c0ba33b5e
commit 9199a743db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 273 additions and 14 deletions

View file

@ -2,7 +2,7 @@ ARG BUN_VERSION="1.3.3"
FROM oven/bun:${BUN_VERSION}-alpine AS base
RUN apk add --no-cache davfs2=1.6.1-r2 openssh-client
RUN apk add --no-cache davfs2=1.6.1-r2 openssh-client fuse3
# ------------------------------

View file

@ -1,7 +1,7 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import { type } from "arktype";
import { CheckCircle, Loader2, Pencil, Plug, Save, XCircle } from "lucide-react";
import { CheckCircle, ExternalLink, Loader2, Pencil, Plug, Save, XCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { cn, slugify } from "~/client/lib/utils";
@ -12,7 +12,10 @@ import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, For
import { Input } from "./ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { volumeConfigSchema } from "~/schemas/volumes";
import { testConnectionMutation } from "../api-client/@tanstack/react-query.gen";
import { listRcloneRemotesOptions, testConnectionMutation } from "../api-client/@tanstack/react-query.gen";
import { Alert, AlertDescription } from "./ui/alert";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { useSystemInfo } from "~/client/hooks/use-system-info";
export const formSchema = type({
name: "2<=string<=32",
@ -35,6 +38,7 @@ const defaultValuesForType = {
nfs: { backend: "nfs" as const, port: 2049, version: "4.1" as const },
smb: { backend: "smb" as const, port: 445, vers: "3.0" as const },
webdav: { backend: "webdav" as const, port: 80, ssl: false },
rclone: { backend: "rclone" as const, remote: "", path: "" },
};
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => {
@ -49,6 +53,13 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
const { watch, getValues } = form;
const { capabilities } = useSystemInfo();
const { data: rcloneRemotes, isLoading: isLoadingRemotes } = useQuery({
...listRcloneRemotesOptions(),
enabled: capabilities.rclone,
});
const watchedBackend = watch("backend");
useEffect(() => {
@ -122,14 +133,24 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
<SelectTrigger>
<SelectValue placeholder="Select a backend" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="directory">Directory</SelectItem>
<SelectItem value="nfs">NFS</SelectItem>
<SelectItem value="smb">SMB</SelectItem>
<SelectItem value="webdav">WebDAV</SelectItem>
</SelectContent>
</Select>
</FormControl>
<SelectContent>
<SelectItem value="directory">Directory</SelectItem>
<SelectItem value="nfs">NFS</SelectItem>
<SelectItem value="smb">SMB</SelectItem>
<SelectItem value="webdav">WebDAV</SelectItem>
<Tooltip>
<TooltipTrigger>
<SelectItem disabled={!capabilities.rclone} value="rclone">
rclone (40+ cloud providers)
</SelectItem>
</TooltipTrigger>
<TooltipContent className={cn({ hidden: capabilities.rclone })}>
<p>Setup rclone to use this backend</p>
</TooltipContent>
</Tooltip>
</SelectContent>
</Select>
<FormDescription>Choose the storage backend for this volume.</FormDescription>
<FormMessage />
</FormItem>
@ -545,7 +566,99 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
</>
)}
{watchedBackend && watchedBackend !== "directory" && (
{watchedBackend === "rclone" &&
(!rcloneRemotes || rcloneRemotes.length === 0 ? (
<Alert>
<AlertDescription className="space-y-2">
<p className="font-medium">No rclone remotes configured</p>
<p className="text-sm text-muted-foreground">
To use rclone, you need to configure remotes on your host system
</p>
<a
href="https://rclone.org/docs/"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-strong-accent inline-flex items-center gap-1"
>
View rclone documentation
<ExternalLink className="w-3 h-3" />
</a>
</AlertDescription>
</Alert>
) : (
<>
<FormField
control={form.control}
name="remote"
render={({ field }) => (
<FormItem>
<FormLabel>Remote</FormLabel>
<Select onValueChange={(v) => field.onChange(v)} defaultValue={field.value} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an rclone remote" />
</SelectTrigger>
</FormControl>
<SelectContent>
{isLoadingRemotes ? (
<SelectItem value="loading" disabled>
Loading remotes...
</SelectItem>
) : (
rcloneRemotes.map((remote: { name: string; type: string }) => (
<SelectItem key={remote.name} value={remote.name}>
{remote.name} ({remote.type})
</SelectItem>
))
)}
</SelectContent>
</Select>
<FormDescription>Select the rclone remote configured on your host system.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="path"
render={({ field }) => (
<FormItem>
<FormLabel>Path</FormLabel>
<FormControl>
<Input placeholder="/" {...field} />
</FormControl>
<FormDescription>Path on the remote to mount. Use "/" for the root.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="readOnly"
defaultValue={false}
render={({ field }) => (
<FormItem>
<FormLabel>Read-only Mode</FormLabel>
<FormControl>
<div className="flex items-center space-x-2">
<input
type="checkbox"
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
className="rounded border-gray-300"
/>
<span className="text-sm">Mount volume as read-only</span>
</div>
</FormControl>
<FormDescription>
Prevent any modifications to the volume. Recommended for backup sources and sensitive data.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
))} {watchedBackend && watchedBackend !== "directory" && watchedBackend !== "rclone" && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Button

View file

@ -31,6 +31,12 @@ const getIconAndColor = (backend: BackendType) => {
color: "text-green-600 dark:text-green-400",
label: "WebDAV",
};
case "rclone":
return {
icon: Cloud,
color: "text-cyan-600 dark:text-cyan-400",
label: "Rclone",
};
default:
return {
icon: Folder,

View file

@ -5,6 +5,7 @@ export const BACKEND_TYPES = {
smb: "smb",
directory: "directory",
webdav: "webdav",
rclone: "rclone",
} as const;
export type BackendType = keyof typeof BACKEND_TYPES;
@ -47,7 +48,14 @@ export const webdavConfigSchema = type({
ssl: "boolean?",
});
export const volumeConfigSchema = nfsConfigSchema.or(smbConfigSchema).or(webdavConfigSchema).or(directoryConfigSchema);
export const rcloneConfigSchema = type({
backend: "'rclone'",
remote: "string",
path: "string",
readOnly: "boolean?",
});
export const volumeConfigSchema = nfsConfigSchema.or(smbConfigSchema).or(webdavConfigSchema).or(directoryConfigSchema).or(rcloneConfigSchema);
export type BackendConfig = typeof volumeConfigSchema.infer;

View file

@ -3,6 +3,7 @@ import type { Volume } from "../../db/schema";
import { getVolumePath } from "../volumes/helpers";
import { makeDirectoryBackend } from "./directory/directory-backend";
import { makeNfsBackend } from "./nfs/nfs-backend";
import { makeRcloneBackend } from "./rclone/rclone-backend";
import { makeSmbBackend } from "./smb/smb-backend";
import { makeWebdavBackend } from "./webdav/webdav-backend";
@ -33,5 +34,8 @@ export const createVolumeBackend = (volume: Volume): VolumeBackend => {
case "webdav": {
return makeWebdavBackend(volume.config, path);
}
case "rclone": {
return makeRcloneBackend(volume.config, path);
}
}
};

View file

@ -0,0 +1,128 @@
import * as fs from "node:fs/promises";
import * as os from "node:os";
import { $ } from "bun";
import { OPERATION_TIMEOUT } from "../../../core/constants";
import { toMessage } from "../../../utils/errors";
import { logger } from "../../../utils/logger";
import { getMountForPath } from "../../../utils/mountinfo";
import { withTimeout } from "../../../utils/timeout";
import type { VolumeBackend } from "../backend";
import { executeUnmount } from "../utils/backend-utils";
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
const mount = async (config: BackendConfig, path: string) => {
logger.debug(`Mounting rclone volume ${path}...`);
if (config.backend !== "rclone") {
logger.error("Provided config is not for rclone backend");
return { status: BACKEND_STATUS.error, error: "Provided config is not for rclone backend" };
}
if (os.platform() !== "linux") {
logger.error("Rclone mounting is only supported on Linux hosts.");
return { status: BACKEND_STATUS.error, error: "Rclone mounting is only supported on Linux hosts." };
}
const { status } = await checkHealth(path);
if (status === "mounted") {
return { status: BACKEND_STATUS.mounted };
}
logger.debug(`Trying to unmount any existing mounts at ${path} before mounting...`);
await unmount(path);
const run = async () => {
await fs.mkdir(path, { recursive: true });
const remotePath = `${config.remote}:${config.path}`;
const args = ["mount", remotePath, path, "--daemon"];
if (config.readOnly) {
args.push("--read-only");
}
args.push("--vfs-cache-mode", "writes");
args.push("--allow-non-empty");
args.push("--allow-other");
logger.debug(`Mounting rclone volume ${path}...`);
logger.info(`Executing rclone: rclone ${args.join(" ")}`);
const result = await $`rclone ${args}`.nothrow();
if (result.exitCode !== 0) {
const errorMsg = result.stderr.toString() || result.stdout.toString() || "Unknown error";
throw new Error(`Failed to mount rclone volume: ${errorMsg}`);
}
logger.info(`Rclone volume at ${path} mounted successfully.`);
return { status: BACKEND_STATUS.mounted };
};
try {
return await withTimeout(run(), OPERATION_TIMEOUT, "Rclone mount");
} catch (error) {
const errorMsg = toMessage(error);
logger.error("Error mounting rclone volume", { error: errorMsg });
return { status: BACKEND_STATUS.error, error: errorMsg };
}
};
const unmount = async (path: string) => {
if (os.platform() !== "linux") {
logger.error("Rclone unmounting is only supported on Linux hosts.");
return { status: BACKEND_STATUS.error, error: "Rclone unmounting is only supported on Linux hosts." };
}
const run = async () => {
try {
await fs.access(path);
} catch (e) {
logger.warn(`Path ${path} does not exist. Skipping unmount.`, e);
return { status: BACKEND_STATUS.unmounted };
}
await executeUnmount(path);
await fs.rmdir(path);
logger.info(`Rclone volume at ${path} unmounted successfully.`);
return { status: BACKEND_STATUS.unmounted };
};
try {
return await withTimeout(run(), OPERATION_TIMEOUT, "Rclone unmount");
} catch (error) {
logger.error("Error unmounting rclone volume", { path, error: toMessage(error) });
return { status: BACKEND_STATUS.error, error: toMessage(error) };
}
};
const checkHealth = async (path: string) => {
const run = async () => {
logger.debug(`Checking health of rclone volume at ${path}...`);
await fs.access(path);
const mount = await getMountForPath(path);
if (!mount || mount.fstype !== "fuse.rclone") {
throw new Error(`Path ${path} is not mounted as rclone.`);
}
logger.debug(`Rclone volume at ${path} is healthy and mounted.`);
return { status: BACKEND_STATUS.mounted };
};
try {
return await withTimeout(run(), OPERATION_TIMEOUT, "Rclone health check");
} catch (error) {
logger.error("Rclone volume health check failed:", toMessage(error));
return { status: BACKEND_STATUS.error, error: toMessage(error) };
}
};
export const makeRcloneBackend = (config: BackendConfig, path: string): VolumeBackend => ({
mount: () => mount(config, path),
unmount: () => unmount(path),
checkHealth: () => checkHealth(path),
});