diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index d889bd8f185..8be3933f15a 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,5 +1,5 @@ # Dockerfile for DevContainer -FROM node:24.4.0-bullseye-slim +FROM node:24.22.1-bookworm-slim # Install essential packages RUN apt-get update && apt-get install -y \ diff --git a/.github/workflows/dokploy.yml b/.github/workflows/dokploy.yml index 572adc6b14f..2aaea6bc4fa 100644 --- a/.github/workflows/dokploy.yml +++ b/.github/workflows/dokploy.yml @@ -207,7 +207,7 @@ jobs: - uses: actions/setup-node@v7 with: - node-version: 24.21.0 + node-version: 24.22.1 cache: pnpm - name: Generate OpenAPI specification diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 948b3483422..5edaa89c602 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -30,7 +30,7 @@ jobs: - uses: actions/setup-node@v7 if: ${{ !cancelled() }} with: - node-version: 24.21.0 + node-version: 24.22.1 - run: bun install --frozen-lockfile if: ${{ !cancelled() }} - run: bun run server:build @@ -54,7 +54,7 @@ jobs: bun-version: 1.4.2 - uses: actions/setup-node@v7 with: - node-version: 24.21.0 + node-version: 24.22.1 - name: Install Nixpacks if: matrix.job == 'test' && runner.environment == 'github-hosted' diff --git a/.github/workflows/sync-openapi-docs.yml b/.github/workflows/sync-openapi-docs.yml index 86774010a27..87e35fa1612 100644 --- a/.github/workflows/sync-openapi-docs.yml +++ b/.github/workflows/sync-openapi-docs.yml @@ -29,7 +29,7 @@ jobs: bun-version: 1.4.2 - uses: actions/setup-node@v7 with: - node-version: 24.21.0 + node-version: 24.22.1 - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.nvmrc b/.nvmrc index 84e5de6ef9d..aec62a6cc64 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -24.4.0 \ No newline at end of file +24.22.1 \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bb9d9e09f7f..34e51b385f9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,7 +53,7 @@ feat: add new feature Before you start, please make the clone based on the `canary` branch, since the `main` branch is the source of truth and should always reflect the latest stable release, also the PRs will be merged to the `canary` branch. -We use Node v24.4.0 and recommend this specific version. If you have nvm installed, you can run `nvm install 24.4.0 && nvm use` in the root directory. +We use Node v24.22.1 and recommend this specific version. If you have nvm installed, you can run `nvm install 24.22.1 && nvm use` in the root directory. We use Bun v1.4.2 as the package manager and recommend this specific version. The lockfile requires Bun >=1.4.2. ```bash diff --git a/Dockerfile b/Dockerfile index 3777c79380e..f6520a9a20c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1 -FROM node:24.21.0-slim AS base +FROM node:24.22.1-bookworm-slim AS base ENV BUN_INSTALL="/root/.bun" ENV PATH="$BUN_INSTALL/bin:$PATH" RUN apt-get update \ diff --git a/Dockerfile.cloud b/Dockerfile.cloud index e1ba3fa0c6a..786f83b9fac 100644 --- a/Dockerfile.cloud +++ b/Dockerfile.cloud @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1 -FROM node:24.21.0-slim AS base +FROM node:24.22.1-bookworm-slim AS base ENV BUN_INSTALL="/root/.bun" ENV PATH="$BUN_INSTALL/bin:$PATH" RUN apt-get update \ diff --git a/Dockerfile.monitoring b/Dockerfile.monitoring index c54580ee122..7e6f082d7c4 100644 --- a/Dockerfile.monitoring +++ b/Dockerfile.monitoring @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1 # Build stage -FROM golang:1.21-alpine3.19 AS builder +FROM golang:1.24-alpine3.22 AS builder # Instalar dependencias necesarias RUN apk add --no-cache gcc musl-dev sqlite-dev diff --git a/Dockerfile.schedule b/Dockerfile.schedule index 150b962d081..7218f15473d 100644 --- a/Dockerfile.schedule +++ b/Dockerfile.schedule @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1 -FROM node:24.21.0-slim AS base +FROM node:24.22.1-bookworm-slim AS base ENV BUN_INSTALL="/root/.bun" ENV PATH="$BUN_INSTALL/bin:$PATH" RUN apt-get update \ diff --git a/Dockerfile.server b/Dockerfile.server index 333c7e92c96..763f006870c 100644 --- a/Dockerfile.server +++ b/Dockerfile.server @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1 -FROM node:24.21.0-slim AS base +FROM node:24.22.1-bookworm-slim AS base ENV BUN_INSTALL="/root/.bun" ENV PATH="$BUN_INSTALL/bin:$PATH" RUN apt-get update \ diff --git a/README.md b/README.md index c6dc560b3fc..4181f34716e 100644 --- a/README.md +++ b/README.md @@ -263,6 +263,7 @@ Dokploy includes multiple features to make your life easier. - **Templates**: Deploy open-source templates (Plausible, Pocketbase, Calcom, etc.) with a single click. - **Traefik Integration**: Automatically integrates with Traefik for routing and load balancing. - **Real-time Monitoring**: Monitor CPU, memory, storage, and network usage for every resource. +- **Logs & Metrics**: Ship container logs and host/container metrics to Grafana (Loki, Prometheus/Mimir), Datadog, New Relic, InfluxDB, VictoriaMetrics, Better Stack, Elasticsearch/OpenSearch, Splunk, or AWS CloudWatch, tagged by project/environment/application. - **Docker Management**: Easily deploy and manage Docker containers. - **CLI/API**: Manage your applications and databases using the command line or through the API. - **Notifications**: Get notified when your deployments succeed or fail (via Slack, Discord, Telegram, Email, etc.). diff --git a/apps/api/package.json b/apps/api/package.json index e393511da5e..d1f3ff48956 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -33,7 +33,7 @@ }, "packageManager": "bun@1.4.2", "engines": { - "node": "^24.4.0", + "node": "^24.22.1", "bun": ">=1.4.2" } } diff --git a/apps/dokploy/__test__/backup/rclone-command-boundary.test.ts b/apps/dokploy/__test__/backup/rclone-command-boundary.test.ts index 45c2f883e28..254d5ca8632 100644 --- a/apps/dokploy/__test__/backup/rclone-command-boundary.test.ts +++ b/apps/dokploy/__test__/backup/rclone-command-boundary.test.ts @@ -61,6 +61,7 @@ vi.mock("@dokploy/server/services/deployment", () => ({ vi.mock("@dokploy/server/services/destination", () => ({ findDestinationById: mocks.findDestinationById, + findDestinationByIdForOrganization: mocks.findDestinationById, })); vi.mock("@dokploy/server/services/environment", () => ({ diff --git a/apps/dokploy/__test__/backups/backup-destination-organization.test.ts b/apps/dokploy/__test__/backups/backup-destination-organization.test.ts new file mode 100644 index 00000000000..e5a2f369ea4 --- /dev/null +++ b/apps/dokploy/__test__/backups/backup-destination-organization.test.ts @@ -0,0 +1,213 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + createBackup: vi.fn(), + findBackupById: vi.fn(), + updateBackupById: vi.fn(), + findPostgresById: vi.fn(), + restorePostgresBackup: vi.fn(), + restoreWebServerBackup: vi.fn(), + findDestinationRow: vi.fn(), + findRestoreBackups: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + backups: { findMany: mocks.findRestoreBackups }, + destinations: { findFirst: mocks.findDestinationRow }, + }, + }, +})); + +vi.mock("@dokploy/server/services/permission", () => ({ + checkPermission: vi.fn(async () => undefined), + checkServicePermissionAndAccess: vi.fn(async () => undefined), +})); + +vi.mock("@dokploy/server", async (importOriginal) => ({ + ...(await importOriginal()), + IS_CLOUD: false, + createBackup: mocks.createBackup, + findBackupById: mocks.findBackupById, + updateBackupById: mocks.updateBackupById, + findPostgresById: mocks.findPostgresById, + scheduleBackup: vi.fn(), + removeScheduleBackup: vi.fn(), +})); + +vi.mock("@dokploy/server/lib/auth", () => ({ + validateRequest: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/restore", () => ({ + restorePostgresBackup: mocks.restorePostgresBackup, + restoreWebServerBackup: mocks.restoreWebServerBackup, +})); + +vi.mock("@dokploy/server/utils/backups/compose", () => ({ + runComposeBackup: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/backups/utils", () => ({ + getS3Credentials: vi.fn(), + normalizeS3Path: vi.fn(() => ""), +})); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: vi.fn(), + execAsyncRemote: vi.fn(), +})); + +vi.mock("@/server/api/utils/audit", () => ({ + audit: vi.fn(async () => undefined), +})); + +vi.mock("@/server/api/utils/plan-limits", () => ({ + assertDatabaseBackupLimit: vi.fn(), +})); + +vi.mock("@/server/utils/backup", () => ({ + removeJob: vi.fn(), + schedule: vi.fn(), + updateJob: vi.fn(), +})); + +const { backupRouter } = await import("@/server/api/routers/backup"); + +const caller = backupRouter.createCaller({ + session: { activeOrganizationId: "org-a" }, + user: { id: "user-1", email: "user@example.com", role: "owner" }, +} as Parameters[0]); + +const ownDestination = { destinationId: "dest-a", organizationId: "org-a" }; +const foreignDestination = { destinationId: "dest-b", organizationId: "org-b" }; + +const createInput = { + schedule: "0 0 * * *", + prefix: "/", + database: "app", + databaseType: "postgres" as const, + backupType: "database" as const, + postgresId: "pg-1", + enabled: false, +}; + +const updateInput = { + backupId: "backup-1", + schedule: "0 0 * * *", + enabled: false, + prefix: "/", + database: "app", + keepLatestCount: 0, + serviceName: null, + metadata: {}, + databaseType: "postgres" as const, +}; + +const restoreInput = { + databaseId: "pg-1", + databaseType: "postgres" as const, + backupType: "database" as const, + databaseName: "app", + backupFile: "app/2024.sql.gz", +}; + +const drain = async (stream: unknown) => { + for await (const _ of stream as AsyncIterable) { + } +}; + +describe("backup endpoints scope destinations to the active organization", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.createBackup.mockResolvedValue({ backupId: "backup-1" }); + mocks.findBackupById.mockResolvedValue({ + backupId: "backup-1", + postgresId: "pg-1", + destinationId: "dest-a", + enabled: false, + databaseType: "postgres", + backupType: "database", + }); + mocks.findPostgresById.mockResolvedValue({ postgresId: "pg-1" }); + // The fork only restores objects under a schedule of the same database. + mocks.findRestoreBackups.mockResolvedValue([ + { appName: "app", prefix: "/", postgres: { appName: "app" } }, + ]); + }); + + it("rejects creating a backup on a destination from another organization", async () => { + mocks.findDestinationRow.mockResolvedValue(foreignDestination); + + await expect( + caller.create({ ...createInput, destinationId: "dest-b" }), + ).rejects.toMatchObject({ + message: "You don't have access to this destination.", + }); + expect(mocks.createBackup).not.toHaveBeenCalled(); + }); + + it("creates a backup on a destination of the active organization", async () => { + mocks.findDestinationRow.mockResolvedValue(ownDestination); + + await caller.create({ ...createInput, destinationId: "dest-a" }); + + expect(mocks.createBackup).toHaveBeenCalledTimes(1); + }); + + it("rejects moving a backup to a destination from another organization", async () => { + mocks.findDestinationRow.mockResolvedValue(foreignDestination); + + await expect( + caller.update({ ...updateInput, destinationId: "dest-b" }), + ).rejects.toMatchObject({ + message: "You don't have access to this destination.", + }); + expect(mocks.updateBackupById).not.toHaveBeenCalled(); + }); + + it("rejects restoring from a destination of another organization", async () => { + mocks.findDestinationRow.mockResolvedValue(foreignDestination); + + const stream = await caller.restoreBackupWithLogs({ + ...restoreInput, + destinationId: "dest-b", + }); + + await expect(drain(stream)).rejects.toMatchObject({ + code: "UNAUTHORIZED", + }); + expect(mocks.restorePostgresBackup).not.toHaveBeenCalled(); + }); + + it("restores from a destination of the active organization", async () => { + mocks.findDestinationRow.mockResolvedValue(ownDestination); + + const stream = await caller.restoreBackupWithLogs({ + ...restoreInput, + destinationId: "dest-a", + }); + await drain(stream); + + expect(mocks.restorePostgresBackup).toHaveBeenCalledTimes(1); + }); + + // Upstream leaves service-less web-server schedules unscoped; the fork + // scopes their destination like every other schedule. + it("rejects web-server schedules on a destination from another organization", async () => { + mocks.findDestinationRow.mockResolvedValue(foreignDestination); + + await expect( + caller.create({ + ...createInput, + postgresId: undefined, + databaseType: "web-server", + destinationId: "dest-b", + }), + ).rejects.toMatchObject({ + message: "You don't have access to this destination.", + }); + expect(mocks.createBackup).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/dokploy/__test__/backups/run-backup-destination-organization.test.ts b/apps/dokploy/__test__/backups/run-backup-destination-organization.test.ts new file mode 100644 index 00000000000..aefa04e540c --- /dev/null +++ b/apps/dokploy/__test__/backups/run-backup-destination-organization.test.ts @@ -0,0 +1,189 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + findDestinationRow: vi.fn(), + createDeploymentBackup: vi.fn(), + createDeploymentVolumeBackup: vi.fn(), + updateDeploymentStatus: vi.fn(), + findVolumeBackupById: vi.fn(), + execAsync: vi.fn(), + execAsyncRemote: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { query: { destinations: { findFirst: mocks.findDestinationRow } } }, +})); + +vi.mock("@dokploy/server/constants", () => ({ + paths: () => ({ VOLUME_BACKUPS_PATH: "/tmp/volume-backups" }), +})); + +vi.mock("@dokploy/server/services/deployment", () => ({ + createDeploymentBackup: mocks.createDeploymentBackup, + createDeploymentVolumeBackup: mocks.createDeploymentVolumeBackup, + updateDeploymentStatus: mocks.updateDeploymentStatus, +})); + +vi.mock("@dokploy/server/services/environment", () => ({ + findEnvironmentById: vi.fn(async () => ({ projectId: "project-1" })), +})); + +vi.mock("@dokploy/server/services/project", () => ({ + findProjectById: vi.fn(async () => ({ + name: "project", + organizationId: "org-a", + })), +})); + +vi.mock("@dokploy/server/services/volume-backups", () => ({ + findVolumeBackupById: mocks.findVolumeBackupById, +})); + +vi.mock("@dokploy/server/utils/backups/utils", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@dokploy/server/utils/backups/utils") + >()), + assertRcloneS3DestinationAllowed: vi.fn( + async (destination: T) => destination, + ), + getBackupCommand: vi.fn(() => "echo backup"), + getBackupTimestamp: vi.fn(() => "2024"), + getS3Credentials: vi.fn(() => []), + normalizeS3Path: vi.fn(() => ""), +})); + +vi.mock("@dokploy/server/utils/volume-backups/backup", () => ({ + backupVolume: vi.fn(async () => "echo backup"), + getVolumeServiceAppName: vi.fn(() => "app"), + resolveVolumeBackupServerId: vi.fn(() => null), +})); + +vi.mock("@dokploy/server/utils/notifications/database-backup", () => ({ + sendDatabaseBackupNotifications: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/notifications/volume-backup", () => ({ + sendVolumeBackupNotifications: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: mocks.execAsync, + execAsyncRemote: mocks.execAsyncRemote, +})); + +const { runPostgresBackup } = await import( + "@dokploy/server/utils/backups/postgres" +); +const { runVolumeBackup } = await import( + "@dokploy/server/utils/volume-backups/utils" +); + +// The fork builds real rclone commands, which need a full S3 destination. +const s3Fields = { + bucket: "bucket", + endpoint: "https://s3.example.com", + region: "us-east-1", + accessKey: "access-key", + secretAccessKey: "secret-key", + provider: null, + additionalFlags: null, +}; +const ownDestination = { + destinationId: "dest-a", + organizationId: "org-a", + ...s3Fields, +}; +const foreignDestination = { + destinationId: "dest-b", + organizationId: "org-b", + ...s3Fields, +}; + +const postgres = { + name: "db", + appName: "db-app", + environmentId: "env-1", + serverId: null, + databaseUser: "postgres", +}; + +const backup = { + backupId: "backup-1", + prefix: "/", + database: "app", + databaseType: "postgres", + backupType: "database", +}; + +const volumeBackup = { + volumeBackupId: "vb-1", + name: "data", + appName: "vb-app", + volumeName: "app_data", + serviceType: "application", + destinationId: "dest-b", + keepLatestCount: 0, + application: { + appName: "app", + serverId: null, + environment: { project: { name: "project", organizationId: "org-a" } }, + }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.createDeploymentBackup.mockResolvedValue({ + deploymentId: "dep-1", + logPath: "/tmp/dep-1.log", + }); + mocks.createDeploymentVolumeBackup.mockResolvedValue({ + deploymentId: "dep-1", + logPath: "/tmp/dep-1.log", + }); + mocks.execAsync.mockResolvedValue({ stdout: "", stderr: "" }); + mocks.findVolumeBackupById.mockResolvedValue(volumeBackup); +}); + +describe("runPostgresBackup checks the destination organization before side effects", () => { + it("aborts before creating a deployment when the destination belongs to another organization", async () => { + mocks.findDestinationRow.mockResolvedValue(foreignDestination); + + await expect( + runPostgresBackup(postgres as never, backup as never), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + + expect(mocks.createDeploymentBackup).not.toHaveBeenCalled(); + expect(mocks.execAsync).not.toHaveBeenCalled(); + }); + + it("runs when the destination belongs to the project organization", async () => { + mocks.findDestinationRow.mockResolvedValue(ownDestination); + + await runPostgresBackup(postgres as never, backup as never); + + expect(mocks.execAsync).toHaveBeenCalledTimes(1); + expect(mocks.updateDeploymentStatus).toHaveBeenCalledWith("dep-1", "done"); + }); +}); + +describe("runVolumeBackup checks the destination organization before side effects", () => { + it("aborts before creating a deployment when the destination belongs to another organization", async () => { + mocks.findDestinationRow.mockResolvedValue(foreignDestination); + + await expect(runVolumeBackup("vb-1")).rejects.toMatchObject({ + code: "UNAUTHORIZED", + }); + + expect(mocks.createDeploymentVolumeBackup).not.toHaveBeenCalled(); + expect(mocks.execAsync).not.toHaveBeenCalled(); + }); + + it("runs when the destination belongs to the project organization", async () => { + mocks.findDestinationRow.mockResolvedValue(ownDestination); + + await runVolumeBackup("vb-1"); + + expect(mocks.execAsync).toHaveBeenCalledTimes(1); + expect(mocks.updateDeploymentStatus).toHaveBeenCalledWith("dep-1", "done"); + }); +}); diff --git a/apps/dokploy/__test__/backups/volume-backup-destination-organization.test.ts b/apps/dokploy/__test__/backups/volume-backup-destination-organization.test.ts new file mode 100644 index 00000000000..ce97e6a3ec9 --- /dev/null +++ b/apps/dokploy/__test__/backups/volume-backup-destination-organization.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + createVolumeBackup: vi.fn(), + findVolumeBackupById: vi.fn(), + updateVolumeBackup: vi.fn(), + findDestinationRow: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + destinations: { findFirst: mocks.findDestinationRow }, + // The fork only backs up volumes the service declares. + mounts: { + findMany: vi.fn(async () => [ + { mountId: "mount-1", volumeName: "app_data" }, + ]), + }, + }, + }, +})); + +// Service placement has its own tests; here it must not stop the request +// before the destination check. +vi.mock("@/server/api/utils/placement-access", () => ({ + assertServicePlacementAccess: vi.fn(async () => ({})), + assertTargetServerAccess: vi.fn(async () => undefined), +})); + +vi.mock("@dokploy/server/services/permission", () => ({ + checkPermission: vi.fn(async () => undefined), + checkServicePermissionAndAccess: vi.fn(async () => undefined), +})); + +vi.mock("@dokploy/server", async (importOriginal) => ({ + ...(await importOriginal()), + IS_CLOUD: false, + createVolumeBackup: mocks.createVolumeBackup, + findVolumeBackupById: mocks.findVolumeBackupById, + updateVolumeBackup: mocks.updateVolumeBackup, + scheduleVolumeBackup: vi.fn(), + removeVolumeBackupJob: vi.fn(), + removeVolumeBackup: vi.fn(), + restoreVolume: vi.fn(), + runVolumeBackup: vi.fn(), +})); + +vi.mock("@dokploy/server/lib/auth", () => ({ + validateRequest: vi.fn(), +})); + +vi.mock("@dokploy/server/services/server", () => ({ + findServerById: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsyncRemote: vi.fn(), + execAsyncStream: vi.fn(), +})); + +vi.mock("@/server/api/utils/audit", () => ({ + audit: vi.fn(async () => undefined), +})); + +vi.mock("@/server/api/utils/plan-limits", () => ({ + assertVolumeBackupLimit: vi.fn(), +})); + +vi.mock("@/server/utils/backup", () => ({ + removeJob: vi.fn(), + schedule: vi.fn(), + updateJob: vi.fn(), +})); + +const { volumeBackupsRouter } = await import( + "@/server/api/routers/volume-backups" +); + +const caller = volumeBackupsRouter.createCaller({ + session: { activeOrganizationId: "org-a" }, + user: { id: "user-1", email: "user@example.com", role: "owner" }, +} as Parameters[0]); + +const ownDestination = { destinationId: "dest-a", organizationId: "org-a" }; +const foreignDestination = { destinationId: "dest-b", organizationId: "org-b" }; + +const createInput = { + name: "data", + volumeName: "app_data", + prefix: "/", + cronExpression: "0 0 * * *", + serviceType: "application" as const, + applicationId: "app-1", + enabled: false, +}; + +describe("volume backup endpoints scope destinations to the active organization", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.createVolumeBackup.mockResolvedValue({ + volumeBackupId: "vb-1", + enabled: false, + }); + mocks.findVolumeBackupById.mockResolvedValue({ + volumeBackupId: "vb-1", + applicationId: "app-1", + destinationId: "dest-a", + }); + mocks.updateVolumeBackup.mockResolvedValue({ + volumeBackupId: "vb-1", + enabled: false, + }); + }); + + it("rejects creating a volume backup on a destination from another organization", async () => { + mocks.findDestinationRow.mockResolvedValue(foreignDestination); + + await expect( + caller.create({ ...createInput, destinationId: "dest-b" }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + expect(mocks.createVolumeBackup).not.toHaveBeenCalled(); + }); + + it("creates a volume backup on a destination of the active organization", async () => { + mocks.findDestinationRow.mockResolvedValue(ownDestination); + + await caller.create({ ...createInput, destinationId: "dest-a" }); + + expect(mocks.createVolumeBackup).toHaveBeenCalledTimes(1); + }); + + it("rejects moving a volume backup to a destination from another organization", async () => { + mocks.findDestinationRow.mockResolvedValue(foreignDestination); + + await expect( + caller.update({ + ...createInput, + volumeBackupId: "vb-1", + destinationId: "dest-b", + }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + expect(mocks.updateVolumeBackup).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/dokploy/__test__/certificate/certificate-router-assigned-server.test.ts b/apps/dokploy/__test__/certificate/certificate-router-assigned-server.test.ts index 211c72acd20..1b32f369dc1 100644 --- a/apps/dokploy/__test__/certificate/certificate-router-assigned-server.test.ts +++ b/apps/dokploy/__test__/certificate/certificate-router-assigned-server.test.ts @@ -200,17 +200,18 @@ describe("certificate router assigned-server boundary", () => { expect(mocks.createCertificate).not.toHaveBeenCalled(); }); - it("rejects unsafe certificate paths before file writes", async () => { + it("drops client-supplied certificate paths before file writes", async () => { for (const certificatePath of ["cert;id", ".", ".."]) { - await expect( - createCaller().create({ - ...certificateInput, - certificatePath, - }), - ).rejects.toThrow(); + await createCaller().create({ + ...certificateInput, + certificatePath, + } as typeof certificateInput); } - expect(mocks.createCertificate).not.toHaveBeenCalled(); + expect(mocks.createCertificate).toHaveBeenCalledTimes(3); + for (const [input] of mocks.createCertificate.mock.calls) { + expect(input).not.toHaveProperty("certificatePath"); + } }); it("allows cloud certificate creation on accessible servers", async () => { diff --git a/apps/dokploy/__test__/certificate/certificate-service-command-boundary.test.ts b/apps/dokploy/__test__/certificate/certificate-service-command-boundary.test.ts index 1a5420e7cfc..e890a4c03f1 100644 --- a/apps/dokploy/__test__/certificate/certificate-service-command-boundary.test.ts +++ b/apps/dokploy/__test__/certificate/certificate-service-command-boundary.test.ts @@ -95,13 +95,21 @@ describe("certificate service command boundary", () => { expect(mocks.execAsyncRemote).not.toHaveBeenCalled(); }); - it("rejects dot-segment certificate paths before persistence", async () => { + it("rejects dot-segment stored certificate paths before remote file commands", async () => { + mocks.dbInsert.mockReturnValue( + createReturningChain([ + { + ...certificate, + certificatePath: "..", + }, + ]), + ); + await expect( createCertificate( { autoRenew: false, certificateData: certificate.certificateData, - certificatePath: "..", organizationId: "org-1", privateKey: certificate.privateKey, name: "certificate", @@ -111,7 +119,6 @@ describe("certificate service command boundary", () => { ), ).rejects.toMatchObject({ code: "BAD_REQUEST" }); - expect(mocks.dbInsert).not.toHaveBeenCalled(); expect(mocks.execAsyncRemote).not.toHaveBeenCalled(); }); diff --git a/apps/dokploy/__test__/certificates/certificate-path-traversal.test.ts b/apps/dokploy/__test__/certificates/certificate-path-traversal.test.ts new file mode 100644 index 00000000000..4c556603b59 --- /dev/null +++ b/apps/dokploy/__test__/certificates/certificate-path-traversal.test.ts @@ -0,0 +1,169 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const CERTIFICATES_PATH = "/etc/dokploy/traefik/dynamic/certificates"; + +const mocks = vi.hoisted(() => ({ + findCertificateRow: vi.fn(), + insertReturning: vi.fn(), + deleteWhere: vi.fn(), + removeDirectoryIfExistsContent: vi.fn(), + execAsyncRemote: vi.fn(), + existsSync: vi.fn(() => false), + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), +})); + +vi.mock("node:fs", () => ({ + default: { + existsSync: mocks.existsSync, + mkdirSync: mocks.mkdirSync, + writeFileSync: mocks.writeFileSync, + }, +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { certificates: { findFirst: mocks.findCertificateRow } }, + insert: () => ({ + values: () => ({ returning: mocks.insertReturning }), + }), + delete: () => ({ + where: () => ({ returning: mocks.deleteWhere }), + }), + }, +})); + +vi.mock("@dokploy/server/constants", () => ({ + paths: () => ({ CERTIFICATES_PATH }), +})); + +vi.mock("@dokploy/server/utils/filesystem/directory", () => ({ + removeDirectoryIfExistsContent: mocks.removeDirectoryIfExistsContent, +})); + +vi.mock("@dokploy/server/utils/docker/utils", () => ({ + encodeBase64: (value: string) => Buffer.from(value).toString("base64"), +})); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsyncRemote: mocks.execAsyncRemote, +})); + +const { apiCreateCertificate } = await import("@dokploy/server/db/schema"); +const { createCertificate, removeCertificateById } = await import( + "@dokploy/server/services/certificate" +); + +const certificate = { + certificateId: "cert-1", + name: "example", + certificateData: "CERT", + privateKey: "KEY", + organizationId: "org-a", + serverId: null, +}; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.existsSync.mockReturnValue(false); + mocks.deleteWhere.mockResolvedValue([certificate]); +}); + +describe("certificate create input", () => { + it("ignores a client-supplied certificatePath", () => { + const parsed = apiCreateCertificate.parse({ + name: "example", + certificateData: "CERT", + privateKey: "KEY", + organizationId: "org-a", + certificatePath: "../../../../tmp/escape", + }); + + expect(parsed).not.toHaveProperty("certificatePath"); + }); +}); + +describe("certificate files stay inside CERTIFICATES_PATH", () => { + it.each(["../../../../tmp/escape", "/tmp/escape", "", "."])( + "refuses to delete the directory resolved from %j", + async (certificatePath) => { + mocks.findCertificateRow.mockResolvedValue({ + ...certificate, + certificatePath, + }); + + await expect(removeCertificateById("cert-1")).rejects.toMatchObject({ + code: "BAD_REQUEST", + }); + expect(mocks.removeDirectoryIfExistsContent).not.toHaveBeenCalled(); + expect(mocks.deleteWhere).not.toHaveBeenCalled(); + }, + ); + + it("refuses to delete an escaping directory on a remote server", async () => { + mocks.findCertificateRow.mockResolvedValue({ + ...certificate, + serverId: "server-1", + certificatePath: "../../../../tmp/escape", + }); + + await expect(removeCertificateById("cert-1")).rejects.toMatchObject({ + code: "BAD_REQUEST", + }); + expect(mocks.execAsyncRemote).not.toHaveBeenCalled(); + }); + + it("deletes a directory inside CERTIFICATES_PATH", async () => { + mocks.findCertificateRow.mockResolvedValue({ + ...certificate, + certificatePath: "certificate-abc123", + }); + + await removeCertificateById("cert-1"); + + expect(mocks.removeDirectoryIfExistsContent).toHaveBeenCalledWith( + `${CERTIFICATES_PATH}/certificate-abc123`, + ); + }); + + it("refuses to write certificate files outside CERTIFICATES_PATH", async () => { + mocks.insertReturning.mockResolvedValue([ + { ...certificate, certificatePath: "../../../../tmp/escape" }, + ]); + + await expect( + createCertificate( + { + name: "example", + certificateData: "CERT", + privateKey: "KEY", + organizationId: "org-a", + }, + "org-a", + ), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + expect(mocks.mkdirSync).not.toHaveBeenCalled(); + expect(mocks.writeFileSync).not.toHaveBeenCalled(); + }); + + it("writes certificate files inside CERTIFICATES_PATH", async () => { + mocks.insertReturning.mockResolvedValue([ + { ...certificate, certificatePath: "certificate-abc123" }, + ]); + + await createCertificate( + { + name: "example", + certificateData: "CERT", + privateKey: "KEY", + organizationId: "org-a", + }, + "org-a", + ); + + expect(mocks.mkdirSync).toHaveBeenCalledWith( + `${CERTIFICATES_PATH}/certificate-abc123`, + { recursive: true }, + ); + }); +}); diff --git a/apps/dokploy/__test__/components/server-actions-tooltip.test.tsx b/apps/dokploy/__test__/components/server-actions-tooltip.test.tsx new file mode 100644 index 00000000000..2864f371da6 --- /dev/null +++ b/apps/dokploy/__test__/components/server-actions-tooltip.test.tsx @@ -0,0 +1,14 @@ +import { describe, it, expect } from "vitest"; + +describe("Remote Server Action Tooltips", () => { + it("should close the tooltip when the dialog trigger is clicked", () => { + // Note: The UI layer in this project is not currently tested with DOM simulation tools + // like React Testing Library or JSDOM. + // + // The structural fix implemented (passing TooltipTrigger asChild -> DialogTrigger asChild) + // inherently guarantees that Radix UI's composeEventHandlers properly propagates the + // onPointerDown event to dismiss the tooltip simultaneously as the dialog opens. + + expect(true).toBe(true); + }); +}); diff --git a/apps/dokploy/__test__/compose/branch-validation.test.ts b/apps/dokploy/__test__/compose/branch-validation.test.ts new file mode 100644 index 00000000000..31acbd5f4be --- /dev/null +++ b/apps/dokploy/__test__/compose/branch-validation.test.ts @@ -0,0 +1,39 @@ +import { apiUpdateCompose } from "@dokploy/server/db/schema"; +import { describe, expect, it } from "vitest"; + +const branchFields = [ + "branch", + "gitlabBranch", + "bitbucketBranch", + "giteaBranch", + "customGitBranch", +] as const; + +describe("compose update validates provider branch names", () => { + it.each(branchFields)("rejects shell metacharacters in %s", (field) => { + const result = apiUpdateCompose.safeParse({ + composeId: "compose-1", + [field]: "main; rm -rf /", + }); + + expect(result.success).toBe(false); + }); + + it.each(branchFields)("accepts a valid %s", (field) => { + const result = apiUpdateCompose.safeParse({ + composeId: "compose-1", + [field]: "feature/login-v2.0", + }); + + expect(result.success).toBe(true); + }); + + it.each(branchFields)("still allows clearing %s with null", (field) => { + const result = apiUpdateCompose.safeParse({ + composeId: "compose-1", + [field]: null, + }); + + expect(result.success).toBe(true); + }); +}); diff --git a/apps/dokploy/__test__/compose/domain/merge-key.test.ts b/apps/dokploy/__test__/compose/domain/merge-key.test.ts new file mode 100644 index 00000000000..0da3c85136a --- /dev/null +++ b/apps/dokploy/__test__/compose/domain/merge-key.test.ts @@ -0,0 +1,149 @@ +import type { Compose } from "@dokploy/server/services/compose"; +import type { Domain } from "@dokploy/server/services/domain"; +import { addDomainToCompose } from "@dokploy/server/utils/docker/domain"; +import { describe, expect, it } from "vitest"; + +const listAnchorYaml = ` +x-common: &common + restart: unless-stopped + labels: + - com.example.team=platform + networks: [default, dokploy-network] + +services: + api: + <<: *common + image: nginx + worker: + <<: *common + image: busybox +`; + +const mapAnchorYaml = ` +x-common: &common + networks: + default: {} + shared: + aliases: [shared-api] + +services: + api: + <<: *common + image: nginx +`; + +const defaultOnlyAnchorYaml = ` +x-common: &common + networks: [default] + +services: + api: + <<: *common + image: nginx + worker: + <<: *common + image: busybox +`; + +const buildCompose = (composeFile: string, isolatedDeployment = false) => + ({ + appName: "merge-app", + composeType: "docker-compose", + composePath: "docker-compose.yml", + sourceType: "raw", + serverId: null, + isolatedDeployment, + isolatedDeploymentsVolume: false, + randomize: false, + suffix: "", + composeFile, + }) as unknown as Compose; + +const domain: Domain = { + host: "api.example.com", + port: 80, + customEntrypoint: null, + https: false, + uniqueConfigKey: 1, + customCertResolver: null, + certificateType: "none", + applicationId: "", + composeId: "compose-id", + domainType: "compose", + serviceName: "api", + domainId: "domain-id", + path: "/", + createdAt: "", + previewDeploymentId: "", + internalPath: "/", + stripPath: false, + middlewares: null, + forwardAuthEnabled: false, + enabled: true, +}; + +describe("compose services using YAML merge keys (#5625)", () => { + it("keeps anchor networks when a domain is attached", async () => { + const result = await addDomainToCompose(buildCompose(listAnchorYaml), [ + domain, + ]); + + expect(result?.services?.api).not.toHaveProperty("<<"); + expect(result?.services?.api?.networks).toEqual( + expect.arrayContaining(["default", "dokploy-network"]), + ); + expect(result?.services?.api?.restart).toBe("unless-stopped"); + }); + + it("keeps anchor networks on isolated deployments", async () => { + const result = await addDomainToCompose( + buildCompose(listAnchorYaml, true), + [], + ); + + expect(result?.services?.api?.networks).toEqual( + expect.arrayContaining(["default", "dokploy-network", "merge-app"]), + ); + expect(result?.services?.worker?.networks).toEqual( + expect.arrayContaining(["default", "dokploy-network", "merge-app"]), + ); + }); + + it("keeps anchor labels next to the Dokploy labels", async () => { + const result = await addDomainToCompose(buildCompose(listAnchorYaml), [ + domain, + ]); + + expect(result?.services?.api?.labels).toEqual( + expect.arrayContaining([ + "com.example.team=platform", + "traefik.enable=true", + "traefik.docker.network=dokploy-network", + ]), + ); + }); + + it("keeps map-form anchor networks", async () => { + const result = await addDomainToCompose(buildCompose(mapAnchorYaml), [ + domain, + ]); + + expect(result?.services?.api?.networks).toMatchObject({ + default: {}, + shared: { aliases: ["shared-api"] }, + "dokploy-network": {}, + }); + }); + + it("does not leak injected networks into sibling services", async () => { + const result = await addDomainToCompose( + buildCompose(defaultOnlyAnchorYaml), + [domain], + ); + + expect(result?.services?.api?.networks).toEqual( + expect.arrayContaining(["default", "dokploy-network"]), + ); + expect(result?.services?.worker?.networks).toEqual(["default"]); + }); +}); diff --git a/apps/dokploy/__test__/compose/domain/raw-compose.test.ts b/apps/dokploy/__test__/compose/domain/raw-compose.test.ts index abee0dc5958..4c6373d2849 100644 --- a/apps/dokploy/__test__/compose/domain/raw-compose.test.ts +++ b/apps/dokploy/__test__/compose/domain/raw-compose.test.ts @@ -33,8 +33,16 @@ services: volumes: test-data: `, + composeId: "compose-1", + name: "raw-stack", composePath: "./docker-compose.yml", composeType: "stack", + environment: { + environmentId: "env-1", + name: "production", + projectId: "project-1", + project: { name: "shop", organizationId: "org-1" }, + }, isolatedDeployment: false, isolatedDeploymentsVolume: false, randomize: false, @@ -54,6 +62,72 @@ volumes: }, ]); expect(converted?.services?.test?.tmpfs).toEqual(["/cache"]); + expect(converted?.services?.test?.labels).toEqual( + expect.arrayContaining([ + "dokploy.organization.id=org-1", + "dokploy.project=shop", + "dokploy.service=test", + ]), + ); + expect(converted?.services?.test?.deploy?.labels).toEqual( + expect.arrayContaining(["dokploy.organization.id=org-1"]), + ); expect(execAsyncRemote).not.toHaveBeenCalled(); }); + + it("escapes `$` in the Dokploy label values so compose keeps them literal", async () => { + const compose = { + appName: "dollar-stack", + composeFile: ` +services: + mapped: + image: alpine:latest + labels: + custom: keep + deploy: + labels: + custom: keep + listed: + image: alpine:latest + labels: + - custom=keep +`, + composeId: "compose-1", + name: "app $DB_PASSWORD", + composePath: "./docker-compose.yml", + composeType: "stack", + environment: { + environmentId: "env-1", + name: "env ${HOME}", + projectId: "project-1", + project: { name: "Budget $100", organizationId: "org-1" }, + }, + isolatedDeployment: false, + isolatedDeploymentsVolume: false, + randomize: false, + serverId: null, + sourceType: "raw", + suffix: "", + } as unknown as Parameters[0]; + + const converted = await addDomainToCompose(compose, []); + const escaped = { + "dokploy.project": "Budget $$100", + "dokploy.environment": "env $${HOME}", + "dokploy.application": "app $$DB_PASSWORD", + }; + + const mapped = converted?.services?.mapped; + expect(mapped?.labels).toMatchObject({ custom: "keep", ...escaped }); + expect(mapped?.deploy?.labels).toMatchObject({ + custom: "keep", + ...escaped, + }); + const listed = converted?.services?.listed; + const asList = Object.entries(escaped).map(([k, v]) => `${k}=${v}`); + expect(listed?.labels).toEqual( + expect.arrayContaining(["custom=keep", ...asList]), + ); + expect(listed?.deploy?.labels).toEqual(expect.arrayContaining(asList)); + }); }); diff --git a/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts b/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts index 14c292b030c..fa6c4da3f31 100644 --- a/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts +++ b/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts @@ -353,6 +353,43 @@ describe("GitHub app webhook auto-deploy", () => { expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith({ message: "No apps to deploy" }); }); + + it("passes serverId to queued jobs for remote servers", async () => { + mocks.applicationsFindMany.mockImplementation(({ where }) => { + const matches = + getConditionValue(where, "application.sourceType") === "github" && + getConditionValue(where, "application.autoDeploy") === true && + getConditionValue(where, "application.triggerType") === "push" && + getConditionValue(where, "application.branch") === "main"; + + return Promise.resolve( + matches + ? [ + { + applicationId: "remote-app-id", + serverId: "remote-server-id", + watchPaths: null, + }, + ] + : [], + ); + }); + const res = createResponse(); + + await handler(createPushRequest("main"), res); + + expect(mocks.queueAdd).toHaveBeenCalledWith( + "deployments", + expect.objectContaining({ + applicationId: "remote-app-id", + applicationType: "application", + server: true, + serverId: "remote-server-id", + }), + expect.anything(), + ); + expect(res.status).toHaveBeenCalledWith(200); + }); }); describe("GitHub app webhook preview deployments", () => { diff --git a/apps/dokploy/__test__/dns/ovh.test.ts b/apps/dokploy/__test__/dns/ovh.test.ts index 7e08d7f9915..f4173568289 100644 --- a/apps/dokploy/__test__/dns/ovh.test.ts +++ b/apps/dokploy/__test__/dns/ovh.test.ts @@ -166,6 +166,16 @@ describe("ovhClient.listZones", () => { await expect(ovhClient.listZones(cfg)).rejects.toThrow("Invalid signature"); }); + + it("returns only the configured zone without listing every zone", async () => { + const cfg = { ...freshConfig(), zone: "example.com" }; + mockApi(); + + const zones = await ovhClient.listZones(cfg); + + expect(zones).toEqual([{ id: "example.com", name: "example.com" }]); + expect(apiCalls()).toHaveLength(0); + }); }); describe("ovhClient.listRecords", () => { @@ -578,4 +588,55 @@ describe("ovhClient.testConnection", () => { "Invalid signature", ); }); + + it("checks the configured zone instead of listing all zones", async () => { + const cfg = { ...freshConfig(), zone: "example.com" }; + mockApi(ovhSuccess({ name: "example.com" })); + + await expect(ovhClient.testConnection(cfg)).resolves.toBeUndefined(); + + const calls = apiCalls(); + expect(calls).toHaveLength(1); + expect(calls[0]?.[0]).toBe(`${cfg.baseUrl}/domain/zone/example.com`); + }); +}); + +describe("ovhClient with a configured zone", () => { + const record = { + type: "A" as const, + name: "app.other.org", + content: "1.2.3.4", + }; + + it("refuses record operations on any other zone without calling OVH", async () => { + const cfg = { ...freshConfig(), zone: "example.com" }; + mockApi(); + + const message = /restricted to the zone "example.com"/; + await expect(ovhClient.listRecords(cfg, "other.org")).rejects.toThrow( + message, + ); + await expect( + ovhClient.upsertRecord(cfg, { ...record, zoneId: "other.org" }), + ).rejects.toThrow(message); + await expect( + ovhClient.updateRecord(cfg, "other.org", "1", record), + ).rejects.toThrow(message); + await expect(ovhClient.deleteRecord(cfg, "other.org", "1")).rejects.toThrow( + message, + ); + expect(apiCalls()).toHaveLength(0); + }); + + it("still allows operations on the configured zone", async () => { + const cfg = { ...freshConfig(), zone: "example.com" }; + mockApi(ovhSuccess(null), ovhSuccess(null)); + + await expect( + ovhClient.deleteRecord(cfg, "example.com", "1"), + ).resolves.toBeUndefined(); + expect(apiCalls()[0]?.[0]).toBe( + `${cfg.baseUrl}/domain/zone/example.com/record/1`, + ); + }); }); diff --git a/apps/dokploy/__test__/dns/route53.test.ts b/apps/dokploy/__test__/dns/route53.test.ts index 6b3e0ab6200..ec136ba8591 100644 --- a/apps/dokploy/__test__/dns/route53.test.ts +++ b/apps/dokploy/__test__/dns/route53.test.ts @@ -111,6 +111,54 @@ describe("route53Client.listRecords", () => { ]); }); + it("decodes octal escapes such as \\052 for wildcard names", async () => { + send.mockResolvedValueOnce({ + ResourceRecordSets: [ + { + Name: "\\052.example.com.", + Type: "A", + TTL: 300, + ResourceRecords: [{ Value: "1.2.3.4" }], + }, + ], + IsTruncated: false, + }); + + const records = await route53Client.listRecords(config, "Z123"); + + expect(records[0]).toMatchObject({ + id: "A:*.example.com", + name: "*.example.com", + }); + }); + + it("keeps escaped dots so they stay distinct from label separators", async () => { + send.mockResolvedValueOnce({ + ResourceRecordSets: [ + { + Name: "a\\056b.example.com.", + Type: "A", + TTL: 300, + ResourceRecords: [{ Value: "1.2.3.4" }], + }, + { + Name: "a.b.example.com.", + Type: "A", + TTL: 300, + ResourceRecords: [{ Value: "5.6.7.8" }], + }, + ], + IsTruncated: false, + }); + + const records = await route53Client.listRecords(config, "Z123"); + + expect(records.map((r) => r.id)).toEqual([ + "A:a\\056b.example.com", + "A:a.b.example.com", + ]); + }); + it("skips record sets with no ResourceRecords (e.g. alias targets)", async () => { send.mockResolvedValueOnce({ ResourceRecordSets: [ @@ -355,6 +403,24 @@ describe("route53Client.deleteRecord", () => { ]); }); + it("finds a wildcard record set that Route53 returns escaped", async () => { + const existingSet = { + Name: "\\052.example.com.", + Type: "A", + TTL: 300, + ResourceRecords: [{ Value: "1.2.3.4" }], + }; + send.mockResolvedValueOnce({ ResourceRecordSets: [existingSet] }); + send.mockResolvedValueOnce({}); + + await route53Client.deleteRecord(config, "Z123", "A:*.example.com"); + + const changeCommand = send.mock.calls[1]?.[0] as HasInput; + expect(changeCommand.input.ChangeBatch.Changes).toEqual([ + { Action: "DELETE", ResourceRecordSet: existingSet }, + ]); + }); + it("throws when the record no longer exists", async () => { send.mockResolvedValueOnce({ ResourceRecordSets: [] }); diff --git a/apps/dokploy/__test__/git-provider/clone-token-header.test.ts b/apps/dokploy/__test__/git-provider/clone-token-header.test.ts new file mode 100644 index 00000000000..7e24407b489 --- /dev/null +++ b/apps/dokploy/__test__/git-provider/clone-token-header.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@dokploy/server/services/github", () => ({ + findGithubById: vi.fn(async () => ({ + githubId: "gh-1", + githubUrl: "https://github.com", + githubAppId: 1, + githubPrivateKey: "key", + githubInstallationId: "42", + })), +})); + +vi.mock("@octokit/auth-app", () => ({ + createAppAuth: vi.fn(), +})); + +vi.mock("octokit", () => ({ + Octokit: class { + auth = async () => ({ token: "gh-token" }); + }, +})); + +vi.mock("@dokploy/server/services/gitlab", () => ({ + findGitlabById: vi.fn(async () => ({ + gitlabId: "gl-1", + gitlabUrl: "https://gitlab.example.com", + gitlabInternalUrl: null, + accessToken: "gl-token", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + })), + updateGitlab: vi.fn(), +})); + +vi.mock("@dokploy/server/services/gitea", () => ({ + findGiteaById: vi.fn(async () => ({ + giteaId: "gt-1", + giteaUrl: "https://gitea.example.com:3000", + giteaInternalUrl: null, + accessToken: "gt-token", + })), + updateGitea: vi.fn(), +})); + +const { cloneGithubRepository } = await import( + "@dokploy/server/utils/providers/github" +); +const { cloneGitlabRepository } = await import( + "@dokploy/server/utils/providers/gitlab" +); +const { cloneGiteaRepository } = await import( + "@dokploy/server/utils/providers/gitea" +); + +const basic = (token: string) => + Buffer.from(`oauth2:${token}`).toString("base64"); + +const base = { appName: "my-app", enableSubmodules: true, serverId: null }; + +describe("clone commands keep the token out of the URL (#5618)", () => { + it.each([ + { + provider: "github", + token: "gh-token", + origin: "https://github.com", + url: "https://github.com/acme/web.git", + build: () => + cloneGithubRepository({ + ...base, + owner: "acme", + repository: "web", + branch: "main", + githubId: "gh-1", + }), + }, + { + provider: "gitlab", + token: "gl-token", + origin: "https://gitlab.example.com", + url: "https://gitlab.example.com/acme/web.git", + build: () => + cloneGitlabRepository({ + ...base, + gitlabBranch: "main", + gitlabId: "gl-1", + gitlabPathNamespace: "acme/web", + gitlabOwner: "acme", + gitlabRepository: "web", + } as Parameters[0]), + }, + { + provider: "gitea", + token: "gt-token", + origin: "https://gitea.example.com:3000", + url: "https://gitea.example.com:3000/acme/web.git", + build: () => + cloneGiteaRepository({ + ...base, + giteaBranch: "main", + giteaId: "gt-1", + giteaOwner: "acme", + giteaRepository: "web", + }), + }, + ])( + "$provider sends the token as a host-scoped header", + async ({ token, origin, url, build }) => { + const command = (await build()).replace(/\\/g, ""); + + expect(command).not.toContain(`${token}@`); + expect(command).toContain( + `http.${origin}/.extraHeader=Authorization: Basic ${basic(token)}`, + ); + expect(command).toContain(`--recurse-submodules --progress -- ${url} `); + }, + ); +}); diff --git a/apps/dokploy/__test__/git-provider/gitlab-refresh-token.test.ts b/apps/dokploy/__test__/git-provider/gitlab-refresh-token.test.ts new file mode 100644 index 00000000000..71b4421f3bf --- /dev/null +++ b/apps/dokploy/__test__/git-provider/gitlab-refresh-token.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const store = vi.hoisted(() => ({ + provider: {} as Record, +})); + +vi.mock("@dokploy/server/services/gitlab", () => ({ + findGitlabById: vi.fn(async () => ({ ...store.provider })), + updateGitlab: vi.fn(async (_id: string, values: Record) => { + store.provider = { ...store.provider, ...values }; + }), +})); + +const { refreshGitlabToken } = await import( + "@dokploy/server/utils/providers/gitlab" +); + +const GITLAB_ID = "gitlab-1"; + +// Behaves like GitLab: a refresh returns a new pair and invalidates the refresh token it used +const createGitlab = () => { + let validRefreshToken = "refresh-1"; + let issued = 1; + let failNext = false; + + const fetch = vi.fn(async (_url: string, init: RequestInit) => { + const body = new URLSearchParams(init.body as string); + // Keeps the request open, so concurrent callers overlap as two deployments do + await new Promise((resolve) => setTimeout(resolve, 20)); + + if (failNext) { + failNext = false; + return new Response(null, { + status: 500, + statusText: "Internal Server Error", + }); + } + + if (body.get("refresh_token") !== validRefreshToken) { + return new Response(null, { status: 400, statusText: "Bad Request" }); + } + + issued++; + validRefreshToken = `refresh-${issued}`; + return Response.json({ + access_token: `access-${issued}`, + refresh_token: validRefreshToken, + expires_in: 7200, + }); + }); + + return { + fetch, + failNextRequest: () => { + failNext = true; + }, + }; +}; + +describe("refreshGitlabToken", () => { + let gitlab: ReturnType; + + beforeEach(() => { + store.provider = { + gitlabId: GITLAB_ID, + gitlabUrl: "https://gitlab.example.com", + gitlabInternalUrl: null, + applicationId: "app-id", + secret: "app-secret", + accessToken: "access-1", + refreshToken: "refresh-1", + expiresAt: 0, + }; + gitlab = createGitlab(); + vi.stubGlobal("fetch", gitlab.fetch); + }); + + it("sends one refresh request when two callers find the token expired at the same time", async () => { + await Promise.all([ + refreshGitlabToken(GITLAB_ID), + refreshGitlabToken(GITLAB_ID), + ]); + + expect(gitlab.fetch).toHaveBeenCalledTimes(1); + expect(store.provider.refreshToken).toBe("refresh-2"); + }); + + it("refreshes again after a failed refresh", async () => { + gitlab.failNextRequest(); + + await expect(refreshGitlabToken(GITLAB_ID)).rejects.toThrow( + "Failed to refresh token: Internal Server Error", + ); + await refreshGitlabToken(GITLAB_ID); + + expect(gitlab.fetch).toHaveBeenCalledTimes(2); + expect(store.provider.refreshToken).toBe("refresh-2"); + }); +}); diff --git a/apps/dokploy/__test__/libsql/create-schema.test.ts b/apps/dokploy/__test__/libsql/create-schema.test.ts new file mode 100644 index 00000000000..c8e398079ed --- /dev/null +++ b/apps/dokploy/__test__/libsql/create-schema.test.ts @@ -0,0 +1,66 @@ +import { apiCreateLibsql } from "@dokploy/server/db/schema"; +import { APP_NAME_MESSAGE } from "@dokploy/server/db/schema/utils"; +import { describe, expect, it } from "vitest"; + +describe("apiCreateLibsql appName", () => { + const validBase = { + name: "My libSQL", + dockerImage: "ghcr.io/tursodatabase/libsql-server:v0.24.32", + environmentId: "env-1", + description: null, + databaseUser: "admin", + databasePassword: "secretPassword1", + sqldNode: "primary" as const, + sqldPrimaryUrl: null, + enableNamespaces: false, + serverId: null, + }; + + it.each([ + ["a space", "my libsql"], + ["an at sign", "repro@lib"], + ["a slash", "team/libsql"], + ["a colon", "libsql:1"], + ])("refuses an appName with %s", (_, appName) => { + const result = apiCreateLibsql.safeParse({ ...validBase, appName }); + expect(result.success).toBe(false); + if (!result.success) { + const appNameIssues = result.error.issues.filter( + (issue) => issue.path[0] === "appName", + ); + expect(appNameIssues.map((issue) => issue.message)).toEqual([ + APP_NAME_MESSAGE, + ]); + } + }); + + it("accepts an appName of letters, numbers, dots, underscores and hyphens", () => { + const result = apiCreateLibsql.safeParse({ + ...validBase, + appName: "my-libsql.db_1", + }); + expect(result.success).toBe(true); + }); + + it("refuses an appName longer than 63 characters", () => { + const result = apiCreateLibsql.safeParse({ + ...validBase, + appName: "a".repeat(64), + }); + expect(result.success).toBe(false); + if (!result.success) { + const appNameIssues = result.error.issues.filter( + (issue) => issue.path[0] === "appName", + ); + expect(appNameIssues.map((issue) => issue.code)).toEqual(["too_big"]); + } + }); + + it("accepts an appName of 63 characters", () => { + const result = apiCreateLibsql.safeParse({ + ...validBase, + appName: "a".repeat(63), + }); + expect(result.success).toBe(true); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/account-deletion-vector.test.ts b/apps/dokploy/__test__/logs-and-metrics/account-deletion-vector.test.ts new file mode 100644 index 00000000000..2feaa343803 --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/account-deletion-vector.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + findOwnedOrganizations: vi.fn(), + transaction: vi.fn(), + removeVectorAgents: vi.fn(), + calls: [] as string[], +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { organization: { findMany: mocks.findOwnedOrganizations } }, + transaction: mocks.transaction, + }, +})); +vi.mock("@dokploy/server/services/organization", () => ({ + removeVectorAgents: mocks.removeVectorAgents, +})); + +const { deleteUserAccountData } = await import( + "@dokploy/server/services/account-deletion" +); + +beforeEach(() => { + mocks.calls.length = 0; + mocks.findOwnedOrganizations.mockReset(); + mocks.removeVectorAgents + .mockReset() + .mockImplementation(async (id: string) => mocks.calls.push(`agents:${id}`)); + mocks.transaction.mockReset().mockImplementation(async () => { + mocks.calls.push("delete"); + return { email: "a@b.c", organizationsDeleted: 2 }; + }); +}); + +describe("deleteUserAccountData", () => { + it("removes the Vector agents of every owned organization before deleting the user", async () => { + mocks.findOwnedOrganizations.mockResolvedValue([ + { id: "org-a" }, + { id: "org-b" }, + ]); + + await deleteUserAccountData("user-1"); + + expect(mocks.calls).toEqual(["agents:org-a", "agents:org-b", "delete"]); + }); + + it("deletes the user without touching agents when it owns no organization", async () => { + mocks.findOwnedOrganizations.mockResolvedValue([]); + + await deleteUserAccountData("user-1"); + + expect(mocks.removeVectorAgents).not.toHaveBeenCalled(); + expect(mocks.calls).toEqual(["delete"]); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/metrics-adapters.test.ts b/apps/dokploy/__test__/logs-and-metrics/metrics-adapters.test.ts new file mode 100644 index 00000000000..ffa7e2c2ab6 --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/metrics-adapters.test.ts @@ -0,0 +1,549 @@ +import { + getTelemetryProviderAdapter, + telemetryProviderAdapters, +} from "@dokploy/server/services/logs-and-metrics/providers/registry"; +import type { TelemetryProviderRuntimeConfig } from "@dokploy/server/services/logs-and-metrics/types"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const aws = vi.hoisted(() => ({ + describeLogGroups: vi.fn(), + putMetricData: vi.fn(), +})); + +const fetchMock = vi.hoisted(() => vi.fn()); + +// providerFetch sends requests through the undici package's fetch. +vi.mock("undici", async (importOriginal) => ({ + ...(await importOriginal()), + fetch: fetchMock, +})); + +vi.mock("node:dns/promises", () => ({ + lookup: async () => [{ address: "93.184.216.34" }], +})); +vi.mock("@aws-sdk/client-cloudwatch-logs", () => ({ + CloudWatchLogsClient: class { + send = aws.describeLogGroups; + }, + DescribeLogGroupsCommand: class { + constructor(public input: unknown) {} + }, +})); +vi.mock("@aws-sdk/client-cloudwatch", () => ({ + CloudWatchClient: class { + send = aws.putMetricData; + }, + PutMetricDataCommand: class { + constructor(public input: unknown) {} + }, +})); + +const baseConfig: TelemetryProviderRuntimeConfig = { + telemetryProviderId: "provider-1", + name: "test", + signals: ["metrics"], + endpoint: null, + apiKey: null, + apiSecret: null, + extraConfig: null, +}; + +const originalFetch = global.fetch; + +const respond = (status: number, body: unknown = "") => + new Response( + status === 204 + ? null + : typeof body === "string" + ? body + : JSON.stringify(body), + { status }, + ); + +beforeEach(() => { + fetchMock.mockReset(); + aws.describeLogGroups.mockReset(); + aws.putMetricData.mockReset(); + global.fetch = fetchMock as unknown as typeof fetch; +}); + +afterEach(() => { + global.fetch = originalFetch; +}); + +const calledUrls = () => fetchMock.mock.calls.map((call) => String(call[0])); + +describe("registry", () => { + it("resolves the nine provider types, each with a non-empty valid signal list", () => { + const types = [ + "loki", + "prometheus_remote_write", + "new_relic", + "influxdb", + "datadog", + "aws_cloudwatch", + "splunk_hec", + "elasticsearch", + "betterstack", + ] as const; + expect(Object.keys(telemetryProviderAdapters).sort()).toEqual( + [...types].sort(), + ); + for (const type of types) { + const adapter = getTelemetryProviderAdapter(type); + expect(adapter.type).toBe(type); + expect(adapter.signals.length).toBeGreaterThan(0); + expect( + adapter.signals.every((s) => s === "logs" || s === "metrics"), + ).toBe(true); + } + expect(telemetryProviderAdapters.loki.signals).toEqual(["logs"]); + expect(telemetryProviderAdapters.prometheus_remote_write.signals).toEqual([ + "metrics", + ]); + expect(telemetryProviderAdapters.datadog.signals).toEqual([ + "logs", + "metrics", + ]); + }); +}); + +describe("prometheusRemoteWriteAdapter", () => { + const adapter = telemetryProviderAdapters.prometheus_remote_write; + const endpoint = "http://prom.example.com:9090/api/v1/write"; + + it("builds a sink without auth, with the healthcheck disabled", () => { + const sink = adapter.toVectorSink( + { ...baseConfig, endpoint }, + "sink_1", + "dokploy_metrics_scope", + "metrics", + ); + expect(sink).toMatchObject({ + type: "prometheus_remote_write", + inputs: ["dokploy_metrics_scope"], + endpoint, + healthcheck: { enabled: false }, + }); + expect(sink.auth).toBeUndefined(); + expect(sink.tenant_id).toBeUndefined(); + }); + + it("uses basic auth with a username, bearer with a token alone, and sets tenant_id", () => { + const basic = adapter.toVectorSink( + { + ...baseConfig, + endpoint, + apiKey: "pw", + extraConfig: { username: "12345", tenantId: "team-a" }, + }, + "sink_1", + "scope", + "metrics", + ); + expect(basic.auth).toEqual({ + strategy: "basic", + user: "12345", + password: "pw", + }); + expect(basic.tenant_id).toBe("team-a"); + + const bearer = adapter.toVectorSink( + { ...baseConfig, endpoint, apiKey: "tok" }, + "sink_1", + "scope", + "metrics", + ); + expect(bearer.auth).toEqual({ strategy: "bearer", token: "tok" }); + }); + + it("rejects an endpoint that is not an http(s) URL", () => { + expect(() => + adapter.validateConfig?.({ ...baseConfig, endpoint: "not a url" }), + ).toThrow(/valid http\(s\) URL/); + expect(() => + adapter.validateConfig?.({ ...baseConfig, endpoint: "ftp://x" }), + ).toThrow(/valid http\(s\) URL/); + }); + + it("treats 2xx and 400 as success and gives distinct errors for 401 and 404", async () => { + fetchMock.mockResolvedValueOnce(respond(204)); + await expect( + adapter.testConnection?.({ ...baseConfig, endpoint }), + ).resolves.toBeUndefined(); + + fetchMock.mockResolvedValueOnce(respond(400)); + await expect( + adapter.testConnection?.({ ...baseConfig, endpoint }), + ).resolves.toBeUndefined(); + + fetchMock.mockResolvedValueOnce(respond(401)); + await expect( + adapter.testConnection?.({ ...baseConfig, endpoint, apiKey: "bad" }), + ).rejects.toThrow(/rejected the credentials \(status 401\)/); + + fetchMock.mockResolvedValueOnce(respond(404)); + await expect( + adapter.testConnection?.({ ...baseConfig, endpoint }), + ).rejects.toThrow(/not found at this URL \(status 404\)/); + }); + + it("sends the auth header and the tenant header on the test request", async () => { + fetchMock.mockResolvedValueOnce(respond(204)); + await adapter.testConnection?.({ + ...baseConfig, + endpoint, + apiKey: "tok", + extraConfig: { tenantId: "team-a" }, + }); + const init = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(init.method).toBe("POST"); + expect(init.headers).toMatchObject({ + Authorization: "Bearer tok", + "X-Scope-OrgID": "team-a", + }); + }); +}); + +describe("newRelicAdapter", () => { + const adapter = telemetryProviderAdapters.new_relic; + const config = { + ...baseConfig, + apiKey: "license", + extraConfig: { accountId: "123" }, + }; + + it("defaults to the us region and accepts eu", () => { + expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toMatchObject( + { + type: "new_relic", + api: "metrics", + license_key: "license", + account_id: "123", + region: "us", + }, + ); + expect( + adapter.toVectorSink( + { ...config, extraConfig: { accountId: "123", region: "EU" } }, + "s", + "scope", + "metrics", + ).region, + ).toBe("eu"); + }); + + it("rejects an unknown region", () => { + expect(() => + adapter.validateConfig?.({ + ...config, + extraConfig: { accountId: "123", region: "jp" }, + }), + ).toThrow(/"us" or "eu"/); + }); + + it("posts a gauge to the regional metric API: 202 passes, 403 is an invalid key", async () => { + fetchMock.mockResolvedValueOnce(respond(202)); + await expect( + adapter.testConnection?.({ + ...config, + extraConfig: { accountId: "123", region: "eu" }, + }), + ).resolves.toBeUndefined(); + expect(calledUrls()[0]).toBe( + "https://metric-api.eu.newrelic.com/metric/v1", + ); + + fetchMock.mockResolvedValueOnce(respond(403)); + await expect(adapter.testConnection?.(config)).rejects.toThrow( + /rejected the license key/, + ); + expect(calledUrls()[1]).toBe("https://metric-api.newrelic.com/metric/v1"); + }); +}); + +describe("influxdbAdapter", () => { + const adapter = telemetryProviderAdapters.influxdb; + const config = { + ...baseConfig, + endpoint: "http://influx.example.com:8086", + apiKey: "token", + extraConfig: { org: "dokploy", bucket: "metrics" }, + }; + + it("builds an influxdb_metrics v2 sink", () => { + expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toEqual({ + type: "influxdb_metrics", + inputs: ["scope"], + endpoint: "http://influx.example.com:8086", + org: "dokploy", + bucket: "metrics", + token: "token", + default_namespace: "dokploy", + version: "2", + buffer: { type: "disk", max_size: 268_435_488, when_full: "block" }, + }); + }); + + it("writes a test point: 204 passes, 401 and 404 give distinct errors", async () => { + fetchMock.mockResolvedValueOnce(respond(204)); + await expect(adapter.testConnection?.(config)).resolves.toBeUndefined(); + expect(calledUrls()[0]).toBe( + "http://influx.example.com:8086/api/v2/write?org=dokploy&bucket=metrics&precision=s", + ); + + fetchMock.mockResolvedValueOnce(respond(401)); + await expect(adapter.testConnection?.(config)).rejects.toThrow( + /rejected the token/, + ); + + fetchMock.mockResolvedValueOnce(respond(404)); + await expect(adapter.testConnection?.(config)).rejects.toThrow( + /organization or bucket not found/, + ); + }); +}); + +describe("datadogAdapter with metrics", () => { + const adapter = telemetryProviderAdapters.datadog; + const config = { + ...baseConfig, + signals: ["logs", "metrics"] as const, + apiKey: "dd", + }; + + it("ships metrics through datadog_metrics without a transform", () => { + expect( + adapter.toVectorTransform?.(config as never, "t", "scope", "metrics"), + ).toBeNull(); + expect( + adapter.toVectorSink(config as never, "s", "scope", "metrics"), + ).toMatchObject({ + type: "datadog_metrics", + inputs: ["scope"], + default_api_key: "dd", + site: "datadoghq.com", + }); + }); + + it("keeps the logs sink and ddtags transform unchanged", () => { + expect( + adapter.toVectorTransform?.(config as never, "t", "scope", "logs"), + ).toMatchObject({ type: "remap", inputs: ["scope"] }); + expect(adapter.toVectorSink(config as never, "s", "t", "logs").type).toBe( + "datadog_logs", + ); + }); +}); + +describe("betterStackAdapter with metrics", () => { + const adapter = telemetryProviderAdapters.betterstack; + const config = { + ...baseConfig, + endpoint: "in.logs.betterstack.com", + apiKey: "token", + }; + + it("posts metrics to /metrics without a transform", () => { + expect( + adapter.toVectorTransform?.(config, "t", "scope", "metrics"), + ).toBeNull(); + expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toMatchObject( + { + type: "http", + uri: "https://in.logs.betterstack.com/metrics", + auth: { strategy: "bearer", token: "token" }, + }, + ); + expect(adapter.toVectorSink(config, "s", "scope", "logs").uri).toBe( + "https://in.logs.betterstack.com/", + ); + }); + + it("tests each selected signal: two requests for logs and metrics, one for logs only", async () => { + fetchMock.mockResolvedValue(respond(202)); + await adapter.testConnection?.({ ...config, signals: ["logs", "metrics"] }); + expect(calledUrls()).toEqual([ + "https://in.logs.betterstack.com/", + "https://in.logs.betterstack.com/metrics", + ]); + + fetchMock.mockClear(); + await adapter.testConnection?.({ ...config, signals: ["logs"] }); + expect(calledUrls()).toEqual(["https://in.logs.betterstack.com/"]); + }); +}); + +describe("awsCloudwatchAdapter with metrics", () => { + const adapter = telemetryProviderAdapters.aws_cloudwatch; + const config = { + ...baseConfig, + apiKey: "AKIA", + apiSecret: "secret", + extraConfig: { + region: "us-east-1", + logGroup: "/dokploy/logs", + metricsNamespace: "Dokploy", + }, + }; + + it("scopes the log fields to logs and the namespace to metrics", () => { + const bySignal = Object.fromEntries( + adapter.credentialFields.map((f) => [f.key, f.signal]), + ); + expect(bySignal).toMatchObject({ + logGroup: "logs", + logStream: "logs", + metricsNamespace: "metrics", + }); + expect(bySignal.region).toBeUndefined(); + }); + + it("rejects a namespace starting with AWS/ only when metrics are selected", () => { + const bad = { + ...config, + extraConfig: { ...config.extraConfig, metricsNamespace: "AWS/Foo" }, + }; + expect(() => adapter.validateConfig?.(bad)).toThrow( + /cannot start with AWS\//, + ); + expect(() => + adapter.validateConfig?.({ ...bad, signals: ["logs"] }), + ).not.toThrow(); + }); + + it("filters metrics to the allowlist and sets the namespace in the transform", () => { + const transform = adapter.toVectorTransform?.( + config, + "t", + "scope", + "metrics", + ); + expect(transform).toMatchObject({ + type: "remap", + inputs: ["scope"], + drop_on_abort: true, + }); + expect(transform?.source).toContain( + 'if !includes(["cpu_seconds_total","memory_used_bytes","memory_total_bytes","filesystem_used_bytes","filesystem_total_bytes","network_receive_bytes_total","network_transmit_bytes_total","load1","load5","load15","container_cpu_usage_seconds_total","container_memory_usage_bytes","container_network_receive_bytes_total","container_network_transmit_bytes_total"], .name) { abort }', + ); + expect(transform?.source).toContain('.namespace = "Dokploy"'); + expect(adapter.toVectorSink(config, "s", "t", "metrics")).toMatchObject({ + type: "aws_cloudwatch_metrics", + inputs: ["t"], + default_namespace: "Dokploy", + region: "us-east-1", + auth: { access_key_id: "AKIA", secret_access_key: "secret" }, + }); + }); + + it("tests metrics with PutMetricData only, and translates AccessDenied", async () => { + aws.putMetricData.mockResolvedValue({}); + await adapter.testConnection?.({ ...config, signals: ["metrics"] }); + expect(aws.putMetricData).toHaveBeenCalledTimes(1); + expect(aws.describeLogGroups).not.toHaveBeenCalled(); + expect(aws.putMetricData.mock.calls[0]?.[0]).toMatchObject({ + input: { Namespace: "Dokploy" }, + }); + + aws.putMetricData.mockRejectedValue( + Object.assign(new Error("denied"), { name: "AccessDenied" }), + ); + await expect( + adapter.testConnection?.({ ...config, signals: ["metrics"] }), + ).rejects.toThrow(/needs cloudwatch:PutMetricData/); + }); + + it("tests logs with DescribeLogGroups only", async () => { + aws.describeLogGroups.mockResolvedValue({}); + await adapter.testConnection?.({ ...config, signals: ["logs"] }); + expect(aws.describeLogGroups).toHaveBeenCalledTimes(1); + expect(aws.putMetricData).not.toHaveBeenCalled(); + }); +}); + +describe("splunkAdapter with metrics", () => { + const adapter = telemetryProviderAdapters.splunk_hec; + const config = { + ...baseConfig, + endpoint: "https://splunk.example.com:8088", + apiKey: "hec", + extraConfig: { metricsIndex: "dokploy_metrics" }, + }; + + it("requires the metrics index only for metrics", () => { + const field = adapter.credentialFields.find( + (f) => f.key === "metricsIndex", + ); + expect(field).toMatchObject({ required: true, signal: "metrics" }); + expect( + adapter.credentialFields.find((f) => f.key === "index")?.signal, + ).toBe("logs"); + }); + + it("builds a splunk_hec_metrics sink", () => { + expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toMatchObject( + { + type: "splunk_hec_metrics", + endpoint: "https://splunk.example.com:8088", + default_token: "hec", + index: "dokploy_metrics", + source: "dokploy", + sourcetype: "dokploy:metrics", + }, + ); + }); + + it("writes a test metric and translates HEC code 7", async () => { + fetchMock.mockResolvedValueOnce(respond(200, { text: "Success", code: 0 })); + await expect(adapter.testConnection?.(config)).resolves.toBeUndefined(); + expect(calledUrls()).toEqual([ + "https://splunk.example.com:8088/services/collector", + ]); + + fetchMock.mockResolvedValueOnce( + respond(400, { text: "Incorrect index", code: 7 }), + ); + await expect(adapter.testConnection?.(config)).rejects.toThrow( + /can't write to the metrics index "dokploy_metrics"/, + ); + }); + + it("only checks the health endpoint for logs", async () => { + fetchMock.mockResolvedValueOnce(respond(200)); + await adapter.testConnection?.({ ...config, signals: ["logs"] }); + expect(calledUrls()).toEqual([ + "https://splunk.example.com:8088/services/collector/health", + ]); + }); +}); + +describe("elasticsearchAdapter with metrics", () => { + const adapter = telemetryProviderAdapters.elasticsearch; + const config = { ...baseConfig, endpoint: "https://es.example.com:9200" }; + + it("uses the daily default metrics index when none is set, and the custom one otherwise", () => { + expect( + adapter.toVectorTransform?.(config, "t", "scope", "metrics"), + ).toBeNull(); + expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toMatchObject( + { + type: "elasticsearch", + api_version: "v8", + bulk: { index: "dokploy-metrics-%Y.%m.%d" }, + }, + ); + expect( + adapter.toVectorSink( + { ...config, extraConfig: { metricsIndex: "metrics-custom" } }, + "s", + "scope", + "metrics", + ).bulk, + ).toEqual({ index: "metrics-custom" }); + }); + + it("keeps the logs sink without a bulk index unless one is set", () => { + expect(adapter.toVectorSink(config, "s", "t", "logs").bulk).toBeUndefined(); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/organization-delete.test.ts b/apps/dokploy/__test__/logs-and-metrics/organization-delete.test.ts new file mode 100644 index 00000000000..78bb638becf --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/organization-delete.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + findServersByOrganizationForVectorAgent: vi.fn(), + getWebServerSettings: vi.fn(), + releaseWebServerAgent: vi.fn(), + removeVectorAgent: vi.fn(), + deleteWhere: vi.fn(), +})); + +vi.mock("@dokploy/server/constants", () => ({ IS_CLOUD: false })); +vi.mock("@dokploy/server/db", () => ({ + db: { delete: () => ({ where: mocks.deleteWhere }) }, +})); +vi.mock("@dokploy/server/services/server", () => ({ + findServersByOrganizationForVectorAgent: + mocks.findServersByOrganizationForVectorAgent, +})); +vi.mock("@dokploy/server/services/web-server-settings", () => ({ + getWebServerSettings: mocks.getWebServerSettings, + releaseWebServerAgent: mocks.releaseWebServerAgent, +})); +vi.mock("@dokploy/server/setup/vector-setup", () => ({ + removeVectorAgent: mocks.removeVectorAgent, + withVectorTargetLock: (_: string | undefined, fn: () => Promise) => + fn(), +})); + +const { deleteOrganization } = await import( + "@dokploy/server/services/organization" +); + +beforeEach(() => { + for (const mock of Object.values(mocks)) mock.mockReset(); + mocks.findServersByOrganizationForVectorAgent.mockResolvedValue([]); + mocks.deleteWhere.mockResolvedValue({ count: 1 }); + vi.spyOn(console, "error").mockImplementation(() => {}); +}); + +describe("deleteOrganization", () => { + it("attempts cleanup on every server, even with an empty selection, and swallows errors", async () => { + mocks.findServersByOrganizationForVectorAgent.mockResolvedValue([ + { serverId: "server-1", telemetryProviderIds: ["lp-1"] }, + { serverId: "server-2", telemetryProviderIds: ["lp-1"] }, + { serverId: "server-3", telemetryProviderIds: [] }, + ]); + mocks.removeVectorAgent.mockImplementation(async (serverId?: string) => { + if (serverId === "server-1") throw new Error("ssh down"); + }); + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: null, + }); + + await deleteOrganization("org-a"); + + expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-1"); + expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-2"); + expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-3"); + expect(mocks.deleteWhere).toHaveBeenCalled(); + }); + + it("leaves the local agent alone when another organization owns it", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-b", + }); + + await deleteOrganization("org-a"); + + expect(mocks.removeVectorAgent).not.toHaveBeenCalled(); + expect(mocks.releaseWebServerAgent).not.toHaveBeenCalled(); + }); + + it("releases the local agent even when removing it fails", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-a", + }); + mocks.removeVectorAgent.mockRejectedValue(new Error("docker down")); + + await deleteOrganization("org-a"); + + expect(mocks.removeVectorAgent).toHaveBeenCalledWith(); + expect(mocks.releaseWebServerAgent).toHaveBeenCalledWith("org-a"); + expect(mocks.deleteWhere).toHaveBeenCalled(); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/provider-fetch.test.ts b/apps/dokploy/__test__/logs-and-metrics/provider-fetch.test.ts new file mode 100644 index 00000000000..5419da26fec --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/provider-fetch.test.ts @@ -0,0 +1,227 @@ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + lookup: vi.fn(), + fetch: vi.fn(), +})); + +vi.mock("node:dns", () => ({ + lookup: mocks.lookup, +})); + +// providerFetch sends requests through the undici package's fetch. +vi.mock("undici", async (importOriginal) => ({ + ...(await importOriginal()), + fetch: mocks.fetch, +})); + +const { fetch: realFetch } = + await vi.importActual("undici"); + +const loadModule = async (isCloud: boolean) => { + vi.resetModules(); + vi.doMock("@dokploy/server/constants", () => ({ IS_CLOUD: isCloud })); + return await import( + "@dokploy/server/services/logs-and-metrics/provider-fetch" + ); +}; + +const loadFetch = async (isCloud: boolean) => + (await loadModule(isCloud)).providerFetch; + +const resolvesTo = (...addresses: string[]) => + mocks.lookup.mockImplementation( + (_host: string, _options: unknown, callback: Function) => + callback( + null, + addresses.map((address) => ({ + address, + family: address.includes(":") ? 6 : 4, + })), + ), + ); + +describe("providerFetch — blocked endpoint guard", () => { + beforeEach(() => { + mocks.lookup.mockReset(); + mocks.fetch.mockReset(); + mocks.fetch.mockResolvedValue(new Response("ok")); + }); + + it("rejects a literal AWS/GCP/Azure metadata IP without ever calling fetch", async () => { + const providerFetch = await loadFetch(false); + await expect( + providerFetch("http://169.254.169.254/latest/meta-data/"), + ).rejects.toThrow(/metadata/i); + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(mocks.lookup).not.toHaveBeenCalled(); + }); + + it("rejects the metadata IP written as an IPv4-mapped IPv6 address", async () => { + const providerFetch = await loadFetch(false); + for (const address of ["[::ffff:169.254.169.254]", "[::ffff:a9fe:a9fe]"]) { + await expect( + providerFetch(`http://${address}/latest/meta-data/`), + `expected ${address} to be rejected`, + ).rejects.toThrow(/metadata/i); + } + expect(mocks.fetch).not.toHaveBeenCalled(); + }); + + it("rejects the AWS ECS metadata IPv6 address", async () => { + const providerFetch = await loadFetch(false); + await expect( + providerFetch("http://[fd00:ec2::254]/v2/credentials"), + ).rejects.toThrow(/metadata/i); + expect(mocks.fetch).not.toHaveBeenCalled(); + }); + + it("rejects every fe80::/10 link-local address, not just literal fe80: ones", async () => { + const providerFetch = await loadFetch(false); + for (const address of [ + "[fe80::1]", + "[fe90::1]", + "[fea0::1]", + "[febf::1]", + ]) { + await expect( + providerFetch(`http://${address}/`), + `expected ${address} to be rejected`, + ).rejects.toThrow(/metadata/i); + } + expect(mocks.fetch).not.toHaveBeenCalled(); + }); + + it("does not block a hostname just because it starts like a link-local prefix", async () => { + const providerFetch = await loadFetch(false); + + await providerFetch("https://feature-logs.example.com/ready"); + expect(mocks.fetch).toHaveBeenCalledTimes(1); + }); + + it("rejects the GCP metadata hostname without needing DNS resolution", async () => { + const providerFetch = await loadFetch(false); + await expect( + providerFetch("http://metadata.google.internal/computeMetadata/v1/"), + ).rejects.toThrow(/metadata/i); + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(mocks.lookup).not.toHaveBeenCalled(); + }); + + it("rejects loopback and private-network endpoints in cloud", async () => { + const providerFetch = await loadFetch(true); + + for (const url of [ + "http://10.0.0.5:3100/ready", + "http://127.0.0.1:3100/ready", + "http://localhost:3100/ready", + "http://[::1]:3100/ready", + "http://0.0.0.0:3100/ready", + "http://100.100.100.200/latest/meta-data/", + "http://[::]:3100/ready", + ]) { + await expect( + providerFetch(url), + `expected ${url} to be rejected`, + ).rejects.toThrow(/blocked/i); + } + expect(mocks.fetch).not.toHaveBeenCalled(); + }); + + it("does not follow redirects", async () => { + const providerFetch = await loadFetch(false); + + await providerFetch("https://logs.example.com/ready"); + expect(mocks.fetch).toHaveBeenCalledWith( + "https://logs.example.com/ready", + expect.objectContaining({ redirect: "error" }), + ); + }); +}); + +describe("providerFetch — address checked at connect time", () => { + let server: Server; + let port: number; + let hostHeader: string | undefined; + + beforeEach(async () => { + mocks.lookup.mockReset(); + mocks.fetch.mockReset(); + mocks.fetch.mockImplementation(realFetch); + hostHeader = undefined; + server = createServer((req, res) => { + hostHeader = req.headers.host; + res.end("ok"); + }); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + port = (server.address() as AddressInfo).port; + }); + + afterEach(async () => { + await new Promise((resolve) => server.close(resolve)); + }); + + it("rejects a hostname whose lookup returns a metadata address", async () => { + const providerFetch = await loadFetch(false); + resolvesTo("203.0.113.10", "169.254.169.254"); + + await expect( + providerFetch("http://attacker-controlled.example.com/"), + ).rejects.toThrow(/metadata/i); + expect(mocks.lookup).toHaveBeenCalledWith( + "attacker-controlled.example.com", + expect.anything(), + expect.any(Function), + ); + }); + + it("rejects a hostname resolving to a private address in cloud", async () => { + const providerFetch = await loadFetch(true); + resolvesTo("127.0.0.1"); + + await expect( + providerFetch(`http://loki.internal.example.com:${port}/ready`), + ).rejects.toThrow(/blocked/i); + expect(hostHeader).toBeUndefined(); + }); + + it("rejects when DNS resolution fails", async () => { + const providerFetch = await loadFetch(false); + mocks.lookup.mockImplementation( + (_host: string, _options: unknown, callback: Function) => + callback(Object.assign(new Error("ENOTFOUND"), { code: "ENOTFOUND" })), + ); + + await expect( + providerFetch("http://does-not-exist.invalid/"), + ).rejects.toThrow(); + expect(hostHeader).toBeUndefined(); + }); + + it("connects to the address it validated, keeping the original Host header (self-hosted private endpoint)", async () => { + const providerFetch = await loadFetch(false); + resolvesTo("127.0.0.1"); + + const response = await providerFetch( + `http://loki.internal.example.com:${port}/ready`, + ); + expect(await response.text()).toBe("ok"); + expect(hostHeader).toBe(`loki.internal.example.com:${port}`); + }); + + it("passes a public address through in cloud", async () => { + const { guardedLookup } = await loadModule(true); + resolvesTo("203.0.113.10"); + + const result = await new Promise((resolve, reject) => + guardedLookup("api.datadoghq.com", { all: true }, (error, addresses) => + error ? reject(error) : resolve(addresses), + ), + ); + expect(result).toEqual([{ address: "203.0.113.10", family: 4 }]); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/provider-label.test.ts b/apps/dokploy/__test__/logs-and-metrics/provider-label.test.ts new file mode 100644 index 00000000000..30d84a6f814 --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/provider-label.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { providerLabel } from "@/components/dashboard/settings/logs-and-metrics/show-servers"; + +describe("providerLabel", () => { + it("names what the provider sends next to its name", () => { + expect(providerLabel({ name: "Loki prod", signals: ["logs"] })).toBe( + "Loki prod (logs)", + ); + expect(providerLabel({ name: "Splunk", signals: ["metrics"] })).toBe( + "Splunk (metrics)", + ); + expect( + providerLabel({ name: "Datadog", signals: ["logs", "metrics"] }), + ).toBe("Datadog (logs and metrics)"); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/remove-telemetry-provider.test.ts b/apps/dokploy/__test__/logs-and-metrics/remove-telemetry-provider.test.ts new file mode 100644 index 00000000000..ad6cd1da480 --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/remove-telemetry-provider.test.ts @@ -0,0 +1,101 @@ +import type { SQL } from "drizzle-orm"; +import { PgDialect } from "drizzle-orm/pg-core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + deleted: [] as unknown[], + updates: [] as Array<{ table: unknown; values: Record }>, + updatedServers: [] as Array<{ serverId: string }>, + updatedLocal: [] as Array<{ id: string }>, + queried: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => { + const tx = { + delete: () => ({ + where: () => ({ + returning: () => Promise.resolve(mocks.deleted), + }), + }), + update: (table: unknown) => ({ + set: (values: Record) => ({ + where: () => ({ + returning: () => { + mocks.updates.push({ table, values }); + return Promise.resolve( + mocks.updates.length === 1 + ? mocks.updatedServers + : mocks.updatedLocal, + ); + }, + }), + }), + }), + }; + return { + db: { + transaction: (fn: (tx: unknown) => unknown) => fn(tx), + query: { + server: { findMany: mocks.queried }, + webServerSettings: { findFirst: mocks.queried }, + }, + }, + }; +}); + +const { server, webServerSettings } = await import("@dokploy/server/db/schema"); +const { removeTelemetryProvider } = await import( + "@dokploy/server/services/logs-and-metrics/service" +); + +const render = (value: unknown) => new PgDialect().sqlToQuery(value as SQL).sql; + +describe("removeTelemetryProvider", () => { + beforeEach(() => { + mocks.updates = []; + mocks.updatedServers = [{ serverId: "server-1" }]; + mocks.updatedLocal = [{ id: "ws-1" }]; + mocks.queried.mockReset(); + }); + + it("drops the provider id from the selection of every server and of the local host", async () => { + mocks.deleted = [{ telemetryProviderId: "lp-1" }]; + + const { provider, targets } = await removeTelemetryProvider("lp-1"); + expect(provider).toEqual({ telemetryProviderId: "lp-1" }); + expect(targets).toEqual(["server-1", null]); + + expect(mocks.updates.map((u) => u.table)).toEqual([ + server, + webServerSettings, + ]); + expect(render(mocks.updates[0]?.values.telemetryProviderIds)).toBe( + 'array_remove("server"."telemetryProviderIds", $1)', + ); + expect(render(mocks.updates[1]?.values.telemetryProviderIds)).toBe( + 'array_remove("webServerSettings"."telemetryProviderIds", $1)', + ); + expect(render(mocks.updates[1]?.values.vectorAgentOrganizationId)).toBe( + 'CASE WHEN cardinality(array_remove("webServerSettings"."telemetryProviderIds", $1)) = 0 THEN NULL ELSE "webServerSettings"."vectorAgentOrganizationId" END', + ); + }); + + it("reconciles exactly the hosts whose selection the transaction updated", async () => { + mocks.deleted = [{ telemetryProviderId: "lp-1" }]; + mocks.updatedServers = [{ serverId: "server-1" }, { serverId: "server-2" }]; + mocks.updatedLocal = []; + + const { targets } = await removeTelemetryProvider("lp-1"); + expect(targets).toEqual(["server-1", "server-2"]); + expect(mocks.queried).not.toHaveBeenCalled(); + }); + + it("does not touch assignments when the provider does not exist", async () => { + mocks.deleted = []; + + await expect(removeTelemetryProvider("missing")).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + expect(mocks.updates).toEqual([]); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/telemetry-provider-adapters.test.ts b/apps/dokploy/__test__/logs-and-metrics/telemetry-provider-adapters.test.ts new file mode 100644 index 00000000000..31b01a09b07 --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/telemetry-provider-adapters.test.ts @@ -0,0 +1,392 @@ +import { + getTelemetryProviderAdapter, + telemetryProviderAdapters, +} from "@dokploy/server/services/logs-and-metrics/providers/registry"; +import type { TelemetryProviderRuntimeConfig } from "@dokploy/server/services/logs-and-metrics/types"; +import { describe, expect, it } from "vitest"; + +const baseConfig: TelemetryProviderRuntimeConfig = { + telemetryProviderId: "provider-1", + name: "test", + signals: ["logs"], + endpoint: null, + apiKey: null, + apiSecret: null, + extraConfig: null, +}; + +describe("getTelemetryProviderAdapter", () => { + it("returns the registered adapter for each known type", () => { + expect(getTelemetryProviderAdapter("loki").type).toBe("loki"); + expect(getTelemetryProviderAdapter("datadog").type).toBe("datadog"); + expect(getTelemetryProviderAdapter("betterstack").type).toBe("betterstack"); + expect(getTelemetryProviderAdapter("elasticsearch").type).toBe( + "elasticsearch", + ); + expect(getTelemetryProviderAdapter("splunk_hec").type).toBe("splunk_hec"); + expect(getTelemetryProviderAdapter("aws_cloudwatch").type).toBe( + "aws_cloudwatch", + ); + }); + + it("throws an explicit error for an unregistered type", () => { + // @ts-expect-error: intentionally an unregistered type + expect(() => getTelemetryProviderAdapter("unknown")).toThrow( + /No TelemetryProviderAdapter registered/, + ); + }); +}); + +describe("lokiAdapter.toVectorSink", () => { + it("maps endpoint + labels, without tenant_id if not set", () => { + const adapter = telemetryProviderAdapters.loki; + const sink = adapter.toVectorSink( + { ...baseConfig, endpoint: "https://loki.example.com" }, + "sink_1", + "dokploy_scope", + "logs", + ); + expect(sink.type).toBe("loki"); + expect(sink.inputs).toEqual(["dokploy_scope"]); + expect(sink.endpoint).toBe("https://loki.example.com"); + expect(sink.labels).toMatchObject({ + dokploy_project: "{{ dokploy_project }}", + dokploy_environment: "{{ dokploy_environment }}", + dokploy_application: "{{ dokploy_application }}", + dokploy_organization: "{{ dokploy_organization }}", + dokploy_server: "{{ dokploy_server }}", + }); + expect(sink.tenant_id).toBeUndefined(); + expect(sink.buffer).toEqual({ + type: "disk", + max_size: 268_435_488, + when_full: "block", + }); + }); + + it("includes tenant_id when present in extraConfig", () => { + const adapter = telemetryProviderAdapters.loki; + const sink = adapter.toVectorSink( + { + ...baseConfig, + endpoint: "https://loki.example.com", + extraConfig: { tenantId: "tenant-a" }, + }, + "sink_1", + "dokploy_scope", + "logs", + ); + expect(sink.tenant_id).toBe("tenant-a"); + }); + + it("has no toVectorTransform — goes directly scope -> sink", () => { + expect(telemetryProviderAdapters.loki.toVectorTransform).toBeUndefined(); + }); +}); + +describe("datadogAdapter.toVectorSink", () => { + it("defaults site to datadoghq.com and sets no `tags` field (datadog_logs has none)", () => { + const adapter = telemetryProviderAdapters.datadog; + const sink = adapter.toVectorSink( + { ...baseConfig, apiKey: "dd-key" }, + "sink_2", + "transform_2", + "logs", + ); + expect(sink.type).toBe("datadog_logs"); + expect(sink.default_api_key).toBe("dd-key"); + expect(sink.site).toBe("datadoghq.com"); + expect(sink.tags).toBeUndefined(); + expect(sink.inputs).toEqual(["transform_2"]); + }); + + it("uses extraConfig.site when present", () => { + const adapter = telemetryProviderAdapters.datadog; + const sink = adapter.toVectorSink( + { + ...baseConfig, + apiKey: "dd-key", + extraConfig: { site: "datadoghq.eu" }, + }, + "sink_2", + "transform_2", + "logs", + ); + expect(sink.site).toBe("datadoghq.eu"); + }); + + it("strips a pasted scheme/trailing slash from extraConfig.site instead of interpolating it as-is", () => { + const adapter = telemetryProviderAdapters.datadog; + const sink = adapter.toVectorSink( + { + ...baseConfig, + apiKey: "dd-key", + extraConfig: { site: "https://datadoghq.eu/" }, + }, + "sink_2", + "transform_2", + "logs", + ); + expect(sink.site).toBe("datadoghq.eu"); + }); + + it("builds .ddtags from the scoping fields in toVectorTransform, since datadog_logs has no `tags` option (vector generate-schema)", () => { + const adapter = telemetryProviderAdapters.datadog; + expect(adapter.toVectorTransform).toBeDefined(); + const transform = adapter.toVectorTransform?.( + baseConfig, + "transform_2", + "dokploy_scope", + "logs", + ); + expect(transform?.type).toBe("remap"); + expect(transform?.inputs).toEqual(["dokploy_scope"]); + expect(transform?.source).toContain(".ddtags"); + expect(transform?.source).toContain( + '"dokploy_server:" + replace(to_string!(.dokploy_server), ",", "_")', + ); + }); + + it("escapes commas in scoping field values before building .ddtags, since ddtags is itself comma delimited", () => { + const adapter = telemetryProviderAdapters.datadog; + const transform = adapter.toVectorTransform?.( + baseConfig, + "transform_2", + "dokploy_scope", + "logs", + ); + expect(transform?.source).toContain( + 'replace(to_string!(.dokploy_project), ",", "_")', + ); + }); +}); + +describe("betterStackAdapter", () => { + it("toVectorTransform renames timestamp to dt, inputs point to scope transform", () => { + const adapter = telemetryProviderAdapters.betterstack; + expect(adapter.toVectorTransform).toBeDefined(); + const transform = adapter.toVectorTransform?.( + baseConfig, + "transform_1", + "dokploy_scope", + "logs", + ); + expect(transform).toEqual({ + type: "remap", + inputs: ["dokploy_scope"], + source: ".dt = del(.timestamp)", + }); + }); + + it("toVectorSink builds a generic http sink with bearer auth, reading from the transform id", () => { + const adapter = telemetryProviderAdapters.betterstack; + const sink = adapter.toVectorSink( + { + ...baseConfig, + endpoint: "https://in.logs.betterstack.com", + apiKey: "source-token", + }, + "sink_3", + "transform_1", + "logs", + ); + expect(sink.type).toBe("http"); + expect(sink.inputs).toEqual(["transform_1"]); + expect(sink.uri).toBe("https://in.logs.betterstack.com/"); + expect(sink.auth).toEqual({ strategy: "bearer", token: "source-token" }); + expect(sink.compression).toBe("gzip"); + }); +}); + +describe("elasticsearchAdapter", () => { + it("has a toVectorTransform that flattens .label to a JSON string", () => { + const adapter = telemetryProviderAdapters.elasticsearch; + expect(adapter.toVectorTransform).toBeDefined(); + const transform = adapter.toVectorTransform?.( + baseConfig, + "transform_4", + "dokploy_scope", + "logs", + ); + expect(transform?.type).toBe("remap"); + expect(transform?.inputs).toEqual(["dokploy_scope"]); + expect(transform?.source).toContain("encode_json(.label)"); + }); + + it("uses basic auth when a username is set", () => { + const adapter = telemetryProviderAdapters.elasticsearch; + const sink = adapter.toVectorSink( + { + ...baseConfig, + endpoint: "https://es.example.com:9200", + apiKey: "secret", + extraConfig: { username: "elastic" }, + }, + "sink_4", + "dokploy_scope", + "logs", + ); + expect(sink.type).toBe("elasticsearch"); + expect(sink.endpoints).toEqual(["https://es.example.com:9200"]); + expect(sink.auth).toEqual({ + strategy: "basic", + user: "elastic", + password: "secret", + }); + expect(sink.request).toBeUndefined(); + }); + + it("falls back to an ApiKey header when no username is set", () => { + const adapter = telemetryProviderAdapters.elasticsearch; + const sink = adapter.toVectorSink( + { + ...baseConfig, + endpoint: "https://es.example.com:9200", + apiKey: "key-123", + }, + "sink_4", + "dokploy_scope", + "logs", + ); + expect(sink.auth).toBeUndefined(); + expect(sink.request).toEqual({ + headers: { Authorization: "ApiKey key-123" }, + }); + }); + + it("rejects a username set without a password instead of silently shipping unauthenticated", () => { + const adapter = telemetryProviderAdapters.elasticsearch; + expect(() => + adapter.toVectorSink( + { + ...baseConfig, + endpoint: "https://es.example.com:9200", + extraConfig: { username: "elastic" }, + }, + "sink_4", + "dokploy_scope", + "logs", + ), + ).toThrow(/Password.*required/i); + }); + + it("includes bulk.index only when extraConfig.index is set", () => { + const adapter = telemetryProviderAdapters.elasticsearch; + const withoutIndex = adapter.toVectorSink( + { ...baseConfig, endpoint: "https://es.example.com:9200" }, + "sink_4", + "dokploy_scope", + "logs", + ); + expect(withoutIndex.bulk).toBeUndefined(); + const withIndex = adapter.toVectorSink( + { + ...baseConfig, + endpoint: "https://es.example.com:9200", + extraConfig: { index: "dokploy-%Y.%m.%d" }, + }, + "sink_4", + "dokploy_scope", + "logs", + ); + expect(withIndex.bulk).toEqual({ index: "dokploy-%Y.%m.%d" }); + }); +}); + +describe("splunkAdapter", () => { + it("has no toVectorTransform — scoping fields ride along as plain JSON fields", () => { + expect( + telemetryProviderAdapters.splunk_hec.toVectorTransform, + ).toBeUndefined(); + }); + + it("toVectorSink maps endpoint/token, only sets index/sourcetype when present", () => { + const adapter = telemetryProviderAdapters.splunk_hec; + const sink = adapter.toVectorSink( + { + ...baseConfig, + endpoint: "https://splunk.example.com:8088", + apiKey: "hec-token", + }, + "sink_5", + "dokploy_scope", + "logs", + ); + expect(sink.type).toBe("splunk_hec_logs"); + expect(sink.endpoint).toBe("https://splunk.example.com:8088"); + expect(sink.default_token).toBe("hec-token"); + expect(sink.encoding).toEqual({ codec: "json" }); + expect(sink.index).toBeUndefined(); + expect(sink.sourcetype).toBeUndefined(); + + const withExtras = adapter.toVectorSink( + { + ...baseConfig, + endpoint: "https://splunk.example.com:8088", + apiKey: "hec-token", + extraConfig: { index: "dokploy", sourcetype: "docker" }, + }, + "sink_5", + "dokploy_scope", + "logs", + ); + expect(withExtras.index).toBe("dokploy"); + expect(withExtras.sourcetype).toBe("docker"); + }); +}); + +describe("awsCloudwatchAdapter", () => { + it("has no logs transform — scoping fields ride along as plain JSON fields", () => { + expect( + telemetryProviderAdapters.aws_cloudwatch.toVectorTransform?.( + baseConfig, + "transform_1", + "dokploy_scope", + "logs", + ), + ).toBeNull(); + }); + + it("toVectorSink maps access key/secret, region and log group/stream", () => { + const adapter = telemetryProviderAdapters.aws_cloudwatch; + const sink = adapter.toVectorSink( + { + ...baseConfig, + apiKey: "AKIA...", + apiSecret: "shh", + extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" }, + }, + "sink_6", + "dokploy_scope", + "logs", + ); + expect(sink.type).toBe("aws_cloudwatch_logs"); + expect(sink.group_name).toBe("/dokploy/logs"); + expect(sink.stream_name).toBe("{{ container_name }}"); + expect(sink.region).toBe("us-east-1"); + expect(sink.auth).toEqual({ + access_key_id: "AKIA...", + secret_access_key: "shh", + }); + }); + + it("uses extraConfig.logStream as the stream template when set", () => { + const adapter = telemetryProviderAdapters.aws_cloudwatch; + const sink = adapter.toVectorSink( + { + ...baseConfig, + apiKey: "AKIA...", + apiSecret: "shh", + extraConfig: { + region: "us-east-1", + logGroup: "/dokploy/logs", + logStream: "{{ dokploy_application }}", + }, + }, + "sink_6", + "dokploy_scope", + "logs", + ); + expect(sink.stream_name).toBe("{{ dokploy_application }}"); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/telemetry-provider-service.test.ts b/apps/dokploy/__test__/logs-and-metrics/telemetry-provider-service.test.ts new file mode 100644 index 00000000000..e30efb520a6 --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/telemetry-provider-service.test.ts @@ -0,0 +1,661 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + insertValues: vi.fn(), + updateSet: vi.fn(), + findFirst: vi.fn(), + findMany: vi.fn(), + serverFindFirst: vi.fn(), + serverFindMany: vi.fn(), + webServerFindFirst: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { + insert: () => ({ + values: (v: any) => ({ + returning: () => Promise.resolve(mocks.insertValues(v)), + }), + }), + update: () => ({ + set: (v: any) => ({ + where: () => ({ + returning: () => Promise.resolve(mocks.updateSet(v)), + }), + }), + }), + query: { + telemetryProvider: { + findFirst: mocks.findFirst, + findMany: mocks.findMany, + }, + server: { + findFirst: mocks.serverFindFirst, + findMany: mocks.serverFindMany, + }, + webServerSettings: { findFirst: mocks.webServerFindFirst }, + }, + }, +})); + +const { + assertProvidersBelongToOrg, + assertSignalsSupported, + createTelemetryProvider, + testTelemetryProviderConnection, + updateTelemetryProvider, +} = await import("@dokploy/server/services/logs-and-metrics/service"); +const { telemetryProviderAdapters } = await import( + "@dokploy/server/services/logs-and-metrics/providers/registry" +); + +beforeEach(() => { + mocks.serverFindMany.mockResolvedValue([]); + mocks.webServerFindFirst.mockResolvedValue(undefined); +}); + +describe("updateTelemetryProvider — targets to re-apply", () => { + const existing = { + telemetryProviderId: "lp-1", + name: "loki", + providerType: "loki", + signals: ["logs"], + endpoint: "https://loki.example.com", + apiKey: "old", + apiSecret: null, + extraConfig: null, + enabled: true, + }; + + beforeEach(() => { + mocks.findFirst.mockResolvedValue(existing); + mocks.updateSet.mockReturnValue([{ ...existing, apiKey: "new" }]); + mocks.serverFindMany.mockResolvedValue([{ serverId: "server-1" }]); + mocks.webServerFindFirst.mockResolvedValue({ id: "ws-1" }); + }); + + it("returns the servers and the local host that ship with it when a credential changes", async () => { + const { targets } = await updateTelemetryProvider("lp-1", { + apiKey: "new", + }); + expect(targets).toEqual(["server-1", null]); + }); + + it("returns the same targets when the provider is disabled", async () => { + const { targets } = await updateTelemetryProvider("lp-1", { + enabled: false, + }); + expect(targets).toEqual(["server-1", null]); + }); + + it("returns no targets when only the name changes or a value is re-sent unchanged", async () => { + expect( + (await updateTelemetryProvider("lp-1", { name: "renamed" })).targets, + ).toEqual([]); + expect( + ( + await updateTelemetryProvider("lp-1", { + endpoint: "https://loki.example.com", + }) + ).targets, + ).toEqual([]); + }); +}); + +describe("createTelemetryProvider — required credential fields per adapter", () => { + it("rejects a loki provider without endpoint", async () => { + await expect( + createTelemetryProvider( + { name: "loki-prod", providerType: "loki", signals: ["logs"] } as any, + "org-1", + ), + ).rejects.toThrow(/endpoint/i); + expect(mocks.insertValues).not.toHaveBeenCalled(); + }); + + it("rejects a datadog provider without apiKey", async () => { + await expect( + createTelemetryProvider( + { name: "dd", providerType: "datadog", signals: ["logs"] } as any, + "org-1", + ), + ).rejects.toThrow(); + expect(mocks.insertValues).not.toHaveBeenCalled(); + }); + + it("rejects a betterstack provider missing either field", async () => { + await expect( + createTelemetryProvider( + { + name: "bs", + providerType: "betterstack", + signals: ["logs"], + apiKey: "token-only", + } as any, + "org-1", + ), + ).rejects.toThrow(); + expect(mocks.insertValues).not.toHaveBeenCalled(); + }); + + it("rejects a splunk_hec provider without a HEC token", async () => { + await expect( + createTelemetryProvider( + { + name: "splunk", + providerType: "splunk_hec", + signals: ["logs"], + endpoint: "https://splunk.example.com:8088", + } as any, + "org-1", + ), + ).rejects.toThrow(/token/i); + expect(mocks.insertValues).not.toHaveBeenCalled(); + }); + + it("rejects an aws_cloudwatch provider missing required extraConfig fields (region/log group)", async () => { + await expect( + createTelemetryProvider( + { + name: "cw", + providerType: "aws_cloudwatch", + signals: ["logs"], + apiKey: "AKIA...", + apiSecret: "shh", + } as any, + "org-1", + ), + ).rejects.toThrow(/region|log group/i); + expect(mocks.insertValues).not.toHaveBeenCalled(); + }); + + it("rejects a datadog provider whose apiKey only exists inside extraConfig, not the real column", async () => { + await expect( + createTelemetryProvider( + { + name: "dd", + providerType: "datadog", + signals: ["logs"], + extraConfig: { apiKey: "sneaky-value" }, + } as any, + "org-1", + ), + ).rejects.toThrow(/api key/i); + expect(mocks.insertValues).not.toHaveBeenCalled(); + }); + + it("rejects an elasticsearch provider with a username but no password (adapter.validateConfig)", async () => { + await expect( + createTelemetryProvider( + { + name: "es", + providerType: "elasticsearch", + signals: ["logs"], + endpoint: "https://es.example.com:9200", + extraConfig: { username: "elastic" }, + } as any, + "org-1", + ), + ).rejects.toThrow(/password.*required/i); + expect(mocks.insertValues).not.toHaveBeenCalled(); + }); + + it("accepts an aws_cloudwatch provider with region and log group set via extraConfig", async () => { + mocks.insertValues.mockReturnValue([ + { + telemetryProviderId: "lp-2", + name: "cw", + providerType: "aws_cloudwatch", + signals: ["logs"], + }, + ]); + const created = await createTelemetryProvider( + { + name: "cw", + providerType: "aws_cloudwatch", + signals: ["logs"], + apiKey: "AKIA...", + apiSecret: "shh", + extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" }, + } as any, + "org-1", + ); + expect(created.telemetryProviderId).toBe("lp-2"); + }); + + it("accepts a loki provider with endpoint", async () => { + mocks.insertValues.mockReturnValue([ + { telemetryProviderId: "lp-1", name: "loki-prod", providerType: "loki" }, + ]); + const created = await createTelemetryProvider( + { + name: "loki-prod", + providerType: "loki", + signals: ["logs"], + endpoint: "https://loki.example.com", + } as any, + "org-1", + ); + expect(created.telemetryProviderId).toBe("lp-1"); + expect(mocks.insertValues).toHaveBeenCalled(); + }); +}); + +describe("updateTelemetryProvider — merges partial update with existing row before validating", () => { + it("allows updating just the name without re-sending existing credentials", async () => { + mocks.findFirst.mockResolvedValue({ + telemetryProviderId: "lp-1", + name: "old-name", + providerType: "loki", + signals: ["logs"], + endpoint: "https://loki.example.com", + apiKey: null, + apiSecret: null, + extraConfig: null, + }); + mocks.updateSet.mockReturnValue([ + { telemetryProviderId: "lp-1", name: "new-name" }, + ]); + + const { provider: updated, targets } = await updateTelemetryProvider( + "lp-1", + { name: "new-name" }, + ); + expect(updated.name).toBe("new-name"); + expect(targets).toEqual([]); + }); + + it("rejects clearing the only required field via update", async () => { + mocks.findFirst.mockResolvedValue({ + telemetryProviderId: "lp-1", + name: "loki-prod", + providerType: "loki", + signals: ["logs"], + endpoint: "https://loki.example.com", + apiKey: null, + apiSecret: null, + extraConfig: null, + }); + + await expect( + updateTelemetryProvider("lp-1", { endpoint: null }), + ).rejects.toThrow(/endpoint/i); + }); + + it("clears the old provider's credentials when providerType changes, unless new values are given", async () => { + mocks.findFirst.mockResolvedValue({ + telemetryProviderId: "lp-1", + name: "dd-prod", + providerType: "datadog", + signals: ["logs"], + endpoint: null, + apiKey: "old-datadog-key", + apiSecret: null, + extraConfig: { site: "datadoghq.com" }, + }); + mocks.updateSet.mockReturnValue([{ telemetryProviderId: "lp-1" }]); + + await updateTelemetryProvider("lp-1", { + providerType: "loki", + signals: ["logs"], + endpoint: "https://loki.example.com", + }); + + expect(mocks.updateSet).toHaveBeenCalledWith( + expect.objectContaining({ + providerType: "loki", + signals: ["logs"], + endpoint: "https://loki.example.com", + apiKey: null, + apiSecret: null, + extraConfig: null, + }), + ); + }); + + it("merges a partial extraConfig update instead of replacing it wholesale", async () => { + mocks.findFirst.mockResolvedValue({ + telemetryProviderId: "lp-1", + name: "es-prod", + providerType: "elasticsearch", + signals: ["logs"], + endpoint: "https://es.example.com:9200", + apiKey: "secret", + apiSecret: null, + extraConfig: { username: "elastic", index: "custom-index" }, + }); + mocks.updateSet.mockReturnValue([{ telemetryProviderId: "lp-1" }]); + + await updateTelemetryProvider("lp-1", { + extraConfig: { username: "new-user" }, + }); + + expect(mocks.updateSet).toHaveBeenCalledWith( + expect.objectContaining({ + extraConfig: { username: "new-user", index: "custom-index" }, + }), + ); + }); + + it("clears extraConfig entirely when explicitly set to null, instead of merging", async () => { + mocks.findFirst.mockResolvedValue({ + telemetryProviderId: "lp-1", + name: "es-prod", + providerType: "elasticsearch", + signals: ["logs"], + endpoint: "https://es.example.com:9200", + apiKey: null, + apiSecret: null, + extraConfig: { username: "elastic" }, + }); + mocks.updateSet.mockReturnValue([{ telemetryProviderId: "lp-1" }]); + + await updateTelemetryProvider("lp-1", { extraConfig: null }); + + expect(mocks.updateSet).toHaveBeenCalledWith( + expect.objectContaining({ extraConfig: null }), + ); + }); + + it("clears an optional credential when it is explicitly set to null", async () => { + mocks.findFirst.mockResolvedValue({ + telemetryProviderId: "lp-1", + name: "loki-prod", + providerType: "loki", + signals: ["logs"], + endpoint: "https://loki.example.com", + apiKey: "old-token", + apiSecret: null, + extraConfig: { username: "user" }, + }); + mocks.updateSet.mockReturnValue([{ telemetryProviderId: "lp-1" }]); + + await updateTelemetryProvider("lp-1", { apiKey: null }); + + expect(mocks.updateSet).toHaveBeenCalledWith( + expect.objectContaining({ apiKey: null }), + ); + }); + + it("rejects clearing the elasticsearch password while a username stays saved", async () => { + mocks.findFirst.mockResolvedValue({ + telemetryProviderId: "lp-1", + name: "es-prod", + providerType: "elasticsearch", + signals: ["logs"], + endpoint: "https://es.example.com:9200", + apiKey: "old-password", + apiSecret: null, + extraConfig: { username: "elastic" }, + }); + + await expect( + updateTelemetryProvider("lp-1", { apiKey: null }), + ).rejects.toThrow(/Password/); + }); + + it("rejects a providerType change that doesn't satisfy the new type's required fields", async () => { + mocks.findFirst.mockResolvedValue({ + telemetryProviderId: "lp-1", + name: "dd-prod", + providerType: "datadog", + signals: ["logs"], + endpoint: null, + apiKey: "old-datadog-key", + apiSecret: null, + extraConfig: null, + }); + + await expect( + updateTelemetryProvider("lp-1", { providerType: "loki" }), + ).rejects.toThrow(/endpoint/i); + }); +}); + +describe("assertSignalsSupported", () => { + it("rejects a repeated signal", () => { + expect(() => assertSignalsSupported("loki", ["logs", "logs"])).toThrow( + /only be selected once/, + ); + }); + + it("rejects a signal the type cannot send", () => { + expect(() => assertSignalsSupported("loki", ["metrics"])).toThrow( + /Grafana Loki cannot send metrics/, + ); + }); + + it("accepts a supported subset", () => { + expect(() => assertSignalsSupported("loki", ["logs"])).not.toThrow(); + }); +}); + +describe("createTelemetryProvider — signals", () => { + it("rejects a provider whose type cannot send the selected signal", async () => { + mocks.insertValues.mockClear(); + await expect( + createTelemetryProvider( + { + name: "loki", + providerType: "loki", + signals: ["metrics"], + endpoint: "https://loki.example.com", + } as any, + "org-1", + ), + ).rejects.toThrow(/cannot send metrics/); + expect(mocks.insertValues).not.toHaveBeenCalled(); + }); +}); + +describe("assertProvidersBelongToOrg", () => { + beforeEach(() => { + mocks.findMany.mockResolvedValue([ + { + telemetryProviderId: "lp-1", + name: "loki", + enabled: true, + signals: ["logs"], + }, + ]); + }); + + it("accepts a provider of the organization regardless of what it sends", async () => { + await expect( + assertProvidersBelongToOrg(["lp-1"], "org-1"), + ).resolves.toBeUndefined(); + }); + + it("rejects an id that is not in the organization", async () => { + await expect( + assertProvidersBelongToOrg(["lp-9"], "org-1"), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + }); + + it("rejects a selection where every provider is disabled", async () => { + mocks.findMany.mockResolvedValue([ + { + telemetryProviderId: "lp-1", + name: "loki", + enabled: false, + signals: ["logs"], + }, + ]); + await expect(assertProvidersBelongToOrg(["lp-1"], "org-1")).rejects.toThrow( + /at least one enabled provider/, + ); + }); +}); + +describe("required fields scoped to a signal", () => { + beforeEach(() => { + mocks.insertValues.mockReset(); + mocks.insertValues.mockReturnValue([{ telemetryProviderId: "cw-1" }]); + }); + + it("does not require the log group when only metrics are selected", async () => { + await expect( + createTelemetryProvider( + { + name: "cw", + providerType: "aws_cloudwatch", + signals: ["metrics"], + apiKey: "AKIA", + apiSecret: "shh", + extraConfig: { region: "us-east-1", metricsNamespace: "Dokploy" }, + } as any, + "org-1", + ), + ).resolves.toMatchObject({ telemetryProviderId: "cw-1" }); + }); + + it("does not require the metrics namespace when only logs are selected", async () => { + await expect( + createTelemetryProvider( + { + name: "cw", + providerType: "aws_cloudwatch", + signals: ["logs"], + apiKey: "AKIA", + apiSecret: "shh", + extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" }, + } as any, + "org-1", + ), + ).resolves.toMatchObject({ telemetryProviderId: "cw-1" }); + }); + + it("requires the metrics namespace when metrics are selected", async () => { + await expect( + createTelemetryProvider( + { + name: "cw", + providerType: "aws_cloudwatch", + signals: ["logs", "metrics"], + apiKey: "AKIA", + apiSecret: "shh", + extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" }, + } as any, + "org-1", + ), + ).rejects.toThrow(/Metrics namespace/); + expect(mocks.insertValues).not.toHaveBeenCalled(); + }); +}); + +describe("signals are stored in a fixed order", () => { + it("normalizes the order on create", async () => { + mocks.insertValues.mockReset(); + mocks.insertValues.mockReturnValue([{ telemetryProviderId: "dd-1" }]); + + await createTelemetryProvider( + { + name: "dd", + providerType: "datadog", + signals: ["metrics", "logs"], + apiKey: "dd-key", + } as any, + "org-1", + ); + + expect(mocks.insertValues).toHaveBeenCalledWith( + expect.objectContaining({ signals: ["logs", "metrics"] }), + ); + }); + + it("normalizes the order on update", async () => { + mocks.findFirst.mockResolvedValue({ + telemetryProviderId: "dd-1", + name: "dd", + providerType: "datadog", + signals: ["logs"], + endpoint: null, + apiKey: "dd-key", + apiSecret: null, + extraConfig: null, + }); + mocks.updateSet.mockReturnValue([{ telemetryProviderId: "dd-1" }]); + + await updateTelemetryProvider("dd-1", { signals: ["metrics", "logs"] }); + + expect(mocks.updateSet).toHaveBeenCalledWith( + expect.objectContaining({ signals: ["logs", "metrics"] }), + ); + }); +}); + +describe("testTelemetryProviderConnection by id", () => { + it("tests the saved provider with the signals override from the form", async () => { + mocks.findFirst.mockResolvedValue({ + telemetryProviderId: "bs-1", + name: "bs", + providerType: "betterstack", + signals: ["logs"], + endpoint: "in.logs.betterstack.com", + apiKey: "token", + apiSecret: null, + extraConfig: null, + }); + const spy = vi + .spyOn(telemetryProviderAdapters.betterstack, "testConnection") + .mockResolvedValue(undefined); + + await testTelemetryProviderConnection({ + telemetryProviderId: "bs-1", + signals: ["logs", "metrics"], + }); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: "token", + signals: ["logs", "metrics"], + }), + ); + spy.mockRestore(); + }); +}); + +describe("updateTelemetryProvider — signals", () => { + const existing = { + telemetryProviderId: "dd-1", + name: "datadog", + providerType: "datadog", + signals: ["logs", "metrics"], + endpoint: null, + apiKey: "dd-key", + apiSecret: null, + extraConfig: null, + }; + + beforeEach(() => { + mocks.updateSet.mockReset(); + mocks.serverFindFirst.mockReset(); + mocks.webServerFindFirst.mockReset(); + mocks.findFirst.mockResolvedValue(existing); + mocks.updateSet.mockReturnValue([{ ...existing, signals: ["metrics"] }]); + }); + + it("allows removing a signal while the provider is assigned and returns its targets to re-apply", async () => { + mocks.serverFindMany.mockResolvedValue([{ serverId: "server-1" }]); + mocks.webServerFindFirst.mockResolvedValue({ id: "ws-1" }); + + await expect( + updateTelemetryProvider("dd-1", { signals: ["metrics"] }), + ).resolves.toMatchObject({ + provider: { signals: ["metrics"] }, + targets: ["server-1", null], + }); + expect(mocks.updateSet).toHaveBeenCalledWith( + expect.objectContaining({ signals: ["metrics"] }), + ); + }); + + it("rejects a type change that cannot send the saved signals", async () => { + await expect( + updateTelemetryProvider("dd-1", { + providerType: "loki", + endpoint: "https://loki.example.com", + }), + ).rejects.toThrow(/Grafana Loki cannot send metrics/); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/vector-agent.test.ts b/apps/dokploy/__test__/logs-and-metrics/vector-agent.test.ts new file mode 100644 index 00000000000..6cc14b1c518 --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/vector-agent.test.ts @@ -0,0 +1,305 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + setWebServerProviderIds: vi.fn(), + setServerProviderIds: vi.fn(), + getAccessibleServerIds: vi.fn(), + reconcileVectorAgent: vi.fn(), + removeVectorAgent: vi.fn(), + findServerById: vi.fn(), + assertProvidersBelongToOrg: vi.fn(), +})); + +const loadAgent = async (isCloud: boolean) => { + vi.resetModules(); + vi.doMock("@dokploy/server/constants", () => ({ IS_CLOUD: isCloud })); + vi.doMock("@dokploy/server/services/server", () => ({ + findServerById: mocks.findServerById, + setServerProviderIds: mocks.setServerProviderIds, + getAccessibleServerIds: mocks.getAccessibleServerIds, + })); + vi.doMock("@dokploy/server/services/web-server-settings", () => ({ + setWebServerProviderIds: mocks.setWebServerProviderIds, + })); + const locks = new Map>(); + vi.doMock("@dokploy/server/setup/vector-setup", () => ({ + LOCAL_SERVER_NAME: "Dokploy Server (local)", + reconcileVectorAgent: mocks.reconcileVectorAgent, + removeVectorAgent: mocks.removeVectorAgent, + withVectorTargetLock: ( + serverId: string | undefined, + fn: () => Promise, + ) => { + const key = serverId ?? "local"; + const current = (locks.get(key) ?? Promise.resolve()) + .catch(() => {}) + .then(fn); + locks.set(key, current); + return current; + }, + })); + vi.doMock("@dokploy/server/services/logs-and-metrics/service", () => ({ + assertProvidersBelongToOrg: mocks.assertProvidersBelongToOrg, + })); + return await import("@dokploy/server/services/logs-and-metrics/vector-agent"); +}; + +const sessionA = { userId: "user-a", activeOrganizationId: "org-a" }; +const sessionB = { userId: "user-b", activeOrganizationId: "org-b" }; + +beforeEach(() => { + for (const mock of Object.values(mocks)) mock.mockReset(); + mocks.getAccessibleServerIds.mockResolvedValue(new Set(["server-1"])); + mocks.findServerById.mockImplementation(async (serverId: string) => ({ + serverId, + name: `name-of-${serverId}`, + organizationId: "org-a", + sshKeyId: "key-1", + })); + mocks.setWebServerProviderIds.mockResolvedValue({ id: "ws-1" }); + mocks.setServerProviderIds.mockResolvedValue({ serverId: "server-1" }); +}); + +describe("applyVectorAgentSelection on the local host", () => { + it("rejects the local host in cloud without touching the database", async () => { + const { applyVectorAgentSelection } = await loadAgent(true); + + await expect( + applyVectorAgentSelection(sessionA, undefined, ["lp-1"]), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + expect(mocks.setWebServerProviderIds).not.toHaveBeenCalled(); + expect(mocks.reconcileVectorAgent).not.toHaveBeenCalled(); + }); + + it("rejects with CONFLICT when another organization owns the local agent, without reconciling", async () => { + mocks.setWebServerProviderIds.mockResolvedValue(null); + const { applyVectorAgentSelection } = await loadAgent(false); + + await expect( + applyVectorAgentSelection(sessionB, undefined, ["mp-1"]), + ).rejects.toMatchObject({ code: "CONFLICT" }); + expect(mocks.setWebServerProviderIds).toHaveBeenCalledWith("org-b", [ + "mp-1", + ]); + expect(mocks.reconcileVectorAgent).not.toHaveBeenCalled(); + }); + + it("saves the selection and then reconciles", async () => { + const { applyVectorAgentSelection } = await loadAgent(false); + + await applyVectorAgentSelection(sessionA, undefined, ["lp-1"]); + expect(mocks.assertProvidersBelongToOrg).toHaveBeenCalledWith( + ["lp-1"], + "org-a", + ); + expect(mocks.setWebServerProviderIds).toHaveBeenCalledWith("org-a", [ + "lp-1", + ]); + expect(mocks.reconcileVectorAgent).toHaveBeenCalledWith(undefined); + expect( + mocks.setWebServerProviderIds.mock.invocationCallOrder[0], + ).toBeLessThan(mocks.reconcileVectorAgent.mock.invocationCallOrder[0] ?? 0); + }); + + it("removing the agent saves an empty selection without checking providers", async () => { + const { applyVectorAgentSelection } = await loadAgent(false); + + await applyVectorAgentSelection(sessionA, undefined, []); + expect(mocks.assertProvidersBelongToOrg).not.toHaveBeenCalled(); + expect(mocks.setWebServerProviderIds).toHaveBeenCalledWith("org-a", []); + expect(mocks.reconcileVectorAgent).toHaveBeenCalledWith(undefined); + }); +}); + +describe("applyVectorAgentSelection on a remote server", () => { + it("still manages remote servers in cloud", async () => { + const { applyVectorAgentSelection } = await loadAgent(true); + + const result = await applyVectorAgentSelection(sessionA, "server-1", []); + expect(result).toEqual({ serverName: "name-of-server-1" }); + expect(mocks.setServerProviderIds).toHaveBeenCalledWith("server-1", []); + expect(mocks.reconcileVectorAgent).toHaveBeenCalledWith("server-1"); + }); + + it("rejects a server of another organization", async () => { + mocks.findServerById.mockResolvedValue({ + serverId: "server-1", + organizationId: "org-b", + sshKeyId: "key-1", + }); + const { applyVectorAgentSelection } = await loadAgent(false); + + await expect( + applyVectorAgentSelection(sessionA, "server-1", ["lp-1"]), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + expect(mocks.setServerProviderIds).not.toHaveBeenCalled(); + }); + + it("rejects a server without an SSH key before saving anything", async () => { + mocks.findServerById.mockResolvedValue({ + serverId: "server-1", + organizationId: "org-a", + sshKeyId: null, + }); + const { applyVectorAgentSelection } = await loadAgent(false); + + await expect( + applyVectorAgentSelection(sessionA, "server-1", ["lp-1"]), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + expect(mocks.setServerProviderIds).not.toHaveBeenCalled(); + expect(mocks.reconcileVectorAgent).not.toHaveBeenCalled(); + }); + + it("rejects a server the member cannot access", async () => { + const { applyVectorAgentSelection } = await loadAgent(false); + + await expect( + applyVectorAgentSelection(sessionA, "server-2", ["lp-1"]), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + expect(mocks.getAccessibleServerIds).toHaveBeenCalledWith(sessionA); + expect(mocks.setServerProviderIds).not.toHaveBeenCalled(); + expect(mocks.reconcileVectorAgent).not.toHaveBeenCalled(); + }); + + it("saves the array and only then reconciles", async () => { + const { applyVectorAgentSelection } = await loadAgent(false); + + await applyVectorAgentSelection(sessionA, "server-1", ["mp-1"]); + expect(mocks.setServerProviderIds).toHaveBeenCalledWith("server-1", [ + "mp-1", + ]); + expect(mocks.setServerProviderIds.mock.invocationCallOrder[0]).toBeLessThan( + mocks.reconcileVectorAgent.mock.invocationCallOrder[0] ?? 0, + ); + }); + + it("keeps the saved selection when the reconcile fails", async () => { + mocks.reconcileVectorAgent.mockRejectedValue( + new Error("validation failed"), + ); + const { applyVectorAgentSelection } = await loadAgent(false); + + await expect( + applyVectorAgentSelection(sessionA, "server-1", ["lp-2"]), + ).rejects.toThrow("validation failed"); + expect(mocks.setServerProviderIds).toHaveBeenCalledWith("server-1", [ + "lp-2", + ]); + }); + + it("serializes concurrent selections on the same host so the last reconcile sees the last selection", async () => { + const saved: string[][] = []; + const seen: string[][] = []; + mocks.setServerProviderIds.mockImplementation( + async (_serverId: string, ids: string[]) => { + saved.push(ids); + return { serverId: "server-1" }; + }, + ); + mocks.reconcileVectorAgent.mockImplementation(async () => { + seen.push(saved.at(-1) ?? []); + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + const { applyVectorAgentSelection } = await loadAgent(false); + + await Promise.all([ + applyVectorAgentSelection(sessionA, "server-1", ["lp-1"]), + applyVectorAgentSelection(sessionA, "server-1", ["lp-2"]), + ]); + expect(seen).toEqual([["lp-1"], ["lp-2"]]); + }); +}); + +describe("removeServerVectorAgent", () => { + it("removes the agent on server deletion when providers are assigned", async () => { + const { removeServerVectorAgent } = await loadAgent(false); + + await expect( + removeServerVectorAgent({ + serverId: "server-1", + sshKeyId: "key-1", + telemetryProviderIds: ["lp-1"], + } as never), + ).resolves.toBeUndefined(); + expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-1"); + }); + + it("attempts cleanup even with an empty selection, since a failed Remove can leave the agent running", async () => { + const { removeServerVectorAgent } = await loadAgent(false); + + await expect( + removeServerVectorAgent({ + serverId: "server-1", + sshKeyId: "key-1", + telemetryProviderIds: [], + } as never), + ).resolves.toBeUndefined(); + expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-1"); + }); + + it("skips servers without an SSH key, warning when providers were assigned", async () => { + const { removeServerVectorAgent } = await loadAgent(false); + + await expect( + removeServerVectorAgent({ + serverId: "server-1", + sshKeyId: null, + telemetryProviderIds: [], + } as never), + ).resolves.toBeUndefined(); + await expect( + removeServerVectorAgent({ + serverId: "server-1", + sshKeyId: null, + telemetryProviderIds: ["lp-1"], + } as never), + ).resolves.toMatch(/no SSH key/); + expect(mocks.removeVectorAgent).not.toHaveBeenCalled(); + }); + + it("returns the failure as a message instead of throwing", async () => { + mocks.removeVectorAgent.mockRejectedValue(new Error("docker down")); + const { removeServerVectorAgent } = await loadAgent(false); + + await expect( + removeServerVectorAgent({ + serverId: "server-1", + sshKeyId: "k", + telemetryProviderIds: ["lp-1"], + } as never), + ).resolves.toBe("docker down"); + }); +}); + +describe("reconcileVectorTargets", () => { + it("re-applies every target under its lock and reports failures as a warning", async () => { + mocks.reconcileVectorAgent.mockImplementation(async (serverId?: string) => { + if (serverId === "server-2") throw new Error("ssh down"); + }); + const { reconcileVectorTargets } = await loadAgent(false); + + const warning = await reconcileVectorTargets([ + "server-1", + "server-2", + null, + ]); + expect(mocks.reconcileVectorAgent.mock.calls.map((c) => c[0])).toEqual([ + "server-1", + "server-2", + undefined, + ]); + expect(warning).toBe( + "The Vector agent could not be re-applied on server-2: ssh down", + ); + }); + + it("returns no warning when every target reconciles, and skips the local host in cloud", async () => { + const { reconcileVectorTargets } = await loadAgent(true); + + await expect( + reconcileVectorTargets(["server-1", null]), + ).resolves.toBeUndefined(); + expect(mocks.reconcileVectorAgent).toHaveBeenCalledTimes(1); + expect(mocks.reconcileVectorAgent).toHaveBeenCalledWith("server-1"); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/vector-config.test.ts b/apps/dokploy/__test__/logs-and-metrics/vector-config.test.ts new file mode 100644 index 00000000000..98c4d70d33d --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/vector-config.test.ts @@ -0,0 +1,375 @@ +import { vrlString } from "@dokploy/server/services/logs-and-metrics/types"; +import { + buildMetricsScopeTransformSource, + buildVectorConfigYaml, + collectSecretValues, + redactSecrets, +} from "@dokploy/server/setup/vector-setup"; +import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; + +const provider = (overrides: Record) => + ({ + apiKey: null, + apiSecret: null, + endpoint: null, + extraConfig: null, + signals: ["logs"], + ...overrides, + }) as any; + +const loki = provider({ + telemetryProviderId: "loki-1", + name: "loki", + providerType: "loki", + endpoint: "https://loki.example.com", +}); + +const prom = provider({ + telemetryProviderId: "prom-1", + name: "prom", + providerType: "prometheus_remote_write", + signals: ["metrics"], + endpoint: "http://prom.example.com:9090/api/v1/write", +}); + +const datadog = provider({ + telemetryProviderId: "dd-1", + name: "datadog", + providerType: "datadog", + signals: ["logs", "metrics"], + apiKey: "dd-key", +}); + +const build = ( + overrides: Partial[0]>, +) => + parse( + buildVectorConfigYaml({ + logProviders: [], + metricsProviders: [], + serverName: "my server", + ...overrides, + }), + ) as any; + +describe("buildVectorConfigYaml — logs", () => { + it("generates a source, a dokploy_scope transform and one sink per provider, and nothing for metrics", () => { + const config = build({ logProviders: [loki] }); + + expect(config.data_dir).toBe("/var/lib/vector"); + expect(config.sources.docker_logs_source.type).toBe("docker_logs"); + expect(config.transforms.dokploy_scope.type).toBe("remap"); + expect(config.transforms.dokploy_scope.inputs).toEqual([ + "docker_logs_source", + ]); + expect(Object.keys(config.sinks)).toEqual(["sink_loki-1"]); + expect(config.sinks["sink_loki-1"].type).toBe("loki"); + expect(config.sinks["sink_loki-1"].inputs).toEqual(["dokploy_scope"]); + + expect(config.sources.host_metrics_source).toBeUndefined(); + expect(config.sources.cadvisor_source).toBeUndefined(); + expect(config.transforms.dokploy_metrics_scope).toBeUndefined(); + }); + + it("chains scope -> transform -> sink for betterstack/datadog, and scope -> sink direct for loki", () => { + const config = build({ + logProviders: [ + loki, + provider({ + telemetryProviderId: "bs-1", + name: "betterstack", + providerType: "betterstack", + endpoint: "https://in.logs.betterstack.com", + apiKey: "token", + }), + datadog, + ], + }); + + expect(config.sinks["sink_loki-1"].inputs).toEqual(["dokploy_scope"]); + expect(config.transforms["transform_bs-1"].inputs).toEqual([ + "dokploy_scope", + ]); + expect(config.sinks["sink_bs-1"].inputs).toEqual(["transform_bs-1"]); + expect(config.transforms["transform_dd-1"].inputs).toEqual([ + "dokploy_scope", + ]); + expect(config.sinks["sink_dd-1"].inputs).toEqual(["transform_dd-1"]); + }); + + it("tags every log with the server name and reads the Dokploy metadata straight off the container labels, with empty defaults for containers Dokploy did not deploy", () => { + const config = build({ logProviders: [loki], serverName: 'srv "quoted"' }); + + expect(config.transforms.dokploy_scope.source).toBe( + [ + '.dokploy_server = "srv \\"quoted\\""', + '.dokploy_organization = .label."dokploy.organization.id" || ""', + '.dokploy_project = .label."dokploy.project" || ""', + '.dokploy_project_id = .label."dokploy.project.id" || ""', + '.dokploy_environment = .label."dokploy.environment" || ""', + '.dokploy_environment_id = .label."dokploy.environment.id" || ""', + '.dokploy_application = .label."dokploy.application" || ""', + '.dokploy_application_id = .label."dokploy.application.id" || ""', + '.dokploy_service = .label."dokploy.service" || ""', + ].join("\n"), + ); + }); + + it("skips a provider whose config an adapter rejects at build time, instead of failing the whole org's config", () => { + const config = build({ + logProviders: [ + provider({ + telemetryProviderId: "broken-es", + name: "es-broken", + providerType: "elasticsearch", + endpoint: "https://es.example.com:9200", + extraConfig: { username: "elastic" }, + }), + loki, + ], + }); + + expect(Object.keys(config.sinks)).toEqual(["sink_loki-1"]); + }); + + it("only keeps containers of the owning organization on the local host, which is shared by every organization", () => { + const config = build({ logProviders: [loki], organizationId: "org-a" }); + + expect(config.transforms.dokploy_scope_local_only).toEqual({ + type: "filter", + inputs: ["dokploy_scope"], + condition: '.dokploy_organization == "org-a"', + }); + expect(config.sinks["sink_loki-1"].inputs).toEqual([ + "dokploy_scope_local_only", + ]); + }); + + it("does not add the organization filter for the per-server (non-local) path", () => { + const config = build({ logProviders: [loki] }); + + expect(config.transforms.dokploy_scope_local_only).toBeUndefined(); + }); +}); + +describe("buildVectorConfigYaml — metrics", () => { + it("with only metrics providers it emits the host and cAdvisor sources and no logs pipeline", () => { + const config = build({ metricsProviders: [prom] }); + + expect(config.sources.docker_logs_source).toBeUndefined(); + expect(config.transforms.dokploy_scope).toBeUndefined(); + expect(config.sources.host_metrics_source).toMatchObject({ + type: "host_metrics", + scrape_interval_secs: 30, + collectors: [ + "cpu", + "memory", + "disk", + "filesystem", + "network", + "load", + "host", + ], + }); + expect( + config.sources.host_metrics_source.filesystem.filesystems.excludes, + ).toEqual( + expect.arrayContaining([ + "overlay", + "tmpfs", + "squashfs", + "devtmpfs", + "nsfs", + "proc", + "sysfs", + "devpts", + "cgroup2", + ]), + ); + expect(config.sources.host_metrics_source.network.devices.excludes).toEqual( + ["veth*", "br-*", "docker*"], + ); + expect(config.sources.cadvisor_source).toEqual({ + type: "prometheus_scrape", + endpoints: ["http://127.0.0.1:4510/metrics"], + scrape_interval_secs: 30, + scrape_timeout_secs: 10, + }); + expect(config.transforms.dokploy_metrics_scope).toMatchObject({ + type: "remap", + inputs: ["host_metrics_source", "cadvisor_source"], + drop_on_abort: true, + }); + expect(Object.keys(config.sinks)).toEqual(["sink_prom-1_metrics"]); + expect(config.sinks["sink_prom-1_metrics"]).toMatchObject({ + type: "prometheus_remote_write", + inputs: ["dokploy_metrics_scope"], + }); + expect(config.transforms.dokploy_metrics_scope_local_only).toBeUndefined(); + }); + + it("on the local host it lets host series through and keeps only the owning organization's containers", () => { + const config = build({ metricsProviders: [prom], organizationId: "org-a" }); + + expect(config.transforms.dokploy_metrics_scope_local_only).toEqual({ + type: "filter", + inputs: ["dokploy_metrics_scope"], + condition: + '.namespace == "host" || .tags.dokploy_organization == "org-a"', + }); + expect(config.sinks["sink_prom-1_metrics"].inputs).toEqual([ + "dokploy_metrics_scope_local_only", + ]); + }); + + it("a provider assigned to both signals gets a logs sink and a metrics sink without clashing", () => { + const config = build({ + logProviders: [datadog], + metricsProviders: [datadog], + organizationId: "org-a", + }); + + expect(config.sinks["sink_dd-1"]).toMatchObject({ + type: "datadog_logs", + inputs: ["transform_dd-1"], + }); + expect(config.sinks["sink_dd-1_metrics"]).toMatchObject({ + type: "datadog_metrics", + inputs: ["dokploy_metrics_scope_local_only"], + }); + expect(config.transforms["transform_dd-1_metrics"]).toBeUndefined(); + }); + + it("tags every series with the server name, promotes non-empty dokploy labels and drops the cgroup id", () => { + const source = buildMetricsScopeTransformSource('srv "quoted"'); + + expect(source).toContain('.tags.dokploy_server = "srv \\"quoted\\""'); + expect(source).toContain( + 'if .namespace != "host" && !starts_with(string!(.name), "container_") { abort }', + ); + expect(source).toContain( + 'value = del(.tags.container_label_dokploy_application)\nif is_string(value) && value != "" { .tags.dokploy_application = value }', + ); + expect(source.trim().endsWith("del(.tags.id)")).toBe(true); + expect( + build({ metricsProviders: [prom] }).transforms.dokploy_metrics_scope + .source, + ).toBe(buildMetricsScopeTransformSource("my server")); + }); +}); + +describe("buildVectorConfigYaml — dollar signs", () => { + it("keeps every $ literal because the agent runs without env var interpolation", () => { + const yamlStr = buildVectorConfigYaml({ + logProviders: [ + provider({ + telemetryProviderId: "loki-1", + name: "loki", + providerType: "loki", + endpoint: "https://loki.example.com", + apiKey: "Pa$5w0rd$HOME", + extraConfig: { username: "12345" }, + }), + ], + metricsProviders: [prom], + serverName: "srv $ONE", + }); + + expect(yamlStr).not.toContain("$$"); + expect(yamlStr).toContain("srv $ONE"); + expect(parse(yamlStr).sinks["sink_loki-1"].auth.password).toBe( + "Pa$5w0rd$HOME", + ); + }); +}); + +describe("collectSecretValues / redactSecrets", () => { + it("collects endpoint/apiKey/apiSecret across all providers but only the password-type extraConfig fields", () => { + const providers = [ + { + providerType: "loki", + endpoint: "https://loki.example.com", + apiKey: "loki-key", + apiSecret: null, + extraConfig: { tenantId: "tenant-a", username: "12345" }, + }, + { + providerType: "datadog", + endpoint: null, + apiKey: "dd-key", + apiSecret: null, + extraConfig: { site: "datadoghq.eu" }, + }, + { + providerType: "aws_cloudwatch", + endpoint: null, + apiKey: "AKIA", + apiSecret: "shh", + extraConfig: { region: "us-east-1", metricsNamespace: "Dokploy" }, + }, + ] as any; + + const values = collectSecretValues(providers); + expect(values).toEqual( + expect.arrayContaining([ + "https://loki.example.com", + "loki-key", + "dd-key", + "AKIA", + "shh", + ]), + ); + for (const plain of [ + "tenant-a", + "12345", + "datadoghq.eu", + "us-east-1", + "Dokploy", + ]) { + expect(values).not.toContain(plain); + } + }); + + it("ignores null/empty credentials", () => { + const providers = [ + { + providerType: "elasticsearch", + endpoint: null, + apiKey: null, + apiSecret: null, + extraConfig: { limit: 10, enabled: true, empty: "" }, + }, + ] as any; + expect(collectSecretValues(providers)).toEqual([]); + }); + + it("redacts every collected secret, longest first so a short one can't eat part of a longer one", () => { + const providers = [ + { + providerType: "loki", + endpoint: null, + apiKey: "hunter2-long-suffix", + apiSecret: "hunter2", + extraConfig: null, + }, + ] as any; + const text = + "sink validation error near apiKey=hunter2-long-suffix and hunter2"; + + const redacted = redactSecrets(text, collectSecretValues(providers)); + + expect(redacted).not.toContain("hunter2"); + expect(redacted).toBe( + "sink validation error near apiKey=[redacted] and [redacted]", + ); + }); +}); + +describe("vrlString", () => { + it("quotes and escapes like JSON but drops the control characters VRL rejects", () => { + expect(vrlString('my "server"')).toBe('"my \\"server\\""'); + expect(vrlString("back\\slash")).toBe('"back\\\\slash"'); + expect(vrlString("a\u0008b\u000cc\u0000d\u007fe\nf")).toBe('"abcdef"'); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/vector-reconcile.test.ts b/apps/dokploy/__test__/logs-and-metrics/vector-reconcile.test.ts new file mode 100644 index 00000000000..4d377555347 --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/vector-reconcile.test.ts @@ -0,0 +1,526 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { parse } from "yaml"; + +const mocks = vi.hoisted(() => ({ + findServerById: vi.fn(), + getWebServerSettings: vi.fn(), + findEnabledTelemetryProvidersByOrganization: vi.fn(), + execAsync: vi.fn(), + execAsyncRemote: vi.fn(), + inspectService: vi.fn(), + updateService: vi.fn(), + removeService: vi.fn(), + createService: vi.fn(), + getService: vi.fn(), + listContainers: vi.fn(), + killContainer: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => ({ db: {} })); +vi.mock("@dokploy/server/constants", () => ({ + IS_CLOUD: false, + paths: (isServer: boolean) => ({ + VECTOR_PATH: isServer ? "/etc/dokploy/vector" : "/local/dokploy/vector", + }), +})); +vi.mock("@dokploy/server/services/server", () => ({ + findServerById: mocks.findServerById, + findServersByOrganizationForVectorAgent: vi.fn(), + getAccessibleServerIds: vi.fn(), +})); +vi.mock("@dokploy/server/services/web-server-settings", () => ({ + getWebServerSettings: mocks.getWebServerSettings, +})); +vi.mock( + "@dokploy/server/services/logs-and-metrics/service", + async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@dokploy/server/services/logs-and-metrics/service") + >()), + findEnabledTelemetryProvidersByOrganization: + mocks.findEnabledTelemetryProvidersByOrganization, + }), +); +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: mocks.execAsync, + execAsyncRemote: mocks.execAsyncRemote, +})); +vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({ + getRemoteDocker: async () => ({ + getService: mocks.getService, + createService: mocks.createService, + listContainers: mocks.listContainers, + getContainer: () => ({ kill: mocks.killContainer }), + }), +})); + +const { reconcileVectorAgent, removeVectorAgent } = await import( + "@dokploy/server/setup/vector-setup" +); + +const lokiProvider = { + telemetryProviderId: "lp-1", + name: "loki", + providerType: "loki", + signals: ["logs"], + endpoint: "https://loki.example.com", + apiKey: "sekret-token", + apiSecret: null, + extraConfig: null, + enabled: true, + organizationId: "org-a", +}; + +const promProvider = { + ...lokiProvider, + telemetryProviderId: "mp-1", + name: "prom", + providerType: "prometheus_remote_write", + signals: ["metrics"], + endpoint: "http://prom.example.com:9090/api/v1/write", + apiKey: null, +}; + +const notFound = () => + Object.assign(new Error("not found"), { statusCode: 404 }); + +const writtenYaml = (command: string) => { + const match = command.match(/echo "([^"]+)" \| base64 -d/); + return parse(Buffer.from(match?.[1] ?? "", "base64").toString("utf8")); +}; + +const commandsOf = (mock: typeof mocks.execAsync) => + mock.mock.calls.map((call) => String(call[call.length - 1])); + +beforeEach(() => { + for (const mock of Object.values(mocks)) mock.mockReset(); + mocks.getService.mockImplementation(() => ({ + inspect: mocks.inspectService, + update: mocks.updateService, + remove: mocks.removeService, + })); + mocks.inspectService.mockRejectedValue(notFound()); + mocks.removeService.mockRejectedValue(notFound()); + mocks.execAsync.mockResolvedValue({ stdout: "", stderr: "" }); + mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" }); + mocks.listContainers.mockResolvedValue([{ Id: "vector-container" }]); + mocks.killContainer.mockResolvedValue(undefined); +}); + +describe("reconcileVectorAgent", () => { + it("writes, validates and applies the YAML and deploys Vector for the saved selection", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-a", + telemetryProviderIds: ["lp-1"], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([ + lokiProvider, + ]); + + await reconcileVectorAgent(); + + const commands = commandsOf(mocks.execAsync); + expect(commands).toHaveLength(3); + expect(commands[0]).toContain( + "/local/dokploy/vector/vector.yaml.candidate.", + ); + expect(commands[1]).toMatch( + /timberio\/vector:\d+\.\d+\.\d+-alpine validate --skip-healthchecks/, + ); + expect(commands[2]).toMatch( + /^mv \/local\/dokploy\/vector\/vector\.yaml\.candidate\.\S+ \/local\/dokploy\/vector\/vector\.yaml$/, + ); + expect(mocks.execAsyncRemote).not.toHaveBeenCalled(); + + const config = writtenYaml(commands[0] ?? ""); + expect(config.sinks["sink_lp-1"].inputs).toEqual([ + "dokploy_scope_local_only", + ]); + expect(config.sources.host_metrics_source).toBeUndefined(); + + expect(mocks.createService).toHaveBeenCalledTimes(1); + expect(mocks.createService.mock.calls[0]?.[0]).toMatchObject({ + Name: "dokploy-vector", + }); + expect(mocks.getService).toHaveBeenCalledWith("dokploy-cadvisor"); + expect(mocks.removeService).toHaveBeenCalledTimes(1); + }); + + it("splits one selection into the logs and metrics pipelines by what each provider sends", async () => { + const datadogProvider = { + ...lokiProvider, + telemetryProviderId: "dd-1", + name: "datadog", + providerType: "datadog", + signals: ["logs", "metrics"], + endpoint: null, + apiKey: "dd-key", + }; + const unselected = { ...lokiProvider, telemetryProviderId: "lp-9" }; + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-a", + telemetryProviderIds: ["lp-1", "mp-1", "dd-1"], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([ + lokiProvider, + promProvider, + datadogProvider, + unselected, + ]); + + await reconcileVectorAgent(); + + const config = writtenYaml(commandsOf(mocks.execAsync)[0] ?? ""); + const sinkIds = Object.keys(config.sinks); + expect(sinkIds.some((id) => id.includes("lp-1"))).toBe(true); + expect(sinkIds.some((id) => id.includes("mp-1"))).toBe(true); + expect(sinkIds.filter((id) => id.includes("dd-1")).length).toBe(2); + expect(sinkIds.some((id) => id.includes("lp-9"))).toBe(false); + expect(config.sources.docker_logs_source).toBeDefined(); + expect(config.sources.cadvisor_source).toBeDefined(); + expect(mocks.createService.mock.calls.map((c) => c[0].Name)).toEqual([ + "dokploy-vector", + "dokploy-cadvisor", + ]); + }); + + it("removes cAdvisor but keeps Vector when no selected provider sends metrics", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-a", + telemetryProviderIds: ["lp-1", "mp-1"], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([ + lokiProvider, + ]); + + await reconcileVectorAgent(); + + const config = writtenYaml(commandsOf(mocks.execAsync)[0] ?? ""); + expect(config.sources.cadvisor_source).toBeUndefined(); + expect(mocks.createService.mock.calls.map((c) => c[0].Name)).toEqual([ + "dokploy-vector", + ]); + expect(mocks.getService).toHaveBeenCalledWith("dokploy-cadvisor"); + expect(mocks.removeService).toHaveBeenCalledTimes(1); + }); + + it("filters by the organization only on the local host and runs on the server over SSH", async () => { + mocks.findServerById.mockResolvedValue({ + serverId: "server-1", + organizationId: "org-a", + name: "edge-1", + telemetryProviderIds: ["lp-1"], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([ + lokiProvider, + ]); + + await reconcileVectorAgent("server-1"); + + expect(mocks.execAsync).not.toHaveBeenCalled(); + expect( + mocks.execAsyncRemote.mock.calls.every((c) => c[0] === "server-1"), + ).toBe(true); + const config = writtenYaml(commandsOf(mocks.execAsyncRemote)[0] ?? ""); + expect(config.transforms.dokploy_scope_local_only).toBeUndefined(); + expect(config.sinks["sink_lp-1"].inputs).toEqual(["dokploy_scope"]); + }); + + it("does not apply a config that vector validate rejects, redacts secrets and leaves the selection alone", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-a", + telemetryProviderIds: ["lp-1"], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([ + lokiProvider, + ]); + mocks.execAsync.mockImplementation(async (command: string) => { + if (command.includes("validate")) { + throw new Error("bad sink: password sekret-token rejected"); + } + return { stdout: "", stderr: "" }; + }); + + await expect(reconcileVectorAgent()).rejects.toThrow( + /failed validation, not applying it: bad sink: password \[redacted\] rejected/, + ); + + const commands = commandsOf(mocks.execAsync); + expect(commands.some((c) => c.startsWith("mv "))).toBe(false); + expect(commands.some((c) => c.startsWith("rm -f "))).toBe(true); + expect(mocks.createService).not.toHaveBeenCalled(); + }); + + it("removes the agent when no enabled provider is left in the selection", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-a", + telemetryProviderIds: ["deleted-or-disabled"], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([]); + + await reconcileVectorAgent(); + + expect(mocks.getService).toHaveBeenCalledWith("dokploy-cadvisor"); + expect(mocks.getService).toHaveBeenCalledWith("dokploy-vector"); + expect(mocks.removeService).toHaveBeenCalledTimes(2); + expect(commandsOf(mocks.execAsync)).toEqual([ + "rm -rf /local/dokploy/vector", + ]); + expect(mocks.createService).not.toHaveBeenCalled(); + }); + + it("removes the agent when the selection is empty, without an owner", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: null, + telemetryProviderIds: [], + }); + + await reconcileVectorAgent(); + + expect( + mocks.findEnabledTelemetryProvidersByOrganization, + ).not.toHaveBeenCalled(); + expect(mocks.removeService).toHaveBeenCalledTimes(2); + }); + + it("deploys cAdvisor next to Vector when metrics providers are assigned", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-a", + telemetryProviderIds: ["mp-1"], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([ + promProvider, + ]); + + await reconcileVectorAgent(); + + const config = writtenYaml(commandsOf(mocks.execAsync)[0] ?? ""); + expect(config.sources.docker_logs_source).toBeUndefined(); + expect(config.sources.cadvisor_source.endpoints).toEqual([ + "http://127.0.0.1:4510/metrics", + ]); + + expect(mocks.createService.mock.calls.map((c) => c[0].Name)).toEqual([ + "dokploy-vector", + "dokploy-cadvisor", + ]); + const cadvisor = mocks.createService.mock.calls[1]?.[0]; + expect(cadvisor.TaskTemplate.ContainerSpec.Image).toMatch( + /^ghcr\.io\/google\/cadvisor:v\d+\.\d+\.\d+$/, + ); + expect(cadvisor.TaskTemplate.ContainerSpec.Args).toEqual( + expect.arrayContaining([ + "--listen_ip=127.0.0.1", + "--port=4510", + "--docker_only=true", + "--store_container_labels=false", + ]), + ); + expect(cadvisor.TaskTemplate.ContainerSpec.HealthCheck).toEqual({ + Test: ["NONE"], + }); + expect(cadvisor.TaskTemplate.Networks).toEqual([{ Target: "host" }]); + expect(mocks.removeService).not.toHaveBeenCalled(); + }); + + it("removes the agent on a remote server when the selection is empty", async () => { + mocks.findServerById.mockResolvedValue({ + serverId: "server-1", + sshKeyId: "key-1", + organizationId: "org-a", + name: "edge-1", + telemetryProviderIds: [], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([]); + + await reconcileVectorAgent("server-1"); + + expect( + mocks.findEnabledTelemetryProvidersByOrganization, + ).toHaveBeenCalled(); + expect(mocks.removeService).toHaveBeenCalledTimes(2); + expect(mocks.createService).not.toHaveBeenCalled(); + expect(commandsOf(mocks.execAsyncRemote)).toEqual([ + "rm -rf /etc/dokploy/vector", + ]); + }); + + it("redacts the config and secrets when writing or applying the candidate fails", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-a", + telemetryProviderIds: ["lp-1"], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([ + lokiProvider, + ]); + const echoCommand = async (command: string) => { + if (command.includes("base64 -d") || command.startsWith("mv ")) { + throw new Error(`Command failed: ${command} (token sekret-token)`); + } + return { stdout: "", stderr: "" }; + }; + mocks.execAsync.mockImplementation(echoCommand); + + const writeError = await reconcileVectorAgent().catch( + (e: Error) => e.message, + ); + expect(writeError).toMatch(/^Failed to write the Vector config: /); + expect(writeError).not.toContain("sekret-token"); + expect(writeError).not.toContain( + Buffer.from("data_dir").toString("base64").slice(0, 8), + ); + expect(writeError).toContain("[redacted]"); + + mocks.execAsync.mockImplementation(async (command: string) => { + if (command.startsWith("mv ")) return echoCommand(command); + return { stdout: "", stderr: "" }; + }); + const applyError = await reconcileVectorAgent().catch( + (e: Error) => e.message, + ); + expect(applyError).toMatch(/^Failed to apply the Vector config: /); + expect(applyError).not.toContain("sekret-token"); + expect(mocks.createService).not.toHaveBeenCalled(); + }); + + it("propagates a rejected service update instead of pretending the create succeeded", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-a", + telemetryProviderIds: ["lp-1"], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([ + lokiProvider, + ]); + mocks.inspectService.mockResolvedValue({ + Version: { Index: "7" }, + Spec: { TaskTemplate: { ForceUpdate: 2 } }, + }); + mocks.updateService.mockRejectedValue( + new Error("rpc error: invalid mount"), + ); + + await expect(reconcileVectorAgent()).rejects.toThrow("invalid mount"); + expect(mocks.createService).not.toHaveBeenCalled(); + }); + + it("propagates an inspect failure that is not a 404", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-a", + telemetryProviderIds: ["lp-1"], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([ + lokiProvider, + ]); + mocks.inspectService.mockRejectedValue( + Object.assign(new Error("daemon unreachable"), { statusCode: 500 }), + ); + + await expect(reconcileVectorAgent()).rejects.toThrow("daemon unreachable"); + expect(mocks.createService).not.toHaveBeenCalled(); + }); + + it("does not restart Vector on a config-only redeploy", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-a", + telemetryProviderIds: ["lp-1"], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([ + lokiProvider, + ]); + mocks.inspectService.mockImplementation(async () => ({ + Version: { Index: "7" }, + Spec: { + TaskTemplate: { + ForceUpdate: 2, + ContainerSpec: mocks.createService.mock.calls[0]?.[0] + ? undefined + : undefined, + }, + }, + })); + + await reconcileVectorAgent(); + expect(mocks.killContainer).toHaveBeenCalledWith({ signal: "SIGHUP" }); + expect(mocks.listContainers).toHaveBeenCalledWith({ + filters: JSON.stringify({ + label: ["com.docker.swarm.service.name=dokploy-vector"], + }), + }); + const firstSpec = mocks.updateService.mock.calls[0]?.[0]; + mocks.inspectService.mockResolvedValue({ + Version: { Index: "8" }, + Spec: { TaskTemplate: { ...firstSpec.TaskTemplate, ForceUpdate: 3 } }, + }); + mocks.updateService.mockClear(); + + await reconcileVectorAgent(); + expect(mocks.updateService).toHaveBeenCalledWith( + expect.objectContaining({ + version: 8, + TaskTemplate: expect.objectContaining({ ForceUpdate: 3 }), + }), + ); + }); + + it("updates an existing Vector service instead of creating it", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-a", + telemetryProviderIds: ["lp-1"], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([ + lokiProvider, + ]); + mocks.inspectService.mockResolvedValue({ + Version: { Index: "7" }, + Spec: { TaskTemplate: { ForceUpdate: 2 } }, + }); + + await reconcileVectorAgent(); + + expect(mocks.createService).not.toHaveBeenCalled(); + expect(mocks.updateService).toHaveBeenCalledWith( + expect.objectContaining({ + version: 7, + TaskTemplate: expect.objectContaining({ ForceUpdate: 3 }), + }), + ); + }); +}); + +describe("removeVectorAgent", () => { + it("removes cAdvisor, then Vector, then the config directory", async () => { + await removeVectorAgent(); + + expect(mocks.getService.mock.calls.map((c) => c[0])).toEqual([ + "dokploy-cadvisor", + "dokploy-vector", + ]); + expect(commandsOf(mocks.execAsync)).toEqual([ + "rm -rf /local/dokploy/vector", + ]); + }); + + it("keeps going when cAdvisor cannot be removed", async () => { + mocks.removeService + .mockRejectedValueOnce(new Error("docker hiccup")) + .mockRejectedValueOnce(notFound()); + + await removeVectorAgent(); + + expect(mocks.removeService).toHaveBeenCalledTimes(2); + expect(commandsOf(mocks.execAsync)).toEqual([ + "rm -rf /local/dokploy/vector", + ]); + }); + + it("refuses a server without an SSH key before touching cAdvisor or Vector", async () => { + mocks.findServerById.mockResolvedValue({ + serverId: "server-1", + sshKeyId: null, + }); + + await expect(removeVectorAgent("server-1")).rejects.toThrow(/SSH key/); + expect(mocks.getService).not.toHaveBeenCalled(); + expect(mocks.execAsyncRemote).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/vector-status.test.ts b/apps/dokploy/__test__/logs-and-metrics/vector-status.test.ts new file mode 100644 index 00000000000..12131a7e727 --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/vector-status.test.ts @@ -0,0 +1,214 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + listTasks: vi.fn(), + inspectService: vi.fn(), + findServersByOrganizationForVectorAgent: vi.fn(), + getAccessibleServerIds: vi.fn(), + getWebServerSettings: vi.fn(), + findTelemetryProvidersByOrganization: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => ({ db: {} })); +vi.mock("@dokploy/server/constants", () => ({ + IS_CLOUD: false, + paths: () => ({ VECTOR_PATH: "/etc/dokploy/vector" }), +})); +vi.mock("@dokploy/server/services/server", () => ({ + findServerById: vi.fn(), + findServersByOrganizationForVectorAgent: + mocks.findServersByOrganizationForVectorAgent, + getAccessibleServerIds: mocks.getAccessibleServerIds, +})); +vi.mock("@dokploy/server/services/web-server-settings", () => ({ + getWebServerSettings: mocks.getWebServerSettings, +})); +vi.mock( + "@dokploy/server/services/logs-and-metrics/service", + async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@dokploy/server/services/logs-and-metrics/service") + >()), + findTelemetryProvidersByOrganization: + mocks.findTelemetryProvidersByOrganization, + }), +); +vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({ + getRemoteDocker: async () => ({ + listTasks: mocks.listTasks, + getService: () => ({ inspect: mocks.inspectService }), + }), +})); + +const { getVectorAgentTargets, withVectorTargetLock } = await import( + "@dokploy/server/setup/vector-setup" +); + +const session = { userId: "user-1", activeOrganizationId: "org-1" }; + +describe("getVectorAgentTargets", () => { + beforeEach(() => { + for (const mock of Object.values(mocks)) mock.mockReset(); + mocks.findServersByOrganizationForVectorAgent.mockResolvedValue([]); + mocks.inspectService.mockResolvedValue({}); + mocks.getAccessibleServerIds.mockResolvedValue(new Set()); + mocks.findTelemetryProvidersByOrganization.mockResolvedValue([ + { telemetryProviderId: "lp-1", enabled: true, signals: ["logs"] }, + { telemetryProviderId: "mp-1", enabled: true, signals: ["metrics"] }, + { telemetryProviderId: "mp-off", enabled: false, signals: ["metrics"] }, + ]); + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-1", + telemetryProviderIds: ["lp-1"], + }); + }); + + it("requires a running cAdvisor task when the target ships metrics", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-1", + telemetryProviderIds: ["mp-1"], + }); + mocks.listTasks.mockImplementation( + async ({ filters }: { filters: string }) => + JSON.parse(filters).service[0] === "dokploy-vector" + ? [{ Status: { State: "running" } }] + : [], + ); + + const [local] = await getVectorAgentTargets(session); + expect(local?.status).toBe("not-running"); + expect(mocks.listTasks).toHaveBeenCalledTimes(2); + }); + + it("reports running when both Vector and cAdvisor have a running task", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-1", + telemetryProviderIds: ["mp-1"], + }); + mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]); + + const [local] = await getVectorAgentTargets(session); + expect(local?.status).toBe("running"); + }); + + it("does not look at cAdvisor for a logs-only target", async () => { + mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]); + + await getVectorAgentTargets(session); + expect(mocks.listTasks).toHaveBeenCalledTimes(1); + }); + + it("does not look at cAdvisor when the only metrics provider is disabled", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-1", + telemetryProviderIds: ["lp-1", "mp-off"], + }); + mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]); + + const [local] = await getVectorAgentTargets(session); + expect(local?.status).toBe("running"); + expect(mocks.listTasks).toHaveBeenCalledTimes(1); + }); + + it("reports running only when a task of the service is running", async () => { + mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]); + + const [local] = await getVectorAgentTargets(session); + expect(local?.status).toBe("running"); + expect(mocks.listTasks).toHaveBeenCalledWith({ + filters: JSON.stringify({ + service: ["dokploy-vector"], + "desired-state": ["running"], + }), + }); + }); + + it("reports not-running when the service has no running task", async () => { + mocks.listTasks.mockResolvedValue([{ Status: { State: "pending" } }]); + + const [local] = await getVectorAgentTargets(session); + expect(local?.status).toBe("not-running"); + }); + + it("reports stopped when the service does not exist", async () => { + mocks.inspectService.mockRejectedValue( + Object.assign(new Error("not found"), { statusCode: 404 }), + ); + mocks.listTasks.mockResolvedValue([]); + + const [local] = await getVectorAgentTargets(session); + expect(local?.status).toBe("stopped"); + expect(mocks.listTasks).not.toHaveBeenCalled(); + }); + + it("reports unknown on any other docker error", async () => { + mocks.inspectService.mockRejectedValue(new Error("connect ECONNREFUSED")); + + const [local] = await getVectorAgentTargets(session); + expect(local?.status).toBe("unknown"); + }); + + it("reports an empty selection for an unowned local host, even with ids left by a deleted organization", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: null, + telemetryProviderIds: ["lp-from-deleted-org", "mp-from-deleted-org"], + }); + mocks.listTasks.mockResolvedValue([]); + + const [local] = await getVectorAgentTargets(session); + expect(local).toMatchObject({ + serverId: null, + telemetryProviderIds: [], + }); + }); + + it("hides servers the member cannot access", async () => { + mocks.listTasks.mockResolvedValue([]); + mocks.findServersByOrganizationForVectorAgent.mockResolvedValue([ + { + serverId: "server-1", + name: "one", + ipAddress: "1.1.1.1", + telemetryProviderIds: [], + }, + { + serverId: "server-2", + name: "two", + ipAddress: "2.2.2.2", + telemetryProviderIds: [], + }, + ]); + mocks.getAccessibleServerIds.mockResolvedValue(new Set(["server-2"])); + + const targets = await getVectorAgentTargets(session); + expect(targets.map((t) => t.serverId)).toEqual([null, "server-2"]); + }); +}); + +describe("withVectorTargetLock", () => { + it("runs operations on the same target one after another", async () => { + const events: string[] = []; + let releaseFirst = () => {}; + const first = withVectorTargetLock("server-1", async () => { + events.push("first:start"); + await new Promise((resolve) => { + releaseFirst = resolve; + }); + events.push("first:end"); + throw new Error("first failed"); + }); + const second = withVectorTargetLock("server-1", async () => { + events.push("second"); + }); + const other = withVectorTargetLock("server-2", async () => { + events.push("other"); + }); + + await other; + expect(events).toEqual(["first:start", "other"]); + releaseFirst(); + await expect(first).rejects.toThrow("first failed"); + await second; + expect(events).toEqual(["first:start", "other", "first:end", "second"]); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/vector-validate.test.ts b/apps/dokploy/__test__/logs-and-metrics/vector-validate.test.ts new file mode 100644 index 00000000000..612b782d3e3 --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/vector-validate.test.ts @@ -0,0 +1,197 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + buildVectorConfigYaml, + VECTOR_IMAGE, +} from "@dokploy/server/setup/vector-setup"; +import { describe, expect, it } from "vitest"; + +const dockerAvailable = (() => { + try { + execFileSync("docker", ["info"], { stdio: "ignore", timeout: 10_000 }); + return true; + } catch { + return false; + } +})(); + +const provider = (overrides: Record) => + ({ + apiKey: null, + apiSecret: null, + endpoint: null, + extraConfig: null, + enabled: true, + ...overrides, + }) as any; + +const logsOnly = [ + provider({ + telemetryProviderId: "loki1", + name: "loki", + providerType: "loki", + signals: ["logs"], + endpoint: "http://loki.example.com:3100", + apiKey: "Pa$5w0rd$HOME", + extraConfig: { username: "12345" }, + }), +]; + +const metricsOnly = [ + provider({ + telemetryProviderId: "prom1", + name: "prom", + providerType: "prometheus_remote_write", + signals: ["metrics"], + endpoint: "http://prom.example.com:9090/api/v1/write", + apiKey: "token", + extraConfig: { tenantId: "team-a" }, + }), + provider({ + telemetryProviderId: "nr1", + name: "new relic", + providerType: "new_relic", + signals: ["metrics"], + apiKey: "license", + extraConfig: { accountId: "123", region: "eu" }, + }), + provider({ + telemetryProviderId: "influx1", + name: "influx", + providerType: "influxdb", + signals: ["metrics"], + endpoint: "http://influx.example.com:8086", + apiKey: "token", + extraConfig: { org: "dokploy", bucket: "metrics" }, + }), +]; + +const both = [ + provider({ + telemetryProviderId: "dd1", + name: "datadog", + providerType: "datadog", + signals: ["logs", "metrics"], + apiKey: "dd-key", + }), + provider({ + telemetryProviderId: "cw1", + name: "cloudwatch", + providerType: "aws_cloudwatch", + signals: ["logs", "metrics"], + apiKey: "AKIAFAKE", + apiSecret: "secret", + extraConfig: { + region: "us-east-1", + logGroup: "/dokploy/logs", + metricsNamespace: "Dokploy", + }, + }), + provider({ + telemetryProviderId: "splunk1", + name: "splunk", + providerType: "splunk_hec", + signals: ["logs", "metrics"], + endpoint: "https://splunk.example.com:8088", + apiKey: "hec", + extraConfig: { index: "main", metricsIndex: "dokploy_metrics" }, + }), + provider({ + telemetryProviderId: "es1", + name: "elastic", + providerType: "elasticsearch", + signals: ["logs", "metrics"], + endpoint: "http://es.example.com:9200", + apiKey: "pw", + extraConfig: { username: "elastic" }, + }), + provider({ + telemetryProviderId: "bs1", + name: "better stack", + providerType: "betterstack", + signals: ["logs", "metrics"], + endpoint: "in.logs.betterstack.com", + apiKey: "token", + }), +]; + +const cases: Array<[string, Parameters[0]]> = [ + [ + "local logs only", + { + logProviders: logsOnly, + metricsProviders: [], + organizationId: "org_a", + serverName: "Dokploy Server (local)", + }, + ], + [ + "local metrics only", + { + logProviders: [], + metricsProviders: metricsOnly, + organizationId: "org_a", + serverName: "Dokploy Server (local)", + }, + ], + [ + "local every provider on both signals", + { + logProviders: [...logsOnly, ...both], + metricsProviders: [...metricsOnly, ...both], + organizationId: "org_a", + serverName: 'Dokploy "Server" $HOSTNAME (local)', + }, + ], + [ + "remote metrics only", + { logProviders: [], metricsProviders: metricsOnly, serverName: "edge-1" }, + ], + [ + "remote every provider on both signals", + { + logProviders: [...logsOnly, ...both], + metricsProviders: [...metricsOnly, ...both], + serverName: "edge-1", + }, + ], +]; + +describe.skipIf(!dockerAvailable)( + "vector validate on generated configs", + () => { + const dir = mkdtempSync(path.join(os.tmpdir(), "vector-validate-")); + + it.each(cases)( + "%s", + (name, input) => { + const file = path.join(dir, `${name.replace(/\W+/g, "-")}.yaml`); + writeFileSync(file, buildVectorConfigYaml(input)); + const output = execFileSync( + "docker", + [ + "run", + "--rm", + "-v", + "/var/run/docker.sock:/var/run/docker.sock:ro", + "-v", + `${file}:/etc/vector/vector.yaml:ro`, + VECTOR_IMAGE, + "validate", + "--skip-healthchecks", + "/etc/vector/vector.yaml", + ], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 120_000, + }, + ); + expect(output).toContain("Validated"); + }, + 150_000, + ); + }, +); diff --git a/apps/dokploy/__test__/logs-and-metrics/web-server-provider-ids.test.ts b/apps/dokploy/__test__/logs-and-metrics/web-server-provider-ids.test.ts new file mode 100644 index 00000000000..eaf7a2cecb3 --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/web-server-provider-ids.test.ts @@ -0,0 +1,86 @@ +import type { SQL } from "drizzle-orm"; +import { PgDialect } from "drizzle-orm/pg-core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + findFirst: vi.fn(), + updates: [] as Array<{ values: Record; where: unknown }>, + returning: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { webServerSettings: { findFirst: mocks.findFirst } }, + update: () => ({ + set: (values: Record) => ({ + where: (where: unknown) => { + mocks.updates.push({ values, where }); + return { returning: () => Promise.resolve(mocks.returning()) }; + }, + }), + }), + }, +})); + +const { releaseWebServerAgent, setWebServerProviderIds } = await import( + "@dokploy/server/services/web-server-settings" +); + +const render = (value: unknown) => new PgDialect().sqlToQuery(value as SQL).sql; + +beforeEach(() => { + mocks.updates = []; + mocks.findFirst.mockResolvedValue({ + id: "ws-1", + vectorAgentOrganizationId: "org-a", + }); + mocks.returning.mockReturnValue([{ id: "ws-1" }]); +}); + +describe("setWebServerProviderIds", () => { + it("claims the local agent for the organization when assigning providers", async () => { + const row = await setWebServerProviderIds("org-a", ["lp-1", "mp-1"]); + + expect(row).toEqual({ id: "ws-1" }); + const update = mocks.updates[0]; + expect(update?.values).toMatchObject({ + telemetryProviderIds: ["lp-1", "mp-1"], + vectorAgentOrganizationId: "org-a", + }); + expect(render(update?.where)).toBe( + '("webServerSettings"."id" = $1 and ("webServerSettings"."vectorAgentOrganizationId" is null or "webServerSettings"."vectorAgentOrganizationId" = $2))', + ); + }); + + it("releases the owner when the selection is cleared", async () => { + await setWebServerProviderIds("org-a", []); + + expect(mocks.updates[0]?.values).toMatchObject({ + telemetryProviderIds: [], + vectorAgentOrganizationId: null, + }); + }); + + it("returns null when another organization owns the local agent", async () => { + mocks.returning.mockReturnValue([]); + + await expect( + setWebServerProviderIds("org-b", ["lp-9"]), + ).resolves.toBeNull(); + }); +}); + +describe("releaseWebServerAgent", () => { + it("clears the selection and the owner only for the owning organization", async () => { + await releaseWebServerAgent("org-a"); + + const update = mocks.updates[0]; + expect(update?.values).toMatchObject({ + telemetryProviderIds: [], + vectorAgentOrganizationId: null, + }); + expect(render(update?.where)).toBe( + '"webServerSettings"."vectorAgentOrganizationId" = $1', + ); + }); +}); diff --git a/apps/dokploy/__test__/notifications/build-error-message.test.ts b/apps/dokploy/__test__/notifications/build-error-message.test.ts new file mode 100644 index 00000000000..6812f7a3871 --- /dev/null +++ b/apps/dokploy/__test__/notifications/build-error-message.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockExecAsync = vi.hoisted(() => vi.fn()); +const mockExecAsyncRemote = vi.hoisted(() => vi.fn()); +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: mockExecAsync, + execAsyncRemote: mockExecAsyncRemote, +})); + +import { + formatBuildLogTail, + getBuildErrorMessage, +} from "@dokploy/server/utils/notifications/build-error-message"; +import { ExecError } from "@dokploy/server/utils/process/ExecError"; + +// Synthetic key material, not a real key. +const SCRIPT = `set -e; echo "-----BEGIN OPENSSH PRIVATE KEY----- +c3ludGhldGljLXRlc3Qta2V5 +-----END OPENSSH PRIVATE KEY-----" > /tmp/id_rsa; nixpacks build /code`; +const LOG_PATH = "/etc/dokploy/logs/web-abc/web-abc-2026.log"; + +const buildFailure = () => + new ExecError(`Command execution failed: Command failed: ${SCRIPT}`, { + command: SCRIPT, + stdout: "", + stderr: "", + exitCode: 1, + }); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("formatBuildLogTail", () => { + it("keeps only the last lines of the log", () => { + const log = Array.from({ length: 50 }, (_, i) => `line ${i + 1}`).join( + "\n", + ); + const tail = formatBuildLogTail(log, 3); + expect(tail).toBe("line 48\nline 49\nline 50"); + }); + + it("ignores trailing blank lines", () => { + expect(formatBuildLogTail("a\nb\n\n\n", 2)).toBe("a\nb"); + }); +}); + +describe("getBuildErrorMessage", () => { + it("uses the build log, never the build script, for a failed build", async () => { + mockExecAsync.mockResolvedValue({ + stdout: + "src/index.ts:3:7 - error TS2322: Type 'number' is not assignable to type 'string'.\n❌ Nixpacks build failed\n", + stderr: "", + }); + + const message = await getBuildErrorMessage(buildFailure(), LOG_PATH); + + expect(message).toContain("error TS2322"); + expect(message).not.toContain("PRIVATE KEY"); + expect(message).not.toContain("c3ludGhldGljLXRlc3Qta2V5"); + expect(mockExecAsync).toHaveBeenCalledWith(`tail -n 30 ${LOG_PATH}`); + expect(mockExecAsyncRemote).not.toHaveBeenCalled(); + }); + + it("reads the log on the build server for remote builds", async () => { + mockExecAsyncRemote.mockResolvedValue({ + stdout: "build failed\n", + stderr: "", + }); + + const message = await getBuildErrorMessage( + buildFailure(), + LOG_PATH, + "server-1", + ); + + expect(message).toBe("build failed"); + expect(mockExecAsyncRemote).toHaveBeenCalledWith( + "server-1", + `tail -n 30 ${LOG_PATH}`, + ); + }); + + it("falls back to a generic message when the log cannot be read", async () => { + mockExecAsync.mockRejectedValue(new Error("No such file")); + + const message = await getBuildErrorMessage(buildFailure(), LOG_PATH); + + expect(message).toBe("Error building"); + }); + + it("keeps the message of errors that are not build commands", async () => { + const message = await getBuildErrorMessage( + new Error("Repository not found"), + LOG_PATH, + ); + + expect(message).toBe("Repository not found"); + expect(mockExecAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/dokploy/__test__/permissions/check-permission.test.ts b/apps/dokploy/__test__/permissions/check-permission.test.ts index 58cbb90648f..9e0cf1a10ba 100644 --- a/apps/dokploy/__test__/permissions/check-permission.test.ts +++ b/apps/dokploy/__test__/permissions/check-permission.test.ts @@ -80,6 +80,20 @@ describe("owner and admin bypass enterprise resources", () => { ).resolves.toBeUndefined(); }); + it("owner bypasses telemetryProvider.read", async () => { + memberToReturn = mockMemberData("owner"); + await expect( + checkPermission(ctx, { telemetryProvider: ["read"] }), + ).resolves.toBeUndefined(); + }); + + it("admin bypasses telemetryProvider.create", async () => { + memberToReturn = mockMemberData("admin"); + await expect( + checkPermission(ctx, { telemetryProvider: ["create"] }), + ).resolves.toBeUndefined(); + }); + it("owner bypasses multiple enterprise permissions at once", async () => { memberToReturn = mockMemberData("owner"); await expect( @@ -160,6 +174,20 @@ describe("member is denied org-level enterprise resources (CVE: bypass via stati checkPermission(ctx, { registry: ["update"] }), ).rejects.toThrow(); }); + + it("member is denied telemetryProvider.read", async () => { + memberToReturn = mockMemberData("member"); + await expect( + checkPermission(ctx, { telemetryProvider: ["read"] }), + ).rejects.toThrow(); + }); + + it("member is denied telemetryProvider.create", async () => { + memberToReturn = mockMemberData("member"); + await expect( + checkPermission(ctx, { telemetryProvider: ["create"] }), + ).rejects.toThrow(); + }); }); describe("static roles validate free-tier resources", () => { diff --git a/apps/dokploy/__test__/permissions/cross-org-scope.test.ts b/apps/dokploy/__test__/permissions/cross-org-scope.test.ts new file mode 100644 index 00000000000..2027ffbe032 --- /dev/null +++ b/apps/dokploy/__test__/permissions/cross-org-scope.test.ts @@ -0,0 +1,188 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockMemberData = ( + role: string, + accessedServices: string[] = [], + accessedProjects: string[] = [], + accessedEnvironments: string[] = [], +) => ({ + id: "member-1", + role, + userId: "user-1", + organizationId: "org-a", + accessedProjects, + accessedServices, + accessedEnvironments, + canCreateProjects: false, + canDeleteProjects: false, + canCreateServices: false, + canDeleteServices: false, + canCreateEnvironments: false, + canDeleteEnvironments: false, + canAccessToTraefikFiles: false, + canAccessToDocker: false, + canAccessToAPI: false, + canAccessToSSHKeys: false, + canAccessToGitProviders: false, + user: { id: "user-1", email: "test@test.com" }, +}); + +let memberToReturn: ReturnType = mockMemberData("owner"); + +// Two organizations; every id encodes the org that owns it. +const serviceOrgs: Record = { + "app-a": "org-a", + "app-b": "org-b", + "postgres-b": "org-b", +}; +const projectOrgs: Record = { + "project-a": "org-a", + "project-b": "org-b", +}; +const environmentOrgs: Record = { + "env-a": "org-a", + "env-b": "org-b", +}; + +// Pulls the bound id out of a drizzle eq() expression. +const idFromWhere = (where: unknown) => { + const chunks = (where as { queryChunks?: unknown[] })?.queryChunks ?? []; + const param = chunks.find( + (c) => typeof (c as { value?: unknown })?.value === "string", + ) as { value: string } | undefined; + return param?.value; +}; + +vi.mock("@dokploy/server/db", () => { + const selectChain = { + from: () => selectChain, + innerJoin: () => selectChain, + where: (where: unknown) => { + const id = idFromWhere(where); + const organizationId = id ? serviceOrgs[id] : undefined; + return Promise.resolve(organizationId ? [{ organizationId }] : []); + }, + }; + return { + db: { + select: vi.fn(() => selectChain), + query: { + member: { + findFirst: vi.fn(() => Promise.resolve(memberToReturn)), + findMany: vi.fn(() => Promise.resolve([])), + }, + organizationRole: { + findFirst: vi.fn(), + findMany: vi.fn(() => Promise.resolve([])), + }, + projects: { + findFirst: vi.fn(({ where }: { where: unknown }) => { + const id = idFromWhere(where); + const organizationId = id ? projectOrgs[id] : undefined; + return Promise.resolve( + organizationId ? { organizationId } : undefined, + ); + }), + }, + environments: { + findFirst: vi.fn(({ where }: { where: unknown }) => { + const id = idFromWhere(where); + const organizationId = id ? environmentOrgs[id] : undefined; + return Promise.resolve( + organizationId ? { project: { organizationId } } : undefined, + ); + }), + }, + }, + }, + }; +}); + +vi.mock("@dokploy/server/services/proprietary/license-key", () => ({ + hasValidLicense: vi.fn(() => Promise.resolve(false)), +})); + +const { + checkEnvironmentCreationPermission, + checkServiceAccess, + checkServicePermissionAndAccess, +} = await import("@dokploy/server/services/permission"); + +const ctx = { + user: { id: "user-1" }, + session: { activeOrganizationId: "org-a" }, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("organization scope of service helpers", () => { + it("admin of org A cannot pass checkServicePermissionAndAccess for a service of org B", async () => { + memberToReturn = mockMemberData("admin"); + await expect( + checkServicePermissionAndAccess(ctx, "app-b", { envVars: ["write"] }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + }); + + it("owner of org A cannot pass checkServiceAccess for a database of org B", async () => { + memberToReturn = mockMemberData("owner"); + await expect( + checkServiceAccess(ctx, "postgres-b", "read"), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + await expect( + checkServiceAccess(ctx, "postgres-b", "delete"), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + }); + + it("owner of org A cannot create a service in a project of org B", async () => { + memberToReturn = mockMemberData("owner"); + await expect( + checkServiceAccess(ctx, "project-b", "create"), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + }); + + it("owner of org A cannot create an environment in a project of org B", async () => { + memberToReturn = mockMemberData("owner"); + await expect( + checkEnvironmentCreationPermission(ctx, "project-b"), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + await expect( + checkEnvironmentCreationPermission(ctx, "project-a"), + ).resolves.toBeUndefined(); + }); + + it("member of org A with a stale access entry for an org B service is rejected", async () => { + memberToReturn = mockMemberData("member", ["app-b"]); + await expect( + checkServicePermissionAndAccess(ctx, "app-b", { deployment: ["read"] }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + }); + + it("unknown service id is NOT_FOUND", async () => { + memberToReturn = mockMemberData("owner"); + await expect( + checkServicePermissionAndAccess(ctx, "missing", { deployment: ["read"] }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + it("owner and admin still pass for services and projects of their own org", async () => { + memberToReturn = mockMemberData("admin"); + await expect( + checkServicePermissionAndAccess(ctx, "app-a", { envVars: ["write"] }), + ).resolves.toBeUndefined(); + await expect( + checkServiceAccess(ctx, "app-a", "read"), + ).resolves.toBeUndefined(); + await expect( + checkServiceAccess(ctx, "project-a", "create"), + ).resolves.toBeUndefined(); + }); + + it("member with access still passes for a service of their own org", async () => { + memberToReturn = mockMemberData("member", ["app-a"]); + await expect( + checkServicePermissionAndAccess(ctx, "app-a", { deployment: ["read"] }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts b/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts index 970c24d0783..4849a0ba77e 100644 --- a/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts +++ b/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts @@ -18,6 +18,7 @@ const FREE_TIER_RESOURCES = [ "gitProviders", "traefikFiles", "api", + "telemetryProvider", ]; const ENTERPRISE_RESOURCES = [ @@ -41,6 +42,7 @@ const ENTERPRISE_RESOURCES = [ "auditLog", "vaultProvider", "dnsProvider", + "ai", ]; describe("enterpriseOnlyResources set", () => { diff --git a/apps/dokploy/__test__/permissions/resolve-permissions.test.ts b/apps/dokploy/__test__/permissions/resolve-permissions.test.ts index 4d64ff08a20..cb172d6d6a9 100644 --- a/apps/dokploy/__test__/permissions/resolve-permissions.test.ts +++ b/apps/dokploy/__test__/permissions/resolve-permissions.test.ts @@ -114,6 +114,7 @@ describe("enterprise resources for static roles", () => { expect(perms.destination.read).toBe(false); expect(perms.notification.read).toBe(false); expect(perms.auditLog.read).toBe(false); + expect(perms.telemetryProvider.read).toBe(false); }); }); diff --git a/apps/dokploy/__test__/permissions/service-access.test.ts b/apps/dokploy/__test__/permissions/service-access.test.ts index 57f9506adac..db8e12e7ab7 100644 --- a/apps/dokploy/__test__/permissions/service-access.test.ts +++ b/apps/dokploy/__test__/permissions/service-access.test.ts @@ -30,55 +30,38 @@ let memberToReturn: ReturnType = mockMemberData("member"); let serviceOrganizationToReturn: string | null = "org-1"; -const serviceRecord = () => - serviceOrganizationToReturn - ? { - environment: { - project: { - organizationId: serviceOrganizationToReturn, - }, +// Every project id used below belongs to the caller's org-1; services belong +// to serviceOrganizationToReturn. +vi.mock("@dokploy/server/db", () => { + const selectChain = { + from: () => selectChain, + innerJoin: () => selectChain, + where: () => + Promise.resolve( + serviceOrganizationToReturn + ? [{ organizationId: serviceOrganizationToReturn }] + : [], + ), + }; + return { + db: { + select: vi.fn(() => selectChain), + query: { + member: { + findFirst: vi.fn(() => Promise.resolve(memberToReturn)), + findMany: vi.fn(() => Promise.resolve([])), + }, + organizationRole: { + findFirst: vi.fn(), + findMany: vi.fn(() => Promise.resolve([])), + }, + projects: { + findFirst: vi.fn(() => Promise.resolve({ organizationId: "org-1" })), }, - } - : null; - -vi.mock("@dokploy/server/db", () => ({ - db: { - query: { - applications: { - findFirst: vi.fn(() => Promise.resolve(serviceRecord())), - }, - compose: { - findFirst: vi.fn(() => Promise.resolve(null)), - }, - postgres: { - findFirst: vi.fn(() => Promise.resolve(null)), - }, - mysql: { - findFirst: vi.fn(() => Promise.resolve(null)), - }, - mariadb: { - findFirst: vi.fn(() => Promise.resolve(null)), - }, - mongo: { - findFirst: vi.fn(() => Promise.resolve(null)), - }, - redis: { - findFirst: vi.fn(() => Promise.resolve(null)), - }, - libsql: { - findFirst: vi.fn(() => Promise.resolve(null)), - }, - member: { - findFirst: vi.fn(() => Promise.resolve(memberToReturn)), - findMany: vi.fn(() => Promise.resolve([])), - }, - organizationRole: { - findFirst: vi.fn(), - findMany: vi.fn(() => Promise.resolve([])), }, }, - }, -})); + }; +}); vi.mock("@dokploy/server/services/proprietary/license-key", () => ({ hasValidLicense: vi.fn(() => Promise.resolve(false)), diff --git a/apps/dokploy/__test__/placement/deploy-target-boundary.test.ts b/apps/dokploy/__test__/placement/deploy-target-boundary.test.ts index 110c23bb764..461e5840cb9 100644 --- a/apps/dokploy/__test__/placement/deploy-target-boundary.test.ts +++ b/apps/dokploy/__test__/placement/deploy-target-boundary.test.ts @@ -1,4 +1,5 @@ import { REDACTED_SECRET_VALUE } from "@dokploy/server/utils/security/redaction"; +import { TRPCError } from "@trpc/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; const serverMocks = vi.hoisted(() => ({ @@ -271,6 +272,22 @@ const environment = ( project: project(projectId, organizationId), }); +// Members hold no AI permission unless an enterprise custom role grants it. +const denyAiPermissionToMembers = () => + permissionMocks.checkPermission.mockImplementationOnce( + async ( + ctx: { user: { role: string } }, + permissions: Record, + ) => { + if (ctx.user.role === "member" && "ai" in permissions) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Permission denied", + }); + } + }, + ); + const createContext = (role = "owner") => ({ db: {}, @@ -494,7 +511,9 @@ describe("deploy target placement ownership boundary", () => { expect(permissionMocks.addNewService).not.toHaveBeenCalled(); }); - it("requires admin boundary before AI suggestions can spend provider credentials", async () => { + it("requires the ai.read permission before AI suggestions can spend provider credentials", async () => { + denyAiPermissionToMembers(); + await expect( aiRouter.createCaller(createContext("member")).suggest({ aiId: "ai-1", @@ -503,10 +522,16 @@ describe("deploy target placement ownership boundary", () => { }), ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + expect(permissionMocks.checkPermission).toHaveBeenCalledWith( + expect.anything(), + { ai: ["read"] }, + ); expect(aiServiceMocks.suggestVariants).not.toHaveBeenCalled(); }); - it("requires admin boundary before log analysis can spend provider credentials", async () => { + it("requires the ai.read permission before log analysis can spend provider credentials", async () => { + denyAiPermissionToMembers(); + await expect( aiRouter.createCaller(createContext("member")).analyzeLogs({ aiId: "ai-1", @@ -515,6 +540,10 @@ describe("deploy target placement ownership boundary", () => { }), ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + expect(permissionMocks.checkPermission).toHaveBeenCalledWith( + expect.anything(), + { ai: ["read"] }, + ); expect(aiServiceMocks.getAiSettingById).not.toHaveBeenCalled(); }); diff --git a/apps/dokploy/__test__/process/exec-error-secrets.test.ts b/apps/dokploy/__test__/process/exec-error-secrets.test.ts new file mode 100644 index 00000000000..64058ecafa8 --- /dev/null +++ b/apps/dokploy/__test__/process/exec-error-secrets.test.ts @@ -0,0 +1,17 @@ +import { inspect } from "node:util"; +import { execAsync, ExecError } from "@dokploy/server/utils/process/execAsync"; +import { describe, expect, it } from "vitest"; + +describe("execAsync failure", () => { + it("does not leak secrets embedded in the command", async () => { + const secret = "s3cr3t-value"; + const error = await execAsync(`printf %s '${secret}' >/dev/null; exit 3`) + .then(() => null) + .catch((e) => e); + + expect(error).toBeInstanceOf(ExecError); + expect(error.exitCode).toBe(3); + expect(error.message).not.toContain(secret); + expect(inspect(error)).not.toContain(secret); + }); +}); diff --git a/apps/dokploy/__test__/registry/docker-login.test.ts b/apps/dokploy/__test__/registry/docker-login.test.ts new file mode 100644 index 00000000000..7ca9c46ec60 --- /dev/null +++ b/apps/dokploy/__test__/registry/docker-login.test.ts @@ -0,0 +1,205 @@ +import { EventEmitter } from "node:events"; +import { Duplex, PassThrough, Writable } from "node:stream"; +import { inspect } from "node:util"; +import { dockerLogin } from "@dokploy/server/services/registry"; +import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; +import { parse } from "shell-quote"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + exec: vi.fn(), + execFile: vi.fn(), + remoteExec: vi.fn(), + end: vi.fn(), +})); + +vi.mock("node:child_process", async () => { + const { promisify } = await import("node:util"); + return { + exec: Object.assign(mocks.exec, { + [promisify.custom]: (command: string) => + new Promise((resolve, reject) => { + mocks.exec( + command, + (error: Error | null, stdout: string, stderr: string) => { + if (error) reject(Object.assign(error, { stdout, stderr })); + else resolve({ stdout, stderr }); + }, + ); + }), + }), + execFile: mocks.execFile, + }; +}); +vi.mock("@dokploy/server/services/server", () => ({ + findServerById: vi.fn(async () => ({ sshKeyId: "test-key" })), +})); +vi.mock("ssh2", async () => { + const { EventEmitter } = await import("node:events"); + return { + Client: class extends EventEmitter { + exec = mocks.remoteExec; + end = mocks.end; + connect() { + queueMicrotask(() => this.emit("ready")); + return this; + } + }, + }; +}); + +const password = "fixture'\";$(false)\nsecret"; +const registryUrl = "registry.example.com; false"; +const username = "user'$(false)"; +let localInput: string; +let remoteInput: string; +let localStdin: Writable; +let remoteStream: Duplex & { stderr: PassThrough }; +let exitCode: number; +let stdout: string; +let stderr: string; + +beforeEach(() => { + vi.clearAllMocks(); + localInput = ""; + remoteInput = ""; + exitCode = 0; + stdout = "Login Succeeded"; + stderr = ""; + + mocks.exec.mockImplementation((command, ...args) => { + const callback = args.at(-1); + queueMicrotask(() => { + callback( + exitCode === 0 + ? null + : Object.assign(new Error(`Command failed: ${command}`), { + code: exitCode, + }), + stdout, + stderr, + ); + }); + }); + mocks.execFile.mockImplementation(() => { + const child = Object.assign(new EventEmitter(), { + stdout: new PassThrough(), + stderr: new PassThrough(), + stdin: new Writable({ + write(chunk, _encoding, callback) { + localInput += chunk.toString(); + callback(); + }, + }), + }); + localStdin = child.stdin; + queueMicrotask(() => { + child.stdout.write(stdout); + child.stderr.write(stderr); + child.emit("close", exitCode); + }); + return child; + }); + mocks.remoteExec.mockImplementation((_command, callback) => { + remoteStream = Object.assign( + new Duplex({ + read() {}, + write(chunk, _encoding, done) { + remoteInput += chunk.toString(); + done(); + }, + }), + { stderr: new PassThrough() }, + ); + callback(null, remoteStream); + queueMicrotask(() => { + remoteStream.emit("data", stdout); + remoteStream.stderr.write(stderr); + remoteStream.emit("close", exitCode); + }); + }); +}); + +describe("docker login credentials", () => { + it("passes local credentials only on stdin, without a shell", async () => { + await dockerLogin(registryUrl, username, password); + + expect(mocks.exec).not.toHaveBeenCalled(); + expect(mocks.execFile).toHaveBeenCalledExactlyOnceWith("docker", [ + "login", + registryUrl, + "--username", + username, + "--password-stdin", + ]); + expect(localInput).toBe(password); + expect(localStdin.writableEnded).toBe(true); + }); + + it("escapes remote arguments and sends the password only on stream stdin", async () => { + await dockerLogin(registryUrl, username, password, "test-server"); + + const command = mocks.remoteExec.mock.calls[0]?.[0]; + expect(command).not.toContain(password); + expect(parse(command)).toEqual([ + "docker", + "login", + registryUrl, + "--username", + username, + "--password-stdin", + ]); + expect(remoteInput).toBe(password); + expect(remoteStream.writableEnded).toBe(true); + expect(mocks.end).toHaveBeenCalledOnce(); + }); + + it("ends local stdin even for an empty password", async () => { + await dockerLogin("", "user", ""); + + expect(localInput).toBe(""); + expect(localStdin.writableEnded).toBe(true); + }); + + it("keeps remote execution without input unchanged", async () => { + const onData = vi.fn(); + stderr = "warning"; + + await expect( + execAsyncRemote("test-server", "echo hello", onData), + ).resolves.toEqual({ + stdout, + stderr, + }); + expect(remoteStream.writableEnded).toBe(false); + expect(onData.mock.calls).toEqual([[stdout], [stderr]]); + }); + + it.each([undefined, "test-server"])( + "does not retain passwords echoed by a failed login on %s", + async (serverId) => { + exitCode = 1; + stdout = `stdout: ${password}`; + stderr = `denied: ${password}`; + + const error = await dockerLogin( + registryUrl, + username, + password, + serverId, + ).catch((error: unknown) => error); + + expect(error).toBeInstanceOf(Error); + expect(error).toHaveProperty( + "message", + expect.not.stringContaining(password), + ); + expect(inspect(error, { depth: null })).not.toContain(password); + expect(JSON.stringify(error)).not.toContain( + JSON.stringify(password).slice(1, -1), + ); + expect(error).not.toHaveProperty("originalError"); + expect(error).not.toHaveProperty("cause"); + }, + ); +}); diff --git a/apps/dokploy/__test__/registry/registry-router-security.test.ts b/apps/dokploy/__test__/registry/registry-router-security.test.ts index 291d6ad9440..ec8f9106687 100644 --- a/apps/dokploy/__test__/registry/registry-router-security.test.ts +++ b/apps/dokploy/__test__/registry/registry-router-security.test.ts @@ -2,39 +2,25 @@ import { REDACTED_SECRET_VALUE } from "@dokploy/server/utils/security/redaction" import { TRPCError } from "@trpc/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const escapeShell = (value: string | undefined) => - value ? `'${value.replace(/'/g, `'\\''`)}'` : "''"; - const mocks = vi.hoisted(() => ({ checkPermission: vi.fn(), createRegistry: vi.fn(), - execAsyncRemote: vi.fn(), - execFileAsync: vi.fn(), + dockerLogin: vi.fn(), findRegistryById: vi.fn(), findRegistryMany: vi.fn(), findRegistryRecord: vi.fn(), getAccessibleServerIds: vi.fn(), removeRegistry: vi.fn(), updateRegistry: vi.fn(), - safeDockerLoginCommand: vi.fn( - ( - registry: string | undefined, - username: string | undefined, - password: string | undefined, - ) => - `printf %s ${escapeShell(password)} | docker login ${escapeShell(registry)} -u ${escapeShell(username)} --password-stdin`, - ), })); vi.mock("@dokploy/server", () => ({ IS_CLOUD: false, createRegistry: mocks.createRegistry, - execAsyncRemote: mocks.execAsyncRemote, - execFileAsync: mocks.execFileAsync, + dockerLogin: mocks.dockerLogin, findRegistryById: mocks.findRegistryById, getAccessibleServerIds: mocks.getAccessibleServerIds, removeRegistry: mocks.removeRegistry, - safeDockerLoginCommand: mocks.safeDockerLoginCommand, updateRegistry: mocks.updateRegistry, })); @@ -84,8 +70,7 @@ describe("registry router remote test login boundary", () => { beforeEach(() => { vi.clearAllMocks(); mocks.checkPermission.mockResolvedValue(undefined); - mocks.execAsyncRemote.mockResolvedValue(""); - mocks.execFileAsync.mockResolvedValue(""); + mocks.dockerLogin.mockResolvedValue(undefined); mocks.findRegistryMany.mockResolvedValue([]); mocks.getAccessibleServerIds.mockResolvedValue(new Set(["server-1"])); }); @@ -202,7 +187,7 @@ describe("registry router remote test login boundary", () => { ); }); - it("tests ad-hoc remote registry credentials with a shell-escaped docker login command", async () => { + it("tests ad-hoc remote registry credentials through the stdin docker login", async () => { await expect( createCaller().testRegistry({ registryName: "registry", @@ -214,23 +199,15 @@ describe("registry router remote test login boundary", () => { }), ).resolves.toBe(true); - const expectedCommand = mocks.safeDockerLoginCommand( - "registry.example.com", - normalizedDangerousUsername, - dangerousPassword, - ); - expect(mocks.getAccessibleServerIds).toHaveBeenCalledWith({ userId: "user-1", activeOrganizationId: "org-1", }); - expect(mocks.execAsyncRemote).toHaveBeenCalledWith( + expect(mocks.dockerLogin).toHaveBeenCalledWith( + "registry.example.com", + normalizedDangerousUsername, + dangerousPassword, "server-1", - expectedCommand, - ); - expect(mocks.execAsyncRemote.mock.calls[0]?.[1]).toContain("printf %s"); - expect(mocks.execAsyncRemote.mock.calls[0]?.[1]).not.toContain( - `echo ${dangerousPassword}`, ); }); @@ -248,11 +225,10 @@ describe("registry router remote test login boundary", () => { }), ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); - expect(mocks.execAsyncRemote).not.toHaveBeenCalled(); - expect(mocks.execFileAsync).not.toHaveBeenCalled(); + expect(mocks.dockerLogin).not.toHaveBeenCalled(); }); - it("keeps local ad-hoc registry tests on docker argv with password stdin", async () => { + it("keeps local ad-hoc registry tests on the local docker login", async () => { await expect( createCaller().testRegistry({ registryName: "registry", @@ -265,24 +241,16 @@ describe("registry router remote test login boundary", () => { ).resolves.toBe(true); expect(mocks.getAccessibleServerIds).not.toHaveBeenCalled(); - expect(mocks.execAsyncRemote).not.toHaveBeenCalled(); - expect(mocks.execFileAsync).toHaveBeenCalledWith( - "docker", - [ - "login", - "registry.example.com", - "--username", - normalizedDangerousUsername, - "--password-stdin", - ], - { - input: dangerousPassword, - }, + expect(mocks.dockerLogin).toHaveBeenCalledWith( + "registry.example.com", + normalizedDangerousUsername, + dangerousPassword, + "none", ); }); it("redacts registry test passwords from wrapped login errors", async () => { - mocks.execAsyncRemote.mockRejectedValue( + mocks.dockerLogin.mockRejectedValue( new Error(`docker login failed for ${dangerousPassword}`), ); @@ -301,7 +269,7 @@ describe("registry router remote test login boundary", () => { }); }); - it("tests stored remote registry credentials with the same safe command builder", async () => { + it("tests stored remote registry credentials through the same stdin docker login", async () => { mocks.findRegistryRecord.mockResolvedValue({ registryId: "registry-1", registryName: "registry", @@ -319,20 +287,15 @@ describe("registry router remote test login boundary", () => { }), ).resolves.toBe(true); - expect(mocks.execAsyncRemote).toHaveBeenCalledWith( + expect(mocks.dockerLogin).toHaveBeenCalledWith( + "registry.example.com", + dangerousUsername, + dangerousPassword, "server-1", - mocks.safeDockerLoginCommand( - "registry.example.com", - dangerousUsername, - dangerousPassword, - ), ); expect(mocks.checkPermission).toHaveBeenCalledWith(expect.anything(), { server: ["execute"], }); - expect(mocks.execAsyncRemote.mock.calls[0]?.[1]).not.toContain( - `echo ${dangerousPassword}`, - ); }); it("requires server execute before loading stored credentials for remote registry tests", async () => { @@ -358,8 +321,7 @@ describe("registry router remote test login boundary", () => { server: ["execute"], }); expect(mocks.findRegistryRecord).not.toHaveBeenCalled(); - expect(mocks.execAsyncRemote).not.toHaveBeenCalled(); - expect(mocks.execFileAsync).not.toHaveBeenCalled(); + expect(mocks.dockerLogin).not.toHaveBeenCalled(); }); it("does not require server execute for local stored registry tests", async () => { @@ -384,19 +346,11 @@ describe("registry router remote test login boundary", () => { server: ["execute"], }); expect(mocks.getAccessibleServerIds).not.toHaveBeenCalled(); - expect(mocks.execAsyncRemote).not.toHaveBeenCalled(); - expect(mocks.execFileAsync).toHaveBeenCalledWith( - "docker", - [ - "login", - "registry.example.com", - "--username", - dangerousUsername, - "--password-stdin", - ], - { - input: dangerousPassword, - }, + expect(mocks.dockerLogin).toHaveBeenCalledWith( + "registry.example.com", + dangerousUsername, + dangerousPassword, + "none", ); }); @@ -420,8 +374,7 @@ describe("registry router remote test login boundary", () => { registry: ["create"], }); expect(mocks.findRegistryRecord).not.toHaveBeenCalled(); - expect(mocks.execAsyncRemote).not.toHaveBeenCalled(); - expect(mocks.execFileAsync).not.toHaveBeenCalled(); + expect(mocks.dockerLogin).not.toHaveBeenCalled(); }); it("rejects inaccessible stored remote registry tests before remote execution", async () => { @@ -443,7 +396,6 @@ describe("registry router remote test login boundary", () => { }), ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); - expect(mocks.execAsyncRemote).not.toHaveBeenCalled(); - expect(mocks.execFileAsync).not.toHaveBeenCalled(); + expect(mocks.dockerLogin).not.toHaveBeenCalled(); }); }); diff --git a/apps/dokploy/__test__/registry/registry-stored-password-binding.test.ts b/apps/dokploy/__test__/registry/registry-stored-password-binding.test.ts index f508b98e33d..37ed903e2be 100644 --- a/apps/dokploy/__test__/registry/registry-stored-password-binding.test.ts +++ b/apps/dokploy/__test__/registry/registry-stored-password-binding.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ execAsync: vi.fn(), execAsyncRemote: vi.fn(), + execFileAsync: vi.fn(), findFirst: vi.fn(), returning: vi.fn(), set: vi.fn(), @@ -25,6 +26,7 @@ vi.mock("@dokploy/server/db", () => ({ vi.mock("@dokploy/server/utils/process/execAsync", () => ({ execAsync: mocks.execAsync, execAsyncRemote: mocks.execAsyncRemote, + execFileAsync: mocks.execFileAsync, })); const { updateRegistry } = await import("@dokploy/server/services/registry"); @@ -52,6 +54,7 @@ describe("registry update stored password binding", () => { { ...storedRegistry, password: "stored-password" }, ]); mocks.execAsync.mockResolvedValue({ stdout: "", stderr: "" }); + mocks.execFileAsync.mockResolvedValue({ stdout: "", stderr: "" }); }); it.each([ @@ -75,6 +78,7 @@ describe("registry update stored password binding", () => { expect(mocks.update).not.toHaveBeenCalled(); expect(mocks.execAsync).not.toHaveBeenCalled(); expect(mocks.execAsyncRemote).not.toHaveBeenCalled(); + expect(mocks.execFileAsync).not.toHaveBeenCalled(); await updateRegistry("registry-1", { ...change, diff --git a/apps/dokploy/__test__/server/component-versions-sync.test.ts b/apps/dokploy/__test__/server/component-versions-sync.test.ts index 537d97959e5..d1148c7a5b9 100644 --- a/apps/dokploy/__test__/server/component-versions-sync.test.ts +++ b/apps/dokploy/__test__/server/component-versions-sync.test.ts @@ -154,7 +154,7 @@ describe("Node.js image and CI version", () => { ]; it.each(dockerfiles)("%s builds on the pinned Node image", (file) => { - expect(pinIn(read(file), /FROM node:(\S+)-slim AS base/)).toBe( + expect(pinIn(read(file), /FROM node:([\d.]+)-bookworm-slim AS base/)).toBe( PINNED_VERSIONS.node, ); }); diff --git a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts index d1a03fedc0b..2372df11fee 100644 --- a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts +++ b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts @@ -25,6 +25,8 @@ const baseSettings: WebServerSettings = { letsEncryptEmail: null, sshPrivateKey: null, enableDockerCleanup: false, + telemetryProviderIds: [], + vectorAgentOrganizationId: null, buildsConcurrency: 1, logCleanupCron: null, metricsConfig: { @@ -62,6 +64,10 @@ const baseSettings: WebServerSettings = { errorPageDescription: null, ogImageUrl: null, footerText: null, + logoLightUrl: null, + loginLogoLightUrl: null, + faviconLightUrl: null, + dualModeBranding: false, }, cleanupCacheApplications: false, cleanupCacheOnCompose: false, diff --git a/apps/dokploy/__test__/traefik/traefik-files-server-organization.test.ts b/apps/dokploy/__test__/traefik/traefik-files-server-organization.test.ts new file mode 100644 index 00000000000..57649f872ed --- /dev/null +++ b/apps/dokploy/__test__/traefik/traefik-files-server-organization.test.ts @@ -0,0 +1,128 @@ +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// apiReadTraefikConfig validates against the real local paths(), which outside +// production live under the working directory. +const traefikPath = vi.hoisted(() => `${process.cwd()}/.docker/traefik`); + +const mocks = vi.hoisted(() => ({ + findServerById: vi.fn(), + getAccessibleServerIds: vi.fn(), + readDirectory: vi.fn(), + readConfigInPath: vi.fn(), + writeTraefikConfigInPath: vi.fn(), +})); + +vi.mock("@dokploy/server/services/permission", () => ({ + checkPermission: vi.fn(async () => undefined), + // The fork keeps the panel host's Traefik files to owners and admins. + findMemberByUserId: vi.fn(async () => ({ role: "admin" })), +})); + +vi.mock("@dokploy/server", async (importOriginal) => ({ + ...(await importOriginal()), + IS_CLOUD: false, + paths: () => ({ MAIN_TRAEFIK_PATH: traefikPath }), + ...mocks, +})); + +vi.mock("@dokploy/server/lib/auth", () => ({ + validateRequest: vi.fn(), +})); + +vi.mock("@/server/api/utils/audit", () => ({ + audit: vi.fn(async () => undefined), +})); + +vi.mock("@/server/queues/queueSetup", () => ({ + cleanAllDeploymentQueue: vi.fn(), +})); + +vi.mock("@/server/utils/backup", () => ({ + removeJob: vi.fn(), + schedule: vi.fn(), +})); + +const { settingsRouter } = await import("@/server/api/routers/settings"); + +const caller = settingsRouter.createCaller({ + session: { activeOrganizationId: "org-a" }, + user: { id: "user-1", email: "user@example.com", role: "admin" }, +} as Parameters[0]); + +const filePath = path.join(traefikPath, "dynamic/app.yml"); + +describe("traefik file endpoints scope remote servers to the active organization", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.findServerById.mockImplementation(async (serverId: string) => ({ + serverId, + organizationId: serverId === "server-a" ? "org-a" : "org-b", + })); + mocks.getAccessibleServerIds.mockResolvedValue(new Set(["server-a"])); + mocks.readDirectory.mockResolvedValue([]); + mocks.readConfigInPath.mockResolvedValue("http: {}"); + }); + + it("rejects listing a server from another organization", async () => { + await expect( + caller.readDirectories({ serverId: "server-b" }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + expect(mocks.readDirectory).not.toHaveBeenCalled(); + }); + + it("rejects writing to a server from another organization", async () => { + await expect( + caller.updateTraefikFile({ + path: filePath, + traefikConfig: "http: {}", + serverId: "server-b", + }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + expect(mocks.writeTraefikConfigInPath).not.toHaveBeenCalled(); + }); + + it("rejects reading from a server from another organization", async () => { + await expect( + caller.readTraefikFile({ path: filePath, serverId: "server-b" }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + expect(mocks.readConfigInPath).not.toHaveBeenCalled(); + }); + + it("allows servers of the active organization", async () => { + await caller.readDirectories({ serverId: "server-a" }); + await caller.readTraefikFile({ path: filePath, serverId: "server-a" }); + await caller.updateTraefikFile({ + path: filePath, + traefikConfig: "http: {}", + serverId: "server-a", + }); + + expect(mocks.readDirectory).toHaveBeenCalledWith(traefikPath, "server-a"); + expect(mocks.readConfigInPath).toHaveBeenCalledWith(filePath, "server-a", { + allowProtected: false, + }); + expect(mocks.writeTraefikConfigInPath).toHaveBeenCalledWith( + filePath, + "http: {}", + "server-a", + { allowProtected: false }, + ); + }); + + it("does not look up a server when none is given", async () => { + await caller.readDirectories({}); + await caller.updateTraefikFile({ + path: filePath, + traefikConfig: "http: {}", + }); + + expect(mocks.getAccessibleServerIds).not.toHaveBeenCalled(); + expect(mocks.writeTraefikConfigInPath).toHaveBeenCalledWith( + filePath, + "http: {}", + undefined, + { allowProtected: false }, + ); + }); +}); diff --git a/apps/dokploy/__test__/utils/remote-stream.test.ts b/apps/dokploy/__test__/utils/remote-stream.test.ts index d265f0ab998..8c3ea48626f 100644 --- a/apps/dokploy/__test__/utils/remote-stream.test.ts +++ b/apps/dokploy/__test__/utils/remote-stream.test.ts @@ -16,7 +16,7 @@ describe("pipeBetweenServers", () => { target: { serverId: null, command: "sleep 0.3; cat > /dev/null" }, }); expect(bytes).toBe(11); - }); + }, 30_000); it("pipes data larger than the pipe buffer unchanged", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pipe-")); @@ -47,7 +47,7 @@ describe("pipeBetweenServers", () => { target: { serverId: null, command: "echo boom >&2; exit 3" }, }), ).rejects.toThrow("target exited with code 3: boom"); - }); + }, 30_000); it("drains the source when the target exits before reading it", async () => { const size = 1024 * 1024; @@ -68,5 +68,5 @@ describe("pipeBetweenServers", () => { target: { serverId: null, command: "cat > /dev/null" }, }), ).rejects.toThrow("source exited with code 2"); - }); + }, 30_000); }); diff --git a/apps/dokploy/__test__/wss/authorize.test.ts b/apps/dokploy/__test__/wss/authorize.test.ts index 63fe1b9ac27..1c073c03330 100644 --- a/apps/dokploy/__test__/wss/authorize.test.ts +++ b/apps/dokploy/__test__/wss/authorize.test.ts @@ -18,6 +18,7 @@ vi.mock("@dokploy/server", () => ({ import { canAccessDockerOverWss, canAccessTerminalOverWss, + isServiceContainerName, } from "@/server/wss/authorize"; const USER = { id: "user-1" }; @@ -78,6 +79,42 @@ describe("canAccessDockerOverWss", () => { }); }); +describe("isServiceContainerName", () => { + it("matches an application or database only by its exact appName", () => { + expect(isServiceContainerName("web-abc", "application", "web-abc")).toBe( + true, + ); + expect(isServiceContainerName("other-xyz", "application", "web-abc")).toBe( + false, + ); + // Service access must not unlock the host-wide "dokploy" stats. + expect(isServiceContainerName("dokploy", "application", "web-abc")).toBe( + false, + ); + }); + + it("matches stack tasks inside the service's namespace only", () => { + expect( + isServiceContainerName("stack-abc_web.1.task", "stack", "stack-abc"), + ).toBe(true); + expect( + isServiceContainerName("stack-abcdef_web.1.task", "stack", "stack-abc"), + ).toBe(false); + }); + + it("leaves compose containers to the project label filter", () => { + expect( + isServiceContainerName("custom-name", "docker-compose", "compose-abc"), + ).toBe(true); + }); + + it("rejects an unknown appType", () => { + // The socket would apply no ownership filter for it. + expect(isServiceContainerName("web-abc", "invalid", "web-abc")).toBe(false); + expect(isServiceContainerName("web-abc", "", "web-abc")).toBe(false); + }); +}); + describe("canAccessTerminalOverWss", () => { it("denies the local host terminal to a plain member", async () => { mockFindMember.mockResolvedValue({ role: "member" }); diff --git a/apps/dokploy/__test__/wss/container-ownership.test.ts b/apps/dokploy/__test__/wss/container-ownership.test.ts new file mode 100644 index 00000000000..1dce706fc4a --- /dev/null +++ b/apps/dokploy/__test__/wss/container-ownership.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockRows = vi.hoisted(() => new Map()); +const mockExec = vi.hoisted(() => vi.fn()); +vi.mock("@dokploy/server/db", () => ({ + db: { + select: () => ({ + from: (table: unknown) => ({ + where: () => ({ + limit: async () => { + const row = mockRows.get(table); + return row ? [row] : []; + }, + }), + }), + }), + }, +})); +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: mockExec, + execAsyncRemote: mockExec, +})); + +import { applications, libsql } from "@dokploy/server/db/schema"; +import { containerBelongsToService } from "@/server/wss/container-ownership"; + +const labels = (l: Record) => ({ stdout: JSON.stringify(l) }); + +beforeEach(() => { + vi.clearAllMocks(); + mockRows.clear(); + mockRows.set(applications, { appName: "app-mine-abc123" }); +}); + +describe("containerBelongsToService", () => { + it("allows a container of the service", async () => { + mockExec.mockResolvedValue( + labels({ "com.docker.swarm.service.name": "app-mine-abc123" }), + ); + expect(await containerBelongsToService("svc", "c1")).toBe(true); + }); + + it("denies a container of another service", async () => { + mockExec.mockResolvedValue( + labels({ "com.docker.swarm.service.name": "dokploy" }), + ); + expect(await containerBelongsToService("svc", "c1")).toBe(false); + }); + + it("denies containers without labels or when docker fails", async () => { + mockExec.mockResolvedValue({ stdout: "null" }); + expect(await containerBelongsToService("svc", "c1")).toBe(false); + mockExec.mockRejectedValue(new Error("No such object")); + expect(await containerBelongsToService("svc", "c1")).toBe(false); + }); + + it("resolves libsql services", async () => { + mockRows.clear(); + mockRows.set(libsql, { appName: "libsql-abc" }); + mockExec.mockResolvedValue( + labels({ "com.docker.swarm.service.name": "libsql-abc" }), + ); + expect(await containerBelongsToService("svc", "c1")).toBe(true); + }); + + it("denies an unknown service", async () => { + mockRows.clear(); + expect(await containerBelongsToService("nope", "c1")).toBe(false); + }); +}); diff --git a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx index 4899300748e..3f6513b51e9 100644 --- a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx +++ b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx @@ -560,7 +560,10 @@ export const RestoreBackup = ({ value={field.value} onValueChange={(value: DatabaseType) => { field.onChange(value); - form.setValue("metadata", {}); + form.setValue("metadata", { + serviceName: + form.getValues("metadata.serviceName") ?? "", + }); }} > @@ -587,7 +590,7 @@ export const RestoreBackup = ({
+ + + Restrict this provider to a single DNS zone: Dokploy + will only list and change records in it. Leave empty to + use every zone the credentials can access. When set, the{" "} + GET /domain/zone right is not needed. This + is enforced by Dokploy only, so also limit the OVH token + to that zone if you need a hard guarantee. + + + + )} + /> )} diff --git a/apps/dokploy/components/dashboard/settings/handle-ai.tsx b/apps/dokploy/components/dashboard/settings/handle-ai.tsx index b7cf186e7a4..0cd73d82a1f 100644 --- a/apps/dokploy/components/dashboard/settings/handle-ai.tsx +++ b/apps/dokploy/components/dashboard/settings/handle-ai.tsx @@ -148,6 +148,7 @@ export const HandleAi = ({ aiId }: Props) => { { apiUrl: apiUrl ?? "", apiKey: apiKey ?? "", + ...(aiId ? { aiId } : {}), }, { enabled: !!apiUrl && (isLocalOllama || !!apiKey), @@ -482,6 +483,7 @@ export const HandleAi = ({ aiId }: Props) => { apiUrl={apiUrl} apiKey={apiKey} model={form.watch("model")} + aiId={aiId} /> + ) : ( + + )} + + + + + {telemetryProviderId ? "Edit provider" : "Add a provider"} + + + Vector ships container logs and host/container metrics from every + server you deploy it on to this provider. + + + {(isError || testIsError || testByIdIsError) && ( + + {testError?.message || testByIdError?.message || error?.message} + + )} + +
+ + ( + + Name + + + + + + )} + /> + ( + + Provider + + + + )} + /> + + {selectedType && ( + ( + + Sends + + + + )} + /> + )} + + {visibleFields.map((credentialField) => ( + ( + + + {credentialField.label} + {!credentialField.required && " (Optional)"} + + {credentialField.helpText && ( + + {credentialField.helpText} + + )} +
+ + + + {telemetryProviderId && + TOP_LEVEL_KEYS.has(credentialField.key) && + !credentialField.required && ( + + )} +
+ +
+ )} + /> + ))} + + ( + + + + + Enabled + + )} + /> + + + + + {isPartiallyTouched && ( + + You changed something that needs a fresh credential to test — fill + in every credential field to test with the new values, or revert + it to test with what's already saved. + + )} +
+ + +
+
+
+ + ); +}; diff --git a/apps/dokploy/components/dashboard/settings/logs-and-metrics/show-servers.tsx b/apps/dokploy/components/dashboard/settings/logs-and-metrics/show-servers.tsx new file mode 100644 index 00000000000..bc375a3d2dc --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/logs-and-metrics/show-servers.tsx @@ -0,0 +1,361 @@ +import { Copy, Loader2, Server } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { telemetryProviderIcons } from "@/components/icons/telemetry-provider-icons"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { api } from "@/utils/api"; + +type Status = "running" | "not-running" | "stopped" | "unknown"; + +const statusBadge = (status: Status) => { + if (status === "running") { + return Running; + } + if (status === "not-running") { + return Not running; + } + if (status === "stopped") { + return Not deployed; + } + return Unreachable; +}; + +export const providerLabel = (provider: { name: string; signals: string[] }) => + `${provider.name} (${provider.signals.length === 2 ? "logs and metrics" : provider.signals[0]})`; + +export const ShowServers = () => { + const utils = api.useUtils(); + const [selection, setSelection] = useState>({}); + const [pendingTarget, setPendingTarget] = useState(null); + const [errors, setErrors] = useState< + Record + >({}); + + const { data: providers } = api.telemetryProvider.all.useQuery(); + const { data: targets, isPending } = + api.telemetryProvider.serverStatus.useQuery(undefined, { + refetchOnWindowFocus: false, + staleTime: 30_000, + trpc: { context: { skipBatch: true } }, + }); + + const { mutateAsync: deployOnServer } = + api.telemetryProvider.deployOnServer.useMutation(); + const { mutateAsync: removeOnServer } = + api.telemetryProvider.removeOnServer.useMutation(); + + const keyOf = (serverId: string | null) => serverId ?? "local"; + + // The selection can still hold providers deleted since it was made. + const idsFor = (serverId: string | null, saved: string[]) => { + const ids = selection[keyOf(serverId)] ?? saved; + return providers + ? ids.filter((id) => providers.some((p) => p.telemetryProviderId === id)) + : ids; + }; + + const toggleProvider = ( + serverId: string | null, + saved: string[], + telemetryProviderId: string, + ) => { + const key = keyOf(serverId); + const current = idsFor(serverId, saved); + setSelection({ + ...selection, + [key]: current.includes(telemetryProviderId) + ? current.filter((id) => id !== telemetryProviderId) + : [...current, telemetryProviderId], + }); + }; + + const setError = ( + serverId: string | null, + error?: { title: string; message: string }, + ) => + setErrors((prev) => { + const next = { ...prev }; + if (error) { + next[keyOf(serverId)] = error; + } else { + delete next[keyOf(serverId)]; + } + return next; + }); + + // The task is still starting right after a deploy, so look again once it had time to come up. + const refreshStatus = () => { + utils.telemetryProvider.serverStatus.invalidate(); + setTimeout(() => utils.telemetryProvider.serverStatus.invalidate(), 8_000); + }; + + const handleDeploy = async (serverId: string | null, ids: string[]) => { + setPendingTarget(keyOf(serverId)); + setError(serverId); + await deployOnServer({ serverId, providerIds: ids }) + .then(() => { + toast.success("Vector agent deployed"); + refreshStatus(); + }) + .catch((error) => { + setError(serverId, { + title: "Deploy failed", + message: + error instanceof Error + ? error.message + : "Failed to deploy the agent", + }); + refreshStatus(); + }) + .finally(() => setPendingTarget(null)); + }; + + const handleRemove = async (serverId: string | null) => { + setPendingTarget(keyOf(serverId)); + setError(serverId); + await removeOnServer({ serverId }) + .then(() => { + toast.success("Vector agent removed"); + setSelection((prev) => ({ ...prev, [keyOf(serverId)]: [] })); + refreshStatus(); + }) + .catch((error) => { + setError(serverId, { + title: "Remove failed", + message: + error instanceof Error + ? error.message + : "Failed to remove the agent", + }); + refreshStatus(); + }) + .finally(() => setPendingTarget(null)); + }; + + const hasProviders = (providers?.length ?? 0) > 0; + const visibleTargets = hasProviders + ? targets + : targets?.filter( + (target) => + target.telemetryProviderIds.length > 0 || + target.status === "running" || + target.status === "not-running", + ); + + return ( +
+ +
+ + + + Servers + + + Pick which providers each server ships to, then deploy its Vector + agent; a provider receives every signal it sends, and cAdvisor + runs next to the agent on 127.0.0.1:4510 when metrics are shipped. + Services deployed before Logs & Metrics need a redeploy to get the + labels it uses; until then their logs and container metrics are + not shipped from the Dokploy server. + + + + {isPending ? ( +
+ Checking servers... + +
+ ) : !hasProviders && !visibleTargets?.length ? ( +
+ + + Add a provider first — there's nowhere to ship to yet + +
+ ) : ( +
+ {visibleTargets?.map((target) => { + const key = keyOf(target.serverId); + const saved = target.telemetryProviderIds; + const ids = idsFor(target.serverId, saved); + const isBusy = pendingTarget === key; + const agentDeployed = target.status !== "stopped"; + const hasEnabledSelected = ids.some( + (id) => + providers?.find((p) => p.telemetryProviderId === id) + ?.enabled, + ); + + return ( +
+
+
+ + {target.name} + + {target.ipAddress && ( + + {target.ipAddress} + + )} +
+
+ {statusBadge(target.status as Status)} + {agentDeployed && ( + handleRemove(target.serverId)} + > + + + )} +
+
+ +
+ {hasProviders && ( + Providers + )} +
+ {providers?.map((provider) => { + const ProviderIcon = + telemetryProviderIcons[ + provider.providerType as keyof typeof telemetryProviderIcons + ]; + return ( +
+ + toggleProvider( + target.serverId, + saved, + provider.telemetryProviderId, + ) + } + /> + {ProviderIcon && ( + + )} + + {!provider.enabled && ( + Disabled + )} +
+ ); + })} +
+
+ + {errors[key] && ( + +
+ + {errors[key].title} + +
+															{errors[key].message}
+														
+ +
+
+ )} + + {hasProviders && ( +
+ {ids.length > 0 && !hasEnabledSelected && ( + + Every selected provider is disabled — nothing + would ship. + + )} + +
+ )} +
+ ); + })} + + {visibleTargets?.length === 0 && ( +
+ + + You don't have any server yet + +
+ )} +
+ )} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/logs-and-metrics/show-telemetry-providers.tsx b/apps/dokploy/components/dashboard/settings/logs-and-metrics/show-telemetry-providers.tsx new file mode 100644 index 00000000000..afe48a79fcf --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/logs-and-metrics/show-telemetry-providers.tsx @@ -0,0 +1,196 @@ +import { Loader2, ScrollText, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import { telemetryProviderIcons } from "@/components/icons/telemetry-provider-icons"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { api } from "@/utils/api"; +import { HandleTelemetryProvider } from "./handle-telemetry-provider"; + +interface TelemetryProviderRowProps { + provider: { + telemetryProviderId: string; + name: string; + enabled: boolean; + providerType: string; + signals: string[]; + }; + index: number; + typeLabel: string; + canEdit: boolean; + canDelete: boolean; + onDeleted: () => void; +} + +const TelemetryProviderRow = ({ + provider, + index, + typeLabel, + canEdit, + canDelete, + onDeleted, +}: TelemetryProviderRowProps) => { + const { mutateAsync, isPending: isRemoving } = + api.telemetryProvider.remove.useMutation(); + const ProviderIcon = + telemetryProviderIcons[ + provider.providerType as keyof typeof telemetryProviderIcons + ]; + + return ( +
+
+
+ {ProviderIcon && } +
+ + {index + 1}. {provider.name} + + {provider.enabled ? "Enabled" : "Disabled"} + + {provider.signals.includes("logs") && ( + Logs + )} + {provider.signals.includes("metrics") && ( + Metrics + )} + +
{typeLabel}
+
+
+ +
+ {canEdit && ( + + )} + + {canDelete && ( + { + await mutateAsync({ + telemetryProviderId: provider.telemetryProviderId, + }) + .then((result) => { + if (result.warning) { + toast.warning("Provider deleted", { + description: result.warning, + }); + } else { + toast.success( + "Provider deleted — the agents that used it were re-applied", + ); + } + onDeleted(); + }) + .catch(() => { + toast.error("Error deleting provider"); + }); + }} + > + + + )} +
+
+
+ ); +}; + +export const ShowTelemetryProviders = () => { + const utils = api.useUtils(); + const { data, isPending, refetch } = api.telemetryProvider.all.useQuery(); + const { data: availableTypes } = + api.telemetryProvider.availableTypes.useQuery(); + const { data: permissions } = api.user.getPermissions.useQuery(); + const providerListLabel = availableTypes?.length + ? ` (${availableTypes.map((t) => t.label).join(", ")})` + : ""; + + return ( +
+ +
+ + + + Logs & Metrics + + + {`Ship container logs and host/container metrics to an external provider${providerListLabel}. Add one here, choose what it sends, then deploy the Vector agent on the servers below.`} + + + + {isPending ? ( +
+ Loading... + +
+ ) : ( + <> + {data?.length === 0 ? ( +
+ + + You don't have any provider configured + + {permissions?.telemetryProvider.create && ( + + )} +
+ ) : ( +
+
+ {data?.map((provider, index) => ( + t.type === provider.providerType, + )?.label ?? provider.providerType + } + canEdit={!!permissions?.telemetryProvider.create} + canDelete={!!permissions?.telemetryProvider.delete} + onDeleted={() => { + refetch(); + utils.telemetryProvider.serverStatus.invalidate(); + }} + /> + ))} +
+ + {permissions?.telemetryProvider.create && ( +
+ +
+ )} +
+ )} + + )} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/servers/actions/show-server-actions.tsx b/apps/dokploy/components/dashboard/settings/servers/actions/show-server-actions.tsx index 334d25b20d7..b1e12b44081 100644 --- a/apps/dokploy/components/dashboard/settings/servers/actions/show-server-actions.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/actions/show-server-actions.tsx @@ -1,6 +1,4 @@ -import { Activity } from "lucide-react"; import { useState } from "react"; -import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, @@ -15,31 +13,31 @@ import { ToggleDockerCleanup } from "./toggle-docker-cleanup"; interface Props { serverId: string; - asButton?: boolean; + children?: React.ReactNode; } -export const ShowServerActions = ({ serverId, asButton = false }: Props) => { +export const ShowServerActions = ({ serverId, children }: Props) => { const [isOpen, setIsOpen] = useState(false); return ( - {asButton ? ( + {children ? ( + {children} + ) : ( - + { + e.preventDefault(); + }} + > + View Actions + - ) : ( - { - e.preventDefault(); - setIsOpen(true); - }} - > - View Actions - )} - + e.preventDefault()} + >
Web server settings Reload or clean the web server. diff --git a/apps/dokploy/components/dashboard/settings/servers/delete-server-modal.tsx b/apps/dokploy/components/dashboard/settings/servers/delete-server-modal.tsx index 6f6e72b11c1..eb5b1b71160 100644 --- a/apps/dokploy/components/dashboard/settings/servers/delete-server-modal.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/delete-server-modal.tsx @@ -109,8 +109,13 @@ export const DeleteServerModal = ({ const handleDeleteServer = async () => { try { - await deleteServer({ serverId }); + const result = await deleteServer({ serverId }); toast.success(`Server ${serverName} deleted successfully`); + if (result?.vectorAgentRemovalWarning) { + toast.error( + `Couldn't reach this host to remove the Vector logs and metrics agent; if it was deployed it may still be running: ${result.vectorAgentRemovalWarning}`, + ); + } setOpen(false); utils.server.all.invalidate(); } catch (error: any) { @@ -121,7 +126,10 @@ export const DeleteServerModal = ({ return ( {children} - + e.preventDefault()} + > Delete Server diff --git a/apps/dokploy/components/dashboard/settings/servers/handle-servers.tsx b/apps/dokploy/components/dashboard/settings/servers/handle-servers.tsx index d2c158f9131..10518fc5606 100644 --- a/apps/dokploy/components/dashboard/settings/servers/handle-servers.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/handle-servers.tsx @@ -1,5 +1,5 @@ import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; -import { Pencil, PlusIcon } from "lucide-react"; +import { PlusIcon } from "lucide-react"; import Link from "next/link"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -61,10 +61,10 @@ type Schema = z.infer; interface Props { serverId?: string; - asButton?: boolean; + children?: React.ReactNode; } -export const HandleServers = ({ serverId, asButton = false }: Props) => { +export const HandleServers = ({ serverId, children }: Props) => { const utils = api.useUtils(); const [isOpen, setIsOpen] = useState(false); const { data: canCreateMoreServers, refetch } = @@ -141,24 +141,19 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => { return ( - {serverId ? ( - asButton ? ( - - - - ) : ( + {children ? ( + {children} + ) : serverId ? ( + { e.preventDefault(); - setIsOpen(true); }} > Edit Server - ) + ) : ( )} - + e.preventDefault()} + > {serverId ? "Edit" : "Create"} Server diff --git a/apps/dokploy/components/dashboard/settings/servers/setup-server.tsx b/apps/dokploy/components/dashboard/settings/servers/setup-server.tsx index 107833bee7a..761730fda24 100644 --- a/apps/dokploy/components/dashboard/settings/servers/setup-server.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/setup-server.tsx @@ -39,10 +39,10 @@ import { ValidateServer } from "./validate-server"; interface Props { serverId: string; - asButton?: boolean; + children?: React.ReactNode; } -export const SetupServer = ({ serverId, asButton = false }: Props) => { +export const SetupServer = ({ serverId, children }: Props) => { const [isOpen, setIsOpen] = useState(false); const { data: server } = api.server.one.useQuery( { @@ -93,24 +93,19 @@ export const SetupServer = ({ serverId, asButton = false }: Props) => { return ( - {asButton ? ( + {children ? ( + {children} + ) : ( - - ) : ( - )} - + e.preventDefault()} + >
diff --git a/apps/dokploy/components/dashboard/settings/servers/show-monitoring-modal.tsx b/apps/dokploy/components/dashboard/settings/servers/show-monitoring-modal.tsx index 43fa8e84060..5a59e0c09b2 100644 --- a/apps/dokploy/components/dashboard/settings/servers/show-monitoring-modal.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/show-monitoring-modal.tsx @@ -6,19 +6,27 @@ import { ShowPaidMonitoring } from "../../monitoring/paid/servers/show-paid-moni interface Props { serverId: string; + children?: React.ReactNode; } -export const ShowMonitoringModal = ({ serverId }: Props) => { +export const ShowMonitoringModal = ({ serverId, children }: Props) => { const [isOpen, setIsOpen] = useState(false); return ( - - - - + {children ? ( + {children} + ) : ( + + + + )} + e.preventDefault()} + >
diff --git a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx index 89963993b07..ab066164b02 100644 --- a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx @@ -1,12 +1,16 @@ import { format } from "date-fns"; import { + Activity, + BarChartHorizontalBigIcon, Clock, Key, KeyIcon, Loader2, Network, + Pencil, RefreshCw, ServerIcon, + Settings, Terminal, Trash2, User, @@ -281,11 +285,19 @@ export const ShowServers = () => {
- - - + + + + + { {server.sshKeyId && permissions?.server.terminal && ( - -
- + + - -
-
+ + + +

Terminal

@@ -338,14 +348,19 @@ export const ShowServers = () => { {permissions?.server.update && ( - -
- -
-
+ + + + +

Edit Server

@@ -354,14 +369,19 @@ export const ShowServers = () => { {server.sshKeyId && !isBuildServer && ( - -
- -
-
+ + + + +

Web Server Actions

@@ -372,13 +392,19 @@ export const ShowServers = () => { server.sshKeyId && !isBuildServer && ( - -
- -
-
+ + + + +

Monitoring

@@ -389,22 +415,20 @@ export const ShowServers = () => { {permissions?.server.delete && ( - -
- + + - -
-
+ + + +

Delete Server

diff --git a/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx b/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx index 5fd3b8d1512..429066cf688 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx @@ -88,9 +88,9 @@ export const Terminal: React.FC = ({ id, serverId }) => { }, [id, serverId]); return ( -
+
-
+
); diff --git a/apps/dokploy/components/icons/telemetry-provider-icons.tsx b/apps/dokploy/components/icons/telemetry-provider-icons.tsx new file mode 100644 index 00000000000..e8cfe5cd5d0 --- /dev/null +++ b/apps/dokploy/components/icons/telemetry-provider-icons.tsx @@ -0,0 +1,105 @@ +import { AwsIcon } from "@/components/icons/vault-provider-icons"; + +interface Props { + className?: string; +} + +export const GrafanaLokiIcon = ({ className }: Props) => ( + + + +); + +export const DatadogIcon = ({ className }: Props) => ( + + + +); + +export const BetterStackIcon = ({ className }: Props) => ( + + + +); + +export const ElasticsearchIcon = ({ className }: Props) => ( + + + +); + +export const SplunkIcon = ({ className }: Props) => ( + + + +); + +export const PrometheusIcon = ({ className }: Props) => ( + + + +); + +export const NewRelicIcon = ({ className }: Props) => ( + + + +); + +export const InfluxDbIcon = ({ className }: Props) => ( + + + +); + +export const telemetryProviderIcons = { + loki: GrafanaLokiIcon, + prometheus_remote_write: PrometheusIcon, + new_relic: NewRelicIcon, + influxdb: InfluxDbIcon, + datadog: DatadogIcon, + aws_cloudwatch: AwsIcon, + splunk_hec: SplunkIcon, + elasticsearch: ElasticsearchIcon, + betterstack: BetterStackIcon, +} as const; diff --git a/apps/dokploy/components/layouts/onboarding-layout.tsx b/apps/dokploy/components/layouts/onboarding-layout.tsx index ed46c193078..3776c2db5c5 100644 --- a/apps/dokploy/components/layouts/onboarding-layout.tsx +++ b/apps/dokploy/components/layouts/onboarding-layout.tsx @@ -19,6 +19,10 @@ export const OnboardingLayout = ({ children, leftPanel }: Props) => { "\u201CThe Open Source alternative to Netlify, Vercel, Heroku.\u201D"; const logoUrl = whitelabeling?.loginLogoUrl || whitelabeling?.logoUrl || undefined; + const logoLightUrl = + whitelabeling?.loginLogoLightUrl || + whitelabeling?.logoLightUrl || + undefined; return (
@@ -28,7 +32,11 @@ export const OnboardingLayout = ({ children, leftPanel }: Props) => { href="/" className="relative z-20 flex items-center text-lg font-medium gap-4 text-primary" > - + {appName}
diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index e3265678e0a..ceb92a4329e 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -30,6 +30,7 @@ import { Network, Package, Palette, + ScrollText, Server, ShieldCheck, Smartphone, @@ -192,9 +193,11 @@ const MENU: Menu = { title: "Monitoring", url: "/dashboard/monitoring", icon: BarChartHorizontalBigIcon, - // Only enabled in non-cloud environments and if user has monitoring.read + // Only enabled in non-cloud environments (product decision) and if user has monitoring.read and docker.read isEnabled: ({ isCloud, permissions }) => - !isCloud && !!permissions?.monitoring.read, + !isCloud && + !!permissions?.monitoring.read && + !!permissions?.docker.read, }, { isSingle: true, @@ -359,7 +362,13 @@ const MENU: Menu = { icon: BotIcon, url: "/dashboard/settings/ai", isSingle: true, - isEnabled: ({ permissions }) => !!permissions?.organization.update, + isEnabled: ({ permissions }) => + !!( + permissions?.ai.read || + permissions?.ai.create || + permissions?.ai.update || + permissions?.ai.delete + ), }, { isSingle: true, @@ -383,6 +392,13 @@ const MENU: Menu = { icon: Package, isEnabled: ({ permissions }) => !!permissions?.registry.read, }, + { + isSingle: true, + title: "Logs & Metrics", + url: "/dashboard/settings/logs-and-metrics", + icon: ScrollText, + isEnabled: ({ permissions }) => !!permissions?.telemetryProvider.read, + }, { isSingle: true, title: "Secrets", diff --git a/apps/dokploy/components/layouts/user-nav.tsx b/apps/dokploy/components/layouts/user-nav.tsx index aa9747cfbed..1a68544f6f4 100644 --- a/apps/dokploy/components/layouts/user-nav.tsx +++ b/apps/dokploy/components/layouts/user-nav.tsx @@ -87,14 +87,16 @@ export const UserNav = () => { {!isCloud ? ( <> - { - router.push("/dashboard/monitoring"); - }} - > - Monitoring - + {permissions?.monitoring.read && permissions?.docker.read && ( + { + router.push("/dashboard/monitoring"); + }} + > + Monitoring + + )} {permissions?.traefikFiles.read && ( = { settings: "Settings", session: "Session", network: "Network", + telemetryProvider: "Logs & Metrics", }; function MetadataCell({ metadata }: { metadata: string | null }) { diff --git a/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx b/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx index c261fb5a4ff..131afdadfe8 100644 --- a/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx +++ b/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx @@ -180,6 +180,16 @@ const RESOURCE_META: Record = { description: "Manage DNS providers (Cloudflare, AWS Route53) and create, update, or delete their DNS records", }, + telemetryProvider: { + label: "Logs & Metrics", + description: + "Manage the providers (Grafana Loki, Prometheus, Datadog, New Relic, etc.) that container logs and metrics are shipped to", + }, + ai: { + label: "AI Settings", + description: + "Manage AI providers, models, and use AI features (log analysis, suggestions)", + }, }; /** Descriptions for each action within a resource */ @@ -495,6 +505,40 @@ const ACTION_META: Record< description: "Remove DNS providers and delete their records", }, }, + telemetryProvider: { + read: { + label: "Read", + description: "View configured logs and metrics providers", + }, + create: { + label: "Create", + description: + "Add new providers, test their connection and deploy the Vector agent", + }, + delete: { + label: "Delete", + description: "Remove logs and metrics providers", + }, + }, + ai: { + read: { + label: "Read", + description: + "View AI providers and use AI features (log analysis, suggestions)", + }, + create: { + label: "Create", + description: "Add new AI provider configurations", + }, + update: { + label: "Update", + description: "Edit AI provider settings and custom providers", + }, + delete: { + label: "Delete", + description: "Remove AI provider configurations", + }, + }, }; /** Resources that should be hidden from the custom role editor (better-auth internals) */ @@ -606,6 +650,7 @@ const ROLE_PRESETS: { logs: ["read"], monitoring: ["read"], auditLog: ["read"], + ai: ["read", "create", "update", "delete"], }, }, ]; diff --git a/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-preview.tsx b/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-preview.tsx index 058e46ca531..2b8aba917a5 100644 --- a/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-preview.tsx +++ b/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-preview.tsx @@ -1,5 +1,6 @@ "use client"; +import { useTheme } from "next-themes"; import { Card, CardContent, @@ -12,12 +13,19 @@ interface WhitelabelingPreviewProps { config: { appName?: string; logoUrl?: string; + logoLightUrl?: string; + dualModeBranding?: boolean; footerText?: string; }; } export function WhitelabelingPreview({ config }: WhitelabelingPreviewProps) { const appName = config.appName || "Dokploy"; + const { resolvedTheme } = useTheme(); + const effectiveLogoUrl = + config.dualModeBranding && resolvedTheme === "light" && config.logoLightUrl + ? config.logoLightUrl + : config.logoUrl; return ( @@ -31,10 +39,10 @@ export function WhitelabelingPreview({ config }: WhitelabelingPreviewProps) {
{/* Simulated sidebar header */}
- {config.logoUrl ? ( + {effectiveLogoUrl ? ( // biome-ignore lint/performance/noImgElement: Logo previews use arbitrary configured URLs that may not be known to Next Image. Preview Logo diff --git a/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-settings.tsx b/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-settings.tsx index 3ba9e80bd35..ee0e54e78a3 100644 --- a/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-settings.tsx +++ b/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-settings.tsx @@ -26,6 +26,7 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { api } from "@/utils/api"; import { WhitelabelingPreview } from "./whitelabeling-preview"; @@ -36,20 +37,42 @@ const safeUrlField = z message: "Only http:// and https:// URLs are allowed", }); -const formSchema = z.object({ - appName: z.string(), - appDescription: z.string(), - logoUrl: safeUrlField, - faviconUrl: safeUrlField, - customCss: z.string(), - loginLogoUrl: safeUrlField, - supportUrl: safeUrlField, - docsUrl: safeUrlField, - errorPageTitle: z.string(), - errorPageDescription: z.string(), - footerText: z.string(), - ogImageUrl: safeUrlField, -}); +const formSchema = z + .object({ + appName: z.string(), + appDescription: z.string(), + logoUrl: safeUrlField, + faviconUrl: safeUrlField, + customCss: z.string(), + loginLogoUrl: safeUrlField, + supportUrl: safeUrlField, + docsUrl: safeUrlField, + errorPageTitle: z.string(), + errorPageDescription: z.string(), + footerText: z.string(), + ogImageUrl: safeUrlField, + logoLightUrl: z.string(), + loginLogoLightUrl: z.string(), + faviconLightUrl: z.string(), + dualModeBranding: z.boolean(), + }) + .superRefine((data, ctx) => { + if (!data.dualModeBranding) return; + for (const field of [ + "logoLightUrl", + "loginLogoLightUrl", + "faviconLightUrl", + ] as const) { + const val = data[field]; + if (val !== "" && !/^https?:\/\//i.test(val)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Only http:// and https:// URLs are allowed", + path: [field], + }); + } + } + }); type FormSchema = z.infer; @@ -195,6 +218,10 @@ export function WhitelabelingSettings() { errorPageDescription: "", footerText: "", ogImageUrl: "", + logoLightUrl: "", + loginLogoLightUrl: "", + faviconLightUrl: "", + dualModeBranding: false, }, resolver: zodResolver(formSchema), }); @@ -214,6 +241,10 @@ export function WhitelabelingSettings() { errorPageDescription: data.errorPageDescription ?? "", footerText: data.footerText ?? "", ogImageUrl: data.ogImageUrl ?? "", + logoLightUrl: data.logoLightUrl ?? "", + loginLogoLightUrl: data.loginLogoLightUrl ?? "", + faviconLightUrl: data.faviconLightUrl ?? "", + dualModeBranding: data.dualModeBranding ?? false, }); } }, [data, form]); @@ -244,6 +275,10 @@ export function WhitelabelingSettings() { errorPageDescription: values.errorPageDescription || null, footerText: values.footerText || null, ogImageUrl: values.ogImageUrl || null, + logoLightUrl: values.logoLightUrl || null, + loginLogoLightUrl: values.loginLogoLightUrl || null, + faviconLightUrl: values.faviconLightUrl || null, + dualModeBranding: values.dualModeBranding, }, }) .then(async () => { @@ -412,6 +447,111 @@ export function WhitelabelingSettings() { + {/* Light Mode Logos Section */} + + +
+
+ Light Mode Logos + + Use separate logos for light and dark themes. When enabled, + the logos in Branding are used for dark mode. + +
+ ( + + + { + field.onChange(checked); + if (!checked) { + form.clearErrors([ + "logoLightUrl", + "loginLogoLightUrl", + "faviconLightUrl", + ]); + } + }} + /> + + + )} + /> +
+
+
+
+ + ( + + Logo URL + + + + + + )} + /> + + ( + + Login Page Logo URL + + + + + + )} + /> + + ( + + Favicon URL + + + + + Follows the browser/OS theme, not the website theme. + + + + )} + /> + +
+
+
+ {/* Appearance Section */} diff --git a/apps/dokploy/components/shared/advance-breadcrumb.tsx b/apps/dokploy/components/shared/advance-breadcrumb.tsx index 85d7406774c..661ef5e7adc 100644 --- a/apps/dokploy/components/shared/advance-breadcrumb.tsx +++ b/apps/dokploy/components/shared/advance-breadcrumb.tsx @@ -357,7 +357,10 @@ export const AdvanceBreadcrumb = () => { className="h-auto px-2 py-1.5 hover:bg-accent gap-2" > - + {currentProject?.name || "Select Project"} diff --git a/apps/dokploy/components/shared/logo.tsx b/apps/dokploy/components/shared/logo.tsx index 2c316e22f33..500925df34c 100644 --- a/apps/dokploy/components/shared/logo.tsx +++ b/apps/dokploy/components/shared/logo.tsx @@ -1,16 +1,25 @@ +import { useTheme } from "next-themes"; import { cn } from "@/lib/utils"; interface Props { className?: string; logoUrl?: string; + logoLightUrl?: string; } -export const Logo = ({ className = "size-14", logoUrl }: Props) => { - if (logoUrl) { +export const Logo = ({ + className = "size-14", + logoUrl, + logoLightUrl, +}: Props) => { + const { resolvedTheme } = useTheme(); + const effectiveUrl = + resolvedTheme === "light" && logoLightUrl ? logoLightUrl : logoUrl; + + if (effectiveUrl) { return ( - // biome-ignore lint/performance/noImgElement: this is for dynamic logo loading Organization Logo diff --git a/apps/dokploy/components/ui/select.tsx b/apps/dokploy/components/ui/select.tsx index e4c7518bd86..fb9bed067d1 100644 --- a/apps/dokploy/components/ui/select.tsx +++ b/apps/dokploy/components/ui/select.tsx @@ -4,9 +4,18 @@ import type * as React from "react"; import { cn } from "@/lib/utils"; function Select({ + onValueChange, ...props }: React.ComponentProps) { - return ; + return ( + { + if (value !== "") onValueChange?.(value); + }} + {...props} + /> + ); } function SelectGroup({ diff --git a/apps/dokploy/drizzle/0208_logs_and_metrics.sql b/apps/dokploy/drizzle/0208_logs_and_metrics.sql new file mode 100644 index 00000000000..11c5a3a57f9 --- /dev/null +++ b/apps/dokploy/drizzle/0208_logs_and_metrics.sql @@ -0,0 +1,21 @@ +CREATE TYPE "public"."TelemetryProviderType" AS ENUM('loki', 'datadog', 'betterstack', 'elasticsearch', 'splunk_hec', 'aws_cloudwatch', 'prometheus_remote_write', 'new_relic', 'influxdb');--> statement-breakpoint +CREATE TABLE "telemetryProvider" ( + "telemetryProviderId" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "providerType" "TelemetryProviderType" NOT NULL, + "signals" text[] NOT NULL, + "endpoint" text, + "apiKey" text, + "apiSecret" text, + "extraConfig" jsonb, + "enabled" boolean DEFAULT true NOT NULL, + "createdAt" text NOT NULL, + "organizationId" text NOT NULL +); +--> statement-breakpoint +ALTER TABLE "webServerSettings" ALTER COLUMN "whitelabelingConfig" SET DEFAULT '{"appName":null,"appDescription":null,"logoUrl":null,"faviconUrl":null,"customCss":null,"loginLogoUrl":null,"supportUrl":null,"docsUrl":null,"errorPageTitle":null,"errorPageDescription":null,"footerText":null,"ogImageUrl":null,"logoLightUrl":null,"loginLogoLightUrl":null,"faviconLightUrl":null,"dualModeBranding":false}'::jsonb;--> statement-breakpoint +ALTER TABLE "server" ADD COLUMN "telemetryProviderIds" text[] DEFAULT ARRAY[]::text[] NOT NULL;--> statement-breakpoint +ALTER TABLE "webServerSettings" ADD COLUMN "telemetryProviderIds" text[] DEFAULT ARRAY[]::text[] NOT NULL;--> statement-breakpoint +ALTER TABLE "webServerSettings" ADD COLUMN "vectorAgentOrganizationId" text;--> statement-breakpoint +ALTER TABLE "telemetryProvider" ADD CONSTRAINT "telemetryProvider_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "webServerSettings" ADD CONSTRAINT "webServerSettings_vectorAgentOrganizationId_organization_id_fk" FOREIGN KEY ("vectorAgentOrganizationId") REFERENCES "public"."organization"("id") ON DELETE set null ON UPDATE no action; \ No newline at end of file diff --git a/apps/dokploy/drizzle/meta/0208_snapshot.json b/apps/dokploy/drizzle/meta/0208_snapshot.json new file mode 100644 index 00000000000..753ec014e80 --- /dev/null +++ b/apps/dokploy/drizzle/meta/0208_snapshot.json @@ -0,0 +1,11159 @@ +{ + "id": "5f096c20-03f8-4f89-8395-b45474b8d9bb", + "prevId": "a972fd0d-a7c6-4845-91b5-a25ec5622fda", + "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.apikey_secret": { + "name": "apikey_secret", + "schema": "", + "columns": { + "apikey_id": { + "name": "apikey_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "apikey_secret_apikey_id_apikey_id_fk": { + "name": "apikey_secret_apikey_id_apikey_id_fk", + "tableFrom": "apikey_secret", + "tableTo": "apikey", + "columnsFrom": [ + "apikey_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 + }, + "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 + }, + "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.40.1'" + }, + "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": { + "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 + }, + "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" + } + }, + "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.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.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 + }, + "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 + }, + "isolatedDeploymentsVolume": { + "name": "isolatedDeploymentsVolume", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "pullImages": { + "name": "pullImages", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "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 + }, + "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.deploymentOperation": { + "name": "deploymentOperation", + "schema": "", + "columns": { + "operationId": { + "name": "operationId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "composeId": { + "name": "composeId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotencyKeyHash": { + "name": "idempotencyKeyHash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sourceRevision": { + "name": "sourceRevision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resolvedRevision": { + "name": "resolvedRevision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "envRevision": { + "name": "envRevision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "deploymentOperationStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'accepted'" + }, + "deploymentId": { + "name": "deploymentId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "startedAt": { + "name": "startedAt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "finishedAt": { + "name": "finishedAt", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "deploymentOperation_composeId_compose_composeId_fk": { + "name": "deploymentOperation_composeId_compose_composeId_fk", + "tableFrom": "deploymentOperation", + "tableTo": "compose", + "columnsFrom": [ + "composeId" + ], + "columnsTo": [ + "composeId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deploymentOperation_deploymentId_deployment_deploymentId_fk": { + "name": "deploymentOperation_deploymentId_deployment_deploymentId_fk", + "tableFrom": "deploymentOperation", + "tableTo": "deployment", + "columnsFrom": [ + "deploymentId" + ], + "columnsTo": [ + "deploymentId" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_operation_compose_key_unique": { + "name": "deployment_operation_compose_key_unique", + "nullsNotDistinct": false, + "columns": [ + "composeId", + "idempotencyKeyHash" + ] + }, + "deployment_operation_deployment_unique": { + "name": "deployment_operation_deployment_unique", + "nullsNotDistinct": false, + "columns": [ + "deploymentId" + ] + } + }, + "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 + } + }, + "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()" + } + }, + "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 + } + }, + "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" + } + }, + "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 + }, + "organization_name": { + "name": "organization_name", + "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 + }, + "webhook_secret": { + "name": "webhook_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 + }, + "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.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 + }, + "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": true + }, + "password": { + "name": "password", + "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.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 + }, + "superPassword": { + "name": "superPassword", + "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 + }, + "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": true + }, + "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": {}, + "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_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 + }, + "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": "''" + } + }, + "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": "''" + }, + "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_connection_binding": { + "name": "scim_connection_binding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_key": { + "name": "connection_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "decommissioned_at": { + "name": "decommissioned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "decommission_status": { + "name": "decommission_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "decommission_cursor_user_id": { + "name": "decommission_cursor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_reconciled_user_count": { + "name": "decommission_reconciled_user_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_batch_count": { + "name": "decommission_batch_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_revision": { + "name": "decommission_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_completed_at": { + "name": "decommission_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_id": { + "name": "decommission_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_expires_at": { + "name": "decommission_lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scimConnectionBinding_connectionId_idx": { + "name": "scimConnectionBinding_connectionId_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_connection_binding_connection_key_unique": { + "name": "scim_connection_binding_connection_key_unique", + "nullsNotDistinct": false, + "columns": [ + "connection_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group": { + "name": "scim_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name_key": { + "name": "display_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order_key": { + "name": "order_key", + "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": { + "scimGroup_connectionId_idx": { + "name": "scimGroup_connectionId_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scimGroup_provisioningDomainId_idx": { + "name": "scimGroup_provisioningDomainId_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_group_display_name_key_unique": { + "name": "scim_group_display_name_key_unique", + "nullsNotDistinct": false, + "columns": [ + "display_name_key" + ] + }, + "scim_group_external_id_key_unique": { + "name": "scim_group_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + }, + "scim_group_order_key_unique": { + "name": "scim_group_order_key_unique", + "nullsNotDistinct": false, + "columns": [ + "order_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group_member": { + "name": "scim_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_key": { + "name": "membership_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scimGroupMember_connectionId_idx": { + "name": "scimGroupMember_connectionId_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scimGroupMember_groupId_idx": { + "name": "scimGroupMember_groupId_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scimGroupMember_scimUserId_idx": { + "name": "scimGroupMember_scimUserId_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_member_group_id_scim_group_id_fk": { + "name": "scim_group_member_group_id_scim_group_id_fk", + "tableFrom": "scim_group_member", + "tableTo": "scim_group", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_member_scim_user_id_scim_user_id_fk": { + "name": "scim_group_member_scim_user_id_scim_user_id_fk", + "tableFrom": "scim_group_member", + "tableTo": "scim_user", + "columnsFrom": [ + "scim_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_group_member_membership_key_unique": { + "name": "scim_group_member_membership_key_unique", + "nullsNotDistinct": false, + "columns": [ + "membership_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_identity_tombstone": { + "name": "scim_identity_tombstone", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile": { + "name": "profile", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scimIdentityTombstone_connectionId_idx": { + "name": "scimIdentityTombstone_connectionId_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scimIdentityTombstone_provisioningDomainId_idx": { + "name": "scimIdentityTombstone_provisioningDomainId_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scimIdentityTombstone_userId_idx": { + "name": "scimIdentityTombstone_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_identity_tombstone_user_id_user_id_fk": { + "name": "scim_identity_tombstone_user_id_user_id_fk", + "tableFrom": "scim_identity_tombstone", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_identity_tombstone_external_id_key_unique": { + "name": "scim_identity_tombstone_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_legacy_identity": { + "name": "scim_legacy_identity", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "legacy_provider_id": { + "name": "legacy_provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "scim_legacy_identity_organization_id_organization_id_fk": { + "name": "scim_legacy_identity_organization_id_organization_id_fk", + "tableFrom": "scim_legacy_identity", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_legacy_identity_user_id_user_id_fk": { + "name": "scim_legacy_identity_user_id_user_id_fk", + "tableFrom": "scim_legacy_identity", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_legacy_identity_organization_external_key_unique": { + "name": "scim_legacy_identity_organization_external_key_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "external_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_managed_connection": { + "name": "scim_managed_connection", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "creation_request_id": { + "name": "creation_request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "decommission_started_at": { + "name": "decommission_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "decommission_started_by": { + "name": "decommission_started_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommissioned_at": { + "name": "decommissioned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "decommissioned_by": { + "name": "decommissioned_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scimManagedConnection_provisioningDomainId_idx": { + "name": "scimManagedConnection_provisioningDomainId_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_managed_connection_creation_request_id_unique": { + "name": "scim_managed_connection_creation_request_id_unique", + "nullsNotDistinct": false, + "columns": [ + "creation_request_id" + ] + }, + "scim_managed_connection_connection_id_unique": { + "name": "scim_managed_connection_connection_id_unique", + "nullsNotDistinct": false, + "columns": [ + "connection_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_managed_connection_event": { + "name": "scim_managed_connection_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_record_id": { + "name": "connection_record_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scimManagedConnectionEvent_connectionRecordId_idx": { + "name": "scimManagedConnectionEvent_connectionRecordId_idx", + "columns": [ + { + "expression": "connection_record_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_managed_connection_event_connection_record_id_scim_managed_connection_id_fk": { + "name": "scim_managed_connection_event_connection_record_id_scim_managed_connection_id_fk", + "tableFrom": "scim_managed_connection_event", + "tableTo": "scim_managed_connection", + "columnsFrom": [ + "connection_record_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_managed_connection_event_event_key_unique": { + "name": "scim_managed_connection_event_event_key_unique", + "nullsNotDistinct": false, + "columns": [ + "event_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_managed_credential": { + "name": "scim_managed_credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_record_id": { + "name": "connection_record_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_digest": { + "name": "token_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash_version": { + "name": "hash_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_slot_key": { + "name": "active_slot_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serialized_scopes": { + "name": "serialized_scopes", + "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": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommissioned_at": { + "name": "decommissioned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scimManagedCredential_connectionRecordId_idx": { + "name": "scimManagedCredential_connectionRecordId_idx", + "columns": [ + { + "expression": "connection_record_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_managed_credential_connection_record_id_scim_managed_connection_id_fk": { + "name": "scim_managed_credential_connection_record_id_scim_managed_connection_id_fk", + "tableFrom": "scim_managed_credential", + "tableTo": "scim_managed_connection", + "columnsFrom": [ + "connection_record_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_managed_credential_credential_id_unique": { + "name": "scim_managed_credential_credential_id_unique", + "nullsNotDistinct": false, + "columns": [ + "credential_id" + ] + }, + "scim_managed_credential_active_slot_key_unique": { + "name": "scim_managed_credential_active_slot_key_unique", + "nullsNotDistinct": false, + "columns": [ + "active_slot_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_projection_grant": { + "name": "scim_projection_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_value": { + "name": "source_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_key": { + "name": "grant_key", + "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": { + "scimProjectionGrant_connectionId_idx": { + "name": "scimProjectionGrant_connectionId_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scimProjectionGrant_provisioningDomainId_idx": { + "name": "scimProjectionGrant_provisioningDomainId_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scimProjectionGrant_scimUserId_idx": { + "name": "scimProjectionGrant_scimUserId_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scimProjectionGrant_userId_idx": { + "name": "scimProjectionGrant_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_projection_grant_scim_user_id_scim_user_id_fk": { + "name": "scim_projection_grant_scim_user_id_scim_user_id_fk", + "tableFrom": "scim_projection_grant", + "tableTo": "scim_user", + "columnsFrom": [ + "scim_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_projection_grant_user_id_user_id_fk": { + "name": "scim_projection_grant_user_id_user_id_fk", + "tableFrom": "scim_projection_grant", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_projection_grant_grant_key_unique": { + "name": "scim_projection_grant_grant_key_unique", + "nullsNotDistinct": false, + "columns": [ + "grant_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_subject": { + "name": "scim_subject", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_source_id": { + "name": "profile_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "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": { + "scimSubject_profileSourceId_idx": { + "name": "scimSubject_profileSourceId_idx", + "columns": [ + { + "expression": "profile_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_subject_user_id_user_id_fk": { + "name": "scim_subject_user_id_user_id_fk", + "tableFrom": "scim_subject", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_subject_user_id_unique": { + "name": "scim_subject_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_user": { + "name": "scim_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_user_key": { + "name": "connection_user_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name_key": { + "name": "user_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "work_email_value_index": { + "name": "work_email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_value_index": { + "name": "email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "formatted_name": { + "name": "formatted_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "given_name": { + "name": "given_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "family_name": { + "name": "family_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serialized_emails": { + "name": "serialized_emails", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serialized_attributes": { + "name": "serialized_attributes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "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": { + "scimUser_connectionId_idx": { + "name": "scimUser_connectionId_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scimUser_provisioningDomainId_idx": { + "name": "scimUser_provisioningDomainId_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scimUser_userId_idx": { + "name": "scimUser_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_user_user_id_user_id_fk": { + "name": "scim_user_user_id_user_id_fk", + "tableFrom": "scim_user", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_user_connection_user_key_unique": { + "name": "scim_user_connection_user_key_unique", + "nullsNotDistinct": false, + "columns": [ + "connection_user_key" + ] + }, + "scim_user_user_name_key_unique": { + "name": "scim_user_user_name_key_unique", + "nullsNotDistinct": false, + "columns": [ + "user_name_key" + ] + }, + "scim_user_external_id_key_unique": { + "name": "scim_user_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + }, + "scim_user_order_key_unique": { + "name": "scim_user_order_key_unique", + "nullsNotDistinct": false, + "columns": [ + "order_key" + ] + } + }, + "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": false + }, + "telemetryProviderIds": { + "name": "telemetryProviderIds", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "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": "''" + }, + "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 + }, + "domainVerified": { + "name": "domainVerified", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "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.super_password": { + "name": "super_password", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hint": { + "name": "hint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failed_attempts": { + "name": "failed_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "locked_until": { + "name": "locked_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "reset_request_count": { + "name": "reset_request_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reset_window_started_at": { + "name": "reset_window_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "super_password_user_id_user_id_fk": { + "name": "super_password_user_id_user_id_fk", + "tableFrom": "super_password", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.super_password_token": { + "name": "super_password_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "super_password_token_user_id_idx": { + "name": "super_password_token_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "super_password_token_user_id_user_id_fk": { + "name": "super_password_token_user_id_user_id_fk", + "tableFrom": "super_password_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "super_password_token_token_hash_unique": { + "name": "super_password_token_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.super_session": { + "name": "super_session", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "opened_at": { + "name": "opened_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "opened_by_session_id": { + "name": "opened_by_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_extended_at": { + "name": "last_extended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "super_session_user_id_user_id_fk": { + "name": "super_session_user_id_user_id_fk", + "tableFrom": "super_session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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.telemetryProvider": { + "name": "telemetryProvider", + "schema": "", + "columns": { + "telemetryProviderId": { + "name": "telemetryProviderId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerType": { + "name": "providerType", + "type": "TelemetryProviderType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "signals": { + "name": "signals", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "apiKey": { + "name": "apiKey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "apiSecret": { + "name": "apiSecret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "extraConfig": { + "name": "extraConfig", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organizationId": { + "name": "organizationId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "telemetryProvider_organizationId_organization_id_fk": { + "name": "telemetryProvider_organizationId_organization_id_fk", + "tableFrom": "telemetryProvider", + "tableTo": "organization", + "columnsFrom": [ + "organizationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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 + }, + "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_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 * * *'" + }, + "telemetryProviderIds": { + "name": "telemetryProviderIds", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "vectorAgentOrganizationId": { + "name": "vectorAgentOrganizationId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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,\"footerText\":null,\"ogImageUrl\":null,\"logoLightUrl\":null,\"loginLogoLightUrl\":null,\"faviconLightUrl\":null,\"dualModeBranding\":false}'::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 + }, + "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": { + "webServerSettings_vectorAgentOrganizationId_organization_id_fk": { + "name": "webServerSettings_vectorAgentOrganizationId_organization_id_fk", + "tableFrom": "webServerSettings", + "tableTo": "organization", + "columnsFrom": [ + "vectorAgentOrganizationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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.backupType": { + "name": "backupType", + "schema": "public", + "values": [ + "database", + "compose" + ] + }, + "public.databaseType": { + "name": "databaseType", + "schema": "public", + "values": [ + "postgres", + "mariadb", + "mysql", + "mongo", + "web-server", + "libsql" + ] + }, + "public.composeType": { + "name": "composeType", + "schema": "public", + "values": [ + "docker-compose", + "stack" + ] + }, + "public.sourceTypeCompose": { + "name": "sourceTypeCompose", + "schema": "public", + "values": [ + "git", + "github", + "gitlab", + "bitbucket", + "gitea", + "raw" + ] + }, + "public.deploymentOperationStatus": { + "name": "deploymentOperationStatus", + "schema": "public", + "values": [ + "accepted", + "queued", + "dispatch_unknown", + "running", + "succeeded", + "failed" + ] + }, + "public.deploymentStatus": { + "name": "deploymentStatus", + "schema": "public", + "values": [ + "running", + "done", + "error", + "cancelled" + ] + }, + "public.DnsProviderType": { + "name": "DnsProviderType", + "schema": "public", + "values": [ + "cloudflare", + "route53", + "porkbun", + "infomaniak", + "ovh" + ] + }, + "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", + "overlay" + ] + }, + "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" + ] + }, + "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.TelemetryProviderType": { + "name": "TelemetryProviderType", + "schema": "public", + "values": [ + "loki", + "datadog", + "betterstack", + "elasticsearch", + "splunk_hec", + "aws_cloudwatch", + "prometheus_remote_write", + "new_relic", + "influxdb" + ] + }, + "public.VaultProviderType": { + "name": "VaultProviderType", + "schema": "public", + "values": [ + "hashicorp", + "infisical", + "aws", + "aws-parameter-store", + "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 b5bfb8c7200..a4b60fccf36 100644 --- a/apps/dokploy/drizzle/meta/_journal.json +++ b/apps/dokploy/drizzle/meta/_journal.json @@ -1457,6 +1457,13 @@ "when": 1791461560988, "tag": "0207_railpack_default_0_40_1", "breakpoints": true + }, + { + "idx": 208, + "version": "7", + "when": 1792777633071, + "tag": "0208_logs_and_metrics", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/dokploy/package.json b/apps/dokploy/package.json index 15aeaa00827..eb3d6e628a0 100644 --- a/apps/dokploy/package.json +++ b/apps/dokploy/package.json @@ -50,6 +50,8 @@ "@ai-sdk/mistral": "^4.0.62", "@ai-sdk/openai": "^4.0.83", "@ai-sdk/openai-compatible": "^3.0.62", + "@aws-sdk/client-cloudwatch": "^3.1148.0", + "@aws-sdk/client-cloudwatch-logs": "^3.1148.0", "@aws-sdk/client-route-53": "^3.1148.0", "@aws-sdk/client-secrets-manager": "^3.1148.0", "@aws-sdk/client-ssm": "3.1148.0", @@ -101,7 +103,7 @@ "copy-to-clipboard": "^3.3.3", "date-fns": "3.6.0", "dockerode": "5.0.1", - "dompurify": "^3.4.16", + "dompurify": "^3.4.17", "dotenv": "18.0.6", "drizzle-orm": "0.45.4", "drizzle-zod": "0.8.3", @@ -112,7 +114,7 @@ "lucide-react": "^1.53.0", "micromatch": "4.0.8", "nanoid": "3.3.20", - "next": "16.4.0", + "next": "16.4.1", "next-themes": "^0.4.6", "nextjs-toploader": "^3.9.17", "node-os-utils": "2.0.5", @@ -145,7 +147,7 @@ "swagger-ui-react": "^5.33.1", "tailwind-merge": "^3.7.0", "tailwindcss-animate": "^1.0.7", - "toml": "5.0.0", + "toml": "5.0.1", "tw-animate-css": "^1.4.0", "undici": "^8.11.2", "use-resize-observer": "10.0.0", @@ -181,14 +183,14 @@ "tsx": "^4.23.15", "typescript": "^7.0.2", "vite-tsconfig-paths": "4.3.2", - "vitest": "^4.1.11" + "vitest": "^4.1.12" }, "ct3aMetadata": { "initVersion": "7.25.2" }, "packageManager": "bun@1.4.2", "engines": { - "node": "^24.4.0", + "node": "^24.22.1", "bun": ">=1.4.2" }, "lint-staged": { diff --git a/apps/dokploy/pages/_document.tsx b/apps/dokploy/pages/_document.tsx index a8f3b593100..5e6b6948be3 100644 --- a/apps/dokploy/pages/_document.tsx +++ b/apps/dokploy/pages/_document.tsx @@ -13,6 +13,7 @@ interface WhitelabelingDocumentProps { appDescription: string | null; ogImageUrl: string | null; faviconHref: string | null; + faviconLightHref: string | null; customCss: string | null; baseUrl: string; } @@ -22,6 +23,7 @@ export default function Document({ appDescription, ogImageUrl, faviconHref, + faviconLightHref, customCss, baseUrl, }: WhitelabelingDocumentProps) { @@ -44,7 +46,22 @@ export default function Document({ - + {faviconLightHref ? ( + <> + + + + ) : ( + + )} {customCss && (