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 00000000000..7389c28da60 --- /dev/null +++ b/apps/dokploy/__test__/build-policy/coalesce.test.ts @@ -0,0 +1,221 @@ +import { + coalesceQueuedDeploy, + isCoalescableDeployJob, +} from "@dokploy/server/services/build-policy/coalesce"; +import { describe, expect, it, vi } from "vitest"; + +describe("coalesceQueuedDeploy", () => { + const base = { + unitType: "application" as const, + unitId: "app-1", + organizationId: "org-1", + unitName: "sendly-web", + }; + + it("drops the older queued deploy and audits it", async () => { + const removeWaiting = vi.fn().mockResolvedValue(1); + const recordAudit = vi.fn().mockResolvedValue(undefined); + + const result = await coalesceQueuedDeploy({ + ...base, + removeWaiting, + recordAudit, + }); + + expect(result).toEqual({ removed: 1 }); + expect(removeWaiting).toHaveBeenCalledTimes(1); + expect(recordAudit).toHaveBeenCalledTimes(1); + expect(recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: "org-1", + action: "deploy_coalesced", + applicationId: "app-1", + composeId: null, + metadata: expect.objectContaining({ + removed: 1, + unitName: "sendly-web", + }), + }), + ); + }); + + it("collapses several queued deploys into one audit entry", async () => { + const recordAudit = vi.fn().mockResolvedValue(undefined); + const result = await coalesceQueuedDeploy({ + ...base, + removeWaiting: vi.fn().mockResolvedValue(4), + recordAudit, + }); + expect(result).toEqual({ removed: 4 }); + expect(recordAudit).toHaveBeenCalledTimes(1); + expect(recordAudit.mock.calls[0]?.[0].metadata.removed).toBe(4); + }); + + it("writes nothing when there was no queued deploy to drop", async () => { + const recordAudit = vi.fn(); + const result = await coalesceQueuedDeploy({ + ...base, + removeWaiting: vi.fn().mockResolvedValue(0), + recordAudit, + }); + expect(result).toEqual({ removed: 0 }); + expect(recordAudit).not.toHaveBeenCalled(); + }); + + it("targets the compose id for a compose unit", async () => { + const recordAudit = vi.fn().mockResolvedValue(undefined); + await coalesceQueuedDeploy({ + unitType: "compose", + unitId: "compose-1", + organizationId: "org-1", + unitName: "stack", + removeWaiting: vi.fn().mockResolvedValue(2), + recordAudit, + }); + expect(recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ applicationId: null, composeId: "compose-1" }), + ); + }); + + it("never lets an audit failure block the deploy that is being enqueued", async () => { + const result = await coalesceQueuedDeploy({ + ...base, + removeWaiting: vi.fn().mockResolvedValue(1), + recordAudit: vi.fn().mockRejectedValue(new Error("db down")), + }); + expect(result).toEqual({ removed: 1 }); + }); + + it("never lets a queue failure block the deploy that is being enqueued", async () => { + const recordAudit = vi.fn(); + const result = await coalesceQueuedDeploy({ + ...base, + removeWaiting: vi.fn().mockRejectedValue(new Error("queue gone")), + recordAudit, + }); + expect(result).toEqual({ removed: 0 }); + expect(recordAudit).not.toHaveBeenCalled(); + }); +}); + +/** + * Finding 5 of the PR #209 review. Coalescing used the same predicate as the + * explicit "clean queues" action, which matches on `applicationId` alone, so a + * push to main silently cancelled the pull request preview that was waiting for + * the same application. + */ +describe("isCoalescableDeployJob", () => { + const push = { + applicationId: "app-1", + applicationType: "application", + titleLog: "Push to main", + }; + const preview = { + applicationId: "app-1", + applicationType: "application-preview", + previewDeploymentId: "preview-1", + titleLog: "PR #42 preview", + }; + + it("drops the unit's own plain deploy", () => { + expect(isCoalescableDeployJob("application", "app-1", push)).toBe(true); + }); + + it("never drops a preview deployment of the same application", () => { + expect(isCoalescableDeployJob("application", "app-1", preview)).toBe(false); + }); + + it("never drops a plain deploy of a different application", () => { + expect(isCoalescableDeployJob("application", "app-2", push)).toBe(false); + }); + + it("never drops a compose job while coalescing an application", () => { + expect( + isCoalescableDeployJob("application", "app-1", { + composeId: "app-1", + applicationType: "compose", + }), + ).toBe(false); + }); + + it("drops the unit's own compose deploy but not its compose preview", () => { + expect( + isCoalescableDeployJob("compose", "compose-1", { + composeId: "compose-1", + applicationType: "compose", + }), + ).toBe(true); + expect( + isCoalescableDeployJob("compose", "compose-1", { + composeId: "compose-1", + applicationType: "compose-preview", + previewDeploymentId: "preview-9", + }), + ).toBe(false); + }); + + it("tolerates a job payload that is not an object", () => { + expect(isCoalescableDeployJob("application", "app-1", null)).toBe(false); + expect(isCoalescableDeployJob("application", "app-1", "app-1")).toBe(false); + }); +}); + +describe("coalescing a queue that also holds a preview", () => { + /** Stands in for the in-memory queue's `removeWaiting`. */ + const fakeQueue = (jobs: Record[]) => ({ + 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/compose-redeploy-gate.test.ts b/apps/dokploy/__test__/build-policy/compose-redeploy-gate.test.ts new file mode 100644 index 00000000000..4f1779951fa --- /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/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 00000000000..56162e26145 --- /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/__test__/build-policy/deploy-path.integration.test.ts b/apps/dokploy/__test__/build-policy/deploy-path.integration.test.ts new file mode 100644 index 00000000000..b48984a80bc --- /dev/null +++ b/apps/dokploy/__test__/build-policy/deploy-path.integration.test.ts @@ -0,0 +1,1053 @@ +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(), + 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", () => { + 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 }, + deployHook: { findFirst: vi.fn() }, + patch: { findMany: vi.fn().mockResolvedValue([]) }, + member: { findMany: vi.fn().mockResolvedValue([]) }, + environments: { findFirst: mocks.environmentsFindFirst }, + buildPolicySettings: { + findFirst: mocks.buildPolicySettingsFindFirst, + }, + deployments: { findFirst: mocks.deploymentsFindFirst }, + }, + }, + }; +}); + +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", () => ({ + createDeploymentPreview: mocks.createDeploymentPreview, + 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 { rollbackToDeploymentDigest } from "@dokploy/server/services/build-policy/rollback"; +import { clearBuildPolicyEnforcementCache } from "@dokploy/server/services/build-policy/settings"; +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"; +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"; + +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" }, + }); + 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", + }); +}; + +/** 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(); + clearBuildPolicyEnforcementCache(); + 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" }); + }); + + /** + * 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 }), + ); + 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("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({ + 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(); + }); + + 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", () => { + 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/__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 00000000000..37a4c240c19 --- /dev/null +++ b/apps/dokploy/__test__/build-policy/gate-audit-and-registry.test.ts @@ -0,0 +1,218 @@ +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'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(() => ({ + isBuildPolicyEnforcedAnywhere: vi.fn(), + findBuildPolicySettings: vi.fn(), + recordBuildPolicyAudit: vi.fn().mockResolvedValue(undefined), + environmentsFindFirst: 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(), +})); + +import { buildPolicyDeployGate } 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"]); + }); + + /** + * 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", + 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(); + }); +}); 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 00000000000..5a0121eb7da --- /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/__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 00000000000..7293decbf65 --- /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/hook-body-before-coalescing.test.ts b/apps/dokploy/__test__/build-policy/hook-body-before-coalescing.test.ts new file mode 100644 index 00000000000..97ad21651e3 --- /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/__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 00000000000..e5c24b09254 --- /dev/null +++ b/apps/dokploy/__test__/build-policy/image-and-hook-body.test.ts @@ -0,0 +1,243 @@ +import { BuildPolicyError } from "@dokploy/server/services/build-policy/errors"; +import { parseDeployHookImage } from "@dokploy/server/services/build-policy/hook-body"; +import { + buildDigestRef, + DIGEST_MARKER, + imageTagForSha, + parseImageDigestFromLog, + parseImageTagFromLog, + registryHostOf, + SHA_PLACEHOLDER, +} from "@dokploy/server/services/build-policy/image"; +import { describe, expect, it } from "vitest"; + +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)}`; + // 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" }); + expect(parseDeployHookImage({}, allowed)).toEqual({ kind: "none" }); + expect(parseDeployHookImage("", allowed)).toEqual({ kind: "none" }); + }); + + 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 }, + 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 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( + { 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("rejects every image when the unit has no repository to compare against", () => { + expect(() => + parseDeployHookImage( + { image: "ghcr.io/devino/sendly-web", tag: "a", digest }, + [], + ), + ).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 00000000000..c65a88ee4d7 --- /dev/null +++ b/apps/dokploy/__test__/build-policy/plan-failure.test.ts @@ -0,0 +1,196 @@ +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 { + 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") => ({ + 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); + }); +}); + +/** + * 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/apps/dokploy/__test__/build-policy/policy-decision.test.ts b/apps/dokploy/__test__/build-policy/policy-decision.test.ts new file mode 100644 index 00000000000..4e6bba3c708 --- /dev/null +++ b/apps/dokploy/__test__/build-policy/policy-decision.test.ts @@ -0,0 +1,250 @@ +import { + type BuildPolicyDecisionInput, + decideBuildPolicy, +} from "@dokploy/server/services/build-policy/policy"; +import { describe, expect, it } from "vitest"; + +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/policy-off-is-upstream.test.ts b/apps/dokploy/__test__/build-policy/policy-off-is-upstream.test.ts new file mode 100644 index 00000000000..b7a1feb8db7 --- /dev/null +++ b/apps/dokploy/__test__/build-policy/policy-off-is-upstream.test.ts @@ -0,0 +1,450 @@ +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, + rejectComposeDeployHookImage, + 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", + unitType: "application" as const, + unitId: "app-1", + unitName: "Sendly Web", + sourceType: "github", + }, + { + 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", + unitType: "application" as const, + unitId: "app-1", + unitName: "Sendly Web", + sourceType: "github", + }, + { + image: "ghcr.io/devinosolutions/sendly-web", + digest: `sha256:${"a".repeat(64)}`, + }, + ); + 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", () => { + 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 00000000000..bc3f2fb3cf8 --- /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-before-build.test.ts b/apps/dokploy/__test__/build-policy/required-checks-before-build.test.ts new file mode 100644 index 00000000000..461e470ae1a --- /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/__test__/build-policy/required-checks-support.test.ts b/apps/dokploy/__test__/build-policy/required-checks-support.test.ts new file mode 100644 index 00000000000..bce34ebb53e --- /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/__test__/build-policy/required-checks.test.ts b/apps/dokploy/__test__/build-policy/required-checks.test.ts new file mode 100644 index 00000000000..1297eac311c --- /dev/null +++ b/apps/dokploy/__test__/build-policy/required-checks.test.ts @@ -0,0 +1,211 @@ +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, + 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/rollback-by-digest.test.ts b/apps/dokploy/__test__/build-policy/rollback-by-digest.test.ts new file mode 100644 index 00000000000..d0ca9425cb7 --- /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__/build-policy/router-and-checks.test.ts b/apps/dokploy/__test__/build-policy/router-and-checks.test.ts new file mode 100644 index 00000000000..7077c5a6f85 --- /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 new file mode 100644 index 00000000000..b3385676d15 --- /dev/null +++ b/apps/dokploy/__test__/build-policy/source-and-markers.test.ts @@ -0,0 +1,199 @@ +import { + hasSkipDeployMarker, + SKIP_DEPLOY_MARKERS, +} from "@dokploy/server/services/build-policy/skip-deploy"; +import { + isGithubHostUrl, + isGithubSourcedUnit, +} from "@dokploy/server/services/build-policy/source"; +import { deriveDefaultWatchPaths } from "@dokploy/server/services/build-policy/watch-paths"; +import { describe, expect, it } from "vitest"; + +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/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 00000000000..565536004e7 --- /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/__test__/deploy/application.command.test.ts b/apps/dokploy/__test__/deploy/application.command.test.ts index e8def89eee2..be5dfbecc60 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 5ff5c96b12e..88ba024cfdc 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 edc291ed959..0f9c0fa9d24 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 17b261d1018..a60ba28af3f 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 1af9e66a665..d977ed04167 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,14 @@ 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-rollbackToDigest": "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 c97cb29914a..2be0c8c781b 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/components/dashboard/application/advanced/show-build-server.tsx b/apps/dokploy/components/dashboard/application/advanced/show-build-server.tsx index eaeafde1a8f..7e78a1dd947 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 new file mode 100644 index 00000000000..7867c82c286 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/show-required-checks.tsx @@ -0,0 +1,139 @@ +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 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. 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. + +
+
+
+ +
+ 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 00000000000..694ec6796f1 --- /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 00000000000..e70877fed24 --- /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 00000000000..3a7bc4fab11 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/build-policy.tsx @@ -0,0 +1,296 @@ +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: 5, + }, + 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. + 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 new file mode 100644 index 00000000000..79ecdd63d53 --- /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 5 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 00000000000..e0d54d3c4fb --- /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": 5 + }, + "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 d69d9425d7e..6fddb827f68 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/api/deploy/[refreshToken].ts b/apps/dokploy/pages/api/deploy/[refreshToken].ts index d5a0888c2e9..cc5682b88a7 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 { + coalesceQueuedApplicationDeploys, + myQueue, +} from "@/server/queues/queueSetup"; import { deploy } from "@/server/utils/deploy"; import { handleGiteaApplicationPullRequestEvent, @@ -286,6 +292,64 @@ export default async function handler( } } + // >>> 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, + // 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, + ); + if (!hookImage.ok) { + res.status(400).json({ message: hookImage.message }); + return; + } + const gate = await buildPolicyDeployGate({ + unitType: "application", + unit: { + unitId: application.applicationId, + 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, + }, + commitMessage: deploymentTitle, + removeWaiting: () => + coalesceQueuedApplicationDeploys(application.applicationId), + }); + if (!gate.deploy) { + res.status(301).json({ message: gate.message }); + return; + } + // <<< build-policy hook + try { const jobData: DeploymentJob = { applicationId: application.applicationId as string, @@ -294,6 +358,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 aef86ddba62..2b4d9e35863 100644 --- a/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts +++ b/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts @@ -1,6 +1,9 @@ import { + // build-policy hook: enqueue-time gate and deploy-hook image body. + buildPolicyDeployGate, IS_CLOUD, normalizeChangedFilesFromCommits, + rejectComposeDeployHookImage, shouldDeploy, } from "@dokploy/server"; import { db } from "@dokploy/server/db"; @@ -8,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 { myQueue } from "@/server/queues/queueSetup"; +import { + coalesceQueuedComposeDeploys, + myQueue, +} from "@/server/queues/queueSetup"; import { deploy } from "@/server/utils/deploy"; import { handleGiteaComposePullRequestEvent, @@ -228,6 +234,46 @@ export default async function handler( } } + // >>> 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: { + unitId: composeResult.composeId, + unitName: composeResult.name, + environmentId: composeResult.environmentId, + watchPaths: composeResult.watchPaths, + composePath: composeResult.composePath, + }, + commitMessage: deploymentTitle, + removeWaiting: () => + coalesceQueuedComposeDeploys(composeResult.composeId), + }); + if (!gate.deploy) { + res.status(301).json({ message: gate.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 9c3a5496f04..b85bae5b8dd 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 { + coalesceQueuedApplicationDeploys, + coalesceQueuedComposeDeploys, + myQueue, +} from "@/server/queues/queueSetup"; +// <<< build-policy hook import { deploy } from "@/server/utils/deploy"; import { extractCommitMessage, @@ -283,6 +292,32 @@ 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, + 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, + }, + changedFiles: normalizedCommits, + commitMessage: deploymentTitle, + removeWaiting: () => + coalesceQueuedApplicationDeploys(app.applicationId), + }); + if (!gate.deploy) continue; + // <<< build-policy hook + if (IS_CLOUD && app.serverId) { jobData.serverId = app.serverId; deploy(jobData).catch((error) => { @@ -330,6 +365,25 @@ 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: () => + coalesceQueuedComposeDeploys(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/pages/api/deploy/gitlab.ts b/apps/dokploy/pages/api/deploy/gitlab.ts index 8355ca59ee3..1f6c962fa60 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,32 @@ 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, + 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, + }, + 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 +272,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/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 abcc566bc71..b9bd5f6a300 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 bc76a651718..2811951d058 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/apps/dokploy/server/api/root.ts b/apps/dokploy/server/api/root.ts index f8f5b04c322..a1deec55e58 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/application.ts b/apps/dokploy/server/api/routers/application.ts index 0670c471afb..a9614db502d 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/apps/dokploy/server/api/routers/build-policy.ts b/apps/dokploy/server/api/routers/build-policy.ts new file mode 100644 index 00000000000..ffb8b4e6e3e --- /dev/null +++ b/apps/dokploy/server/api/routers/build-policy.ts @@ -0,0 +1,255 @@ +import { + addBuildPolicyExclusion, + assertUnitInOrganization, + findBuildPolicySettings, + findRegistryById, + findRollbackTarget, + getAccessibleServerIds, + grantBreakGlass, + listBuildPolicyAudit, + listBuildPolicyExclusions, + recordBuildPolicyAudit, + removeBuildPolicyExclusion, + rollbackToDeploymentDigest, + upsertBuildPolicySettings, +} from "@dokploy/server"; +import { + apiAddBuildPolicyExclusion, + apiGrantBuildPolicyBreakGlass, + apiListBuildPolicyAudit, + apiRemoveBuildPolicyExclusion, + apiRollbackToBuildPolicyDigest, + 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. + */ + +/** + * 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), + ), + + 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 { unitType, unitId } = await assertUnitInOrganization({ + organizationId, + applicationId: input.applicationId, + composeId: input.composeId, + }); + assertNotComposeUnit(unitType, "An exclusion"); + 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: unitType === "application" ? unitId : null, + composeId: unitType === "compose" ? unitId : 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; + const { unitType, unitId } = await assertUnitInOrganization({ + organizationId, + applicationId: input.applicationId, + composeId: input.composeId, + }); + assertNotComposeUnit(unitType, "A break-glass grant"); + await grantBreakGlass({ + organizationId, + unitType, + unitId, + 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 }; + }), + + /** + * 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) + .query(async ({ ctx, input }) => + listBuildPolicyAudit({ + organizationId: ctx.session.activeOrganizationId, + limit: input.limit, + offset: input.offset, + }), + ), +}); diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index 5f9031fbf8b..8d19447a92d 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/apps/dokploy/server/queues/deployments-queue.ts b/apps/dokploy/server/queues/deployments-queue.ts index ed7dca6ff67..50104f8bf94 100644 --- a/apps/dokploy/server/queues/deployments-queue.ts +++ b/apps/dokploy/server/queues/deployments-queue.ts @@ -2,6 +2,8 @@ import { deployApplication, deployCompose, deployComposePreview, + // build-policy hook: see the pinnedImage branch below. + deployPinnedApplicationImage, deployPreviewApplication, rebuildApplication, rebuildCompose, @@ -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 4096942e0f3..796f3fa028f 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 b104ef0d0ae..3fe93167daa 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, @@ -95,6 +96,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 +108,7 @@ export const cleanQueuesByApplication = async (applicationId: string) => { `Removed ${removed} waiting job(s) for application ${applicationId}`, ); } + return removed; }; export const cleanQueuesByCompose = async (composeId: string) => { @@ -113,8 +118,41 @@ export const cleanQueuesByCompose = async (composeId: string) => { if (removed > 0) { console.log(`Removed ${removed} waiting job(s) for compose ${composeId}`); } + 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/db/schema/application.ts b/packages/server/src/db/schema/application.ts index 157c7754580..94ca2fb19bb 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/build-policy.ts b/packages/server/src/db/schema/build-policy.ts new file mode 100644 index 00000000000..cfbe245adca --- /dev/null +++ b/packages/server/src/db/schema/build-policy.ts @@ -0,0 +1,276 @@ +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. + * + * 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(5), + 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), +}); + +export const apiRollbackToBuildPolicyDigest = z.object({ + deploymentId: z.string().min(1), +}); diff --git a/packages/server/src/db/schema/compose.ts b/packages/server/src/db/schema/compose.ts index e12995b9123..a9e6eb6d3da 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 0c1b547e017..bb4bfe52cdf 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 dc1f114997a..79a2872290c 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 fa2da54cc44..e55cd524950 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 1317c41db41..b91939c12af 100644 --- a/packages/server/src/services/application.ts +++ b/packages/server/src/services/application.ts @@ -35,6 +35,16 @@ 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 { + type BuildPolicyPlan, + getBuildPolicyPushCommand, + planApplicationBuild, + prepareBuildPolicyDeploy, + reportBuildPolicyPlanFailure, + runBuildPolicyPreBuildGate, + toBuildPolicyUnit, +} from "./build-policy/apply"; import { createDeployment, createDeploymentPreview, @@ -191,18 +201,43 @@ 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. 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 + 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 || + application.serverId; + // <<< build-policy hook 1/4 const applicationEntity = { ...application, serverId: serverId, }; 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;"; @@ -228,8 +263,29 @@ 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 + // 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 +293,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 +320,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,19 +420,57 @@ 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 + 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 || + 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({ - 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;"; + // >>> 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) + 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 +478,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 +502,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, @@ -526,15 +642,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(), @@ -550,6 +686,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}`, @@ -573,7 +713,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, @@ -603,15 +746,46 @@ 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 + // 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"); @@ -669,11 +843,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({ @@ -686,6 +876,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( @@ -710,7 +903,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, @@ -747,14 +943,39 @@ 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, { + 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/build-policy/README.md b/packages/server/src/services/build-policy/README.md new file mode 100644 index 00000000000..d7adeccbc70 --- /dev/null +++ b/packages/server/src/services/build-policy/README.md @@ -0,0 +1,702 @@ +# 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, 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` | + +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 +`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. `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 `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, 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 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. + **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: + +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. + +**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, 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. `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 | +| `pinned-deploy.ts` | a whole deploy of a supplied image, with no build | +| `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 | +| `compose-checks.ts` | `requiredChecks` for compose units, run between the clone and the build | +| `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 | + +**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. + +`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 the literal string `build-policy hook`, +in one of two forms: + +- 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` + +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. + +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. | +| 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(...)` 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`. + +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. +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` + +`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`. +- `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. + +### `packages/server/src/services/compose.ts` + +Two calls to `waitForComposeRequiredChecks`, one per compose deploy pipeline: + +- 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` + +- 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 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` + +- `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` + +- `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` + +- `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` + +- 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`. + +### 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 + +- `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. + +--- + +## 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 +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. + +**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. + +### 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`) +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 + +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. + +--- + +## 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 +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. + +### 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**, 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 | + +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"). + +**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 + +`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 | +| `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 | +| `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 | + +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` 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 00000000000..c5a94360216 --- /dev/null +++ b/packages/server/src/services/build-policy/apply.ts @@ -0,0 +1,654 @@ +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 { 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 { + assertSafeImageReference, + buildDigestRef, + DIGEST_MARKER, + parseImageDigestFromLog, + parseImageTagFromLog, +} from "./image"; +import type { BuildPolicyDecision } from "./policy"; +import { assertBuildPolicyOk, resolveBuildPolicy } from "./resolve"; +import { 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; + /** + * 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 { + unitId: string; + unitName: string; + appName: 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; + 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; +}; + +/** + * 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, +): BuildPolicyPlan => ({ + enforced: false, + reason, + buildServerId: null, + registryId: null, + repository: null, + settings, +}); + +/** + * 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 | null } | null } | null; +}): PlanUnit => ({ + unitId: application.applicationId, + unitName: application.name, + appName: application.appName, + // `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, + 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 => { + 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, + 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, settings); + } + + const registry = await findRegistryByIdWithCredentials(ok.registryId); + return { + enforced: true, + buildServerId: ok.buildServerId, + registryId: ok.registryId, + repository: getRegistryTag(registry, unit.appName), + settings, + }; +}; + +/** + * 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); + // 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 ` +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, + 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 = ""; + 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; + + 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) }; +}; + +/** + * 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, + expectedRepository: plan.repository ?? "", + }); + 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; +}; + +/** 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": 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. + */ +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 | null } | null; + } | null; + }, +>({ + application, + plan, + deployment, + serverId, +}: { + application: T; + plan: BuildPolicyPlan; + deployment: { deploymentId: string; logPath: string }; + serverId: string | null; +}): Promise => { + // 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) 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 = await requirePublishedImage({ + plan, + logPath: deployment.logPath, + serverId, + organizationId, + applicationId: application.applicationId, + unitName: application.name, + }); + + await updateDeployment(deployment.deploymentId, { + imageTag: published.tag, + imageDigest: published.digest, + }); + + return { + ...application, + buildPolicyImage: published.ref, + ...(await authForPublishedRegistry(plan.registryId)), + }; +}; 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 00000000000..f956db64dc3 --- /dev/null +++ b/packages/server/src/services/build-policy/audit.ts @@ -0,0 +1,160 @@ +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/coalesce.ts b/packages/server/src/services/build-policy/coalesce.ts new file mode 100644 index 00000000000..231b713e273 --- /dev/null +++ b/packages/server/src/services/build-policy/coalesce.ts @@ -0,0 +1,120 @@ +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. + * + * 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; + applicationId: string | null; + composeId: string | null; + metadata: Record; +} + +export interface CoalesceRemoval { + removed: number; + titles?: string[]; +} + +export interface CoalesceQueuedDeployInput { + unitType: BuildPolicyUnitType; + unitId: string; + unitName?: string; + organizationId: string; + /** + * 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, + unitName, + organizationId, + removeWaiting, + recordAudit, +}: CoalesceQueuedDeployInput): Promise<{ removed: number }> => { + let result: CoalesceRemoval; + try { + result = normalize(await removeWaiting()); + } catch (error) { + console.error("[build-policy] queue coalescing failed", error); + return { removed: 0 }; + } + + if (result.removed <= 0) return { removed: 0 }; + + try { + await recordAudit({ + organizationId, + action: "deploy_coalesced", + applicationId: unitType === "application" ? unitId : null, + composeId: unitType === "compose" ? unitId : null, + 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: result.removed }; +}; 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 00000000000..044387feea4 --- /dev/null +++ b/packages/server/src/services/build-policy/compose-checks.ts @@ -0,0 +1,123 @@ +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. + * + * **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 + * 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/errors.ts b/packages/server/src/services/build-policy/errors.ts new file mode 100644 index 00000000000..173d861a734 --- /dev/null +++ b/packages/server/src/services/build-policy/errors.ts @@ -0,0 +1,36 @@ +/** + * 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" + /** The unit can never satisfy required checks; refused at the API boundary. */ + | "REQUIRED_CHECKS_UNSUPPORTED"; + +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/exclusions.ts b/packages/server/src/services/build-policy/exclusions.ts new file mode 100644 index 00000000000..f12ef561609 --- /dev/null +++ b/packages/server/src/services/build-policy/exclusions.ts @@ -0,0 +1,80 @@ +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 00000000000..5e6bef554db --- /dev/null +++ b/packages/server/src/services/build-policy/github-checks.ts @@ -0,0 +1,211 @@ +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)); + +/** + * 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; + 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) { + // 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. " + + "Connect a GitHub App provider on the unit's Git tab, or clear " + + "the unit's required checks.", + ); + } + const provider = await findGithubById(unit.githubId); + const octokit = authGithub(provider); + // 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 { + 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/hook-body.ts b/packages/server/src/services/build-policy/hook-body.ts new file mode 100644 index 00000000000..5775743e249 --- /dev/null +++ b/packages/server/src/services/build-policy/hook-body.ts @@ -0,0 +1,103 @@ +import { BuildPolicyError } from "./errors"; +import { + assertSafeImageReference, + buildDigestRef, + isValidDigest, + repositoryOf, + splitRepositoryAndTag, +} 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, + /** + * 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" }; + 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.", + ); + } + + // 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( + "REGISTRY_NOT_ALLOWED", + `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 = splitRepositoryAndTag(reference).tag; + + 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 00000000000..ad51a997a49 --- /dev/null +++ b/packages/server/src/services/build-policy/image.ts @@ -0,0 +1,112 @@ +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. */ +export 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 }; +}; + +/** 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; + 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/index.ts b/packages/server/src/services/build-policy/index.ts new file mode 100644 index 00000000000..5026b5b6f86 --- /dev/null +++ b/packages/server/src/services/build-policy/index.ts @@ -0,0 +1,20 @@ +export * from "./apply"; +export * from "./audit"; +export * from "./coalesce"; +export * from "./compose-checks"; +export * from "./errors"; +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"; +export * from "./resolve"; +export * from "./rollback"; +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/ownership.ts b/packages/server/src/services/build-policy/ownership.ts new file mode 100644 index 00000000000..4506c7b23cb --- /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 new file mode 100644 index 00000000000..8ad4f8cadf9 --- /dev/null +++ b/packages/server/src/services/build-policy/pinned-deploy.ts @@ -0,0 +1,188 @@ +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 { + createDeployment, + updateDeployment, + updateDeploymentStatus, +} from "../deployment"; +import { findAllRegistryByOrganizationId } from "../registry"; +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 = "", + 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; + 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] ${introLog}\n` + + ` image: ${pinnedImage.tag ?? pinnedImage.ref}\n` + + ` digest: ${pinnedImage.digest}\n`, + ); + + 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, + 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, + ...(pullRegistry + ? { registry: null, buildRegistry: pullRegistry } + : { buildRegistry: application.buildRegistry ?? null }), + }); + + 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/policy.ts b/packages/server/src/services/build-policy/policy.ts new file mode 100644 index 00000000000..23a65fd1549 --- /dev/null +++ b/packages/server/src/services/build-policy/policy.ts @@ -0,0 +1,134 @@ +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. + // + // 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, 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 + // `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" }; + } + + 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 00000000000..1a50d9158d6 --- /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/resolve.ts b/packages/server/src/services/build-policy/resolve.ts new file mode 100644 index 00000000000..105226cee92 --- /dev/null +++ b/packages/server/src/services/build-policy/resolve.ts @@ -0,0 +1,160 @@ +import type { + BuildPolicyAudit, + 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; +} + +/** + * 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, +): 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, + grant: null, + 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, + }); + + 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 && + 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/rollback.ts b/packages/server/src/services/build-policy/rollback.ts new file mode 100644 index 00000000000..662e0e319c6 --- /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.", + }); +}; 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 00000000000..b13de65cc61 --- /dev/null +++ b/packages/server/src/services/build-policy/settings.ts @@ -0,0 +1,117 @@ +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; +}; + +/** + * "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; + defaultRegistryId?: string | null; + requiredChecksTimeoutMinutes?: number; +} + +export const upsertBuildPolicySettings = async ( + organizationId: string, + updates: BuildPolicySettingsUpdate, +): Promise => { + const existing = await findBuildPolicySettings(organizationId); + const now = new Date().toISOString(); + + // 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"); + } + return created; + } + + const [updated] = await db + .update(buildPolicySettings) + .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. + * + * 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 ?? + DEFAULT_REQUIRED_CHECKS_TIMEOUT_MINUTES) * 60_000; 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 00000000000..47d8f698c83 --- /dev/null +++ b/packages/server/src/services/build-policy/skip-deploy.ts @@ -0,0 +1,41 @@ +/** + * `[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*, 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]", + "[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 00000000000..58ec1d1808b --- /dev/null +++ b/packages/server/src/services/build-policy/source.ts @@ -0,0 +1,199 @@ +import { BuildPolicyError } from "./errors"; + +/** + * "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. + * + * 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, +): { 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 }; +}; + +/** + * 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, + ); +}; 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 00000000000..bfa06ae1717 --- /dev/null +++ b/packages/server/src/services/build-policy/watch-paths.ts @@ -0,0 +1,158 @@ +/** + * Default `watchPaths` for a unit that has none (spec 5.2.6): derive them from + * the unit's build path, the Dockerfile directory and the compose file + * directory. + * + * Three deliberate choices: + * + * - 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, 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. */ +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(buildPathForSource(input)) ?? ""; + 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 }; +}; 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 00000000000..be43d2628f3 --- /dev/null +++ b/packages/server/src/services/build-policy/webhook.ts @@ -0,0 +1,336 @@ +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 { findRegistryByIdWithCredentials } from "../registry"; +import { recordBuildPolicyAudit } from "./audit"; +import { + type CoalesceQueuedDeployInput, + coalesceQueuedDeploy, +} from "./coalesce"; +import { parseDeployHookImage } from "./hook-body"; +import { toPinnedImageJob } from "./pinned-deploy"; +import type { BuildPolicyUnitType } from "./policy"; +import { previewBuildPolicyDecision } from "./resolve"; +import { + findBuildPolicySettings, + isBuildPolicyEnforcedAnywhere, +} from "./settings"; +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. + * + * **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. + */ +export interface BuildPolicyGateUnit { + unitId: string; + 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; +} + +export type BuildPolicyGateResult = + | { deploy: true; coalesced: number } + | { + deploy: false; + reason: "skip_deploy_marker" | "watch_paths"; + message: string; + }; + +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 => { + 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 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}`; + 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, + 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, + }); + if (!shouldDeploy(paths, changedFiles)) { + 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, + 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 }; + } + } + + const { removed } = await coalesceQueuedDeploy({ + unitType, + unitId: unit.unitId, + unitName: unit.unitName, + organizationId, + removeWaiting, + recordAudit: recordBuildPolicyAudit, + }); + + 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). + * + * Two gates, both required: + * + * 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; + /** + * 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 ( + unit: DeployHookImageUnit, + body: unknown, +): Promise< + { 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 { + // 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. + // + // 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 = + decision.mode === "remote" + ? decision.registryId + : (unit.registryId ?? + unit.buildRegistryId ?? + settings.defaultRegistryId); + if (!registryId) { + return { + ok: false, + message: + "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 registry = await findRegistryByIdWithCredentials(registryId); + const allowedRepository = getRegistryTag(registry, unit.appName); + const parsed = parseDeployHookImage(body, [allowedRepository]); + return { ok: true, pinnedImage: toPinnedImageJob(parsed) }; + } catch (error) { + return { + ok: false, + message: + error instanceof Error ? error.message : "Invalid deploy hook image", + }; + } +}; + +/** + * 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; diff --git a/packages/server/src/services/compose.ts b/packages/server/src/services/compose.ts index 9718ebd896b..b6570c3fe33 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); @@ -508,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`; diff --git a/packages/server/src/services/deployment.ts b/packages/server/src/services/deployment.ts index e419483178b..dfe0a8daa17 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]) { @@ -242,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; diff --git a/packages/server/src/utils/builders/index.ts b/packages/server/src/utils/builders/index.ts index 2c0d9ab3b6b..6a9671f9d30 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`;