fix: prepend mount path in exclude patterns (#188)

* fix: prepend mount path location in exclude patterns

* test(backups): add tests suite for backend include & exclude patterns

* test: fix leaking global module mock
This commit is contained in:
Nico 2025-12-19 22:35:34 +01:00 committed by GitHub
parent 61dc07b36b
commit 579bc89970
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 179 additions and 19 deletions

View file

@ -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 && (
<div className="mt-4">
@ -342,7 +342,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
<Textarea
{...field}
placeholder="/data/**&#10;/config/*.json&#10;*.db"
className="font-mono text-sm min-h-[100px]"
className="font-mono text-sm min-h-25"
/>
</FormControl>
<FormDescription>
@ -375,7 +375,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
<Textarea
{...field}
placeholder="*.tmp&#10;node_modules/**&#10;.cache/&#10;*.log"
className="font-mono text-sm min-h-[120px]"
className="font-mono text-sm min-h-30"
/>
</FormControl>
<FormDescription>

View file

@ -7,6 +7,7 @@ import { $ } from "bun";
export const executeMount = async (args: string[]): Promise<void> => {
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<void> => {
export const executeUnmount = async (path: string): Promise<void> => {
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()) {

View file

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

View file

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

View file

@ -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<number, AbortController>();
@ -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}`);

View file

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