feat: pre/post backup webhooks
This commit is contained in:
parent
2000ebd254
commit
15fb9cc0aa
25 changed files with 3477 additions and 88 deletions
|
|
@ -2434,6 +2434,16 @@ export type ListBackupSchedulesResponses = {
|
||||||
includePatterns: Array<string> | null;
|
includePatterns: Array<string> | null;
|
||||||
oneFileSystem: boolean;
|
oneFileSystem: boolean;
|
||||||
customResticParams: Array<string> | null;
|
customResticParams: Array<string> | null;
|
||||||
|
preBackupWebhook: {
|
||||||
|
url: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: string;
|
||||||
|
} | null;
|
||||||
|
postBackupWebhook: {
|
||||||
|
url: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: string;
|
||||||
|
} | null;
|
||||||
maxRetries: number;
|
maxRetries: number;
|
||||||
retryDelay: number;
|
retryDelay: number;
|
||||||
lastBackupAt: number | null;
|
lastBackupAt: number | null;
|
||||||
|
|
@ -2712,6 +2722,16 @@ export type CreateBackupScheduleData = {
|
||||||
oneFileSystem?: boolean;
|
oneFileSystem?: boolean;
|
||||||
tags?: Array<string>;
|
tags?: Array<string>;
|
||||||
customResticParams?: Array<string>;
|
customResticParams?: Array<string>;
|
||||||
|
preBackupWebhook?: {
|
||||||
|
url: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: string;
|
||||||
|
} | null;
|
||||||
|
postBackupWebhook?: {
|
||||||
|
url: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: string;
|
||||||
|
} | null;
|
||||||
maxRetries?: number;
|
maxRetries?: number;
|
||||||
retryDelay?: number;
|
retryDelay?: number;
|
||||||
};
|
};
|
||||||
|
|
@ -2747,6 +2767,16 @@ export type CreateBackupScheduleResponses = {
|
||||||
includePatterns: Array<string> | null;
|
includePatterns: Array<string> | null;
|
||||||
oneFileSystem: boolean;
|
oneFileSystem: boolean;
|
||||||
customResticParams: Array<string> | null;
|
customResticParams: Array<string> | null;
|
||||||
|
preBackupWebhook: {
|
||||||
|
url: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: string;
|
||||||
|
} | null;
|
||||||
|
postBackupWebhook: {
|
||||||
|
url: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: string;
|
||||||
|
} | null;
|
||||||
maxRetries: number;
|
maxRetries: number;
|
||||||
retryDelay: number;
|
retryDelay: number;
|
||||||
lastBackupAt: number | null;
|
lastBackupAt: number | null;
|
||||||
|
|
@ -2816,6 +2846,16 @@ export type GetBackupScheduleResponses = {
|
||||||
includePatterns: Array<string> | null;
|
includePatterns: Array<string> | null;
|
||||||
oneFileSystem: boolean;
|
oneFileSystem: boolean;
|
||||||
customResticParams: Array<string> | null;
|
customResticParams: Array<string> | null;
|
||||||
|
preBackupWebhook: {
|
||||||
|
url: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: string;
|
||||||
|
} | null;
|
||||||
|
postBackupWebhook: {
|
||||||
|
url: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: string;
|
||||||
|
} | null;
|
||||||
maxRetries: number;
|
maxRetries: number;
|
||||||
retryDelay: number;
|
retryDelay: number;
|
||||||
lastBackupAt: number | null;
|
lastBackupAt: number | null;
|
||||||
|
|
@ -3093,6 +3133,16 @@ export type UpdateBackupScheduleData = {
|
||||||
oneFileSystem?: boolean;
|
oneFileSystem?: boolean;
|
||||||
tags?: Array<string>;
|
tags?: Array<string>;
|
||||||
customResticParams?: Array<string>;
|
customResticParams?: Array<string>;
|
||||||
|
preBackupWebhook?: {
|
||||||
|
url: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: string;
|
||||||
|
} | null;
|
||||||
|
postBackupWebhook?: {
|
||||||
|
url: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: string;
|
||||||
|
} | null;
|
||||||
maxRetries?: number;
|
maxRetries?: number;
|
||||||
retryDelay?: number;
|
retryDelay?: number;
|
||||||
};
|
};
|
||||||
|
|
@ -3130,6 +3180,16 @@ export type UpdateBackupScheduleResponses = {
|
||||||
includePatterns: Array<string> | null;
|
includePatterns: Array<string> | null;
|
||||||
oneFileSystem: boolean;
|
oneFileSystem: boolean;
|
||||||
customResticParams: Array<string> | null;
|
customResticParams: Array<string> | null;
|
||||||
|
preBackupWebhook: {
|
||||||
|
url: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: string;
|
||||||
|
} | null;
|
||||||
|
postBackupWebhook: {
|
||||||
|
url: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: string;
|
||||||
|
} | null;
|
||||||
maxRetries: number;
|
maxRetries: number;
|
||||||
retryDelay: number;
|
retryDelay: number;
|
||||||
lastBackupAt: number | null;
|
lastBackupAt: number | null;
|
||||||
|
|
@ -3179,6 +3239,16 @@ export type GetBackupScheduleForVolumeResponses = {
|
||||||
includePatterns: Array<string> | null;
|
includePatterns: Array<string> | null;
|
||||||
oneFileSystem: boolean;
|
oneFileSystem: boolean;
|
||||||
customResticParams: Array<string> | null;
|
customResticParams: Array<string> | null;
|
||||||
|
preBackupWebhook: {
|
||||||
|
url: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: string;
|
||||||
|
} | null;
|
||||||
|
postBackupWebhook: {
|
||||||
|
url: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: string;
|
||||||
|
} | null;
|
||||||
maxRetries: number;
|
maxRetries: number;
|
||||||
retryDelay: number;
|
retryDelay: number;
|
||||||
lastBackupAt: number | null;
|
lastBackupAt: number | null;
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,102 @@ export const AdvancedSection = ({ form }: AdvancedSectionProps) => {
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="preBackupWebhookUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Pre-backup webhook</FormLabel>
|
||||||
|
<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
|
||||||
|
control={form.control}
|
||||||
|
name="preBackupWebhookHeadersText"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Pre-backup webhook headers</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
{...field}
|
||||||
|
placeholder={'{\n "Authorization": "Bearer token"\n}'}
|
||||||
|
className="font-mono text-sm min-h-24"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Optional JSON object. 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="postBackupWebhookHeadersText"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Post-backup webhook headers</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
{...field}
|
||||||
|
placeholder={'{\n "Authorization": "Bearer token"\n}'}
|
||||||
|
className="font-mono text-sm min-h-24"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Optional JSON object. 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}
|
||||||
name="customResticParamsText"
|
name="customResticParamsText"
|
||||||
|
|
|
||||||
|
|
@ -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 } from "./utils";
|
import { backupScheduleToFormValues, parseMultilineEntries, parseWebhookHeaders } from "./utils";
|
||||||
|
|
||||||
export type { BackupScheduleFormValues };
|
export type { BackupScheduleFormValues };
|
||||||
|
|
||||||
|
|
@ -46,12 +46,22 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
||||||
cronExpression,
|
cronExpression,
|
||||||
maxRetries,
|
maxRetries,
|
||||||
retryDelay,
|
retryDelay,
|
||||||
|
preBackupWebhookUrl,
|
||||||
|
preBackupWebhookHeadersText,
|
||||||
|
preBackupWebhookBody,
|
||||||
|
postBackupWebhookUrl,
|
||||||
|
postBackupWebhookHeadersText,
|
||||||
|
postBackupWebhookBody,
|
||||||
...rest
|
...rest
|
||||||
} = data;
|
} = data;
|
||||||
const excludePatterns = parseMultilineEntries(excludePatternsText);
|
const excludePatterns = parseMultilineEntries(excludePatternsText);
|
||||||
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 postBackupWebhookUrlValue = postBackupWebhookUrl?.trim();
|
||||||
|
const preBackupWebhookBodyValue = preBackupWebhookBody?.trim();
|
||||||
|
const postBackupWebhookBodyValue = postBackupWebhookBody?.trim();
|
||||||
|
|
||||||
onSubmit({
|
onSubmit({
|
||||||
...rest,
|
...rest,
|
||||||
|
|
@ -61,6 +71,20 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
||||||
excludePatterns,
|
excludePatterns,
|
||||||
excludeIfPresent,
|
excludeIfPresent,
|
||||||
customResticParams,
|
customResticParams,
|
||||||
|
preBackupWebhook: preBackupWebhookUrlValue
|
||||||
|
? {
|
||||||
|
url: preBackupWebhookUrlValue,
|
||||||
|
headers: parseWebhookHeaders(preBackupWebhookHeadersText),
|
||||||
|
body: preBackupWebhookBodyValue || undefined,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
postBackupWebhook: postBackupWebhookUrlValue
|
||||||
|
? {
|
||||||
|
url: postBackupWebhookUrlValue,
|
||||||
|
headers: parseWebhookHeaders(postBackupWebhookHeadersText),
|
||||||
|
body: postBackupWebhookBodyValue || undefined,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
maxRetries,
|
maxRetries,
|
||||||
retryDelay,
|
retryDelay,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,30 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const webhookHeadersTextSchema = z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.refine(
|
||||||
|
(value) => {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
return (
|
||||||
|
parsed &&
|
||||||
|
typeof parsed === "object" &&
|
||||||
|
!Array.isArray(parsed) &&
|
||||||
|
Object.values(parsed).every((headerValue) => typeof headerValue === "string")
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ message: "Headers must be a JSON object with string values" },
|
||||||
|
);
|
||||||
|
|
||||||
export const internalFormSchema = z.object({
|
export const internalFormSchema = z.object({
|
||||||
name: z.string().min(1).max(128),
|
name: z.string().min(1).max(128),
|
||||||
repositoryId: z.string(),
|
repositoryId: z.string(),
|
||||||
|
|
@ -20,6 +45,12 @@ export const internalFormSchema = z.object({
|
||||||
keepYearly: z.number().optional(),
|
keepYearly: z.number().optional(),
|
||||||
oneFileSystem: z.boolean().optional(),
|
oneFileSystem: z.boolean().optional(),
|
||||||
customResticParamsText: z.string().optional(),
|
customResticParamsText: z.string().optional(),
|
||||||
|
preBackupWebhookUrl: z.union([z.string().url(), z.literal("")]).optional(),
|
||||||
|
preBackupWebhookHeadersText: webhookHeadersTextSchema,
|
||||||
|
preBackupWebhookBody: z.string().optional(),
|
||||||
|
postBackupWebhookUrl: z.union([z.string().url(), z.literal("")]).optional(),
|
||||||
|
postBackupWebhookHeadersText: webhookHeadersTextSchema,
|
||||||
|
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(),
|
||||||
});
|
});
|
||||||
|
|
@ -38,10 +69,21 @@ export type InternalFormValues = z.infer<typeof internalFormSchema>;
|
||||||
|
|
||||||
export type BackupScheduleFormValues = Omit<
|
export type BackupScheduleFormValues = Omit<
|
||||||
InternalFormValues,
|
InternalFormValues,
|
||||||
"excludePatternsText" | "excludeIfPresentText" | "includePatterns" | "customResticParamsText"
|
| "excludePatternsText"
|
||||||
|
| "excludeIfPresentText"
|
||||||
|
| "includePatterns"
|
||||||
|
| "customResticParamsText"
|
||||||
|
| "preBackupWebhookUrl"
|
||||||
|
| "preBackupWebhookHeadersText"
|
||||||
|
| "preBackupWebhookBody"
|
||||||
|
| "postBackupWebhookUrl"
|
||||||
|
| "postBackupWebhookHeadersText"
|
||||||
|
| "postBackupWebhookBody"
|
||||||
> & {
|
> & {
|
||||||
excludePatterns?: string[];
|
excludePatterns?: string[];
|
||||||
excludeIfPresent?: string[];
|
excludeIfPresent?: string[];
|
||||||
includePatterns?: string[];
|
includePatterns?: string[];
|
||||||
customResticParams?: string[];
|
customResticParams?: string[];
|
||||||
|
preBackupWebhook?: { url: string; headers?: Record<string, string>; body?: string } | null;
|
||||||
|
postBackupWebhook?: { url: string; headers?: Record<string, string>; body?: string } | null;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,23 @@ export const parseMultilineEntries = (value?: string) =>
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
export const parseWebhookHeaders = (value?: string) => {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(trimmed) as Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stringifyWebhookHeaders = (headers?: Record<string, string>) => {
|
||||||
|
if (!headers || Object.keys(headers).length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(headers, null, 2);
|
||||||
|
};
|
||||||
|
|
||||||
export const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValues | undefined => {
|
export const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValues | undefined => {
|
||||||
if (!schedule) {
|
if (!schedule) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
@ -26,6 +43,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 ?? "",
|
||||||
|
preBackupWebhookHeadersText: stringifyWebhookHeaders(schedule.preBackupWebhook?.headers),
|
||||||
|
preBackupWebhookBody: schedule.preBackupWebhook?.body ?? "",
|
||||||
|
postBackupWebhookUrl: schedule.postBackupWebhook?.url ?? "",
|
||||||
|
postBackupWebhookHeadersText: stringifyWebhookHeaders(schedule.postBackupWebhook?.headers),
|
||||||
|
postBackupWebhookBody: schedule.postBackupWebhook?.body ?? "",
|
||||||
maxRetries: schedule.maxRetries,
|
maxRetries: schedule.maxRetries,
|
||||||
retryDelay: schedule.retryDelay,
|
retryDelay: schedule.retryDelay,
|
||||||
...cronValues,
|
...cronValues,
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,8 @@ const schedule = {
|
||||||
excludeIfPresent: [],
|
excludeIfPresent: [],
|
||||||
oneFileSystem: false,
|
oneFileSystem: false,
|
||||||
customResticParams: [],
|
customResticParams: [],
|
||||||
|
preBackupWebhook: null,
|
||||||
|
postBackupWebhook: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const snapshot = {
|
const snapshot = {
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,8 @@ const renderEditBackupPage = ({ enabled, cronExpression }: { enabled: boolean; c
|
||||||
excludeIfPresent: [],
|
excludeIfPresent: [],
|
||||||
oneFileSystem: false,
|
oneFileSystem: false,
|
||||||
customResticParams: [],
|
customResticParams: [],
|
||||||
|
preBackupWebhook: null,
|
||||||
|
postBackupWebhook: null,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
http.get("/api/v1/repositories", () => {
|
http.get("/api/v1/repositories", () => {
|
||||||
|
|
@ -124,3 +126,25 @@ test("preserves a disabled schedule when saving a non-manual frequency", async (
|
||||||
cronExpression: "00 02 * * *",
|
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({
|
||||||
|
preBackupWebhook: {
|
||||||
|
url: "http://localhost:8080/stop",
|
||||||
|
headers: { Authorization: "Bearer stop-token" },
|
||||||
|
body: '{"action":"stop"}',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,8 @@ 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,
|
||||||
|
postBackupWebhook: schedule.postBackupWebhook,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,8 @@ export function CreateBackupPage() {
|
||||||
excludeIfPresent: formValues.excludeIfPresent,
|
excludeIfPresent: formValues.excludeIfPresent,
|
||||||
oneFileSystem: formValues.oneFileSystem,
|
oneFileSystem: formValues.oneFileSystem,
|
||||||
customResticParams: formValues.customResticParams,
|
customResticParams: formValues.customResticParams,
|
||||||
|
preBackupWebhook: formValues.preBackupWebhook,
|
||||||
|
postBackupWebhook: formValues.postBackupWebhook,
|
||||||
maxRetries: formValues.maxRetries,
|
maxRetries: formValues.maxRetries,
|
||||||
retryDelay: formValues.retryDelay,
|
retryDelay: formValues.retryDelay,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE `backup_schedules_table` ADD `pre_backup_webhook` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `backup_schedules_table` ADD `post_backup_webhook` text;
|
||||||
2482
app/drizzle/20260423190302_cute_pete_wisdom/snapshot.json
Normal file
2482
app/drizzle/20260423190302_cute_pete_wisdom/snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -9,6 +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 { 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";
|
||||||
|
|
@ -310,6 +311,8 @@ 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>(),
|
||||||
|
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),
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,7 @@ test("close emits a synthetic backup.cancelled for a queued backup", () => {
|
||||||
defaultExcludes: [],
|
defaultExcludes: [],
|
||||||
rcloneConfigFile: "/tmp/rclone.conf",
|
rcloneConfigFile: "/tmp/rclone.conf",
|
||||||
},
|
},
|
||||||
|
webhooks: { pre: null, post: null },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -276,6 +276,48 @@ describe("backup execution - validation failures", () => {
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("passes configured backup webhooks to the backup agent", async () => {
|
||||||
|
const { runBackupMock } = setup();
|
||||||
|
const volume = await createTestVolume();
|
||||||
|
const repository = await createTestRepository();
|
||||||
|
const schedule = await createTestBackupSchedule({
|
||||||
|
volumeId: volume.id,
|
||||||
|
repositoryId: repository.id,
|
||||||
|
preBackupWebhook: {
|
||||||
|
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"}',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await backupsService.executeBackup(schedule.id);
|
||||||
|
|
||||||
|
expect(runBackupMock).toHaveBeenCalledWith(
|
||||||
|
"local",
|
||||||
|
expect.objectContaining({
|
||||||
|
payload: expect.objectContaining({
|
||||||
|
webhooks: {
|
||||||
|
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"}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("should fail backup when the local agent is unavailable", async () => {
|
test("should fail backup when the local agent is unavailable", async () => {
|
||||||
const { runBackupMock } = setup();
|
const { runBackupMock } = setup();
|
||||||
const volume = await createTestVolume();
|
const volume = await createTestVolume();
|
||||||
|
|
|
||||||
|
|
@ -402,6 +402,67 @@ 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 schedule = await backupsService.createSchedule({
|
||||||
|
name: "with-webhooks",
|
||||||
|
volumeId: volume.shortId,
|
||||||
|
repositoryId: repository.shortId,
|
||||||
|
enabled: true,
|
||||||
|
cronExpression: "0 0 * * *",
|
||||||
|
preBackupWebhook: {
|
||||||
|
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,
|
||||||
|
maxRetries: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(schedule.preBackupWebhook).toEqual({
|
||||||
|
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 () => {
|
||||||
|
setup();
|
||||||
|
const repository = await createTestRepository();
|
||||||
|
const schedule = await createTestBackupSchedule({
|
||||||
|
repositoryId: repository.id,
|
||||||
|
preBackupWebhook: { url: "http://localhost:8080/stop" },
|
||||||
|
postBackupWebhook: { url: "http://localhost:8080/start" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = await backupsService.updateSchedule(schedule.id, {
|
||||||
|
repositoryId: repository.shortId,
|
||||||
|
cronExpression: schedule.cronExpression,
|
||||||
|
preBackupWebhook: null,
|
||||||
|
postBackupWebhook: null,
|
||||||
|
retryDelay: 15 * 60 * 1000,
|
||||||
|
maxRetries: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updated.preBackupWebhook).toBeNull();
|
||||||
|
expect(updated.postBackupWebhook).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("listSchedules", () => {
|
describe("listSchedules", () => {
|
||||||
test("should ignore schedules with missing relations", async () => {
|
test("should ignore schedules with missing relations", async () => {
|
||||||
setup();
|
setup();
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Effect } from "effect";
|
import { Effect } from "effect";
|
||||||
|
import { createBackupWebhooks, runBackupWithWebhooks } 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";
|
||||||
|
|
@ -7,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 } from "../../utils/errors";
|
import { toErrorDetails, toMessage } from "../../utils/errors";
|
||||||
|
|
||||||
const LOCAL_AGENT_ID = "local";
|
const LOCAL_AGENT_ID = "local";
|
||||||
|
|
||||||
|
|
@ -54,6 +55,7 @@ const createBackupRunPayload = async ({
|
||||||
rcloneConfigFile: resticDeps.rcloneConfigFile,
|
rcloneConfigFile: resticDeps.rcloneConfigFile,
|
||||||
hostname: resticDeps.hostname,
|
hostname: resticDeps.hostname,
|
||||||
},
|
},
|
||||||
|
webhooks: createBackupWebhooks(schedule.preBackupWebhook, schedule.postBackupWebhook),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -61,41 +63,34 @@ const executeBackupWithoutAgent = async (
|
||||||
payload: BackupRunPayload,
|
payload: BackupRunPayload,
|
||||||
{ signal, onProgress }: Pick<BackupExecutionRequest, "signal" | "onProgress">,
|
{ signal, onProgress }: Pick<BackupExecutionRequest, "signal" | "onProgress">,
|
||||||
) => {
|
) => {
|
||||||
try {
|
return Effect.runPromise(
|
||||||
const execution = await Effect.runPromise(
|
runBackupWithWebhooks({
|
||||||
restic
|
metadata: {
|
||||||
.backup(payload.repositoryConfig, payload.sourcePath, {
|
jobId: payload.jobId,
|
||||||
|
scheduleId: payload.scheduleId,
|
||||||
|
organizationId: payload.organizationId,
|
||||||
|
sourcePath: payload.sourcePath,
|
||||||
|
},
|
||||||
|
webhooks: payload.webhooks,
|
||||||
|
signal,
|
||||||
|
formatErrorDetails: toErrorDetails,
|
||||||
|
formatErrorMessage: toMessage,
|
||||||
|
runBackup: () =>
|
||||||
|
restic.backup(payload.repositoryConfig, payload.sourcePath, {
|
||||||
...payload.options,
|
...payload.options,
|
||||||
organizationId: payload.organizationId,
|
organizationId: payload.organizationId,
|
||||||
signal,
|
signal,
|
||||||
onProgress,
|
onProgress,
|
||||||
})
|
}).pipe(
|
||||||
.pipe(
|
Effect.map((result) => ({
|
||||||
Effect.map((result) => ({ success: true as const, result })),
|
status: "completed" as const,
|
||||||
Effect.catchAll((error) => Effect.succeed({ success: false as const, error })),
|
exitCode: result.exitCode,
|
||||||
|
result: result.result,
|
||||||
|
warningDetails: result.warningDetails,
|
||||||
|
})),
|
||||||
),
|
),
|
||||||
);
|
}),
|
||||||
|
);
|
||||||
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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const backupExecutor = {
|
export const backupExecutor = {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +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 { 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";
|
||||||
|
|
@ -29,6 +30,8 @@ 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(),
|
||||||
|
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(),
|
||||||
|
|
@ -126,6 +129,8 @@ 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(),
|
||||||
|
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()
|
||||||
|
|
@ -171,6 +176,8 @@ 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(),
|
||||||
|
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()
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,8 @@ 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,
|
||||||
|
postBackupWebhook: data.postBackupWebhook ?? null,
|
||||||
nextBackupAt: nextBackupAt,
|
nextBackupAt: nextBackupAt,
|
||||||
shortId: generateShortId(),
|
shortId: generateShortId(),
|
||||||
maxRetries: data.maxRetries,
|
maxRetries: data.maxRetries,
|
||||||
|
|
@ -163,7 +165,14 @@ const updateSchedule = async (scheduleIdOrShortId: number | string, data: Update
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(backupSchedulesTable)
|
.update(backupSchedulesTable)
|
||||||
.set({ ...data, repositoryId: repository.id, nextBackupAt, updatedAt: Date.now() })
|
.set({
|
||||||
|
...data,
|
||||||
|
repositoryId: repository.id,
|
||||||
|
preBackupWebhook: data.preBackupWebhook === undefined ? schedule.preBackupWebhook : data.preBackupWebhook,
|
||||||
|
postBackupWebhook: data.postBackupWebhook === undefined ? schedule.postBackupWebhook : data.postBackupWebhook,
|
||||||
|
nextBackupAt,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
.where(and(eq(backupSchedulesTable.id, schedule.id), eq(backupSchedulesTable.organizationId, organizationId)))
|
.where(and(eq(backupSchedulesTable.id, schedule.id), eq(backupSchedulesTable.organizationId, organizationId)))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -149,7 +149,7 @@ export async function finalizeSuccessfulBackup(
|
||||||
warningDetails: string | null,
|
warningDetails: string | null,
|
||||||
) {
|
) {
|
||||||
const scheduleId = ctx.schedule.id;
|
const scheduleId = ctx.schedule.id;
|
||||||
const finalStatus = exitCode === 0 ? "success" : "warning";
|
const finalStatus = exitCode === 0 && !warningDetails ? "success" : "warning";
|
||||||
|
|
||||||
if (ctx.schedule.retentionPolicy) {
|
if (ctx.schedule.retentionPolicy) {
|
||||||
void runForget(scheduleId, undefined, ctx.organizationId).catch((error) => {
|
void runForget(scheduleId, undefined, ctx.organizationId).catch((error) => {
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ test("emits backup.failed when a backup command hits a restic error", async () =
|
||||||
defaultExcludes: [],
|
defaultExcludes: [],
|
||||||
rcloneConfigFile: "/root/.config/rclone/rclone.conf",
|
rcloneConfigFile: "/root/.config/rclone/rclone.conf",
|
||||||
},
|
},
|
||||||
|
webhooks: { pre: null, post: null },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,197 @@ const createDeferred = <T>() => {
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
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[] = [];
|
||||||
|
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (_url: URL, init: RequestInit) => {
|
||||||
|
const body = JSON.parse(String(init.body)) as { event: string };
|
||||||
|
events.push(body.event);
|
||||||
|
return new Response(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 }> = [];
|
||||||
|
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (url: URL, init: RequestInit) => {
|
||||||
|
requests.push({
|
||||||
|
url: url.toString(),
|
||||||
|
headers: new Headers(init.headers),
|
||||||
|
body: String(init.body),
|
||||||
|
});
|
||||||
|
return new Response(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();
|
||||||
|
vi.stubGlobal("fetch", vi.fn(async () => new Response("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-backup webhook returned HTTP 500");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports a post-backup webhook failure as completed warning details", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn(async () => new Response("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-backup webhook returned HTTP 500");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("waits for running-job registration before returning to the processor loop", async () => {
|
test("waits for running-job registration before returning to the processor loop", async () => {
|
||||||
|
|
@ -78,6 +269,7 @@ test("waits for running-job registration before returning to the processor loop"
|
||||||
passFile: "/tmp/restic-pass",
|
passFile: "/tmp/restic-pass",
|
||||||
defaultExcludes: [],
|
defaultExcludes: [],
|
||||||
},
|
},
|
||||||
|
webhooks: { pre: null, post: null },
|
||||||
});
|
});
|
||||||
const cancelPayload = fromPartial<BackupCancelPayload>({
|
const cancelPayload = fromPartial<BackupCancelPayload>({
|
||||||
jobId: "job-1",
|
jobId: "job-1",
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
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 { 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";
|
||||||
import { toErrorDetails, toMessage } from "@zerobyte/core/utils";
|
import { toMessage } from "@zerobyte/core/utils";
|
||||||
import type { ControllerCommandContext } from "../context";
|
import type { ControllerCommandContext } from "../context";
|
||||||
|
|
||||||
export const handleBackupRunCommand = (context: ControllerCommandContext, payload: BackupRunPayload) => {
|
export const handleBackupRunCommand = (context: ControllerCommandContext, payload: BackupRunPayload) => {
|
||||||
|
|
@ -56,61 +57,71 @@ 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>();
|
||||||
|
|
||||||
yield* restic
|
const backupResult = yield* runBackupWithWebhooks({
|
||||||
.backup(payload.repositoryConfig, payload.sourcePath, {
|
metadata: {
|
||||||
|
jobId: payload.jobId,
|
||||||
|
scheduleId: payload.scheduleId,
|
||||||
organizationId: payload.organizationId,
|
organizationId: payload.organizationId,
|
||||||
...payload.options,
|
sourcePath: payload.sourcePath,
|
||||||
signal: abortController.signal,
|
},
|
||||||
onProgress: (progress) => {
|
webhooks: payload.webhooks,
|
||||||
void Runtime.runPromise(
|
signal: abortController.signal,
|
||||||
runtime,
|
runBackup: () =>
|
||||||
context.offerOutbound(
|
restic.backup(payload.repositoryConfig, payload.sourcePath, {
|
||||||
createAgentMessage("backup.progress", {
|
organizationId: payload.organizationId,
|
||||||
jobId: payload.jobId,
|
...payload.options,
|
||||||
scheduleId: payload.scheduleId,
|
signal: abortController.signal,
|
||||||
progress,
|
onProgress: (progress) => {
|
||||||
}),
|
void Runtime.runPromise(
|
||||||
),
|
runtime,
|
||||||
).catch((error) => {
|
context.offerOutbound(
|
||||||
logger.error(`Failed to send backup progress update: ${toMessage(error)}`);
|
createAgentMessage("backup.progress", {
|
||||||
});
|
jobId: payload.jobId,
|
||||||
},
|
scheduleId: payload.scheduleId,
|
||||||
})
|
progress,
|
||||||
.pipe(
|
}),
|
||||||
Effect.matchEffect({
|
),
|
||||||
onSuccess: (result) => {
|
).catch((error) => {
|
||||||
if (abortController.signal.aborted) {
|
logger.error(`Failed to send backup progress update: ${toMessage(error)}`);
|
||||||
return sendCancelled();
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return context.offerOutbound(
|
|
||||||
createAgentMessage("backup.completed", {
|
|
||||||
jobId: payload.jobId,
|
|
||||||
scheduleId: payload.scheduleId,
|
|
||||||
exitCode: result.exitCode,
|
|
||||||
result: result.result,
|
|
||||||
warningDetails: result.warningDetails ?? undefined,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
onFailure: (error) => {
|
}).pipe(
|
||||||
if (abortController.signal.aborted) {
|
Effect.map((result) => ({
|
||||||
return sendCancelled();
|
status: "completed" as const,
|
||||||
}
|
exitCode: result.exitCode,
|
||||||
|
result: result.result,
|
||||||
|
warningDetails: result.warningDetails,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
return context.offerOutbound(
|
switch (backupResult.status) {
|
||||||
createAgentMessage("backup.failed", {
|
case "completed":
|
||||||
jobId: payload.jobId,
|
yield* context.offerOutbound(
|
||||||
scheduleId: payload.scheduleId,
|
createAgentMessage("backup.completed", {
|
||||||
error: toMessage(error),
|
jobId: payload.jobId,
|
||||||
errorDetails: toErrorDetails(error),
|
scheduleId: payload.scheduleId,
|
||||||
}),
|
exitCode: backupResult.exitCode,
|
||||||
);
|
result: backupResult.result,
|
||||||
},
|
warningDetails: backupResult.warningDetails ?? undefined,
|
||||||
}),
|
}),
|
||||||
Effect.ensuring(context.deleteRunningJob(payload.jobId)),
|
);
|
||||||
);
|
return;
|
||||||
}),
|
case "failed":
|
||||||
|
yield* context.offerOutbound(
|
||||||
|
createAgentMessage("backup.failed", {
|
||||||
|
jobId: payload.jobId,
|
||||||
|
scheduleId: payload.scheduleId,
|
||||||
|
error: backupResult.error,
|
||||||
|
errorDetails: backupResult.error,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
case "cancelled":
|
||||||
|
yield* sendCancelled();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}).pipe(Effect.ensuring(context.deleteRunningJob(payload.jobId))),
|
||||||
);
|
);
|
||||||
}).pipe(Effect.asVoid);
|
}).pipe(Effect.asVoid);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { backupWebhooksSchema } from "@zerobyte/core/backup-hooks";
|
||||||
import { safeJsonParse } from "@zerobyte/core/utils";
|
import { safeJsonParse } from "@zerobyte/core/utils";
|
||||||
import {
|
import {
|
||||||
repositoryConfigSchema,
|
repositoryConfigSchema,
|
||||||
|
|
@ -39,6 +40,7 @@ const backupRunSchema = z.object({
|
||||||
repositoryConfig: repositoryConfigSchema,
|
repositoryConfig: repositoryConfigSchema,
|
||||||
options: backupExecutionOptionsSchema,
|
options: backupExecutionOptionsSchema,
|
||||||
runtime: backupRuntimeSchema,
|
runtime: backupRuntimeSchema,
|
||||||
|
webhooks: backupWebhooksSchema,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,11 @@
|
||||||
"import": "./src/utils/index.ts",
|
"import": "./src/utils/index.ts",
|
||||||
"default": "./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": {
|
"./node": {
|
||||||
"types": "./src/node/index.ts",
|
"types": "./src/node/index.ts",
|
||||||
"import": "./src/node/index.ts",
|
"import": "./src/node/index.ts",
|
||||||
|
|
|
||||||
291
packages/core/src/backup-hooks/index.ts
Normal file
291
packages/core/src/backup-hooks/index.ts
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
import { Effect } from "effect";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { toErrorDetails, toMessage } from "../utils/index.js";
|
||||||
|
|
||||||
|
export const DEFAULT_BACKUP_WEBHOOK_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
|
export const backupWebhookConfigSchema = z.object({
|
||||||
|
url: z.url(),
|
||||||
|
headers: z.record(z.string(), 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>;
|
||||||
|
|
||||||
|
export type BackupWebhookPhase = "pre" | "post";
|
||||||
|
export type BackupWebhookStatus = "success" | "warning" | "error" | "cancelled";
|
||||||
|
|
||||||
|
export type BackupWebhookMetadata = {
|
||||||
|
jobId: string;
|
||||||
|
scheduleId: string;
|
||||||
|
organizationId: string;
|
||||||
|
sourcePath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BackupWebhookContext = {
|
||||||
|
phase: BackupWebhookPhase;
|
||||||
|
event: "backup.pre" | "backup.post";
|
||||||
|
jobId: string;
|
||||||
|
scheduleId: string;
|
||||||
|
organizationId: string;
|
||||||
|
sourcePath: string;
|
||||||
|
status?: BackupWebhookStatus;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BackupOperationResult<TResult> =
|
||||||
|
| { status: "completed"; exitCode: number;
|
||||||
|
result: TResult;
|
||||||
|
warningDetails: string | null;
|
||||||
|
}
|
||||||
|
| { status: "failed";
|
||||||
|
error: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BackupHookedExecutionResult<TResult> =
|
||||||
|
| {
|
||||||
|
status: "completed";
|
||||||
|
exitCode: number;
|
||||||
|
result: TResult;
|
||||||
|
warningDetails: string | null;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: "failed";
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: "cancelled";
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BackupHookedExecutionOptions<TResult, R = never> = {
|
||||||
|
metadata: BackupWebhookMetadata;
|
||||||
|
webhooks: BackupWebhooks;
|
||||||
|
signal: AbortSignal;
|
||||||
|
runBackup: () => Effect.Effect<BackupOperationResult<TResult>, unknown, R>;
|
||||||
|
formatErrorDetails?: (error: unknown) => string;
|
||||||
|
formatErrorMessage?: (error: unknown) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class BackupWebhookError extends Error {
|
||||||
|
override name = "BackupWebhookError";
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPhaseLabel = (phase: BackupWebhookPhase) => (phase === "pre" ? "Pre-backup" : "Post-backup");
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
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 [name, value] of Object.entries(config.headers ?? {})) {
|
||||||
|
headers.set(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const runBackupWebhook = async (
|
||||||
|
config: BackupWebhookConfig,
|
||||||
|
context: BackupWebhookContext,
|
||||||
|
options: { signal?: AbortSignal; timeoutMs?: number } = {},
|
||||||
|
) => {
|
||||||
|
const phaseLabel = getPhaseLabel(context.phase);
|
||||||
|
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 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 Error(
|
||||||
|
`${phaseLabel} webhook returned HTTP ${response.status}${details ? `: ${details}` : ""}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (controller.signal.aborted && controller.signal.reason instanceof Error) {
|
||||||
|
throw new BackupWebhookError(`${phaseLabel} webhook failed: ${controller.signal.reason.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BackupWebhookError(`${phaseLabel} webhook failed: ${toMessage(error)}`);
|
||||||
|
} finally {
|
||||||
|
controller.cleanup();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runConfiguredWebhook = (
|
||||||
|
config: BackupWebhookConfig | null,
|
||||||
|
context: BackupWebhookContext,
|
||||||
|
formatErrorDetails: (error: unknown) => string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) => {
|
||||||
|
if (!config) {
|
||||||
|
return Effect.succeed(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Effect.tryPromise({
|
||||||
|
try: () => runBackupWebhook(config, context, { signal }),
|
||||||
|
catch: (error) => error,
|
||||||
|
}).pipe(
|
||||||
|
Effect.as(null),
|
||||||
|
Effect.catchAll((error) => Effect.succeed(formatErrorDetails(error))),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const runBackupWithWebhooks = <TResult, R = never>({
|
||||||
|
metadata,
|
||||||
|
webhooks,
|
||||||
|
signal,
|
||||||
|
runBackup,
|
||||||
|
formatErrorDetails = toErrorDetails,
|
||||||
|
formatErrorMessage = toMessage,
|
||||||
|
}: BackupHookedExecutionOptions<TResult, R>): Effect.Effect<BackupHookedExecutionResult<TResult>, never, R> =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const preHookError = yield* runConfiguredWebhook(
|
||||||
|
webhooks.pre,
|
||||||
|
createWebhookContext(metadata, "pre"),
|
||||||
|
formatErrorDetails,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
if (preHookError) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
return { status: "cancelled", message: formatErrorMessage(signal.reason) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: "failed", error: preHookError };
|
||||||
|
}
|
||||||
|
|
||||||
|
const backupResult = yield* Effect.suspend(runBackup).pipe(
|
||||||
|
Effect.catchAll((error) => Effect.succeed({ status: "failed" as const, error })),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (backupResult.status === "completed") {
|
||||||
|
const hookStatus = getCompletedStatus(backupResult.exitCode, signal);
|
||||||
|
const postHookError = yield* runConfiguredWebhook(
|
||||||
|
webhooks.post,
|
||||||
|
createWebhookContext(metadata, "post", hookStatus, backupResult.warningDetails ?? undefined),
|
||||||
|
formatErrorDetails,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (signal.aborted) {
|
||||||
|
return { status: "cancelled", message: formatErrorMessage(signal.reason) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "completed",
|
||||||
|
exitCode: backupResult.exitCode,
|
||||||
|
result: backupResult.result,
|
||||||
|
warningDetails: appendDetails(backupResult.warningDetails, postHookError) || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorDetails = formatErrorDetails(backupResult.error);
|
||||||
|
const postHookError = yield* runConfiguredWebhook(
|
||||||
|
webhooks.post,
|
||||||
|
createWebhookContext(metadata, "post", signal.aborted ? "cancelled" : "error", errorDetails),
|
||||||
|
formatErrorDetails,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (signal.aborted) {
|
||||||
|
return {
|
||||||
|
status: "cancelled",
|
||||||
|
message: appendDetails(formatErrorMessage(signal.reason || backupResult.error), postHookError) || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "failed",
|
||||||
|
error: appendDetails(errorDetails, postHookError) || errorDetails,
|
||||||
|
};
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue