From 44ebb05cbb468860b6d0ac0b2b90bf5c299708fa Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:24:48 -0400 Subject: [PATCH] fix: keep nested application relations under the postgres argument limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Что: - findDeploymentById берёт из трёх вложенных связей application (у самого деплоя, у schedule и у volumeBackup) только serverId: другие поля этих связей вызывающий код не читает. - Проверка восстановления volume backup (assertVolumeRestoreObjectBound) берёт из связей application и compose только appName. - Поиск preview-деплоя в monitoring-access берёт из связи application только applicationId и serverId. - Новый тестовый хелпер __test__/helpers/postgres-function-args.ts собирает настоящий SQL через drizzle.mock с реальной схемой и считает аргументы json_build_array. Для каждого из трёх запросов добавлен тест, который проверяет, что аргументов не больше 100. Зачем: - В таблице application 101 колонка. Drizzle кладёт все выбранные колонки вложенной связи в один вызов json_build_array, а Postgres принимает не больше 100 аргументов и отвечает ошибкой 54023 "cannot pass more than 100 arguments to a function". - Из-за findDeploymentById не работали WebSocket логов деплоя (закрывался молча), deployment.readLogs, killProcess и removeDeployment, откат через rollback и создание rollback при деплое с rollback registry. Удаление старых rollback молча пропускалось. - Из-за проверки восстановления не запускалось восстановление volume backup. - Поиск preview-деплоя выполняется для каждого имени, которое не нашлось среди application. Поэтому мониторинг compose, баз данных и preview-деплоев (WebSocket docker stats, user.getContainerMetrics) отвечал ошибкой. - Upstream уже сужал связи в findDeploymentById (97885ee44), но при слиянии upstream 5a1d8eac3 (2026-10-01) конфликт разрешился в пользу application: true. Риски: - Если код начнёт читать из этих связей другие поля, их там не будет. Typecheck это ловит: при выборе appName вместо serverId tsc падает в listen-deployment.ts. - backup.listBackupFiles в AgentHits-Dev падает с той же ошибкой. Его чинит открытый PR #6, здесь он не менялся. В тесте PR #6 своя копия подсчёта аргументов, после слияния её можно заменить общим хелпером. Проверки: - Команды и результаты: CI=true bunx vitest run --config __test__/vitest.config.ts __test__/deployment/find-deployment-by-id.test.ts __test__/volume-backups/restore-security.test.ts __test__/server/container-resource-stats-access.test.ts - 28 тестов прошли; на старом коде три новых теста падают с "expected 101 to be less than or equal to 100" (для monitoring - 102). Полный прогон CI=true bunx vitest run --config __test__/vitest.config.ts: 2055 тестов прошли, 1 пропущен, 8 упали только в __test__/deploy/application.real.test.ts и __test__/setup/monitoring-setup.real.test.ts из-за отсутствия сокета Docker Desktop (connect ECONNREFUSED /Users/agent/.docker/run/docker.sock). bun run typecheck - 4/4 пакета без ошибок. bun run format-and-lint - 0 ошибок (33 предупреждения в других файлах, в изменённых файлах их нет). Временный vitest-аудит через toSQL (удалён перед коммитом) проверил остальные вложенные связи с application из grep: у них не больше 50 аргументов. - Ограничения: запросы не выполнялись против живой базы; *.real.test.ts без локального Docker не проверены; ничего не пушилось и не выкатывалось. What: - findDeploymentById loads only serverId from its three nested application relations (on the deployment itself, on schedule and on volumeBackup); callers read no other field from them. - The volume backup restore check (assertVolumeRestoreObjectBound) loads only appName from the application and compose relations. - The preview deployment lookup in monitoring-access loads only applicationId and serverId from the application relation. - A new test helper, __test__/helpers/postgres-function-args.ts, compiles the real SQL with drizzle.mock and the real schema and counts json_build_array arguments. Each of the three queries gets a test that checks there are no more than 100. Why: - The application table has 101 columns. Drizzle puts every selected column of a nested relation into one json_build_array call, and Postgres accepts at most 100 arguments and fails with 54023 "cannot pass more than 100 arguments to a function". - Because of findDeploymentById, the deployment log WebSocket (closed silently), deployment.readLogs, killProcess and removeDeployment, rolling back, and rollback creation during deploys with a rollback registry all failed. Removing old rollbacks was silently skipped. - Because of the restore check, a volume backup restore could not start. - The preview deployment lookup runs for every name that is not found among applications. Monitoring for compose services, databases and preview deployments (the docker stats WebSocket, user.getContainerMetrics) therefore failed. - Upstream had already narrowed the relations in findDeploymentById (97885ee44), but the upstream merge 5a1d8eac3 (2026-10-01) resolved the conflict in favour of application: true. Risks: - Code that starts reading other fields from these relations will not find them there. Typecheck catches this: selecting appName instead of serverId makes tsc fail in listen-deployment.ts. - backup.listBackupFiles fails with the same error in AgentHits-Dev. Open PR #6 fixes it, so it is unchanged here. The PR #6 test has its own copy of the argument counter, which can switch to the shared helper after both are merged. Checks: - Commands and results: CI=true bunx vitest run --config __test__/vitest.config.ts __test__/deployment/find-deployment-by-id.test.ts __test__/volume-backups/restore-security.test.ts __test__/server/container-resource-stats-access.test.ts - 28 tests passed; on the old code the three new tests fail with "expected 101 to be less than or equal to 100" (102 for monitoring). Full run CI=true bunx vitest run --config __test__/vitest.config.ts: 2055 passed, 1 skipped, 8 failed, all in __test__/deploy/application.real.test.ts and __test__/setup/monitoring-setup.real.test.ts because the Docker Desktop socket is missing (connect ECONNREFUSED /Users/agent/.docker/run/docker.sock). bun run typecheck - 4/4 packages clean. bun run format-and-lint - 0 errors (33 warnings in other files, none in the changed files). A temporary vitest audit with toSQL (removed before the commit) checked the other nested application relations found by grep: they peak at 50 arguments. - Limitations: the queries were not run against a live database; the *.real.test.ts tests were not verified without a local Docker; nothing was pushed or deployed. Co-Authored-By: Claude Opus 5.5 --- .../deployment/find-deployment-by-id.test.ts | 41 ++++++++++++++ .../helpers/postgres-function-args.ts | 39 +++++++++++++ .../container-resource-stats-access.test.ts | 56 +++++++++++++++++++ .../volume-backups/restore-security.test.ts | 41 ++++++++++++++ .../server/api/routers/volume-backups.ts | 7 ++- .../server/api/utils/monitoring-access.ts | 4 ++ packages/server/src/services/deployment.ts | 9 ++- 7 files changed, 192 insertions(+), 5 deletions(-) create mode 100644 apps/dokploy/__test__/deployment/find-deployment-by-id.test.ts create mode 100644 apps/dokploy/__test__/helpers/postgres-function-args.ts 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,