fix: separate raw include paths and patterns (#683)
Separate include patters and included path cleanly to avoid path with special characters to be expanded. Closes https://github.com/nicotsx/zerobyte/discussions/680 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added ability to select specific directories and paths for inclusion in backup schedules, separate from pattern-based rules. * **Bug Fixes & Improvements** * Automatically migrates existing backup configurations to work with the new path selection system. * Enhanced backup restoration to properly handle both selected paths and pattern-based inclusions. * **Chores** * Updated database schema to support path selections in backup schedules. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
parent
f6f17cd61c
commit
a039bb478e
24 changed files with 2551 additions and 72 deletions
|
|
@ -2430,6 +2430,7 @@ export type ListBackupSchedulesResponses = {
|
|||
} | null;
|
||||
excludePatterns: Array<string> | null;
|
||||
excludeIfPresent: Array<string> | null;
|
||||
includePaths: Array<string> | null;
|
||||
includePatterns: Array<string> | null;
|
||||
oneFileSystem: boolean;
|
||||
customResticParams: Array<string> | null;
|
||||
|
|
@ -2704,6 +2705,7 @@ export type CreateBackupScheduleData = {
|
|||
};
|
||||
excludePatterns?: Array<string>;
|
||||
excludeIfPresent?: Array<string>;
|
||||
includePaths?: Array<string>;
|
||||
includePatterns?: Array<string>;
|
||||
oneFileSystem?: boolean;
|
||||
tags?: Array<string>;
|
||||
|
|
@ -2737,6 +2739,7 @@ export type CreateBackupScheduleResponses = {
|
|||
} | null;
|
||||
excludePatterns: Array<string> | null;
|
||||
excludeIfPresent: Array<string> | null;
|
||||
includePaths: Array<string> | null;
|
||||
includePatterns: Array<string> | null;
|
||||
oneFileSystem: boolean;
|
||||
customResticParams: Array<string> | null;
|
||||
|
|
@ -2803,6 +2806,7 @@ export type GetBackupScheduleResponses = {
|
|||
} | null;
|
||||
excludePatterns: Array<string> | null;
|
||||
excludeIfPresent: Array<string> | null;
|
||||
includePaths: Array<string> | null;
|
||||
includePatterns: Array<string> | null;
|
||||
oneFileSystem: boolean;
|
||||
customResticParams: Array<string> | null;
|
||||
|
|
@ -3076,6 +3080,7 @@ export type UpdateBackupScheduleData = {
|
|||
};
|
||||
excludePatterns?: Array<string>;
|
||||
excludeIfPresent?: Array<string>;
|
||||
includePaths?: Array<string>;
|
||||
includePatterns?: Array<string>;
|
||||
oneFileSystem?: boolean;
|
||||
tags?: Array<string>;
|
||||
|
|
@ -3111,6 +3116,7 @@ export type UpdateBackupScheduleResponses = {
|
|||
} | null;
|
||||
excludePatterns: Array<string> | null;
|
||||
excludeIfPresent: Array<string> | null;
|
||||
includePaths: Array<string> | null;
|
||||
includePatterns: Array<string> | null;
|
||||
oneFileSystem: boolean;
|
||||
customResticParams: Array<string> | null;
|
||||
|
|
@ -3157,6 +3163,7 @@ export type GetBackupScheduleForVolumeResponses = {
|
|||
} | null;
|
||||
excludePatterns: Array<string> | null;
|
||||
excludeIfPresent: Array<string> | null;
|
||||
includePaths: Array<string> | null;
|
||||
includePatterns: Array<string> | null;
|
||||
oneFileSystem: boolean;
|
||||
customResticParams: Array<string> | null;
|
||||
|
|
@ -4154,13 +4161,13 @@ export type GetBackupProgressResponses = {
|
|||
scheduleId: string;
|
||||
volumeName: string;
|
||||
repositoryName: string;
|
||||
seconds_elapsed: number;
|
||||
seconds_elapsed?: number;
|
||||
seconds_remaining?: number;
|
||||
percent_done: number;
|
||||
total_files: number;
|
||||
files_done: number;
|
||||
total_bytes: number;
|
||||
bytes_done: number;
|
||||
percent_done?: number;
|
||||
total_files?: number;
|
||||
files_done?: number;
|
||||
total_bytes?: number;
|
||||
bytes_done?: number;
|
||||
current_files?: Array<string>;
|
||||
} | null;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -19,10 +19,19 @@ export const BackupProgressCard = ({ scheduleShortId, initialProgress }: Props)
|
|||
refetchInterval: 1000,
|
||||
});
|
||||
|
||||
const percentDone = progress ? Math.round(progress.percent_done * 100) : 0;
|
||||
const {
|
||||
percent_done = 0,
|
||||
bytes_done = 0,
|
||||
total_bytes = 0,
|
||||
seconds_elapsed = 0,
|
||||
files_done = 0,
|
||||
total_files = 0,
|
||||
} = progress ?? {};
|
||||
|
||||
const percentDone = progress ? Math.round(percent_done * 100) : 0;
|
||||
const currentFile = progress?.current_files?.[0] || "";
|
||||
const fileName = currentFile.split("/").pop() || currentFile;
|
||||
const speed = progress ? formatBytes(progress.bytes_done / progress.seconds_elapsed) : null;
|
||||
const speed = progress ? formatBytes(bytes_done / seconds_elapsed) : null;
|
||||
const eta = progress?.seconds_remaining ? formatDuration(progress.seconds_remaining) : null;
|
||||
|
||||
return (
|
||||
|
|
@ -43,7 +52,7 @@ export const BackupProgressCard = ({ scheduleShortId, initialProgress }: Props)
|
|||
<p className="font-medium">
|
||||
{progress ? (
|
||||
<>
|
||||
{progress.files_done.toLocaleString()} / {progress.total_files.toLocaleString()}
|
||||
{files_done.toLocaleString()} / {total_files.toLocaleString()}
|
||||
</>
|
||||
) : (
|
||||
"—"
|
||||
|
|
@ -55,9 +64,9 @@ export const BackupProgressCard = ({ scheduleShortId, initialProgress }: Props)
|
|||
<p className="font-medium">
|
||||
{progress ? (
|
||||
<>
|
||||
<ByteSize bytes={progress.bytes_done} base={1024} />
|
||||
<ByteSize bytes={bytes_done} base={1024} />
|
||||
/
|
||||
<ByteSize bytes={progress.total_bytes} base={1024} />
|
||||
<ByteSize bytes={total_bytes} base={1024} />
|
||||
</>
|
||||
) : (
|
||||
"—"
|
||||
|
|
@ -66,12 +75,12 @@ export const BackupProgressCard = ({ scheduleShortId, initialProgress }: Props)
|
|||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Elapsed</p>
|
||||
<p className="font-medium">{progress ? formatDuration(progress.seconds_elapsed) : "—"}</p>
|
||||
<p className="font-medium">{progress ? formatDuration(seconds_elapsed) : "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Speed</p>
|
||||
<p className="font-medium">
|
||||
{progress ? (progress.seconds_elapsed > 0 ? `${speed?.text} ${speed?.unit}/s` : "Calculating...") : "—"}
|
||||
{progress ? (seconds_elapsed > 0 ? `${speed?.text} ${speed?.unit}/s` : "Calculating...") : "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -40,22 +40,22 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
|||
const {
|
||||
excludePatternsText,
|
||||
excludeIfPresentText,
|
||||
includePatternsText,
|
||||
includePatterns,
|
||||
customResticParamsText,
|
||||
includePatterns: fileBrowserPatterns,
|
||||
includePaths,
|
||||
cronExpression,
|
||||
...rest
|
||||
} = data;
|
||||
const excludePatterns = parseMultilineEntries(excludePatternsText);
|
||||
const excludeIfPresent = parseMultilineEntries(excludeIfPresentText);
|
||||
const textPatterns = parseMultilineEntries(includePatternsText);
|
||||
const includePatterns = [...(fileBrowserPatterns || []), ...textPatterns];
|
||||
const parsedIncludePatterns = parseMultilineEntries(includePatterns);
|
||||
const customResticParams = parseMultilineEntries(customResticParamsText);
|
||||
|
||||
onSubmit({
|
||||
...rest,
|
||||
cronExpression,
|
||||
includePatterns: includePatterns.length > 0 ? includePatterns : [],
|
||||
includePaths: includePaths?.length ? includePaths : [],
|
||||
includePatterns: parsedIncludePatterns.length > 0 ? parsedIncludePatterns : [],
|
||||
excludePatterns,
|
||||
excludeIfPresent,
|
||||
customResticParams,
|
||||
|
|
@ -67,13 +67,13 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
|||
const frequency = form.watch("frequency");
|
||||
const formValues = form.watch();
|
||||
|
||||
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set(initialValues?.includePatterns || []));
|
||||
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set(initialValues?.includePaths || []));
|
||||
const [showAllSelectedPaths, setShowAllSelectedPaths] = useState(false);
|
||||
|
||||
const handleSelectionChange = useCallback(
|
||||
(paths: Set<string>) => {
|
||||
setSelectedPaths(paths);
|
||||
form.setValue("includePatterns", Array.from(paths));
|
||||
form.setValue("includePaths", Array.from(paths));
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
|
@ -83,7 +83,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
|||
const newPaths = new Set(selectedPaths);
|
||||
newPaths.delete(pathToRemove);
|
||||
setSelectedPaths(newPaths);
|
||||
form.setValue("includePatterns", Array.from(newPaths));
|
||||
form.setValue("includePaths", Array.from(newPaths));
|
||||
},
|
||||
[selectedPaths, form],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ export const PathsSection = ({
|
|||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="includePatternsText"
|
||||
name="includePatterns"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-6">
|
||||
<FormLabel>Additional include patterns</FormLabel>
|
||||
|
|
|
|||
|
|
@ -38,22 +38,22 @@ export const SummarySection = ({ volume, frequency, formValues }: SummarySection
|
|||
{repositoriesData?.find((r) => r.shortId === formValues.repositoryId)?.name || "—"}
|
||||
</p>
|
||||
</div>
|
||||
{(formValues.includePatterns && formValues.includePatterns.length > 0) || formValues.includePatternsText ? (
|
||||
{(formValues.includePaths && formValues.includePaths.length > 0) || formValues.includePatterns ? (
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Include paths/patterns</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{formValues.includePatterns?.slice(0, 20).map((path) => (
|
||||
{formValues.includePaths?.slice(0, 20).map((path) => (
|
||||
<span key={path} className="text-xs font-mono bg-accent px-1.5 py-0.5 rounded">
|
||||
{path}
|
||||
</span>
|
||||
))}
|
||||
{formValues.includePatterns && formValues.includePatterns.length > 20 && (
|
||||
<span className="text-xs text-muted-foreground">+ {formValues.includePatterns.length - 20} more</span>
|
||||
{formValues.includePaths && formValues.includePaths.length > 20 && (
|
||||
<span className="text-xs text-muted-foreground">+ {formValues.includePaths.length - 20} more</span>
|
||||
)}
|
||||
{formValues.includePatternsText
|
||||
{formValues.includePatterns
|
||||
?.split("\n")
|
||||
.filter(Boolean)
|
||||
.slice(0, 20 - (formValues.includePatterns?.length || 0))
|
||||
.slice(0, 20 - (formValues.includePaths?.length || 0))
|
||||
.map((pattern) => (
|
||||
<span key={pattern} className="text-xs font-mono bg-accent px-1.5 py-0.5 rounded">
|
||||
{pattern.trim()}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ export const internalFormSchema = z.object({
|
|||
repositoryId: z.string(),
|
||||
excludePatternsText: z.string().optional(),
|
||||
excludeIfPresentText: z.string().optional(),
|
||||
includePatternsText: z.string().optional(),
|
||||
includePatterns: z.array(z.string()).optional(),
|
||||
includePatterns: z.string().optional(),
|
||||
includePaths: z.array(z.string()).optional(),
|
||||
frequency: z.string(),
|
||||
dailyTime: z.string().optional(),
|
||||
weeklyDay: z.string().optional(),
|
||||
|
|
@ -36,9 +36,10 @@ export type InternalFormValues = z.infer<typeof internalFormSchema>;
|
|||
|
||||
export type BackupScheduleFormValues = Omit<
|
||||
InternalFormValues,
|
||||
"excludePatternsText" | "excludeIfPresentText" | "includePatternsText" | "customResticParamsText"
|
||||
"excludePatternsText" | "excludeIfPresentText" | "includePatterns" | "customResticParamsText"
|
||||
> & {
|
||||
excludePatterns?: string[];
|
||||
excludeIfPresent?: string[];
|
||||
includePatterns?: string[];
|
||||
customResticParams?: string[];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -17,16 +17,11 @@ export const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalF
|
|||
|
||||
const cronValues = cronToFormValues(schedule.cronExpression ?? "0 * * * *");
|
||||
|
||||
const patterns = schedule.includePatterns || [];
|
||||
const isGlobPattern = (p: string) => /[*?[\]]/.test(p);
|
||||
const fileBrowserPaths = patterns.filter((p) => !isGlobPattern(p));
|
||||
const textPatterns = patterns.filter(isGlobPattern);
|
||||
|
||||
return {
|
||||
name: schedule.name,
|
||||
repositoryId: schedule.repository.shortId,
|
||||
includePatterns: fileBrowserPaths.length > 0 ? fileBrowserPaths : undefined,
|
||||
includePatternsText: textPatterns.length > 0 ? textPatterns.join("\n") : undefined,
|
||||
includePaths: schedule.includePaths?.length ? schedule.includePaths : undefined,
|
||||
includePatterns: schedule.includePatterns?.length ? schedule.includePatterns.join("\n") : undefined,
|
||||
excludePatternsText: schedule.excludePatterns?.join("\n") || undefined,
|
||||
excludeIfPresentText: schedule.excludeIfPresent?.join("\n") || undefined,
|
||||
oneFileSystem: schedule.oneFileSystem ?? false,
|
||||
|
|
|
|||
|
|
@ -185,6 +185,7 @@ export function ScheduleDetailsPage(props: Props) {
|
|||
enabled: schedule.enabled,
|
||||
cronExpression,
|
||||
retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined,
|
||||
includePaths: formValues.includePaths,
|
||||
includePatterns: formValues.includePatterns,
|
||||
excludePatterns: formValues.excludePatterns,
|
||||
excludeIfPresent: formValues.excludeIfPresent,
|
||||
|
|
@ -203,6 +204,7 @@ export function ScheduleDetailsPage(props: Props) {
|
|||
enabled,
|
||||
cronExpression: schedule.cronExpression,
|
||||
retentionPolicy: schedule.retentionPolicy || undefined,
|
||||
includePaths: schedule.includePaths || [],
|
||||
includePatterns: schedule.includePatterns || [],
|
||||
excludePatterns: schedule.excludePatterns || [],
|
||||
excludeIfPresent: schedule.excludeIfPresent || [],
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ export function CreateBackupPage() {
|
|||
enabled: true,
|
||||
cronExpression,
|
||||
retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined,
|
||||
includePaths: formValues.includePaths,
|
||||
includePatterns: formValues.includePatterns,
|
||||
excludePatterns: formValues.excludePatterns,
|
||||
excludeIfPresent: formValues.excludeIfPresent,
|
||||
|
|
|
|||
1
app/drizzle/20260320172926_hesitant_naoko/migration.sql
Normal file
1
app/drizzle/20260320172926_hesitant_naoko/migration.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE `backup_schedules_table` ADD `include_paths` text DEFAULT '[]';
|
||||
2281
app/drizzle/20260320172926_hesitant_naoko/snapshot.json
Normal file
2281
app/drizzle/20260320172926_hesitant_naoko/snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -27,12 +27,12 @@ const dumpStartedEventSchema = z.object({
|
|||
});
|
||||
|
||||
const restoreProgressMetricsSchema = z.object({
|
||||
seconds_elapsed: z.number(),
|
||||
percent_done: z.number(),
|
||||
total_files: z.number(),
|
||||
files_restored: z.number(),
|
||||
total_bytes: z.number(),
|
||||
bytes_restored: z.number(),
|
||||
seconds_elapsed: z.number().default(0),
|
||||
percent_done: z.number().default(0),
|
||||
total_files: z.number().default(0),
|
||||
files_restored: z.number().default(0),
|
||||
total_bytes: z.number().default(0),
|
||||
bytes_restored: z.number().default(0),
|
||||
});
|
||||
|
||||
export const backupStartedEventSchema = backupEventBaseSchema;
|
||||
|
|
|
|||
|
|
@ -300,6 +300,7 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
|
|||
}>(),
|
||||
excludePatterns: text("exclude_patterns", { mode: "json" }).$type<string[]>().default([]),
|
||||
excludeIfPresent: text("exclude_if_present", { mode: "json" }).$type<string[]>().default([]),
|
||||
includePaths: text("include_paths", { mode: "json" }).$type<string[]>().default([]),
|
||||
includePatterns: text("include_patterns", { mode: "json" }).$type<string[]>().default([]),
|
||||
lastBackupAt: int("last_backup_at", { mode: "number" }),
|
||||
lastBackupStatus: text("last_backup_status").$type<"success" | "error" | "in_progress" | "warning" | null>(),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ const createSchedule = (overrides: Partial<BackupScheduleInput> = {}): BackupSch
|
|||
fromAny({
|
||||
shortId: "sched-1234",
|
||||
oneFileSystem: false,
|
||||
includePaths: [],
|
||||
includePatterns: [],
|
||||
excludePatterns: [],
|
||||
excludeIfPresent: [],
|
||||
|
|
@ -20,7 +21,8 @@ describe("executeBackup - include / exclude patterns", () => {
|
|||
// arrange
|
||||
const volumePath = "/var/lib/zerobyte/volumes/vol123/_data";
|
||||
const schedule = createSchedule({
|
||||
includePatterns: ["*.zip", "/Photos", "!/Temp", "!*.log"],
|
||||
includePaths: ["/Photos"],
|
||||
includePatterns: ["*.zip", "!/Temp", "!*.log"],
|
||||
excludePatterns: [".DS_Store", "/Config", "!/Important", "!*.tmp"],
|
||||
excludeIfPresent: [".nobackup"],
|
||||
});
|
||||
|
|
@ -31,9 +33,9 @@ describe("executeBackup - include / exclude patterns", () => {
|
|||
|
||||
// assert
|
||||
expect(options).toMatchObject({
|
||||
include: [
|
||||
includePaths: [path.join(volumePath, "Photos")],
|
||||
includePatterns: [
|
||||
path.join(volumePath, "*.zip"),
|
||||
path.join(volumePath, "Photos"),
|
||||
`!${path.join(volumePath, "Temp")}`,
|
||||
`!${path.join(volumePath, "*.log")}`,
|
||||
],
|
||||
|
|
@ -48,7 +50,7 @@ describe("executeBackup - include / exclude patterns", () => {
|
|||
const volumePath = `/${volumeName}`;
|
||||
const selectedPath = `/${volumeName}`;
|
||||
const schedule = createSchedule({
|
||||
includePatterns: [selectedPath],
|
||||
includePaths: [selectedPath],
|
||||
});
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
|
|
@ -56,7 +58,7 @@ describe("executeBackup - include / exclude patterns", () => {
|
|||
const options = createBackupOptions(schedule, volumePath, signal);
|
||||
|
||||
// assert
|
||||
expect(options.include).toEqual([path.join(volumePath, volumeName)]);
|
||||
expect(options.includePaths).toEqual([path.join(volumePath, volumeName)]);
|
||||
});
|
||||
|
||||
test("should correctly mix relative and absolute patterns", () => {
|
||||
|
|
@ -73,7 +75,7 @@ describe("executeBackup - include / exclude patterns", () => {
|
|||
const options = createBackupOptions(schedule, volumePath, signal);
|
||||
|
||||
// assert
|
||||
expect(options.include).toEqual([
|
||||
expect(options.includePatterns).toEqual([
|
||||
path.join(volumePath, relativeInclude),
|
||||
path.join(volumePath, "anchored/include"),
|
||||
]);
|
||||
|
|
@ -91,7 +93,8 @@ describe("executeBackup - include / exclude patterns", () => {
|
|||
const options = createBackupOptions(schedule, "/var/lib/zerobyte/volumes/vol999/_data", signal);
|
||||
|
||||
// assert
|
||||
expect(options.include).toEqual([]);
|
||||
expect(options.includePaths).toEqual([]);
|
||||
expect(options.includePatterns).toEqual([]);
|
||||
expect(options.exclude).toEqual([]);
|
||||
});
|
||||
|
||||
|
|
@ -124,10 +127,24 @@ describe("executeBackup - include / exclude patterns", () => {
|
|||
|
||||
const options = createBackupOptions(schedule, volumePath, signal);
|
||||
|
||||
expect(options.include).toEqual([
|
||||
expect(options.includePatterns).toEqual([
|
||||
path.join(volumePath, "**/*.xyz"),
|
||||
path.join(volumePath, "*.zip"),
|
||||
`!${path.join(volumePath, "**/*.tmp")}`,
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps selected include paths separate from include patterns", () => {
|
||||
const volumePath = "/var/lib/zerobyte/volumes/vol123/_data";
|
||||
const schedule = createSchedule({
|
||||
includePaths: ["/movies [1]"],
|
||||
includePatterns: ["**/*.txt"],
|
||||
});
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
const options = createBackupOptions(schedule, volumePath, signal);
|
||||
|
||||
expect(options.includePaths).toEqual([path.join(volumePath, "movies [1]")]);
|
||||
expect(options.includePatterns).toEqual([path.join(volumePath, "**/*.txt")]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -53,7 +53,10 @@ export const createBackupOptions = (schedule: BackupSchedule, volumePath: string
|
|||
signal,
|
||||
exclude: schedule.excludePatterns ? schedule.excludePatterns.map((p) => processPattern(p, volumePath)) : undefined,
|
||||
excludeIfPresent: schedule.excludeIfPresent ?? undefined,
|
||||
include: schedule.includePatterns
|
||||
includePaths: schedule.includePaths
|
||||
? schedule.includePaths.map((p) => processPattern(p, volumePath, true))
|
||||
: undefined,
|
||||
includePatterns: schedule.includePatterns
|
||||
? schedule.includePatterns.map((p) => processPattern(p, volumePath, true))
|
||||
: undefined,
|
||||
customResticParams: schedule.customResticParams ?? undefined,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ const backupScheduleSchema = z.object({
|
|||
retentionPolicy: retentionPolicySchema.nullable(),
|
||||
excludePatterns: z.array(z.string()).nullable(),
|
||||
excludeIfPresent: z.array(z.string()).nullable(),
|
||||
includePaths: z.array(z.string()).nullable(),
|
||||
includePatterns: z.array(z.string()).nullable(),
|
||||
oneFileSystem: z.boolean(),
|
||||
customResticParams: z.array(z.string()).nullable(),
|
||||
|
|
@ -122,6 +123,7 @@ export const createBackupScheduleBody = z.object({
|
|||
retentionPolicy: retentionPolicySchema.optional(),
|
||||
excludePatterns: z.array(z.string()).optional(),
|
||||
excludeIfPresent: z.array(z.string()).optional(),
|
||||
includePaths: z.array(z.string()).optional(),
|
||||
includePatterns: z.array(z.string()).optional(),
|
||||
oneFileSystem: z.boolean().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
|
|
@ -158,6 +160,7 @@ export const updateBackupScheduleBody = z.object({
|
|||
retentionPolicy: retentionPolicySchema.optional(),
|
||||
excludePatterns: z.array(z.string()).optional(),
|
||||
excludeIfPresent: z.array(z.string()).optional(),
|
||||
includePaths: z.array(z.string()).optional(),
|
||||
includePatterns: z.array(z.string()).optional(),
|
||||
oneFileSystem: z.boolean().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
|
|
|
|||
|
|
@ -147,6 +147,7 @@ const createSchedule = async (data: CreateBackupScheduleBody) => {
|
|||
retentionPolicy: data.retentionPolicy ?? null,
|
||||
excludePatterns: data.excludePatterns ?? [],
|
||||
excludeIfPresent: data.excludeIfPresent ?? [],
|
||||
includePaths: data.includePaths ?? [],
|
||||
includePatterns: data.includePatterns ?? [],
|
||||
oneFileSystem: data.oneFileSystem,
|
||||
customResticParams: data.customResticParams ?? [],
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { v00001 } from "./migrations/00001-retag-snapshots";
|
|||
import { v00002 } from "./migrations/00002-isolate-restic-passwords";
|
||||
import { v00003 } from "./migrations/00003-assign-organization";
|
||||
import { v00004 } from "./migrations/00004-concat-path-name";
|
||||
import { v00005 } from "./migrations/00005-split-backup-include-paths";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { appMetadataTable, usersTable } from "../../db/schema";
|
||||
import { db } from "../../db/db";
|
||||
|
|
@ -37,7 +38,7 @@ type MigrationEntity = {
|
|||
dependsOn?: string[];
|
||||
};
|
||||
|
||||
const registry: MigrationEntity[] = [v00001, v00002, v00003, v00004];
|
||||
const registry: MigrationEntity[] = [v00001, v00002, v00003, v00004, v00005];
|
||||
|
||||
export const runMigrations = async () => {
|
||||
const userCount = await db.select({ count: sql<number>`count(*)` }).from(usersTable);
|
||||
|
|
@ -77,7 +78,7 @@ export const runMigrations = async () => {
|
|||
"Seek support by opening an issue on the Zerobyte GitHub repository if you need assistance.",
|
||||
"================================================================================",
|
||||
];
|
||||
err.forEach((line) => logger.error(line));
|
||||
err.forEach(logger.error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
|
@ -105,7 +106,7 @@ export const runMigrations = async () => {
|
|||
"on the Zerobyte GitHub repository if you need assistance.",
|
||||
"================================================================================",
|
||||
];
|
||||
err.forEach((line) => logger.error(line));
|
||||
err.forEach(logger.error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { db } from "../../../db/db";
|
||||
import { backupSchedulesTable } from "../../../db/schema";
|
||||
import { toMessage } from "~/server/utils/errors";
|
||||
|
||||
export const isIncludePatternEntry = (value: string) => value.startsWith("!") || /[*?[\]]/.test(value);
|
||||
|
||||
const execute = async () => {
|
||||
const errors: Array<{ name: string; error: string }> = [];
|
||||
const schedules = await db.query.backupSchedulesTable.findMany();
|
||||
let migratedCount = 0;
|
||||
|
||||
for (const schedule of schedules) {
|
||||
if (schedule.includePaths?.length || !schedule.includePatterns?.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const existingIncludePatterns = schedule.includePatterns ?? [];
|
||||
const includePaths = existingIncludePatterns.filter((value) => !isIncludePatternEntry(value));
|
||||
if (includePaths.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const includePatterns = existingIncludePatterns.filter(isIncludePatternEntry);
|
||||
|
||||
await db
|
||||
.update(backupSchedulesTable)
|
||||
.set({
|
||||
includePaths,
|
||||
includePatterns,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(backupSchedulesTable.id, schedule.id));
|
||||
|
||||
migratedCount += 1;
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
name: `backup-schedule:${schedule.id}`,
|
||||
error: toMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Migration 00005-split-backup-include-paths updated ${migratedCount} backup schedules.`);
|
||||
|
||||
return { success: errors.length === 0, errors };
|
||||
};
|
||||
|
||||
export const v00005 = {
|
||||
execute,
|
||||
id: "00005-split-backup-include-paths",
|
||||
type: "maintenance" as const,
|
||||
};
|
||||
|
|
@ -16,6 +16,7 @@ type ScenarioOptions = {
|
|||
includePatterns?: string;
|
||||
excludePatterns?: string;
|
||||
excludeIfPresent?: string;
|
||||
selectedPaths?: string[];
|
||||
};
|
||||
|
||||
type BackupJobOptions = ScenarioOptions & {
|
||||
|
|
@ -99,6 +100,15 @@ async function createBackupJob(page: Page, options: BackupJobOptions) {
|
|||
if (options.includePatterns) {
|
||||
await page.getByLabel("Additional include patterns").fill(options.includePatterns);
|
||||
}
|
||||
if (options.selectedPaths) {
|
||||
for (const selectedPath of options.selectedPaths) {
|
||||
const escapedPath = path.posix.basename(selectedPath).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
await page
|
||||
.getByRole("button", { name: new RegExp(escapedPath) })
|
||||
.getByRole("checkbox")
|
||||
.click();
|
||||
}
|
||||
}
|
||||
if (options.excludePatterns) {
|
||||
await page.getByLabel("Exclusion patterns").fill(options.excludePatterns);
|
||||
}
|
||||
|
|
@ -462,3 +472,38 @@ test("backup respects include globs, exclusion patterns, and exclude-if-present"
|
|||
await expect(page.getByRole("button", { name: /\.nobackup/ })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /blocked\.xyz/ })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("backup can include a selected folder whose name contains brackets", async ({ page }, testInfo) => {
|
||||
const runId = getRunId(testInfo);
|
||||
const names = getScenarioNames(runId);
|
||||
const bracketDir = `movies [${runId}]`;
|
||||
const bracketPath = path.join(testDataPath, bracketDir);
|
||||
const fileName = `inside-${runId}.txt`;
|
||||
|
||||
fs.mkdirSync(bracketPath, { recursive: true });
|
||||
fs.writeFileSync(path.join(bracketPath, fileName), "bracket path content");
|
||||
|
||||
await gotoAndWaitForAppReady(page, "/");
|
||||
await expect(page).toHaveURL("/volumes");
|
||||
|
||||
await createBackupScenario(page, names, {
|
||||
selectedPaths: [`/${bracketDir}`],
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Backup now" }).click();
|
||||
await expect(page.getByText("Backup started successfully")).toBeVisible();
|
||||
await expect(page.getByText("✓ Success")).toBeVisible({ timeout: 30000 });
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: /\d+ B$/ })
|
||||
.first()
|
||||
.click();
|
||||
const bracketFolderRow = page.getByRole("button", {
|
||||
name: new RegExp(bracketDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
|
||||
});
|
||||
await expect(bracketFolderRow).toBeVisible();
|
||||
await bracketFolderRow.locator("svg").first().click();
|
||||
await expect(page.getByRole("button", { name: new RegExp(fileName.replace(".", "\\.")) })).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ const config = {
|
|||
|
||||
type SetupOptions = {
|
||||
spawnResult?: Partial<SpawnResult>;
|
||||
onSpawnCall?: (params: SafeSpawnParams) => void;
|
||||
onSpawnCall?: (params: SafeSpawnParams) => void | Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -64,8 +64,12 @@ const setup = ({ spawnResult = {}, onSpawnCall }: SetupOptions = {}) => {
|
|||
const cleanupSpy = spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
|
||||
spyOn(spawnModule, "safeSpawn").mockImplementation((params) => {
|
||||
capturedArgs = params.args;
|
||||
onSpawnCall?.(params);
|
||||
return Promise.resolve({ exitCode: 0, summary: VALID_SUMMARY, error: "", ...spawnResult });
|
||||
return Promise.resolve(onSpawnCall?.(params)).then(() => ({
|
||||
exitCode: 0,
|
||||
summary: VALID_SUMMARY,
|
||||
error: "",
|
||||
...spawnResult,
|
||||
}));
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
@ -117,7 +121,7 @@ describe("backup command", () => {
|
|||
"/mnt/data",
|
||||
{
|
||||
organizationId: "org-1",
|
||||
include: ["/mnt/data/docs", "/mnt/data/photos"],
|
||||
includePatterns: ["/mnt/data/docs", "/mnt/data/photos"],
|
||||
},
|
||||
mockDeps,
|
||||
);
|
||||
|
|
@ -126,6 +130,42 @@ describe("backup command", () => {
|
|||
expect(getArgs()).not.toContain("/mnt/data");
|
||||
});
|
||||
|
||||
test("passes include paths via --files-from-raw and include patterns via --files-from", async () => {
|
||||
const literalDir = "/mnt/data/movies [1]";
|
||||
let rawIncludeContent = "";
|
||||
let patternIncludeContent = "";
|
||||
const { hasFlag } = setup({
|
||||
onSpawnCall: async (params) => {
|
||||
const rawIncludeIndex = params.args.indexOf("--files-from-raw");
|
||||
const patternIncludeIndex = params.args.indexOf("--files-from");
|
||||
|
||||
if (rawIncludeIndex > -1) {
|
||||
rawIncludeContent = await Bun.file(params.args[rawIncludeIndex + 1]!).text();
|
||||
}
|
||||
|
||||
if (patternIncludeIndex > -1) {
|
||||
patternIncludeContent = await Bun.file(params.args[patternIncludeIndex + 1]!).text();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
await backup(
|
||||
config,
|
||||
"/mnt/data",
|
||||
{
|
||||
organizationId: "org-1",
|
||||
includePaths: [literalDir],
|
||||
includePatterns: ["/mnt/data/**/*.zip"],
|
||||
},
|
||||
mockDeps,
|
||||
);
|
||||
|
||||
expect(hasFlag("--files-from-raw")).toBe(true);
|
||||
expect(hasFlag("--files-from")).toBe(true);
|
||||
expect(rawIncludeContent).toBe(`${literalDir}\0`);
|
||||
expect(patternIncludeContent).toBe("/mnt/data/**/*.zip");
|
||||
});
|
||||
|
||||
test("adds --tag for each entry in options.tags", async () => {
|
||||
const { getOptionValues } = setup();
|
||||
await backup(
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ export const backup = async (
|
|||
organizationId: string;
|
||||
exclude?: string[];
|
||||
excludeIfPresent?: string[];
|
||||
include?: string[];
|
||||
includePaths?: string[];
|
||||
includePatterns?: string[];
|
||||
tags?: string[];
|
||||
oneFileSystem?: boolean;
|
||||
compressionMode?: CompressionMode;
|
||||
|
|
@ -50,23 +51,35 @@ export const backup = async (
|
|||
}
|
||||
|
||||
let includeFile: string | null = null;
|
||||
const usesSourceArg = !options.include || options.include.length === 0;
|
||||
let rawIncludeFile: string | null = null;
|
||||
const usesSourceArg =
|
||||
(!options.includePaths || options.includePaths.length === 0) &&
|
||||
(!options.includePatterns || options.includePatterns.length === 0);
|
||||
|
||||
if (options.include && options.include.length > 0) {
|
||||
if (options.includePatterns?.length) {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-restic-include-"));
|
||||
includeFile = path.join(tmp, "include.txt");
|
||||
|
||||
await fs.writeFile(includeFile, options.include.join("\n"), "utf-8");
|
||||
await fs.writeFile(includeFile, options.includePatterns.join("\n"), "utf-8");
|
||||
|
||||
args.push("--files-from", includeFile);
|
||||
}
|
||||
|
||||
if (options.includePaths?.length) {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-restic-include-raw-"));
|
||||
rawIncludeFile = path.join(tmp, "include.raw");
|
||||
|
||||
await fs.writeFile(rawIncludeFile, Buffer.from(`${options.includePaths.join("\0")}\0`, "utf-8"));
|
||||
|
||||
args.push("--files-from-raw", rawIncludeFile);
|
||||
}
|
||||
|
||||
for (const exclude of deps.defaultExcludes) {
|
||||
args.push("--exclude", exclude);
|
||||
}
|
||||
|
||||
let excludeFile: string | null = null;
|
||||
if (options.exclude && options.exclude.length > 0) {
|
||||
if (options.exclude?.length) {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-restic-exclude-"));
|
||||
excludeFile = path.join(tmp, "exclude.txt");
|
||||
|
||||
|
|
@ -75,13 +88,13 @@ export const backup = async (
|
|||
args.push("--exclude-file", excludeFile);
|
||||
}
|
||||
|
||||
if (options.excludeIfPresent && options.excludeIfPresent.length > 0) {
|
||||
if (options.excludeIfPresent?.length) {
|
||||
for (const filename of options.excludeIfPresent) {
|
||||
args.push("--exclude-if-present", filename);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.customResticParams && options.customResticParams.length > 0) {
|
||||
if (options.customResticParams?.length) {
|
||||
const validationError = validateCustomResticParams(options.customResticParams);
|
||||
if (validationError) {
|
||||
throw new Error(`Invalid customResticParams: ${validationError}`);
|
||||
|
|
@ -147,6 +160,9 @@ export const backup = async (
|
|||
if (includeFile) {
|
||||
await fs.unlink(includeFile).catch(() => {});
|
||||
}
|
||||
if (rawIncludeFile) {
|
||||
await fs.unlink(rawIncludeFile).catch(() => {});
|
||||
}
|
||||
if (excludeFile) {
|
||||
await fs.unlink(excludeFile).catch(() => {});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@ import type { ResticDeps } from "../types";
|
|||
|
||||
const restoreProgressSchema = z.object({
|
||||
message_type: z.enum(["status", "summary"]),
|
||||
seconds_elapsed: z.number(),
|
||||
seconds_elapsed: z.number().default(0),
|
||||
percent_done: z.number().default(0),
|
||||
total_files: z.number(),
|
||||
total_files: z.number().default(0),
|
||||
files_restored: z.number().default(0),
|
||||
total_bytes: z.number().default(0),
|
||||
bytes_restored: z.number().default(0),
|
||||
|
|
|
|||
|
|
@ -32,10 +32,10 @@ export const resticBackupOutputSchema = resticBackupRunSummarySchema.extend({
|
|||
export const resticBackupProgressMetricsSchema = z.object({
|
||||
seconds_elapsed: z.number().default(0),
|
||||
seconds_remaining: z.number().default(0),
|
||||
percent_done: z.number(),
|
||||
total_files: z.number(),
|
||||
percent_done: z.number().default(0),
|
||||
total_files: z.number().default(0),
|
||||
files_done: z.number().default(0),
|
||||
total_bytes: z.number(),
|
||||
total_bytes: z.number().default(0),
|
||||
bytes_done: z.number().default(0),
|
||||
current_files: z.array(z.string()).default([]),
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue