Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
40bf19b
feat(build-policy): pure policy core with unit tests
AminDhouib Sep 10, 2026
7bde0bd
feat(build-policy): wire the module into the deploy path
AminDhouib Sep 10, 2026
39f6134
feat(build-policy): migration, integration test, README and settings UI
AminDhouib Sep 10, 2026
09a8942
fix(build-policy): review findings 1-6, 9-11
AminDhouib Sep 10, 2026
84f8251
fix(build-policy): ignore a compose deploy-hook image body while the …
AminDhouib Sep 10, 2026
cf6cd23
fix(build-policy): never crash a deploy on an unloaded environment re…
AminDhouib Sep 10, 2026
fd406b3
feat(build-policy): enforce the policy on PR preview deploys
AminDhouib Sep 10, 2026
b2157f9
feat(build-policy): roll back to a stored image digest
AminDhouib Sep 10, 2026
9dce3d5
docs(build-policy): bring the README in line with review round 1
AminDhouib Sep 10, 2026
1cb880d
fix(build-policy): gate the GitLab push webhook (review round 2, find…
AminDhouib Sep 10, 2026
ccf89f5
fix(build-policy): refuse an unsatisfiable requiredChecks up front (f…
AminDhouib Sep 10, 2026
e1dc9c7
fix(build-policy): run the checks gate before the build, and gate it …
AminDhouib Sep 10, 2026
12fd698
fix(build-policy): wire requiredChecks for compose, and stop claiming…
AminDhouib Sep 10, 2026
6095a34
fix(build-policy): audit watch-path skips, validate hook bodies first…
AminDhouib Sep 10, 2026
dbd68b7
fix(build-policy): gate the compose redeploy path too (round 3, findi…
AminDhouib Sep 10, 2026
f1ed8c2
fix(build-policy): resolve the hook allowlist through the plan (round…
AminDhouib Sep 10, 2026
5b460d6
fix(build-policy): derive watch paths from the unit's own build path …
AminDhouib Sep 10, 2026
6d27c88
docs(build-policy): generate the hook-point table, and cap the audite…
AminDhouib Sep 10, 2026
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
221 changes: 221 additions & 0 deletions apps/dokploy/__test__/build-policy/coalesce.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import {
coalesceQueuedDeploy,
isCoalescableDeployJob,
} from "@dokploy/server/services/build-policy/coalesce";
import { describe, expect, it, vi } from "vitest";

describe("coalesceQueuedDeploy", () => {
const base = {
unitType: "application" as const,
unitId: "app-1",
organizationId: "org-1",
unitName: "sendly-web",
};

it("drops the older queued deploy and audits it", async () => {
const removeWaiting = vi.fn().mockResolvedValue(1);
const recordAudit = vi.fn().mockResolvedValue(undefined);

const result = await coalesceQueuedDeploy({
...base,
removeWaiting,
recordAudit,
});

expect(result).toEqual({ removed: 1 });
expect(removeWaiting).toHaveBeenCalledTimes(1);
expect(recordAudit).toHaveBeenCalledTimes(1);
expect(recordAudit).toHaveBeenCalledWith(
expect.objectContaining({
organizationId: "org-1",
action: "deploy_coalesced",
applicationId: "app-1",
composeId: null,
metadata: expect.objectContaining({
removed: 1,
unitName: "sendly-web",
}),
}),
);
});

it("collapses several queued deploys into one audit entry", async () => {
const recordAudit = vi.fn().mockResolvedValue(undefined);
const result = await coalesceQueuedDeploy({
...base,
removeWaiting: vi.fn().mockResolvedValue(4),
recordAudit,
});
expect(result).toEqual({ removed: 4 });
expect(recordAudit).toHaveBeenCalledTimes(1);
expect(recordAudit.mock.calls[0]?.[0].metadata.removed).toBe(4);
});

it("writes nothing when there was no queued deploy to drop", async () => {
const recordAudit = vi.fn();
const result = await coalesceQueuedDeploy({
...base,
removeWaiting: vi.fn().mockResolvedValue(0),
recordAudit,
});
expect(result).toEqual({ removed: 0 });
expect(recordAudit).not.toHaveBeenCalled();
});

it("targets the compose id for a compose unit", async () => {
const recordAudit = vi.fn().mockResolvedValue(undefined);
await coalesceQueuedDeploy({
unitType: "compose",
unitId: "compose-1",
organizationId: "org-1",
unitName: "stack",
removeWaiting: vi.fn().mockResolvedValue(2),
recordAudit,
});
expect(recordAudit).toHaveBeenCalledWith(
expect.objectContaining({ applicationId: null, composeId: "compose-1" }),
);
});

it("never lets an audit failure block the deploy that is being enqueued", async () => {
const result = await coalesceQueuedDeploy({
...base,
removeWaiting: vi.fn().mockResolvedValue(1),
recordAudit: vi.fn().mockRejectedValue(new Error("db down")),
});
expect(result).toEqual({ removed: 1 });
});

it("never lets a queue failure block the deploy that is being enqueued", async () => {
const recordAudit = vi.fn();
const result = await coalesceQueuedDeploy({
...base,
removeWaiting: vi.fn().mockRejectedValue(new Error("queue gone")),
recordAudit,
});
expect(result).toEqual({ removed: 0 });
expect(recordAudit).not.toHaveBeenCalled();
});
});

/**
* Finding 5 of the PR #209 review. Coalescing used the same predicate as the
* explicit "clean queues" action, which matches on `applicationId` alone, so a
* push to main silently cancelled the pull request preview that was waiting for
* the same application.
*/
describe("isCoalescableDeployJob", () => {
const push = {
applicationId: "app-1",
applicationType: "application",
titleLog: "Push to main",
};
const preview = {
applicationId: "app-1",
applicationType: "application-preview",
previewDeploymentId: "preview-1",
titleLog: "PR #42 preview",
};

it("drops the unit's own plain deploy", () => {
expect(isCoalescableDeployJob("application", "app-1", push)).toBe(true);
});

it("never drops a preview deployment of the same application", () => {
expect(isCoalescableDeployJob("application", "app-1", preview)).toBe(false);
});

it("never drops a plain deploy of a different application", () => {
expect(isCoalescableDeployJob("application", "app-2", push)).toBe(false);
});

it("never drops a compose job while coalescing an application", () => {
expect(
isCoalescableDeployJob("application", "app-1", {
composeId: "app-1",
applicationType: "compose",
}),
).toBe(false);
});

it("drops the unit's own compose deploy but not its compose preview", () => {
expect(
isCoalescableDeployJob("compose", "compose-1", {
composeId: "compose-1",
applicationType: "compose",
}),
).toBe(true);
expect(
isCoalescableDeployJob("compose", "compose-1", {
composeId: "compose-1",
applicationType: "compose-preview",
previewDeploymentId: "preview-9",
}),
).toBe(false);
});

it("tolerates a job payload that is not an object", () => {
expect(isCoalescableDeployJob("application", "app-1", null)).toBe(false);
expect(isCoalescableDeployJob("application", "app-1", "app-1")).toBe(false);
});
});

describe("coalescing a queue that also holds a preview", () => {
/** Stands in for the in-memory queue's `removeWaiting`. */
const fakeQueue = (jobs: Record<string, unknown>[]) => ({
jobs,
removeWaiting(predicate: (data: unknown) => boolean) {
const titles: string[] = [];
const kept = jobs.filter((job) => {
if (!predicate(job)) return true;
if (typeof job.titleLog === "string") titles.push(job.titleLog);
return false;
});
this.jobs = kept;
return { removed: jobs.length - kept.length, titles };
},
});

it("leaves the waiting preview in the queue and names the dropped deploys", async () => {
const queue = fakeQueue([
{
applicationId: "app-1",
applicationType: "application",
titleLog: "Push 1",
},
{
applicationId: "app-1",
applicationType: "application-preview",
previewDeploymentId: "preview-1",
titleLog: "PR #42 preview",
},
{
applicationId: "app-2",
applicationType: "application",
titleLog: "Other app",
},
]);
const recordAudit = vi.fn().mockResolvedValue(undefined);

const result = await coalesceQueuedDeploy({
unitType: "application",
unitId: "app-1",
unitName: "sendly-web",
organizationId: "org-1",
removeWaiting: () =>
queue.removeWaiting((data) =>
isCoalescableDeployJob("application", "app-1", data),
),
recordAudit,
});

expect(result).toEqual({ removed: 1 });
expect(queue.jobs.map((job) => job.titleLog)).toEqual([
"PR #42 preview",
"Other app",
]);
expect(recordAudit.mock.calls[0]?.[0].metadata.droppedTitles).toEqual([
"Push 1",
]);
});
});
Loading
Loading