Skip to content
Closed
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
113 changes: 113 additions & 0 deletions apps/dokploy/__test__/api/mutation-schema-optionality.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import {
apiSaveBuildType,
apiSaveEnvironmentVariables,
} from "@dokploy/server/db/schema/application";
import { apiCreateRegistry, apiTestRegistry } from "@dokploy/server/db/schema/registry";

// Regression for https://github.com/Dokploy/dokploy/issues/4724:
// API mutations forced callers to pass fields unrelated to their use case
// (nullable in the DB) because the input schemas were built with bare
// `.required()`. The web UI never notices - it submits every form field - but
// API consumers hit BAD_REQUEST until they discover each field by trial.
//
// The masks are optional-with-default-null rather than merely optional:
// drizzle skips `undefined` on update, so an omitted field must still clear
// the stale column value, exactly like the UI's explicit null.

describe("mutation schema optionality (#4724)", () => {
it("saveBuildType accepts a dockerfile build without heroku/railpack fields", () => {
const parsed = apiSaveBuildType.parse({
applicationId: "app-1",
buildType: "dockerfile",
});
expect(parsed.buildType).toBe("dockerfile");
});

it("saveBuildType defaults omitted nullable fields to null so updates clear stale values", () => {
const parsed = apiSaveBuildType.parse({
applicationId: "app-1",
buildType: "dockerfile",
});
// Switching build type to dockerfile must clear a stale herokuVersion
// from a previous heroku_buildpacks configuration - drizzle writes the
// nulls, while `undefined` would silently keep the old values.
expect(parsed.herokuVersion).toBeNull();
expect(parsed.railpackVersion).toBeNull();
expect(parsed.dockerfile).toBeNull();
expect(parsed.dockerContextPath).toBeNull();
expect(parsed.dockerBuildStage).toBeNull();
});

it("saveBuildType still rejects a missing applicationId or buildType", () => {
expect(() => apiSaveBuildType.parse({ buildType: "dockerfile" })).toThrow();
expect(() => apiSaveBuildType.parse({ applicationId: "app-1" })).toThrow();
});

it("saveBuildType still accepts explicit nulls (web UI shape)", () => {
const parsed = apiSaveBuildType.parse({
applicationId: "app-1",
buildType: "heroku_buildpacks",
dockerfile: null,
dockerContextPath: null,
dockerBuildStage: null,
herokuVersion: "3.0",
railpackVersion: null,
publishDirectory: null,
isStaticSpa: null,
});
expect(parsed.herokuVersion).toBe("3.0");
});

it("saveEnvironment accepts env only, without buildArgs/buildSecrets/createEnvFile", () => {
const parsed = apiSaveEnvironmentVariables.parse({
applicationId: "app-1",
env: "FOO=bar",
});
expect(parsed.env).toBe("FOO=bar");
expect(parsed.buildArgs).toBeUndefined();
});

it("saveEnvironment rejects applicationId-only payloads (env is required)", () => {
// Without env, the handler forwards only undefined values and drizzle's
// mapUpdateSet throws "No values to set" - surfacing INTERNAL_SERVER_ERROR
// where a clean BAD_REQUEST belongs.
expect(() =>
apiSaveEnvironmentVariables.parse({ applicationId: "app-1" }),
).toThrow();
});

it("saveEnvironment still requires applicationId", () => {
expect(() =>
apiSaveEnvironmentVariables.parse({ env: "FOO=bar" }),
).toThrow();
});

it("registry.create defaults registryType to cloud", () => {
const parsed = apiCreateRegistry.parse({
registryName: "dockerhub",
username: "u",
password: "p",
registryUrl: "",
});
expect(parsed.registryType).toBe("cloud");
});

it("registry.create still requires the credential fields", () => {
expect(() =>
apiCreateRegistry.parse({
registryName: "dockerhub",
}),
).toThrow();
});

it("apiTestRegistry defaults registryType to cloud", () => {
const parsed = apiTestRegistry.parse({
registryName: "dockerhub",
username: "u",
password: "p",
registryUrl: "",
});
expect(parsed.registryType).toBe("cloud");
});
});
8 changes: 8 additions & 0 deletions apps/dokploy/__test__/deploy/railpack.command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,4 +110,12 @@ describe("getRailpackCommand", () => {
getSecretsHash(secondCommand),
);
});

