refactor: simplify hooks ceremonies
This commit is contained in:
parent
687810fb13
commit
95067c39fa
23 changed files with 2954 additions and 3163 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 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 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 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}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -79,8 +79,7 @@ const schedule = {
|
||||||
excludeIfPresent: [],
|
excludeIfPresent: [],
|
||||||
oneFileSystem: false,
|
oneFileSystem: false,
|
||||||
customResticParams: [],
|
customResticParams: [],
|
||||||
preBackupWebhook: null,
|
backupWebhooks: null,
|
||||||
postBackupWebhook: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const snapshot = {
|
const snapshot = {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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
1
app/drizzle/20260429194428_short_mentor/migration.sql
Normal file
1
app/drizzle/20260429194428_short_mentor/migration.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE `backup_schedules_table` ADD `backup_webhooks` text;
|
||||||
2341
app/drizzle/20260429194428_short_mentor/snapshot.json
Normal file
2341
app/drizzle/20260429194428_short_mentor/snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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"}',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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))),
|
||||||
|
|
|
||||||
|
|
@ -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" });
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue