diff --git a/app/client/modules/backups/components/create-schedule-form.tsx b/app/client/modules/backups/components/create-schedule-form.tsx
index 8f6de25c..2cf62e15 100644
--- a/app/client/modules/backups/components/create-schedule-form.tsx
+++ b/app/client/modules/backups/components/create-schedule-form.tsx
@@ -318,7 +318,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
onSelectionChange={handleSelectionChange}
withCheckboxes={true}
foldersOnly={false}
- className="flex-1 border rounded-md bg-card p-2 min-h-[300px] max-h-[400px] overflow-auto"
+ className="flex-1 border rounded-md bg-card p-2 min-h-75 max-h-100 overflow-auto"
/>
{selectedPaths.size > 0 && (
@@ -342,7 +342,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
@@ -375,7 +375,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
diff --git a/app/server/modules/backends/utils/backend-utils.ts b/app/server/modules/backends/utils/backend-utils.ts
index 637001fb..e610217d 100644
--- a/app/server/modules/backends/utils/backend-utils.ts
+++ b/app/server/modules/backends/utils/backend-utils.ts
@@ -7,6 +7,7 @@ import { $ } from "bun";
export const executeMount = async (args: string[]): Promise => {
let stderr: string | undefined;
+ logger.debug(`Executing mount ${args.join(" ")}`);
const result = await $`mount ${args}`.nothrow();
stderr = result.stderr.toString();
@@ -22,7 +23,8 @@ export const executeMount = async (args: string[]): Promise => {
export const executeUnmount = async (path: string): Promise => {
let stderr: string | undefined;
- const result = await $`umount -l -f ${path}`.nothrow();
+ logger.debug(`Executing umount -l ${path}`);
+ const result = await $`umount -l ${path}`.nothrow();
stderr = result.stderr.toString();
if (stderr?.trim()) {
diff --git a/app/server/modules/backups/__tests__/backups.patterns.test.ts b/app/server/modules/backups/__tests__/backups.patterns.test.ts
new file mode 100644
index 00000000..df7b3cb2
--- /dev/null
+++ b/app/server/modules/backups/__tests__/backups.patterns.test.ts
@@ -0,0 +1,135 @@
+import { test, describe, mock, expect, beforeEach, afterEach, spyOn } from "bun:test";
+import { backupsService } from "../backups.service";
+import { createTestVolume } from "~/test/helpers/volume";
+import { createTestBackupSchedule } from "~/test/helpers/backup";
+import { createTestRepository } from "~/test/helpers/repository";
+import { generateBackupOutput } from "~/test/helpers/restic";
+import { getVolumePath } from "../../volumes/helpers";
+import { restic } from "~/server/utils/restic";
+import path from "node:path";
+
+const backupMock = mock(() => Promise.resolve({ exitCode: 0, result: JSON.parse(generateBackupOutput()) }));
+
+beforeEach(() => {
+ backupMock.mockClear();
+ spyOn(restic, "backup").mockImplementation(backupMock);
+ spyOn(restic, "forget").mockImplementation(mock(() => Promise.resolve({ success: true })));
+});
+
+afterEach(() => {
+ mock.restore();
+});
+
+describe("executeBackup - include / exclude patterns", () => {
+ test("should correctly build include and exclude patterns by joining with volume path", async () => {
+ // arrange
+ const volume = await createTestVolume();
+ const repository = await createTestRepository();
+ const schedule = await createTestBackupSchedule({
+ volumeId: volume.id,
+ repositoryId: repository.id,
+ includePatterns: ["include1", "subdir/include2"],
+ excludePatterns: ["exclude1", "subdir/exclude2"],
+ excludeIfPresent: [".nobackup"],
+ });
+
+ // act
+ await backupsService.executeBackup(schedule.id);
+
+ // assert
+ const volumePath = getVolumePath(volume);
+
+ expect(backupMock).toHaveBeenCalledWith(
+ expect.anything(),
+ volumePath,
+ expect.objectContaining({
+ include: [path.join(volumePath, "include1"), path.join(volumePath, "subdir/include2")],
+ exclude: [path.join(volumePath, "exclude1"), path.join(volumePath, "subdir/exclude2")],
+ excludeIfPresent: [".nobackup"],
+ }),
+ );
+ });
+
+ test("should not join with volume path if pattern already starts with it", async () => {
+ // arrange
+ const volume = await createTestVolume();
+ const volumePath = getVolumePath(volume);
+ const repository = await createTestRepository();
+
+ const alreadyJoinedInclude = path.join(volumePath, "already/joined");
+ const alreadyJoinedExclude = path.join(volumePath, "already/excluded");
+
+ const schedule = await createTestBackupSchedule({
+ volumeId: volume.id,
+ repositoryId: repository.id,
+ includePatterns: [alreadyJoinedInclude],
+ excludePatterns: [alreadyJoinedExclude],
+ });
+
+ // act
+ await backupsService.executeBackup(schedule.id);
+
+ // assert
+ expect(backupMock).toHaveBeenCalledWith(
+ expect.anything(),
+ volumePath,
+ expect.objectContaining({
+ include: [alreadyJoinedInclude],
+ exclude: [alreadyJoinedExclude],
+ }),
+ );
+ });
+
+ test("should correctly mix relative and absolute patterns", async () => {
+ // arrange
+ const volume = await createTestVolume();
+ const volumePath = getVolumePath(volume);
+ const repository = await createTestRepository();
+
+ const alreadyJoinedInclude = path.join(volumePath, "already/joined");
+ const relativeInclude = "relative/include";
+
+ const schedule = await createTestBackupSchedule({
+ volumeId: volume.id,
+ repositoryId: repository.id,
+ includePatterns: [alreadyJoinedInclude, relativeInclude],
+ });
+
+ // act
+ await backupsService.executeBackup(schedule.id);
+
+ // assert
+ expect(backupMock).toHaveBeenCalledWith(
+ expect.anything(),
+ volumePath,
+ expect.objectContaining({
+ include: [alreadyJoinedInclude, path.join(volumePath, relativeInclude)],
+ }),
+ );
+ });
+
+ test("should handle empty include and exclude patterns", async () => {
+ // arrange
+ const volume = await createTestVolume();
+ const repository = await createTestRepository();
+ const schedule = await createTestBackupSchedule({
+ volumeId: volume.id,
+ repositoryId: repository.id,
+ includePatterns: [],
+ excludePatterns: [],
+ });
+
+ // act
+ await backupsService.executeBackup(schedule.id);
+
+ // assert
+ expect(backupMock).toHaveBeenCalledWith(
+ expect.anything(),
+ getVolumePath(volume),
+ expect.not.objectContaining({
+ include: expect.anything(),
+ exclude: expect.anything(),
+ }),
+ );
+ });
+});
diff --git a/app/server/modules/backups/__tests__/backups.service.test.ts b/app/server/modules/backups/__tests__/backups.service.test.ts
index 8fa5ecd7..4381a0cd 100644
--- a/app/server/modules/backups/__tests__/backups.service.test.ts
+++ b/app/server/modules/backups/__tests__/backups.service.test.ts
@@ -1,19 +1,20 @@
-import { test, describe, mock, expect } from "bun:test";
+import { test, describe, mock, expect, beforeEach, afterEach, spyOn } from "bun:test";
import { backupsService } from "../backups.service";
import { createTestVolume } from "~/test/helpers/volume";
import { createTestBackupSchedule } from "~/test/helpers/backup";
import { createTestRepository } from "~/test/helpers/repository";
import { generateBackupOutput } from "~/test/helpers/restic";
-import { beforeEach } from "bun:test";
+import * as spawnModule from "~/server/utils/spawn";
-const resticBackupMock = mock(() => Promise.resolve({ exitCode: 0 }));
-
-mock.module("~/server/utils/spawn", () => ({
- safeSpawn: resticBackupMock,
-}));
+const resticBackupMock = mock(() => Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }));
beforeEach(() => {
resticBackupMock.mockClear();
+ spyOn(spawnModule, "safeSpawn").mockImplementation(resticBackupMock);
+});
+
+afterEach(() => {
+ mock.restore();
});
describe("execute backup", () => {
diff --git a/app/server/modules/backups/backups.service.ts b/app/server/modules/backups/backups.service.ts
index bb28f29a..b32c317a 100644
--- a/app/server/modules/backups/backups.service.ts
+++ b/app/server/modules/backups/backups.service.ts
@@ -13,6 +13,7 @@ import { serverEvents } from "../../core/events";
import { notificationsService } from "../notifications/notifications.service";
import { repoMutex } from "../../core/repository-mutex";
import { checkMirrorCompatibility, getIncompatibleMirrorError } from "~/server/utils/backend-compatibility";
+import path from "node:path";
const runningBackups = new Map();
@@ -258,7 +259,16 @@ const executeBackup = async (scheduleId: number, manual = false) => {
};
if (schedule.excludePatterns && schedule.excludePatterns.length > 0) {
- backupOptions.exclude = schedule.excludePatterns;
+ const excludePatterns = [];
+ for (const pattern of schedule.excludePatterns) {
+ if (!pattern.startsWith(volumePath)) {
+ excludePatterns.push(path.join(volumePath, pattern));
+ } else {
+ excludePatterns.push(pattern);
+ }
+ }
+
+ backupOptions.exclude = excludePatterns;
}
if (schedule.excludeIfPresent && schedule.excludeIfPresent.length > 0) {
@@ -266,7 +276,16 @@ const executeBackup = async (scheduleId: number, manual = false) => {
}
if (schedule.includePatterns && schedule.includePatterns.length > 0) {
- backupOptions.include = schedule.includePatterns;
+ const includePatterns = [];
+ for (const pattern of schedule.includePatterns) {
+ if (!pattern.startsWith(volumePath)) {
+ includePatterns.push(path.join(volumePath, pattern));
+ } else {
+ includePatterns.push(pattern);
+ }
+ }
+
+ backupOptions.include = includePatterns;
}
const releaseBackupLock = await repoMutex.acquireShared(repository.id, `backup:${volume.name}`);
diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts
index 0e1a7330..a4e076f5 100644
--- a/app/server/utils/restic.ts
+++ b/app/server/utils/restic.ts
@@ -267,9 +267,7 @@ const backup = async (
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-restic-include-"));
includeFile = path.join(tmp, `include.txt`);
- const includePaths = options.include.map((p) => path.join(source, p));
-
- await fs.writeFile(includeFile, includePaths.join("\n"), "utf-8");
+ await fs.writeFile(includeFile, options.include.join("\n"), "utf-8");
args.push("--files-from", includeFile);
} else {
@@ -280,10 +278,14 @@ const backup = async (
args.push("--exclude", exclude);
}
+ let excludeFile: string | null = null;
if (options?.exclude && options.exclude.length > 0) {
- for (const pattern of options.exclude) {
- args.push("--exclude", pattern);
- }
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-restic-exclude-"));
+ excludeFile = path.join(tmp, `exclude.txt`);
+
+ await fs.writeFile(excludeFile, options.exclude.join("\n"), "utf-8");
+
+ args.push("--exclude-file", excludeFile);
}
if (options?.excludeIfPresent && options.excludeIfPresent.length > 0) {
@@ -330,6 +332,7 @@ const backup = async (
},
finally: async () => {
includeFile && (await fs.unlink(includeFile).catch(() => {}));
+ excludeFile && (await fs.unlink(excludeFile).catch(() => {}));
await cleanupTemporaryKeys(config, env);
},
});