Files
zerobyte/app/server/modules/backups/backup-executor.ts
2026-05-30 16:54:49 +02:00

174 lines
5.8 KiB
TypeScript

import { runBackupLifecycle } from "@zerobyte/core/backup-hooks";
import type { BackupSchedule, Volume, Repository } from "../../db/schema";
import { config } from "../../core/config";
import { restic, resticDeps } from "../../core/restic";
import type { BackupRunPayload } from "@zerobyte/contracts/agent-protocol";
import { agentManager, type BackupExecutionProgress } from "../agents/agents-manager";
import { LOCAL_AGENT_ID } from "../agents/constants";
import { getVolumePath } from "../volumes/helpers";
import { decryptVolumeConfig } from "../volumes/volume-config-secrets";
import { decryptRepositoryConfig } from "../repositories/repository-config-secrets";
import { createBackupOptions } from "./backup.helpers";
import { runEffectPromise, toErrorDetails } from "../../utils/errors";
import { BadRequestError } from "http-errors-enhanced";
const FUSE_VOLUME_BACKENDS = new Set<Volume["type"]>(["rclone", "sftp", "webdav"]);
const IGNORE_INODE_FLAG = "--ignore-inode";
type BackupExecutionRequest = {
jobId: string;
scheduleId: number;
schedule: BackupSchedule;
volume: Volume;
repository: Repository;
organizationId: string;
signal: AbortSignal;
onProgress: (progress: BackupExecutionProgress) => void;
};
export type { BackupExecutionResult } from "../agents/agents-manager";
const activeControllersByScheduleId = new Map<number, { abortController: AbortController; agentId: string | null }>();
const getBackupExecutionAgentId = (volume: Volume, repository: Repository) => {
if (repository.type === "local" && volume.agentId !== LOCAL_AGENT_ID) {
throw new BadRequestError(`Local repository "${repository.name}" can only be used with the local agent`);
}
return volume.agentId;
};
const createBackupRunPayload = async ({
jobId,
schedule,
volume,
repository,
organizationId,
}: BackupExecutionRequest): Promise<BackupRunPayload> => {
const agentVolume = { ...volume, config: await decryptVolumeConfig(volume.config) };
const customResticParams = schedule.customResticParams ?? [];
const repositoryConfig = await decryptRepositoryConfig(repository.config);
const encryptedResticPassword = await resticDeps.getOrganizationResticPassword(organizationId);
const resticPassword = await resticDeps.resolveSecret(encryptedResticPassword);
return {
jobId,
scheduleId: schedule.shortId,
organizationId,
volume: agentVolume,
repositoryConfig,
options: {
oneFileSystem: schedule.oneFileSystem,
excludePatterns: schedule.excludePatterns,
excludeIfPresent: schedule.excludeIfPresent,
includePaths: schedule.includePaths,
includePatterns: schedule.includePatterns,
customResticParams:
FUSE_VOLUME_BACKENDS.has(volume.type) && !customResticParams.includes(IGNORE_INODE_FLAG)
? [...customResticParams, IGNORE_INODE_FLAG]
: customResticParams,
compressionMode: repository.compressionMode ?? "auto",
},
runtime: {
password: resticPassword,
},
webhooks: schedule.backupWebhooks ?? { pre: null, post: null },
webhookAllowedOrigins: config.webhookAllowedOrigins,
webhookTimeoutMs: config.webhookTimeout * 1000,
};
};
const executeBackupWithoutAgent = async (
payload: BackupRunPayload,
{ schedule, volume, signal, onProgress }: BackupExecutionRequest,
) => {
const sourcePath = getVolumePath(volume);
const { signal: _, ...backupOptions } = createBackupOptions(schedule, sourcePath, signal);
const options = {
...backupOptions,
customResticParams: payload.options.customResticParams ?? [],
compressionMode: payload.options.compressionMode,
};
return runEffectPromise(
runBackupLifecycle({
restic,
repositoryConfig: payload.repositoryConfig,
sourcePath,
jobId: payload.jobId,
scheduleId: payload.scheduleId,
organizationId: payload.organizationId,
options,
webhooks: payload.webhooks,
webhookAllowedOrigins: payload.webhookAllowedOrigins,
webhookTimeoutMs: payload.webhookTimeoutMs,
signal,
onProgress,
formatError: toErrorDetails,
}),
);
};
export const backupExecutor = {
track: (scheduleId: number) => {
const abortController = new AbortController();
activeControllersByScheduleId.set(scheduleId, { abortController, agentId: null });
return abortController;
},
untrack: (scheduleId: number, abortController: AbortController) => {
if (activeControllersByScheduleId.get(scheduleId)?.abortController === abortController) {
activeControllersByScheduleId.delete(scheduleId);
}
},
execute: async (request: BackupExecutionRequest) => {
const trackedExecution = activeControllersByScheduleId.get(request.scheduleId);
if (!trackedExecution || trackedExecution.abortController.signal !== request.signal) {
throw new Error(`Backup execution for schedule ${request.scheduleId} was not tracked`);
}
if (request.signal.aborted) {
throw request.signal.reason || new Error("Operation aborted");
}
const payload = await createBackupRunPayload(request);
if (request.signal.aborted) {
throw request.signal.reason || new Error("Operation aborted");
}
const executionAgentId = getBackupExecutionAgentId(request.volume, request.repository);
trackedExecution.agentId = executionAgentId;
const executionResult = await agentManager.runBackup(executionAgentId, {
scheduleId: request.scheduleId,
payload,
signal: request.signal,
onProgress: request.onProgress,
});
if (
executionResult.status === "unavailable" &&
executionAgentId === LOCAL_AGENT_ID &&
!config.flags.enableLocalAgent
) {
return executeBackupWithoutAgent(payload, request);
}
return executionResult;
},
cancel: async (scheduleId: number) => {
const trackedExecution = activeControllersByScheduleId.get(scheduleId);
if (!trackedExecution) {
return false;
}
trackedExecution.abortController.abort();
if (!trackedExecution.agentId) {
return true;
}
await agentManager.cancelBackup(trackedExecution.agentId, scheduleId);
return true;
},
};