feat: rclone volume backend
This commit is contained in:
parent
4c0ba33b5e
commit
d5d3caad83
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
|
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 { arktypeResolver } from "@hookform/resolvers/arktype";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { type } from "arktype";
|
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 { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { cn, slugify } from "~/client/lib/utils";
|
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 { Input } from "./ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||||
import { volumeConfigSchema } from "~/schemas/volumes";
|
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({
|
export const formSchema = type({
|
||||||
name: "2<=string<=32",
|
name: "2<=string<=32",
|
||||||
|
|
@ -35,6 +38,7 @@ const defaultValuesForType = {
|
||||||
nfs: { backend: "nfs" as const, port: 2049, version: "4.1" as const },
|
nfs: { backend: "nfs" as const, port: 2049, version: "4.1" as const },
|
||||||
smb: { backend: "smb" as const, port: 445, vers: "3.0" as const },
|
smb: { backend: "smb" as const, port: 445, vers: "3.0" as const },
|
||||||
webdav: { backend: "webdav" as const, port: 80, ssl: false },
|
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) => {
|
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 { watch, getValues } = form;
|
||||||
|
|
||||||
|
const { capabilities } = useSystemInfo();
|
||||||
|
|
||||||
|
const { data: rcloneRemotes, isLoading: isLoadingRemotes } = useQuery({
|
||||||
|
...listRcloneRemotesOptions(),
|
||||||
|
enabled: capabilities.rclone,
|
||||||
|
});
|
||||||
|
|
||||||
const watchedBackend = watch("backend");
|
const watchedBackend = watch("backend");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -128,6 +139,16 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
||||||
<SelectItem value="nfs">NFS</SelectItem>
|
<SelectItem value="nfs">NFS</SelectItem>
|
||||||
<SelectItem value="smb">SMB</SelectItem>
|
<SelectItem value="smb">SMB</SelectItem>
|
||||||
<SelectItem value="webdav">WebDAV</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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormDescription>Choose the storage backend for this volume.</FormDescription>
|
<FormDescription>Choose the storage backend for this volume.</FormDescription>
|
||||||
|
|
@ -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="space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,12 @@ const getIconAndColor = (backend: BackendType) => {
|
||||||
color: "text-green-600 dark:text-green-400",
|
color: "text-green-600 dark:text-green-400",
|
||||||
label: "WebDAV",
|
label: "WebDAV",
|
||||||
};
|
};
|
||||||
|
case "rclone":
|
||||||
|
return {
|
||||||
|
icon: Cloud,
|
||||||
|
color: "text-cyan-600 dark:text-cyan-400",
|
||||||
|
label: "Rclone",
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
icon: Folder,
|
icon: Folder,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ export const BACKEND_TYPES = {
|
||||||
smb: "smb",
|
smb: "smb",
|
||||||
directory: "directory",
|
directory: "directory",
|
||||||
webdav: "webdav",
|
webdav: "webdav",
|
||||||
|
rclone: "rclone",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type BackendType = keyof typeof BACKEND_TYPES;
|
export type BackendType = keyof typeof BACKEND_TYPES;
|
||||||
|
|
@ -47,7 +48,14 @@ export const webdavConfigSchema = type({
|
||||||
ssl: "boolean?",
|
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;
|
export type BackendConfig = typeof volumeConfigSchema.infer;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import type { Volume } from "../../db/schema";
|
||||||
import { getVolumePath } from "../volumes/helpers";
|
import { getVolumePath } from "../volumes/helpers";
|
||||||
import { makeDirectoryBackend } from "./directory/directory-backend";
|
import { makeDirectoryBackend } from "./directory/directory-backend";
|
||||||
import { makeNfsBackend } from "./nfs/nfs-backend";
|
import { makeNfsBackend } from "./nfs/nfs-backend";
|
||||||
|
import { makeRcloneBackend } from "./rclone/rclone-backend";
|
||||||
import { makeSmbBackend } from "./smb/smb-backend";
|
import { makeSmbBackend } from "./smb/smb-backend";
|
||||||
import { makeWebdavBackend } from "./webdav/webdav-backend";
|
import { makeWebdavBackend } from "./webdav/webdav-backend";
|
||||||
|
|
||||||
|
|
@ -33,5 +34,8 @@ export const createVolumeBackend = (volume: Volume): VolumeBackend => {
|
||||||
case "webdav": {
|
case "webdav": {
|
||||||
return makeWebdavBackend(volume.config, path);
|
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