feat: pre/post backup webhooks (#835)

* feat: pre/post backup webhooks

* fix(hooks): run post when cancelled

* refactor(webhooks): headers as array

* refactor: pr feedback

* refactor: simplify hooks ceremonies

* chore: pr feedbacks

* chore: re-gen migration
This commit is contained in:
Nico 2026-04-29 23:48:58 +02:00 committed by GitHub
parent 6728271a98
commit 11e9fbcc44
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 3762 additions and 122 deletions

View file

@ -2434,6 +2434,18 @@ export type ListBackupSchedulesResponses = {
includePatterns: Array<string> | null;
oneFileSystem: boolean;
customResticParams: Array<string> | null;
backupWebhooks: {
pre: {
url: string;
headers?: Array<string>;
body?: string;
} | null;
post: {
url: string;
headers?: Array<string>;
body?: string;
} | null;
} | null;
maxRetries: number;
retryDelay: number;
lastBackupAt: number | null;
@ -2712,6 +2724,18 @@ export type CreateBackupScheduleData = {
oneFileSystem?: boolean;
tags?: Array<string>;
customResticParams?: Array<string>;
backupWebhooks?: {
pre: {
url: string;
headers?: Array<string>;
body?: string;
} | null;
post: {
url: string;
headers?: Array<string>;
body?: string;
} | null;
} | null;
maxRetries?: number;
retryDelay?: number;
};
@ -2747,6 +2771,18 @@ export type CreateBackupScheduleResponses = {
includePatterns: Array<string> | null;
oneFileSystem: boolean;
customResticParams: Array<string> | null;
backupWebhooks: {
pre: {
url: string;
headers?: Array<string>;
body?: string;
} | null;
post: {
url: string;
headers?: Array<string>;
body?: string;
} | null;
} | null;
maxRetries: number;
retryDelay: number;
lastBackupAt: number | null;
@ -2816,6 +2852,18 @@ export type GetBackupScheduleResponses = {
includePatterns: Array<string> | null;
oneFileSystem: boolean;
customResticParams: Array<string> | null;
backupWebhooks: {
pre: {
url: string;
headers?: Array<string>;
body?: string;
} | null;
post: {
url: string;
headers?: Array<string>;
body?: string;
} | null;
} | null;
maxRetries: number;
retryDelay: number;
lastBackupAt: number | null;
@ -3093,6 +3141,18 @@ export type UpdateBackupScheduleData = {
oneFileSystem?: boolean;
tags?: Array<string>;
customResticParams?: Array<string>;
backupWebhooks?: {
pre: {
url: string;
headers?: Array<string>;
body?: string;
} | null;
post: {
url: string;
headers?: Array<string>;
body?: string;
} | null;
} | null;
maxRetries?: number;
retryDelay?: number;
};
@ -3130,6 +3190,18 @@ export type UpdateBackupScheduleResponses = {
includePatterns: Array<string> | null;
oneFileSystem: boolean;
customResticParams: Array<string> | null;
backupWebhooks: {
pre: {
url: string;
headers?: Array<string>;
body?: string;
} | null;
post: {
url: string;
headers?: Array<string>;
body?: string;
} | null;
} | null;
maxRetries: number;
retryDelay: number;
lastBackupAt: number | null;
@ -3179,6 +3251,18 @@ export type GetBackupScheduleForVolumeResponses = {
includePatterns: Array<string> | null;
oneFileSystem: boolean;
customResticParams: Array<string> | null;
backupWebhooks: {
pre: {
url: string;
headers?: Array<string>;
body?: string;
} | null;
post: {
url: string;
headers?: Array<string>;
body?: string;
} | null;
} | null;
maxRetries: number;
retryDelay: number;
lastBackupAt: number | null;

View file

@ -0,0 +1,25 @@
import { CodeBlock } from "~/client/components/ui/code-block";
import { Label } from "~/client/components/ui/label";
type WebhookRequestPreviewProps = {
method: string;
url?: string;
contentType?: string;
headers?: string[];
body: string;
};
export const WebhookRequestPreview = ({ method, url, contentType, headers, body }: WebhookRequestPreviewProps) => {
const headerLines = [contentType ? `Content-Type: ${contentType}` : undefined, ...(headers ?? [])].filter(Boolean);
const previewCode = `${method} ${url || "https://api.example.com/webhook"}${headerLines.length > 0 ? `\n${headerLines.join("\n")}` : ""}
${body}`;
return (
<div className="space-y-2 pt-4 border-t">
<Label>Request Preview</Label>
<CodeBlock code={previewCode} filename="HTTP Request" />
<p className="text-[0.8rem] text-muted-foreground">This is a preview of the HTTP request that will be sent.</p>
</div>
);
};

View file

@ -1,6 +1,6 @@
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
import { Textarea } from "~/client/components/ui/textarea";
import type { UseFormReturn } from "react-hook-form";
import { type UseFormReturn } from "react-hook-form";
import type { InternalFormValues } from "./types";
import { Input } from "~/client/components/ui/input";
@ -8,6 +8,72 @@ type AdvancedSectionProps = {
form: UseFormReturn<InternalFormValues>;
};
type WebhookFieldsProps = {
form: UseFormReturn<InternalFormValues>;
phase: "pre" | "post";
urlPlaceholder: string;
bodyPlaceholder: string;
description: string;
};
const WebhookFields = ({ form, phase, urlPlaceholder, bodyPlaceholder, description }: WebhookFieldsProps) => {
const labelPrefix = phase === "pre" ? "Pre-backup" : "Post-backup";
const fieldPrefix = phase === "pre" ? "preBackupWebhook" : "postBackupWebhook";
return (
<>
<FormField
control={form.control}
name={`${fieldPrefix}Url`}
render={({ field }) => (
<FormItem>
<FormLabel>{labelPrefix} webhook</FormLabel>
<FormControl>
<Input {...field} type="url" placeholder={urlPlaceholder} />
</FormControl>
<FormDescription>{description}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`${fieldPrefix}HeadersText`}
render={({ field }) => (
<FormItem>
<FormLabel>{labelPrefix} webhook headers</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder="Authorization: Bearer token&#10;X-Custom-Header: value"
className="font-mono text-sm min-h-24"
/>
</FormControl>
<FormDescription>
One header per line in Key: Value format. Values are stored as plain text.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`${fieldPrefix}Body`}
render={({ field }) => (
<FormItem>
<FormLabel>{labelPrefix} webhook body</FormLabel>
<FormControl>
<Textarea {...field} placeholder={bodyPlaceholder} className="font-mono text-sm min-h-24" />
</FormControl>
<FormDescription>Optional raw POST body. Leave empty to send the backup context JSON.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
);
};
export const AdvancedSection = ({ form }: AdvancedSectionProps) => {
return (
<>
@ -56,6 +122,20 @@ export const AdvancedSection = ({ form }: AdvancedSectionProps) => {
</FormItem>
)}
/>
<WebhookFields
form={form}
phase="pre"
urlPlaceholder="http://host.docker.internal:8080/stop"
bodyPlaceholder='{"action":"stop"}'
description="Called with POST before restic starts. A non-2xx response stops the backup."
/>
<WebhookFields
form={form}
phase="post"
urlPlaceholder="http://host.docker.internal:8080/start"
bodyPlaceholder='{"action":"start"}'
description="Called with POST after restic finishes, including failed or cancelled runs."
/>
<FormField
control={form.control}
name="customResticParamsText"

View file

@ -14,7 +14,7 @@ import { PathsSection } from "./paths-section";
import { RetentionSection } from "./retention-section";
import { SummarySection } from "./summary-section";
import { internalFormSchema, type BackupScheduleFormValues, type InternalFormValues } from "./types";
import { backupScheduleToFormValues, parseMultilineEntries } from "./utils";
import { backupScheduleToFormValues, parseMultilineEntries, toWebhookConfig } from "./utils";
export type { BackupScheduleFormValues };
@ -46,12 +46,24 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
cronExpression,
maxRetries,
retryDelay,
preBackupWebhookUrl,
preBackupWebhookHeadersText,
preBackupWebhookBody,
postBackupWebhookUrl,
postBackupWebhookHeadersText,
postBackupWebhookBody,
...rest
} = data;
const excludePatterns = parseMultilineEntries(excludePatternsText);
const excludeIfPresent = parseMultilineEntries(excludeIfPresentText);
const parsedIncludePatterns = parseMultilineEntries(includePatterns);
const customResticParams = parseMultilineEntries(customResticParamsText);
const preBackupWebhook = toWebhookConfig(preBackupWebhookUrl, preBackupWebhookHeadersText, preBackupWebhookBody);
const postBackupWebhook = toWebhookConfig(
postBackupWebhookUrl,
postBackupWebhookHeadersText,
postBackupWebhookBody,
);
onSubmit({
...rest,
@ -61,6 +73,8 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
excludePatterns,
excludeIfPresent,
customResticParams,
backupWebhooks:
preBackupWebhook || postBackupWebhook ? { pre: preBackupWebhook, post: postBackupWebhook } : null,
maxRetries,
retryDelay,
});

View file

@ -1,4 +1,19 @@
import { z } from "zod";
import type { BackupWebhooks } from "@zerobyte/core/backup-hooks";
const webhookHeadersSchema = z.string().refine(
(value) =>
value
.split("\n")
.map((header) => header.trim())
.filter(Boolean)
.every((header) => {
const [key, value] = header.split(":", 2);
return /^[A-Za-z0-9-]+$/.test(key.trim()) && (value?.trim().length ?? 0) > 0;
}),
{ message: "Headers must use non-empty Key: Value format with valid header names" },
);
export const internalFormSchema = z.object({
name: z.string().min(1).max(128),
@ -20,6 +35,12 @@ export const internalFormSchema = z.object({
keepYearly: z.number().optional(),
oneFileSystem: z.boolean().optional(),
customResticParamsText: z.string().optional(),
preBackupWebhookUrl: z.union([z.url(), z.literal("")]).optional(),
preBackupWebhookHeadersText: webhookHeadersSchema.optional(),
preBackupWebhookBody: z.string().optional(),
postBackupWebhookUrl: z.union([z.url(), z.literal("")]).optional(),
postBackupWebhookHeadersText: webhookHeadersSchema.optional(),
postBackupWebhookBody: z.string().optional(),
maxRetries: z.number().min(0).max(32).optional(),
retryDelay: z.number().min(1).max(1440).optional(),
});
@ -38,10 +59,20 @@ export type InternalFormValues = z.infer<typeof internalFormSchema>;
export type BackupScheduleFormValues = Omit<
InternalFormValues,
"excludePatternsText" | "excludeIfPresentText" | "includePatterns" | "customResticParamsText"
| "excludePatternsText"
| "excludeIfPresentText"
| "includePatterns"
| "customResticParamsText"
| "preBackupWebhookUrl"
| "preBackupWebhookHeadersText"
| "preBackupWebhookBody"
| "postBackupWebhookUrl"
| "postBackupWebhookHeadersText"
| "postBackupWebhookBody"
> & {
excludePatterns?: string[];
excludeIfPresent?: string[];
includePatterns?: string[];
customResticParams?: string[];
backupWebhooks?: BackupWebhooks | null;
};

View file

@ -2,13 +2,29 @@ import type { BackupSchedule } from "~/client/lib/types";
import { cronToFormValues } from "../../lib/cron-utils";
import type { InternalFormValues } from "./types";
export const parseMultilineEntries = (value?: string) =>
value
? value
.split("\n")
.map((entry) => entry.trim())
.filter(Boolean)
: [];
export const parseMultilineEntries = (value?: string) => {
if (!value) {
return [];
}
return value
.split("\n")
.map((entry) => entry.trim())
.filter(Boolean);
};
export const toWebhookConfig = (url?: string, headers?: string, body?: string) => {
const trimmedUrl = url?.trim();
const parsedHeaders = parseMultilineEntries(headers);
return trimmedUrl
? {
url: trimmedUrl,
headers: parsedHeaders.length > 0 ? parsedHeaders : undefined,
body: body === "" ? undefined : body,
}
: null;
};
export const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValues | undefined => {
if (!schedule) {
@ -26,6 +42,12 @@ export const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalF
excludeIfPresentText: schedule.excludeIfPresent?.join("\n") || undefined,
oneFileSystem: schedule.oneFileSystem ?? false,
customResticParamsText: schedule.customResticParams?.join("\n") ?? "",
preBackupWebhookUrl: schedule.backupWebhooks?.pre?.url ?? "",
preBackupWebhookHeadersText: schedule.backupWebhooks?.pre?.headers?.join("\n") ?? "",
preBackupWebhookBody: schedule.backupWebhooks?.pre?.body ?? "",
postBackupWebhookUrl: schedule.backupWebhooks?.post?.url ?? "",
postBackupWebhookHeadersText: schedule.backupWebhooks?.post?.headers?.join("\n") ?? "",
postBackupWebhookBody: schedule.backupWebhooks?.post?.body ?? "",
maxRetries: schedule.maxRetries,
retryDelay: schedule.retryDelay,
...cronValues,

View file

@ -79,6 +79,7 @@ const schedule = {
excludeIfPresent: [],
oneFileSystem: false,
customResticParams: [],
backupWebhooks: null,
};
const snapshot = {

View file

@ -49,6 +49,7 @@ const renderEditBackupPage = ({ enabled, cronExpression }: { enabled: boolean; c
excludeIfPresent: [],
oneFileSystem: false,
customResticParams: [],
backupWebhooks: null,
});
}),
http.get("/api/v1/repositories", () => {
@ -124,3 +125,28 @@ test("preserves a disabled schedule when saving a non-manual frequency", async (
cronExpression: "00 02 * * *",
});
});
test("submits webhook headers and body as plain config values", async () => {
const { submittedBody } = renderEditBackupPage({ enabled: true, cronExpression: "0 2 * * *" });
await userEvent.click(await screen.findByText("Advanced"));
await userEvent.type(screen.getByLabelText("Pre-backup webhook"), "http://localhost:8080/stop");
fireEvent.change(screen.getByLabelText("Pre-backup webhook headers"), {
target: { value: "Authorization: Bearer stop-token" },
});
fireEvent.change(screen.getByLabelText("Pre-backup webhook body"), {
target: { value: '{"action":"stop"}' },
});
await userEvent.click(screen.getByRole("button", { name: "Update schedule" }));
await expect(submittedBody).resolves.toMatchObject({
backupWebhooks: {
pre: {
url: "http://localhost:8080/stop",
headers: ["Authorization: Bearer stop-token"],
body: '{"action":"stop"}',
},
post: null,
},
});
});

View file

@ -167,6 +167,7 @@ export function ScheduleDetailsPage(props: Props) {
excludeIfPresent: schedule.excludeIfPresent || [],
oneFileSystem: schedule.oneFileSystem,
customResticParams: schedule.customResticParams || [],
backupWebhooks: schedule.backupWebhooks,
},
});
};

View file

@ -75,6 +75,7 @@ export function CreateBackupPage() {
excludeIfPresent: formValues.excludeIfPresent,
oneFileSystem: formValues.oneFileSystem,
customResticParams: formValues.customResticParams,
backupWebhooks: formValues.backupWebhooks,
maxRetries: formValues.maxRetries,
retryDelay: formValues.retryDelay,
},

View file

@ -4,8 +4,7 @@ import { Input } from "~/client/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Checkbox } from "~/client/components/ui/checkbox";
import { Textarea } from "~/client/components/ui/textarea";
import { CodeBlock } from "~/client/components/ui/code-block";
import { Label } from "~/client/components/ui/label";
import { WebhookRequestPreview } from "~/client/components/webhook-request-preview";
import type { NotificationFormValues } from "../create-notification-form";
type Props = {
@ -35,16 +34,14 @@ const WebhookPreview = ({ values }: { values: Partial<NotificationFormValues> })
body = "Notification message";
}
const previewCode = `${values.method} ${values.url}\nContent-Type: ${contentType}${headers.length > 0 ? `\n${headers.join("\n")}` : ""}
${body}`;
return (
<div className="space-y-2 pt-4 border-t">
<Label>Request Preview</Label>
<CodeBlock code={previewCode} filename="HTTP Request" />
<p className="text-[0.8rem] text-muted-foreground">This is a preview of the HTTP request that will be sent.</p>
</div>
<WebhookRequestPreview
method={values.method || "POST"}
url={values.url}
contentType={contentType}
headers={headers}
body={body}
/>
);
};

View file

@ -0,0 +1 @@
ALTER TABLE `backup_schedules_table` ADD `backup_webhooks` text;

File diff suppressed because it is too large Load diff

View file

@ -9,6 +9,7 @@ import type {
DoctorResult,
ResticStatsDto,
} from "@zerobyte/core/restic";
import type { BackupWebhooks } from "@zerobyte/core/backup-hooks";
import type { BackendConfig, BackendStatus, BackendType } from "~/schemas/volumes";
import type { NotificationConfig, NotificationType } from "~/schemas/notifications";
import type { ShortId } from "~/server/utils/branded";
@ -310,6 +311,7 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
nextBackupAt: int("next_backup_at", { mode: "number" }),
oneFileSystem: int("one_file_system", { mode: "boolean" }).notNull().default(false),
customResticParams: text("custom_restic_params", { mode: "json" }).$type<string[]>().default([]),
backupWebhooks: text("backup_webhooks", { mode: "json" }).$type<BackupWebhooks | null>(),
sortOrder: int("sort_order", { mode: "number" }).notNull().default(0),
failureRetryCount: int("failure_retry_count").notNull().default(0),
maxRetries: int("max_retries").notNull().default(2),

View file

@ -144,6 +144,7 @@ test("close emits a synthetic backup.cancelled for a queued backup", () => {
defaultExcludes: [],
rcloneConfigFile: "/tmp/rclone.conf",
},
webhooks: { pre: null, post: null },
}),
);

View file

@ -276,6 +276,41 @@ describe("backup execution - validation failures", () => {
).toBe(false);
});
test("passes configured backup webhooks to the backup agent", async () => {
const { runBackupMock } = setup();
const volume = await createTestVolume();
const repository = await createTestRepository();
const backupWebhooks = {
pre: {
url: "http://localhost:8080/stop",
headers: ["authorization: Bearer stop-token"],
body: '{"action":"stop"}',
},
post: {
url: "http://localhost:8080/start",
headers: ["authorization: Bearer start-token"],
body: '{"action":"start"}',
},
};
const schedule = await createTestBackupSchedule({
volumeId: volume.id,
repositoryId: repository.id,
backupWebhooks,
});
await backupsService.executeBackup(schedule.id);
expect(runBackupMock).toHaveBeenCalledWith(
"local",
expect.objectContaining({
payload: expect.objectContaining({
webhooks: backupWebhooks,
}),
}),
);
});
test("should fail backup when the local agent is unavailable", async () => {
const { runBackupMock } = setup();
const volume = await createTestVolume();

View file

@ -402,6 +402,62 @@ describe("manual only schedules", () => {
});
});
describe("schedule webhooks", () => {
test("stores pre and post backup webhooks when creating a schedule", async () => {
setup();
const volume = await createTestVolume();
const repository = await createTestRepository();
const backupWebhooks = {
pre: {
url: "http://localhost:8080/stop",
headers: ["authorization: Bearer stop-token"],
body: '{"action":"stop"}',
},
post: {
url: "http://localhost:8080/start",
headers: ["authorization: Bearer start-token"],
body: '{"action":"start"}',
},
};
const schedule = await backupsService.createSchedule({
name: "with-webhooks",
volumeId: volume.shortId,
repositoryId: repository.shortId,
enabled: true,
cronExpression: "0 0 * * *",
backupWebhooks,
retryDelay: 15 * 60 * 1000,
maxRetries: 2,
});
expect(schedule.backupWebhooks).toEqual(backupWebhooks);
});
test("clears backup webhooks when updating a schedule with null values", async () => {
setup();
const repository = await createTestRepository();
const schedule = await createTestBackupSchedule({
repositoryId: repository.id,
backupWebhooks: {
pre: { url: "http://localhost:8080/stop" },
post: { url: "http://localhost:8080/start" },
},
});
const updated = await backupsService.updateSchedule(schedule.id, {
repositoryId: repository.shortId,
cronExpression: schedule.cronExpression,
backupWebhooks: null,
retryDelay: 15 * 60 * 1000,
maxRetries: 2,
});
expect(updated.backupWebhooks).toBeNull();
});
});
describe("listSchedules", () => {
test("should ignore schedules with missing relations", async () => {
setup();

View file

@ -1,4 +1,5 @@
import { Effect } from "effect";
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";
@ -54,6 +55,7 @@ const createBackupRunPayload = async ({
rcloneConfigFile: resticDeps.rcloneConfigFile,
hostname: resticDeps.hostname,
},
webhooks: schedule.backupWebhooks ?? { pre: null, post: null },
};
};
@ -61,41 +63,21 @@ const executeBackupWithoutAgent = async (
payload: BackupRunPayload,
{ signal, onProgress }: Pick<BackupExecutionRequest, "signal" | "onProgress">,
) => {
try {
const execution = await Effect.runPromise(
restic
.backup(payload.repositoryConfig, payload.sourcePath, {
...payload.options,
organizationId: payload.organizationId,
signal,
onProgress,
})
.pipe(
Effect.map((result) => ({ success: true as const, result })),
Effect.catchAll((error) => Effect.succeed({ success: false as const, error })),
),
);
if (!execution.success) {
return {
status: "failed" as const,
error: toErrorDetails(execution.error),
};
}
const { exitCode, result, warningDetails } = execution.result;
return {
status: "completed" as const,
exitCode,
result,
warningDetails,
};
} catch (error) {
return {
status: "failed" as const,
error: toErrorDetails(error),
};
}
return Effect.runPromise(
runBackupLifecycle({
restic,
repositoryConfig: payload.repositoryConfig,
sourcePath: payload.sourcePath,
jobId: payload.jobId,
scheduleId: payload.scheduleId,
organizationId: payload.organizationId,
options: payload.options,
webhooks: payload.webhooks,
signal,
onProgress,
formatError: toErrorDetails,
}),
);
};
export const backupExecutor = {

View file

@ -2,11 +2,13 @@ import { Hono } from "hono";
import { validator } from "hono-openapi";
import {
createBackupScheduleBody,
createBackupScheduleResponse,
createBackupScheduleDto,
deleteBackupScheduleDto,
getBackupScheduleDto,
getBackupScheduleResponse,
getBackupScheduleForVolumeDto,
getBackupScheduleForVolumeResponse,
listBackupSchedulesDto,
listBackupSchedulesResponse,
runBackupNowDto,
@ -14,6 +16,7 @@ import {
stopBackupDto,
updateBackupScheduleDto,
updateBackupScheduleBody,
updateBackupScheduleResponse,
getScheduleMirrorsDto,
updateScheduleMirrorsDto,
updateScheduleMirrorsBody,
@ -73,20 +76,20 @@ export const backupScheduleController = new Hono()
const volumeShortId = asShortId(c.req.param("volumeShortId"));
const schedule = await backupsService.getScheduleForVolume(volumeShortId);
return c.json<GetBackupScheduleForVolumeResponseDto>(schedule, 200);
return c.json<GetBackupScheduleForVolumeResponseDto>(getBackupScheduleForVolumeResponse.parse(schedule), 200);
})
.post("/", createBackupScheduleDto, validator("json", createBackupScheduleBody), async (c) => {
const body = c.req.valid("json");
const schedule = await backupsService.createSchedule(body);
return c.json<CreateBackupScheduleDto>(schedule, 201);
return c.json<CreateBackupScheduleDto>(createBackupScheduleResponse.parse(schedule), 201);
})
.patch("/:shortId", updateBackupScheduleDto, validator("json", updateBackupScheduleBody), async (c) => {
const shortId = asShortId(c.req.param("shortId"));
const body = c.req.valid("json");
const schedule = await backupsService.updateSchedule(shortId, body);
return c.json<UpdateBackupScheduleDto>(schedule, 200);
return c.json<UpdateBackupScheduleDto>(updateBackupScheduleResponse.parse(schedule), 200);
})
.delete("/:shortId", deleteBackupScheduleDto, async (c) => {
const shortId = asShortId(c.req.param("shortId"));

View file

@ -1,5 +1,6 @@
import { z } from "zod";
import { describeRoute, resolver } from "hono-openapi";
import { backupWebhooksSchema } from "@zerobyte/core/backup-hooks";
import { volumeSchema } from "../volumes/volume.dto";
import { repositorySchema } from "../repositories/repositories.dto";
import { backupProgressEventSchema } from "~/schemas/events-dto";
@ -29,6 +30,7 @@ const backupScheduleSchema = z.object({
includePatterns: z.array(z.string()).nullable(),
oneFileSystem: z.boolean(),
customResticParams: z.array(z.string()).nullable(),
backupWebhooks: backupWebhooksSchema.nullable(),
maxRetries: z.number(),
retryDelay: z.number().transform((ms) => Math.round(ms / 60000)),
lastBackupAt: z.number().nullable(),
@ -92,7 +94,7 @@ export const getBackupScheduleDto = describeRoute({
},
});
const getBackupScheduleForVolumeResponse = backupScheduleSchema.nullable();
export const getBackupScheduleForVolumeResponse = backupScheduleSchema.nullable();
export type GetBackupScheduleForVolumeResponseDto = z.infer<typeof getBackupScheduleForVolumeResponse>;
@ -126,6 +128,7 @@ export const createBackupScheduleBody = z.object({
oneFileSystem: z.boolean().optional(),
tags: z.array(z.string()).optional(),
customResticParams: z.array(z.string()).optional(),
backupWebhooks: backupWebhooksSchema.nullable().optional(),
maxRetries: z.number().min(0).max(32).optional().default(2),
retryDelay: z
.number()
@ -138,7 +141,7 @@ export const createBackupScheduleBody = z.object({
export type CreateBackupScheduleBody = z.infer<typeof createBackupScheduleBody>;
const createBackupScheduleResponse = backupScheduleSchema.omit({ volume: true, repository: true });
export const createBackupScheduleResponse = backupScheduleSchema.omit({ volume: true, repository: true });
export type CreateBackupScheduleDto = z.infer<typeof createBackupScheduleResponse>;
@ -171,6 +174,7 @@ export const updateBackupScheduleBody = z.object({
oneFileSystem: z.boolean().optional(),
tags: z.array(z.string()).optional(),
customResticParams: z.array(z.string()).optional(),
backupWebhooks: backupWebhooksSchema.nullable().optional(),
maxRetries: z.number().min(0).max(32).optional().default(2),
retryDelay: z
.number()
@ -183,7 +187,7 @@ export const updateBackupScheduleBody = z.object({
export type UpdateBackupScheduleBody = z.infer<typeof updateBackupScheduleBody>;
const updateBackupScheduleResponse = backupScheduleSchema.omit({ volume: true, repository: true });
export const updateBackupScheduleResponse = backupScheduleSchema.omit({ volume: true, repository: true });
export type UpdateBackupScheduleDto = z.infer<typeof updateBackupScheduleResponse>;

View file

@ -104,6 +104,7 @@ const createSchedule = async (data: CreateBackupScheduleBody) => {
includePatterns: data.includePatterns ?? [],
oneFileSystem: data.oneFileSystem,
customResticParams: data.customResticParams ?? [],
backupWebhooks: data.backupWebhooks ?? null,
nextBackupAt: nextBackupAt,
shortId: generateShortId(),
maxRetries: data.maxRetries,
@ -163,7 +164,13 @@ const updateSchedule = async (scheduleIdOrShortId: number | string, data: Update
const [updated] = await db
.update(backupSchedulesTable)
.set({ ...data, repositoryId: repository.id, nextBackupAt, updatedAt: Date.now() })
.set({
...data,
repositoryId: repository.id,
backupWebhooks: data.backupWebhooks === undefined ? schedule.backupWebhooks : data.backupWebhooks,
nextBackupAt,
updatedAt: Date.now(),
})
.where(and(eq(backupSchedulesTable.id, schedule.id), eq(backupSchedulesTable.organizationId, organizationId)))
.returning();

View file

@ -149,7 +149,7 @@ export async function finalizeSuccessfulBackup(
warningDetails: string | null,
) {
const scheduleId = ctx.schedule.id;
const finalStatus = exitCode === 0 ? "success" : "warning";
const finalStatus = exitCode === 0 && !warningDetails ? "success" : "warning";
if (ctx.schedule.retentionPolicy) {
void runForget(scheduleId, undefined, ctx.organizationId).catch((error) => {

View file

@ -46,6 +46,7 @@ test("emits backup.failed when a backup command hits a restic error", async () =
defaultExcludes: [],
rcloneConfigFile: "/root/.config/rclone/rclone.conf",
},
webhooks: { pre: null, post: null },
}),
);

View file

@ -1,5 +1,7 @@
import { afterEach, expect, test, vi } from "vitest";
import { Effect } from "effect";
import { HttpResponse, http } from "msw";
import { setupServer } from "msw/node";
import { afterAll, afterEach, beforeAll, expect, test, vi } from "vitest";
import waitForExpect from "wait-for-expect";
import { fromPartial } from "@total-typescript/shoehorn";
import { parseAgentMessage, type BackupCancelPayload, type BackupRunPayload } from "@zerobyte/contracts/agent-protocol";
@ -8,6 +10,12 @@ import { handleBackupCancelCommand } from "./backup-cancel";
import { handleBackupRunCommand } from "./backup-run";
import type { ControllerCommandContext, RunningJob } from "../context";
const server = setupServer();
beforeAll(() => {
server.listen({ onUnhandledRequest: "error" });
});
const createDeferred = <T>() => {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
@ -19,6 +27,253 @@ const createDeferred = <T>() => {
afterEach(() => {
vi.restoreAllMocks();
server.resetHandlers();
});
afterAll(() => {
server.close();
});
const createRunPayload = (overrides: Partial<BackupRunPayload> = {}) =>
fromPartial<BackupRunPayload>({
jobId: "job-1",
scheduleId: "schedule-1",
organizationId: "org-1",
sourcePath: "/tmp/source",
repositoryConfig: {
backend: "local",
path: "/tmp/repository",
},
options: {},
runtime: {
password: "password",
cacheDir: "/tmp/restic-cache",
passFile: "/tmp/restic-pass",
defaultExcludes: [],
rcloneConfigFile: "/tmp/rclone.conf",
},
webhooks: { pre: null, post: null },
...overrides,
});
const runBackupCommand = async (payload: BackupRunPayload) => {
const outboundMessages: string[] = [];
const runningJobs = new Map<string, RunningJob>();
const context: ControllerCommandContext = {
getRunningJob: (jobId) => Effect.succeed(runningJobs.get(jobId)),
setRunningJob: (jobId, job) =>
Effect.sync(() => {
runningJobs.set(jobId, job);
}),
deleteRunningJob: (jobId) =>
Effect.sync(() => {
runningJobs.delete(jobId);
}),
offerOutbound: (message) =>
Effect.sync(() => {
outboundMessages.push(message);
return true;
}),
};
await Effect.runPromise(
Effect.gen(function* () {
yield* handleBackupRunCommand(context, payload);
yield* Effect.promise(() =>
waitForExpect(() => {
expect(runningJobs.has(payload.jobId)).toBe(false);
}),
);
}),
);
return outboundMessages.map((message) => parseAgentMessage(message));
};
test("runs pre and post backup webhooks around restic", async () => {
const events: string[] = [];
server.use(
http.post("http://localhost:8080/pre", async ({ request }) => {
const body = (await request.json()) as { event: string };
events.push(body.event);
return new HttpResponse(null, { status: 204 });
}),
http.post("http://localhost:8080/post", async ({ request }) => {
const body = (await request.json()) as { event: string };
events.push(body.event);
return new HttpResponse(null, { status: 204 });
}),
);
vi.spyOn(resticServer, "createRestic").mockReturnValue(
fromPartial({
backup: () =>
Effect.sync(() => {
events.push("restic");
return { exitCode: 0, result: null, warningDetails: null };
}),
}),
);
const messages = await runBackupCommand(
createRunPayload({
webhooks: {
pre: { url: "http://localhost:8080/pre" },
post: { url: "http://localhost:8080/post" },
},
}),
);
expect(events).toEqual(["backup.pre", "restic", "backup.post"]);
expect(messages.some((message) => message?.success && message.data.type === "backup.completed")).toBe(true);
});
test("sends configured webhook headers and body without changing them", async () => {
const requests: Array<{ url: string; headers: Headers; body: string }> = [];
server.use(
http.post("http://localhost:8080/pre", async ({ request }) => {
requests.push({
url: request.url,
headers: request.headers,
body: await request.text(),
});
return new HttpResponse(null, { status: 204 });
}),
http.post("http://localhost:8080/post", async ({ request }) => {
requests.push({
url: request.url,
headers: request.headers,
body: await request.text(),
});
return new HttpResponse(null, { status: 204 });
}),
);
vi.spyOn(resticServer, "createRestic").mockReturnValue(
fromPartial({
backup: () => Effect.succeed({ exitCode: 0, result: null, warningDetails: null }),
}),
);
await runBackupCommand(
createRunPayload({
webhooks: {
pre: {
url: "http://localhost:8080/pre",
headers: ["authorization: Bearer pre-token", "content-type: application/json"],
body: '{"action":"stop"}',
},
post: {
url: "http://localhost:8080/post",
headers: ["authorization: Bearer post-token"],
body: "start-container",
},
},
}),
);
expect(requests).toHaveLength(2);
expect(requests[0]?.url).toBe("http://localhost:8080/pre");
expect(requests[0]?.headers.get("authorization")).toBe("Bearer pre-token");
expect(requests[0]?.headers.get("content-type")).toBe("application/json");
expect(requests[0]?.body).toBe('{"action":"stop"}');
expect(requests[1]?.url).toBe("http://localhost:8080/post");
expect(requests[1]?.headers.get("authorization")).toBe("Bearer post-token");
expect(requests[1]?.body).toBe("start-container");
});
test("fails without running restic when the pre-backup webhook fails", async () => {
const backupMock = vi.fn();
server.use(
http.post("http://localhost:8080/pre", () => {
return new HttpResponse("stop failed", { status: 500 });
}),
);
vi.spyOn(resticServer, "createRestic").mockReturnValue(
fromPartial({
backup: backupMock,
}),
);
const messages = await runBackupCommand(
createRunPayload({
webhooks: {
pre: { url: "http://localhost:8080/pre" },
post: null,
},
}),
);
const failed = messages.find((message) => message?.success && message.data.type === "backup.failed");
expect(backupMock).not.toHaveBeenCalled();
expect(failed?.success).toBe(true);
if (failed?.success && failed.data.type === "backup.failed") {
expect(failed.data.payload.errorDetails).toContain("pre webhook returned HTTP 500");
}
});
test("reports a post-backup webhook failure as completed warning details", async () => {
server.use(
http.post("http://localhost:8080/post", () => {
return new HttpResponse("start failed", { status: 500 });
}),
);
vi.spyOn(resticServer, "createRestic").mockReturnValue(
fromPartial({
backup: () => Effect.succeed({ exitCode: 0, result: null, warningDetails: null }),
}),
);
const messages = await runBackupCommand(
createRunPayload({
webhooks: {
pre: null,
post: { url: "http://localhost:8080/post" },
},
}),
);
const completed = messages.find((message) => message?.success && message.data.type === "backup.completed");
expect(completed?.success).toBe(true);
if (completed?.success && completed.data.type === "backup.completed") {
expect(completed.data.payload.warningDetails).toContain("post webhook returned HTTP 500");
}
});
test("includes post-backup webhook failure details when a backup is cancelled", async () => {
server.use(
http.post("http://localhost:8080/post", () => {
return new HttpResponse("start failed", { status: 500 });
}),
);
vi.spyOn(resticServer, "createRestic").mockReturnValue(
fromPartial({
backup: (_config: unknown, _source: string, options: { signal: AbortSignal }) =>
Effect.sync(() => {
vi.spyOn(options.signal, "aborted", "get").mockReturnValue(true);
vi.spyOn(options.signal, "reason", "get").mockReturnValue(new Error("Backup was cancelled"));
return { exitCode: 0, result: null, warningDetails: null };
}),
}),
);
const messages = await runBackupCommand(
createRunPayload({
webhooks: {
pre: null,
post: { url: "http://localhost:8080/post" },
},
}),
);
const cancelled = messages.find((message) => message?.success && message.data.type === "backup.cancelled");
expect(cancelled?.success).toBe(true);
if (cancelled?.success && cancelled.data.type === "backup.cancelled") {
expect(cancelled.data.payload.message).toContain("post webhook returned HTTP 500: start failed");
}
});
test("waits for running-job registration before returning to the processor loop", async () => {
@ -78,6 +333,7 @@ test("waits for running-job registration before returning to the processor loop"
passFile: "/tmp/restic-pass",
defaultExcludes: [],
},
webhooks: { pre: null, post: null },
});
const cancelPayload = fromPartial<BackupCancelPayload>({
jobId: "job-1",

View file

@ -1,9 +1,10 @@
import { Effect, Runtime } from "effect";
import { createAgentMessage, type BackupRunPayload } from "@zerobyte/contracts/agent-protocol";
import { runBackupLifecycle } from "@zerobyte/core/backup-hooks";
import { logger } from "@zerobyte/core/node";
import { type ResticDeps } from "@zerobyte/core/restic";
import { createRestic } from "@zerobyte/core/restic/server";
import { toErrorDetails, toMessage } from "@zerobyte/core/utils";
import { toMessage } from "@zerobyte/core/utils";
import type { ControllerCommandContext } from "../context";
export const handleBackupRunCommand = (context: ControllerCommandContext, payload: BackupRunPayload) => {
@ -26,12 +27,12 @@ export const handleBackupRunCommand = (context: ControllerCommandContext, payloa
yield* Effect.fork(
Effect.gen(function* () {
const sendCancelled = () => {
const sendCancelled = (message?: string) => {
return context.offerOutbound(
createAgentMessage("backup.cancelled", {
jobId: payload.jobId,
scheduleId: payload.scheduleId,
message: "Backup was cancelled",
message: message ?? "Backup was cancelled",
}),
);
};
@ -56,61 +57,59 @@ export const handleBackupRunCommand = (context: ControllerCommandContext, payloa
const restic = createRestic(deps);
const runtime = yield* Effect.runtime<never>();
yield* restic
.backup(payload.repositoryConfig, payload.sourcePath, {
organizationId: payload.organizationId,
...payload.options,
signal: abortController.signal,
onProgress: (progress) => {
void Runtime.runPromise(
runtime,
context.offerOutbound(
createAgentMessage("backup.progress", {
jobId: payload.jobId,
scheduleId: payload.scheduleId,
progress,
}),
),
).catch((error) => {
logger.error(`Failed to send backup progress update: ${toMessage(error)}`);
});
},
})
.pipe(
Effect.matchEffect({
onSuccess: (result) => {
if (abortController.signal.aborted) {
return sendCancelled();
}
const backupResult = yield* runBackupLifecycle({
restic,
repositoryConfig: payload.repositoryConfig,
sourcePath: payload.sourcePath,
jobId: payload.jobId,
scheduleId: payload.scheduleId,
organizationId: payload.organizationId,
options: payload.options,
webhooks: payload.webhooks,
signal: abortController.signal,
onProgress: (progress) => {
void Runtime.runPromise(
runtime,
context.offerOutbound(
createAgentMessage("backup.progress", {
jobId: payload.jobId,
scheduleId: payload.scheduleId,
progress,
}),
),
).catch((error) => {
logger.error(`Failed to send backup progress update: ${toMessage(error)}`);
});
},
});
return context.offerOutbound(
createAgentMessage("backup.completed", {
jobId: payload.jobId,
scheduleId: payload.scheduleId,
exitCode: result.exitCode,
result: result.result,
warningDetails: result.warningDetails ?? undefined,
}),
);
},
onFailure: (error) => {
if (abortController.signal.aborted) {
return sendCancelled();
}
return context.offerOutbound(
createAgentMessage("backup.failed", {
jobId: payload.jobId,
scheduleId: payload.scheduleId,
error: toMessage(error),
errorDetails: toErrorDetails(error),
}),
);
},
}),
Effect.ensuring(context.deleteRunningJob(payload.jobId)),
);
}),
switch (backupResult.status) {
case "completed":
yield* context.offerOutbound(
createAgentMessage("backup.completed", {
jobId: payload.jobId,
scheduleId: payload.scheduleId,
exitCode: backupResult.exitCode,
result: backupResult.result,
warningDetails: backupResult.warningDetails ?? undefined,
}),
);
return;
case "failed":
yield* context.offerOutbound(
createAgentMessage("backup.failed", {
jobId: payload.jobId,
scheduleId: payload.scheduleId,
error: toMessage(backupResult.error),
errorDetails: backupResult.error,
}),
);
return;
case "cancelled":
yield* sendCancelled(backupResult.message);
return;
}
}).pipe(Effect.ensuring(context.deleteRunningJob(payload.jobId))),
);
}).pipe(Effect.asVoid);
};

View file

@ -1,4 +1,5 @@
import { z } from "zod";
import { backupWebhooksSchema } from "@zerobyte/core/backup-hooks";
import { safeJsonParse } from "@zerobyte/core/utils";
import {
repositoryConfigSchema,
@ -39,6 +40,7 @@ const backupRunSchema = z.object({
repositoryConfig: repositoryConfigSchema,
options: backupExecutionOptionsSchema,
runtime: backupRuntimeSchema,
webhooks: backupWebhooksSchema,
}),
});

View file

@ -18,6 +18,11 @@
"import": "./src/utils/index.ts",
"default": "./src/utils/index.ts"
},
"./backup-hooks": {
"types": "./src/backup-hooks/index.ts",
"import": "./src/backup-hooks/index.ts",
"default": "./src/backup-hooks/index.ts"
},
"./node": {
"types": "./src/node/index.ts",
"import": "./src/node/index.ts",

View file

@ -0,0 +1,383 @@
import { Effect } from "effect";
import { HttpResponse, http } from "msw";
import { setupServer } from "msw/node";
import { afterAll, afterEach, beforeAll, expect, test } from "vitest";
import { runBackupLifecycle } from "../index.js";
const server = setupServer();
beforeAll(() => {
server.listen({ onUnhandledRequest: "error" });
});
afterEach(() => {
server.resetHandlers();
});
afterAll(() => {
server.close();
});
const metadata = {
jobId: "job-1",
scheduleId: "schedule-1",
organizationId: "org-1",
sourcePath: "/tmp/source",
};
const defaultSignal = () => new AbortController().signal;
const completedBackup = <TResult>(result: TResult, exitCode = 0, warningDetails: string | null = null) =>
Effect.succeed({ exitCode, result, warningDetails });
const runWithHooks = <TResult>(
overrides: Omit<Partial<Parameters<typeof runBackupLifecycle<TResult>>[0]>, "restic"> & {
runBackup: () => Effect.Effect<{ exitCode: number; result: TResult; warningDetails: string | null }, unknown>;
},
) => {
const { runBackup, ...options } = overrides;
return Effect.runPromise(
runBackupLifecycle({
...metadata,
restic: { backup: runBackup },
repositoryConfig: { backend: "local", path: "/tmp/repository" },
options: {},
webhooks: { pre: null, post: null },
signal: defaultSignal(),
...options,
}),
);
};
type WebhookBody = {
phase?: string;
event?: string;
jobId?: string;
scheduleId?: string;
organizationId?: string;
sourcePath?: string;
status?: string;
error?: string;
};
test("runs pre and post webhooks around a successful backup", async () => {
const events: string[] = [];
let preBody: WebhookBody | undefined;
let postBody: WebhookBody | undefined;
server.use(
http.post("http://localhost:8080/pre", async ({ request }) => {
events.push("pre");
preBody = (await request.json()) as WebhookBody;
return new HttpResponse(null, { status: 204 });
}),
http.post("http://localhost:8080/post", async ({ request }) => {
events.push("post");
postBody = (await request.json()) as WebhookBody;
return new HttpResponse(null, { status: 204 });
}),
);
const result = await runWithHooks({
webhooks: {
pre: { url: "http://localhost:8080/pre" },
post: { url: "http://localhost:8080/post" },
},
runBackup: () =>
Effect.sync(() => {
events.push("backup");
return { exitCode: 0, result: "snapshot-1", warningDetails: null };
}),
});
expect(events).toEqual(["pre", "backup", "post"]);
expect(preBody).toMatchObject({ ...metadata, phase: "pre", event: "backup.pre" });
expect(postBody).toMatchObject({ ...metadata, phase: "post", event: "backup.post", status: "success" });
expect(result).toEqual({ status: "completed", exitCode: 0, result: "snapshot-1", warningDetails: null });
});
test("sends warning details to the post-backup webhook for a non-zero completed backup", async () => {
let postBody: WebhookBody | undefined;
server.use(
http.post("http://localhost:8080/post", async ({ request }) => {
postBody = (await request.json()) as WebhookBody;
return new HttpResponse(null, { status: 204 });
}),
);
const result = await runWithHooks({
webhooks: {
pre: null,
post: { url: "http://localhost:8080/post" },
},
runBackup: () => completedBackup("snapshot-1", 3, "some files could not be read"),
});
expect(postBody).toMatchObject({ status: "warning", error: "some files could not be read" });
expect(result).toEqual({
status: "completed",
exitCode: 3,
result: "snapshot-1",
warningDetails: "some files could not be read",
});
});
test("sends error details to the post-backup webhook when the backup fails", async () => {
let postBody: WebhookBody | undefined;
server.use(
http.post("http://localhost:8080/post", async ({ request }) => {
postBody = (await request.json()) as WebhookBody;
return new HttpResponse(null, { status: 204 });
}),
);
const result = await runWithHooks({
webhooks: {
pre: null,
post: { url: "http://localhost:8080/post" },
},
runBackup: () => Effect.fail(new Error("restic failed")),
});
expect(postBody).toMatchObject({ status: "error", error: "restic failed" });
expect(result).toEqual({ status: "failed", error: "restic failed" });
});
test("fails without running the backup or post webhook when the pre-backup webhook fails", async () => {
let backupRan = false;
let postRan = false;
server.use(
http.post("http://localhost:8080/pre", () => {
return new HttpResponse("stop failed", { status: 500 });
}),
http.post("http://localhost:8080/post", () => {
postRan = true;
return new HttpResponse(null, { status: 204 });
}),
);
const result = await runWithHooks({
webhooks: {
pre: { url: "http://localhost:8080/pre" },
post: { url: "http://localhost:8080/post" },
},
runBackup: () =>
Effect.sync(() => {
backupRan = true;
return { exitCode: 0, result: null, warningDetails: null };
}),
});
expect(backupRan).toBe(false);
expect(postRan).toBe(false);
expect(result.status).toBe("failed");
if (result.status === "failed") {
expect(result.error).toContain("pre webhook returned HTTP 500: stop failed");
}
});
test("sends configured webhook headers and body without replacing them", async () => {
let body: string | undefined;
let authorization: string | null | undefined;
let contentType: string | null | undefined;
server.use(
http.post("http://localhost:8080/post", async ({ request }) => {
body = await request.text();
authorization = request.headers.get("authorization");
contentType = request.headers.get("content-type");
return new HttpResponse(null, { status: 204 });
}),
);
await runWithHooks({
webhooks: {
pre: null,
post: {
url: "http://localhost:8080/post",
headers: ["authorization: Bearer post-token"],
body: "start-container",
},
},
runBackup: () => completedBackup(null),
});
expect(body).toBe("start-container");
expect(authorization).toBe("Bearer post-token");
expect(contentType).toBeNull();
});
test("runs the post-backup webhook after cancellation without using the cancelled signal", async () => {
const abortController = new AbortController();
let postBody: { status?: string; error?: string } | undefined;
server.use(
http.post("http://localhost:8080/post", async ({ request }) => {
postBody = (await request.json()) as { status?: string; error?: string };
return new HttpResponse(null, { status: 204 });
}),
);
const result = await runWithHooks({
webhooks: {
pre: null,
post: { url: "http://localhost:8080/post" },
},
signal: abortController.signal,
runBackup: () =>
Effect.gen(function* () {
abortController.abort(new Error("Backup was cancelled"));
return yield* Effect.fail(new Error("restic cancelled"));
}),
});
expect(postBody).toMatchObject({ status: "cancelled", error: "restic cancelled" });
expect(result).toEqual({ status: "cancelled", message: "Backup was cancelled" });
});
test("runs the post-backup webhook when cancellation returns a completed backup result", async () => {
const abortController = new AbortController();
let postBody: { status?: string; error?: string } | undefined;
server.use(
http.post("http://localhost:8080/post", async ({ request }) => {
postBody = (await request.json()) as { status?: string; error?: string };
return new HttpResponse(null, { status: 204 });
}),
);
const result = await runWithHooks({
webhooks: {
pre: null,
post: { url: "http://localhost:8080/post" },
},
signal: abortController.signal,
runBackup: () =>
Effect.sync(() => {
abortController.abort(new Error("Backup was cancelled"));
return { exitCode: 0, result: null, warningDetails: null };
}),
});
expect(postBody).toMatchObject({ status: "cancelled" });
expect(result).toEqual({ status: "cancelled", message: "Backup was cancelled" });
});
test("includes post-backup webhook failure details after cancellation", async () => {
const abortController = new AbortController();
let postBody: { status?: string; error?: string } | undefined;
server.use(
http.post("http://localhost:8080/post", async ({ request }) => {
postBody = (await request.json()) as { status?: string; error?: string };
return new HttpResponse("start failed", { status: 500 });
}),
);
const result = await runWithHooks({
webhooks: {
pre: null,
post: { url: "http://localhost:8080/post" },
},
signal: abortController.signal,
runBackup: () =>
Effect.gen(function* () {
abortController.abort(new Error("Backup was cancelled"));
return yield* Effect.fail(new Error("restic cancelled"));
}),
});
expect(postBody).toMatchObject({ status: "cancelled", error: "restic cancelled" });
expect(result.status).toBe("cancelled");
if (result.status === "cancelled") {
expect(result.message).toContain("Backup was cancelled");
expect(result.message).toContain("post webhook returned HTTP 500: start failed");
}
});
test("includes post-backup webhook failure details after completed cancellation", async () => {
const abortController = new AbortController();
let postBody: { status?: string; error?: string } | undefined;
server.use(
http.post("http://localhost:8080/post", async ({ request }) => {
postBody = (await request.json()) as { status?: string; error?: string };
return new HttpResponse("cleanup failed", { status: 500 });
}),
);
const result = await runWithHooks({
webhooks: {
pre: null,
post: { url: "http://localhost:8080/post" },
},
signal: abortController.signal,
runBackup: () =>
Effect.sync(() => {
abortController.abort(new Error("Backup was cancelled"));
return { exitCode: 0, result: null, warningDetails: null };
}),
});
expect(postBody).toMatchObject({ status: "cancelled", error: "Backup was cancelled" });
expect(result.status).toBe("cancelled");
if (result.status === "cancelled") {
expect(result.message).toContain("Backup was cancelled");
expect(result.message).toContain("post webhook returned HTTP 500: cleanup failed");
}
});
test("cancels before the pre-backup webhook without running the backup", async () => {
const abortController = new AbortController();
let backupRan = false;
abortController.abort(new Error("Backup was cancelled"));
const result = await runWithHooks({
webhooks: {
pre: { url: "http://localhost:8080/pre" },
post: { url: "http://localhost:8080/post" },
},
signal: abortController.signal,
runBackup: () =>
Effect.sync(() => {
backupRan = true;
return { exitCode: 0, result: null, warningDetails: null };
}),
});
expect(backupRan).toBe(false);
expect(result).toEqual({ status: "cancelled", message: "Backup was cancelled" });
});
test("cancels after the pre-backup webhook without running the backup", async () => {
const abortController = new AbortController();
let backupRan = false;
server.use(
http.post("http://localhost:8080/pre", () => {
abortController.abort(new Error("Backup was cancelled"));
return new HttpResponse(null, { status: 204 });
}),
);
const result = await runWithHooks({
webhooks: {
pre: { url: "http://localhost:8080/pre" },
post: null,
},
signal: abortController.signal,
runBackup: () =>
Effect.sync(() => {
backupRan = true;
return { exitCode: 0, result: null, warningDetails: null };
}),
});
expect(backupRan).toBe(false);
expect(result).toEqual({ status: "cancelled", message: "Backup was cancelled" });
});

View file

@ -0,0 +1,280 @@
import { Data, Effect } from "effect";
import { z } from "zod";
import type { CompressionMode, RepositoryConfig, ResticBackupProgressDto } from "../restic/index.js";
import { toErrorDetails, toMessage } from "../utils/index.js";
const DEFAULT_BACKUP_WEBHOOK_TIMEOUT_MS = 60_000;
export const backupWebhookConfigSchema = z.object({
url: z.url(),
headers: z.array(z.string()).optional(),
body: z.string().optional(),
});
export const backupWebhooksSchema = z.object({
pre: backupWebhookConfigSchema.nullable(),
post: backupWebhookConfigSchema.nullable(),
});
export type BackupWebhookConfig = z.infer<typeof backupWebhookConfigSchema>;
export type BackupWebhooks = z.infer<typeof backupWebhooksSchema>;
type BackupWebhookPhase = "pre" | "post";
type BackupWebhookStatus = "success" | "warning" | "error" | "cancelled";
type BackupWebhookContext = {
phase: BackupWebhookPhase;
event: "backup.pre" | "backup.post";
jobId: string;
scheduleId: string;
organizationId: string;
sourcePath: string;
status?: BackupWebhookStatus;
error?: string;
};
type BackupResult<TResult> = { exitCode: number; result: TResult; warningDetails: string | null };
type BackupLifecycleResult<TResult> =
| { status: "completed"; exitCode: number; result: TResult; warningDetails: string | null }
| { status: "failed"; error: string }
| { status: "cancelled"; message?: string };
type BackupOptions = {
tags?: string[];
oneFileSystem?: boolean;
exclude?: string[];
excludeIfPresent?: string[];
includePaths?: string[];
includePatterns?: string[];
customResticParams?: string[];
compressionMode?: CompressionMode;
};
type BackupLifecycleOptions<TResult> = {
jobId: string;
scheduleId: string;
organizationId: string;
sourcePath: string;
restic: {
backup: (
config: RepositoryConfig,
sourcePath: string,
options: BackupOptions & {
organizationId: string;
signal: AbortSignal;
onProgress?: (progress: ResticBackupProgressDto) => void;
},
) => Effect.Effect<BackupResult<TResult>, unknown>;
};
repositoryConfig: RepositoryConfig;
options: BackupOptions;
webhooks: BackupWebhooks;
signal: AbortSignal;
onProgress?: (progress: ResticBackupProgressDto) => void;
formatError?: (error: unknown) => string;
};
class BackupWebhookError extends Data.TaggedError("BackupWebhookError")<{
cause: unknown;
message: string;
}> {}
const appendDetails = (primary: string | null | undefined, next: string | null | undefined) => {
return [primary, next].filter(Boolean).join("\n\n");
};
const getCompletedStatus = (exitCode: number, signal: AbortSignal): BackupWebhookStatus => {
if (signal.aborted) {
return "cancelled";
}
return exitCode === 0 ? "success" : "warning";
};
const createAbortController = (timeoutMs: number, signal?: AbortSignal) => {
const abortController = new AbortController();
const timeout = setTimeout(() => {
abortController.abort(new Error(`Webhook timed out after ${Math.round(timeoutMs / 1000)} seconds`));
}, timeoutMs);
const abortFromSignal = () => {
abortController.abort(signal?.reason || new Error("Operation aborted"));
};
if (signal?.aborted) {
abortFromSignal();
} else {
signal?.addEventListener("abort", abortFromSignal, { once: true });
}
return {
signal: abortController.signal,
cleanup: () => {
clearTimeout(timeout);
signal?.removeEventListener("abort", abortFromSignal);
},
};
};
const createRequestInit = (config: BackupWebhookConfig, context: BackupWebhookContext): RequestInit => {
const headers = new Headers();
const body = config.body ?? JSON.stringify(context);
if (config.body === undefined) {
headers.set("content-type", "application/json");
}
for (const header of config.headers ?? []) {
const [name, ...valueParts] = header.split(":");
if (name && valueParts.length > 0) {
headers.set(name.trim(), valueParts.join(":").trim());
}
}
return { method: "POST", headers, body };
};
const runBackupWebhook = (
config: BackupWebhookConfig | null,
context: BackupWebhookContext,
options: {
formatError: (error: unknown) => string;
signal?: AbortSignal;
timeoutMs?: number;
},
) =>
Effect.suspend(() => {
if (!config) {
return Effect.succeed(null);
}
const timeoutMs = options.timeoutMs ?? DEFAULT_BACKUP_WEBHOOK_TIMEOUT_MS;
const controller = createAbortController(timeoutMs, options.signal);
return Effect.tryPromise({
try: async () => {
const response = await fetch(config.url, { ...createRequestInit(config, context), signal: controller.signal });
if (!response.ok) {
const responseText = await response.text().catch(() => "");
const details = responseText.trim().slice(0, 500);
throw new BackupWebhookError({
cause: new Error(`${context.phase} webhook returned HTTP ${response.status}`),
message: `${context.phase} webhook returned HTTP ${response.status}${details ? `: ${details}` : ""}`,
});
}
},
catch: (error) => {
if (error instanceof BackupWebhookError) {
return error;
}
if (controller.signal.aborted && controller.signal.reason instanceof Error) {
return new BackupWebhookError({
cause: controller.signal.reason,
message: `${context.phase} webhook failed: ${controller.signal.reason.message}`,
});
}
return new BackupWebhookError({
cause: error,
message: `${context.phase} webhook failed: ${toMessage(error)}`,
});
},
}).pipe(
Effect.as(null),
Effect.catchAll((error) => Effect.succeed(options.formatError(error))),
Effect.ensuring(Effect.sync(controller.cleanup)),
);
});
export const runBackupLifecycle = <TResult>({
jobId,
scheduleId,
organizationId,
sourcePath,
restic,
repositoryConfig,
options,
webhooks,
signal,
onProgress,
formatError = toErrorDetails,
}: BackupLifecycleOptions<TResult>): Effect.Effect<BackupLifecycleResult<TResult>, never> =>
Effect.gen(function* () {
const context = { jobId, scheduleId, organizationId, sourcePath };
const preHookError = yield* runBackupWebhook(
webhooks.pre,
{ ...context, phase: "pre", event: "backup.pre" },
{
formatError,
signal,
},
);
if (preHookError) {
if (signal.aborted) {
return { status: "cancelled", message: formatError(signal.reason) };
}
return { status: "failed", error: preHookError };
}
if (signal.aborted) {
return { status: "cancelled", message: formatError(signal.reason) };
}
const backupResult = yield* Effect.suspend(() =>
restic.backup(repositoryConfig, sourcePath, { ...options, organizationId, signal, onProgress }),
).pipe(
Effect.map((result) => ({
status: "completed" as const,
...result,
hookStatus: getCompletedStatus(result.exitCode, signal),
hookError: signal.aborted ? formatError(signal.reason) : (result.warningDetails ?? undefined),
})),
Effect.catchAll((error) => {
const errorDetails = formatError(error);
return Effect.succeed({
status: "failed" as const,
errorDetails,
hookStatus: signal.aborted ? ("cancelled" as const) : ("error" as const),
hookError: errorDetails,
});
}),
);
const postHookError = yield* runBackupWebhook(
webhooks.post,
{
...context,
phase: "post",
event: "backup.post",
status: backupResult.hookStatus,
error: backupResult.hookError,
},
{ formatError },
);
if (signal.aborted) {
return {
status: "cancelled",
message: appendDetails(formatError(signal.reason || backupResult.hookError), postHookError) || undefined,
};
}
if (backupResult.status === "completed") {
return {
status: "completed",
exitCode: backupResult.exitCode,
result: backupResult.result,
warningDetails: appendDetails(backupResult.warningDetails, postHookError) || null,
};
}
return {
status: "failed",
error: appendDetails(backupResult.errorDetails, postHookError) || backupResult.errorDetails,
};
});