From c6d95c46aac9c99da151dd11693f06bd05b76fcc Mon Sep 17 00:00:00 2001 From: haouarihk Date: Tue, 9 Jun 2026 09:00:58 +0100 Subject: [PATCH 1/2] feat: register GitLab deploy webhooks automatically --- apps/dokploy/__test__/gitlab-webhooks.test.ts | 147 ++++++++++++++++++ .../dokploy/server/api/routers/application.ts | 15 ++ apps/dokploy/server/api/routers/compose.ts | 17 ++ packages/server/src/utils/providers/gitlab.ts | 118 ++++++++++++++ 4 files changed, 297 insertions(+) create mode 100644 apps/dokploy/__test__/gitlab-webhooks.test.ts diff --git a/apps/dokploy/__test__/gitlab-webhooks.test.ts b/apps/dokploy/__test__/gitlab-webhooks.test.ts new file mode 100644 index 0000000000..2cda3d396f --- /dev/null +++ b/apps/dokploy/__test__/gitlab-webhooks.test.ts @@ -0,0 +1,147 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + findGitlabById: vi.fn(), + updateGitlab: vi.fn(), +})); + +vi.mock("@dokploy/server/services/gitlab", () => mocks); + +import { registerGitlabDeployWebhook } from "@dokploy/server/utils/providers/gitlab"; + +const deployWebhookUrl = "https://dokploy.example.com/api/deploy/refresh-token"; + +const createResponse = ( + body: unknown, + init: { status?: number; statusText?: string } = {}, +) => + new Response(JSON.stringify(body), { + status: init.status ?? 200, + statusText: init.statusText ?? "OK", + headers: { + "Content-Type": "application/json", + }, + }); + +const mockGitlabProvider = (overrides = {}) => ({ + gitlabId: "gitlab-id", + gitlabUrl: "https://gitlab.example.com/", + gitlabInternalUrl: null, + applicationId: "application-id", + redirectUri: "https://dokploy.example.com/api/providers/gitlab/callback", + secret: "secret", + accessToken: "access-token", + refreshToken: "refresh-token", + groupName: null, + expiresAt: Math.floor(Date.now() / 1000) + 3600, + gitProviderId: "git-provider-id", + ...overrides, +}); + +describe("registerGitlabDeployWebhook", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("creates a project hook when no hook exists for the deploy URL", async () => { + mocks.findGitlabById.mockResolvedValue(mockGitlabProvider()); + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(createResponse([])) + .mockResolvedValueOnce(createResponse({ id: 1, url: deployWebhookUrl })); + + await registerGitlabDeployWebhook({ + gitlabId: "gitlab-id", + gitlabProjectId: 123, + branch: "main", + deployWebhookUrl, + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://gitlab.example.com/api/v4/projects/123/hooks?per_page=100", + { + headers: { + Authorization: "Bearer access-token", + "Content-Type": "application/json", + }, + }, + ); + + const createCall = fetchMock.mock.calls[1]; + expect(createCall?.[0]).toBe( + "https://gitlab.example.com/api/v4/projects/123/hooks", + ); + expect(createCall?.[1]).toMatchObject({ + method: "POST", + headers: { + Authorization: "Bearer access-token", + "Content-Type": "application/json", + }, + }); + expect(JSON.parse(createCall?.[1]?.body as string)).toEqual({ + url: deployWebhookUrl, + push_events: true, + enable_ssl_verification: true, + push_events_branch_filter: "main", + branch_filter_strategy: "wildcard", + }); + }); + + it("updates the existing project hook with the same deploy URL", async () => { + mocks.findGitlabById.mockResolvedValue(mockGitlabProvider()); + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + createResponse([{ id: 456, url: deployWebhookUrl }]), + ) + .mockResolvedValueOnce( + createResponse({ id: 456, url: deployWebhookUrl }), + ); + + await registerGitlabDeployWebhook({ + gitlabId: "gitlab-id", + gitlabProjectId: 123, + branch: "production", + deployWebhookUrl, + }); + + const updateCall = fetchMock.mock.calls[1]; + expect(updateCall?.[0]).toBe( + "https://gitlab.example.com/api/v4/projects/123/hooks/456", + ); + expect(updateCall?.[1]).toMatchObject({ + method: "PUT", + }); + expect(JSON.parse(updateCall?.[1]?.body as string)).toMatchObject({ + url: deployWebhookUrl, + push_events_branch_filter: "production", + }); + }); + + it("uses the internal GitLab URL before the public URL", async () => { + mocks.findGitlabById.mockResolvedValue( + mockGitlabProvider({ + gitlabUrl: "https://gitlab.example.com", + gitlabInternalUrl: "http://gitlab:8080/", + }), + ); + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(createResponse([])) + .mockResolvedValueOnce(createResponse({ id: 1, url: deployWebhookUrl })); + + await registerGitlabDeployWebhook({ + gitlabId: "gitlab-id", + gitlabProjectId: 123, + branch: "main", + deployWebhookUrl, + }); + + expect(fetchMock.mock.calls[0]?.[0]).toBe( + "http://gitlab:8080/api/v4/projects/123/hooks?per_page=100", + ); + expect(fetchMock.mock.calls[1]?.[0]).toBe( + "http://gitlab:8080/api/v4/projects/123/hooks", + ); + }); +}); diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index de847a3014..a61203fa91 100644 --- a/apps/dokploy/server/api/routers/application.ts +++ b/apps/dokploy/server/api/routers/application.ts @@ -8,11 +8,13 @@ import { getAccessibleServerIds, getApplicationStats, getContainerLogs, + getDokployUrl, getWebServerSettings, IS_CLOUD, mechanizeDockerContainer, readConfig, readRemoteConfig, + registerGitlabDeployWebhook, removeDeployments, removeDirectoryCode, removeMonitoringDirectory, @@ -448,6 +450,12 @@ export const applicationRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.applicationId, { service: ["create"], }); + if (!input.gitlabId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "GitLab provider is required", + }); + } await updateApplication(input.applicationId, { gitlabRepository: input.gitlabRepository, gitlabOwner: input.gitlabOwner, @@ -462,6 +470,13 @@ export const applicationRouter = createTRPCRouter({ enableSubmodules: input.enableSubmodules, }); const application = await findApplicationById(input.applicationId); + const dokployUrl = await getDokployUrl(); + await registerGitlabDeployWebhook({ + gitlabId: input.gitlabId, + gitlabProjectId: input.gitlabProjectId, + branch: input.gitlabBranch, + deployWebhookUrl: `${dokployUrl}/api/deploy/${application.refreshToken}`, + }); await audit(ctx, { action: "update", resourceType: "application", diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index 126e80b1db..03262ae30c 100644 --- a/apps/dokploy/server/api/routers/compose.ts +++ b/apps/dokploy/server/api/routers/compose.ts @@ -18,11 +18,13 @@ import { getAccessibleServerIds, getComposeContainer, getContainerLogs, + getDokployUrl, getWebServerSettings, IS_CLOUD, loadServices, randomizeComposeFile, randomizeIsolatedDeploymentComposeFile, + registerGitlabDeployWebhook, removeCompose, removeComposeDirectory, removeDeploymentsByComposeId, @@ -197,6 +199,21 @@ export const composeRouter = createTRPCRouter({ service: ["create"], }); const updated = await updateCompose(input.composeId, input); + if ( + input.sourceType === "gitlab" && + input.gitlabId && + input.gitlabProjectId && + input.gitlabBranch && + updated?.refreshToken + ) { + const dokployUrl = await getDokployUrl(); + await registerGitlabDeployWebhook({ + gitlabId: input.gitlabId, + gitlabProjectId: input.gitlabProjectId, + branch: input.gitlabBranch, + deployWebhookUrl: `${dokployUrl}/api/deploy/compose/${updated.refreshToken}`, + }); + } await audit(ctx, { action: "update", resourceType: "compose", diff --git a/packages/server/src/utils/providers/gitlab.ts b/packages/server/src/utils/providers/gitlab.ts index 02121d346c..dc976fe7ca 100644 --- a/packages/server/src/utils/providers/gitlab.ts +++ b/packages/server/src/utils/providers/gitlab.ts @@ -102,6 +102,124 @@ const getGitlabCloneUrl = (gitlab: GitlabInfo, repoClone: string) => { return cloneUrl; }; +interface GitlabProjectHook { + id: number; + url: string; +} + +interface RegisterGitlabDeployWebhookInput { + gitlabId: string; + gitlabProjectId: number | null; + branch: string; + deployWebhookUrl: string; +} + +const createGitlabHookPayload = ({ + branch, + deployWebhookUrl, +}: Pick) => ({ + url: deployWebhookUrl, + push_events: true, + enable_ssl_verification: true, + push_events_branch_filter: branch, + branch_filter_strategy: "wildcard", +}); + +const getGitlabApiBaseUrl = (gitlabProvider: Gitlab) => { + return (gitlabProvider.gitlabInternalUrl || gitlabProvider.gitlabUrl).replace( + /\/+$/, + "", + ); +}; + +const getGitlabApiHeaders = (accessToken: string | null) => { + if (!accessToken) { + throw new Error("GitLab provider access token not found"); + } + + return { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }; +}; + +const getGitlabHookErrorMessage = async (response: Response) => { + const body = await response.text(); + + if (!body) { + return response.statusText; + } + + try { + const parsed = JSON.parse(body); + if (typeof parsed.message === "string") { + return parsed.message; + } + if (parsed.message) { + return JSON.stringify(parsed.message); + } + return body; + } catch { + return body; + } +}; + +const throwGitlabHookError = async (action: string, response: Response) => { + const message = await getGitlabHookErrorMessage(response); + throw new Error( + `Failed to ${action}: ${response.status} ${response.statusText}${message ? ` - ${message}` : ""}`, + ); +}; + +export const registerGitlabDeployWebhook = async ({ + gitlabId, + gitlabProjectId, + branch, + deployWebhookUrl, +}: RegisterGitlabDeployWebhookInput) => { + if (!gitlabProjectId) { + throw new Error("GitLab project ID is required to register webhook"); + } + + await refreshGitlabToken(gitlabId); + const gitlabProvider = await findGitlabById(gitlabId); + const baseUrl = getGitlabApiBaseUrl(gitlabProvider); + const hooksUrl = `${baseUrl}/api/v4/projects/${gitlabProjectId}/hooks`; + const headers = getGitlabApiHeaders(gitlabProvider.accessToken); + const payload = createGitlabHookPayload({ branch, deployWebhookUrl }); + + const hooksResponse = await fetch(`${hooksUrl}?per_page=100`, { + headers, + }); + + if (!hooksResponse.ok) { + await throwGitlabHookError("fetch GitLab project hooks", hooksResponse); + } + + const hooks = (await hooksResponse.json()) as GitlabProjectHook[]; + const existingHook = hooks.find((hook) => hook.url === deployWebhookUrl); + + const response = await fetch( + existingHook ? `${hooksUrl}/${existingHook.id}` : hooksUrl, + { + method: existingHook ? "PUT" : "POST", + headers, + body: JSON.stringify(payload), + }, + ); + + if (!response.ok) { + await throwGitlabHookError( + existingHook + ? "update GitLab project webhook" + : "create GitLab project webhook", + response, + ); + } + + return await response.json(); +}; + interface CloneGitlabRepository { appName: string; gitlabBranch: string | null; From d8f4fa583e4d1138bd7e49c7f7fa66b6cd760a1b Mon Sep 17 00:00:00 2001 From: haouarihk Date: Tue, 9 Jun 2026 09:13:57 +0100 Subject: [PATCH 2/2] add GitLab auto deploy toggle --- apps/dokploy/__test__/gitlab-webhooks.test.ts | 20 + .../git/gitlab/add-gitlab-provider.tsx | 28 + .../git/gitlab/edit-gitlab-provider.tsx | 28 + apps/dokploy/drizzle/0172_brainy_tag.sql | 1 + apps/dokploy/drizzle/meta/0172_snapshot.json | 8451 +++++++++++++++++ apps/dokploy/drizzle/meta/_journal.json | 7 + packages/server/src/db/schema/gitlab.ts | 5 +- packages/server/src/utils/providers/gitlab.ts | 6 +- 8 files changed, 8544 insertions(+), 2 deletions(-) create mode 100644 apps/dokploy/drizzle/0172_brainy_tag.sql create mode 100644 apps/dokploy/drizzle/meta/0172_snapshot.json diff --git a/apps/dokploy/__test__/gitlab-webhooks.test.ts b/apps/dokploy/__test__/gitlab-webhooks.test.ts index 2cda3d396f..a7d258aa38 100644 --- a/apps/dokploy/__test__/gitlab-webhooks.test.ts +++ b/apps/dokploy/__test__/gitlab-webhooks.test.ts @@ -34,6 +34,7 @@ const mockGitlabProvider = (overrides = {}) => ({ refreshToken: "refresh-token", groupName: null, expiresAt: Math.floor(Date.now() / 1000) + 3600, + enableAutoDeploy: true, gitProviderId: "git-provider-id", ...overrides, }); @@ -144,4 +145,23 @@ describe("registerGitlabDeployWebhook", () => { "http://gitlab:8080/api/v4/projects/123/hooks", ); }); + + it("skips project hook registration when automatic deployments are disabled", async () => { + mocks.findGitlabById.mockResolvedValue( + mockGitlabProvider({ + enableAutoDeploy: false, + }), + ); + const fetchMock = vi.spyOn(globalThis, "fetch"); + + const result = await registerGitlabDeployWebhook({ + gitlabId: "gitlab-id", + gitlabProjectId: 123, + branch: "main", + deployWebhookUrl, + }); + + expect(result).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); }); diff --git a/apps/dokploy/components/dashboard/settings/git/gitlab/add-gitlab-provider.tsx b/apps/dokploy/components/dashboard/settings/git/gitlab/add-gitlab-provider.tsx index b48f8253b8..8635d5a18f 100644 --- a/apps/dokploy/components/dashboard/settings/git/gitlab/add-gitlab-provider.tsx +++ b/apps/dokploy/components/dashboard/settings/git/gitlab/add-gitlab-provider.tsx @@ -26,6 +26,7 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; import { api } from "@/utils/api"; import { useUrl } from "@/utils/hooks/use-url"; @@ -51,6 +52,7 @@ const Schema = z.object({ message: "Redirect URI is required", }), groupName: z.string().optional(), + enableAutoDeploy: z.boolean().default(true), }); type Schema = z.infer; @@ -72,6 +74,7 @@ export const AddGitlabProvider = () => { name: "", gitlabUrl: "https://gitlab.com", gitlabInternalUrl: "", + enableAutoDeploy: true, }, resolver: zodResolver(Schema), }); @@ -87,6 +90,7 @@ export const AddGitlabProvider = () => { name: "", gitlabUrl: "https://gitlab.com", gitlabInternalUrl: "", + enableAutoDeploy: true, }); }, [form, isOpen]); @@ -100,6 +104,7 @@ export const AddGitlabProvider = () => { redirectUri: data.redirectUri || "", gitlabUrl: data.gitlabUrl || "https://gitlab.com", gitlabInternalUrl: data.gitlabInternalUrl || undefined, + enableAutoDeploy: data.enableAutoDeploy, }) .then(async () => { await utils.gitProvider.getAll.invalidate(); @@ -292,6 +297,29 @@ export const AddGitlabProvider = () => { )} /> + ( + +
+ Enable Automatic Deployments + + Automatically configure deploy webhooks when this + GitLab provider is used by an application or compose + service. + +
+ + + +
+ )} + /> + diff --git a/apps/dokploy/components/dashboard/settings/git/gitlab/edit-gitlab-provider.tsx b/apps/dokploy/components/dashboard/settings/git/gitlab/edit-gitlab-provider.tsx index 43c1740557..48571eb310 100644 --- a/apps/dokploy/components/dashboard/settings/git/gitlab/edit-gitlab-provider.tsx +++ b/apps/dokploy/components/dashboard/settings/git/gitlab/edit-gitlab-provider.tsx @@ -25,6 +25,7 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; import { api } from "@/utils/api"; const Schema = z.object({ @@ -39,6 +40,7 @@ const Schema = z.object({ .optional() .transform((v) => (v === "" ? undefined : v)), groupName: z.string().optional(), + enableAutoDeploy: z.boolean().default(true), }); type Schema = z.infer; @@ -67,6 +69,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => { name: "", gitlabUrl: "https://gitlab.com", gitlabInternalUrl: "", + enableAutoDeploy: true, }, resolver: zodResolver(Schema), }); @@ -79,6 +82,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => { name: gitlab?.gitProvider.name || "", gitlabUrl: gitlab?.gitlabUrl || "", gitlabInternalUrl: gitlab?.gitlabInternalUrl || "", + enableAutoDeploy: gitlab?.enableAutoDeploy ?? true, }); }, [form, isOpen]); @@ -90,6 +94,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => { name: data.name || "", gitlabUrl: data.gitlabUrl || "", gitlabInternalUrl: data.gitlabInternalUrl ?? null, + enableAutoDeploy: data.enableAutoDeploy, }) .then(async () => { await utils.gitProvider.getAll.invalidate(); @@ -201,6 +206,29 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => { )} /> + ( + +
+ Enable Automatic Deployments + + Automatically configure deploy webhooks when this + GitLab provider is used by an application or compose + service. + +
+ + + +
+ )} + /> +