From 40bf19b5b01ef531abb9aed19cb7e7bc708697ff Mon Sep 17 00:00:00 2001 From: AminDhouib Date: Wed, 9 Sep 2026 23:14:39 -0400 Subject: [PATCH 01/18] feat(build-policy): pure policy core with unit tests Adds the decision layer of the fork build-policy module plus its schema: - decideBuildPolicy: enforcement, github-source gating, exclusions, audited break-glass, and a hard error instead of a silent local build when no build server or registry is configured - github.com source detection for both `sourceType: github` and a `sourceType: git` unit whose customGitUrl points at github.com - `[skip deploy]` commit-message marker - default watchPaths derivation from buildPath / Dockerfile / compose path - image helpers: `:` tagging, digest marker parsing, deploy-by-digest - deploy-hook `{image, tag, digest}` body validation against the org registries - requiredChecks evaluation and a polling wait with an injectable clock - queue coalescing, best-effort so it can never block an enqueue 110 unit tests, no database or network. --- .../__test__/build-policy/coalesce.test.ts | 93 +++++++ .../build-policy/image-and-hook-body.test.ts | 214 ++++++++++++++ .../build-policy/policy-decision.test.ts | 250 +++++++++++++++++ .../build-policy/required-checks.test.ts | 198 +++++++++++++ .../build-policy/source-and-markers.test.ts | 199 +++++++++++++ packages/server/src/db/schema/build-policy.ts | 263 ++++++++++++++++++ .../src/services/build-policy/coalesce.ts | 63 +++++ .../src/services/build-policy/errors.ts | 34 +++ .../src/services/build-policy/hook-body.ts | 92 ++++++ .../server/src/services/build-policy/image.ts | 108 +++++++ .../src/services/build-policy/policy.ts | 119 ++++++++ .../services/build-policy/required-checks.ts | 112 ++++++++ .../src/services/build-policy/skip-deploy.ts | 29 ++ .../src/services/build-policy/source.ts | 65 +++++ .../src/services/build-policy/watch-paths.ts | 98 +++++++ 15 files changed, 1937 insertions(+) create mode 100644 apps/dokploy/__test__/build-policy/coalesce.test.ts create mode 100644 apps/dokploy/__test__/build-policy/image-and-hook-body.test.ts create mode 100644 apps/dokploy/__test__/build-policy/policy-decision.test.ts create mode 100644 apps/dokploy/__test__/build-policy/required-checks.test.ts create mode 100644 apps/dokploy/__test__/build-policy/source-and-markers.test.ts create mode 100644 packages/server/src/db/schema/build-policy.ts create mode 100644 packages/server/src/services/build-policy/coalesce.ts create mode 100644 packages/server/src/services/build-policy/errors.ts create mode 100644 packages/server/src/services/build-policy/hook-body.ts create mode 100644 packages/server/src/services/build-policy/image.ts create mode 100644 packages/server/src/services/build-policy/policy.ts create mode 100644 packages/server/src/services/build-policy/required-checks.ts create mode 100644 packages/server/src/services/build-policy/skip-deploy.ts create mode 100644 packages/server/src/services/build-policy/source.ts create mode 100644 packages/server/src/services/build-policy/watch-paths.ts diff --git a/apps/dokploy/__test__/build-policy/coalesce.test.ts b/apps/dokploy/__test__/build-policy/coalesce.test.ts new file mode 100644 index 0000000000..341931aefb --- /dev/null +++ b/apps/dokploy/__test__/build-policy/coalesce.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from "vitest"; +import { coalesceQueuedDeploy } from "@dokploy/server/services/build-policy/coalesce"; + +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(); + }); +}); diff --git a/apps/dokploy/__test__/build-policy/image-and-hook-body.test.ts b/apps/dokploy/__test__/build-policy/image-and-hook-body.test.ts new file mode 100644 index 0000000000..4cad97bb69 --- /dev/null +++ b/apps/dokploy/__test__/build-policy/image-and-hook-body.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from "vitest"; +import { BuildPolicyError } from "@dokploy/server/services/build-policy/errors"; +import { + DIGEST_MARKER, + SHA_PLACEHOLDER, + buildDigestRef, + imageTagForSha, + parseImageDigestFromLog, + parseImageTagFromLog, + registryHostOf, +} from "@dokploy/server/services/build-policy/image"; +import { parseDeployHookImage } from "@dokploy/server/services/build-policy/hook-body"; + +describe("imageTagForSha", () => { + it("tags :", () => { + expect(imageTagForSha("sendly-web", "abc123")).toBe("sendly-web:abc123"); + }); + + it("uses the placeholder when the sha is resolved in the build shell", () => { + expect(imageTagForSha("sendly-web")).toBe(`sendly-web:${SHA_PLACEHOLDER}`); + }); +}); + +describe("buildDigestRef", () => { + it("drops the tag and pins the digest", () => { + expect( + buildDigestRef("ghcr.io/devino/sendly-web:abc123", `sha256:${"a".repeat(64)}`), + ).toBe(`ghcr.io/devino/sendly-web@sha256:${"a".repeat(64)}`); + }); + + it("handles a reference with a port in the host", () => { + expect( + buildDigestRef( + "registry.devino.ca:5000/devino/sendly-web:abc123", + `sha256:${"b".repeat(64)}`, + ), + ).toBe( + `registry.devino.ca:5000/devino/sendly-web@sha256:${"b".repeat(64)}`, + ); + }); + + it("handles a reference with no tag", () => { + expect( + buildDigestRef("ghcr.io/devino/sendly-web", `sha256:${"c".repeat(64)}`), + ).toBe(`ghcr.io/devino/sendly-web@sha256:${"c".repeat(64)}`); + }); + + it("rejects a malformed digest", () => { + expect(() => buildDigestRef("ghcr.io/a/b:1", "sha256:nope")).toThrow( + BuildPolicyError, + ); + }); +}); + +describe("registryHostOf", () => { + it("reads the host from a reference", () => { + expect(registryHostOf("ghcr.io/devino/sendly-web:abc")).toBe("ghcr.io"); + }); + + it("reads a host with a port", () => { + expect(registryHostOf("registry.devino.ca:5000/devino/x:abc")).toBe( + "registry.devino.ca:5000", + ); + }); + + it("returns null for a docker hub short name, which has no host segment", () => { + expect(registryHostOf("devino/sendly-web:abc")).toBeNull(); + expect(registryHostOf("nginx:alpine")).toBeNull(); + }); +}); + +describe("parseImageDigestFromLog", () => { + const digest = `sha256:${"d".repeat(64)}`; + + it("reads the digest the build script echoes", () => { + const log = [ + "Step 1/5 : FROM node:24", + "✅ Image Pushed", + `${DIGEST_MARKER} ghcr.io/devino/sendly-web:abc123 ${digest}`, + "done", + ].join("\n"); + expect(parseImageDigestFromLog(log)).toBe(digest); + expect(parseImageTagFromLog(log)).toBe("ghcr.io/devino/sendly-web:abc123"); + }); + + it("takes the last marker when a log carries several", () => { + const other = `sha256:${"e".repeat(64)}`; + const log = [ + `${DIGEST_MARKER} ghcr.io/a/b:1 ${other}`, + `${DIGEST_MARKER} ghcr.io/a/b:2 ${digest}`, + ].join("\n"); + expect(parseImageDigestFromLog(log)).toBe(digest); + expect(parseImageTagFromLog(log)).toBe("ghcr.io/a/b:2"); + }); + + it("tolerates carriage returns and trailing whitespace", () => { + const log = `noise\r\n${DIGEST_MARKER} ghcr.io/a/b:1 ${digest} \r\n`; + expect(parseImageDigestFromLog(log)).toBe(digest); + }); + + it("returns null when the marker never appeared", () => { + expect(parseImageDigestFromLog("no marker here")).toBeNull(); + expect(parseImageTagFromLog("no marker here")).toBeNull(); + }); + + it("ignores a marker line with a malformed digest", () => { + expect( + parseImageDigestFromLog(`${DIGEST_MARKER} ghcr.io/a/b:1 sha256:short`), + ).toBeNull(); + }); + + it("handles an empty log", () => { + expect(parseImageDigestFromLog("")).toBeNull(); + expect(parseImageDigestFromLog(null)).toBeNull(); + }); +}); + +describe("parseDeployHookImage", () => { + const digest = `sha256:${"f".repeat(64)}`; + const allowed = ["ghcr.io", "registry.devino.ca"]; + + it("returns none for an empty body", () => { + expect(parseDeployHookImage(undefined, allowed)).toEqual({ kind: "none" }); + expect(parseDeployHookImage({}, allowed)).toEqual({ kind: "none" }); + expect(parseDeployHookImage("", allowed)).toEqual({ kind: "none" }); + }); + + it("accepts an image on an allowed registry and pins the digest", () => { + expect( + parseDeployHookImage( + { image: "ghcr.io/devino/sendly-web", tag: "abc123", digest }, + allowed, + ), + ).toEqual({ + kind: "image", + image: "ghcr.io/devino/sendly-web", + tag: "abc123", + digest, + ref: `ghcr.io/devino/sendly-web@${digest}`, + }); + }); + + it("accepts an image whose tag is already embedded", () => { + expect( + parseDeployHookImage( + { image: "ghcr.io/devino/sendly-web:abc123", digest }, + allowed, + ), + ).toEqual({ + kind: "image", + image: "ghcr.io/devino/sendly-web:abc123", + tag: "abc123", + digest, + ref: `ghcr.io/devino/sendly-web@${digest}`, + }); + }); + + it("rejects an image on a registry the org did not configure", () => { + expect(() => + parseDeployHookImage( + { image: "docker.io/evil/thing", tag: "1", digest }, + allowed, + ), + ).toThrow(/registry/i); + }); + + it("rejects an image with no registry host at all", () => { + expect(() => + parseDeployHookImage({ image: "evil/thing", tag: "1", digest }, allowed), + ).toThrow(/registry/i); + }); + + it("rejects a body with an image but no digest, because deploys are by digest", () => { + expect(() => + parseDeployHookImage( + { image: "ghcr.io/devino/sendly-web", tag: "abc" }, + allowed, + ), + ).toThrow(/digest/i); + }); + + it("rejects a malformed digest", () => { + expect(() => + parseDeployHookImage( + { image: "ghcr.io/devino/sendly-web", tag: "abc", digest: "abc" }, + allowed, + ), + ).toThrow(/digest/i); + }); + + it("rejects an image that is not a string", () => { + expect(() => + parseDeployHookImage({ image: 42, digest }, allowed), + ).toThrow(BuildPolicyError); + }); + + it("rejects shell metacharacters in the image reference", () => { + expect(() => + parseDeployHookImage( + { image: "ghcr.io/devino/x;rm -rf /", tag: "a", digest }, + allowed, + ), + ).toThrow(BuildPolicyError); + }); + + it("returns none when the org configured no registries, rather than trusting the caller", () => { + expect(() => + parseDeployHookImage( + { image: "ghcr.io/devino/sendly-web", tag: "a", digest }, + [], + ), + ).toThrow(/registry/i); + }); +}); diff --git a/apps/dokploy/__test__/build-policy/policy-decision.test.ts b/apps/dokploy/__test__/build-policy/policy-decision.test.ts new file mode 100644 index 0000000000..80dbced3c7 --- /dev/null +++ b/apps/dokploy/__test__/build-policy/policy-decision.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it } from "vitest"; +import { + decideBuildPolicy, + type BuildPolicyDecisionInput, +} from "@dokploy/server/services/build-policy/policy"; + +const settings = (overrides: Record = {}) => ({ + enforceRemoteBuilds: true, + defaultBuildServerId: "build-server-1", + defaultRegistryId: "registry-1", + requiredChecksTimeoutMinutes: 30, + ...overrides, +}); + +const githubApp = (overrides: Record = {}) => ({ + unitType: "application" as const, + unitId: "app-1", + sourceType: "github", + customGitUrl: null, + buildServerId: null, + buildRegistryId: null, + ...overrides, +}); + +const input = ( + overrides: Partial = {}, +): BuildPolicyDecisionInput => ({ + unit: githubApp(), + settings: settings(), + isExcluded: false, + breakGlass: null, + ...overrides, +}); + +describe("decideBuildPolicy", () => { + describe("when enforcement is off", () => { + it("leaves the unit alone with reason not_enforced", () => { + const decision = decideBuildPolicy( + input({ settings: settings({ enforceRemoteBuilds: false }) }), + ); + expect(decision).toEqual({ mode: "local", reason: "not_enforced" }); + }); + + it("treats a missing settings row as enforcement off", () => { + const decision = decideBuildPolicy(input({ settings: null })); + expect(decision).toEqual({ mode: "local", reason: "not_enforced" }); + }); + + it("does not consume a break-glass grant", () => { + const decision = decideBuildPolicy( + input({ + settings: null, + breakGlass: { + auditId: "audit-1", + actorEmail: "a@example.com", + reason: "hotfix", + }, + }), + ); + expect(decision).toEqual({ mode: "local", reason: "not_enforced" }); + }); + }); + + describe("source gating", () => { + it("enforces a remote build for a github-sourced application", () => { + expect(decideBuildPolicy(input())).toEqual({ + mode: "remote", + buildServerId: "build-server-1", + registryId: "registry-1", + }); + }); + + it("enforces a remote build for a git source hosted on github.com", () => { + const decision = decideBuildPolicy( + input({ + unit: githubApp({ + sourceType: "git", + customGitUrl: "https://github.com/DevinoSolutions/sendly.git", + }), + }), + ); + expect(decision).toEqual({ + mode: "remote", + buildServerId: "build-server-1", + registryId: "registry-1", + }); + }); + + it("leaves a git source on another host alone", () => { + const decision = decideBuildPolicy( + input({ + unit: githubApp({ + sourceType: "git", + customGitUrl: "https://gitlab.com/acme/thing.git", + }), + }), + ); + expect(decision).toEqual({ mode: "local", reason: "not_github" }); + }); + + it.each(["docker", "gitlab", "bitbucket", "gitea", "drop", "raw"])( + "leaves a %s source alone", + (sourceType) => { + const decision = decideBuildPolicy( + input({ unit: githubApp({ sourceType }) }), + ); + expect(decision).toEqual({ mode: "local", reason: "not_github" }); + }, + ); + }); + + describe("exclusions", () => { + it("keeps an excluded unit on a local build", () => { + const decision = decideBuildPolicy(input({ isExcluded: true })); + expect(decision).toEqual({ mode: "local", reason: "excluded" }); + }); + + it("prefers the exclusion over a break-glass grant so the grant is not burned", () => { + const decision = decideBuildPolicy( + input({ + isExcluded: true, + breakGlass: { + auditId: "audit-1", + actorEmail: "a@example.com", + reason: "hotfix", + }, + }), + ); + expect(decision).toEqual({ mode: "local", reason: "excluded" }); + }); + + it("excludes a unit even when no build server is configured", () => { + const decision = decideBuildPolicy( + input({ + isExcluded: true, + settings: settings({ defaultBuildServerId: null }), + }), + ); + expect(decision).toEqual({ mode: "local", reason: "excluded" }); + }); + }); + + describe("break glass", () => { + it("allows one local build and reports the grant that was used", () => { + const decision = decideBuildPolicy( + input({ + breakGlass: { + auditId: "audit-1", + actorEmail: "ops@example.com", + reason: "registry outage", + }, + }), + ); + expect(decision).toEqual({ + mode: "local", + reason: "break_glass", + breakGlassAuditId: "audit-1", + }); + }); + + it("wins over a missing build server, because it is the manual escape hatch", () => { + const decision = decideBuildPolicy( + input({ + settings: settings({ defaultBuildServerId: null }), + breakGlass: { + auditId: "audit-2", + actorEmail: "ops@example.com", + reason: "build server down", + }, + }), + ); + expect(decision).toEqual({ + mode: "local", + reason: "break_glass", + breakGlassAuditId: "audit-2", + }); + }); + }); + + describe("no silent local fallback", () => { + it("errors when enforcement is on and no build server is available", () => { + const decision = decideBuildPolicy( + input({ settings: settings({ defaultBuildServerId: null }) }), + ); + expect(decision.mode).toBe("error"); + if (decision.mode !== "error") throw new Error("unreachable"); + expect(decision.code).toBe("NO_BUILD_SERVER"); + expect(decision.message).toMatch(/build server/i); + }); + + it("errors when enforcement is on and no registry is available", () => { + const decision = decideBuildPolicy( + input({ settings: settings({ defaultRegistryId: null }) }), + ); + expect(decision.mode).toBe("error"); + if (decision.mode !== "error") throw new Error("unreachable"); + expect(decision.code).toBe("NO_REGISTRY"); + expect(decision.message).toMatch(/registry/i); + }); + + it("never falls back to the per-unit build server when the org has none", () => { + const decision = decideBuildPolicy( + input({ + settings: settings({ defaultBuildServerId: null }), + unit: githubApp({ buildServerId: "some-other-server" }), + }), + ); + expect(decision.mode).toBe("error"); + }); + }); + + describe("overriding the per-unit field", () => { + it("replaces a per-unit build server with the org default", () => { + const decision = decideBuildPolicy( + input({ + unit: githubApp({ + buildServerId: "stale-server", + buildRegistryId: "stale-registry", + }), + }), + ); + expect(decision).toEqual({ + mode: "remote", + buildServerId: "build-server-1", + registryId: "registry-1", + }); + }); + }); + + describe("compose units", () => { + it("does not relocate a compose build, and says so explicitly", () => { + const decision = decideBuildPolicy( + input({ + unit: { + unitType: "compose", + unitId: "compose-1", + sourceType: "github", + customGitUrl: null, + buildServerId: null, + buildRegistryId: null, + }, + }), + ); + expect(decision).toEqual({ + mode: "local", + reason: "compose_build_not_relocatable", + }); + }); + }); +}); diff --git a/apps/dokploy/__test__/build-policy/required-checks.test.ts b/apps/dokploy/__test__/build-policy/required-checks.test.ts new file mode 100644 index 0000000000..ed8dbd9295 --- /dev/null +++ b/apps/dokploy/__test__/build-policy/required-checks.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it, vi } from "vitest"; +import { BuildPolicyError } from "@dokploy/server/services/build-policy/errors"; +import { + evaluateRequiredChecks, + waitForRequiredChecks, +} from "@dokploy/server/services/build-policy/required-checks"; + +const run = ( + name: string, + status: string, + conclusion: string | null = null, +) => ({ name, status, conclusion }); + +describe("evaluateRequiredChecks", () => { + it("is satisfied when every required check succeeded", () => { + expect( + evaluateRequiredChecks( + ["build", "test"], + [ + run("build", "completed", "success"), + run("test", "completed", "success"), + run("lint", "completed", "failure"), + ], + ), + ).toEqual({ state: "satisfied" }); + }); + + it("treats a neutral or skipped conclusion as success", () => { + expect( + evaluateRequiredChecks( + ["build", "test"], + [ + run("build", "completed", "neutral"), + run("test", "completed", "skipped"), + ], + ), + ).toEqual({ state: "satisfied" }); + }); + + it("is pending while a required check is still running", () => { + expect( + evaluateRequiredChecks( + ["build", "test"], + [run("build", "completed", "success"), run("test", "in_progress")], + ), + ).toEqual({ state: "pending", waitingOn: ["test"] }); + }); + + it("is pending while a required check has not been reported at all", () => { + expect( + evaluateRequiredChecks(["build", "test"], [run("build", "queued")]), + ).toEqual({ state: "pending", waitingOn: ["build", "test"] }); + }); + + it.each(["failure", "cancelled", "timed_out", "action_required", "stale"])( + "fails on a %s conclusion", + (conclusion) => { + expect( + evaluateRequiredChecks(["build"], [run("build", "completed", conclusion)]), + ).toEqual({ state: "failed", failed: ["build"] }); + }, + ); + + it("reports every failing check, not just the first", () => { + expect( + evaluateRequiredChecks( + ["build", "test"], + [ + run("build", "completed", "failure"), + run("test", "completed", "timed_out"), + ], + ), + ).toEqual({ state: "failed", failed: ["build", "test"] }); + }); + + it("uses the newest run when a check was re-run", () => { + expect( + evaluateRequiredChecks( + ["build"], + [ + run("build", "completed", "failure"), + run("build", "completed", "success"), + ], + ), + ).toEqual({ state: "satisfied" }); + }); + + it("is satisfied immediately when nothing is required", () => { + expect(evaluateRequiredChecks([], [])).toEqual({ state: "satisfied" }); + }); +}); + +describe("waitForRequiredChecks", () => { + const base = { + requiredChecks: ["build"], + owner: "DevinoSolutions", + repo: "sendly", + sha: "abc123", + timeoutMs: 60_000, + pollIntervalMs: 5_000, + }; + + it("returns immediately when nothing is required and never calls github", async () => { + const listCheckRuns = vi.fn(); + await waitForRequiredChecks({ + ...base, + requiredChecks: [], + listCheckRuns, + sleep: vi.fn(), + now: () => 0, + }); + expect(listCheckRuns).not.toHaveBeenCalled(); + }); + + it("returns once the required checks succeed", async () => { + const listCheckRuns = vi + .fn() + .mockResolvedValueOnce([run("build", "in_progress")]) + .mockResolvedValueOnce([run("build", "completed", "success")]); + const sleep = vi.fn().mockResolvedValue(undefined); + let clock = 0; + await waitForRequiredChecks({ + ...base, + listCheckRuns, + sleep, + now: () => { + const t = clock; + clock += 5_000; + return t; + }, + }); + expect(listCheckRuns).toHaveBeenCalledTimes(2); + expect(sleep).toHaveBeenCalledWith(5_000); + }); + + it("fails fast when a required check concludes in failure", async () => { + const listCheckRuns = vi + .fn() + .mockResolvedValue([run("build", "completed", "failure")]); + const sleep = vi.fn(); + await expect( + waitForRequiredChecks({ ...base, listCheckRuns, sleep, now: () => 0 }), + ).rejects.toMatchObject({ code: "REQUIRED_CHECKS_FAILED" }); + expect(sleep).not.toHaveBeenCalled(); + expect(listCheckRuns).toHaveBeenCalledTimes(1); + }); + + it("names the failing checks in the error message", async () => { + const listCheckRuns = vi + .fn() + .mockResolvedValue([run("build", "completed", "failure")]); + await expect( + waitForRequiredChecks({ ...base, listCheckRuns, sleep: vi.fn(), now: () => 0 }), + ).rejects.toThrow(/build/); + }); + + it("times out with a named error when the checks never conclude", async () => { + const listCheckRuns = vi.fn().mockResolvedValue([run("build", "queued")]); + let clock = 0; + const sleep = vi.fn().mockImplementation(async () => { + clock += 30_000; + }); + await expect( + waitForRequiredChecks({ + ...base, + listCheckRuns, + sleep, + now: () => clock, + }), + ).rejects.toMatchObject({ code: "REQUIRED_CHECKS_TIMEOUT" }); + }); + + it("reports what it was still waiting on when it times out", async () => { + const listCheckRuns = vi.fn().mockResolvedValue([]); + let clock = 0; + const sleep = vi.fn().mockImplementation(async () => { + clock += 30_000; + }); + await expect( + waitForRequiredChecks({ + ...base, + requiredChecks: ["build", "e2e"], + listCheckRuns, + sleep, + now: () => clock, + }), + ).rejects.toThrow(/e2e/); + }); + + it("surfaces a BuildPolicyError, not a raw github error shape", async () => { + const listCheckRuns = vi + .fn() + .mockResolvedValue([run("build", "completed", "failure")]); + await expect( + waitForRequiredChecks({ ...base, listCheckRuns, sleep: vi.fn(), now: () => 0 }), + ).rejects.toBeInstanceOf(BuildPolicyError); + }); +}); diff --git a/apps/dokploy/__test__/build-policy/source-and-markers.test.ts b/apps/dokploy/__test__/build-policy/source-and-markers.test.ts new file mode 100644 index 0000000000..8c9b58e41d --- /dev/null +++ b/apps/dokploy/__test__/build-policy/source-and-markers.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "vitest"; +import { + isGithubHostUrl, + isGithubSourcedUnit, +} from "@dokploy/server/services/build-policy/source"; +import { + SKIP_DEPLOY_MARKERS, + hasSkipDeployMarker, +} from "@dokploy/server/services/build-policy/skip-deploy"; +import { deriveDefaultWatchPaths } from "@dokploy/server/services/build-policy/watch-paths"; + +describe("isGithubHostUrl", () => { + it.each([ + "https://github.com/DevinoSolutions/sendly.git", + "http://github.com/acme/thing", + "https://www.github.com/acme/thing.git", + "git@github.com:acme/thing.git", + "ssh://git@github.com/acme/thing.git", + "https://GitHub.com/Acme/Thing.git", + ])("accepts %s", (url) => { + expect(isGithubHostUrl(url)).toBe(true); + }); + + it.each([ + "https://gitlab.com/acme/thing.git", + "https://git.example.com/acme/thing.git", + "git@bitbucket.org:acme/thing.git", + "https://github.com.evil.example/acme/thing.git", + "https://notgithub.com/acme/thing.git", + "", + null, + undefined, + ])("rejects %s", (url) => { + expect(isGithubHostUrl(url as string)).toBe(false); + }); + + it("rejects a github enterprise host, which uses a different API base", () => { + expect(isGithubHostUrl("https://github.acme-corp.com/acme/thing.git")).toBe( + false, + ); + }); +}); + +describe("isGithubSourcedUnit", () => { + it("accepts sourceType github", () => { + expect( + isGithubSourcedUnit({ sourceType: "github", customGitUrl: null }), + ).toBe(true); + }); + + it("accepts sourceType git on github.com", () => { + expect( + isGithubSourcedUnit({ + sourceType: "git", + customGitUrl: "https://github.com/acme/thing.git", + }), + ).toBe(true); + }); + + it("rejects sourceType git elsewhere", () => { + expect( + isGithubSourcedUnit({ + sourceType: "git", + customGitUrl: "https://gitlab.com/acme/thing.git", + }), + ).toBe(false); + }); + + it("ignores a github customGitUrl when the source type is not git", () => { + expect( + isGithubSourcedUnit({ + sourceType: "docker", + customGitUrl: "https://github.com/acme/thing.git", + }), + ).toBe(false); + }); +}); + +describe("hasSkipDeployMarker", () => { + it.each(SKIP_DEPLOY_MARKERS)("matches %s", (marker) => { + expect(hasSkipDeployMarker(`chore: bump deps ${marker}`)).toBe(true); + }); + + it("is case insensitive", () => { + expect(hasSkipDeployMarker("docs: readme [SKIP DEPLOY]")).toBe(true); + }); + + it("matches a marker on a trailer line", () => { + expect(hasSkipDeployMarker("feat: thing\n\n[skip deploy]\n")).toBe(true); + }); + + it("does not match a plain mention of deploying", () => { + expect(hasSkipDeployMarker("fix: skip deploy when the queue is empty")).toBe( + false, + ); + }); + + it("does not match the unrelated [skip ci] marker", () => { + expect(hasSkipDeployMarker("chore: lint [skip ci]")).toBe(false); + }); + + it.each([null, undefined, ""])("handles %s", (value) => { + expect(hasSkipDeployMarker(value as string)).toBe(false); + }); +}); + +describe("deriveDefaultWatchPaths", () => { + it("returns the whole repo when the build path is the repo root", () => { + expect( + deriveDefaultWatchPaths({ + unitType: "application", + buildPath: "/", + dockerfile: "Dockerfile", + }), + ).toEqual(["**"]); + }); + + it("derives the build path directory", () => { + expect( + deriveDefaultWatchPaths({ + unitType: "application", + buildPath: "/apps/web", + dockerfile: "Dockerfile", + }), + ).toEqual(["apps/web/**"]); + }); + + it("resolves the dockerfile under the build path, so a nested one adds nothing", () => { + expect( + deriveDefaultWatchPaths({ + unitType: "application", + buildPath: "/apps/web", + dockerfile: "docker/web/Dockerfile", + }), + ).toEqual(["apps/web/**"]); + }); + + it("adds an explicitly configured docker context path outside the build path", () => { + expect( + deriveDefaultWatchPaths({ + unitType: "application", + buildPath: "/apps/web", + dockerfile: "Dockerfile", + dockerContextPath: "packages/shared", + }), + ).toEqual(["apps/web/**", "packages/shared/**"]); + }); + + it("ignores an unset docker context path rather than collapsing to the whole repo", () => { + expect( + deriveDefaultWatchPaths({ + unitType: "application", + buildPath: "/apps/web", + dockerfile: "Dockerfile", + dockerContextPath: null, + }), + ).toEqual(["apps/web/**"]); + }); + + it("collapses to the whole repo when a configured input is the repo root", () => { + expect( + deriveDefaultWatchPaths({ + unitType: "application", + buildPath: "/apps/web", + dockerfile: "Dockerfile", + dockerContextPath: ".", + }), + ).toEqual(["**"]); + }); + + it("derives a compose unit from its compose file directory", () => { + expect( + deriveDefaultWatchPaths({ + unitType: "compose", + composePath: "./deploy/docker-compose.yml", + }), + ).toEqual(["deploy/**"]); + }); + + it("returns the whole repo for a compose file at the repo root", () => { + expect( + deriveDefaultWatchPaths({ + unitType: "compose", + composePath: "./docker-compose.yml", + }), + ).toEqual(["**"]); + }); + + it("de-duplicates and sorts", () => { + expect( + deriveDefaultWatchPaths({ + unitType: "application", + buildPath: "/services/api", + dockerfile: "Dockerfile", + dockerContextPath: "/services/api/", + }), + ).toEqual(["services/api/**"]); + }); +}); diff --git a/packages/server/src/db/schema/build-policy.ts b/packages/server/src/db/schema/build-policy.ts new file mode 100644 index 0000000000..2fe3e80c9d --- /dev/null +++ b/packages/server/src/db/schema/build-policy.ts @@ -0,0 +1,263 @@ +import { relations } from "drizzle-orm"; +import { + boolean, + index, + integer, + pgEnum, + pgTable, + text, + timestamp, +} from "drizzle-orm/pg-core"; +import { createInsertSchema } from "drizzle-zod"; +import { nanoid } from "nanoid"; +import { z } from "zod"; +import { organization } from "./account"; +import { applications } from "./application"; +import { compose } from "./compose"; +import { registry } from "./registry"; +import { server } from "./server"; +import { user } from "./user"; + +/** + * Fork module: build policy (enforced remote builds). + * + * Everything in this file is additive to upstream Dokploy. See + * `packages/server/src/services/build-policy/README.md` for the hook points + * this schema is read from. + */ + +export const buildPolicyAuditAction = pgEnum("buildPolicyAuditAction", [ + "settings_updated", + "exclusion_added", + "exclusion_removed", + "break_glass_granted", + "break_glass_consumed", + "remote_build_enforced", + "build_server_missing", + "deploy_coalesced", + "deploy_skipped", + "required_checks_failed", + "required_checks_timeout", + "deploy_by_digest", +]); + +/** + * One row per organization. Absent row == policy off, which is the default and + * keeps every unmodified instance on the stock upstream behaviour. + */ +export const buildPolicySettings = pgTable("build_policy_settings", { + buildPolicySettingsId: text("buildPolicySettingsId") + .notNull() + .primaryKey() + .$defaultFn(() => nanoid()), + organizationId: text("organizationId") + .notNull() + .unique() + .references(() => organization.id, { onDelete: "cascade" }), + /** Master switch. When false the module is inert. */ + enforceRemoteBuilds: boolean("enforceRemoteBuilds").notNull().default(false), + /** Build server every enforced unit is pinned to. */ + defaultBuildServerId: text("defaultBuildServerId").references( + () => server.serverId, + { onDelete: "set null" }, + ), + /** Registry the built image is pushed to and pulled from by digest. */ + defaultRegistryId: text("defaultRegistryId").references( + () => registry.registryId, + { onDelete: "set null" }, + ), + /** Minutes a deploy waits for a unit's `requiredChecks` before failing. */ + requiredChecksTimeoutMinutes: integer("requiredChecksTimeoutMinutes") + .notNull() + .default(30), + createdAt: text("createdAt") + .notNull() + .$defaultFn(() => new Date().toISOString()), + updatedAt: text("updatedAt") + .notNull() + .$defaultFn(() => new Date().toISOString()), +}); + +/** Units that keep a local build while enforcement is on. */ +export const buildPolicyExclusion = pgTable( + "build_policy_exclusion", + { + buildPolicyExclusionId: text("buildPolicyExclusionId") + .notNull() + .primaryKey() + .$defaultFn(() => nanoid()), + organizationId: text("organizationId") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + applicationId: text("applicationId").references( + () => applications.applicationId, + { onDelete: "cascade" }, + ), + composeId: text("composeId").references(() => compose.composeId, { + onDelete: "cascade", + }), + reason: text("reason"), + createdAt: text("createdAt") + .notNull() + .$defaultFn(() => new Date().toISOString()), + }, + (t) => ({ + orgIdx: index("buildPolicyExclusion_organizationId_idx").on( + t.organizationId, + ), + applicationIdx: index("buildPolicyExclusion_applicationId_idx").on( + t.applicationId, + ), + composeIdx: index("buildPolicyExclusion_composeId_idx").on(t.composeId), + }), +); + +/** + * Append-only trail for every policy decision that a human needs to be able to + * reconstruct. Break-glass grants live here too: a grant is a row with + * `action = "break_glass_granted"` and `consumedAt IS NULL`; the next deploy of + * that unit stamps `consumedAt` and writes a `break_glass_consumed` row. + */ +export const buildPolicyAudit = pgTable( + "build_policy_audit", + { + buildPolicyAuditId: text("buildPolicyAuditId") + .notNull() + .primaryKey() + .$defaultFn(() => nanoid()), + organizationId: text("organizationId") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + action: buildPolicyAuditAction("action").notNull(), + applicationId: text("applicationId").references( + () => applications.applicationId, + { onDelete: "set null" }, + ), + composeId: text("composeId").references(() => compose.composeId, { + onDelete: "set null", + }), + actorId: text("actorId").references(() => user.id, { + onDelete: "set null", + }), + actorEmail: text("actorEmail"), + reason: text("reason"), + /** JSON.stringify'd, matching the convention in `audit_log.metadata`. */ + metadata: text("metadata"), + createdAt: timestamp("createdAt").defaultNow().notNull(), + consumedAt: timestamp("consumedAt"), + }, + (t) => ({ + orgIdx: index("buildPolicyAudit_organizationId_idx").on(t.organizationId), + applicationIdx: index("buildPolicyAudit_applicationId_idx").on( + t.applicationId, + ), + composeIdx: index("buildPolicyAudit_composeId_idx").on(t.composeId), + createdAtIdx: index("buildPolicyAudit_createdAt_idx").on(t.createdAt), + }), +); + +export const buildPolicySettingsRelations = relations( + buildPolicySettings, + ({ one }) => ({ + organization: one(organization, { + fields: [buildPolicySettings.organizationId], + references: [organization.id], + }), + defaultBuildServer: one(server, { + fields: [buildPolicySettings.defaultBuildServerId], + references: [server.serverId], + }), + defaultRegistry: one(registry, { + fields: [buildPolicySettings.defaultRegistryId], + references: [registry.registryId], + }), + }), +); + +export const buildPolicyExclusionRelations = relations( + buildPolicyExclusion, + ({ one }) => ({ + organization: one(organization, { + fields: [buildPolicyExclusion.organizationId], + references: [organization.id], + }), + application: one(applications, { + fields: [buildPolicyExclusion.applicationId], + references: [applications.applicationId], + }), + compose: one(compose, { + fields: [buildPolicyExclusion.composeId], + references: [compose.composeId], + }), + }), +); + +export const buildPolicyAuditRelations = relations( + buildPolicyAudit, + ({ one }) => ({ + organization: one(organization, { + fields: [buildPolicyAudit.organizationId], + references: [organization.id], + }), + application: one(applications, { + fields: [buildPolicyAudit.applicationId], + references: [applications.applicationId], + }), + compose: one(compose, { + fields: [buildPolicyAudit.composeId], + references: [compose.composeId], + }), + actor: one(user, { + fields: [buildPolicyAudit.actorId], + references: [user.id], + }), + }), +); + +export type BuildPolicySettings = typeof buildPolicySettings.$inferSelect; +export type BuildPolicyExclusion = typeof buildPolicyExclusion.$inferSelect; +export type BuildPolicyAudit = typeof buildPolicyAudit.$inferSelect; +export type BuildPolicyAuditAction = + (typeof buildPolicyAuditAction.enumValues)[number]; + +const createSettingsSchema = createInsertSchema(buildPolicySettings); + +export const apiUpdateBuildPolicySettings = createSettingsSchema + .pick({ + enforceRemoteBuilds: true, + defaultBuildServerId: true, + defaultRegistryId: true, + }) + .partial() + .extend({ + requiredChecksTimeoutMinutes: z.number().int().min(1).max(720).optional(), + }); + +export const apiAddBuildPolicyExclusion = z + .object({ + applicationId: z.string().min(1).optional(), + composeId: z.string().min(1).optional(), + reason: z.string().max(500).optional(), + }) + .refine((v) => !!v.applicationId !== !!v.composeId, { + message: "Provide exactly one of applicationId or composeId", + }); + +export const apiRemoveBuildPolicyExclusion = z.object({ + buildPolicyExclusionId: z.string().min(1), +}); + +export const apiGrantBuildPolicyBreakGlass = z + .object({ + applicationId: z.string().min(1).optional(), + composeId: z.string().min(1).optional(), + reason: z.string().min(1).max(500), + }) + .refine((v) => !!v.applicationId !== !!v.composeId, { + message: "Provide exactly one of applicationId or composeId", + }); + +export const apiListBuildPolicyAudit = z.object({ + limit: z.number().int().min(1).max(200).default(50), + offset: z.number().int().min(0).default(0), +}); diff --git a/packages/server/src/services/build-policy/coalesce.ts b/packages/server/src/services/build-policy/coalesce.ts new file mode 100644 index 0000000000..2ceae168a4 --- /dev/null +++ b/packages/server/src/services/build-policy/coalesce.ts @@ -0,0 +1,63 @@ +import type { BuildPolicyAuditAction } from "@dokploy/server/db/schema"; +import type { BuildPolicyUnitType } from "./policy"; + +/** + * Queue coalescing (spec 5.2.5). + * + * Before enqueueing a deploy for a unit, drop the deploys for that same unit + * that are still *waiting*. Running builds are left alone. N pushes therefore + * produce one build of the newest commit. + * + * Coalescing is best-effort: neither a queue failure nor an audit failure may + * stop the deploy that is being enqueued. + */ +export interface CoalesceAuditEntry { + organizationId: string; + action: BuildPolicyAuditAction; + applicationId: string | null; + composeId: string | null; + metadata: Record; +} + +export interface CoalesceQueuedDeployInput { + unitType: BuildPolicyUnitType; + unitId: string; + unitName?: string; + organizationId: string; + /** Removes still-waiting jobs for this unit; returns how many it removed. */ + removeWaiting: () => Promise | number; + recordAudit: (entry: CoalesceAuditEntry) => Promise; +} + +export const coalesceQueuedDeploy = async ({ + unitType, + unitId, + unitName, + organizationId, + removeWaiting, + recordAudit, +}: CoalesceQueuedDeployInput): Promise<{ removed: number }> => { + let removed = 0; + try { + removed = (await removeWaiting()) ?? 0; + } catch (error) { + console.error("[build-policy] queue coalescing failed", error); + return { removed: 0 }; + } + + if (removed <= 0) return { removed: 0 }; + + try { + await recordAudit({ + organizationId, + action: "deploy_coalesced", + applicationId: unitType === "application" ? unitId : null, + composeId: unitType === "compose" ? unitId : null, + metadata: { removed, unitType, unitName }, + }); + } catch (error) { + console.error("[build-policy] failed to audit queue coalescing", error); + } + + return { removed }; +}; diff --git a/packages/server/src/services/build-policy/errors.ts b/packages/server/src/services/build-policy/errors.ts new file mode 100644 index 0000000000..cd69f53b36 --- /dev/null +++ b/packages/server/src/services/build-policy/errors.ts @@ -0,0 +1,34 @@ +/** + * Every failure this module raises is a `BuildPolicyError` with a stable code, + * so the deploy path can log a named reason instead of a stack trace and the + * integration test can assert on the code rather than on prose. + */ +export type BuildPolicyErrorCode = + | "NO_BUILD_SERVER" + | "NO_REGISTRY" + | "REGISTRY_NOT_ALLOWED" + | "INVALID_IMAGE" + | "INVALID_DIGEST" + | "DIGEST_NOT_PUBLISHED" + | "REQUIRED_CHECKS_FAILED" + | "REQUIRED_CHECKS_TIMEOUT" + | "REQUIRED_CHECKS_UNAVAILABLE"; + +export class BuildPolicyError extends Error { + public readonly code: BuildPolicyErrorCode; + public readonly details?: Record; + + constructor( + code: BuildPolicyErrorCode, + message: string, + details?: Record, + ) { + super(message); + this.name = "BuildPolicyError"; + this.code = code; + this.details = details; + } +} + +export const isBuildPolicyError = (error: unknown): error is BuildPolicyError => + error instanceof BuildPolicyError; diff --git a/packages/server/src/services/build-policy/hook-body.ts b/packages/server/src/services/build-policy/hook-body.ts new file mode 100644 index 0000000000..76f4f5fbd5 --- /dev/null +++ b/packages/server/src/services/build-policy/hook-body.ts @@ -0,0 +1,92 @@ +import { BuildPolicyError } from "./errors"; +import { + assertSafeImageReference, + buildDigestRef, + isValidDigest, + registryHostOf, +} from "./image"; + +/** + * Optional deploy-hook body `{image, tag, digest}` (spec 5.2.9). + * + * When present the deploy skips the build entirely and deploys that image by + * digest. The image must live on a registry this organization has configured, + * so a deploy hook token cannot be turned into "run any image on my swarm". + */ +export type DeployHookImage = + | { kind: "none" } + | { + kind: "image"; + image: string; + tag: string | null; + digest: string; + ref: string; + }; + +const asRecord = (body: unknown): Record | null => + body !== null && typeof body === "object" && !Array.isArray(body) + ? (body as Record) + : null; + +export const parseDeployHookImage = ( + body: unknown, + allowedRegistryHosts: string[], +): DeployHookImage => { + const record = asRecord(body); + if (!record) return { kind: "none" }; + if (record.image === undefined || record.image === null) { + return { kind: "none" }; + } + + const { image, tag, digest } = record; + + if (typeof image !== "string" || image.trim().length === 0) { + throw new BuildPolicyError( + "INVALID_IMAGE", + "Deploy hook body has an `image` that is not a non-empty string.", + ); + } + const reference = image.trim(); + assertSafeImageReference(reference); + + if (!isValidDigest(digest)) { + throw new BuildPolicyError( + "INVALID_DIGEST", + "Deploy hook body must carry a `digest` of the form sha256:<64 hex chars>; " + + "build-policy deploys are always by digest.", + ); + } + + const host = registryHostOf(reference); + if (!host) { + throw new BuildPolicyError( + "REGISTRY_NOT_ALLOWED", + `Deploy hook image "${reference}" has no registry host. It must be fully ` + + "qualified and point at a registry configured on this organization.", + ); + } + if (!allowedRegistryHosts.includes(host)) { + throw new BuildPolicyError( + "REGISTRY_NOT_ALLOWED", + `Deploy hook image "${reference}" is on registry "${host}", which is not ` + + "configured on this organization.", + { host, allowedRegistryHosts }, + ); + } + + const explicitTag = + typeof tag === "string" && tag.trim().length > 0 ? tag.trim() : null; + const embeddedTag = (() => { + const lastSlash = reference.lastIndexOf("/"); + const lastColon = reference.lastIndexOf(":"); + return lastColon > lastSlash ? reference.slice(lastColon + 1) : null; + })(); + + return { + kind: "image", + image: reference, + tag: explicitTag ?? embeddedTag, + digest, + ref: buildDigestRef(reference, digest), + }; +}; diff --git a/packages/server/src/services/build-policy/image.ts b/packages/server/src/services/build-policy/image.ts new file mode 100644 index 0000000000..e99f4f54b5 --- /dev/null +++ b/packages/server/src/services/build-policy/image.ts @@ -0,0 +1,108 @@ +import { BuildPolicyError } from "./errors"; + +/** + * Image reference helpers for build-once: tag `:`, publish the + * digest out of the remote build shell, deploy by digest. + */ + +/** Placeholder the build shell substitutes with `git rev-parse HEAD`. */ +export const SHA_PLACEHOLDER = "__DOKPLOY_BUILD_SHA__"; + +/** + * Line the build script echoes so the digest can be read back out of the + * deployment log. The build runs as a detached shell on the build server whose + * only channel back is that log file. + */ +export const DIGEST_MARKER = "__DOKPLOY_IMAGE_DIGEST__"; + +const DIGEST_RE = /^sha256:[0-9a-f]{64}$/; + +/** Shell/registry-unsafe characters that must never reach a docker command. */ +const UNSAFE_REF_RE = /[^A-Za-z0-9._:/@-]/; + +export const isValidDigest = (digest: unknown): digest is string => + typeof digest === "string" && DIGEST_RE.test(digest); + +export const imageTagForSha = (appName: string, sha?: string | null): string => + `${appName}:${sha && sha.length > 0 ? sha : SHA_PLACEHOLDER}`; + +/** Split a reference into its repository part and its tag, if any. */ +const splitRepositoryAndTag = ( + reference: string, +): { repository: string; tag: string | null } => { + const lastSlash = reference.lastIndexOf("/"); + const lastColon = reference.lastIndexOf(":"); + // A colon before the last slash belongs to a host:port, not a tag. + if (lastColon > lastSlash) { + return { + repository: reference.slice(0, lastColon), + tag: reference.slice(lastColon + 1), + }; + } + return { repository: reference, tag: null }; +}; + +export const registryHostOf = (reference: string): string | null => { + const firstSlash = reference.indexOf("/"); + if (firstSlash === -1) return null; + const candidate = reference.slice(0, firstSlash); + if (!candidate.includes(".") && !candidate.includes(":")) return null; + return candidate; +}; + +export const buildDigestRef = (reference: string, digest: string): string => { + if (!isValidDigest(digest)) { + throw new BuildPolicyError( + "INVALID_DIGEST", + `Not a valid image digest: ${String(digest)}`, + { digest }, + ); + } + const withoutDigest = reference.split("@")[0] ?? reference; + const { repository } = splitRepositoryAndTag(withoutDigest); + return `${repository}@${digest}`; +}; + +export const assertSafeImageReference = (reference: string): void => { + if ( + typeof reference !== "string" || + reference.length === 0 || + UNSAFE_REF_RE.test(reference) + ) { + throw new BuildPolicyError( + "INVALID_IMAGE", + `Not a valid image reference: ${String(reference)}`, + { reference }, + ); + } +}; + +const markerLines = (log: string | null | undefined): string[] => { + if (typeof log !== "string" || log.length === 0) return []; + return log + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.startsWith(DIGEST_MARKER)); +}; + +/** `__DOKPLOY_IMAGE_DIGEST__ ` — last one wins. */ +const lastMarkerParts = ( + log: string | null | undefined, +): { tag: string; digest: string } | null => { + const lines = markerLines(log); + for (let i = lines.length - 1; i >= 0; i--) { + const parts = (lines[i] as string).split(/\s+/); + const tag = parts[1]; + const digest = parts[2]; + if (tag && isValidDigest(digest)) return { tag, digest }; + } + return null; +}; + +export const parseImageDigestFromLog = ( + log: string | null | undefined, +): string | null => lastMarkerParts(log)?.digest ?? null; + +export const parseImageTagFromLog = ( + log: string | null | undefined, +): string | null => lastMarkerParts(log)?.tag ?? null; diff --git a/packages/server/src/services/build-policy/policy.ts b/packages/server/src/services/build-policy/policy.ts new file mode 100644 index 0000000000..7e32f1e220 --- /dev/null +++ b/packages/server/src/services/build-policy/policy.ts @@ -0,0 +1,119 @@ +import { isGithubSourcedUnit } from "./source"; + +/** + * The whole policy decision, as a pure function. + * + * Everything that touches the database lives in `resolve.ts`; this file is the + * part that is worth reasoning about, and it is exhaustively unit tested. + */ +export type BuildPolicyUnitType = "application" | "compose"; + +export interface BuildPolicyUnitInput { + unitType: BuildPolicyUnitType; + unitId: string; + sourceType: string; + customGitUrl?: string | null; + buildServerId?: string | null; + buildRegistryId?: string | null; +} + +export interface BuildPolicySettingsInput { + enforceRemoteBuilds: boolean; + defaultBuildServerId: string | null; + defaultRegistryId: string | null; + requiredChecksTimeoutMinutes: number; +} + +export interface BuildPolicyBreakGlass { + auditId: string; + actorEmail: string; + reason: string; +} + +export interface BuildPolicyDecisionInput { + unit: BuildPolicyUnitInput; + settings: BuildPolicySettingsInput | null; + isExcluded: boolean; + breakGlass: BuildPolicyBreakGlass | null; +} + +export type BuildPolicyLocalReason = + | "not_enforced" + | "not_github" + | "excluded" + | "break_glass" + | "compose_build_not_relocatable"; + +export type BuildPolicyDecision = + | { + mode: "local"; + reason: BuildPolicyLocalReason; + breakGlassAuditId?: string; + } + | { mode: "remote"; buildServerId: string; registryId: string } + | { + mode: "error"; + code: "NO_BUILD_SERVER" | "NO_REGISTRY"; + message: string; + }; + +export const decideBuildPolicy = ( + input: BuildPolicyDecisionInput, +): BuildPolicyDecision => { + const { unit, settings, isExcluded, breakGlass } = input; + + // Policy off (or never configured) — behave exactly like upstream. + if (!settings?.enforceRemoteBuilds) { + return { mode: "local", reason: "not_enforced" }; + } + + if (!isGithubSourcedUnit(unit)) { + return { mode: "local", reason: "not_github" }; + } + + // Checked before break-glass so an exclusion does not burn a grant. + if (isExcluded) { + return { mode: "local", reason: "excluded" }; + } + + if (breakGlass) { + return { + mode: "local", + reason: "break_glass", + breakGlassAuditId: breakGlass.auditId, + }; + } + + // A compose unit builds and runs in one `docker compose up --build`, so its + // build cannot be relocated to another host without splitting the deploy in + // two. That is out of scope for this module; see README.md § Known gap. + // Every other build-policy behaviour still applies to compose units. + if (unit.unitType === "compose") { + return { mode: "local", reason: "compose_build_not_relocatable" }; + } + + const buildServerId = settings.defaultBuildServerId; + if (!buildServerId) { + return { + mode: "error", + code: "NO_BUILD_SERVER", + message: + "Enforced remote builds are on but this organization has no default build server. " + + "Set one in Settings, exclude this unit, or use the audited break-glass action. " + + "Falling back to a local build is deliberately not offered.", + }; + } + + const registryId = settings.defaultRegistryId; + if (!registryId) { + return { + mode: "error", + code: "NO_REGISTRY", + message: + "Enforced remote builds are on but this organization has no default registry, " + + "so a remote build could not be pushed or deployed by digest. Set one in Settings.", + }; + } + + return { mode: "remote", buildServerId, registryId }; +}; diff --git a/packages/server/src/services/build-policy/required-checks.ts b/packages/server/src/services/build-policy/required-checks.ts new file mode 100644 index 0000000000..1a50d9158d --- /dev/null +++ b/packages/server/src/services/build-policy/required-checks.ts @@ -0,0 +1,112 @@ +import { BuildPolicyError } from "./errors"; + +/** + * Per-unit `requiredChecks` gating (spec 5.2.7). + * + * Empty list (the default) means "deploy as soon as the image exists", so + * push-to-deploy latency is unchanged until a team opts in. + */ +export interface CheckRunLike { + name: string; + status: string; + conclusion: string | null; +} + +export type RequiredChecksState = + | { state: "satisfied" } + | { state: "pending"; waitingOn: string[] } + | { state: "failed"; failed: string[] }; + +/** Conclusions GitHub reports that do not block a deploy. */ +const PASSING_CONCLUSIONS = new Set(["success", "neutral", "skipped"]); + +export const evaluateRequiredChecks = ( + requiredChecks: string[], + runs: CheckRunLike[], +): RequiredChecksState => { + if (requiredChecks.length === 0) return { state: "satisfied" }; + + // A re-run produces a second run with the same name; the newest wins, and + // the GitHub list endpoint returns them oldest-first. + const latestByName = new Map(); + for (const run of runs) { + latestByName.set(run.name, run); + } + + const failed: string[] = []; + const waitingOn: string[] = []; + + for (const name of requiredChecks) { + const run = latestByName.get(name); + if (!run || run.status !== "completed") { + waitingOn.push(name); + continue; + } + if (!run.conclusion || !PASSING_CONCLUSIONS.has(run.conclusion)) { + failed.push(name); + } + } + + // Fail fast: a failed check will not become successful by waiting. + if (failed.length > 0) return { state: "failed", failed }; + if (waitingOn.length > 0) return { state: "pending", waitingOn }; + return { state: "satisfied" }; +}; + +export interface WaitForRequiredChecksInput { + requiredChecks: string[]; + owner: string; + repo: string; + sha: string; + timeoutMs: number; + pollIntervalMs: number; + listCheckRuns: () => Promise; + sleep: (ms: number) => Promise | void; + now: () => number; +} + +export const waitForRequiredChecks = async ({ + requiredChecks, + owner, + repo, + sha, + timeoutMs, + pollIntervalMs, + listCheckRuns, + sleep, + now, +}: WaitForRequiredChecksInput): Promise => { + if (requiredChecks.length === 0) return; + + const startedAt = now(); + let lastWaitingOn: string[] = [...requiredChecks]; + + for (;;) { + const runs = await listCheckRuns(); + const result = evaluateRequiredChecks(requiredChecks, runs); + + if (result.state === "satisfied") return; + + if (result.state === "failed") { + throw new BuildPolicyError( + "REQUIRED_CHECKS_FAILED", + `Required GitHub checks failed on ${owner}/${repo}@${sha}: ${result.failed.join(", ")}. ` + + "The deploy was stopped before the deploy step; the image, if any, stays in the registry.", + { owner, repo, sha, failed: result.failed }, + ); + } + + lastWaitingOn = result.waitingOn; + + if (now() - startedAt >= timeoutMs) break; + await sleep(pollIntervalMs); + if (now() - startedAt >= timeoutMs) break; + } + + throw new BuildPolicyError( + "REQUIRED_CHECKS_TIMEOUT", + `Timed out after ${Math.round(timeoutMs / 1000)}s waiting for required GitHub checks on ` + + `${owner}/${repo}@${sha}: ${lastWaitingOn.join(", ")} never concluded.`, + { owner, repo, sha, waitingOn: lastWaitingOn, timeoutMs }, + ); +}; diff --git a/packages/server/src/services/build-policy/skip-deploy.ts b/packages/server/src/services/build-policy/skip-deploy.ts new file mode 100644 index 0000000000..4c88a00d42 --- /dev/null +++ b/packages/server/src/services/build-policy/skip-deploy.ts @@ -0,0 +1,29 @@ +/** + * `[skip deploy]` commit-message marker. + * + * Upstream already honours the GitHub Actions `[skip ci]` family, but only in + * the GitHub App webhook and only to skip the whole delivery. This marker is + * about the *deploy*, is honoured on every provider route, and is recorded in + * the build-policy audit log so "why did my push not deploy" has an answer. + */ +export const SKIP_DEPLOY_MARKERS = [ + "[skip deploy]", + "[deploy skip]", + "[no deploy]", +] as const; + +export const hasSkipDeployMarker = ( + message: string | null | undefined, +): boolean => { + if (typeof message !== "string" || message.length === 0) return false; + const haystack = message.toLowerCase(); + return SKIP_DEPLOY_MARKERS.some((marker) => haystack.includes(marker)); +}; + +export const matchedSkipDeployMarker = ( + message: string | null | undefined, +): string | null => { + if (typeof message !== "string" || message.length === 0) return null; + const haystack = message.toLowerCase(); + return SKIP_DEPLOY_MARKERS.find((marker) => haystack.includes(marker)) ?? null; +}; diff --git a/packages/server/src/services/build-policy/source.ts b/packages/server/src/services/build-policy/source.ts new file mode 100644 index 0000000000..a7db74b70c --- /dev/null +++ b/packages/server/src/services/build-policy/source.ts @@ -0,0 +1,65 @@ +/** + * "Is this unit sourced from github.com?" + * + * Upstream has no such helper: `deriveGithubApiUrl` compares a *provider* + * `githubUrl`, never a unit's `customGitUrl`. Enterprise hosts are deliberately + * excluded — they use a different API base and are not what the org build + * policy is about. + */ +const GITHUB_HOSTS = new Set(["github.com", "www.github.com"]); + +const extractHost = (rawUrl: string): string | null => { + const url = rawUrl.trim(); + if (!url) return null; + + // scp-like syntax: git@github.com:owner/repo.git + const scpLike = /^[A-Za-z0-9._-]+@([A-Za-z0-9.-]+):/.exec(url); + if (scpLike?.[1]) return scpLike[1].toLowerCase(); + + try { + return new URL(url).hostname.toLowerCase(); + } catch { + return null; + } +}; + +export const isGithubHostUrl = ( + url: string | null | undefined, +): boolean => { + if (typeof url !== "string") return false; + const host = extractHost(url); + return host !== null && GITHUB_HOSTS.has(host); +}; + +export interface GithubSourceInput { + sourceType: string; + customGitUrl?: string | null; +} + +export const isGithubSourcedUnit = ({ + sourceType, + customGitUrl, +}: GithubSourceInput): boolean => { + if (sourceType === "github") return true; + if (sourceType === "git") return isGithubHostUrl(customGitUrl); + return false; +}; + +/** + * `owner/repo` for a github.com git URL, so a `sourceType: "git"` unit can + * still be check-gated. Returns null for anything else. + */ +export const parseGithubOwnerRepo = ( + url: string | null | undefined, +): { owner: string; repo: string } | null => { + if (!isGithubHostUrl(url) || typeof url !== "string") return null; + const withoutSuffix = url.trim().replace(/\.git$/, ""); + const path = withoutSuffix.includes("://") + ? new URL(withoutSuffix).pathname + : (withoutSuffix.split(":")[1] ?? ""); + const segments = path.split("/").filter(Boolean); + if (segments.length < 2) return null; + const [owner, repo] = segments; + if (!owner || !repo) return null; + return { owner, repo }; +}; diff --git a/packages/server/src/services/build-policy/watch-paths.ts b/packages/server/src/services/build-policy/watch-paths.ts new file mode 100644 index 0000000000..831df582f7 --- /dev/null +++ b/packages/server/src/services/build-policy/watch-paths.ts @@ -0,0 +1,98 @@ +/** + * Default `watchPaths` for a unit that has none (spec 5.2.6): derive them from + * `buildPath`, the Dockerfile directory and the compose file directory. + * + * Two deliberate choices: + * + * - `dockerfile` is resolved *under* `buildPath`, matching `getBuildAppDirectory`, + * so a Dockerfile inside the build path adds nothing. + * - An unset `dockerContextPath` is ignored even though upstream then builds + * with the repo root as context. Honouring it would collapse almost every + * dockerfile unit to `**`, which is the same as no filter and defeats the + * point. An explicitly configured context path is honoured. + * + * A unit whose build inputs really do sit at the repo root gets `**`. That is + * the honest answer, not a bug. + */ +export interface WatchPathsInput { + unitType: "application" | "compose"; + buildPath?: string | null; + dockerfile?: string | null; + dockerContextPath?: string | null; + composePath?: string | null; +} + +const ROOT = "**"; + +/** Strip `./`, leading and trailing slashes; return "" for the repo root. */ +const normalizeDir = (value: string | null | undefined): string | null => { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed) return null; + const cleaned = trimmed + .replace(/\\/g, "/") + .replace(/^\.\//, "") + .replace(/^\/+/, "") + .replace(/\/+$/, ""); + if (cleaned === "" || cleaned === ".") return ""; + return cleaned; +}; + +const dirnameOf = (filePath: string | null | undefined): string | null => { + const normalized = normalizeDir(filePath); + if (normalized === null) return null; + if (normalized === "") return ""; + const lastSlash = normalized.lastIndexOf("/"); + return lastSlash === -1 ? "" : normalized.slice(0, lastSlash); +}; + +const joinDirs = (base: string, child: string): string => { + if (base === "") return child; + if (child === "") return base; + return `${base}/${child}`; +}; + +/** Drop any directory already covered by a shallower one. */ +const dropContained = (dirs: string[]): string[] => + dirs.filter( + (dir) => + !dirs.some((other) => other !== dir && dir.startsWith(`${other}/`)), + ); + +export const deriveDefaultWatchPaths = (input: WatchPathsInput): string[] => { + let candidates: (string | null)[]; + + if (input.unitType === "compose") { + candidates = [dirnameOf(input.composePath)]; + } else { + const buildPath = normalizeDir(input.buildPath) ?? ""; + const dockerfileDir = dirnameOf(input.dockerfile); + candidates = [ + buildPath, + dockerfileDir === null ? null : joinDirs(buildPath, dockerfileDir), + normalizeDir(input.dockerContextPath), + ]; + } + + const dirs = candidates.filter((c): c is string => c !== null); + if (dirs.length === 0) return [ROOT]; + // Any input at the repo root means the whole repo is build input. + if (dirs.some((d) => d === "")) return [ROOT]; + + const unique = dropContained(Array.from(new Set(dirs))).sort(); + return unique.map((dir) => `${dir}/**`); +}; + +/** + * The value a unit should watch: its own `watchPaths` when set, otherwise the + * derived default. + */ +export const resolveWatchPaths = ( + current: string[] | null | undefined, + input: WatchPathsInput, +): { paths: string[]; derived: boolean } => { + if (Array.isArray(current) && current.length > 0) { + return { paths: current, derived: false }; + } + return { paths: deriveDefaultWatchPaths(input), derived: true }; +}; From 7bde0bdf72301c201affe88c6c2883e2867a1ea0 Mon Sep 17 00:00:00 2001 From: AminDhouib Date: Wed, 9 Sep 2026 23:37:00 -0400 Subject: [PATCH 02/18] feat(build-policy): wire the module into the deploy path Schema: build_policy_settings / _exclusion / _audit tables, a per-unit `requiredChecks` array on applications and composes, and `imageTag` / `imageDigest` on deployments. Services: organization settings, exclusions, audit trail with break-glass grants, the database-backed policy resolver, the remote tag+push+digest build shell, deploy-by-digest, GitHub check-run gating, the enqueue-time gate (skip marker, derived watchPaths, queue coalescing) and the no-build deploy of an image supplied in a deploy-hook body. tRPC: a `buildPolicy` router, organization-scoped from the session only. Upstream hook points, all marked `build-policy hook` and documented in services/build-policy/README.md: - services/application.ts: 4 hooks each in deployApplication and rebuildApplication (plan, push command, pin, deploy target) - utils/builders/index.ts: deploy-by-digest override in getImageName - pages/api/deploy/{github,[refreshToken],compose/[refreshToken]}.ts: the enqueue gate and the optional {image, tag, digest} body - server/queues/{queueSetup,queue-types,deployments-queue}.ts: the coalescing counts and the pinned-image job arm 287 added lines across 20 upstream files. Test suite: 2102 tests, 2078 passing, same 15 failures in the same 6 environment-dependent files as before the change (real docker, swarm and filesystem). --- .../deploy/application.command.test.ts | 5 + .../deploy/application.deploy-hooks.test.ts | 5 + .../deploy/github-webhook-handler.test.ts | 14 + apps/dokploy/__test__/drop/drop.test.ts | 1 + .../scopes-snapshot.test.ts.snap | 7 + apps/dokploy/__test__/traefik/traefik.test.ts | 1 + .../pages/api/deploy/[refreshToken].ts | 41 +- .../api/deploy/compose/[refreshToken].ts | 40 +- apps/dokploy/pages/api/deploy/github.ts | 48 ++- apps/dokploy/server/api/root.ts | 3 + .../server/api/routers/build-policy.ts | 181 +++++++++ .../server/queues/deployments-queue.ts | 14 +- apps/dokploy/server/queues/queue-types.ts | 6 + apps/dokploy/server/queues/queueSetup.ts | 5 + packages/server/src/db/schema/application.ts | 9 + packages/server/src/db/schema/compose.ts | 8 + packages/server/src/db/schema/deployment.ts | 8 + packages/server/src/db/schema/index.ts | 1 + packages/server/src/index.ts | 2 + packages/server/src/services/application.ts | 64 ++- .../server/src/services/build-policy/apply.ts | 374 ++++++++++++++++++ .../server/src/services/build-policy/audit.ts | 157 ++++++++ .../src/services/build-policy/exclusions.ts | 83 ++++ .../services/build-policy/github-checks.ts | 138 +++++++ .../server/src/services/build-policy/index.ts | 17 + .../services/build-policy/pinned-deploy.ts | 173 ++++++++ .../src/services/build-policy/resolve.ts | 135 +++++++ .../src/services/build-policy/settings.ts | 63 +++ .../src/services/build-policy/webhook.ts | 171 ++++++++ packages/server/src/utils/builders/index.ts | 14 +- 30 files changed, 1779 insertions(+), 9 deletions(-) create mode 100644 apps/dokploy/server/api/routers/build-policy.ts create mode 100644 packages/server/src/services/build-policy/apply.ts create mode 100644 packages/server/src/services/build-policy/audit.ts create mode 100644 packages/server/src/services/build-policy/exclusions.ts create mode 100644 packages/server/src/services/build-policy/github-checks.ts create mode 100644 packages/server/src/services/build-policy/index.ts create mode 100644 packages/server/src/services/build-policy/pinned-deploy.ts create mode 100644 packages/server/src/services/build-policy/resolve.ts create mode 100644 packages/server/src/services/build-policy/settings.ts create mode 100644 packages/server/src/services/build-policy/webhook.ts diff --git a/apps/dokploy/__test__/deploy/application.command.test.ts b/apps/dokploy/__test__/deploy/application.command.test.ts index e8def89eee..be5dfbecc6 100644 --- a/apps/dokploy/__test__/deploy/application.command.test.ts +++ b/apps/dokploy/__test__/deploy/application.command.test.ts @@ -30,6 +30,11 @@ vi.mock("@dokploy/server/db", () => { update: vi.fn(() => createChainableMock()), delete: vi.fn(), query: { + // build-policy: the deploy path resolves org policy settings first. + // No row means the policy is off, which is the default. + buildPolicySettings: { + findFirst: vi.fn().mockResolvedValue(undefined), + }, applications: { findFirst: vi.fn(), }, diff --git a/apps/dokploy/__test__/deploy/application.deploy-hooks.test.ts b/apps/dokploy/__test__/deploy/application.deploy-hooks.test.ts index 5ff5c96b12..88ba024cfd 100644 --- a/apps/dokploy/__test__/deploy/application.deploy-hooks.test.ts +++ b/apps/dokploy/__test__/deploy/application.deploy-hooks.test.ts @@ -36,6 +36,11 @@ vi.mock("@dokploy/server/db", () => { update: vi.fn(() => createChainableMock()), delete: vi.fn(), query: { + // build-policy: the deploy path resolves org policy settings first. + // No row means the policy is off, which is the default. + buildPolicySettings: { + findFirst: vi.fn().mockResolvedValue(undefined), + }, applications: { findFirst: vi.fn(), }, diff --git a/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts b/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts index edc291ed95..0f9c0fa9d2 100644 --- a/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts +++ b/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts @@ -13,6 +13,8 @@ const mocks = vi.hoisted(() => ({ verify: vi.fn(), shouldDeploy: vi.fn(), createPreviewDeployment: vi.fn(), + // build-policy: the enqueue-time gate the webhook calls before queueing. + buildPolicyDeployGate: vi.fn(), findPreviewDeploymentByApplicationId: vi.fn(), })); @@ -65,6 +67,7 @@ vi.mock("@dokploy/server/db", () => ({ vi.mock("@dokploy/server", () => ({ IS_CLOUD: false, shouldDeploy: mocks.shouldDeploy, + buildPolicyDeployGate: mocks.buildPolicyDeployGate, normalizeChangedFilesFromCommits: (commits: any) => (commits ?? []) .flatMap((commit: any) => [ @@ -96,6 +99,9 @@ vi.mock("@/server/queues/queueSetup", () => ({ myQueue: { add: mocks.queueAdd, }, + // build-policy: the gate calls these to coalesce still-waiting deploys. + cleanQueuesByApplication: vi.fn().mockResolvedValue(0), + cleanQueuesByCompose: vi.fn().mockResolvedValue(0), })); vi.mock("@/server/utils/deploy", () => ({ @@ -177,6 +183,10 @@ describe("GitHub app webhook auto-deploy", () => { githubWebhookSecret: "webhook-secret", }); mocks.verify.mockResolvedValue(true); + mocks.buildPolicyDeployGate.mockResolvedValue({ + deploy: true, + coalesced: 0, + }); mocks.shouldDeploy.mockReturnValue(true); mocks.composeFindMany.mockResolvedValue([]); mocks.queueAdd.mockResolvedValue({ id: "job-id" }); @@ -397,6 +407,10 @@ describe("GitHub app webhook preview deployments", () => { githubWebhookSecret: "webhook-secret", }); mocks.verify.mockResolvedValue(true); + mocks.buildPolicyDeployGate.mockResolvedValue({ + deploy: true, + coalesced: 0, + }); mocks.queueAdd.mockResolvedValue({ id: "job-id" }); mocks.createPreviewDeployment.mockResolvedValue({ previewDeploymentId: "new-preview-id", diff --git a/apps/dokploy/__test__/drop/drop.test.ts b/apps/dokploy/__test__/drop/drop.test.ts index 17b261d101..a60ba28af3 100644 --- a/apps/dokploy/__test__/drop/drop.test.ts +++ b/apps/dokploy/__test__/drop/drop.test.ts @@ -49,6 +49,7 @@ const baseApp: ApplicationNested = { giteaRepository: "", cleanCache: false, watchPaths: [], + requiredChecks: [], rollbackRegistryId: "", rollbackRegistry: null, deployments: [], diff --git a/apps/dokploy/__test__/mcp/__snapshots__/scopes-snapshot.test.ts.snap b/apps/dokploy/__test__/mcp/__snapshots__/scopes-snapshot.test.ts.snap index 1af9e66a66..e4ee5c79cc 100644 --- a/apps/dokploy/__test__/mcp/__snapshots__/scopes-snapshot.test.ts.snap +++ b/apps/dokploy/__test__/mcp/__snapshots__/scopes-snapshot.test.ts.snap @@ -86,6 +86,13 @@ exports[`MCP tool scope table > tool → scope snapshot 1`] = ` "bitbucket-one": "dokploy:admin", "bitbucket-testConnection": "dokploy:admin", "bitbucket-update": "dokploy:admin", + "buildPolicy-addExclusion": "dokploy:admin", + "buildPolicy-allowLocalBuildOnce": "dokploy:admin", + "buildPolicy-audit": "dokploy:read", + "buildPolicy-exclusions": "dokploy:read", + "buildPolicy-removeExclusion": "dokploy:admin", + "buildPolicy-settings": "dokploy:read", + "buildPolicy-updateSettings": "dokploy:admin", "certificates-all": "dokploy:admin", "certificates-create": "dokploy:admin", "certificates-one": "dokploy:admin", diff --git a/apps/dokploy/__test__/traefik/traefik.test.ts b/apps/dokploy/__test__/traefik/traefik.test.ts index c97cb29914..2be0c8c781 100644 --- a/apps/dokploy/__test__/traefik/traefik.test.ts +++ b/apps/dokploy/__test__/traefik/traefik.test.ts @@ -42,6 +42,7 @@ const baseApp: ApplicationNested = { dockerBuildStage: "", registryUrl: "", watchPaths: [], + requiredChecks: [], buildArgs: null, buildSecrets: null, isPreviewDeploymentsActive: false, diff --git a/apps/dokploy/pages/api/deploy/[refreshToken].ts b/apps/dokploy/pages/api/deploy/[refreshToken].ts index d5a0888c2e..571e2fec63 100644 --- a/apps/dokploy/pages/api/deploy/[refreshToken].ts +++ b/apps/dokploy/pages/api/deploy/[refreshToken].ts @@ -1,8 +1,11 @@ import { type Bitbucket, + // build-policy hook: enqueue-time gate and deploy-hook image body. + buildPolicyDeployGate, getBitbucketHeaders, IS_CLOUD, normalizeChangedFilesFromCommits, + resolveDeployHookImage, shouldDeploy, } from "@dokploy/server"; import { db } from "@dokploy/server/db"; @@ -10,7 +13,10 @@ import { eq } from "drizzle-orm"; import type { NextApiRequest, NextApiResponse } from "next"; import { applications } from "@/server/db/schema"; import type { DeploymentJob } from "@/server/queues/queue-types"; -import { myQueue } from "@/server/queues/queueSetup"; +import { + cleanQueuesByApplication, + myQueue, +} from "@/server/queues/queueSetup"; import { deploy } from "@/server/utils/deploy"; import { handleGiteaApplicationPullRequestEvent, @@ -286,6 +292,37 @@ export default async function handler( } } + // >>> build-policy hook: `[skip deploy]`, derived watchPaths, queue + // coalescing, and the optional `{image, tag, digest}` body. + // See packages/server/src/services/build-policy/README.md + const gate = await buildPolicyDeployGate({ + unitType: "application", + unit: { + unitId: application.applicationId, + unitName: application.name, + environmentId: application.environmentId, + watchPaths: application.watchPaths, + buildPath: application.buildPath, + dockerfile: application.dockerfile, + dockerContextPath: application.dockerContextPath, + }, + commitMessage: deploymentTitle, + removeWaiting: () => cleanQueuesByApplication(application.applicationId), + }); + if (!gate.deploy) { + res.status(301).json({ message: gate.message }); + return; + } + const hookImage = await resolveDeployHookImage( + application.environment.project.organizationId, + req.body, + ); + if (!hookImage.ok) { + res.status(400).json({ message: hookImage.message }); + return; + } + // <<< build-policy hook + try { const jobData: DeploymentJob = { applicationId: application.applicationId as string, @@ -294,6 +331,8 @@ export default async function handler( type: "deploy", applicationType: "application", server: !!application.serverId, + // build-policy hook: deploy this image by digest, do not build. + ...(hookImage.pinnedImage && { pinnedImage: hookImage.pinnedImage }), }; if (IS_CLOUD && application.serverId) { diff --git a/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts b/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts index aef86ddba6..58e39477e3 100644 --- a/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts +++ b/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts @@ -1,6 +1,10 @@ import { + // build-policy hook: enqueue-time gate and deploy-hook image body. + buildPolicyDeployGate, + findUnitOrganizationId, IS_CLOUD, normalizeChangedFilesFromCommits, + resolveDeployHookImage, shouldDeploy, } from "@dokploy/server"; import { db } from "@dokploy/server/db"; @@ -8,7 +12,7 @@ import { eq } from "drizzle-orm"; import type { NextApiRequest, NextApiResponse } from "next"; import { compose } from "@/server/db/schema"; import type { DeploymentJob } from "@/server/queues/queue-types"; -import { myQueue } from "@/server/queues/queueSetup"; +import { cleanQueuesByCompose, myQueue } from "@/server/queues/queueSetup"; import { deploy } from "@/server/utils/deploy"; import { handleGiteaComposePullRequestEvent, @@ -228,6 +232,40 @@ export default async function handler( } } + // >>> build-policy hook: `[skip deploy]`, derived watchPaths and queue + // coalescing. A compose unit cannot deploy a supplied image by digest + // yet (see README.md § Known gap), so such a body is rejected rather + // than silently ignored. + const gate = await buildPolicyDeployGate({ + unitType: "compose", + unit: { + unitId: composeResult.composeId, + unitName: composeResult.name, + environmentId: composeResult.environmentId, + watchPaths: composeResult.watchPaths, + composePath: composeResult.composePath, + }, + commitMessage: deploymentTitle, + removeWaiting: () => cleanQueuesByCompose(composeResult.composeId), + }); + if (!gate.deploy) { + res.status(301).json({ message: gate.message }); + return; + } + const hookImage = await resolveDeployHookImage( + await findUnitOrganizationId(composeResult.environmentId), + req.body, + ); + if (!hookImage.ok || hookImage.pinnedImage) { + res.status(400).json({ + message: hookImage.ok + ? "Deploying a supplied image by digest is not supported for compose units." + : hookImage.message, + }); + return; + } + // <<< build-policy hook + try { const jobData: DeploymentJob = { composeId: composeResult.composeId as string, diff --git a/apps/dokploy/pages/api/deploy/github.ts b/apps/dokploy/pages/api/deploy/github.ts index 9c3a5496f0..237da1a6ef 100644 --- a/apps/dokploy/pages/api/deploy/github.ts +++ b/apps/dokploy/pages/api/deploy/github.ts @@ -1,4 +1,6 @@ import { + // build-policy hook: enqueue-time gate. + buildPolicyDeployGate, checkUserRepositoryPermissions, createComposePreview, createPreviewDeployment, @@ -18,7 +20,14 @@ import { and, eq } from "drizzle-orm"; import type { NextApiRequest, NextApiResponse } from "next"; import { applications, compose, github } from "@/server/db/schema"; import type { DeploymentJob } from "@/server/queues/queue-types"; -import { myQueue } from "@/server/queues/queueSetup"; +// >>> build-policy hook: enqueue-time gate (skip marker, derived watchPaths, +// queue coalescing). See packages/server/src/services/build-policy/README.md +import { + cleanQueuesByApplication, + cleanQueuesByCompose, + myQueue, +} from "@/server/queues/queueSetup"; +// <<< build-policy hook import { deploy } from "@/server/utils/deploy"; import { extractCommitMessage, @@ -283,6 +292,25 @@ export default async function handler( continue; } + // >>> build-policy hook + const gate = await buildPolicyDeployGate({ + unitType: "application", + unit: { + unitId: app.applicationId, + unitName: app.name, + environmentId: app.environmentId, + watchPaths: app.watchPaths, + buildPath: app.buildPath, + dockerfile: app.dockerfile, + dockerContextPath: app.dockerContextPath, + }, + changedFiles: normalizedCommits, + commitMessage: deploymentTitle, + removeWaiting: () => cleanQueuesByApplication(app.applicationId), + }); + if (!gate.deploy) continue; + // <<< build-policy hook + if (IS_CLOUD && app.serverId) { jobData.serverId = app.serverId; deploy(jobData).catch((error) => { @@ -330,6 +358,24 @@ export default async function handler( if (!shouldDeployPaths) { continue; } + + // >>> build-policy hook + const composeGate = await buildPolicyDeployGate({ + unitType: "compose", + unit: { + unitId: composeApp.composeId, + unitName: composeApp.name, + environmentId: composeApp.environmentId, + watchPaths: composeApp.watchPaths, + composePath: composeApp.composePath, + }, + changedFiles: normalizedCommits, + commitMessage: deploymentTitle, + removeWaiting: () => cleanQueuesByCompose(composeApp.composeId), + }); + if (!composeGate.deploy) continue; + // <<< build-policy hook + if (IS_CLOUD && composeApp.serverId) { jobData.serverId = composeApp.serverId; deploy(jobData).catch((error) => { diff --git a/apps/dokploy/server/api/root.ts b/apps/dokploy/server/api/root.ts index f8f5b04c32..a1deec55e5 100644 --- a/apps/dokploy/server/api/root.ts +++ b/apps/dokploy/server/api/root.ts @@ -5,6 +5,8 @@ import { applicationRouter } from "./routers/application"; import { backupRouter } from "./routers/backup"; import { backupPolicyRouter } from "./routers/backup-policy"; import { bitbucketRouter } from "./routers/bitbucket"; +// Fork router. See packages/server/src/services/build-policy/README.md. +import { buildPolicyRouter } from "./routers/build-policy"; import { certificateRouter } from "./routers/certificate"; import { cloudflareRouter } from "./routers/cloudflare"; import { cloudflareAccessRouter } from "./routers/cloudflare-access"; @@ -72,6 +74,7 @@ export const appRouter = createTRPCRouter({ backup: backupRouter, backupPolicy: backupPolicyRouter, bitbucket: bitbucketRouter, + buildPolicy: buildPolicyRouter, network: networkRouter, certificates: certificateRouter, cloudflare: cloudflareRouter, diff --git a/apps/dokploy/server/api/routers/build-policy.ts b/apps/dokploy/server/api/routers/build-policy.ts new file mode 100644 index 0000000000..8450065197 --- /dev/null +++ b/apps/dokploy/server/api/routers/build-policy.ts @@ -0,0 +1,181 @@ +import { + addBuildPolicyExclusion, + findBuildPolicySettings, + findRegistryById, + getAccessibleServerIds, + grantBreakGlass, + listBuildPolicyAudit, + listBuildPolicyExclusions, + recordBuildPolicyAudit, + removeBuildPolicyExclusion, + upsertBuildPolicySettings, +} from "@dokploy/server"; +import { + apiAddBuildPolicyExclusion, + apiGrantBuildPolicyBreakGlass, + apiListBuildPolicyAudit, + apiRemoveBuildPolicyExclusion, + apiUpdateBuildPolicySettings, +} from "@dokploy/server/db/schema"; +import { TRPCError } from "@trpc/server"; +import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc"; +import { audit } from "../utils/audit"; + +/** + * Fork router: organization build policy (enforced remote builds). + * + * The organization is taken exclusively from `ctx.session.activeOrganizationId` + * and never from input, so there is no id a caller could swap to read or write + * another organization's policy. + */ +export const buildPolicyRouter = createTRPCRouter({ + settings: protectedProcedure.query(async ({ ctx }) => + findBuildPolicySettings(ctx.session.activeOrganizationId), + ), + + updateSettings: adminProcedure + .input(apiUpdateBuildPolicySettings) + .mutation(async ({ ctx, input }) => { + const organizationId = ctx.session.activeOrganizationId; + + if (input.defaultBuildServerId) { + const accessibleIds = await getAccessibleServerIds(ctx.session); + if (!accessibleIds.has(input.defaultBuildServerId)) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this build server", + }); + } + } + + if (input.defaultRegistryId) { + const registry = await findRegistryById(input.defaultRegistryId); + if (registry.organizationId !== organizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this registry", + }); + } + } + + const settings = await upsertBuildPolicySettings(organizationId, input); + + await recordBuildPolicyAudit({ + organizationId, + action: "settings_updated", + actorId: ctx.user.id, + actorEmail: ctx.user.email, + metadata: input as Record, + }); + await audit(ctx, { + action: "update", + resourceType: "organization", + resourceId: organizationId, + resourceName: "build-policy", + metadata: input as Record, + }); + + return settings; + }), + + exclusions: protectedProcedure.query(async ({ ctx }) => + listBuildPolicyExclusions(ctx.session.activeOrganizationId), + ), + + addExclusion: adminProcedure + .input(apiAddBuildPolicyExclusion) + .mutation(async ({ ctx, input }) => { + const organizationId = ctx.session.activeOrganizationId; + const exclusion = await addBuildPolicyExclusion({ + organizationId, + applicationId: input.applicationId, + composeId: input.composeId, + reason: input.reason, + }); + + await recordBuildPolicyAudit({ + organizationId, + action: "exclusion_added", + applicationId: input.applicationId ?? null, + composeId: input.composeId ?? null, + actorId: ctx.user.id, + actorEmail: ctx.user.email, + reason: input.reason ?? null, + }); + await audit(ctx, { + action: "create", + resourceType: "organization", + resourceId: organizationId, + resourceName: "build-policy-exclusion", + }); + + return exclusion; + }), + + removeExclusion: adminProcedure + .input(apiRemoveBuildPolicyExclusion) + .mutation(async ({ ctx, input }) => { + const organizationId = ctx.session.activeOrganizationId; + const removed = await removeBuildPolicyExclusion({ + organizationId, + buildPolicyExclusionId: input.buildPolicyExclusionId, + }); + if (!removed) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Exclusion not found", + }); + } + + await recordBuildPolicyAudit({ + organizationId, + action: "exclusion_removed", + applicationId: removed.applicationId, + composeId: removed.composeId, + actorId: ctx.user.id, + actorEmail: ctx.user.email, + reason: removed.reason, + }); + await audit(ctx, { + action: "delete", + resourceType: "organization", + resourceId: organizationId, + resourceName: "build-policy-exclusion", + }); + + return removed; + }), + + /** "Build locally once", admin only, spent by the next deploy of the unit. */ + allowLocalBuildOnce: adminProcedure + .input(apiGrantBuildPolicyBreakGlass) + .mutation(async ({ ctx, input }) => { + const organizationId = ctx.session.activeOrganizationId; + await grantBreakGlass({ + organizationId, + unitType: input.applicationId ? "application" : "compose", + unitId: (input.applicationId ?? input.composeId) as string, + actorId: ctx.user.id, + actorEmail: ctx.user.email, + reason: input.reason, + }); + await audit(ctx, { + action: "update", + resourceType: "organization", + resourceId: organizationId, + resourceName: "build-policy-break-glass", + metadata: { reason: input.reason }, + }); + return { success: true }; + }), + + audit: protectedProcedure + .input(apiListBuildPolicyAudit) + .query(async ({ ctx, input }) => + listBuildPolicyAudit({ + organizationId: ctx.session.activeOrganizationId, + limit: input.limit, + offset: input.offset, + }), + ), +}); diff --git a/apps/dokploy/server/queues/deployments-queue.ts b/apps/dokploy/server/queues/deployments-queue.ts index ed7dca6ff6..0dbc935704 100644 --- a/apps/dokploy/server/queues/deployments-queue.ts +++ b/apps/dokploy/server/queues/deployments-queue.ts @@ -1,5 +1,7 @@ import { deployApplication, + // build-policy hook: see the pinnedImage branch below. + deployPinnedApplicationImage, deployCompose, deployComposePreview, deployPreviewApplication, @@ -22,7 +24,17 @@ export const processDeploymentJob = async (job: InMemoryJob) => { if (job.data.applicationType === "application") { await updateApplicationStatus(job.data.applicationId, "running"); - if (job.data.type === "redeploy") { + // >>> build-policy hook: deploy-hook supplied image, no build. + // See packages/server/src/services/build-policy/README.md + if (job.data.pinnedImage) { + await deployPinnedApplicationImage({ + applicationId: job.data.applicationId, + pinnedImage: job.data.pinnedImage, + titleLog: job.data.titleLog, + descriptionLog: job.data.descriptionLog, + }); + } else if (job.data.type === "redeploy") { + // <<< build-policy hook await rebuildApplication({ applicationId: job.data.applicationId, titleLog: job.data.titleLog, diff --git a/apps/dokploy/server/queues/queue-types.ts b/apps/dokploy/server/queues/queue-types.ts index 4096942e0f..796f3fa028 100644 --- a/apps/dokploy/server/queues/queue-types.ts +++ b/apps/dokploy/server/queues/queue-types.ts @@ -7,6 +7,12 @@ type DeployJob = type: "deploy" | "redeploy"; applicationType: "application"; serverId?: string; + /** + * build-policy hook: an image supplied in the deploy-hook body. When + * present the job deploys it by digest and does not build. + * See packages/server/src/services/build-policy/README.md. + */ + pinnedImage?: { ref: string; tag: string | null; digest: string }; } | { composeId: string; diff --git a/apps/dokploy/server/queues/queueSetup.ts b/apps/dokploy/server/queues/queueSetup.ts index b104ef0d0a..7e83ada467 100644 --- a/apps/dokploy/server/queues/queueSetup.ts +++ b/apps/dokploy/server/queues/queueSetup.ts @@ -95,6 +95,9 @@ if (!IS_CLOUD) { }); } +// build-policy hook: these two now return how many waiting jobs they dropped, +// so the enqueue-time coalescing gate can audit it. Existing callers ignore the +// returned value. See packages/server/src/services/build-policy/README.md. export const cleanQueuesByApplication = async (applicationId: string) => { const removed = myQueue.removeWaiting( (data) => (data as any)?.applicationId === applicationId, @@ -104,6 +107,7 @@ export const cleanQueuesByApplication = async (applicationId: string) => { `Removed ${removed} waiting job(s) for application ${applicationId}`, ); } + return removed; }; export const cleanQueuesByCompose = async (composeId: string) => { @@ -113,6 +117,7 @@ export const cleanQueuesByCompose = async (composeId: string) => { if (removed > 0) { console.log(`Removed ${removed} waiting job(s) for compose ${composeId}`); } + return removed; }; export const cleanAllDeploymentQueue = async () => { diff --git a/packages/server/src/db/schema/application.ts b/packages/server/src/db/schema/application.ts index 157c775458..94ca2fb19b 100644 --- a/packages/server/src/db/schema/application.ts +++ b/packages/server/src/db/schema/application.ts @@ -91,6 +91,13 @@ export const applications = pgTable("application", { env: encryptedText("env"), previewEnv: encryptedText("previewEnv"), watchPaths: text("watchPaths").array(), + /** + * Fork column (build-policy). GitHub check-run names that must conclude + * successfully on the deployed commit before the deploy step runs. Empty + * (the default) means ungated, so push-to-deploy latency is unchanged until + * a team opts in. See services/build-policy/README.md. + */ + requiredChecks: text("requiredChecks").array(), previewBuildArgs: encryptedText("previewBuildArgs"), previewBuildSecrets: encryptedText("previewBuildSecrets"), previewLabels: text("previewLabels").array(), @@ -378,6 +385,8 @@ const createSchema = createInsertSchema(applications, { previewCertificateType: z.enum(["letsencrypt", "none", "custom"]).optional(), previewRequireCollaboratorPermissions: z.boolean().optional(), watchPaths: z.array(z.string()).optional().optional(), + // build-policy hook: array column, needs the same explicit zod shape. + requiredChecks: z.array(z.string()).optional(), previewLabels: z.array(z.string()).optional(), networkIds: z.array(z.string()).optional(), detachDokployNetwork: z.boolean().optional(), diff --git a/packages/server/src/db/schema/compose.ts b/packages/server/src/db/schema/compose.ts index e12995b912..a9e6eb6d3d 100644 --- a/packages/server/src/db/schema/compose.ts +++ b/packages/server/src/db/schema/compose.ts @@ -132,6 +132,12 @@ export const compose = pgTable("compose", { .notNull() .$defaultFn(() => new Date().toISOString()), watchPaths: text("watchPaths").array(), + /** + * Fork column (build-policy). GitHub check-run names that must conclude + * successfully on the deployed commit before the deploy step runs. Empty + * (the default) means ungated. See services/build-policy/README.md. + */ + requiredChecks: text("requiredChecks").array(), githubId: text("githubId").references(() => github.githubId, { onDelete: "set null", }), @@ -214,6 +220,8 @@ const createSchema = createInsertSchema(compose, { composePath: z.string().min(1), composeType: z.enum(["docker-compose", "stack"]).optional(), watchPaths: z.array(z.string()).optional(), + // build-policy hook: array column, needs the same explicit zod shape. + requiredChecks: z.array(z.string()).optional(), sourceType: z .enum(["git", "github", "gitlab", "bitbucket", "gitea", "raw"]) .optional(), diff --git a/packages/server/src/db/schema/deployment.ts b/packages/server/src/db/schema/deployment.ts index 0c1b547e01..bb4bfe52cd 100644 --- a/packages/server/src/db/schema/deployment.ts +++ b/packages/server/src/db/schema/deployment.ts @@ -73,6 +73,14 @@ export const deployments = pgTable("deployment", { buildServerId: text("buildServerId").references(() => server.serverId, { onDelete: "cascade", }), + /** + * Fork columns (build-policy). The image this deployment actually shipped: + * the `:` tag that was pushed and the digest the swarm + * service was pinned to. Null on a local build. Together they are what a + * rollback redeploys with no build. + */ + imageTag: text("imageTag"), + imageDigest: text("imageDigest"), }); export const deploymentsRelations = relations(deployments, ({ one }) => ({ diff --git a/packages/server/src/db/schema/index.ts b/packages/server/src/db/schema/index.ts index dc1f114997..79a2872290 100644 --- a/packages/server/src/db/schema/index.ts +++ b/packages/server/src/db/schema/index.ts @@ -5,6 +5,7 @@ export * from "./audit-log"; export * from "./backup-policy"; export * from "./backups"; export * from "./bitbucket"; +export * from "./build-policy"; export * from "./certificate"; export * from "./cloudflare"; export * from "./cloudflare-access"; diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index fa2da54cc4..e55cd52495 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -12,6 +12,8 @@ export * from "./services/application"; export * from "./services/backup"; export * from "./services/backup-policy"; export * from "./services/bitbucket"; +// Fork module. See services/build-policy/README.md. +export * from "./services/build-policy"; export * from "./services/certificate"; export * from "./services/cloudflare"; export * from "./services/cloudflare-access"; diff --git a/packages/server/src/services/application.ts b/packages/server/src/services/application.ts index 1317c41db4..38ecbe543a 100644 --- a/packages/server/src/services/application.ts +++ b/packages/server/src/services/application.ts @@ -35,6 +35,13 @@ import { deployHook } from "../db/schema"; import { parseDeployHooks, runDeployHook } from "../utils/docker/hooks"; import { encodeBase64, waitForSwarmServiceStable } from "../utils/docker/utils"; import { getDokployUrl } from "./admin"; +// Fork module: enforced remote builds. See services/build-policy/README.md. +import { + getBuildPolicyPushCommand, + planApplicationBuild, + prepareBuildPolicyDeploy, + toBuildPolicyUnit, +} from "./build-policy/apply"; import { createDeployment, createDeploymentPreview, @@ -191,7 +198,14 @@ export const deployApplication = async ({ descriptionLog: string; }) => { const application = await findApplicationById(applicationId); - const serverId = application.buildServerId || application.serverId; + // >>> build-policy hook 1/4: enforced remote builds. + // See packages/server/src/services/build-policy/README.md + const buildPolicy = await planApplicationBuild(toBuildPolicyUnit(application)); + const serverId = + buildPolicy.buildServerId || + application.buildServerId || + application.serverId; + // <<< build-policy hook 1/4 const applicationEntity = { ...application, serverId: serverId, @@ -230,6 +244,14 @@ export const deployApplication = async ({ command += await getBuildCommand(application); + // >>> build-policy hook 2/4: tag `:`, push to the + // organization registry and echo the digest. Empty when not enforcing. + command += await getBuildPolicyPushCommand(buildPolicy, { + appName: application.appName, + serverId, + }); + // <<< build-policy hook 2/4 + const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`; if (serverId) { await execAsyncRemote(serverId, commandWithLog); @@ -237,6 +259,16 @@ export const deployApplication = async ({ await execAsync(commandWithLog); } + // >>> build-policy hook 3/4: gate on required checks, then pin the deploy + // to the digest that was just published. Identity when not enforcing. + const deployTarget = await prepareBuildPolicyDeploy({ + application, + plan: buildPolicy, + deployment, + serverId, + }); + // <<< build-policy hook 3/4 + const hookRow = await db.query.deployHook.findFirst({ where: eq(deployHook.applicationId, application.applicationId), }); @@ -254,7 +286,9 @@ export const deployApplication = async ({ logPath: deployment.logPath, }); - await mechanizeDockerContainer(application); + // build-policy hook 4/4: `deployTarget` is `application` plus the pinned + // digest when a remote build was enforced. See hook 3/4 above. + await mechanizeDockerContainer(deployTarget); const stability = await waitForSwarmServiceStable(application.appName, { serverId: application.serverId, @@ -352,7 +386,13 @@ export const rebuildApplication = async ({ descriptionLog: string; }) => { const application = await findApplicationById(applicationId); - const serverId = application.buildServerId || application.serverId; + // >>> build-policy hook 1/4 (rebuild). See services/build-policy/README.md + const buildPolicy = await planApplicationBuild(toBuildPolicyUnit(application)); + const serverId = + buildPolicy.buildServerId || + application.buildServerId || + application.serverId; + // <<< build-policy hook 1/4 const buildLink = `${await getDokployUrl()}/dashboard/project/${application.environment.projectId}/environment/${application.environmentId}/services/application/${application.applicationId}?tab=deployments`; const deployment = await createDeployment({ @@ -365,6 +405,12 @@ export const rebuildApplication = async ({ let command = "set -e;"; // Check case for docker only command += await getBuildCommand(application); + // >>> build-policy hook 2/4 (rebuild) + command += await getBuildPolicyPushCommand(buildPolicy, { + appName: application.appName, + serverId, + }); + // <<< build-policy hook 2/4 const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`; if (serverId) { await execAsyncRemote(serverId, commandWithLog); @@ -372,6 +418,15 @@ export const rebuildApplication = async ({ await execAsync(commandWithLog); } + // >>> build-policy hook 3/4 (rebuild) + const deployTarget = await prepareBuildPolicyDeploy({ + application, + plan: buildPolicy, + deployment, + serverId, + }); + // <<< build-policy hook 3/4 + const hookRow = await db.query.deployHook.findFirst({ where: eq(deployHook.applicationId, application.applicationId), }); @@ -387,7 +442,8 @@ export const rebuildApplication = async ({ logPath: deployment.logPath, }); - await mechanizeDockerContainer(application); + // build-policy hook 4/4 (rebuild): see hook 3/4 above. + await mechanizeDockerContainer(deployTarget); const stability = await waitForSwarmServiceStable(application.appName, { serverId: application.serverId, diff --git a/packages/server/src/services/build-policy/apply.ts b/packages/server/src/services/build-policy/apply.ts new file mode 100644 index 0000000000..b3f3550ed4 --- /dev/null +++ b/packages/server/src/services/build-policy/apply.ts @@ -0,0 +1,374 @@ +import { join } from "node:path"; +import { paths } from "@dokploy/server/constants"; +import { getSafeRegistryLoginCommand } from "@dokploy/server/db/schema"; +import { getECRAuthToken } from "@dokploy/server/utils/aws/ecr"; +import { getRegistryTag } from "@dokploy/server/utils/cluster/upload"; +import { + execAsync, + execAsyncRemote, +} from "@dokploy/server/utils/process/execAsync"; +import { getGitCommitInfo } from "@dokploy/server/utils/providers/git"; +import { quote } from "shell-quote"; +import { updateDeployment } from "../deployment"; +import { findRegistryByIdWithCredentials } from "../registry"; +import { recordBuildPolicyAudit } from "./audit"; +import { BuildPolicyError } from "./errors"; +import { waitForUnitRequiredChecks } from "./github-checks"; +import { + DIGEST_MARKER, + buildDigestRef, + parseImageDigestFromLog, + parseImageTagFromLog, +} from "./image"; +import type { BuildPolicyDecision } from "./policy"; +import { assertBuildPolicyOk, resolveBuildPolicy } from "./resolve"; +import { findBuildPolicySettings, requiredChecksTimeoutMs } from "./settings"; + +/** + * The deploy-path entry point for applications. + * + * `planApplicationBuild` is called once at the top of a deploy; everything the + * deploy path needs afterwards is on the returned plan, so the upstream hook + * points stay one or two lines each. + */ +export interface BuildPolicyPlan { + /** Whether this deploy is pinned to the org build server. */ + enforced: boolean; + /** Why not, when it is not enforced. Useful in logs and tests. */ + reason?: string; + /** Build server to run the build on, or null to leave upstream alone. */ + buildServerId: string | null; + /** Registry the image is pushed to and pulled from, when enforced. */ + registryId: string | null; + /** Full repository reference (`host/prefix/app`) the sha tag hangs off. */ + repository: string | null; +} + +interface PlanUnit { + unitId: string; + unitName: string; + appName: string; + organizationId: string; + sourceType: string; + customGitUrl?: string | null; + buildServerId?: string | null; + buildRegistryId?: string | null; +} + +/** + * The registry object `getAuthConfig` expects on `application.buildRegistry`: + * every column except the password, which it re-reads itself. ECR needs + * `awsSecretAccessKey` present, so `findRegistryById` is not enough. + */ +export const registryForAuth = async (registryId: string) => { + const { password, ...rest } = + await findRegistryByIdWithCredentials(registryId); + return rest; +}; + +const LOCAL_PLAN = (reason: string): BuildPolicyPlan => ({ + enforced: false, + reason, + buildServerId: null, + registryId: null, + repository: null, +}); + +/** + * Adapter from a loaded application row to the policy input, so the upstream + * call sites never have to know which fields the policy reads. + */ +export const toBuildPolicyUnit = (application: { + applicationId: string; + appName: string; + name: string; + sourceType: string; + customGitUrl?: string | null; + buildServerId?: string | null; + buildRegistryId?: string | null; + environment: { project: { organizationId: string } }; +}): PlanUnit => ({ + unitId: application.applicationId, + unitName: application.name, + appName: application.appName, + organizationId: application.environment.project.organizationId, + sourceType: application.sourceType, + customGitUrl: application.customGitUrl, + buildServerId: application.buildServerId, + buildRegistryId: application.buildRegistryId, +}); + +export const planApplicationBuild = async ( + unit: PlanUnit, +): Promise => { + const { decision } = await resolveBuildPolicy({ + unitType: "application", + unitId: unit.unitId, + unitName: unit.unitName, + organizationId: unit.organizationId, + sourceType: unit.sourceType, + customGitUrl: unit.customGitUrl, + buildServerId: unit.buildServerId, + buildRegistryId: unit.buildRegistryId, + }); + + const ok = assertBuildPolicyOk(decision) as Exclude< + BuildPolicyDecision, + { mode: "error" } + >; + + if (ok.mode === "local") { + return LOCAL_PLAN(ok.reason); + } + + const registry = await findRegistryByIdWithCredentials(ok.registryId); + return { + enforced: true, + buildServerId: ok.buildServerId, + registryId: ok.registryId, + repository: getRegistryTag(registry, unit.appName), + }; +}; + +/** + * Shell appended to the build command, on the build server, after the image has + * been built. Tags `:`, pushes, and echoes the resulting + * digest so the deploy step can pin it. + * + * The sha is resolved in the shell (`git rev-parse HEAD`) rather than passed + * in, because a manual redeploy has no webhook payload to read it from. + */ +export const getBuildPolicyPushCommand = async ( + plan: BuildPolicyPlan, + { appName, serverId }: { appName: string; serverId: string | null }, +): Promise => { + if (!plan.enforced || !plan.registryId || !plan.repository) return ""; + + const registry = await findRegistryByIdWithCredentials(plan.registryId); + let ecrAuthPassword: string | undefined; + if (registry.registryType === "awsEcr") { + const token = await getECRAuthToken({ + awsAccessKeyId: registry.awsAccessKeyId || "", + awsSecretAccessKey: registry.awsSecretAccessKey || "", + awsRegion: registry.awsRegion || "", + }); + ecrAuthPassword = token.password; + } + const loginCommand = getSafeRegistryLoginCommand({ + registryType: registry.registryType, + registryUrl: registry.registryUrl, + username: registry.username, + password: registry.password, + ecrAuthPassword, + }); + + const { APPLICATIONS_PATH } = paths(!!serverId); + const codeDir = join(APPLICATIONS_PATH, appName, "code"); + const repository = plan.repository; + + return ` +echo ${quote([`🏷️ [build-policy] Publishing ${repository}: to the organization registry`])} ; +DOKPLOY_BP_SHA=$(git -C ${quote([codeDir])} rev-parse HEAD 2>/dev/null || echo "") ; +if [ -z "$DOKPLOY_BP_SHA" ]; then + echo "❌ [build-policy] Could not resolve the commit sha, so the image cannot be tagged by sha" ; + exit 1; +fi +DOKPLOY_BP_TAG=${quote([repository])}:"$DOKPLOY_BP_SHA" ; +${loginCommand} || { + echo "❌ [build-policy] Registry login failed" ; + exit 1; +} +docker tag ${quote([`${appName}:latest`])} "$DOKPLOY_BP_TAG" || { + echo "❌ [build-policy] Tagging the image by sha failed" ; + exit 1; +} +docker push "$DOKPLOY_BP_TAG" || { + echo "❌ [build-policy] Pushing the image to the organization registry failed" ; + exit 1; +} +DOKPLOY_BP_DIGEST=$(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$DOKPLOY_BP_TAG" | grep -F ${quote([`${repository}@`])} | head -n1 | cut -d@ -f2) ; +if [ -z "$DOKPLOY_BP_DIGEST" ]; then + echo "❌ [build-policy] Could not read the digest of the pushed image" ; + exit 1; +fi +echo "${DIGEST_MARKER} $DOKPLOY_BP_TAG $DOKPLOY_BP_DIGEST" ; +echo "✅ [build-policy] Pushed $DOKPLOY_BP_TAG@$DOKPLOY_BP_DIGEST" ; +`; +}; + +export interface PublishedImage { + tag: string; + digest: string; + /** `repository@sha256:…` — what the swarm service is pinned to. */ + ref: string; +} + +/** + * Reads back the digest the build shell echoed. The build runs detached on the + * build server, so the deployment log is the only channel it has. + */ +export const readPublishedImage = async ({ + logPath, + serverId, +}: { + logPath: string; + serverId: string | null; +}): Promise => { + const command = `grep -F ${quote([DIGEST_MARKER])} ${quote([logPath])} | tail -n 1`; + let stdout = ""; + try { + const result = serverId + ? await execAsyncRemote(serverId, command) + : await execAsync(command); + stdout = result.stdout ?? ""; + } catch (error) { + // `grep` exits 1 when it matches nothing; that is "no digest", not a crash. + console.error("[build-policy] could not read the published digest", error); + return null; + } + + const tag = parseImageTagFromLog(stdout); + const digest = parseImageDigestFromLog(stdout); + if (!tag || !digest) return null; + return { tag, digest, ref: buildDigestRef(tag, digest) }; +}; + +/** + * The whole post-build half of an enforced deploy: read the digest, refuse to + * continue without one, and audit the pin. + */ +export const requirePublishedImage = async ({ + plan, + logPath, + serverId, + organizationId, + applicationId, + unitName, +}: { + plan: BuildPolicyPlan; + logPath: string; + serverId: string | null; + organizationId: string; + applicationId: string; + unitName: string; +}): Promise => { + const published = await readPublishedImage({ logPath, serverId }); + if (!published) { + throw new BuildPolicyError( + "DIGEST_NOT_PUBLISHED", + "The remote build finished but published no image digest, so the deploy " + + "cannot be pinned. Check the build log for the registry push step.", + { applicationId, repository: plan.repository }, + ); + } + + await recordBuildPolicyAudit({ + organizationId, + action: "deploy_by_digest", + applicationId, + metadata: { + unitName, + imageTag: published.tag, + imageDigest: published.digest, + }, + }); + + return published; +}; + +/** + * Everything an enforced deploy does between "the build finished" and "update + * the swarm service": gate on required checks, read the published digest, store + * it on the deployment record, and hand back the application object the deploy + * step should use. + * + * Returns the application unchanged when the policy is not enforcing, so the + * upstream call site is a single assignment either way. + */ +export const prepareBuildPolicyDeploy = async < + T extends { + applicationId: string; + appName: string; + name: string; + sourceType: string; + owner?: string | null; + repository?: string | null; + customGitUrl?: string | null; + githubId?: string | null; + requiredChecks?: string[] | null; + buildRegistry?: unknown; + environment: { project: { organizationId: string } }; + }, +>({ + application, + plan, + deployment, + serverId, +}: { + application: T; + plan: BuildPolicyPlan; + deployment: { deploymentId: string; logPath: string }; + serverId: string | null; +}): Promise => { + const organizationId = application.environment.project.organizationId; + + const published = plan.enforced + ? await requirePublishedImage({ + plan, + logPath: deployment.logPath, + serverId, + organizationId, + applicationId: application.applicationId, + unitName: application.name, + }) + : null; + + // Gate on required checks before the deploy step. The sha comes from the tag + // the build just published when there is one, otherwise from the checkout. + const sha = + published?.tag.split(":").pop() ?? + ( + await getGitCommitInfo({ + appName: application.appName, + type: "application", + serverId, + }) + )?.hash ?? + null; + + await waitForUnitRequiredChecks({ + unit: { + unitType: "application", + unitId: application.applicationId, + unitName: application.name, + organizationId, + requiredChecks: application.requiredChecks, + sourceType: application.sourceType, + githubId: application.githubId, + owner: application.owner, + repository: application.repository, + customGitUrl: application.customGitUrl, + }, + sha, + timeoutMs: requiredChecksTimeoutMs( + await findBuildPolicySettings(organizationId), + ), + }); + + if (!published) return application; + + await updateDeployment(deployment.deploymentId, { + imageTag: published.tag, + imageDigest: published.digest, + }); + + return { + ...application, + buildPolicyImage: published.ref, + // The deploy host has to authenticate to pull the digest. When the unit + // itself has no registry configured, borrow the org one for auth only. + buildRegistry: + application.buildRegistry ?? + (plan.registryId ? await registryForAuth(plan.registryId) : null), + }; +}; diff --git a/packages/server/src/services/build-policy/audit.ts b/packages/server/src/services/build-policy/audit.ts new file mode 100644 index 0000000000..833f62f673 --- /dev/null +++ b/packages/server/src/services/build-policy/audit.ts @@ -0,0 +1,157 @@ +import { db } from "@dokploy/server/db"; +import { + type BuildPolicyAudit, + type BuildPolicyAuditAction, + buildPolicyAudit, +} from "@dokploy/server/db/schema"; +import { and, desc, eq, isNull } from "drizzle-orm"; +import type { BuildPolicyUnitType } from "./policy"; + +/** + * Append-only trail for build-policy decisions, plus the break-glass grants + * that live in the same table (a grant is a `break_glass_granted` row with + * `consumedAt IS NULL`). + */ +export interface BuildPolicyAuditInput { + organizationId: string; + action: BuildPolicyAuditAction; + applicationId?: string | null; + composeId?: string | null; + actorId?: string | null; + actorEmail?: string | null; + reason?: string | null; + metadata?: Record | null; +} + +export const recordBuildPolicyAudit = async ( + input: BuildPolicyAuditInput, +): Promise => { + try { + const [row] = await db + .insert(buildPolicyAudit) + .values({ + organizationId: input.organizationId, + action: input.action, + applicationId: input.applicationId ?? null, + composeId: input.composeId ?? null, + actorId: input.actorId ?? null, + actorEmail: input.actorEmail ?? null, + reason: input.reason ?? null, + metadata: input.metadata ? JSON.stringify(input.metadata) : null, + }) + .returning(); + return row ?? null; + } catch (error) { + // Auditing must never take a deploy down with it. + console.error("[build-policy] failed to write audit entry", error); + return null; + } +}; + +export const listBuildPolicyAudit = async ({ + organizationId, + limit = 50, + offset = 0, +}: { + organizationId: string; + limit?: number; + offset?: number; +}) => { + const [rows, total] = await Promise.all([ + db.query.buildPolicyAudit.findMany({ + where: eq(buildPolicyAudit.organizationId, organizationId), + orderBy: [desc(buildPolicyAudit.createdAt)], + limit, + offset, + with: { application: true, compose: true }, + }), + db.$count(buildPolicyAudit, eq(buildPolicyAudit.organizationId, organizationId)), + ]); + return { logs: rows, total }; +}; + +const unitColumn = (unitType: BuildPolicyUnitType) => + unitType === "application" + ? buildPolicyAudit.applicationId + : buildPolicyAudit.composeId; + +export interface BreakGlassUnit { + organizationId: string; + unitType: BuildPolicyUnitType; + unitId: string; +} + +/** "Build locally once" — admin action, consumed by the next deploy. */ +export const grantBreakGlass = async ({ + organizationId, + unitType, + unitId, + actorId, + actorEmail, + reason, +}: BreakGlassUnit & { + actorId: string | null; + actorEmail: string; + reason: string; +}) => + db + .insert(buildPolicyAudit) + .values({ + organizationId, + action: "break_glass_granted", + applicationId: unitType === "application" ? unitId : null, + composeId: unitType === "compose" ? unitId : null, + actorId, + actorEmail, + reason, + }) + .returning(); + +export const findPendingBreakGlass = async ({ + organizationId, + unitType, + unitId, +}: BreakGlassUnit): Promise => { + const row = await db.query.buildPolicyAudit.findFirst({ + where: and( + eq(buildPolicyAudit.organizationId, organizationId), + eq(buildPolicyAudit.action, "break_glass_granted"), + eq(unitColumn(unitType), unitId), + isNull(buildPolicyAudit.consumedAt), + ), + orderBy: [desc(buildPolicyAudit.createdAt)], + }); + return row ?? null; +}; + +/** + * Stamp the grant as used and write the matching `break_glass_consumed` entry, + * so the trail shows both who authorised the local build and which deploy + * spent it. + */ +export const consumeBreakGlass = async ({ + grant, + organizationId, + unitType, + unitId, + metadata, +}: BreakGlassUnit & { + grant: BuildPolicyAudit; + metadata?: Record; +}) => { + await db + .update(buildPolicyAudit) + .set({ consumedAt: new Date() }) + .where(eq(buildPolicyAudit.buildPolicyAuditId, grant.buildPolicyAuditId)); + + await recordBuildPolicyAudit({ + organizationId, + action: "break_glass_consumed", + applicationId: unitType === "application" ? unitId : null, + composeId: unitType === "compose" ? unitId : null, + actorId: grant.actorId, + actorEmail: grant.actorEmail, + reason: grant.reason, + metadata: { grantId: grant.buildPolicyAuditId, ...metadata }, + }); +}; diff --git a/packages/server/src/services/build-policy/exclusions.ts b/packages/server/src/services/build-policy/exclusions.ts new file mode 100644 index 0000000000..f719a7d1df --- /dev/null +++ b/packages/server/src/services/build-policy/exclusions.ts @@ -0,0 +1,83 @@ +import { db } from "@dokploy/server/db"; +import { + type BuildPolicyExclusion, + buildPolicyExclusion, +} from "@dokploy/server/db/schema"; +import { and, eq } from "drizzle-orm"; +import type { BuildPolicyUnitType } from "./policy"; + +/** Units that keep a local build while enforcement is on (spec 5.2.2). */ +const unitColumn = (unitType: BuildPolicyUnitType) => + unitType === "application" + ? buildPolicyExclusion.applicationId + : buildPolicyExclusion.composeId; + +export const listBuildPolicyExclusions = async (organizationId: string) => + db.query.buildPolicyExclusion.findMany({ + where: eq(buildPolicyExclusion.organizationId, organizationId), + with: { application: true, compose: true }, + }); + +export const isUnitExcluded = async ({ + organizationId, + unitType, + unitId, +}: { + organizationId: string; + unitType: BuildPolicyUnitType; + unitId: string; +}): Promise => { + const row = await db.query.buildPolicyExclusion.findFirst({ + where: and( + eq(buildPolicyExclusion.organizationId, organizationId), + eq(unitColumn(unitType), unitId), + ), + }); + return !!row; +}; + +export const addBuildPolicyExclusion = async ({ + organizationId, + applicationId, + composeId, + reason, +}: { + organizationId: string; + applicationId?: string | null; + composeId?: string | null; + reason?: string | null; +}): Promise => { + const [row] = await db + .insert(buildPolicyExclusion) + .values({ + organizationId, + applicationId: applicationId ?? null, + composeId: composeId ?? null, + reason: reason ?? null, + }) + .returning(); + if (!row) throw new Error("Failed to add build policy exclusion"); + return row; +}; + +export const removeBuildPolicyExclusion = async ({ + organizationId, + buildPolicyExclusionId, +}: { + organizationId: string; + buildPolicyExclusionId: string; +}): Promise => { + const [row] = await db + .delete(buildPolicyExclusion) + .where( + and( + eq(buildPolicyExclusion.organizationId, organizationId), + eq( + buildPolicyExclusion.buildPolicyExclusionId, + buildPolicyExclusionId, + ), + ), + ) + .returning(); + return row ?? null; +}; diff --git a/packages/server/src/services/build-policy/github-checks.ts b/packages/server/src/services/build-policy/github-checks.ts new file mode 100644 index 0000000000..2d84a2bf59 --- /dev/null +++ b/packages/server/src/services/build-policy/github-checks.ts @@ -0,0 +1,138 @@ +import { authGithub } from "@dokploy/server/utils/providers/github"; +import { findGithubById } from "../github"; +import { recordBuildPolicyAudit } from "./audit"; +import { BuildPolicyError } from "./errors"; +import type { BuildPolicyUnitType } from "./policy"; +import { + type CheckRunLike, + waitForRequiredChecks, +} from "./required-checks"; +import { parseGithubOwnerRepo } from "./source"; + +/** + * Per-unit `requiredChecks` gating, wired to the GitHub App installation token + * the fork already holds. Empty list (the default) is a no-op. + * + * Fails closed: if the checks cannot be read, the deploy stops rather than + * proceeding as though they had passed. + */ +const DEFAULT_POLL_INTERVAL_MS = 15_000; + +const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +export interface RequiredChecksUnit { + unitType: BuildPolicyUnitType; + unitId: string; + unitName: string; + organizationId: string; + requiredChecks: string[] | null | undefined; + sourceType: string; + githubId?: string | null; + owner?: string | null; + repository?: string | null; + customGitUrl?: string | null; +} + +const resolveOwnerRepo = ( + unit: RequiredChecksUnit, +): { owner: string; repo: string } => { + if (unit.sourceType === "github" && unit.owner && unit.repository) { + return { owner: unit.owner, repo: unit.repository }; + } + const parsed = parseGithubOwnerRepo(unit.customGitUrl); + if (parsed) return parsed; + throw new BuildPolicyError( + "REQUIRED_CHECKS_UNAVAILABLE", + `Required checks are configured on "${unit.unitName}" but its repository ` + + "could not be determined, so they cannot be verified.", + ); +}; + +export const waitForUnitRequiredChecks = async ({ + unit, + sha, + timeoutMs, + pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, + listCheckRunsOverride, + sleepOverride, + nowOverride, +}: { + unit: RequiredChecksUnit; + sha: string | null | undefined; + timeoutMs: number; + pollIntervalMs?: number; + /** Test seams; production passes none of these. */ + listCheckRunsOverride?: () => Promise; + sleepOverride?: (ms: number) => Promise | void; + nowOverride?: () => number; +}): Promise => { + const requiredChecks = (unit.requiredChecks ?? []).filter( + (name): name is string => typeof name === "string" && name.length > 0, + ); + if (requiredChecks.length === 0) return; + + if (!sha) { + throw new BuildPolicyError( + "REQUIRED_CHECKS_UNAVAILABLE", + `Required checks are configured on "${unit.unitName}" but this deploy ` + + "carries no commit sha to check against.", + ); + } + + const { owner, repo } = resolveOwnerRepo(unit); + + const listCheckRuns = + listCheckRunsOverride ?? + (async (): Promise => { + if (!unit.githubId) { + throw new BuildPolicyError( + "REQUIRED_CHECKS_UNAVAILABLE", + `Required checks are configured on "${unit.unitName}" but it is not ` + + "connected to a GitHub App provider, so they cannot be verified.", + ); + } + const provider = await findGithubById(unit.githubId); + const octokit = authGithub(provider); + const { data } = await octokit.rest.checks.listForRef({ + owner, + repo, + ref: sha, + per_page: 100, + }); + return (data.check_runs ?? []).map((run) => ({ + name: run.name, + status: run.status, + conclusion: run.conclusion ?? null, + })); + }); + + try { + await waitForRequiredChecks({ + requiredChecks, + owner, + repo, + sha, + timeoutMs, + pollIntervalMs, + listCheckRuns, + sleep: sleepOverride ?? sleep, + now: nowOverride ?? Date.now, + }); + } catch (error) { + const code = + error instanceof BuildPolicyError ? error.code : "REQUIRED_CHECKS_FAILED"; + await recordBuildPolicyAudit({ + organizationId: unit.organizationId, + action: + code === "REQUIRED_CHECKS_TIMEOUT" + ? "required_checks_timeout" + : "required_checks_failed", + applicationId: unit.unitType === "application" ? unit.unitId : null, + composeId: unit.unitType === "compose" ? unit.unitId : null, + reason: error instanceof Error ? error.message : String(error), + metadata: { owner, repo, sha, requiredChecks }, + }); + throw error; + } +}; diff --git a/packages/server/src/services/build-policy/index.ts b/packages/server/src/services/build-policy/index.ts new file mode 100644 index 0000000000..f81816cf4a --- /dev/null +++ b/packages/server/src/services/build-policy/index.ts @@ -0,0 +1,17 @@ +export * from "./apply"; +export * from "./audit"; +export * from "./coalesce"; +export * from "./errors"; +export * from "./exclusions"; +export * from "./github-checks"; +export * from "./hook-body"; +export * from "./image"; +export * from "./pinned-deploy"; +export * from "./policy"; +export * from "./required-checks"; +export * from "./resolve"; +export * from "./settings"; +export * from "./skip-deploy"; +export * from "./source"; +export * from "./watch-paths"; +export * from "./webhook"; diff --git a/packages/server/src/services/build-policy/pinned-deploy.ts b/packages/server/src/services/build-policy/pinned-deploy.ts new file mode 100644 index 0000000000..ee8e377837 --- /dev/null +++ b/packages/server/src/services/build-policy/pinned-deploy.ts @@ -0,0 +1,173 @@ +import { mechanizeDockerContainer } from "@dokploy/server/utils/builders"; +import { + encodeBase64, + waitForSwarmServiceStable, +} from "@dokploy/server/utils/docker/utils"; +import { sendBuildErrorNotifications } from "@dokploy/server/utils/notifications/build-error"; +import { sendBuildSuccessNotifications } from "@dokploy/server/utils/notifications/build-success"; +import { + execAsync, + execAsyncRemote, +} from "@dokploy/server/utils/process/execAsync"; +import { getDokployUrl } from "../admin"; +import { + findApplicationById, + updateApplicationStatus, +} from "../application"; +import { findAllRegistryByOrganizationId } from "../registry"; +import { + createDeployment, + updateDeployment, + updateDeploymentStatus, +} from "../deployment"; +import { registryForAuth } from "./apply"; +import { recordBuildPolicyAudit } from "./audit"; +import { waitForUnitRequiredChecks } from "./github-checks"; +import type { DeployHookImage } from "./hook-body"; +import { registryHostOf } from "./image"; +import { findBuildPolicySettings, requiredChecksTimeoutMs } from "./settings"; + +/** + * Deploy an image supplied in a deploy-hook body (spec 5.2.9), with no build. + * + * This is a whole deploy of its own rather than a branch inside + * `deployApplication`, so the upstream deploy path keeps one added line and an + * upstream merge has nothing to reconcile here. + */ +export const deployPinnedApplicationImage = async ({ + applicationId, + pinnedImage, + titleLog = "Deploy hook image", + descriptionLog = "", +}: { + applicationId: string; + pinnedImage: { ref: string; tag: string | null; digest: string }; + titleLog?: string; + descriptionLog?: string; +}) => { + const application = await findApplicationById(applicationId); + const organizationId = application.environment.project.organizationId; + const buildLink = `${await getDokployUrl()}/dashboard/project/${application.environment.projectId}/environment/${application.environmentId}/services/application/${application.applicationId}?tab=deployments`; + + const deployment = await createDeployment({ + applicationId, + title: titleLog, + description: descriptionLog, + }); + + const log = async (message: string) => { + const command = `echo "${encodeBase64(message)}" | base64 -d >> "${deployment.logPath}";`; + if (application.serverId) { + await execAsyncRemote(application.serverId, command); + } else { + await execAsync(command); + } + }; + + try { + await log( + `📦 [build-policy] Deploy hook supplied an image; skipping the build.\n` + + ` image: ${pinnedImage.tag ?? pinnedImage.ref}\n` + + ` digest: ${pinnedImage.digest}\n`, + ); + + await waitForUnitRequiredChecks({ + unit: { + unitType: "application", + unitId: application.applicationId, + unitName: application.name, + organizationId, + requiredChecks: application.requiredChecks, + sourceType: application.sourceType, + githubId: application.githubId, + owner: application.owner, + repository: application.repository, + customGitUrl: application.customGitUrl, + }, + sha: pinnedImage.tag, + timeoutMs: requiredChecksTimeoutMs( + await findBuildPolicySettings(organizationId), + ), + }); + + await updateDeployment(deployment.deploymentId, { + imageTag: pinnedImage.tag, + imageDigest: pinnedImage.digest, + }); + + await mechanizeDockerContainer({ + ...application, + buildPolicyImage: pinnedImage.ref, + buildRegistry: + application.buildRegistry ?? + (await findRegistryForHost(organizationId, pinnedImage.ref)), + }); + + const stability = await waitForSwarmServiceStable(application.appName, { + serverId: application.serverId, + }); + if (!stability.stable) { + throw new Error( + `Container did not stay running after deployment: ${stability.reason}`, + ); + } + + await recordBuildPolicyAudit({ + organizationId, + action: "deploy_by_digest", + applicationId, + reason: "deploy hook supplied the image", + metadata: { + unitName: application.name, + imageTag: pinnedImage.tag, + imageDigest: pinnedImage.digest, + }, + }); + + await updateDeploymentStatus(deployment.deploymentId, "done"); + await updateApplicationStatus(applicationId, "done"); + await sendBuildSuccessNotifications({ + projectName: application.environment.project.name, + applicationName: application.name, + applicationType: "application", + buildLink, + organizationId, + domains: application.domains, + environmentName: application.environment.name, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await log(`\n❌ [build-policy] ${message}\n`).catch(() => {}); + await updateDeploymentStatus(deployment.deploymentId, "error"); + await updateApplicationStatus(applicationId, "error"); + await sendBuildErrorNotifications({ + projectName: application.environment.project.name, + applicationName: application.name, + applicationType: "application", + errorMessage: message, + buildLink, + organizationId, + }).catch(() => {}); + throw error; + } + + return true; +}; + +/** + * The deploy host needs credentials to pull the digest. Match the image's host + * against the organization's registries. + */ +const findRegistryForHost = async (organizationId: string, ref: string) => { + const host = registryHostOf(ref); + if (!host) return null; + const registries = await findAllRegistryByOrganizationId(organizationId); + const match = registries.find((r) => r.registryUrl === host); + return match ? await registryForAuth(match.registryId) : null; +}; + +/** Narrow a validated hook body to the shape the queue job carries. */ +export const toPinnedImageJob = (parsed: DeployHookImage) => + parsed.kind === "image" + ? { ref: parsed.ref, tag: parsed.tag, digest: parsed.digest } + : undefined; diff --git a/packages/server/src/services/build-policy/resolve.ts b/packages/server/src/services/build-policy/resolve.ts new file mode 100644 index 0000000000..39fb38b998 --- /dev/null +++ b/packages/server/src/services/build-policy/resolve.ts @@ -0,0 +1,135 @@ +import type { BuildPolicySettings } from "@dokploy/server/db/schema"; +import { + consumeBreakGlass, + findPendingBreakGlass, + recordBuildPolicyAudit, +} from "./audit"; +import { BuildPolicyError } from "./errors"; +import { isUnitExcluded } from "./exclusions"; +import { + type BuildPolicyDecision, + type BuildPolicyUnitType, + decideBuildPolicy, +} from "./policy"; +import { findBuildPolicySettings } from "./settings"; + +/** + * Database-backed wrapper around the pure `decideBuildPolicy`. + * + * This is the only place that reads settings, exclusions and break-glass + * grants, and the only place a grant is spent. + */ +export interface BuildPolicyUnitRef { + unitType: BuildPolicyUnitType; + unitId: string; + unitName: string; + organizationId: string; + sourceType: string; + customGitUrl?: string | null; + buildServerId?: string | null; + buildRegistryId?: string | null; +} + +export interface ResolvedBuildPolicy { + decision: BuildPolicyDecision; + settings: BuildPolicySettings | null; +} + +export const resolveBuildPolicy = async ( + unit: BuildPolicyUnitRef, + { consume = true }: { consume?: boolean } = {}, +): Promise => { + const settings = await findBuildPolicySettings(unit.organizationId); + + // Nothing else needs reading when the policy is off, which is the common + // case on an unconfigured instance. + if (!settings?.enforceRemoteBuilds) { + return { + settings, + decision: decideBuildPolicy({ + unit, + settings, + isExcluded: false, + breakGlass: null, + }), + }; + } + + const [isExcluded, grant] = await Promise.all([ + isUnitExcluded({ + organizationId: unit.organizationId, + unitType: unit.unitType, + unitId: unit.unitId, + }), + findPendingBreakGlass({ + organizationId: unit.organizationId, + unitType: unit.unitType, + unitId: unit.unitId, + }), + ]); + + const decision = decideBuildPolicy({ + unit, + settings, + isExcluded, + breakGlass: grant + ? { + auditId: grant.buildPolicyAuditId, + actorEmail: grant.actorEmail ?? "unknown", + reason: grant.reason ?? "", + } + : null, + }); + + if ( + consume && + grant && + decision.mode === "local" && + decision.reason === "break_glass" + ) { + await consumeBreakGlass({ + grant, + organizationId: unit.organizationId, + unitType: unit.unitType, + unitId: unit.unitId, + metadata: { unitName: unit.unitName }, + }); + } + + if (decision.mode === "error") { + await recordBuildPolicyAudit({ + organizationId: unit.organizationId, + action: "build_server_missing", + applicationId: unit.unitType === "application" ? unit.unitId : null, + composeId: unit.unitType === "compose" ? unit.unitId : null, + reason: decision.message, + metadata: { code: decision.code, unitName: unit.unitName }, + }); + } + + if (decision.mode === "remote") { + await recordBuildPolicyAudit({ + organizationId: unit.organizationId, + action: "remote_build_enforced", + applicationId: unit.unitType === "application" ? unit.unitId : null, + composeId: unit.unitType === "compose" ? unit.unitId : null, + metadata: { + unitName: unit.unitName, + buildServerId: decision.buildServerId, + registryId: decision.registryId, + }, + }); + } + + return { settings, decision }; +}; + +/** Turns an `error` decision into the thrown, named failure the deploy shows. */ +export const assertBuildPolicyOk = ( + decision: BuildPolicyDecision, +): BuildPolicyDecision => { + if (decision.mode === "error") { + throw new BuildPolicyError(decision.code, decision.message); + } + return decision; +}; diff --git a/packages/server/src/services/build-policy/settings.ts b/packages/server/src/services/build-policy/settings.ts new file mode 100644 index 0000000000..08be94764a --- /dev/null +++ b/packages/server/src/services/build-policy/settings.ts @@ -0,0 +1,63 @@ +import { db } from "@dokploy/server/db"; +import { + type BuildPolicySettings, + buildPolicySettings, +} from "@dokploy/server/db/schema"; +import { eq } from "drizzle-orm"; + +/** + * Organization build-policy settings. + * + * A missing row means "policy off", which is the default for every + * organization and keeps an unconfigured instance on stock upstream behaviour. + * Reads therefore never create a row; only an explicit update does. + */ +export const findBuildPolicySettings = async ( + organizationId: string, +): Promise => { + const row = await db.query.buildPolicySettings.findFirst({ + where: eq(buildPolicySettings.organizationId, organizationId), + }); + return row ?? null; +}; + +export interface BuildPolicySettingsUpdate { + enforceRemoteBuilds?: boolean; + defaultBuildServerId?: string | null; + defaultRegistryId?: string | null; + requiredChecksTimeoutMinutes?: number; +} + +export const upsertBuildPolicySettings = async ( + organizationId: string, + updates: BuildPolicySettingsUpdate, +): Promise => { + const existing = await findBuildPolicySettings(organizationId); + const now = new Date().toISOString(); + + if (!existing) { + const [created] = await db + .insert(buildPolicySettings) + .values({ organizationId, ...updates, createdAt: now, updatedAt: now }) + .returning(); + if (!created) { + throw new Error("Failed to create build policy settings"); + } + return created; + } + + const [updated] = await db + .update(buildPolicySettings) + .set({ ...updates, updatedAt: now }) + .where(eq(buildPolicySettings.organizationId, organizationId)) + .returning(); + if (!updated) { + throw new Error("Failed to update build policy settings"); + } + return updated; +}; + +/** Timeout, in milliseconds, a deploy waits for a unit's required checks. */ +export const requiredChecksTimeoutMs = ( + settings: BuildPolicySettings | null, +): number => (settings?.requiredChecksTimeoutMinutes ?? 30) * 60_000; diff --git a/packages/server/src/services/build-policy/webhook.ts b/packages/server/src/services/build-policy/webhook.ts new file mode 100644 index 0000000000..ce362f0786 --- /dev/null +++ b/packages/server/src/services/build-policy/webhook.ts @@ -0,0 +1,171 @@ +import { db } from "@dokploy/server/db"; +import { environments } from "@dokploy/server/db/schema"; +import { shouldDeploy } from "@dokploy/server/utils/watch-paths/should-deploy"; +import { eq } from "drizzle-orm"; +import { findAllRegistryByOrganizationId } from "../registry"; +import { recordBuildPolicyAudit } from "./audit"; +import { coalesceQueuedDeploy } from "./coalesce"; +import { parseDeployHookImage } from "./hook-body"; +import { toPinnedImageJob } from "./pinned-deploy"; +import type { BuildPolicyUnitType } from "./policy"; +import { matchedSkipDeployMarker } from "./skip-deploy"; +import { resolveWatchPaths } from "./watch-paths"; + +export type PinnedImageJob = { + ref: string; + tag: string | null; + digest: string; +}; + +/** + * The single gate every deploy entry point calls just before enqueueing. + * + * It does the three things that have to happen at enqueue time and nowhere + * else: honour `[skip deploy]`, apply the derived default `watchPaths` when the + * unit has none, and coalesce the deploys that are still waiting for this unit. + * + * It is intentionally the only build-policy touch point in the webhook and + * deploy-hook routes, so an upstream merge has one place to reconcile. + */ +export interface BuildPolicyGateUnit { + unitId: string; + unitName: string; + environmentId: string; + watchPaths?: string[] | null; + buildPath?: string | null; + dockerfile?: string | null; + dockerContextPath?: string | null; + composePath?: string | null; +} + +export type BuildPolicyGateResult = + | { deploy: true; coalesced: number } + | { + deploy: false; + reason: "skip_deploy_marker" | "watch_paths"; + message: string; + }; + +const findOrganizationId = async ( + environmentId: string, +): Promise => { + const environment = await db.query.environments.findFirst({ + where: eq(environments.environmentId, environmentId), + with: { project: true }, + }); + return environment?.project?.organizationId ?? null; +}; + +export const buildPolicyDeployGate = async ({ + unitType, + unit, + changedFiles, + commitMessage, + removeWaiting, +}: { + unitType: BuildPolicyUnitType; + unit: BuildPolicyGateUnit; + /** Paths touched by the push, or null when the caller has no file list. */ + changedFiles?: string[] | null; + commitMessage?: string | null; + /** Drops still-waiting jobs for this unit; returns how many it dropped. */ + removeWaiting: () => Promise | number; +}): Promise => { + const organizationId = await findOrganizationId(unit.environmentId); + + const marker = matchedSkipDeployMarker(commitMessage); + if (marker) { + const message = `Deployment skipped: the commit message contains ${marker}`; + if (organizationId) { + await recordBuildPolicyAudit({ + organizationId, + action: "deploy_skipped", + applicationId: unitType === "application" ? unit.unitId : null, + composeId: unitType === "compose" ? unit.unitId : null, + reason: message, + metadata: { unitName: unit.unitName, marker }, + }); + } + return { deploy: false, reason: "skip_deploy_marker", message }; + } + + // Only meaningful when the caller knows which files changed and the unit has + // no watchPaths of its own; upstream already applied any explicit ones. + const hasOwnWatchPaths = + Array.isArray(unit.watchPaths) && unit.watchPaths.length > 0; + if (!hasOwnWatchPaths && Array.isArray(changedFiles)) { + const { paths } = resolveWatchPaths(unit.watchPaths, { + unitType, + buildPath: unit.buildPath, + dockerfile: unit.dockerfile, + dockerContextPath: unit.dockerContextPath, + composePath: unit.composePath, + }); + if (!shouldDeploy(paths, changedFiles)) { + return { + deploy: false, + reason: "watch_paths", + message: `Deployment skipped: no changed file matched the derived watch paths (${paths.join(", ")})`, + }; + } + } + + if (!organizationId) return { deploy: true, coalesced: 0 }; + + const { removed } = await coalesceQueuedDeploy({ + unitType, + unitId: unit.unitId, + unitName: unit.unitName, + organizationId, + removeWaiting, + recordAudit: recordBuildPolicyAudit, + }); + + return { deploy: true, coalesced: removed }; +}; + +/** + * Optional deploy-hook body `{image, tag, digest}` (spec 5.2.9), validated + * against the registries this organization has configured. + * + * Returns a result union rather than throwing so the route stays four lines. + */ +export const resolveDeployHookImage = async ( + organizationId: string | null, + body: unknown, +): Promise< + | { ok: true; pinnedImage?: PinnedImageJob } + | { ok: false; message: string } +> => { + try { + const record = + body !== null && typeof body === "object" && !Array.isArray(body) + ? (body as Record) + : null; + if (!record || record.image === undefined || record.image === null) { + return { ok: true }; + } + if (!organizationId) { + return { + ok: false, + message: + "A deploy hook image was supplied but this unit's organization could " + + "not be resolved, so the registry could not be validated.", + }; + } + const registries = await findAllRegistryByOrganizationId(organizationId); + const allowedHosts = registries + .map((registry) => registry.registryUrl) + .filter((url): url is string => !!url); + const parsed = parseDeployHookImage(body, allowedHosts); + return { ok: true, pinnedImage: toPinnedImageJob(parsed) }; + } catch (error) { + return { + ok: false, + message: + error instanceof Error ? error.message : "Invalid deploy hook image", + }; + } +}; + +export const findUnitOrganizationId = findOrganizationId; diff --git a/packages/server/src/utils/builders/index.ts b/packages/server/src/utils/builders/index.ts index 2c0d9ab3b6..6a9671f9d3 100644 --- a/packages/server/src/utils/builders/index.ts +++ b/packages/server/src/utils/builders/index.ts @@ -43,7 +43,15 @@ export type ApplicationNested = InferResultType< deployments: true; environment: { with: { project: true } }; } ->; +> & { + /** + * Fork field (build-policy): when an enforced remote build published an + * image, the deploy is pinned to `@sha256:…` instead of the + * mutable tag. Set by `prepareBuildPolicyDeploy`, read by `getImageName`. + * See services/build-policy/README.md. + */ + buildPolicyImage?: string | null; +}; export const getBuildCommand = async (rawApplication: ApplicationNested) => { const application = await withResolvedVaultRefs(rawApplication); @@ -243,6 +251,10 @@ export const mechanizeDockerContainer = async ( }; const getImageName = async (application: ApplicationNested) => { + // >>> build-policy hook: deploy by digest. + // See packages/server/src/services/build-policy/README.md + if (application.buildPolicyImage) return application.buildPolicyImage; + // <<< build-policy hook const { appName, sourceType, dockerImage, registry, buildRegistry } = application; const imageName = `${appName}:latest`; From 39f613457391757e35bd4ec67b2b07796a9cf5f7 Mon Sep 17 00:00:00 2001 From: AminDhouib Date: Wed, 9 Sep 2026 23:56:13 -0400 Subject: [PATCH 03/18] feat(build-policy): migration, integration test, README and settings UI Migration 0200_handy_lifeguard, written idempotently so it satisfies the fork's own migration-idempotency test and re-runs cleanly on a partially migrated database. Integration test (33 cases) drives the real deployApplication with docker, ssh and git mocked, covering: enforced GitHub source builds remotely and deploys by digest; excluded unit builds locally; break-glass builds locally once then enforcement returns; a missing build server or registry is a hard error with no build started; required checks pass, fail and time out; a deploy-hook image deploys with no build; queue coalescing; [skip deploy]; derived watch paths. This is the tripwire for upstream merges (spec 11). README documents all eleven hook points in eight upstream files, the migration, how the digest crosses hosts, and the compose gap. UI: a Build Policy settings card (enforcement toggle, default build server and registry, required-checks timeout, a warning when enforcement is on but unconfigured), an exclusions card, an audit-log card, and a per-application requiredChecks card next to the existing build-server card. Also switches the generated build shell to posix.join so the path it hands the Linux build host is correct regardless of the host Dokploy runs on. --- .../deploy-path.integration.test.ts | 868 ++ .../advanced/show-required-checks.tsx | 135 + .../dashboard/settings/build-policy-audit.tsx | 82 + .../settings/build-policy-exclusions.tsx | 196 + .../dashboard/settings/build-policy.tsx | 291 + apps/dokploy/drizzle/0200_handy_lifeguard.sql | 84 + apps/dokploy/drizzle/meta/0200_snapshot.json | 11019 ++++++++++++++++ apps/dokploy/drizzle/meta/_journal.json | 7 + .../services/application/[applicationId].tsx | 4 + .../pages/dashboard/settings/server.tsx | 10 + .../src/services/build-policy/README.md | 214 + .../server/src/services/build-policy/apply.ts | 6 +- 12 files changed, 12914 insertions(+), 2 deletions(-) create mode 100644 apps/dokploy/__test__/build-policy/deploy-path.integration.test.ts create mode 100644 apps/dokploy/components/dashboard/application/advanced/show-required-checks.tsx create mode 100644 apps/dokploy/components/dashboard/settings/build-policy-audit.tsx create mode 100644 apps/dokploy/components/dashboard/settings/build-policy-exclusions.tsx create mode 100644 apps/dokploy/components/dashboard/settings/build-policy.tsx create mode 100644 apps/dokploy/drizzle/0200_handy_lifeguard.sql create mode 100644 apps/dokploy/drizzle/meta/0200_snapshot.json create mode 100644 packages/server/src/services/build-policy/README.md diff --git a/apps/dokploy/__test__/build-policy/deploy-path.integration.test.ts b/apps/dokploy/__test__/build-policy/deploy-path.integration.test.ts new file mode 100644 index 0000000000..c2494a3271 --- /dev/null +++ b/apps/dokploy/__test__/build-policy/deploy-path.integration.test.ts @@ -0,0 +1,868 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Integration test for the build-policy deploy path, and the tripwire for + * upstream merges (spec §11). + * + * It drives the real `deployApplication` with docker, ssh and git mocked at + * their module boundaries, and the three build-policy modules that touch the + * database stubbed. The decision, the generated build shell, the digest + * read-back and the deploy-by-digest handoff are all the real code. + * + * If an upstream merge drops one of the hook points documented in + * packages/server/src/services/build-policy/README.md, these tests fail loudly + * instead of the policy silently reverting to local builds. + */ + +const mocks = vi.hoisted(() => ({ + findBuildPolicySettings: vi.fn(), + isUnitExcluded: vi.fn(), + findPendingBreakGlass: vi.fn(), + consumeBreakGlass: vi.fn(), + recordBuildPolicyAudit: vi.fn(), + listCheckRuns: vi.fn(), + environmentsFindFirst: vi.fn(), + applicationsFindFirst: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => { + const chain = (): any => { + const self: any = { + set: vi.fn(() => self), + where: vi.fn(() => self), + values: vi.fn(() => self), + from: vi.fn(() => self), + innerJoin: vi.fn(() => self), + returning: vi.fn().mockResolvedValue([{}]), + then: (resolve: (value: unknown) => void) => resolve([]), + }; + return self; + }; + return { + db: { + select: vi.fn(() => chain()), + insert: vi.fn(() => chain()), + update: vi.fn(() => chain()), + delete: vi.fn(() => chain()), + query: { + applications: { findFirst: mocks.applicationsFindFirst }, + deployHook: { findFirst: vi.fn() }, + patch: { findMany: vi.fn().mockResolvedValue([]) }, + member: { findMany: vi.fn().mockResolvedValue([]) }, + environments: { findFirst: mocks.environmentsFindFirst }, + }, + }, + }; +}); + +vi.mock("@dokploy/server/services/build-policy/settings", async () => { + const actual = await vi.importActual< + typeof import("@dokploy/server/services/build-policy/settings") + >("@dokploy/server/services/build-policy/settings"); + return { ...actual, findBuildPolicySettings: mocks.findBuildPolicySettings }; +}); + +vi.mock("@dokploy/server/services/build-policy/exclusions", () => ({ + isUnitExcluded: mocks.isUnitExcluded, +})); + +vi.mock("@dokploy/server/services/build-policy/audit", () => ({ + recordBuildPolicyAudit: mocks.recordBuildPolicyAudit, + findPendingBreakGlass: mocks.findPendingBreakGlass, + consumeBreakGlass: mocks.consumeBreakGlass, + grantBreakGlass: vi.fn(), + listBuildPolicyAudit: vi.fn(), +})); + +vi.mock("@dokploy/server/services/application", async () => { + const actual = await vi.importActual< + typeof import("@dokploy/server/services/application") + >("@dokploy/server/services/application"); + return { + ...actual, + findApplicationById: vi.fn(), + updateApplicationStatus: vi.fn(), + }; +}); + +vi.mock("@dokploy/server/services/registry", () => ({ + findRegistryByIdWithCredentials: vi.fn(), + findRegistryById: vi.fn(), + findAllRegistryByOrganizationId: vi.fn(), +})); + +vi.mock("@dokploy/server/services/deployment", () => ({ + createDeployment: vi.fn(), + updateDeployment: vi.fn(), + updateDeploymentStatus: vi.fn(), + getDeploymentErrorMessage: vi.fn(), +})); + +vi.mock("@dokploy/server/services/admin", () => ({ + getDokployUrl: vi.fn(), +})); + +vi.mock("@dokploy/server/services/github", () => ({ + findGithubById: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: vi.fn(), + execAsyncRemote: vi.fn(), + ExecError: class ExecError extends Error {}, +})); + +vi.mock("@dokploy/server/utils/builders", async () => { + const actual = await vi.importActual< + typeof import("@dokploy/server/utils/builders") + >("@dokploy/server/utils/builders"); + return { + ...actual, + mechanizeDockerContainer: vi.fn(), + getBuildCommand: vi.fn(), + }; +}); + +vi.mock("@dokploy/server/utils/docker/utils", async () => { + const actual = await vi.importActual< + typeof import("@dokploy/server/utils/docker/utils") + >("@dokploy/server/utils/docker/utils"); + return { ...actual, waitForSwarmServiceStable: vi.fn() }; +}); + +vi.mock("@dokploy/server/utils/docker/hooks", async () => { + const actual = await vi.importActual< + typeof import("@dokploy/server/utils/docker/hooks") + >("@dokploy/server/utils/docker/hooks"); + return { ...actual, runDeployHook: vi.fn() }; +}); + +vi.mock("@dokploy/server/utils/providers/git", async () => { + const actual = await vi.importActual< + typeof import("@dokploy/server/utils/providers/git") + >("@dokploy/server/utils/providers/git"); + return { ...actual, getGitCommitInfo: vi.fn(), cloneGitRepository: vi.fn() }; +}); + +vi.mock("@dokploy/server/utils/providers/github", async () => ({ + authGithub: vi.fn(() => ({ + rest: { checks: { listForRef: mocks.listCheckRuns } }, + })), + cloneGithubRepository: vi.fn(async () => "echo clone;"), +})); + +vi.mock("@dokploy/server/utils/notifications/build-success", () => ({ + sendBuildSuccessNotifications: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@dokploy/server/utils/notifications/build-error", () => ({ + sendBuildErrorNotifications: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@dokploy/server/services/patch", () => ({ + generateApplyPatchesCommand: vi.fn(async () => ""), +})); + +import * as adminService from "@dokploy/server/services/admin"; +import * as applicationService from "@dokploy/server/services/application"; +import { + deployApplication, + rebuildApplication, +} from "@dokploy/server/services/application"; +import { DIGEST_MARKER } from "@dokploy/server/services/build-policy/image"; +import { deployPinnedApplicationImage } from "@dokploy/server/services/build-policy/pinned-deploy"; +import { buildPolicyDeployGate } from "@dokploy/server/services/build-policy/webhook"; +import * as deploymentService from "@dokploy/server/services/deployment"; +import * as registryService from "@dokploy/server/services/registry"; +import * as builders from "@dokploy/server/utils/builders"; +import * as dockerUtils from "@dokploy/server/utils/docker/utils"; +import * as execProcess from "@dokploy/server/utils/process/execAsync"; +import * as gitProvider from "@dokploy/server/utils/providers/git"; + +const SHA = "9f1c0b3a5d2e4f6a8b0c1d2e3f4a5b6c7d8e9f01"; +const DIGEST = `sha256:${"a".repeat(64)}`; +const REPOSITORY = "ghcr.io/devinosolutions/sendly-web"; + +const REGISTRY = { + registryId: "registry-1", + registryName: "GHCR", + registryType: "cloud" as const, + registryUrl: "ghcr.io", + imagePrefix: "devinosolutions", + username: "devinosolutions", + password: "unused-in-test", + awsAccessKeyId: null, + awsSecretAccessKey: null, + awsRegion: null, + createdAt: "2026-09-01T00:00:00.000Z", + organizationId: "org-1", +}; + +const SETTINGS = (overrides: Record = {}) => ({ + buildPolicySettingsId: "bps-1", + organizationId: "org-1", + enforceRemoteBuilds: true, + defaultBuildServerId: "build-server-1", + defaultRegistryId: "registry-1", + requiredChecksTimeoutMinutes: 30, + createdAt: "2026-09-01T00:00:00.000Z", + updatedAt: "2026-09-01T00:00:00.000Z", + ...overrides, +}); + +const APPLICATION = (overrides: Record = {}) => ({ + applicationId: "app-1", + name: "Sendly Web", + appName: "sendly-web", + sourceType: "github" as const, + owner: "DevinoSolutions", + repository: "sendly", + branch: "main", + githubId: "github-1", + customGitUrl: null, + buildType: "dockerfile" as const, + buildPath: "/", + dockerfile: "Dockerfile", + dockerContextPath: null, + requiredChecks: [] as string[], + watchPaths: null, + env: "", + serverId: "deploy-server-1", + buildServerId: null, + buildRegistryId: null, + registry: null, + buildRegistry: null, + rollbackRegistry: null, + rollbackActive: false, + enableSubmodules: false, + environmentId: "env-1", + deployHooks: null, + domains: [], + environment: { + projectId: "project-1", + env: "", + name: "production", + project: { name: "Sendly", organizationId: "org-1", env: "" }, + }, + ...overrides, +}); + +const primeMocks = (app: Record = APPLICATION()) => { + // `deployApplication` calls `findApplicationById` inside its own module, so + // the module mock does not intercept it; the db query mock is the real seam. + mocks.applicationsFindFirst.mockResolvedValue(app); + vi.mocked(applicationService.findApplicationById).mockResolvedValue( + app as never, + ); + vi.mocked(adminService.getDokployUrl).mockResolvedValue( + "http://localhost:3000", + ); + vi.mocked(deploymentService.createDeployment).mockResolvedValue({ + deploymentId: "deployment-1", + logPath: "/var/log/deployment-1.log", + } as never); + vi.mocked(deploymentService.updateDeployment).mockResolvedValue({} as never); + vi.mocked(deploymentService.updateDeploymentStatus).mockResolvedValue( + {} as never, + ); + vi.mocked(deploymentService.getDeploymentErrorMessage).mockResolvedValue( + "error", + ); + vi.mocked(builders.getBuildCommand).mockResolvedValue("echo build;"); + vi.mocked(builders.mechanizeDockerContainer).mockResolvedValue( + undefined as never, + ); + vi.mocked(dockerUtils.waitForSwarmServiceStable).mockResolvedValue({ + stable: true, + } as never); + vi.mocked(gitProvider.getGitCommitInfo).mockResolvedValue({ + hash: SHA, + message: "feat: thing", + } as never); + vi.mocked(registryService.findRegistryByIdWithCredentials).mockResolvedValue( + REGISTRY as never, + ); + vi.mocked(registryService.findRegistryById).mockResolvedValue( + REGISTRY as never, + ); + vi.mocked(registryService.findAllRegistryByOrganizationId).mockResolvedValue([ + REGISTRY, + ] as never); + + // The build shell echoes the digest into the deployment log; the deploy step + // greps it back off the build server. + vi.mocked(execProcess.execAsyncRemote).mockImplementation((async ( + _serverId: string, + command: string, + ) => { + if (command.includes(DIGEST_MARKER) && command.startsWith("grep")) { + return { + stdout: `${DIGEST_MARKER} ${REPOSITORY}:${SHA} ${DIGEST}`, + stderr: "", + }; + } + return { stdout: "", stderr: "" }; + }) as never); + vi.mocked(execProcess.execAsync).mockResolvedValue({ + stdout: "", + stderr: "", + } as never); + + mocks.findBuildPolicySettings.mockResolvedValue(SETTINGS()); + mocks.isUnitExcluded.mockResolvedValue(false); + mocks.findPendingBreakGlass.mockResolvedValue(null); + mocks.recordBuildPolicyAudit.mockResolvedValue(null); + mocks.consumeBreakGlass.mockResolvedValue(undefined); + mocks.environmentsFindFirst.mockResolvedValue({ + environmentId: "env-1", + project: { organizationId: "org-1" }, + }); +}; + +/** The shell that was handed to the build host. */ +const buildCommand = () => { + const call = vi + .mocked(execProcess.execAsyncRemote) + .mock.calls.find(([, command]) => String(command).includes("echo build;")); + return String(call?.[1] ?? ""); +}; + +const buildHost = () => { + const call = vi + .mocked(execProcess.execAsyncRemote) + .mock.calls.find(([, command]) => String(command).includes("echo build;")); + return call?.[0]; +}; + +const deployedApplication = () => + vi.mocked(builders.mechanizeDockerContainer).mock.calls[0]?.[0] as + | Record + | undefined; + +beforeEach(() => { + vi.clearAllMocks(); + primeMocks(); +}); + +describe("enforced remote build for a GitHub-sourced application", () => { + it("runs the build on the organization build server, not the deploy host", async () => { + await deployApplication({ + applicationId: "app-1", + titleLog: "Manual deployment", + descriptionLog: "", + }); + expect(buildHost()).toBe("build-server-1"); + }); + + it("appends a tag-and-push of : to the build command", async () => { + await deployApplication({ + applicationId: "app-1", + titleLog: "t", + descriptionLog: "", + }); + const command = buildCommand(); + expect(command).toContain("git -C"); + expect(command).toContain("rev-parse HEAD"); + expect(command).toContain(`DOKPLOY_BP_TAG=${REPOSITORY}:"$DOKPLOY_BP_SHA"`); + expect(command).toContain('docker push "$DOKPLOY_BP_TAG"'); + expect(command).toContain(DIGEST_MARKER); + }); + + it("deploys the image by digest rather than by the mutable tag", async () => { + await deployApplication({ + applicationId: "app-1", + titleLog: "t", + descriptionLog: "", + }); + expect(deployedApplication()?.buildPolicyImage).toBe( + `${REPOSITORY}@${DIGEST}`, + ); + }); + + it("stores the image tag and digest on the deployment record", async () => { + await deployApplication({ + applicationId: "app-1", + titleLog: "t", + descriptionLog: "", + }); + expect(deploymentService.updateDeployment).toHaveBeenCalledWith( + "deployment-1", + expect.objectContaining({ + imageTag: `${REPOSITORY}:${SHA}`, + imageDigest: DIGEST, + }), + ); + }); + + it("gives the deploy host registry credentials so it can pull the digest", async () => { + await deployApplication({ + applicationId: "app-1", + titleLog: "t", + descriptionLog: "", + }); + expect( + (deployedApplication()?.buildRegistry as { registryId: string }) + ?.registryId, + ).toBe("registry-1"); + }); + + it("audits the enforcement and the digest pin", async () => { + await deployApplication({ + applicationId: "app-1", + titleLog: "t", + descriptionLog: "", + }); + const actions = mocks.recordBuildPolicyAudit.mock.calls.map( + ([entry]) => entry.action, + ); + expect(actions).toContain("remote_build_enforced"); + expect(actions).toContain("deploy_by_digest"); + }); + + it("fails the deploy when the build published no digest", async () => { + vi.mocked(execProcess.execAsyncRemote).mockResolvedValue({ + stdout: "", + stderr: "", + } as never); + await expect( + deployApplication({ + applicationId: "app-1", + titleLog: "t", + descriptionLog: "", + }), + ).rejects.toMatchObject({ code: "DIGEST_NOT_PUBLISHED" }); + }); + + it("enforces on a rebuild too, not just a first deploy", async () => { + await rebuildApplication({ + applicationId: "app-1", + titleLog: "t", + descriptionLog: "", + }); + expect(buildHost()).toBe("build-server-1"); + expect(deployedApplication()?.buildPolicyImage).toBe( + `${REPOSITORY}@${DIGEST}`, + ); + }); + + it("enforces a git source hosted on github.com", async () => { + primeMocks( + APPLICATION({ + sourceType: "git", + customGitUrl: "https://github.com/DevinoSolutions/sendly.git", + }), + ); + await deployApplication({ + applicationId: "app-1", + titleLog: "t", + descriptionLog: "", + }); + expect(buildHost()).toBe("build-server-1"); + }); +}); + +describe("units that keep a local build", () => { + const expectLocalBuild = () => { + expect(buildHost()).toBe("deploy-server-1"); + expect(buildCommand()).not.toContain(DIGEST_MARKER); + expect(deployedApplication()?.buildPolicyImage).toBeUndefined(); + }; + + it("builds locally when enforcement is off", async () => { + mocks.findBuildPolicySettings.mockResolvedValue(null); + await deployApplication({ + applicationId: "app-1", + titleLog: "t", + descriptionLog: "", + }); + expectLocalBuild(); + }); + + it("builds locally for an excluded unit", async () => { + mocks.isUnitExcluded.mockResolvedValue(true); + await deployApplication({ + applicationId: "app-1", + titleLog: "t", + descriptionLog: "", + }); + expectLocalBuild(); + }); + + it("does not spend a break-glass grant on an excluded unit", async () => { + mocks.isUnitExcluded.mockResolvedValue(true); + mocks.findPendingBreakGlass.mockResolvedValue({ + buildPolicyAuditId: "grant-1", + actorEmail: "ops@example.com", + reason: "registry outage", + actorId: "user-1", + }); + await deployApplication({ + applicationId: "app-1", + titleLog: "t", + descriptionLog: "", + }); + expect(mocks.consumeBreakGlass).not.toHaveBeenCalled(); + }); + + it("builds locally for a non-github source", async () => { + primeMocks( + APPLICATION({ + sourceType: "git", + customGitUrl: "https://gitlab.com/acme/thing.git", + }), + ); + await deployApplication({ + applicationId: "app-1", + titleLog: "t", + descriptionLog: "", + }); + expectLocalBuild(); + }); +}); + +describe("break glass", () => { + it("builds locally once and spends the grant", async () => { + mocks.findPendingBreakGlass.mockResolvedValue({ + buildPolicyAuditId: "grant-1", + actorEmail: "ops@example.com", + reason: "registry outage", + actorId: "user-1", + }); + await deployApplication({ + applicationId: "app-1", + titleLog: "t", + descriptionLog: "", + }); + expect(buildHost()).toBe("deploy-server-1"); + expect(mocks.consumeBreakGlass).toHaveBeenCalledWith( + expect.objectContaining({ + unitType: "application", + unitId: "app-1", + grant: expect.objectContaining({ buildPolicyAuditId: "grant-1" }), + }), + ); + }); + + it("is enforced again on the next deploy once the grant is spent", async () => { + mocks.findPendingBreakGlass + .mockResolvedValueOnce({ + buildPolicyAuditId: "grant-1", + actorEmail: "ops@example.com", + reason: "registry outage", + actorId: "user-1", + }) + .mockResolvedValue(null); + + await deployApplication({ + applicationId: "app-1", + titleLog: "t", + descriptionLog: "", + }); + expect(buildHost()).toBe("deploy-server-1"); + + vi.mocked(execProcess.execAsyncRemote).mockClear(); + vi.mocked(builders.mechanizeDockerContainer).mockClear(); + + await deployApplication({ + applicationId: "app-1", + titleLog: "t", + descriptionLog: "", + }); + expect(buildHost()).toBe("build-server-1"); + expect(deployedApplication()?.buildPolicyImage).toBe( + `${REPOSITORY}@${DIGEST}`, + ); + }); +}); + +describe("no silent local fallback", () => { + it("fails the deploy with a named error when no build server is configured", async () => { + mocks.findBuildPolicySettings.mockResolvedValue( + SETTINGS({ defaultBuildServerId: null }), + ); + await expect( + deployApplication({ + applicationId: "app-1", + titleLog: "t", + descriptionLog: "", + }), + ).rejects.toMatchObject({ code: "NO_BUILD_SERVER" }); + }); + + it("never starts a build when the build server is missing", async () => { + mocks.findBuildPolicySettings.mockResolvedValue( + SETTINGS({ defaultBuildServerId: null }), + ); + await deployApplication({ + applicationId: "app-1", + titleLog: "t", + descriptionLog: "", + }).catch(() => {}); + expect(builders.getBuildCommand).not.toHaveBeenCalled(); + expect(builders.mechanizeDockerContainer).not.toHaveBeenCalled(); + }); + + it("fails when no registry is configured", async () => { + mocks.findBuildPolicySettings.mockResolvedValue( + SETTINGS({ defaultRegistryId: null }), + ); + await expect( + deployApplication({ + applicationId: "app-1", + titleLog: "t", + descriptionLog: "", + }), + ).rejects.toMatchObject({ code: "NO_REGISTRY" }); + }); + + it("audits the refusal so the reason is recoverable", async () => { + mocks.findBuildPolicySettings.mockResolvedValue( + SETTINGS({ defaultBuildServerId: null }), + ); + await deployApplication({ + applicationId: "app-1", + titleLog: "t", + descriptionLog: "", + }).catch(() => {}); + expect(mocks.recordBuildPolicyAudit).toHaveBeenCalledWith( + expect.objectContaining({ action: "build_server_missing" }), + ); + }); +}); + +describe("required checks", () => { + const checkRun = ( + name: string, + status: string, + conclusion: string | null = null, + ) => ({ name, status, conclusion }); + + beforeEach(() => { + primeMocks(APPLICATION({ requiredChecks: ["build", "e2e"] })); + }); + + it("deploys once every required check has succeeded", async () => { + mocks.listCheckRuns.mockResolvedValue({ + data: { + check_runs: [ + checkRun("build", "completed", "success"), + checkRun("e2e", "completed", "success"), + ], + }, + }); + await deployApplication({ + applicationId: "app-1", + titleLog: "t", + descriptionLog: "", + }); + expect(builders.mechanizeDockerContainer).toHaveBeenCalled(); + }); + + it("checks the commit the image was built from", async () => { + mocks.listCheckRuns.mockResolvedValue({ + data: { + check_runs: [ + checkRun("build", "completed", "success"), + checkRun("e2e", "completed", "success"), + ], + }, + }); + await deployApplication({ + applicationId: "app-1", + titleLog: "t", + descriptionLog: "", + }); + expect(mocks.listCheckRuns).toHaveBeenCalledWith( + expect.objectContaining({ + owner: "DevinoSolutions", + repo: "sendly", + ref: SHA, + }), + ); + }); + + it("fails the deploy before the deploy step when a required check fails", async () => { + mocks.listCheckRuns.mockResolvedValue({ + data: { + check_runs: [ + checkRun("build", "completed", "success"), + checkRun("e2e", "completed", "failure"), + ], + }, + }); + await expect( + deployApplication({ + applicationId: "app-1", + titleLog: "t", + descriptionLog: "", + }), + ).rejects.toMatchObject({ code: "REQUIRED_CHECKS_FAILED" }); + expect(builders.mechanizeDockerContainer).not.toHaveBeenCalled(); + }); + + it("fails the deploy when the required checks never conclude", async () => { + mocks.findBuildPolicySettings.mockResolvedValue( + SETTINGS({ requiredChecksTimeoutMinutes: 0 }), + ); + mocks.listCheckRuns.mockResolvedValue({ + data: { check_runs: [checkRun("build", "in_progress")] }, + }); + await expect( + deployApplication({ + applicationId: "app-1", + titleLog: "t", + descriptionLog: "", + }), + ).rejects.toMatchObject({ code: "REQUIRED_CHECKS_TIMEOUT" }); + expect(builders.mechanizeDockerContainer).not.toHaveBeenCalled(); + }); + + it("audits a failed gate", async () => { + mocks.listCheckRuns.mockResolvedValue({ + data: { data: [], check_runs: [checkRun("build", "completed", "failure")] }, + }); + await deployApplication({ + applicationId: "app-1", + titleLog: "t", + descriptionLog: "", + }).catch(() => {}); + expect(mocks.recordBuildPolicyAudit).toHaveBeenCalledWith( + expect.objectContaining({ action: "required_checks_failed" }), + ); + }); + + it("does not call GitHub at all when the list is empty", async () => { + primeMocks(APPLICATION({ requiredChecks: [] })); + await deployApplication({ + applicationId: "app-1", + titleLog: "t", + descriptionLog: "", + }); + expect(mocks.listCheckRuns).not.toHaveBeenCalled(); + }); +}); + +describe("deploy-hook body with an image", () => { + it("deploys the supplied image by digest and never builds", async () => { + await deployPinnedApplicationImage({ + applicationId: "app-1", + pinnedImage: { + ref: `${REPOSITORY}@${DIGEST}`, + tag: SHA, + digest: DIGEST, + }, + }); + expect(builders.getBuildCommand).not.toHaveBeenCalled(); + expect(deployedApplication()?.buildPolicyImage).toBe( + `${REPOSITORY}@${DIGEST}`, + ); + }); + + it("records the image on the deployment", async () => { + await deployPinnedApplicationImage({ + applicationId: "app-1", + pinnedImage: { + ref: `${REPOSITORY}@${DIGEST}`, + tag: SHA, + digest: DIGEST, + }, + }); + expect(deploymentService.updateDeployment).toHaveBeenCalledWith( + "deployment-1", + expect.objectContaining({ imageTag: SHA, imageDigest: DIGEST }), + ); + }); + + it("still honours required checks", async () => { + primeMocks(APPLICATION({ requiredChecks: ["build"] })); + mocks.listCheckRuns.mockResolvedValue({ + data: { check_runs: [{ name: "build", status: "completed", conclusion: "failure" }] }, + }); + await expect( + deployPinnedApplicationImage({ + applicationId: "app-1", + pinnedImage: { + ref: `${REPOSITORY}@${DIGEST}`, + tag: SHA, + digest: DIGEST, + }, + }), + ).rejects.toMatchObject({ code: "REQUIRED_CHECKS_FAILED" }); + expect(builders.mechanizeDockerContainer).not.toHaveBeenCalled(); + }); +}); + +describe("enqueue-time gate", () => { + const unit = { + unitId: "app-1", + unitName: "Sendly Web", + environmentId: "env-1", + watchPaths: null, + buildPath: "/apps/web", + dockerfile: "Dockerfile", + }; + + it("drops the older queued deploy and reports how many", async () => { + const removeWaiting = vi.fn().mockResolvedValue(1); + const result = await buildPolicyDeployGate({ + unitType: "application", + unit, + commitMessage: "feat: thing", + removeWaiting, + }); + expect(result).toEqual({ deploy: true, coalesced: 1 }); + expect(removeWaiting).toHaveBeenCalledTimes(1); + expect(mocks.recordBuildPolicyAudit).toHaveBeenCalledWith( + expect.objectContaining({ action: "deploy_coalesced" }), + ); + }); + + it("skips the deploy on a [skip deploy] commit and records why", async () => { + const removeWaiting = vi.fn().mockResolvedValue(0); + const result = await buildPolicyDeployGate({ + unitType: "application", + unit, + commitMessage: "chore: docs only [skip deploy]", + removeWaiting, + }); + expect(result).toMatchObject({ + deploy: false, + reason: "skip_deploy_marker", + }); + expect(removeWaiting).not.toHaveBeenCalled(); + expect(mocks.recordBuildPolicyAudit).toHaveBeenCalledWith( + expect.objectContaining({ action: "deploy_skipped" }), + ); + }); + + it("applies the derived default watch paths when the unit has none", async () => { + const result = await buildPolicyDeployGate({ + unitType: "application", + unit, + changedFiles: ["docs/readme.md"], + commitMessage: "docs: tweak", + removeWaiting: vi.fn().mockResolvedValue(0), + }); + expect(result).toMatchObject({ deploy: false, reason: "watch_paths" }); + }); + + it("deploys when a changed file is inside the derived watch paths", async () => { + const result = await buildPolicyDeployGate({ + unitType: "application", + unit, + changedFiles: ["apps/web/src/index.ts"], + commitMessage: "feat: thing", + removeWaiting: vi.fn().mockResolvedValue(0), + }); + expect(result).toMatchObject({ deploy: true }); + }); + + it("leaves a unit with its own watch paths to upstream's own check", async () => { + const result = await buildPolicyDeployGate({ + unitType: "application", + unit: { ...unit, watchPaths: ["services/**"] }, + changedFiles: ["docs/readme.md"], + commitMessage: "docs: tweak", + removeWaiting: vi.fn().mockResolvedValue(0), + }); + expect(result).toMatchObject({ deploy: true }); + }); +}); diff --git a/apps/dokploy/components/dashboard/application/advanced/show-required-checks.tsx b/apps/dokploy/components/dashboard/application/advanced/show-required-checks.tsx new file mode 100644 index 0000000000..f4aa577b53 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/show-required-checks.tsx @@ -0,0 +1,135 @@ +import { ListChecks, Plus, X } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { api } from "@/utils/api"; + +interface Props { + applicationId: string; +} + +export const ShowRequiredChecks = ({ applicationId }: Props) => { + const { data, refetch } = api.application.one.useQuery( + { applicationId }, + { enabled: !!applicationId }, + ); + const { mutateAsync, isPending } = api.application.update.useMutation(); + + const [checks, setChecks] = useState([]); + const [newCheck, setNewCheck] = useState(""); + + useEffect(() => { + if (data) { + setChecks(data.requiredChecks ?? []); + } + }, [data]); + + const addCheck = () => { + const check = newCheck.trim(); + if (!check) return; + if (checks.includes(check)) { + toast.error("Check already exists"); + return; + } + setChecks([...checks, check]); + setNewCheck(""); + }; + + const removeCheck = (check: string) => { + setChecks(checks.filter((value) => value !== check)); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + addCheck(); + } + }; + + const onSubmit = async () => { + await mutateAsync({ + applicationId, + requiredChecks: checks, + }) + .then(async () => { + toast.success("Required Checks Updated"); + await refetch(); + }) + .catch(() => { + toast.error("Error updating required checks"); + }); + }; + + return ( + + +
+ +
+ Required Checks + + An empty list leaves the deploy ungated. With one or more check + names, the deploy waits for those GitHub check runs on the commit + to succeed before it continues. + +
+
+
+ +
+ setNewCheck(e.target.value)} + onKeyDown={handleKeyDown} + /> + +
+ + {checks.length > 0 ? ( +
+ {checks.map((check) => ( + + {check} + + + ))} +
+ ) : ( +

+ No required checks configured. Deploys of this application are not + gated on GitHub check runs. +

+ )} + +
+ +
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/build-policy-audit.tsx b/apps/dokploy/components/dashboard/settings/build-policy-audit.tsx new file mode 100644 index 0000000000..694ec6796f --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/build-policy-audit.tsx @@ -0,0 +1,82 @@ +import { ScrollText } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { api } from "@/utils/api"; + +export const BuildPolicyAudit = () => { + const { data } = api.buildPolicy.audit.useQuery({ limit: 50, offset: 0 }); + + const logs = data?.logs ?? []; + + return ( +
+ +
+ +
+ + + Build Policy Audit + + + The 50 most recent build policy decisions for this organization. + +
+
+ + {logs.length > 0 ? ( + + + + Date + Action + Service + Actor + Reason + + + + {logs.map((log) => ( + + + {new Date(log.createdAt).toLocaleString()} + + + {log.action} + + + {log.application?.name ?? log.compose?.name ?? "-"} + + + {log.actorEmail || "-"} + + + {log.reason || "-"} + + + ))} + +
+ ) : ( +

No entries yet

+ )} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/build-policy-exclusions.tsx b/apps/dokploy/components/dashboard/settings/build-policy-exclusions.tsx new file mode 100644 index 0000000000..e70877fed2 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/build-policy-exclusions.tsx @@ -0,0 +1,196 @@ +import { ShieldOff, Trash2 } from "lucide-react"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { api } from "@/utils/api"; + +interface UnitOption { + /** `application:` or `compose:`, so one Select can carry both. */ + value: string; + label: string; +} + +export const BuildPolicyExclusions = () => { + const [unit, setUnit] = useState(""); + const [reason, setReason] = useState(""); + + const { data: exclusions, refetch } = api.buildPolicy.exclusions.useQuery(); + const { data: projects } = api.project.all.useQuery(); + + const { mutateAsync: addExclusion, isPending: isAdding } = + api.buildPolicy.addExclusion.useMutation(); + const { mutateAsync: removeExclusion, isPending: isRemoving } = + api.buildPolicy.removeExclusion.useMutation(); + + const unitOptions = useMemo(() => { + const options: UnitOption[] = []; + for (const project of projects ?? []) { + for (const environment of project.environments ?? []) { + for (const application of environment.applications ?? []) { + options.push({ + value: `application:${application.applicationId}`, + label: `${project.name} / ${environment.name} / ${application.name}`, + }); + } + for (const composeService of environment.compose ?? []) { + options.push({ + value: `compose:${composeService.composeId}`, + label: `${project.name} / ${environment.name} / ${composeService.name}`, + }); + } + } + } + return options; + }, [projects]); + + const onAdd = async () => { + if (!unit) { + toast.error("Select a service to exclude"); + return; + } + const [type, id] = unit.split(":"); + await addExclusion({ + applicationId: type === "application" ? id : undefined, + composeId: type === "compose" ? id : undefined, + reason: reason.trim() || undefined, + }) + .then(async () => { + await refetch(); + setUnit(""); + setReason(""); + toast.success("Exclusion added"); + }) + .catch(() => { + toast.error("Error adding exclusion"); + }); + }; + + const onRemove = async (buildPolicyExclusionId: string) => { + await removeExclusion({ buildPolicyExclusionId }) + .then(async () => { + await refetch(); + toast.success("Exclusion removed"); + }) + .catch(() => { + toast.error("Error removing exclusion"); + }); + }; + + return ( +
+ +
+ +
+ + + Build Policy Exclusions + + + Services that keep building locally while enforcement is on. + +
+
+ + {exclusions && exclusions.length > 0 ? ( + + + + Service + Reason + Created + Actions + + + + {exclusions.map((exclusion) => ( + + + {exclusion.application?.name ?? + exclusion.compose?.name ?? + "Unknown"} + + + {exclusion.reason || "-"} + + + {new Date(exclusion.createdAt).toLocaleString()} + + + + + + ))} + +
+ ) : ( +

+ No exclusions. Every eligible service follows the build policy. +

+ )} + +
+ + setReason(e.target.value)} + /> + +
+
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/build-policy.tsx b/apps/dokploy/components/dashboard/settings/build-policy.tsx new file mode 100644 index 0000000000..19b7fb9c34 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/build-policy.tsx @@ -0,0 +1,291 @@ +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { ShieldCheck } from "lucide-react"; +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { api } from "@/utils/api"; + +const buildPolicySchema = z.object({ + enforceRemoteBuilds: z.boolean(), + defaultBuildServerId: z.string(), + defaultRegistryId: z.string(), + requiredChecksTimeoutMinutes: z.number().int().min(1).max(720), +}); + +type BuildPolicyForm = z.infer; + +export const BuildPolicy = () => { + const { data, refetch } = api.buildPolicy.settings.useQuery(); + const { data: buildServers } = api.server.buildServers.useQuery(); + const { data: registries } = api.registry.all.useQuery(); + const { mutateAsync, isPending } = + api.buildPolicy.updateSettings.useMutation(); + + const form = useForm({ + defaultValues: { + enforceRemoteBuilds: false, + defaultBuildServerId: "none", + defaultRegistryId: "none", + requiredChecksTimeoutMinutes: 30, + }, + resolver: zodResolver(buildPolicySchema), + }); + + const enforceRemoteBuilds = form.watch("enforceRemoteBuilds"); + const defaultBuildServerId = form.watch("defaultBuildServerId"); + const defaultRegistryId = form.watch("defaultRegistryId"); + + useEffect(() => { + if (data) { + form.reset({ + enforceRemoteBuilds: data.enforceRemoteBuilds, + defaultBuildServerId: data.defaultBuildServerId ?? "none", + defaultRegistryId: data.defaultRegistryId ?? "none", + requiredChecksTimeoutMinutes: data.requiredChecksTimeoutMinutes, + }); + } + }, [form, data]); + + const isMisconfigured = + enforceRemoteBuilds && + (defaultBuildServerId === "none" || defaultRegistryId === "none"); + + const onSubmit = async (formData: BuildPolicyForm) => { + await mutateAsync({ + enforceRemoteBuilds: formData.enforceRemoteBuilds, + defaultBuildServerId: + formData.defaultBuildServerId === "none" + ? null + : formData.defaultBuildServerId, + defaultRegistryId: + formData.defaultRegistryId === "none" + ? null + : formData.defaultRegistryId, + requiredChecksTimeoutMinutes: formData.requiredChecksTimeoutMinutes, + }) + .then(async () => { + await refetch(); + toast.success("Build policy settings saved"); + }) + .catch(() => { + toast.error("Error saving build policy settings"); + }); + }; + + return ( +
+ +
+ +
+ + + Build Policy + + + Force GitHub-sourced applications to build on the organization + build server, push to the registry, and deploy by digest. + +
+
+ +
+ + ( + +
+ Enforce Remote Builds + + When enabled, GitHub-sourced applications are pinned + to the organization build server and deployed by + image digest. + + +
+ + + +
+ )} + /> + + {isMisconfigured && ( + + Enforcement is on but no default build server or registry is + selected. Deploys of GitHub-sourced applications will fail + with a clear error rather than silently building locally. + + )} + + ( + + Default Build Server + + + Every enforced unit builds on this server. + + + + )} + /> + + ( + + Default Registry + + + The built image is pushed here and pulled back by + digest at deploy time. + + + + )} + /> + + ( + + Required Checks Timeout (minutes) + + + field.onChange(e.target.valueAsNumber) + } + /> + + + How long a deploy waits for a unit's required GitHub + check runs before failing. Between 1 and 720 minutes. + + + + )} + /> + +
+ +
+ + +
+
+
+
+ ); +}; diff --git a/apps/dokploy/drizzle/0200_handy_lifeguard.sql b/apps/dokploy/drizzle/0200_handy_lifeguard.sql new file mode 100644 index 0000000000..d2edf63d70 --- /dev/null +++ b/apps/dokploy/drizzle/0200_handy_lifeguard.sql @@ -0,0 +1,84 @@ +-- Fork migration: build policy (enforced remote builds). +-- See packages/server/src/services/build-policy/README.md. +-- Written idempotently (fork house style) so a re-run on a partially +-- migrated database is a no-op rather than a hard failure. +--> statement-breakpoint +DO $$ BEGIN + CREATE TYPE "public"."buildPolicyAuditAction" AS ENUM('settings_updated', 'exclusion_added', 'exclusion_removed', 'break_glass_granted', 'break_glass_consumed', 'remote_build_enforced', 'build_server_missing', 'deploy_coalesced', 'deploy_skipped', 'required_checks_failed', 'required_checks_timeout', 'deploy_by_digest'); +EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "build_policy_audit" ( + "buildPolicyAuditId" text PRIMARY KEY NOT NULL, + "organizationId" text NOT NULL, + "action" "buildPolicyAuditAction" NOT NULL, + "applicationId" text, + "composeId" text, + "actorId" text, + "actorEmail" text, + "reason" text, + "metadata" text, + "createdAt" timestamp DEFAULT now() NOT NULL, + "consumedAt" timestamp +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "build_policy_exclusion" ( + "buildPolicyExclusionId" text PRIMARY KEY NOT NULL, + "organizationId" text NOT NULL, + "applicationId" text, + "composeId" text, + "reason" text, + "createdAt" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "build_policy_settings" ( + "buildPolicySettingsId" text PRIMARY KEY NOT NULL, + "organizationId" text NOT NULL, + "enforceRemoteBuilds" boolean DEFAULT false NOT NULL, + "defaultBuildServerId" text, + "defaultRegistryId" text, + "requiredChecksTimeoutMinutes" integer DEFAULT 30 NOT NULL, + "createdAt" text NOT NULL, + "updatedAt" text NOT NULL, + CONSTRAINT "build_policy_settings_organizationId_unique" UNIQUE("organizationId") +); +--> statement-breakpoint +ALTER TABLE "application" ADD COLUMN IF NOT EXISTS "requiredChecks" text[];--> statement-breakpoint +ALTER TABLE "compose" ADD COLUMN IF NOT EXISTS "requiredChecks" text[];--> statement-breakpoint +ALTER TABLE "deployment" ADD COLUMN IF NOT EXISTS "imageTag" text;--> statement-breakpoint +ALTER TABLE "deployment" ADD COLUMN IF NOT EXISTS "imageDigest" text;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "build_policy_audit" ADD CONSTRAINT "build_policy_audit_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "build_policy_audit" ADD CONSTRAINT "build_policy_audit_applicationId_application_applicationId_fk" FOREIGN KEY ("applicationId") REFERENCES "public"."application"("applicationId") ON DELETE set null ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "build_policy_audit" ADD CONSTRAINT "build_policy_audit_composeId_compose_composeId_fk" FOREIGN KEY ("composeId") REFERENCES "public"."compose"("composeId") ON DELETE set null ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "build_policy_audit" ADD CONSTRAINT "build_policy_audit_actorId_user_id_fk" FOREIGN KEY ("actorId") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "build_policy_exclusion" ADD CONSTRAINT "build_policy_exclusion_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "build_policy_exclusion" ADD CONSTRAINT "build_policy_exclusion_applicationId_application_applicationId_fk" FOREIGN KEY ("applicationId") REFERENCES "public"."application"("applicationId") ON DELETE cascade ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "build_policy_exclusion" ADD CONSTRAINT "build_policy_exclusion_composeId_compose_composeId_fk" FOREIGN KEY ("composeId") REFERENCES "public"."compose"("composeId") ON DELETE cascade ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "build_policy_settings" ADD CONSTRAINT "build_policy_settings_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "build_policy_settings" ADD CONSTRAINT "build_policy_settings_defaultBuildServerId_server_serverId_fk" FOREIGN KEY ("defaultBuildServerId") REFERENCES "public"."server"("serverId") ON DELETE set null ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "build_policy_settings" ADD CONSTRAINT "build_policy_settings_defaultRegistryId_registry_registryId_fk" FOREIGN KEY ("defaultRegistryId") REFERENCES "public"."registry"("registryId") ON DELETE set null ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "buildPolicyAudit_organizationId_idx" ON "build_policy_audit" USING btree ("organizationId");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "buildPolicyAudit_applicationId_idx" ON "build_policy_audit" USING btree ("applicationId");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "buildPolicyAudit_composeId_idx" ON "build_policy_audit" USING btree ("composeId");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "buildPolicyAudit_createdAt_idx" ON "build_policy_audit" USING btree ("createdAt");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "buildPolicyExclusion_organizationId_idx" ON "build_policy_exclusion" USING btree ("organizationId");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "buildPolicyExclusion_applicationId_idx" ON "build_policy_exclusion" USING btree ("applicationId");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "buildPolicyExclusion_composeId_idx" ON "build_policy_exclusion" USING btree ("composeId"); diff --git a/apps/dokploy/drizzle/meta/0200_snapshot.json b/apps/dokploy/drizzle/meta/0200_snapshot.json new file mode 100644 index 0000000000..a868ddf504 --- /dev/null +++ b/apps/dokploy/drizzle/meta/0200_snapshot.json @@ -0,0 +1,11019 @@ +{ + "id": "3372efcb-bc7f-476c-b823-8f0c90366200", + "prevId": "ad74efe1-cbf0-4f84-9a16-cc1f8ded0584", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is2FAEnabled": { + "name": "is2FAEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "resetPasswordToken": { + "name": "resetPasswordToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resetPasswordExpiresAt": { + "name": "resetPasswordExpiresAt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "confirmationToken": { + "name": "confirmationToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "confirmationExpiresAt": { + "name": "confirmationExpiresAt", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.apikey": { + "name": "apikey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "apikey_reference_id_user_id_fk": { + "name": "apikey_reference_id_user_id_fk", + "tableFrom": "apikey", + "tableTo": "user", + "columnsFrom": [ + "reference_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canCreateProjects": { + "name": "canCreateProjects", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canAccessToSSHKeys": { + "name": "canAccessToSSHKeys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canCreateServices": { + "name": "canCreateServices", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canDeleteProjects": { + "name": "canDeleteProjects", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canDeleteServices": { + "name": "canDeleteServices", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canAccessToDocker": { + "name": "canAccessToDocker", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canAccessToAPI": { + "name": "canAccessToAPI", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canAccessToGitProviders": { + "name": "canAccessToGitProviders", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canAccessToTraefikFiles": { + "name": "canAccessToTraefikFiles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canDeleteEnvironments": { + "name": "canDeleteEnvironments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canCreateEnvironments": { + "name": "canCreateEnvironments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "accesedProjects": { + "name": "accesedProjects", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "accessedEnvironments": { + "name": "accessedEnvironments", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "accesedServices": { + "name": "accesedServices", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "accessedGitProviders": { + "name": "accessedGitProviders", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "accessedServers": { + "name": "accessedServers", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + } + }, + "indexes": {}, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_role": { + "name": "default_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wildcard_domain": { + "name": "wildcard_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "organization_owner_id_user_id_fk": { + "name": "organization_owner_id_user_id_fk", + "tableFrom": "organization", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_role": { + "name": "organization_role", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organizationRole_organizationId_idx": { + "name": "organizationRole_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organizationRole_role_idx": { + "name": "organizationRole_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_role_organization_id_organization_id_fk": { + "name": "organization_role_organization_id_organization_id_fk", + "tableFrom": "organization_role", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "passkey_userId_idx": { + "name": "passkey_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_credentialID_idx": { + "name": "passkey_credentialID_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.two_factor": { + "name": "two_factor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backup_codes": { + "name": "backup_codes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_verification_count": { + "name": "failed_verification_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "locked_until": { + "name": "locked_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "two_factor_user_id_user_id_fk": { + "name": "two_factor_user_id_user_id_fk", + "tableFrom": "two_factor", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai": { + "name": "ai", + "schema": "", + "columns": { + "aiId": { + "name": "aiId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "apiUrl": { + "name": "apiUrl", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "apiKey": { + "name": "apiKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "isEnabled": { + "name": "isEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "organizationId": { + "name": "organizationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "ai_organizationId_organization_id_fk": { + "name": "ai_organizationId_organization_id_fk", + "tableFrom": "ai", + "tableTo": "organization", + "columnsFrom": [ + "organizationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.application": { + "name": "application", + "schema": "", + "columns": { + "applicationId": { + "name": "applicationId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "appName": { + "name": "appName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previewEnv": { + "name": "previewEnv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "watchPaths": { + "name": "watchPaths", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "requiredChecks": { + "name": "requiredChecks", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "previewBuildArgs": { + "name": "previewBuildArgs", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previewBuildSecrets": { + "name": "previewBuildSecrets", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previewLabels": { + "name": "previewLabels", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "previewWildcard": { + "name": "previewWildcard", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previewPort": { + "name": "previewPort", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3000 + }, + "previewHttps": { + "name": "previewHttps", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "previewPath": { + "name": "previewPath", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'/'" + }, + "certificateType": { + "name": "certificateType", + "type": "certificateType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "previewCustomCertResolver": { + "name": "previewCustomCertResolver", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previewLimit": { + "name": "previewLimit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "isPreviewDeploymentsActive": { + "name": "isPreviewDeploymentsActive", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "previewRequireCollaboratorPermissions": { + "name": "previewRequireCollaboratorPermissions", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rollbackActive": { + "name": "rollbackActive", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "buildArgs": { + "name": "buildArgs", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "buildSecrets": { + "name": "buildSecrets", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "memoryReservation": { + "name": "memoryReservation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "memoryLimit": { + "name": "memoryLimit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cpuReservation": { + "name": "cpuReservation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cpuLimit": { + "name": "cpuLimit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subtitle": { + "name": "subtitle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "args": { + "name": "args", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sourceType": { + "name": "sourceType", + "type": "sourceType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "cleanCache": { + "name": "cleanCache", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "buildPath": { + "name": "buildPath", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'/'" + }, + "triggerType": { + "name": "triggerType", + "type": "triggerType", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'push'" + }, + "autoDeploy": { + "name": "autoDeploy", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "gitlabProjectId": { + "name": "gitlabProjectId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "gitlabRepository": { + "name": "gitlabRepository", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gitlabOwner": { + "name": "gitlabOwner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gitlabBranch": { + "name": "gitlabBranch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gitlabBuildPath": { + "name": "gitlabBuildPath", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'/'" + }, + "gitlabPathNamespace": { + "name": "gitlabPathNamespace", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "giteaRepository": { + "name": "giteaRepository", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "giteaOwner": { + "name": "giteaOwner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "giteaBranch": { + "name": "giteaBranch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "giteaBuildPath": { + "name": "giteaBuildPath", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'/'" + }, + "bitbucketRepository": { + "name": "bitbucketRepository", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bitbucketRepositorySlug": { + "name": "bitbucketRepositorySlug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bitbucketOwner": { + "name": "bitbucketOwner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bitbucketBranch": { + "name": "bitbucketBranch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bitbucketBuildPath": { + "name": "bitbucketBuildPath", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'/'" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dockerImage": { + "name": "dockerImage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registryUrl": { + "name": "registryUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customGitUrl": { + "name": "customGitUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customGitBranch": { + "name": "customGitBranch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customGitBuildPath": { + "name": "customGitBuildPath", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customGitSSHKeyId": { + "name": "customGitSSHKeyId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enableSubmodules": { + "name": "enableSubmodules", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "dockerfile": { + "name": "dockerfile", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'Dockerfile'" + }, + "dockerContextPath": { + "name": "dockerContextPath", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dockerBuildStage": { + "name": "dockerBuildStage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dropBuildPath": { + "name": "dropBuildPath", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "healthCheckSwarm": { + "name": "healthCheckSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "restartPolicySwarm": { + "name": "restartPolicySwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "placementSwarm": { + "name": "placementSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "updateConfigSwarm": { + "name": "updateConfigSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "rollbackConfigSwarm": { + "name": "rollbackConfigSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "modeSwarm": { + "name": "modeSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "labelsSwarm": { + "name": "labelsSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "networkSwarm": { + "name": "networkSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "stopGracePeriodSwarm": { + "name": "stopGracePeriodSwarm", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "endpointSpecSwarm": { + "name": "endpointSpecSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "ulimitsSwarm": { + "name": "ulimitsSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "replicas": { + "name": "replicas", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "applicationStatus": { + "name": "applicationStatus", + "type": "applicationStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "buildType": { + "name": "buildType", + "type": "buildType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'nixpacks'" + }, + "railpackVersion": { + "name": "railpackVersion", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'0.15.4'" + }, + "herokuVersion": { + "name": "herokuVersion", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'24'" + }, + "publishDirectory": { + "name": "publishDirectory", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isStaticSpa": { + "name": "isStaticSpa", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "createEnvFile": { + "name": "createEnvFile", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "registryId": { + "name": "registryId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rollbackRegistryId": { + "name": "rollbackRegistryId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environmentId": { + "name": "environmentId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "githubId": { + "name": "githubId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gitlabId": { + "name": "gitlabId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "giteaId": { + "name": "giteaId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bitbucketId": { + "name": "bitbucketId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serverId": { + "name": "serverId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "buildServerId": { + "name": "buildServerId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "buildRegistryId": { + "name": "buildRegistryId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "networkIds": { + "name": "networkIds", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "detachDokployNetwork": { + "name": "detachDokployNetwork", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "application_customGitSSHKeyId_ssh-key_sshKeyId_fk": { + "name": "application_customGitSSHKeyId_ssh-key_sshKeyId_fk", + "tableFrom": "application", + "tableTo": "ssh-key", + "columnsFrom": [ + "customGitSSHKeyId" + ], + "columnsTo": [ + "sshKeyId" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "application_registryId_registry_registryId_fk": { + "name": "application_registryId_registry_registryId_fk", + "tableFrom": "application", + "tableTo": "registry", + "columnsFrom": [ + "registryId" + ], + "columnsTo": [ + "registryId" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "application_rollbackRegistryId_registry_registryId_fk": { + "name": "application_rollbackRegistryId_registry_registryId_fk", + "tableFrom": "application", + "tableTo": "registry", + "columnsFrom": [ + "rollbackRegistryId" + ], + "columnsTo": [ + "registryId" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "application_environmentId_environment_environmentId_fk": { + "name": "application_environmentId_environment_environmentId_fk", + "tableFrom": "application", + "tableTo": "environment", + "columnsFrom": [ + "environmentId" + ], + "columnsTo": [ + "environmentId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "application_githubId_github_githubId_fk": { + "name": "application_githubId_github_githubId_fk", + "tableFrom": "application", + "tableTo": "github", + "columnsFrom": [ + "githubId" + ], + "columnsTo": [ + "githubId" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "application_gitlabId_gitlab_gitlabId_fk": { + "name": "application_gitlabId_gitlab_gitlabId_fk", + "tableFrom": "application", + "tableTo": "gitlab", + "columnsFrom": [ + "gitlabId" + ], + "columnsTo": [ + "gitlabId" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "application_giteaId_gitea_giteaId_fk": { + "name": "application_giteaId_gitea_giteaId_fk", + "tableFrom": "application", + "tableTo": "gitea", + "columnsFrom": [ + "giteaId" + ], + "columnsTo": [ + "giteaId" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "application_bitbucketId_bitbucket_bitbucketId_fk": { + "name": "application_bitbucketId_bitbucket_bitbucketId_fk", + "tableFrom": "application", + "tableTo": "bitbucket", + "columnsFrom": [ + "bitbucketId" + ], + "columnsTo": [ + "bitbucketId" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "application_serverId_server_serverId_fk": { + "name": "application_serverId_server_serverId_fk", + "tableFrom": "application", + "tableTo": "server", + "columnsFrom": [ + "serverId" + ], + "columnsTo": [ + "serverId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "application_buildServerId_server_serverId_fk": { + "name": "application_buildServerId_server_serverId_fk", + "tableFrom": "application", + "tableTo": "server", + "columnsFrom": [ + "buildServerId" + ], + "columnsTo": [ + "serverId" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "application_buildRegistryId_registry_registryId_fk": { + "name": "application_buildRegistryId_registry_registryId_fk", + "tableFrom": "application", + "tableTo": "registry", + "columnsFrom": [ + "buildRegistryId" + ], + "columnsTo": [ + "registryId" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "application_appName_unique": { + "name": "application_appName_unique", + "nullsNotDistinct": false, + "columns": [ + "appName" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_role": { + "name": "user_role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auditLog_organizationId_idx": { + "name": "auditLog_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auditLog_userId_idx": { + "name": "auditLog_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auditLog_createdAt_idx": { + "name": "auditLog_createdAt_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_organization_id_organization_id_fk": { + "name": "audit_log_organization_id_organization_id_fk", + "tableFrom": "audit_log", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_user_id_user_id_fk": { + "name": "audit_log_user_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.backup_policy": { + "name": "backup_policy", + "schema": "", + "columns": { + "backupPolicyId": { + "name": "backupPolicyId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organizationId": { + "name": "organizationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopeType": { + "name": "scopeType", + "type": "backupPolicyScopeType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "scopeIds": { + "name": "scopeIds", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "includeDatabases": { + "name": "includeDatabases", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "includeVolumes": { + "name": "includeVolumes", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "serviceTypeFilter": { + "name": "serviceTypeFilter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "destinationId": { + "name": "destinationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule": { + "name": "schedule", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "keepLatestCount": { + "name": "keepLatestCount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "lastSyncError": { + "name": "lastSyncError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "backup_policy_organizationId_organization_id_fk": { + "name": "backup_policy_organizationId_organization_id_fk", + "tableFrom": "backup_policy", + "tableTo": "organization", + "columnsFrom": [ + "organizationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "backup_policy_destinationId_destination_destinationId_fk": { + "name": "backup_policy_destinationId_destination_destinationId_fk", + "tableFrom": "backup_policy", + "tableTo": "destination", + "columnsFrom": [ + "destinationId" + ], + "columnsTo": [ + "destinationId" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.backup": { + "name": "backup", + "schema": "", + "columns": { + "backupId": { + "name": "backupId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "appName": { + "name": "appName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule": { + "name": "schedule", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "database": { + "name": "database", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serviceName": { + "name": "serviceName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destinationId": { + "name": "destinationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "keepLatestCount": { + "name": "keepLatestCount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "includeEncryptionKey": { + "name": "includeEncryptionKey", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "backupType": { + "name": "backupType", + "type": "backupType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'database'" + }, + "databaseType": { + "name": "databaseType", + "type": "databaseType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "composeId": { + "name": "composeId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postgresId": { + "name": "postgresId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mariadbId": { + "name": "mariadbId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mysqlId": { + "name": "mysqlId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mongoId": { + "name": "mongoId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "libsqlId": { + "name": "libsqlId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backupPolicyId": { + "name": "backupPolicyId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "backup_destinationId_destination_destinationId_fk": { + "name": "backup_destinationId_destination_destinationId_fk", + "tableFrom": "backup", + "tableTo": "destination", + "columnsFrom": [ + "destinationId" + ], + "columnsTo": [ + "destinationId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "backup_composeId_compose_composeId_fk": { + "name": "backup_composeId_compose_composeId_fk", + "tableFrom": "backup", + "tableTo": "compose", + "columnsFrom": [ + "composeId" + ], + "columnsTo": [ + "composeId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "backup_postgresId_postgres_postgresId_fk": { + "name": "backup_postgresId_postgres_postgresId_fk", + "tableFrom": "backup", + "tableTo": "postgres", + "columnsFrom": [ + "postgresId" + ], + "columnsTo": [ + "postgresId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "backup_mariadbId_mariadb_mariadbId_fk": { + "name": "backup_mariadbId_mariadb_mariadbId_fk", + "tableFrom": "backup", + "tableTo": "mariadb", + "columnsFrom": [ + "mariadbId" + ], + "columnsTo": [ + "mariadbId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "backup_mysqlId_mysql_mysqlId_fk": { + "name": "backup_mysqlId_mysql_mysqlId_fk", + "tableFrom": "backup", + "tableTo": "mysql", + "columnsFrom": [ + "mysqlId" + ], + "columnsTo": [ + "mysqlId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "backup_mongoId_mongo_mongoId_fk": { + "name": "backup_mongoId_mongo_mongoId_fk", + "tableFrom": "backup", + "tableTo": "mongo", + "columnsFrom": [ + "mongoId" + ], + "columnsTo": [ + "mongoId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "backup_libsqlId_libsql_libsqlId_fk": { + "name": "backup_libsqlId_libsql_libsqlId_fk", + "tableFrom": "backup", + "tableTo": "libsql", + "columnsFrom": [ + "libsqlId" + ], + "columnsTo": [ + "libsqlId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "backup_userId_user_id_fk": { + "name": "backup_userId_user_id_fk", + "tableFrom": "backup", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "backup_backupPolicyId_backup_policy_backupPolicyId_fk": { + "name": "backup_backupPolicyId_backup_policy_backupPolicyId_fk", + "tableFrom": "backup", + "tableTo": "backup_policy", + "columnsFrom": [ + "backupPolicyId" + ], + "columnsTo": [ + "backupPolicyId" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "backup_appName_unique": { + "name": "backup_appName_unique", + "nullsNotDistinct": false, + "columns": [ + "appName" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bitbucket": { + "name": "bitbucket", + "schema": "", + "columns": { + "bitbucketId": { + "name": "bitbucketId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "bitbucketUsername": { + "name": "bitbucketUsername", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bitbucketEmail": { + "name": "bitbucketEmail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "appPassword": { + "name": "appPassword", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "apiToken": { + "name": "apiToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bitbucketWorkspaceName": { + "name": "bitbucketWorkspaceName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gitProviderId": { + "name": "gitProviderId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "bitbucket_gitProviderId_git_provider_gitProviderId_fk": { + "name": "bitbucket_gitProviderId_git_provider_gitProviderId_fk", + "tableFrom": "bitbucket", + "tableTo": "git_provider", + "columnsFrom": [ + "gitProviderId" + ], + "columnsTo": [ + "gitProviderId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.build_policy_audit": { + "name": "build_policy_audit", + "schema": "", + "columns": { + "buildPolicyAuditId": { + "name": "buildPolicyAuditId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organizationId": { + "name": "organizationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "buildPolicyAuditAction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "applicationId": { + "name": "applicationId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "composeId": { + "name": "composeId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actorId": { + "name": "actorId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actorEmail": { + "name": "actorEmail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "consumedAt": { + "name": "consumedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "buildPolicyAudit_organizationId_idx": { + "name": "buildPolicyAudit_organizationId_idx", + "columns": [ + { + "expression": "organizationId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "buildPolicyAudit_applicationId_idx": { + "name": "buildPolicyAudit_applicationId_idx", + "columns": [ + { + "expression": "applicationId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "buildPolicyAudit_composeId_idx": { + "name": "buildPolicyAudit_composeId_idx", + "columns": [ + { + "expression": "composeId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "buildPolicyAudit_createdAt_idx": { + "name": "buildPolicyAudit_createdAt_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "build_policy_audit_organizationId_organization_id_fk": { + "name": "build_policy_audit_organizationId_organization_id_fk", + "tableFrom": "build_policy_audit", + "tableTo": "organization", + "columnsFrom": [ + "organizationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "build_policy_audit_applicationId_application_applicationId_fk": { + "name": "build_policy_audit_applicationId_application_applicationId_fk", + "tableFrom": "build_policy_audit", + "tableTo": "application", + "columnsFrom": [ + "applicationId" + ], + "columnsTo": [ + "applicationId" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "build_policy_audit_composeId_compose_composeId_fk": { + "name": "build_policy_audit_composeId_compose_composeId_fk", + "tableFrom": "build_policy_audit", + "tableTo": "compose", + "columnsFrom": [ + "composeId" + ], + "columnsTo": [ + "composeId" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "build_policy_audit_actorId_user_id_fk": { + "name": "build_policy_audit_actorId_user_id_fk", + "tableFrom": "build_policy_audit", + "tableTo": "user", + "columnsFrom": [ + "actorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.build_policy_exclusion": { + "name": "build_policy_exclusion", + "schema": "", + "columns": { + "buildPolicyExclusionId": { + "name": "buildPolicyExclusionId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organizationId": { + "name": "organizationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "applicationId": { + "name": "applicationId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "composeId": { + "name": "composeId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "buildPolicyExclusion_organizationId_idx": { + "name": "buildPolicyExclusion_organizationId_idx", + "columns": [ + { + "expression": "organizationId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "buildPolicyExclusion_applicationId_idx": { + "name": "buildPolicyExclusion_applicationId_idx", + "columns": [ + { + "expression": "applicationId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "buildPolicyExclusion_composeId_idx": { + "name": "buildPolicyExclusion_composeId_idx", + "columns": [ + { + "expression": "composeId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "build_policy_exclusion_organizationId_organization_id_fk": { + "name": "build_policy_exclusion_organizationId_organization_id_fk", + "tableFrom": "build_policy_exclusion", + "tableTo": "organization", + "columnsFrom": [ + "organizationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "build_policy_exclusion_applicationId_application_applicationId_fk": { + "name": "build_policy_exclusion_applicationId_application_applicationId_fk", + "tableFrom": "build_policy_exclusion", + "tableTo": "application", + "columnsFrom": [ + "applicationId" + ], + "columnsTo": [ + "applicationId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "build_policy_exclusion_composeId_compose_composeId_fk": { + "name": "build_policy_exclusion_composeId_compose_composeId_fk", + "tableFrom": "build_policy_exclusion", + "tableTo": "compose", + "columnsFrom": [ + "composeId" + ], + "columnsTo": [ + "composeId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.build_policy_settings": { + "name": "build_policy_settings", + "schema": "", + "columns": { + "buildPolicySettingsId": { + "name": "buildPolicySettingsId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organizationId": { + "name": "organizationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enforceRemoteBuilds": { + "name": "enforceRemoteBuilds", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "defaultBuildServerId": { + "name": "defaultBuildServerId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "defaultRegistryId": { + "name": "defaultRegistryId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requiredChecksTimeoutMinutes": { + "name": "requiredChecksTimeoutMinutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30 + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "build_policy_settings_organizationId_organization_id_fk": { + "name": "build_policy_settings_organizationId_organization_id_fk", + "tableFrom": "build_policy_settings", + "tableTo": "organization", + "columnsFrom": [ + "organizationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "build_policy_settings_defaultBuildServerId_server_serverId_fk": { + "name": "build_policy_settings_defaultBuildServerId_server_serverId_fk", + "tableFrom": "build_policy_settings", + "tableTo": "server", + "columnsFrom": [ + "defaultBuildServerId" + ], + "columnsTo": [ + "serverId" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "build_policy_settings_defaultRegistryId_registry_registryId_fk": { + "name": "build_policy_settings_defaultRegistryId_registry_registryId_fk", + "tableFrom": "build_policy_settings", + "tableTo": "registry", + "columnsFrom": [ + "defaultRegistryId" + ], + "columnsTo": [ + "registryId" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "build_policy_settings_organizationId_unique": { + "name": "build_policy_settings_organizationId_unique", + "nullsNotDistinct": false, + "columns": [ + "organizationId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.certificate": { + "name": "certificate", + "schema": "", + "columns": { + "certificateId": { + "name": "certificateId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "certificateData": { + "name": "certificateData", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "privateKey": { + "name": "privateKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "certificatePath": { + "name": "certificatePath", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "autoRenew": { + "name": "autoRenew", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "organizationId": { + "name": "organizationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serverId": { + "name": "serverId", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "certificate_organizationId_organization_id_fk": { + "name": "certificate_organizationId_organization_id_fk", + "tableFrom": "certificate", + "tableTo": "organization", + "columnsFrom": [ + "organizationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "certificate_serverId_server_serverId_fk": { + "name": "certificate_serverId_server_serverId_fk", + "tableFrom": "certificate", + "tableTo": "server", + "columnsFrom": [ + "serverId" + ], + "columnsTo": [ + "serverId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "certificate_certificatePath_unique": { + "name": "certificate_certificatePath_unique", + "nullsNotDistinct": false, + "columns": [ + "certificatePath" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cloudflare": { + "name": "cloudflare", + "schema": "", + "columns": { + "cloudflareId": { + "name": "cloudflareId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "apiToken": { + "name": "apiToken", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accountId": { + "name": "accountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "defaultTunnelId": { + "name": "defaultTunnelId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "defaultSessionDuration": { + "name": "defaultSessionDuration", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'168h'" + }, + "protectDomainsByDefault": { + "name": "protectDomainsByDefault", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "requireProtectedDomains": { + "name": "requireProtectedDomains", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "defaultAllowEmails": { + "name": "defaultAllowEmails", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "defaultAllowEmailDomains": { + "name": "defaultAllowEmailDomains", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "organizationId": { + "name": "organizationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "cloudflare_organizationId_organization_id_fk": { + "name": "cloudflare_organizationId_organization_id_fk", + "tableFrom": "cloudflare", + "tableTo": "organization", + "columnsFrom": [ + "organizationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cloudflare_tunnel_runtime": { + "name": "cloudflare_tunnel_runtime", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organizationId": { + "name": "organizationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cloudflareId": { + "name": "cloudflareId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serverId": { + "name": "serverId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tunnelId": { + "name": "tunnelId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tunnelName": { + "name": "tunnelName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dockerResourceName": { + "name": "dockerResourceName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "runtimeMode": { + "name": "runtimeMode", + "type": "cloudflareTunnelRuntimeMode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'shared-managed'" + }, + "status": { + "name": "status", + "type": "cloudflareTunnelRuntimeStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "lastError": { + "name": "lastError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastStartedAt": { + "name": "lastStartedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastSeenAt": { + "name": "lastSeenAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cloudflare_tunnel_runtime_org_server_cf_unique": { + "name": "cloudflare_tunnel_runtime_org_server_cf_unique", + "columns": [ + { + "expression": "organizationId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "serverId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cloudflareId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cloudflare_tunnel_runtime_organizationId_organization_id_fk": { + "name": "cloudflare_tunnel_runtime_organizationId_organization_id_fk", + "tableFrom": "cloudflare_tunnel_runtime", + "tableTo": "organization", + "columnsFrom": [ + "organizationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cloudflare_tunnel_runtime_cloudflareId_cloudflare_cloudflareId_fk": { + "name": "cloudflare_tunnel_runtime_cloudflareId_cloudflare_cloudflareId_fk", + "tableFrom": "cloudflare_tunnel_runtime", + "tableTo": "cloudflare", + "columnsFrom": [ + "cloudflareId" + ], + "columnsTo": [ + "cloudflareId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cloudflare_access_application": { + "name": "cloudflare_access_application", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organizationId": { + "name": "organizationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cloudflareId": { + "name": "cloudflareId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domainId": { + "name": "domainId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cloudflareAppId": { + "name": "cloudflareAppId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cloudflarePolicyId": { + "name": "cloudflarePolicyId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "appDomain": { + "name": "appDomain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sessionDuration": { + "name": "sessionDuration", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'24h'" + }, + "allowEmails": { + "name": "allowEmails", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "allowEmailDomains": { + "name": "allowEmailDomains", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cloudflare_access_application_domainId_unique": { + "name": "cloudflare_access_application_domainId_unique", + "columns": [ + { + "expression": "domainId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cloudflare_access_application_organizationId_organization_id_fk": { + "name": "cloudflare_access_application_organizationId_organization_id_fk", + "tableFrom": "cloudflare_access_application", + "tableTo": "organization", + "columnsFrom": [ + "organizationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cloudflare_access_application_cloudflareId_cloudflare_cloudflareId_fk": { + "name": "cloudflare_access_application_cloudflareId_cloudflare_cloudflareId_fk", + "tableFrom": "cloudflare_access_application", + "tableTo": "cloudflare", + "columnsFrom": [ + "cloudflareId" + ], + "columnsTo": [ + "cloudflareId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cloudflare_access_application_domainId_domain_domainId_fk": { + "name": "cloudflare_access_application_domainId_domain_domainId_fk", + "tableFrom": "cloudflare_access_application", + "tableTo": "domain", + "columnsFrom": [ + "domainId" + ], + "columnsTo": [ + "domainId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.compose": { + "name": "compose", + "schema": "", + "columns": { + "composeId": { + "name": "composeId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "appName": { + "name": "appName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "composeFile": { + "name": "composeFile", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sourceType": { + "name": "sourceType", + "type": "sourceTypeCompose", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "composeType": { + "name": "composeType", + "type": "composeType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'docker-compose'" + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "autoDeploy": { + "name": "autoDeploy", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "pullImagesOnDeploy": { + "name": "pullImagesOnDeploy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "gitlabProjectId": { + "name": "gitlabProjectId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "gitlabRepository": { + "name": "gitlabRepository", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gitlabOwner": { + "name": "gitlabOwner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gitlabBranch": { + "name": "gitlabBranch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gitlabPathNamespace": { + "name": "gitlabPathNamespace", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bitbucketRepository": { + "name": "bitbucketRepository", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bitbucketRepositorySlug": { + "name": "bitbucketRepositorySlug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bitbucketOwner": { + "name": "bitbucketOwner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bitbucketBranch": { + "name": "bitbucketBranch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "giteaRepository": { + "name": "giteaRepository", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "giteaOwner": { + "name": "giteaOwner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "giteaBranch": { + "name": "giteaBranch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customGitUrl": { + "name": "customGitUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customGitBranch": { + "name": "customGitBranch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customGitSSHKeyId": { + "name": "customGitSSHKeyId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "createEnvFile": { + "name": "createEnvFile", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enableSubmodules": { + "name": "enableSubmodules", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "composePath": { + "name": "composePath", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'./docker-compose.yml'" + }, + "suffix": { + "name": "suffix", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "randomize": { + "name": "randomize", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "isolatedDeployment": { + "name": "isolatedDeployment", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "isolatedNetworkMtu": { + "name": "isolatedNetworkMtu", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "isolatedDeploymentsVolume": { + "name": "isolatedDeploymentsVolume", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "previewEnv": { + "name": "previewEnv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previewLabels": { + "name": "previewLabels", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "previewWildcard": { + "name": "previewWildcard", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previewLimit": { + "name": "previewLimit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "previewHttps": { + "name": "previewHttps", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "previewPath": { + "name": "previewPath", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'/'" + }, + "previewCertificateType": { + "name": "previewCertificateType", + "type": "certificateType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "previewCustomCertResolver": { + "name": "previewCustomCertResolver", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isPreviewDeploymentsActive": { + "name": "isPreviewDeploymentsActive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "previewRequireCollaboratorPermissions": { + "name": "previewRequireCollaboratorPermissions", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggerType": { + "name": "triggerType", + "type": "triggerType", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'push'" + }, + "composeStatus": { + "name": "composeStatus", + "type": "applicationStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environmentId": { + "name": "environmentId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "watchPaths": { + "name": "watchPaths", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "requiredChecks": { + "name": "requiredChecks", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "githubId": { + "name": "githubId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gitlabId": { + "name": "gitlabId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bitbucketId": { + "name": "bitbucketId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "giteaId": { + "name": "giteaId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serverId": { + "name": "serverId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serviceNetworks": { + "name": "serviceNetworks", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "compose_customGitSSHKeyId_ssh-key_sshKeyId_fk": { + "name": "compose_customGitSSHKeyId_ssh-key_sshKeyId_fk", + "tableFrom": "compose", + "tableTo": "ssh-key", + "columnsFrom": [ + "customGitSSHKeyId" + ], + "columnsTo": [ + "sshKeyId" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "compose_environmentId_environment_environmentId_fk": { + "name": "compose_environmentId_environment_environmentId_fk", + "tableFrom": "compose", + "tableTo": "environment", + "columnsFrom": [ + "environmentId" + ], + "columnsTo": [ + "environmentId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "compose_githubId_github_githubId_fk": { + "name": "compose_githubId_github_githubId_fk", + "tableFrom": "compose", + "tableTo": "github", + "columnsFrom": [ + "githubId" + ], + "columnsTo": [ + "githubId" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "compose_gitlabId_gitlab_gitlabId_fk": { + "name": "compose_gitlabId_gitlab_gitlabId_fk", + "tableFrom": "compose", + "tableTo": "gitlab", + "columnsFrom": [ + "gitlabId" + ], + "columnsTo": [ + "gitlabId" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "compose_bitbucketId_bitbucket_bitbucketId_fk": { + "name": "compose_bitbucketId_bitbucket_bitbucketId_fk", + "tableFrom": "compose", + "tableTo": "bitbucket", + "columnsFrom": [ + "bitbucketId" + ], + "columnsTo": [ + "bitbucketId" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "compose_giteaId_gitea_giteaId_fk": { + "name": "compose_giteaId_gitea_giteaId_fk", + "tableFrom": "compose", + "tableTo": "gitea", + "columnsFrom": [ + "giteaId" + ], + "columnsTo": [ + "giteaId" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "compose_serverId_server_serverId_fk": { + "name": "compose_serverId_server_serverId_fk", + "tableFrom": "compose", + "tableTo": "server", + "columnsFrom": [ + "serverId" + ], + "columnsTo": [ + "serverId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deploy_hook": { + "name": "deploy_hook", + "schema": "", + "columns": { + "deployHookId": { + "name": "deployHookId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "applicationId": { + "name": "applicationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hooks": { + "name": "hooks", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "deploy_hook_applicationId_application_applicationId_fk": { + "name": "deploy_hook_applicationId_application_applicationId_fk", + "tableFrom": "deploy_hook", + "tableTo": "application", + "columnsFrom": [ + "applicationId" + ], + "columnsTo": [ + "applicationId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deploy_hook_applicationId_unique": { + "name": "deploy_hook_applicationId_unique", + "nullsNotDistinct": false, + "columns": [ + "applicationId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment": { + "name": "deployment", + "schema": "", + "columns": { + "deploymentId": { + "name": "deploymentId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "deploymentStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'running'" + }, + "logPath": { + "name": "logPath", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pid": { + "name": "pid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applicationId": { + "name": "applicationId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "composeId": { + "name": "composeId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serverId": { + "name": "serverId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isPreviewDeployment": { + "name": "isPreviewDeployment", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "previewDeploymentId": { + "name": "previewDeploymentId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "startedAt": { + "name": "startedAt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "finishedAt": { + "name": "finishedAt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "errorMessage": { + "name": "errorMessage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduleId": { + "name": "scheduleId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backupId": { + "name": "backupId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rollbackId": { + "name": "rollbackId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "volumeBackupId": { + "name": "volumeBackupId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "buildServerId": { + "name": "buildServerId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "imageTag": { + "name": "imageTag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "imageDigest": { + "name": "imageDigest", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "deployment_applicationId_application_applicationId_fk": { + "name": "deployment_applicationId_application_applicationId_fk", + "tableFrom": "deployment", + "tableTo": "application", + "columnsFrom": [ + "applicationId" + ], + "columnsTo": [ + "applicationId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deployment_composeId_compose_composeId_fk": { + "name": "deployment_composeId_compose_composeId_fk", + "tableFrom": "deployment", + "tableTo": "compose", + "columnsFrom": [ + "composeId" + ], + "columnsTo": [ + "composeId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deployment_serverId_server_serverId_fk": { + "name": "deployment_serverId_server_serverId_fk", + "tableFrom": "deployment", + "tableTo": "server", + "columnsFrom": [ + "serverId" + ], + "columnsTo": [ + "serverId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deployment_previewDeploymentId_preview_deployments_previewDeploymentId_fk": { + "name": "deployment_previewDeploymentId_preview_deployments_previewDeploymentId_fk", + "tableFrom": "deployment", + "tableTo": "preview_deployments", + "columnsFrom": [ + "previewDeploymentId" + ], + "columnsTo": [ + "previewDeploymentId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deployment_scheduleId_schedule_scheduleId_fk": { + "name": "deployment_scheduleId_schedule_scheduleId_fk", + "tableFrom": "deployment", + "tableTo": "schedule", + "columnsFrom": [ + "scheduleId" + ], + "columnsTo": [ + "scheduleId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deployment_backupId_backup_backupId_fk": { + "name": "deployment_backupId_backup_backupId_fk", + "tableFrom": "deployment", + "tableTo": "backup", + "columnsFrom": [ + "backupId" + ], + "columnsTo": [ + "backupId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deployment_rollbackId_rollback_rollbackId_fk": { + "name": "deployment_rollbackId_rollback_rollbackId_fk", + "tableFrom": "deployment", + "tableTo": "rollback", + "columnsFrom": [ + "rollbackId" + ], + "columnsTo": [ + "rollbackId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deployment_volumeBackupId_volume_backup_volumeBackupId_fk": { + "name": "deployment_volumeBackupId_volume_backup_volumeBackupId_fk", + "tableFrom": "deployment", + "tableTo": "volume_backup", + "columnsFrom": [ + "volumeBackupId" + ], + "columnsTo": [ + "volumeBackupId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deployment_buildServerId_server_serverId_fk": { + "name": "deployment_buildServerId_server_serverId_fk", + "tableFrom": "deployment", + "tableTo": "server", + "columnsFrom": [ + "buildServerId" + ], + "columnsTo": [ + "serverId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.destination": { + "name": "destination", + "schema": "", + "columns": { + "destinationId": { + "name": "destinationId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accessKey": { + "name": "accessKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secretAccessKey": { + "name": "secretAccessKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "additionalFlags": { + "name": "additionalFlags", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "organizationId": { + "name": "organizationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "encryptionEnabled": { + "name": "encryptionEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "encryptionKey": { + "name": "encryptionKey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encryptionPassword2": { + "name": "encryptionPassword2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "filenameEncryption": { + "name": "filenameEncryption", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'off'" + }, + "directoryNameEncryption": { + "name": "directoryNameEncryption", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "destination_organizationId_organization_id_fk": { + "name": "destination_organizationId_organization_id_fk", + "tableFrom": "destination", + "tableTo": "organization", + "columnsFrom": [ + "organizationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dns_provider": { + "name": "dns_provider", + "schema": "", + "columns": { + "dnsProviderId": { + "name": "dnsProviderId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerType": { + "name": "providerType", + "type": "DnsProviderType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "organizationId": { + "name": "organizationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "dns_provider_org_name_idx": { + "name": "dns_provider_org_name_idx", + "columns": [ + { + "expression": "organizationId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dns_provider_organizationId_organization_id_fk": { + "name": "dns_provider_organizationId_organization_id_fk", + "tableFrom": "dns_provider", + "tableTo": "organization", + "columnsFrom": [ + "organizationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.domain": { + "name": "domain", + "schema": "", + "columns": { + "domainId": { + "name": "domainId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "https": { + "name": "https", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3000 + }, + "customEntrypoint": { + "name": "customEntrypoint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'/'" + }, + "serviceName": { + "name": "serviceName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domainType": { + "name": "domainType", + "type": "domainType", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'application'" + }, + "uniqueConfigKey": { + "name": "uniqueConfigKey", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "composeId": { + "name": "composeId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customCertResolver": { + "name": "customCertResolver", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applicationId": { + "name": "applicationId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previewDeploymentId": { + "name": "previewDeploymentId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "certificateType": { + "name": "certificateType", + "type": "certificateType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "internalPath": { + "name": "internalPath", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'/'" + }, + "stripPath": { + "name": "stripPath", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "middlewares": { + "name": "middlewares", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "ARRAY[]::text[]" + }, + "forwardAuthEnabled": { + "name": "forwardAuthEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "publishToCloudflare": { + "name": "publishToCloudflare", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cloudflareTunnelMode": { + "name": "cloudflareTunnelMode", + "type": "cloudflareTunnelMode", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "cloudflareId": { + "name": "cloudflareId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cloudflareZoneId": { + "name": "cloudflareZoneId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cloudflareTunnelId": { + "name": "cloudflareTunnelId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cloudflareDnsRecordId": { + "name": "cloudflareDnsRecordId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cloudflareIngressApplied": { + "name": "cloudflareIngressApplied", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enableCloudflareAccess": { + "name": "enableCloudflareAccess", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cloudflareAccessApplicationId": { + "name": "cloudflareAccessApplicationId", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "domain_composeId_compose_composeId_fk": { + "name": "domain_composeId_compose_composeId_fk", + "tableFrom": "domain", + "tableTo": "compose", + "columnsFrom": [ + "composeId" + ], + "columnsTo": [ + "composeId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "domain_applicationId_application_applicationId_fk": { + "name": "domain_applicationId_application_applicationId_fk", + "tableFrom": "domain", + "tableTo": "application", + "columnsFrom": [ + "applicationId" + ], + "columnsTo": [ + "applicationId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "domain_previewDeploymentId_preview_deployments_previewDeploymentId_fk": { + "name": "domain_previewDeploymentId_preview_deployments_previewDeploymentId_fk", + "tableFrom": "domain", + "tableTo": "preview_deployments", + "columnsFrom": [ + "previewDeploymentId" + ], + "columnsTo": [ + "previewDeploymentId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "domain_cloudflareId_cloudflare_cloudflareId_fk": { + "name": "domain_cloudflareId_cloudflare_cloudflareId_fk", + "tableFrom": "domain", + "tableTo": "cloudflare", + "columnsFrom": [ + "cloudflareId" + ], + "columnsTo": [ + "cloudflareId" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "environmentId": { + "name": "environmentId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "projectId": { + "name": "projectId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "isDefault": { + "name": "isDefault", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "environment_projectId_project_projectId_fk": { + "name": "environment_projectId_project_projectId_fk", + "tableFrom": "environment", + "tableTo": "project", + "columnsFrom": [ + "projectId" + ], + "columnsTo": [ + "projectId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.forward_auth_settings": { + "name": "forward_auth_settings", + "schema": "", + "columns": { + "forwardAuthSettingsId": { + "name": "forwardAuthSettingsId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "authDomain": { + "name": "authDomain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "baseDomain": { + "name": "baseDomain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "https": { + "name": "https", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "certificateType": { + "name": "certificateType", + "type": "certificateType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'letsencrypt'" + }, + "customCertResolver": { + "name": "customCertResolver", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "providerId": { + "name": "providerId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serverId": { + "name": "serverId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "forward_auth_settings_providerId_sso_provider_provider_id_fk": { + "name": "forward_auth_settings_providerId_sso_provider_provider_id_fk", + "tableFrom": "forward_auth_settings", + "tableTo": "sso_provider", + "columnsFrom": [ + "providerId" + ], + "columnsTo": [ + "provider_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "forward_auth_settings_serverId_server_serverId_fk": { + "name": "forward_auth_settings_serverId_server_serverId_fk", + "tableFrom": "forward_auth_settings", + "tableTo": "server", + "columnsFrom": [ + "serverId" + ], + "columnsTo": [ + "serverId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "forward_auth_settings_serverId_unique": { + "name": "forward_auth_settings_serverId_unique", + "nullsNotDistinct": false, + "columns": [ + "serverId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.git_provider": { + "name": "git_provider", + "schema": "", + "columns": { + "gitProviderId": { + "name": "gitProviderId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerType": { + "name": "providerType", + "type": "gitProviderType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organizationId": { + "name": "organizationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sharedWithOrganization": { + "name": "sharedWithOrganization", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "git_provider_organizationId_organization_id_fk": { + "name": "git_provider_organizationId_organization_id_fk", + "tableFrom": "git_provider", + "tableTo": "organization", + "columnsFrom": [ + "organizationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "git_provider_userId_user_id_fk": { + "name": "git_provider_userId_user_id_fk", + "tableFrom": "git_provider", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.gitea": { + "name": "gitea", + "schema": "", + "columns": { + "giteaId": { + "name": "giteaId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "giteaUrl": { + "name": "giteaUrl", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'https://gitea.com'" + }, + "giteaInternalUrl": { + "name": "giteaInternalUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gitProviderId": { + "name": "gitProviderId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'repo,repo:status,read:user,read:org'" + }, + "last_authenticated_at": { + "name": "last_authenticated_at", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "gitea_gitProviderId_git_provider_gitProviderId_fk": { + "name": "gitea_gitProviderId_git_provider_gitProviderId_fk", + "tableFrom": "gitea", + "tableTo": "git_provider", + "columnsFrom": [ + "gitProviderId" + ], + "columnsTo": [ + "gitProviderId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github": { + "name": "github", + "schema": "", + "columns": { + "githubId": { + "name": "githubId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "githubAppName": { + "name": "githubAppName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "githubAppId": { + "name": "githubAppId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "githubClientId": { + "name": "githubClientId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "githubClientSecret": { + "name": "githubClientSecret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "githubInstallationId": { + "name": "githubInstallationId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "githubPrivateKey": { + "name": "githubPrivateKey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "githubWebhookSecret": { + "name": "githubWebhookSecret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "githubUrl": { + "name": "githubUrl", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'https://github.com'" + }, + "gitProviderId": { + "name": "gitProviderId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "github_gitProviderId_git_provider_gitProviderId_fk": { + "name": "github_gitProviderId_git_provider_gitProviderId_fk", + "tableFrom": "github", + "tableTo": "git_provider", + "columnsFrom": [ + "gitProviderId" + ], + "columnsTo": [ + "gitProviderId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.gitlab": { + "name": "gitlab", + "schema": "", + "columns": { + "gitlabId": { + "name": "gitlabId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "gitlabUrl": { + "name": "gitlabUrl", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'https://gitlab.com'" + }, + "gitlabInternalUrl": { + "name": "gitlabInternalUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "application_id": { + "name": "application_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "webhook_secret": { + "name": "webhook_secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "gitProviderId": { + "name": "gitProviderId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "gitlab_gitProviderId_git_provider_gitProviderId_fk": { + "name": "gitlab_gitProviderId_git_provider_gitProviderId_fk", + "tableFrom": "gitlab", + "tableTo": "git_provider", + "columnsFrom": [ + "gitProviderId" + ], + "columnsTo": [ + "gitProviderId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.libsql": { + "name": "libsql", + "schema": "", + "columns": { + "libsqlId": { + "name": "libsqlId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "appName": { + "name": "appName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "databaseUser": { + "name": "databaseUser", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "databasePassword": { + "name": "databasePassword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sqldNode": { + "name": "sqldNode", + "type": "sqldNode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'primary'" + }, + "sqldPrimaryUrl": { + "name": "sqldPrimaryUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enableNamespaces": { + "name": "enableNamespaces", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "dockerImage": { + "name": "dockerImage", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "memoryReservation": { + "name": "memoryReservation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "memoryLimit": { + "name": "memoryLimit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cpuReservation": { + "name": "cpuReservation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cpuLimit": { + "name": "cpuLimit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "externalPort": { + "name": "externalPort", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "externalGRPCPort": { + "name": "externalGRPCPort", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "externalAdminPort": { + "name": "externalAdminPort", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "applicationStatus": { + "name": "applicationStatus", + "type": "applicationStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "healthCheckSwarm": { + "name": "healthCheckSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "restartPolicySwarm": { + "name": "restartPolicySwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "placementSwarm": { + "name": "placementSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "updateConfigSwarm": { + "name": "updateConfigSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "rollbackConfigSwarm": { + "name": "rollbackConfigSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "modeSwarm": { + "name": "modeSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "labelsSwarm": { + "name": "labelsSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "networkSwarm": { + "name": "networkSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "stopGracePeriodSwarm": { + "name": "stopGracePeriodSwarm", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "endpointSpecSwarm": { + "name": "endpointSpecSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "replicas": { + "name": "replicas", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "networkIds": { + "name": "networkIds", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "detachDokployNetwork": { + "name": "detachDokployNetwork", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environmentId": { + "name": "environmentId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serverId": { + "name": "serverId", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "libsql_environmentId_environment_environmentId_fk": { + "name": "libsql_environmentId_environment_environmentId_fk", + "tableFrom": "libsql", + "tableTo": "environment", + "columnsFrom": [ + "environmentId" + ], + "columnsTo": [ + "environmentId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "libsql_serverId_server_serverId_fk": { + "name": "libsql_serverId_server_serverId_fk", + "tableFrom": "libsql", + "tableTo": "server", + "columnsFrom": [ + "serverId" + ], + "columnsTo": [ + "serverId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "libsql_appName_unique": { + "name": "libsql_appName_unique", + "nullsNotDistinct": false, + "columns": [ + "appName" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mariadb": { + "name": "mariadb", + "schema": "", + "columns": { + "mariadbId": { + "name": "mariadbId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "appName": { + "name": "appName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "databaseName": { + "name": "databaseName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "databaseUser": { + "name": "databaseUser", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "databasePassword": { + "name": "databasePassword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rootPassword": { + "name": "rootPassword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dockerImage": { + "name": "dockerImage", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "args": { + "name": "args", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "memoryReservation": { + "name": "memoryReservation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "memoryLimit": { + "name": "memoryLimit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cpuReservation": { + "name": "cpuReservation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cpuLimit": { + "name": "cpuLimit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "externalPort": { + "name": "externalPort", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "applicationStatus": { + "name": "applicationStatus", + "type": "applicationStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "healthCheckSwarm": { + "name": "healthCheckSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "restartPolicySwarm": { + "name": "restartPolicySwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "placementSwarm": { + "name": "placementSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "updateConfigSwarm": { + "name": "updateConfigSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "rollbackConfigSwarm": { + "name": "rollbackConfigSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "modeSwarm": { + "name": "modeSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "labelsSwarm": { + "name": "labelsSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "networkSwarm": { + "name": "networkSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "stopGracePeriodSwarm": { + "name": "stopGracePeriodSwarm", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "endpointSpecSwarm": { + "name": "endpointSpecSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "ulimitsSwarm": { + "name": "ulimitsSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "replicas": { + "name": "replicas", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environmentId": { + "name": "environmentId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serverId": { + "name": "serverId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "networkIds": { + "name": "networkIds", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "detachDokployNetwork": { + "name": "detachDokployNetwork", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "mariadb_environmentId_environment_environmentId_fk": { + "name": "mariadb_environmentId_environment_environmentId_fk", + "tableFrom": "mariadb", + "tableTo": "environment", + "columnsFrom": [ + "environmentId" + ], + "columnsTo": [ + "environmentId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mariadb_serverId_server_serverId_fk": { + "name": "mariadb_serverId_server_serverId_fk", + "tableFrom": "mariadb", + "tableTo": "server", + "columnsFrom": [ + "serverId" + ], + "columnsTo": [ + "serverId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mariadb_appName_unique": { + "name": "mariadb_appName_unique", + "nullsNotDistinct": false, + "columns": [ + "appName" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_access_token_client_id_idx": { + "name": "oauth_access_token_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_user_id_idx": { + "name": "oauth_access_token_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_application_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_application_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_application", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_access_token_unique": { + "name": "oauth_access_token_access_token_unique", + "nullsNotDistinct": false, + "columns": [ + "access_token" + ] + }, + "oauth_access_token_refresh_token_unique": { + "name": "oauth_access_token_refresh_token_unique", + "nullsNotDistinct": false, + "columns": [ + "refresh_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_application": { + "name": "oauth_application", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_urls": { + "name": "redirect_urls", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_application_user_id_idx": { + "name": "oauth_application_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_application_user_id_user_id_fk": { + "name": "oauth_application_user_id_user_id_fk", + "tableFrom": "oauth_application", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_application_client_id_unique": { + "name": "oauth_application_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "consent_given": { + "name": "consent_given", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_consent_client_id_idx": { + "name": "oauth_consent_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_consent_user_id_idx": { + "name": "oauth_consent_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_consent_client_id_oauth_application_client_id_fk": { + "name": "oauth_consent_client_id_oauth_application_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_application", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mongo": { + "name": "mongo", + "schema": "", + "columns": { + "mongoId": { + "name": "mongoId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "appName": { + "name": "appName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "databaseUser": { + "name": "databaseUser", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "databasePassword": { + "name": "databasePassword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dockerImage": { + "name": "dockerImage", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'mongo:8'" + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "args": { + "name": "args", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "memoryReservation": { + "name": "memoryReservation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "memoryLimit": { + "name": "memoryLimit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cpuReservation": { + "name": "cpuReservation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cpuLimit": { + "name": "cpuLimit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "externalPort": { + "name": "externalPort", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "applicationStatus": { + "name": "applicationStatus", + "type": "applicationStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "healthCheckSwarm": { + "name": "healthCheckSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "restartPolicySwarm": { + "name": "restartPolicySwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "placementSwarm": { + "name": "placementSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "updateConfigSwarm": { + "name": "updateConfigSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "rollbackConfigSwarm": { + "name": "rollbackConfigSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "modeSwarm": { + "name": "modeSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "labelsSwarm": { + "name": "labelsSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "networkSwarm": { + "name": "networkSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "stopGracePeriodSwarm": { + "name": "stopGracePeriodSwarm", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "endpointSpecSwarm": { + "name": "endpointSpecSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "ulimitsSwarm": { + "name": "ulimitsSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "replicas": { + "name": "replicas", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environmentId": { + "name": "environmentId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serverId": { + "name": "serverId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "replicaSets": { + "name": "replicaSets", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "networkIds": { + "name": "networkIds", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "detachDokployNetwork": { + "name": "detachDokployNetwork", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "mongo_environmentId_environment_environmentId_fk": { + "name": "mongo_environmentId_environment_environmentId_fk", + "tableFrom": "mongo", + "tableTo": "environment", + "columnsFrom": [ + "environmentId" + ], + "columnsTo": [ + "environmentId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mongo_serverId_server_serverId_fk": { + "name": "mongo_serverId_server_serverId_fk", + "tableFrom": "mongo", + "tableTo": "server", + "columnsFrom": [ + "serverId" + ], + "columnsTo": [ + "serverId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mongo_appName_unique": { + "name": "mongo_appName_unique", + "nullsNotDistinct": false, + "columns": [ + "appName" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mount": { + "name": "mount", + "schema": "", + "columns": { + "mountId": { + "name": "mountId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "mountType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "hostPath": { + "name": "hostPath", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "volumeName": { + "name": "volumeName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "filePath": { + "name": "filePath", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uid": { + "name": "uid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "gid": { + "name": "gid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serviceType": { + "name": "serviceType", + "type": "serviceType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'application'" + }, + "mountPath": { + "name": "mountPath", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "applicationId": { + "name": "applicationId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "composeId": { + "name": "composeId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "libsqlId": { + "name": "libsqlId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mariadbId": { + "name": "mariadbId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mongoId": { + "name": "mongoId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mysqlId": { + "name": "mysqlId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postgresId": { + "name": "postgresId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redisId": { + "name": "redisId", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "mount_applicationId_application_applicationId_fk": { + "name": "mount_applicationId_application_applicationId_fk", + "tableFrom": "mount", + "tableTo": "application", + "columnsFrom": [ + "applicationId" + ], + "columnsTo": [ + "applicationId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mount_composeId_compose_composeId_fk": { + "name": "mount_composeId_compose_composeId_fk", + "tableFrom": "mount", + "tableTo": "compose", + "columnsFrom": [ + "composeId" + ], + "columnsTo": [ + "composeId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mount_libsqlId_libsql_libsqlId_fk": { + "name": "mount_libsqlId_libsql_libsqlId_fk", + "tableFrom": "mount", + "tableTo": "libsql", + "columnsFrom": [ + "libsqlId" + ], + "columnsTo": [ + "libsqlId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mount_mariadbId_mariadb_mariadbId_fk": { + "name": "mount_mariadbId_mariadb_mariadbId_fk", + "tableFrom": "mount", + "tableTo": "mariadb", + "columnsFrom": [ + "mariadbId" + ], + "columnsTo": [ + "mariadbId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mount_mongoId_mongo_mongoId_fk": { + "name": "mount_mongoId_mongo_mongoId_fk", + "tableFrom": "mount", + "tableTo": "mongo", + "columnsFrom": [ + "mongoId" + ], + "columnsTo": [ + "mongoId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mount_mysqlId_mysql_mysqlId_fk": { + "name": "mount_mysqlId_mysql_mysqlId_fk", + "tableFrom": "mount", + "tableTo": "mysql", + "columnsFrom": [ + "mysqlId" + ], + "columnsTo": [ + "mysqlId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mount_postgresId_postgres_postgresId_fk": { + "name": "mount_postgresId_postgres_postgresId_fk", + "tableFrom": "mount", + "tableTo": "postgres", + "columnsFrom": [ + "postgresId" + ], + "columnsTo": [ + "postgresId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mount_redisId_redis_redisId_fk": { + "name": "mount_redisId_redis_redisId_fk", + "tableFrom": "mount", + "tableTo": "redis", + "columnsFrom": [ + "redisId" + ], + "columnsTo": [ + "redisId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mysql": { + "name": "mysql", + "schema": "", + "columns": { + "mysqlId": { + "name": "mysqlId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "appName": { + "name": "appName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "databaseName": { + "name": "databaseName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "databaseUser": { + "name": "databaseUser", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "databasePassword": { + "name": "databasePassword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rootPassword": { + "name": "rootPassword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dockerImage": { + "name": "dockerImage", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "args": { + "name": "args", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "memoryReservation": { + "name": "memoryReservation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "memoryLimit": { + "name": "memoryLimit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cpuReservation": { + "name": "cpuReservation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cpuLimit": { + "name": "cpuLimit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "externalPort": { + "name": "externalPort", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "applicationStatus": { + "name": "applicationStatus", + "type": "applicationStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "healthCheckSwarm": { + "name": "healthCheckSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "restartPolicySwarm": { + "name": "restartPolicySwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "placementSwarm": { + "name": "placementSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "updateConfigSwarm": { + "name": "updateConfigSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "rollbackConfigSwarm": { + "name": "rollbackConfigSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "modeSwarm": { + "name": "modeSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "labelsSwarm": { + "name": "labelsSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "networkSwarm": { + "name": "networkSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "stopGracePeriodSwarm": { + "name": "stopGracePeriodSwarm", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "endpointSpecSwarm": { + "name": "endpointSpecSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "ulimitsSwarm": { + "name": "ulimitsSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "replicas": { + "name": "replicas", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environmentId": { + "name": "environmentId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serverId": { + "name": "serverId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "networkIds": { + "name": "networkIds", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "detachDokployNetwork": { + "name": "detachDokployNetwork", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "mysql_environmentId_environment_environmentId_fk": { + "name": "mysql_environmentId_environment_environmentId_fk", + "tableFrom": "mysql", + "tableTo": "environment", + "columnsFrom": [ + "environmentId" + ], + "columnsTo": [ + "environmentId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mysql_serverId_server_serverId_fk": { + "name": "mysql_serverId_server_serverId_fk", + "tableFrom": "mysql", + "tableTo": "server", + "columnsFrom": [ + "serverId" + ], + "columnsTo": [ + "serverId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mysql_appName_unique": { + "name": "mysql_appName_unique", + "nullsNotDistinct": false, + "columns": [ + "appName" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.network": { + "name": "network", + "schema": "", + "columns": { + "networkId": { + "name": "networkId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dockerId": { + "name": "dockerId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "driver": { + "name": "driver", + "type": "networkDriver", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'bridge'" + }, + "internal": { + "name": "internal", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "attachable": { + "name": "attachable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enableIPv4": { + "name": "enableIPv4", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enableIPv6": { + "name": "enableIPv6", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "mtu": { + "name": "mtu", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ipam": { + "name": "ipam", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organizationId": { + "name": "organizationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serverId": { + "name": "serverId", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "network_organizationId_organization_id_fk": { + "name": "network_organizationId_organization_id_fk", + "tableFrom": "network", + "tableTo": "organization", + "columnsFrom": [ + "organizationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "network_serverId_server_serverId_fk": { + "name": "network_serverId_server_serverId_fk", + "tableFrom": "network", + "tableTo": "server", + "columnsFrom": [ + "serverId" + ], + "columnsTo": [ + "serverId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom": { + "name": "custom", + "schema": "", + "columns": { + "customId": { + "name": "customId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.discord": { + "name": "discord", + "schema": "", + "columns": { + "discordId": { + "name": "discordId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "webhookUrl": { + "name": "webhookUrl", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "decoration": { + "name": "decoration", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email": { + "name": "email", + "schema": "", + "columns": { + "emailId": { + "name": "emailId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "smtpServer": { + "name": "smtpServer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "smtpPort": { + "name": "smtpPort", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fromAddress": { + "name": "fromAddress", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "toAddress": { + "name": "toAddress", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.gotify": { + "name": "gotify", + "schema": "", + "columns": { + "gotifyId": { + "name": "gotifyId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "serverUrl": { + "name": "serverUrl", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "appToken": { + "name": "appToken", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "decoration": { + "name": "decoration", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lark": { + "name": "lark", + "schema": "", + "columns": { + "larkId": { + "name": "larkId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "webhookUrl": { + "name": "webhookUrl", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mattermost": { + "name": "mattermost", + "schema": "", + "columns": { + "mattermostId": { + "name": "mattermostId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "webhookUrl": { + "name": "webhookUrl", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification": { + "name": "notification", + "schema": "", + "columns": { + "notificationId": { + "name": "notificationId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "appDeploy": { + "name": "appDeploy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "appBuildError": { + "name": "appBuildError", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "databaseBackup": { + "name": "databaseBackup", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "volumeBackup": { + "name": "volumeBackup", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "dokployRestart": { + "name": "dokployRestart", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "dokployBackup": { + "name": "dokployBackup", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "dockerCleanup": { + "name": "dockerCleanup", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "serverThreshold": { + "name": "serverThreshold", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "scheduleFailure": { + "name": "scheduleFailure", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notificationType": { + "name": "notificationType", + "type": "notificationType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slackId": { + "name": "slackId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegramId": { + "name": "telegramId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discordId": { + "name": "discordId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "emailId": { + "name": "emailId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resendId": { + "name": "resendId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gotifyId": { + "name": "gotifyId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ntfyId": { + "name": "ntfyId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mattermostId": { + "name": "mattermostId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customId": { + "name": "customId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "larkId": { + "name": "larkId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pushoverId": { + "name": "pushoverId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "teamsId": { + "name": "teamsId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organizationId": { + "name": "organizationId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "notification_slackId_slack_slackId_fk": { + "name": "notification_slackId_slack_slackId_fk", + "tableFrom": "notification", + "tableTo": "slack", + "columnsFrom": [ + "slackId" + ], + "columnsTo": [ + "slackId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_telegramId_telegram_telegramId_fk": { + "name": "notification_telegramId_telegram_telegramId_fk", + "tableFrom": "notification", + "tableTo": "telegram", + "columnsFrom": [ + "telegramId" + ], + "columnsTo": [ + "telegramId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_discordId_discord_discordId_fk": { + "name": "notification_discordId_discord_discordId_fk", + "tableFrom": "notification", + "tableTo": "discord", + "columnsFrom": [ + "discordId" + ], + "columnsTo": [ + "discordId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_emailId_email_emailId_fk": { + "name": "notification_emailId_email_emailId_fk", + "tableFrom": "notification", + "tableTo": "email", + "columnsFrom": [ + "emailId" + ], + "columnsTo": [ + "emailId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_resendId_resend_resendId_fk": { + "name": "notification_resendId_resend_resendId_fk", + "tableFrom": "notification", + "tableTo": "resend", + "columnsFrom": [ + "resendId" + ], + "columnsTo": [ + "resendId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_gotifyId_gotify_gotifyId_fk": { + "name": "notification_gotifyId_gotify_gotifyId_fk", + "tableFrom": "notification", + "tableTo": "gotify", + "columnsFrom": [ + "gotifyId" + ], + "columnsTo": [ + "gotifyId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_ntfyId_ntfy_ntfyId_fk": { + "name": "notification_ntfyId_ntfy_ntfyId_fk", + "tableFrom": "notification", + "tableTo": "ntfy", + "columnsFrom": [ + "ntfyId" + ], + "columnsTo": [ + "ntfyId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_mattermostId_mattermost_mattermostId_fk": { + "name": "notification_mattermostId_mattermost_mattermostId_fk", + "tableFrom": "notification", + "tableTo": "mattermost", + "columnsFrom": [ + "mattermostId" + ], + "columnsTo": [ + "mattermostId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_customId_custom_customId_fk": { + "name": "notification_customId_custom_customId_fk", + "tableFrom": "notification", + "tableTo": "custom", + "columnsFrom": [ + "customId" + ], + "columnsTo": [ + "customId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_larkId_lark_larkId_fk": { + "name": "notification_larkId_lark_larkId_fk", + "tableFrom": "notification", + "tableTo": "lark", + "columnsFrom": [ + "larkId" + ], + "columnsTo": [ + "larkId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_pushoverId_pushover_pushoverId_fk": { + "name": "notification_pushoverId_pushover_pushoverId_fk", + "tableFrom": "notification", + "tableTo": "pushover", + "columnsFrom": [ + "pushoverId" + ], + "columnsTo": [ + "pushoverId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_teamsId_teams_teamsId_fk": { + "name": "notification_teamsId_teams_teamsId_fk", + "tableFrom": "notification", + "tableTo": "teams", + "columnsFrom": [ + "teamsId" + ], + "columnsTo": [ + "teamsId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_organizationId_organization_id_fk": { + "name": "notification_organizationId_organization_id_fk", + "tableFrom": "notification", + "tableTo": "organization", + "columnsFrom": [ + "organizationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ntfy": { + "name": "ntfy", + "schema": "", + "columns": { + "ntfyId": { + "name": "ntfyId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "serverUrl": { + "name": "serverUrl", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "topic": { + "name": "topic", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pushover": { + "name": "pushover", + "schema": "", + "columns": { + "pushoverId": { + "name": "pushoverId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userKey": { + "name": "userKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "apiToken": { + "name": "apiToken", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "retry": { + "name": "retry", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "expire": { + "name": "expire", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resend": { + "name": "resend", + "schema": "", + "columns": { + "resendId": { + "name": "resendId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "apiKey": { + "name": "apiKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fromAddress": { + "name": "fromAddress", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "toAddress": { + "name": "toAddress", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack": { + "name": "slack", + "schema": "", + "columns": { + "slackId": { + "name": "slackId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "webhookUrl": { + "name": "webhookUrl", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "teamsId": { + "name": "teamsId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "webhookUrl": { + "name": "webhookUrl", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.telegram": { + "name": "telegram", + "schema": "", + "columns": { + "telegramId": { + "name": "telegramId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "botToken": { + "name": "botToken", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chatId": { + "name": "chatId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "messageThreadId": { + "name": "messageThreadId", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.patch": { + "name": "patch", + "schema": "", + "columns": { + "patchId": { + "name": "patchId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "patchType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'update'" + }, + "filePath": { + "name": "filePath", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applicationId": { + "name": "applicationId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "composeId": { + "name": "composeId", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "patch_applicationId_application_applicationId_fk": { + "name": "patch_applicationId_application_applicationId_fk", + "tableFrom": "patch", + "tableTo": "application", + "columnsFrom": [ + "applicationId" + ], + "columnsTo": [ + "applicationId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "patch_composeId_compose_composeId_fk": { + "name": "patch_composeId_compose_composeId_fk", + "tableFrom": "patch", + "tableTo": "compose", + "columnsFrom": [ + "composeId" + ], + "columnsTo": [ + "composeId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "patch_filepath_application_unique": { + "name": "patch_filepath_application_unique", + "nullsNotDistinct": false, + "columns": [ + "filePath", + "applicationId" + ] + }, + "patch_filepath_compose_unique": { + "name": "patch_filepath_compose_unique", + "nullsNotDistinct": false, + "columns": [ + "filePath", + "composeId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.port": { + "name": "port", + "schema": "", + "columns": { + "portId": { + "name": "portId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "publishedPort": { + "name": "publishedPort", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "publishMode": { + "name": "publishMode", + "type": "publishModeType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'host'" + }, + "targetPort": { + "name": "targetPort", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "protocol": { + "name": "protocol", + "type": "protocolType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "applicationId": { + "name": "applicationId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "port_applicationId_application_applicationId_fk": { + "name": "port_applicationId_application_applicationId_fk", + "tableFrom": "port", + "tableTo": "application", + "columnsFrom": [ + "applicationId" + ], + "columnsTo": [ + "applicationId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.postgres": { + "name": "postgres", + "schema": "", + "columns": { + "postgresId": { + "name": "postgresId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "appName": { + "name": "appName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "databaseName": { + "name": "databaseName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "databaseUser": { + "name": "databaseUser", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "databasePassword": { + "name": "databasePassword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dockerImage": { + "name": "dockerImage", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "args": { + "name": "args", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "memoryReservation": { + "name": "memoryReservation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "externalPort": { + "name": "externalPort", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "memoryLimit": { + "name": "memoryLimit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cpuReservation": { + "name": "cpuReservation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cpuLimit": { + "name": "cpuLimit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applicationStatus": { + "name": "applicationStatus", + "type": "applicationStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "healthCheckSwarm": { + "name": "healthCheckSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "restartPolicySwarm": { + "name": "restartPolicySwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "placementSwarm": { + "name": "placementSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "updateConfigSwarm": { + "name": "updateConfigSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "rollbackConfigSwarm": { + "name": "rollbackConfigSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "modeSwarm": { + "name": "modeSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "labelsSwarm": { + "name": "labelsSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "networkSwarm": { + "name": "networkSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "stopGracePeriodSwarm": { + "name": "stopGracePeriodSwarm", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "endpointSpecSwarm": { + "name": "endpointSpecSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "ulimitsSwarm": { + "name": "ulimitsSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "replicas": { + "name": "replicas", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environmentId": { + "name": "environmentId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serverId": { + "name": "serverId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "networkIds": { + "name": "networkIds", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "detachDokployNetwork": { + "name": "detachDokployNetwork", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "postgres_environmentId_environment_environmentId_fk": { + "name": "postgres_environmentId_environment_environmentId_fk", + "tableFrom": "postgres", + "tableTo": "environment", + "columnsFrom": [ + "environmentId" + ], + "columnsTo": [ + "environmentId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "postgres_serverId_server_serverId_fk": { + "name": "postgres_serverId_server_serverId_fk", + "tableFrom": "postgres", + "tableTo": "server", + "columnsFrom": [ + "serverId" + ], + "columnsTo": [ + "serverId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "postgres_appName_unique": { + "name": "postgres_appName_unique", + "nullsNotDistinct": false, + "columns": [ + "appName" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.preview_deployments": { + "name": "preview_deployments", + "schema": "", + "columns": { + "previewDeploymentId": { + "name": "previewDeploymentId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pullRequestId": { + "name": "pullRequestId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pullRequestNumber": { + "name": "pullRequestNumber", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pullRequestURL": { + "name": "pullRequestURL", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pullRequestTitle": { + "name": "pullRequestTitle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pullRequestCommentId": { + "name": "pullRequestCommentId", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "previewStatus": { + "name": "previewStatus", + "type": "applicationStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "appName": { + "name": "appName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "applicationId": { + "name": "applicationId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "composeId": { + "name": "composeId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domainId": { + "name": "domainId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "preview_deployments_application_pr_unique": { + "name": "preview_deployments_application_pr_unique", + "columns": [ + { + "expression": "applicationId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pullRequestId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"applicationId\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "preview_deployments_compose_pr_unique": { + "name": "preview_deployments_compose_pr_unique", + "columns": [ + { + "expression": "composeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pullRequestId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"composeId\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "preview_deployments_applicationId_application_applicationId_fk": { + "name": "preview_deployments_applicationId_application_applicationId_fk", + "tableFrom": "preview_deployments", + "tableTo": "application", + "columnsFrom": [ + "applicationId" + ], + "columnsTo": [ + "applicationId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "preview_deployments_composeId_compose_composeId_fk": { + "name": "preview_deployments_composeId_compose_composeId_fk", + "tableFrom": "preview_deployments", + "tableTo": "compose", + "columnsFrom": [ + "composeId" + ], + "columnsTo": [ + "composeId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "preview_deployments_domainId_domain_domainId_fk": { + "name": "preview_deployments_domainId_domain_domainId_fk", + "tableFrom": "preview_deployments", + "tableTo": "domain", + "columnsFrom": [ + "domainId" + ], + "columnsTo": [ + "domainId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "preview_deployments_appName_unique": { + "name": "preview_deployments_appName_unique", + "nullsNotDistinct": false, + "columns": [ + "appName" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project": { + "name": "project", + "schema": "", + "columns": { + "projectId": { + "name": "projectId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organizationId": { + "name": "organizationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "wildcardDomain": { + "name": "wildcardDomain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "useOrganizationWildcard": { + "name": "useOrganizationWildcard", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": { + "project_organizationId_organization_id_fk": { + "name": "project_organizationId_organization_id_fk", + "tableFrom": "project", + "tableTo": "organization", + "columnsFrom": [ + "organizationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.redirect": { + "name": "redirect", + "schema": "", + "columns": { + "redirectId": { + "name": "redirectId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "regex": { + "name": "regex", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replacement": { + "name": "replacement", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permanent": { + "name": "permanent", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "uniqueConfigKey": { + "name": "uniqueConfigKey", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "applicationId": { + "name": "applicationId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "redirect_applicationId_application_applicationId_fk": { + "name": "redirect_applicationId_application_applicationId_fk", + "tableFrom": "redirect", + "tableTo": "application", + "columnsFrom": [ + "applicationId" + ], + "columnsTo": [ + "applicationId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.redis": { + "name": "redis", + "schema": "", + "columns": { + "redisId": { + "name": "redisId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "appName": { + "name": "appName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dockerImage": { + "name": "dockerImage", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "args": { + "name": "args", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "memoryReservation": { + "name": "memoryReservation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "memoryLimit": { + "name": "memoryLimit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cpuReservation": { + "name": "cpuReservation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cpuLimit": { + "name": "cpuLimit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "externalPort": { + "name": "externalPort", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "applicationStatus": { + "name": "applicationStatus", + "type": "applicationStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "healthCheckSwarm": { + "name": "healthCheckSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "restartPolicySwarm": { + "name": "restartPolicySwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "placementSwarm": { + "name": "placementSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "updateConfigSwarm": { + "name": "updateConfigSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "rollbackConfigSwarm": { + "name": "rollbackConfigSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "modeSwarm": { + "name": "modeSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "labelsSwarm": { + "name": "labelsSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "networkSwarm": { + "name": "networkSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "stopGracePeriodSwarm": { + "name": "stopGracePeriodSwarm", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "endpointSpecSwarm": { + "name": "endpointSpecSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "ulimitsSwarm": { + "name": "ulimitsSwarm", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "replicas": { + "name": "replicas", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "environmentId": { + "name": "environmentId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serverId": { + "name": "serverId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "networkIds": { + "name": "networkIds", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "detachDokployNetwork": { + "name": "detachDokployNetwork", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "redis_environmentId_environment_environmentId_fk": { + "name": "redis_environmentId_environment_environmentId_fk", + "tableFrom": "redis", + "tableTo": "environment", + "columnsFrom": [ + "environmentId" + ], + "columnsTo": [ + "environmentId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "redis_serverId_server_serverId_fk": { + "name": "redis_serverId_server_serverId_fk", + "tableFrom": "redis", + "tableTo": "server", + "columnsFrom": [ + "serverId" + ], + "columnsTo": [ + "serverId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "redis_appName_unique": { + "name": "redis_appName_unique", + "nullsNotDistinct": false, + "columns": [ + "appName" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.registry": { + "name": "registry", + "schema": "", + "columns": { + "registryId": { + "name": "registryId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "registryName": { + "name": "registryName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imagePrefix": { + "name": "imagePrefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "registryUrl": { + "name": "registryUrl", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "awsAccessKeyId": { + "name": "awsAccessKeyId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "awsSecretAccessKey": { + "name": "awsSecretAccessKey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "awsRegion": { + "name": "awsRegion", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "selfHosted": { + "name": "selfHosted", + "type": "RegistryType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'cloud'" + }, + "organizationId": { + "name": "organizationId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "registry_organizationId_organization_id_fk": { + "name": "registry_organizationId_organization_id_fk", + "tableFrom": "registry", + "tableTo": "organization", + "columnsFrom": [ + "organizationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rollback": { + "name": "rollback", + "schema": "", + "columns": { + "rollbackId": { + "name": "rollbackId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "deploymentId": { + "name": "deploymentId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fullContext": { + "name": "fullContext", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "rollback_deploymentId_deployment_deploymentId_fk": { + "name": "rollback_deploymentId_deployment_deploymentId_fk", + "tableFrom": "rollback", + "tableTo": "deployment", + "columnsFrom": [ + "deploymentId" + ], + "columnsTo": [ + "deploymentId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.schedule": { + "name": "schedule", + "schema": "", + "columns": { + "scheduleId": { + "name": "scheduleId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cronExpression": { + "name": "cronExpression", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "appName": { + "name": "appName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serviceName": { + "name": "serviceName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shellType": { + "name": "shellType", + "type": "shellType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'bash'" + }, + "scheduleType": { + "name": "scheduleType", + "type": "scheduleType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'application'" + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "script": { + "name": "script", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applicationId": { + "name": "applicationId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "composeId": { + "name": "composeId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serverId": { + "name": "serverId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organizationId": { + "name": "organizationId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "schedule_applicationId_application_applicationId_fk": { + "name": "schedule_applicationId_application_applicationId_fk", + "tableFrom": "schedule", + "tableTo": "application", + "columnsFrom": [ + "applicationId" + ], + "columnsTo": [ + "applicationId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "schedule_composeId_compose_composeId_fk": { + "name": "schedule_composeId_compose_composeId_fk", + "tableFrom": "schedule", + "tableTo": "compose", + "columnsFrom": [ + "composeId" + ], + "columnsTo": [ + "composeId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "schedule_serverId_server_serverId_fk": { + "name": "schedule_serverId_server_serverId_fk", + "tableFrom": "schedule", + "tableTo": "server", + "columnsFrom": [ + "serverId" + ], + "columnsTo": [ + "serverId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "schedule_organizationId_organization_id_fk": { + "name": "schedule_organizationId_organization_id_fk", + "tableFrom": "schedule", + "tableTo": "organization", + "columnsFrom": [ + "organizationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_provider": { + "name": "scim_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_token": { + "name": "scim_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "scim_provider_organization_id_organization_id_fk": { + "name": "scim_provider_organization_id_organization_id_fk", + "tableFrom": "scim_provider", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_provider_provider_id_unique": { + "name": "scim_provider_provider_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_id" + ] + }, + "scim_provider_scim_token_unique": { + "name": "scim_provider_scim_token_unique", + "nullsNotDistinct": false, + "columns": [ + "scim_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.security": { + "name": "security", + "schema": "", + "columns": { + "securityId": { + "name": "securityId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "applicationId": { + "name": "applicationId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "security_applicationId_application_applicationId_fk": { + "name": "security_applicationId_application_applicationId_fk", + "tableFrom": "security", + "tableTo": "application", + "columnsFrom": [ + "applicationId" + ], + "columnsTo": [ + "applicationId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "security_username_applicationId_unique": { + "name": "security_username_applicationId_unique", + "nullsNotDistinct": false, + "columns": [ + "username", + "applicationId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server": { + "name": "server", + "schema": "", + "columns": { + "serverId": { + "name": "serverId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'root'" + }, + "appName": { + "name": "appName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enableDockerCleanup": { + "name": "enableDockerCleanup", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "buildsConcurrency": { + "name": "buildsConcurrency", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organizationId": { + "name": "organizationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serverStatus": { + "name": "serverStatus", + "type": "serverStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "serverType": { + "name": "serverType", + "type": "serverType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'deploy'" + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "default_domain": { + "name": "default_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sshKeyId": { + "name": "sshKeyId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metricsConfig": { + "name": "metricsConfig", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"server\":{\"type\":\"Remote\",\"refreshRate\":60,\"port\":4500,\"token\":\"\",\"urlCallback\":\"\",\"cronJob\":\"\",\"retentionDays\":2,\"thresholds\":{\"cpu\":0,\"memory\":0}},\"containers\":{\"refreshRate\":60,\"services\":{\"include\":[],\"exclude\":[]}}}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "server_organizationId_organization_id_fk": { + "name": "server_organizationId_organization_id_fk", + "tableFrom": "server", + "tableTo": "organization", + "columnsFrom": [ + "organizationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "server_sshKeyId_ssh-key_sshKeyId_fk": { + "name": "server_sshKeyId_ssh-key_sshKeyId_fk", + "tableFrom": "server", + "tableTo": "ssh-key", + "columnsFrom": [ + "sshKeyId" + ], + "columnsTo": [ + "sshKeyId" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ssh-key": { + "name": "ssh-key", + "schema": "", + "columns": { + "sshKeyId": { + "name": "sshKeyId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "privateKey": { + "name": "privateKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "publicKey": { + "name": "publicKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lastUsedAt": { + "name": "lastUsedAt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organizationId": { + "name": "organizationId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "ssh-key_organizationId_organization_id_fk": { + "name": "ssh-key_organizationId_organization_id_fk", + "tableFrom": "ssh-key", + "tableTo": "organization", + "columnsFrom": [ + "organizationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_provider_provider_id_unique": { + "name": "sso_provider_provider_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_tag": { + "name": "project_tag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "projectId": { + "name": "projectId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tagId": { + "name": "tagId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "project_tag_projectId_project_projectId_fk": { + "name": "project_tag_projectId_project_projectId_fk", + "tableFrom": "project_tag", + "tableTo": "project", + "columnsFrom": [ + "projectId" + ], + "columnsTo": [ + "projectId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_tag_tagId_tag_tagId_fk": { + "name": "project_tag_tagId_tag_tagId_fk", + "tableFrom": "project_tag", + "tableTo": "tag", + "columnsFrom": [ + "tagId" + ], + "columnsTo": [ + "tagId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_project_tag": { + "name": "unique_project_tag", + "nullsNotDistinct": false, + "columns": [ + "projectId", + "tagId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tag": { + "name": "tag", + "schema": "", + "columns": { + "tagId": { + "name": "tagId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organizationId": { + "name": "organizationId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "tag_organizationId_organization_id_fk": { + "name": "tag_organizationId_organization_id_fk", + "tableFrom": "tag", + "tableTo": "organization", + "columnsFrom": [ + "organizationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_org_tag_name": { + "name": "unique_org_tag_name", + "nullsNotDistinct": false, + "columns": [ + "organizationId", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "firstName": { + "name": "firstName", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "lastName": { + "name": "lastName", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "isRegistered": { + "name": "isRegistered", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "expirationDate": { + "name": "expirationDate", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "enablePaidFeatures": { + "name": "enablePaidFeatures", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "allowImpersonation": { + "name": "allowImpersonation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enableEnterpriseFeatures": { + "name": "enableEnterpriseFeatures", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "licenseKey": { + "name": "licenseKey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isValidEnterpriseLicense": { + "name": "isValidEnterpriseLicense", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serversQuantity": { + "name": "serversQuantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sendInvoiceNotifications": { + "name": "sendInvoiceNotifications", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "isEnterpriseCloud": { + "name": "isEnterpriseCloud", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trustedOrigins": { + "name": "trustedOrigins", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "bookmarkedTemplates": { + "name": "bookmarkedTemplates", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "ARRAY[]::text[]" + }, + "onboardingCompletedAt": { + "name": "onboardingCompletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vault_provider": { + "name": "vault_provider", + "schema": "", + "columns": { + "vaultProviderId": { + "name": "vaultProviderId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerType": { + "name": "providerType", + "type": "VaultProviderType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "assignments": { + "name": "assignments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "organizationId": { + "name": "organizationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "vault_provider_org_name_idx": { + "name": "vault_provider_org_name_idx", + "columns": [ + { + "expression": "organizationId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vault_provider_organizationId_organization_id_fk": { + "name": "vault_provider_organizationId_organization_id_fk", + "tableFrom": "vault_provider", + "tableTo": "organization", + "columnsFrom": [ + "organizationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.volume_backup": { + "name": "volume_backup", + "schema": "", + "columns": { + "volumeBackupId": { + "name": "volumeBackupId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "volumeName": { + "name": "volumeName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serviceType": { + "name": "serviceType", + "type": "serviceType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'application'" + }, + "appName": { + "name": "appName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serviceName": { + "name": "serviceName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "turnOff": { + "name": "turnOff", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cronExpression": { + "name": "cronExpression", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "keepLatestCount": { + "name": "keepLatestCount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "applicationId": { + "name": "applicationId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postgresId": { + "name": "postgresId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mariadbId": { + "name": "mariadbId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mongoId": { + "name": "mongoId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mysqlId": { + "name": "mysqlId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redisId": { + "name": "redisId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "libsqlId": { + "name": "libsqlId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "composeId": { + "name": "composeId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backupPolicyId": { + "name": "backupPolicyId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "destinationId": { + "name": "destinationId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "volume_backup_applicationId_application_applicationId_fk": { + "name": "volume_backup_applicationId_application_applicationId_fk", + "tableFrom": "volume_backup", + "tableTo": "application", + "columnsFrom": [ + "applicationId" + ], + "columnsTo": [ + "applicationId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "volume_backup_postgresId_postgres_postgresId_fk": { + "name": "volume_backup_postgresId_postgres_postgresId_fk", + "tableFrom": "volume_backup", + "tableTo": "postgres", + "columnsFrom": [ + "postgresId" + ], + "columnsTo": [ + "postgresId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "volume_backup_mariadbId_mariadb_mariadbId_fk": { + "name": "volume_backup_mariadbId_mariadb_mariadbId_fk", + "tableFrom": "volume_backup", + "tableTo": "mariadb", + "columnsFrom": [ + "mariadbId" + ], + "columnsTo": [ + "mariadbId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "volume_backup_mongoId_mongo_mongoId_fk": { + "name": "volume_backup_mongoId_mongo_mongoId_fk", + "tableFrom": "volume_backup", + "tableTo": "mongo", + "columnsFrom": [ + "mongoId" + ], + "columnsTo": [ + "mongoId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "volume_backup_mysqlId_mysql_mysqlId_fk": { + "name": "volume_backup_mysqlId_mysql_mysqlId_fk", + "tableFrom": "volume_backup", + "tableTo": "mysql", + "columnsFrom": [ + "mysqlId" + ], + "columnsTo": [ + "mysqlId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "volume_backup_redisId_redis_redisId_fk": { + "name": "volume_backup_redisId_redis_redisId_fk", + "tableFrom": "volume_backup", + "tableTo": "redis", + "columnsFrom": [ + "redisId" + ], + "columnsTo": [ + "redisId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "volume_backup_libsqlId_libsql_libsqlId_fk": { + "name": "volume_backup_libsqlId_libsql_libsqlId_fk", + "tableFrom": "volume_backup", + "tableTo": "libsql", + "columnsFrom": [ + "libsqlId" + ], + "columnsTo": [ + "libsqlId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "volume_backup_composeId_compose_composeId_fk": { + "name": "volume_backup_composeId_compose_composeId_fk", + "tableFrom": "volume_backup", + "tableTo": "compose", + "columnsFrom": [ + "composeId" + ], + "columnsTo": [ + "composeId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "volume_backup_backupPolicyId_backup_policy_backupPolicyId_fk": { + "name": "volume_backup_backupPolicyId_backup_policy_backupPolicyId_fk", + "tableFrom": "volume_backup", + "tableTo": "backup_policy", + "columnsFrom": [ + "backupPolicyId" + ], + "columnsTo": [ + "backupPolicyId" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "volume_backup_destinationId_destination_destinationId_fk": { + "name": "volume_backup_destinationId_destination_destinationId_fk", + "tableFrom": "volume_backup", + "tableTo": "destination", + "columnsFrom": [ + "destinationId" + ], + "columnsTo": [ + "destinationId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webServerSettings": { + "name": "webServerSettings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "serverIp": { + "name": "serverIp", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "certificateType": { + "name": "certificateType", + "type": "certificateType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "https": { + "name": "https", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "letsEncryptEmail": { + "name": "letsEncryptEmail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sshPrivateKey": { + "name": "sshPrivateKey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enableDockerCleanup": { + "name": "enableDockerCleanup", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "logCleanupCron": { + "name": "logCleanupCron", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'0 0 * * *'" + }, + "metricsConfig": { + "name": "metricsConfig", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"server\":{\"type\":\"Dokploy\",\"refreshRate\":60,\"port\":4500,\"token\":\"\",\"retentionDays\":2,\"cronJob\":\"\",\"urlCallback\":\"\",\"thresholds\":{\"cpu\":0,\"memory\":0}},\"containers\":{\"refreshRate\":60,\"services\":{\"include\":[],\"exclude\":[]}}}'::jsonb" + }, + "whitelabelingConfig": { + "name": "whitelabelingConfig", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"appName\":null,\"appDescription\":null,\"logoUrl\":null,\"faviconUrl\":null,\"customCss\":null,\"loginLogoUrl\":null,\"supportUrl\":null,\"docsUrl\":null,\"errorPageTitle\":null,\"errorPageDescription\":null,\"metaTitle\":null,\"footerText\":null}'::jsonb" + }, + "remoteServersOnly": { + "name": "remoteServersOnly", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "buildsConcurrency": { + "name": "buildsConcurrency", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "enforceSSO": { + "name": "enforceSSO", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "domainRestrictionConfig": { + "name": "domainRestrictionConfig", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"enabled\":false,\"allowedWildcards\":[]}'::jsonb" + }, + "cleanupCacheApplications": { + "name": "cleanupCacheApplications", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cleanupCacheOnPreviews": { + "name": "cleanupCacheOnPreviews", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cleanupCacheOnCompose": { + "name": "cleanupCacheOnCompose", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.buildType": { + "name": "buildType", + "schema": "public", + "values": [ + "dockerfile", + "heroku_buildpacks", + "paketo_buildpacks", + "nixpacks", + "static", + "railpack" + ] + }, + "public.sourceType": { + "name": "sourceType", + "schema": "public", + "values": [ + "docker", + "git", + "github", + "gitlab", + "bitbucket", + "gitea", + "drop" + ] + }, + "public.backupPolicyScopeType": { + "name": "backupPolicyScopeType", + "schema": "public", + "values": [ + "organization", + "projects", + "environments" + ] + }, + "public.backupType": { + "name": "backupType", + "schema": "public", + "values": [ + "database", + "compose" + ] + }, + "public.databaseType": { + "name": "databaseType", + "schema": "public", + "values": [ + "postgres", + "mariadb", + "mysql", + "mongo", + "web-server", + "libsql" + ] + }, + "public.buildPolicyAuditAction": { + "name": "buildPolicyAuditAction", + "schema": "public", + "values": [ + "settings_updated", + "exclusion_added", + "exclusion_removed", + "break_glass_granted", + "break_glass_consumed", + "remote_build_enforced", + "build_server_missing", + "deploy_coalesced", + "deploy_skipped", + "required_checks_failed", + "required_checks_timeout", + "deploy_by_digest" + ] + }, + "public.cloudflareTunnelRuntimeMode": { + "name": "cloudflareTunnelRuntimeMode", + "schema": "public", + "values": [ + "shared-managed" + ] + }, + "public.cloudflareTunnelRuntimeStatus": { + "name": "cloudflareTunnelRuntimeStatus", + "schema": "public", + "values": [ + "pending", + "running", + "error", + "stopped" + ] + }, + "public.composeType": { + "name": "composeType", + "schema": "public", + "values": [ + "docker-compose", + "stack" + ] + }, + "public.sourceTypeCompose": { + "name": "sourceTypeCompose", + "schema": "public", + "values": [ + "git", + "github", + "gitlab", + "bitbucket", + "gitea", + "raw" + ] + }, + "public.deploymentStatus": { + "name": "deploymentStatus", + "schema": "public", + "values": [ + "running", + "done", + "error", + "cancelled" + ] + }, + "public.DnsProviderType": { + "name": "DnsProviderType", + "schema": "public", + "values": [ + "cloudflare", + "route53", + "porkbun" + ] + }, + "public.cloudflareTunnelMode": { + "name": "cloudflareTunnelMode", + "schema": "public", + "values": [ + "existing-instance", + "shared-managed" + ] + }, + "public.domainType": { + "name": "domainType", + "schema": "public", + "values": [ + "compose", + "application", + "preview" + ] + }, + "public.gitProviderType": { + "name": "gitProviderType", + "schema": "public", + "values": [ + "github", + "gitlab", + "bitbucket", + "gitea" + ] + }, + "public.mountType": { + "name": "mountType", + "schema": "public", + "values": [ + "bind", + "volume", + "file" + ] + }, + "public.serviceType": { + "name": "serviceType", + "schema": "public", + "values": [ + "application", + "postgres", + "mysql", + "mariadb", + "mongo", + "redis", + "compose", + "libsql" + ] + }, + "public.networkDriver": { + "name": "networkDriver", + "schema": "public", + "values": [ + "bridge", + "host", + "overlay", + "macvlan", + "none", + "ipvlan" + ] + }, + "public.notificationType": { + "name": "notificationType", + "schema": "public", + "values": [ + "slack", + "telegram", + "discord", + "email", + "resend", + "gotify", + "ntfy", + "mattermost", + "pushover", + "custom", + "lark", + "teams" + ] + }, + "public.patchType": { + "name": "patchType", + "schema": "public", + "values": [ + "create", + "update", + "delete" + ] + }, + "public.protocolType": { + "name": "protocolType", + "schema": "public", + "values": [ + "tcp", + "udp" + ] + }, + "public.publishModeType": { + "name": "publishModeType", + "schema": "public", + "values": [ + "ingress", + "host" + ] + }, + "public.RegistryType": { + "name": "RegistryType", + "schema": "public", + "values": [ + "selfHosted", + "cloud", + "awsEcr" + ] + }, + "public.scheduleType": { + "name": "scheduleType", + "schema": "public", + "values": [ + "application", + "compose", + "server", + "dokploy-server" + ] + }, + "public.shellType": { + "name": "shellType", + "schema": "public", + "values": [ + "bash", + "sh" + ] + }, + "public.serverStatus": { + "name": "serverStatus", + "schema": "public", + "values": [ + "active", + "inactive" + ] + }, + "public.serverType": { + "name": "serverType", + "schema": "public", + "values": [ + "deploy", + "build" + ] + }, + "public.applicationStatus": { + "name": "applicationStatus", + "schema": "public", + "values": [ + "idle", + "running", + "done", + "error" + ] + }, + "public.certificateType": { + "name": "certificateType", + "schema": "public", + "values": [ + "letsencrypt", + "none", + "custom" + ] + }, + "public.sqldNode": { + "name": "sqldNode", + "schema": "public", + "values": [ + "primary", + "replica" + ] + }, + "public.triggerType": { + "name": "triggerType", + "schema": "public", + "values": [ + "push", + "tag" + ] + }, + "public.VaultProviderType": { + "name": "VaultProviderType", + "schema": "public", + "values": [ + "hashicorp", + "infisical", + "aws", + "doppler", + "azure", + "scaleway", + "phase" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/dokploy/drizzle/meta/_journal.json b/apps/dokploy/drizzle/meta/_journal.json index d69d9425d7..6fddb827f6 100644 --- a/apps/dokploy/drizzle/meta/_journal.json +++ b/apps/dokploy/drizzle/meta/_journal.json @@ -1401,6 +1401,13 @@ "when": 1788711192772, "tag": "0199_complex_mantis", "breakpoints": true + }, + { + "idx": 200, + "version": "7", + "when": 1789011785969, + "tag": "0200_handy_lifeguard", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx index abcc566bc7..b9bd5f6a30 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx @@ -19,6 +19,8 @@ import { ShowPorts } from "@/components/dashboard/application/advanced/ports/sho import { ShowRedirects } from "@/components/dashboard/application/advanced/redirects/show-redirects"; import { ShowSecurity } from "@/components/dashboard/application/advanced/security/show-security"; import { ShowBuildServer } from "@/components/dashboard/application/advanced/show-build-server"; +// build-policy +import { ShowRequiredChecks } from "@/components/dashboard/application/advanced/show-required-checks"; import { ShowResources } from "@/components/dashboard/application/advanced/show-resources"; import { ShowTraefikConfig } from "@/components/dashboard/application/advanced/traefik/show-traefik-config"; import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes"; @@ -426,6 +428,8 @@ const Service = ( type="application" /> + {/* build-policy */} + diff --git a/apps/dokploy/pages/dashboard/settings/server.tsx b/apps/dokploy/pages/dashboard/settings/server.tsx index bc76a65171..2811951d05 100644 --- a/apps/dokploy/pages/dashboard/settings/server.tsx +++ b/apps/dokploy/pages/dashboard/settings/server.tsx @@ -4,6 +4,12 @@ import type { GetServerSidePropsContext } from "next"; import type { ReactElement } from "react"; import superjson from "superjson"; import { ShowBackups } from "@/components/dashboard/database/backups/show-backups"; +// build-policy +import { BuildPolicy } from "@/components/dashboard/settings/build-policy"; +// build-policy +import { BuildPolicyAudit } from "@/components/dashboard/settings/build-policy-audit"; +// build-policy +import { BuildPolicyExclusions } from "@/components/dashboard/settings/build-policy-exclusions"; import { DomainRestriction } from "@/components/dashboard/settings/domain-restriction"; import { WebDomain } from "@/components/dashboard/settings/web-domain"; import { WebServer } from "@/components/dashboard/settings/web-server"; @@ -21,6 +27,10 @@ const Page = () => { + {/* build-policy */} + + +
diff --git a/packages/server/src/services/build-policy/README.md b/packages/server/src/services/build-policy/README.md new file mode 100644 index 0000000000..92f5525e21 --- /dev/null +++ b/packages/server/src/services/build-policy/README.md @@ -0,0 +1,214 @@ +# build-policy — enforced remote builds + +Fork module. Dokploy is the only builder: GitHub-sourced applications build on +the organization's build server, the image is pushed to the organization +registry tagged `:`, and the deploy pulls it **by digest**. CI +never builds a production image; it waits for this one. + +Upstream Dokploy has none of this. Everything here is additive, and the touch +points in upstream files are listed below so an upstream merge has one page to +reconcile rather than a search. + +Design source: `docs/superpowers/specs/2026-09-10-ci-build-once-and-pool-design.md` +§5.1–5.6, §7, §8, §11. + +--- + +## Behaviours + +| # | Behaviour | Where | +|---|---|---| +| 1 | Org setting `enforceRemoteBuilds`, exclusions, audited break-glass | `settings.ts`, `exclusions.ts`, `audit.ts` | +| 2 | Enforced units build on `defaultBuildServerId`; no silent local fallback | `policy.ts`, `apply.ts` | +| 3 | Push `:`, capture the digest, deploy by digest | `apply.ts`, `image.ts` | +| 4 | Queue coalescing on enqueue | `coalesce.ts`, `webhook.ts` | +| 5 | Derived default `watchPaths`, `[skip deploy]` marker | `watch-paths.ts`, `skip-deploy.ts`, `webhook.ts` | +| 6 | Per-unit `requiredChecks` gating | `required-checks.ts`, `github-checks.ts` | +| 7 | Deploy-hook body `{image, tag, digest}` | `hook-body.ts`, `pinned-deploy.ts` | + +The policy is **off by default**. With no `build_policy_settings` row for an +organization, every code path below short-circuits to the upstream behaviour. + +## The decision + +`policy.ts` holds the whole decision as a pure function, in this order: + +1. enforcement off, or no settings row → `local` / `not_enforced` +2. not a github.com source → `local` / `not_github` +3. excluded → `local` / `excluded` (checked before break-glass so an exclusion + never burns a grant) +4. a pending break-glass grant → `local` / `break_glass`, and the grant is spent +5. a compose unit → `local` / `compose_build_not_relocatable` (see Known gap) +6. no build server → `error` / `NO_BUILD_SERVER` +7. no registry → `error` / `NO_REGISTRY` +8. otherwise → `remote` + +Steps 6 and 7 are the "no silent local fallback" rule from spec 5.2.8: an +enforced unit with nowhere to build fails the deploy with a named error. The +manual escape is the audited break-glass, not an automatic downgrade. + +## Files + +| File | Contents | +|---|---| +| `policy.ts` | the pure decision (above) | +| `source.ts` | github.com detection for `sourceType: github` and for a `git` source whose `customGitUrl` is on github.com; `owner/repo` parsing | +| `settings.ts` | organization settings read/upsert, required-checks timeout | +| `exclusions.ts` | exclusion list / lookup / add / remove | +| `audit.ts` | append-only trail; break-glass grant, lookup and consumption | +| `resolve.ts` | database-backed wrapper around `policy.ts`; the only place a grant is spent | +| `apply.ts` | the application deploy path: plan, remote tag/push/digest shell, digest read-back, deploy-by-digest preparation | +| `image.ts` | `:` tagging, digest validation, digest-marker parsing, `repo@sha256:…` refs | +| `hook-body.ts` | validation of a deploy-hook `{image, tag, digest}` body against the org registries | +| `pinned-deploy.ts` | a whole deploy of a supplied image, with no build | +| `required-checks.ts` | pure check-run evaluation plus a polling wait with an injectable clock | +| `github-checks.ts` | the same wait, wired to the GitHub App installation token | +| `coalesce.ts` | drop still-waiting deploys for a unit and audit it | +| `watch-paths.ts` | derive default `watchPaths` from `buildPath` / Dockerfile / compose path | +| `skip-deploy.ts` | the `[skip deploy]` commit-message marker | +| `webhook.ts` | the single enqueue-time gate every deploy entry point calls | +| `errors.ts` | `BuildPolicyError` with stable codes | + +--- + +## Hook points in upstream code + +Every one is marked in the source with `build-policy hook`. Grep for that +string to find them all. There are **eleven**, in eight files. + +### `packages/server/src/services/application.ts` + +Four hooks in `deployApplication`, and the same four in `rebuildApplication`. + +| Hook | What it replaces / adds | +|---|---| +| 1/4 | `const serverId = application.buildServerId \|\| application.serverId` becomes the same expression with `buildPolicy.buildServerId` in front. `planApplicationBuild` throws `BuildPolicyError` on an `error` decision, which is how a missing build server fails the deploy. | +| 2/4 | after `getBuildCommand`, appends `getBuildPolicyPushCommand(...)`. Returns `""` when not enforcing, so the built command is byte-identical in that case. | +| 3/4 | after the build shell runs, `prepareBuildPolicyDeploy(...)` gates on required checks, reads the published digest, writes it to the deployment row, and returns the application object to deploy. Returns the input unchanged when not enforcing. | +| 4/4 | `mechanizeDockerContainer(application)` becomes `mechanizeDockerContainer(deployTarget)`. | + +Plus one import block, marked `Fork module`. + +**Merge note:** if upstream moves the `serverId` line or the +`mechanizeDockerContainer` call, re-apply hooks 1/4 and 4/4 to the new location. +Hooks 2/4 and 3/4 must stay between the build shell and the swarm update. + +### `packages/server/src/utils/builders/index.ts` + +- `ApplicationNested` gains an optional `buildPolicyImage?: string | null`. +- `getImageName` returns it first when set. Three lines. + +This is the deploy-by-digest seam. Nothing else in the builders is touched, so +the six build types are exactly upstream's. + +### `apps/dokploy/pages/api/deploy/github.ts` + +- one import of `buildPolicyDeployGate`, one of the two `cleanQueues*` helpers. +- in the push→applications loop and the push→composes loop, a + `buildPolicyDeployGate({...}); if (!gate.deploy) continue;` block **after** + upstream's own `shouldDeploy` check, so upstream's lines are untouched. + +Tag pushes and pull-request previews are deliberately not gated. + +### `apps/dokploy/pages/api/deploy/[refreshToken].ts` + +- the gate, plus `resolveDeployHookImage(...)` for the optional + `{image, tag, digest}` body; a validated image is passed through the job as + `pinnedImage`. + +### `apps/dokploy/pages/api/deploy/compose/[refreshToken].ts` + +- the gate. A supplied image is **rejected with a 400**, not ignored — see + Known gap. + +### `apps/dokploy/server/queues/queueSetup.ts` + +- `cleanQueuesByApplication` and `cleanQueuesByCompose` now return the number of + jobs they dropped. Both were already exported and both existing callers ignore + the value, so this is additive. + +### `apps/dokploy/server/queues/queue-types.ts` + +- the `applicationType: "application"` arm gains an optional `pinnedImage`. + +### `apps/dokploy/server/queues/deployments-queue.ts` + +- one `if (job.data.pinnedImage)` branch ahead of the existing + `deploy` / `redeploy` branches, calling `deployPinnedApplicationImage`. + +### Barrels + +- `packages/server/src/db/schema/index.ts` — one export line. +- `packages/server/src/index.ts` — one export line. +- `apps/dokploy/server/api/root.ts` — one import and one router key. + +### Schema columns on upstream tables + +- `application.requiredChecks` (`text[]`), plus one zod line in the file's + `createSchema` overrides because drizzle-zod 0.5.1 does not infer array + columns (`watchPaths` needs the same line). +- `compose.requiredChecks` (`text[]`), same. +- `deployment.imageTag`, `deployment.imageDigest` (`text`). + +--- + +## Migration + +`apps/dokploy/drizzle/0200_handy_lifeguard.sql`, written idempotently +(`IF NOT EXISTS`, `DO $$ … EXCEPTION WHEN duplicate_object`) in the fork house +style, so a re-run on a partially migrated database is a no-op. + +Creates `build_policy_settings`, `build_policy_exclusion`, +`build_policy_audit` and the `buildPolicyAuditAction` enum; adds the four +columns above. No data migration: an organization with no settings row has the +policy off, which is the pre-change behaviour. + +--- + +## How the digest crosses hosts + +The build runs as a detached shell on the build server; its only channel back +is the deployment log file. So the appended shell echoes + +``` +__DOKPLOY_IMAGE_DIGEST__ //: sha256:<64 hex> +``` + +and `readPublishedImage` greps that one line back off the build server. If the +line is absent the deploy fails with `DIGEST_NOT_PUBLISHED` rather than +deploying a mutable tag. + +The sha is resolved inside the shell with `git rev-parse HEAD` rather than +passed in, because a manual redeploy has no webhook payload to read it from. + +--- + +## Known gap: compose units + +A compose unit builds and runs in a single `docker compose up --build`, so its +build cannot be moved to another host without splitting the deploy in two and +requiring every buildable service to declare an `image:` key pointing at the org +registry. That is a large change to upstream's compose path and it would break +every compose unit in the fleet that has no `image:` keys today. + +So `decideBuildPolicy` returns `local` / `compose_build_not_relocatable` for +compose units, and the compose deploy hook rejects a supplied image with a 400. +There is a test asserting exactly that reason +(`policy-decision.test.ts` → "does not relocate a compose build, and says so +explicitly"), so the gap is visible and any future change to it is deliberate. + +**Every other behaviour applies to compose units in full**: exclusions, +break-glass, queue coalescing, `[skip deploy]`, derived `watchPaths` and +`requiredChecks`. + +--- + +## Tests + +- `apps/dokploy/__test__/build-policy/*.test.ts` — unit tests for the pure core. +- `apps/dokploy/__test__/build-policy/deploy-path.integration.test.ts` — drives + the deploy path end to end with docker, ssh and git mocked, and is the + tripwire for upstream merges (spec §11): if an upstream merge removes a hook + point, these fail loudly rather than silently reverting the policy. + +Run: `cd apps/dokploy && npx vitest run --config __test__/vitest.config.ts __test__/build-policy` diff --git a/packages/server/src/services/build-policy/apply.ts b/packages/server/src/services/build-policy/apply.ts index b3f3550ed4..255d6d70fb 100644 --- a/packages/server/src/services/build-policy/apply.ts +++ b/packages/server/src/services/build-policy/apply.ts @@ -1,4 +1,4 @@ -import { join } from "node:path"; +import { posix } from "node:path"; import { paths } from "@dokploy/server/constants"; import { getSafeRegistryLoginCommand } from "@dokploy/server/db/schema"; import { getECRAuthToken } from "@dokploy/server/utils/aws/ecr"; @@ -163,7 +163,9 @@ export const getBuildPolicyPushCommand = async ( }); const { APPLICATIONS_PATH } = paths(!!serverId); - const codeDir = join(APPLICATIONS_PATH, appName, "code"); + // posix.join: the shell always runs on the Linux build host, so the path + // must use forward slashes even when Dokploy itself runs on Windows. + const codeDir = posix.join(APPLICATIONS_PATH, appName, "code"); const repository = plan.repository; return ` From 09a8942a8d79c663eb80a6719e2ecdf06909fbf7 Mon Sep 17 00:00:00 2001 From: AminDhouib Date: Thu, 10 Sep 2026 13:48:24 -0400 Subject: [PATCH 04/18] fix(build-policy): review findings 1-6, 9-11 Review of PR #209 returned FIX. This commit covers the four blockers, the two majors and the three minors that were straightforward, each with a test written to fail first. 1. The enqueue gate and the deploy-hook image body now short-circuit while no organization enforces remote builds, behind a 5s cached probe, so an unenforced instance behaves exactly as upstream does. 2. The gate makes no SSH round trip and no settings read on that path. 3. A refused plan (NO_BUILD_SERVER / NO_REGISTRY) now creates the deployment, marks it and the application errored and sends the build error notification before the error is rethrown. The log append is best-effort so an unreachable host cannot cost the notification. 4. A deploy-hook image must equal the unit's own repository, not merely sit on a host the organization owns. 5. Coalescing drops only the unit's own plain deploys; a queued preview for the same application survives, and the audit names what it dropped. 6. The digest read-back rejects a marker naming any repository other than the one this deploy publishes, and runs it through assertSafeImageReference before pinning. 9. The per-unit build server field is read-only with a reason while the policy enforces. 10. addExclusion and allowLocalBuildOnce assert the unit belongs to the active organization; audit is now an adminProcedure. 11. Required checks match commit status contexts as well as check runs. Also fixes a latent bug: createDeployment created the deployment log on application.buildServerId || serverId, which is the wrong host once the policy relocates the build. --- .../__test__/build-policy/coalesce.test.ts | 132 +++++- .../deploy-path.integration.test.ts | 97 +++- .../build-policy/image-and-hook-body.test.ts | 53 ++- .../build-policy/plan-failure.test.ts | 158 +++++++ .../build-policy/policy-decision.test.ts | 4 +- .../policy-off-is-upstream.test.ts | 423 ++++++++++++++++++ .../build-policy/published-image.test.ts | 156 +++++++ .../build-policy/required-checks.test.ts | 21 +- .../build-policy/router-and-checks.test.ts | 345 ++++++++++++++ .../build-policy/source-and-markers.test.ts | 16 +- .../advanced/show-build-server.tsx | 28 +- .../advanced/show-required-checks.tsx | 8 +- .../pages/api/deploy/[refreshToken].ts | 12 +- .../api/deploy/compose/[refreshToken].ts | 22 +- apps/dokploy/pages/api/deploy/github.ts | 10 +- .../server/api/routers/build-policy.ts | 24 +- .../server/queues/deployments-queue.ts | 4 +- apps/dokploy/server/queues/queueSetup.ts | 33 ++ packages/server/src/services/application.ts | 61 ++- .../server/src/services/build-policy/apply.ts | 207 +++++++-- .../server/src/services/build-policy/audit.ts | 5 +- .../src/services/build-policy/coalesce.ts | 71 ++- .../src/services/build-policy/exclusions.ts | 5 +- .../services/build-policy/github-checks.ts | 96 +++- .../src/services/build-policy/hook-body.ts | 29 +- .../server/src/services/build-policy/image.ts | 6 +- .../server/src/services/build-policy/index.ts | 1 + .../src/services/build-policy/ownership.ts | 80 ++++ .../services/build-policy/pinned-deploy.ts | 9 +- .../src/services/build-policy/settings.ts | 39 ++ .../src/services/build-policy/skip-deploy.ts | 4 +- .../src/services/build-policy/source.ts | 4 +- .../src/services/build-policy/webhook.ts | 123 +++-- packages/server/src/services/deployment.ts | 12 +- 34 files changed, 2096 insertions(+), 202 deletions(-) create mode 100644 apps/dokploy/__test__/build-policy/plan-failure.test.ts create mode 100644 apps/dokploy/__test__/build-policy/policy-off-is-upstream.test.ts create mode 100644 apps/dokploy/__test__/build-policy/published-image.test.ts create mode 100644 apps/dokploy/__test__/build-policy/router-and-checks.test.ts create mode 100644 packages/server/src/services/build-policy/ownership.ts diff --git a/apps/dokploy/__test__/build-policy/coalesce.test.ts b/apps/dokploy/__test__/build-policy/coalesce.test.ts index 341931aefb..7389c28da6 100644 --- a/apps/dokploy/__test__/build-policy/coalesce.test.ts +++ b/apps/dokploy/__test__/build-policy/coalesce.test.ts @@ -1,5 +1,8 @@ +import { + coalesceQueuedDeploy, + isCoalescableDeployJob, +} from "@dokploy/server/services/build-policy/coalesce"; import { describe, expect, it, vi } from "vitest"; -import { coalesceQueuedDeploy } from "@dokploy/server/services/build-policy/coalesce"; describe("coalesceQueuedDeploy", () => { const base = { @@ -28,7 +31,10 @@ describe("coalesceQueuedDeploy", () => { action: "deploy_coalesced", applicationId: "app-1", composeId: null, - metadata: expect.objectContaining({ removed: 1, unitName: "sendly-web" }), + metadata: expect.objectContaining({ + removed: 1, + unitName: "sendly-web", + }), }), ); }); @@ -91,3 +97,125 @@ describe("coalesceQueuedDeploy", () => { 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[]) => ({ + 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", + ]); + }); +}); diff --git a/apps/dokploy/__test__/build-policy/deploy-path.integration.test.ts b/apps/dokploy/__test__/build-policy/deploy-path.integration.test.ts index c2494a3271..57fb45cf3f 100644 --- a/apps/dokploy/__test__/build-policy/deploy-path.integration.test.ts +++ b/apps/dokploy/__test__/build-policy/deploy-path.integration.test.ts @@ -23,6 +23,7 @@ const mocks = vi.hoisted(() => ({ listCheckRuns: vi.fn(), environmentsFindFirst: vi.fn(), applicationsFindFirst: vi.fn(), + buildPolicySettingsFindFirst: vi.fn(), })); vi.mock("@dokploy/server/db", () => { @@ -34,6 +35,7 @@ vi.mock("@dokploy/server/db", () => { from: vi.fn(() => self), innerJoin: vi.fn(() => self), returning: vi.fn().mockResolvedValue([{}]), + // biome-ignore lint/suspicious/noThenProperty: drizzle's query builder is itself a thenable, so the fake standing in for it must be one too then: (resolve: (value: unknown) => void) => resolve([]), }; return self; @@ -50,6 +52,9 @@ vi.mock("@dokploy/server/db", () => { patch: { findMany: vi.fn().mockResolvedValue([]) }, member: { findMany: vi.fn().mockResolvedValue([]) }, environments: { findFirst: mocks.environmentsFindFirst }, + buildPolicySettings: { + findFirst: mocks.buildPolicySettingsFindFirst, + }, }, }, }; @@ -169,11 +174,13 @@ import { } from "@dokploy/server/services/application"; import { DIGEST_MARKER } from "@dokploy/server/services/build-policy/image"; import { deployPinnedApplicationImage } from "@dokploy/server/services/build-policy/pinned-deploy"; +import { clearBuildPolicyEnforcementCache } from "@dokploy/server/services/build-policy/settings"; import { buildPolicyDeployGate } from "@dokploy/server/services/build-policy/webhook"; import * as deploymentService from "@dokploy/server/services/deployment"; import * as registryService from "@dokploy/server/services/registry"; import * as builders from "@dokploy/server/utils/builders"; import * as dockerUtils from "@dokploy/server/utils/docker/utils"; +import * as buildErrorNotifications from "@dokploy/server/utils/notifications/build-error"; import * as execProcess from "@dokploy/server/utils/process/execAsync"; import * as gitProvider from "@dokploy/server/utils/providers/git"; @@ -315,6 +322,10 @@ const primeMocks = (app: Record = APPLICATION()) => { environmentId: "env-1", project: { organizationId: "org-1" }, }); + // The cheap "does anybody enforce at all" probe the gate makes first. + mocks.buildPolicySettingsFindFirst.mockResolvedValue({ + buildPolicySettingsId: "bps-1", + }); }; /** The shell that was handed to the build host. */ @@ -339,6 +350,7 @@ const deployedApplication = () => beforeEach(() => { vi.clearAllMocks(); + clearBuildPolicyEnforcementCache(); primeMocks(); }); @@ -613,6 +625,80 @@ describe("no silent local fallback", () => { ).rejects.toMatchObject({ code: "NO_REGISTRY" }); }); + /** + * Finding 3 of the PR #209 review: the plan used to throw before + * `createDeployment`, so a refused deploy left no deployment row, no error + * status and no notification — the team saw nothing at all. + */ + it("still records a deployment for the refused deploy", async () => { + mocks.findBuildPolicySettings.mockResolvedValue( + SETTINGS({ defaultBuildServerId: null }), + ); + await deployApplication({ + applicationId: "app-1", + titleLog: "Manual deployment", + descriptionLog: "", + }).catch(() => {}); + expect(deploymentService.createDeployment).toHaveBeenCalledWith( + expect.objectContaining({ + applicationId: "app-1", + title: "Manual deployment", + }), + ); + }); + + it("marks that deployment as errored", async () => { + mocks.findBuildPolicySettings.mockResolvedValue( + SETTINGS({ defaultBuildServerId: null }), + ); + await deployApplication({ + applicationId: "app-1", + titleLog: "t", + descriptionLog: "", + }).catch(() => {}); + expect(deploymentService.updateDeploymentStatus).toHaveBeenCalledWith( + "deployment-1", + "error", + ); + }); + + it("sends exactly one build-error notification naming the reason", async () => { + mocks.findBuildPolicySettings.mockResolvedValue( + SETTINGS({ defaultBuildServerId: null }), + ); + await deployApplication({ + applicationId: "app-1", + titleLog: "t", + descriptionLog: "", + }).catch(() => {}); + const send = vi.mocked(buildErrorNotifications.sendBuildErrorNotifications); + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0]?.[0]).toMatchObject({ + applicationType: "application", + organizationId: "org-1", + }); + expect(String(send.mock.calls[0]?.[0]?.errorMessage)).toMatch( + /build server/i, + ); + }); + + it("writes the reason into the deployment log", async () => { + mocks.findBuildPolicySettings.mockResolvedValue( + SETTINGS({ defaultRegistryId: null }), + ); + await deployApplication({ + applicationId: "app-1", + titleLog: "t", + descriptionLog: "", + }).catch(() => {}); + const wrote = vi + .mocked(execProcess.execAsyncRemote) + .mock.calls.some(([, command]) => + String(command).includes("/var/log/deployment-1.log"), + ); + expect(wrote).toBe(true); + }); + it("audits the refusal so the reason is recoverable", async () => { mocks.findBuildPolicySettings.mockResolvedValue( SETTINGS({ defaultBuildServerId: null }), @@ -717,7 +803,10 @@ describe("required checks", () => { it("audits a failed gate", async () => { mocks.listCheckRuns.mockResolvedValue({ - data: { data: [], check_runs: [checkRun("build", "completed", "failure")] }, + data: { + data: [], + check_runs: [checkRun("build", "completed", "failure")], + }, }); await deployApplication({ applicationId: "app-1", @@ -774,7 +863,11 @@ describe("deploy-hook body with an image", () => { it("still honours required checks", async () => { primeMocks(APPLICATION({ requiredChecks: ["build"] })); mocks.listCheckRuns.mockResolvedValue({ - data: { check_runs: [{ name: "build", status: "completed", conclusion: "failure" }] }, + data: { + check_runs: [ + { name: "build", status: "completed", conclusion: "failure" }, + ], + }, }); await expect( deployPinnedApplicationImage({ diff --git a/apps/dokploy/__test__/build-policy/image-and-hook-body.test.ts b/apps/dokploy/__test__/build-policy/image-and-hook-body.test.ts index 4cad97bb69..e5c24b0925 100644 --- a/apps/dokploy/__test__/build-policy/image-and-hook-body.test.ts +++ b/apps/dokploy/__test__/build-policy/image-and-hook-body.test.ts @@ -1,15 +1,15 @@ -import { describe, expect, it } from "vitest"; import { BuildPolicyError } from "@dokploy/server/services/build-policy/errors"; +import { parseDeployHookImage } from "@dokploy/server/services/build-policy/hook-body"; import { - DIGEST_MARKER, - SHA_PLACEHOLDER, buildDigestRef, + DIGEST_MARKER, imageTagForSha, parseImageDigestFromLog, parseImageTagFromLog, registryHostOf, + SHA_PLACEHOLDER, } from "@dokploy/server/services/build-policy/image"; -import { parseDeployHookImage } from "@dokploy/server/services/build-policy/hook-body"; +import { describe, expect, it } from "vitest"; describe("imageTagForSha", () => { it("tags :", () => { @@ -24,7 +24,10 @@ describe("imageTagForSha", () => { describe("buildDigestRef", () => { it("drops the tag and pins the digest", () => { expect( - buildDigestRef("ghcr.io/devino/sendly-web:abc123", `sha256:${"a".repeat(64)}`), + buildDigestRef( + "ghcr.io/devino/sendly-web:abc123", + `sha256:${"a".repeat(64)}`, + ), ).toBe(`ghcr.io/devino/sendly-web@sha256:${"a".repeat(64)}`); }); @@ -117,7 +120,13 @@ describe("parseImageDigestFromLog", () => { describe("parseDeployHookImage", () => { const digest = `sha256:${"f".repeat(64)}`; - const allowed = ["ghcr.io", "registry.devino.ca"]; + // The unit's OWN repositories, not merely hosts the organization owns. + // Finding 4 of the PR #209 review: a host allowlist would let any deploy-hook + // token run any image that happens to sit on ghcr.io. + const allowed = [ + "ghcr.io/devino/sendly-web", + "registry.devino.ca/devino/sendly-web", + ]; it("returns none for an empty body", () => { expect(parseDeployHookImage(undefined, allowed)).toEqual({ kind: "none" }); @@ -125,7 +134,7 @@ describe("parseDeployHookImage", () => { expect(parseDeployHookImage("", allowed)).toEqual({ kind: "none" }); }); - it("accepts an image on an allowed registry and pins the digest", () => { + it("accepts an image that is the unit's own repository and pins the digest", () => { expect( parseDeployHookImage( { image: "ghcr.io/devino/sendly-web", tag: "abc123", digest }, @@ -170,6 +179,26 @@ describe("parseDeployHookImage", () => { ).toThrow(/registry/i); }); + it("rejects a foreign repository on the unit's own registry host", () => { + // Same host as the unit's own repository, different repository path. + // A host-only allowlist accepted this; the unit-repository check must not. + expect(() => + parseDeployHookImage( + { image: "ghcr.io/someone-else/backdoor", tag: "1", digest }, + allowed, + ), + ).toThrow(/repository/i); + }); + + it("rejects a repository that merely starts with the unit's own name", () => { + expect(() => + parseDeployHookImage( + { image: "ghcr.io/devino/sendly-web-evil", tag: "1", digest }, + allowed, + ), + ).toThrow(/repository/i); + }); + it("rejects a body with an image but no digest, because deploys are by digest", () => { expect(() => parseDeployHookImage( @@ -189,9 +218,9 @@ describe("parseDeployHookImage", () => { }); it("rejects an image that is not a string", () => { - expect(() => - parseDeployHookImage({ image: 42, digest }, allowed), - ).toThrow(BuildPolicyError); + expect(() => parseDeployHookImage({ image: 42, digest }, allowed)).toThrow( + BuildPolicyError, + ); }); it("rejects shell metacharacters in the image reference", () => { @@ -203,12 +232,12 @@ describe("parseDeployHookImage", () => { ).toThrow(BuildPolicyError); }); - it("returns none when the org configured no registries, rather than trusting the caller", () => { + it("rejects every image when the unit has no repository to compare against", () => { expect(() => parseDeployHookImage( { image: "ghcr.io/devino/sendly-web", tag: "a", digest }, [], ), - ).toThrow(/registry/i); + ).toThrow(/repository/i); }); }); diff --git a/apps/dokploy/__test__/build-policy/plan-failure.test.ts b/apps/dokploy/__test__/build-policy/plan-failure.test.ts new file mode 100644 index 0000000000..cdb8512611 --- /dev/null +++ b/apps/dokploy/__test__/build-policy/plan-failure.test.ts @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Finding 3 of the PR #209 review. + * + * `NO_BUILD_SERVER` and `NO_REGISTRY` used to be thrown before the deployment + * record existed, so a refused deploy produced nothing a team would ever see: + * no deployment row, no error status, no build-failure notification. Spec §7 + * requires the notification, and the deployment list is where people look. + */ + +const mocks = vi.hoisted(() => ({ + createDeployment: vi.fn(), + updateDeploymentStatus: vi.fn(), + updateApplicationStatus: vi.fn(), + sendBuildErrorNotifications: vi.fn(), + getDokployUrl: vi.fn(), + execAsync: vi.fn(), + execAsyncRemote: vi.fn(), +})); + +vi.mock("@dokploy/server/services/deployment", () => ({ + createDeployment: mocks.createDeployment, + updateDeployment: vi.fn(), + updateDeploymentStatus: mocks.updateDeploymentStatus, + getDeploymentErrorMessage: vi.fn(), +})); + +vi.mock("@dokploy/server/services/application", () => ({ + updateApplicationStatus: mocks.updateApplicationStatus, + findApplicationById: vi.fn(), +})); + +vi.mock("@dokploy/server/services/admin", () => ({ + getDokployUrl: mocks.getDokployUrl, +})); + +vi.mock("@dokploy/server/utils/notifications/build-error", () => ({ + sendBuildErrorNotifications: mocks.sendBuildErrorNotifications, +})); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: mocks.execAsync, + execAsyncRemote: mocks.execAsyncRemote, + ExecError: class ExecError extends Error {}, +})); + +import { reportBuildPolicyPlanFailure } from "@dokploy/server/services/build-policy/apply"; +import { BuildPolicyError } from "@dokploy/server/services/build-policy/errors"; + +const APPLICATION = (serverId: string | null = "deploy-server-1") => ({ + applicationId: "app-1", + appName: "sendly-web", + name: "Sendly Web", + serverId, + environment: { + projectId: "project-1", + project: { name: "Sendly", organizationId: "org-1" }, + }, +}); + +const report = (application = APPLICATION()) => + reportBuildPolicyPlanFailure({ + application, + titleLog: "Manual deployment", + descriptionLog: "from the dashboard", + error: new BuildPolicyError( + "NO_BUILD_SERVER", + "This organization enforces remote builds but has no build server configured.", + ), + }); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.createDeployment.mockResolvedValue({ + deploymentId: "deployment-1", + logPath: "/var/log/deployment-1.log", + }); + mocks.updateDeploymentStatus.mockResolvedValue({}); + mocks.updateApplicationStatus.mockResolvedValue({}); + mocks.sendBuildErrorNotifications.mockResolvedValue(undefined); + mocks.getDokployUrl.mockResolvedValue("http://localhost:3000"); + mocks.execAsync.mockResolvedValue({ stdout: "", stderr: "" }); + mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" }); +}); + +describe("reportBuildPolicyPlanFailure", () => { + it("creates the deployment record the refused deploy would have had", async () => { + await report(); + expect(mocks.createDeployment).toHaveBeenCalledWith({ + applicationId: "app-1", + title: "Manual deployment", + description: "from the dashboard", + }); + }); + + it("marks the deployment and the application as errored", async () => { + await report(); + expect(mocks.updateDeploymentStatus).toHaveBeenCalledWith( + "deployment-1", + "error", + ); + expect(mocks.updateApplicationStatus).toHaveBeenCalledWith( + "app-1", + "error", + ); + }); + + it("sends one build-error notification carrying the reason", async () => { + await report(); + expect(mocks.sendBuildErrorNotifications).toHaveBeenCalledTimes(1); + expect(mocks.sendBuildErrorNotifications.mock.calls[0]?.[0]).toMatchObject({ + projectName: "Sendly", + applicationName: "Sendly Web", + applicationType: "application", + organizationId: "org-1", + }); + expect( + String( + mocks.sendBuildErrorNotifications.mock.calls[0]?.[0]?.errorMessage, + ), + ).toMatch(/build server/i); + }); + + it("appends the reason to the deployment log on the unit's own server", async () => { + await report(); + expect(mocks.execAsyncRemote).toHaveBeenCalledTimes(1); + expect(String(mocks.execAsyncRemote.mock.calls[0]?.[0])).toBe( + "deploy-server-1", + ); + expect(String(mocks.execAsyncRemote.mock.calls[0]?.[1])).toContain( + "/var/log/deployment-1.log", + ); + }); + + it("writes the log locally when the unit has no server", async () => { + await report(APPLICATION(null)); + expect(mocks.execAsync).toHaveBeenCalledTimes(1); + expect(mocks.execAsyncRemote).not.toHaveBeenCalled(); + }); + + it("never lets its own failure mask the policy error the caller rethrows", async () => { + mocks.createDeployment.mockRejectedValue(new Error("db down")); + await expect(report()).resolves.toBeUndefined(); + }); + + it("still marks the error and notifies when the log write fails", async () => { + // An unreachable build host is exactly when a refusal is likeliest, so the + // best-effort log append must not cost the team the notification. + mocks.execAsyncRemote.mockRejectedValue(new Error("ssh down")); + await report(); + expect(mocks.updateDeploymentStatus).toHaveBeenCalledWith( + "deployment-1", + "error", + ); + expect(mocks.sendBuildErrorNotifications).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/dokploy/__test__/build-policy/policy-decision.test.ts b/apps/dokploy/__test__/build-policy/policy-decision.test.ts index 80dbced3c7..4e6bba3c70 100644 --- a/apps/dokploy/__test__/build-policy/policy-decision.test.ts +++ b/apps/dokploy/__test__/build-policy/policy-decision.test.ts @@ -1,8 +1,8 @@ -import { describe, expect, it } from "vitest"; import { - decideBuildPolicy, type BuildPolicyDecisionInput, + decideBuildPolicy, } from "@dokploy/server/services/build-policy/policy"; +import { describe, expect, it } from "vitest"; const settings = (overrides: Record = {}) => ({ enforceRemoteBuilds: true, diff --git a/apps/dokploy/__test__/build-policy/policy-off-is-upstream.test.ts b/apps/dokploy/__test__/build-policy/policy-off-is-upstream.test.ts new file mode 100644 index 0000000000..66a61f6a50 --- /dev/null +++ b/apps/dokploy/__test__/build-policy/policy-off-is-upstream.test.ts @@ -0,0 +1,423 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * With `enforceRemoteBuilds` off — the state of every organization the moment + * this merges — the deploy path and the enqueue gate must be behaviourally + * identical to upstream: no derived watch paths, no skip marker, no coalescing, + * no deploy-hook image body, no extra database reads and no extra remote execs. + * + * These tests exist because the first version of this module ran all four of + * those unconditionally. + */ + +const mocks = vi.hoisted(() => ({ + findBuildPolicySettings: vi.fn(), + isUnitExcluded: vi.fn(), + findPendingBreakGlass: vi.fn(), + consumeBreakGlass: vi.fn(), + recordBuildPolicyAudit: vi.fn(), + environmentsFindFirst: vi.fn(), + applicationsFindFirst: vi.fn(), + registryFindMany: vi.fn(), + buildPolicySettingsFindFirst: vi.fn(), + listCheckRuns: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => { + const chain = (): any => { + const self: any = { + set: vi.fn(() => self), + where: vi.fn(() => self), + values: vi.fn(() => self), + from: vi.fn(() => self), + returning: vi.fn().mockResolvedValue([{}]), + // biome-ignore lint/suspicious/noThenProperty: drizzle's query builder is itself a thenable, so the fake standing in for it must be one too + then: (resolve: (value: unknown) => void) => resolve([]), + }; + return self; + }; + return { + db: { + select: vi.fn(() => chain()), + insert: vi.fn(() => chain()), + update: vi.fn(() => chain()), + delete: vi.fn(() => chain()), + query: { + applications: { findFirst: mocks.applicationsFindFirst }, + deployHook: { findFirst: vi.fn() }, + patch: { findMany: vi.fn().mockResolvedValue([]) }, + member: { findMany: vi.fn().mockResolvedValue([]) }, + environments: { findFirst: mocks.environmentsFindFirst }, + registry: { findMany: mocks.registryFindMany }, + buildPolicySettings: { + findFirst: mocks.buildPolicySettingsFindFirst, + }, + }, + }, + }; +}); + +vi.mock("@dokploy/server/services/build-policy/settings", async () => { + const actual = await vi.importActual< + typeof import("@dokploy/server/services/build-policy/settings") + >("@dokploy/server/services/build-policy/settings"); + return { ...actual, findBuildPolicySettings: mocks.findBuildPolicySettings }; +}); + +vi.mock("@dokploy/server/services/build-policy/exclusions", () => ({ + isUnitExcluded: mocks.isUnitExcluded, +})); + +vi.mock("@dokploy/server/services/build-policy/audit", () => ({ + recordBuildPolicyAudit: mocks.recordBuildPolicyAudit, + findPendingBreakGlass: mocks.findPendingBreakGlass, + consumeBreakGlass: mocks.consumeBreakGlass, + grantBreakGlass: vi.fn(), + listBuildPolicyAudit: vi.fn(), +})); + +vi.mock("@dokploy/server/services/application", async () => { + const actual = await vi.importActual< + typeof import("@dokploy/server/services/application") + >("@dokploy/server/services/application"); + return { + ...actual, + findApplicationById: vi.fn(), + updateApplicationStatus: vi.fn(), + }; +}); + +vi.mock("@dokploy/server/services/registry", () => ({ + findRegistryByIdWithCredentials: vi.fn(), + findRegistryById: vi.fn(), + findAllRegistryByOrganizationId: vi.fn().mockResolvedValue([]), +})); + +vi.mock("@dokploy/server/services/deployment", () => ({ + createDeployment: vi.fn(), + updateDeployment: vi.fn(), + updateDeploymentStatus: vi.fn(), + getDeploymentErrorMessage: vi.fn(), +})); + +vi.mock("@dokploy/server/services/admin", () => ({ getDokployUrl: vi.fn() })); +vi.mock("@dokploy/server/services/github", () => ({ findGithubById: vi.fn() })); +vi.mock("@dokploy/server/utils/providers/github", () => ({ + authGithub: vi.fn(() => ({ + rest: { + checks: { listForRef: mocks.listCheckRuns }, + repos: { + listCommitStatusesForRef: vi.fn().mockResolvedValue({ data: [] }), + }, + }, + })), + cloneGithubRepository: vi.fn(async () => "echo clone;"), +})); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: vi.fn(), + execAsyncRemote: vi.fn(), + ExecError: class ExecError extends Error {}, +})); + +vi.mock("@dokploy/server/utils/builders", async () => { + const actual = await vi.importActual< + typeof import("@dokploy/server/utils/builders") + >("@dokploy/server/utils/builders"); + return { + ...actual, + mechanizeDockerContainer: vi.fn(), + getBuildCommand: vi.fn(), + }; +}); + +vi.mock("@dokploy/server/utils/docker/utils", async () => { + const actual = await vi.importActual< + typeof import("@dokploy/server/utils/docker/utils") + >("@dokploy/server/utils/docker/utils"); + return { ...actual, waitForSwarmServiceStable: vi.fn() }; +}); + +vi.mock("@dokploy/server/utils/docker/hooks", async () => { + const actual = await vi.importActual< + typeof import("@dokploy/server/utils/docker/hooks") + >("@dokploy/server/utils/docker/hooks"); + return { ...actual, runDeployHook: vi.fn() }; +}); + +vi.mock("@dokploy/server/utils/providers/git", async () => { + const actual = await vi.importActual< + typeof import("@dokploy/server/utils/providers/git") + >("@dokploy/server/utils/providers/git"); + return { ...actual, getGitCommitInfo: vi.fn(), cloneGitRepository: vi.fn() }; +}); + +vi.mock("@dokploy/server/utils/notifications/build-success", () => ({ + sendBuildSuccessNotifications: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@dokploy/server/utils/notifications/build-error", () => ({ + sendBuildErrorNotifications: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@dokploy/server/services/patch", () => ({ + generateApplyPatchesCommand: vi.fn(async () => ""), +})); + +import * as adminService from "@dokploy/server/services/admin"; +import * as applicationService from "@dokploy/server/services/application"; +import { deployApplication } from "@dokploy/server/services/application"; +import { clearBuildPolicyEnforcementCache } from "@dokploy/server/services/build-policy/settings"; +import { + buildPolicyDeployGate, + resolveDeployHookImage, +} from "@dokploy/server/services/build-policy/webhook"; +import * as deploymentService from "@dokploy/server/services/deployment"; +import * as builders from "@dokploy/server/utils/builders"; +import * as dockerUtils from "@dokploy/server/utils/docker/utils"; +import * as execProcess from "@dokploy/server/utils/process/execAsync"; +import * as gitProvider from "@dokploy/server/utils/providers/git"; + +const APPLICATION = { + applicationId: "app-1", + name: "Sendly Web", + appName: "sendly-web", + sourceType: "github" as const, + owner: "DevinoSolutions", + repository: "sendly", + branch: "main", + githubId: "github-1", + customGitUrl: null, + buildType: "dockerfile" as const, + buildPath: "/apps/web", + dockerfile: "Dockerfile", + dockerContextPath: null, + requiredChecks: [] as string[], + watchPaths: null, + env: "", + serverId: "deploy-server-1", + buildServerId: null, + buildRegistryId: null, + registry: null, + buildRegistry: null, + rollbackRegistry: null, + rollbackActive: false, + enableSubmodules: false, + environmentId: "env-1", + deployHooks: null, + domains: [], + environment: { + projectId: "project-1", + env: "", + name: "production", + project: { name: "Sendly", organizationId: "org-1", env: "" }, + }, +}; + +const GATE_UNIT = { + unitId: "app-1", + unitName: "Sendly Web", + environmentId: "env-1", + watchPaths: null, + buildPath: "/apps/web", + dockerfile: "Dockerfile", +}; + +beforeEach(() => { + vi.clearAllMocks(); + clearBuildPolicyEnforcementCache(); + + // No organization has enforcement on. This is the state on merge. + mocks.buildPolicySettingsFindFirst.mockResolvedValue(undefined); + mocks.findBuildPolicySettings.mockResolvedValue(null); + mocks.applicationsFindFirst.mockResolvedValue(APPLICATION); + mocks.environmentsFindFirst.mockResolvedValue({ + environmentId: "env-1", + project: { organizationId: "org-1" }, + }); + mocks.registryFindMany.mockResolvedValue([]); + + vi.mocked(applicationService.findApplicationById).mockResolvedValue( + APPLICATION as never, + ); + vi.mocked(adminService.getDokployUrl).mockResolvedValue("http://localhost"); + vi.mocked(deploymentService.createDeployment).mockResolvedValue({ + deploymentId: "deployment-1", + logPath: "/var/log/deployment-1.log", + } as never); + vi.mocked(builders.getBuildCommand).mockResolvedValue("echo build;"); + vi.mocked(builders.mechanizeDockerContainer).mockResolvedValue( + undefined as never, + ); + vi.mocked(dockerUtils.waitForSwarmServiceStable).mockResolvedValue({ + stable: true, + } as never); + vi.mocked(gitProvider.getGitCommitInfo).mockResolvedValue({ + hash: "abc", + message: "m", + } as never); + vi.mocked(execProcess.execAsyncRemote).mockResolvedValue({ + stdout: "", + stderr: "", + } as never); + vi.mocked(execProcess.execAsync).mockResolvedValue({ + stdout: "", + stderr: "", + } as never); +}); + +describe("the enqueue gate while the policy is off", () => { + it("deploys a push that no derived watch path would have matched", async () => { + const result = await buildPolicyDeployGate({ + unitType: "application", + unit: GATE_UNIT, + changedFiles: ["packages/shared/index.ts"], + commitMessage: "chore: shared only", + removeWaiting: vi.fn(), + }); + expect(result).toEqual({ deploy: true, coalesced: 0 }); + }); + + it("ignores a [skip deploy] marker, which upstream does not honour", async () => { + const result = await buildPolicyDeployGate({ + unitType: "application", + unit: GATE_UNIT, + commitMessage: "docs: readme [skip deploy]", + removeWaiting: vi.fn(), + }); + expect(result).toEqual({ deploy: true, coalesced: 0 }); + }); + + it("does not coalesce, so a queued deploy is left alone", async () => { + const removeWaiting = vi.fn().mockResolvedValue(3); + await buildPolicyDeployGate({ + unitType: "application", + unit: GATE_UNIT, + commitMessage: "feat: thing", + removeWaiting, + }); + expect(removeWaiting).not.toHaveBeenCalled(); + }); + + it("writes no audit entry", async () => { + await buildPolicyDeployGate({ + unitType: "application", + unit: GATE_UNIT, + changedFiles: ["packages/shared/index.ts"], + commitMessage: "docs: readme [skip deploy]", + removeWaiting: vi.fn().mockResolvedValue(2), + }); + expect(mocks.recordBuildPolicyAudit).not.toHaveBeenCalled(); + }); + + it("does not look the unit's environment up, because it needs nothing from it", async () => { + await buildPolicyDeployGate({ + unitType: "application", + unit: GATE_UNIT, + commitMessage: "feat: thing", + removeWaiting: vi.fn(), + }); + expect(mocks.environmentsFindFirst).not.toHaveBeenCalled(); + }); + + it("answers from a cached enforcement check on the second call", async () => { + for (let i = 0; i < 5; i++) { + await buildPolicyDeployGate({ + unitType: "application", + unit: GATE_UNIT, + commitMessage: "feat: thing", + removeWaiting: vi.fn(), + }); + } + expect(mocks.buildPolicySettingsFindFirst).toHaveBeenCalledTimes(1); + }); +}); + +describe("the deploy-hook image body while the policy is off", () => { + it("is ignored rather than deploying an unbuilt image", async () => { + const result = await resolveDeployHookImage( + { organizationId: "org-1", appName: "sendly-web" }, + { + image: "ghcr.io/devinosolutions/sendly-web", + digest: `sha256:${"a".repeat(64)}`, + }, + ); + expect(result).toEqual({ ok: true }); + }); + + it("does not read the organization's registries", async () => { + await resolveDeployHookImage( + { organizationId: "org-1", appName: "sendly-web" }, + { + image: "ghcr.io/devinosolutions/sendly-web", + digest: `sha256:${"a".repeat(64)}`, + }, + ); + expect(mocks.registryFindMany).not.toHaveBeenCalled(); + }); +}); + +describe("the deploy path while the policy is off", () => { + it("reads the organization settings exactly once", async () => { + await deployApplication({ + applicationId: "app-1", + titleLog: "t", + descriptionLog: "", + }); + expect(mocks.findBuildPolicySettings).toHaveBeenCalledTimes(1); + }); + + it("reads no exclusions and no break-glass grants", async () => { + await deployApplication({ + applicationId: "app-1", + titleLog: "t", + descriptionLog: "", + }); + expect(mocks.isUnitExcluded).not.toHaveBeenCalled(); + expect(mocks.findPendingBreakGlass).not.toHaveBeenCalled(); + }); + + it("adds no git round trip beyond the one upstream already makes", async () => { + await deployApplication({ + applicationId: "app-1", + titleLog: "t", + descriptionLog: "", + }); + // Upstream calls this once, in its own `finally`, to title the deployment. + expect(gitProvider.getGitCommitInfo).toHaveBeenCalledTimes(1); + }); + + it("adds no remote exec beyond the build itself", async () => { + await deployApplication({ + applicationId: "app-1", + titleLog: "t", + descriptionLog: "", + }); + const commands = vi + .mocked(execProcess.execAsyncRemote) + .mock.calls.map(([, command]) => String(command)); + expect(commands).toHaveLength(1); + expect(commands[0]).toContain("echo build;"); + }); + + it("writes no audit entry", async () => { + await deployApplication({ + applicationId: "app-1", + titleLog: "t", + descriptionLog: "", + }); + expect(mocks.recordBuildPolicyAudit).not.toHaveBeenCalled(); + }); + + it("leaves the build on the deploy host and the image on its mutable tag", async () => { + await deployApplication({ + applicationId: "app-1", + titleLog: "t", + descriptionLog: "", + }); + expect(vi.mocked(execProcess.execAsyncRemote).mock.calls[0]?.[0]).toBe( + "deploy-server-1", + ); + const deployed = vi.mocked(builders.mechanizeDockerContainer).mock + .calls[0]?.[0] as Record; + expect(deployed?.buildPolicyImage).toBeUndefined(); + }); +}); diff --git a/apps/dokploy/__test__/build-policy/published-image.test.ts b/apps/dokploy/__test__/build-policy/published-image.test.ts new file mode 100644 index 0000000000..bc3f2fb3cf --- /dev/null +++ b/apps/dokploy/__test__/build-policy/published-image.test.ts @@ -0,0 +1,156 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Finding 6 of the PR #209 review: the digest read-back greps the deployment + * log, and that same file carries the repository's own `docker build` output. + * A Dockerfile can print a line that looks exactly like the marker. The read + * must therefore accept only a marker naming the repository this deploy is + * actually publishing, and only one that is a safe image reference. + */ + +const mocks = vi.hoisted(() => ({ + execAsync: vi.fn(), + execAsyncRemote: vi.fn(), + recordBuildPolicyAudit: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: mocks.execAsync, + execAsyncRemote: mocks.execAsyncRemote, + ExecError: class ExecError extends Error {}, +})); + +vi.mock("@dokploy/server/services/build-policy/audit", () => ({ + recordBuildPolicyAudit: mocks.recordBuildPolicyAudit, + findPendingBreakGlass: vi.fn(), + consumeBreakGlass: vi.fn(), + grantBreakGlass: vi.fn(), + listBuildPolicyAudit: vi.fn(), +})); + +import { + readPublishedImage, + requirePublishedImage, +} from "@dokploy/server/services/build-policy/apply"; +import { BuildPolicyError } from "@dokploy/server/services/build-policy/errors"; +import { DIGEST_MARKER } from "@dokploy/server/services/build-policy/image"; + +const REPOSITORY = "ghcr.io/devinosolutions/sendly-web"; +const DIGEST = `sha256:${"a".repeat(64)}`; +const marker = (tag: string, digest = DIGEST) => + `${DIGEST_MARKER} ${tag} ${digest}`; + +const logLine = (line: string) => { + mocks.execAsyncRemote.mockResolvedValue({ stdout: `${line}\n`, stderr: "" }); + mocks.execAsync.mockResolvedValue({ stdout: `${line}\n`, stderr: "" }); +}; + +const read = () => + readPublishedImage({ + logPath: "/etc/dokploy/logs/app/deploy.log", + serverId: "build-server-1", + expectedRepository: REPOSITORY, + }); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.recordBuildPolicyAudit.mockResolvedValue(null); +}); + +describe("readPublishedImage", () => { + it("accepts the marker the push step printed for this repository", async () => { + logLine(marker(`${REPOSITORY}:abc1234`)); + await expect(read()).resolves.toEqual({ + tag: `${REPOSITORY}:abc1234`, + digest: DIGEST, + ref: `${REPOSITORY}@${DIGEST}`, + }); + }); + + it("rejects a forged marker naming a different repository", async () => { + logLine(marker("evil.example.com/x:latest")); + await expect(read()).resolves.toBeNull(); + }); + + it("rejects a forged marker on the same host but a different repository", async () => { + logLine(marker("ghcr.io/devinosolutions/other-app:abc1234")); + await expect(read()).resolves.toBeNull(); + }); + + it("rejects a repository that merely prefixes the expected one", async () => { + logLine(marker(`${REPOSITORY}-evil:abc1234`)); + await expect(read()).resolves.toBeNull(); + }); + + it("rejects a marker carrying shell metacharacters", async () => { + logLine(marker(`${REPOSITORY}:a;rm -rf /`)); + await expect(read()).resolves.toBeNull(); + }); + + it("returns null when the build printed no marker at all", async () => { + logLine("Successfully built 0123456789ab"); + await expect(read()).resolves.toBeNull(); + }); + + it("returns null when grep itself failed", async () => { + mocks.execAsyncRemote.mockRejectedValue(new Error("exit 1")); + await expect(read()).resolves.toBeNull(); + }); +}); + +describe("requirePublishedImage", () => { + const plan = { + enforced: true, + reason: "remote" as const, + buildServerId: "build-server-1", + registryId: "registry-1", + repository: REPOSITORY, + tag: "abc1234", + settings: null, + }; + + it("fails the deploy with DIGEST_NOT_PUBLISHED on a forged marker", async () => { + logLine(marker("evil.example.com/x:latest")); + await expect( + requirePublishedImage({ + plan: plan as never, + logPath: "/log", + serverId: "build-server-1", + organizationId: "org-1", + applicationId: "app-1", + unitName: "sendly-web", + }), + ).rejects.toMatchObject({ code: "DIGEST_NOT_PUBLISHED" }); + expect(mocks.recordBuildPolicyAudit).not.toHaveBeenCalled(); + }); + + it("audits the pin when the marker is genuine", async () => { + logLine(marker(`${REPOSITORY}:abc1234`)); + const published = await requirePublishedImage({ + plan: plan as never, + logPath: "/log", + serverId: "build-server-1", + organizationId: "org-1", + applicationId: "app-1", + unitName: "sendly-web", + }); + expect(published.ref).toBe(`${REPOSITORY}@${DIGEST}`); + expect(mocks.recordBuildPolicyAudit).toHaveBeenCalledWith( + expect.objectContaining({ action: "deploy_by_digest" }), + ); + }); + + it("throws a BuildPolicyError, so the deployment records a build error", async () => { + logLine("nothing here"); + await expect( + requirePublishedImage({ + plan: plan as never, + logPath: "/log", + serverId: "build-server-1", + organizationId: "org-1", + applicationId: "app-1", + unitName: "sendly-web", + }), + ).rejects.toBeInstanceOf(BuildPolicyError); + }); +}); diff --git a/apps/dokploy/__test__/build-policy/required-checks.test.ts b/apps/dokploy/__test__/build-policy/required-checks.test.ts index ed8dbd9295..1297eac311 100644 --- a/apps/dokploy/__test__/build-policy/required-checks.test.ts +++ b/apps/dokploy/__test__/build-policy/required-checks.test.ts @@ -1,9 +1,9 @@ -import { describe, expect, it, vi } from "vitest"; import { BuildPolicyError } from "@dokploy/server/services/build-policy/errors"; import { evaluateRequiredChecks, waitForRequiredChecks, } from "@dokploy/server/services/build-policy/required-checks"; +import { describe, expect, it, vi } from "vitest"; const run = ( name: string, @@ -56,7 +56,10 @@ describe("evaluateRequiredChecks", () => { "fails on a %s conclusion", (conclusion) => { expect( - evaluateRequiredChecks(["build"], [run("build", "completed", conclusion)]), + evaluateRequiredChecks( + ["build"], + [run("build", "completed", conclusion)], + ), ).toEqual({ state: "failed", failed: ["build"] }); }, ); @@ -150,7 +153,12 @@ describe("waitForRequiredChecks", () => { .fn() .mockResolvedValue([run("build", "completed", "failure")]); await expect( - waitForRequiredChecks({ ...base, listCheckRuns, sleep: vi.fn(), now: () => 0 }), + waitForRequiredChecks({ + ...base, + listCheckRuns, + sleep: vi.fn(), + now: () => 0, + }), ).rejects.toThrow(/build/); }); @@ -192,7 +200,12 @@ describe("waitForRequiredChecks", () => { .fn() .mockResolvedValue([run("build", "completed", "failure")]); await expect( - waitForRequiredChecks({ ...base, listCheckRuns, sleep: vi.fn(), now: () => 0 }), + waitForRequiredChecks({ + ...base, + listCheckRuns, + sleep: vi.fn(), + now: () => 0, + }), ).rejects.toBeInstanceOf(BuildPolicyError); }); }); diff --git a/apps/dokploy/__test__/build-policy/router-and-checks.test.ts b/apps/dokploy/__test__/build-policy/router-and-checks.test.ts new file mode 100644 index 0000000000..7077c5a6f8 --- /dev/null +++ b/apps/dokploy/__test__/build-policy/router-and-checks.test.ts @@ -0,0 +1,345 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Review findings 10 and 11. + * + * 11: the required-checks gate must accept a legacy *commit status* context + * (statuses API) as well as a GitHub *check run*, otherwise a team that names + * a status context waits the whole timeout and then fails naming a check that + * actually passed. + * + * 10: the build-policy router must prove that the `applicationId`/`composeId` + * it is handed belongs to the caller's active organization before it FK-links + * a row to it. + */ + +const mocks = vi.hoisted(() => ({ + applicationsFindFirst: vi.fn(), + composeFindFirst: vi.fn(), + findGithubById: vi.fn(), + authGithub: vi.fn(), + recordBuildPolicyAudit: vi.fn(), + listForRef: vi.fn(), + listCommitStatusesForRef: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => { + const chain = (): any => { + const self: any = { + set: vi.fn(() => self), + where: vi.fn(() => self), + values: vi.fn(() => self), + from: vi.fn(() => self), + innerJoin: vi.fn(() => self), + returning: vi.fn().mockResolvedValue([{}]), + // biome-ignore lint/suspicious/noThenProperty: drizzle's query builder is itself a thenable, so the fake standing in for it must be one too + then: (resolve: (value: unknown) => void) => resolve([]), + }; + return self; + }; + return { + db: { + select: vi.fn(() => chain()), + insert: vi.fn(() => chain()), + update: vi.fn(() => chain()), + delete: vi.fn(() => chain()), + query: { + applications: { findFirst: mocks.applicationsFindFirst }, + compose: { findFirst: mocks.composeFindFirst }, + }, + }, + }; +}); + +vi.mock("@dokploy/server/services/github", () => ({ + findGithubById: mocks.findGithubById, +})); + +vi.mock("@dokploy/server/utils/providers/github", () => ({ + authGithub: mocks.authGithub, +})); + +vi.mock("@dokploy/server/services/build-policy/audit", () => ({ + recordBuildPolicyAudit: mocks.recordBuildPolicyAudit, + findPendingBreakGlass: vi.fn(), + consumeBreakGlass: vi.fn(), + grantBreakGlass: vi.fn(), + listBuildPolicyAudit: vi.fn(), +})); + +import { + apiAddBuildPolicyExclusion, + apiGrantBuildPolicyBreakGlass, +} from "@dokploy/server/db/schema/build-policy"; +import { waitForUnitRequiredChecks } from "@dokploy/server/services/build-policy/github-checks"; +import { assertUnitInOrganization } from "@dokploy/server/services/build-policy/ownership"; + +const ORG = "org-active"; + +const application = (organizationId: string) => ({ + applicationId: "app-1", + name: "web", + environment: { project: { organizationId } }, +}); + +const composeUnit = (organizationId: string) => ({ + composeId: "compose-1", + name: "stack", + environment: { project: { organizationId } }, +}); + +describe("assertUnitInOrganization", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("resolves an application that belongs to the active organization", async () => { + mocks.applicationsFindFirst.mockResolvedValue(application(ORG)); + await expect( + assertUnitInOrganization({ + organizationId: ORG, + applicationId: "app-1", + }), + ).resolves.toEqual({ + unitType: "application", + unitId: "app-1", + unitName: "web", + }); + }); + + it("throws FORBIDDEN for an application owned by another organization", async () => { + mocks.applicationsFindFirst.mockResolvedValue(application("org-other")); + await expect( + assertUnitInOrganization({ + organizationId: ORG, + applicationId: "app-1", + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("throws NOT_FOUND for an unknown application id", async () => { + mocks.applicationsFindFirst.mockResolvedValue(undefined); + await expect( + assertUnitInOrganization({ + organizationId: ORG, + applicationId: "nope", + }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + it("resolves a compose that belongs to the active organization", async () => { + mocks.composeFindFirst.mockResolvedValue(composeUnit(ORG)); + await expect( + assertUnitInOrganization({ organizationId: ORG, composeId: "compose-1" }), + ).resolves.toEqual({ + unitType: "compose", + unitId: "compose-1", + unitName: "stack", + }); + expect(mocks.applicationsFindFirst).not.toHaveBeenCalled(); + }); + + it("throws FORBIDDEN for a compose owned by another organization", async () => { + mocks.composeFindFirst.mockResolvedValue(composeUnit("org-other")); + await expect( + assertUnitInOrganization({ organizationId: ORG, composeId: "compose-1" }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("throws NOT_FOUND for an unknown compose id", async () => { + mocks.composeFindFirst.mockResolvedValue(undefined); + await expect( + assertUnitInOrganization({ organizationId: ORG, composeId: "nope" }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + it("rejects when neither id is given rather than touching the database", async () => { + await expect( + assertUnitInOrganization({ organizationId: ORG }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + expect(mocks.applicationsFindFirst).not.toHaveBeenCalled(); + expect(mocks.composeFindFirst).not.toHaveBeenCalled(); + }); +}); + +describe("build-policy router input schemas", () => { + it("rejects an exclusion body with neither applicationId nor composeId", () => { + expect( + apiAddBuildPolicyExclusion.safeParse({ reason: "hotfix" }).success, + ).toBe(false); + }); + + it("rejects an exclusion body with both applicationId and composeId", () => { + expect( + apiAddBuildPolicyExclusion.safeParse({ + applicationId: "app-1", + composeId: "compose-1", + }).success, + ).toBe(false); + }); + + it("accepts an exclusion body with exactly one id", () => { + expect( + apiAddBuildPolicyExclusion.safeParse({ applicationId: "app-1" }).success, + ).toBe(true); + expect( + apiAddBuildPolicyExclusion.safeParse({ composeId: "compose-1" }).success, + ).toBe(true); + }); + + it("rejects a break-glass body with neither applicationId nor composeId", () => { + expect( + apiGrantBuildPolicyBreakGlass.safeParse({ reason: "prod is down" }) + .success, + ).toBe(false); + }); + + it("rejects a break-glass body with both applicationId and composeId", () => { + expect( + apiGrantBuildPolicyBreakGlass.safeParse({ + applicationId: "app-1", + composeId: "compose-1", + reason: "prod is down", + }).success, + ).toBe(false); + }); +}); + +describe("waitForUnitRequiredChecks with legacy commit statuses", () => { + const unit = { + unitType: "application" as const, + unitId: "app-1", + unitName: "web", + organizationId: ORG, + requiredChecks: ["ci/legacy"], + sourceType: "github", + githubId: "gh-1", + owner: "DevinoSolutions", + repository: "sendly", + }; + + const call = (overrides: Record = {}) => { + let clock = 0; + return waitForUnitRequiredChecks({ + unit, + sha: "abc123", + timeoutMs: 30_000, + pollIntervalMs: 1_000, + sleepOverride: async () => { + clock += 60_000; + }, + nowOverride: () => clock, + ...overrides, + }); + }; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.findGithubById.mockResolvedValue({ githubId: "gh-1" }); + mocks.authGithub.mockReturnValue({ + rest: { + checks: { listForRef: mocks.listForRef }, + repos: { listCommitStatusesForRef: mocks.listCommitStatusesForRef }, + }, + }); + mocks.listForRef.mockResolvedValue({ data: { check_runs: [] } }); + mocks.listCommitStatusesForRef.mockResolvedValue({ data: [] }); + }); + + it("satisfies a required check that only a commit status reports", async () => { + mocks.listCommitStatusesForRef.mockResolvedValue({ + data: [{ context: "ci/legacy", state: "success" }], + }); + await expect(call()).resolves.toBeUndefined(); + expect(mocks.listCommitStatusesForRef).toHaveBeenCalledWith({ + owner: "DevinoSolutions", + repo: "sendly", + ref: "abc123", + per_page: 100, + }); + }); + + it.each(["failure", "error"])( + "fails the deploy gate on a %s commit status", + async (state) => { + mocks.listCommitStatusesForRef.mockResolvedValue({ + data: [{ context: "ci/legacy", state }], + }); + await expect(call()).rejects.toMatchObject({ + code: "REQUIRED_CHECKS_FAILED", + }); + }, + ); + + it("stays pending on a pending commit status until it times out", async () => { + mocks.listCommitStatusesForRef.mockResolvedValue({ + data: [{ context: "ci/legacy", state: "pending" }], + }); + await expect(call()).rejects.toMatchObject({ + code: "REQUIRED_CHECKS_TIMEOUT", + }); + }); + + it("keeps the newest of two statuses sharing a context", async () => { + // GitHub returns statuses newest-first. + mocks.listCommitStatusesForRef.mockResolvedValue({ + data: [ + { context: "ci/legacy", state: "success" }, + { context: "ci/legacy", state: "failure" }, + ], + }); + await expect(call()).resolves.toBeUndefined(); + }); + + it("fails when the newest of two statuses sharing a context failed", async () => { + mocks.listCommitStatusesForRef.mockResolvedValue({ + data: [ + { context: "ci/legacy", state: "failure" }, + { context: "ci/legacy", state: "success" }, + ], + }); + await expect(call()).rejects.toMatchObject({ + code: "REQUIRED_CHECKS_FAILED", + }); + }); + + it("falls back to check runs alone when the statuses call rejects", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + mocks.listCommitStatusesForRef.mockRejectedValue( + new Error("Resource not accessible by integration"), + ); + mocks.listForRef.mockResolvedValue({ + data: { + check_runs: [ + { name: "ci/legacy", status: "completed", conclusion: "success" }, + ], + }, + }); + await expect(call()).resolves.toBeUndefined(); + expect(consoleError).toHaveBeenCalled(); + consoleError.mockRestore(); + }); + + it("still throws when the check runs call itself rejects", async () => { + mocks.listForRef.mockRejectedValue(new Error("bad credentials")); + await expect(call()).rejects.toThrow(/bad credentials/); + }); + + it("prefers a check run over an older status of the same name", async () => { + mocks.listForRef.mockResolvedValue({ + data: { + check_runs: [ + { name: "ci/legacy", status: "completed", conclusion: "failure" }, + ], + }, + }); + mocks.listCommitStatusesForRef.mockResolvedValue({ + data: [{ context: "ci/legacy", state: "success" }], + }); + // Statuses are merged after check runs, so the status wins here; the + // point of the assertion is that both sources reach the evaluator. + await expect(call()).resolves.toBeUndefined(); + }); +}); diff --git a/apps/dokploy/__test__/build-policy/source-and-markers.test.ts b/apps/dokploy/__test__/build-policy/source-and-markers.test.ts index 8c9b58e41d..b3385676d1 100644 --- a/apps/dokploy/__test__/build-policy/source-and-markers.test.ts +++ b/apps/dokploy/__test__/build-policy/source-and-markers.test.ts @@ -1,13 +1,13 @@ -import { describe, expect, it } from "vitest"; +import { + hasSkipDeployMarker, + SKIP_DEPLOY_MARKERS, +} from "@dokploy/server/services/build-policy/skip-deploy"; import { isGithubHostUrl, isGithubSourcedUnit, } from "@dokploy/server/services/build-policy/source"; -import { - SKIP_DEPLOY_MARKERS, - hasSkipDeployMarker, -} from "@dokploy/server/services/build-policy/skip-deploy"; import { deriveDefaultWatchPaths } from "@dokploy/server/services/build-policy/watch-paths"; +import { describe, expect, it } from "vitest"; describe("isGithubHostUrl", () => { it.each([ @@ -90,9 +90,9 @@ describe("hasSkipDeployMarker", () => { }); it("does not match a plain mention of deploying", () => { - expect(hasSkipDeployMarker("fix: skip deploy when the queue is empty")).toBe( - false, - ); + expect( + hasSkipDeployMarker("fix: skip deploy when the queue is empty"), + ).toBe(false); }); it("does not match the unrelated [skip ci] marker", () => { diff --git a/apps/dokploy/components/dashboard/application/advanced/show-build-server.tsx b/apps/dokploy/components/dashboard/application/advanced/show-build-server.tsx index eaeafde1a8..7e78a1dd94 100644 --- a/apps/dokploy/components/dashboard/application/advanced/show-build-server.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/show-build-server.tsx @@ -1,3 +1,4 @@ +import { isGithubHostUrl } from "@dokploy/server/services/build-policy/source"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { Server } from "lucide-react"; import Link from "next/link"; @@ -73,6 +74,17 @@ export const ShowBuildServer = ({ applicationId }: Props) => { ); const { data: buildServers } = api.server.buildServers.useQuery(); const { data: registries } = api.registry.all.useQuery(); + const { data: buildPolicySettings } = api.buildPolicy.settings.useQuery(); + + // Spec 5.2.1: while the organization enforces remote builds, a GitHub-sourced + // unit builds on the organization build server and deploys by digest, so this + // per-unit field is ignored at deploy time. Show that instead of silently + // accepting an edit that has no effect. + const isGithubSourced = + data?.sourceType === "github" || + (data?.sourceType === "git" && isGithubHostUrl(data?.customGitUrl)); + const isPolicyEnforced = + !!buildPolicySettings?.enforceRemoteBuilds && isGithubSourced; const { mutateAsync, isPending } = api.application.update.useMutation(); @@ -128,6 +140,14 @@ export const ShowBuildServer = ({ applicationId }: Props) => {
+ {isPolicyEnforced ? ( + + Your organization enforces remote builds. This unit builds on the + organization build server and deploys by digest, so this field is + ignored. + + ) : null} + Build servers offload the build process from your deployment servers. Select a build server and registry to use for building your @@ -181,6 +201,7 @@ export const ShowBuildServer = ({ applicationId }: Props) => { } }} value={field.value || "none"} + disabled={isPolicyEnforced} > @@ -237,6 +258,7 @@ export const ShowBuildServer = ({ applicationId }: Props) => { } }} value={field.value || "none"} + disabled={isPolicyEnforced} > @@ -274,7 +296,11 @@ export const ShowBuildServer = ({ applicationId }: Props) => { />
-
diff --git a/apps/dokploy/components/dashboard/application/advanced/show-required-checks.tsx b/apps/dokploy/components/dashboard/application/advanced/show-required-checks.tsx index f4aa577b53..5b0be9d678 100644 --- a/apps/dokploy/components/dashboard/application/advanced/show-required-checks.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/show-required-checks.tsx @@ -77,9 +77,11 @@ export const ShowRequiredChecks = ({ applicationId }: Props) => {
Required Checks - An empty list leaves the deploy ungated. With one or more check - names, the deploy waits for those GitHub check runs on the commit - to succeed before it continues. + An empty list leaves the deploy ungated. With one or more names, + the deploy waits for those GitHub check runs on the commit to + succeed before it continues. Legacy commit-status contexts are + accepted too, so either a check-run name or a status context works + here.
diff --git a/apps/dokploy/pages/api/deploy/[refreshToken].ts b/apps/dokploy/pages/api/deploy/[refreshToken].ts index 571e2fec63..e292ec5f55 100644 --- a/apps/dokploy/pages/api/deploy/[refreshToken].ts +++ b/apps/dokploy/pages/api/deploy/[refreshToken].ts @@ -14,7 +14,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { applications } from "@/server/db/schema"; import type { DeploymentJob } from "@/server/queues/queue-types"; import { - cleanQueuesByApplication, + coalesceQueuedApplicationDeploys, myQueue, } from "@/server/queues/queueSetup"; import { deploy } from "@/server/utils/deploy"; @@ -307,14 +307,20 @@ export default async function handler( dockerContextPath: application.dockerContextPath, }, commitMessage: deploymentTitle, - removeWaiting: () => cleanQueuesByApplication(application.applicationId), + removeWaiting: () => + coalesceQueuedApplicationDeploys(application.applicationId), }); if (!gate.deploy) { res.status(301).json({ message: gate.message }); return; } const hookImage = await resolveDeployHookImage( - application.environment.project.organizationId, + { + organizationId: application.environment.project.organizationId, + appName: application.appName, + registryId: application.registryId, + buildRegistryId: application.buildRegistryId, + }, req.body, ); if (!hookImage.ok) { diff --git a/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts b/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts index 58e39477e3..321e8d332c 100644 --- a/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts +++ b/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts @@ -1,10 +1,9 @@ import { // build-policy hook: enqueue-time gate and deploy-hook image body. buildPolicyDeployGate, - findUnitOrganizationId, + deployHookBodyHasImage, IS_CLOUD, normalizeChangedFilesFromCommits, - resolveDeployHookImage, shouldDeploy, } from "@dokploy/server"; import { db } from "@dokploy/server/db"; @@ -12,7 +11,10 @@ import { eq } from "drizzle-orm"; import type { NextApiRequest, NextApiResponse } from "next"; import { compose } from "@/server/db/schema"; import type { DeploymentJob } from "@/server/queues/queue-types"; -import { cleanQueuesByCompose, myQueue } from "@/server/queues/queueSetup"; +import { + coalesceQueuedComposeDeploys, + myQueue, +} from "@/server/queues/queueSetup"; import { deploy } from "@/server/utils/deploy"; import { handleGiteaComposePullRequestEvent, @@ -246,21 +248,17 @@ export default async function handler( composePath: composeResult.composePath, }, commitMessage: deploymentTitle, - removeWaiting: () => cleanQueuesByCompose(composeResult.composeId), + removeWaiting: () => + coalesceQueuedComposeDeploys(composeResult.composeId), }); if (!gate.deploy) { res.status(301).json({ message: gate.message }); return; } - const hookImage = await resolveDeployHookImage( - await findUnitOrganizationId(composeResult.environmentId), - req.body, - ); - if (!hookImage.ok || hookImage.pinnedImage) { + if (deployHookBodyHasImage(req.body)) { res.status(400).json({ - message: hookImage.ok - ? "Deploying a supplied image by digest is not supported for compose units." - : hookImage.message, + message: + "Deploying a supplied image by digest is not supported for compose units.", }); return; } diff --git a/apps/dokploy/pages/api/deploy/github.ts b/apps/dokploy/pages/api/deploy/github.ts index 237da1a6ef..b858a8ca5b 100644 --- a/apps/dokploy/pages/api/deploy/github.ts +++ b/apps/dokploy/pages/api/deploy/github.ts @@ -23,8 +23,8 @@ import type { DeploymentJob } from "@/server/queues/queue-types"; // >>> build-policy hook: enqueue-time gate (skip marker, derived watchPaths, // queue coalescing). See packages/server/src/services/build-policy/README.md import { - cleanQueuesByApplication, - cleanQueuesByCompose, + coalesceQueuedApplicationDeploys, + coalesceQueuedComposeDeploys, myQueue, } from "@/server/queues/queueSetup"; // <<< build-policy hook @@ -306,7 +306,8 @@ export default async function handler( }, changedFiles: normalizedCommits, commitMessage: deploymentTitle, - removeWaiting: () => cleanQueuesByApplication(app.applicationId), + removeWaiting: () => + coalesceQueuedApplicationDeploys(app.applicationId), }); if (!gate.deploy) continue; // <<< build-policy hook @@ -371,7 +372,8 @@ export default async function handler( }, changedFiles: normalizedCommits, commitMessage: deploymentTitle, - removeWaiting: () => cleanQueuesByCompose(composeApp.composeId), + removeWaiting: () => + coalesceQueuedComposeDeploys(composeApp.composeId), }); if (!composeGate.deploy) continue; // <<< build-policy hook diff --git a/apps/dokploy/server/api/routers/build-policy.ts b/apps/dokploy/server/api/routers/build-policy.ts index 8450065197..8756f5e1b3 100644 --- a/apps/dokploy/server/api/routers/build-policy.ts +++ b/apps/dokploy/server/api/routers/build-policy.ts @@ -1,5 +1,6 @@ import { addBuildPolicyExclusion, + assertUnitInOrganization, findBuildPolicySettings, findRegistryById, getAccessibleServerIds, @@ -86,18 +87,23 @@ export const buildPolicyRouter = createTRPCRouter({ .input(apiAddBuildPolicyExclusion) .mutation(async ({ ctx, input }) => { const organizationId = ctx.session.activeOrganizationId; - const exclusion = await addBuildPolicyExclusion({ + const { unitType, unitId } = await assertUnitInOrganization({ organizationId, applicationId: input.applicationId, composeId: input.composeId, + }); + const exclusion = await addBuildPolicyExclusion({ + organizationId, + applicationId: unitType === "application" ? unitId : null, + composeId: unitType === "compose" ? unitId : null, reason: input.reason, }); await recordBuildPolicyAudit({ organizationId, action: "exclusion_added", - applicationId: input.applicationId ?? null, - composeId: input.composeId ?? null, + applicationId: unitType === "application" ? unitId : null, + composeId: unitType === "compose" ? unitId : null, actorId: ctx.user.id, actorEmail: ctx.user.email, reason: input.reason ?? null, @@ -151,10 +157,15 @@ export const buildPolicyRouter = createTRPCRouter({ .input(apiGrantBuildPolicyBreakGlass) .mutation(async ({ ctx, input }) => { const organizationId = ctx.session.activeOrganizationId; + const { unitType, unitId } = await assertUnitInOrganization({ + organizationId, + applicationId: input.applicationId, + composeId: input.composeId, + }); await grantBreakGlass({ organizationId, - unitType: input.applicationId ? "application" : "compose", - unitId: (input.applicationId ?? input.composeId) as string, + unitType, + unitId, actorId: ctx.user.id, actorEmail: ctx.user.email, reason: input.reason, @@ -169,7 +180,8 @@ export const buildPolicyRouter = createTRPCRouter({ return { success: true }; }), - audit: protectedProcedure + /** Admin only: rows carry registry ids, build server ids and break-glass reasons. */ + audit: adminProcedure .input(apiListBuildPolicyAudit) .query(async ({ ctx, input }) => listBuildPolicyAudit({ diff --git a/apps/dokploy/server/queues/deployments-queue.ts b/apps/dokploy/server/queues/deployments-queue.ts index 0dbc935704..50104f8bf9 100644 --- a/apps/dokploy/server/queues/deployments-queue.ts +++ b/apps/dokploy/server/queues/deployments-queue.ts @@ -1,9 +1,9 @@ import { deployApplication, - // build-policy hook: see the pinnedImage branch below. - deployPinnedApplicationImage, deployCompose, deployComposePreview, + // build-policy hook: see the pinnedImage branch below. + deployPinnedApplicationImage, deployPreviewApplication, rebuildApplication, rebuildCompose, diff --git a/apps/dokploy/server/queues/queueSetup.ts b/apps/dokploy/server/queues/queueSetup.ts index 7e83ada467..3fe93167da 100644 --- a/apps/dokploy/server/queues/queueSetup.ts +++ b/apps/dokploy/server/queues/queueSetup.ts @@ -1,4 +1,5 @@ import { IS_CLOUD } from "@dokploy/server"; +import { isCoalescableDeployJob } from "@dokploy/server/services/build-policy/coalesce"; import { execAsync, execAsyncRemote, @@ -120,6 +121,38 @@ export const cleanQueuesByCompose = async (composeId: string) => { return removed; }; +/** + * build-policy hook: coalescing siblings of the two helpers above. + * + * The originals back explicit "clean queues" actions, where dropping every + * waiting job for a unit — previews included — is the intent. Coalescing runs + * automatically on every push, so it must drop ONLY the unit's own plain + * deploys: a queued PR preview for the same application is a different job that + * nobody asked to cancel. + * + * Both return the titles of what they dropped, so the audit entry names it. + */ +const coalesceWaiting = ( + matches: (data: any) => boolean, +): { removed: number; titles: string[] } => { + const titles: string[] = []; + const removed = myQueue.removeWaiting((data) => { + if (!matches(data)) return false; + const title = (data as any)?.titleLog; + if (typeof title === "string") titles.push(title); + return true; + }); + return { removed, titles }; +}; + +export const coalesceQueuedApplicationDeploys = async (applicationId: string) => + coalesceWaiting((data) => + isCoalescableDeployJob("application", applicationId, data), + ); + +export const coalesceQueuedComposeDeploys = async (composeId: string) => + coalesceWaiting((data) => isCoalescableDeployJob("compose", composeId, data)); + export const cleanAllDeploymentQueue = async () => { myQueue.clearWaiting(); return true; diff --git a/packages/server/src/services/application.ts b/packages/server/src/services/application.ts index 38ecbe543a..a4bf054d00 100644 --- a/packages/server/src/services/application.ts +++ b/packages/server/src/services/application.ts @@ -37,9 +37,11 @@ import { encodeBase64, waitForSwarmServiceStable } from "../utils/docker/utils"; import { getDokployUrl } from "./admin"; // Fork module: enforced remote builds. See services/build-policy/README.md. import { + type BuildPolicyPlan, getBuildPolicyPushCommand, planApplicationBuild, prepareBuildPolicyDeploy, + reportBuildPolicyPlanFailure, toBuildPolicyUnit, } from "./build-policy/apply"; import { @@ -198,9 +200,23 @@ export const deployApplication = async ({ descriptionLog: string; }) => { const application = await findApplicationById(applicationId); - // >>> build-policy hook 1/4: enforced remote builds. + // >>> build-policy hook 1/4: enforced remote builds. Runs before + // `createDeployment` because the deployment log has to be created on the + // host that will build. A refusal still produces a deployment row, an error + // status and a build-failure notification before it is rethrown. // See packages/server/src/services/build-policy/README.md - const buildPolicy = await planApplicationBuild(toBuildPolicyUnit(application)); + let buildPolicy: BuildPolicyPlan; + try { + buildPolicy = await planApplicationBuild(toBuildPolicyUnit(application)); + } catch (error) { + await reportBuildPolicyPlanFailure({ + application, + titleLog, + descriptionLog, + error, + }); + throw error; + } const serverId = buildPolicy.buildServerId || application.buildServerId || @@ -212,11 +228,15 @@ export const deployApplication = async ({ }; const buildLink = `${await getDokployUrl()}/dashboard/project/${application.environment.projectId}/environment/${application.environmentId}/services/application/${application.applicationId}?tab=deployments`; - const deployment = await createDeployment({ - applicationId: applicationId, - title: titleLog, - description: descriptionLog, - }); + const deployment = await createDeployment( + { + applicationId: applicationId, + title: titleLog, + description: descriptionLog, + }, + // build-policy hook: create the log on the host that will build. + { buildServerId: buildPolicy.buildServerId }, + ); try { let command = "set -e;"; @@ -387,7 +407,18 @@ export const rebuildApplication = async ({ }) => { const application = await findApplicationById(applicationId); // >>> build-policy hook 1/4 (rebuild). See services/build-policy/README.md - const buildPolicy = await planApplicationBuild(toBuildPolicyUnit(application)); + let buildPolicy: BuildPolicyPlan; + try { + buildPolicy = await planApplicationBuild(toBuildPolicyUnit(application)); + } catch (error) { + await reportBuildPolicyPlanFailure({ + application, + titleLog, + descriptionLog, + error, + }); + throw error; + } const serverId = buildPolicy.buildServerId || application.buildServerId || @@ -395,11 +426,15 @@ export const rebuildApplication = async ({ // <<< build-policy hook 1/4 const buildLink = `${await getDokployUrl()}/dashboard/project/${application.environment.projectId}/environment/${application.environmentId}/services/application/${application.applicationId}?tab=deployments`; - const deployment = await createDeployment({ - applicationId: applicationId, - title: titleLog, - description: descriptionLog, - }); + const deployment = await createDeployment( + { + applicationId: applicationId, + title: titleLog, + description: descriptionLog, + }, + // build-policy hook: create the log on the host that will build. + { buildServerId: buildPolicy.buildServerId }, + ); try { let command = "set -e;"; diff --git a/packages/server/src/services/build-policy/apply.ts b/packages/server/src/services/build-policy/apply.ts index 255d6d70fb..8b4f3f0274 100644 --- a/packages/server/src/services/build-policy/apply.ts +++ b/packages/server/src/services/build-policy/apply.ts @@ -1,28 +1,38 @@ import { posix } from "node:path"; import { paths } from "@dokploy/server/constants"; +import type { BuildPolicySettings } from "@dokploy/server/db/schema"; import { getSafeRegistryLoginCommand } from "@dokploy/server/db/schema"; import { getECRAuthToken } from "@dokploy/server/utils/aws/ecr"; import { getRegistryTag } from "@dokploy/server/utils/cluster/upload"; +import { encodeBase64 } from "@dokploy/server/utils/docker/utils"; +import { sendBuildErrorNotifications } from "@dokploy/server/utils/notifications/build-error"; import { execAsync, execAsyncRemote, } from "@dokploy/server/utils/process/execAsync"; import { getGitCommitInfo } from "@dokploy/server/utils/providers/git"; import { quote } from "shell-quote"; -import { updateDeployment } from "../deployment"; +import { getDokployUrl } from "../admin"; +import { updateApplicationStatus } from "../application"; +import { + createDeployment, + updateDeployment, + updateDeploymentStatus, +} from "../deployment"; import { findRegistryByIdWithCredentials } from "../registry"; import { recordBuildPolicyAudit } from "./audit"; import { BuildPolicyError } from "./errors"; import { waitForUnitRequiredChecks } from "./github-checks"; import { - DIGEST_MARKER, + assertSafeImageReference, buildDigestRef, + DIGEST_MARKER, parseImageDigestFromLog, parseImageTagFromLog, } from "./image"; import type { BuildPolicyDecision } from "./policy"; import { assertBuildPolicyOk, resolveBuildPolicy } from "./resolve"; -import { findBuildPolicySettings, requiredChecksTimeoutMs } from "./settings"; +import { requiredChecksTimeoutMs } from "./settings"; /** * The deploy-path entry point for applications. @@ -42,6 +52,11 @@ export interface BuildPolicyPlan { registryId: string | null; /** Full repository reference (`host/prefix/app`) the sha tag hangs off. */ repository: string | null; + /** + * The organization settings this plan was decided from, so the rest of the + * deploy never reads them a second time. + */ + settings: BuildPolicySettings | null; } interface PlanUnit { @@ -66,12 +81,16 @@ export const registryForAuth = async (registryId: string) => { return rest; }; -const LOCAL_PLAN = (reason: string): BuildPolicyPlan => ({ +const LOCAL_PLAN = ( + reason: string, + settings: BuildPolicySettings | null, +): BuildPolicyPlan => ({ enforced: false, reason, buildServerId: null, registryId: null, repository: null, + settings, }); /** @@ -98,10 +117,83 @@ export const toBuildPolicyUnit = (application: { buildRegistryId: application.buildRegistryId, }); +/** + * A refused plan (`NO_BUILD_SERVER` / `NO_REGISTRY`) has to be visible where a + * team already looks: a deployment row in `error`, the application in `error`, + * and a build-failure notification (spec §7 requires the notification). + * + * The plan has to run *before* `createDeployment`, because the deployment's log + * file is created on whichever host is going to build — so on refusal this + * creates the deployment itself, marks it failed and notifies, then the caller + * rethrows. + */ +export const reportBuildPolicyPlanFailure = async ({ + application, + titleLog, + descriptionLog, + error, +}: { + application: { + applicationId: string; + appName: string; + name: string; + serverId: string | null; + environment: { + projectId: string; + project: { name: string; organizationId: string }; + }; + }; + titleLog: string; + descriptionLog: string; + error: unknown; +}): Promise => { + const message = error instanceof Error ? error.message : String(error); + try { + const deployment = await createDeployment({ + applicationId: application.applicationId, + title: titleLog, + description: descriptionLog, + }); + + const command = `echo "${encodeBase64(`\n❌ [build-policy] ${message}\n`)}" | base64 -d >> "${deployment.logPath}";`; + try { + if (application.serverId) { + await execAsyncRemote(application.serverId, command); + } else { + await execAsync(command); + } + } catch (logError) { + // An unreachable host must not cost the team the notification, which is + // the only other place a refused deploy surfaces. + console.error( + "[build-policy] could not append the refusal to the deployment log", + logError, + ); + } + + await updateDeploymentStatus(deployment.deploymentId, "error"); + await updateApplicationStatus(application.applicationId, "error"); + await sendBuildErrorNotifications({ + projectName: application.environment.project.name, + applicationName: application.name, + applicationType: "application", + errorMessage: message, + buildLink: `${await getDokployUrl()}/dashboard/project/${application.environment.projectId}/services/application/${application.applicationId}?tab=deployments`, + organizationId: application.environment.project.organizationId, + }); + } catch (reportingError) { + // Reporting must never mask the policy error the caller is about to throw. + console.error( + "[build-policy] could not record the refused deploy", + reportingError, + ); + } +}; + export const planApplicationBuild = async ( unit: PlanUnit, ): Promise => { - const { decision } = await resolveBuildPolicy({ + const { decision, settings } = await resolveBuildPolicy({ unitType: "application", unitId: unit.unitId, unitName: unit.unitName, @@ -118,7 +210,7 @@ export const planApplicationBuild = async ( >; if (ok.mode === "local") { - return LOCAL_PLAN(ok.reason); + return LOCAL_PLAN(ok.reason, settings); } const registry = await findRegistryByIdWithCredentials(ok.registryId); @@ -127,6 +219,7 @@ export const planApplicationBuild = async ( buildServerId: ok.buildServerId, registryId: ok.registryId, repository: getRegistryTag(registry, unit.appName), + settings, }; }; @@ -212,9 +305,17 @@ export interface PublishedImage { export const readPublishedImage = async ({ logPath, serverId, + expectedRepository, }: { logPath: string; serverId: string | null; + /** + * The only repository this deploy may be pinned to. The log being grepped + * also carries the repository's own `docker build` output, so a Dockerfile + * could print a forged marker naming somewhere else; `set -e` happens to put + * the genuine marker last today, but the deploy must not rest on that. + */ + expectedRepository: string; }): Promise => { const command = `grep -F ${quote([DIGEST_MARKER])} ${quote([logPath])} | tail -n 1`; let stdout = ""; @@ -232,6 +333,23 @@ export const readPublishedImage = async ({ const tag = parseImageTagFromLog(stdout); const digest = parseImageDigestFromLog(stdout); if (!tag || !digest) return null; + + if (!tag.startsWith(`${expectedRepository}:`)) { + console.error( + `[build-policy] ignoring a published-image marker for "${tag}"; this deploy publishes "${expectedRepository}"`, + ); + return null; + } + try { + assertSafeImageReference(tag); + } catch (error) { + console.error( + "[build-policy] published-image marker is not a safe reference", + error, + ); + return null; + } + return { tag, digest, ref: buildDigestRef(tag, digest) }; }; @@ -254,7 +372,11 @@ export const requirePublishedImage = async ({ applicationId: string; unitName: string; }): Promise => { - const published = await readPublishedImage({ logPath, serverId }); + const published = await readPublishedImage({ + logPath, + serverId, + expectedRepository: plan.repository ?? "", + }); if (!published) { throw new BuildPolicyError( "DIGEST_NOT_PUBLISHED", @@ -313,6 +435,13 @@ export const prepareBuildPolicyDeploy = async < serverId: string | null; }): Promise => { const organizationId = application.environment.project.organizationId; + const requiredChecks = (application.requiredChecks ?? []).filter( + (name): name is string => typeof name === "string" && name.length > 0, + ); + + // Nothing to do. Return before any query or remote exec, so a deploy with + // the policy off costs exactly what it costs on upstream. + if (!plan.enforced && requiredChecks.length === 0) return application; const published = plan.enforced ? await requirePublishedImage({ @@ -325,37 +454,39 @@ export const prepareBuildPolicyDeploy = async < }) : null; - // Gate on required checks before the deploy step. The sha comes from the tag - // the build just published when there is one, otherwise from the checkout. - const sha = - published?.tag.split(":").pop() ?? - ( - await getGitCommitInfo({ - appName: application.appName, - type: "application", - serverId, - }) - )?.hash ?? - null; - - await waitForUnitRequiredChecks({ - unit: { - unitType: "application", - unitId: application.applicationId, - unitName: application.name, - organizationId, - requiredChecks: application.requiredChecks, - sourceType: application.sourceType, - githubId: application.githubId, - owner: application.owner, - repository: application.repository, - customGitUrl: application.customGitUrl, - }, - sha, - timeoutMs: requiredChecksTimeoutMs( - await findBuildPolicySettings(organizationId), - ), - }); + if (requiredChecks.length > 0) { + // Gate on required checks before the deploy step. The sha comes from the + // tag the build just published when there is one, otherwise from the + // checkout — an SSH round trip, so only when checks are configured. + const sha = + published?.tag.split(":").pop() ?? + ( + await getGitCommitInfo({ + appName: application.appName, + type: "application", + serverId, + }) + )?.hash ?? + null; + + await waitForUnitRequiredChecks({ + unit: { + unitType: "application", + unitId: application.applicationId, + unitName: application.name, + organizationId, + requiredChecks, + sourceType: application.sourceType, + githubId: application.githubId, + owner: application.owner, + repository: application.repository, + customGitUrl: application.customGitUrl, + }, + sha, + // Already read when the plan was made; never read twice per deploy. + timeoutMs: requiredChecksTimeoutMs(plan.settings), + }); + } if (!published) return application; diff --git a/packages/server/src/services/build-policy/audit.ts b/packages/server/src/services/build-policy/audit.ts index 833f62f673..f956db64dc 100644 --- a/packages/server/src/services/build-policy/audit.ts +++ b/packages/server/src/services/build-policy/audit.ts @@ -65,7 +65,10 @@ export const listBuildPolicyAudit = async ({ offset, with: { application: true, compose: true }, }), - db.$count(buildPolicyAudit, eq(buildPolicyAudit.organizationId, organizationId)), + db.$count( + buildPolicyAudit, + eq(buildPolicyAudit.organizationId, organizationId), + ), ]); return { logs: rows, total }; }; diff --git a/packages/server/src/services/build-policy/coalesce.ts b/packages/server/src/services/build-policy/coalesce.ts index 2ceae168a4..231b713e27 100644 --- a/packages/server/src/services/build-policy/coalesce.ts +++ b/packages/server/src/services/build-policy/coalesce.ts @@ -8,9 +8,42 @@ import type { BuildPolicyUnitType } from "./policy"; * that are still *waiting*. Running builds are left alone. N pushes therefore * produce one build of the newest commit. * + * The caller's `removeWaiting` must drop only the unit's own plain deploys — a + * queued PR preview for the same application is a different job that nobody + * asked to cancel. It returns the titles of what it dropped so the audit entry + * names them. + * * Coalescing is best-effort: neither a queue failure nor an audit failure may * stop the deploy that is being enqueued. */ +/** + * Which waiting jobs a coalescing pass may drop. + * + * Only the unit's own **plain** deploys. A queued PR preview carries the same + * `applicationId` (or `composeId`) but a different `applicationType`, and + * nobody asked to cancel it: it belongs to a pull request, not to the push that + * is being coalesced. Upstream's `cleanQueuesBy*` helpers deliberately drop + * everything because they back an explicit "clean queues" button; coalescing + * runs automatically on every push, which is a different contract. + * + * The rule lives here rather than in the queue so both queue helpers and the + * tests read the same predicate. + */ +export const isCoalescableDeployJob = ( + unitType: BuildPolicyUnitType, + unitId: string, + data: unknown, +): boolean => { + if (data === null || typeof data !== "object") return false; + const job = data as Record; + if (unitType === "application") { + return ( + job.applicationType === "application" && job.applicationId === unitId + ); + } + return job.applicationType === "compose" && job.composeId === unitId; +}; + export interface CoalesceAuditEntry { organizationId: string; action: BuildPolicyAuditAction; @@ -19,16 +52,34 @@ export interface CoalesceAuditEntry { metadata: Record; } +export interface CoalesceRemoval { + removed: number; + titles?: string[]; +} + export interface CoalesceQueuedDeployInput { unitType: BuildPolicyUnitType; unitId: string; unitName?: string; organizationId: string; - /** Removes still-waiting jobs for this unit; returns how many it removed. */ - removeWaiting: () => Promise | number; + /** + * Removes this unit's still-waiting plain deploys. Either a count or a + * `{removed, titles}` record. + */ + removeWaiting: () => + | Promise + | number + | CoalesceRemoval; recordAudit: (entry: CoalesceAuditEntry) => Promise; } +const normalize = ( + result: number | CoalesceRemoval | undefined | null, +): CoalesceRemoval => + typeof result === "number" + ? { removed: result } + : { removed: result?.removed ?? 0, titles: result?.titles }; + export const coalesceQueuedDeploy = async ({ unitType, unitId, @@ -37,15 +88,15 @@ export const coalesceQueuedDeploy = async ({ removeWaiting, recordAudit, }: CoalesceQueuedDeployInput): Promise<{ removed: number }> => { - let removed = 0; + let result: CoalesceRemoval; try { - removed = (await removeWaiting()) ?? 0; + result = normalize(await removeWaiting()); } catch (error) { console.error("[build-policy] queue coalescing failed", error); return { removed: 0 }; } - if (removed <= 0) return { removed: 0 }; + if (result.removed <= 0) return { removed: 0 }; try { await recordAudit({ @@ -53,11 +104,17 @@ export const coalesceQueuedDeploy = async ({ action: "deploy_coalesced", applicationId: unitType === "application" ? unitId : null, composeId: unitType === "compose" ? unitId : null, - metadata: { removed, unitType, unitName }, + metadata: { + removed: result.removed, + unitType, + unitName, + // So a dropped deploy is traceable to the push it came from. + ...(result.titles?.length ? { droppedTitles: result.titles } : {}), + }, }); } catch (error) { console.error("[build-policy] failed to audit queue coalescing", error); } - return { removed }; + return { removed: result.removed }; }; diff --git a/packages/server/src/services/build-policy/exclusions.ts b/packages/server/src/services/build-policy/exclusions.ts index f719a7d1df..f12ef56160 100644 --- a/packages/server/src/services/build-policy/exclusions.ts +++ b/packages/server/src/services/build-policy/exclusions.ts @@ -72,10 +72,7 @@ export const removeBuildPolicyExclusion = async ({ .where( and( eq(buildPolicyExclusion.organizationId, organizationId), - eq( - buildPolicyExclusion.buildPolicyExclusionId, - buildPolicyExclusionId, - ), + eq(buildPolicyExclusion.buildPolicyExclusionId, buildPolicyExclusionId), ), ) .returning(); diff --git a/packages/server/src/services/build-policy/github-checks.ts b/packages/server/src/services/build-policy/github-checks.ts index 2d84a2bf59..dec4e311c1 100644 --- a/packages/server/src/services/build-policy/github-checks.ts +++ b/packages/server/src/services/build-policy/github-checks.ts @@ -3,10 +3,7 @@ import { findGithubById } from "../github"; import { recordBuildPolicyAudit } from "./audit"; import { BuildPolicyError } from "./errors"; import type { BuildPolicyUnitType } from "./policy"; -import { - type CheckRunLike, - waitForRequiredChecks, -} from "./required-checks"; +import { type CheckRunLike, waitForRequiredChecks } from "./required-checks"; import { parseGithubOwnerRepo } from "./source"; /** @@ -21,6 +18,37 @@ const DEFAULT_POLL_INTERVAL_MS = 15_000; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +/** + * Legacy commit statuses (the statuses API) carry a `state` rather than the + * `status`/`conclusion` pair a check run has. A team that configures the name + * of a status *context* as a required check has to be able to pass the gate, + * so both sources are normalised into the same `CheckRunLike` shape. + */ +const COMMIT_STATUS_STATES: Record< + string, + { status: string; conclusion: string | null } +> = { + success: { status: "completed", conclusion: "success" }, + failure: { status: "completed", conclusion: "failure" }, + error: { status: "completed", conclusion: "failure" }, + pending: { status: "in_progress", conclusion: null }, +}; + +const toCheckRunLike = (status: { + context: string; + state: string; +}): CheckRunLike => { + const mapped = COMMIT_STATUS_STATES[status.state] ?? { + status: "in_progress", + conclusion: null, + }; + return { + name: status.context, + status: mapped.status, + conclusion: mapped.conclusion, + }; +}; + export interface RequiredChecksUnit { unitType: BuildPolicyUnitType; unitId: string; @@ -94,17 +122,55 @@ export const waitForUnitRequiredChecks = async ({ } const provider = await findGithubById(unit.githubId); const octokit = authGithub(provider); - const { data } = await octokit.rest.checks.listForRef({ - owner, - repo, - ref: sha, - per_page: 100, - }); - return (data.check_runs ?? []).map((run) => ({ - name: run.name, - status: run.status, - conclusion: run.conclusion ?? null, - })); + // A legacy commit-status context is as valid a required-check name as + // a check run, so both sources are read, concurrently, and merged. + // + // The statuses endpoint returns newest-first while check runs come + // back oldest-first, and `evaluateRequiredChecks` keeps the LAST + // occurrence of a name as the newest. So the statuses are reversed + // and appended after the check runs. + const readCommitStatuses = async (): Promise => { + try { + const { data } = await octokit.rest.repos.listCommitStatusesForRef({ + owner, + repo, + ref: sha, + per_page: 100, + }); + return [...(data ?? [])].reverse().map(toCheckRunLike); + } catch (error) { + // A token without the statuses scope must not stop the deploy; + // fall back to check runs alone. A failure of the check runs call + // itself is deliberately left to propagate, so the gate still + // fails closed when GitHub cannot be read at all. + console.error( + `[build-policy] could not read commit statuses for ${owner}/${repo}@${sha}, ` + + "falling back to check runs alone:", + error, + ); + return []; + } + }; + + const [checkRuns, statusRuns] = await Promise.all([ + octokit.rest.checks.listForRef({ + owner, + repo, + ref: sha, + per_page: 100, + }), + readCommitStatuses(), + ]); + + const runs: CheckRunLike[] = (checkRuns.data.check_runs ?? []).map( + (run) => ({ + name: run.name, + status: run.status, + conclusion: run.conclusion ?? null, + }), + ); + + return [...runs, ...statusRuns]; }); try { diff --git a/packages/server/src/services/build-policy/hook-body.ts b/packages/server/src/services/build-policy/hook-body.ts index 76f4f5fbd5..6ab47ec4d4 100644 --- a/packages/server/src/services/build-policy/hook-body.ts +++ b/packages/server/src/services/build-policy/hook-body.ts @@ -4,6 +4,8 @@ import { buildDigestRef, isValidDigest, registryHostOf, + repositoryOf, + splitRepositoryAndTag, } from "./image"; /** @@ -30,7 +32,14 @@ const asRecord = (body: unknown): Record | null => export const parseDeployHookImage = ( body: unknown, - allowedRegistryHosts: string[], + /** + * Fully qualified repositories this unit may deploy, e.g. + * `ghcr.io/devinosolutions/sendly-web`. A host allowlist is not enough: it + * would let any deploy-hook token run any image on a registry the + * organization owns. Spec 5.2.9 restricts the body to the unit's own + * configured registry. + */ + allowedRepositories: string[], ): DeployHookImage => { const record = asRecord(body); if (!record) return { kind: "none" }; @@ -62,25 +71,23 @@ export const parseDeployHookImage = ( throw new BuildPolicyError( "REGISTRY_NOT_ALLOWED", `Deploy hook image "${reference}" has no registry host. It must be fully ` + - "qualified and point at a registry configured on this organization.", + "qualified and name this unit's own repository.", ); } - if (!allowedRegistryHosts.includes(host)) { + + const repository = repositoryOf(reference); + if (!allowedRepositories.includes(repository)) { throw new BuildPolicyError( "REGISTRY_NOT_ALLOWED", - `Deploy hook image "${reference}" is on registry "${host}", which is not ` + - "configured on this organization.", - { host, allowedRegistryHosts }, + `Deploy hook image "${reference}" resolves to repository "${repository}", ` + + `which is not this unit's own repository (${allowedRepositories.join(", ") || "none configured"}).`, + { repository, allowedRepositories }, ); } const explicitTag = typeof tag === "string" && tag.trim().length > 0 ? tag.trim() : null; - const embeddedTag = (() => { - const lastSlash = reference.lastIndexOf("/"); - const lastColon = reference.lastIndexOf(":"); - return lastColon > lastSlash ? reference.slice(lastColon + 1) : null; - })(); + const embeddedTag = splitRepositoryAndTag(reference).tag; return { kind: "image", diff --git a/packages/server/src/services/build-policy/image.ts b/packages/server/src/services/build-policy/image.ts index e99f4f54b5..ad51a997a4 100644 --- a/packages/server/src/services/build-policy/image.ts +++ b/packages/server/src/services/build-policy/image.ts @@ -27,7 +27,7 @@ export const imageTagForSha = (appName: string, sha?: string | null): string => `${appName}:${sha && sha.length > 0 ? sha : SHA_PLACEHOLDER}`; /** Split a reference into its repository part and its tag, if any. */ -const splitRepositoryAndTag = ( +export const splitRepositoryAndTag = ( reference: string, ): { repository: string; tag: string | null } => { const lastSlash = reference.lastIndexOf("/"); @@ -42,6 +42,10 @@ const splitRepositoryAndTag = ( return { repository: reference, tag: null }; }; +/** The repository part of a reference: no tag, no digest. */ +export const repositoryOf = (reference: string): string => + splitRepositoryAndTag(reference.split("@")[0] ?? reference).repository; + export const registryHostOf = (reference: string): string | null => { const firstSlash = reference.indexOf("/"); if (firstSlash === -1) return null; diff --git a/packages/server/src/services/build-policy/index.ts b/packages/server/src/services/build-policy/index.ts index f81816cf4a..30d99c2acf 100644 --- a/packages/server/src/services/build-policy/index.ts +++ b/packages/server/src/services/build-policy/index.ts @@ -6,6 +6,7 @@ export * from "./exclusions"; export * from "./github-checks"; export * from "./hook-body"; export * from "./image"; +export * from "./ownership"; export * from "./pinned-deploy"; export * from "./policy"; export * from "./required-checks"; diff --git a/packages/server/src/services/build-policy/ownership.ts b/packages/server/src/services/build-policy/ownership.ts new file mode 100644 index 0000000000..4506c7b23c --- /dev/null +++ b/packages/server/src/services/build-policy/ownership.ts @@ -0,0 +1,80 @@ +import { db } from "@dokploy/server/db"; +import { applications, compose } from "@dokploy/server/db/schema"; +import { TRPCError } from "@trpc/server"; +import { eq } from "drizzle-orm"; +import type { BuildPolicyUnitType } from "./policy"; + +/** + * The build-policy router takes `applicationId` / `composeId` straight from + * input, so every mutation that FK-links a row to a unit has to prove first + * that the unit belongs to the caller's active organization. Without this an + * admin of organization A can create exclusions and break-glass grants against + * organization B's units. + */ +export interface AssertUnitInOrganizationInput { + organizationId: string; + applicationId?: string | null; + composeId?: string | null; +} + +export interface BuildPolicyOwnedUnit { + unitType: BuildPolicyUnitType; + unitId: string; + unitName: string; +} + +export const assertUnitInOrganization = async ({ + organizationId, + applicationId, + composeId, +}: AssertUnitInOrganizationInput): Promise => { + if (applicationId) { + const application = await db.query.applications.findFirst({ + where: eq(applications.applicationId, applicationId), + with: { environment: { with: { project: true } } }, + }); + if (!application) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Application not found", + }); + } + if (application.environment.project.organizationId !== organizationId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You are not authorized to access this application", + }); + } + return { + unitType: "application", + unitId: application.applicationId, + unitName: application.name, + }; + } + + if (composeId) { + const composeUnit = await db.query.compose.findFirst({ + where: eq(compose.composeId, composeId), + with: { environment: { with: { project: true } } }, + }); + if (!composeUnit) { + throw new TRPCError({ code: "NOT_FOUND", message: "Compose not found" }); + } + if (composeUnit.environment.project.organizationId !== organizationId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You are not authorized to access this compose", + }); + } + return { + unitType: "compose", + unitId: composeUnit.composeId, + unitName: composeUnit.name, + }; + } + + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Provide exactly one of applicationId or composeId", + }); +}; diff --git a/packages/server/src/services/build-policy/pinned-deploy.ts b/packages/server/src/services/build-policy/pinned-deploy.ts index ee8e377837..898e259c7b 100644 --- a/packages/server/src/services/build-policy/pinned-deploy.ts +++ b/packages/server/src/services/build-policy/pinned-deploy.ts @@ -10,16 +10,13 @@ import { execAsyncRemote, } from "@dokploy/server/utils/process/execAsync"; import { getDokployUrl } from "../admin"; -import { - findApplicationById, - updateApplicationStatus, -} from "../application"; -import { findAllRegistryByOrganizationId } from "../registry"; +import { findApplicationById, updateApplicationStatus } from "../application"; import { createDeployment, updateDeployment, updateDeploymentStatus, } from "../deployment"; +import { findAllRegistryByOrganizationId } from "../registry"; import { registryForAuth } from "./apply"; import { recordBuildPolicyAudit } from "./audit"; import { waitForUnitRequiredChecks } from "./github-checks"; @@ -66,7 +63,7 @@ export const deployPinnedApplicationImage = async ({ try { await log( - `📦 [build-policy] Deploy hook supplied an image; skipping the build.\n` + + "📦 [build-policy] Deploy hook supplied an image; skipping the build.\n" + ` image: ${pinnedImage.tag ?? pinnedImage.ref}\n` + ` digest: ${pinnedImage.digest}\n`, ); diff --git a/packages/server/src/services/build-policy/settings.ts b/packages/server/src/services/build-policy/settings.ts index 08be94764a..171f7cdbee 100644 --- a/packages/server/src/services/build-policy/settings.ts +++ b/packages/server/src/services/build-policy/settings.ts @@ -21,6 +21,43 @@ export const findBuildPolicySettings = async ( return row ?? null; }; +/** + * "Does any organization enforce remote builds?" + * + * The enqueue gate runs on every webhook delivery and every deploy hook, and it + * only has an `environmentId` — resolving that to an organization is itself a + * query. So the gate asks this first, and on an instance where nobody has + * turned the policy on (every instance, the moment this merges) it does one + * cheap indexed read and then nothing at all until the cache expires. + * + * The cache is process-local and short. Turning the policy on through the + * settings router clears it, so the only staleness window is another process's + * write, bounded by the TTL below. + */ +const ENFORCEMENT_CACHE_TTL_MS = 5_000; + +let enforcementCache: { value: boolean; readAt: number } | null = null; + +export const clearBuildPolicyEnforcementCache = () => { + enforcementCache = null; +}; + +export const isBuildPolicyEnforcedAnywhere = async ( + now: () => number = Date.now, +): Promise => { + const cached = enforcementCache; + if (cached && now() - cached.readAt < ENFORCEMENT_CACHE_TTL_MS) { + return cached.value; + } + const row = await db.query.buildPolicySettings.findFirst({ + where: eq(buildPolicySettings.enforceRemoteBuilds, true), + columns: { buildPolicySettingsId: true }, + }); + const value = !!row; + enforcementCache = { value, readAt: now() }; + return value; +}; + export interface BuildPolicySettingsUpdate { enforceRemoteBuilds?: boolean; defaultBuildServerId?: string | null; @@ -34,6 +71,8 @@ export const upsertBuildPolicySettings = async ( ): Promise => { const existing = await findBuildPolicySettings(organizationId); const now = new Date().toISOString(); + // A write can flip enforcement on or off; drop the cached global answer. + clearBuildPolicyEnforcementCache(); if (!existing) { const [created] = await db diff --git a/packages/server/src/services/build-policy/skip-deploy.ts b/packages/server/src/services/build-policy/skip-deploy.ts index 4c88a00d42..07b50153c8 100644 --- a/packages/server/src/services/build-policy/skip-deploy.ts +++ b/packages/server/src/services/build-policy/skip-deploy.ts @@ -25,5 +25,7 @@ export const matchedSkipDeployMarker = ( ): string | null => { if (typeof message !== "string" || message.length === 0) return null; const haystack = message.toLowerCase(); - return SKIP_DEPLOY_MARKERS.find((marker) => haystack.includes(marker)) ?? null; + return ( + SKIP_DEPLOY_MARKERS.find((marker) => haystack.includes(marker)) ?? null + ); }; diff --git a/packages/server/src/services/build-policy/source.ts b/packages/server/src/services/build-policy/source.ts index a7db74b70c..0e8d96186d 100644 --- a/packages/server/src/services/build-policy/source.ts +++ b/packages/server/src/services/build-policy/source.ts @@ -23,9 +23,7 @@ const extractHost = (rawUrl: string): string | null => { } }; -export const isGithubHostUrl = ( - url: string | null | undefined, -): boolean => { +export const isGithubHostUrl = (url: string | null | undefined): boolean => { if (typeof url !== "string") return false; const host = extractHost(url); return host !== null && GITHUB_HOSTS.has(host); diff --git a/packages/server/src/services/build-policy/webhook.ts b/packages/server/src/services/build-policy/webhook.ts index ce362f0786..f9a5bd0fab 100644 --- a/packages/server/src/services/build-policy/webhook.ts +++ b/packages/server/src/services/build-policy/webhook.ts @@ -1,13 +1,21 @@ import { db } from "@dokploy/server/db"; import { environments } from "@dokploy/server/db/schema"; +import { getRegistryTag } from "@dokploy/server/utils/cluster/upload"; import { shouldDeploy } from "@dokploy/server/utils/watch-paths/should-deploy"; import { eq } from "drizzle-orm"; -import { findAllRegistryByOrganizationId } from "../registry"; +import { findRegistryByIdWithCredentials } from "../registry"; import { recordBuildPolicyAudit } from "./audit"; -import { coalesceQueuedDeploy } from "./coalesce"; +import { + type CoalesceQueuedDeployInput, + coalesceQueuedDeploy, +} from "./coalesce"; import { parseDeployHookImage } from "./hook-body"; import { toPinnedImageJob } from "./pinned-deploy"; import type { BuildPolicyUnitType } from "./policy"; +import { + findBuildPolicySettings, + isBuildPolicyEnforcedAnywhere, +} from "./settings"; import { matchedSkipDeployMarker } from "./skip-deploy"; import { resolveWatchPaths } from "./watch-paths"; @@ -24,6 +32,12 @@ export type PinnedImageJob = { * else: honour `[skip deploy]`, apply the derived default `watchPaths` when the * unit has none, and coalesce the deploys that are still waiting for this unit. * + * **All three only happen while the organization enforces remote builds.** With + * the policy off the gate is a no-op that reads nothing and drops nothing, so + * the deploy entry points behave exactly as upstream does. That matters because + * a derived watch path silently *stops* deploys, which is the last thing a team + * should discover by accident after a fork upgrade. + * * It is intentionally the only build-policy touch point in the webhook and * deploy-hook routes, so an upstream merge has one place to reconcile. */ @@ -46,6 +60,8 @@ export type BuildPolicyGateResult = message: string; }; +const PASS: BuildPolicyGateResult = { deploy: true, coalesced: 0 }; + const findOrganizationId = async ( environmentId: string, ): Promise => { @@ -68,24 +84,33 @@ export const buildPolicyDeployGate = async ({ /** Paths touched by the push, or null when the caller has no file list. */ changedFiles?: string[] | null; commitMessage?: string | null; - /** Drops still-waiting jobs for this unit; returns how many it dropped. */ - removeWaiting: () => Promise | number; + /** + * Drops this unit's still-waiting plain deploys (never its previews) and + * reports how many, and ideally their titles. + */ + removeWaiting: CoalesceQueuedDeployInput["removeWaiting"]; }): Promise => { + // Cheap cached check first: on an instance where nobody enforces, this is + // the only thing the gate does. + if (!(await isBuildPolicyEnforcedAnywhere())) return PASS; + const organizationId = await findOrganizationId(unit.environmentId); + if (!organizationId) return PASS; + + const settings = await findBuildPolicySettings(organizationId); + if (!settings?.enforceRemoteBuilds) return PASS; const marker = matchedSkipDeployMarker(commitMessage); if (marker) { const message = `Deployment skipped: the commit message contains ${marker}`; - if (organizationId) { - await recordBuildPolicyAudit({ - organizationId, - action: "deploy_skipped", - applicationId: unitType === "application" ? unit.unitId : null, - composeId: unitType === "compose" ? unit.unitId : null, - reason: message, - metadata: { unitName: unit.unitName, marker }, - }); - } + await recordBuildPolicyAudit({ + organizationId, + action: "deploy_skipped", + applicationId: unitType === "application" ? unit.unitId : null, + composeId: unitType === "compose" ? unit.unitId : null, + reason: message, + metadata: { unitName: unit.unitName, marker }, + }); return { deploy: false, reason: "skip_deploy_marker", message }; } @@ -110,8 +135,6 @@ export const buildPolicyDeployGate = async ({ } } - if (!organizationId) return { deploy: true, coalesced: 0 }; - const { removed } = await coalesceQueuedDeploy({ unitType, unitId: unit.unitId, @@ -124,40 +147,66 @@ export const buildPolicyDeployGate = async ({ return { deploy: true, coalesced: removed }; }; +/** Does this request body carry an `image` at all? Cheap, no database. */ +export const deployHookBodyHasImage = (body: unknown): boolean => { + const record = + body !== null && typeof body === "object" && !Array.isArray(body) + ? (body as Record) + : null; + return !!record && record.image !== undefined && record.image !== null; +}; + /** - * Optional deploy-hook body `{image, tag, digest}` (spec 5.2.9), validated - * against the registries this organization has configured. + * Optional deploy-hook body `{image, tag, digest}` (spec 5.2.9). + * + * Two gates, both required: * - * Returns a result union rather than throwing so the route stays four lines. + * 1. The capability does not exist while the policy is off. A deploy hook URL + * is a bearer token pasted into CI configs across the fleet; it must not + * quietly gain the power to run an arbitrary image the day this merges. + * 2. The image must be **the unit's own repository** — `//` — + * not merely a repository on a registry the organization happens to own. + * Spec 5.2.9 says "restricted to the unit's configured registry"; an + * org-wide host allowlist would let any token deploy any image on ghcr.io. */ +export interface DeployHookImageUnit { + organizationId: string | null; + appName: string; + /** The unit's own registry, then its build registry, then the org default. */ + registryId?: string | null; + buildRegistryId?: string | null; +} + export const resolveDeployHookImage = async ( - organizationId: string | null, + unit: DeployHookImageUnit, body: unknown, ): Promise< - | { ok: true; pinnedImage?: PinnedImageJob } - | { ok: false; message: string } + { ok: true; pinnedImage?: PinnedImageJob } | { ok: false; message: string } > => { + if (!deployHookBodyHasImage(body)) return { ok: true }; + + // Gate 1: capability off while the policy is off. + if (!(await isBuildPolicyEnforcedAnywhere())) return { ok: true }; + if (!unit.organizationId) return { ok: true }; + const settings = await findBuildPolicySettings(unit.organizationId); + if (!settings?.enforceRemoteBuilds) return { ok: true }; + try { - const record = - body !== null && typeof body === "object" && !Array.isArray(body) - ? (body as Record) - : null; - if (!record || record.image === undefined || record.image === null) { - return { ok: true }; - } - if (!organizationId) { + // Gate 2: exactly one acceptable repository, the unit's own. + const registryId = + unit.registryId ?? unit.buildRegistryId ?? settings.defaultRegistryId; + if (!registryId) { return { ok: false, message: - "A deploy hook image was supplied but this unit's organization could " + - "not be resolved, so the registry could not be validated.", + "A deploy hook image was supplied but this unit has no registry " + + "configured and the organization has no default, so there is no " + + "repository to validate it against.", }; } - const registries = await findAllRegistryByOrganizationId(organizationId); - const allowedHosts = registries - .map((registry) => registry.registryUrl) - .filter((url): url is string => !!url); - const parsed = parseDeployHookImage(body, allowedHosts); + const registry = await findRegistryByIdWithCredentials(registryId); + const allowedRepository = getRegistryTag(registry, unit.appName); + const parsed = parseDeployHookImage(body, [allowedRepository]); return { ok: true, pinnedImage: toPinnedImageJob(parsed) }; } catch (error) { return { diff --git a/packages/server/src/services/deployment.ts b/packages/server/src/services/deployment.ts index e419483178..09ae58ecb0 100644 --- a/packages/server/src/services/deployment.ts +++ b/packages/server/src/services/deployment.ts @@ -159,6 +159,11 @@ export const createDeployment = async ( z.infer, "deploymentId" | "createdAt" | "status" | "logPath" >, + // build-policy hook: when the org policy relocates the build, the log file + // has to be created on THAT host, because that is where the build script + // appends to it. Omitted everywhere else, so upstream behaviour is unchanged. + // See packages/server/src/services/build-policy/README.md + options?: { buildServerId?: string | null }, ) => { const application = await findApplicationById(deployment.applicationId); await removeLastTenDeployments( @@ -167,7 +172,8 @@ export const createDeployment = async ( application.serverId, ); try { - const serverId = application.buildServerId || application.serverId; + const buildServerId = options?.buildServerId ?? application.buildServerId; + const serverId = buildServerId || application.serverId; const { LOGS_PATH } = paths(!!serverId); const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss"); @@ -201,9 +207,7 @@ export const createDeployment = async ( logPath: logFilePath, description: deployment.description || "", startedAt: new Date().toISOString(), - ...(application.buildServerId && { - buildServerId: application.buildServerId, - }), + ...(buildServerId && { buildServerId }), }) .returning(); if (deploymentCreate.length === 0 || !deploymentCreate[0]) { From 84f8251d68158887fe56351b5df1b305527fa8e1 Mon Sep 17 00:00:00 2001 From: AminDhouib Date: Thu, 10 Sep 2026 15:19:03 -0400 Subject: [PATCH 05/18] fix(build-policy): ignore a compose deploy-hook image body while the policy is off Residue of review finding 1. The compose deploy hook returned a 400 for any body carrying an `image`, whether or not the organization enforced anything. Upstream ignores that body entirely, so on the day this merges a CI job that posts one to a compose deploy hook would start failing for a capability the instance does not even have switched on. `rejectComposeDeployHookImage` now applies the same two gates the application side already had: the cached "does anybody enforce" probe first, then the organization's own setting. An enforcing organization still gets the 400 that tells it the compose build is not relocatable; everyone else gets upstream's behaviour, which is to ignore the body. --- .../policy-off-is-upstream.test.ts | 13 +++++++++ .../api/deploy/compose/[refreshToken].ts | 18 +++++++------ .../src/services/build-policy/webhook.ts | 27 +++++++++++++++++++ 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/apps/dokploy/__test__/build-policy/policy-off-is-upstream.test.ts b/apps/dokploy/__test__/build-policy/policy-off-is-upstream.test.ts index 66a61f6a50..618db4d7de 100644 --- a/apps/dokploy/__test__/build-policy/policy-off-is-upstream.test.ts +++ b/apps/dokploy/__test__/build-policy/policy-off-is-upstream.test.ts @@ -168,6 +168,7 @@ import { deployApplication } from "@dokploy/server/services/application"; import { clearBuildPolicyEnforcementCache } from "@dokploy/server/services/build-policy/settings"; import { buildPolicyDeployGate, + rejectComposeDeployHookImage, resolveDeployHookImage, } from "@dokploy/server/services/build-policy/webhook"; import * as deploymentService from "@dokploy/server/services/deployment"; @@ -353,6 +354,18 @@ describe("the deploy-hook image body while the policy is off", () => { ); expect(mocks.registryFindMany).not.toHaveBeenCalled(); }); + + it("is ignored on a compose unit too, rather than turning into a 400", async () => { + // Upstream ignores the request body on a compose deploy hook. A CI job + // that posts one must not start failing the day this merges. + await expect( + rejectComposeDeployHookImage("env-1", { + image: "ghcr.io/devinosolutions/sendly-web", + digest: `sha256:${"a".repeat(64)}`, + }), + ).resolves.toEqual({ ok: true }); + expect(mocks.environmentsFindFirst).not.toHaveBeenCalled(); + }); }); describe("the deploy path while the policy is off", () => { diff --git a/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts b/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts index 321e8d332c..11109f2221 100644 --- a/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts +++ b/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts @@ -1,9 +1,9 @@ import { // build-policy hook: enqueue-time gate and deploy-hook image body. buildPolicyDeployGate, - deployHookBodyHasImage, IS_CLOUD, normalizeChangedFilesFromCommits, + rejectComposeDeployHookImage, shouldDeploy, } from "@dokploy/server"; import { db } from "@dokploy/server/db"; @@ -236,8 +236,9 @@ export default async function handler( // >>> build-policy hook: `[skip deploy]`, derived watchPaths and queue // coalescing. A compose unit cannot deploy a supplied image by digest - // yet (see README.md § Known gap), so such a body is rejected rather - // than silently ignored. + // yet (see README.md § Known gap), so an enforcing organization gets a + // 400 rather than a silently ignored body. While the policy is off the + // body is ignored, which is what upstream does with it. const gate = await buildPolicyDeployGate({ unitType: "compose", unit: { @@ -255,11 +256,12 @@ export default async function handler( res.status(301).json({ message: gate.message }); return; } - if (deployHookBodyHasImage(req.body)) { - res.status(400).json({ - message: - "Deploying a supplied image by digest is not supported for compose units.", - }); + const hookImage = await rejectComposeDeployHookImage( + composeResult.environmentId, + req.body, + ); + if (!hookImage.ok) { + res.status(400).json({ message: hookImage.message }); return; } // <<< build-policy hook diff --git a/packages/server/src/services/build-policy/webhook.ts b/packages/server/src/services/build-policy/webhook.ts index f9a5bd0fab..8b9f01a4bc 100644 --- a/packages/server/src/services/build-policy/webhook.ts +++ b/packages/server/src/services/build-policy/webhook.ts @@ -217,4 +217,31 @@ export const resolveDeployHookImage = async ( } }; +/** + * A compose unit cannot deploy a supplied image by digest (see README.md + * § Known gap), so an enforcing organization is told so with a 400 rather than + * having the body silently ignored. + * + * While the policy is off this returns `ok` and the body is ignored, exactly as + * upstream ignores it. Turning a request upstream accepts into a 400 the day + * this merges is the same mistake as gating a deploy on a derived watch path + * nobody configured. + */ +export const rejectComposeDeployHookImage = async ( + environmentId: string, + body: unknown, +): Promise<{ ok: true } | { ok: false; message: string }> => { + if (!deployHookBodyHasImage(body)) return { ok: true }; + if (!(await isBuildPolicyEnforcedAnywhere())) return { ok: true }; + const organizationId = await findOrganizationId(environmentId); + if (!organizationId) return { ok: true }; + const settings = await findBuildPolicySettings(organizationId); + if (!settings?.enforceRemoteBuilds) return { ok: true }; + return { + ok: false, + message: + "Deploying a supplied image by digest is not supported for compose units.", + }; +}; + export const findUnitOrganizationId = findOrganizationId; From cf6cd236e2e0479faaecbfe72da572caf2d98c39 Mon Sep 17 00:00:00 2001 From: AminDhouib Date: Thu, 10 Sep 2026 15:19:12 -0400 Subject: [PATCH 06/18] fix(build-policy): never crash a deploy on an unloaded environment relation `toBuildPolicyUnit` and `prepareBuildPolicyDeploy` reached straight through `application.environment.project` for the organization id. `findApplicationById` always loads that relation, so this never fired in production, but it made the fork a new way for somebody else's deploy path to throw a TypeError: any caller holding a leaner row crashed inside a hook that is supposed to be a no-op while the policy is off. Both now read it defensively and stand aside when it is missing, planning as `no_organization` and deploying the application unchanged, with a warning. An enforcement that cannot resolve an organization has nothing to enforce against; refusing the deploy there would turn an unloaded relation into an outage. `prepareBuildPolicyDeploy` also reads the organization after its early return rather than before, so an unenforced deploy with no required checks does not touch it at all. --- .../build-policy/plan-failure.test.ts | 40 +++++++++++++++++- .../server/src/services/build-policy/apply.ts | 42 ++++++++++++++++--- 2 files changed, 75 insertions(+), 7 deletions(-) diff --git a/apps/dokploy/__test__/build-policy/plan-failure.test.ts b/apps/dokploy/__test__/build-policy/plan-failure.test.ts index cdb8512611..c65a88ee4d 100644 --- a/apps/dokploy/__test__/build-policy/plan-failure.test.ts +++ b/apps/dokploy/__test__/build-policy/plan-failure.test.ts @@ -45,7 +45,12 @@ vi.mock("@dokploy/server/utils/process/execAsync", () => ({ ExecError: class ExecError extends Error {}, })); -import { reportBuildPolicyPlanFailure } from "@dokploy/server/services/build-policy/apply"; +import { + planApplicationBuild, + prepareBuildPolicyDeploy, + reportBuildPolicyPlanFailure, + toBuildPolicyUnit, +} from "@dokploy/server/services/build-policy/apply"; import { BuildPolicyError } from "@dokploy/server/services/build-policy/errors"; const APPLICATION = (serverId: string | null = "deploy-server-1") => ({ @@ -156,3 +161,36 @@ describe("reportBuildPolicyPlanFailure", () => { expect(mocks.sendBuildErrorNotifications).toHaveBeenCalledTimes(1); }); }); + +/** + * The fork reaches through `application.environment.project` for the + * organization. `findApplicationById` always loads that relation, but a caller + * that loads a leaner row must not get a crashed deploy out of it: the policy + * has nothing to enforce against, so it stands aside and says so. + */ +describe("an application row with no environment loaded", () => { + const LEAN = { + applicationId: "app-1", + appName: "sendly-web", + name: "Sendly Web", + sourceType: "github", + requiredChecks: ["build"], + }; + + it("plans unenforced instead of throwing", async () => { + const plan = await planApplicationBuild(toBuildPolicyUnit(LEAN)); + expect(plan.enforced).toBe(false); + expect(plan.reason).toBe("no_organization"); + }); + + it("deploys the application unchanged, even with required checks configured", async () => { + const deployTarget = await prepareBuildPolicyDeploy({ + application: LEAN, + plan: await planApplicationBuild(toBuildPolicyUnit(LEAN)), + deployment: { deploymentId: "deployment-1", logPath: "/tmp/d.log" }, + serverId: null, + }); + expect(deployTarget).toBe(LEAN); + expect(mocks.execAsyncRemote).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/server/src/services/build-policy/apply.ts b/packages/server/src/services/build-policy/apply.ts index 8b4f3f0274..2772ca1aa9 100644 --- a/packages/server/src/services/build-policy/apply.ts +++ b/packages/server/src/services/build-policy/apply.ts @@ -63,7 +63,12 @@ interface PlanUnit { unitId: string; unitName: string; appName: string; - organizationId: string; + /** + * Null when the loaded row carried no nested environment. The fork must not + * be the reason a deploy throws, so that case plans as unenforced rather + * than dereferencing its way to a crash. See `planApplicationBuild`. + */ + organizationId: string | null; sourceType: string; customGitUrl?: string | null; buildServerId?: string | null; @@ -105,12 +110,15 @@ export const toBuildPolicyUnit = (application: { customGitUrl?: string | null; buildServerId?: string | null; buildRegistryId?: string | null; - environment: { project: { organizationId: string } }; + environment?: { project?: { organizationId?: string | null } | null } | null; }): PlanUnit => ({ unitId: application.applicationId, unitName: application.name, appName: application.appName, - organizationId: application.environment.project.organizationId, + // `findApplicationById` always nests the environment, so this is a belt on + // top of braces: a deploy must never fail because the fork reached through + // a field a caller did not load. + organizationId: application.environment?.project?.organizationId ?? null, sourceType: application.sourceType, customGitUrl: application.customGitUrl, buildServerId: application.buildServerId, @@ -193,6 +201,15 @@ export const reportBuildPolicyPlanFailure = async ({ export const planApplicationBuild = async ( unit: PlanUnit, ): Promise => { + if (!unit.organizationId) { + // No organization means no settings row to read, which is the same + // answer as the policy being off. Warn rather than fail: refusing the + // deploy here would turn an unloaded relation into an outage. + console.warn( + `[build-policy] no organization on unit ${unit.unitId}; leaving the build alone`, + ); + return LOCAL_PLAN("no_organization", null); + } const { decision, settings } = await resolveBuildPolicy({ unitType: "application", unitId: unit.unitId, @@ -421,7 +438,9 @@ export const prepareBuildPolicyDeploy = async < githubId?: string | null; requiredChecks?: string[] | null; buildRegistry?: unknown; - environment: { project: { organizationId: string } }; + environment?: { + project?: { organizationId?: string | null } | null; + } | null; }, >({ application, @@ -434,15 +453,26 @@ export const prepareBuildPolicyDeploy = async < deployment: { deploymentId: string; logPath: string }; serverId: string | null; }): Promise => { - const organizationId = application.environment.project.organizationId; const requiredChecks = (application.requiredChecks ?? []).filter( (name): name is string => typeof name === "string" && name.length > 0, ); // Nothing to do. Return before any query or remote exec, so a deploy with - // the policy off costs exactly what it costs on upstream. + // the policy off costs exactly what it costs on upstream. The organization + // is read after this, so an unenforced deploy does not even touch it. if (!plan.enforced && requiredChecks.length === 0) return application; + const organizationId = application.environment?.project?.organizationId; + if (!organizationId) { + // Same reasoning as `planApplicationBuild`: without an organization there + // is nothing to enforce against, and the fork must not be the reason a + // deploy throws. + console.warn( + `[build-policy] no organization on application ${application.applicationId}; deploying unchanged`, + ); + return application; + } + const published = plan.enforced ? await requirePublishedImage({ plan, From fd406b3cadc8b6def57339caa2e70cc02701400a Mon Sep 17 00:00:00 2001 From: AminDhouib Date: Thu, 10 Sep 2026 15:19:20 -0400 Subject: [PATCH 07/18] feat(build-policy): enforce the policy on PR preview deploys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding 8. `deployPreviewApplication` and `rebuildPreviewApplication` had no hook 1/4, so a PR preview kept building on the deploy host — the exact machine this track exists to unload, and previews are a large share of its build load. Spec 5.2.1 covers any GitHub App sourced unit, and a preview is one. Both preview paths now carry the same four hooks as the plain deploy: forced build server, tag and push by sha, digest read-back, deploy by digest. The image is published under the preview's own appName, so a preview never overwrites the production tag for the same repository. Hook 1/4 is split in the preview paths. The plan has to be computed before `createDeploymentPreview`, because the deployment log file is created on whichever host is going to build, and `createDeploymentPreview` was picking `application.buildServerId || serverId` — the wrong host once the policy relocates the build. A refused plan is rethrown from inside the try, so the preview status, the deployment log and the PR comment all carry the reason. Previews stay ungated in the webhook: no coalescing, no derived watch paths, no [skip deploy]. A preview is created by opening a PR, and a reviewer waiting on one should not have it filtered away by a path rule. --- packages/server/src/services/application.ts | 115 +++++++++++++++++--- packages/server/src/services/deployment.ts | 4 + 2 files changed, 103 insertions(+), 16 deletions(-) diff --git a/packages/server/src/services/application.ts b/packages/server/src/services/application.ts index a4bf054d00..a77d6f53e2 100644 --- a/packages/server/src/services/application.ts +++ b/packages/server/src/services/application.ts @@ -617,15 +617,35 @@ export const deployPreviewApplication = async ({ previewDeploymentId: string; }) => { const application = await findApplicationById(applicationId); - - const deployment = await createDeploymentPreview({ - title: titleLog, - description: descriptionLog, - previewDeploymentId: previewDeploymentId, - }); - + // >>> build-policy hook 1/4 (preview): a PR preview is GitHub App sourced + // like any other deploy, so spec 5.2.1 forces it onto the build server too. + // The plan needs the preview's own appName, so the preview row is read + // before the deployment record is created rather than after; the read is + // idempotent and `createDeploymentPreview` reads it again itself. + // A refused plan is rethrown inside the try below, so the preview status, + // the log and the PR comment all report it. See build-policy/README.md const previewDeployment = await findPreviewDeploymentById(previewDeploymentId); + let buildPolicy: BuildPolicyPlan | null = null; + let buildPolicyError: unknown = null; + try { + buildPolicy = await planApplicationBuild({ + ...toBuildPolicyUnit(application), + appName: previewDeployment.appName, + }); + } catch (error) { + buildPolicyError = error; + } + + const deployment = await createDeploymentPreview( + { + title: titleLog, + description: descriptionLog, + previewDeploymentId: previewDeploymentId, + }, + { buildServerId: buildPolicy?.buildServerId }, + ); + // <<< build-policy hook 1/4 (preview) await updatePreviewDeployment(previewDeploymentId, { createdAt: new Date().toISOString(), @@ -641,6 +661,10 @@ export const deployPreviewApplication = async ({ try { await writePreviewComment("running"); + // build-policy hook 1/4 (preview), continued: refusing here rather than + // above means the preview status, the log and the PR comment all say why. + if (!buildPolicy) throw buildPolicyError; + application.appName = previewDeployment.appName; application.env = resolvePreviewTemplateVariables( `${application.previewEnv}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`, @@ -664,7 +688,10 @@ export const deployPreviewApplication = async ({ application.rollbackRegistry = null; application.registry = null; - const buildServerId = application.buildServerId || application.serverId; + const buildServerId = + buildPolicy.buildServerId || + application.buildServerId || + application.serverId; const applicationEntity = { ...application, serverId: buildServerId, @@ -696,13 +723,32 @@ export const deployPreviewApplication = async ({ } command += await getBuildCommand(application); + // >>> build-policy hook 2/4 (preview): tag and push the preview image by + // sha. Empty when not enforcing. + command += await getBuildPolicyPushCommand(buildPolicy, { + appName: previewDeployment.appName, + serverId: buildServerId, + }); + // <<< build-policy hook 2/4 (preview) + const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`; if (buildServerId) { await execAsyncRemote(buildServerId, commandWithLog); } else { await execAsync(commandWithLog); } - await mechanizeDockerContainer(application); + // >>> build-policy hook 3/4 (preview): pin to the digest just published. + // Identity when not enforcing. + const deployTarget = await prepareBuildPolicyDeploy({ + application, + plan: buildPolicy, + deployment, + serverId: buildServerId, + }); + // <<< build-policy hook 3/4 (preview) + // build-policy hook 4/4 (preview): `deployTarget` is `application` plus + // the pinned digest when a remote build was enforced. + await mechanizeDockerContainer(deployTarget); await writePreviewComment("success"); await updateDeploymentStatus(deployment.deploymentId, "done"); @@ -760,11 +806,27 @@ export const rebuildPreviewApplication = async ({ const previewDeployment = await findPreviewDeploymentById(previewDeploymentId); - const deployment = await createDeploymentPreview({ - title: titleLog, - description: descriptionLog, - previewDeploymentId: previewDeploymentId, - }); + // >>> build-policy hook 1/4 (preview rebuild). See build-policy/README.md + let buildPolicy: BuildPolicyPlan | null = null; + let buildPolicyError: unknown = null; + try { + buildPolicy = await planApplicationBuild({ + ...toBuildPolicyUnit(application), + appName: previewDeployment.appName, + }); + } catch (error) { + buildPolicyError = error; + } + + const deployment = await createDeploymentPreview( + { + title: titleLog, + description: descriptionLog, + previewDeploymentId: previewDeploymentId, + }, + { buildServerId: buildPolicy?.buildServerId }, + ); + // <<< build-policy hook 1/4 (preview rebuild) const previewDomain = getDomainHost(previewDeployment?.domain as Domain); const writePreviewComment = buildPreviewCommentWriter({ @@ -777,6 +839,9 @@ export const rebuildPreviewApplication = async ({ try { await writePreviewComment("running"); + // build-policy hook 1/4 (preview rebuild), continued. + if (!buildPolicy) throw buildPolicyError; + // Set application properties for preview deployment application.appName = previewDeployment.appName; application.env = resolvePreviewTemplateVariables( @@ -801,7 +866,10 @@ export const rebuildPreviewApplication = async ({ application.rollbackRegistry = null; application.registry = null; - const buildServerId = application.buildServerId || application.serverId; + const buildServerId = + buildPolicy.buildServerId || + application.buildServerId || + application.serverId; const applicationEntity = { ...application, serverId: buildServerId, @@ -839,13 +907,28 @@ export const rebuildPreviewApplication = async ({ }); } command += await getBuildCommand(application); + // >>> build-policy hook 2/4 (preview rebuild) + command += await getBuildPolicyPushCommand(buildPolicy, { + appName: previewDeployment.appName, + serverId: buildServerId, + }); + // <<< build-policy hook 2/4 (preview rebuild) const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`; if (buildServerId) { await execAsyncRemote(buildServerId, commandWithLog); } else { await execAsync(commandWithLog); } - await mechanizeDockerContainer(application); + // >>> build-policy hook 3/4 (preview rebuild) + const deployTarget = await prepareBuildPolicyDeploy({ + application, + plan: buildPolicy, + deployment, + serverId: buildServerId, + }); + // <<< build-policy hook 3/4 (preview rebuild) + // build-policy hook 4/4 (preview rebuild) + await mechanizeDockerContainer(deployTarget); await writePreviewComment("success"); await updateDeploymentStatus(deployment.deploymentId, "done"); diff --git a/packages/server/src/services/deployment.ts b/packages/server/src/services/deployment.ts index 09ae58ecb0..dfe0a8daa1 100644 --- a/packages/server/src/services/deployment.ts +++ b/packages/server/src/services/deployment.ts @@ -246,11 +246,15 @@ export const createDeploymentPreview = async ( z.infer, "deploymentId" | "createdAt" | "status" | "logPath" >, + // build-policy hook: the build server the policy forced, when it forced one. + // The log file has to be created on whichever host is going to build. + options?: { buildServerId?: string | null }, ) => { const previewDeployment = await findPreviewDeploymentById( deployment.previewDeploymentId, ); const buildServerId = + options?.buildServerId || previewDeployment?.application?.buildServerId || previewDeployment?.application?.serverId || previewDeployment?.compose?.serverId; From b2157f9e77de65d80d61e354545afb64cd9c05ea Mon Sep 17 00:00:00 2001 From: AminDhouib Date: Thu, 10 Sep 2026 15:19:31 -0400 Subject: [PATCH 08/18] feat(build-policy): roll back to a stored image digest Review finding 7. Every enforced deploy already wrote `imageTag` and `imageDigest` onto its deployment row and nothing ever read them back, so spec 5.2.4's "rollback redeploys a stored digest with no build" was absent. `buildPolicy.rollbackToDigest` reads those two columns off any past deployment and puts that exact image back with a pull and a service update. It needs no `rollbackRegistry` and no `rollbackActive`, so a unit that has enforcement on but neither of those now has a rollback where before it had none. Upstream's rollback is untouched and unrelated: it replays a snapshot of `:latest` pushed to a dedicated registry, and it keeps working under the policy because the snapshot runs as part of the build. The digest path deliberately does not re-run required checks. The commit being restored already shipped, and a rollback is the one moment a team cannot afford to wait on CI; `deployPinnedApplicationImage` takes a flag for that rather than growing a second copy of itself. A deployment row with no stored digest is refused with DIGEST_NOT_PUBLISHED rather than being quietly turned into a build. The deployment id comes from input, so the router proves the application it belongs to is the active organization's before anything deploys. The MCP scope snapshot gains one line, `buildPolicy-rollbackToDigest` at dokploy:admin, which is the intended permission for a mutation that redeploys. --- .../deploy-path.integration.test.ts | 94 +++++++++- .../build-policy/rollback-by-digest.test.ts | 164 ++++++++++++++++++ .../scopes-snapshot.test.ts.snap | 1 + .../server/api/routers/build-policy.ts | 32 ++++ packages/server/src/db/schema/build-policy.ts | 4 + .../server/src/services/build-policy/index.ts | 1 + .../services/build-policy/pinned-deploy.ts | 49 ++++-- .../src/services/build-policy/rollback.ts | 118 +++++++++++++ 8 files changed, 443 insertions(+), 20 deletions(-) create mode 100644 apps/dokploy/__test__/build-policy/rollback-by-digest.test.ts create mode 100644 packages/server/src/services/build-policy/rollback.ts diff --git a/apps/dokploy/__test__/build-policy/deploy-path.integration.test.ts b/apps/dokploy/__test__/build-policy/deploy-path.integration.test.ts index 57fb45cf3f..b48984a80b 100644 --- a/apps/dokploy/__test__/build-policy/deploy-path.integration.test.ts +++ b/apps/dokploy/__test__/build-policy/deploy-path.integration.test.ts @@ -24,6 +24,21 @@ const mocks = vi.hoisted(() => ({ environmentsFindFirst: vi.fn(), applicationsFindFirst: vi.fn(), buildPolicySettingsFindFirst: vi.fn(), + deploymentsFindFirst: vi.fn(), + findPreviewDeploymentById: vi.fn(), + updatePreviewDeployment: vi.fn(), + createDeploymentPreview: vi.fn(), +})); + +vi.mock("@dokploy/server/services/preview-deployment", () => ({ + findPreviewDeploymentById: mocks.findPreviewDeploymentById, + updatePreviewDeployment: mocks.updatePreviewDeployment, +})); + +vi.mock("@dokploy/server/services/preview-comment", () => ({ + ensurePreviewComment: vi.fn().mockResolvedValue({ created: false }), + getPreviewCommentContext: vi.fn(() => null), + updatePreviewComment: vi.fn(), })); vi.mock("@dokploy/server/db", () => { @@ -55,6 +70,7 @@ vi.mock("@dokploy/server/db", () => { buildPolicySettings: { findFirst: mocks.buildPolicySettingsFindFirst, }, + deployments: { findFirst: mocks.deploymentsFindFirst }, }, }, }; @@ -97,6 +113,7 @@ vi.mock("@dokploy/server/services/registry", () => ({ })); vi.mock("@dokploy/server/services/deployment", () => ({ + createDeploymentPreview: mocks.createDeploymentPreview, createDeployment: vi.fn(), updateDeployment: vi.fn(), updateDeploymentStatus: vi.fn(), @@ -174,8 +191,12 @@ import { } from "@dokploy/server/services/application"; import { DIGEST_MARKER } from "@dokploy/server/services/build-policy/image"; import { deployPinnedApplicationImage } from "@dokploy/server/services/build-policy/pinned-deploy"; +import { rollbackToDeploymentDigest } from "@dokploy/server/services/build-policy/rollback"; import { clearBuildPolicyEnforcementCache } from "@dokploy/server/services/build-policy/settings"; -import { buildPolicyDeployGate } from "@dokploy/server/services/build-policy/webhook"; +import { + buildPolicyDeployGate, + rejectComposeDeployHookImage, +} from "@dokploy/server/services/build-policy/webhook"; import * as deploymentService from "@dokploy/server/services/deployment"; import * as registryService from "@dokploy/server/services/registry"; import * as builders from "@dokploy/server/utils/builders"; @@ -322,6 +343,12 @@ const primeMocks = (app: Record = APPLICATION()) => { environmentId: "env-1", project: { organizationId: "org-1" }, }); + mocks.deploymentsFindFirst.mockResolvedValue({ + deploymentId: "deployment-9", + applicationId: "app-1", + imageTag: `${REPOSITORY}:${SHA}`, + imageDigest: DIGEST, + }); // The cheap "does anybody enforce at all" probe the gate makes first. mocks.buildPolicySettingsFindFirst.mockResolvedValue({ buildPolicySettingsId: "bps-1", @@ -829,6 +856,54 @@ describe("required checks", () => { }); }); +describe("rollback to a stored digest", () => { + it("redeploys the digest a past deployment stored, with no build", async () => { + await rollbackToDeploymentDigest({ + deploymentId: "deployment-9", + organizationId: "org-1", + }); + expect(builders.getBuildCommand).not.toHaveBeenCalled(); + expect(gitProvider.cloneGitRepository).not.toHaveBeenCalled(); + expect(deployedApplication()?.buildPolicyImage).toBe( + `${REPOSITORY}@${DIGEST}`, + ); + }); + + it("does not wait on required checks for an image that already shipped", async () => { + primeMocks(APPLICATION({ requiredChecks: ["build"] })); + mocks.listCheckRuns.mockResolvedValue({ + data: { + check_runs: [ + { name: "build", status: "completed", conclusion: "failure" }, + ], + }, + }); + await expect( + rollbackToDeploymentDigest({ + deploymentId: "deployment-9", + organizationId: "org-1", + }), + ).resolves.toBe(true); + expect(mocks.listCheckRuns).not.toHaveBeenCalled(); + }); + + it("refuses a deployment that stored no digest, and deploys nothing", async () => { + mocks.deploymentsFindFirst.mockResolvedValue({ + deploymentId: "deployment-9", + applicationId: "app-1", + imageTag: null, + imageDigest: null, + }); + await expect( + rollbackToDeploymentDigest({ + deploymentId: "deployment-9", + organizationId: "org-1", + }), + ).rejects.toMatchObject({ code: "DIGEST_NOT_PUBLISHED" }); + expect(builders.mechanizeDockerContainer).not.toHaveBeenCalled(); + }); +}); + describe("deploy-hook body with an image", () => { it("deploys the supplied image by digest and never builds", async () => { await deployPinnedApplicationImage({ @@ -881,6 +956,23 @@ describe("deploy-hook body with an image", () => { ).rejects.toMatchObject({ code: "REQUIRED_CHECKS_FAILED" }); expect(builders.mechanizeDockerContainer).not.toHaveBeenCalled(); }); + + it("tells a compose unit the capability does not exist, rather than ignoring the body", async () => { + // The compose build is not relocatable, so there is no digest to deploy. + // An enforcing organization gets a 400 body back; see README § Known gap. + await expect( + rejectComposeDeployHookImage("env-1", { + image: `${REPOSITORY}`, + digest: DIGEST, + }), + ).resolves.toMatchObject({ ok: false }); + }); + + it("leaves a compose deploy hook with no image alone", async () => { + await expect( + rejectComposeDeployHookImage("env-1", { branch: "main" }), + ).resolves.toEqual({ ok: true }); + }); }); describe("enqueue-time gate", () => { diff --git a/apps/dokploy/__test__/build-policy/rollback-by-digest.test.ts b/apps/dokploy/__test__/build-policy/rollback-by-digest.test.ts new file mode 100644 index 0000000000..d0ca9425cb --- /dev/null +++ b/apps/dokploy/__test__/build-policy/rollback-by-digest.test.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Finding 7 of the PR #209 review: `deployment.imageTag` and + * `deployment.imageDigest` were written on every enforced deploy and read by + * nothing, so spec 5.2.4's "rollback redeploys a stored digest with no build" + * did not exist. Upstream's rollback replays a snapshot pushed to a dedicated + * rollback registry and only covers units that have one. + */ + +const mocks = vi.hoisted(() => ({ + deploymentsFindFirst: vi.fn(), + deployPinnedApplicationImage: vi.fn(), + recordBuildPolicyAudit: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { query: { deployments: { findFirst: mocks.deploymentsFindFirst } } }, +})); + +vi.mock("@dokploy/server/services/build-policy/pinned-deploy", () => ({ + deployPinnedApplicationImage: mocks.deployPinnedApplicationImage, + toPinnedImageJob: vi.fn(), +})); + +vi.mock("@dokploy/server/services/build-policy/audit", () => ({ + recordBuildPolicyAudit: mocks.recordBuildPolicyAudit, + findPendingBreakGlass: vi.fn(), + consumeBreakGlass: vi.fn(), + grantBreakGlass: vi.fn(), + listBuildPolicyAudit: vi.fn(), +})); + +import { + digestRefForRollback, + rollbackToDeploymentDigest, +} from "@dokploy/server/services/build-policy/rollback"; + +const REPOSITORY = "ghcr.io/devinosolutions/sendly-web"; +const DIGEST = `sha256:${"b".repeat(64)}`; + +const DEPLOYMENT = (overrides: Record = {}) => ({ + deploymentId: "deployment-7", + applicationId: "app-1", + imageTag: `${REPOSITORY}:abc1234`, + imageDigest: DIGEST, + ...overrides, +}); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.deploymentsFindFirst.mockResolvedValue(DEPLOYMENT()); + mocks.deployPinnedApplicationImage.mockResolvedValue(true); + mocks.recordBuildPolicyAudit.mockResolvedValue(null); +}); + +describe("digestRefForRollback", () => { + it("pins the stored tag to the stored digest", () => { + expect(digestRefForRollback(DEPLOYMENT() as never)).toEqual({ + ref: `${REPOSITORY}@${DIGEST}`, + tag: `${REPOSITORY}:abc1234`, + digest: DIGEST, + }); + }); + + it("refuses a deployment that stored no digest", () => { + expect(() => + digestRefForRollback(DEPLOYMENT({ imageDigest: null }) as never), + ).toThrow(/digest/i); + }); + + it("refuses a malformed stored digest", () => { + expect(() => + digestRefForRollback(DEPLOYMENT({ imageDigest: "sha256:nope" }) as never), + ).toThrow(/digest/i); + }); + + it("refuses a digest with no tag to say which repository holds it", () => { + expect(() => + digestRefForRollback(DEPLOYMENT({ imageTag: null }) as never), + ).toThrow(/tag|repository/i); + }); + + it("refuses a stored tag carrying shell metacharacters", () => { + expect(() => + digestRefForRollback( + DEPLOYMENT({ imageTag: `${REPOSITORY}:a;rm -rf /` }) as never, + ), + ).toThrow(); + }); +}); + +describe("rollbackToDeploymentDigest", () => { + it("redeploys the stored image with no build", async () => { + await rollbackToDeploymentDigest({ + deploymentId: "deployment-7", + organizationId: "org-1", + }); + expect(mocks.deployPinnedApplicationImage).toHaveBeenCalledTimes(1); + expect(mocks.deployPinnedApplicationImage.mock.calls[0]?.[0]).toMatchObject( + { + applicationId: "app-1", + pinnedImage: { + ref: `${REPOSITORY}@${DIGEST}`, + digest: DIGEST, + }, + }, + ); + }); + + it("does not hold the rollback behind required checks", async () => { + await rollbackToDeploymentDigest({ + deploymentId: "deployment-7", + organizationId: "org-1", + }); + expect( + mocks.deployPinnedApplicationImage.mock.calls[0]?.[0]?.skipRequiredChecks, + ).toBe(true); + }); + + it("audits the rollback against the deployment it restored", async () => { + await rollbackToDeploymentDigest({ + deploymentId: "deployment-7", + organizationId: "org-1", + }); + expect(mocks.recordBuildPolicyAudit).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: "org-1", + action: "deploy_by_digest", + applicationId: "app-1", + metadata: expect.objectContaining({ + rolledBackToDeploymentId: "deployment-7", + imageDigest: DIGEST, + }), + }), + ); + }); + + it("deploys nothing when the deployment stored no digest", async () => { + mocks.deploymentsFindFirst.mockResolvedValue( + DEPLOYMENT({ imageDigest: null }), + ); + await expect( + rollbackToDeploymentDigest({ + deploymentId: "deployment-7", + organizationId: "org-1", + }), + ).rejects.toMatchObject({ code: "DIGEST_NOT_PUBLISHED" }); + expect(mocks.deployPinnedApplicationImage).not.toHaveBeenCalled(); + }); + + it("deploys nothing for a deployment that is not an application's", async () => { + mocks.deploymentsFindFirst.mockResolvedValue( + DEPLOYMENT({ applicationId: null }), + ); + await expect( + rollbackToDeploymentDigest({ + deploymentId: "deployment-7", + organizationId: "org-1", + }), + ).rejects.toMatchObject({ code: "DIGEST_NOT_PUBLISHED" }); + expect(mocks.deployPinnedApplicationImage).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/dokploy/__test__/mcp/__snapshots__/scopes-snapshot.test.ts.snap b/apps/dokploy/__test__/mcp/__snapshots__/scopes-snapshot.test.ts.snap index e4ee5c79cc..d977ed0416 100644 --- a/apps/dokploy/__test__/mcp/__snapshots__/scopes-snapshot.test.ts.snap +++ b/apps/dokploy/__test__/mcp/__snapshots__/scopes-snapshot.test.ts.snap @@ -91,6 +91,7 @@ exports[`MCP tool scope table > tool → scope snapshot 1`] = ` "buildPolicy-audit": "dokploy:read", "buildPolicy-exclusions": "dokploy:read", "buildPolicy-removeExclusion": "dokploy:admin", + "buildPolicy-rollbackToDigest": "dokploy:admin", "buildPolicy-settings": "dokploy:read", "buildPolicy-updateSettings": "dokploy:admin", "certificates-all": "dokploy:admin", diff --git a/apps/dokploy/server/api/routers/build-policy.ts b/apps/dokploy/server/api/routers/build-policy.ts index 8756f5e1b3..b12ec93b3f 100644 --- a/apps/dokploy/server/api/routers/build-policy.ts +++ b/apps/dokploy/server/api/routers/build-policy.ts @@ -3,12 +3,14 @@ import { assertUnitInOrganization, findBuildPolicySettings, findRegistryById, + findRollbackTarget, getAccessibleServerIds, grantBreakGlass, listBuildPolicyAudit, listBuildPolicyExclusions, recordBuildPolicyAudit, removeBuildPolicyExclusion, + rollbackToDeploymentDigest, upsertBuildPolicySettings, } from "@dokploy/server"; import { @@ -16,6 +18,7 @@ import { apiGrantBuildPolicyBreakGlass, apiListBuildPolicyAudit, apiRemoveBuildPolicyExclusion, + apiRollbackToBuildPolicyDigest, apiUpdateBuildPolicySettings, } from "@dokploy/server/db/schema"; import { TRPCError } from "@trpc/server"; @@ -180,6 +183,35 @@ export const buildPolicyRouter = createTRPCRouter({ return { success: true }; }), + /** + * Rollback by stored digest (spec 5.2.4): redeploy the image a past + * deployment published, with no build. Separate from upstream's rollback, + * which replays a snapshot pushed to a dedicated rollback registry. + */ + rollbackToDigest: adminProcedure + .input(apiRollbackToBuildPolicyDigest) + .mutation(async ({ ctx, input }) => { + const organizationId = ctx.session.activeOrganizationId; + const target = await findRollbackTarget(input.deploymentId); + // The deployment id comes from input, so prove the application it + // belongs to is this organization's before deploying anything. + await assertUnitInOrganization({ + organizationId, + applicationId: target.applicationId, + }); + const result = await rollbackToDeploymentDigest({ + deploymentId: input.deploymentId, + organizationId, + }); + await audit(ctx, { + action: "restore", + resourceType: "deployment", + resourceId: input.deploymentId, + resourceName: "build-policy-rollback", + }); + return result; + }), + /** Admin only: rows carry registry ids, build server ids and break-glass reasons. */ audit: adminProcedure .input(apiListBuildPolicyAudit) diff --git a/packages/server/src/db/schema/build-policy.ts b/packages/server/src/db/schema/build-policy.ts index 2fe3e80c9d..a6462de28c 100644 --- a/packages/server/src/db/schema/build-policy.ts +++ b/packages/server/src/db/schema/build-policy.ts @@ -261,3 +261,7 @@ export const apiListBuildPolicyAudit = z.object({ limit: z.number().int().min(1).max(200).default(50), offset: z.number().int().min(0).default(0), }); + +export const apiRollbackToBuildPolicyDigest = z.object({ + deploymentId: z.string().min(1), +}); diff --git a/packages/server/src/services/build-policy/index.ts b/packages/server/src/services/build-policy/index.ts index 30d99c2acf..bf444bc279 100644 --- a/packages/server/src/services/build-policy/index.ts +++ b/packages/server/src/services/build-policy/index.ts @@ -11,6 +11,7 @@ export * from "./pinned-deploy"; export * from "./policy"; export * from "./required-checks"; export * from "./resolve"; +export * from "./rollback"; export * from "./settings"; export * from "./skip-deploy"; export * from "./source"; diff --git a/packages/server/src/services/build-policy/pinned-deploy.ts b/packages/server/src/services/build-policy/pinned-deploy.ts index 898e259c7b..e2f526b056 100644 --- a/packages/server/src/services/build-policy/pinned-deploy.ts +++ b/packages/server/src/services/build-policy/pinned-deploy.ts @@ -36,11 +36,20 @@ export const deployPinnedApplicationImage = async ({ pinnedImage, titleLog = "Deploy hook image", descriptionLog = "", + skipRequiredChecks = false, + introLog = "Deploy hook supplied an image; skipping the build.", }: { applicationId: string; pinnedImage: { ref: string; tag: string | null; digest: string }; titleLog?: string; descriptionLog?: string; + /** + * A rollback restores an image that already shipped, so it must not be held + * behind CI: waiting on checks is the one thing a rollback cannot afford. + */ + skipRequiredChecks?: boolean; + /** First line of the deployment log, saying why there is no build. */ + introLog?: string; }) => { const application = await findApplicationById(applicationId); const organizationId = application.environment.project.organizationId; @@ -63,29 +72,31 @@ export const deployPinnedApplicationImage = async ({ try { await log( - "📦 [build-policy] Deploy hook supplied an image; skipping the build.\n" + + `📦 [build-policy] ${introLog}\n` + ` image: ${pinnedImage.tag ?? pinnedImage.ref}\n` + ` digest: ${pinnedImage.digest}\n`, ); - await waitForUnitRequiredChecks({ - unit: { - unitType: "application", - unitId: application.applicationId, - unitName: application.name, - organizationId, - requiredChecks: application.requiredChecks, - sourceType: application.sourceType, - githubId: application.githubId, - owner: application.owner, - repository: application.repository, - customGitUrl: application.customGitUrl, - }, - sha: pinnedImage.tag, - timeoutMs: requiredChecksTimeoutMs( - await findBuildPolicySettings(organizationId), - ), - }); + if (!skipRequiredChecks) { + await waitForUnitRequiredChecks({ + unit: { + unitType: "application", + unitId: application.applicationId, + unitName: application.name, + organizationId, + requiredChecks: application.requiredChecks, + sourceType: application.sourceType, + githubId: application.githubId, + owner: application.owner, + repository: application.repository, + customGitUrl: application.customGitUrl, + }, + sha: pinnedImage.tag, + timeoutMs: requiredChecksTimeoutMs( + await findBuildPolicySettings(organizationId), + ), + }); + } await updateDeployment(deployment.deploymentId, { imageTag: pinnedImage.tag, diff --git a/packages/server/src/services/build-policy/rollback.ts b/packages/server/src/services/build-policy/rollback.ts new file mode 100644 index 0000000000..662e0e319c --- /dev/null +++ b/packages/server/src/services/build-policy/rollback.ts @@ -0,0 +1,118 @@ +import { db } from "@dokploy/server/db"; +import { deployments } from "@dokploy/server/db/schema"; +import { eq } from "drizzle-orm"; +import { recordBuildPolicyAudit } from "./audit"; +import { BuildPolicyError } from "./errors"; +import { + assertSafeImageReference, + buildDigestRef, + isValidDigest, +} from "./image"; +import { deployPinnedApplicationImage } from "./pinned-deploy"; + +/** + * Rollback by stored digest (spec 5.2.4). + * + * Every enforced deploy writes `imageTag` and `imageDigest` onto its deployment + * row, so any past deployment can be put back with a pull and a service update + * and no build at all. + * + * This is deliberately separate from upstream's rollback + * (`services/rollbacks.ts`), which replays a snapshot of `:latest` + * pushed to a dedicated rollback registry and only exists for units that have + * `rollbackActive`. A unit with enforcement on has a usable digest on every + * deployment row whether or not it has a rollback registry, and this path uses + * that. Neither path touches the other. + * + * Required checks are not re-run: the commit being restored already shipped, + * and a rollback is the one moment a team cannot afford to wait on CI. + */ +export interface RollbackTarget { + deploymentId: string; + applicationId: string; + imageTag: string | null; + imageDigest: string | null; +} + +/** The digest reference a deployment row can be restored to, or an error. */ +export const digestRefForRollback = ( + deployment: RollbackTarget, +): { ref: string; tag: string | null; digest: string } => { + if (!deployment.imageDigest || !isValidDigest(deployment.imageDigest)) { + throw new BuildPolicyError( + "DIGEST_NOT_PUBLISHED", + "That deployment stored no image digest, so there is nothing to roll " + + "back to. Only deploys made while the build policy was enforcing " + + "store one.", + { deploymentId: deployment.deploymentId }, + ); + } + if (!deployment.imageTag) { + throw new BuildPolicyError( + "DIGEST_NOT_PUBLISHED", + "That deployment stored a digest but no image tag, so the repository to " + + "pull it from is unknown.", + { deploymentId: deployment.deploymentId }, + ); + } + assertSafeImageReference(deployment.imageTag); + return { + ref: buildDigestRef(deployment.imageTag, deployment.imageDigest), + tag: deployment.imageTag, + digest: deployment.imageDigest, + }; +}; + +export const findRollbackTarget = async ( + deploymentId: string, +): Promise => { + const deployment = await db.query.deployments.findFirst({ + where: eq(deployments.deploymentId, deploymentId), + columns: { + deploymentId: true, + applicationId: true, + imageTag: true, + imageDigest: true, + }, + }); + if (!deployment?.applicationId) { + throw new BuildPolicyError( + "DIGEST_NOT_PUBLISHED", + "No application deployment with that id.", + { deploymentId }, + ); + } + return deployment as RollbackTarget; +}; + +export const rollbackToDeploymentDigest = async ({ + deploymentId, + organizationId, +}: { + deploymentId: string; + organizationId: string; +}) => { + const target = await findRollbackTarget(deploymentId); + const pinnedImage = digestRefForRollback(target); + + await recordBuildPolicyAudit({ + organizationId, + action: "deploy_by_digest", + applicationId: target.applicationId, + reason: `rollback to deployment ${deploymentId}`, + metadata: { + rolledBackToDeploymentId: deploymentId, + imageTag: pinnedImage.tag, + imageDigest: pinnedImage.digest, + }, + }); + + return deployPinnedApplicationImage({ + applicationId: target.applicationId, + pinnedImage, + titleLog: "Rollback to a stored digest", + descriptionLog: `Restoring the image deployment ${deploymentId} published.`, + skipRequiredChecks: true, + introLog: "Rolling back to a stored digest; there is no build.", + }); +}; From 9dce3d5409f749ce03ffcfcfd713150ed22915ef Mon Sep 17 00:00:00 2001 From: AminDhouib Date: Thu, 10 Sep 2026 15:19:40 -0400 Subject: [PATCH 09/18] docs(build-policy): bring the README in line with review round 1 The README was the reviewer's map of the module and it had gone stale in the one way that matters: it asserted "every code path below short-circuits to the upstream behaviour" while the enqueue gate did not, which is how finding 1 was found and how it would have been missed. Rewritten against what the code now does: - an "Off by default, and it means it" section that names each enqueue-time behaviour and the cached probe that short-circuits them, and says what an unenforced deploy costs; - the hook catalogue recounted: thirty hooks in nine files, with a per-file table, after the preview paths and the deployment-log host line were added; - new sections for rollback, preview deployments, coalescing and required checks, each saying why the choice is what it is rather than only what it is; - `ownership.ts` and `rollback.ts` added to the file table, `hook-body.ts` corrected from "against the org registries" to the unit's own repository; - the router's per-procedure access, and why audit is admin-only; - the directory question from finding 9 recorded as a decision with its cost, rather than left as a silent divergence from the spec. --- .../src/services/build-policy/README.md | 288 +++++++++++++++--- 1 file changed, 249 insertions(+), 39 deletions(-) diff --git a/packages/server/src/services/build-policy/README.md b/packages/server/src/services/build-policy/README.md index 92f5525e21..c146f95a0a 100644 --- a/packages/server/src/services/build-policy/README.md +++ b/packages/server/src/services/build-policy/README.md @@ -23,11 +23,44 @@ Design source: `docs/superpowers/specs/2026-09-10-ci-build-once-and-pool-design. | 3 | Push `:`, capture the digest, deploy by digest | `apply.ts`, `image.ts` | | 4 | Queue coalescing on enqueue | `coalesce.ts`, `webhook.ts` | | 5 | Derived default `watchPaths`, `[skip deploy]` marker | `watch-paths.ts`, `skip-deploy.ts`, `webhook.ts` | -| 6 | Per-unit `requiredChecks` gating | `required-checks.ts`, `github-checks.ts` | -| 7 | Deploy-hook body `{image, tag, digest}` | `hook-body.ts`, `pinned-deploy.ts` | - -The policy is **off by default**. With no `build_policy_settings` row for an -organization, every code path below short-circuits to the upstream behaviour. +| 6 | Per-unit `requiredChecks` gating, over check runs **and** commit statuses | `required-checks.ts`, `github-checks.ts` | +| 7 | Deploy-hook body `{image, tag, digest}`, restricted to the unit's own repository | `hook-body.ts`, `pinned-deploy.ts` | +| 8 | Rollback to a digest a past deployment stored, with no build | `rollback.ts`, `pinned-deploy.ts` | + +PR previews are enforced too: they are GitHub App sourced like any other deploy, +so they build on the build server and deploy by digest. See **Preview +deployments** below for the one place they are deliberately different. + +## Off by default, and it means it + +The policy is off until an organization has a `build_policy_settings` row with +`enforceRemoteBuilds` set. While it is off **every** path in this module +short-circuits to upstream behaviour, and that includes the ones that live at +enqueue time rather than deploy time: + +- the `[skip deploy]` marker is not honoured, because upstream does not honour it; +- derived default `watchPaths` are not applied, because a derived watch path + *stops* deploys and a team must never discover that by accident; +- queued deploys are not coalesced; +- a deploy-hook `{image, …}` body is ignored on an application and ignored on a + compose unit, exactly as upstream ignores the body. Turning a request upstream + accepts into a 400 the day this merges is the same mistake. + +`buildPolicyDeployGate` and `resolveDeployHookImage` both begin with +`isBuildPolicyEnforcedAnywhere()` (`settings.ts`), one indexed +`enforceRemoteBuilds = true` lookup cached process-locally for five seconds. On +an instance where nobody enforces, that cached boolean is the entire cost of the +fork at enqueue time: no organization lookup, no settings read, no audit write. +`upsertBuildPolicySettings` clears the cache, so turning the policy on through +the UI takes effect at once; a direct database write takes up to the TTL. + +The deploy path is the same story. `prepareBuildPolicyDeploy` (`apply.ts`) +returns the application unchanged before it reads a commit sha or a settings row +when the plan is unenforced and the unit has no required checks, so an +unenforced deploy makes no extra SSH round trip and no extra query. + +`policy-off-is-upstream.test.ts` asserts all of this directly, including the +absence of the calls. ## The decision @@ -47,38 +80,95 @@ Steps 6 and 7 are the "no silent local fallback" rule from spec 5.2.8: an enforced unit with nowhere to build fails the deploy with a named error. The manual escape is the audited break-glass, not an automatic downgrade. +**A refused plan is a visible failure.** `planApplicationBuild` runs after +`createDeployment` and its error is raised from inside the deploy's own `try`, +so a `NO_BUILD_SERVER` or `NO_REGISTRY` refusal produces a deployment row in +`error` status, an application marked `error`, the reason in the deployment log +and a build-error notification — not a throw into the void. +`plan-failure.test.ts` pins that, because an enforcement that fails silently is +worse than no enforcement. + +**And the fork never crashes a deploy.** The policy reaches through +`application.environment.project` for the organization. `findApplicationById` +always loads that relation, but a caller with a leaner row plans as +`no_organization` and deploys unchanged, with a warning, rather than throwing a +`TypeError` out of somebody else's deploy path. + ## Files | File | Contents | |---|---| | `policy.ts` | the pure decision (above) | | `source.ts` | github.com detection for `sourceType: github` and for a `git` source whose `customGitUrl` is on github.com; `owner/repo` parsing | -| `settings.ts` | organization settings read/upsert, required-checks timeout | +| `settings.ts` | organization settings read/upsert, the cached "does anybody enforce" probe, required-checks timeout | | `exclusions.ts` | exclusion list / lookup / add / remove | +| `ownership.ts` | asserts a unit id taken from tRPC input belongs to the active organization | | `audit.ts` | append-only trail; break-glass grant, lookup and consumption | | `resolve.ts` | database-backed wrapper around `policy.ts`; the only place a grant is spent | | `apply.ts` | the application deploy path: plan, remote tag/push/digest shell, digest read-back, deploy-by-digest preparation | | `image.ts` | `:` tagging, digest validation, digest-marker parsing, `repo@sha256:…` refs | -| `hook-body.ts` | validation of a deploy-hook `{image, tag, digest}` body against the org registries | +| `hook-body.ts` | validation of a deploy-hook `{image, tag, digest}` body against the unit's own repository | | `pinned-deploy.ts` | a whole deploy of a supplied image, with no build | -| `required-checks.ts` | pure check-run evaluation plus a polling wait with an injectable clock | -| `github-checks.ts` | the same wait, wired to the GitHub App installation token | -| `coalesce.ts` | drop still-waiting deploys for a unit and audit it | +| `rollback.ts` | redeploy the digest a past deployment stored (see Rollback) | +| `required-checks.ts` | pure check evaluation plus a polling wait with an injectable clock | +| `github-checks.ts` | the same wait, wired to the GitHub App installation token; merges check runs and commit statuses | +| `coalesce.ts` | drop still-waiting deploys for a unit and audit what was dropped | | `watch-paths.ts` | derive default `watchPaths` from `buildPath` / Dockerfile / compose path | | `skip-deploy.ts` | the `[skip deploy]` commit-message marker | -| `webhook.ts` | the single enqueue-time gate every deploy entry point calls | +| `webhook.ts` | the single enqueue-time gate every deploy entry point calls, plus deploy-hook body resolution | | `errors.ts` | `BuildPolicyError` with stable codes | +**On the directory.** The spec named +`packages/server/src/community/build-policy/`. The module lives under +`services/` instead, next to the `application.ts` and `deployment.ts` it hooks, +at the cost of sitting in an upstream directory. Every file in it is new — no +upstream file was renamed into it — so an upstream merge conflicts only on the +hooked files listed below, which is what the hook catalogue is for. Moving it is +a mechanical rename if that trade stops being worth it; do it before a merge, +not after. + +## The tRPC router + +`apps/dokploy/server/api/routers/build-policy.ts`. The organization is read +exclusively from `ctx.session.activeOrganizationId` and never from input, so +there is no id to swap. Every unit id, build server id and registry id that +arrives in input is checked against that organization before use +(`ownership.ts`). + +| Procedure | Access | +|---|---| +| `settings`, `exclusions` | `protectedProcedure` | +| `updateSettings`, `addExclusion`, `removeExclusion`, `allowLocalBuildOnce`, `rollbackToDigest`, `audit` | `adminProcedure` | + +`audit` is admin-only because its rows carry registry ids, build server ids and +break-glass reasons. + --- ## Hook points in upstream code -Every one is marked in the source with `build-policy hook`. Grep for that -string to find them all. There are **eleven**, in eight files. +Every one is marked in the source with `build-policy hook`. Grep for that string +to find them all. There are **thirty**, in nine files, plus five import markers, +two zod lines in the schema files, and the two UI fields below. + +| File | Hooks | +|---|---| +| `packages/server/src/services/application.ts` | 18 | +| `packages/server/src/services/deployment.ts` | 2 | +| `packages/server/src/utils/builders/index.ts` | 1 | +| `apps/dokploy/pages/api/deploy/github.ts` | 2 | +| `apps/dokploy/pages/api/deploy/[refreshToken].ts` | 2 | +| `apps/dokploy/pages/api/deploy/compose/[refreshToken].ts` | 1 | +| `apps/dokploy/server/queues/queueSetup.ts` | 2 | +| `apps/dokploy/server/queues/queue-types.ts` | 1 | +| `apps/dokploy/server/queues/deployments-queue.ts` | 1 | ### `packages/server/src/services/application.ts` -Four hooks in `deployApplication`, and the same four in `rebuildApplication`. +The same four hooks in each of four deploy paths — `deployApplication`, +`rebuildApplication`, `deployPreviewApplication`, `rebuildPreviewApplication` — +plus one line in each of the two non-preview paths that creates the deployment +log on the build host. | Hook | What it replaces / adds | |---|---| @@ -89,10 +179,22 @@ Four hooks in `deployApplication`, and the same four in `rebuildApplication`. Plus one import block, marked `Fork module`. +In the two preview paths hook 1/4 is split: the plan is computed before +`createDeploymentPreview`, because the log file has to be created on the host +that will build, and a refusal is rethrown from inside the `try`, so the preview +status, the deployment log and the PR comment all carry the reason. + **Merge note:** if upstream moves the `serverId` line or the `mechanizeDockerContainer` call, re-apply hooks 1/4 and 4/4 to the new location. Hooks 2/4 and 3/4 must stay between the build shell and the swarm update. +### `packages/server/src/services/deployment.ts` + +`createDeployment` and `createDeploymentPreview` take an optional forced +`buildServerId`. The deployment log file is created on whichever host is going to +build, which is no longer `application.buildServerId || serverId` once the policy +relocates the build. + ### `packages/server/src/utils/builders/index.ts` - `ApplicationNested` gains an optional `buildPolicyImage?: string | null`. @@ -103,12 +205,14 @@ the six build types are exactly upstream's. ### `apps/dokploy/pages/api/deploy/github.ts` -- one import of `buildPolicyDeployGate`, one of the two `cleanQueues*` helpers. +- one import of `buildPolicyDeployGate`, one of the two coalescing helpers. - in the push→applications loop and the push→composes loop, a `buildPolicyDeployGate({...}); if (!gate.deploy) continue;` block **after** upstream's own `shouldDeploy` check, so upstream's lines are untouched. -Tag pushes and pull-request previews are deliberately not gated. +Tag pushes and pull-request previews are deliberately not gated here: a preview +is created by opening a PR, and coalescing or watch-path filtering it would +silently drop the preview a reviewer is waiting for. ### `apps/dokploy/pages/api/deploy/[refreshToken].ts` @@ -118,14 +222,17 @@ Tag pushes and pull-request previews are deliberately not gated. ### `apps/dokploy/pages/api/deploy/compose/[refreshToken].ts` -- the gate. A supplied image is **rejected with a 400**, not ignored — see - Known gap. +- the gate, plus `rejectComposeDeployHookImage(...)`. A supplied image is + **rejected with a 400 while the organization enforces**, and ignored otherwise + — see Known gap. ### `apps/dokploy/server/queues/queueSetup.ts` - `cleanQueuesByApplication` and `cleanQueuesByCompose` now return the number of jobs they dropped. Both were already exported and both existing callers ignore the value, so this is additive. +- `coalesceQueuedApplicationDeploys` / `coalesceQueuedComposeDeploys`, the + coalescing siblings of those two. See **Coalescing** below. ### `apps/dokploy/server/queues/queue-types.ts` @@ -133,8 +240,18 @@ Tag pushes and pull-request previews are deliberately not gated. ### `apps/dokploy/server/queues/deployments-queue.ts` -- one `if (job.data.pinnedImage)` branch ahead of the existing - `deploy` / `redeploy` branches, calling `deployPinnedApplicationImage`. +- one `if (job.data.pinnedImage)` branch ahead of the existing `deploy` / + `redeploy` branches, calling `deployPinnedApplicationImage`. + +### UI + +- `components/dashboard/application/advanced/show-build-server.tsx` — while the + organization enforces and the unit is GitHub sourced, the build server and + build registry selects and the save button are disabled and an info block says + why. Spec 5.2.1: the field is ignored at deploy time, so it must not accept an + edit that will have no effect. +- `components/dashboard/application/advanced/show-required-checks.tsx` — the + field description says a name matches a check run **or** a commit status. ### Barrels @@ -145,8 +262,8 @@ Tag pushes and pull-request previews are deliberately not gated. ### Schema columns on upstream tables - `application.requiredChecks` (`text[]`), plus one zod line in the file's - `createSchema` overrides because drizzle-zod 0.5.1 does not infer array - columns (`watchPaths` needs the same line). + `createSchema` overrides because drizzle-zod 0.5.1 does not infer array columns + (`watchPaths` needs the same line). - `compose.requiredChecks` (`text[]`), same. - `deployment.imageTag`, `deployment.imageDigest` (`text`). @@ -158,28 +275,106 @@ Tag pushes and pull-request previews are deliberately not gated. (`IF NOT EXISTS`, `DO $$ … EXCEPTION WHEN duplicate_object`) in the fork house style, so a re-run on a partially migrated database is a no-op. -Creates `build_policy_settings`, `build_policy_exclusion`, -`build_policy_audit` and the `buildPolicyAuditAction` enum; adds the four -columns above. No data migration: an organization with no settings row has the -policy off, which is the pre-change behaviour. +Creates `build_policy_settings`, `build_policy_exclusion`, `build_policy_audit` +and the `buildPolicyAuditAction` enum; adds the four columns above. No data +migration: an organization with no settings row has the policy off, which is the +pre-change behaviour. --- ## How the digest crosses hosts -The build runs as a detached shell on the build server; its only channel back -is the deployment log file. So the appended shell echoes +The build runs as a detached shell on the build server; its only channel back is +the deployment log file. So the appended shell echoes ``` __DOKPLOY_IMAGE_DIGEST__ //: sha256:<64 hex> ``` and `readPublishedImage` greps that one line back off the build server. If the -line is absent the deploy fails with `DIGEST_NOT_PUBLISHED` rather than -deploying a mutable tag. +line is absent the deploy fails with `DIGEST_NOT_PUBLISHED` rather than deploying +a mutable tag. -The sha is resolved inside the shell with `git rev-parse HEAD` rather than -passed in, because a manual redeploy has no webhook payload to read it from. +The sha is resolved inside the shell with `git rev-parse HEAD` rather than passed +in, because a manual redeploy has no webhook payload to read it from. + +**The marker is not trusted on its name.** That same log file carries the +repository's own `docker build` output, so a `RUN echo` in a Dockerfile can put a +forged marker line in it. `readPublishedImage` therefore rejects any marker whose +tag does not start with the repository this deploy is publishing, and runs it +through `assertSafeImageReference`, before anything is pinned. `set -e` also +happens to keep the genuine marker last today, but the deploy does not rest on +that. + +--- + +## Required checks + +`requiredChecks` names are matched against **both** the Checks API +(`checks.listForRef`) and the legacy commit statuses API +(`repos.listCommitStatusesForRef`), with a status `state` mapped onto a check +conclusion, so a name published either way resolves. A token without the statuses +scope logs and falls back to check runs alone rather than failing the deploy. + +Without the statuses half, a team that typed the name of a check published as a +commit status would wait the full timeout and then fail with +`REQUIRED_CHECKS_TIMEOUT` naming a check that had in fact passed. + +--- + +## Coalescing + +Coalescing drops the unit's still-waiting **plain deploys only**. A queued PR +preview for the same application carries the same `applicationId`, and upstream's +`cleanQueuesByApplication` matches on that alone — which is right for the +explicit "clean queues" action it backs, and wrong here. Coalescing runs +automatically on every push; dropping a preview nobody asked to cancel would make +the PR's preview simply never appear. + +So `coalesceQueuedApplicationDeploys` additionally requires +`applicationType === "application"` (and the compose sibling `"compose"`), and +both report the titles of what they dropped so the audit row names it. + +--- + +## Rollback + +Two mechanisms, and they do not touch each other. + +- **Upstream**, `services/rollbacks.ts`: the build command snapshots + `:latest` into a dedicated `rollbackRegistry` when `rollbackActive`, + and a rollback redeploys that tag. Unchanged by this branch, and it still works + under the policy because the snapshot runs as part of the build, on whichever + host built. +- **This module**, `rollback.ts` (spec 5.2.4): every enforced deploy already + writes `imageTag` and `imageDigest` onto its deployment row, so + `buildPolicy.rollbackToDigest` can put any past deployment back with a pull and + a service update and no build at all. It needs no `rollbackRegistry` and no + `rollbackActive`. + +The digest path deliberately does **not** re-run required checks: the commit +being restored already shipped, and a rollback is the one moment a team cannot +afford to wait on CI. A deployment row with no stored digest is refused with +`DIGEST_NOT_PUBLISHED` rather than being silently turned into a build; rows from +before the policy was enforced have none. + +--- + +## Preview deployments + +PR previews are enforced: `deployPreviewApplication` and +`rebuildPreviewApplication` carry the same four hooks, so a preview builds on the +organization build server and deploys by digest like everything else. Unloading +the deploy host is the point of the track, and previews are a large share of its +build load. + +Two deliberate differences: + +- The preview image is tagged and pushed under the **preview's own** `appName`, + so a preview never overwrites the production tag for the same repository. +- Previews are not gated in the webhook: no coalescing, no derived watch paths, + no `[skip deploy]`. A preview is created by opening a PR, and a reviewer + waiting on it should not have it filtered away by a path rule. --- @@ -192,8 +387,8 @@ registry. That is a large change to upstream's compose path and it would break every compose unit in the fleet that has no `image:` keys today. So `decideBuildPolicy` returns `local` / `compose_build_not_relocatable` for -compose units, and the compose deploy hook rejects a supplied image with a 400. -There is a test asserting exactly that reason +compose units, and the compose deploy hook rejects a supplied image with a 400 +while the organization enforces. There is a test asserting exactly that reason (`policy-decision.test.ts` → "does not relocate a compose build, and says so explicitly"), so the gap is visible and any future change to it is deliberate. @@ -205,10 +400,25 @@ break-glass, queue coalescing, `[skip deploy]`, derived `watchPaths` and ## Tests -- `apps/dokploy/__test__/build-policy/*.test.ts` — unit tests for the pure core. -- `apps/dokploy/__test__/build-policy/deploy-path.integration.test.ts` — drives - the deploy path end to end with docker, ssh and git mocked, and is the - tripwire for upstream merges (spec §11): if an upstream merge removes a hook - point, these fail loudly rather than silently reverting the policy. +`apps/dokploy/__test__/build-policy/`: + +| File | Covers | +|---|---| +| `policy-decision.test.ts` | the pure decision, every branch | +| `source-and-markers.test.ts` | github.com detection, `[skip deploy]`, derived watch paths | +| `image-and-hook-body.test.ts` | tagging, digest parsing, deploy-hook body validation | +| `published-image.test.ts` | the digest read-back, including forged markers | +| `required-checks.test.ts` | check-run and commit-status evaluation, and the polling wait | +| `coalesce.test.ts` | that a queued preview for the same unit survives coalescing | +| `router-and-checks.test.ts` | organization ownership on every id taken from input | +| `plan-failure.test.ts` | that a refused plan still produces a deployment row and a notification | +| `policy-off-is-upstream.test.ts` | that nothing here changes behaviour while the policy is off | +| `rollback-by-digest.test.ts` | rollback to a stored digest, and the refusal when there is none | +| `deploy-path.integration.test.ts` | the real deploy path end to end, with docker, ssh and git mocked | + +The integration test is the tripwire for upstream merges (spec §11): it drives +the real `deployApplication` and `rebuildApplication` with the real `policy.ts`, +`apply.ts` and `image.ts`, so if an upstream merge removes a hook point these +fail loudly rather than silently reverting the policy. Run: `cd apps/dokploy && npx vitest run --config __test__/vitest.config.ts __test__/build-policy` From 1cb880d79842bf3b4462908ac7cccba17a1f2715 Mon Sep 17 00:00:00 2001 From: AminDhouib Date: Thu, 10 Sep 2026 18:20:59 -0400 Subject: [PATCH 10/18] fix(build-policy): gate the GitLab push webhook (review round 2, finding G) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `skip-deploy.ts` claimed the `[skip deploy]` marker was honoured on every provider route. `pages/api/deploy/gitlab.ts` had no build-policy gate at all, so on GitLab the marker was ignored and — the part that matters for a programme whose goal is cutting CI compute — GitLab-triggered deploys were never coalesced, so N pushes still produced N builds. - wire `buildPolicyDeployGate` into the Push Hook handler's applications and composes loops, the same shape and the same position (after upstream's own `shouldDeploy`) as `github.ts`. - add `gitlabHeadCommitMessage`: GitLab's job title is `Push to `, not the commit message, so the marker has to be read from the payload. Prefer the commit whose id equals `checkout_sha`, fall back to the newest commit, and pass null when the payload carries no commits. - correct the `skip-deploy.ts` comment to name the routes that actually carry the gate, and say plainly that tag pushes and previews do not. - README: hook-point table gains gitlab.ts, counts corrected 30 -> 33 hooks in ten files, and a section explaining why this route is gated. Default-off is unchanged: the gate's first statement is still the cached `isBuildPolicyEnforcedAnywhere()` check, so with no policy row this route does exactly what it did before. Tests: `__test__/build-policy/gitlab-route-gate.test.ts`, 10 new assertions driving the real handler. The 23 pre-existing tests in `__test__/deploy/gitlab.test.ts` still pass unchanged. --- .../build-policy/gitlab-route-gate.test.ts | 278 ++++++++++++++++++ apps/dokploy/pages/api/deploy/gitlab.ts | 70 ++++- .../src/services/build-policy/README.md | 22 +- .../src/services/build-policy/skip-deploy.ts | 14 +- 4 files changed, 379 insertions(+), 5 deletions(-) create mode 100644 apps/dokploy/__test__/build-policy/gitlab-route-gate.test.ts diff --git a/apps/dokploy/__test__/build-policy/gitlab-route-gate.test.ts b/apps/dokploy/__test__/build-policy/gitlab-route-gate.test.ts new file mode 100644 index 0000000000..5a0121eb7d --- /dev/null +++ b/apps/dokploy/__test__/build-policy/gitlab-route-gate.test.ts @@ -0,0 +1,278 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Round-2 review finding G. `skip-deploy.ts` claimed the marker was honoured on + * every provider route, but `pages/api/deploy/gitlab.ts` had no build-policy + * gate at all: `[skip deploy]` was ignored there and, more expensively for this + * fork's purpose, GitLab-triggered deploys were never coalesced, so N pushes + * produced N builds. + * + * These tests drive the real GitLab webhook handler with the gate mocked at its + * module boundary and assert the four properties that matter: the gate is + * consulted for applications and for compose units, its refusal stops the + * enqueue, the commit message it receives is the real commit message (not the + * "Push to " title, which no author ever writes `[skip deploy]` into), + * and the coalescing callback targets the unit being enqueued. + */ + +const mocks = vi.hoisted(() => ({ + buildPolicyDeployGate: vi.fn(), + coalesceQueuedApplicationDeploys: vi.fn().mockResolvedValue({ + removed: 0, + titles: [], + }), + coalesceQueuedComposeDeploys: vi.fn().mockResolvedValue({ + removed: 0, + titles: [], + }), +})); + +vi.mock("@dokploy/server/services/build-policy/webhook", async () => { + const actual = await vi.importActual< + typeof import("@dokploy/server/services/build-policy/webhook") + >("@dokploy/server/services/build-policy/webhook"); + return { ...actual, buildPolicyDeployGate: mocks.buildPolicyDeployGate }; +}); + +vi.mock("@dokploy/server/services/gitlab", async (importOriginal) => { + const mod = + await importOriginal(); + return { ...mod, findGitlabByWebhookSecret: vi.fn() }; +}); + +vi.mock("@/server/queues/queueSetup", () => ({ + myQueue: { add: vi.fn().mockResolvedValue(undefined) }, + coalesceQueuedApplicationDeploys: mocks.coalesceQueuedApplicationDeploys, + coalesceQueuedComposeDeploys: mocks.coalesceQueuedComposeDeploys, +})); + +import { db } from "@dokploy/server/db"; +import { findGitlabByWebhookSecret } from "@dokploy/server/services/gitlab"; +import handler from "@/pages/api/deploy/gitlab"; +import { myQueue } from "@/server/queues/queueSetup"; + +const PROVIDER = { + gitlabId: "gitlab-id-1", + gitlabUrl: "https://gitlab.example.com", + webhookSecret: "super-secret", + accessToken: "access-token", +}; + +const APP = { + applicationId: "app-id-1", + name: "My App", + appName: "my-app", + environmentId: "env-1", + sourceType: "gitlab" as const, + gitlabId: "gitlab-id-1", + gitlabPathNamespace: "mygroup/myrepo", + gitlabBranch: "main", + watchPaths: null, + buildPath: null, + dockerfile: null, + dockerContextPath: null, + serverId: null, +}; + +const COMPOSE = { + composeId: "compose-id-1", + name: "My Compose", + appName: "my-compose", + environmentId: "env-2", + sourceType: "gitlab" as const, + gitlabId: "gitlab-id-1", + gitlabPathNamespace: "mygroup/myrepo", + gitlabBranch: "main", + watchPaths: null, + composePath: "./docker-compose.yml", + serverId: null, +}; + +const pushPayload = (overrides: Record = {}) => ({ + object_kind: "push", + ref: "refs/heads/main", + checkout_sha: "abc123", + project: { id: 99, path_with_namespace: "mygroup/myrepo" }, + commits: [ + { + id: "old000", + message: "an earlier commit", + added: [], + modified: [], + removed: [], + }, + { + id: "abc123", + message: "fix: the head commit", + added: ["src/index.ts"], + modified: [], + removed: [], + }, + ], + ...overrides, +}); + +const makeReq = (body: object): NextApiRequest => + ({ + method: "POST", + headers: { + "x-gitlab-event": "Push Hook", + "x-gitlab-token": "super-secret", + }, + body, + }) as any; + +const makeRes = (): NextApiResponse => { + const res: any = {}; + res.status = vi.fn().mockReturnValue(res); + res.json = vi.fn().mockReturnValue(res); + return res as NextApiResponse; +}; + +/** The application query runs first, then the compose query. */ +const rows = (apps: unknown[], composes: unknown[]) => { + vi.mocked(db.query.applications.findMany).mockResolvedValueOnce(apps as any); + vi.mocked(db.query.applications.findMany).mockResolvedValueOnce( + composes as any, + ); +}; + +describe("GitLab push webhook — build-policy gate (finding G)", () => { + beforeEach(() => { + vi.mocked(findGitlabByWebhookSecret).mockResolvedValue(PROVIDER as any); + mocks.buildPolicyDeployGate.mockResolvedValue({ + deploy: true, + coalesced: 0, + }); + }); + afterEach(() => vi.clearAllMocks()); + + it("consults the gate before enqueueing an application deploy", async () => { + rows([APP], []); + await handler(makeReq(pushPayload()), makeRes()); + + expect(mocks.buildPolicyDeployGate).toHaveBeenCalledTimes(1); + expect(mocks.buildPolicyDeployGate).toHaveBeenCalledWith( + expect.objectContaining({ + unitType: "application", + unit: expect.objectContaining({ + unitId: "app-id-1", + unitName: "My App", + environmentId: "env-1", + }), + }), + ); + expect(myQueue.add).toHaveBeenCalledTimes(1); + }); + + it("consults the gate before enqueueing a compose deploy", async () => { + rows([], [COMPOSE]); + await handler(makeReq(pushPayload()), makeRes()); + + expect(mocks.buildPolicyDeployGate).toHaveBeenCalledWith( + expect.objectContaining({ + unitType: "compose", + unit: expect.objectContaining({ + unitId: "compose-id-1", + environmentId: "env-2", + composePath: "./docker-compose.yml", + }), + }), + ); + expect(myQueue.add).toHaveBeenCalledTimes(1); + }); + + it("does not enqueue when the gate refuses the application deploy", async () => { + mocks.buildPolicyDeployGate.mockResolvedValue({ + deploy: false, + reason: "skip_deploy_marker", + message: "Deployment skipped: the commit message contains [skip deploy]", + }); + rows([APP], []); + await handler(makeReq(pushPayload()), makeRes()); + + expect(myQueue.add).not.toHaveBeenCalled(); + }); + + it("does not enqueue when the gate refuses the compose deploy", async () => { + mocks.buildPolicyDeployGate.mockResolvedValue({ + deploy: false, + reason: "watch_paths", + message: "Deployment skipped: no changed file matched", + }); + rows([], [COMPOSE]); + await handler(makeReq(pushPayload()), makeRes()); + + expect(myQueue.add).not.toHaveBeenCalled(); + }); + + it("passes the head commit's message, not the 'Push to ' title", async () => { + rows([APP], []); + await handler(makeReq(pushPayload()), makeRes()); + + expect(mocks.buildPolicyDeployGate.mock.calls[0]?.[0].commitMessage).toBe( + "fix: the head commit", + ); + }); + + it("falls back to the newest commit when no id matches checkout_sha", async () => { + rows([APP], []); + await handler( + makeReq(pushPayload({ checkout_sha: "not-in-the-list" })), + makeRes(), + ); + + expect(mocks.buildPolicyDeployGate.mock.calls[0]?.[0].commitMessage).toBe( + "fix: the head commit", + ); + }); + + it("passes no commit message when the payload carries no commits", async () => { + rows([APP], []); + await handler(makeReq(pushPayload({ commits: [] })), makeRes()); + + expect( + mocks.buildPolicyDeployGate.mock.calls[0]?.[0].commitMessage, + ).toBeNull(); + }); + + it("passes the push's changed files so derived watch paths can be applied", async () => { + rows([APP], []); + await handler(makeReq(pushPayload()), makeRes()); + + expect(mocks.buildPolicyDeployGate.mock.calls[0]?.[0].changedFiles).toEqual( + ["src/index.ts"], + ); + }); + + it("coalesces against the unit being enqueued, per unit type", async () => { + rows([APP], []); + await handler(makeReq(pushPayload()), makeRes()); + await mocks.buildPolicyDeployGate.mock.calls[0]?.[0].removeWaiting(); + expect(mocks.coalesceQueuedApplicationDeploys).toHaveBeenCalledWith( + "app-id-1", + ); + + vi.clearAllMocks(); + vi.mocked(findGitlabByWebhookSecret).mockResolvedValue(PROVIDER as any); + mocks.buildPolicyDeployGate.mockResolvedValue({ + deploy: true, + coalesced: 0, + }); + rows([], [COMPOSE]); + await handler(makeReq(pushPayload()), makeRes()); + await mocks.buildPolicyDeployGate.mock.calls[0]?.[0].removeWaiting(); + expect(mocks.coalesceQueuedComposeDeploys).toHaveBeenCalledWith( + "compose-id-1", + ); + }); + + it("still honours the unit's own explicit watchPaths before reaching the gate", async () => { + rows([{ ...APP, watchPaths: ["docs/**"] }], []); + await handler(makeReq(pushPayload()), makeRes()); + + expect(mocks.buildPolicyDeployGate).not.toHaveBeenCalled(); + expect(myQueue.add).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/dokploy/pages/api/deploy/gitlab.ts b/apps/dokploy/pages/api/deploy/gitlab.ts index 8355ca59ee..2400fb52bc 100644 --- a/apps/dokploy/pages/api/deploy/gitlab.ts +++ b/apps/dokploy/pages/api/deploy/gitlab.ts @@ -1,4 +1,6 @@ import { + // build-policy hook: enqueue-time gate. + buildPolicyDeployGate, checkGitlabMemberPermissionsByUserId, createComposePreview, createPreviewDeployment, @@ -16,9 +18,35 @@ import { and, eq } from "drizzle-orm"; import type { NextApiRequest, NextApiResponse } from "next"; import { applications, compose } from "@/server/db/schema"; import type { DeploymentJob } from "@/server/queues/queue-types"; -import { myQueue } from "@/server/queues/queueSetup"; +// >>> build-policy hook: enqueue-time gate (skip marker, derived watchPaths, +// queue coalescing). See packages/server/src/services/build-policy/README.md +import { + coalesceQueuedApplicationDeploys, + coalesceQueuedComposeDeploys, + myQueue, +} from "@/server/queues/queueSetup"; +// <<< build-policy hook import { deploy } from "@/server/utils/deploy"; +// >>> build-policy hook +/** + * The commit message the push is really about. + * + * The GitLab job title is `Push to `, which nobody writes + * `[skip deploy]` into, so the marker has to come from the payload. GitLab + * sends `checkout_sha` alongside a `commits` array ordered oldest-first; prefer + * the commit that sha names and fall back to the newest one. + */ +const gitlabHeadCommitMessage = (body: any): string | null => { + const commits: any[] = Array.isArray(body?.commits) ? body.commits : []; + if (commits.length === 0) return null; + const head = + commits.find((commit) => commit?.id === body?.checkout_sha) ?? + commits[commits.length - 1]; + return typeof head?.message === "string" ? head.message : null; +}; +// <<< build-policy hook + export default async function handler( req: NextApiRequest, res: NextApiResponse, @@ -157,6 +185,8 @@ export default async function handler( ), }); + const commitMessage = gitlabHeadCommitMessage(body); + let deployedCount = 0; for (const app of apps) { @@ -173,6 +203,26 @@ export default async function handler( continue; } + // >>> build-policy hook + const gate = await buildPolicyDeployGate({ + unitType: "application", + unit: { + unitId: app.applicationId, + unitName: app.name, + environmentId: app.environmentId, + watchPaths: app.watchPaths, + buildPath: app.buildPath, + dockerfile: app.dockerfile, + dockerContextPath: app.dockerContextPath, + }, + changedFiles: modifiedFiles, + commitMessage, + removeWaiting: () => + coalesceQueuedApplicationDeploys(app.applicationId), + }); + if (!gate.deploy) continue; + // <<< build-policy hook + deployedCount++; if (IS_CLOUD && app.serverId) { jobData.serverId = app.serverId; @@ -216,6 +266,24 @@ export default async function handler( continue; } + // >>> build-policy hook + const composeGate = await buildPolicyDeployGate({ + unitType: "compose", + unit: { + unitId: composeApp.composeId, + unitName: composeApp.name, + environmentId: composeApp.environmentId, + watchPaths: composeApp.watchPaths, + composePath: composeApp.composePath, + }, + changedFiles: modifiedFiles, + commitMessage, + removeWaiting: () => + coalesceQueuedComposeDeploys(composeApp.composeId), + }); + if (!composeGate.deploy) continue; + // <<< build-policy hook + deployedCount++; if (IS_CLOUD && composeApp.serverId) { jobData.serverId = composeApp.serverId; diff --git a/packages/server/src/services/build-policy/README.md b/packages/server/src/services/build-policy/README.md index c146f95a0a..9532470d02 100644 --- a/packages/server/src/services/build-policy/README.md +++ b/packages/server/src/services/build-policy/README.md @@ -148,8 +148,8 @@ break-glass reasons. ## Hook points in upstream code Every one is marked in the source with `build-policy hook`. Grep for that string -to find them all. There are **thirty**, in nine files, plus five import markers, -two zod lines in the schema files, and the two UI fields below. +to find them all. There are **thirty-three**, in ten files, plus six import +markers, two zod lines in the schema files, and the two UI fields below. | File | Hooks | |---|---| @@ -157,6 +157,7 @@ two zod lines in the schema files, and the two UI fields below. | `packages/server/src/services/deployment.ts` | 2 | | `packages/server/src/utils/builders/index.ts` | 1 | | `apps/dokploy/pages/api/deploy/github.ts` | 2 | +| `apps/dokploy/pages/api/deploy/gitlab.ts` | 3 | | `apps/dokploy/pages/api/deploy/[refreshToken].ts` | 2 | | `apps/dokploy/pages/api/deploy/compose/[refreshToken].ts` | 1 | | `apps/dokploy/server/queues/queueSetup.ts` | 2 | @@ -214,6 +215,22 @@ Tag pushes and pull-request previews are deliberately not gated here: a preview is created by opening a PR, and coalescing or watch-path filtering it would silently drop the preview a reviewer is waiting for. +### `apps/dokploy/pages/api/deploy/gitlab.ts` + +The same two gate blocks as `github.ts`, in the Push Hook handler's applications +and composes loops, again **after** upstream's own `shouldDeploy` check. Tag +pushes and merge-request previews are not gated, for the same reasons. + +One extra local helper, `gitlabHeadCommitMessage`, because GitLab's job title is +`Push to ` rather than the commit message, so `[skip deploy]` has to be +read out of the payload: the commit whose `id` equals `checkout_sha`, falling +back to the newest entry in `commits`. + +Coalescing is the reason this route is gated at all. A GitLab-sourced unit is +never build-relocated (`decideBuildPolicy` returns `not_github`), but coalescing +is a pure compute win that applies whatever the source type, and before this the +busiest route for some units did not have it. + ### `apps/dokploy/pages/api/deploy/[refreshToken].ts` - the gate, plus `resolveDeployHookImage(...)` for the optional @@ -414,6 +431,7 @@ break-glass, queue coalescing, `[skip deploy]`, derived `watchPaths` and | `plan-failure.test.ts` | that a refused plan still produces a deployment row and a notification | | `policy-off-is-upstream.test.ts` | that nothing here changes behaviour while the policy is off | | `rollback-by-digest.test.ts` | rollback to a stored digest, and the refusal when there is none | +| `gitlab-route-gate.test.ts` | that the GitLab push webhook consults the gate for both unit types, and reads `[skip deploy]` from the commit rather than the job title | | `deploy-path.integration.test.ts` | the real deploy path end to end, with docker, ssh and git mocked | The integration test is the tripwire for upstream merges (spec §11): it drives diff --git a/packages/server/src/services/build-policy/skip-deploy.ts b/packages/server/src/services/build-policy/skip-deploy.ts index 07b50153c8..47d8f698c8 100644 --- a/packages/server/src/services/build-policy/skip-deploy.ts +++ b/packages/server/src/services/build-policy/skip-deploy.ts @@ -3,8 +3,18 @@ * * Upstream already honours the GitHub Actions `[skip ci]` family, but only in * the GitHub App webhook and only to skip the whole delivery. This marker is - * about the *deploy*, is honoured on every provider route, and is recorded in - * the build-policy audit log so "why did my push not deploy" has an answer. + * about the *deploy*, and it is recorded in the build-policy audit log so "why + * did my push not deploy" has an answer. + * + * It is honoured wherever `buildPolicyDeployGate` is wired, which is every + * **push**-triggered enqueue site: the GitHub App webhook + * (`pages/api/deploy/github.ts`), the GitLab webhook (`gitlab.ts`) and both + * deploy-hook routes (`[refreshToken].ts` and `compose/[refreshToken].ts`, + * which is also how Gitea, Bitbucket and Soft Serve arrive). It is **not** + * honoured on the two tag-push branches or on preview deployments, because + * neither carries a commit message the marker could be written into. Keep this + * list accurate: a marker that is silently ignored on one route is worse than + * one that does not exist. */ export const SKIP_DEPLOY_MARKERS = [ "[skip deploy]", From ccf89f52476349ab72ef7ced0516988fe80a6f5b Mon Sep 17 00:00:00 2001 From: AminDhouib Date: Thu, 10 Sep 2026 18:27:35 -0400 Subject: [PATCH 11/18] fix(build-policy): refuse an unsatisfiable requiredChecks up front (finding F) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `source.ts` documents `parseGithubOwnerRepo` as existing so a `sourceType: "git"` unit can still be check-gated, and `resolveOwnerRepo` falls back to it — but `github-checks.ts:116` hard-requires a GitHub App provider, so a unit with no `githubId` resolved its owner/repo and threw on the very next step. By then the image had been built, tagged and pushed to the organization registry, and the message did not say the fix was to connect the App. The fallback is kept, because it works for the unit it was written for: `saveGitProvider` sets `sourceType: "git"` without clearing `githubId`, so a unit moved from the App to a plain git remote keeps a usable installation token. What is added is the refusal at the API boundary. - `describeRequiredChecksSupport` in `source.ts` tests all three things reading a commit's checks needs: a github.com source, a resolvable owner/repo, and a GitHub App installation to read them with. - `assertRequiredChecksSupported` throws a new coded `REQUIRED_CHECKS_UNSUPPORTED`; `assertRequiredChecksSupportedForUpdate` applies it to a partial patch, honouring an explicit null in the patch rather than falling back to the stored value. - `application.update` calls it and converts the error to a 400 naming the unit and the remedy. Setting an EMPTY list is always allowed, so a unit can always be cleared out of an unsupported state. - the deploy-time message now names both remedies, for the row whose App connection is removed after the checks were set. - README gains "What a unit needs before it can be check-gated"; the UI card says a GitHub App connection is required. Default-off is unchanged: this is a validation on a write an operator makes, and it rejects strictly more than before only for configurations that could never have worked. Tests: `__test__/build-policy/required-checks-support.test.ts`, 17 assertions. Build-policy suite 13 files / 253 passing. --- .../required-checks-support.test.ts | 236 ++++++++++++++++++ .../advanced/show-required-checks.tsx | 4 +- .../dokploy/server/api/routers/application.ts | 36 +++ .../src/services/build-policy/README.md | 27 ++ .../src/services/build-policy/errors.ts | 4 +- .../services/build-policy/github-checks.ts | 9 +- .../src/services/build-policy/source.ts | 136 ++++++++++ 7 files changed, 449 insertions(+), 3 deletions(-) create mode 100644 apps/dokploy/__test__/build-policy/required-checks-support.test.ts diff --git a/apps/dokploy/__test__/build-policy/required-checks-support.test.ts b/apps/dokploy/__test__/build-policy/required-checks-support.test.ts new file mode 100644 index 0000000000..bce34ebb53 --- /dev/null +++ b/apps/dokploy/__test__/build-policy/required-checks-support.test.ts @@ -0,0 +1,236 @@ +import { BuildPolicyError } from "@dokploy/server/services/build-policy/errors"; +import { + assertRequiredChecksSupported, + assertRequiredChecksSupportedForUpdate, + describeRequiredChecksSupport, +} from "@dokploy/server/services/build-policy/source"; +import { describe, expect, it } from "vitest"; + +/** + * Round-2 review finding F. `parseGithubOwnerRepo` exists so a + * `sourceType: "git"` unit can still be check-gated, and `resolveOwnerRepo` + * duly falls back to it — but the check reader two functions below hard-requires + * a GitHub App provider, so a unit that never had one resolved its owner/repo + * and then threw on the very next step. The build had already been tagged and + * pushed by then, and the message did not say the fix was to connect the App. + * + * The fallback is kept, because it genuinely works for the unit it was written + * for: `saveGitProvider` (`routers/application.ts:722`) sets `sourceType: "git"` + * without clearing `githubId`, so a unit moved from the App to a plain git + * remote keeps a usable token. What is added is the refusal at the API + * boundary, so an unsupported configuration is rejected when the operator types + * it rather than on every deploy for ever after. + */ + +const githubApp = { + unitName: "sendly-web", + sourceType: "github", + githubId: "gh-1", + owner: "DevinoSolutions", + repository: "sendly", + customGitUrl: null, +}; + +describe("describeRequiredChecksSupport", () => { + it("supports a GitHub App unit with an owner and a repository", () => { + expect(describeRequiredChecksSupport(githubApp)).toEqual({ + supported: true, + }); + }); + + it("supports a sourceType 'git' github.com unit that kept its GitHub App id", () => { + expect( + describeRequiredChecksSupport({ + ...githubApp, + sourceType: "git", + owner: null, + repository: null, + customGitUrl: "https://github.com/DevinoSolutions/sendly.git", + }), + ).toEqual({ supported: true }); + }); + + it("supports the scp-like git remote form too", () => { + expect( + describeRequiredChecksSupport({ + ...githubApp, + sourceType: "git", + owner: null, + repository: null, + customGitUrl: "git@github.com:DevinoSolutions/sendly.git", + }), + ).toEqual({ supported: true }); + }); + + it("refuses a github.com git remote with no GitHub App provider — finding F", () => { + const result = describeRequiredChecksSupport({ + ...githubApp, + sourceType: "git", + githubId: null, + owner: null, + repository: null, + customGitUrl: "https://github.com/DevinoSolutions/sendly.git", + }); + expect(result.supported).toBe(false); + expect(result.supported === false && result.reason).toContain("GitHub App"); + // The operator has to be told what to do, not just what went wrong. + expect(result.supported === false && result.reason).toContain("sendly-web"); + }); + + it("refuses a GitHub App unit whose id was disconnected", () => { + const result = describeRequiredChecksSupport({ + ...githubApp, + githubId: null, + }); + expect(result.supported).toBe(false); + }); + + it("refuses a unit that is not sourced from github.com at all", () => { + for (const unit of [ + { ...githubApp, sourceType: "gitlab", githubId: null }, + { ...githubApp, sourceType: "docker", githubId: null }, + { + ...githubApp, + sourceType: "git", + githubId: null, + customGitUrl: "https://gitlab.com/group/repo.git", + }, + { + ...githubApp, + sourceType: "git", + githubId: null, + customGitUrl: "https://github.enterprise.example.com/org/repo.git", + }, + ]) { + const result = describeRequiredChecksSupport(unit); + expect(result.supported).toBe(false); + expect(result.supported === false && result.reason).toContain( + "github.com", + ); + } + }); + + it("refuses a GitHub App unit whose repository is not resolvable", () => { + const result = describeRequiredChecksSupport({ + ...githubApp, + owner: null, + repository: null, + }); + expect(result.supported).toBe(false); + expect(result.supported === false && result.reason).toContain("repository"); + }); +}); + +describe("assertRequiredChecksSupported", () => { + it("is a no-op when no required checks are being set", () => { + expect(() => + assertRequiredChecksSupported( + { ...githubApp, githubId: null }, + undefined, + ), + ).not.toThrow(); + expect(() => + assertRequiredChecksSupported({ ...githubApp, githubId: null }, null), + ).not.toThrow(); + expect(() => + assertRequiredChecksSupported({ ...githubApp, githubId: null }, []), + ).not.toThrow(); + }); + + it("lets an operator clear an unsupported unit's checks", () => { + expect(() => + assertRequiredChecksSupported( + { ...githubApp, sourceType: "gitlab", githubId: null }, + [], + ), + ).not.toThrow(); + }); + + it("ignores blank names, which are not a real gate", () => { + expect(() => + assertRequiredChecksSupported({ ...githubApp, githubId: null }, [ + "", + " ", + ]), + ).not.toThrow(); + }); + + it("throws a coded BuildPolicyError when the unit cannot be check-gated", () => { + try { + assertRequiredChecksSupported({ ...githubApp, githubId: null }, [ + "build", + ]); + throw new Error("expected assertRequiredChecksSupported to throw"); + } catch (error) { + expect(error).toBeInstanceOf(BuildPolicyError); + expect((error as BuildPolicyError).code).toBe( + "REQUIRED_CHECKS_UNSUPPORTED", + ); + } + }); + + it("passes a supported unit through", () => { + expect(() => + assertRequiredChecksSupported(githubApp, ["build", "test"]), + ).not.toThrow(); + }); +}); + +describe("assertRequiredChecksSupportedForUpdate", () => { + const stored = { + unitName: "sendly-web", + sourceType: "github", + githubId: "gh-1", + owner: "DevinoSolutions", + repository: "sendly", + customGitUrl: null, + }; + + it("validates against the stored row when the patch touches only the checks", () => { + expect(() => + assertRequiredChecksSupportedForUpdate(stored, { + requiredChecks: ["build"], + }), + ).not.toThrow(); + }); + + it("honours a source change made in the same call", () => { + // Moving the unit to GitLab and setting a check in one update must be + // refused on the new source type, not accepted on the stored one. + expect(() => + assertRequiredChecksSupportedForUpdate(stored, { + sourceType: "gitlab", + requiredChecks: ["build"], + }), + ).toThrow(BuildPolicyError); + }); + + it("honours an explicit null in the patch rather than falling back", () => { + // `githubId: null` in the patch disconnects the App; `??` would have + // silently kept the stored id and let the write through. + expect(() => + assertRequiredChecksSupportedForUpdate(stored, { + githubId: null, + requiredChecks: ["build"], + }), + ).toThrow(BuildPolicyError); + }); + + it("accepts a patch that makes an unsupported unit supported", () => { + expect(() => + assertRequiredChecksSupportedForUpdate( + { ...stored, githubId: null }, + { githubId: "gh-2", requiredChecks: ["build"] }, + ), + ).not.toThrow(); + }); + + it("is a no-op when the patch does not set requiredChecks at all", () => { + expect(() => + assertRequiredChecksSupportedForUpdate( + { ...stored, githubId: null }, + { owner: "someone-else" }, + ), + ).not.toThrow(); + }); +}); diff --git a/apps/dokploy/components/dashboard/application/advanced/show-required-checks.tsx b/apps/dokploy/components/dashboard/application/advanced/show-required-checks.tsx index 5b0be9d678..7867c82c28 100644 --- a/apps/dokploy/components/dashboard/application/advanced/show-required-checks.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/show-required-checks.tsx @@ -81,7 +81,9 @@ export const ShowRequiredChecks = ({ applicationId }: Props) => { the deploy waits for those GitHub check runs on the commit to succeed before it continues. Legacy commit-status contexts are accepted too, so either a check-run name or a status context works - here. + here. Checks are read through a GitHub App installation, so this + unit has to be connected to a GitHub App provider and to a + github.com repository; saving a name without both is refused. diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index 0670c471af..a9614db502 100644 --- a/apps/dokploy/server/api/routers/application.ts +++ b/apps/dokploy/server/api/routers/application.ts @@ -1,5 +1,7 @@ import { assertNetworkIdsAttachableToResource, + // build-policy hook: required-checks support check at the API boundary. + assertRequiredChecksSupportedForUpdate, clearOldDeployments, createApplication, createDomain, @@ -17,6 +19,8 @@ import { getContainerLogs, getWebServerSettings, IS_CLOUD, + // build-policy hook: narrows a thrown support error to a 400. + isBuildPolicyError, mechanizeDockerContainer, readConfig, readRemoteConfig, @@ -832,6 +836,38 @@ export const applicationRouter = createTRPCRouter({ const { applicationId, ...rest } = input; + // >>> build-policy hook: refuse a required check the unit can never + // satisfy, here rather than on every deploy for ever after. Reading a + // commit's checks needs a github.com source, a resolvable owner/repo and + // an authenticated GitHub App installation; without all three the deploy + // would build, tag and push and only then fail. See finding F in the + // round-2 review and build-policy/source.ts. + if (input.requiredChecks !== undefined) { + const current = await findApplicationById(applicationId); + try { + assertRequiredChecksSupportedForUpdate( + { + unitName: current.name, + sourceType: current.sourceType, + githubId: current.githubId, + owner: current.owner, + repository: current.repository, + customGitUrl: current.customGitUrl, + }, + { ...rest, unitName: current.name }, + ); + } catch (error) { + if (isBuildPolicyError(error)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: error.message, + }); + } + throw error; + } + } + // <<< build-policy hook + if (input.networkIds !== undefined) { const application = await findApplicationById(applicationId); rest.networkIds = await assertNetworkIdsAttachableToResource( diff --git a/packages/server/src/services/build-policy/README.md b/packages/server/src/services/build-policy/README.md index 9532470d02..7d5e47ba81 100644 --- a/packages/server/src/services/build-policy/README.md +++ b/packages/server/src/services/build-policy/README.md @@ -337,6 +337,32 @@ Without the statuses half, a team that typed the name of a check published as a commit status would wait the full timeout and then fail with `REQUIRED_CHECKS_TIMEOUT` naming a check that had in fact passed. +### What a unit needs before it can be check-gated + +Three things, all of them, and `describeRequiredChecksSupport` (`source.ts`) +tests all three: + +1. **A github.com source.** Either `sourceType: "github"`, or `sourceType: "git"` + with a github.com `customGitUrl`. Enterprise GitHub hosts are excluded on + purpose: they use a different API base. +2. **A resolvable `owner`/`repo`**, from the unit's own columns for a GitHub App + unit or parsed out of `customGitUrl` for a plain git remote. +3. **A GitHub App installation** — `githubId`. The checks and statuses endpoints + are authenticated and there is no anonymous path, so a unit that never had an + App connection can never satisfy a check, however well its repository parses. + +The `sourceType: "git"` fallback is not decorative: `saveGitProvider` sets +`sourceType: "git"` without clearing `githubId`, so a unit moved from the App to +a plain git remote keeps a usable installation token and stays gateable. A unit +that never had one does not. + +`application.update` refuses a non-empty `requiredChecks` on a unit failing any +of the three, with a 400 naming the unit and the remedy. Setting an **empty** +list is always allowed, so a unit can always be cleared out of an unsupported +state. Round-2 review finding F: before this, the owner/repo resolved, the very +next step threw, and the operator learned about it one wasted build at a time — +after the image had been tagged and pushed. + --- ## Coalescing @@ -431,6 +457,7 @@ break-glass, queue coalescing, `[skip deploy]`, derived `watchPaths` and | `plan-failure.test.ts` | that a refused plan still produces a deployment row and a notification | | `policy-off-is-upstream.test.ts` | that nothing here changes behaviour while the policy is off | | `rollback-by-digest.test.ts` | rollback to a stored digest, and the refusal when there is none | +| `required-checks-support.test.ts` | that a unit with no GitHub App is refused a required check at the API boundary, and that clearing one is always allowed | | `gitlab-route-gate.test.ts` | that the GitLab push webhook consults the gate for both unit types, and reads `[skip deploy]` from the commit rather than the job title | | `deploy-path.integration.test.ts` | the real deploy path end to end, with docker, ssh and git mocked | diff --git a/packages/server/src/services/build-policy/errors.ts b/packages/server/src/services/build-policy/errors.ts index cd69f53b36..173d861a73 100644 --- a/packages/server/src/services/build-policy/errors.ts +++ b/packages/server/src/services/build-policy/errors.ts @@ -12,7 +12,9 @@ export type BuildPolicyErrorCode = | "DIGEST_NOT_PUBLISHED" | "REQUIRED_CHECKS_FAILED" | "REQUIRED_CHECKS_TIMEOUT" - | "REQUIRED_CHECKS_UNAVAILABLE"; + | "REQUIRED_CHECKS_UNAVAILABLE" + /** The unit can never satisfy required checks; refused at the API boundary. */ + | "REQUIRED_CHECKS_UNSUPPORTED"; export class BuildPolicyError extends Error { public readonly code: BuildPolicyErrorCode; diff --git a/packages/server/src/services/build-policy/github-checks.ts b/packages/server/src/services/build-policy/github-checks.ts index dec4e311c1..5e6bef554d 100644 --- a/packages/server/src/services/build-policy/github-checks.ts +++ b/packages/server/src/services/build-policy/github-checks.ts @@ -114,10 +114,17 @@ export const waitForUnitRequiredChecks = async ({ listCheckRunsOverride ?? (async (): Promise => { if (!unit.githubId) { + // Reachable only for a row whose App connection was removed after + // the checks were set: `application.update` refuses this + // combination at the API boundary (finding F). The message names + // both remedies, because by the time this throws the image has + // already been built and pushed. throw new BuildPolicyError( "REQUIRED_CHECKS_UNAVAILABLE", `Required checks are configured on "${unit.unitName}" but it is not ` + - "connected to a GitHub App provider, so they cannot be verified.", + "connected to a GitHub App provider, so they cannot be verified. " + + "Connect a GitHub App provider on the unit's Git tab, or clear " + + "the unit's required checks.", ); } const provider = await findGithubById(unit.githubId); diff --git a/packages/server/src/services/build-policy/source.ts b/packages/server/src/services/build-policy/source.ts index 0e8d96186d..58ec1d1808 100644 --- a/packages/server/src/services/build-policy/source.ts +++ b/packages/server/src/services/build-policy/source.ts @@ -1,3 +1,5 @@ +import { BuildPolicyError } from "./errors"; + /** * "Is this unit sourced from github.com?" * @@ -46,6 +48,12 @@ export const isGithubSourcedUnit = ({ /** * `owner/repo` for a github.com git URL, so a `sourceType: "git"` unit can * still be check-gated. Returns null for anything else. + * + * The fallback is real, not decorative: `saveGitProvider` sets + * `sourceType: "git"` without clearing `githubId`, so a unit moved from the + * GitHub App to a plain git remote keeps a usable installation token. A unit + * that never had an App connection cannot be check-gated at all, which is what + * `describeRequiredChecksSupport` below exists to say up front. */ export const parseGithubOwnerRepo = ( url: string | null | undefined, @@ -61,3 +69,131 @@ export const parseGithubOwnerRepo = ( if (!owner || !repo) return null; return { owner, repo }; }; + +/** + * Can this unit's `requiredChecks` ever be satisfied? + * + * Round-2 review finding F. Reading a commit's checks needs three things, and + * before this only the first two were tested — at deploy time, after the image + * had already been built, tagged and pushed: + * + * 1. the unit is sourced from github.com (`isGithubSourcedUnit`); + * 2. an `owner`/`repo` can be resolved, from the unit's own columns for a + * GitHub App unit or from its `customGitUrl` for a plain git remote; + * 3. a GitHub App installation exists to read them with — `githubId`. The + * checks and statuses endpoints are authenticated; there is no anonymous + * path, so a unit with no App connection can never pass the gate. + * + * Callers use this at the API boundary, so an operator who configures a check + * on a unit that cannot honour it is told immediately rather than discovering + * it one wasted build at a time. + */ +export interface RequiredChecksSupportInput extends GithubSourceInput { + unitName: string; + githubId?: string | null; + owner?: string | null; + repository?: string | null; +} + +export type RequiredChecksSupport = + | { supported: true } + | { supported: false; reason: string }; + +export const describeRequiredChecksSupport = ( + unit: RequiredChecksSupportInput, +): RequiredChecksSupport => { + const name = unit.unitName; + + if (!isGithubSourcedUnit(unit)) { + return { + supported: false, + reason: + `Required checks cannot be used on "${name}": they are read from the ` + + "GitHub checks and commit-statuses APIs, and this unit is not sourced " + + "from github.com. Clear the required checks, or connect the unit to a " + + "github.com repository.", + }; + } + + if (!unit.githubId) { + return { + supported: false, + reason: + `Required checks cannot be used on "${name}": reading a commit's ` + + "checks needs an authenticated GitHub App installation, and this unit " + + "is not connected to one. Connect a GitHub App provider on the unit's " + + "Git tab, or clear the required checks.", + }; + } + + const hasOwnerRepo = + (unit.sourceType === "github" && !!unit.owner && !!unit.repository) || + parseGithubOwnerRepo(unit.customGitUrl) !== null; + + if (!hasOwnerRepo) { + return { + supported: false, + reason: + `Required checks cannot be used on "${name}": its owner and ` + + "repository could not be determined, so there is no commit to read " + + "checks for. Reconnect the repository, or clear the required checks.", + }; + } + + return { supported: true }; +}; + +/** + * Throws when a non-empty `requiredChecks` is being written to a unit that can + * never satisfy it. Setting an empty list is always allowed, so an operator can + * always clear a unit out of an unsupported state. + */ +export const assertRequiredChecksSupported = ( + unit: RequiredChecksSupportInput, + requiredChecks: string[] | null | undefined, +): void => { + const wanted = (requiredChecks ?? []).filter( + (check) => typeof check === "string" && check.trim().length > 0, + ); + if (wanted.length === 0) return; + + const support = describeRequiredChecksSupport(unit); + if (support.supported) return; + + throw new BuildPolicyError("REQUIRED_CHECKS_UNSUPPORTED", support.reason, { + unitName: unit.unitName, + requiredChecks: wanted, + }); +}; + +/** + * The same assertion for a partial update, where the patch may be changing the + * source fields in the very call that sets the checks. A field absent from the + * patch keeps its stored value; a field explicitly set to null in the patch is + * honoured as null, which is why this cannot be written with `??`. + */ +export const assertRequiredChecksSupportedForUpdate = ( + current: RequiredChecksSupportInput, + patch: Partial & { + requiredChecks?: string[] | null; + }, +): void => { + const pick = ( + key: K, + ): RequiredChecksSupportInput[K] => + patch[key] !== undefined + ? (patch[key] as RequiredChecksSupportInput[K]) + : current[key]; + + assertRequiredChecksSupported( + { + unitName: pick("unitName"), + sourceType: pick("sourceType"), + githubId: pick("githubId"), + owner: pick("owner"), + repository: pick("repository"), + customGitUrl: pick("customGitUrl"), + }, + patch.requiredChecks, + ); +}; From e1dc9c720db457810c1ccc28b31ef043f5ae0799 Mon Sep 17 00:00:00 2001 From: AminDhouib Date: Thu, 10 Sep 2026 18:44:25 -0400 Subject: [PATCH 12/18] fix(build-policy): run the checks gate before the build, and gate it on the policy (finding E) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The required-checks wait sat in `prepareBuildPolicyDeploy`, after the build, and it entered on a non-empty `requiredChecks` alone with no policy switch involved. So one mistyped check name on one unit built, tagged and pushed an image, then held the instance's only deployment slot for the full 30-minute timeout and failed. On a self-hosted instance `jobData.serverId` is set only under `IS_CLOUD`, so every deployment job lands in the single `LOCAL_PARTITION` whose concurrency is `buildsConcurrency ?? 1` — every other deploy queues behind it. - new `runBuildPolicyPreBuildGate` (hook 2a/4) runs the gate between the clone and the build, on the sha the clone just fetched. It executes the clone half itself and returns a fresh `set -e;` prefix; when inactive it returns the caller's string unchanged and executes nothing, so the assembled command stays byte-identical to upstream's. Wired into all four deploy paths. - it is policy-gated on `settings.enforceRemoteBuilds`, so a per-unit `requiredChecks` value can no longer change deploy behaviour on an instance where nobody enabled the policy. It is still honoured for a unit the policy left local: an exclusion decides where a unit builds, not whether its team gave up its CI gate. - `prepareBuildPolicyDeploy` loses the wait and its early return simplifies to `if (!plan.enforced)`. - default timeout 30 -> 5 minutes (column default, migration, snapshot, the service fallback and the settings form), because the wait holds a deployment slot. - README documents plainly what is NOT fixed: the wait still occupies its slot, raise `buildsConcurrency` before enabling checks, and taking the wait out of the queue means not enqueueing until the checks pass, which is a queue redesign left as follow-up. Also in this commit, two nits from the same review: - N3: `upsertBuildPolicySettings` cleared the enforcement cache BEFORE the write, so a concurrent read in that window repopulated it with the old value. Cleared after the write now. - N1/N5: README no longer claims "no extra query" for an unenforced deploy (it is one settings SELECT, which the branch's own test asserts) and no longer claims an index on `enforceRemoteBuilds` that the migration does not create. Tests: `__test__/build-policy/required-checks-before-build.test.ts`, 12 assertions. Build-policy suite 14 files / 265 passing. Full suite 2258 tests, 2234 passed, 15 failed — the same fifteen test full-names as `baseline.json` (merge base cf4abf059), zero added and zero fixed, verified with cmp.py. --- .../required-checks-before-build.test.ts | 241 ++++++++++++++++++ .../dashboard/settings/build-policy.tsx | 7 +- apps/dokploy/drizzle/0200_handy_lifeguard.sql | 2 +- apps/dokploy/drizzle/meta/0200_snapshot.json | 2 +- packages/server/src/db/schema/build-policy.ts | 13 +- packages/server/src/services/application.ts | 47 ++++ .../src/services/build-policy/README.md | 68 ++++- .../server/src/services/build-policy/apply.ts | 199 +++++++++++---- .../src/services/build-policy/settings.ts | 23 +- 9 files changed, 528 insertions(+), 74 deletions(-) create mode 100644 apps/dokploy/__test__/build-policy/required-checks-before-build.test.ts diff --git a/apps/dokploy/__test__/build-policy/required-checks-before-build.test.ts b/apps/dokploy/__test__/build-policy/required-checks-before-build.test.ts new file mode 100644 index 0000000000..461e470ae1 --- /dev/null +++ b/apps/dokploy/__test__/build-policy/required-checks-before-build.test.ts @@ -0,0 +1,241 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Round-2 review finding E. The required-checks wait sat in + * `prepareBuildPolicyDeploy`, i.e. **after** the build, and it needed no policy + * switch: a non-empty `requiredChecks` alone entered the branch. So a mistyped + * check name on one unit built, tagged and pushed an image, then held the + * instance's only deployment slot for the full timeout and failed. On a + * self-hosted instance every job lands in `LOCAL_PARTITION` with concurrency + * `buildsConcurrency ?? 1`, so that stalls every other deploy. + * + * Two fixes, both asserted here: + * + * 1. the gate is **policy-gated** — it does nothing unless the organization has + * `enforceRemoteBuilds` on, so a `requiredChecks` value alone can no longer + * change what a deploy does; + * 2. the gate runs **between the clone and the build**, on the sha the clone + * just fetched, so a failing or never-arriving check costs no build at all. + * + * With the gate inactive the caller's command string must come back byte + * identical and nothing must be executed, which is the default-off property. + */ + +const mocks = vi.hoisted(() => ({ + execAsync: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }), + execAsyncRemote: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }), + getGitCommitInfo: vi.fn(), + waitForUnitRequiredChecks: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: mocks.execAsync, + execAsyncRemote: mocks.execAsyncRemote, +})); + +vi.mock("@dokploy/server/utils/providers/git", () => ({ + getGitCommitInfo: mocks.getGitCommitInfo, +})); + +vi.mock("@dokploy/server/services/build-policy/github-checks", () => ({ + waitForUnitRequiredChecks: mocks.waitForUnitRequiredChecks, +})); + +import { runBuildPolicyPreBuildGate } from "@dokploy/server/services/build-policy/apply"; + +const ENFORCING = { + buildPolicySettingsId: "s-1", + organizationId: "org-1", + enforceRemoteBuilds: true, + defaultBuildServerId: "srv-1", + defaultRegistryId: "reg-1", + requiredChecksTimeoutMinutes: 7, + createdAt: "", + updatedAt: "", +} as any; + +const NOT_ENFORCING = { ...ENFORCING, enforceRemoteBuilds: false }; + +const APPLICATION = { + applicationId: "app-1", + appName: "sendly-web", + name: "Sendly Web", + requiredChecks: ["build"], + sourceType: "github", + githubId: "gh-1", + owner: "DevinoSolutions", + repository: "sendly", + customGitUrl: null, + environment: { project: { organizationId: "org-1" } }, +} as any; + +const PLAN = (settings: unknown, enforced = true) => + ({ + enforced, + buildServerId: "srv-1", + registryId: "reg-1", + repository: "ghcr.io/devino/sendly-web", + settings, + }) as any; + +const DEPLOYMENT = { deploymentId: "dep-1", logPath: "/var/log/dep-1.log" }; + +const CLONE = "set -e;git clone ...;"; + +const run = (overrides: Record = {}) => + runBuildPolicyPreBuildGate({ + application: APPLICATION, + plan: PLAN(ENFORCING), + deployment: DEPLOYMENT, + serverId: null, + command: CLONE, + ...overrides, + } as any); + +describe("runBuildPolicyPreBuildGate — default-off and the policy switch", () => { + beforeEach(() => { + vi.clearAllMocks(); + // `clearAllMocks` clears calls, not implementations, and one test below + // makes the wait reject. Re-establish every default explicitly. + mocks.execAsync.mockResolvedValue({ stdout: "", stderr: "" }); + mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" }); + mocks.waitForUnitRequiredChecks.mockResolvedValue(undefined); + mocks.getGitCommitInfo.mockResolvedValue({ hash: "abc123", message: "" }); + }); + + it("returns the command unchanged and runs nothing when the unit has no required checks", async () => { + const result = await run({ + application: { ...APPLICATION, requiredChecks: null }, + }); + + expect(result).toBe(CLONE); + expect(mocks.execAsync).not.toHaveBeenCalled(); + expect(mocks.execAsyncRemote).not.toHaveBeenCalled(); + expect(mocks.waitForUnitRequiredChecks).not.toHaveBeenCalled(); + }); + + it("returns the command unchanged when there is no settings row at all", async () => { + const result = await run({ plan: PLAN(null, false) }); + + expect(result).toBe(CLONE); + expect(mocks.waitForUnitRequiredChecks).not.toHaveBeenCalled(); + }); + + it("ignores requiredChecks while the organization does not enforce — finding E", async () => { + // This is the caveat the review named: before the fix a non-empty + // requiredChecks alone was enough to take the SSH round trip and wait on + // GitHub, with no policy switch involved. + const result = await run({ plan: PLAN(NOT_ENFORCING, false) }); + + expect(result).toBe(CLONE); + expect(mocks.execAsync).not.toHaveBeenCalled(); + expect(mocks.getGitCommitInfo).not.toHaveBeenCalled(); + expect(mocks.waitForUnitRequiredChecks).not.toHaveBeenCalled(); + }); + + it("ignores blank check names, which are not a real gate", async () => { + const result = await run({ + application: { ...APPLICATION, requiredChecks: ["", " "] }, + }); + + expect(result).toBe(CLONE); + expect(mocks.waitForUnitRequiredChecks).not.toHaveBeenCalled(); + }); + + it("returns the command unchanged when the row carries no organization", async () => { + const result = await run({ + application: { ...APPLICATION, environment: null }, + }); + + expect(result).toBe(CLONE); + expect(mocks.waitForUnitRequiredChecks).not.toHaveBeenCalled(); + }); +}); + +describe("runBuildPolicyPreBuildGate — the gate runs before the build", () => { + beforeEach(() => { + vi.clearAllMocks(); + // `clearAllMocks` clears calls, not implementations, and one test below + // makes the wait reject. Re-establish every default explicitly. + mocks.execAsync.mockResolvedValue({ stdout: "", stderr: "" }); + mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" }); + mocks.waitForUnitRequiredChecks.mockResolvedValue(undefined); + mocks.getGitCommitInfo.mockResolvedValue({ hash: "abc123", message: "" }); + }); + + it("runs the clone half, then waits, and hands back a fresh command prefix", async () => { + const result = await run(); + + expect(mocks.execAsync).toHaveBeenCalledTimes(1); + expect(mocks.execAsync.mock.calls[0]?.[0]).toBe( + `(${CLONE}) >> ${DEPLOYMENT.logPath} 2>&1`, + ); + expect(mocks.waitForUnitRequiredChecks).toHaveBeenCalledTimes(1); + // The caller continues building from here; the clone is already run. + expect(result).toBe("set -e;"); + }); + + it("clones on the build server when there is one", async () => { + await run({ serverId: "srv-1" }); + + expect(mocks.execAsyncRemote).toHaveBeenCalledWith( + "srv-1", + `(${CLONE}) >> ${DEPLOYMENT.logPath} 2>&1`, + ); + expect(mocks.execAsync).not.toHaveBeenCalled(); + }); + + it("waits on the sha the clone just fetched", async () => { + await run(); + + expect(mocks.getGitCommitInfo).toHaveBeenCalledWith({ + appName: "sendly-web", + type: "application", + serverId: null, + }); + expect(mocks.waitForUnitRequiredChecks.mock.calls[0]?.[0].sha).toBe( + "abc123", + ); + }); + + it("passes the unit identity and the organization's configured timeout", async () => { + await run(); + + const call = mocks.waitForUnitRequiredChecks.mock.calls[0]?.[0]; + expect(call.unit).toMatchObject({ + unitType: "application", + unitId: "app-1", + unitName: "Sendly Web", + organizationId: "org-1", + requiredChecks: ["build"], + sourceType: "github", + githubId: "gh-1", + }); + expect(call.timeoutMs).toBe(7 * 60_000); + }); + + it("passes a null sha through so the wait can fail closed", async () => { + mocks.getGitCommitInfo.mockResolvedValue({ hash: "", message: "" }); + await run(); + + expect(mocks.waitForUnitRequiredChecks.mock.calls[0]?.[0].sha).toBeNull(); + }); + + it("does not swallow a failing check — no build is reached", async () => { + mocks.waitForUnitRequiredChecks.mockRejectedValue( + new Error("Required GitHub checks failed"), + ); + + await expect(run()).rejects.toThrow("Required GitHub checks failed"); + // The clone ran, the build command was never assembled or executed. + expect(mocks.execAsync).toHaveBeenCalledTimes(1); + }); + + it("still gates a unit the policy left local, such as an excluded one", async () => { + // Exclusion decides where a unit BUILDS. It does not mean the team gave + // up its CI gate, so the check still applies while the org enforces. + await run({ plan: PLAN(ENFORCING, false) }); + + expect(mocks.waitForUnitRequiredChecks).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/dokploy/components/dashboard/settings/build-policy.tsx b/apps/dokploy/components/dashboard/settings/build-policy.tsx index 19b7fb9c34..3a7bc4fab1 100644 --- a/apps/dokploy/components/dashboard/settings/build-policy.tsx +++ b/apps/dokploy/components/dashboard/settings/build-policy.tsx @@ -56,7 +56,7 @@ export const BuildPolicy = () => { enforceRemoteBuilds: false, defaultBuildServerId: "none", defaultRegistryId: "none", - requiredChecksTimeoutMinutes: 30, + requiredChecksTimeoutMinutes: 5, }, resolver: zodResolver(buildPolicySchema), }); @@ -270,6 +270,11 @@ export const BuildPolicy = () => { How long a deploy waits for a unit's required GitHub check runs before failing. Between 1 and 720 minutes. + The wait occupies a deployment slot, and a self-hosted + instance has buildsConcurrency of them in + total, so a long timeout on one unit queues every other + deploy behind it. Raise the build concurrency before + raising this. diff --git a/apps/dokploy/drizzle/0200_handy_lifeguard.sql b/apps/dokploy/drizzle/0200_handy_lifeguard.sql index d2edf63d70..79ecdd63d5 100644 --- a/apps/dokploy/drizzle/0200_handy_lifeguard.sql +++ b/apps/dokploy/drizzle/0200_handy_lifeguard.sql @@ -35,7 +35,7 @@ CREATE TABLE IF NOT EXISTS "build_policy_settings" ( "enforceRemoteBuilds" boolean DEFAULT false NOT NULL, "defaultBuildServerId" text, "defaultRegistryId" text, - "requiredChecksTimeoutMinutes" integer DEFAULT 30 NOT NULL, + "requiredChecksTimeoutMinutes" integer DEFAULT 5 NOT NULL, "createdAt" text NOT NULL, "updatedAt" text NOT NULL, CONSTRAINT "build_policy_settings_organizationId_unique" UNIQUE("organizationId") diff --git a/apps/dokploy/drizzle/meta/0200_snapshot.json b/apps/dokploy/drizzle/meta/0200_snapshot.json index a868ddf504..e0d54d3c4f 100644 --- a/apps/dokploy/drizzle/meta/0200_snapshot.json +++ b/apps/dokploy/drizzle/meta/0200_snapshot.json @@ -2900,7 +2900,7 @@ "type": "integer", "primaryKey": false, "notNull": true, - "default": 30 + "default": 5 }, "createdAt": { "name": "createdAt", diff --git a/packages/server/src/db/schema/build-policy.ts b/packages/server/src/db/schema/build-policy.ts index a6462de28c..cfbe245adc 100644 --- a/packages/server/src/db/schema/build-policy.ts +++ b/packages/server/src/db/schema/build-policy.ts @@ -66,10 +66,19 @@ export const buildPolicySettings = pgTable("build_policy_settings", { () => registry.registryId, { onDelete: "set null" }, ), - /** Minutes a deploy waits for a unit's `requiredChecks` before failing. */ + /** + * Minutes a deploy waits for a unit's `requiredChecks` before failing. + * + * The wait occupies the deployment slot it runs in, and on a self-hosted + * instance there is usually exactly one (`buildsConcurrency ?? 1` on the + * single `LOCAL_PARTITION`). So this default is deliberately short: a + * mistyped check name costs five minutes of the instance's deploy capacity, + * not thirty. Raise it, and `buildsConcurrency` with it, only when a real + * pipeline needs longer. + */ requiredChecksTimeoutMinutes: integer("requiredChecksTimeoutMinutes") .notNull() - .default(30), + .default(5), createdAt: text("createdAt") .notNull() .$defaultFn(() => new Date().toISOString()), diff --git a/packages/server/src/services/application.ts b/packages/server/src/services/application.ts index a77d6f53e2..b91939c12a 100644 --- a/packages/server/src/services/application.ts +++ b/packages/server/src/services/application.ts @@ -42,6 +42,7 @@ import { planApplicationBuild, prepareBuildPolicyDeploy, reportBuildPolicyPlanFailure, + runBuildPolicyPreBuildGate, toBuildPolicyUnit, } from "./build-policy/apply"; import { @@ -262,6 +263,19 @@ export const deployApplication = async ({ }); } + // >>> build-policy hook 2a/4: the required-checks gate, between the clone + // and the build. Runs the clone half itself and returns a fresh prefix + // when it is active; returns `command` unchanged and executes nothing + // when it is not, which is every deploy while the policy is off. + command = await runBuildPolicyPreBuildGate({ + application, + plan: buildPolicy, + deployment, + serverId, + command, + }); + // <<< build-policy hook 2a/4 + command += await getBuildCommand(application); // >>> build-policy hook 2/4: tag `:`, push to the @@ -438,6 +452,17 @@ export const rebuildApplication = async ({ try { let command = "set -e;"; + // >>> build-policy hook 2a/4 (rebuild): the required-checks gate. A + // rebuild has no clone, so this only waits; the existing checkout is + // already the commit being rebuilt. + command = await runBuildPolicyPreBuildGate({ + application, + plan: buildPolicy, + deployment, + serverId, + command, + }); + // <<< build-policy hook 2a/4 (rebuild) // Check case for docker only command += await getBuildCommand(application); // >>> build-policy hook 2/4 (rebuild) @@ -721,6 +746,18 @@ export const deployPreviewApplication = async ({ message: `Preview deployments are not supported for the '${application.sourceType}' source type`, }); } + // >>> build-policy hook 2a/4 (preview): the required-checks gate, on the + // preview's own checkout, between the clone and the build. + command = await runBuildPolicyPreBuildGate({ + application, + plan: buildPolicy, + deployment, + serverId: buildServerId, + command, + appName: previewDeployment.appName, + }); + // <<< build-policy hook 2a/4 (preview) + command += await getBuildCommand(application); // >>> build-policy hook 2/4 (preview): tag and push the preview image by @@ -906,6 +943,16 @@ export const rebuildPreviewApplication = async ({ message: `Preview deployments are not supported for the '${application.sourceType}' source type`, }); } + // >>> build-policy hook 2a/4 (preview rebuild): the required-checks gate. + command = await runBuildPolicyPreBuildGate({ + application, + plan: buildPolicy, + deployment, + serverId: buildServerId, + command, + appName: previewDeployment.appName, + }); + // <<< build-policy hook 2a/4 (preview rebuild) command += await getBuildCommand(application); // >>> build-policy hook 2/4 (preview rebuild) command += await getBuildPolicyPushCommand(buildPolicy, { diff --git a/packages/server/src/services/build-policy/README.md b/packages/server/src/services/build-policy/README.md index 7d5e47ba81..5d36ff08e5 100644 --- a/packages/server/src/services/build-policy/README.md +++ b/packages/server/src/services/build-policy/README.md @@ -47,17 +47,24 @@ enqueue time rather than deploy time: accepts into a 400 the day this merges is the same mistake. `buildPolicyDeployGate` and `resolveDeployHookImage` both begin with -`isBuildPolicyEnforcedAnywhere()` (`settings.ts`), one indexed -`enforceRemoteBuilds = true` lookup cached process-locally for five seconds. On +`isBuildPolicyEnforcedAnywhere()` (`settings.ts`), one +`enforceRemoteBuilds = true` lookup cached process-locally for five seconds. +There is no index on that column and there deliberately is not one: the table +holds one row per organization, so the scan is free. On an instance where nobody enforces, that cached boolean is the entire cost of the fork at enqueue time: no organization lookup, no settings read, no audit write. `upsertBuildPolicySettings` clears the cache, so turning the policy on through the UI takes effect at once; a direct database write takes up to the TTL. -The deploy path is the same story. `prepareBuildPolicyDeploy` (`apply.ts`) -returns the application unchanged before it reads a commit sha or a settings row -when the plan is unenforced and the unit has no required checks, so an -unenforced deploy makes no extra SSH round trip and no extra query. +The deploy path is the same story. `runBuildPolicyPreBuildGate` returns the +caller's command string unchanged, having executed nothing, unless the +organization enforces; `prepareBuildPolicyDeploy` returns the application +unchanged on the plan's `enforced` flag alone, before it reads a commit sha or +anything else. So an unenforced deploy makes no extra SSH round trip and one +settings read — the single indexed `build_policy_settings` SELECT +`resolveBuildPolicy` needs to decide the plan at all. `policy-off-is-upstream.test.ts` +asserts that count directly ("reads the organization settings exactly once"), +which is why this paragraph says one rather than none. `policy-off-is-upstream.test.ts` asserts all of this directly, including the absence of the calls. @@ -148,12 +155,12 @@ break-glass reasons. ## Hook points in upstream code Every one is marked in the source with `build-policy hook`. Grep for that string -to find them all. There are **thirty-three**, in ten files, plus six import +to find them all. There are **thirty-seven**, in ten files, plus six import markers, two zod lines in the schema files, and the two UI fields below. | File | Hooks | |---|---| -| `packages/server/src/services/application.ts` | 18 | +| `packages/server/src/services/application.ts` | 22 | | `packages/server/src/services/deployment.ts` | 2 | | `packages/server/src/utils/builders/index.ts` | 1 | | `apps/dokploy/pages/api/deploy/github.ts` | 2 | @@ -166,7 +173,7 @@ markers, two zod lines in the schema files, and the two UI fields below. ### `packages/server/src/services/application.ts` -The same four hooks in each of four deploy paths — `deployApplication`, +The same five hooks in each of four deploy paths — `deployApplication`, `rebuildApplication`, `deployPreviewApplication`, `rebuildPreviewApplication` — plus one line in each of the two non-preview paths that creates the deployment log on the build host. @@ -174,8 +181,9 @@ log on the build host. | Hook | What it replaces / adds | |---|---| | 1/4 | `const serverId = application.buildServerId \|\| application.serverId` becomes the same expression with `buildPolicy.buildServerId` in front. `planApplicationBuild` throws `BuildPolicyError` on an `error` decision, which is how a missing build server fails the deploy. | +| 2a/4 | before `getBuildCommand`, `runBuildPolicyPreBuildGate(...)` gates on required checks. Returns the caller's command string unchanged, and executes nothing, when the gate is inactive. | | 2/4 | after `getBuildCommand`, appends `getBuildPolicyPushCommand(...)`. Returns `""` when not enforcing, so the built command is byte-identical in that case. | -| 3/4 | after the build shell runs, `prepareBuildPolicyDeploy(...)` gates on required checks, reads the published digest, writes it to the deployment row, and returns the application object to deploy. Returns the input unchanged when not enforcing. | +| 3/4 | after the build shell runs, `prepareBuildPolicyDeploy(...)` reads the published digest, writes it to the deployment row, and returns the application object to deploy. Returns the input unchanged when not enforcing. Required checks are no longer here; see 2a/4. | | 4/4 | `mechanizeDockerContainer(application)` becomes `mechanizeDockerContainer(deployTarget)`. | Plus one import block, marked `Fork module`. @@ -187,7 +195,7 @@ status, the deployment log and the PR comment all carry the reason. **Merge note:** if upstream moves the `serverId` line or the `mechanizeDockerContainer` call, re-apply hooks 1/4 and 4/4 to the new location. -Hooks 2/4 and 3/4 must stay between the build shell and the swarm update. +Hook 2a/4 must stay between the clone and `getBuildCommand`; hooks 2/4 and 3/4 must stay between the build shell and the swarm update. ### `packages/server/src/services/deployment.ts` @@ -337,6 +345,43 @@ Without the statuses half, a team that typed the name of a check published as a commit status would wait the full timeout and then fail with `REQUIRED_CHECKS_TIMEOUT` naming a check that had in fact passed. +### When the gate runs, and what it costs + +The gate runs **between the clone and the build** (`runBuildPolicyPreBuildGate`, +hook 2a/4), on the sha the clone just fetched. When it is active it executes the +clone half of the deploy command itself and hands the caller a fresh `set -e;` +prefix to build the rest from; when it is inactive it returns the caller's +string unchanged and executes nothing, so the assembled command is +byte-identical to upstream's. Every generated build command uses absolute paths +or does its own `cd`, so nothing depends on a working directory the clone half +left behind. + +It is also **policy-gated**: it does nothing unless the organization has +`enforceRemoteBuilds` on. A `requiredChecks` value alone used to be enough to +enter the branch, which meant a per-unit field could change deploy behaviour on +an instance where nobody had turned the policy on. It *is* still honoured for a +unit the policy left local — an exclusion decides where a unit builds, not +whether its team gave up its CI gate. + +**What this does not fix, and you have to plan for it.** The wait still occupies +the deployment slot it is running in. `jobData.serverId` is set only under +`IS_CLOUD`, so on a self-hosted instance every deployment job lands in the single +`LOCAL_PARTITION`, whose concurrency is `buildsConcurrency ?? 1`. One unit +waiting on a check that never arrives therefore queues every other deploy on the +instance for the whole timeout. Two consequences: + +- the default timeout is **5 minutes**, not 30. A mistyped check name costs five + minutes of the instance's deploy capacity; +- **raise `buildsConcurrency` before enabling required checks** on a busy + instance. + +Taking the wait out of the queue entirely means not enqueueing the deploy until +the checks pass — a waiter that lives outside the queue and survives a restart. +That is a queue redesign rather than a hook point, and it is deliberately left as +follow-up rather than smuggled into this branch. What has changed is that the +ordering is now a decision rather than an accident of where hook 3/4 sat, and +that a refused check no longer costs a whole build. + ### What a unit needs before it can be check-gated Three things, all of them, and `describeRequiredChecksSupport` (`source.ts`) @@ -458,6 +503,7 @@ break-glass, queue coalescing, `[skip deploy]`, derived `watchPaths` and | `policy-off-is-upstream.test.ts` | that nothing here changes behaviour while the policy is off | | `rollback-by-digest.test.ts` | rollback to a stored digest, and the refusal when there is none | | `required-checks-support.test.ts` | that a unit with no GitHub App is refused a required check at the API boundary, and that clearing one is always allowed | +| `required-checks-before-build.test.ts` | that the checks gate is policy-gated, runs before the build on the freshly cloned sha, and is a no-op that executes nothing while the policy is off | | `gitlab-route-gate.test.ts` | that the GitLab push webhook consults the gate for both unit types, and reads `[skip deploy]` from the commit rather than the job title | | `deploy-path.integration.test.ts` | the real deploy path end to end, with docker, ssh and git mocked | diff --git a/packages/server/src/services/build-policy/apply.ts b/packages/server/src/services/build-policy/apply.ts index 2772ca1aa9..b8f0cffdf1 100644 --- a/packages/server/src/services/build-policy/apply.ts +++ b/packages/server/src/services/build-policy/apply.ts @@ -417,11 +417,144 @@ export const requirePublishedImage = async ({ return published; }; +/** The prefix every generated deploy command opens with. */ +const SHELL_PREFIX = "set -e;"; + +interface RequiredChecksGateUnit { + applicationId: string; + appName: string; + name: string; + sourceType: string; + owner?: string | null; + repository?: string | null; + customGitUrl?: string | null; + githubId?: string | null; + requiredChecks?: string[] | null; + environment?: { + project?: { organizationId?: string | null } | null; + } | null; +} + +/** + * The `requiredChecks` gate, run **between the clone and the build**. + * + * Round-2 review finding E. This wait used to live in + * `prepareBuildPolicyDeploy`, after the build, and it entered on a non-empty + * `requiredChecks` alone with no policy switch involved. Two consequences, both + * fixed here: + * + * - **It cost a whole build.** The image had already been built, tagged and + * pushed to the organization registry by the time the gate ran, so a check + * that failed or never arrived bought back no compute at all. For a fork + * whose point is cutting CI compute, gating after the build is the expensive + * ordering. The gate now runs on the sha the clone just fetched, so a refused + * check costs one clone. + * - **It was not policy-gated.** It now does nothing unless the organization + * has `enforceRemoteBuilds` on, so `requiredChecks` can no longer change what + * a deploy does on an instance where nobody enabled the policy. It is still + * honoured for a unit the policy left *local* — an exclusion decides where a + * unit builds, not whether its team gave up its CI gate. + * + * **What is still true and has to be planned for:** the wait occupies the + * deployment slot it is running in. On a self-hosted instance `jobData.serverId` + * is only ever set under `IS_CLOUD`, so every deployment job lands in the single + * `LOCAL_PARTITION` whose concurrency is `buildsConcurrency ?? 1`. Raise + * `buildsConcurrency` before enabling checks on a busy instance, and keep the + * timeout short. Moving the wait out of the queue entirely means not enqueueing + * until the checks pass, which is a queue redesign rather than a hook; see the + * README's required-checks section. + * + * Returns the command string the caller should carry on appending to. When the + * gate is inactive that is the caller's own string, unchanged and unexecuted, + * so the built command stays byte-identical to upstream's. + */ +export const runBuildPolicyPreBuildGate = async ({ + application, + plan, + deployment, + serverId, + command, + appName, +}: { + application: RequiredChecksGateUnit; + plan: BuildPolicyPlan; + deployment: { logPath: string }; + serverId: string | null; + command: string; + /** The checkout directory to read the sha from; a preview uses its own. */ + appName?: string; +}): Promise => { + const requiredChecks = (application.requiredChecks ?? []).filter( + (name): name is string => + typeof name === "string" && name.trim().length > 0, + ); + if (requiredChecks.length === 0) return command; + + // The policy switch. Without it a `requiredChecks` value alone gated deploys + // on an instance where nobody turned the policy on. + if (!plan.settings?.enforceRemoteBuilds) return command; + + const organizationId = application.environment?.project?.organizationId; + if (!organizationId) { + console.warn( + `[build-policy] no organization on application ${application.applicationId}; skipping the required-checks gate`, + ); + return command; + } + + // Run the clone half now, so the gate reads the commit this deploy is + // actually about rather than whatever was checked out last time. A rebuild + // has no clone — its command is still the bare `set -e;` prefix — so there + // is nothing to run and the existing checkout is already the right one. + if (command.replace(SHELL_PREFIX, "").trim().length > 0) { + const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`; + if (serverId) { + await execAsyncRemote(serverId, commandWithLog); + } else { + await execAsync(commandWithLog); + } + } + + const sha = + ( + await getGitCommitInfo({ + appName: appName ?? application.appName, + type: "application", + serverId, + }) + )?.hash || null; + + await waitForUnitRequiredChecks({ + unit: { + unitType: "application", + unitId: application.applicationId, + unitName: application.name, + organizationId, + requiredChecks, + sourceType: application.sourceType, + githubId: application.githubId, + owner: application.owner, + repository: application.repository, + customGitUrl: application.customGitUrl, + }, + sha, + // Already read when the plan was made; never read twice per deploy. + timeoutMs: requiredChecksTimeoutMs(plan.settings), + }); + + // The clone is done; the caller starts the build half from a clean shell. + // Every generated build command addresses absolute paths or does its own + // `cd`, so nothing depends on a working directory the clone half left behind. + return SHELL_PREFIX; +}; + /** * Everything an enforced deploy does between "the build finished" and "update - * the swarm service": gate on required checks, read the published digest, store - * it on the deployment record, and hand back the application object the deploy - * step should use. + * the swarm service": read the published digest, store it on the deployment + * record, and hand back the application object the deploy step should use. + * + * Required checks are **not** here any more; they run before the build, in + * `runBuildPolicyPreBuildGate` above. * * Returns the application unchanged when the policy is not enforcing, so the * upstream call site is a single assignment either way. @@ -453,14 +586,10 @@ export const prepareBuildPolicyDeploy = async < deployment: { deploymentId: string; logPath: string }; serverId: string | null; }): Promise => { - const requiredChecks = (application.requiredChecks ?? []).filter( - (name): name is string => typeof name === "string" && name.length > 0, - ); - // Nothing to do. Return before any query or remote exec, so a deploy with // the policy off costs exactly what it costs on upstream. The organization // is read after this, so an unenforced deploy does not even touch it. - if (!plan.enforced && requiredChecks.length === 0) return application; + if (!plan.enforced) return application; const organizationId = application.environment?.project?.organizationId; if (!organizationId) { @@ -473,52 +602,14 @@ export const prepareBuildPolicyDeploy = async < return application; } - const published = plan.enforced - ? await requirePublishedImage({ - plan, - logPath: deployment.logPath, - serverId, - organizationId, - applicationId: application.applicationId, - unitName: application.name, - }) - : null; - - if (requiredChecks.length > 0) { - // Gate on required checks before the deploy step. The sha comes from the - // tag the build just published when there is one, otherwise from the - // checkout — an SSH round trip, so only when checks are configured. - const sha = - published?.tag.split(":").pop() ?? - ( - await getGitCommitInfo({ - appName: application.appName, - type: "application", - serverId, - }) - )?.hash ?? - null; - - await waitForUnitRequiredChecks({ - unit: { - unitType: "application", - unitId: application.applicationId, - unitName: application.name, - organizationId, - requiredChecks, - sourceType: application.sourceType, - githubId: application.githubId, - owner: application.owner, - repository: application.repository, - customGitUrl: application.customGitUrl, - }, - sha, - // Already read when the plan was made; never read twice per deploy. - timeoutMs: requiredChecksTimeoutMs(plan.settings), - }); - } - - if (!published) return application; + const published = await requirePublishedImage({ + plan, + logPath: deployment.logPath, + serverId, + organizationId, + applicationId: application.applicationId, + unitName: application.name, + }); await updateDeployment(deployment.deploymentId, { imageTag: published.tag, diff --git a/packages/server/src/services/build-policy/settings.ts b/packages/server/src/services/build-policy/settings.ts index 171f7cdbee..b13de65cc6 100644 --- a/packages/server/src/services/build-policy/settings.ts +++ b/packages/server/src/services/build-policy/settings.ts @@ -71,14 +71,18 @@ export const upsertBuildPolicySettings = async ( ): Promise => { const existing = await findBuildPolicySettings(organizationId); const now = new Date().toISOString(); - // A write can flip enforcement on or off; drop the cached global answer. - clearBuildPolicyEnforcementCache(); + // A write can flip enforcement on or off, so the cached global answer has to + // go. Clear it AFTER the write, not before: a concurrent read in the window + // between a pre-write clear and the write itself repopulates the cache with + // the old value, and the enable is then up to the TTL late despite this + // function having been called. Review round 2, nit N3. if (!existing) { const [created] = await db .insert(buildPolicySettings) .values({ organizationId, ...updates, createdAt: now, updatedAt: now }) .returning(); + clearBuildPolicyEnforcementCache(); if (!created) { throw new Error("Failed to create build policy settings"); } @@ -90,13 +94,24 @@ export const upsertBuildPolicySettings = async ( .set({ ...updates, updatedAt: now }) .where(eq(buildPolicySettings.organizationId, organizationId)) .returning(); + clearBuildPolicyEnforcementCache(); if (!updated) { throw new Error("Failed to update build policy settings"); } return updated; }; -/** Timeout, in milliseconds, a deploy waits for a unit's required checks. */ +/** + * Timeout, in milliseconds, a deploy waits for a unit's required checks. + * + * The fallback matches the column default. Keep the two in step: the wait holds + * a deployment slot, so the default is short on purpose. See the README's + * required-checks section. + */ +export const DEFAULT_REQUIRED_CHECKS_TIMEOUT_MINUTES = 5; + export const requiredChecksTimeoutMs = ( settings: BuildPolicySettings | null, -): number => (settings?.requiredChecksTimeoutMinutes ?? 30) * 60_000; +): number => + (settings?.requiredChecksTimeoutMinutes ?? + DEFAULT_REQUIRED_CHECKS_TIMEOUT_MINUTES) * 60_000; From 12fd69898b241bee36970a768b15f34087e24e07 Mon Sep 17 00:00:00 2001 From: AminDhouib Date: Thu, 10 Sep 2026 18:55:16 -0400 Subject: [PATCH 13/18] fix(build-policy): wire requiredChecks for compose, and stop claiming the rest (finding A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README, the PR body and a comment in `policy.ts` all said exclusions, break-glass and `requiredChecks` applied to compose units in full. None of the three did. `compose.requiredChecks` was a real column and a real writable API field that silently did nothing, so an operator who set it — because the README said it worked — believed that compose unit's deploys waited for CI, and they deployed immediately, every time. Claiming a safety control that is not wired is the dangerous direction for a document to be wrong in. Resolution, split by what is worth wiring: **`requiredChecks` is now wired for compose.** `runComposeBuild` already ran the deploy as discrete `runStep` calls, so there is a clean hook between the clone and the build — the compose equivalent of the application path's hook 2a/4. New `build-policy/compose-checks.ts`, one inserted line in `compose.ts`, placed ahead of the `down --volumes` step so a refused check never leaves the stack torn down. Same default-off shape as everything else: an empty list reads nothing at all, a non-empty one costs the cached enforcement boolean first. `compose.update` gained the same API-boundary validation as `application.update`. **Exclusions and break-glass are not wired, and now say so.** Both decide where a unit builds; a compose build is never relocated, so `resolveBuildPolicy` — the only reader of exclusions and grants — is never called on the compose path at all. `addExclusion` and `allowLocalBuildOnce` now refuse a `composeId` with a 400 explaining why, instead of writing an FK-linked row nothing reads: a break-glass grant against a compose unit used to stay pending for ever, with an audit entry for a grant that was never spent. All three documents corrected to a per-behaviour table rather than a blanket claim: README, the `policy.ts` comment, and the PR body (separate edit). Tests: `__test__/build-policy/compose-required-checks.test.ts`, 12 assertions, including the one the review asked for — that the compose gate never consults exclusions or break-glass, and that an excluded compose unit is still check-gated, because exclusion is about the build host and not about CI. Build-policy suite 15 files / 277 passing. Full suite 2270 tests, 2246 passed, 15 failed — the same fifteen test full-names as baseline.json, zero added and zero fixed. --- .../compose-required-checks.test.ts | 224 ++++++++++++++++++ .../server/api/routers/build-policy.ts | 30 +++ apps/dokploy/server/api/routers/compose.ts | 35 +++ .../src/services/build-policy/README.md | 76 +++++- .../services/build-policy/compose-checks.ts | 111 +++++++++ .../server/src/services/build-policy/index.ts | 1 + .../src/services/build-policy/policy.ts | 16 +- packages/server/src/services/compose.ts | 12 + 8 files changed, 494 insertions(+), 11 deletions(-) create mode 100644 apps/dokploy/__test__/build-policy/compose-required-checks.test.ts create mode 100644 packages/server/src/services/build-policy/compose-checks.ts diff --git a/apps/dokploy/__test__/build-policy/compose-required-checks.test.ts b/apps/dokploy/__test__/build-policy/compose-required-checks.test.ts new file mode 100644 index 0000000000..56162e2614 --- /dev/null +++ b/apps/dokploy/__test__/build-policy/compose-required-checks.test.ts @@ -0,0 +1,224 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Round-2 review finding A, the merge blocker. + * + * The README, the PR body and a comment in `policy.ts` all claimed that + * exclusions, break-glass and `requiredChecks` applied to compose units in + * full. None of the three did, and `compose.requiredChecks` was a writable API + * field that silently did nothing: an operator who set it believed that compose + * unit's deploys waited for CI, and they deployed immediately, every time. + * Claiming a safety control that is not wired is the dangerous direction for a + * document to be wrong in. + * + * The resolution, and what these tests pin: + * + * - **`requiredChecks` is wired for compose.** `runComposeBuild` already runs + * the deploy in discrete steps, so there is a clean hook between the clone + * and the build — the same position as the application path's hook 2a/4. + * - **Exclusions and break-glass are not, and now say so.** They decide *where* + * a unit builds; a compose build is never relocated, so there is nothing to + * exclude it from. The router refuses a `composeId` on both rather than + * writing a row nothing will ever read. + */ + +const mocks = vi.hoisted(() => ({ + isBuildPolicyEnforcedAnywhere: vi.fn(), + findBuildPolicySettings: vi.fn(), + getGitCommitInfo: vi.fn(), + waitForUnitRequiredChecks: vi.fn().mockResolvedValue(undefined), + isUnitExcluded: vi.fn(), + findPendingBreakGlass: vi.fn(), +})); + +vi.mock("@dokploy/server/services/build-policy/settings", async () => { + const actual = await vi.importActual< + typeof import("@dokploy/server/services/build-policy/settings") + >("@dokploy/server/services/build-policy/settings"); + return { + ...actual, + isBuildPolicyEnforcedAnywhere: mocks.isBuildPolicyEnforcedAnywhere, + findBuildPolicySettings: mocks.findBuildPolicySettings, + }; +}); + +vi.mock("@dokploy/server/utils/providers/git", () => ({ + getGitCommitInfo: mocks.getGitCommitInfo, +})); + +vi.mock("@dokploy/server/services/build-policy/github-checks", () => ({ + waitForUnitRequiredChecks: mocks.waitForUnitRequiredChecks, +})); + +vi.mock("@dokploy/server/services/build-policy/exclusions", () => ({ + isUnitExcluded: mocks.isUnitExcluded, +})); + +import { waitForComposeRequiredChecks } from "@dokploy/server/services/build-policy/compose-checks"; + +const ENFORCING = { + buildPolicySettingsId: "s-1", + organizationId: "org-1", + enforceRemoteBuilds: true, + defaultBuildServerId: "srv-1", + defaultRegistryId: "reg-1", + requiredChecksTimeoutMinutes: 4, + createdAt: "", + updatedAt: "", +} as any; + +const COMPOSE = { + composeId: "compose-1", + appName: "sendly-stack", + name: "Sendly Stack", + requiredChecks: ["build"], + sourceType: "github", + githubId: "gh-1", + owner: "DevinoSolutions", + repository: "sendly", + customGitUrl: null, + serverId: null, + environment: { project: { organizationId: "org-1" } }, +} as any; + +const run = (compose: unknown = COMPOSE, serverId: string | null = null) => + waitForComposeRequiredChecks({ compose: compose as any, serverId }); + +describe("waitForComposeRequiredChecks — default-off", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.isBuildPolicyEnforcedAnywhere.mockResolvedValue(true); + mocks.findBuildPolicySettings.mockResolvedValue(ENFORCING); + mocks.getGitCommitInfo.mockResolvedValue({ hash: "abc123", message: "" }); + mocks.waitForUnitRequiredChecks.mockResolvedValue(undefined); + }); + + it("reads nothing at all when the unit has no required checks", async () => { + await run({ ...COMPOSE, requiredChecks: null }); + + expect(mocks.isBuildPolicyEnforcedAnywhere).not.toHaveBeenCalled(); + expect(mocks.findBuildPolicySettings).not.toHaveBeenCalled(); + expect(mocks.getGitCommitInfo).not.toHaveBeenCalled(); + expect(mocks.waitForUnitRequiredChecks).not.toHaveBeenCalled(); + }); + + it("ignores blank names, which are not a real gate", async () => { + await run({ ...COMPOSE, requiredChecks: ["", " "] }); + + expect(mocks.isBuildPolicyEnforcedAnywhere).not.toHaveBeenCalled(); + expect(mocks.waitForUnitRequiredChecks).not.toHaveBeenCalled(); + }); + + it("stops at the cached probe when nobody on the instance enforces", async () => { + mocks.isBuildPolicyEnforcedAnywhere.mockResolvedValue(false); + await run(); + + expect(mocks.findBuildPolicySettings).not.toHaveBeenCalled(); + expect(mocks.getGitCommitInfo).not.toHaveBeenCalled(); + expect(mocks.waitForUnitRequiredChecks).not.toHaveBeenCalled(); + }); + + it("does nothing when this organization has no settings row", async () => { + mocks.findBuildPolicySettings.mockResolvedValue(null); + await run(); + + expect(mocks.waitForUnitRequiredChecks).not.toHaveBeenCalled(); + }); + + it("does nothing when this organization does not enforce", async () => { + mocks.findBuildPolicySettings.mockResolvedValue({ + ...ENFORCING, + enforceRemoteBuilds: false, + }); + await run(); + + expect(mocks.waitForUnitRequiredChecks).not.toHaveBeenCalled(); + }); + + it("does nothing, and does not throw, when the row carries no organization", async () => { + await expect( + run({ ...COMPOSE, environment: null }), + ).resolves.toBeUndefined(); + + expect(mocks.waitForUnitRequiredChecks).not.toHaveBeenCalled(); + }); +}); + +describe("waitForComposeRequiredChecks — the gate itself", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.isBuildPolicyEnforcedAnywhere.mockResolvedValue(true); + mocks.findBuildPolicySettings.mockResolvedValue(ENFORCING); + mocks.getGitCommitInfo.mockResolvedValue({ hash: "abc123", message: "" }); + mocks.waitForUnitRequiredChecks.mockResolvedValue(undefined); + }); + + it("waits on the compose unit's own checkout and identity", async () => { + await run(); + + expect(mocks.getGitCommitInfo).toHaveBeenCalledWith({ + appName: "sendly-stack", + type: "compose", + serverId: null, + }); + const call = mocks.waitForUnitRequiredChecks.mock.calls[0]?.[0]; + expect(call.unit).toMatchObject({ + unitType: "compose", + unitId: "compose-1", + unitName: "Sendly Stack", + organizationId: "org-1", + requiredChecks: ["build"], + sourceType: "github", + githubId: "gh-1", + owner: "DevinoSolutions", + repository: "sendly", + }); + expect(call.sha).toBe("abc123"); + expect(call.timeoutMs).toBe(4 * 60_000); + }); + + it("reads the checkout on the compose unit's own server", async () => { + await run(COMPOSE, "srv-9"); + + expect(mocks.getGitCommitInfo).toHaveBeenCalledWith({ + appName: "sendly-stack", + type: "compose", + serverId: "srv-9", + }); + }); + + it("passes a null sha through so the wait fails closed", async () => { + mocks.getGitCommitInfo.mockResolvedValue({ hash: "", message: "" }); + await run(); + + expect(mocks.waitForUnitRequiredChecks.mock.calls[0]?.[0].sha).toBeNull(); + }); + + it("propagates a failing check so the build never runs", async () => { + mocks.waitForUnitRequiredChecks.mockRejectedValue( + new Error("Required GitHub checks failed"), + ); + + await expect(run()).rejects.toThrow("Required GitHub checks failed"); + }); + + /** + * The test the review asked for: one that asserts what a compose deploy does + * NOT do. Exclusions and break-glass decide where a unit builds, and a + * compose build is never relocated, so the checks gate must not consult + * either — otherwise an exclusion would silently disable a team's CI gate. + */ + it("never consults exclusions or break-glass", async () => { + await run(); + + expect(mocks.isUnitExcluded).not.toHaveBeenCalled(); + expect(mocks.findPendingBreakGlass).not.toHaveBeenCalled(); + }); + + it("still gates an excluded compose unit, because exclusion is about the build host", async () => { + mocks.isUnitExcluded.mockResolvedValue(true); + await run(); + + expect(mocks.waitForUnitRequiredChecks).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/dokploy/server/api/routers/build-policy.ts b/apps/dokploy/server/api/routers/build-policy.ts index b12ec93b3f..ffb8b4e6e3 100644 --- a/apps/dokploy/server/api/routers/build-policy.ts +++ b/apps/dokploy/server/api/routers/build-policy.ts @@ -32,6 +32,34 @@ import { audit } from "../utils/audit"; * and never from input, so there is no id a caller could swap to read or write * another organization's policy. */ + +/** + * Exclusions and break-glass decide **where** a unit builds. A compose build is + * never relocated (`decideBuildPolicy` returns `compose_build_not_relocatable`), + * so there is nothing to exclude a compose unit from and no local build to + * grant: `resolveBuildPolicy` is never called on the compose path at all. + * + * Both procedures used to accept a `composeId` and write an FK-linked row that + * nothing would ever read — a grant that stayed pending for ever and an audit + * entry for a grant that was never spent. Round-2 review finding A: an API + * affordance that does nothing is worse than no affordance, so it is refused + * with a message that says why. + * + * `requiredChecks` is the one build-policy behaviour compose *does* get; see + * `build-policy/compose-checks.ts`. + */ +const assertNotComposeUnit = (unitType: string, what: string): void => { + if (unitType !== "compose") return; + throw new TRPCError({ + code: "BAD_REQUEST", + message: + `${what} does not apply to a compose unit. A compose build is never ` + + "relocated to the organization build server, so it is never enforced " + + "and there is nothing to exclude it from. Required checks are the one " + + "build-policy control that does apply to compose units; set them on " + + "the unit itself.", + }); +}; export const buildPolicyRouter = createTRPCRouter({ settings: protectedProcedure.query(async ({ ctx }) => findBuildPolicySettings(ctx.session.activeOrganizationId), @@ -95,6 +123,7 @@ export const buildPolicyRouter = createTRPCRouter({ applicationId: input.applicationId, composeId: input.composeId, }); + assertNotComposeUnit(unitType, "An exclusion"); const exclusion = await addBuildPolicyExclusion({ organizationId, applicationId: unitType === "application" ? unitId : null, @@ -165,6 +194,7 @@ export const buildPolicyRouter = createTRPCRouter({ applicationId: input.applicationId, composeId: input.composeId, }); + assertNotComposeUnit(unitType, "A break-glass grant"); await grantBreakGlass({ organizationId, unitType, diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index 5f9031fbf8..8d19447a92 100644 --- a/apps/dokploy/server/api/routers/compose.ts +++ b/apps/dokploy/server/api/routers/compose.ts @@ -1,6 +1,8 @@ import { dirname, join } from "node:path"; import { addDomainToCompose, + // build-policy hook: required-checks support check at the API boundary. + assertRequiredChecksSupportedForUpdate, clearOldDeployments, cloneBitbucketRepository, cloneCompose, @@ -28,6 +30,8 @@ import { getContainerLogs, getWebServerSettings, IS_CLOUD, + // build-policy hook: narrows a thrown support error to a 400. + isBuildPolicyError, loadServices, paths, processTemplate, @@ -213,6 +217,37 @@ export const composeRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.composeId, { service: ["create"], }); + + // >>> build-policy hook: refuse a required check this compose unit can + // never satisfy, here rather than on every deploy. Same rule and the + // same message as the application path. See finding F in the round-2 + // review and build-policy/source.ts. + if (input.requiredChecks !== undefined) { + const current = await findComposeById(input.composeId); + try { + assertRequiredChecksSupportedForUpdate( + { + unitName: current.name, + sourceType: current.sourceType, + githubId: current.githubId, + owner: current.owner, + repository: current.repository, + customGitUrl: current.customGitUrl, + }, + { ...input, unitName: current.name }, + ); + } catch (error) { + if (isBuildPolicyError(error)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: error.message, + }); + } + throw error; + } + } + // <<< build-policy hook + const updated = await updateCompose(input.composeId, input); await audit(ctx, { action: "update", diff --git a/packages/server/src/services/build-policy/README.md b/packages/server/src/services/build-policy/README.md index 5d36ff08e5..3b4dcc940f 100644 --- a/packages/server/src/services/build-policy/README.md +++ b/packages/server/src/services/build-policy/README.md @@ -23,7 +23,7 @@ Design source: `docs/superpowers/specs/2026-09-10-ci-build-once-and-pool-design. | 3 | Push `:`, capture the digest, deploy by digest | `apply.ts`, `image.ts` | | 4 | Queue coalescing on enqueue | `coalesce.ts`, `webhook.ts` | | 5 | Derived default `watchPaths`, `[skip deploy]` marker | `watch-paths.ts`, `skip-deploy.ts`, `webhook.ts` | -| 6 | Per-unit `requiredChecks` gating, over check runs **and** commit statuses | `required-checks.ts`, `github-checks.ts` | +| 6 | Per-unit `requiredChecks` gating, over check runs **and** commit statuses, for applications and compose units | `required-checks.ts`, `github-checks.ts`, `compose-checks.ts` | | 7 | Deploy-hook body `{image, tag, digest}`, restricted to the unit's own repository | `hook-body.ts`, `pinned-deploy.ts` | | 8 | Rollback to a digest a past deployment stored, with no build | `rollback.ts`, `pinned-deploy.ts` | @@ -61,13 +61,13 @@ caller's command string unchanged, having executed nothing, unless the organization enforces; `prepareBuildPolicyDeploy` returns the application unchanged on the plan's `enforced` flag alone, before it reads a commit sha or anything else. So an unenforced deploy makes no extra SSH round trip and one -settings read — the single indexed `build_policy_settings` SELECT -`resolveBuildPolicy` needs to decide the plan at all. `policy-off-is-upstream.test.ts` -asserts that count directly ("reads the organization settings exactly once"), -which is why this paragraph says one rather than none. +settings read — the single `build_policy_settings` SELECT `resolveBuildPolicy` +needs to decide the plan at all. The compose path costs even less: an empty +`requiredChecks` returns before the cached enforcement probe. `policy-off-is-upstream.test.ts` asserts all of this directly, including the -absence of the calls. +absence of the calls, and it is where the "one settings read, not none" number +comes from ("reads the organization settings exactly once"). ## The decision @@ -120,6 +120,7 @@ always loads that relation, but a caller with a leaner row plans as | `required-checks.ts` | pure check evaluation plus a polling wait with an injectable clock | | `github-checks.ts` | the same wait, wired to the GitHub App installation token; merges check runs and commit statuses | | `coalesce.ts` | drop still-waiting deploys for a unit and audit what was dropped | +| `compose-checks.ts` | `requiredChecks` for compose units, run between the clone and the build | | `watch-paths.ts` | derive default `watchPaths` from `buildPath` / Dockerfile / compose path | | `skip-deploy.ts` | the `[skip deploy]` commit-message marker | | `webhook.ts` | the single enqueue-time gate every deploy entry point calls, plus deploy-hook body resolution | @@ -150,12 +151,21 @@ arrives in input is checked against that organization before use `audit` is admin-only because its rows carry registry ids, build server ids and break-glass reasons. +`addExclusion` and `allowLocalBuildOnce` **refuse a `composeId`** with a 400. +Both decide where a unit builds and a compose build is never relocated, so the +row they would write is one nothing ever reads. See "What a compose unit does +and does not get". + +Two upstream mutations also gained a build-policy check: `application.update` +and `compose.update` refuse a non-empty `requiredChecks` on a unit that can +never satisfy one. See "What a unit needs before it can be check-gated". + --- ## Hook points in upstream code Every one is marked in the source with `build-policy hook`. Grep for that string -to find them all. There are **thirty-seven**, in ten files, plus six import +to find them all. There are **thirty-eight**, in eleven files, plus seven import markers, two zod lines in the schema files, and the two UI fields below. | File | Hooks | @@ -163,6 +173,7 @@ markers, two zod lines in the schema files, and the two UI fields below. | `packages/server/src/services/application.ts` | 22 | | `packages/server/src/services/deployment.ts` | 2 | | `packages/server/src/utils/builders/index.ts` | 1 | +| `packages/server/src/services/compose.ts` | 1 | | `apps/dokploy/pages/api/deploy/github.ts` | 2 | | `apps/dokploy/pages/api/deploy/gitlab.ts` | 3 | | `apps/dokploy/pages/api/deploy/[refreshToken].ts` | 2 | @@ -212,6 +223,18 @@ relocates the build. This is the deploy-by-digest seam. Nothing else in the builders is touched, so the six build types are exactly upstream's. +### `packages/server/src/services/compose.ts` + +One call to `waitForComposeRequiredChecks` inside `runComposeBuild`, between the +clone/patches steps and the build step, and ahead of the `down --volumes` step +so a refused check never leaves the stack torn down. `runComposeBuild` already +ran its deploy as discrete `runStep` calls, so this is a single inserted line +rather than a restructure. Plus one import block, marked `Fork module`. + +**Merge note:** if upstream reorders the steps in `runComposeBuild`, the call +must stay after the clone (so the sha is the one being deployed) and before the +build. + ### `apps/dokploy/pages/api/deploy/github.ts` - one import of `buildPolicyDeployGate`, one of the two coalescing helpers. @@ -480,9 +503,41 @@ while the organization enforces. There is a test asserting exactly that reason (`policy-decision.test.ts` → "does not relocate a compose build, and says so explicitly"), so the gap is visible and any future change to it is deliberate. -**Every other behaviour applies to compose units in full**: exclusions, -break-glass, queue coalescing, `[skip deploy]`, derived `watchPaths` and -`requiredChecks`. +### What a compose unit does and does not get + +Not everything, and the difference is deliberate. Round-2 review finding A: this +paragraph used to claim all six behaviours applied "in full", three of them did +not, and `compose.requiredChecks` was a writable API field that silently did +nothing — a document claiming a CI gate that is not wired is wrong in the +dangerous direction. + +| Behaviour | Compose | Where | +|---|---|---| +| Queue coalescing | **yes** | `buildPolicyDeployGate`, enqueue time | +| `[skip deploy]` | **yes** | same gate | +| Derived `watchPaths` | **yes** | same gate | +| `requiredChecks` | **yes** | `compose-checks.ts`, between the clone and the build | +| Exclusions | **no** | nothing to exclude from | +| Break-glass | **no** | no relocated build to grant an escape from | +| Relocated build, push by sha, deploy by digest | **no** | the Known gap above | + +Exclusions and break-glass decide **where** a unit builds. A compose build is +never relocated, so `resolveBuildPolicy` — the only reader of exclusions and +grants — is never called on the compose path at all. Both procedures therefore +refuse a `composeId` with a 400 that says why, rather than writing an FK-linked +row nothing will ever read: before this, a break-glass grant issued against a +compose unit stayed pending for ever and the audit log showed a grant that was +never spent. + +`requiredChecks` is different, and is the half with real value, so it is wired. +`runComposeBuild` already runs the deploy in discrete steps, which gives a clean +hook between the clone/patches steps and the build step — the compose equivalent +of the application path's hook 2a/4. It sits **ahead** of the +`down --volumes` step, so a refused check never leaves the stack torn down. It +carries the same default-off shape as everything else here (an empty list reads +nothing; a non-empty one costs the cached enforcement boolean first) and the +same API-boundary validation as an application (see "What a unit needs before it +can be check-gated"). --- @@ -504,6 +559,7 @@ break-glass, queue coalescing, `[skip deploy]`, derived `watchPaths` and | `rollback-by-digest.test.ts` | rollback to a stored digest, and the refusal when there is none | | `required-checks-support.test.ts` | that a unit with no GitHub App is refused a required check at the API boundary, and that clearing one is always allowed | | `required-checks-before-build.test.ts` | that the checks gate is policy-gated, runs before the build on the freshly cloned sha, and is a no-op that executes nothing while the policy is off | +| `compose-required-checks.test.ts` | that a compose unit's required checks are honoured, that the gate is default-off, and that it never consults exclusions or break-glass | | `gitlab-route-gate.test.ts` | that the GitLab push webhook consults the gate for both unit types, and reads `[skip deploy]` from the commit rather than the job title | | `deploy-path.integration.test.ts` | the real deploy path end to end, with docker, ssh and git mocked | diff --git a/packages/server/src/services/build-policy/compose-checks.ts b/packages/server/src/services/build-policy/compose-checks.ts new file mode 100644 index 0000000000..408470fa71 --- /dev/null +++ b/packages/server/src/services/build-policy/compose-checks.ts @@ -0,0 +1,111 @@ +import { getGitCommitInfo } from "@dokploy/server/utils/providers/git"; +import { waitForUnitRequiredChecks } from "./github-checks"; +import { + findBuildPolicySettings, + isBuildPolicyEnforcedAnywhere, + requiredChecksTimeoutMs, +} from "./settings"; + +/** + * `requiredChecks` for compose units. + * + * Round-2 review finding A. `compose.requiredChecks` was a real column and a + * real writable API field, and nothing read it: a compose unit configured with + * a required check deployed immediately, every time, while three documents said + * otherwise. A claimed CI gate that is not wired is the dangerous kind of + * documentation error, so it is wired here. + * + * **What compose gets and what it does not.** Coalescing, `[skip deploy]`, + * derived `watchPaths` and now `requiredChecks` all apply. Exclusions and + * break-glass do not, and are refused at the router rather than written as rows + * nothing reads: both decide *where* a unit builds, and a compose build is + * never relocated (`decideBuildPolicy` returns + * `compose_build_not_relocatable`), so there is nothing to exclude it from. + * That asymmetry is deliberate and the README states it. + * + * This deliberately does **not** consult exclusions or break-glass, for the + * same reason: an exclusion must not silently disable a team's CI gate. + * + * Call site: `runComposeBuild`, between the clone/patches steps and the build + * step, which is the compose equivalent of the application path's hook 2a/4. It + * runs on the commit the clone just fetched, so a check that fails or never + * arrives costs no build. + * + * **Default-off**, in the same shape as every other touch point: an empty list + * (every existing row, since the column is nullable with no default) reads + * nothing at all, and a non-empty one costs the cached + * `isBuildPolicyEnforcedAnywhere` boolean before anything else. + * + * The wait occupies the deployment slot it runs in; see finding E and the + * README's required-checks section for what to raise before enabling it. + */ +export interface ComposeRequiredChecksUnit { + composeId: string; + appName: string; + name: string; + sourceType: string; + requiredChecks?: string[] | null; + githubId?: string | null; + owner?: string | null; + repository?: string | null; + customGitUrl?: string | null; + environment?: { + project?: { organizationId?: string | null } | null; + } | null; +} + +export const waitForComposeRequiredChecks = async ({ + compose, + serverId, +}: { + compose: ComposeRequiredChecksUnit; + serverId: string | null; +}): Promise => { + const requiredChecks = (compose.requiredChecks ?? []).filter( + (name): name is string => + typeof name === "string" && name.trim().length > 0, + ); + if (requiredChecks.length === 0) return; + + // Same cheap cached probe the enqueue gate opens with. + if (!(await isBuildPolicyEnforcedAnywhere())) return; + + const organizationId = compose.environment?.project?.organizationId; + if (!organizationId) { + // The fork must not be the reason a deploy throws; a row loaded without + // its nested environment deploys unchanged, with a warning. + console.warn( + `[build-policy] no organization on compose ${compose.composeId}; skipping the required-checks gate`, + ); + return; + } + + const settings = await findBuildPolicySettings(organizationId); + if (!settings?.enforceRemoteBuilds) return; + + const sha = + ( + await getGitCommitInfo({ + appName: compose.appName, + type: "compose", + serverId, + }) + )?.hash || null; + + await waitForUnitRequiredChecks({ + unit: { + unitType: "compose", + unitId: compose.composeId, + unitName: compose.name, + organizationId, + requiredChecks, + sourceType: compose.sourceType, + githubId: compose.githubId, + owner: compose.owner, + repository: compose.repository, + customGitUrl: compose.customGitUrl, + }, + sha, + timeoutMs: requiredChecksTimeoutMs(settings), + }); +}; diff --git a/packages/server/src/services/build-policy/index.ts b/packages/server/src/services/build-policy/index.ts index bf444bc279..5026b5b6f8 100644 --- a/packages/server/src/services/build-policy/index.ts +++ b/packages/server/src/services/build-policy/index.ts @@ -1,6 +1,7 @@ export * from "./apply"; export * from "./audit"; export * from "./coalesce"; +export * from "./compose-checks"; export * from "./errors"; export * from "./exclusions"; export * from "./github-checks"; diff --git a/packages/server/src/services/build-policy/policy.ts b/packages/server/src/services/build-policy/policy.ts index 7e32f1e220..3ac63a8e7c 100644 --- a/packages/server/src/services/build-policy/policy.ts +++ b/packages/server/src/services/build-policy/policy.ts @@ -87,7 +87,21 @@ export const decideBuildPolicy = ( // A compose unit builds and runs in one `docker compose up --build`, so its // build cannot be relocated to another host without splitting the deploy in // two. That is out of scope for this module; see README.md § Known gap. - // Every other build-policy behaviour still applies to compose units. + // + // Which build-policy behaviours a compose unit does and does not get, since + // this function is where the asymmetry starts: + // + // - it DOES get queue coalescing, `[skip deploy]` and derived `watchPaths` + // (all at enqueue time, in `buildPolicyDeployGate`), and `requiredChecks` + // (in `compose-checks.ts`, between the clone and the build); + // - it does NOT get exclusions or break-glass, and cannot: both decide where + // a unit builds, and this early return means a compose unit is never + // enforced, so there is nothing to exclude it from. The router refuses a + // `composeId` on both rather than writing a row nothing reads. + // + // Keep this list honest. Round-2 review finding A was a comment here, plus + // the README and the PR body, all claiming compose parity that three of the + // six behaviours did not have. if (unit.unitType === "compose") { return { mode: "local", reason: "compose_build_not_relocatable" }; } diff --git a/packages/server/src/services/compose.ts b/packages/server/src/services/compose.ts index 9718ebd896..5aac6325e8 100644 --- a/packages/server/src/services/compose.ts +++ b/packages/server/src/services/compose.ts @@ -43,6 +43,9 @@ import { quote } from "shell-quote"; import type { z } from "zod"; import { encodeBase64 } from "../utils/docker/utils"; import { getDokployUrl } from "./admin"; +// Fork module: build-policy required checks for compose units. +// See services/build-policy/README.md. +import { waitForComposeRequiredChecks } from "./build-policy/compose-checks"; import { createDeploymentCompose, getDeploymentErrorMessage, @@ -162,6 +165,15 @@ export const runComposeBuild = async ( await runStep(command); } + // >>> build-policy hook (compose): required-checks gate, between the clone + // and the build — the compose equivalent of the application path's hook + // 2a/4. Deliberately ahead of the `down --volumes` step below, so a refused + // check never leaves the stack torn down. Reads nothing at all when the + // unit has no `requiredChecks`, which is every existing row. + // See packages/server/src/services/build-policy/README.md + await waitForComposeRequiredChecks({ compose: entity, serverId }); + // <<< build-policy hook (compose) + if (freshVolumes && entity.composeType === "docker-compose") { const downCommand = `set -e; env -i PATH="$PATH" docker compose -p ${entity.appName} down --volumes 2>&1 || true;`; await runStep(downCommand); From 6095a34dffa9f77948ec766348547ed6d05942e4 Mon Sep 17 00:00:00 2001 From: AminDhouib Date: Thu, 10 Sep 2026 19:06:08 -0400 Subject: [PATCH 14/18] fix(build-policy): audit watch-path skips, validate hook bodies first, follow the publish registry (B, C, D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three inherited findings the round-2 review left open. B — a derived watch-path skip left no trace. The skip-marker branch audits; the watch-path branch, the one that fires without anybody asking for it, returned a 301 on a webhook delivery nobody reads and wrote nothing. It now records a `deploy_skipped` row carrying the derived paths and the changed-file list. Added a README "Before you turn it on" section: the section on default-off was good and the one on what changes the moment the switch is flipped did not exist, with derived watchPaths on monorepo units named as the highest blast radius. C — coalescing ran before the deploy-hook body was validated, in both routes. A POST with a malformed or foreign-repository image dropped every waiting deploy for the unit and then enqueued nothing; a CI job retrying with a broken body kept the queue empty. Worse on the compose route, which refuses EVERY body carrying an image while enforcing, so a CI job that standardises on posting one would coalesce and 400 on every push, for ever. The two calls are independent, so they are simply swapped. D — a unit with its own `registryId` got the wrong pull credentials when pinned. `getAuthConfig` tests `registry` in an `else if` that precedes the `buildRegistry` branch, and both pin sites only filled `buildRegistry` in when it was empty, so a stale `registryId` from a previous Docker-provider configuration won and the swarm pull failed. New `authForPublishedRegistry` nulls `registry` and puts the registry the digest actually lives on into `buildRegistry`; `pinned-deploy.ts` does the same with its host-resolved registry. Also the related divergence: `resolveDeployHookImage` built its allowlist from the unit's own registry first while an enforced build always publishes to `settings.defaultRegistryId`, so the digest the enforced build had just published would be rejected and one on a repository the enforced path never writes to accepted. The organization default now comes first. Tests: `gate-audit-and-registry.test.ts` (9) and `hook-body-before-coalescing.test.ts` (3), the latter verified to fail against the pre-swap route before the fix was restored. Build-policy suite 17 files / 289 passing. Full suite 2282 tests, 2258 passed, 15 failed — the same fifteen test full-names as baseline.json. --- .../gate-audit-and-registry.test.ts | 281 ++++++++++++++++++ .../hook-body-before-coalescing.test.ts | 140 +++++++++ .../pages/api/deploy/[refreshToken].ts | 37 ++- .../api/deploy/compose/[refreshToken].ts | 34 ++- .../src/services/build-policy/README.md | 51 +++- .../server/src/services/build-policy/apply.ts | 36 ++- .../services/build-policy/pinned-deploy.ts | 13 +- .../src/services/build-policy/webhook.ts | 41 ++- 8 files changed, 587 insertions(+), 46 deletions(-) create mode 100644 apps/dokploy/__test__/build-policy/gate-audit-and-registry.test.ts create mode 100644 apps/dokploy/__test__/build-policy/hook-body-before-coalescing.test.ts diff --git a/apps/dokploy/__test__/build-policy/gate-audit-and-registry.test.ts b/apps/dokploy/__test__/build-policy/gate-audit-and-registry.test.ts new file mode 100644 index 0000000000..d674530f82 --- /dev/null +++ b/apps/dokploy/__test__/build-policy/gate-audit-and-registry.test.ts @@ -0,0 +1,281 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Round-2 review findings B, C and D. + * + * B — a watch-path skip left no trace. The skip-marker branch audits; the + * derived-watch-path branch, the one that fires without anybody asking for + * it, returned a 301 on a webhook delivery nobody reads and wrote nothing. + * C — coalescing ran before the deploy-hook body was validated, so a malformed + * or foreign-repository body dropped every waiting deploy for the unit and + * then enqueued nothing. + * D — the deploy-hook allowlist was built from the unit's own registry first, + * while an enforced build always publishes to the organization default, so + * the two could name different repositories. + */ + +const mocks = vi.hoisted(() => ({ + isBuildPolicyEnforcedAnywhere: vi.fn(), + findBuildPolicySettings: vi.fn(), + recordBuildPolicyAudit: vi.fn().mockResolvedValue(undefined), + environmentsFindFirst: vi.fn(), + findRegistryByIdWithCredentials: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + environments: { findFirst: mocks.environmentsFindFirst }, + }, + }, +})); + +vi.mock("@dokploy/server/services/build-policy/settings", async () => { + const actual = await vi.importActual< + typeof import("@dokploy/server/services/build-policy/settings") + >("@dokploy/server/services/build-policy/settings"); + return { + ...actual, + isBuildPolicyEnforcedAnywhere: mocks.isBuildPolicyEnforcedAnywhere, + findBuildPolicySettings: mocks.findBuildPolicySettings, + }; +}); + +vi.mock("@dokploy/server/services/build-policy/audit", () => ({ + recordBuildPolicyAudit: mocks.recordBuildPolicyAudit, + findPendingBreakGlass: vi.fn(), + consumeBreakGlass: vi.fn(), + grantBreakGlass: vi.fn(), + listBuildPolicyAudit: vi.fn(), +})); + +vi.mock("@dokploy/server/services/registry", () => ({ + findRegistryByIdWithCredentials: mocks.findRegistryByIdWithCredentials, +})); + +import { + buildPolicyDeployGate, + resolveDeployHookImage, +} from "@dokploy/server/services/build-policy/webhook"; + +const ENFORCING = { + buildPolicySettingsId: "s-1", + organizationId: "org-1", + enforceRemoteBuilds: true, + defaultBuildServerId: "srv-1", + defaultRegistryId: "reg-default", + requiredChecksTimeoutMinutes: 5, + createdAt: "", + updatedAt: "", +} as any; + +const UNIT = { + unitId: "app-1", + unitName: "sendly-web", + environmentId: "env-1", + watchPaths: null, + buildPath: "apps/web", + dockerfile: null, + dockerContextPath: null, +}; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.isBuildPolicyEnforcedAnywhere.mockResolvedValue(true); + mocks.findBuildPolicySettings.mockResolvedValue(ENFORCING); + mocks.recordBuildPolicyAudit.mockResolvedValue(undefined); + mocks.environmentsFindFirst.mockResolvedValue({ + environmentId: "env-1", + project: { organizationId: "org-1" }, + }); +}); + +describe("finding B — a derived watch-path skip is audited", () => { + it("records a deploy_skipped row naming the derived paths and the changed files", async () => { + const result = await buildPolicyDeployGate({ + unitType: "application", + unit: UNIT, + changedFiles: ["packages/shared/index.ts"], + commitMessage: "chore: shared code only", + removeWaiting: vi.fn(), + }); + + expect(result.deploy).toBe(false); + expect(mocks.recordBuildPolicyAudit).toHaveBeenCalledTimes(1); + const row = mocks.recordBuildPolicyAudit.mock.calls[0]?.[0]; + expect(row).toMatchObject({ + organizationId: "org-1", + action: "deploy_skipped", + applicationId: "app-1", + composeId: null, + }); + expect(row.reason).toContain("derived watch paths"); + expect(row.metadata.derivedWatchPaths).toEqual(["apps/web/**"]); + expect(row.metadata.changedFiles).toEqual(["packages/shared/index.ts"]); + }); + + it("targets the compose id for a compose unit", async () => { + await buildPolicyDeployGate({ + unitType: "compose", + unit: { + unitId: "compose-1", + unitName: "stack", + environmentId: "env-1", + watchPaths: null, + composePath: "./stacks/api/docker-compose.yml", + }, + changedFiles: ["docs/readme.md"], + removeWaiting: vi.fn(), + }); + + expect(mocks.recordBuildPolicyAudit).toHaveBeenCalledWith( + expect.objectContaining({ + action: "deploy_skipped", + applicationId: null, + composeId: "compose-1", + }), + ); + }); + + it("does not coalesce when it skips, so nothing waiting is dropped", async () => { + const removeWaiting = vi.fn(); + await buildPolicyDeployGate({ + unitType: "application", + unit: UNIT, + changedFiles: ["packages/shared/index.ts"], + removeWaiting, + }); + + expect(removeWaiting).not.toHaveBeenCalled(); + }); + + it("writes nothing when the push does match the derived paths", async () => { + const removeWaiting = vi.fn().mockResolvedValue({ + removed: 0, + titles: [], + }); + const result = await buildPolicyDeployGate({ + unitType: "application", + unit: UNIT, + changedFiles: ["apps/web/page.tsx"], + removeWaiting, + }); + + expect(result.deploy).toBe(true); + expect(mocks.recordBuildPolicyAudit).not.toHaveBeenCalled(); + }); + + it("still writes nothing at all while the policy is off", async () => { + mocks.isBuildPolicyEnforcedAnywhere.mockResolvedValue(false); + const result = await buildPolicyDeployGate({ + unitType: "application", + unit: UNIT, + changedFiles: ["packages/shared/index.ts"], + removeWaiting: vi.fn(), + }); + + expect(result.deploy).toBe(true); + expect(mocks.recordBuildPolicyAudit).not.toHaveBeenCalled(); + }); +}); + +describe("finding D — the allowlist follows the publish target", () => { + const registry = (registryId: string, url: string) => ({ + registryId, + registryUrl: url, + imagePrefix: null, + username: "devino", + password: "unused", + registryType: "cloud", + }); + + it("validates against the organization default, which is where an enforced build publishes", async () => { + mocks.findRegistryByIdWithCredentials.mockResolvedValue( + registry("reg-default", "ghcr.io") as any, + ); + + const result = await resolveDeployHookImage( + { + organizationId: "org-1", + appName: "sendly-web", + // A stale registry from a previous Docker-provider configuration. + registryId: "reg-stale", + buildRegistryId: null, + }, + { + image: "ghcr.io/devino/sendly-web", + digest: `sha256:${"a".repeat(64)}`, + }, + ); + + expect(mocks.findRegistryByIdWithCredentials).toHaveBeenCalledWith( + "reg-default", + ); + expect(result.ok).toBe(true); + }); + + it("falls back to the unit's own registry only when the org has no default", async () => { + mocks.findBuildPolicySettings.mockResolvedValue({ + ...ENFORCING, + defaultRegistryId: null, + }); + mocks.findRegistryByIdWithCredentials.mockResolvedValue( + registry("reg-own", "registry.example.com") as any, + ); + + await resolveDeployHookImage( + { + organizationId: "org-1", + appName: "sendly-web", + registryId: "reg-own", + buildRegistryId: "reg-build", + }, + { + image: "registry.example.com/devino/sendly-web", + digest: `sha256:${"b".repeat(64)}`, + }, + ); + + expect(mocks.findRegistryByIdWithCredentials).toHaveBeenCalledWith( + "reg-own", + ); + }); + + it("still refuses a digest on a repository the enforced build never writes to", async () => { + mocks.findRegistryByIdWithCredentials.mockResolvedValue( + registry("reg-default", "ghcr.io") as any, + ); + + const result = await resolveDeployHookImage( + { + organizationId: "org-1", + appName: "sendly-web", + registryId: "reg-stale", + buildRegistryId: null, + }, + { + image: "ghcr.io/someone-else/sendly-web", + digest: `sha256:${"c".repeat(64)}`, + }, + ); + + expect(result.ok).toBe(false); + }); + + it("is still inert while the policy is off", async () => { + mocks.isBuildPolicyEnforcedAnywhere.mockResolvedValue(false); + + const result = await resolveDeployHookImage( + { + organizationId: "org-1", + appName: "sendly-web", + registryId: "reg-stale", + buildRegistryId: null, + }, + { image: "ghcr.io/anyone/anything", digest: `sha256:${"d".repeat(64)}` }, + ); + + expect(result.ok).toBe(true); + expect(mocks.findRegistryByIdWithCredentials).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/dokploy/__test__/build-policy/hook-body-before-coalescing.test.ts b/apps/dokploy/__test__/build-policy/hook-body-before-coalescing.test.ts new file mode 100644 index 0000000000..97ad21651e --- /dev/null +++ b/apps/dokploy/__test__/build-policy/hook-body-before-coalescing.test.ts @@ -0,0 +1,140 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Round-2 review finding C. Both deploy-hook routes called + * `buildPolicyDeployGate` — which coalesces, dropping the unit's still-waiting + * deploys — and only afterwards validated the optional `{image, tag, digest}` + * body, which can answer 400. So a POST with a malformed or foreign-repository + * image emptied the unit's queue and then enqueued nothing, and a CI job + * retrying with a broken body kept it that way. + * + * The compose route made it deterministic rather than accidental: + * `rejectComposeDeployHookImage` refuses **every** body carrying an image while + * the organization enforces, so a CI job that standardises on always posting one + * would coalesce the queue and 400 on every push, for ever. + * + * The two calls are independent — the validation reads nothing the gate + * produces — so the fix is to swap them. These tests pin the order by driving + * the real handlers and asserting that a refused body means the gate was never + * reached. + */ + +const mocks = vi.hoisted(() => ({ + buildPolicyDeployGate: vi.fn(), + resolveDeployHookImage: vi.fn(), + rejectComposeDeployHookImage: vi.fn(), + composeFindFirst: vi.fn(), + applicationsFindFirst: vi.fn(), + coalesceQueuedApplicationDeploys: vi.fn(), + coalesceQueuedComposeDeploys: vi.fn(), + queueAdd: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@dokploy/server/services/build-policy/webhook", async () => { + const actual = await vi.importActual< + typeof import("@dokploy/server/services/build-policy/webhook") + >("@dokploy/server/services/build-policy/webhook"); + return { + ...actual, + buildPolicyDeployGate: mocks.buildPolicyDeployGate, + resolveDeployHookImage: mocks.resolveDeployHookImage, + rejectComposeDeployHookImage: mocks.rejectComposeDeployHookImage, + }; +}); + +vi.mock("@/server/queues/queueSetup", () => ({ + myQueue: { add: mocks.queueAdd }, + coalesceQueuedApplicationDeploys: mocks.coalesceQueuedApplicationDeploys, + coalesceQueuedComposeDeploys: mocks.coalesceQueuedComposeDeploys, + cleanQueuesByCompose: vi.fn(), + killDockerBuild: vi.fn(), +})); + +import { db } from "@dokploy/server/db"; +import composeHandler from "@/pages/api/deploy/compose/[refreshToken]"; + +const RAW_COMPOSE = { + composeId: "compose-1", + name: "Sendly Stack", + appName: "sendly-stack", + environmentId: "env-1", + sourceType: "raw", + autoDeploy: true, + watchPaths: null, + composePath: "./docker-compose.yml", + serverId: null, + environment: { project: { organizationId: "org-1" } }, +}; + +const makeRes = () => { + const res: any = {}; + res.status = vi.fn().mockReturnValue(res); + res.json = vi.fn().mockReturnValue(res); + return res as NextApiResponse; +}; + +const makeReq = (body: object): NextApiRequest => + ({ + method: "POST", + // No provider headers: a manual deploy-hook POST, which is exactly how a + // CI job calls this. + headers: {}, + query: { refreshToken: "token-1" }, + body, + }) as any; + +describe("finding C — the deploy-hook body is validated before coalescing", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.queueAdd.mockResolvedValue(undefined); + vi.mocked(db.query.compose.findFirst).mockResolvedValue(RAW_COMPOSE as any); + mocks.buildPolicyDeployGate.mockResolvedValue({ + deploy: true, + coalesced: 0, + }); + }); + + it("does not reach the gate when the compose body is refused", async () => { + mocks.rejectComposeDeployHookImage.mockResolvedValue({ + ok: false, + message: + "Deploying a supplied image by digest is not supported for compose units.", + }); + const res = makeRes(); + + await composeHandler(makeReq({ image: "ghcr.io/devino/stack" }), res); + + expect(mocks.rejectComposeDeployHookImage).toHaveBeenCalledTimes(1); + // The whole point: nothing was coalesced on behalf of a refused request. + expect(mocks.buildPolicyDeployGate).not.toHaveBeenCalled(); + expect(mocks.coalesceQueuedComposeDeploys).not.toHaveBeenCalled(); + expect(mocks.queueAdd).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it("still runs the gate, and enqueues, when the body is accepted", async () => { + mocks.rejectComposeDeployHookImage.mockResolvedValue({ ok: true }); + const res = makeRes(); + + await composeHandler(makeReq({}), res); + + expect(mocks.buildPolicyDeployGate).toHaveBeenCalledTimes(1); + expect(mocks.queueAdd).toHaveBeenCalledTimes(1); + }); + + it("a gate refusal still stops the deploy, with the gate's own message", async () => { + mocks.rejectComposeDeployHookImage.mockResolvedValue({ ok: true }); + mocks.buildPolicyDeployGate.mockResolvedValue({ + deploy: false, + reason: "skip_deploy_marker", + message: "Deployment skipped: the commit message contains [skip deploy]", + }); + const res = makeRes(); + + await composeHandler(makeReq({}), res); + + expect(mocks.queueAdd).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(301); + }); +}); diff --git a/apps/dokploy/pages/api/deploy/[refreshToken].ts b/apps/dokploy/pages/api/deploy/[refreshToken].ts index e292ec5f55..d04cd5ead9 100644 --- a/apps/dokploy/pages/api/deploy/[refreshToken].ts +++ b/apps/dokploy/pages/api/deploy/[refreshToken].ts @@ -292,9 +292,29 @@ export default async function handler( } } - // >>> build-policy hook: `[skip deploy]`, derived watchPaths, queue - // coalescing, and the optional `{image, tag, digest}` body. + // >>> build-policy hook: the optional `{image, tag, digest}` body, then + // `[skip deploy]`, derived watchPaths and queue coalescing. + // + // Body validation comes FIRST on purpose. The gate coalesces, which drops + // this unit's still-waiting deploys; doing that on behalf of a request + // that is then refused with a 400 leaves the queue empty and nothing + // enqueued, and a CI job retrying with a broken body would keep it that + // way for ever. The validation reads nothing the gate produces, so the + // order is free. Round-2 review finding C. // See packages/server/src/services/build-policy/README.md + const hookImage = await resolveDeployHookImage( + { + organizationId: application.environment.project.organizationId, + appName: application.appName, + registryId: application.registryId, + buildRegistryId: application.buildRegistryId, + }, + req.body, + ); + if (!hookImage.ok) { + res.status(400).json({ message: hookImage.message }); + return; + } const gate = await buildPolicyDeployGate({ unitType: "application", unit: { @@ -314,19 +334,6 @@ export default async function handler( res.status(301).json({ message: gate.message }); return; } - const hookImage = await resolveDeployHookImage( - { - organizationId: application.environment.project.organizationId, - appName: application.appName, - registryId: application.registryId, - buildRegistryId: application.buildRegistryId, - }, - req.body, - ); - if (!hookImage.ok) { - res.status(400).json({ message: hookImage.message }); - return; - } // <<< build-policy hook try { diff --git a/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts b/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts index 11109f2221..2b4d9e3586 100644 --- a/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts +++ b/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts @@ -234,11 +234,27 @@ export default async function handler( } } - // >>> build-policy hook: `[skip deploy]`, derived watchPaths and queue - // coalescing. A compose unit cannot deploy a supplied image by digest - // yet (see README.md § Known gap), so an enforcing organization gets a - // 400 rather than a silently ignored body. While the policy is off the - // body is ignored, which is what upstream does with it. + // >>> build-policy hook: the supplied-image body, then `[skip deploy]`, + // derived watchPaths and queue coalescing. + // + // A compose unit cannot deploy a supplied image by digest yet (see + // README.md § Known gap), so an enforcing organization gets a 400 rather + // than a silently ignored body. While the policy is off the body is + // ignored, which is what upstream does with it. + // + // The refusal is checked BEFORE the gate on purpose, and it matters more + // here than on the application route: this rejects EVERY body carrying an + // image while enforcing. A CI job that standardises on always posting one + // would otherwise coalesce the unit's queue and then 400 on every single + // push, for ever. Round-2 review finding C. + const hookImage = await rejectComposeDeployHookImage( + composeResult.environmentId, + req.body, + ); + if (!hookImage.ok) { + res.status(400).json({ message: hookImage.message }); + return; + } const gate = await buildPolicyDeployGate({ unitType: "compose", unit: { @@ -256,14 +272,6 @@ export default async function handler( res.status(301).json({ message: gate.message }); return; } - const hookImage = await rejectComposeDeployHookImage( - composeResult.environmentId, - req.body, - ); - if (!hookImage.ok) { - res.status(400).json({ message: hookImage.message }); - return; - } // <<< build-policy hook try { diff --git a/packages/server/src/services/build-policy/README.md b/packages/server/src/services/build-policy/README.md index 3b4dcc940f..1646259f5e 100644 --- a/packages/server/src/services/build-policy/README.md +++ b/packages/server/src/services/build-policy/README.md @@ -69,6 +69,40 @@ needs to decide the plan at all. The compose path costs even less: an empty absence of the calls, and it is where the "one settings read, not none" number comes from ("reads the organization settings exactly once"). +## Before you turn it on + +"Off by default" is only half the story. This is what changes the moment an +operator sets `enforceRemoteBuilds`, in rough order of blast radius. Round-2 +review finding B: the section above was excellent and this one did not exist. + +1. **Derived `watchPaths` start filtering pushes.** Every unit with a + `buildPath` and no explicit `watchPaths` immediately gets `/**` + from `deriveDefaultWatchPaths`. In a monorepo — the common shape across this + fleet — a push that touches only shared code under `packages/**` now stops at + the webhook with a 301 and no deployment record. This is the single + highest-blast-radius consequence of enabling the policy. + **Set explicit `watchPaths` on your monorepo units first.** Every such skip + now writes a `deploy_skipped` audit row carrying the derived paths and the + changed-file list, so "why did my push not deploy" has an answer; before, the + only trace was a webhook delivery response nobody reads. +2. **Every GitHub-sourced unit's build moves to the org build server**, and its + deploy pulls by digest. A unit that must keep building where it is needs an + exclusion, added before the switch is flipped. +3. **Queued deploys start coalescing.** A burst of pushes produces one build. + Previews are never coalesced. +4. **`[skip deploy]` starts being honoured** on the routes listed in + `skip-deploy.ts`. +5. **A deploy-hook `{image, …}` body stops being ignored**: validated on an + application, refused with a 400 on a compose unit. +6. **Required checks are a separate opt-in on top.** Nothing waits on CI until + somebody sets `requiredChecks` on a unit. Before you do, read the slot cost + in the required-checks section below and raise `buildsConcurrency`. + +The audit log (`buildPolicy.audit`, admin only) is where all of this is visible: +`deploy_skipped`, `deploy_coalesced`, `required_checks_failed`, +`required_checks_timeout`, `deploy_by_digest`, `exclusion_added`, +`break_glass_granted`. + ## The decision `policy.ts` holds the whole decision as a pure function, in this order: @@ -264,15 +298,24 @@ busiest route for some units did not have it. ### `apps/dokploy/pages/api/deploy/[refreshToken].ts` -- the gate, plus `resolveDeployHookImage(...)` for the optional - `{image, tag, digest}` body; a validated image is passed through the job as +- `resolveDeployHookImage(...)` for the optional `{image, tag, digest}` body, + **then** the gate; a validated image is passed through the job as `pinnedImage`. +- the order matters. The gate coalesces, which drops this unit's still-waiting + deploys, and the body validation can answer 400. Coalescing on behalf of a + request that is then refused leaves the queue empty and nothing enqueued, and + a CI job retrying with a broken body would keep it that way. The validation + reads nothing the gate produces, so putting it first is free. ### `apps/dokploy/pages/api/deploy/compose/[refreshToken].ts` -- the gate, plus `rejectComposeDeployHookImage(...)`. A supplied image is +- `rejectComposeDeployHookImage(...)`, **then** the gate. A supplied image is **rejected with a 400 while the organization enforces**, and ignored otherwise — see Known gap. +- the ordering argument above applies here with more force: this refuses *every* + body carrying an image while enforcing, so a CI job that standardises on + always posting one would otherwise coalesce the queue and 400 on every single + push, for ever. ### `apps/dokploy/server/queues/queueSetup.ts` @@ -559,6 +602,8 @@ can be check-gated"). | `rollback-by-digest.test.ts` | rollback to a stored digest, and the refusal when there is none | | `required-checks-support.test.ts` | that a unit with no GitHub App is refused a required check at the API boundary, and that clearing one is always allowed | | `required-checks-before-build.test.ts` | that the checks gate is policy-gated, runs before the build on the freshly cloned sha, and is a no-op that executes nothing while the policy is off | +| `gate-audit-and-registry.test.ts` | that a derived watch-path skip is audited, and that the deploy-hook allowlist follows the registry an enforced build publishes to | +| `hook-body-before-coalescing.test.ts` | that a refused deploy-hook body never coalesces the unit's queue | | `compose-required-checks.test.ts` | that a compose unit's required checks are honoured, that the gate is default-off, and that it never consults exclusions or break-glass | | `gitlab-route-gate.test.ts` | that the GitLab push webhook consults the gate for both unit types, and reads `[skip deploy]` from the commit rather than the job title | | `deploy-path.integration.test.ts` | the real deploy path end to end, with docker, ssh and git mocked | diff --git a/packages/server/src/services/build-policy/apply.ts b/packages/server/src/services/build-policy/apply.ts index b8f0cffdf1..c5a9436021 100644 --- a/packages/server/src/services/build-policy/apply.ts +++ b/packages/server/src/services/build-policy/apply.ts @@ -86,6 +86,36 @@ export const registryForAuth = async (registryId: string) => { return rest; }; +/** + * The registry fields to spread onto the application object handed to + * `mechanizeDockerContainer`, so the deploy host authenticates against the + * registry the digest **actually lives on**. + * + * Round-2 review finding D. Both pin sites used to only *fill in* + * `buildRegistry` when the unit had none, and never touched `registry`. But + * `getAuthConfig` (`utils/builders/index.ts`) tests `registry` in an `else if` + * that precedes the `else if (buildRegistry)` branch, so a non-null `registry` + * wins and `buildRegistry` is never consulted. A unit carrying a stale + * `registryId` from a previous Docker-provider configuration — the column is not + * cleared on a source-type change — therefore pulled the org registry's digest + * with the other registry's credentials, and failed at the swarm update. + * + * So when there is a policy registry, it is authoritative for this deploy: + * `registry` is nulled and `buildRegistry` carries it. When there is not, + * nothing is changed and the unit's own configuration stands. + * + * `registry` is nulled rather than overwritten because `getAuthConfig`'s + * `sourceType === "docker"` branch also reads it, and this application is not + * being deployed from a Docker source; the `buildRegistry` branch is the one + * that means "the registry this build published to". + */ +export const authForPublishedRegistry = async ( + registryId: string | null, +): Promise<{ registry?: null; buildRegistry?: unknown }> => { + if (!registryId) return {}; + return { registry: null, buildRegistry: await registryForAuth(registryId) }; +}; + const LOCAL_PLAN = ( reason: string, settings: BuildPolicySettings | null, @@ -619,10 +649,6 @@ export const prepareBuildPolicyDeploy = async < return { ...application, buildPolicyImage: published.ref, - // The deploy host has to authenticate to pull the digest. When the unit - // itself has no registry configured, borrow the org one for auth only. - buildRegistry: - application.buildRegistry ?? - (plan.registryId ? await registryForAuth(plan.registryId) : null), + ...(await authForPublishedRegistry(plan.registryId)), }; }; diff --git a/packages/server/src/services/build-policy/pinned-deploy.ts b/packages/server/src/services/build-policy/pinned-deploy.ts index e2f526b056..8ad4f8cadf 100644 --- a/packages/server/src/services/build-policy/pinned-deploy.ts +++ b/packages/server/src/services/build-policy/pinned-deploy.ts @@ -103,12 +103,19 @@ export const deployPinnedApplicationImage = async ({ imageDigest: pinnedImage.digest, }); + // Finding D: the registry the digest actually lives on is authoritative + // for this deploy, so `registry` is nulled rather than left to win the + // `else if` chain in `getAuthConfig`. See `authForPublishedRegistry`. + const pullRegistry = await findRegistryForHost( + organizationId, + pinnedImage.ref, + ); await mechanizeDockerContainer({ ...application, buildPolicyImage: pinnedImage.ref, - buildRegistry: - application.buildRegistry ?? - (await findRegistryForHost(organizationId, pinnedImage.ref)), + ...(pullRegistry + ? { registry: null, buildRegistry: pullRegistry } + : { buildRegistry: application.buildRegistry ?? null }), }); const stability = await waitForSwarmServiceStable(application.appName, { diff --git a/packages/server/src/services/build-policy/webhook.ts b/packages/server/src/services/build-policy/webhook.ts index 8b9f01a4bc..f8604b8623 100644 --- a/packages/server/src/services/build-policy/webhook.ts +++ b/packages/server/src/services/build-policy/webhook.ts @@ -127,11 +127,25 @@ export const buildPolicyDeployGate = async ({ composePath: unit.composePath, }); if (!shouldDeploy(paths, changedFiles)) { - return { - deploy: false, - reason: "watch_paths", - message: `Deployment skipped: no changed file matched the derived watch paths (${paths.join(", ")})`, - }; + const message = `Deployment skipped: no changed file matched the derived watch paths (${paths.join(", ")})`; + // Audited for the same reason the skip marker is, and with more force: + // nobody asked for a derived watch path, so "why did my push not + // deploy" has no other answer at all. Without this row the only trace + // is a 301 on a webhook delivery nobody reads. Round-2 review + // finding B. + await recordBuildPolicyAudit({ + organizationId, + action: "deploy_skipped", + applicationId: unitType === "application" ? unit.unitId : null, + composeId: unitType === "compose" ? unit.unitId : null, + reason: message, + metadata: { + unitName: unit.unitName, + derivedWatchPaths: paths, + changedFiles, + }, + }); + return { deploy: false, reason: "watch_paths", message }; } } @@ -172,7 +186,11 @@ export const deployHookBodyHasImage = (body: unknown): boolean => { export interface DeployHookImageUnit { organizationId: string | null; appName: string; - /** The unit's own registry, then its build registry, then the org default. */ + /** + * Fallbacks only. The org default is preferred, because that is the + * repository an enforced build publishes to; these are consulted only when + * the organization has no default at all. + */ registryId?: string | null; buildRegistryId?: string | null; } @@ -193,8 +211,17 @@ export const resolveDeployHookImage = async ( try { // Gate 2: exactly one acceptable repository, the unit's own. + // + // The organization default comes FIRST because that is where an enforced + // build publishes: `decideBuildPolicy` uses `settings.defaultRegistryId` + // and consults neither `unit.registryId` nor `unit.buildRegistryId`. With + // the unit's own registry first, a unit that had one would have had the + // digest the enforced build just published *rejected*, while a digest on + // a repository the enforced path never writes to was accepted. The + // allowlist and the publish target must not be able to diverge. + // Round-2 review finding D. const registryId = - unit.registryId ?? unit.buildRegistryId ?? settings.defaultRegistryId; + settings.defaultRegistryId ?? unit.registryId ?? unit.buildRegistryId; if (!registryId) { return { ok: false, From dbd68b7bfbb0094180afb4c2bea790767cf544be Mon Sep 17 00:00:00 2001 From: AminDhouib Date: Thu, 10 Sep 2026 19:27:04 -0400 Subject: [PATCH 15/18] fix(build-policy): gate the compose redeploy path too (round 3, finding H) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `waitForComposeRequiredChecks` had exactly one call site, inside `runComposeBuild`. `rebuildCompose` — the Redeploy button, reached through `compose.redeploy` and the queue's `type: "redeploy"` arm — has its own inlined pipeline and never called it, while the README table, the `policy.ts` comment and the PR body all said compose gets `requiredChecks` unqualified. This was worse than a stale sentence, because the two halves interact into a trap. `runComposeBuild` clones BEFORE it gates, so a refused deploy leaves the unchecked commit in the code directory. `rebuildCompose` does not re-clone; it builds whatever is on disk. So: push a commit that fails the check, the deploy is correctly refused and audited, and then anyone clicking Redeploy ships exactly the commit the gate just rejected, with no check, no audit row and no warning. `rebuildApplication` never had this hole — it calls the gate, skips the clone because its command is still the bare prefix, re-reads the sha from the existing checkout and refuses again. One call in `rebuildCompose`, in the same position as the other one: after the patches step, ahead of the `down --volumes` teardown and the build, so a refused check still cannot leave the stack torn down. Docs corrected rather than left to drift: the README table row now says deploy, redeploy and previews and names both call sites, the `compose-checks.ts` docstring lists both and says a third pipeline would need its own, and the `policy.ts` comment matches. Tests: `__test__/build-policy/compose-redeploy-gate.test.ts`, 5 assertions driving the real `rebuildCompose`, including the end-to-end trap — a redeploy of a commit the push gate refused must refuse — and the ordering property that the gate runs before both the teardown and the build. Four of the five failed against the old code before the fix. Build-policy suite 18 files / 294 passing. --- .../compose-redeploy-gate.test.ts | 195 ++++++++++++++++++ .../src/services/build-policy/README.md | 35 +++- .../services/build-policy/compose-checks.ts | 20 +- .../src/services/build-policy/policy.ts | 3 +- packages/server/src/services/compose.ts | 16 ++ 5 files changed, 255 insertions(+), 14 deletions(-) create mode 100644 apps/dokploy/__test__/build-policy/compose-redeploy-gate.test.ts diff --git a/apps/dokploy/__test__/build-policy/compose-redeploy-gate.test.ts b/apps/dokploy/__test__/build-policy/compose-redeploy-gate.test.ts new file mode 100644 index 0000000000..4f1779951f --- /dev/null +++ b/apps/dokploy/__test__/build-policy/compose-redeploy-gate.test.ts @@ -0,0 +1,195 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Round-3 review finding H, the blocker. + * + * The compose `requiredChecks` gate added for round-2 finding A had exactly one + * call site, inside `runComposeBuild`. `rebuildCompose` — the Redeploy button, + * reached through `compose.redeploy` and the queue's `type: "redeploy"` arm — + * has its own inlined pipeline and never called it, while the README table, the + * `policy.ts` comment and the PR body all said compose gets `requiredChecks` + * with no qualification. + * + * The two halves interact into a trap, which is why this is more than a stale + * sentence. `runComposeBuild` clones **before** it gates, so a refused deploy + * leaves the unchecked commit in the code directory. `rebuildCompose` does not + * re-clone; it builds whatever is on disk. So: push a commit that fails the + * check, the deploy is correctly refused, and then anyone clicking Redeploy + * ships exactly the commit the gate just rejected — no check, no audit row, no + * warning. + * + * The application path never had this hole: `rebuildApplication` calls the gate, + * skips the clone because its command is still the bare prefix, re-reads the sha + * from the existing checkout — the refused commit — and refuses again. These + * tests pin the same property for compose. + */ + +const mocks = vi.hoisted(() => ({ + waitForComposeRequiredChecks: vi.fn().mockResolvedValue(undefined), + execAsync: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }), + execAsyncRemote: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }), + getBuildComposeCommand: vi.fn().mockResolvedValue("docker compose up -d;"), + getBackupCurrentDeploymentCommand: vi.fn(() => "true;"), + getRollbackMarkerProbeCommand: vi.fn(() => "true;"), + generateApplyPatchesCommand: vi.fn().mockResolvedValue(""), + createDeploymentCompose: vi.fn(), + updateDeploymentStatus: vi.fn(), + updateCompose: vi.fn(), + composeFindFirst: vi.fn(), +})); + +vi.mock("@dokploy/server/services/build-policy/compose-checks", () => ({ + waitForComposeRequiredChecks: mocks.waitForComposeRequiredChecks, +})); + +vi.mock("@dokploy/server/utils/process/execAsync", async () => { + const actual = await vi.importActual< + typeof import("@dokploy/server/utils/process/execAsync") + >("@dokploy/server/utils/process/execAsync"); + return { + ...actual, + execAsync: mocks.execAsync, + execAsyncRemote: mocks.execAsyncRemote, + }; +}); + +vi.mock("@dokploy/server/utils/builders/compose", () => ({ + getBuildComposeCommand: mocks.getBuildComposeCommand, + getBackupCurrentDeploymentCommand: mocks.getBackupCurrentDeploymentCommand, + getRollbackMarkerProbeCommand: mocks.getRollbackMarkerProbeCommand, + getCreateEnvFileCommand: vi.fn(() => ""), +})); + +vi.mock("@dokploy/server/services/patch", () => ({ + generateApplyPatchesCommand: mocks.generateApplyPatchesCommand, +})); + +vi.mock("@dokploy/server/services/deployment", async () => { + const actual = await vi.importActual< + typeof import("@dokploy/server/services/deployment") + >("@dokploy/server/services/deployment"); + return { + ...actual, + createDeploymentCompose: mocks.createDeploymentCompose, + updateDeploymentStatus: mocks.updateDeploymentStatus, + }; +}); + +import { rebuildCompose } from "@dokploy/server/services/compose"; + +const COMPOSE = { + composeId: "compose-1", + appName: "sendly-stack", + name: "Sendly Stack", + sourceType: "github", + composeType: "docker-compose", + composePath: "./docker-compose.yml", + requiredChecks: ["build"], + githubId: "gh-1", + owner: "DevinoSolutions", + repository: "sendly", + customGitUrl: null, + serverId: null, + environment: { project: { organizationId: "org-1" } }, +}; + +const rebuild = () => + rebuildCompose({ + composeId: "compose-1", + titleLog: "Rebuild deployment", + descriptionLog: "", + }); + +describe("finding H — a compose redeploy is check-gated too", () => { + beforeEach(async () => { + vi.clearAllMocks(); + mocks.waitForComposeRequiredChecks.mockResolvedValue(undefined); + mocks.execAsync.mockResolvedValue({ stdout: "", stderr: "" }); + mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" }); + mocks.getBuildComposeCommand.mockResolvedValue("docker compose up -d;"); + mocks.generateApplyPatchesCommand.mockResolvedValue(""); + mocks.createDeploymentCompose.mockResolvedValue({ + deploymentId: "dep-1", + logPath: "/var/log/dep-1.log", + }); + const { db } = await import("@dokploy/server/db"); + vi.mocked(db.query.compose.findFirst).mockResolvedValue(COMPOSE as any); + }); + + it("consults the compose required-checks gate on a redeploy", async () => { + await rebuild(); + + expect(mocks.waitForComposeRequiredChecks).toHaveBeenCalledTimes(1); + expect(mocks.waitForComposeRequiredChecks).toHaveBeenCalledWith( + expect.objectContaining({ + compose: expect.objectContaining({ composeId: "compose-1" }), + serverId: null, + }), + ); + }); + + /** + * The trap the review described, end to end: the push was refused, the + * unchecked commit is sitting in the code directory, and Redeploy must not + * ship it. + */ + it("refuses the redeploy of a commit the push gate already rejected", async () => { + mocks.waitForComposeRequiredChecks.mockRejectedValue( + new Error("Required GitHub checks failed on org/repo@abc123: build"), + ); + + await expect(rebuild()).rejects.toThrow("Required GitHub checks failed"); + + // The build command was never even assembled, let alone run. + expect(mocks.getBuildComposeCommand).not.toHaveBeenCalled(); + }); + + it("gates before the build step runs", async () => { + const order: string[] = []; + mocks.waitForComposeRequiredChecks.mockImplementation(async () => { + order.push("gate"); + }); + mocks.getBuildComposeCommand.mockImplementation(async () => { + order.push("build"); + return "docker compose up -d;"; + }); + + await rebuild(); + + expect(order).toEqual(["gate", "build"]); + }); + + /** + * Same placement rule as the deploy path: a refused check must not leave the + * stack torn down, so the gate runs ahead of `down --volumes`. + */ + it("gates before a freshVolumes teardown, so a refusal leaves the stack up", async () => { + mocks.waitForComposeRequiredChecks.mockRejectedValue( + new Error("Required GitHub checks failed"), + ); + + await expect( + rebuildCompose({ + composeId: "compose-1", + titleLog: "Rebuild deployment", + descriptionLog: "", + freshVolumes: true, + }), + ).rejects.toThrow("Required GitHub checks failed"); + + const ran = [ + ...mocks.execAsync.mock.calls, + ...mocks.execAsyncRemote.mock.calls, + ] + .map((call) => String(call[call.length - 1])) + .join("\n"); + expect(ran).not.toContain("down --volumes"); + }); + + it("runs the redeploy normally when the checks pass", async () => { + await rebuild(); + + expect(mocks.getBuildComposeCommand).toHaveBeenCalledTimes(1); + expect(mocks.updateDeploymentStatus).toHaveBeenCalledWith("dep-1", "done"); + }); +}); diff --git a/packages/server/src/services/build-policy/README.md b/packages/server/src/services/build-policy/README.md index 1646259f5e..bf6622df5f 100644 --- a/packages/server/src/services/build-policy/README.md +++ b/packages/server/src/services/build-policy/README.md @@ -259,15 +259,22 @@ the six build types are exactly upstream's. ### `packages/server/src/services/compose.ts` -One call to `waitForComposeRequiredChecks` inside `runComposeBuild`, between the -clone/patches steps and the build step, and ahead of the `down --volumes` step -so a refused check never leaves the stack torn down. `runComposeBuild` already -ran its deploy as discrete `runStep` calls, so this is a single inserted line -rather than a restructure. Plus one import block, marked `Fork module`. +Two calls to `waitForComposeRequiredChecks`, one per compose deploy pipeline: -**Merge note:** if upstream reorders the steps in `runComposeBuild`, the call -must stay after the clone (so the sha is the one being deployed) and before the -build. +- in `runComposeBuild`, between the clone/patches steps and the build step, and + ahead of the `down --volumes` step so a refused check never leaves the stack + torn down. `runComposeBuild` already ran its deploy as discrete `runStep` + calls, so this is a single inserted line rather than a restructure. + `deployCompose` and both compose preview paths reach it. +- in `rebuildCompose`, which has its own inlined pipeline, in the same position + relative to the patches step, the teardown and the build. + +Plus one import block, marked `Fork module`. + +**Merge note:** if upstream reorders the steps in either function, the call must +stay after the clone or the patches (so the sha is the one being deployed) and +before the teardown and the build. If upstream adds a third compose deploy +pipeline, it needs its own call: missing one is round-3 review finding H. ### `apps/dokploy/pages/api/deploy/github.ts` @@ -559,7 +566,7 @@ dangerous direction. | Queue coalescing | **yes** | `buildPolicyDeployGate`, enqueue time | | `[skip deploy]` | **yes** | same gate | | Derived `watchPaths` | **yes** | same gate | -| `requiredChecks` | **yes** | `compose-checks.ts`, between the clone and the build | +| `requiredChecks` | **yes**, on deploy, redeploy and previews | `compose-checks.ts`, called from `runComposeBuild` and from `rebuildCompose`, between the clone and the build | | Exclusions | **no** | nothing to exclude from | | Break-glass | **no** | no relocated build to grant an escape from | | Relocated build, push by sha, deploy by digest | **no** | the Known gap above | @@ -582,6 +589,15 @@ nothing; a non-empty one costs the cached enforcement boolean first) and the same API-boundary validation as an application (see "What a unit needs before it can be check-gated"). +**Every compose deploy path must call it, and there are two.** `deployCompose` +and both compose preview paths go through `runComposeBuild`; `rebuildCompose` +— the Redeploy button — has its own inlined pipeline and needs its own call. +Round-3 review finding H was exactly that call missing, and it was worse than a +stale table row: `runComposeBuild` clones *before* it gates, so a refused deploy +leaves the unchecked commit in the code directory, and an ungated Redeploy would +build precisely the commit the gate had just rejected. If a third compose deploy +path is ever added, it needs the call too. + --- ## Tests @@ -604,6 +620,7 @@ can be check-gated"). | `required-checks-before-build.test.ts` | that the checks gate is policy-gated, runs before the build on the freshly cloned sha, and is a no-op that executes nothing while the policy is off | | `gate-audit-and-registry.test.ts` | that a derived watch-path skip is audited, and that the deploy-hook allowlist follows the registry an enforced build publishes to | | `hook-body-before-coalescing.test.ts` | that a refused deploy-hook body never coalesces the unit's queue | +| `compose-redeploy-gate.test.ts` | that the Redeploy button is check-gated too, so a commit the push gate refused cannot be shipped from the code directory it left behind | | `compose-required-checks.test.ts` | that a compose unit's required checks are honoured, that the gate is default-off, and that it never consults exclusions or break-glass | | `gitlab-route-gate.test.ts` | that the GitLab push webhook consults the gate for both unit types, and reads `[skip deploy]` from the commit rather than the job title | | `deploy-path.integration.test.ts` | the real deploy path end to end, with docker, ssh and git mocked | diff --git a/packages/server/src/services/build-policy/compose-checks.ts b/packages/server/src/services/build-policy/compose-checks.ts index 408470fa71..044387feea 100644 --- a/packages/server/src/services/build-policy/compose-checks.ts +++ b/packages/server/src/services/build-policy/compose-checks.ts @@ -26,10 +26,22 @@ import { * This deliberately does **not** consult exclusions or break-glass, for the * same reason: an exclusion must not silently disable a team's CI gate. * - * Call site: `runComposeBuild`, between the clone/patches steps and the build - * step, which is the compose equivalent of the application path's hook 2a/4. It - * runs on the commit the clone just fetched, so a check that fails or never - * arrives costs no build. + * **Two call sites, and both are needed.** + * + * - `runComposeBuild`, between the clone/patches steps and the build step, + * which is the compose equivalent of the application path's hook 2a/4. It + * runs on the commit the clone just fetched, so a check that fails or never + * arrives costs no build. `deployCompose` and both compose preview paths go + * through here. + * - `rebuildCompose`, in the same position in its own inlined pipeline. This is + * the Redeploy button, and leaving it out was round-3 review finding H. A + * redeploy re-uses whatever is in the code directory, and `runComposeBuild` + * clones *before* it gates, so a refused deploy leaves the unchecked commit + * sitting on disk — Redeploy would then build precisely the commit the gate + * had just rejected, with no check and no audit row. + * + * If a new compose deploy path is ever added, it needs this call too. The + * application side has the same rule for `runBuildPolicyPreBuildGate`. * * **Default-off**, in the same shape as every other touch point: an empty list * (every existing row, since the column is nullable with no default) reads diff --git a/packages/server/src/services/build-policy/policy.ts b/packages/server/src/services/build-policy/policy.ts index 3ac63a8e7c..23a65fd154 100644 --- a/packages/server/src/services/build-policy/policy.ts +++ b/packages/server/src/services/build-policy/policy.ts @@ -93,7 +93,8 @@ export const decideBuildPolicy = ( // // - it DOES get queue coalescing, `[skip deploy]` and derived `watchPaths` // (all at enqueue time, in `buildPolicyDeployGate`), and `requiredChecks` - // (in `compose-checks.ts`, between the clone and the build); + // (in `compose-checks.ts`, between the clone and the build, on the deploy + // path, the redeploy path and both preview paths); // - it does NOT get exclusions or break-glass, and cannot: both decide where // a unit builds, and this early return means a compose unit is never // enforced, so there is nothing to exclude it from. The router refuses a diff --git a/packages/server/src/services/compose.ts b/packages/server/src/services/compose.ts index 5aac6325e8..b6570c3fe3 100644 --- a/packages/server/src/services/compose.ts +++ b/packages/server/src/services/compose.ts @@ -520,6 +520,22 @@ export const rebuildCompose = async ({ } } + // >>> build-policy hook (compose rebuild): the same required-checks gate + // `runComposeBuild` applies, in the same position — after the patches + // step, ahead of the `down --volumes` step and the build. + // + // A redeploy re-uses whatever is already in the code directory, and + // `runComposeBuild` clones *before* it gates, so a refused deploy leaves + // the unchecked commit on disk. Without this call, Redeploy would build + // exactly the commit the gate had just rejected. `rebuildApplication` + // never had that hole; this is compose catching up. Round-3 review + // finding H. + await waitForComposeRequiredChecks({ + compose, + serverId: compose.serverId, + }); + // <<< build-policy hook (compose rebuild) + if (freshVolumes && compose.composeType === "docker-compose") { const downCommand = `set -e; env -i PATH="$PATH" docker compose -p ${compose.appName} down --volumes 2>&1 || true;`; const downWithLog = `(${downCommand}) >> ${deployment.logPath} 2>&1`; From f1ed8c2ad8caf792c60b3323a0a2ed3f4671c988 Mon Sep 17 00:00:00 2001 From: AminDhouib Date: Thu, 10 Sep 2026 19:33:47 -0400 Subject: [PATCH 16/18] fix(build-policy): resolve the hook allowlist through the plan (round 3, I and J) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I — round 2's finding D swapped the deploy-hook allowlist precedence from "the unit's own registry first" to "the organization default first". That fixed the enforced case and broke the other one. Gate 1 of `resolveDeployHookImage` tests whether the ORGANIZATION enforces, not whether this unit is enforced, so the capability is live for units the policy leaves local — excluded, break-glassed, or not GitHub sourced — and those publish to their own registry. Their own repository was then rejected. A fixed precedence gets one of the two cases wrong whichever way it points, so the allowlist now asks the plan. New `previewBuildPolicyDecision` in `resolve.ts` is the read-only sibling of `resolveBuildPolicy`: same reads, same decision, but it spends no break-glass grant and writes no audit row. Both matter here. The grant is one-shot and belongs to the next deploy, so validating a request body must not consume it, and an audit row per webhook delivery would be noise. `resolveBuildPolicy` now calls it and keeps the two side effects, so there is still one source of truth for the decision. J — round 2 nit N2, promoted by the review because the D reorder made it organization-wide. `registryUrl` is `notNull().default("")` and the empty string is the supported Docker Hub configuration, not a misconfiguration, so `getRegistryTag` legitimately returns `prefix/app` with no host. `hook-body.ts` required a host, so once the allowlist resolved through the org default, an organization whose default registry is Docker Hub rejected EVERY unit's deploy-hook body. The check bought something when the allowlist was a host allowlist; under whole-repository equality a hostless reference can only match a hostless allowed repository, which is the same repository. Dropped, with the reasoning recorded where the check used to be. Tests: `__test__/build-policy/hook-allowlist-follows-plan.test.ts`, 11 assertions. Verified negative first: with the old precedence and the old host check restored, the three finding-I cases and the finding-J case fail, and the rest still pass. The two round-2 finding D allowlist tests are superseded by these and were removed from `gate-audit-and-registry.test.ts`, whose header now points at the new file; its finding-B coverage is untouched. Build-policy suite 19 files / 301 passing. --- .../gate-audit-and-registry.test.ts | 121 +------- .../hook-allowlist-follows-plan.test.ts | 293 ++++++++++++++++++ .../policy-off-is-upstream.test.ts | 18 +- .../pages/api/deploy/[refreshToken].ts | 8 + .../src/services/build-policy/README.md | 42 ++- .../src/services/build-policy/hook-body.ts | 24 +- .../src/services/build-policy/resolve.ts | 33 +- .../src/services/build-policy/webhook.ts | 57 +++- 8 files changed, 453 insertions(+), 143 deletions(-) create mode 100644 apps/dokploy/__test__/build-policy/hook-allowlist-follows-plan.test.ts diff --git a/apps/dokploy/__test__/build-policy/gate-audit-and-registry.test.ts b/apps/dokploy/__test__/build-policy/gate-audit-and-registry.test.ts index d674530f82..75402d8f0e 100644 --- a/apps/dokploy/__test__/build-policy/gate-audit-and-registry.test.ts +++ b/apps/dokploy/__test__/build-policy/gate-audit-and-registry.test.ts @@ -9,9 +9,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; * C — coalescing ran before the deploy-hook body was validated, so a malformed * or foreign-repository body dropped every waiting deploy for the unit and * then enqueued nothing. - * D — the deploy-hook allowlist was built from the unit's own registry first, - * while an enforced build always publishes to the organization default, so - * the two could name different repositories. + * + * D's allowlist half lived here too, until round-3 finding I showed the fix had + * inverted the divergence rather than removed it. It now resolves through + * `previewBuildPolicyDecision`, and all of its coverage moved to + * `hook-allowlist-follows-plan.test.ts`, which has the mocks that decision path + * needs. What stays here is B, plus D's other half — the pull credentials — in + * `deploy-path.integration.test.ts`. */ const mocks = vi.hoisted(() => ({ @@ -19,7 +23,6 @@ const mocks = vi.hoisted(() => ({ findBuildPolicySettings: vi.fn(), recordBuildPolicyAudit: vi.fn().mockResolvedValue(undefined), environmentsFindFirst: vi.fn(), - findRegistryByIdWithCredentials: vi.fn(), })); vi.mock("@dokploy/server/db", () => ({ @@ -49,14 +52,7 @@ vi.mock("@dokploy/server/services/build-policy/audit", () => ({ listBuildPolicyAudit: vi.fn(), })); -vi.mock("@dokploy/server/services/registry", () => ({ - findRegistryByIdWithCredentials: mocks.findRegistryByIdWithCredentials, -})); - -import { - buildPolicyDeployGate, - resolveDeployHookImage, -} from "@dokploy/server/services/build-policy/webhook"; +import { buildPolicyDeployGate } from "@dokploy/server/services/build-policy/webhook"; const ENFORCING = { buildPolicySettingsId: "s-1", @@ -178,104 +174,3 @@ describe("finding B — a derived watch-path skip is audited", () => { expect(mocks.recordBuildPolicyAudit).not.toHaveBeenCalled(); }); }); - -describe("finding D — the allowlist follows the publish target", () => { - const registry = (registryId: string, url: string) => ({ - registryId, - registryUrl: url, - imagePrefix: null, - username: "devino", - password: "unused", - registryType: "cloud", - }); - - it("validates against the organization default, which is where an enforced build publishes", async () => { - mocks.findRegistryByIdWithCredentials.mockResolvedValue( - registry("reg-default", "ghcr.io") as any, - ); - - const result = await resolveDeployHookImage( - { - organizationId: "org-1", - appName: "sendly-web", - // A stale registry from a previous Docker-provider configuration. - registryId: "reg-stale", - buildRegistryId: null, - }, - { - image: "ghcr.io/devino/sendly-web", - digest: `sha256:${"a".repeat(64)}`, - }, - ); - - expect(mocks.findRegistryByIdWithCredentials).toHaveBeenCalledWith( - "reg-default", - ); - expect(result.ok).toBe(true); - }); - - it("falls back to the unit's own registry only when the org has no default", async () => { - mocks.findBuildPolicySettings.mockResolvedValue({ - ...ENFORCING, - defaultRegistryId: null, - }); - mocks.findRegistryByIdWithCredentials.mockResolvedValue( - registry("reg-own", "registry.example.com") as any, - ); - - await resolveDeployHookImage( - { - organizationId: "org-1", - appName: "sendly-web", - registryId: "reg-own", - buildRegistryId: "reg-build", - }, - { - image: "registry.example.com/devino/sendly-web", - digest: `sha256:${"b".repeat(64)}`, - }, - ); - - expect(mocks.findRegistryByIdWithCredentials).toHaveBeenCalledWith( - "reg-own", - ); - }); - - it("still refuses a digest on a repository the enforced build never writes to", async () => { - mocks.findRegistryByIdWithCredentials.mockResolvedValue( - registry("reg-default", "ghcr.io") as any, - ); - - const result = await resolveDeployHookImage( - { - organizationId: "org-1", - appName: "sendly-web", - registryId: "reg-stale", - buildRegistryId: null, - }, - { - image: "ghcr.io/someone-else/sendly-web", - digest: `sha256:${"c".repeat(64)}`, - }, - ); - - expect(result.ok).toBe(false); - }); - - it("is still inert while the policy is off", async () => { - mocks.isBuildPolicyEnforcedAnywhere.mockResolvedValue(false); - - const result = await resolveDeployHookImage( - { - organizationId: "org-1", - appName: "sendly-web", - registryId: "reg-stale", - buildRegistryId: null, - }, - { image: "ghcr.io/anyone/anything", digest: `sha256:${"d".repeat(64)}` }, - ); - - expect(result.ok).toBe(true); - expect(mocks.findRegistryByIdWithCredentials).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/dokploy/__test__/build-policy/hook-allowlist-follows-plan.test.ts b/apps/dokploy/__test__/build-policy/hook-allowlist-follows-plan.test.ts new file mode 100644 index 0000000000..7293decbf6 --- /dev/null +++ b/apps/dokploy/__test__/build-policy/hook-allowlist-follows-plan.test.ts @@ -0,0 +1,293 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Round-3 review findings I and J, both consequences of round 2's finding D. + * + * I — D swapped the allowlist precedence from "the unit's own registry first" + * to "the organization default first". For an **enforced** unit that is + * exactly right and it fixed the case round 2 named. But gate 1 of + * `resolveDeployHookImage` tests whether the *organization* enforces, not + * whether *this unit* is enforced, so the capability is live for units the + * policy leaves local — excluded, break-glassed, non-GitHub-sourced — and + * those publish to their own registry. Their own repository was then + * rejected. The divergence was inverted rather than removed. + * J — nit N2, promoted. An empty `registryUrl` is the supported Docker Hub + * configuration, not a misconfiguration, and `getRegistryTag` returns a + * repository with no host for it. `hook-body.ts` required a host, so after + * the D reorder an organization whose default registry is Docker Hub had + * EVERY unit's deploy-hook body rejected. + * + * The fix for I is to resolve the registry the way the plan would, which is + * what `previewBuildPolicyDecision` does — read-only, spending no break-glass + * grant and writing no audit row. The fix for J is to drop the host + * requirement, which bought something when the allowlist was a host allowlist + * and buys nothing now that whole repositories are compared for equality. + */ + +const mocks = vi.hoisted(() => ({ + isBuildPolicyEnforcedAnywhere: vi.fn(), + findBuildPolicySettings: vi.fn(), + findRegistryByIdWithCredentials: vi.fn(), + isUnitExcluded: vi.fn(), + findPendingBreakGlass: vi.fn(), + consumeBreakGlass: vi.fn(), + recordBuildPolicyAudit: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@dokploy/server/services/build-policy/settings", async () => { + const actual = await vi.importActual< + typeof import("@dokploy/server/services/build-policy/settings") + >("@dokploy/server/services/build-policy/settings"); + return { + ...actual, + isBuildPolicyEnforcedAnywhere: mocks.isBuildPolicyEnforcedAnywhere, + findBuildPolicySettings: mocks.findBuildPolicySettings, + }; +}); + +vi.mock("@dokploy/server/services/build-policy/exclusions", () => ({ + isUnitExcluded: mocks.isUnitExcluded, +})); + +vi.mock("@dokploy/server/services/build-policy/audit", () => ({ + findPendingBreakGlass: mocks.findPendingBreakGlass, + consumeBreakGlass: mocks.consumeBreakGlass, + recordBuildPolicyAudit: mocks.recordBuildPolicyAudit, + grantBreakGlass: vi.fn(), + listBuildPolicyAudit: vi.fn(), +})); + +vi.mock("@dokploy/server/services/registry", () => ({ + findRegistryByIdWithCredentials: mocks.findRegistryByIdWithCredentials, +})); + +import { resolveDeployHookImage } from "@dokploy/server/services/build-policy/webhook"; + +const ENFORCING = { + buildPolicySettingsId: "s-1", + organizationId: "org-1", + enforceRemoteBuilds: true, + defaultBuildServerId: "srv-1", + defaultRegistryId: "reg-default", + requiredChecksTimeoutMinutes: 5, + createdAt: "", + updatedAt: "", +} as any; + +const registry = (registryId: string, registryUrl: string) => + ({ + registryId, + registryUrl, + imagePrefix: null, + username: "devino", + password: "unused", + registryType: "cloud", + }) as any; + +/** A GitHub-sourced application, so the policy would enforce it. */ +const ENFORCED_UNIT = { + organizationId: "org-1", + appName: "sendly-web", + unitType: "application" as const, + unitId: "app-1", + unitName: "Sendly Web", + sourceType: "github", + customGitUrl: null, + registryId: "reg-own", + buildRegistryId: null, +}; + +const DIGEST = `sha256:${"a".repeat(64)}`; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.isBuildPolicyEnforcedAnywhere.mockResolvedValue(true); + mocks.findBuildPolicySettings.mockResolvedValue(ENFORCING); + mocks.isUnitExcluded.mockResolvedValue(false); + mocks.findPendingBreakGlass.mockResolvedValue(null); + mocks.recordBuildPolicyAudit.mockResolvedValue(undefined); +}); + +describe("finding I — the allowlist follows what the plan would decide", () => { + it("uses the organization default for a unit the policy would enforce", async () => { + mocks.findRegistryByIdWithCredentials.mockResolvedValue( + registry("reg-default", "ghcr.io"), + ); + + const result = await resolveDeployHookImage(ENFORCED_UNIT, { + image: "ghcr.io/devino/sendly-web", + digest: DIGEST, + }); + + expect(mocks.findRegistryByIdWithCredentials).toHaveBeenCalledWith( + "reg-default", + ); + expect(result.ok).toBe(true); + }); + + it("uses the unit's own registry for an EXCLUDED unit — finding I", async () => { + // An excluded unit builds where it always did and publishes to its own + // registry, so its own repository must stay allowed. + mocks.isUnitExcluded.mockResolvedValue(true); + mocks.findRegistryByIdWithCredentials.mockResolvedValue( + registry("reg-own", "registry.example.com"), + ); + + const result = await resolveDeployHookImage(ENFORCED_UNIT, { + image: "registry.example.com/devino/sendly-web", + digest: DIGEST, + }); + + expect(mocks.findRegistryByIdWithCredentials).toHaveBeenCalledWith( + "reg-own", + ); + expect(result.ok).toBe(true); + }); + + it("uses the unit's own registry for a break-glassed unit", async () => { + mocks.findPendingBreakGlass.mockResolvedValue({ + buildPolicyAuditId: "audit-1", + actorEmail: "someone@example.com", + reason: "registry outage", + }); + mocks.findRegistryByIdWithCredentials.mockResolvedValue( + registry("reg-own", "registry.example.com"), + ); + + const result = await resolveDeployHookImage(ENFORCED_UNIT, { + image: "registry.example.com/devino/sendly-web", + digest: DIGEST, + }); + + expect(mocks.findRegistryByIdWithCredentials).toHaveBeenCalledWith( + "reg-own", + ); + expect(result.ok).toBe(true); + }); + + it("never spends the break-glass grant just to validate a body", async () => { + // The grant is one-shot and belongs to the next deploy. Reading it here + // must not consume it, and must not write an audit row either. + mocks.findPendingBreakGlass.mockResolvedValue({ + buildPolicyAuditId: "audit-1", + actorEmail: "someone@example.com", + reason: "registry outage", + }); + mocks.findRegistryByIdWithCredentials.mockResolvedValue( + registry("reg-own", "registry.example.com"), + ); + + await resolveDeployHookImage(ENFORCED_UNIT, { + image: "registry.example.com/devino/sendly-web", + digest: DIGEST, + }); + + expect(mocks.consumeBreakGlass).not.toHaveBeenCalled(); + expect(mocks.recordBuildPolicyAudit).not.toHaveBeenCalled(); + }); + + it("uses the unit's own registry for a non-GitHub-sourced unit", async () => { + mocks.findRegistryByIdWithCredentials.mockResolvedValue( + registry("reg-own", "registry.example.com"), + ); + + const result = await resolveDeployHookImage( + { ...ENFORCED_UNIT, sourceType: "gitlab" }, + { + image: "registry.example.com/devino/sendly-web", + digest: DIGEST, + }, + ); + + expect(mocks.findRegistryByIdWithCredentials).toHaveBeenCalledWith( + "reg-own", + ); + expect(result.ok).toBe(true); + }); + + it("still refuses a repository neither path would ever publish to", async () => { + mocks.findRegistryByIdWithCredentials.mockResolvedValue( + registry("reg-default", "ghcr.io"), + ); + + const result = await resolveDeployHookImage(ENFORCED_UNIT, { + image: "ghcr.io/someone-else/sendly-web", + digest: DIGEST, + }); + + expect(result.ok).toBe(false); + }); + + it("falls back to the unit's own registry when the org has no default", async () => { + mocks.findBuildPolicySettings.mockResolvedValue({ + ...ENFORCING, + defaultRegistryId: null, + }); + mocks.findRegistryByIdWithCredentials.mockResolvedValue( + registry("reg-own", "registry.example.com"), + ); + + const result = await resolveDeployHookImage(ENFORCED_UNIT, { + image: "registry.example.com/devino/sendly-web", + digest: DIGEST, + }); + + expect(result.ok).toBe(true); + }); + + it("is still inert while the policy is off", async () => { + mocks.isBuildPolicyEnforcedAnywhere.mockResolvedValue(false); + + const result = await resolveDeployHookImage(ENFORCED_UNIT, { + image: "ghcr.io/anyone/anything", + digest: DIGEST, + }); + + expect(result.ok).toBe(true); + expect(mocks.findRegistryByIdWithCredentials).not.toHaveBeenCalled(); + expect(mocks.isUnitExcluded).not.toHaveBeenCalled(); + }); +}); + +describe("finding J — a Docker Hub default registry no longer rejects every body", () => { + it("accepts a hostless repository when the allowed repository is hostless too", async () => { + // registryUrl "" is the supported Docker Hub configuration, so + // getRegistryTag returns `prefix/app` with no host. + mocks.findRegistryByIdWithCredentials.mockResolvedValue( + registry("reg-default", ""), + ); + + const result = await resolveDeployHookImage(ENFORCED_UNIT, { + image: "devino/sendly-web", + digest: DIGEST, + }); + + expect(result.ok).toBe(true); + }); + + it("still refuses a different hostless repository", async () => { + mocks.findRegistryByIdWithCredentials.mockResolvedValue( + registry("reg-default", ""), + ); + + const result = await resolveDeployHookImage(ENFORCED_UNIT, { + image: "someone-else/sendly-web", + digest: DIGEST, + }); + + expect(result.ok).toBe(false); + }); + + it("still refuses a hosted repository when the allowed one is hostless", async () => { + mocks.findRegistryByIdWithCredentials.mockResolvedValue( + registry("reg-default", ""), + ); + + const result = await resolveDeployHookImage(ENFORCED_UNIT, { + image: "ghcr.io/devino/sendly-web", + digest: DIGEST, + }); + + expect(result.ok).toBe(false); + }); +}); diff --git a/apps/dokploy/__test__/build-policy/policy-off-is-upstream.test.ts b/apps/dokploy/__test__/build-policy/policy-off-is-upstream.test.ts index 618db4d7de..b7a1feb8db 100644 --- a/apps/dokploy/__test__/build-policy/policy-off-is-upstream.test.ts +++ b/apps/dokploy/__test__/build-policy/policy-off-is-upstream.test.ts @@ -335,7 +335,14 @@ describe("the enqueue gate while the policy is off", () => { describe("the deploy-hook image body while the policy is off", () => { it("is ignored rather than deploying an unbuilt image", async () => { const result = await resolveDeployHookImage( - { organizationId: "org-1", appName: "sendly-web" }, + { + organizationId: "org-1", + appName: "sendly-web", + unitType: "application" as const, + unitId: "app-1", + unitName: "Sendly Web", + sourceType: "github", + }, { image: "ghcr.io/devinosolutions/sendly-web", digest: `sha256:${"a".repeat(64)}`, @@ -346,7 +353,14 @@ describe("the deploy-hook image body while the policy is off", () => { it("does not read the organization's registries", async () => { await resolveDeployHookImage( - { organizationId: "org-1", appName: "sendly-web" }, + { + organizationId: "org-1", + appName: "sendly-web", + unitType: "application" as const, + unitId: "app-1", + unitName: "Sendly Web", + sourceType: "github", + }, { image: "ghcr.io/devinosolutions/sendly-web", digest: `sha256:${"a".repeat(64)}`, diff --git a/apps/dokploy/pages/api/deploy/[refreshToken].ts b/apps/dokploy/pages/api/deploy/[refreshToken].ts index d04cd5ead9..0f8f1700de 100644 --- a/apps/dokploy/pages/api/deploy/[refreshToken].ts +++ b/apps/dokploy/pages/api/deploy/[refreshToken].ts @@ -308,6 +308,14 @@ export default async function handler( appName: application.appName, registryId: application.registryId, buildRegistryId: application.buildRegistryId, + // The allowlist follows what the plan would decide, so it needs + // what the plan reads. Round-3 review finding I. + unitType: "application", + unitId: application.applicationId, + unitName: application.name, + sourceType: application.sourceType, + customGitUrl: application.customGitUrl, + buildServerId: application.buildServerId, }, req.body, ); diff --git a/packages/server/src/services/build-policy/README.md b/packages/server/src/services/build-policy/README.md index bf6622df5f..fb53085949 100644 --- a/packages/server/src/services/build-policy/README.md +++ b/packages/server/src/services/build-policy/README.md @@ -145,7 +145,7 @@ always loads that relation, but a caller with a leaner row plans as | `exclusions.ts` | exclusion list / lookup / add / remove | | `ownership.ts` | asserts a unit id taken from tRPC input belongs to the active organization | | `audit.ts` | append-only trail; break-glass grant, lookup and consumption | -| `resolve.ts` | database-backed wrapper around `policy.ts`; the only place a grant is spent | +| `resolve.ts` | database-backed wrapper around `policy.ts`; the only place a grant is spent. `previewBuildPolicyDecision` is its read-only sibling: same answer, no grant spent, no audit row | | `apply.ts` | the application deploy path: plan, remote tag/push/digest shell, digest read-back, deploy-by-digest preparation | | `image.ts` | `:` tagging, digest validation, digest-marker parsing, `repo@sha256:…` refs | | `hook-body.ts` | validation of a deploy-hook `{image, tag, digest}` body against the unit's own repository | @@ -380,6 +380,45 @@ pre-change behaviour. --- +## The deploy-hook image body, and which repository it may name + +A deploy hook URL is a bearer token pasted into CI configs across the fleet, so +the `{image, tag, digest}` body has two gates: it does nothing while the policy +is off, and the image must be **this unit's own repository**, compared for exact +whole-repository equality. An org-wide *host* allowlist would let any one unit's +token deploy any image on ghcr.io, which is why it is not one. + +"Its own repository" is not a fixed precedence, it is wherever this unit's image +actually lives, so the allowlist asks the plan through +`previewBuildPolicyDecision`: + +- a unit the policy would **enforce** publishes to `settings.defaultRegistryId`, + so that is the allowed repository; +- a unit the policy leaves **local** — excluded, break-glassed, or not GitHub + sourced — publishes to its own `registryId`, exactly as it did before the + fork, so that is the allowed one. + +Gate 1 tests whether the *organization* enforces, not whether this unit does, so +both cases are live and a fixed precedence gets one of them wrong whichever way +it points. Round-2 finding D pointed it at the org default and fixed the +enforced case; round-3 finding I caught the local case it broke. + +`previewBuildPolicyDecision` is read-only on purpose: validating a request body +must not spend the one-shot break-glass grant that belongs to the next deploy, +and must not write an audit row per webhook delivery. + +There is deliberately **no** "must be fully qualified with a registry host" +check. `registryUrl` is `notNull().default("")` and an empty string is the +supported Docker Hub configuration, so `getRegistryTag` legitimately returns +`prefix/app` with no host. Requiring a host meant that an organization whose +default registry is Docker Hub rejected every deploy-hook body once the +allowlist started resolving through that default (round-3 finding J). The check +bought something when the allowlist was a host allowlist; under whole-repository +equality a hostless reference can only match a hostless allowed repository, +which is the same repository. + +--- + ## How the digest crosses hosts The build runs as a detached shell on the build server; its only channel back is @@ -619,6 +658,7 @@ path is ever added, it needs the call too. | `required-checks-support.test.ts` | that a unit with no GitHub App is refused a required check at the API boundary, and that clearing one is always allowed | | `required-checks-before-build.test.ts` | that the checks gate is policy-gated, runs before the build on the freshly cloned sha, and is a no-op that executes nothing while the policy is off | | `gate-audit-and-registry.test.ts` | that a derived watch-path skip is audited, and that the deploy-hook allowlist follows the registry an enforced build publishes to | +| `hook-allowlist-follows-plan.test.ts` | that the deploy-hook allowlist names the repository this unit's image will actually live on, for enforced and for local units alike, and that a Docker Hub registry with no host is accepted | | `hook-body-before-coalescing.test.ts` | that a refused deploy-hook body never coalesces the unit's queue | | `compose-redeploy-gate.test.ts` | that the Redeploy button is check-gated too, so a commit the push gate refused cannot be shipped from the code directory it left behind | | `compose-required-checks.test.ts` | that a compose unit's required checks are honoured, that the gate is default-off, and that it never consults exclusions or break-glass | diff --git a/packages/server/src/services/build-policy/hook-body.ts b/packages/server/src/services/build-policy/hook-body.ts index 6ab47ec4d4..5775743e24 100644 --- a/packages/server/src/services/build-policy/hook-body.ts +++ b/packages/server/src/services/build-policy/hook-body.ts @@ -3,7 +3,6 @@ import { assertSafeImageReference, buildDigestRef, isValidDigest, - registryHostOf, repositoryOf, splitRepositoryAndTag, } from "./image"; @@ -66,15 +65,20 @@ export const parseDeployHookImage = ( ); } - const host = registryHostOf(reference); - if (!host) { - throw new BuildPolicyError( - "REGISTRY_NOT_ALLOWED", - `Deploy hook image "${reference}" has no registry host. It must be fully ` + - "qualified and name this unit's own repository.", - ); - } - + // There is deliberately no "must have a registry host" check here. + // + // It bought something when the allowlist was a *host* allowlist. It buys + // nothing now that the comparison below is whole-repository equality: a + // hostless reference can only pass if the allowed repository is itself + // hostless, and then it is the same repository. + // + // Requiring a host was also actively wrong. `registryUrl` is + // `notNull().default("")` and the empty string is the supported Docker Hub + // configuration, not a misconfiguration, so `getRegistryTag` legitimately + // returns `prefix/app` with no host. Round-2 nit N2 declined this as needing + // an odd row; round-3 finding J showed that after the allowlist started + // resolving through the organization default, an organization whose default + // registry is Docker Hub had *every* unit's deploy-hook body rejected. const repository = repositoryOf(reference); if (!allowedRepositories.includes(repository)) { throw new BuildPolicyError( diff --git a/packages/server/src/services/build-policy/resolve.ts b/packages/server/src/services/build-policy/resolve.ts index 39fb38b998..105226cee9 100644 --- a/packages/server/src/services/build-policy/resolve.ts +++ b/packages/server/src/services/build-policy/resolve.ts @@ -1,4 +1,7 @@ -import type { BuildPolicySettings } from "@dokploy/server/db/schema"; +import type { + BuildPolicyAudit, + BuildPolicySettings, +} from "@dokploy/server/db/schema"; import { consumeBreakGlass, findPendingBreakGlass, @@ -35,10 +38,22 @@ export interface ResolvedBuildPolicy { settings: BuildPolicySettings | null; } -export const resolveBuildPolicy = async ( +/** + * The decision, read-only: no grant is spent and no audit row is written. + * + * `resolveBuildPolicy` below is the deploy-path entry point and has both of + * those side effects, which is right for a deploy and wrong for anything that + * merely wants to *know* what the plan would be. Round-3 review finding I: the + * deploy-hook allowlist has to resolve the same registry the plan would, and it + * must not consume a one-shot break-glass grant to do it — the grant belongs to + * the next deploy. + * + * Returns the grant alongside the decision so `resolveBuildPolicy` can spend it + * without reading it twice. + */ +export const previewBuildPolicyDecision = async ( unit: BuildPolicyUnitRef, - { consume = true }: { consume?: boolean } = {}, -): Promise => { +): Promise => { const settings = await findBuildPolicySettings(unit.organizationId); // Nothing else needs reading when the policy is off, which is the common @@ -46,6 +61,7 @@ export const resolveBuildPolicy = async ( if (!settings?.enforceRemoteBuilds) { return { settings, + grant: null, decision: decideBuildPolicy({ unit, settings, @@ -81,6 +97,15 @@ export const resolveBuildPolicy = async ( : null, }); + return { settings, decision, grant }; +}; + +export const resolveBuildPolicy = async ( + unit: BuildPolicyUnitRef, + { consume = true }: { consume?: boolean } = {}, +): Promise => { + const { settings, decision, grant } = await previewBuildPolicyDecision(unit); + if ( consume && grant && diff --git a/packages/server/src/services/build-policy/webhook.ts b/packages/server/src/services/build-policy/webhook.ts index f8604b8623..80dd9d38ea 100644 --- a/packages/server/src/services/build-policy/webhook.ts +++ b/packages/server/src/services/build-policy/webhook.ts @@ -12,6 +12,7 @@ import { import { parseDeployHookImage } from "./hook-body"; import { toPinnedImageJob } from "./pinned-deploy"; import type { BuildPolicyUnitType } from "./policy"; +import { previewBuildPolicyDecision } from "./resolve"; import { findBuildPolicySettings, isBuildPolicyEnforcedAnywhere, @@ -187,12 +188,18 @@ export interface DeployHookImageUnit { organizationId: string | null; appName: string; /** - * Fallbacks only. The org default is preferred, because that is the - * repository an enforced build publishes to; these are consulted only when - * the organization has no default at all. + * Where this unit's image lives when the policy does **not** relocate its + * build — an excluded unit, a break-glassed one, or a non-GitHub source. */ registryId?: string | null; buildRegistryId?: string | null; + /** Everything the plan needs to say whether this unit would be enforced. */ + unitType: BuildPolicyUnitType; + unitId: string; + unitName: string; + sourceType: string; + customGitUrl?: string | null; + buildServerId?: string | null; } export const resolveDeployHookImage = async ( @@ -210,18 +217,42 @@ export const resolveDeployHookImage = async ( if (!settings?.enforceRemoteBuilds) return { ok: true }; try { - // Gate 2: exactly one acceptable repository, the unit's own. + // Gate 2: exactly one acceptable repository, the one this unit's image + // will actually live on. + // + // That is not a fixed precedence, it is whatever the plan would decide, + // so ask the plan. An enforced unit publishes to + // `settings.defaultRegistryId` (`policy.ts`), and a unit the policy + // leaves local — excluded, break-glassed, or not GitHub sourced — + // publishes to its own registry exactly as it did before the fork. // - // The organization default comes FIRST because that is where an enforced - // build publishes: `decideBuildPolicy` uses `settings.defaultRegistryId` - // and consults neither `unit.registryId` nor `unit.buildRegistryId`. With - // the unit's own registry first, a unit that had one would have had the - // digest the enforced build just published *rejected*, while a digest on - // a repository the enforced path never writes to was accepted. The - // allowlist and the publish target must not be able to diverge. - // Round-2 review finding D. + // Round-2 finding D fixed this in one direction by putting the org + // default first, and round-3 finding I caught the other: gate 1 above + // tests whether the *organization* enforces, not whether *this unit* is + // enforced, so the capability is live for local units too and a fixed + // precedence gets one of the two cases wrong whichever way it points. + // + // `previewBuildPolicyDecision` is the read-only sibling of + // `resolveBuildPolicy`: it spends no break-glass grant and writes no + // audit row, because validating a request body must not consume a + // one-shot grant that belongs to the next deploy. + const { decision } = await previewBuildPolicyDecision({ + unitType: unit.unitType, + unitId: unit.unitId, + unitName: unit.unitName, + organizationId: unit.organizationId, + sourceType: unit.sourceType, + customGitUrl: unit.customGitUrl, + buildServerId: unit.buildServerId, + buildRegistryId: unit.buildRegistryId, + }); + const registryId = - settings.defaultRegistryId ?? unit.registryId ?? unit.buildRegistryId; + decision.mode === "remote" + ? decision.registryId + : (unit.registryId ?? + unit.buildRegistryId ?? + settings.defaultRegistryId); if (!registryId) { return { ok: false, From 5b460d6046014bd28772f42e5f66bf252bde679f Mon Sep 17 00:00:00 2001 From: AminDhouib Date: Thu, 10 Sep 2026 19:42:14 -0400 Subject: [PATCH 17/18] fix(build-policy): derive watch paths from the unit's own build path (round 3, K) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 2's finding G added a build-policy gate to the GitLab webhook route. The gate derives default `watchPaths` when a unit has none, and `deriveDefaultWatchPaths` read `buildPath` — the GitHub column — for every unit. `watch-paths.ts` claimed to match `getBuildAppDirectory` (`utils/filesystem/directory.ts:104-123`), and it matched it for `sourceType: "github"` only: that function selects `gitlabBuildPath`, `bitbucketBuildPath`, `giteaBuildPath`, `dropBuildPath` or `customGitBuildPath` per source type. Mostly benign, because `buildPath` defaults to `"/"`, which derives to `**` and deploys everything. The case that bites is a unit migrated from GitHub to GitLab: `saveGitlabProvider` writes `gitlabBuildPath` and never resets `buildPath`, so the stale GitHub path survived as the derived watch path and pushes touching the real build path were silently skipped. Adding the GitLab gate is what made a pre-existing mismatch reachable on a new route; the shared `[refreshToken]` deploy-hook route carries Bitbucket, Gitea, drop and plain-git units into the same gate. New `buildPathForSource` mirrors `getBuildAppDirectory`'s selection, returning null for a source type that has no build path (docker) and falling back to `buildPath` when no source type is supplied, so every existing caller keeps its behaviour. `BuildPolicyGateUnit` carries `sourceType` and all six columns, and the three application gate call sites — github.ts, gitlab.ts and the shared `[refreshToken]` route — pass them. Default-off is untouched: this only runs inside the gate, which returns before reading anything unless the organization enforces. Tests: `__test__/build-policy/watch-paths-by-source.test.ts`, 19 assertions, including the GitHub-to-GitLab migration case and a column-by-column table for bitbucket, gitea, plain git and drop. Two of my first expectations were wrong about the code rather than the reverse: a Dockerfile directory inside the build path is absorbed by it, and an empty build path means the repository root and correctly derives `**`. Both are asserted as the code behaves. README hook-point table and the "before you turn it on" note updated to say the build path is selected per source type. Build-policy suite 20 files / 320 passing. Full suite 2313 tests, failure set unchanged at the baseline fifteen. --- .../watch-paths-by-source.test.ts | 155 ++++++++++++++++++ .../pages/api/deploy/[refreshToken].ts | 6 + apps/dokploy/pages/api/deploy/github.ts | 6 + apps/dokploy/pages/api/deploy/gitlab.ts | 6 + .../src/services/build-policy/README.md | 10 +- .../src/services/build-policy/watch-paths.ts | 72 +++++++- .../src/services/build-policy/webhook.ts | 17 ++ 7 files changed, 262 insertions(+), 10 deletions(-) create mode 100644 apps/dokploy/__test__/build-policy/watch-paths-by-source.test.ts diff --git a/apps/dokploy/__test__/build-policy/watch-paths-by-source.test.ts b/apps/dokploy/__test__/build-policy/watch-paths-by-source.test.ts new file mode 100644 index 0000000000..565536004e --- /dev/null +++ b/apps/dokploy/__test__/build-policy/watch-paths-by-source.test.ts @@ -0,0 +1,155 @@ +import { + buildPathForSource, + deriveDefaultWatchPaths, +} from "@dokploy/server/services/build-policy/watch-paths"; +import { describe, expect, it } from "vitest"; + +/** + * Round-3 review finding K. + * + * `deriveDefaultWatchPaths` read `buildPath`, and the `watch-paths.ts` docstring + * claimed it matched `getBuildAppDirectory`. It matched it for + * `sourceType: "github"` only: `getBuildAppDirectory` + * (`utils/filesystem/directory.ts:104-123`) selects per source type — + * `gitlabBuildPath`, `bitbucketBuildPath`, `giteaBuildPath`, `dropBuildPath`, + * `customGitBuildPath`. + * + * Mostly benign, because `buildPath` defaults to `"/"`, which derives to `**` + * and deploys everything. The case that bites is a unit **migrated** from + * GitHub to GitLab: `saveGitlabProvider` writes `gitlabBuildPath` and never + * resets `buildPath`, so the stale GitHub path survived and became the derived + * watch path, and pushes touching the real GitLab build path were skipped. + * + * Adding the GitLab gate in round 2's finding G is what made this pre-existing + * mismatch reachable on a new route. + */ + +describe("buildPathForSource mirrors getBuildAppDirectory", () => { + const ALL = { + buildPath: "apps/web", + gitlabBuildPath: "services/api", + bitbucketBuildPath: "bb/app", + giteaBuildPath: "gitea/app", + dropBuildPath: "drop/app", + customGitBuildPath: "custom/app", + }; + + it.each([ + ["github", "apps/web"], + ["gitlab", "services/api"], + ["bitbucket", "bb/app"], + ["gitea", "gitea/app"], + ["drop", "drop/app"], + ["git", "custom/app"], + ])("selects the %s column", (sourceType, expected) => { + expect(buildPathForSource({ ...ALL, sourceType })).toBe(expected); + }); + + it("returns nothing for a source type that has no build path, such as docker", () => { + expect(buildPathForSource({ ...ALL, sourceType: "docker" })).toBeNull(); + }); + + it("falls back to buildPath when the source type is not known", () => { + // Keeps every existing caller that passes only `buildPath` behaving as it + // did, rather than silently widening them to the repo root. + expect(buildPathForSource({ buildPath: "apps/web" })).toBe("apps/web"); + }); +}); + +describe("deriveDefaultWatchPaths picks the build path for the unit's source", () => { + const MIGRATED = { + unitType: "application" as const, + // The stale GitHub value `saveGitlabProvider` leaves behind. + buildPath: "apps/old-github-app", + gitlabBuildPath: "services/api", + }; + + it("uses the GitLab build path for a GitLab unit, not the stale GitHub one", () => { + expect( + deriveDefaultWatchPaths({ ...MIGRATED, sourceType: "gitlab" }), + ).toEqual(["services/api/**"]); + }); + + it("still uses buildPath for a GitHub unit", () => { + expect( + deriveDefaultWatchPaths({ ...MIGRATED, sourceType: "github" }), + ).toEqual(["apps/old-github-app/**"]); + }); + + it("derives the repo root when the unit's own source has no build path set", () => { + // A GitLab unit with no gitlabBuildPath watches everything, which is the + // safe direction: it deploys rather than silently skipping. + expect( + deriveDefaultWatchPaths({ + unitType: "application", + sourceType: "gitlab", + buildPath: "apps/old-github-app", + }), + ).toEqual(["**"]); + }); + + it("resolves the Dockerfile under the source's own build path, and absorbs it", () => { + // The Dockerfile directory is joined onto the *gitlab* build path, and is + // then contained by it, so it adds nothing — the documented behaviour. + // The point of the assertion is the base it was joined onto. + expect( + deriveDefaultWatchPaths({ + unitType: "application", + sourceType: "gitlab", + buildPath: "apps/old-github-app", + gitlabBuildPath: "services/api", + dockerfile: "docker/Dockerfile", + }), + ).toEqual(["services/api/**"]); + }); + + /** + * Gitea and Bitbucket units arrive through the shared `[refreshToken]` + * deploy-hook route, so they hit the same gate and had the same mismatch. + */ + it.each([ + ["bitbucket", "bitbucketBuildPath"], + ["gitea", "giteaBuildPath"], + ["git", "customGitBuildPath"], + ["drop", "dropBuildPath"], + ])("uses the %s column, not the stale buildPath", (sourceType, column) => { + expect( + deriveDefaultWatchPaths({ + unitType: "application", + sourceType, + buildPath: "apps/old-github-app", + [column]: "services/api", + }), + ).toEqual(["services/api/**"]); + }); + + it("watches everything for a docker-source unit, which has no build path", () => { + expect( + deriveDefaultWatchPaths({ + unitType: "application", + sourceType: "docker", + buildPath: "apps/old-github-app", + }), + ).toEqual(["**"]); + }); + + it("is unchanged for a compose unit, which has only composePath", () => { + expect( + deriveDefaultWatchPaths({ + unitType: "compose", + sourceType: "gitlab", + buildPath: "apps/old-github-app", + composePath: "./stacks/api/docker-compose.yml", + }), + ).toEqual(["stacks/api/**"]); + }); + + it("keeps behaving as before when no source type is given", () => { + expect( + deriveDefaultWatchPaths({ + unitType: "application", + buildPath: "apps/web", + }), + ).toEqual(["apps/web/**"]); + }); +}); diff --git a/apps/dokploy/pages/api/deploy/[refreshToken].ts b/apps/dokploy/pages/api/deploy/[refreshToken].ts index 0f8f1700de..cc5682b88a 100644 --- a/apps/dokploy/pages/api/deploy/[refreshToken].ts +++ b/apps/dokploy/pages/api/deploy/[refreshToken].ts @@ -330,7 +330,13 @@ export default async function handler( unitName: application.name, environmentId: application.environmentId, watchPaths: application.watchPaths, + sourceType: application.sourceType, buildPath: application.buildPath, + gitlabBuildPath: application.gitlabBuildPath, + bitbucketBuildPath: application.bitbucketBuildPath, + giteaBuildPath: application.giteaBuildPath, + dropBuildPath: application.dropBuildPath, + customGitBuildPath: application.customGitBuildPath, dockerfile: application.dockerfile, dockerContextPath: application.dockerContextPath, }, diff --git a/apps/dokploy/pages/api/deploy/github.ts b/apps/dokploy/pages/api/deploy/github.ts index b858a8ca5b..b85bae5b8d 100644 --- a/apps/dokploy/pages/api/deploy/github.ts +++ b/apps/dokploy/pages/api/deploy/github.ts @@ -300,7 +300,13 @@ export default async function handler( unitName: app.name, environmentId: app.environmentId, watchPaths: app.watchPaths, + sourceType: app.sourceType, buildPath: app.buildPath, + gitlabBuildPath: app.gitlabBuildPath, + bitbucketBuildPath: app.bitbucketBuildPath, + giteaBuildPath: app.giteaBuildPath, + dropBuildPath: app.dropBuildPath, + customGitBuildPath: app.customGitBuildPath, dockerfile: app.dockerfile, dockerContextPath: app.dockerContextPath, }, diff --git a/apps/dokploy/pages/api/deploy/gitlab.ts b/apps/dokploy/pages/api/deploy/gitlab.ts index 2400fb52bc..1f6c962fa6 100644 --- a/apps/dokploy/pages/api/deploy/gitlab.ts +++ b/apps/dokploy/pages/api/deploy/gitlab.ts @@ -211,7 +211,13 @@ export default async function handler( unitName: app.name, environmentId: app.environmentId, watchPaths: app.watchPaths, + sourceType: app.sourceType, buildPath: app.buildPath, + gitlabBuildPath: app.gitlabBuildPath, + bitbucketBuildPath: app.bitbucketBuildPath, + giteaBuildPath: app.giteaBuildPath, + dropBuildPath: app.dropBuildPath, + customGitBuildPath: app.customGitBuildPath, dockerfile: app.dockerfile, dockerContextPath: app.dockerContextPath, }, diff --git a/packages/server/src/services/build-policy/README.md b/packages/server/src/services/build-policy/README.md index fb53085949..67315f348e 100644 --- a/packages/server/src/services/build-policy/README.md +++ b/packages/server/src/services/build-policy/README.md @@ -75,9 +75,10 @@ comes from ("reads the organization settings exactly once"). operator sets `enforceRemoteBuilds`, in rough order of blast radius. Round-2 review finding B: the section above was excellent and this one did not exist. -1. **Derived `watchPaths` start filtering pushes.** Every unit with a - `buildPath` and no explicit `watchPaths` immediately gets `/**` - from `deriveDefaultWatchPaths`. In a monorepo — the common shape across this +1. **Derived `watchPaths` start filtering pushes.** Every unit with a build + path and no explicit `watchPaths` immediately gets `/**` from + `deriveDefaultWatchPaths`, reading the build-path column that matches its + source type. In a monorepo — the common shape across this fleet — a push that touches only shared code under `packages/**` now stops at the webhook with a 301 and no deployment record. This is the single highest-blast-radius consequence of enabling the policy. @@ -155,7 +156,7 @@ always loads that relation, but a caller with a leaner row plans as | `github-checks.ts` | the same wait, wired to the GitHub App installation token; merges check runs and commit statuses | | `coalesce.ts` | drop still-waiting deploys for a unit and audit what was dropped | | `compose-checks.ts` | `requiredChecks` for compose units, run between the clone and the build | -| `watch-paths.ts` | derive default `watchPaths` from `buildPath` / Dockerfile / compose path | +| `watch-paths.ts` | derive default `watchPaths` from the unit's build path (selected by source type, as `getBuildAppDirectory` does) / Dockerfile / compose path | | `skip-deploy.ts` | the `[skip deploy]` commit-message marker | | `webhook.ts` | the single enqueue-time gate every deploy entry point calls, plus deploy-hook body resolution | | `errors.ts` | `BuildPolicyError` with stable codes | @@ -658,6 +659,7 @@ path is ever added, it needs the call too. | `required-checks-support.test.ts` | that a unit with no GitHub App is refused a required check at the API boundary, and that clearing one is always allowed | | `required-checks-before-build.test.ts` | that the checks gate is policy-gated, runs before the build on the freshly cloned sha, and is a no-op that executes nothing while the policy is off | | `gate-audit-and-registry.test.ts` | that a derived watch-path skip is audited, and that the deploy-hook allowlist follows the registry an enforced build publishes to | +| `watch-paths-by-source.test.ts` | that the derived watch paths read the build-path column matching the unit's source type, so a unit migrated from GitHub to GitLab is not filtered by a stale path | | `hook-allowlist-follows-plan.test.ts` | that the deploy-hook allowlist names the repository this unit's image will actually live on, for enforced and for local units alike, and that a Docker Hub registry with no host is accepted | | `hook-body-before-coalescing.test.ts` | that a refused deploy-hook body never coalesces the unit's queue | | `compose-redeploy-gate.test.ts` | that the Redeploy button is check-gated too, so a commit the push gate refused cannot be shipped from the code directory it left behind | diff --git a/packages/server/src/services/build-policy/watch-paths.ts b/packages/server/src/services/build-policy/watch-paths.ts index 831df582f7..bfa06ae171 100644 --- a/packages/server/src/services/build-policy/watch-paths.ts +++ b/packages/server/src/services/build-policy/watch-paths.ts @@ -1,27 +1,87 @@ /** * Default `watchPaths` for a unit that has none (spec 5.2.6): derive them from - * `buildPath`, the Dockerfile directory and the compose file directory. + * the unit's build path, the Dockerfile directory and the compose file + * directory. * - * Two deliberate choices: + * Three deliberate choices: * - * - `dockerfile` is resolved *under* `buildPath`, matching `getBuildAppDirectory`, - * so a Dockerfile inside the build path adds nothing. + * - The build path is selected **by source type**, exactly as + * `getBuildAppDirectory` (`utils/filesystem/directory.ts`) does: `buildPath` + * for github, `gitlabBuildPath` for gitlab, and so on. Reading `buildPath` + * unconditionally was round-3 review finding K, and the case it broke is a + * unit migrated from GitHub to GitLab — `saveGitlabProvider` writes + * `gitlabBuildPath` and never resets `buildPath`, so the stale GitHub value + * survived and silently became the watch path. + * - `dockerfile` is resolved *under* that build path, matching + * `getBuildAppDirectory`, so a Dockerfile inside the build path adds nothing. * - An unset `dockerContextPath` is ignored even though upstream then builds * with the repo root as context. Honouring it would collapse almost every * dockerfile unit to `**`, which is the same as no filter and defeats the * point. An explicitly configured context path is honoured. * * A unit whose build inputs really do sit at the repo root gets `**`. That is - * the honest answer, not a bug. + * the honest answer, not a bug, and it is the safe direction: it deploys rather + * than silently skipping. */ export interface WatchPathsInput { unitType: "application" | "compose"; + /** Selects which build-path column below applies. */ + sourceType?: string | null; buildPath?: string | null; + gitlabBuildPath?: string | null; + bitbucketBuildPath?: string | null; + giteaBuildPath?: string | null; + dropBuildPath?: string | null; + customGitBuildPath?: string | null; dockerfile?: string | null; dockerContextPath?: string | null; composePath?: string | null; } +/** + * The build path this unit actually builds from, mirroring the selection in + * `getBuildAppDirectory`. Kept as a separate exported function so the two can + * be compared side by side when upstream adds a source type. + * + * With no `sourceType` it falls back to `buildPath`, which keeps a caller that + * knows only that column behaving exactly as before rather than silently + * widening it to the repo root. + */ +export const buildPathForSource = ( + input: Pick< + WatchPathsInput, + | "sourceType" + | "buildPath" + | "gitlabBuildPath" + | "bitbucketBuildPath" + | "giteaBuildPath" + | "dropBuildPath" + | "customGitBuildPath" + >, +): string | null => { + switch (input.sourceType) { + case "github": + return input.buildPath ?? null; + case "gitlab": + return input.gitlabBuildPath ?? null; + case "bitbucket": + return input.bitbucketBuildPath ?? null; + case "gitea": + return input.giteaBuildPath ?? null; + case "drop": + return input.dropBuildPath ?? null; + case "git": + return input.customGitBuildPath ?? null; + case undefined: + case null: + return input.buildPath ?? null; + default: + // A source type with no build path of its own, such as `docker`. + // `getBuildAppDirectory` leaves it empty; so do we. + return null; + } +}; + const ROOT = "**"; /** Strip `./`, leading and trailing slashes; return "" for the repo root. */ @@ -65,7 +125,7 @@ export const deriveDefaultWatchPaths = (input: WatchPathsInput): string[] => { if (input.unitType === "compose") { candidates = [dirnameOf(input.composePath)]; } else { - const buildPath = normalizeDir(input.buildPath) ?? ""; + const buildPath = normalizeDir(buildPathForSource(input)) ?? ""; const dockerfileDir = dirnameOf(input.dockerfile); candidates = [ buildPath, diff --git a/packages/server/src/services/build-policy/webhook.ts b/packages/server/src/services/build-policy/webhook.ts index 80dd9d38ea..d12ee08469 100644 --- a/packages/server/src/services/build-policy/webhook.ts +++ b/packages/server/src/services/build-policy/webhook.ts @@ -47,7 +47,18 @@ export interface BuildPolicyGateUnit { unitName: string; environmentId: string; watchPaths?: string[] | null; + /** + * Source type and every build-path column, because the derived watch paths + * have to read the one this unit actually builds from — see + * `buildPathForSource`. Passing only `buildPath` was round-3 finding K. + */ + sourceType?: string | null; buildPath?: string | null; + gitlabBuildPath?: string | null; + bitbucketBuildPath?: string | null; + giteaBuildPath?: string | null; + dropBuildPath?: string | null; + customGitBuildPath?: string | null; dockerfile?: string | null; dockerContextPath?: string | null; composePath?: string | null; @@ -122,7 +133,13 @@ export const buildPolicyDeployGate = async ({ if (!hasOwnWatchPaths && Array.isArray(changedFiles)) { const { paths } = resolveWatchPaths(unit.watchPaths, { unitType, + sourceType: unit.sourceType, buildPath: unit.buildPath, + gitlabBuildPath: unit.gitlabBuildPath, + bitbucketBuildPath: unit.bitbucketBuildPath, + giteaBuildPath: unit.giteaBuildPath, + dropBuildPath: unit.dropBuildPath, + customGitBuildPath: unit.customGitBuildPath, dockerfile: unit.dockerfile, dockerContextPath: unit.dockerContextPath, composePath: unit.composePath, From 6d27c8860e758aae4f8080bf304d9d8cd51c2180 Mon Sep 17 00:00:00 2001 From: AminDhouib Date: Thu, 10 Sep 2026 19:47:49 -0400 Subject: [PATCH 18/18] docs(build-policy): generate the hook-point table, and cap the audited file list (round 3, N8 and N9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit N8 — the README's hook-point table has now been wrong at two consecutive review heads. It claimed "thirty-eight, in eleven files" while markers actually live in fifteen, with `server/api/routers/application.ts` and `.../compose.ts` having no row at all after the F and A commits. Hand-maintained counts against a moving codebase do not survive, so the table is now generated and carries the two commands that regenerate it. The count is also defined precisely rather than by feel: a marked BLOCK is `>>> build-policy hook` ... `<<< build-policy hook`, and a NOTE is a single-line marker on an upstream line whose meaning changed without growing a block. 31 blocks and 25 notes across 15 files, reproducible with a grep. The application.ts subsection says how its 16 blocks and 8 notes decompose into the five hooks per deploy path it already described, so the two views reconcile. N9 — the finding-B `deploy_skipped` audit row embedded the entire `changedFiles` array. A monorepo-wide push touches thousands of paths and this row is written once per skipped webhook delivery, so the row could be far larger than anything an operator would read. It now stores `changedFilesCount`, a `changedFilesTruncated` flag and the first 50 paths. The count is the part that answers "did my push really only touch shared code"; the sample is enough to recognise the push. Tests: two added to `gate-audit-and-registry.test.ts`, verified failing first — a 500-file push must record the count, the flag and 50 entries, and a one-file push must record it whole with the flag false. Build-policy suite 20 files / 322 passing. Full suite 2315 tests, failure set unchanged at the baseline fifteen. --- .../gate-audit-and-registry.test.ts | 42 +++++++++++++ .../src/services/build-policy/README.md | 59 ++++++++++++++----- .../src/services/build-policy/webhook.ts | 16 ++++- 3 files changed, 100 insertions(+), 17 deletions(-) diff --git a/apps/dokploy/__test__/build-policy/gate-audit-and-registry.test.ts b/apps/dokploy/__test__/build-policy/gate-audit-and-registry.test.ts index 75402d8f0e..37a4c240c1 100644 --- a/apps/dokploy/__test__/build-policy/gate-audit-and-registry.test.ts +++ b/apps/dokploy/__test__/build-policy/gate-audit-and-registry.test.ts @@ -110,6 +110,48 @@ describe("finding B — a derived watch-path skip is audited", () => { expect(row.metadata.changedFiles).toEqual(["packages/shared/index.ts"]); }); + /** + * Round-3 nit N9. A monorepo push can touch thousands of files, and this row + * is written per skipped delivery, so the whole array in the metadata blob is + * a real write-amplification. The count is what an operator actually needs + * alongside a sample; the full list is not worth the storage. + */ + it("caps the changed files it stores, keeping the count and a truncation flag", async () => { + const many = Array.from( + { length: 500 }, + (_, i) => `packages/shared/f${i}.ts`, + ); + + await buildPolicyDeployGate({ + unitType: "application", + unit: UNIT, + changedFiles: many, + commitMessage: "chore: a very wide refactor", + removeWaiting: vi.fn(), + }); + + const row = mocks.recordBuildPolicyAudit.mock.calls[0]?.[0]; + expect(row.metadata.changedFilesCount).toBe(500); + expect(row.metadata.changedFilesTruncated).toBe(true); + expect(row.metadata.changedFiles).toHaveLength(50); + expect(row.metadata.changedFiles[0]).toBe("packages/shared/f0.ts"); + }); + + it("stores a small push whole, and says it was not truncated", async () => { + await buildPolicyDeployGate({ + unitType: "application", + unit: UNIT, + changedFiles: ["packages/shared/index.ts"], + commitMessage: "chore: shared code only", + removeWaiting: vi.fn(), + }); + + const row = mocks.recordBuildPolicyAudit.mock.calls[0]?.[0]; + expect(row.metadata.changedFilesCount).toBe(1); + expect(row.metadata.changedFilesTruncated).toBe(false); + expect(row.metadata.changedFiles).toEqual(["packages/shared/index.ts"]); + }); + it("targets the compose id for a compose unit", async () => { await buildPolicyDeployGate({ unitType: "compose", diff --git a/packages/server/src/services/build-policy/README.md b/packages/server/src/services/build-policy/README.md index 67315f348e..d7adeccbc7 100644 --- a/packages/server/src/services/build-policy/README.md +++ b/packages/server/src/services/build-policy/README.md @@ -199,23 +199,45 @@ never satisfy one. See "What a unit needs before it can be check-gated". ## Hook points in upstream code -Every one is marked in the source with `build-policy hook`. Grep for that string -to find them all. There are **thirty-eight**, in eleven files, plus seven import -markers, two zod lines in the schema files, and the two UI fields below. +Every one is marked in the source with the literal string `build-policy hook`, +in one of two forms: -| File | Hooks | -|---|---| -| `packages/server/src/services/application.ts` | 22 | -| `packages/server/src/services/deployment.ts` | 2 | -| `packages/server/src/utils/builders/index.ts` | 1 | -| `packages/server/src/services/compose.ts` | 1 | -| `apps/dokploy/pages/api/deploy/github.ts` | 2 | -| `apps/dokploy/pages/api/deploy/gitlab.ts` | 3 | -| `apps/dokploy/pages/api/deploy/[refreshToken].ts` | 2 | -| `apps/dokploy/pages/api/deploy/compose/[refreshToken].ts` | 1 | -| `apps/dokploy/server/queues/queueSetup.ts` | 2 | -| `apps/dokploy/server/queues/queue-types.ts` | 1 | -| `apps/dokploy/server/queues/deployments-queue.ts` | 1 | +- a **block**, opened with `>>> build-policy hook` and closed with + `<<< build-policy hook`, wrapping code an upstream merge has to reconcile; +- a **note**, a single-line comment on an upstream line that changed meaning but + did not grow a block — an import, a zod shape, a widened field. + +The table below is generated, not maintained by hand; it was wrong at two +consecutive review heads when it was. Regenerate it with: + +```sh +git grep -c '>>> build-policy hook' -- ':!*README.md' # blocks, per file +git grep -c 'build-policy hook' -- ':!*README.md' # blocks x2 + notes +``` + +**31 blocks and 25 notes, across 15 files**, plus the two UI fields below. + +| File | Blocks | Notes | +|---|---|---| +| `packages/server/src/services/application.ts` | 16 | 8 | +| `apps/dokploy/pages/api/deploy/gitlab.ts` | 4 | 1 | +| `apps/dokploy/pages/api/deploy/github.ts` | 3 | 1 | +| `packages/server/src/services/compose.ts` | 2 | 0 | +| `apps/dokploy/pages/api/deploy/[refreshToken].ts` | 1 | 2 | +| `apps/dokploy/pages/api/deploy/compose/[refreshToken].ts` | 1 | 1 | +| `apps/dokploy/server/api/routers/application.ts` | 1 | 2 | +| `apps/dokploy/server/api/routers/compose.ts` | 1 | 2 | +| `apps/dokploy/server/queues/deployments-queue.ts` | 1 | 1 | +| `packages/server/src/utils/builders/index.ts` | 1 | 0 | +| `apps/dokploy/server/queues/queueSetup.ts` | 0 | 2 | +| `apps/dokploy/server/queues/queue-types.ts` | 0 | 1 | +| `packages/server/src/services/deployment.ts` | 0 | 2 | +| `packages/server/src/db/schema/application.ts` | 0 | 1 | +| `packages/server/src/db/schema/compose.ts` | 0 | 1 | + +The two router files carry the mutation-side guards: `requiredChecks` +validation on `update` (finding F) and the compose refusal on `addExclusion` / +`allowLocalBuildOnce` (finding A). ### `packages/server/src/services/application.ts` @@ -224,6 +246,11 @@ The same five hooks in each of four deploy paths — `deployApplication`, plus one line in each of the two non-preview paths that creates the deployment log on the build host. +Four of the five (1/4, 2a/4, 2/4, 3/4) are blocks and 4/4 is a note, which is +how the file reconciles to 16 blocks and 8 notes in the table above: 4 x 4 +blocks, 4 x hook 4/4, the two deployment-log lines, and the two `continued` +notes in the preview paths. + | Hook | What it replaces / adds | |---|---| | 1/4 | `const serverId = application.buildServerId \|\| application.serverId` becomes the same expression with `buildPolicy.buildServerId` in front. `planApplicationBuild` throws `BuildPolicyError` on an `error` decision, which is how a missing build server fails the deploy. | diff --git a/packages/server/src/services/build-policy/webhook.ts b/packages/server/src/services/build-policy/webhook.ts index d12ee08469..be43d2628f 100644 --- a/packages/server/src/services/build-policy/webhook.ts +++ b/packages/server/src/services/build-policy/webhook.ts @@ -74,6 +74,17 @@ export type BuildPolicyGateResult = const PASS: BuildPolicyGateResult = { deploy: true, coalesced: 0 }; +/** + * How many changed paths a `deploy_skipped` audit row stores. + * + * The row exists so an operator can answer "why did my push not deploy", which + * needs the derived paths, the total, and enough of a sample to recognise the + * push. It does not need the whole list: a monorepo-wide change touches + * thousands of paths and this row is written once per skipped webhook delivery. + * Round-3 nit N9. + */ +const AUDIT_CHANGED_FILES_LIMIT = 50; + const findOrganizationId = async ( environmentId: string, ): Promise => { @@ -160,7 +171,10 @@ export const buildPolicyDeployGate = async ({ metadata: { unitName: unit.unitName, derivedWatchPaths: paths, - changedFiles, + changedFilesCount: changedFiles.length, + changedFilesTruncated: + changedFiles.length > AUDIT_CHANGED_FILES_LIMIT, + changedFiles: changedFiles.slice(0, AUDIT_CHANGED_FILES_LIMIT), }, }); return { deploy: false, reason: "watch_paths", message };