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:
Nico 2026-03-20 19:27:54 +01:00 committed by GitHub
parent f6f17cd61c
commit a039bb478e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 2551 additions and 72 deletions

View file

@ -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;
};

View file

@ -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} />
&nbsp;/&nbsp;
<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>

View file

@ -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],
);

View file

@ -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>

View file

@ -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()}

View file

@ -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[];
};

View file

@ -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,

View file

@ -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 || [],

View file

@ -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,

View file

@ -0,0 +1 @@
ALTER TABLE `backup_schedules_table` ADD `include_paths` text DEFAULT '[]';

File diff suppressed because it is too large Load diff

View file

@ -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;

View file

@ -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>(),

View file

@ -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")]);
});
});

View file

@ -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,

View file

@ -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(),

View file

@ -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 ?? [],

View file

@ -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);
}
}

View file

@ -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,
};

View file

@ -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,
});
});

View file

@ -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(

View file

@ -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(() => {});
}

View file

@ -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),

View file

@ -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([]),
});