From 8d0ae19b58142e9ec307d15f15babf3068f707b1 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Thu, 9 Jul 2026 02:24:08 -0600 Subject: [PATCH] feat: make concurrent builds an OSS feature without license gating --- .../__test__/queues/concurrency.test.ts | 91 +++---------------- .../servers/actions/builds-concurrency.tsx | 17 +--- .../pages/dashboard/settings/deployments.tsx | 2 - apps/dokploy/server/api/routers/server.ts | 5 - apps/dokploy/server/api/routers/settings.ts | 6 -- apps/dokploy/server/queues/concurrency.ts | 77 +++------------- apps/dokploy/server/queues/queueSetup.ts | 4 +- .../src/db/schema/web-server-settings.ts | 2 +- 8 files changed, 31 insertions(+), 173 deletions(-) diff --git a/apps/dokploy/__test__/queues/concurrency.test.ts b/apps/dokploy/__test__/queues/concurrency.test.ts index c4e9d319f..210789da1 100644 --- a/apps/dokploy/__test__/queues/concurrency.test.ts +++ b/apps/dokploy/__test__/queues/concurrency.test.ts @@ -1,16 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const hasValidLicense = vi.fn(); const getWebServerSettings = vi.fn(); -const findFirstOrg = vi.fn(); const findFirstServer = vi.fn(); vi.mock("@dokploy/server/db", () => ({ db: { query: { - organization: { - findFirst: (...args: unknown[]) => findFirstOrg(...args), - }, server: { findFirst: (...args: unknown[]) => findFirstServer(...args), }, @@ -19,91 +14,56 @@ vi.mock("@dokploy/server/db", () => ({ })); vi.mock("@dokploy/server/db/schema", () => ({ - organization: {}, server: {}, })); -vi.mock("@dokploy/server/services/proprietary/license-key", () => ({ - hasValidLicense: (...args: unknown[]) => hasValidLicense(...args), -})); - vi.mock("@dokploy/server/services/web-server-settings", () => ({ getWebServerSettings: (...args: unknown[]) => getWebServerSettings(...args), })); vi.mock("drizzle-orm", () => ({ eq: vi.fn() })); -import { - assertBuildsConcurrencyAllowed, - resolveBuildsConcurrency, -} from "../../server/queues/concurrency"; +import { resolveBuildsConcurrency } from "../../server/queues/concurrency"; import { LOCAL_PARTITION } from "../../server/queues/in-memory-queue"; -describe("resolveBuildsConcurrency (enterprise gating)", () => { +describe("resolveBuildsConcurrency", () => { beforeEach(() => { vi.clearAllMocks(); - findFirstOrg.mockResolvedValue({ id: "org-1" }); }); describe("local web server partition", () => { - it("returns the configured concurrency when licensed", async () => { + it("returns the configured concurrency", async () => { getWebServerSettings.mockResolvedValue({ buildsConcurrency: 5 }); - hasValidLicense.mockResolvedValue(true); await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(5); }); - it("clamps to the free max (2) when there is no valid license", async () => { - getWebServerSettings.mockResolvedValue({ buildsConcurrency: 10 }); - hasValidLicense.mockResolvedValue(false); - - await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(2); - }); - - it("allows the free max (2) without a license", async () => { - getWebServerSettings.mockResolvedValue({ buildsConcurrency: 2 }); - hasValidLicense.mockResolvedValue(false); - - await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(2); - }); - - it("does not cap the value when licensed (N allowed)", async () => { + it("does not cap high values", async () => { getWebServerSettings.mockResolvedValue({ buildsConcurrency: 999 }); - hasValidLicense.mockResolvedValue(true); await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe( 999, ); }); + it("floors values below 1 to 1", async () => { + getWebServerSettings.mockResolvedValue({ buildsConcurrency: 0 }); + + await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1); + }); + it("defaults to 1 when settings are missing", async () => { getWebServerSettings.mockResolvedValue(undefined); - hasValidLicense.mockResolvedValue(true); await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1); }); }); describe("remote server partition", () => { - it("returns the server concurrency when its org is licensed", async () => { - findFirstServer.mockResolvedValue({ - buildsConcurrency: 4, - organizationId: "org-1", - }); - hasValidLicense.mockResolvedValue(true); + it("returns the server concurrency", async () => { + findFirstServer.mockResolvedValue({ buildsConcurrency: 4 }); await expect(resolveBuildsConcurrency("server-1")).resolves.toBe(4); - expect(hasValidLicense).toHaveBeenCalledWith("org-1"); - }); - - it("clamps to the free max (2) when the server org is not licensed", async () => { - findFirstServer.mockResolvedValue({ - buildsConcurrency: 8, - organizationId: "org-1", - }); - hasValidLicense.mockResolvedValue(false); - - await expect(resolveBuildsConcurrency("server-1")).resolves.toBe(2); }); it("defaults to 1 for an unknown server", async () => { @@ -119,30 +79,3 @@ describe("resolveBuildsConcurrency (enterprise gating)", () => { await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1); }); }); - -describe("assertBuildsConcurrencyAllowed", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("allows up to the free max (2) without checking the license", async () => { - await expect( - assertBuildsConcurrencyAllowed(2, "org-1"), - ).resolves.toBeUndefined(); - expect(hasValidLicense).not.toHaveBeenCalled(); - }); - - it("allows more than 2 when licensed", async () => { - hasValidLicense.mockResolvedValue(true); - await expect( - assertBuildsConcurrencyAllowed(5, "org-1"), - ).resolves.toBeUndefined(); - }); - - it("rejects more than 2 without a license", async () => { - hasValidLicense.mockResolvedValue(false); - await expect(assertBuildsConcurrencyAllowed(3, "org-1")).rejects.toThrow( - /enterprise license/i, - ); - }); -}); diff --git a/apps/dokploy/components/dashboard/settings/servers/actions/builds-concurrency.tsx b/apps/dokploy/components/dashboard/settings/servers/actions/builds-concurrency.tsx index d53709ea1..85c5dad02 100644 --- a/apps/dokploy/components/dashboard/settings/servers/actions/builds-concurrency.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/actions/builds-concurrency.tsx @@ -4,9 +4,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { api } from "@/utils/api"; -// Free tier may set up to 2 concurrent builds; enterprise unlocks more. -const FREE_MAX_CONCURRENCY = 2; -const ENTERPRISE_MAX_CONCURRENCY = 100; +const MAX_CONCURRENCY = 100; interface Props { /** @@ -20,14 +18,10 @@ interface Props { /** * Control to set the number of concurrent builds, either for a remote server - * (`serverId` provided) or the local web server (omitted). Available to - * everyone self-hosted up to FREE_MAX_CONCURRENCY; higher values require a - * valid enterprise license. Not shown in cloud. + * (`serverId` provided) or the local web server (omitted). Not shown in cloud. */ export const BuildsConcurrency = ({ serverId, label }: Props) => { const { data: isCloud } = api.settings.isCloud.useQuery(); - const { data: haveValidLicense } = - api.licenseKey.haveValidLicenseKey.useQuery(); const serverQuery = api.server.one.useQuery( { serverId: serverId ?? "" }, @@ -59,10 +53,7 @@ export const BuildsConcurrency = ({ serverId, label }: Props) => { // Concurrent builds are a self-hosted feature; not shown in cloud. if (isCloud) return null; - const max = haveValidLicense - ? ENTERPRISE_MAX_CONCURRENCY - : FREE_MAX_CONCURRENCY; - const clamp = (n: number) => Math.min(max, Math.max(1, n)); + const clamp = (n: number) => Math.min(MAX_CONCURRENCY, Math.max(1, n)); const handleSave = async () => { const parsed = clamp(Number.parseInt(value, 10) || 1); @@ -101,7 +92,7 @@ export const BuildsConcurrency = ({ serverId, label }: Props) => { setValue(e.target.value)} className="w-20" diff --git a/apps/dokploy/pages/dashboard/settings/deployments.tsx b/apps/dokploy/pages/dashboard/settings/deployments.tsx index 2c40a1b8f..6cc0fc1da 100644 --- a/apps/dokploy/pages/dashboard/settings/deployments.tsx +++ b/apps/dokploy/pages/dashboard/settings/deployments.tsx @@ -29,8 +29,6 @@ const Page = () => { Configure how many deployments can build at the same time on each server. Builds of the same service are always serialized. - Free plan allows up to 2 concurrent builds; an enterprise - license unlocks more. diff --git a/apps/dokploy/server/api/routers/server.ts b/apps/dokploy/server/api/routers/server.ts index 80e3b2f75..0233173fa 100644 --- a/apps/dokploy/server/api/routers/server.ts +++ b/apps/dokploy/server/api/routers/server.ts @@ -46,7 +46,6 @@ import { redis, server, } from "@/server/db/schema"; -import { assertBuildsConcurrencyAllowed } from "@/server/queues/concurrency"; import { applyDockerCleanupSchedule } from "@/server/utils/docker-cleanup"; export const serverRouter = createTRPCRouter({ @@ -491,10 +490,6 @@ export const serverRouter = createTRPCRouter({ message: "You are not authorized to update this server", }); } - await assertBuildsConcurrencyAllowed( - input.buildsConcurrency, - ctx.session.activeOrganizationId, - ); return await updateServerById(input.serverId, { buildsConcurrency: input.buildsConcurrency, }); diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts index 9f8bc1fe7..fbc41c459 100644 --- a/apps/dokploy/server/api/routers/settings.ts +++ b/apps/dokploy/server/api/routers/settings.ts @@ -71,7 +71,6 @@ import { projects, server, } from "@/server/db/schema"; -import { assertBuildsConcurrencyAllowed } from "@/server/queues/concurrency"; import { cleanAllDeploymentQueue } from "@/server/queues/queueSetup"; import { removeJob, schedule } from "@/server/utils/backup"; import packageInfo from "../../../package.json"; @@ -480,11 +479,6 @@ export const settingsRouter = createTRPCRouter({ }); } - await assertBuildsConcurrencyAllowed( - input.buildsConcurrency, - ctx.session.activeOrganizationId, - ); - await updateWebServerSettings({ buildsConcurrency: input.buildsConcurrency, }); diff --git a/apps/dokploy/server/queues/concurrency.ts b/apps/dokploy/server/queues/concurrency.ts index 72c8c6003..0aba01c7e 100644 --- a/apps/dokploy/server/queues/concurrency.ts +++ b/apps/dokploy/server/queues/concurrency.ts @@ -1,32 +1,30 @@ import { db } from "@dokploy/server/db"; -import { organization, server } from "@dokploy/server/db/schema"; -import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key"; +import { server } from "@dokploy/server/db/schema"; import { getWebServerSettings } from "@dokploy/server/services/web-server-settings"; -import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; import { LOCAL_PARTITION } from "./in-memory-queue"; /** * Resolve the effective builds concurrency for a queue partition. * - * Concurrent deployments (concurrency > 1) are an enterprise feature: without a - * valid license the effective concurrency is always clamped to 1, so the - * community experience is unchanged and an expired license degrades gracefully - * back to sequential deployments instead of breaking anything. - * * - `LOCAL_PARTITION` -> concurrency stored on the web server settings (the - * local Dokploy web server), gated by the owner organization's license. - * - any other partition -> concurrency stored on the matching `server` row, - * gated by that server's organization license. + * local Dokploy web server). + * - any other partition -> concurrency stored on the matching `server` row. */ export const resolveBuildsConcurrency = async ( partition: string, ): Promise => { try { if (partition === LOCAL_PARTITION) { - return await resolveLocalConcurrency(); + const settings = await getWebServerSettings(); + return normalize(settings?.buildsConcurrency ?? 1); } - return await resolveServerConcurrency(partition); + + const currentServer = await db.query.server.findFirst({ + where: eq(server.serverId, partition), + columns: { buildsConcurrency: true }, + }); + return normalize(currentServer?.buildsConcurrency ?? 1); } catch (error) { console.error( "Failed to resolve builds concurrency, defaulting to 1", @@ -36,55 +34,4 @@ export const resolveBuildsConcurrency = async ( } }; -// Max concurrent builds allowed without an enterprise license. With a valid -// license the value is unbounded (N) — only the free tier is capped. -export const FREE_MAX_CONCURRENCY = 2; - -const clamp = (value: number, licensed: boolean): number => { - const min = Math.max(1, Math.floor(value)); - return licensed ? min : Math.min(FREE_MAX_CONCURRENCY, min); -}; - -/** - * Validate a requested builds-concurrency value before persisting it. Free tier - * may set up to FREE_MAX_CONCURRENCY; anything higher requires a valid - * enterprise license. Throws a TRPCError when the value is not allowed. - */ -export const assertBuildsConcurrencyAllowed = async ( - value: number, - organizationId: string, -): Promise => { - if (value <= FREE_MAX_CONCURRENCY) return; - const licensed = await hasValidLicense(organizationId); - if (!licensed) { - throw new TRPCError({ - code: "FORBIDDEN", - message: `A valid enterprise license is required to set more than ${FREE_MAX_CONCURRENCY} concurrent builds.`, - }); - } -}; - -const resolveLocalConcurrency = async (): Promise => { - const settings = await getWebServerSettings(); - const buildsConcurrency = settings?.buildsConcurrency ?? 1; - - // Self-hosted is single-tenant; gate on any organization's license. - const anyOrg = await db.query.organization.findFirst({ - columns: { id: true }, - }); - const licensed = anyOrg ? await hasValidLicense(anyOrg.id) : false; - - return clamp(buildsConcurrency, licensed); -}; - -const resolveServerConcurrency = async (serverId: string): Promise => { - const currentServer = await db.query.server.findFirst({ - where: eq(server.serverId, serverId), - columns: { buildsConcurrency: true, organizationId: true }, - }); - - if (!currentServer) return 1; - - const licensed = await hasValidLicense(currentServer.organizationId); - return clamp(currentServer.buildsConcurrency, licensed); -}; +const normalize = (value: number): number => Math.max(1, Math.floor(value)); diff --git a/apps/dokploy/server/queues/queueSetup.ts b/apps/dokploy/server/queues/queueSetup.ts index 0749c364a..b104ef0d0 100644 --- a/apps/dokploy/server/queues/queueSetup.ts +++ b/apps/dokploy/server/queues/queueSetup.ts @@ -12,8 +12,8 @@ import type { DeploymentJob } from "./queue-types"; * Deployment queue. * * Self-hosted uses an in-memory, per-group FIFO queue with configurable - * concurrency per server (enterprise-gated). Cloud does not use the queue at - * all — deployments run directly in the background — so we expose a no-op. + * concurrency per server. Cloud does not use the queue at all — deployments + * run directly in the background — so we expose a no-op. */ interface DeploymentQueue { diff --git a/packages/server/src/db/schema/web-server-settings.ts b/packages/server/src/db/schema/web-server-settings.ts index e9745b533..c5e84d7b7 100644 --- a/packages/server/src/db/schema/web-server-settings.ts +++ b/packages/server/src/db/schema/web-server-settings.ts @@ -105,7 +105,7 @@ export const webServerSettings = pgTable("webServerSettings", { }), // Deployment Configuration (self-hosted only) remoteServersOnly: boolean("remoteServersOnly").notNull().default(false), - // Concurrent builds on the local web server (enterprise-gated to > 1) + // Concurrent builds on the local web server buildsConcurrency: integer("buildsConcurrency").notNull().default(1), // Auth Configuration (self-hosted only) enforceSSO: boolean("enforceSSO").notNull().default(false),