Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions apps/dokploy/__test__/deployment/find-deployment-by-id.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
39 changes: 39 additions & 0 deletions apps/dokploy/__test__/helpers/postgres-function-args.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Original file line number Diff line number Diff line change
@@ -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(),
Expand Down Expand Up @@ -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"] },
);
});
});
41 changes: 41 additions & 0 deletions apps/dokploy/__test__/volume-backups/restore-security.test.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand Down Expand Up @@ -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({
Expand Down
7 changes: 5 additions & 2 deletions apps/dokploy/server/api/routers/volume-backups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions apps/dokploy/server/api/utils/monitoring-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
9 changes: 6 additions & 3 deletions packages/server/src/services/deployment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Loading