* feat: add restore agent RPC foundation * chore: temp event handlers * refactor: export restore progress from dto file
110 lines
3.5 KiB
TypeScript
110 lines
3.5 KiB
TypeScript
import os from "node:os";
|
|
import path from "node:path";
|
|
import { Effect, Runtime } from "effect";
|
|
import { createAgentMessage, type RestoreRunPayload } from "@zerobyte/contracts/agent-protocol";
|
|
import { createRestic } from "@zerobyte/core/restic/server";
|
|
import { isPathWithin, toMessage } from "@zerobyte/core/utils";
|
|
import { logger } from "@zerobyte/core/node";
|
|
import type { ControllerCommandContext } from "../context";
|
|
import { resticDeps } from "../restic/deps";
|
|
|
|
const REPOSITORY_BASE = process.env.ZEROBYTE_REPOSITORIES_DIR || "/var/lib/zerobyte/repositories";
|
|
const RESTIC_PASS_FILE = process.env.RESTIC_PASS_FILE || "/var/lib/zerobyte/data/restic.pass";
|
|
|
|
const getBlockedRestoreTargets = () =>
|
|
[REPOSITORY_BASE, path.dirname(RESTIC_PASS_FILE), os.tmpdir()].map((target) => path.resolve(target));
|
|
|
|
const assertAllowedRestoreTarget = (target: string) => {
|
|
const resolvedTarget = path.resolve(target);
|
|
|
|
for (const blockedTarget of getBlockedRestoreTargets()) {
|
|
if (isPathWithin(blockedTarget, resolvedTarget)) {
|
|
throw new Error(
|
|
"Restore target path is not allowed. Restoring to this path could overwrite critical system files or application data.",
|
|
);
|
|
}
|
|
}
|
|
};
|
|
|
|
export const handleRestoreRunCommand = (context: ControllerCommandContext, payload: RestoreRunPayload) => {
|
|
return Effect.gen(function* () {
|
|
const restoreContext = {
|
|
restoreId: payload.restoreId,
|
|
organizationId: payload.organizationId,
|
|
repositoryId: payload.repositoryId,
|
|
snapshotId: payload.snapshotId,
|
|
};
|
|
|
|
const existing = yield* context.getRunningJob(payload.restoreId);
|
|
if (existing) {
|
|
yield* context.offerOutbound(
|
|
createAgentMessage("restore.failed", {
|
|
...restoreContext,
|
|
error: "Restore job is already running",
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
logger.info(`Starting restore ${payload.restoreId} for snapshot ${payload.snapshotId}`);
|
|
const abortController = new AbortController();
|
|
yield* context.setRunningJob(payload.restoreId, { kind: "restore", abortController });
|
|
|
|
yield* Effect.fork(
|
|
Effect.gen(function* () {
|
|
assertAllowedRestoreTarget(payload.target);
|
|
|
|
const runtime = yield* Effect.runtime<never>();
|
|
const restic = createRestic(resticDeps(payload.runtime.password));
|
|
|
|
yield* context.offerOutbound(createAgentMessage("restore.started", restoreContext));
|
|
|
|
const result = yield* restic.restore(payload.repositoryConfig, payload.snapshotId, payload.target, {
|
|
...payload.options,
|
|
signal: abortController.signal,
|
|
onProgress: (progress) => {
|
|
void Runtime.runPromise(
|
|
runtime,
|
|
context.offerOutbound(
|
|
createAgentMessage("restore.progress", {
|
|
...restoreContext,
|
|
progress,
|
|
}),
|
|
),
|
|
).catch((error) => {
|
|
logger.error(`Failed to send restore progress update: ${toMessage(error)}`);
|
|
});
|
|
},
|
|
});
|
|
|
|
yield* context.offerOutbound(
|
|
createAgentMessage("restore.completed", {
|
|
...restoreContext,
|
|
result,
|
|
}),
|
|
);
|
|
}).pipe(
|
|
Effect.catchAll((error) => {
|
|
if (abortController.signal.aborted) {
|
|
return context.offerOutbound(
|
|
createAgentMessage("restore.cancelled", {
|
|
...restoreContext,
|
|
message: "Restore was cancelled",
|
|
}),
|
|
);
|
|
}
|
|
|
|
const errorMessage = toMessage(error);
|
|
return context.offerOutbound(
|
|
createAgentMessage("restore.failed", {
|
|
...restoreContext,
|
|
error: errorMessage,
|
|
errorDetails: errorMessage,
|
|
}),
|
|
);
|
|
}),
|
|
Effect.ensuring(context.deleteRunningJob(payload.restoreId)),
|
|
),
|
|
);
|
|
}).pipe(Effect.asVoid);
|
|
};
|