fix: cascade delete not correctly applied (#531)
This commit is contained in:
parent
de1278a416
commit
99bb296866
7 changed files with 175 additions and 18 deletions
|
|
@ -94,14 +94,6 @@ export function VolumeDetails({ volumeId }: { volumeId: string }) {
|
|||
deleteVol.mutate({ path: { id: volumeId } });
|
||||
};
|
||||
|
||||
if (!volumeId) {
|
||||
return <div>Volume not found</div>;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
const { volume, statfs } = data;
|
||||
|
||||
return (
|
||||
|
|
@ -164,6 +156,9 @@ export function VolumeDetails({ volumeId }: { volumeId: string }) {
|
|||
<AlertDialogTitle>Delete volume?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete the volume <strong>{volume.name}</strong>? This action cannot be undone.
|
||||
<br />
|
||||
<br />
|
||||
All backup schedules associated with this volume will also be removed.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="flex gap-3 justify-end">
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
import { logger } from "./server/utils/logger";
|
||||
import { shutdown } from "./server/modules/lifecycle/shutdown";
|
||||
import { runCLI } from "./server/cli";
|
||||
import {
|
||||
createStartHandler,
|
||||
defaultStreamHandler,
|
||||
defineHandlerCallback,
|
||||
} from "@tanstack/react-start/server";
|
||||
import { createStartHandler, defaultStreamHandler, defineHandlerCallback } from "@tanstack/react-start/server";
|
||||
import { createServerEntry } from "@tanstack/react-start/server-entry";
|
||||
import { runDbMigrations } from "./server/db/db";
|
||||
|
||||
await runDbMigrations();
|
||||
|
||||
const cliRun = await runCLI(Bun.argv);
|
||||
if (cliRun) {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@ if (fs.existsSync(path.join(path.dirname(DATABASE_URL), "ironmount.db")) && !fs.
|
|||
const sqlite = new Database(DATABASE_URL);
|
||||
export const db = drizzle({ client: sqlite, relations, schema });
|
||||
|
||||
export const runDbMigrations = () => {
|
||||
let migrationsPromise: Promise<void> | undefined;
|
||||
|
||||
const runMigrations = async () => {
|
||||
let migrationsFolder: string;
|
||||
|
||||
if (config.migrationsPath) {
|
||||
|
|
@ -32,3 +34,12 @@ export const runDbMigrations = () => {
|
|||
|
||||
sqlite.run("PRAGMA foreign_keys = ON;");
|
||||
};
|
||||
|
||||
export const runDbMigrations = () => {
|
||||
if (migrationsPromise) {
|
||||
return migrationsPromise;
|
||||
}
|
||||
|
||||
migrationsPromise = runMigrations();
|
||||
return migrationsPromise;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import waitForExpect from "wait-for-expect";
|
||||
import { test, describe, mock, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { backupsService } from "../backups.service";
|
||||
import { createTestVolume } from "~/test/helpers/volume";
|
||||
import { createTestBackupSchedule } from "~/test/helpers/backup";
|
||||
|
|
@ -7,6 +8,8 @@ import { createTestRepository } from "~/test/helpers/repository";
|
|||
import { generateBackupOutput } from "~/test/helpers/restic";
|
||||
import { faker } from "@faker-js/faker";
|
||||
import * as spawnModule from "~/server/utils/spawn";
|
||||
import { db } from "~/server/db/db";
|
||||
import { backupScheduleMirrorsTable, repositoriesTable, volumesTable } from "~/server/db/schema";
|
||||
import { TEST_ORG_ID } from "~/test/helpers/organization";
|
||||
import * as context from "~/server/core/request-context";
|
||||
import { backupsExecutionService } from "../backups.execution";
|
||||
|
|
@ -183,3 +186,100 @@ describe("getSchedulesToExecute", () => {
|
|||
expect(schedulesToExecute).toContain(schedule.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listSchedules", () => {
|
||||
test("should ignore schedules with missing relations", async () => {
|
||||
const healthyVolume = await createTestVolume();
|
||||
const healthyRepository = await createTestRepository();
|
||||
const healthySchedule = await createTestBackupSchedule({
|
||||
volumeId: healthyVolume.id,
|
||||
repositoryId: healthyRepository.id,
|
||||
});
|
||||
|
||||
const orphanVolume = await createTestVolume();
|
||||
const orphanRepository = await createTestRepository();
|
||||
const orphanSchedule = await createTestBackupSchedule({
|
||||
volumeId: orphanVolume.id,
|
||||
repositoryId: orphanRepository.id,
|
||||
});
|
||||
|
||||
await db.delete(volumesTable).where(eq(volumesTable.id, orphanVolume.id));
|
||||
|
||||
const schedules = await backupsService.listSchedules();
|
||||
|
||||
expect(schedules.map((schedule) => schedule.id)).toContain(healthySchedule.id);
|
||||
expect(schedules.map((schedule) => schedule.id)).not.toContain(orphanSchedule.id);
|
||||
});
|
||||
|
||||
test("should ignore schedules with missing repository relation", async () => {
|
||||
const healthyVolume = await createTestVolume();
|
||||
const healthyRepository = await createTestRepository();
|
||||
const healthySchedule = await createTestBackupSchedule({
|
||||
volumeId: healthyVolume.id,
|
||||
repositoryId: healthyRepository.id,
|
||||
});
|
||||
|
||||
const orphanVolume = await createTestVolume();
|
||||
const orphanRepository = await createTestRepository();
|
||||
const orphanSchedule = await createTestBackupSchedule({
|
||||
volumeId: orphanVolume.id,
|
||||
repositoryId: orphanRepository.id,
|
||||
});
|
||||
|
||||
await db.delete(repositoriesTable).where(eq(repositoriesTable.id, orphanRepository.id));
|
||||
|
||||
const schedules = await backupsService.listSchedules();
|
||||
|
||||
expect(schedules.map((schedule) => schedule.id)).toContain(healthySchedule.id);
|
||||
expect(schedules.map((schedule) => schedule.id)).not.toContain(orphanSchedule.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanupOrphanedSchedules", () => {
|
||||
test("should delete orphaned schedules and their mirror assignments", async () => {
|
||||
const healthyVolume = await createTestVolume();
|
||||
const healthyRepository = await createTestRepository();
|
||||
const healthySchedule = await createTestBackupSchedule({
|
||||
volumeId: healthyVolume.id,
|
||||
repositoryId: healthyRepository.id,
|
||||
});
|
||||
|
||||
const orphanVolume = await createTestVolume();
|
||||
const orphanRepository = await createTestRepository();
|
||||
const orphanSchedule = await createTestBackupSchedule({
|
||||
volumeId: orphanVolume.id,
|
||||
repositoryId: orphanRepository.id,
|
||||
});
|
||||
|
||||
const mirrorRepository = await createTestRepository();
|
||||
await db.insert(backupScheduleMirrorsTable).values({
|
||||
scheduleId: orphanSchedule.id,
|
||||
repositoryId: mirrorRepository.id,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
await db.delete(volumesTable).where(eq(volumesTable.id, orphanVolume.id));
|
||||
|
||||
const cleanupResult = await backupsService.cleanupOrphanedSchedules();
|
||||
|
||||
expect(cleanupResult.deletedSchedules).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const deletedSchedule = await db.query.backupSchedulesTable.findFirst({
|
||||
where: { id: orphanSchedule.id },
|
||||
columns: { id: true },
|
||||
});
|
||||
expect(deletedSchedule).toBeUndefined();
|
||||
|
||||
const remainingHealthySchedule = await db.query.backupSchedulesTable.findFirst({
|
||||
where: { id: healthySchedule.id },
|
||||
columns: { id: true },
|
||||
});
|
||||
expect(remainingHealthySchedule?.id).toBe(healthySchedule.id);
|
||||
|
||||
const orphanMirrors = await db.query.backupScheduleMirrorsTable.findMany({
|
||||
where: { scheduleId: orphanSchedule.id },
|
||||
columns: { id: true },
|
||||
});
|
||||
expect(orphanMirrors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import cron from "node-cron";
|
||||
import { NotFoundError, BadRequestError, ConflictError } from "http-errors-enhanced";
|
||||
import { db } from "../../db/db";
|
||||
import { backupSchedulesTable, backupScheduleMirrorsTable } from "../../db/schema";
|
||||
import { backupScheduleMirrorsTable, backupScheduleNotificationsTable, backupSchedulesTable } from "../../db/schema";
|
||||
import type { CreateBackupScheduleBody, UpdateBackupScheduleBody, UpdateScheduleMirrorsBody } from "./backups.dto";
|
||||
|
||||
import { checkMirrorCompatibility, getIncompatibleMirrorError } from "~/server/utils/backend-compatibility";
|
||||
|
|
@ -17,7 +17,7 @@ const listSchedules = async () => {
|
|||
with: { volume: true, repository: true },
|
||||
orderBy: { sortOrder: "asc", id: "asc" },
|
||||
});
|
||||
return schedules;
|
||||
return schedules.filter((schedule) => schedule.volume && schedule.repository);
|
||||
};
|
||||
|
||||
const getScheduleById = async (scheduleId: number) => {
|
||||
|
|
@ -31,6 +31,10 @@ const getScheduleById = async (scheduleId: number) => {
|
|||
throw new NotFoundError("Backup schedule not found");
|
||||
}
|
||||
|
||||
if (!schedule.volume || !schedule.repository) {
|
||||
throw new NotFoundError("Backup schedule not found");
|
||||
}
|
||||
|
||||
return schedule;
|
||||
};
|
||||
|
||||
|
|
@ -45,6 +49,10 @@ const getScheduleByShortId = async (shortId: string) => {
|
|||
throw new NotFoundError("Backup schedule not found");
|
||||
}
|
||||
|
||||
if (!schedule.volume || !schedule.repository) {
|
||||
throw new NotFoundError("Backup schedule not found");
|
||||
}
|
||||
|
||||
return schedule;
|
||||
};
|
||||
|
||||
|
|
@ -190,6 +198,10 @@ const getScheduleForVolume = async (volumeId: number) => {
|
|||
with: { volume: true, repository: true },
|
||||
});
|
||||
|
||||
if (schedule && (!schedule.volume || !schedule.repository)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return schedule ?? null;
|
||||
};
|
||||
|
||||
|
|
@ -331,6 +343,35 @@ const reorderSchedules = async (scheduleIds: number[]) => {
|
|||
});
|
||||
};
|
||||
|
||||
const cleanupOrphanedSchedules = async () => {
|
||||
const schedules = await db.query.backupSchedulesTable.findMany({
|
||||
with: { volume: true, repository: true },
|
||||
columns: { id: true },
|
||||
});
|
||||
|
||||
const orphanScheduleIds = schedules
|
||||
.filter((schedule) => schedule.volume === null || schedule.repository === null)
|
||||
.map((schedule) => schedule.id);
|
||||
|
||||
if (orphanScheduleIds.length === 0) {
|
||||
return { deletedSchedules: 0 };
|
||||
}
|
||||
|
||||
db.transaction((tx) => {
|
||||
tx.delete(backupScheduleNotificationsTable)
|
||||
.where(inArray(backupScheduleNotificationsTable.scheduleId, orphanScheduleIds))
|
||||
.run();
|
||||
|
||||
tx.delete(backupScheduleMirrorsTable)
|
||||
.where(inArray(backupScheduleMirrorsTable.scheduleId, orphanScheduleIds))
|
||||
.run();
|
||||
|
||||
tx.delete(backupSchedulesTable).where(inArray(backupSchedulesTable.id, orphanScheduleIds)).run();
|
||||
});
|
||||
|
||||
return { deletedSchedules: orphanScheduleIds.length };
|
||||
};
|
||||
|
||||
export const backupsService = {
|
||||
listSchedules,
|
||||
getScheduleById,
|
||||
|
|
@ -343,4 +384,5 @@ export const backupsService = {
|
|||
getMirrorCompatibility,
|
||||
reorderSchedules,
|
||||
getScheduleByShortId,
|
||||
cleanupOrphanedSchedules,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { startup } from "./startup";
|
|||
let bootstrapPromise: Promise<void> | undefined;
|
||||
|
||||
const runBootstrap = async () => {
|
||||
runDbMigrations();
|
||||
await runDbMigrations();
|
||||
await runMigrations();
|
||||
await startup();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { notificationsService } from "../notifications/notifications.service";
|
|||
import { VolumeAutoRemountJob } from "~/server/jobs/auto-remount";
|
||||
import { cache } from "~/server/utils/cache";
|
||||
import { withContext } from "~/server/core/request-context";
|
||||
import { backupsService } from "../backups/backups.service";
|
||||
|
||||
const ensureLatestConfigurationSchema = async () => {
|
||||
const volumes = await db.query.volumesTable.findMany({});
|
||||
|
|
@ -54,6 +55,15 @@ export const startup = async () => {
|
|||
|
||||
await ensureLatestConfigurationSchema();
|
||||
|
||||
const { deletedSchedules } = await backupsService.cleanupOrphanedSchedules().catch((err) => {
|
||||
logger.error(`Failed to cleanup orphaned backup schedules on startup: ${err.message}`);
|
||||
return { deletedSchedules: 0 };
|
||||
});
|
||||
|
||||
if (deletedSchedules > 0) {
|
||||
logger.warn(`Removed ${deletedSchedules} orphaned backup schedule(s) during startup`);
|
||||
}
|
||||
|
||||
const volumes = await db.query.volumesTable.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
|
|
|
|||
Loading…
Reference in a new issue