refactor: simplify hooks ceremonies

This commit is contained in:
Nicolas Meienberger 2026-04-29 22:05:10 +02:00
parent 687810fb13
commit 95067c39fa
No known key found for this signature in database
23 changed files with 2954 additions and 3163 deletions

View file

@ -2434,15 +2434,17 @@ export type ListBackupSchedulesResponses = {
includePatterns: Array<string> | null; includePatterns: Array<string> | null;
oneFileSystem: boolean; oneFileSystem: boolean;
customResticParams: Array<string> | null; customResticParams: Array<string> | null;
preBackupWebhook: { backupWebhooks: {
url: string; pre: {
headers?: Array<string>; url: string;
body?: string; headers?: Array<string>;
} | null; body?: string;
postBackupWebhook: { } | null;
url: string; post: {
headers?: Array<string>; url: string;
body?: string; headers?: Array<string>;
body?: string;
} | null;
} | null; } | null;
maxRetries: number; maxRetries: number;
retryDelay: number; retryDelay: number;
@ -2722,15 +2724,17 @@ export type CreateBackupScheduleData = {
oneFileSystem?: boolean; oneFileSystem?: boolean;
tags?: Array<string>; tags?: Array<string>;
customResticParams?: Array<string>; customResticParams?: Array<string>;
preBackupWebhook?: { backupWebhooks?: {
url: string; pre: {
headers?: Array<string>; url: string;
body?: string; headers?: Array<string>;
} | null; body?: string;
postBackupWebhook?: { } | null;
url: string; post: {
headers?: Array<string>; url: string;
body?: string; headers?: Array<string>;
body?: string;
} | null;
} | null; } | null;
maxRetries?: number; maxRetries?: number;
retryDelay?: number; retryDelay?: number;
@ -2767,15 +2771,17 @@ export type CreateBackupScheduleResponses = {
includePatterns: Array<string> | null; includePatterns: Array<string> | null;
oneFileSystem: boolean; oneFileSystem: boolean;
customResticParams: Array<string> | null; customResticParams: Array<string> | null;
preBackupWebhook: { backupWebhooks: {
url: string; pre: {
headers?: Array<string>; url: string;
body?: string; headers?: Array<string>;
} | null; body?: string;
postBackupWebhook: { } | null;
url: string; post: {
headers?: Array<string>; url: string;
body?: string; headers?: Array<string>;
body?: string;
} | null;
} | null; } | null;
maxRetries: number; maxRetries: number;
retryDelay: number; retryDelay: number;
@ -2846,15 +2852,17 @@ export type GetBackupScheduleResponses = {
includePatterns: Array<string> | null; includePatterns: Array<string> | null;
oneFileSystem: boolean; oneFileSystem: boolean;
customResticParams: Array<string> | null; customResticParams: Array<string> | null;
preBackupWebhook: { backupWebhooks: {
url: string; pre: {
headers?: Array<string>; url: string;
body?: string; headers?: Array<string>;
} | null; body?: string;
postBackupWebhook: { } | null;
url: string; post: {
headers?: Array<string>; url: string;
body?: string; headers?: Array<string>;
body?: string;
} | null;
} | null; } | null;
maxRetries: number; maxRetries: number;
retryDelay: number; retryDelay: number;
@ -3133,15 +3141,17 @@ export type UpdateBackupScheduleData = {
oneFileSystem?: boolean; oneFileSystem?: boolean;
tags?: Array<string>; tags?: Array<string>;
customResticParams?: Array<string>; customResticParams?: Array<string>;
preBackupWebhook?: { backupWebhooks?: {
url: string; pre: {
headers?: Array<string>; url: string;
body?: string; headers?: Array<string>;
} | null; body?: string;
postBackupWebhook?: { } | null;
url: string; post: {
headers?: Array<string>; url: string;
body?: string; headers?: Array<string>;
body?: string;
} | null;
} | null; } | null;
maxRetries?: number; maxRetries?: number;
retryDelay?: number; retryDelay?: number;
@ -3180,15 +3190,17 @@ export type UpdateBackupScheduleResponses = {
includePatterns: Array<string> | null; includePatterns: Array<string> | null;
oneFileSystem: boolean; oneFileSystem: boolean;
customResticParams: Array<string> | null; customResticParams: Array<string> | null;
preBackupWebhook: { backupWebhooks: {
url: string; pre: {
headers?: Array<string>; url: string;
body?: string; headers?: Array<string>;
} | null; body?: string;
postBackupWebhook: { } | null;
url: string; post: {
headers?: Array<string>; url: string;
body?: string; headers?: Array<string>;
body?: string;
} | null;
} | null; } | null;
maxRetries: number; maxRetries: number;
retryDelay: number; retryDelay: number;
@ -3239,15 +3251,17 @@ export type GetBackupScheduleForVolumeResponses = {
includePatterns: Array<string> | null; includePatterns: Array<string> | null;
oneFileSystem: boolean; oneFileSystem: boolean;
customResticParams: Array<string> | null; customResticParams: Array<string> | null;
preBackupWebhook: { backupWebhooks: {
url: string; pre: {
headers?: Array<string>; url: string;
body?: string; headers?: Array<string>;
} | null; body?: string;
postBackupWebhook: { } | null;
url: string; post: {
headers?: Array<string>; url: string;
body?: string; headers?: Array<string>;
body?: string;
} | null;
} | null; } | null;
maxRetries: number; maxRetries: number;
retryDelay: number; retryDelay: number;

View file

@ -8,6 +8,72 @@ type AdvancedSectionProps = {
form: UseFormReturn<InternalFormValues>; 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) => { export const AdvancedSection = ({ form }: AdvancedSectionProps) => {
return ( return (
<> <>
@ -56,109 +122,19 @@ export const AdvancedSection = ({ form }: AdvancedSectionProps) => {
</FormItem> </FormItem>
)} )}
/> />
<FormField <WebhookFields
control={form.control} form={form}
name="preBackupWebhookUrl" phase="pre"
render={({ field }) => ( urlPlaceholder="http://host.docker.internal:8080/stop"
<FormItem> bodyPlaceholder='{"action":"stop"}'
<FormLabel>Pre-backup webhook</FormLabel> description="Called with POST before restic starts. A non-2xx response stops the backup."
<FormControl>
<Input {...field} type="url" placeholder="http://host.docker.internal:8080/stop" />
</FormControl>
<FormDescription>
Called with POST before restic starts. A non-2xx response stops the backup.
</FormDescription>
<FormMessage />
</FormItem>
)}
/> />
<FormField <WebhookFields
control={form.control} form={form}
name="preBackupWebhookHeaders" phase="post"
render={({ field }) => ( urlPlaceholder="http://host.docker.internal:8080/start"
<FormItem> bodyPlaceholder='{"action":"start"}'
<FormLabel>Pre-backup webhook headers</FormLabel> description="Called with POST after restic finishes, including failed or cancelled runs."
<FormControl>
<Textarea
{...field}
placeholder="Authorization: Bearer token&#10;X-Custom-Header: value"
value={Array.isArray(field.value) ? field.value.join("\n") : ""}
onChange={(e) => field.onChange(e.target.value.split("\n"))}
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="preBackupWebhookBody"
render={({ field }) => (
<FormItem>
<FormLabel>Pre-backup webhook body</FormLabel>
<FormControl>
<Textarea {...field} placeholder='{"action":"stop"}' 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>
)}
/>
<FormField
control={form.control}
name="postBackupWebhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Post-backup webhook</FormLabel>
<FormControl>
<Input {...field} type="url" placeholder="http://host.docker.internal:8080/start" />
</FormControl>
<FormDescription>
Called with POST after restic finishes, including failed or cancelled runs.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="postBackupWebhookHeaders"
render={({ field }) => (
<FormItem>
<FormLabel>Post-backup webhook headers</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder="Authorization: Bearer token&#10;X-Custom-Header: value"
value={Array.isArray(field.value) ? field.value.join("\n") : ""}
onChange={(e) => field.onChange(e.target.value.split("\n"))}
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="postBackupWebhookBody"
render={({ field }) => (
<FormItem>
<FormLabel>Post-backup webhook body</FormLabel>
<FormControl>
<Textarea {...field} placeholder='{"action":"start"}' 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>
)}
/> />
<FormField <FormField
control={form.control} control={form.control}

View file

@ -14,7 +14,7 @@ import { PathsSection } from "./paths-section";
import { RetentionSection } from "./retention-section"; import { RetentionSection } from "./retention-section";
import { SummarySection } from "./summary-section"; import { SummarySection } from "./summary-section";
import { internalFormSchema, type BackupScheduleFormValues, type InternalFormValues } from "./types"; import { internalFormSchema, type BackupScheduleFormValues, type InternalFormValues } from "./types";
import { backupScheduleToFormValues, parseMultilineEntries, parseWebhookHeaders } from "./utils"; import { backupScheduleToFormValues, parseMultilineEntries, toWebhookConfig } from "./utils";
export type { BackupScheduleFormValues }; export type { BackupScheduleFormValues };
@ -47,10 +47,10 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
maxRetries, maxRetries,
retryDelay, retryDelay,
preBackupWebhookUrl, preBackupWebhookUrl,
preBackupWebhookHeaders, preBackupWebhookHeadersText,
preBackupWebhookBody, preBackupWebhookBody,
postBackupWebhookUrl, postBackupWebhookUrl,
postBackupWebhookHeaders, postBackupWebhookHeadersText,
postBackupWebhookBody, postBackupWebhookBody,
...rest ...rest
} = data; } = data;
@ -58,10 +58,12 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
const excludeIfPresent = parseMultilineEntries(excludeIfPresentText); const excludeIfPresent = parseMultilineEntries(excludeIfPresentText);
const parsedIncludePatterns = parseMultilineEntries(includePatterns); const parsedIncludePatterns = parseMultilineEntries(includePatterns);
const customResticParams = parseMultilineEntries(customResticParamsText); const customResticParams = parseMultilineEntries(customResticParamsText);
const preBackupWebhookUrlValue = preBackupWebhookUrl?.trim(); const preBackupWebhook = toWebhookConfig(preBackupWebhookUrl, preBackupWebhookHeadersText, preBackupWebhookBody);
const postBackupWebhookUrlValue = postBackupWebhookUrl?.trim(); const postBackupWebhook = toWebhookConfig(
const preBackupWebhookBodyValue = preBackupWebhookBody?.trim(); postBackupWebhookUrl,
const postBackupWebhookBodyValue = postBackupWebhookBody?.trim(); postBackupWebhookHeadersText,
postBackupWebhookBody,
);
onSubmit({ onSubmit({
...rest, ...rest,
@ -71,20 +73,8 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
excludePatterns, excludePatterns,
excludeIfPresent, excludeIfPresent,
customResticParams, customResticParams,
preBackupWebhook: preBackupWebhookUrlValue backupWebhooks:
? { preBackupWebhook || postBackupWebhook ? { pre: preBackupWebhook, post: postBackupWebhook } : null,
url: preBackupWebhookUrlValue,
headers: parseWebhookHeaders(preBackupWebhookHeaders),
body: preBackupWebhookBodyValue || undefined,
}
: null,
postBackupWebhook: postBackupWebhookUrlValue
? {
url: postBackupWebhookUrlValue,
headers: parseWebhookHeaders(postBackupWebhookHeaders),
body: postBackupWebhookBodyValue || undefined,
}
: null,
maxRetries, maxRetries,
retryDelay, retryDelay,
}); });

View file

@ -1,13 +1,15 @@
import { z } from "zod"; import { z } from "zod";
import type { BackupWebhooks } from "@zerobyte/core/backup-hooks";
const webhookHeadersSchema = z const webhookHeadersSchema = z.string().refine(
.array( (value) =>
z.string().refine((header) => !header.trim() || header.includes(":"), { value
message: "Headers must use Key: Value format", .split("\n")
}), .map((header) => header.trim())
) .filter(Boolean)
.catch(() => []) .every((header) => header.includes(":")),
.optional(); { message: "Headers must use Key: Value format" },
);
export const internalFormSchema = z.object({ export const internalFormSchema = z.object({
name: z.string().min(1).max(128), name: z.string().min(1).max(128),
@ -30,10 +32,10 @@ export const internalFormSchema = z.object({
oneFileSystem: z.boolean().optional(), oneFileSystem: z.boolean().optional(),
customResticParamsText: z.string().optional(), customResticParamsText: z.string().optional(),
preBackupWebhookUrl: z.union([z.url(), z.literal("")]).optional(), preBackupWebhookUrl: z.union([z.url(), z.literal("")]).optional(),
preBackupWebhookHeaders: webhookHeadersSchema, preBackupWebhookHeadersText: webhookHeadersSchema.optional(),
preBackupWebhookBody: z.string().optional(), preBackupWebhookBody: z.string().optional(),
postBackupWebhookUrl: z.union([z.url(), z.literal("")]).optional(), postBackupWebhookUrl: z.union([z.url(), z.literal("")]).optional(),
postBackupWebhookHeaders: webhookHeadersSchema, postBackupWebhookHeadersText: webhookHeadersSchema.optional(),
postBackupWebhookBody: z.string().optional(), postBackupWebhookBody: z.string().optional(),
maxRetries: z.number().min(0).max(32).optional(), maxRetries: z.number().min(0).max(32).optional(),
retryDelay: z.number().min(1).max(1440).optional(), retryDelay: z.number().min(1).max(1440).optional(),
@ -58,16 +60,15 @@ export type BackupScheduleFormValues = Omit<
| "includePatterns" | "includePatterns"
| "customResticParamsText" | "customResticParamsText"
| "preBackupWebhookUrl" | "preBackupWebhookUrl"
| "preBackupWebhookHeaders" | "preBackupWebhookHeadersText"
| "preBackupWebhookBody" | "preBackupWebhookBody"
| "postBackupWebhookUrl" | "postBackupWebhookUrl"
| "postBackupWebhookHeaders" | "postBackupWebhookHeadersText"
| "postBackupWebhookBody" | "postBackupWebhookBody"
> & { > & {
excludePatterns?: string[]; excludePatterns?: string[];
excludeIfPresent?: string[]; excludeIfPresent?: string[];
includePatterns?: string[]; includePatterns?: string[];
customResticParams?: string[]; customResticParams?: string[];
preBackupWebhook?: { url: string; headers?: string[]; body?: string } | null; backupWebhooks?: BackupWebhooks | null;
postBackupWebhook?: { url: string; headers?: string[]; body?: string } | null;
}; };

View file

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

View file

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

View file

@ -49,8 +49,7 @@ const renderEditBackupPage = ({ enabled, cronExpression }: { enabled: boolean; c
excludeIfPresent: [], excludeIfPresent: [],
oneFileSystem: false, oneFileSystem: false,
customResticParams: [], customResticParams: [],
preBackupWebhook: null, backupWebhooks: null,
postBackupWebhook: null,
}); });
}), }),
http.get("/api/v1/repositories", () => { http.get("/api/v1/repositories", () => {
@ -141,10 +140,13 @@ test("submits webhook headers and body as plain config values", async () => {
await userEvent.click(screen.getByRole("button", { name: "Update schedule" })); await userEvent.click(screen.getByRole("button", { name: "Update schedule" }));
await expect(submittedBody).resolves.toMatchObject({ await expect(submittedBody).resolves.toMatchObject({
preBackupWebhook: { backupWebhooks: {
url: "http://localhost:8080/stop", pre: {
headers: ["Authorization: Bearer stop-token"], url: "http://localhost:8080/stop",
body: '{"action":"stop"}', headers: ["Authorization: Bearer stop-token"],
body: '{"action":"stop"}',
},
post: null,
}, },
}); });
}); });

View file

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

View file

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

View file

@ -1,2 +0,0 @@
ALTER TABLE `backup_schedules_table` ADD `pre_backup_webhook` text;--> statement-breakpoint
ALTER TABLE `backup_schedules_table` ADD `post_backup_webhook` text;

File diff suppressed because it is too large Load diff

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

View file

@ -280,19 +280,23 @@ describe("backup execution - validation failures", () => {
const { runBackupMock } = setup(); const { runBackupMock } = setup();
const volume = await createTestVolume(); const volume = await createTestVolume();
const repository = await createTestRepository(); const repository = await createTestRepository();
const schedule = await createTestBackupSchedule({ const backupWebhooks = {
volumeId: volume.id, pre: {
repositoryId: repository.id,
preBackupWebhook: {
url: "http://localhost:8080/stop", url: "http://localhost:8080/stop",
headers: ["authorization: Bearer stop-token"], headers: ["authorization: Bearer stop-token"],
body: '{"action":"stop"}', body: '{"action":"stop"}',
}, },
postBackupWebhook: { post: {
url: "http://localhost:8080/start", url: "http://localhost:8080/start",
headers: ["authorization: Bearer start-token"], headers: ["authorization: Bearer start-token"],
body: '{"action":"start"}', body: '{"action":"start"}',
}, },
};
const schedule = await createTestBackupSchedule({
volumeId: volume.id,
repositoryId: repository.id,
backupWebhooks,
}); });
await backupsService.executeBackup(schedule.id); await backupsService.executeBackup(schedule.id);
@ -301,18 +305,7 @@ describe("backup execution - validation failures", () => {
"local", "local",
expect.objectContaining({ expect.objectContaining({
payload: expect.objectContaining({ payload: expect.objectContaining({
webhooks: { webhooks: 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"}',
},
},
}), }),
}), }),
); );

View file

@ -408,36 +408,31 @@ describe("schedule webhooks", () => {
const volume = await createTestVolume(); const volume = await createTestVolume();
const repository = await createTestRepository(); 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({ const schedule = await backupsService.createSchedule({
name: "with-webhooks", name: "with-webhooks",
volumeId: volume.shortId, volumeId: volume.shortId,
repositoryId: repository.shortId, repositoryId: repository.shortId,
enabled: true, enabled: true,
cronExpression: "0 0 * * *", cronExpression: "0 0 * * *",
preBackupWebhook: { backupWebhooks,
url: "http://localhost:8080/stop",
headers: ["authorization: Bearer stop-token"],
body: '{"action":"stop"}',
},
postBackupWebhook: {
url: "http://localhost:8080/start",
headers: ["authorization: Bearer start-token"],
body: '{"action":"start"}',
},
retryDelay: 15 * 60 * 1000, retryDelay: 15 * 60 * 1000,
maxRetries: 2, maxRetries: 2,
}); });
expect(schedule.preBackupWebhook).toEqual({ expect(schedule.backupWebhooks).toEqual(backupWebhooks);
url: "http://localhost:8080/stop",
headers: ["authorization: Bearer stop-token"],
body: '{"action":"stop"}',
});
expect(schedule.postBackupWebhook).toEqual({
url: "http://localhost:8080/start",
headers: ["authorization: Bearer start-token"],
body: '{"action":"start"}',
});
}); });
test("clears backup webhooks when updating a schedule with null values", async () => { test("clears backup webhooks when updating a schedule with null values", async () => {
@ -445,21 +440,21 @@ describe("schedule webhooks", () => {
const repository = await createTestRepository(); const repository = await createTestRepository();
const schedule = await createTestBackupSchedule({ const schedule = await createTestBackupSchedule({
repositoryId: repository.id, repositoryId: repository.id,
preBackupWebhook: { url: "http://localhost:8080/stop" }, backupWebhooks: {
postBackupWebhook: { url: "http://localhost:8080/start" }, pre: { url: "http://localhost:8080/stop" },
post: { url: "http://localhost:8080/start" },
},
}); });
const updated = await backupsService.updateSchedule(schedule.id, { const updated = await backupsService.updateSchedule(schedule.id, {
repositoryId: repository.shortId, repositoryId: repository.shortId,
cronExpression: schedule.cronExpression, cronExpression: schedule.cronExpression,
preBackupWebhook: null, backupWebhooks: null,
postBackupWebhook: null,
retryDelay: 15 * 60 * 1000, retryDelay: 15 * 60 * 1000,
maxRetries: 2, maxRetries: 2,
}); });
expect(updated.preBackupWebhook).toBeNull(); expect(updated.backupWebhooks).toBeNull();
expect(updated.postBackupWebhook).toBeNull();
}); });
}); });

View file

@ -1,5 +1,5 @@
import { Effect } from "effect"; import { Effect } from "effect";
import { createBackupWebhooks, runBackupWithWebhooks } from "@zerobyte/core/backup-hooks"; import { runBackupLifecycle } from "@zerobyte/core/backup-hooks";
import type { BackupSchedule, Volume, Repository } from "../../db/schema"; import type { BackupSchedule, Volume, Repository } from "../../db/schema";
import { config } from "../../core/config"; import { config } from "../../core/config";
import { restic, resticDeps } from "../../core/restic"; import { restic, resticDeps } from "../../core/restic";
@ -8,7 +8,7 @@ import { agentManager, type BackupExecutionProgress } from "../agents/agents-man
import { getVolumePath } from "../volumes/helpers"; import { getVolumePath } from "../volumes/helpers";
import { decryptRepositoryConfig } from "../repositories/repository-config-secrets"; import { decryptRepositoryConfig } from "../repositories/repository-config-secrets";
import { createBackupOptions } from "./backup.helpers"; import { createBackupOptions } from "./backup.helpers";
import { toErrorDetails, toMessage } from "../../utils/errors"; import { toErrorDetails } from "../../utils/errors";
const LOCAL_AGENT_ID = "local"; const LOCAL_AGENT_ID = "local";
@ -55,7 +55,7 @@ const createBackupRunPayload = async ({
rcloneConfigFile: resticDeps.rcloneConfigFile, rcloneConfigFile: resticDeps.rcloneConfigFile,
hostname: resticDeps.hostname, hostname: resticDeps.hostname,
}, },
webhooks: createBackupWebhooks(schedule.preBackupWebhook, schedule.postBackupWebhook), webhooks: schedule.backupWebhooks ?? { pre: null, post: null },
}; };
}; };
@ -64,31 +64,18 @@ const executeBackupWithoutAgent = async (
{ signal, onProgress }: Pick<BackupExecutionRequest, "signal" | "onProgress">, { signal, onProgress }: Pick<BackupExecutionRequest, "signal" | "onProgress">,
) => { ) => {
return Effect.runPromise( return Effect.runPromise(
runBackupWithWebhooks({ runBackupLifecycle({
metadata: { restic,
jobId: payload.jobId, repositoryConfig: payload.repositoryConfig,
scheduleId: payload.scheduleId, sourcePath: payload.sourcePath,
organizationId: payload.organizationId, jobId: payload.jobId,
sourcePath: payload.sourcePath, scheduleId: payload.scheduleId,
}, organizationId: payload.organizationId,
options: payload.options,
webhooks: payload.webhooks, webhooks: payload.webhooks,
signal, signal,
formatErrorDetails: toErrorDetails, onProgress,
formatErrorMessage: toMessage, formatError: toErrorDetails,
runBackup: () =>
restic.backup(payload.repositoryConfig, payload.sourcePath, {
...payload.options,
organizationId: payload.organizationId,
signal,
onProgress,
}).pipe(
Effect.map((result) => ({
status: "completed" as const,
exitCode: result.exitCode,
result: result.result,
warningDetails: result.warningDetails,
})),
),
}), }),
); );
}; };

View file

@ -1,6 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { describeRoute, resolver } from "hono-openapi"; import { describeRoute, resolver } from "hono-openapi";
import { backupWebhookConfigSchema } from "@zerobyte/core/backup-hooks"; import { backupWebhooksSchema } from "@zerobyte/core/backup-hooks";
import { volumeSchema } from "../volumes/volume.dto"; import { volumeSchema } from "../volumes/volume.dto";
import { repositorySchema } from "../repositories/repositories.dto"; import { repositorySchema } from "../repositories/repositories.dto";
import { backupProgressEventSchema } from "~/schemas/events-dto"; import { backupProgressEventSchema } from "~/schemas/events-dto";
@ -30,8 +30,7 @@ const backupScheduleSchema = z.object({
includePatterns: z.array(z.string()).nullable(), includePatterns: z.array(z.string()).nullable(),
oneFileSystem: z.boolean(), oneFileSystem: z.boolean(),
customResticParams: z.array(z.string()).nullable(), customResticParams: z.array(z.string()).nullable(),
preBackupWebhook: backupWebhookConfigSchema.nullable(), backupWebhooks: backupWebhooksSchema.nullable(),
postBackupWebhook: backupWebhookConfigSchema.nullable(),
maxRetries: z.number(), maxRetries: z.number(),
retryDelay: z.number().transform((ms) => Math.round(ms / 60000)), retryDelay: z.number().transform((ms) => Math.round(ms / 60000)),
lastBackupAt: z.number().nullable(), lastBackupAt: z.number().nullable(),
@ -129,8 +128,7 @@ export const createBackupScheduleBody = z.object({
oneFileSystem: z.boolean().optional(), oneFileSystem: z.boolean().optional(),
tags: z.array(z.string()).optional(), tags: z.array(z.string()).optional(),
customResticParams: z.array(z.string()).optional(), customResticParams: z.array(z.string()).optional(),
preBackupWebhook: backupWebhookConfigSchema.nullable().optional(), backupWebhooks: backupWebhooksSchema.nullable().optional(),
postBackupWebhook: backupWebhookConfigSchema.nullable().optional(),
maxRetries: z.number().min(0).max(32).optional().default(2), maxRetries: z.number().min(0).max(32).optional().default(2),
retryDelay: z retryDelay: z
.number() .number()
@ -176,8 +174,7 @@ export const updateBackupScheduleBody = z.object({
oneFileSystem: z.boolean().optional(), oneFileSystem: z.boolean().optional(),
tags: z.array(z.string()).optional(), tags: z.array(z.string()).optional(),
customResticParams: z.array(z.string()).optional(), customResticParams: z.array(z.string()).optional(),
preBackupWebhook: backupWebhookConfigSchema.nullable().optional(), backupWebhooks: backupWebhooksSchema.nullable().optional(),
postBackupWebhook: backupWebhookConfigSchema.nullable().optional(),
maxRetries: z.number().min(0).max(32).optional().default(2), maxRetries: z.number().min(0).max(32).optional().default(2),
retryDelay: z retryDelay: z
.number() .number()

View file

@ -104,8 +104,7 @@ const createSchedule = async (data: CreateBackupScheduleBody) => {
includePatterns: data.includePatterns ?? [], includePatterns: data.includePatterns ?? [],
oneFileSystem: data.oneFileSystem, oneFileSystem: data.oneFileSystem,
customResticParams: data.customResticParams ?? [], customResticParams: data.customResticParams ?? [],
preBackupWebhook: data.preBackupWebhook ?? null, backupWebhooks: data.backupWebhooks ?? null,
postBackupWebhook: data.postBackupWebhook ?? null,
nextBackupAt: nextBackupAt, nextBackupAt: nextBackupAt,
shortId: generateShortId(), shortId: generateShortId(),
maxRetries: data.maxRetries, maxRetries: data.maxRetries,
@ -168,8 +167,7 @@ const updateSchedule = async (scheduleIdOrShortId: number | string, data: Update
.set({ .set({
...data, ...data,
repositoryId: repository.id, repositoryId: repository.id,
preBackupWebhook: data.preBackupWebhook === undefined ? schedule.preBackupWebhook : data.preBackupWebhook, backupWebhooks: data.backupWebhooks === undefined ? schedule.backupWebhooks : data.backupWebhooks,
postBackupWebhook: data.postBackupWebhook === undefined ? schedule.postBackupWebhook : data.postBackupWebhook,
nextBackupAt, nextBackupAt,
updatedAt: Date.now(), updatedAt: Date.now(),
}) })

View file

@ -211,7 +211,7 @@ test("fails without running restic when the pre-backup webhook fails", async ()
expect(backupMock).not.toHaveBeenCalled(); expect(backupMock).not.toHaveBeenCalled();
expect(failed?.success).toBe(true); expect(failed?.success).toBe(true);
if (failed?.success && failed.data.type === "backup.failed") { if (failed?.success && failed.data.type === "backup.failed") {
expect(failed.data.payload.errorDetails).toContain("Pre-backup webhook returned HTTP 500"); expect(failed.data.payload.errorDetails).toContain("pre webhook returned HTTP 500");
} }
}); });
@ -239,7 +239,40 @@ test("reports a post-backup webhook failure as completed warning details", async
const completed = messages.find((message) => message?.success && message.data.type === "backup.completed"); const completed = messages.find((message) => message?.success && message.data.type === "backup.completed");
expect(completed?.success).toBe(true); expect(completed?.success).toBe(true);
if (completed?.success && completed.data.type === "backup.completed") { if (completed?.success && completed.data.type === "backup.completed") {
expect(completed.data.payload.warningDetails).toContain("Post-backup webhook returned HTTP 500"); 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");
} }
}); });

View file

@ -1,6 +1,6 @@
import { Effect, Runtime } from "effect"; import { Effect, Runtime } from "effect";
import { createAgentMessage, type BackupRunPayload } from "@zerobyte/contracts/agent-protocol"; import { createAgentMessage, type BackupRunPayload } from "@zerobyte/contracts/agent-protocol";
import { runBackupWithWebhooks } from "@zerobyte/core/backup-hooks"; import { runBackupLifecycle } from "@zerobyte/core/backup-hooks";
import { logger } from "@zerobyte/core/node"; import { logger } from "@zerobyte/core/node";
import { type ResticDeps } from "@zerobyte/core/restic"; import { type ResticDeps } from "@zerobyte/core/restic";
import { createRestic } from "@zerobyte/core/restic/server"; import { createRestic } from "@zerobyte/core/restic/server";
@ -27,12 +27,12 @@ export const handleBackupRunCommand = (context: ControllerCommandContext, payloa
yield* Effect.fork( yield* Effect.fork(
Effect.gen(function* () { Effect.gen(function* () {
const sendCancelled = () => { const sendCancelled = (message?: string) => {
return context.offerOutbound( return context.offerOutbound(
createAgentMessage("backup.cancelled", { createAgentMessage("backup.cancelled", {
jobId: payload.jobId, jobId: payload.jobId,
scheduleId: payload.scheduleId, scheduleId: payload.scheduleId,
message: "Backup was cancelled", message: message ?? "Backup was cancelled",
}), }),
); );
}; };
@ -57,44 +57,29 @@ export const handleBackupRunCommand = (context: ControllerCommandContext, payloa
const restic = createRestic(deps); const restic = createRestic(deps);
const runtime = yield* Effect.runtime<never>(); const runtime = yield* Effect.runtime<never>();
const backupResult = yield* runBackupWithWebhooks({ const backupResult = yield* runBackupLifecycle({
metadata: { restic,
jobId: payload.jobId, repositoryConfig: payload.repositoryConfig,
scheduleId: payload.scheduleId, sourcePath: payload.sourcePath,
organizationId: payload.organizationId, jobId: payload.jobId,
sourcePath: payload.sourcePath, scheduleId: payload.scheduleId,
}, organizationId: payload.organizationId,
options: payload.options,
webhooks: payload.webhooks, webhooks: payload.webhooks,
signal: abortController.signal, signal: abortController.signal,
runBackup: () => { onProgress: (progress) => {
return restic void Runtime.runPromise(
.backup(payload.repositoryConfig, payload.sourcePath, { runtime,
...payload.options, context.offerOutbound(
organizationId: payload.organizationId, createAgentMessage("backup.progress", {
signal: abortController.signal, jobId: payload.jobId,
onProgress: (progress) => { scheduleId: payload.scheduleId,
void Runtime.runPromise( progress,
runtime, }),
context.offerOutbound( ),
createAgentMessage("backup.progress", { ).catch((error) => {
jobId: payload.jobId, logger.error(`Failed to send backup progress update: ${toMessage(error)}`);
scheduleId: payload.scheduleId, });
progress,
}),
),
).catch((error) => {
logger.error(`Failed to send backup progress update: ${toMessage(error)}`);
});
},
})
.pipe(
Effect.map((result) => ({
status: "completed" as const,
exitCode: result.exitCode,
result: result.result,
warningDetails: result.warningDetails,
})),
);
}, },
}); });
@ -121,7 +106,7 @@ export const handleBackupRunCommand = (context: ControllerCommandContext, payloa
); );
return; return;
case "cancelled": case "cancelled":
yield* sendCancelled(); yield* sendCancelled(backupResult.message);
return; return;
} }
}).pipe(Effect.ensuring(context.deleteRunningJob(payload.jobId))), }).pipe(Effect.ensuring(context.deleteRunningJob(payload.jobId))),

View file

@ -2,7 +2,7 @@ import { Effect } from "effect";
import { HttpResponse, http } from "msw"; import { HttpResponse, http } from "msw";
import { setupServer } from "msw/node"; import { setupServer } from "msw/node";
import { afterAll, afterEach, beforeAll, expect, test } from "vitest"; import { afterAll, afterEach, beforeAll, expect, test } from "vitest";
import { runBackupWithWebhooks } from "../index.js"; import { runBackupLifecycle } from "../index.js";
const server = setupServer(); const server = setupServer();
@ -25,6 +25,31 @@ const metadata = {
sourcePath: "/tmp/source", 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 = { type WebhookBody = {
phase?: string; phase?: string;
event?: string; event?: string;
@ -54,21 +79,17 @@ test("runs pre and post webhooks around a successful backup", async () => {
}), }),
); );
const result = await Effect.runPromise( const result = await runWithHooks({
runBackupWithWebhooks({ webhooks: {
metadata, pre: { url: "http://localhost:8080/pre" },
webhooks: { post: { url: "http://localhost:8080/post" },
pre: { url: "http://localhost:8080/pre" }, },
post: { url: "http://localhost:8080/post" }, runBackup: () =>
}, Effect.sync(() => {
signal: new AbortController().signal, events.push("backup");
runBackup: () => return { exitCode: 0, result: "snapshot-1", warningDetails: null };
Effect.sync(() => { }),
events.push("backup"); });
return { status: "completed" as const, exitCode: 0, result: "snapshot-1", warningDetails: null };
}),
}),
);
expect(events).toEqual(["pre", "backup", "post"]); expect(events).toEqual(["pre", "backup", "post"]);
expect(preBody).toMatchObject({ ...metadata, phase: "pre", event: "backup.pre" }); expect(preBody).toMatchObject({ ...metadata, phase: "pre", event: "backup.pre" });
@ -86,23 +107,13 @@ test("sends warning details to the post-backup webhook for a non-zero completed
}), }),
); );
const result = await Effect.runPromise( const result = await runWithHooks({
runBackupWithWebhooks({ webhooks: {
metadata, pre: null,
webhooks: { post: { url: "http://localhost:8080/post" },
pre: null, },
post: { url: "http://localhost:8080/post" }, runBackup: () => completedBackup("snapshot-1", 3, "some files could not be read"),
}, });
signal: new AbortController().signal,
runBackup: () =>
Effect.succeed({
status: "completed" as const,
exitCode: 3,
result: "snapshot-1",
warningDetails: "some files could not be read",
}),
}),
);
expect(postBody).toMatchObject({ status: "warning", error: "some files could not be read" }); expect(postBody).toMatchObject({ status: "warning", error: "some files could not be read" });
expect(result).toEqual({ expect(result).toEqual({
@ -123,17 +134,13 @@ test("sends error details to the post-backup webhook when the backup fails", asy
}), }),
); );
const result = await Effect.runPromise( const result = await runWithHooks({
runBackupWithWebhooks({ webhooks: {
metadata, pre: null,
webhooks: { post: { url: "http://localhost:8080/post" },
pre: null, },
post: { url: "http://localhost:8080/post" }, runBackup: () => Effect.fail(new Error("restic failed")),
}, });
signal: new AbortController().signal,
runBackup: () => Effect.succeed({ status: "failed" as const, error: new Error("restic failed") }),
}),
);
expect(postBody).toMatchObject({ status: "error", error: "restic failed" }); expect(postBody).toMatchObject({ status: "error", error: "restic failed" });
expect(result).toEqual({ status: "failed", error: "restic failed" }); expect(result).toEqual({ status: "failed", error: "restic failed" });
@ -153,27 +160,23 @@ test("fails without running the backup or post webhook when the pre-backup webho
}), }),
); );
const result = await Effect.runPromise( const result = await runWithHooks({
runBackupWithWebhooks({ webhooks: {
metadata, pre: { url: "http://localhost:8080/pre" },
webhooks: { post: { url: "http://localhost:8080/post" },
pre: { url: "http://localhost:8080/pre" }, },
post: { url: "http://localhost:8080/post" }, runBackup: () =>
}, Effect.sync(() => {
signal: new AbortController().signal, backupRan = true;
runBackup: () => return { exitCode: 0, result: null, warningDetails: null };
Effect.sync(() => { }),
backupRan = true; });
return { status: "completed" as const, exitCode: 0, result: null, warningDetails: null };
}),
}),
);
expect(backupRan).toBe(false); expect(backupRan).toBe(false);
expect(postRan).toBe(false); expect(postRan).toBe(false);
expect(result.status).toBe("failed"); expect(result.status).toBe("failed");
if (result.status === "failed") { if (result.status === "failed") {
expect(result.error).toContain("Pre-backup webhook returned HTTP 500: stop failed"); expect(result.error).toContain("pre webhook returned HTTP 500: stop failed");
} }
}); });
@ -191,22 +194,17 @@ test("sends configured webhook headers and body without replacing them", async (
}), }),
); );
await Effect.runPromise( await runWithHooks({
runBackupWithWebhooks({ webhooks: {
metadata, pre: null,
webhooks: { post: {
pre: null, url: "http://localhost:8080/post",
post: { headers: ["authorization: Bearer post-token"],
url: "http://localhost:8080/post", body: "start-container",
headers: ["authorization: Bearer post-token"],
body: "start-container",
},
}, },
signal: new AbortController().signal, },
runBackup: () => runBackup: () => completedBackup(null),
Effect.succeed({ status: "completed" as const, exitCode: 0, result: null, warningDetails: null }), });
}),
);
expect(body).toBe("start-container"); expect(body).toBe("start-container");
expect(authorization).toBe("Bearer post-token"); expect(authorization).toBe("Bearer post-token");
@ -224,21 +222,18 @@ test("runs the post-backup webhook after cancellation without using the cancelle
}), }),
); );
const result = await Effect.runPromise( const result = await runWithHooks({
runBackupWithWebhooks({ webhooks: {
metadata, pre: null,
webhooks: { post: { url: "http://localhost:8080/post" },
pre: null, },
post: { url: "http://localhost:8080/post" }, signal: abortController.signal,
}, runBackup: () =>
signal: abortController.signal, Effect.gen(function* () {
runBackup: () => abortController.abort(new Error("Backup was cancelled"));
Effect.sync(() => { return yield* Effect.fail(new Error("restic cancelled"));
abortController.abort(new Error("Backup was cancelled")); }),
return { status: "failed" as const, error: new Error("restic cancelled") }; });
}),
}),
);
expect(postBody).toMatchObject({ status: "cancelled", error: "restic cancelled" }); expect(postBody).toMatchObject({ status: "cancelled", error: "restic cancelled" });
expect(result).toEqual({ status: "cancelled", message: "Backup was cancelled" }); expect(result).toEqual({ status: "cancelled", message: "Backup was cancelled" });
@ -255,21 +250,18 @@ test("runs the post-backup webhook when cancellation returns a completed backup
}), }),
); );
const result = await Effect.runPromise( const result = await runWithHooks({
runBackupWithWebhooks({ webhooks: {
metadata, pre: null,
webhooks: { post: { url: "http://localhost:8080/post" },
pre: null, },
post: { url: "http://localhost:8080/post" }, signal: abortController.signal,
}, runBackup: () =>
signal: abortController.signal, Effect.sync(() => {
runBackup: () => abortController.abort(new Error("Backup was cancelled"));
Effect.sync(() => { return { exitCode: 0, result: null, warningDetails: null };
abortController.abort(new Error("Backup was cancelled")); }),
return { status: "completed" as const, exitCode: 0, result: null, warningDetails: null }; });
}),
}),
);
expect(postBody).toMatchObject({ status: "cancelled" }); expect(postBody).toMatchObject({ status: "cancelled" });
expect(result).toEqual({ status: "cancelled", message: "Backup was cancelled" }); expect(result).toEqual({ status: "cancelled", message: "Backup was cancelled" });
@ -286,27 +278,24 @@ test("includes post-backup webhook failure details after cancellation", async ()
}), }),
); );
const result = await Effect.runPromise( const result = await runWithHooks({
runBackupWithWebhooks({ webhooks: {
metadata, pre: null,
webhooks: { post: { url: "http://localhost:8080/post" },
pre: null, },
post: { url: "http://localhost:8080/post" }, signal: abortController.signal,
}, runBackup: () =>
signal: abortController.signal, Effect.gen(function* () {
runBackup: () => abortController.abort(new Error("Backup was cancelled"));
Effect.sync(() => { return yield* Effect.fail(new Error("restic cancelled"));
abortController.abort(new Error("Backup was cancelled")); }),
return { status: "failed" as const, error: new Error("restic cancelled") }; });
}),
}),
);
expect(postBody).toMatchObject({ status: "cancelled", error: "restic cancelled" }); expect(postBody).toMatchObject({ status: "cancelled", error: "restic cancelled" });
expect(result.status).toBe("cancelled"); expect(result.status).toBe("cancelled");
if (result.status === "cancelled") { if (result.status === "cancelled") {
expect(result.message).toContain("Backup was cancelled"); expect(result.message).toContain("Backup was cancelled");
expect(result.message).toContain("Post-backup webhook returned HTTP 500: start failed"); expect(result.message).toContain("post webhook returned HTTP 500: start failed");
} }
}); });
@ -321,27 +310,24 @@ test("includes post-backup webhook failure details after completed cancellation"
}), }),
); );
const result = await Effect.runPromise( const result = await runWithHooks({
runBackupWithWebhooks({ webhooks: {
metadata, pre: null,
webhooks: { post: { url: "http://localhost:8080/post" },
pre: null, },
post: { url: "http://localhost:8080/post" }, signal: abortController.signal,
}, runBackup: () =>
signal: abortController.signal, Effect.sync(() => {
runBackup: () => abortController.abort(new Error("Backup was cancelled"));
Effect.sync(() => { return { exitCode: 0, result: null, warningDetails: null };
abortController.abort(new Error("Backup was cancelled")); }),
return { status: "completed" as const, exitCode: 0, result: null, warningDetails: null }; });
}),
}),
);
expect(postBody).toMatchObject({ status: "cancelled", error: "Backup was cancelled" }); expect(postBody).toMatchObject({ status: "cancelled", error: "Backup was cancelled" });
expect(result.status).toBe("cancelled"); expect(result.status).toBe("cancelled");
if (result.status === "cancelled") { if (result.status === "cancelled") {
expect(result.message).toContain("Backup was cancelled"); expect(result.message).toContain("Backup was cancelled");
expect(result.message).toContain("Post-backup webhook returned HTTP 500: cleanup failed"); expect(result.message).toContain("post webhook returned HTTP 500: cleanup failed");
} }
}); });
@ -351,21 +337,18 @@ test("cancels before the pre-backup webhook without running the backup", async (
abortController.abort(new Error("Backup was cancelled")); abortController.abort(new Error("Backup was cancelled"));
const result = await Effect.runPromise( const result = await runWithHooks({
runBackupWithWebhooks({ webhooks: {
metadata, pre: { url: "http://localhost:8080/pre" },
webhooks: { post: { url: "http://localhost:8080/post" },
pre: { url: "http://localhost:8080/pre" }, },
post: { url: "http://localhost:8080/post" }, signal: abortController.signal,
}, runBackup: () =>
signal: abortController.signal, Effect.sync(() => {
runBackup: () => backupRan = true;
Effect.sync(() => { return { exitCode: 0, result: null, warningDetails: null };
backupRan = true; }),
return { status: "completed" as const, exitCode: 0, result: null, warningDetails: null }; });
}),
}),
);
expect(backupRan).toBe(false); expect(backupRan).toBe(false);
expect(result).toEqual({ status: "cancelled", message: "Backup was cancelled" }); expect(result).toEqual({ status: "cancelled", message: "Backup was cancelled" });

View file

@ -1,15 +1,13 @@
import { Data, Effect } from "effect"; import { Data, Effect } from "effect";
import { z } from "zod"; import { z } from "zod";
import type { CompressionMode, RepositoryConfig, ResticBackupProgressDto } from "../restic/index.js";
import { toErrorDetails, toMessage } from "../utils/index.js"; import { toErrorDetails, toMessage } from "../utils/index.js";
export const DEFAULT_BACKUP_WEBHOOK_TIMEOUT_MS = 60_000; const DEFAULT_BACKUP_WEBHOOK_TIMEOUT_MS = 60_000;
export const backupWebhookConfigSchema = z.object({ export const backupWebhookConfigSchema = z.object({
url: z.url(), url: z.url(),
headers: z headers: z.array(z.string()).optional(),
.array(z.string())
.catch(() => [])
.optional(),
body: z.string().optional(), body: z.string().optional(),
}); });
@ -21,17 +19,10 @@ export const backupWebhooksSchema = z.object({
export type BackupWebhookConfig = z.infer<typeof backupWebhookConfigSchema>; export type BackupWebhookConfig = z.infer<typeof backupWebhookConfigSchema>;
export type BackupWebhooks = z.infer<typeof backupWebhooksSchema>; export type BackupWebhooks = z.infer<typeof backupWebhooksSchema>;
export type BackupWebhookPhase = "pre" | "post"; type BackupWebhookPhase = "pre" | "post";
export type BackupWebhookStatus = "success" | "warning" | "error" | "cancelled"; type BackupWebhookStatus = "success" | "warning" | "error" | "cancelled";
export type BackupWebhookMetadata = { type BackupWebhookContext = {
jobId: string;
scheduleId: string;
organizationId: string;
sourcePath: string;
};
export type BackupWebhookContext = {
phase: BackupWebhookPhase; phase: BackupWebhookPhase;
event: "backup.pre" | "backup.post"; event: "backup.pre" | "backup.post";
jobId: string; jobId: string;
@ -42,60 +33,53 @@ export type BackupWebhookContext = {
error?: string; error?: string;
}; };
export type BackupOperationResult<TResult> = type BackupResult<TResult> = { exitCode: number; result: TResult; warningDetails: string | null };
| { status: "completed"; exitCode: number; result: TResult; warningDetails: string | null }
| { status: "failed"; error: unknown };
export type BackupHookedExecutionResult<TResult> = type BackupLifecycleResult<TResult> =
| { status: "completed"; exitCode: number; result: TResult; warningDetails: string | null } | { status: "completed"; exitCode: number; result: TResult; warningDetails: string | null }
| { status: "failed"; error: string } | { status: "failed"; error: string }
| { status: "cancelled"; message?: string }; | { status: "cancelled"; message?: string };
export type BackupHookedExecutionOptions<TResult, R = never> = { type BackupOptions = {
metadata: BackupWebhookMetadata; tags?: string[];
webhooks: BackupWebhooks; oneFileSystem?: boolean;
signal: AbortSignal; exclude?: string[];
runBackup: () => Effect.Effect<BackupOperationResult<TResult>, unknown, R>; excludeIfPresent?: string[];
formatErrorDetails?: (error: unknown) => string; includePaths?: string[];
formatErrorMessage?: (error: unknown) => string; includePatterns?: string[];
customResticParams?: string[];
compressionMode?: CompressionMode;
}; };
export class BackupWebhookError extends Data.TaggedError("BackupWebhookError")<{ 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; cause: unknown;
message: string; message: string;
}> {} }> {}
export const createBackupWebhooks = (
pre: BackupWebhookConfig | null | undefined,
post: BackupWebhookConfig | null | undefined,
): BackupWebhooks => ({
pre: pre ?? null,
post: post ?? null,
});
const createWebhookContext = (
metadata: BackupWebhookMetadata,
phase: BackupWebhookPhase,
status?: BackupWebhookStatus,
error?: string,
): BackupWebhookContext => {
const context: BackupWebhookContext = {
phase,
event: phase === "pre" ? "backup.pre" : "backup.post",
...metadata,
};
if (status) {
context.status = status;
}
if (error) {
context.error = error;
}
return context;
};
const appendDetails = (primary: string | null | undefined, next: string | null | undefined) => { const appendDetails = (primary: string | null | undefined, next: string | null | undefined) => {
return [primary, next].filter(Boolean).join("\n\n"); return [primary, next].filter(Boolean).join("\n\n");
}; };
@ -108,8 +92,6 @@ const getCompletedStatus = (exitCode: number, signal: AbortSignal): BackupWebhoo
return exitCode === 0 ? "success" : "warning"; return exitCode === 0 ? "success" : "warning";
}; };
const formatWebhookPhase = (phase: BackupWebhookPhase) => `${phase === "pre" ? "Pre" : "Post"}-backup`;
const createAbortController = (timeoutMs: number, signal?: AbortSignal) => { const createAbortController = (timeoutMs: number, signal?: AbortSignal) => {
const abortController = new AbortController(); const abortController = new AbortController();
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
@ -151,127 +133,135 @@ const createRequestInit = (config: BackupWebhookConfig, context: BackupWebhookCo
} }
} }
return { return { method: "POST", headers, body };
method: "POST",
headers,
body,
};
}; };
export const runBackupWebhook = async ( const runBackupWebhook = (
config: BackupWebhookConfig,
context: BackupWebhookContext,
options: { signal?: AbortSignal; timeoutMs?: number } = {},
) => {
const timeoutMs = options.timeoutMs ?? DEFAULT_BACKUP_WEBHOOK_TIMEOUT_MS;
const controller = createAbortController(timeoutMs, options.signal);
try {
const parsedConfig = backupWebhookConfigSchema.parse(config);
const url = new URL(parsedConfig.url);
const phase = formatWebhookPhase(context.phase);
const response = await fetch(url, {
...createRequestInit(parsedConfig, 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(`${phase} webhook returned HTTP ${response.status}`),
message: `${phase} webhook returned HTTP ${response.status}${details ? `: ${details}` : ""}`,
});
}
} catch (error) {
if (error instanceof BackupWebhookError) {
throw error;
}
const phase = formatWebhookPhase(context.phase);
if (controller.signal.aborted && controller.signal.reason instanceof Error) {
throw new BackupWebhookError({
cause: controller.signal.reason,
message: `${phase} webhook failed: ${controller.signal.reason.message}`,
});
}
throw new BackupWebhookError({ cause: error, message: `${phase} webhook failed: ${toMessage(error)}` });
} finally {
controller.cleanup();
}
};
const runConfiguredWebhook = (
config: BackupWebhookConfig | null, config: BackupWebhookConfig | null,
context: BackupWebhookContext, context: BackupWebhookContext,
formatErrorDetails: (error: unknown) => string, options: {
signal?: AbortSignal, formatError: (error: unknown) => string;
) => { signal?: AbortSignal;
if (!config) { timeoutMs?: number;
return Effect.succeed(null); },
} ) =>
Effect.suspend(() => {
if (!config) {
return Effect.succeed(null);
}
return Effect.tryPromise({ const timeoutMs = options.timeoutMs ?? DEFAULT_BACKUP_WEBHOOK_TIMEOUT_MS;
try: () => runBackupWebhook(config, context, { signal }), const controller = createAbortController(timeoutMs, options.signal);
catch: (error) => {
if (error instanceof BackupWebhookError) {
return error;
}
return new BackupWebhookError({ cause: error, message: toMessage(error) }); return Effect.tryPromise({
}, try: async () => {
}).pipe( const response = await fetch(config.url, { ...createRequestInit(config, context), signal: controller.signal });
Effect.as(null),
Effect.catchAll((error) => Effect.succeed(formatErrorDetails(error))),
);
};
export const runBackupWithWebhooks = <TResult, R = never>({ if (!response.ok) {
metadata, 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, webhooks,
signal, signal,
runBackup, onProgress,
formatErrorDetails = toErrorDetails, formatError = toErrorDetails,
formatErrorMessage = toMessage, }: BackupLifecycleOptions<TResult>): Effect.Effect<BackupLifecycleResult<TResult>, never> =>
}: BackupHookedExecutionOptions<TResult, R>): Effect.Effect<BackupHookedExecutionResult<TResult>, never, R> =>
Effect.gen(function* () { Effect.gen(function* () {
const preHookError = yield* runConfiguredWebhook( const context = { jobId, scheduleId, organizationId, sourcePath };
const preHookError = yield* runBackupWebhook(
webhooks.pre, webhooks.pre,
createWebhookContext(metadata, "pre"), { ...context, phase: "pre", event: "backup.pre" },
formatErrorDetails, {
signal, formatError,
signal,
},
); );
if (preHookError) { if (preHookError) {
if (signal.aborted) { if (signal.aborted) {
return { status: "cancelled", message: formatErrorMessage(signal.reason) }; return { status: "cancelled", message: formatError(signal.reason) };
} }
return { status: "failed", error: preHookError }; return { status: "failed", error: preHookError };
} }
const backupResult = yield* Effect.suspend(runBackup).pipe( const backupResult = yield* Effect.suspend(() =>
Effect.catchAll((error) => Effect.succeed({ status: "failed" as const, error })), 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") { if (backupResult.status === "completed") {
const hookStatus = getCompletedStatus(backupResult.exitCode, signal);
const hookError = signal.aborted ? formatErrorMessage(signal.reason) : (backupResult.warningDetails ?? undefined);
const postHookError = yield* runConfiguredWebhook(
webhooks.post,
createWebhookContext(metadata, "post", hookStatus, hookError),
formatErrorDetails,
);
if (signal.aborted) {
return {
status: "cancelled",
message: appendDetails(formatErrorMessage(signal.reason), postHookError) || undefined,
};
}
return { return {
status: "completed", status: "completed",
exitCode: backupResult.exitCode, exitCode: backupResult.exitCode,
@ -280,22 +270,8 @@ export const runBackupWithWebhooks = <TResult, R = never>({
}; };
} }
const errorDetails = formatErrorDetails(backupResult.error);
const postHookError = yield* runConfiguredWebhook(
webhooks.post,
createWebhookContext(metadata, "post", signal.aborted ? "cancelled" : "error", errorDetails),
formatErrorDetails,
);
if (signal.aborted) {
return {
status: "cancelled",
message: appendDetails(formatErrorMessage(signal.reason || backupResult.error), postHookError) || undefined,
};
}
return { return {
status: "failed", status: "failed",
error: appendDetails(errorDetails, postHookError) || errorDetails, error: appendDetails(backupResult.errorDetails, postHookError) || backupResult.errorDetails,
}; };
}); });