diff --git a/apps/dokploy/__test__/api/mutation-schema-optionality.test.ts b/apps/dokploy/__test__/api/mutation-schema-optionality.test.ts new file mode 100644 index 00000000000..9b3f2cd0ec9 --- /dev/null +++ b/apps/dokploy/__test__/api/mutation-schema-optionality.test.ts @@ -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"); + }); +}); diff --git a/apps/dokploy/__test__/deploy/railpack.command.test.ts b/apps/dokploy/__test__/deploy/railpack.command.test.ts index 13345ac0fe9..eaa1bf3edda 100644 --- a/apps/dokploy/__test__/deploy/railpack.command.test.ts +++ b/apps/dokploy/__test__/deploy/railpack.command.test.ts @@ -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"); + }); }); diff --git a/packages/server/src/db/schema/application.ts b/packages/server/src/db/schema/application.ts index def6c344544..995fbd455ec 100644 --- a/packages/server/src/db/schema/application.ts +++ b/packages/server/src/db/schema/application.ts @@ -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 @@ -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), diff --git a/packages/server/src/db/schema/registry.ts b/packages/server/src/db/schema/registry.ts index ff1c6e330b8..c1881009127 100644 --- a/packages/server/src/db/schema/registry.ts +++ b/packages/server/src/db/schema/registry.ts @@ -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(), }); @@ -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(), }); diff --git a/packages/server/src/utils/builders/railpack.ts b/packages/server/src/utils/builders/railpack.ts index 49585bbe2d8..1de2a4c7bbc 100644 --- a/packages/server/src/utils/builders/railpack.ts +++ b/packages/server/src/utils/builders/railpack.ts @@ -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", @@ -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=""