feat(volumes): read only mount mode

This commit is contained in:
Nicolas Meienberger
2025-11-08 10:01:54 +01:00
parent 418369c4ad
commit f5339d3708
9 changed files with 137 additions and 51 deletions

View File

@@ -4,10 +4,8 @@ import type { Options as ClientOptions, TDataShape, Client } from "./client";
import type { import type {
RegisterData, RegisterData,
RegisterResponses, RegisterResponses,
RegisterErrors,
LoginData, LoginData,
LoginResponses, LoginResponses,
LoginErrors,
LogoutData, LogoutData,
LogoutResponses, LogoutResponses,
GetMeData, GetMeData,
@@ -16,7 +14,6 @@ import type {
GetStatusResponses, GetStatusResponses,
ChangePasswordData, ChangePasswordData,
ChangePasswordResponses, ChangePasswordResponses,
ChangePasswordErrors,
ListVolumesData, ListVolumesData,
ListVolumesResponses, ListVolumesResponses,
CreateVolumeData, CreateVolumeData,
@@ -97,7 +94,7 @@ export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends
* Register a new user * Register a new user
*/ */
export const register = <ThrowOnError extends boolean = false>(options?: Options<RegisterData, ThrowOnError>) => { export const register = <ThrowOnError extends boolean = false>(options?: Options<RegisterData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).post<RegisterResponses, RegisterErrors, ThrowOnError>({ return (options?.client ?? _heyApiClient).post<RegisterResponses, unknown, ThrowOnError>({
url: "/api/v1/auth/register", url: "/api/v1/auth/register",
...options, ...options,
headers: { headers: {
@@ -111,7 +108,7 @@ export const register = <ThrowOnError extends boolean = false>(options?: Options
* Login with username and password * Login with username and password
*/ */
export const login = <ThrowOnError extends boolean = false>(options?: Options<LoginData, ThrowOnError>) => { export const login = <ThrowOnError extends boolean = false>(options?: Options<LoginData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).post<LoginResponses, LoginErrors, ThrowOnError>({ return (options?.client ?? _heyApiClient).post<LoginResponses, unknown, ThrowOnError>({
url: "/api/v1/auth/login", url: "/api/v1/auth/login",
...options, ...options,
headers: { headers: {
@@ -157,7 +154,7 @@ export const getStatus = <ThrowOnError extends boolean = false>(options?: Option
export const changePassword = <ThrowOnError extends boolean = false>( export const changePassword = <ThrowOnError extends boolean = false>(
options?: Options<ChangePasswordData, ThrowOnError>, options?: Options<ChangePasswordData, ThrowOnError>,
) => { ) => {
return (options?.client ?? _heyApiClient).post<ChangePasswordResponses, ChangePasswordErrors, ThrowOnError>({ return (options?.client ?? _heyApiClient).post<ChangePasswordResponses, unknown, ThrowOnError>({
url: "/api/v1/auth/change-password", url: "/api/v1/auth/change-password",
...options, ...options,
headers: { headers: {

View File

@@ -10,13 +10,6 @@ export type RegisterData = {
url: "/api/v1/auth/register"; url: "/api/v1/auth/register";
}; };
export type RegisterErrors = {
/**
* Invalid request or username already exists
*/
400: unknown;
};
export type RegisterResponses = { export type RegisterResponses = {
/** /**
* User created successfully * User created successfully
@@ -43,13 +36,6 @@ export type LoginData = {
url: "/api/v1/auth/login"; url: "/api/v1/auth/login";
}; };
export type LoginErrors = {
/**
* Invalid credentials
*/
401: unknown;
};
export type LoginResponses = { export type LoginResponses = {
/** /**
* Login successful * Login successful
@@ -135,17 +121,6 @@ export type ChangePasswordData = {
url: "/api/v1/auth/change-password"; url: "/api/v1/auth/change-password";
}; };
export type ChangePasswordErrors = {
/**
* Invalid current password or validation error
*/
400: unknown;
/**
* Not authenticated
*/
401: unknown;
};
export type ChangePasswordResponses = { export type ChangePasswordResponses = {
/** /**
* Password changed successfully * Password changed successfully

View File

@@ -202,6 +202,31 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
</FormItem> </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>
)}
/>
</> </>
)} )}
@@ -301,6 +326,31 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
</FormItem> </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>
)}
/>
</> </>
)} )}
@@ -422,6 +472,31 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
</FormItem> </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>
)}
/>
</> </>
)} )}

View File

@@ -17,16 +17,20 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "~/components/ui/alert-dialog"; } from "~/components/ui/alert-dialog";
import type { Snapshot } from "~/lib/types"; import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
import type { Snapshot, Volume } from "~/lib/types";
import { toast } from "sonner"; import { toast } from "sonner";
interface Props { interface Props {
snapshot: Snapshot; snapshot: Snapshot;
repositoryName: string; repositoryName: string;
volume?: Volume;
} }
export const SnapshotFileBrowser = (props: Props) => { export const SnapshotFileBrowser = (props: Props) => {
const { snapshot, repositoryName } = props; const { snapshot, repositoryName, volume } = props;
const isReadOnly = volume?.config && "readOnly" in volume.config && volume.config.readOnly === true;
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set()); const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
@@ -195,11 +199,28 @@ export const SnapshotFileBrowser = (props: Props) => {
<CardDescription>{`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`}</CardDescription> <CardDescription>{`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`}</CardDescription>
</div> </div>
{selectedPaths.size > 0 && ( {selectedPaths.size > 0 && (
<Button onClick={handleRestoreClick} variant="primary" size="sm" disabled={isRestoring}> <Tooltip>
<TooltipTrigger asChild>
<span tabIndex={isReadOnly ? 0 : undefined}>
<Button
onClick={handleRestoreClick}
variant="primary"
size="sm"
disabled={isRestoring || isReadOnly}
>
{isRestoring {isRestoring
? "Restoring..." ? "Restoring..."
: `Restore ${selectedPaths.size} selected ${selectedPaths.size === 1 ? "item" : "items"}`} : `Restore ${selectedPaths.size} selected ${selectedPaths.size === 1 ? "item" : "items"}`}
</Button> </Button>
</span>
</TooltipTrigger>
{isReadOnly && (
<TooltipContent className="text-center">
<p>Volume is mounted as read-only.</p>
<p>Please remount with read-only disabled to restore files.</p>
</TooltipContent>
)}
</Tooltip>
)} )}
</div> </div>
</CardHeader> </CardHeader>

View File

@@ -181,6 +181,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
key={selectedSnapshot?.short_id} key={selectedSnapshot?.short_id}
snapshot={selectedSnapshot} snapshot={selectedSnapshot}
repositoryName={schedule.repository.name} repositoryName={schedule.repository.name}
volume={schedule.volume}
/> />
)} )}
</div> </div>

View File

@@ -22,7 +22,7 @@ const mount = async (config: BackendConfig, path: string) => {
return { status: BACKEND_STATUS.error, error: "NFS mounting is only supported on Linux hosts." }; return { status: BACKEND_STATUS.error, error: "NFS mounting is only supported on Linux hosts." };
} }
const { status } = await checkHealth(path); const { status } = await checkHealth(path, config.readOnly);
if (status === "mounted") { if (status === "mounted") {
return { status: BACKEND_STATUS.mounted }; return { status: BACKEND_STATUS.mounted };
} }
@@ -35,6 +35,9 @@ const mount = async (config: BackendConfig, path: string) => {
const source = `${config.server}:${config.exportPath}`; const source = `${config.server}:${config.exportPath}`;
const options = [`vers=${config.version}`, `port=${config.port}`]; const options = [`vers=${config.version}`, `port=${config.port}`];
if (config.readOnly) {
options.push("ro");
}
const args = ["-t", "nfs", "-o", options.join(","), source, path]; const args = ["-t", "nfs", "-o", options.join(","), source, path];
logger.debug(`Mounting volume ${path}...`); logger.debug(`Mounting volume ${path}...`);
@@ -84,7 +87,7 @@ const unmount = async (path: string) => {
} }
}; };
const checkHealth = async (path: string) => { const checkHealth = async (path: string, readOnly: boolean) => {
const run = async () => { const run = async () => {
logger.debug(`Checking health of NFS volume at ${path}...`); logger.debug(`Checking health of NFS volume at ${path}...`);
await fs.access(path); await fs.access(path);
@@ -95,7 +98,9 @@ const checkHealth = async (path: string) => {
throw new Error(`Path ${path} is not mounted as NFS.`); throw new Error(`Path ${path} is not mounted as NFS.`);
} }
if (!readOnly) {
await createTestFile(path); await createTestFile(path);
}
logger.debug(`NFS volume at ${path} is healthy and mounted.`); logger.debug(`NFS volume at ${path} is healthy and mounted.`);
return { status: BACKEND_STATUS.mounted }; return { status: BACKEND_STATUS.mounted };
@@ -112,5 +117,5 @@ const checkHealth = async (path: string) => {
export const makeNfsBackend = (config: BackendConfig, path: string): VolumeBackend => ({ export const makeNfsBackend = (config: BackendConfig, path: string): VolumeBackend => ({
mount: () => mount(config, path), mount: () => mount(config, path),
unmount: () => unmount(path), unmount: () => unmount(path),
checkHealth: () => checkHealth(path), checkHealth: () => checkHealth(path, config.readOnly),
}); });

View File

@@ -22,7 +22,7 @@ const mount = async (config: BackendConfig, path: string) => {
return { status: BACKEND_STATUS.error, error: "SMB mounting is only supported on Linux hosts." }; return { status: BACKEND_STATUS.error, error: "SMB mounting is only supported on Linux hosts." };
} }
const { status } = await checkHealth(path); const { status } = await checkHealth(path, config.readOnly);
if (status === "mounted") { if (status === "mounted") {
return { status: BACKEND_STATUS.mounted }; return { status: BACKEND_STATUS.mounted };
} }
@@ -47,6 +47,10 @@ const mount = async (config: BackendConfig, path: string) => {
options.push(`domain=${config.domain}`); options.push(`domain=${config.domain}`);
} }
if (config.readOnly) {
options.push("ro");
}
const args = ["-t", "cifs", "-o", options.join(","), source, path]; const args = ["-t", "cifs", "-o", options.join(","), source, path];
logger.debug(`Mounting SMB volume ${path}...`); logger.debug(`Mounting SMB volume ${path}...`);
@@ -96,7 +100,7 @@ const unmount = async (path: string) => {
} }
}; };
const checkHealth = async (path: string) => { const checkHealth = async (path: string, readOnly: boolean) => {
const run = async () => { const run = async () => {
logger.debug(`Checking health of SMB volume at ${path}...`); logger.debug(`Checking health of SMB volume at ${path}...`);
await fs.access(path); await fs.access(path);
@@ -107,7 +111,9 @@ const checkHealth = async (path: string) => {
throw new Error(`Path ${path} is not mounted as CIFS/SMB.`); throw new Error(`Path ${path} is not mounted as CIFS/SMB.`);
} }
if (!readOnly) {
await createTestFile(path); await createTestFile(path);
}
logger.debug(`SMB volume at ${path} is healthy and mounted.`); logger.debug(`SMB volume at ${path} is healthy and mounted.`);
return { status: BACKEND_STATUS.mounted }; return { status: BACKEND_STATUS.mounted };
@@ -124,5 +130,5 @@ const checkHealth = async (path: string) => {
export const makeSmbBackend = (config: BackendConfig, path: string): VolumeBackend => ({ export const makeSmbBackend = (config: BackendConfig, path: string): VolumeBackend => ({
mount: () => mount(config, path), mount: () => mount(config, path),
unmount: () => unmount(path), unmount: () => unmount(path),
checkHealth: () => checkHealth(path), checkHealth: () => checkHealth(path, config.readOnly),
}); });

View File

@@ -26,7 +26,7 @@ const mount = async (config: BackendConfig, path: string) => {
return { status: BACKEND_STATUS.error, error: "WebDAV mounting is only supported on Linux hosts." }; return { status: BACKEND_STATUS.error, error: "WebDAV mounting is only supported on Linux hosts." };
} }
const { status } = await checkHealth(path); const { status } = await checkHealth(path, config.readOnly);
if (status === "mounted") { if (status === "mounted") {
return { status: BACKEND_STATUS.mounted }; return { status: BACKEND_STATUS.mounted };
} }
@@ -44,7 +44,9 @@ const mount = async (config: BackendConfig, path: string) => {
const port = config.port !== defaultPort ? `:${config.port}` : ""; const port = config.port !== defaultPort ? `:${config.port}` : "";
const source = `${protocol}://${config.server}${port}${config.path}`; const source = `${protocol}://${config.server}${port}${config.path}`;
const options = ["uid=1000", "gid=1000", "file_mode=0664", "dir_mode=0775"]; const options = config.readOnly
? ["uid=1000", "gid=1000", "file_mode=0444", "dir_mode=0555", "ro"]
: ["uid=1000", "gid=1000", "file_mode=0664", "dir_mode=0775"];
if (config.username && config.password) { if (config.username && config.password) {
const secretsFile = "/etc/davfs2/secrets"; const secretsFile = "/etc/davfs2/secrets";
@@ -132,7 +134,7 @@ const unmount = async (path: string) => {
} }
}; };
const checkHealth = async (path: string) => { const checkHealth = async (path: string, readOnly: boolean) => {
const run = async () => { const run = async () => {
logger.debug(`Checking health of WebDAV volume at ${path}...`); logger.debug(`Checking health of WebDAV volume at ${path}...`);
await fs.access(path); await fs.access(path);
@@ -143,7 +145,9 @@ const checkHealth = async (path: string) => {
throw new Error(`Path ${path} is not mounted as WebDAV.`); throw new Error(`Path ${path} is not mounted as WebDAV.`);
} }
if (!readOnly) {
await createTestFile(path); await createTestFile(path);
}
logger.debug(`WebDAV volume at ${path} is healthy and mounted.`); logger.debug(`WebDAV volume at ${path} is healthy and mounted.`);
return { status: BACKEND_STATUS.mounted }; return { status: BACKEND_STATUS.mounted };
@@ -160,5 +164,5 @@ const checkHealth = async (path: string) => {
export const makeWebdavBackend = (config: BackendConfig, path: string): VolumeBackend => ({ export const makeWebdavBackend = (config: BackendConfig, path: string): VolumeBackend => ({
mount: () => mount(config, path), mount: () => mount(config, path),
unmount: () => unmount(path), unmount: () => unmount(path),
checkHealth: () => checkHealth(path), checkHealth: () => checkHealth(path, config.readOnly),
}); });

View File

@@ -15,6 +15,7 @@ export const nfsConfigSchema = type({
exportPath: "string", exportPath: "string",
port: type("string.integer").or(type("number")).to("1 <= number <= 65536").default(2049), port: type("string.integer").or(type("number")).to("1 <= number <= 65536").default(2049),
version: "'3' | '4' | '4.1'", version: "'3' | '4' | '4.1'",
readOnly: "boolean?",
}); });
export const smbConfigSchema = type({ export const smbConfigSchema = type({
@@ -26,6 +27,7 @@ export const smbConfigSchema = type({
vers: type("'1.0' | '2.0' | '2.1' | '3.0'").default("3.0"), vers: type("'1.0' | '2.0' | '2.1' | '3.0'").default("3.0"),
domain: "string?", domain: "string?",
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(445), port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(445),
readOnly: "boolean?",
}); });
export const directoryConfigSchema = type({ export const directoryConfigSchema = type({