zerobyte/app/server/modules/backups/backup-executor.ts
Nicolas Meienberger 5eb1d2b727 feat: backup through agent
wip
2026-04-02 23:12:51 +02:00

277 lines
8.1 KiB
TypeScript

import type { BackupRunPayload } from "@zerobyte/contracts/agent-protocol";
import { agentManager } from "../agents/agents-manager";
import { resticDeps } from "../../core/restic";
import type { BackupSchedule, Repository, Volume } from "../../db/schema";
import type { ResticBackupOutputDto, ResticBackupProgressDto } from "@zerobyte/core/restic";
import { createBackupOptions } from "./backup.helpers";
import { getVolumePath } from "../volumes/helpers";
import { decryptRepositoryConfig } from "../repositories/repository-config-secrets";
type BackupExecutionRequest = {
scheduleId: number;
schedule: BackupSchedule;
volume: Volume;
repository: Repository;
organizationId: string;
signal: AbortSignal;
onProgress: (progress: BackupExecutionProgress) => void;
};
export type BackupExecutionProgress = ResticBackupProgressDto;
export type BackupExecutionResult =
| {
status: "unavailable";
error: Error;
}
| {
status: "completed";
exitCode: number;
result: ResticBackupOutputDto | null;
warningDetails: string | null;
}
| {
status: "failed";
error: unknown;
}
| {
status: "cancelled";
message?: string;
};
type ActiveBackupExecution = {
abortController: AbortController;
jobId?: string;
scheduleShortId?: string;
onProgress?: (progress: BackupExecutionProgress) => void;
resolve?: (result: BackupExecutionResult) => void;
reject?: (error: unknown) => void;
settled: boolean;
};
const LOCAL_AGENT_ID = "local";
const activeBackupsByScheduleId = new Map<number, ActiveBackupExecution>();
const activeScheduleIdsByJobId = new Map<string, number>();
const resetPendingExecution = (activeBackup: ActiveBackupExecution) => {
activeBackup.jobId = undefined;
activeBackup.scheduleShortId = undefined;
activeBackup.onProgress = undefined;
activeBackup.resolve = undefined;
activeBackup.reject = undefined;
activeBackup.settled = false;
};
const getActiveBackupByJobId = (jobId: string) => {
const scheduleId = activeScheduleIdsByJobId.get(jobId);
if (scheduleId === undefined) {
return null;
}
const activeBackup = activeBackupsByScheduleId.get(scheduleId);
if (!activeBackup || activeBackup.jobId !== jobId) {
activeScheduleIdsByJobId.delete(jobId);
return null;
}
return { scheduleId, activeBackup };
};
const resolveActiveBackup = (activeBackup: ActiveBackupExecution, result: BackupExecutionResult) => {
if (activeBackup.settled) {
return;
}
activeBackup.settled = true;
activeBackup.resolve?.(result);
};
const rejectActiveBackup = (activeBackup: ActiveBackupExecution, error: unknown) => {
if (activeBackup.settled) {
return;
}
activeBackup.settled = true;
activeBackup.reject?.(error);
};
const getCancellationError = (signal: AbortSignal, message?: string) =>
signal.reason instanceof Error ? signal.reason : new Error(message ?? "Backup was stopped by the user");
const buildAgentBackupPayload = async (params: BackupExecutionRequest, jobId: string): Promise<BackupRunPayload> => {
const { schedule, volume, repository, organizationId } = params;
const sourcePath = getVolumePath(volume);
const { signal: _ignoredSignal, ...options } = createBackupOptions(schedule, sourcePath);
const repositoryConfig = await decryptRepositoryConfig(repository.config);
const encryptedResticPassword = await resticDeps.getOrganizationResticPassword(organizationId);
const resticPassword = await resticDeps.resolveSecret(encryptedResticPassword);
return {
jobId,
scheduleId: schedule.shortId,
organizationId,
sourcePath,
repositoryConfig,
options: {
tags: options.tags,
oneFileSystem: options.oneFileSystem,
exclude: options.exclude,
excludeIfPresent: options.excludeIfPresent,
includePaths: options.includePaths,
includePatterns: options.includePatterns,
customResticParams: options.customResticParams,
compressionMode: repository.compressionMode ?? "auto",
},
runtime: {
password: resticPassword,
cacheDir: resticDeps.resticCacheDir,
passFile: resticDeps.resticPassFile,
defaultExcludes: resticDeps.defaultExcludes,
hostname: resticDeps.hostname,
},
};
};
agentManager.setBackupEventHandlers({
onBackupProgress: ({ payload }) => {
const running = getActiveBackupByJobId(payload.jobId);
if (!running || running.activeBackup.scheduleShortId !== payload.scheduleId) {
return;
}
running.activeBackup.onProgress?.(payload.progress);
},
onBackupCompleted: ({ payload }) => {
const running = getActiveBackupByJobId(payload.jobId);
if (!running || running.activeBackup.scheduleShortId !== payload.scheduleId) {
return;
}
activeScheduleIdsByJobId.delete(payload.jobId);
resolveActiveBackup(running.activeBackup, {
status: "completed",
exitCode: payload.exitCode,
result: payload.result,
warningDetails: payload.warningDetails ?? null,
});
},
onBackupFailed: ({ payload }) => {
const running = getActiveBackupByJobId(payload.jobId);
if (!running || running.activeBackup.scheduleShortId !== payload.scheduleId) {
return;
}
activeScheduleIdsByJobId.delete(payload.jobId);
resolveActiveBackup(running.activeBackup, {
status: "failed",
error: payload.errorDetails ?? payload.error,
});
},
onBackupCancelled: ({ payload }) => {
const running = getActiveBackupByJobId(payload.jobId);
if (!running || running.activeBackup.scheduleShortId !== payload.scheduleId) {
return;
}
activeScheduleIdsByJobId.delete(payload.jobId);
if (running.activeBackup.abortController.signal.aborted) {
rejectActiveBackup(
running.activeBackup,
getCancellationError(running.activeBackup.abortController.signal, payload.message),
);
return;
}
resolveActiveBackup(running.activeBackup, {
status: "cancelled",
message: payload.message,
});
},
});
export const backupExecutor = {
track: (scheduleId: number) => {
const abortController = new AbortController();
activeBackupsByScheduleId.set(scheduleId, {
abortController,
settled: false,
});
return abortController;
},
untrack: (scheduleId: number, abortController: AbortController) => {
const activeBackup = activeBackupsByScheduleId.get(scheduleId);
if (activeBackup?.abortController === abortController) {
if (activeBackup.jobId) {
activeScheduleIdsByJobId.delete(activeBackup.jobId);
}
activeBackupsByScheduleId.delete(scheduleId);
}
},
execute: async (params: BackupExecutionRequest): Promise<BackupExecutionResult> => {
const activeBackup = activeBackupsByScheduleId.get(params.scheduleId);
if (!activeBackup) {
throw new Error(`Backup ${params.scheduleId} is not tracked`);
}
if (params.signal.aborted) {
throw getCancellationError(params.signal);
}
const jobId = Bun.randomUUIDv7();
const payload = await buildAgentBackupPayload(params, jobId);
if (params.signal.aborted) {
throw getCancellationError(params.signal);
}
activeBackup.jobId = jobId;
activeBackup.scheduleShortId = params.schedule.shortId;
activeBackup.onProgress = params.onProgress;
activeBackup.settled = false;
const completion = new Promise<BackupExecutionResult>((resolve, reject) => {
activeBackup.resolve = resolve;
activeBackup.reject = reject;
});
const handleAbort = () => {
activeScheduleIdsByJobId.delete(jobId);
agentManager.cancelBackup(LOCAL_AGENT_ID, {
jobId,
scheduleId: params.schedule.shortId,
});
rejectActiveBackup(activeBackup, getCancellationError(params.signal));
};
params.signal.addEventListener("abort", handleAbort, { once: true });
activeScheduleIdsByJobId.set(jobId, params.scheduleId);
const dispatched = agentManager.sendBackup(LOCAL_AGENT_ID, payload);
if (!dispatched) {
params.signal.removeEventListener("abort", handleAbort);
activeScheduleIdsByJobId.delete(jobId);
resetPendingExecution(activeBackup);
return {
status: "unavailable",
error: new Error("Local backup agent is not connected"),
} satisfies BackupExecutionResult;
}
try {
return await completion;
} finally {
params.signal.removeEventListener("abort", handleAbort);
}
},
cancel: (scheduleId: number) => {
const activeBackup = activeBackupsByScheduleId.get(scheduleId);
if (!activeBackup) {
return false;
}
activeBackup.abortController.abort(new Error("Backup was stopped by the user"));
return true;
},
};