348 lines
9.7 KiB
TypeScript
348 lines
9.7 KiB
TypeScript
import { and, eq } 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 type { CreateBackupScheduleBody, UpdateBackupScheduleBody, UpdateScheduleMirrorsBody } from "./backups.dto";
|
|
|
|
import { checkMirrorCompatibility, getIncompatibleMirrorError } from "~/server/utils/backend-compatibility";
|
|
import { generateShortId } from "~/server/utils/id";
|
|
import { getOrganizationId } from "~/server/core/request-context";
|
|
import { calculateNextRun } from "./backup.helpers";
|
|
|
|
const listSchedules = async () => {
|
|
const organizationId = getOrganizationId();
|
|
const schedules = await db.query.backupSchedulesTable.findMany({
|
|
where: { organizationId },
|
|
with: { volume: true, repository: true },
|
|
orderBy: { sortOrder: "asc", id: "asc" },
|
|
});
|
|
return schedules;
|
|
};
|
|
|
|
const getScheduleById = async (scheduleId: number) => {
|
|
const organizationId = getOrganizationId();
|
|
const schedule = await db.query.backupSchedulesTable.findFirst({
|
|
where: { AND: [{ id: scheduleId }, { organizationId }] },
|
|
with: { volume: true, repository: true },
|
|
});
|
|
|
|
if (!schedule) {
|
|
throw new NotFoundError("Backup schedule not found");
|
|
}
|
|
|
|
return schedule;
|
|
};
|
|
|
|
const getScheduleByShortId = async (shortId: string) => {
|
|
const organizationId = getOrganizationId();
|
|
const schedule = await db.query.backupSchedulesTable.findFirst({
|
|
where: { AND: [{ shortId }, { organizationId }] },
|
|
with: { volume: true, repository: true },
|
|
});
|
|
|
|
if (!schedule) {
|
|
throw new NotFoundError("Backup schedule not found");
|
|
}
|
|
|
|
return schedule;
|
|
};
|
|
|
|
const createSchedule = async (data: CreateBackupScheduleBody) => {
|
|
const organizationId = getOrganizationId();
|
|
if (!cron.validate(data.cronExpression)) {
|
|
throw new BadRequestError("Invalid cron expression");
|
|
}
|
|
|
|
const existingName = await db.query.backupSchedulesTable.findFirst({
|
|
where: {
|
|
AND: [{ name: data.name }, { organizationId }],
|
|
},
|
|
});
|
|
|
|
if (existingName) {
|
|
throw new ConflictError("A backup schedule with this name already exists");
|
|
}
|
|
|
|
const volume = await db.query.volumesTable.findFirst({
|
|
where: {
|
|
AND: [{ id: data.volumeId }, { organizationId }],
|
|
},
|
|
});
|
|
|
|
if (!volume) {
|
|
throw new NotFoundError("Volume not found");
|
|
}
|
|
|
|
const repository = await db.query.repositoriesTable.findFirst({
|
|
where: {
|
|
AND: [{ id: data.repositoryId }, { organizationId }],
|
|
},
|
|
});
|
|
|
|
if (!repository) {
|
|
throw new NotFoundError("Repository not found");
|
|
}
|
|
|
|
const nextBackupAt = calculateNextRun(data.cronExpression);
|
|
|
|
const [newSchedule] = await db
|
|
.insert(backupSchedulesTable)
|
|
.values({
|
|
name: data.name,
|
|
volumeId: data.volumeId,
|
|
repositoryId: data.repositoryId,
|
|
enabled: data.enabled,
|
|
cronExpression: data.cronExpression,
|
|
retentionPolicy: data.retentionPolicy ?? null,
|
|
excludePatterns: data.excludePatterns ?? [],
|
|
excludeIfPresent: data.excludeIfPresent ?? [],
|
|
includePatterns: data.includePatterns ?? [],
|
|
oneFileSystem: data.oneFileSystem,
|
|
nextBackupAt: nextBackupAt,
|
|
shortId: generateShortId(),
|
|
organizationId,
|
|
})
|
|
.returning();
|
|
|
|
if (!newSchedule) {
|
|
throw new Error("Failed to create backup schedule");
|
|
}
|
|
|
|
return newSchedule;
|
|
};
|
|
|
|
const updateSchedule = async (scheduleId: number, data: UpdateBackupScheduleBody) => {
|
|
const organizationId = getOrganizationId();
|
|
const schedule = await db.query.backupSchedulesTable.findFirst({
|
|
where: {
|
|
AND: [{ id: scheduleId }, { organizationId }],
|
|
},
|
|
});
|
|
|
|
if (!schedule) {
|
|
throw new NotFoundError("Backup schedule not found");
|
|
}
|
|
|
|
if (data.cronExpression && !cron.validate(data.cronExpression)) {
|
|
throw new BadRequestError("Invalid cron expression");
|
|
}
|
|
|
|
if (data.name) {
|
|
const existingName = await db.query.backupSchedulesTable.findFirst({
|
|
where: {
|
|
AND: [{ name: data.name }, { NOT: { id: scheduleId } }, { organizationId }],
|
|
},
|
|
});
|
|
|
|
if (existingName) {
|
|
throw new ConflictError("A backup schedule with this name already exists");
|
|
}
|
|
}
|
|
|
|
const repository = await db.query.repositoriesTable.findFirst({
|
|
where: {
|
|
AND: [{ id: data.repositoryId }, { organizationId }],
|
|
},
|
|
});
|
|
|
|
if (!repository) {
|
|
throw new NotFoundError("Repository not found");
|
|
}
|
|
|
|
const cronExpression = data.cronExpression ?? schedule.cronExpression;
|
|
const nextBackupAt = data.cronExpression ? calculateNextRun(cronExpression) : schedule.nextBackupAt;
|
|
|
|
const [updated] = await db
|
|
.update(backupSchedulesTable)
|
|
.set({ ...data, nextBackupAt, updatedAt: Date.now() })
|
|
.where(and(eq(backupSchedulesTable.id, scheduleId), eq(backupSchedulesTable.organizationId, organizationId)))
|
|
.returning();
|
|
|
|
if (!updated) {
|
|
throw new Error("Failed to update backup schedule");
|
|
}
|
|
|
|
return updated;
|
|
};
|
|
|
|
const deleteSchedule = async (scheduleId: number) => {
|
|
const organizationId = getOrganizationId();
|
|
const schedule = await db.query.backupSchedulesTable.findFirst({
|
|
where: {
|
|
AND: [{ id: scheduleId }, { organizationId }],
|
|
},
|
|
});
|
|
|
|
if (!schedule) {
|
|
throw new NotFoundError("Backup schedule not found");
|
|
}
|
|
|
|
await db
|
|
.delete(backupSchedulesTable)
|
|
.where(and(eq(backupSchedulesTable.id, scheduleId), eq(backupSchedulesTable.organizationId, organizationId)));
|
|
};
|
|
|
|
const getScheduleForVolume = async (volumeId: number) => {
|
|
const organizationId = getOrganizationId();
|
|
const schedule = await db.query.backupSchedulesTable.findFirst({
|
|
where: { AND: [{ volumeId }, { organizationId }] },
|
|
with: { volume: true, repository: true },
|
|
});
|
|
|
|
return schedule ?? null;
|
|
};
|
|
|
|
const getMirrors = async (scheduleId: number) => {
|
|
const organizationId = getOrganizationId();
|
|
const schedule = await db.query.backupSchedulesTable.findFirst({
|
|
where: {
|
|
AND: [{ id: scheduleId }, { organizationId }],
|
|
},
|
|
});
|
|
|
|
if (!schedule) {
|
|
throw new NotFoundError("Backup schedule not found");
|
|
}
|
|
|
|
const mirrors = await db.query.backupScheduleMirrorsTable.findMany({
|
|
where: {
|
|
scheduleId,
|
|
},
|
|
with: { repository: true },
|
|
});
|
|
|
|
return mirrors;
|
|
};
|
|
|
|
const updateMirrors = async (scheduleId: number, data: UpdateScheduleMirrorsBody) => {
|
|
const organizationId = getOrganizationId();
|
|
const schedule = await db.query.backupSchedulesTable.findFirst({
|
|
where: { AND: [{ id: scheduleId }, { organizationId }] },
|
|
with: { repository: true },
|
|
});
|
|
|
|
if (!schedule) {
|
|
throw new NotFoundError("Backup schedule not found");
|
|
}
|
|
|
|
for (const mirror of data.mirrors) {
|
|
if (mirror.repositoryId === schedule.repositoryId) {
|
|
throw new BadRequestError("Cannot add the primary repository as a mirror");
|
|
}
|
|
|
|
const repo = await db.query.repositoriesTable.findFirst({
|
|
where: { AND: [{ id: mirror.repositoryId }, { organizationId }] },
|
|
});
|
|
|
|
if (!repo) {
|
|
throw new NotFoundError(`Repository ${mirror.repositoryId} not found`);
|
|
}
|
|
|
|
const compatibility = await checkMirrorCompatibility(schedule.repository.config, repo.config, repo.id);
|
|
|
|
if (!compatibility.compatible) {
|
|
throw new BadRequestError(
|
|
getIncompatibleMirrorError(repo.name, schedule.repository.config.backend, repo.config.backend),
|
|
);
|
|
}
|
|
}
|
|
|
|
const existingMirrors = await db.query.backupScheduleMirrorsTable.findMany({
|
|
where: { scheduleId },
|
|
});
|
|
|
|
const existingMirrorsMap = new Map(
|
|
existingMirrors.map((m) => [
|
|
m.repositoryId,
|
|
{ lastCopyAt: m.lastCopyAt, lastCopyStatus: m.lastCopyStatus, lastCopyError: m.lastCopyError },
|
|
]),
|
|
);
|
|
|
|
await db.delete(backupScheduleMirrorsTable).where(eq(backupScheduleMirrorsTable.scheduleId, scheduleId));
|
|
|
|
if (data.mirrors.length > 0) {
|
|
await db.insert(backupScheduleMirrorsTable).values(
|
|
data.mirrors.map((mirror) => {
|
|
const existing = existingMirrorsMap.get(mirror.repositoryId);
|
|
return {
|
|
scheduleId,
|
|
repositoryId: mirror.repositoryId,
|
|
enabled: mirror.enabled,
|
|
lastCopyAt: existing?.lastCopyAt ?? null,
|
|
lastCopyStatus: existing?.lastCopyStatus ?? null,
|
|
lastCopyError: existing?.lastCopyError ?? null,
|
|
};
|
|
}),
|
|
);
|
|
}
|
|
|
|
return getMirrors(scheduleId);
|
|
};
|
|
|
|
const getMirrorCompatibility = async (scheduleId: number) => {
|
|
const organizationId = getOrganizationId();
|
|
const schedule = await db.query.backupSchedulesTable.findFirst({
|
|
where: { AND: [{ id: scheduleId }, { organizationId }] },
|
|
with: { repository: true },
|
|
});
|
|
|
|
if (!schedule) {
|
|
throw new NotFoundError("Backup schedule not found");
|
|
}
|
|
|
|
const allRepositories = await db.query.repositoriesTable.findMany({ where: { organizationId } });
|
|
const repos = allRepositories.filter((repo) => repo.id !== schedule.repositoryId);
|
|
|
|
const compatibility = await Promise.all(
|
|
repos.map((repo) => checkMirrorCompatibility(schedule.repository.config, repo.config, repo.id)),
|
|
);
|
|
|
|
return compatibility;
|
|
};
|
|
|
|
const reorderSchedules = async (scheduleIds: number[]) => {
|
|
const organizationId = getOrganizationId();
|
|
const uniqueIds = new Set(scheduleIds);
|
|
if (uniqueIds.size !== scheduleIds.length) {
|
|
throw new BadRequestError("Duplicate schedule IDs in reorder request");
|
|
}
|
|
|
|
const existingSchedules = await db.query.backupSchedulesTable.findMany({
|
|
where: { organizationId },
|
|
columns: { id: true },
|
|
});
|
|
const existingIds = new Set(existingSchedules.map((s) => s.id));
|
|
|
|
for (const id of scheduleIds) {
|
|
if (!existingIds.has(id)) {
|
|
throw new NotFoundError(`Backup schedule with ID ${id} not found`);
|
|
}
|
|
}
|
|
|
|
await db.transaction(async (tx) => {
|
|
const now = Date.now();
|
|
await Promise.all(
|
|
scheduleIds.map((scheduleId, index) =>
|
|
tx
|
|
.update(backupSchedulesTable)
|
|
.set({ sortOrder: index, updatedAt: now })
|
|
.where(and(eq(backupSchedulesTable.id, scheduleId), eq(backupSchedulesTable.organizationId, organizationId))),
|
|
),
|
|
);
|
|
});
|
|
};
|
|
|
|
export const backupsService = {
|
|
listSchedules,
|
|
getScheduleById,
|
|
createSchedule,
|
|
updateSchedule,
|
|
deleteSchedule,
|
|
getScheduleForVolume,
|
|
getMirrors,
|
|
updateMirrors,
|
|
getMirrorCompatibility,
|
|
reorderSchedules,
|
|
getScheduleByShortId,
|
|
};
|