mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-09 16:05:23 +02:00
Merge pull request #4778 from Dokploy/feat/oss-concurrent-builds
feat: make concurrent builds an OSS feature
This commit is contained in:
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={max}
|
||||
max={MAX_CONCURRENCY}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
className="w-20"
|
||||
|
||||
@@ -29,8 +29,6 @@ const Page = () => {
|
||||
<CardDescription>
|
||||
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.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-6">
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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<number> => {
|
||||
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<void> => {
|
||||
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<number> => {
|
||||
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<number> => {
|
||||
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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user