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 } });
|
deleteVol.mutate({ path: { id: volumeId } });
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!volumeId) {
|
|
||||||
return <div>Volume not found</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return <div>Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { volume, statfs } = data;
|
const { volume, statfs } = data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -164,6 +156,9 @@ export function VolumeDetails({ volumeId }: { volumeId: string }) {
|
||||||
<AlertDialogTitle>Delete volume?</AlertDialogTitle>
|
<AlertDialogTitle>Delete volume?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Are you sure you want to delete the volume <strong>{volume.name}</strong>? This action cannot be undone.
|
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>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<div className="flex gap-3 justify-end">
|
<div className="flex gap-3 justify-end">
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
import { logger } from "./server/utils/logger";
|
import { logger } from "./server/utils/logger";
|
||||||
import { shutdown } from "./server/modules/lifecycle/shutdown";
|
import { shutdown } from "./server/modules/lifecycle/shutdown";
|
||||||
import { runCLI } from "./server/cli";
|
import { runCLI } from "./server/cli";
|
||||||
import {
|
import { createStartHandler, defaultStreamHandler, defineHandlerCallback } from "@tanstack/react-start/server";
|
||||||
createStartHandler,
|
|
||||||
defaultStreamHandler,
|
|
||||||
defineHandlerCallback,
|
|
||||||
} from "@tanstack/react-start/server";
|
|
||||||
import { createServerEntry } from "@tanstack/react-start/server-entry";
|
import { createServerEntry } from "@tanstack/react-start/server-entry";
|
||||||
|
import { runDbMigrations } from "./server/db/db";
|
||||||
|
|
||||||
|
await runDbMigrations();
|
||||||
|
|
||||||
const cliRun = await runCLI(Bun.argv);
|
const cliRun = await runCLI(Bun.argv);
|
||||||
if (cliRun) {
|
if (cliRun) {
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,9 @@ if (fs.existsSync(path.join(path.dirname(DATABASE_URL), "ironmount.db")) && !fs.
|
||||||
const sqlite = new Database(DATABASE_URL);
|
const sqlite = new Database(DATABASE_URL);
|
||||||
export const db = drizzle({ client: sqlite, relations, schema });
|
export const db = drizzle({ client: sqlite, relations, schema });
|
||||||
|
|
||||||
export const runDbMigrations = () => {
|
let migrationsPromise: Promise<void> | undefined;
|
||||||
|
|
||||||
|
const runMigrations = async () => {
|
||||||
let migrationsFolder: string;
|
let migrationsFolder: string;
|
||||||
|
|
||||||
if (config.migrationsPath) {
|
if (config.migrationsPath) {
|
||||||
|
|
@ -32,3 +34,12 @@ export const runDbMigrations = () => {
|
||||||
|
|
||||||
sqlite.run("PRAGMA foreign_keys = ON;");
|
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 waitForExpect from "wait-for-expect";
|
||||||
import { test, describe, mock, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
import { test, describe, mock, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { backupsService } from "../backups.service";
|
import { backupsService } from "../backups.service";
|
||||||
import { createTestVolume } from "~/test/helpers/volume";
|
import { createTestVolume } from "~/test/helpers/volume";
|
||||||
import { createTestBackupSchedule } from "~/test/helpers/backup";
|
import { createTestBackupSchedule } from "~/test/helpers/backup";
|
||||||
|
|
@ -7,6 +8,8 @@ import { createTestRepository } from "~/test/helpers/repository";
|
||||||
import { generateBackupOutput } from "~/test/helpers/restic";
|
import { generateBackupOutput } from "~/test/helpers/restic";
|
||||||
import { faker } from "@faker-js/faker";
|
import { faker } from "@faker-js/faker";
|
||||||
import * as spawnModule from "~/server/utils/spawn";
|
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 { TEST_ORG_ID } from "~/test/helpers/organization";
|
||||||
import * as context from "~/server/core/request-context";
|
import * as context from "~/server/core/request-context";
|
||||||
import { backupsExecutionService } from "../backups.execution";
|
import { backupsExecutionService } from "../backups.execution";
|
||||||
|
|
@ -183,3 +186,100 @@ describe("getSchedulesToExecute", () => {
|
||||||
expect(schedulesToExecute).toContain(schedule.id);
|
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 cron from "node-cron";
|
||||||
import { NotFoundError, BadRequestError, ConflictError } from "http-errors-enhanced";
|
import { NotFoundError, BadRequestError, ConflictError } from "http-errors-enhanced";
|
||||||
import { db } from "../../db/db";
|
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 type { CreateBackupScheduleBody, UpdateBackupScheduleBody, UpdateScheduleMirrorsBody } from "./backups.dto";
|
||||||
|
|
||||||
import { checkMirrorCompatibility, getIncompatibleMirrorError } from "~/server/utils/backend-compatibility";
|
import { checkMirrorCompatibility, getIncompatibleMirrorError } from "~/server/utils/backend-compatibility";
|
||||||
|
|
@ -17,7 +17,7 @@ const listSchedules = async () => {
|
||||||
with: { volume: true, repository: true },
|
with: { volume: true, repository: true },
|
||||||
orderBy: { sortOrder: "asc", id: "asc" },
|
orderBy: { sortOrder: "asc", id: "asc" },
|
||||||
});
|
});
|
||||||
return schedules;
|
return schedules.filter((schedule) => schedule.volume && schedule.repository);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getScheduleById = async (scheduleId: number) => {
|
const getScheduleById = async (scheduleId: number) => {
|
||||||
|
|
@ -31,6 +31,10 @@ const getScheduleById = async (scheduleId: number) => {
|
||||||
throw new NotFoundError("Backup schedule not found");
|
throw new NotFoundError("Backup schedule not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!schedule.volume || !schedule.repository) {
|
||||||
|
throw new NotFoundError("Backup schedule not found");
|
||||||
|
}
|
||||||
|
|
||||||
return schedule;
|
return schedule;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -45,6 +49,10 @@ const getScheduleByShortId = async (shortId: string) => {
|
||||||
throw new NotFoundError("Backup schedule not found");
|
throw new NotFoundError("Backup schedule not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!schedule.volume || !schedule.repository) {
|
||||||
|
throw new NotFoundError("Backup schedule not found");
|
||||||
|
}
|
||||||
|
|
||||||
return schedule;
|
return schedule;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -190,6 +198,10 @@ const getScheduleForVolume = async (volumeId: number) => {
|
||||||
with: { volume: true, repository: true },
|
with: { volume: true, repository: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (schedule && (!schedule.volume || !schedule.repository)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return schedule ?? 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 = {
|
export const backupsService = {
|
||||||
listSchedules,
|
listSchedules,
|
||||||
getScheduleById,
|
getScheduleById,
|
||||||
|
|
@ -343,4 +384,5 @@ export const backupsService = {
|
||||||
getMirrorCompatibility,
|
getMirrorCompatibility,
|
||||||
reorderSchedules,
|
reorderSchedules,
|
||||||
getScheduleByShortId,
|
getScheduleByShortId,
|
||||||
|
cleanupOrphanedSchedules,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { startup } from "./startup";
|
||||||
let bootstrapPromise: Promise<void> | undefined;
|
let bootstrapPromise: Promise<void> | undefined;
|
||||||
|
|
||||||
const runBootstrap = async () => {
|
const runBootstrap = async () => {
|
||||||
runDbMigrations();
|
await runDbMigrations();
|
||||||
await runMigrations();
|
await runMigrations();
|
||||||
await startup();
|
await startup();
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { notificationsService } from "../notifications/notifications.service";
|
||||||
import { VolumeAutoRemountJob } from "~/server/jobs/auto-remount";
|
import { VolumeAutoRemountJob } from "~/server/jobs/auto-remount";
|
||||||
import { cache } from "~/server/utils/cache";
|
import { cache } from "~/server/utils/cache";
|
||||||
import { withContext } from "~/server/core/request-context";
|
import { withContext } from "~/server/core/request-context";
|
||||||
|
import { backupsService } from "../backups/backups.service";
|
||||||
|
|
||||||
const ensureLatestConfigurationSchema = async () => {
|
const ensureLatestConfigurationSchema = async () => {
|
||||||
const volumes = await db.query.volumesTable.findMany({});
|
const volumes = await db.query.volumesTable.findMany({});
|
||||||
|
|
@ -54,6 +55,15 @@ export const startup = async () => {
|
||||||
|
|
||||||
await ensureLatestConfigurationSchema();
|
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({
|
const volumes = await db.query.volumesTable.findMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [
|
OR: [
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue