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:
parent
61dc07b36b
commit
579bc89970
6 changed files with 179 additions and 19 deletions
|
|
@ -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/** /config/*.json *.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 node_modules/** .cache/ *.log"
|
||||
className="font-mono text-sm min-h-[120px]"
|
||||
className="font-mono text-sm min-h-30"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
135
app/server/modules/backups/__tests__/backups.patterns.test.ts
Normal file
135
app/server/modules/backups/__tests__/backups.patterns.test.ts
Normal 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(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue