refactor: pr feedback
This commit is contained in:
parent
be7eef7028
commit
687810fb13
6 changed files with 106 additions and 36 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
|
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
|
||||||
import { Textarea } from "~/client/components/ui/textarea";
|
import { Textarea } from "~/client/components/ui/textarea";
|
||||||
import { useWatch, type UseFormReturn } from "react-hook-form";
|
import { type UseFormReturn } from "react-hook-form";
|
||||||
import type { InternalFormValues } from "./types";
|
import type { InternalFormValues } from "./types";
|
||||||
import { Input } from "~/client/components/ui/input";
|
import { Input } from "~/client/components/ui/input";
|
||||||
|
|
||||||
|
|
@ -9,10 +9,6 @@ type AdvancedSectionProps = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AdvancedSection = ({ form }: AdvancedSectionProps) => {
|
export const AdvancedSection = ({ form }: AdvancedSectionProps) => {
|
||||||
const values = useWatch({ control: form.control });
|
|
||||||
const preBackupWebhookBody = values.preBackupWebhookBody?.trim();
|
|
||||||
const postBackupWebhookBody = values.postBackupWebhookBody?.trim();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FormField
|
<FormField
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,10 @@ 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(),
|
preBackupWebhookUrl: z.union([z.url(), z.literal("")]).optional(),
|
||||||
preBackupWebhookHeaders: webhookHeadersSchema,
|
preBackupWebhookHeaders: webhookHeadersSchema,
|
||||||
preBackupWebhookBody: z.string().optional(),
|
preBackupWebhookBody: z.string().optional(),
|
||||||
postBackupWebhookUrl: z.union([z.string().url(), z.literal("")]).optional(),
|
postBackupWebhookUrl: z.union([z.url(), z.literal("")]).optional(),
|
||||||
postBackupWebhookHeaders: webhookHeadersSchema,
|
postBackupWebhookHeaders: webhookHeadersSchema,
|
||||||
postBackupWebhookBody: z.string().optional(),
|
postBackupWebhookBody: z.string().optional(),
|
||||||
maxRetries: z.number().min(0).max(32).optional(),
|
maxRetries: z.number().min(0).max(32).optional(),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { afterEach, expect, test, vi } from "vitest";
|
|
||||||
import { Effect } from "effect";
|
import { Effect } from "effect";
|
||||||
|
import { HttpResponse, http } from "msw";
|
||||||
|
import { setupServer } from "msw/node";
|
||||||
|
import { afterAll, afterEach, beforeAll, expect, test, vi } from "vitest";
|
||||||
import waitForExpect from "wait-for-expect";
|
import waitForExpect from "wait-for-expect";
|
||||||
import { fromPartial } from "@total-typescript/shoehorn";
|
import { fromPartial } from "@total-typescript/shoehorn";
|
||||||
import { parseAgentMessage, type BackupCancelPayload, type BackupRunPayload } from "@zerobyte/contracts/agent-protocol";
|
import { parseAgentMessage, type BackupCancelPayload, type BackupRunPayload } from "@zerobyte/contracts/agent-protocol";
|
||||||
|
|
@ -8,6 +10,12 @@ import { handleBackupCancelCommand } from "./backup-cancel";
|
||||||
import { handleBackupRunCommand } from "./backup-run";
|
import { handleBackupRunCommand } from "./backup-run";
|
||||||
import type { ControllerCommandContext, RunningJob } from "../context";
|
import type { ControllerCommandContext, RunningJob } from "../context";
|
||||||
|
|
||||||
|
const server = setupServer();
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
server.listen({ onUnhandledRequest: "error" });
|
||||||
|
});
|
||||||
|
|
||||||
const createDeferred = <T>() => {
|
const createDeferred = <T>() => {
|
||||||
let resolve!: (value: T) => void;
|
let resolve!: (value: T) => void;
|
||||||
const promise = new Promise<T>((resolvePromise) => {
|
const promise = new Promise<T>((resolvePromise) => {
|
||||||
|
|
@ -19,7 +27,11 @@ const createDeferred = <T>() => {
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
vi.unstubAllGlobals();
|
server.resetHandlers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
const createRunPayload = (overrides: Partial<BackupRunPayload> = {}) =>
|
const createRunPayload = (overrides: Partial<BackupRunPayload> = {}) =>
|
||||||
|
|
@ -82,12 +94,16 @@ const runBackupCommand = async (payload: BackupRunPayload) => {
|
||||||
test("runs pre and post backup webhooks around restic", async () => {
|
test("runs pre and post backup webhooks around restic", async () => {
|
||||||
const events: string[] = [];
|
const events: string[] = [];
|
||||||
|
|
||||||
vi.stubGlobal(
|
server.use(
|
||||||
"fetch",
|
http.post("http://localhost:8080/pre", async ({ request }) => {
|
||||||
vi.fn(async (_url: URL, init: RequestInit) => {
|
const body = (await request.json()) as { event: string };
|
||||||
const body = JSON.parse(String(init.body)) as { event: string };
|
|
||||||
events.push(body.event);
|
events.push(body.event);
|
||||||
return new Response(null, { status: 204 });
|
return new HttpResponse(null, { status: 204 });
|
||||||
|
}),
|
||||||
|
http.post("http://localhost:8080/post", async ({ request }) => {
|
||||||
|
const body = (await request.json()) as { event: string };
|
||||||
|
events.push(body.event);
|
||||||
|
return new HttpResponse(null, { status: 204 });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -117,15 +133,22 @@ test("runs pre and post backup webhooks around restic", async () => {
|
||||||
test("sends configured webhook headers and body without changing them", async () => {
|
test("sends configured webhook headers and body without changing them", async () => {
|
||||||
const requests: Array<{ url: string; headers: Headers; body: string }> = [];
|
const requests: Array<{ url: string; headers: Headers; body: string }> = [];
|
||||||
|
|
||||||
vi.stubGlobal(
|
server.use(
|
||||||
"fetch",
|
http.post("http://localhost:8080/pre", async ({ request }) => {
|
||||||
vi.fn(async (url: URL, init: RequestInit) => {
|
|
||||||
requests.push({
|
requests.push({
|
||||||
url: url.toString(),
|
url: request.url,
|
||||||
headers: new Headers(init.headers),
|
headers: request.headers,
|
||||||
body: String(init.body),
|
body: await request.text(),
|
||||||
});
|
});
|
||||||
return new Response(null, { status: 204 });
|
return new HttpResponse(null, { status: 204 });
|
||||||
|
}),
|
||||||
|
http.post("http://localhost:8080/post", async ({ request }) => {
|
||||||
|
requests.push({
|
||||||
|
url: request.url,
|
||||||
|
headers: request.headers,
|
||||||
|
body: await request.text(),
|
||||||
|
});
|
||||||
|
return new HttpResponse(null, { status: 204 });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -164,9 +187,10 @@ test("sends configured webhook headers and body without changing them", async ()
|
||||||
|
|
||||||
test("fails without running restic when the pre-backup webhook fails", async () => {
|
test("fails without running restic when the pre-backup webhook fails", async () => {
|
||||||
const backupMock = vi.fn();
|
const backupMock = vi.fn();
|
||||||
vi.stubGlobal(
|
server.use(
|
||||||
"fetch",
|
http.post("http://localhost:8080/pre", () => {
|
||||||
vi.fn(async () => new Response("stop failed", { status: 500 })),
|
return new HttpResponse("stop failed", { status: 500 });
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
vi.spyOn(resticServer, "createRestic").mockReturnValue(
|
vi.spyOn(resticServer, "createRestic").mockReturnValue(
|
||||||
fromPartial({
|
fromPartial({
|
||||||
|
|
@ -192,9 +216,10 @@ test("fails without running restic when the pre-backup webhook fails", async ()
|
||||||
});
|
});
|
||||||
|
|
||||||
test("reports a post-backup webhook failure as completed warning details", async () => {
|
test("reports a post-backup webhook failure as completed warning details", async () => {
|
||||||
vi.stubGlobal(
|
server.use(
|
||||||
"fetch",
|
http.post("http://localhost:8080/post", () => {
|
||||||
vi.fn(async () => new Response("start failed", { status: 500 })),
|
return new HttpResponse("start failed", { status: 500 });
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
vi.spyOn(resticServer, "createRestic").mockReturnValue(
|
vi.spyOn(resticServer, "createRestic").mockReturnValue(
|
||||||
fromPartial({
|
fromPartial({
|
||||||
|
|
|
||||||
|
|
@ -69,8 +69,8 @@ export const handleBackupRunCommand = (context: ControllerCommandContext, payloa
|
||||||
runBackup: () => {
|
runBackup: () => {
|
||||||
return restic
|
return restic
|
||||||
.backup(payload.repositoryConfig, payload.sourcePath, {
|
.backup(payload.repositoryConfig, payload.sourcePath, {
|
||||||
organizationId: payload.organizationId,
|
|
||||||
...payload.options,
|
...payload.options,
|
||||||
|
organizationId: payload.organizationId,
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
onProgress: (progress) => {
|
onProgress: (progress) => {
|
||||||
void Runtime.runPromise(
|
void Runtime.runPromise(
|
||||||
|
|
@ -115,7 +115,7 @@ export const handleBackupRunCommand = (context: ControllerCommandContext, payloa
|
||||||
createAgentMessage("backup.failed", {
|
createAgentMessage("backup.failed", {
|
||||||
jobId: payload.jobId,
|
jobId: payload.jobId,
|
||||||
scheduleId: payload.scheduleId,
|
scheduleId: payload.scheduleId,
|
||||||
error: backupResult.error,
|
error: toMessage(backupResult.error),
|
||||||
errorDetails: backupResult.error,
|
errorDetails: backupResult.error,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -310,6 +310,41 @@ test("includes post-backup webhook failure details after cancellation", async ()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("includes post-backup webhook failure details after completed cancellation", async () => {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
let postBody: { status?: string; error?: string } | undefined;
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.post("http://localhost:8080/post", async ({ request }) => {
|
||||||
|
postBody = (await request.json()) as { status?: string; error?: string };
|
||||||
|
return new HttpResponse("cleanup failed", { status: 500 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
runBackupWithWebhooks({
|
||||||
|
metadata,
|
||||||
|
webhooks: {
|
||||||
|
pre: null,
|
||||||
|
post: { url: "http://localhost:8080/post" },
|
||||||
|
},
|
||||||
|
signal: abortController.signal,
|
||||||
|
runBackup: () =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
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(result.status).toBe("cancelled");
|
||||||
|
if (result.status === "cancelled") {
|
||||||
|
expect(result.message).toContain("Backup was cancelled");
|
||||||
|
expect(result.message).toContain("Post-backup webhook returned HTTP 500: cleanup failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("cancels before the pre-backup webhook without running the backup", async () => {
|
test("cancels before the pre-backup webhook without running the backup", async () => {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
let backupRan = false;
|
let backupRan = false;
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,8 @@ 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(() => {
|
||||||
|
|
@ -167,6 +169,7 @@ export const runBackupWebhook = async (
|
||||||
try {
|
try {
|
||||||
const parsedConfig = backupWebhookConfigSchema.parse(config);
|
const parsedConfig = backupWebhookConfigSchema.parse(config);
|
||||||
const url = new URL(parsedConfig.url);
|
const url = new URL(parsedConfig.url);
|
||||||
|
const phase = formatWebhookPhase(context.phase);
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
...createRequestInit(parsedConfig, context),
|
...createRequestInit(parsedConfig, context),
|
||||||
|
|
@ -176,17 +179,26 @@ export const runBackupWebhook = async (
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const responseText = await response.text().catch(() => "");
|
const responseText = await response.text().catch(() => "");
|
||||||
const details = responseText.trim().slice(0, 500);
|
const details = responseText.trim().slice(0, 500);
|
||||||
throw new Error(`${context.phase} webhook returned HTTP ${response.status}${details ? `: ${details}` : ""}`);
|
throw new BackupWebhookError({
|
||||||
|
cause: new Error(`${phase} webhook returned HTTP ${response.status}`),
|
||||||
|
message: `${phase} webhook returned HTTP ${response.status}${details ? `: ${details}` : ""}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof BackupWebhookError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const phase = formatWebhookPhase(context.phase);
|
||||||
|
|
||||||
if (controller.signal.aborted && controller.signal.reason instanceof Error) {
|
if (controller.signal.aborted && controller.signal.reason instanceof Error) {
|
||||||
throw new BackupWebhookError({
|
throw new BackupWebhookError({
|
||||||
cause: controller.signal.reason,
|
cause: controller.signal.reason,
|
||||||
message: `${context.phase} webhook failed: ${controller.signal.reason.message}`,
|
message: `${phase} webhook failed: ${controller.signal.reason.message}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new BackupWebhookError({ cause: error, message: `${context.phase} webhook failed: ${toMessage(error)}` });
|
throw new BackupWebhookError({ cause: error, message: `${phase} webhook failed: ${toMessage(error)}` });
|
||||||
} finally {
|
} finally {
|
||||||
controller.cleanup();
|
controller.cleanup();
|
||||||
}
|
}
|
||||||
|
|
@ -246,15 +258,18 @@ export const runBackupWithWebhooks = <TResult, R = never>({
|
||||||
|
|
||||||
if (backupResult.status === "completed") {
|
if (backupResult.status === "completed") {
|
||||||
const hookStatus = getCompletedStatus(backupResult.exitCode, signal);
|
const hookStatus = getCompletedStatus(backupResult.exitCode, signal);
|
||||||
|
const hookError = signal.aborted ? formatErrorMessage(signal.reason) : (backupResult.warningDetails ?? undefined);
|
||||||
const postHookError = yield* runConfiguredWebhook(
|
const postHookError = yield* runConfiguredWebhook(
|
||||||
webhooks.post,
|
webhooks.post,
|
||||||
createWebhookContext(metadata, "post", hookStatus, backupResult.warningDetails ?? undefined),
|
createWebhookContext(metadata, "post", hookStatus, hookError),
|
||||||
formatErrorDetails,
|
formatErrorDetails,
|
||||||
signal,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
return { status: "cancelled", message: formatErrorMessage(signal.reason) };
|
return {
|
||||||
|
status: "cancelled",
|
||||||
|
message: appendDetails(formatErrorMessage(signal.reason), postHookError) || undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -270,7 +285,6 @@ export const runBackupWithWebhooks = <TResult, R = never>({
|
||||||
webhooks.post,
|
webhooks.post,
|
||||||
createWebhookContext(metadata, "post", signal.aborted ? "cancelled" : "error", errorDetails),
|
createWebhookContext(metadata, "post", signal.aborted ? "cancelled" : "error", errorDetails),
|
||||||
formatErrorDetails,
|
formatErrorDetails,
|
||||||
signal,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue