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
112 changes: 111 additions & 1 deletion apps/dokploy/__test__/deploy/should-deploy.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
3 changes: 2 additions & 1 deletion packages/server/src/db/schema/application.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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(),
Expand Down
3 changes: 2 additions & 1 deletion packages/server/src/db/schema/compose.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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(),
Expand Down
25 changes: 25 additions & 0 deletions packages/server/src/utils/watch-paths/limits.ts
Original file line number Diff line number Diff line change
@@ -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);
16 changes: 15 additions & 1 deletion packages/server/src/utils/watch-paths/should-deploy.ts
Original file line number Diff line number Diff line change
@@ -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;
}
};