it("falls back to the column default when railpackVersion is null", () => {
const command = getRailpackCommand(createApplication({ railpackVersion: null }));

expect(command).toContain("railpack-frontend:v0.15.4");
expect(command).not.toContain("vnull");
expect(command).not.toContain("RAILPACK_VERSION=null");
});
});
23 changes: 21 additions & 2 deletions packages/server/src/db/schema/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,7 +442,21 @@ export const apiSaveBuildType = createSchema
herokuVersion: true,
railpackVersion: true,
})
.required()
// Only the identity fields are required: herokuVersion/railpackVersion et al.
// apply to other build types, and forcing API callers to pass them as null is
// trial-and-error friction the web UI never sees (it submits every field).
// The nullable fields default to null rather than staying optional: Drizzle
// skips undefined on update, so optional-without-default would leave stale
// values (e.g. switching to dockerfile would keep the old herokuVersion);
// defaulting to null clears them exactly like the UI's explicit nulls.
.required({ applicationId: true, buildType: true })
.extend({
dockerfile: z.string().nullable().default(null),
dockerContextPath: z.string().nullable().default(null),
dockerBuildStage: z.string().nullable().default(null),
herokuVersion: z.string().nullable().default(null),
railpackVersion: z.string().nullable().default(null),
})
.merge(createSchema.pick({ publishDirectory: true, isStaticSpa: true }));

const branchField = z
Expand Down Expand Up @@ -540,7 +554,12 @@ export const apiSaveEnvironmentVariables = createSchema
buildSecrets: true,
createEnvFile: true,
})
.required();
// applicationId and env stay required: env is the point of the mutation,
// and an applicationId-only call would pass validation only to die in
// drizzle's mapUpdateSet ("No values to set") as an INTERNAL_SERVER_ERROR
// instead of a clean BAD_REQUEST. buildArgs/buildSecrets/createEnvFile
// stay optional as reported in #4724.
.required({ applicationId: true, env: true });

export const apiFindMonitoringStats = z.object({
appName: z.string().min(1),
Expand Down
12 changes: 7 additions & 5 deletions packages/server/src/db/schema/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,12 @@ export const apiCreateRegistry = createSchema
username: registryUsernameSchema,
password: z.string().min(1),
registryUrl: registryUrlSchema,
registryType: z.enum(["cloud"]),
imagePrefix: z.string().nullable().optional(),
// Matches the DB default: API callers should not have to pass the only
// accepted value explicitly.
registryType: z.enum(["cloud"]).default("cloud"),
imagePrefix: z.string().nullable().default(null),
})
.required()
.required({ registryName: true, username: true, password: true, registryUrl: true })
.extend({
serverId: z.string().optional(),
});
Expand All @@ -92,8 +94,8 @@ export const apiTestRegistry = createSchema.pick({}).extend({
username: registryUsernameSchema,
password: z.string().min(1),
registryUrl: registryUrlSchema,
registryType: z.enum(["cloud"]),
imagePrefix: z.string().nullable().optional(),
registryType: z.enum(["cloud"]).default("cloud"),
imagePrefix: z.string().nullable().default(null),
serverId: z.string().optional(),
});

Expand Down
4 changes: 2 additions & 2 deletions packages/server/src/utils/builders/railpack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export const getRailpackCommand = (application: ApplicationNested) => {
`secrets-hash=${secretsHash}`,
...(cacheKey ? ["--build-arg", `cache-key=${cacheKey}`] : []),
"--build-arg",
`BUILDKIT_SYNTAX=ghcr.io/railwayapp/railpack-frontend:v${application.railpackVersion}`,
`BUILDKIT_SYNTAX=ghcr.io/railwayapp/railpack-frontend:v${application.railpackVersion || "0.15.4"}`,
"-f",
`${buildAppDirectory}/railpack-plan.json`,
"--output",
Expand Down Expand Up @@ -86,7 +86,7 @@ export const getRailpackCommand = (application: ApplicationNested) => {

# Ensure we have a builder with containerd (isolated per build)

export RAILPACK_VERSION=${application.railpackVersion}
export RAILPACK_VERSION=${application.railpackVersion || "0.15.4"}
# use sudo for non-root so the install can write to /usr/local/bin
if [ "$(id -u)" -eq 0 ]; then
SUDO_CMD=""
Expand Down