diff --git a/apps/dokploy/__test__/deployment/find-deployment-by-id.test.ts b/apps/dokploy/__test__/deployment/find-deployment-by-id.test.ts new file mode 100644 index 00000000000..e52a8abb0d3 --- /dev/null +++ b/apps/dokploy/__test__/deployment/find-deployment-by-id.test.ts @@ -0,0 +1,41 @@ +import { findDeploymentById } from "@dokploy/server/services/deployment"; +import { describe, expect, it, vi } from "vitest"; +import { + maxJsonBuildArrayArgs, + POSTGRES_MAX_FUNCTION_ARGS, + relationalQueryDb, +} from "../helpers/postgres-function-args"; + +const mocks = vi.hoisted(() => ({ + findDeployment: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { deployments: { findFirst: mocks.findDeployment } }, + }, +})); + +describe("findDeploymentById", () => { + it("keeps the deployment lookup within the Postgres function argument limit", async () => { + let compiledSql = ""; + const deployment = { + deploymentId: "deployment-1", + application: { serverId: "server-1" }, + }; + mocks.findDeployment.mockImplementation(async (config) => { + compiledSql = relationalQueryDb.query.deployments + .findFirst(config) + .toSQL().sql; + return deployment; + }); + + await expect(findDeploymentById("deployment-1")).resolves.toBe(deployment); + + expect(mocks.findDeployment).toHaveBeenCalledTimes(1); + expect(compiledSql).toContain("json_build_array("); + expect(maxJsonBuildArrayArgs(compiledSql)).toBeLessThanOrEqual( + POSTGRES_MAX_FUNCTION_ARGS, + ); + }); +}); diff --git a/apps/dokploy/__test__/helpers/postgres-function-args.ts b/apps/dokploy/__test__/helpers/postgres-function-args.ts new file mode 100644 index 00000000000..db8845f64e7 --- /dev/null +++ b/apps/dokploy/__test__/helpers/postgres-function-args.ts @@ -0,0 +1,39 @@ +import * as schema from "@dokploy/server/db/schema"; +import { drizzle } from "drizzle-orm/postgres-js"; + +export const POSTGRES_MAX_FUNCTION_ARGS = 100; + +export const relationalQueryDb = drizzle.mock({ schema }); + +export const maxJsonBuildArrayArgs = (sqlText: string) => { + let max = 0; + for (const match of sqlText.matchAll(/json_build_array\(/g)) { + let depth = 1; + let args = 1; + let quote: string | null = null; + for ( + let index = match.index + match[0].length; + index < sqlText.length && depth > 0; + index++ + ) { + const char = sqlText[index]; + if (quote) { + if (char === quote) { + quote = null; + } + continue; + } + if (char === '"' || char === "'") { + quote = char; + } else if (char === "(") { + depth++; + } else if (char === ")") { + depth--; + } else if (char === "," && depth === 1) { + args++; + } + } + max = Math.max(max, args); + } + return max; +}; diff --git a/apps/dokploy/__test__/server/container-resource-stats-access.test.ts b/apps/dokploy/__test__/server/container-resource-stats-access.test.ts index 8eccf49c6e4..4e7c5bf26e8 100644 --- a/apps/dokploy/__test__/server/container-resource-stats-access.test.ts +++ b/apps/dokploy/__test__/server/container-resource-stats-access.test.ts @@ -1,4 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + maxJsonBuildArrayArgs, + POSTGRES_MAX_FUNCTION_ARGS, + relationalQueryDb, +} from "../helpers/postgres-function-args"; const mocks = vi.hoisted(() => ({ checkServicePermissionAndAccess: vi.fn(), @@ -302,4 +307,55 @@ describe("container resource stats access filtering", () => { expect.anything(), ); }); + + it("keeps the preview deployment lookup within the Postgres function argument limit", async () => { + const compiledQueries: string[] = []; + mocks.db.query.previewDeployments.findFirst.mockImplementation( + async (query) => { + // eq() is mocked in this file, and the filter does not change which + // columns the relations select. + compiledQueries.push( + relationalQueryDb.query.previewDeployments + .findFirst({ ...query, where: undefined }) + .toSQL().sql, + ); + return lookupAppName(query) === "preview-service" + ? { + appName: "preview-service", + application: { + applicationId: "application-allowed", + serverId: null, + environment: environment("org-1"), + }, + } + : null; + }, + ); + const stats = [ + { + ID: "preview", + Labels: { + "com.docker.swarm.service.name": "preview-service", + }, + Name: "preview-service.1.task", + }, + ]; + + await expect( + filterContainerResourceStatsByAccess(ctx, stats), + ).resolves.toEqual(stats); + + expect(compiledQueries.length).toBeGreaterThan(0); + for (const sqlText of compiledQueries) { + expect(sqlText).toContain("json_build_array("); + expect(maxJsonBuildArrayArgs(sqlText)).toBeLessThanOrEqual( + POSTGRES_MAX_FUNCTION_ARGS, + ); + } + expect(mocks.checkServicePermissionAndAccess).toHaveBeenCalledWith( + ctx, + "application-allowed", + { monitoring: ["read"] }, + ); + }); }); diff --git a/apps/dokploy/__test__/volume-backups/restore-security.test.ts b/apps/dokploy/__test__/volume-backups/restore-security.test.ts index 79407a88d40..74ae95b7125 100644 --- a/apps/dokploy/__test__/volume-backups/restore-security.test.ts +++ b/apps/dokploy/__test__/volume-backups/restore-security.test.ts @@ -1,5 +1,10 @@ import { TRPCError } from "@trpc/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + maxJsonBuildArrayArgs, + POSTGRES_MAX_FUNCTION_ARGS, + relationalQueryDb, +} from "../helpers/postgres-function-args"; const mocks = vi.hoisted(() => ({ audit: vi.fn(), @@ -750,6 +755,42 @@ describe("volume backup restore access boundary", () => { expect(mocks.execAsyncStream).not.toHaveBeenCalled(); }); + it("keeps the restore schedule lookup within the Postgres function argument limit", async () => { + let compiledSql = ""; + mocks.findVolumeBackups.mockImplementation(async (config) => { + compiledSql = relationalQueryDb.query.volumeBackups + .findMany(config) + .toSQL().sql; + return [ + { + application: { appName: "app-one" }, + applicationId: "app-1", + destinationId: "destination-1", + prefix: "prefix", + serviceType: "application", + volumeName: "data_volume", + }, + ]; + }); + + await expect( + runVolumeRestoreSubscription({ + backupFileName: "app-one/prefix/data_volume-2026-06-22.tar", + destinationId: "destination-1", + volumeName: "data_volume", + id: "app-1", + serviceType: "application", + }), + ).resolves.toBe(undefined); + + expect(mocks.findVolumeBackups).toHaveBeenCalledTimes(1); + expect(compiledSql).toContain("json_build_array("); + expect(maxJsonBuildArrayArgs(compiledSql)).toBeLessThanOrEqual( + POSTGRES_MAX_FUNCTION_ARGS, + ); + expect(mocks.restoreVolume).toHaveBeenCalledTimes(1); + }); + it("allows restore objects bound to the matching volume-backup schedule", async () => { await expect( runVolumeRestoreSubscription({ diff --git a/apps/dokploy/server/api/routers/volume-backups.ts b/apps/dokploy/server/api/routers/volume-backups.ts index edff95cda73..56366986dd1 100644 --- a/apps/dokploy/server/api/routers/volume-backups.ts +++ b/apps/dokploy/server/api/routers/volume-backups.ts @@ -242,6 +242,9 @@ const assertVolumeRestoreObjectBound = async (input: { input.serviceType === "application" ? volumeBackups.applicationId : volumeBackups.composeId; + // The application table has more than 100 columns. Drizzle puts every + // selected column of a nested relation into one json_build_array() call, + // and Postgres rejects calls with more than 100 arguments (54023). const candidateBackups = await db.query.volumeBackups.findMany({ where: and( eq(volumeBackups.destinationId, input.destinationId), @@ -250,8 +253,8 @@ const assertVolumeRestoreObjectBound = async (input: { eq(serviceIdColumn, input.id), ), with: { - application: true, - compose: true, + application: { columns: { appName: true } }, + compose: { columns: { appName: true } }, destination: { columns: { accessKey: false, diff --git a/apps/dokploy/server/api/utils/monitoring-access.ts b/apps/dokploy/server/api/utils/monitoring-access.ts index e04d21018c3..a25b87c5df7 100644 --- a/apps/dokploy/server/api/utils/monitoring-access.ts +++ b/apps/dokploy/server/api/utils/monitoring-access.ts @@ -55,10 +55,14 @@ const findMonitoringServiceByAppName = async (appName: string) => { } satisfies MonitoringService; } + // The application table has more than 100 columns. Drizzle puts every + // selected column of a nested relation into one json_build_array() call, + // and Postgres rejects calls with more than 100 arguments (54023). const previewDeployment = await db.query.previewDeployments.findFirst({ where: eq(previewDeployments.appName, appName), with: { application: { + columns: { applicationId: true, serverId: true }, with: { environment: { with: { diff --git a/packages/server/src/services/deployment.ts b/packages/server/src/services/deployment.ts index d155f2f91fd..2a8e9d17983 100644 --- a/packages/server/src/services/deployment.ts +++ b/packages/server/src/services/deployment.ts @@ -321,10 +321,13 @@ export const finalizeDeploymentOperation = async ( }; export const findDeploymentById = async (deploymentId: string) => { + // The application table has more than 100 columns. Drizzle puts every + // selected column of a nested relation into one json_build_array() call, + // and Postgres rejects calls with more than 100 arguments (54023). const deployment = await db.query.deployments.findFirst({ where: eq(deployments.deploymentId, deploymentId), with: { - application: true, + application: { columns: { serverId: true } }, backup: { with: { compose: true, @@ -339,13 +342,13 @@ export const findDeploymentById = async (deploymentId: string) => { previewDeployment: true, schedule: { with: { - application: true, + application: { columns: { serverId: true } }, compose: true, }, }, volumeBackup: { with: { - application: true, + application: { columns: { serverId: true } }, compose: true, libsql: true, mariadb: true,