feat: rclone volume backend (#123)
This commit is contained in:
parent
4c0ba33b5e
commit
9199a743db
6 changed files with 273 additions and 14 deletions
|
|
@ -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
|
||||
|
||||
|
||||
# ------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
128
app/server/modules/backends/rclone/rclone-backend.ts
Normal file
128
app/server/modules/backends/rclone/rclone-backend.ts
Normal 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),
|
||||
});
|
||||
Loading…
Reference in a new issue