feat: exclude specific xattr during restore

This commit is contained in:
Nicolas Meienberger
2025-11-22 17:39:08 +01:00
parent 6c30e7e357
commit a622b5e689
8 changed files with 179 additions and 25 deletions

View File

@@ -756,6 +756,15 @@ export type ListRepositoriesResponses = {
password?: string; password?: string;
path?: string; path?: string;
username?: string; username?: string;
} | {
backend: 'sftp';
host: string;
path: string;
privateKey: string;
user: string;
port?: number;
customPassword?: string;
isExistingRepository?: boolean;
}; };
createdAt: number; createdAt: number;
id: string; id: string;
@@ -763,7 +772,7 @@ export type ListRepositoriesResponses = {
lastError: string | null; lastError: string | null;
name: string; name: string;
status: 'error' | 'healthy' | 'unknown' | null; status: 'error' | 'healthy' | 'unknown' | null;
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3'; type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
updatedAt: number; updatedAt: number;
}>; }>;
}; };
@@ -823,6 +832,15 @@ export type CreateRepositoryData = {
password?: string; password?: string;
path?: string; path?: string;
username?: string; username?: string;
} | {
backend: 'sftp';
host: string;
path: string;
privateKey: string;
user: string;
port?: number;
customPassword?: string;
isExistingRepository?: boolean;
}; };
name: string; name: string;
compressionMode?: 'auto' | 'better' | 'fastest' | 'max' | 'off'; compressionMode?: 'auto' | 'better' | 'fastest' | 'max' | 'off';
@@ -952,6 +970,15 @@ export type GetRepositoryResponses = {
password?: string; password?: string;
path?: string; path?: string;
username?: string; username?: string;
} | {
backend: 'sftp';
host: string;
path: string;
privateKey: string;
user: string;
port?: number;
customPassword?: string;
isExistingRepository?: boolean;
}; };
createdAt: number; createdAt: number;
id: string; id: string;
@@ -959,7 +986,7 @@ export type GetRepositoryResponses = {
lastError: string | null; lastError: string | null;
name: string; name: string;
status: 'error' | 'healthy' | 'unknown' | null; status: 'error' | 'healthy' | 'unknown' | null;
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3'; type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
updatedAt: number; updatedAt: number;
}; };
}; };
@@ -1084,6 +1111,7 @@ export type RestoreSnapshotData = {
snapshotId: string; snapshotId: string;
delete?: boolean; delete?: boolean;
exclude?: Array<string>; exclude?: Array<string>;
excludeXattr?: Array<string>;
include?: Array<string>; include?: Array<string>;
}; };
path: { path: {
@@ -1208,6 +1236,15 @@ export type ListBackupSchedulesResponses = {
password?: string; password?: string;
path?: string; path?: string;
username?: string; username?: string;
} | {
backend: 'sftp';
host: string;
path: string;
privateKey: string;
user: string;
port?: number;
customPassword?: string;
isExistingRepository?: boolean;
}; };
createdAt: number; createdAt: number;
id: string; id: string;
@@ -1215,7 +1252,7 @@ export type ListBackupSchedulesResponses = {
lastError: string | null; lastError: string | null;
name: string; name: string;
status: 'error' | 'healthy' | 'unknown' | null; status: 'error' | 'healthy' | 'unknown' | null;
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3'; type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
updatedAt: number; updatedAt: number;
}; };
repositoryId: string; repositoryId: string;
@@ -1430,6 +1467,15 @@ export type GetBackupScheduleResponses = {
password?: string; password?: string;
path?: string; path?: string;
username?: string; username?: string;
} | {
backend: 'sftp';
host: string;
path: string;
privateKey: string;
user: string;
port?: number;
customPassword?: string;
isExistingRepository?: boolean;
}; };
createdAt: number; createdAt: number;
id: string; id: string;
@@ -1437,7 +1483,7 @@ export type GetBackupScheduleResponses = {
lastError: string | null; lastError: string | null;
name: string; name: string;
status: 'error' | 'healthy' | 'unknown' | null; status: 'error' | 'healthy' | 'unknown' | null;
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3'; type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
updatedAt: number; updatedAt: number;
}; };
repositoryId: string; repositoryId: string;
@@ -1633,6 +1679,15 @@ export type GetBackupScheduleForVolumeResponses = {
password?: string; password?: string;
path?: string; path?: string;
username?: string; username?: string;
} | {
backend: 'sftp';
host: string;
path: string;
privateKey: string;
user: string;
port?: number;
customPassword?: string;
isExistingRepository?: boolean;
}; };
createdAt: number; createdAt: number;
id: string; id: string;
@@ -1640,7 +1695,7 @@ export type GetBackupScheduleForVolumeResponses = {
lastError: string | null; lastError: string | null;
name: string; name: string;
status: 'error' | 'healthy' | 'unknown' | null; status: 'error' | 'healthy' | 'unknown' | null;
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3'; type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
updatedAt: number; updatedAt: number;
}; };
repositoryId: string; repositoryId: string;

