diff --git a/apps/dokploy/__test__/deploy/should-deploy.test.ts b/apps/dokploy/__test__/deploy/should-deploy.test.ts index 8b0da4428f2..a9092aad287 100644 --- a/apps/dokploy/__test__/deploy/should-deploy.test.ts +++ b/apps/dokploy/__test__/deploy/should-deploy.test.ts @@ -1,5 +1,10 @@ import { shouldDeploy } from "@dokploy/server"; -import { describe, expect, it } from "vitest"; +import { + apiSaveGithubProvider, + apiSaveGitProvider, + apiUpdateCompose, +} from "@dokploy/server/db/schema"; +import { describe, expect, it, vi } from "vitest"; describe("shouldDeploy", () => { it("should deploy when no watch paths are configured", () => { @@ -63,3 +68,108 @@ describe("shouldDeploy", () => { expect(shouldDeploy(["src/**"], [undefined, undefined] as any)).toBe(false); }); }); + +describe("shouldDeploy limits", () => { + const nested = (depth: number) => `${"{".repeat(depth)}a${"}".repeat(depth)}`; + + it("does not parse an oversized pattern and deploys", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const started = performance.now(); + + expect(shouldDeploy([nested(20000)], ["docs/readme.md"])).toBe(true); + expect(performance.now() - started).toBeLessThan(200); + expect(warn).toHaveBeenCalledTimes(1); + warn.mockRestore(); + }); + + it("deploys when there are more watch paths than allowed", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const paths = Array.from({ length: 65 }, (_, i) => `dir${i}/**`); + + expect(shouldDeploy(paths, ["docs/readme.md"])).toBe(true); + warn.mockRestore(); + }); + + it("still evaluates a pattern of exactly the largest allowed size", () => { + const longest = "a".repeat(256); + + expect(shouldDeploy([longest], ["docs/readme.md"])).toBe(false); + expect(shouldDeploy([longest], [longest])).toBe(true); + }); + + it("deploys instead of throwing when the matcher rejects a pattern", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + expect(shouldDeploy(["src/**", ""], ["docs/readme.md"])).toBe(true); + expect(warn).toHaveBeenCalledTimes(1); + warn.mockRestore(); + }); +}); + +describe("watch paths in the API schemas", () => { + const github = { + repository: "repo", + branch: "main", + owner: "owner", + buildPath: "/", + githubId: "gh", + triggerType: "push" as const, + enableSubmodules: false, + }; + + it("accept ordinary globs", () => { + const result = apiSaveGithubProvider.safeParse({ + ...github, + applicationId: "app", + watchPaths: ["src/**", "!src/**/*.test.ts", "{apps,packages}/*/src/**"], + }); + + expect(result.success).toBe(true); + }); + + it("refuse a pattern over the size limit", () => { + const result = apiSaveGithubProvider.safeParse({ + ...github, + applicationId: "app", + watchPaths: ["src/**", "x".repeat(257)], + }); + + expect(result.success).toBe(false); + expect(result.error?.issues[0]?.path[0]).toBe("watchPaths"); + expect(result.error?.issues[0]?.message).toContain("at most 256"); + }); + + it("refuse too many watch paths", () => { + const result = apiUpdateCompose.safeParse({ + composeId: "compose", + watchPaths: Array.from({ length: 65 }, (_, i) => `dir${i}/**`), + }); + + expect(result.success).toBe(false); + expect(result.error?.issues[0]?.path[0]).toBe("watchPaths"); + }); + + it("apply to the generic git provider and accept a cleared list", () => { + const git = { + applicationId: "app", + customGitUrl: "https://example.com/r.git", + customGitBranch: "main", + customGitBuildPath: "/", + enableSubmodules: false, + }; + const tooLong = apiSaveGitProvider.safeParse({ + ...git, + watchPaths: ["a".repeat(300)], + }); + + expect(tooLong.success).toBe(false); + expect(tooLong.error?.issues[0]?.path[0]).toBe("watchPaths"); + expect( + apiSaveGitProvider.safeParse({ ...git, watchPaths: ["src/**"] }).success, + ).toBe(true); + expect( + apiUpdateCompose.safeParse({ composeId: "compose", watchPaths: null }) + .success, + ).toBe(true); + }); +}); diff --git a/packages/server/src/db/schema/application.ts b/packages/server/src/db/schema/application.ts index cece896ec32..89f1cb6e1aa 100644 --- a/packages/server/src/db/schema/application.ts +++ b/packages/server/src/db/schema/application.ts @@ -1,5 +1,6 @@ import { normalizeRelativeFilePath } from "@dokploy/server/utils/filesystem/safe-path"; import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation"; +import { watchPathsSchema } from "@dokploy/server/utils/watch-paths/limits"; import { relations } from "drizzle-orm"; import { bigint, @@ -436,7 +437,7 @@ const createSchema = createInsertSchema(applications, { previewPath: z.string().nullable().optional(), previewCertificateType: z.enum(["letsencrypt", "none", "custom"]).optional(), previewRequireCollaboratorPermissions: z.boolean().nullable().optional(), - watchPaths: z.array(z.string()).nullable().optional(), + watchPaths: watchPathsSchema.nullable().optional(), previewLabels: z.array(z.string()).nullable().optional(), networkIds: z.array(z.string()).nullable().optional(), detachDokployNetwork: z.boolean().optional(), diff --git a/packages/server/src/db/schema/compose.ts b/packages/server/src/db/schema/compose.ts index 154887bb260..311ebaae165 100644 --- a/packages/server/src/db/schema/compose.ts +++ b/packages/server/src/db/schema/compose.ts @@ -1,4 +1,5 @@ import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation"; +import { watchPathsSchema } from "@dokploy/server/utils/watch-paths/limits"; import { relations } from "drizzle-orm"; import { boolean, @@ -206,7 +207,7 @@ const createSchema = createInsertSchema(compose, { createEnvFile: z.boolean().optional(), composePath: z.string().min(1).optional(), composeType: z.enum(["docker-compose", "stack"]).optional(), - watchPaths: z.array(z.string()).nullable().optional(), + watchPaths: watchPathsSchema.nullable().optional(), sourceType: z .enum(["git", "github", "gitlab", "bitbucket", "gitea", "raw"]) .optional(), diff --git a/packages/server/src/utils/watch-paths/limits.ts b/packages/server/src/utils/watch-paths/limits.ts new file mode 100644 index 00000000000..2348ed3ef64 --- /dev/null +++ b/packages/server/src/utils/watch-paths/limits.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; + +// picomatch's parse time grows with the square of the nesting of braces, and it runs on +// the event loop: a 40 000 character pattern blocked it for 9 s under Bun. Real globs +// stay far below these limits. +export const WATCH_PATH_MAX_LENGTH = 256; +export const WATCH_PATHS_MAX_COUNT = 64; + +export const watchPathsSchema = z + .array( + z + .string() + .max( + WATCH_PATH_MAX_LENGTH, + `A watch path can have at most ${WATCH_PATH_MAX_LENGTH} characters`, + ), + ) + .max( + WATCH_PATHS_MAX_COUNT, + `At most ${WATCH_PATHS_MAX_COUNT} watch paths are allowed`, + ); + +export const areWatchPathsWithinLimits = (watchPaths: string[]): boolean => + watchPaths.length <= WATCH_PATHS_MAX_COUNT && + watchPaths.every((path) => path.length <= WATCH_PATH_MAX_LENGTH); diff --git a/packages/server/src/utils/watch-paths/should-deploy.ts b/packages/server/src/utils/watch-paths/should-deploy.ts index 84e335c1a2c..1a0112cc7a4 100644 --- a/packages/server/src/utils/watch-paths/should-deploy.ts +++ b/packages/server/src/utils/watch-paths/should-deploy.ts @@ -1,12 +1,26 @@ import micromatch from "micromatch"; +import { areWatchPathsWithinLimits } from "./limits"; export const shouldDeploy = ( watchPaths: string[] | null, modifiedFiles: (string | null | undefined)[] | null | undefined, ): boolean => { if (!watchPaths || watchPaths?.length === 0) return true; + // Watch paths that cannot be evaluated must not stop deployments without a trace. + if (!areWatchPathsWithinLimits(watchPaths)) { + console.warn("Watch paths are over the size limits, deploying anyway"); + return true; + } const files = (modifiedFiles ?? []).filter( (file): file is string => typeof file === "string", ); - return micromatch(files, watchPaths).length > 0; + try { + return micromatch(files, watchPaths).length > 0; + } catch (error) { + console.warn( + "Watch paths could not be evaluated, deploying anyway:", + error instanceof Error ? error.message : error, + ); + return true; + } };