zerobyte/app/server/modules/backups/helpers/backup-maintenance.ts
Eric Hess d2f65716fe
feat(mirrors): add selective snapshot sync to mirror repositories (#755)
* feat(mirrors): add selective snapshot sync to mirror repositories

Allow users to sync missing snapshots from the source repository to a
mirror. A new sync button per mirror opens a dialog showing which
snapshots are missing, with checkboxes to select which ones to copy.

- Add GET /:shortId/mirrors/:mirrorShortId/status endpoint to compare
  snapshots between source and mirror repositories
- Add POST /:shortId/mirrors/:mirrorShortId/sync endpoint accepting
  selected snapshotIds in the request body
- Extend restic copy command to accept multiple snapshotIds
- Add sync preview dialog with snapshot selection to the frontend

* refactor: stylistic changes

---------

Co-authored-by: Nicolas Meienberger <github@thisprops.com>
2026-04-16 21:28:48 +02:00

257 lines
8 KiB
TypeScript

import { BadRequestError, NotFoundError } from "http-errors-enhanced";
import { logger } from "@zerobyte/core/node";
import type { BackupSchedule, Repository } from "../../../db/schema";
import { restic } from "../../../core/restic";
import { repoMutex } from "../../../core/repository-mutex";
import { serverEvents } from "../../../core/events";
import { cache, cacheKeys } from "../../../utils/cache";
import { toMessage } from "../../../utils/errors";
import { getOrganizationId } from "~/server/core/request-context";
import { mirrorQueries, repositoryQueries, scheduleQueries } from "../backups.queries";
export async function runForget(scheduleId: number, repositoryId?: string, organizationIdOverride?: string) {
const organizationId = organizationIdOverride ?? getOrganizationId();
const schedule = await scheduleQueries.findById(scheduleId, organizationId);
if (!schedule) {
throw new NotFoundError("Backup schedule not found");
}
if (!schedule.retentionPolicy) {
throw new BadRequestError("No retention policy configured for this schedule");
}
const repository = await repositoryQueries.findById(repositoryId ?? schedule.repositoryId, organizationId);
if (!repository) {
throw new NotFoundError("Repository not found");
}
logger.info(`running retention policy (forget) for schedule ${scheduleId}`);
const releaseLock = await repoMutex.acquireExclusive(repository.id, `forget:${scheduleId}`);
try {
await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.shortId, organizationId });
cache.delByPrefix(cacheKeys.repository.all(repository.id));
} finally {
releaseLock();
}
logger.info(`Retention policy applied successfully for schedule ${scheduleId}`);
}
export async function copyToMirrors(
scheduleId: number,
sourceRepository: Repository,
retentionPolicy: BackupSchedule["retentionPolicy"],
organizationIdOverride?: string,
) {
const organizationId = organizationIdOverride ?? getOrganizationId();
const schedule = await scheduleQueries.findById(scheduleId, organizationId);
if (!schedule) {
throw new NotFoundError("Backup schedule not found");
}
const mirrors = await mirrorQueries.findEnabledBySchedule(scheduleId);
if (mirrors.length === 0) {
return;
}
logger.info(`[Background] Copying snapshots to ${mirrors.length} mirror repositories for schedule ${scheduleId}`);
for (const mirror of mirrors) {
await copyToSingleMirror(scheduleId, schedule, sourceRepository, mirror, retentionPolicy, organizationId);
}
}
export async function syncSnapshotsToMirror(
scheduleId: number,
mirrorRepositoryId: string,
organizationIdOverride?: string,
snapshotIds?: string[],
) {
const organizationId = organizationIdOverride ?? getOrganizationId();
const schedule = await scheduleQueries.findById(scheduleId, organizationId);
if (!schedule) {
throw new NotFoundError("Backup schedule not found");
}
const mirror = await mirrorQueries.findByScheduleAndRepository(scheduleId, mirrorRepositoryId);
if (!mirror) {
throw new NotFoundError("Mirror not found for this schedule");
}
const sourceRepository = await repositoryQueries.findById(schedule.repositoryId, organizationId);
if (!sourceRepository) {
throw new NotFoundError("Source repository not found");
}
const mirrorRepository = await repositoryQueries.findById(mirrorRepositoryId, organizationId);
if (!mirrorRepository) {
throw new NotFoundError("Mirror repository not found");
}
try {
logger.info(`[Background] Syncing all snapshots to mirror repository: ${mirrorRepository.name}`);
serverEvents.emit("mirror:started", {
organizationId,
scheduleId: schedule.shortId,
repositoryId: mirrorRepository.shortId,
repositoryName: mirrorRepository.name,
});
await mirrorQueries.updateStatus(scheduleId, mirrorRepositoryId, {
lastCopyStatus: "in_progress",
lastCopyError: null,
});
const releaseLocks = await repoMutex.acquireMany([
{ repositoryId: sourceRepository.id, type: "shared", operation: `mirror_sync_source:${scheduleId}` },
{ repositoryId: mirrorRepository.id, type: "exclusive", operation: `mirror_sync:${scheduleId}` },
]);
try {
await restic.copy(sourceRepository.config, mirrorRepository.config, {
tag: schedule.shortId,
organizationId,
snapshotIds,
});
cache.delByPrefix(cacheKeys.repository.all(mirrorRepository.id));
} finally {
releaseLocks();
}
if (schedule.retentionPolicy) {
void runForget(scheduleId, mirrorRepository.id, organizationId).catch((error) => {
logger.error(
`Failed to run retention policy for mirror repository ${mirrorRepository.name}: ${toMessage(error)}`,
);
});
}
await mirrorQueries.updateStatus(scheduleId, mirrorRepositoryId, {
lastCopyAt: Date.now(),
lastCopyStatus: "success",
lastCopyError: null,
});
logger.info(`[Background] Successfully synced all snapshots to mirror repository: ${mirrorRepository.name}`);
serverEvents.emit("mirror:completed", {
organizationId,
scheduleId: schedule.shortId,
repositoryId: mirrorRepository.shortId,
repositoryName: mirrorRepository.name,
status: "success",
});
} catch (error) {
const errorMessage = toMessage(error);
logger.error(
`[Background] Failed to sync all snapshots to mirror repository ${mirrorRepository.name}: ${errorMessage}`,
);
await mirrorQueries.updateStatus(scheduleId, mirrorRepositoryId, {
lastCopyAt: Date.now(),
lastCopyStatus: "error",
lastCopyError: errorMessage,
});
serverEvents.emit("mirror:completed", {
organizationId,
scheduleId: schedule.shortId,
repositoryId: mirrorRepository.shortId,
repositoryName: mirrorRepository.name,
status: "error",
error: errorMessage,
});
}
}
async function copyToSingleMirror(
scheduleId: number,
schedule: BackupSchedule,
sourceRepository: Repository,
mirror: {
repositoryId: string;
repository: Repository;
},
retentionPolicy: BackupSchedule["retentionPolicy"],
organizationId: string,
) {
try {
logger.info(`[Background] Copying to mirror repository: ${mirror.repository.name}`);
serverEvents.emit("mirror:started", {
organizationId,
scheduleId: schedule.shortId,
repositoryId: mirror.repository.shortId,
repositoryName: mirror.repository.name,
});
await mirrorQueries.updateStatus(scheduleId, mirror.repositoryId, {
lastCopyStatus: "in_progress",
lastCopyError: null,
});
const releaseLocks = await repoMutex.acquireMany([
{ repositoryId: sourceRepository.id, type: "shared", operation: `mirror_source:${scheduleId}` },
{ repositoryId: mirror.repository.id, type: "exclusive", operation: `mirror:${scheduleId}` },
]);
try {
await restic.copy(sourceRepository.config, mirror.repository.config, { tag: schedule.shortId, organizationId });
cache.delByPrefix(cacheKeys.repository.all(mirror.repository.id));
} finally {
releaseLocks();
}
if (retentionPolicy) {
void runForget(scheduleId, mirror.repository.id, organizationId).catch((error) => {
logger.error(
`Failed to run retention policy for mirror repository ${mirror.repository.name}: ${toMessage(error)}`,
);
});
}
await mirrorQueries.updateStatus(scheduleId, mirror.repositoryId, {
lastCopyAt: Date.now(),
lastCopyStatus: "success",
lastCopyError: null,
});
logger.info(`[Background] Successfully copied to mirror repository: ${mirror.repository.name}`);
serverEvents.emit("mirror:completed", {
organizationId,
scheduleId: schedule.shortId,
repositoryId: mirror.repository.shortId,
repositoryName: mirror.repository.name,
status: "success",
});
} catch (error) {
const errorMessage = toMessage(error);
logger.error(`[Background] Failed to copy to mirror repository ${mirror.repository.name}: ${errorMessage}`);
await mirrorQueries.updateStatus(scheduleId, mirror.repositoryId, {
lastCopyAt: Date.now(),
lastCopyStatus: "error",
lastCopyError: errorMessage,
});
serverEvents.emit("mirror:completed", {
organizationId,
scheduleId: schedule.shortId,
repositoryId: mirror.repository.shortId,
repositoryName: mirror.repository.name,
status: "error",
error: errorMessage,
});
}
}