View File

@@ -42,7 +42,7 @@ export const StatusDot = ({ variant, label, animated }: StatusDotProps) => {
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>
<span className="relative flex size-3 mx-auto"> <span className="relative flex size-3 mx-auto">
{statusMapping.animated && ( {statusMapping?.animated && (
<span <span
className={cn( className={cn(
"absolute inline-flex h-full w-full animate-ping rounded-full opacity-75", "absolute inline-flex h-full w-full animate-ping rounded-full opacity-75",
@@ -50,7 +50,7 @@ export const StatusDot = ({ variant, label, animated }: StatusDotProps) => {
)} )}
/> />
)} )}
<span className={cn("relative inline-flex size-3 rounded-full", `${statusMapping.color}`)} /> <span className={cn("relative inline-flex size-3 rounded-full", `${statusMapping?.color}`)} />
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>

View File

@@ -1,11 +1,12 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FileIcon } from "lucide-react"; import { ChevronDown, FileIcon } from "lucide-react";
import { FileTree } from "~/client/components/file-tree"; import { FileTree } from "~/client/components/file-tree";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { Checkbox } from "~/client/components/ui/checkbox"; import { Checkbox } from "~/client/components/ui/checkbox";
import { Label } from "~/client/components/ui/label"; import { Label } from "~/client/components/ui/label";
import { Input } from "~/client/components/ui/input";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -39,6 +40,8 @@ export const SnapshotFileBrowser = (props: Props) => {
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set()); const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
const [showRestoreDialog, setShowRestoreDialog] = useState(false); const [showRestoreDialog, setShowRestoreDialog] = useState(false);
const [deleteExtraFiles, setDeleteExtraFiles] = useState(false); const [deleteExtraFiles, setDeleteExtraFiles] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const [excludeXattr, setExcludeXattr] = useState("");
const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "/"; const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "/";
@@ -64,9 +67,11 @@ export const SnapshotFileBrowser = (props: Props) => {
const addBasePath = useCallback( const addBasePath = useCallback(
(displayPath: string): string => { (displayPath: string): string => {
if (!volumeBasePath) return displayPath; let vbp = volumeBasePath === "/" ? "" : volumeBasePath;
if (displayPath === "/") return volumeBasePath;
return `${volumeBasePath}${displayPath}`; if (!vbp) return displayPath;
if (displayPath === "/") return vbp;
return `${vbp}${displayPath}`;
}, },
[volumeBasePath], [volumeBasePath],
); );
@@ -117,17 +122,23 @@ export const SnapshotFileBrowser = (props: Props) => {
const pathsArray = Array.from(selectedPaths); const pathsArray = Array.from(selectedPaths);
const includePaths = pathsArray.map((path) => addBasePath(path)); const includePaths = pathsArray.map((path) => addBasePath(path));
const excludeXattrArray = excludeXattr
?.split(",")
.map((s) => s.trim())
.filter(Boolean);
restoreSnapshot({ restoreSnapshot({
path: { name: repositoryName }, path: { name: repositoryName },
body: { body: {
snapshotId: snapshot.short_id, snapshotId: snapshot.short_id,
include: includePaths, include: includePaths,
delete: deleteExtraFiles, delete: deleteExtraFiles,
excludeXattr: excludeXattrArray && excludeXattrArray.length > 0 ? excludeXattrArray : undefined,
}, },
}); });
setShowRestoreDialog(false); setShowRestoreDialog(false);
}, [selectedPaths, addBasePath, repositoryName, snapshot.short_id, restoreSnapshot, deleteExtraFiles]); }, [selectedPaths, addBasePath, repositoryName, snapshot.short_id, restoreSnapshot, deleteExtraFiles, excludeXattr]);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -220,15 +231,46 @@ export const SnapshotFileBrowser = (props: Props) => {
Existing files will be overwritten by what's in the snapshot. This action cannot be undone. Existing files will be overwritten by what's in the snapshot. This action cannot be undone.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<div className="flex items-center space-x-2 py-4"> <div className="space-y-4">
<Checkbox <div>
id="delete-extra" <Button
checked={deleteExtraFiles} type="button"
onCheckedChange={(checked) => setDeleteExtraFiles(checked === true)} variant="ghost"
/> size="sm"
<Label htmlFor="delete-extra" className="text-sm font-normal cursor-pointer"> onClick={() => setShowAdvanced(!showAdvanced)}
Delete files not present in the snapshot? className="h-auto p-0 text-sm font-normal"
</Label> >
Advanced
<ChevronDown size={16} className={`ml-1 transition-transform ${showAdvanced ? "rotate-180" : ""}`} />
</Button>
{showAdvanced && (
<div className="mt-4 space-y-2">
<Label htmlFor="exclude-xattr" className="text-sm">
Exclude Extended Attributes (Optional)
</Label>
<Input
id="exclude-xattr"
placeholder="com.apple.metadata,user.*,nfs4.*"
value={excludeXattr}
onChange={(e) => setExcludeXattr(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Exclude specific extended attributes during restore (comma-separated)
</p>
<div className="flex items-center space-x-2 mt-2">
<Checkbox
id="delete-extra"
checked={deleteExtraFiles}
onCheckedChange={(checked) => setDeleteExtraFiles(checked === true)}
/>
<Label htmlFor="delete-extra" className="text-sm font-normal cursor-pointer">
Delete files not present in the snapshot?
</Label>
</div>
</div>
)}
</div>
</div> </div>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>

View File

@@ -52,12 +52,18 @@ export const RestoreSnapshotDialog = ({ name, snapshotId }: Props) => {
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean); .filter(Boolean);
const excludeXattr = values.excludeXattr
?.split(",")
.map((s) => s.trim())
.filter(Boolean);
restore.mutate({ restore.mutate({
path: { name }, path: { name },
body: { body: {
snapshotId, snapshotId,
include: include && include.length > 0 ? include : undefined, include: include && include.length > 0 ? include : undefined,
exclude: exclude && exclude.length > 0 ? exclude : undefined, exclude: exclude && exclude.length > 0 ? exclude : undefined,
excludeXattr: excludeXattr && excludeXattr.length > 0 ? excludeXattr : undefined,
}, },
}); });
}; };

View File

@@ -1,5 +1,7 @@
import { arktypeResolver } from "@hookform/resolvers/arktype"; import { arktypeResolver } from "@hookform/resolvers/arktype";
import { type } from "arktype"; import { type } from "arktype";
import { ChevronDown } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { import {
Form, Form,
@@ -11,11 +13,13 @@ import {
FormMessage, FormMessage,
} from "~/client/components/ui/form"; } from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input"; import { Input } from "~/client/components/ui/input";
import { Button } from "~/client/components/ui/button";
const restoreSnapshotFormSchema = type({ const restoreSnapshotFormSchema = type({
path: "string?", path: "string?",
include: "string?", include: "string?",
exclude: "string?", exclude: "string?",
excludeXattr: "string?",
}); });
export type RestoreSnapshotFormValues = typeof restoreSnapshotFormSchema.inferIn; export type RestoreSnapshotFormValues = typeof restoreSnapshotFormSchema.inferIn;
@@ -27,12 +31,15 @@ type Props = {
}; };
export const RestoreSnapshotForm = ({ formId, onSubmit, className }: Props) => { export const RestoreSnapshotForm = ({ formId, onSubmit, className }: Props) => {
const [showAdvanced, setShowAdvanced] = useState(false);
const form = useForm<RestoreSnapshotFormValues>({ const form = useForm<RestoreSnapshotFormValues>({
resolver: arktypeResolver(restoreSnapshotFormSchema), resolver: arktypeResolver(restoreSnapshotFormSchema),
defaultValues: { defaultValues: {
path: "", path: "",
include: "", include: "",
exclude: "", exclude: "",
excludeXattr: "",
}, },
}); });
@@ -90,6 +97,43 @@ export const RestoreSnapshotForm = ({ formId, onSubmit, className }: Props) => {
</FormItem> </FormItem>
)} )}
/> />
<div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowAdvanced(!showAdvanced)}
className="h-auto p-0 text-sm font-normal"
>
Advanced
<ChevronDown
size={16}
className={`ml-1 transition-transform ${showAdvanced ? "rotate-180" : ""}`}
/>
</Button>
{showAdvanced && (
<div className="mt-4">
<FormField
control={form.control}
name="excludeXattr"
render={({ field }) => (
<FormItem>
<FormLabel>Exclude Extended Attributes (Optional)</FormLabel>
<FormControl>
<Input placeholder="com.apple.metadata,user.custom" {...field} />
</FormControl>
<FormDescription>
Exclude specific extended attributes during restore (comma-separated)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
</div> </div>
</form> </form>
</Form> </Form>

View File

@@ -237,6 +237,7 @@ export const restoreSnapshotBody = type({
snapshotId: "string", snapshotId: "string",
include: "string[]?", include: "string[]?",
exclude: "string[]?", exclude: "string[]?",
excludeXattr: "string[]?",
delete: "boolean?", delete: "boolean?",
}); });

View File

@@ -15,7 +15,7 @@ const listRepositories = async () => {
}; };
const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig> => { const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig> => {
const encryptedConfig: Record<string, string | boolean> = { ...config }; const encryptedConfig: Record<string, string | boolean | number> = { ...config };
if (config.customPassword) { if (config.customPassword) {
encryptedConfig.customPassword = await cryptoUtils.encrypt(config.customPassword); encryptedConfig.customPassword = await cryptoUtils.encrypt(config.customPassword);
@@ -193,7 +193,7 @@ const listSnapshotFiles = async (name: string, snapshotId: string, path?: string
const restoreSnapshot = async ( const restoreSnapshot = async (
name: string, name: string,
snapshotId: string, snapshotId: string,
options?: { include?: string[]; exclude?: string[]; delete?: boolean }, options?: { include?: string[]; exclude?: string[]; excludeXattr?: string[]; delete?: boolean },
) => { ) => {
const repository = await db.query.repositoriesTable.findFirst({ const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.name, name), where: eq(repositoriesTable.name, name),

View File

@@ -351,6 +351,7 @@ const restore = async (
options?: { options?: {
include?: string[]; include?: string[];
exclude?: string[]; exclude?: string[];
excludeXattr?: string[];
path?: string; path?: string;
delete?: boolean; delete?: boolean;
}, },
@@ -380,11 +381,15 @@ const restore = async (
} }
} }
if (options?.excludeXattr && options.excludeXattr.length > 0) {
for (const xattr of options.excludeXattr) {
args.push("--exclude-xattr", xattr);
}
}
addRepoSpecificArgs(args, config, env); addRepoSpecificArgs(args, config, env);
args.push("--json"); args.push("--json");
console.log("Restic restore command:", ["restic", ...args].join(" "));
const res = await $`restic ${args}`.env(env).nothrow(); const res = await $`restic ${args}`.env(env).nothrow();
await cleanupTemporaryKeys(config, env); await cleanupTemporaryKeys(config, env);
@@ -408,6 +413,7 @@ const restore = async (
}; };
} }
logger.debug(`Restic restore output last line: ${lastLine}`);
const resSummary = JSON.parse(lastLine); const resSummary = JSON.parse(lastLine);
const result = restoreOutputSchema(resSummary); const result = restoreOutputSchema(resSummary);