From 2c80fdf9af3428a57b022bef20b2d53bd16e7b9c Mon Sep 17 00:00:00 2001 From: Mason James Date: Mon, 1 Jun 2026 13:22:05 -0400 Subject: [PATCH 01/37] feat: add caddy web server provider --- Dockerfile | 4 +- .../__test__/caddy/application/domain.test.ts | 127 + .../__test__/caddy/compose/domain.test.ts | 267 + apps/dokploy/__test__/caddy/config.test.ts | 354 + .../__test__/caddy/dashboard-route.test.ts | 81 + .../caddy/migration/apply-rollback.test.ts | 546 ++ .../caddy-migration-rollback-cli.test.ts | 95 + .../compose-label-translator.test.ts | 329 + .../migration/dynamic-file-translator.test.ts | 266 + .../fixtures/generic-compose-labels.yml | 58 + .../fixtures/middleware-coverage.yml | 54 + .../migration/fixtures/priority-dynamic.yml | 58 + .../__test__/caddy/migration/prepare.test.ts | 595 ++ .../caddy/migration/traefik-recovery.test.ts | 290 + .../migration/upstream-preflight.test.ts | 270 + apps/dokploy/__test__/caddy/setup.test.ts | 81 + .../server/update-server-config.test.ts | 1 + .../advanced/traefik/show-traefik-config.tsx | 38 +- .../application/domains/handle-domain.tsx | 270 +- .../servers/actions/show-traefik-actions.tsx | 123 +- .../dashboard/settings/web-server.tsx | 5 + .../web-server/caddy-migration-panel.tsx | 318 + .../settings/web-server/edit-traefik-env.tsx | 26 +- .../web-server/manage-traefik-ports.tsx | 34 +- .../web-server-provider-selector.tsx | 101 + .../drizzle/0170_web_server_provider.sql | 3 + apps/dokploy/drizzle/meta/0170_snapshot.json | 8356 +++++++++++++++++ apps/dokploy/drizzle/meta/_journal.json | 7 + apps/dokploy/esbuild.config.ts | 1 + apps/dokploy/package.json | 2 + .../scripts/caddy-migration-rollback.ts | 87 + .../dokploy/server/api/routers/application.ts | 42 + apps/dokploy/server/api/routers/compose.ts | 7 +- apps/dokploy/server/api/routers/domain.ts | 217 +- apps/dokploy/server/api/routers/settings.ts | 616 +- packages/server/src/constants/index.ts | 12 + packages/server/src/db/schema/server.ts | 5 + packages/server/src/db/schema/shared.ts | 5 + .../src/db/schema/web-server-settings.ts | 6 +- packages/server/src/index.ts | 19 + packages/server/src/services/compose.ts | 19 + packages/server/src/services/domain.ts | 4 +- packages/server/src/services/settings.ts | 384 +- packages/server/src/services/user.ts | 7 + .../src/services/web-server-settings.ts | 57 +- packages/server/src/setup/caddy-setup.ts | 259 + packages/server/src/setup/traefik-setup.ts | 73 +- packages/server/src/utils/builders/compose.ts | 6 +- packages/server/src/utils/caddy/compose.ts | 143 + packages/server/src/utils/caddy/config.ts | 851 ++ packages/server/src/utils/caddy/domain.ts | 145 + .../server/src/utils/caddy/migration/apply.ts | 444 + .../migration/compose-label-translator.ts | 575 ++ .../migration/dynamic-file-translator.ts | 736 ++ .../server/src/utils/caddy/migration/files.ts | 335 + .../src/utils/caddy/migration/prepare.ts | 1014 ++ .../src/utils/caddy/migration/rollback.ts | 238 + .../caddy/migration/traefik-rule-parser.ts | 337 + .../server/src/utils/caddy/migration/types.ts | 194 + .../caddy/migration/upstream-preflight.ts | 336 + packages/server/src/utils/caddy/types.ts | 70 + .../src/utils/caddy/upstream-targets.ts | 34 + packages/server/src/utils/caddy/web-server.ts | 60 + packages/server/src/utils/docker/domain.ts | 350 +- .../server/src/utils/web-server/domain.ts | 31 + packages/server/src/utils/web-server/paths.ts | 29 + .../server/src/utils/web-server/providers.ts | 20 + 67 files changed, 20292 insertions(+), 235 deletions(-) create mode 100644 apps/dokploy/__test__/caddy/application/domain.test.ts create mode 100644 apps/dokploy/__test__/caddy/compose/domain.test.ts create mode 100644 apps/dokploy/__test__/caddy/config.test.ts create mode 100644 apps/dokploy/__test__/caddy/dashboard-route.test.ts create mode 100644 apps/dokploy/__test__/caddy/migration/apply-rollback.test.ts create mode 100644 apps/dokploy/__test__/caddy/migration/caddy-migration-rollback-cli.test.ts create mode 100644 apps/dokploy/__test__/caddy/migration/compose-label-translator.test.ts create mode 100644 apps/dokploy/__test__/caddy/migration/dynamic-file-translator.test.ts create mode 100644 apps/dokploy/__test__/caddy/migration/fixtures/generic-compose-labels.yml create mode 100644 apps/dokploy/__test__/caddy/migration/fixtures/middleware-coverage.yml create mode 100644 apps/dokploy/__test__/caddy/migration/fixtures/priority-dynamic.yml create mode 100644 apps/dokploy/__test__/caddy/migration/prepare.test.ts create mode 100644 apps/dokploy/__test__/caddy/migration/traefik-recovery.test.ts create mode 100644 apps/dokploy/__test__/caddy/migration/upstream-preflight.test.ts create mode 100644 apps/dokploy/__test__/caddy/setup.test.ts create mode 100644 apps/dokploy/components/dashboard/settings/web-server/caddy-migration-panel.tsx create mode 100644 apps/dokploy/components/dashboard/settings/web-server/web-server-provider-selector.tsx create mode 100644 apps/dokploy/drizzle/0170_web_server_provider.sql create mode 100644 apps/dokploy/drizzle/meta/0170_snapshot.json create mode 100644 apps/dokploy/scripts/caddy-migration-rollback.ts create mode 100644 packages/server/src/setup/caddy-setup.ts create mode 100644 packages/server/src/utils/caddy/compose.ts create mode 100644 packages/server/src/utils/caddy/config.ts create mode 100644 packages/server/src/utils/caddy/domain.ts create mode 100644 packages/server/src/utils/caddy/migration/apply.ts create mode 100644 packages/server/src/utils/caddy/migration/compose-label-translator.ts create mode 100644 packages/server/src/utils/caddy/migration/dynamic-file-translator.ts create mode 100644 packages/server/src/utils/caddy/migration/files.ts create mode 100644 packages/server/src/utils/caddy/migration/prepare.ts create mode 100644 packages/server/src/utils/caddy/migration/rollback.ts create mode 100644 packages/server/src/utils/caddy/migration/traefik-rule-parser.ts create mode 100644 packages/server/src/utils/caddy/migration/types.ts create mode 100644 packages/server/src/utils/caddy/migration/upstream-preflight.ts create mode 100644 packages/server/src/utils/caddy/types.ts create mode 100644 packages/server/src/utils/caddy/upstream-targets.ts create mode 100644 packages/server/src/utils/caddy/web-server.ts create mode 100644 packages/server/src/utils/web-server/domain.ts create mode 100644 packages/server/src/utils/web-server/paths.ts create mode 100644 packages/server/src/utils/web-server/providers.ts diff --git a/Dockerfile b/Dockerfile index 4cab0e8f36..6886e32178 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,6 +19,7 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile ENV NODE_ENV=production RUN pnpm --filter=@dokploy/server build RUN pnpm --filter=./apps/dokploy run build +RUN test -f /usr/src/app/apps/dokploy/dist/caddy-migration-rollback.mjs RUN pnpm --filter=./apps/dokploy --prod deploy --legacy /prod/dokploy @@ -43,7 +44,8 @@ COPY --from=build /prod/dokploy/drizzle ./drizzle COPY .env.production ./.env COPY --from=build /prod/dokploy/components.json ./components.json COPY --from=build /prod/dokploy/node_modules ./node_modules - +RUN test -f /app/dist/caddy-migration-rollback.mjs \ + && node -r dotenv/config /app/dist/caddy-migration-rollback.mjs --help | grep -q "Usage: caddy-migration-rollback" # Install docker RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh --version 28.5.2 && rm get-docker.sh && curl https://rclone.org/install.sh | bash diff --git a/apps/dokploy/__test__/caddy/application/domain.test.ts b/apps/dokploy/__test__/caddy/application/domain.test.ts new file mode 100644 index 0000000000..346e16fa84 --- /dev/null +++ b/apps/dokploy/__test__/caddy/application/domain.test.ts @@ -0,0 +1,127 @@ +import type { ApplicationNested, Domain } from "@dokploy/server"; +import { + compileCaddyConfig, + createCaddyApplicationRouteFragment, + getCaddyApplicationFragmentId, +} from "@dokploy/server"; +import { expect, test } from "vitest"; + +const app = (overrides: Partial = {}) => + ({ + appName: "my-app", + serverId: null, + ...overrides, + }) as ApplicationNested; + +const domain = (overrides: Partial = {}) => + ({ + domainId: "domain-1", + applicationId: "app-1", + composeId: null, + previewDeploymentId: null, + domainType: "application", + host: "example.com", + path: null, + internalPath: "/", + stripPath: false, + https: false, + certificateType: "none", + customCertResolver: null, + customEntrypoint: null, + middlewares: null, + port: null, + serviceName: null, + uniqueConfigKey: 7, + createdAt: "", + ...overrides, + }) as Domain; + +const getServers = (config: ReturnType) => { + const apps = config.apps as Record; + return apps.http.servers as Record; +}; + +test("creates deterministic Caddy application fragments", () => { + const fragment = createCaddyApplicationRouteFragment(app(), domain()); + + expect(fragment.id).toBe(getCaddyApplicationFragmentId("my-app", 7)); + expect(fragment.source).toBe("dokploy-application"); + expect(fragment.routes[0]).toMatchObject({ + id: "my-app-route-7", + hosts: ["example.com"], + https: false, + upstreams: ["http://my-app:80"], + }); +}); + +test("renders path rewrites and custom upstream port", () => { + const fragment = createCaddyApplicationRouteFragment( + app(), + domain({ + path: "/public", + stripPath: true, + internalPath: "/internal", + port: 3000, + }), + ); + const config = compileCaddyConfig({ fragments: [fragment] }); + const handlers = getServers(config).http.routes[0].handle; + + expect(getServers(config).http.routes[0].match[0]).toMatchObject({ + host: ["example.com"], + path: ["/public*"], + }); + expect(handlers[0]).toMatchObject({ + handler: "rewrite", + strip_path_prefix: "/public", + }); + expect(handlers[1]).toMatchObject({ + handler: "rewrite", + uri: "/internal{http.request.uri.path}", + }); + expect(handlers[2]).toMatchObject({ + handler: "reverse_proxy", + upstreams: [{ dial: "my-app:3000" }], + }); +}); + +test("HTTPS application routes redirect HTTP and proxy on HTTPS", () => { + const fragment = createCaddyApplicationRouteFragment( + app(), + domain({ https: true, certificateType: "letsencrypt" }), + ); + const config = compileCaddyConfig({ fragments: [fragment] }); + const servers = getServers(config); + + expect(servers.http.routes[0].handle[0]).toMatchObject({ + handler: "static_response", + status_code: 308, + }); + expect(servers.https.routes[0].handle.at(-1)).toMatchObject({ + handler: "reverse_proxy", + upstreams: [{ dial: "my-app:80" }], + }); +}); + +test("punycodes internationalized hosts for Caddy routes", () => { + const fragment = createCaddyApplicationRouteFragment( + app(), + domain({ host: "тест.рф" }), + ); + + expect(fragment.routes[0]?.hosts).toEqual(["xn--e1aybc.xn--p1ai"]); +}); + +test("rejects Traefik-only domain features for Caddy routes", () => { + expect(() => + createCaddyApplicationRouteFragment( + app(), + domain({ + customEntrypoint: "admin", + customCertResolver: "internal", + certificateType: "custom", + middlewares: ["auth@file"], + }), + ), + ).toThrow("unsupported Caddy fields"); +}); diff --git a/apps/dokploy/__test__/caddy/compose/domain.test.ts b/apps/dokploy/__test__/caddy/compose/domain.test.ts new file mode 100644 index 0000000000..d0ded5fc96 --- /dev/null +++ b/apps/dokploy/__test__/caddy/compose/domain.test.ts @@ -0,0 +1,267 @@ +import path from "node:path"; +import type { Compose, Domain } from "@dokploy/server"; +import { fs, vol } from "memfs"; +import { parse, stringify } from "yaml"; + +vi.mock("node:fs", () => ({ + ...fs, + default: fs, +})); + +import { + addDomainToCompose, + addDomainToComposeForWebServer, + compileCaddyConfig, + createCaddyComposeRouteFragment, + createDomainLabels, + isDokployGeneratedTraefikLabel, + paths, +} from "@dokploy/server"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const compose = (overrides: Partial = {}) => + ({ + appName: "my-compose", + serverId: null, + sourceType: "raw", + composePath: "docker-compose.yml", + composeType: "docker-compose", + isolatedDeployment: false, + isolatedDeploymentsVolume: false, + randomize: false, + suffix: null, + ...overrides, + }) as Compose; + +const domain = (overrides: Partial = {}) => + ({ + domainId: "domain-1", + applicationId: null, + composeId: "compose-1", + previewDeploymentId: null, + domainType: "compose", + host: "example.com", + path: "/", + internalPath: "/", + stripPath: false, + https: false, + certificateType: "none", + customCertResolver: null, + customEntrypoint: null, + middlewares: null, + port: 8080, + serviceName: "web", + uniqueConfigKey: 3, + createdAt: "", + ...overrides, + }) as Domain; + +const writeComposeFile = ( + composeInput: Compose, + content: Record, +) => { + const filePath = path.join( + paths(false).COMPOSE_PATH, + composeInput.appName, + "code", + "docker-compose.yml", + ); + vol.mkdirSync(path.dirname(filePath), { recursive: true }); + vol.writeFileSync(filePath, stringify(content)); +}; + +const getServers = (config: ReturnType) => { + const apps = config.apps as Record; + return apps.http.servers as Record; +}; + +beforeEach(() => { + vol.reset(); +}); + +describe("Caddy compose route generation", () => { + test("creates route fragments with compose upstream names", () => { + const fragment = createCaddyComposeRouteFragment( + compose(), + domain(), + "web", + ); + const config = compileCaddyConfig({ fragments: [fragment] }); + + expect(fragment).toMatchObject({ + id: "compose.my-compose.3", + source: "dokploy-compose", + }); + expect(getServers(config).http.routes[0].handle.at(-1)).toMatchObject({ + handler: "reverse_proxy", + upstreams: [{ dial: "my-compose-web:8080" }], + }); + }); + + test("uses swarm stack service names for Caddy upstreams", () => { + const fragment = createCaddyComposeRouteFragment( + compose({ composeType: "stack" }), + domain(), + "web-blue", + ); + const config = compileCaddyConfig({ fragments: [fragment] }); + + expect(getServers(config).http.routes[0].handle.at(-1)).toMatchObject({ + handler: "reverse_proxy", + upstreams: [{ dial: "my-compose_web-blue:8080" }], + }); + }); + + test("Traefik provider conversion delegates to the existing Traefik label path", async () => { + const composeInput = compose(); + writeComposeFile(composeInput, { + services: { + web: { image: "nginx" }, + }, + }); + + const existingTraefikOutput = await addDomainToCompose(composeInput, [ + domain(), + ]); + const providerOutput = await addDomainToComposeForWebServer( + composeInput, + [domain()], + "traefik", + ); + + expect(providerOutput).toEqual(existingTraefikOutput); + }); + + test("Caddy compose conversion strips only Dokploy Traefik labels and attaches shared network", async () => { + const composeInput = compose(); + const routeDomain = domain({ https: true, path: "/app", stripPath: true }); + const dokployLabels = [ + "traefik.docker.network=dokploy-network", + "traefik.enable=true", + ...createDomainLabels(composeInput.appName, routeDomain, "web"), + ...createDomainLabels(composeInput.appName, routeDomain, "websecure"), + ]; + writeComposeFile(composeInput, { + services: { + web: { + image: "nginx", + labels: [ + "com.example.keep=true", + "traefik.http.routers.manual.rule=Host(`manual.example.com`)", + "traefik.http.routers.my-compose-custom-web.rule=Host(`custom.example.com`)", + ...dokployLabels, + ], + }, + }, + }); + + const converted = await addDomainToComposeForWebServer( + composeInput, + [routeDomain], + "caddy", + ); + + const webService = converted?.services?.web; + expect(webService).toBeDefined(); + const labels = webService?.labels as string[]; + expect(labels).toEqual([ + "com.example.keep=true", + "traefik.http.routers.manual.rule=Host(`manual.example.com`)", + "traefik.http.routers.my-compose-custom-web.rule=Host(`custom.example.com`)", + ]); + expect(webService?.networks).toEqual({ + "dokploy-network": { aliases: ["my-compose-web"] }, + default: {}, + }); + expect(converted?.networks?.["dokploy-network"]).toEqual({ + external: true, + }); + }); + + test("Caddy compose conversion rejects unsupported domain fields before mutating compose labels", async () => { + const composeInput = compose(); + writeComposeFile(composeInput, { + services: { + web: { + image: "nginx", + labels: [ + "traefik.enable=true", + "traefik.http.routers.my-compose-3-web.rule=Host(`example.com`)", + ], + }, + }, + }); + + await expect( + addDomainToComposeForWebServer( + composeInput, + [domain({ middlewares: ["auth@file"] })], + "caddy", + ), + ).rejects.toThrow("unsupported Caddy fields"); + + const stored = parse( + vol + .readFileSync( + path.join( + paths(false).COMPOSE_PATH, + composeInput.appName, + "code", + "docker-compose.yml", + ), + "utf8", + ) + .toString(), + ) as any; + expect(stored.services.web.labels).toEqual([ + "traefik.enable=true", + "traefik.http.routers.my-compose-3-web.rule=Host(`example.com`)", + ]); + }); + + test("Caddy compose conversion uses finalized randomized service names", async () => { + const composeInput = compose({ randomize: true, suffix: "blue" }); + writeComposeFile(composeInput, { + services: { + web: { image: "nginx" }, + worker: { image: "busybox", depends_on: ["web"] }, + }, + }); + + const converted = await addDomainToComposeForWebServer( + composeInput, + [domain()], + "caddy", + ); + + expect(converted?.services?.web).toBeUndefined(); + expect(converted?.services?.["web-blue"]?.networks).toEqual({ + "dokploy-network": { aliases: ["my-compose-web-blue"] }, + default: {}, + }); + expect(converted?.services?.["worker-blue"]?.depends_on).toEqual([ + "web-blue", + ]); + }); + + test("classifier identifies current Dokploy-generated Traefik labels without matching unrelated labels", () => { + const routeDomain = domain({ https: true }); + expect( + isDokployGeneratedTraefikLabel( + "traefik.http.routers.my-compose-3-web.rule=Host(`example.com`)", + { appName: "my-compose", domains: [routeDomain] }, + ), + ).toBe(true); + expect( + isDokployGeneratedTraefikLabel("traefik.enable=true", { + includeGenericLabels: true, + }), + ).toBe(true); + expect( + isDokployGeneratedTraefikLabel( + "traefik.http.routers.manual.rule=Host(`manual.example.com`)", + { appName: "my-compose", domains: [routeDomain] }, + ), + ).toBe(false); + }); +}); diff --git a/apps/dokploy/__test__/caddy/config.test.ts b/apps/dokploy/__test__/caddy/config.test.ts new file mode 100644 index 0000000000..f163b40502 --- /dev/null +++ b/apps/dokploy/__test__/caddy/config.test.ts @@ -0,0 +1,354 @@ +import { fs, vol } from "memfs"; + +vi.mock("node:fs", () => ({ + ...fs, + default: fs, +})); + +const execAsyncMock = vi.hoisted(() => vi.fn()); +const execAsyncRemoteMock = vi.hoisted(() => vi.fn()); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: execAsyncMock, + execAsyncRemote: execAsyncRemoteMock, +})); + +import { + type ApplicationNested, + type CaddyRouteFragment, + type CaddyRouteIntent, + compileAndWriteCaddyConfig, + compileCaddyConfig, + type Domain, + getCaddyMigrationArtifactPaths, + manageCaddyDomain, + paths, + readCaddyRouteFragments, + validateCaddyConfigFileWithImage, + writeCaddyRouteFragment, +} from "@dokploy/server"; +import { beforeEach, expect, test, vi } from "vitest"; + +const route = ( + overrides: Partial = {}, +): CaddyRouteIntent => ({ + id: "app-route", + source: "dokploy-application", + hosts: ["example.com"], + https: false, + upstreams: ["http://app:3000"], + ...overrides, +}); + +const getServers = (config: ReturnType) => { + const apps = config.apps as Record; + return apps.http.servers as Record; +}; + +beforeEach(() => { + vol.reset(); + execAsyncMock.mockReset(); + execAsyncRemoteMock.mockReset(); + execAsyncMock.mockResolvedValue({ stdout: "dokploy-caddy\n", stderr: "" }); + execAsyncRemoteMock.mockResolvedValue({ + stdout: "dokploy-caddy\n", + stderr: "", + }); +}); + +test("compiles explicit http and https servers with managed HTTPS redirect", () => { + const config = compileCaddyConfig({ + letsEncryptEmail: "ops@example.com", + routes: [route({ https: true })], + }); + + const servers = getServers(config); + expect(servers.http.listen).toEqual([":80"]); + expect(servers.https.listen).toEqual([":443"]); + expect(servers.http.routes[0].handle[0].handler).toBe("static_response"); + expect(servers.http.routes[0].handle[0].headers.Location).toEqual([ + "https://{http.request.host}{http.request.uri}", + ]); + expect(servers.https.routes[0].handle.at(-1)).toMatchObject({ + handler: "reverse_proxy", + upstreams: [{ dial: "app:3000" }], + }); + expect((config.apps as any).tls.automation.policies[0].issuers[0].email).toBe( + "ops@example.com", + ); +}); + +test("sorts routes by priority before path specificity and stable id", () => { + const config = compileCaddyConfig({ + routes: [ + route({ id: "low-specific", priority: 1, pathPrefix: "/very/specific" }), + route({ id: "high-priority", priority: 10, pathPrefix: "/api" }), + route({ id: "same-priority-longer", priority: 1, pathPrefix: "/longer" }), + route({ + id: "same-tiebreak-b", + priority: 1, + pathPrefix: "/same", + upstreams: ["http://b:3000"], + }), + route({ + id: "same-tiebreak-a", + priority: 1, + pathPrefix: "/same", + upstreams: ["http://a:3000"], + }), + ], + }); + + const [first, second, third, fourth, fifth] = getServers(config).http.routes; + expect(first.match[0].path).toEqual(["/api*"]); + expect(second.match[0].path).toEqual(["/very/specific*"]); + expect(third.match[0].path).toEqual(["/longer*"]); + expect(fourth.handle.at(-1).upstreams).toEqual([{ dial: "a:3000" }]); + expect(fifth.handle.at(-1).upstreams).toEqual([{ dial: "b:3000" }]); +}); + +test("orders migrated manual and Traefik routes before DB fallbacks for identical catch-all matches", () => { + const config = compileCaddyConfig({ + routes: [ + route({ + id: "db-compose-fallback", + source: "dokploy-compose", + https: true, + upstreams: ["http://cms:3000"], + }), + route({ + id: "manual-waf", + source: "manual", + https: true, + upstreams: ["http://waf:8080"], + }), + route({ + id: "dynamic-blog", + source: "traefik-dynamic-file", + https: true, + upstreams: ["http://blog:8080"], + }), + route({ + id: "label-cms", + source: "traefik-compose-label", + https: true, + upstreams: ["http://cms:80"], + }), + ], + }); + + const upstreamOrder = getServers(config).https.routes.map( + (compiledRoute: any) => compiledRoute.handle.at(-1).upstreams[0].dial, + ); + expect(upstreamOrder).toEqual([ + "waf:8080", + "cms:80", + "blog:8080", + "cms:3000", + ]); +}); + +test("renders transforms, headers, and HTTPS upstream transport", () => { + const config = compileCaddyConfig({ + routes: [ + route({ + upstreams: ["https://upstream.example.com:443"], + transforms: { + stripPrefix: "/public", + addPrefix: "/internal", + responseHeaders: { + "Cache-Control": "no-store", + }, + }, + }), + ], + }); + + const handlers = getServers(config).http.routes[0].handle; + expect(handlers[0]).toMatchObject({ + handler: "headers", + response: { set: { "Cache-Control": ["no-store"] } }, + }); + expect(handlers[1]).toMatchObject({ + handler: "rewrite", + strip_path_prefix: "/public", + }); + expect(handlers[2]).toMatchObject({ + handler: "rewrite", + uri: "/internal{http.request.uri.path}", + }); + expect(handlers[3]).toMatchObject({ + handler: "reverse_proxy", + upstreams: [{ dial: "upstream.example.com:443" }], + transport: { protocol: "http", tls: {} }, + }); +}); + +test("rejects proxy upstreams without explicit valid ports", () => { + for (const upstream of [ + "http://admin", + "https://external.example.com", + "http://app:0", + "http://app:65536", + "http://app:abc", + "app", + "app:0", + ]) { + expect(() => + compileCaddyConfig({ + routes: [route({ upstreams: [upstream] })], + }), + ).toThrow(`invalid upstream "${upstream}"`); + } +}); + +test("allows redirect-only routes without upstreams", () => { + const config = compileCaddyConfig({ + routes: [ + route({ + upstreams: [], + redirectScheme: { scheme: "https", permanent: true }, + }), + ], + }); + + expect(getServers(config).http.routes[0].handle[0]).toMatchObject({ + handler: "static_response", + status_code: 308, + }); +}); + +test("allows static response routes without upstreams", () => { + const config = compileCaddyConfig({ + routes: [ + route({ + source: "manual", + https: true, + upstreams: [], + staticResponse: { + statusCode: 404, + headers: { + "Cache-Control": "no-store", + }, + }, + }), + ], + }); + + const servers = getServers(config); + expect(servers.http.routes[0].handle[0]).toMatchObject({ + handler: "static_response", + status_code: 308, + }); + expect(servers.https.routes[0].handle).toEqual([ + { + handler: "static_response", + status_code: 404, + headers: { + "Cache-Control": ["no-store"], + }, + }, + ]); +}); + +test("stores fragments and compiles them into the active config", async () => { + const fragment: CaddyRouteFragment = { + version: 1, + id: "app.example", + source: "dokploy-application", + routes: [route({ id: "stored", pathPrefix: "/stored" })], + }; + + await writeCaddyRouteFragment(fragment); + const fragments = await readCaddyRouteFragments(); + const config = await compileAndWriteCaddyConfig(); + + expect(fragments).toEqual([fragment]); + expect(getServers(config).http.routes[0].match[0].path).toEqual(["/stored*"]); +}); + +test("rejects invalid fragment ids before touching the store", async () => { + for (const id of ["../bad", "..", ".", "bad..segment"]) { + await expect( + writeCaddyRouteFragment({ + version: 1, + id, + source: "manual", + routes: [route()], + }), + ).rejects.toThrow("Invalid Caddy fragment id"); + } + expect(vol.existsSync(paths().CADDY_FRAGMENTS_PATH)).toBe(false); +}); + +test("rejects unsafe Caddy migration ids", () => { + for (const id of ["..", ".", "bad..segment"]) { + expect(() => getCaddyMigrationArtifactPaths(id)).toThrow( + "Invalid Caddy migration id", + ); + } +}); + +test("validates a config file with the Caddy binary in an isolated runtime container", async () => { + await validateCaddyConfigFileWithImage( + "/etc/dokploy/caddy/migrations/test/caddy.json", + ); + + const validateCommand = execAsyncMock.mock.calls + .map(([command]) => command as string) + .find((command) => command.includes("docker run")); + expect(validateCommand).toContain("docker run --rm --network none"); + expect(validateCommand).toContain("caddy\\:2.11.3"); + expect(validateCommand).toContain( + "/etc/dokploy/caddy/migrations/test/caddy.json\\:/etc/caddy/caddy.json\\:ro", + ); + expect(validateCommand).toContain( + "/etc/dokploy/caddy/migrations/test/.validate-runtime/config\\:/config", + ); + expect(validateCommand).toContain(" caddy validate --config"); + expect(validateCommand).not.toContain("caddy\\:2.11.3 validate --config"); +}); + +test("restores previous fragments when Caddy domain reload fails", async () => { + const existingFragment: CaddyRouteFragment = { + version: 1, + id: "app.existing", + source: "dokploy-application", + routes: [route({ id: "existing", hosts: ["old.example.com"] })], + }; + await writeCaddyRouteFragment(existingFragment); + execAsyncMock.mockImplementation(async (command: string) => { + if (command.includes("caddy validate")) { + throw new Error("validation failed"); + } + return { stdout: "dokploy-caddy\n", stderr: "" }; + }); + + await expect( + manageCaddyDomain( + { appName: "my-app", serverId: null } as ApplicationNested, + { + domainId: "domain-1", + applicationId: "app-1", + composeId: null, + previewDeploymentId: null, + domainType: "application", + host: "example.com", + path: "/", + internalPath: "/", + stripPath: false, + https: false, + certificateType: "none", + customCertResolver: null, + customEntrypoint: null, + middlewares: null, + port: 3000, + serviceName: null, + uniqueConfigKey: 7, + createdAt: "", + } as Domain, + ), + ).rejects.toThrow("validation failed"); + + expect(await readCaddyRouteFragments()).toEqual([existingFragment]); +}); diff --git a/apps/dokploy/__test__/caddy/dashboard-route.test.ts b/apps/dokploy/__test__/caddy/dashboard-route.test.ts new file mode 100644 index 0000000000..b4669b930a --- /dev/null +++ b/apps/dokploy/__test__/caddy/dashboard-route.test.ts @@ -0,0 +1,81 @@ +import { + compileCaddyConfig, + createCaddyDashboardRouteFragment, +} from "@dokploy/server"; +import type { webServerSettings } from "@dokploy/server/db/schema"; +import { expect, test } from "vitest"; + +type WebServerSettings = typeof webServerSettings.$inferSelect; + +const settings = (overrides: Partial = {}) => + ({ + id: "settings-1", + webServerProvider: "caddy", + https: false, + certificateType: "none", + host: null, + serverIp: null, + letsEncryptEmail: null, + sshPrivateKey: null, + enableDockerCleanup: false, + logCleanupCron: null, + metricsConfig: {} as WebServerSettings["metricsConfig"], + whitelabelingConfig: null, + cleanupCacheApplications: false, + cleanupCacheOnCompose: false, + cleanupCacheOnPreviews: false, + createdAt: null, + updatedAt: new Date(), + ...overrides, + }) as WebServerSettings; + +const getServers = (config: ReturnType) => { + const apps = config.apps as Record; + return apps.http.servers as Record; +}; + +test("creates a Caddy dashboard route to the Dokploy container", () => { + const fragment = createCaddyDashboardRouteFragment( + settings(), + "dashboard.example.com", + ); + const config = compileCaddyConfig({ fragments: [fragment] }); + const route = getServers(config).http.routes[0]; + + expect(fragment).toMatchObject({ + id: "dashboard.dokploy", + source: "dokploy-dashboard", + }); + expect(route.match[0]).toMatchObject({ + host: ["dashboard.example.com"], + }); + expect(route.match[0].path).toBeUndefined(); + expect(route.handle.at(-1)).toMatchObject({ + handler: "reverse_proxy", + upstreams: [{ dial: "dokploy:3000" }], + }); +}); + +test("dashboard HTTPS route uses global ACME email when compiled", () => { + const fragment = createCaddyDashboardRouteFragment( + settings({ https: true, letsEncryptEmail: "ops@example.com" }), + "панель.example.com", + ); + const config = compileCaddyConfig({ + fragments: [fragment], + letsEncryptEmail: "ops@example.com", + }); + const servers = getServers(config); + + expect(fragment.routes[0]?.hosts).toEqual(["xn--80aksgi6f.example.com"]); + expect(servers.http.routes[0].handle[0]).toMatchObject({ + handler: "static_response", + status_code: 308, + }); + expect(servers.https.routes[0].handle.at(-1)).toMatchObject({ + handler: "reverse_proxy", + }); + expect((config.apps as any).tls.automation.policies[0].issuers[0].email).toBe( + "ops@example.com", + ); +}); diff --git a/apps/dokploy/__test__/caddy/migration/apply-rollback.test.ts b/apps/dokploy/__test__/caddy/migration/apply-rollback.test.ts new file mode 100644 index 0000000000..753ef76f90 --- /dev/null +++ b/apps/dokploy/__test__/caddy/migration/apply-rollback.test.ts @@ -0,0 +1,546 @@ +import { fs, vol } from "memfs"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +vi.mock("node:fs", () => ({ + ...fs, + default: fs, +})); + +vi.mock("@dokploy/server/services/settings", () => ({ + getDockerResourceSnapshot: vi.fn(), + readEnvironmentVariables: vi.fn(), + readPorts: vi.fn(), + stopDockerResource: vi.fn(), + startDockerResourceFromSnapshot: vi.fn(), + ensureTraefikRunningFromSnapshot: vi.fn(), + waitForDockerResourceRunning: vi.fn(), + writeCaddySetup: vi.fn(), +})); + +vi.mock("@dokploy/server/services/web-server-settings", () => ({ + resolveWebServerProvider: vi.fn(), + updateLocalWebServerProvider: vi.fn(), + updateRemoteWebServerProvider: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/caddy/config", () => ({ + reloadCaddyAfterValidation: vi.fn(), + validateCaddyConfigFileWithImage: vi.fn(), + validateCaddyConfigWithContainer: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/caddy/migration/upstream-preflight", () => ({ + runCaddyMigrationUpstreamPreflight: vi.fn(), +})); + +import { paths } from "@dokploy/server/constants"; +import * as settingsService from "@dokploy/server/services/settings"; +import * as providerService from "@dokploy/server/services/web-server-settings"; +import * as caddyConfig from "@dokploy/server/utils/caddy/config"; +import { applyCaddyMigration } from "@dokploy/server/utils/caddy/migration/apply"; +import { getCaddyMigrationArtifactPaths } from "@dokploy/server/utils/caddy/migration/files"; +import { rollbackCaddyMigration } from "@dokploy/server/utils/caddy/migration/rollback"; +import type { CaddyMigrationReport } from "@dokploy/server/utils/caddy/migration/types"; +import * as upstreamPreflight from "@dokploy/server/utils/caddy/migration/upstream-preflight"; + +const createReport = (migrationId: string): CaddyMigrationReport => { + const artifactPaths = getCaddyMigrationArtifactPaths(migrationId); + return { + migrationId, + serverId: null, + createdAt: "2026-05-22T00:00:00.000Z", + updatedAt: "2026-05-22T00:00:00.000Z", + status: "prepared", + sourceProvider: "traefik", + targetProvider: "caddy", + artifactPaths, + inputs: { + traefikStaticConfigPath: `${paths().MAIN_TRAEFIK_PATH}/traefik.yml`, + traefikStaticConfigFound: true, + dynamicFiles: [], + dbApplicationDomains: 0, + dbComposeDomains: 0, + composeFilesScanned: [], + composeFilesSkipped: [], + }, + summary: { + fragments: 1, + routes: 1, + warnings: 0, + blockingWarnings: 0, + }, + validation: { status: "passed", message: "ok" }, + warnings: [], + events: [], + }; +}; + +const seedMigration = (migrationId: string) => { + const report = createReport(migrationId); + const currentPaths = paths(); + vol.mkdirSync(report.artifactPaths.fragmentsDir, { recursive: true }); + vol.writeFileSync( + `${report.artifactPaths.fragmentsDir}/app.json`, + JSON.stringify({ version: 1, id: "app", source: "manual", routes: [] }), + ); + vol.writeFileSync(report.artifactPaths.caddyJson, '{"apps":{"http":{}}}\n'); + vol.writeFileSync( + report.artifactPaths.reportJson, + `${JSON.stringify(report, null, 2)}\n`, + ); + vol.mkdirSync(currentPaths.MAIN_TRAEFIK_PATH, { recursive: true }); + vol.writeFileSync( + `${currentPaths.MAIN_TRAEFIK_PATH}/traefik.yml`, + "entryPoints: {}\n", + ); + vol.mkdirSync(currentPaths.DYNAMIC_TRAEFIK_PATH, { recursive: true }); + vol.writeFileSync( + `${currentPaths.DYNAMIC_TRAEFIK_PATH}/app.yml`, + "http: {}\n", + ); + vol.mkdirSync(currentPaths.CADDY_FRAGMENTS_PATH, { recursive: true }); + vol.writeFileSync(`${currentPaths.CADDY_FRAGMENTS_PATH}/old.json`, "{}\n"); + vol.writeFileSync(currentPaths.CADDY_CONFIG_PATH, '{"old":true}\n'); + return report; +}; + +describe("applyCaddyMigration", () => { + beforeEach(() => { + vol.reset(); + vi.clearAllMocks(); + vi.mocked(providerService.resolveWebServerProvider).mockResolvedValue( + "traefik", + ); + vi.mocked(settingsService.getDockerResourceSnapshot).mockImplementation( + async (resourceName: string) => ({ + resourceName, + resourceType: + resourceName === "dokploy-traefik" ? "standalone" : "unknown", + running: resourceName === "dokploy-traefik", + }), + ); + vi.mocked(settingsService.readEnvironmentVariables).mockResolvedValue(""); + vi.mocked(settingsService.readPorts).mockResolvedValue([]); + vi.mocked(settingsService.waitForDockerResourceRunning).mockResolvedValue({ + resourceName: "dokploy-caddy", + resourceType: "standalone", + running: true, + }); + vi.mocked( + settingsService.ensureTraefikRunningFromSnapshot, + ).mockResolvedValue(undefined); + vi.mocked(caddyConfig.reloadCaddyAfterValidation).mockResolvedValue( + {} as any, + ); + vi.mocked(caddyConfig.validateCaddyConfigFileWithImage).mockResolvedValue( + {} as any, + ); + vi.mocked(caddyConfig.validateCaddyConfigWithContainer).mockResolvedValue( + {} as any, + ); + vi.mocked( + upstreamPreflight.runCaddyMigrationUpstreamPreflight, + ).mockResolvedValue({ + status: "passed", + checkedAt: "2026-05-22T00:00:00.000Z", + network: "dokploy-network", + probeImage: "busybox:1.36", + checks: [], + }); + }); + + test("rejects a concurrent migration operation while a lock exists", async () => { + const report = seedMigration("caddy-apply-locked"); + vol.mkdirSync(`${report.artifactPaths.root}/.operation.lock`); + + await expect( + applyCaddyMigration({ migrationId: report.migrationId }), + ).rejects.toThrow("already has an apply or rollback operation in progress"); + expect( + upstreamPreflight.runCaddyMigrationUpstreamPreflight, + ).not.toHaveBeenCalled(); + }); + + test("writes approved artifacts, starts Caddy, validates, then updates provider", async () => { + const report = seedMigration("caddy-apply-success"); + + const applied = await applyCaddyMigration({ + migrationId: report.migrationId, + }); + + expect(applied.status).toBe("applied"); + expect(settingsService.stopDockerResource).toHaveBeenCalledWith( + "dokploy-traefik", + undefined, + ); + expect( + vi.mocked(upstreamPreflight.runCaddyMigrationUpstreamPreflight).mock + .invocationCallOrder[0], + ).toBeLessThan( + vi.mocked(settingsService.stopDockerResource).mock + .invocationCallOrder[0] ?? 0, + ); + expect(caddyConfig.validateCaddyConfigFileWithImage).toHaveBeenCalledWith( + report.artifactPaths.caddyJson, + undefined, + ); + expect( + vi.mocked(caddyConfig.validateCaddyConfigFileWithImage).mock + .invocationCallOrder[0], + ).toBeLessThan( + vi.mocked(settingsService.stopDockerResource).mock + .invocationCallOrder[0] ?? 0, + ); + expect(settingsService.writeCaddySetup).toHaveBeenCalled(); + expect(caddyConfig.validateCaddyConfigWithContainer).toHaveBeenCalled(); + expect(providerService.updateLocalWebServerProvider).toHaveBeenCalledWith( + "caddy", + ); + expect( + vi.mocked(caddyConfig.validateCaddyConfigWithContainer).mock + .invocationCallOrder[0], + ).toBeLessThan( + vi.mocked(providerService.updateLocalWebServerProvider).mock + .invocationCallOrder[0] ?? 0, + ); + expect(vol.readFileSync(paths().CADDY_CONFIG_PATH, "utf8")).toBe( + '{"apps":{"http":{}}}\n', + ); + expect(vol.existsSync(`${paths().CADDY_FRAGMENTS_PATH}/app.json`)).toBe( + true, + ); + }); + + test("rewrites the mounted Caddy config file in place after setup", async () => { + const report = seedMigration("caddy-apply-preserves-config-inode"); + let mountedConfigFd: number | undefined; + vi.mocked(settingsService.writeCaddySetup).mockImplementationOnce( + async () => { + vol.writeFileSync( + paths().CADDY_CONFIG_PATH, + '{"generatedBySetup":true}\n', + ); + mountedConfigFd = fs.openSync(paths().CADDY_CONFIG_PATH, "r"); + }, + ); + + await applyCaddyMigration({ + migrationId: report.migrationId, + }); + + expect(mountedConfigFd).toBeDefined(); + const mountedContent = Buffer.alloc(1024); + const bytesRead = fs.readSync( + mountedConfigFd as number, + mountedContent, + 0, + mountedContent.length, + 0, + ); + fs.closeSync(mountedConfigFd as number); + expect(mountedContent.toString("utf8", 0, bytesRead)).toBe( + '{"apps":{"http":{}}}\n', + ); + expect(vol.readFileSync(paths().CADDY_CONFIG_PATH, "utf8")).toBe( + '{"apps":{"http":{}}}\n', + ); + }); + + test("runtime upstream preflight failure stops apply before Traefik is stopped", async () => { + const report = seedMigration("caddy-preflight-fails"); + vi.mocked( + upstreamPreflight.runCaddyMigrationUpstreamPreflight, + ).mockResolvedValueOnce({ + status: "failed", + checkedAt: "2026-05-22T00:00:00.000Z", + network: "dokploy-network", + probeImage: "busybox:1.36", + checks: [ + { + dial: "missing:3000", + host: "missing", + port: 3000, + network: "dokploy-network", + status: "failed", + reason: "DNS resolution failed", + routes: [], + }, + ], + }); + + await expect( + applyCaddyMigration({ migrationId: report.migrationId }), + ).rejects.toThrow("Runtime upstream preflight failed"); + + expect(settingsService.stopDockerResource).not.toHaveBeenCalledWith( + "dokploy-traefik", + undefined, + ); + expect(settingsService.writeCaddySetup).not.toHaveBeenCalled(); + expect( + settingsService.ensureTraefikRunningFromSnapshot, + ).not.toHaveBeenCalled(); + const finalReport = JSON.parse( + vol.readFileSync(report.artifactPaths.reportJson, "utf8") as string, + ) as CaddyMigrationReport; + expect(finalReport.status).toBe("failed"); + expect(finalReport.runtimePreflight?.status).toBe("failed"); + }); + + test("rolls back to Traefik and keeps provider Traefik when Caddy setup fails", async () => { + const report = seedMigration("caddy-apply-fails"); + vi.mocked(settingsService.writeCaddySetup).mockRejectedValueOnce( + new Error("caddy failed"), + ); + + await expect( + applyCaddyMigration({ migrationId: report.migrationId }), + ).rejects.toThrow("caddy failed"); + + expect( + providerService.updateLocalWebServerProvider, + ).not.toHaveBeenCalledWith("caddy"); + expect(providerService.updateLocalWebServerProvider).toHaveBeenCalledWith( + "traefik", + ); + expect(settingsService.stopDockerResource).toHaveBeenCalledWith( + "dokploy-caddy", + undefined, + ); + expect( + settingsService.ensureTraefikRunningFromSnapshot, + ).toHaveBeenCalledWith( + expect.objectContaining({ resourceName: "dokploy-traefik" }), + undefined, + ); + const finalReport = JSON.parse( + vol.readFileSync(report.artifactPaths.reportJson, "utf8") as string, + ) as CaddyMigrationReport; + expect(finalReport.status).toBe("rolled_back"); + }); + + test("uses restore-only unredacted resource snapshot during rollback", async () => { + const report = seedMigration("caddy-restore-snapshot"); + vi.mocked(settingsService.getDockerResourceSnapshot).mockImplementation( + async (resourceName: string) => ({ + resourceName, + resourceType: + resourceName === "dokploy-traefik" ? "standalone" : "unknown", + running: resourceName === "dokploy-traefik", + env: resourceName === "dokploy-traefik" ? "SECRET=value" : undefined, + image: + resourceName === "dokploy-traefik" + ? "private.registry.example/traefik:latest" + : undefined, + binds: + resourceName === "dokploy-traefik" + ? ["/etc/dokploy/traefik/acme.json:/letsencrypt/acme.json"] + : undefined, + labels: + resourceName === "dokploy-traefik" + ? { "secret.label": "private" } + : undefined, + }), + ); + vi.mocked(settingsService.writeCaddySetup).mockRejectedValueOnce( + new Error("caddy failed"), + ); + + await expect( + applyCaddyMigration({ migrationId: report.migrationId }), + ).rejects.toThrow("caddy failed"); + + const finalReport = JSON.parse( + vol.readFileSync(report.artifactPaths.reportJson, "utf8") as string, + ) as CaddyMigrationReport; + expect(finalReport.backup?.traefikResource?.env).toBeUndefined(); + expect(finalReport.backup?.traefikResource?.binds).toBeUndefined(); + expect(finalReport.backup?.traefikResource?.labels).toBeUndefined(); + expect(finalReport.backup?.traefikResource?.image).toBeUndefined(); + expect(finalReport.backup?.traefikResource).toMatchObject({ + resourceName: "dokploy-traefik", + resourceType: "standalone", + running: true, + }); + expect(finalReport.backup?.restoreSnapshotPath).toBeTruthy(); + const restoreSnapshots = JSON.parse( + vol.readFileSync( + finalReport.backup?.restoreSnapshotPath ?? "", + "utf8", + ) as string, + ) as { traefikResource: { env?: string; binds?: string[] } }; + expect(restoreSnapshots.traefikResource.env).toBe("SECRET=value"); + expect(restoreSnapshots.traefikResource.binds).toEqual([ + "/etc/dokploy/traefik/acme.json:/letsencrypt/acme.json", + ]); + expect( + settingsService.ensureTraefikRunningFromSnapshot, + ).toHaveBeenCalledWith( + expect.objectContaining({ + env: "SECRET=value", + binds: ["/etc/dokploy/traefik/acme.json:/letsencrypt/acme.json"], + }), + undefined, + ); + }); + + test("rollback failure leaves provider unchanged and writes failed report", async () => { + const report = seedMigration("caddy-rollback-traefik-fails"); + vi.mocked(settingsService.writeCaddySetup).mockRejectedValueOnce( + new Error("caddy failed"), + ); + vi.mocked( + settingsService.ensureTraefikRunningFromSnapshot, + ).mockRejectedValueOnce(new Error("traefik missing")); + + await expect( + applyCaddyMigration({ migrationId: report.migrationId }), + ).rejects.toThrow("caddy failed"); + + expect( + providerService.updateLocalWebServerProvider, + ).not.toHaveBeenCalledWith("traefik"); + const finalReport = JSON.parse( + vol.readFileSync(report.artifactPaths.reportJson, "utf8") as string, + ) as CaddyMigrationReport; + expect(finalReport.status).toBe("failed"); + expect(finalReport.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "rollback-failed", + message: "traefik missing", + blocking: true, + }), + ]), + ); + }); + + test("restores backed-up Caddy files when post-start validation fails", async () => { + const report = seedMigration("caddy-validation-fails"); + vi.mocked( + caddyConfig.validateCaddyConfigWithContainer, + ).mockRejectedValueOnce(new Error("validation failed")); + + await expect( + applyCaddyMigration({ migrationId: report.migrationId }), + ).rejects.toThrow("validation failed"); + + expect( + providerService.updateLocalWebServerProvider, + ).not.toHaveBeenCalledWith("caddy"); + expect(providerService.updateLocalWebServerProvider).toHaveBeenCalledWith( + "traefik", + ); + expect(vol.readFileSync(paths().CADDY_CONFIG_PATH, "utf8")).toBe( + '{"old":true}\n', + ); + expect(vol.existsSync(`${paths().CADDY_FRAGMENTS_PATH}/old.json`)).toBe( + true, + ); + expect(vol.existsSync(`${paths().CADDY_FRAGMENTS_PATH}/app.json`)).toBe( + false, + ); + + const finalReport = JSON.parse( + vol.readFileSync(report.artifactPaths.reportJson, "utf8") as string, + ) as CaddyMigrationReport; + expect(finalReport.status).toBe("rolled_back"); + }); + + test("fails closed when an expected rollback backup path is missing", async () => { + const report = seedMigration("caddy-missing-traefik-backup"); + const traefikConfigPath = `${paths().MAIN_TRAEFIK_PATH}/traefik.yml`; + vol.writeFileSync( + report.artifactPaths.reportJson, + `${JSON.stringify( + { + ...report, + status: "applied", + backup: { + createdAt: "2026-05-22T00:00:00.000Z", + traefikResource: { + resourceName: "dokploy-traefik", + resourceType: "standalone", + running: true, + }, + files: [ + { + label: "traefik-static", + source: traefikConfigPath, + backupPath: `${report.artifactPaths.backupsDir}/missing-traefik.yml`, + existed: true, + }, + ], + }, + }, + null, + 2, + )}\n`, + ); + + await expect( + rollbackCaddyMigration({ migrationId: report.migrationId }), + ).rejects.toThrow("backup path is missing"); + + expect(vol.readFileSync(traefikConfigPath, "utf8")).toBe( + "entryPoints: {}\n", + ); + expect( + settingsService.ensureTraefikRunningFromSnapshot, + ).not.toHaveBeenCalled(); + expect( + providerService.updateLocalWebServerProvider, + ).not.toHaveBeenCalledWith("traefik"); + + const finalReport = JSON.parse( + vol.readFileSync(report.artifactPaths.reportJson, "utf8") as string, + ) as CaddyMigrationReport; + expect(finalReport.status).toBe("failed"); + expect(finalReport.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "rollback-failed", + blocking: true, + }), + ]), + ); + }); + + test("rolls back older reports that have backup metadata without file entries", async () => { + const report = seedMigration("caddy-old-backup-report"); + vol.writeFileSync( + report.artifactPaths.reportJson, + `${JSON.stringify( + { + ...report, + status: "applied", + backup: { + createdAt: "2026-05-22T00:00:00.000Z", + traefikResource: { + resourceName: "dokploy-traefik", + resourceType: "standalone", + running: true, + }, + }, + }, + null, + 2, + )}\n`, + ); + + const rolledBack = await rollbackCaddyMigration({ + migrationId: report.migrationId, + }); + + expect(rolledBack.status).toBe("rolled_back"); + expect( + settingsService.ensureTraefikRunningFromSnapshot, + ).toHaveBeenCalledWith( + expect.objectContaining({ resourceName: "dokploy-traefik" }), + undefined, + ); + expect(providerService.updateLocalWebServerProvider).toHaveBeenCalledWith( + "traefik", + ); + }); +}); diff --git a/apps/dokploy/__test__/caddy/migration/caddy-migration-rollback-cli.test.ts b/apps/dokploy/__test__/caddy/migration/caddy-migration-rollback-cli.test.ts new file mode 100644 index 0000000000..49a301ab44 --- /dev/null +++ b/apps/dokploy/__test__/caddy/migration/caddy-migration-rollback-cli.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const rollbackCaddyMigrationMock = vi.hoisted(() => vi.fn()); + +vi.mock("@dokploy/server", () => ({ + rollbackCaddyMigration: rollbackCaddyMigrationMock, +})); + +import { + parseCaddyRollbackArgs, + runCaddyMigrationRollbackCli, +} from "../../../scripts/caddy-migration-rollback"; + +const createIo = () => ({ + stdout: { write: vi.fn() }, + stderr: { write: vi.fn() }, +}); + +describe("caddy migration rollback CLI", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("requires a migration id", async () => { + expect(() => parseCaddyRollbackArgs([])).toThrow("Missing --migration-id"); + + const io = createIo(); + const code = await runCaddyMigrationRollbackCli([], io); + + expect(code).toBe(1); + expect(rollbackCaddyMigrationMock).not.toHaveBeenCalled(); + expect(io.stderr.write).toHaveBeenCalledWith( + expect.stringContaining("Missing --migration-id"), + ); + }); + + test("prints help and exits zero without invoking rollback", async () => { + const io = createIo(); + + const code = await runCaddyMigrationRollbackCli(["--help"], io); + + expect(code).toBe(0); + expect(rollbackCaddyMigrationMock).not.toHaveBeenCalled(); + expect(io.stdout.write).toHaveBeenCalledWith( + expect.stringContaining("Usage: caddy-migration-rollback"), + ); + expect(io.stderr.write).not.toHaveBeenCalled(); + }); + + test("awaits rollback and exits zero for rolled_back reports", async () => { + rollbackCaddyMigrationMock.mockResolvedValueOnce({ + migrationId: "caddy-123", + status: "rolled_back", + warnings: [], + summary: { warnings: 0, blockingWarnings: 0, fragments: 1, routes: 1 }, + artifactPaths: { reportJson: "/tmp/report.json" }, + }); + const io = createIo(); + + const code = await runCaddyMigrationRollbackCli( + ["--migration-id", "caddy-123", "--server-id", "server-1"], + io, + ); + + expect(code).toBe(0); + expect(rollbackCaddyMigrationMock).toHaveBeenCalledWith({ + migrationId: "caddy-123", + serverId: "server-1", + }); + expect(io.stdout.write).toHaveBeenCalledWith( + expect.stringContaining('"status": "rolled_back"'), + ); + }); + + test("exits non-zero for non-rolled-back terminal reports", async () => { + rollbackCaddyMigrationMock.mockResolvedValueOnce({ + migrationId: "caddy-123", + status: "failed", + warnings: [], + summary: { warnings: 1, blockingWarnings: 1, fragments: 1, routes: 1 }, + artifactPaths: { reportJson: "/tmp/report.json" }, + }); + const io = createIo(); + + const code = await runCaddyMigrationRollbackCli( + ["--migration-id", "caddy-123"], + io, + ); + + expect(code).toBe(1); + expect(io.stdout.write).toHaveBeenCalledWith( + expect.stringContaining('"status": "failed"'), + ); + }); +}); diff --git a/apps/dokploy/__test__/caddy/migration/compose-label-translator.test.ts b/apps/dokploy/__test__/caddy/migration/compose-label-translator.test.ts new file mode 100644 index 0000000000..4360b8e6d5 --- /dev/null +++ b/apps/dokploy/__test__/caddy/migration/compose-label-translator.test.ts @@ -0,0 +1,329 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { + compileCaddyConfig, + translateTraefikComposeLabelsToCaddyFragment, +} from "@dokploy/server"; +import { describe, expect, test } from "vitest"; +import { parse } from "yaml"; + +const fixture = (name: string) => + parse(readFileSync(path.join(__dirname, "fixtures", name), "utf8")) as any; + +const getServers = (config: ReturnType) => { + const apps = config.apps as Record; + return apps.http.servers as Record; +}; + +const composeLabels = (serviceName: string) => + fixture("generic-compose-labels.yml").services[serviceName].deploy?.labels ?? + fixture("generic-compose-labels.yml").services[serviceName].labels; + +describe("Traefik compose labels to Caddy migration", () => { + test("translates representative Dokploy-generated HTTP/HTTPS label pairs", () => { + const result = translateTraefikComposeLabelsToCaddyFragment( + composeLabels("frontend"), + { + sourceFile: "compose-sample-stack/docker-compose.yml", + appName: "compose-sample-stack", + serviceName: "frontend", + upstreamServiceName: "frontend", + }, + ); + const config = compileCaddyConfig({ fragments: [result.fragment] }); + const servers = getServers(config); + + expect(result.warnings).toEqual([]); + expect(result.classifications.some((item) => item.dokployGenerated)).toBe( + true, + ); + + const secureRoute = result.routes.find((route) => + route.id.includes("websecure"), + ); + expect(secureRoute).toMatchObject({ + source: "traefik-compose-label", + hosts: ["app.example.com"], + https: true, + upstreams: ["http://frontend:3000"], + }); + + const redirectRoute = result.routes.find((route) => + route.id.endsWith("-web"), + ); + expect(redirectRoute).toMatchObject({ + redirectScheme: { scheme: "https", permanent: true }, + upstreams: [], + }); + expect(servers.https.routes[0].handle.at(-1)).toMatchObject({ + handler: "reverse_proxy", + upstreams: [{ dial: "frontend:3000" }], + }); + }); + + test("translates manual compose labels with OR host rules and file middleware definitions", () => { + const result = translateTraefikComposeLabelsToCaddyFragment( + composeLabels("cms"), + { + sourceFile: "cms-site/docker-compose.yml", + serviceName: "cms", + upstreamServiceName: "cms", + fileMiddlewares: { + "cms-security-headers": { + headers: { + stsSeconds: 31536000, + stsIncludeSubdomains: true, + stsPreload: true, + referrerPolicy: "strict-origin-when-cross-origin", + contentTypeNosniff: true, + customFrameOptionsValue: "SAMEORIGIN", + }, + }, + }, + }, + ); + + const prodRoute = result.routes.find((route) => + route.id.includes("cms-prod"), + ); + expect(result.warnings).toEqual([]); + expect(prodRoute).toMatchObject({ + hosts: ["example.com", "www.example.com"], + https: true, + upstreams: ["http://cms:8080"], + transforms: { + responseHeaders: { + "Strict-Transport-Security": + "max-age=31536000; includeSubDomains; preload", + "Referrer-Policy": "strict-origin-when-cross-origin", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "SAMEORIGIN", + }, + }, + }); + }); + + test("translates path-specific manual labels with priority and dynamic file middleware mapping", () => { + const result = translateTraefikComposeLabelsToCaddyFragment( + composeLabels("admin"), + { + sourceFile: "admin-console/docker-compose.yml", + serviceName: "admin", + upstreamServiceName: "admin", + fileMiddlewares: { + "admin-no-store": { + headers: { + customResponseHeaders: { + "Cache-Control": "private, no-store", + }, + }, + }, + }, + }, + ); + + expect(result.warnings).toEqual([]); + const adminRoutes = result.routes.filter((route) => + route.id.includes("admin-console"), + ); + expect(adminRoutes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + priority: 100, + pathPrefix: "/admin", + transforms: expect.objectContaining({ + responseHeaders: expect.objectContaining({ + "Cache-Control": "private, no-store", + }), + }), + }), + expect.objectContaining({ + priority: 100, + pathExact: "/login", + }), + ]), + ); + }); + + test("treats tls=true router labels as HTTPS intent", () => { + const result = translateTraefikComposeLabelsToCaddyFragment( + [ + "traefik.http.routers.app.rule=Host(`tls.example.com`)", + "traefik.http.routers.app.entrypoints=websecure", + "traefik.http.routers.app.tls=true", + "traefik.http.routers.app.service=app", + "traefik.http.services.app.loadbalancer.server.port=3000", + ], + { + sourceFile: "tls/docker-compose.yml", + serviceName: "app", + upstreamServiceName: "app", + }, + ); + + expect(result.warnings).toEqual([]); + expect(result.routes[0]).toMatchObject({ + hosts: ["tls.example.com"], + https: true, + upstreams: ["http://app:3000"], + }); + }); + + test("translates compose-label routes that reference ipAllowList file middleware", () => { + const result = translateTraefikComposeLabelsToCaddyFragment( + [ + "traefik.http.routers.dash.rule=Host(`dash.example.com`)", + "traefik.http.routers.dash.entrypoints=websecure", + "traefik.http.routers.dash.tls.certresolver=letsencrypt", + "traefik.http.routers.dash.service=dash", + "traefik.http.routers.dash.middlewares=internal-allowlist@file", + "traefik.http.services.dash.loadbalancer.server.port=8000", + ], + { + sourceFile: "dash/docker-compose.yml", + serviceName: "dash", + upstreamServiceName: "dash", + fileMiddlewares: { + "internal-allowlist": { + ipAllowList: { + sourceRange: ["192.0.2.0/24"], + }, + } as any, + }, + }, + ); + + expect(result.warnings).toEqual([]); + expect(result.routes[0]).toMatchObject({ + allowedRemoteIps: ["192.0.2.0/24"], + }); + }); + + test("drops generated labels when manual labels are present after security middleware is migratable", () => { + const result = translateTraefikComposeLabelsToCaddyFragment( + [ + "traefik.enable=true", + "traefik.http.routers.sample-app-42-websecure.rule=Host(`dashboard.example.com`)", + "traefik.http.routers.sample-app-42-websecure.entrypoints=websecure", + "traefik.http.routers.sample-app-42-websecure.tls.certresolver=letsencrypt", + "traefik.http.routers.sample-app-42-websecure.middlewares=internal-allowlist@file", + "traefik.http.routers.sample-app-42-websecure.service=sample-app-42-websecure", + "traefik.http.services.sample-app-42-websecure.loadbalancer.server.port=8000", + "traefik.http.routers.custom.rule=Host(`custom.example.com`)", + "traefik.http.routers.custom.entrypoints=websecure", + "traefik.http.routers.custom.tls.certresolver=letsencrypt", + "traefik.http.routers.custom.service=custom", + "traefik.http.services.custom.loadbalancer.server.port=3000", + ], + { + sourceFile: "sample-app/dashboard-api/labels", + appName: "sample-app", + domains: [ + { + host: "dashboard.example.com", + uniqueConfigKey: 42, + https: true, + } as any, + ], + serviceName: "dashboard-api", + upstreamServiceName: "dashboard-api", + fileMiddlewares: { + "internal-allowlist": { + ipAllowList: { + sourceRange: ["192.0.2.0/24"], + }, + } as any, + }, + }, + ); + + expect(result.routes).toEqual([ + expect.objectContaining({ + hosts: ["custom.example.com"], + upstreams: ["http://dashboard-api:3000"], + }), + ]); + expect(result.warnings).toEqual([]); + }); + + test("parses inline ipWhiteList labels as Caddy remote IP restrictions", () => { + const result = translateTraefikComposeLabelsToCaddyFragment( + [ + "traefik.http.routers.admin.rule=Host(`admin.example.com`)", + "traefik.http.routers.admin.entrypoints=websecure", + "traefik.http.routers.admin.tls.certresolver=letsencrypt", + "traefik.http.routers.admin.service=admin", + "traefik.http.routers.admin.middlewares=admin-allow", + "traefik.http.services.admin.loadbalancer.server.port=8080", + "traefik.http.middlewares.admin-allow.ipWhiteList.sourceRange=192.0.2.0/24,127.0.0.1/32", + ], + { + sourceFile: "admin/docker-compose.yml", + serviceName: "admin", + upstreamServiceName: "admin", + }, + ); + + expect(result.warnings).toEqual([]); + expect(result.routes[0]).toMatchObject({ + allowedRemoteIps: ["192.0.2.0/24", "127.0.0.1/32"], + }); + }); + + test("does not classify app-name-shaped labels as generated when DB domains are provided", () => { + const result = translateTraefikComposeLabelsToCaddyFragment( + [ + "traefik.http.routers.my-compose-custom-web.rule=Host(`custom.example.com`)", + "traefik.http.routers.my-compose-custom-web.entrypoints=web", + "traefik.http.services.my-compose-custom-web.loadbalancer.server.port=8080", + ], + { + sourceFile: "my-compose/docker-compose.yml", + appName: "my-compose", + domains: [], + serviceName: "web", + upstreamServiceName: "web", + }, + ); + + expect(result.classifications).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + label: + "traefik.http.routers.my-compose-custom-web.rule=Host(`custom.example.com`)", + dokployGenerated: false, + }), + ]), + ); + expect(result.routes[0]).toMatchObject({ + hosts: ["custom.example.com"], + upstreams: ["http://web:8080"], + }); + }); + + test("returns blocking warnings for unsupported label rules and middleware constructs", () => { + const result = translateTraefikComposeLabelsToCaddyFragment( + composeLabels("unsupported"), + { + sourceFile: "unsupported/docker-compose.yml", + serviceName: "unsupported", + }, + ); + + expect(result.routes).toHaveLength(0); + expect(result.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "unsupported-matcher", + routerName: "unsupported", + blocking: true, + }), + expect.objectContaining({ + code: "unsupported-middleware", + middlewareName: "plugin-only", + blocking: true, + }), + ]), + ); + }); +}); diff --git a/apps/dokploy/__test__/caddy/migration/dynamic-file-translator.test.ts b/apps/dokploy/__test__/caddy/migration/dynamic-file-translator.test.ts new file mode 100644 index 0000000000..a04a43c439 --- /dev/null +++ b/apps/dokploy/__test__/caddy/migration/dynamic-file-translator.test.ts @@ -0,0 +1,266 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { + compileCaddyConfig, + translateTraefikDynamicConfigToCaddyFragment, +} from "@dokploy/server"; +import { describe, expect, test } from "vitest"; + +const fixture = (name: string) => + readFileSync(path.join(__dirname, "fixtures", name), "utf8"); + +const getServers = (config: ReturnType) => { + const apps = config.apps as Record; + return apps.http.servers as Record; +}; + +describe("Traefik dynamic file to Caddy migration", () => { + test("translates priority dynamic-file routers, headers, redirects, and external upstreams", () => { + const result = translateTraefikDynamicConfigToCaddyFragment( + fixture("priority-dynamic.yml"), + { sourceFile: "priority-dynamic.yml" }, + ); + const config = compileCaddyConfig({ fragments: [result.fragment] }); + const servers = getServers(config); + + expect(result.warnings).toEqual([]); + expect(result.fragment).toMatchObject({ + id: "migration.traefik-dynamic.priority-dynamic", + source: "traefik-dynamic-file", + }); + + const activityRoutes = result.routes.filter((route) => + route.id.includes("activity-feed"), + ); + expect(activityRoutes).toHaveLength(3); + expect(activityRoutes.map((route) => route.priority)).toEqual([ + 10000, 10000, 10000, + ]); + expect(activityRoutes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + pathPrefix: "/activity", + upstreams: ["https://activity.example.net:443"], + }), + expect.objectContaining({ pathExact: "/.well-known/webfinger" }), + expect.objectContaining({ pathExact: "/.well-known/nodeinfo" }), + ]), + ); + + const adminRoute = result.routes.find((route) => + route.id.includes("admin-console"), + ); + expect(adminRoute).toMatchObject({ + hosts: ["example.com", "www.example.com"], + pathPrefix: "/admin", + priority: 9000, + transforms: { + responseHeaders: { + "Cache-Control": + "private, no-store, no-cache, must-revalidate, max-age=0", + Pragma: "no-cache", + Expires: "0", + }, + }, + }); + + const redirectRoute = result.routes.find((route) => + route.id.includes("site-redirect"), + ); + expect(redirectRoute).toMatchObject({ + redirectScheme: { scheme: "https", permanent: true }, + upstreams: [], + }); + + const activityProxy = servers.https.routes.find((route: any) => + route.match[0].path?.includes("/activity*"), + ); + expect(activityProxy.handle.at(-1)).toMatchObject({ + handler: "reverse_proxy", + upstreams: [{ dial: "activity.example.net:443" }], + transport: { protocol: "http", tls: {} }, + }); + }); + + test("normalizes Traefik service URLs with default scheme ports", () => { + const result = translateTraefikDynamicConfigToCaddyFragment( + [ + "http:", + " routers:", + " admin:", + " rule: Host(`admin.example.com`)", + " entryPoints: [websecure]", + " service: admin", + " tls:", + " certResolver: letsencrypt", + " services:", + " admin:", + " loadBalancer:", + " servers:", + " - url: http://admin", + ].join("\n"), + { sourceFile: "admin.yml" }, + ); + + expect(result.routes[0]).toMatchObject({ + upstreams: ["http://admin:80"], + }); + expect(() => + compileCaddyConfig({ fragments: [result.fragment] }), + ).not.toThrow(); + }); + + test("skips Traefik api@internal dashboard routers as non-migratable internals", () => { + const result = translateTraefikDynamicConfigToCaddyFragment( + [ + "http:", + " routers:", + " traefik-dashboard:", + " rule: PathPrefix(`/dashboard`) || PathPrefix(`/api`)", + " entryPoints: [traefik]", + " service: api@internal", + " middlewares: [internal-allowlist]", + ].join("\n"), + { sourceFile: "routers.yml" }, + ); + + expect(result.routes).toEqual([]); + expect(result.warnings).toEqual([ + expect.objectContaining({ + code: "unsupported-router", + routerName: "traefik-dashboard", + serviceName: "api@internal", + blocking: false, + message: expect.stringContaining("Skipped Traefik internal router"), + }), + ]); + }); + + test("translates active routes that use ipAllowList file middleware", () => { + const result = translateTraefikDynamicConfigToCaddyFragment( + [ + "http:", + " routers:", + " dash:", + " rule: Host(`dash.example.com`)", + " entryPoints: [websecure]", + " service: dash", + " tls:", + " certResolver: letsencrypt", + " middlewares: [internal-allowlist]", + " services:", + " dash:", + " loadBalancer:", + " servers:", + " - url: http://dash:8000", + " middlewares:", + " internal-allowlist:", + " ipAllowList:", + " sourceRange:", + " - 192.0.2.0/24", + ].join("\n"), + { sourceFile: "dash.yml" }, + ); + + expect(result.warnings).toEqual([]); + expect(result.routes[0]).toMatchObject({ + allowedRemoteIps: ["192.0.2.0/24"], + }); + const config = compileCaddyConfig({ fragments: [result.fragment] }); + const servers = getServers(config); + expect(servers.https.routes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + match: [ + expect.objectContaining({ + remote_ip: { ranges: ["192.0.2.0/24"] }, + }), + ], + }), + expect.objectContaining({ + handle: [ + expect.objectContaining({ + handler: "static_response", + status_code: 403, + }), + ], + }), + ]), + ); + }); + + test("translates strip/add prefix, basicAuth, chain, and blocks unsupported constructs", () => { + const result = translateTraefikDynamicConfigToCaddyFragment( + fixture("middleware-coverage.yml"), + { sourceFile: "middleware-coverage.yml" }, + ); + + const toolRoute = result.routes.find((route) => route.id.endsWith("-tool")); + expect(toolRoute).toMatchObject({ + transforms: { + stripPrefix: "/public", + addPrefix: "/internal", + requestHeaders: { "X-Forwarded-Proto": "https" }, + responseHeaders: { "X-Test": "true" }, + }, + basicAuth: [{ username: "admin", hash: "$2y$05$abcdef" }], + }); + + expect(result.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "unsupported-service", + serviceName: "tool", + blocking: true, + }), + expect.objectContaining({ + code: "unsupported-middleware", + middlewareName: "plugin-auth", + blocking: true, + }), + expect.objectContaining({ + code: "unsupported-router", + routerName: "unsupported-plugin", + message: expect.stringContaining("Custom certResolver"), + blocking: true, + }), + ]), + ); + }); + + test("blocks basicAuth hashes that Caddy cannot safely consume", () => { + const result = translateTraefikDynamicConfigToCaddyFragment( + [ + "http:", + " routers:", + " secure:", + " rule: Host(`secure.example.com`)", + " service: secure", + " middlewares: [legacy-auth]", + " services:", + " secure:", + " loadBalancer:", + " servers:", + " - url: http://secure:3000", + " middlewares:", + " legacy-auth:", + " basicAuth:", + " users:", + " - admin:$apr1$legacy-hash", + ].join("\n"), + { sourceFile: "legacy-auth.yml" }, + ); + + expect(result.routes[0]?.basicAuth).toBeNull(); + expect(result.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "unsupported-security-middleware", + middlewareName: "legacy-auth", + blocking: true, + message: expect.stringContaining("hash format"), + }), + ]), + ); + }); +}); diff --git a/apps/dokploy/__test__/caddy/migration/fixtures/generic-compose-labels.yml b/apps/dokploy/__test__/caddy/migration/fixtures/generic-compose-labels.yml new file mode 100644 index 0000000000..2500c4eb69 --- /dev/null +++ b/apps/dokploy/__test__/caddy/migration/fixtures/generic-compose-labels.yml @@ -0,0 +1,58 @@ +# Representative Traefik compose labels for Caddy migration tests. +services: + frontend: + deploy: + labels: + - traefik.enable=true + - traefik.http.routers.compose-sample-stack-frontend-web.rule=Host(`app.example.com`) + - traefik.http.routers.compose-sample-stack-frontend-web.entrypoints=web + - traefik.http.services.compose-sample-stack-frontend-web.loadbalancer.server.port=3000 + - traefik.http.routers.compose-sample-stack-frontend-web.service=compose-sample-stack-frontend-web + - traefik.http.routers.compose-sample-stack-frontend-web.middlewares=redirect-to-https@file + - traefik.http.routers.compose-sample-stack-frontend-websecure.rule=Host(`app.example.com`) + - traefik.http.routers.compose-sample-stack-frontend-websecure.entrypoints=websecure + - traefik.http.services.compose-sample-stack-frontend-websecure.loadbalancer.server.port=3000 + - traefik.http.routers.compose-sample-stack-frontend-websecure.service=compose-sample-stack-frontend-websecure + - traefik.http.routers.compose-sample-stack-frontend-websecure.tls.certresolver=letsencrypt + - traefik.http.routers.compose-sample-stack-frontend-websecure.middlewares=security-headers@file + - traefik.docker.network=dokploy-network + cms: + deploy: + labels: + - traefik.enable=true + - traefik.docker.network=dokploy-network + - traefik.http.routers.cms-prod.rule=Host(`example.com`) || Host(`www.example.com`) + - traefik.http.routers.cms-prod.entrypoints=websecure + - traefik.http.routers.cms-prod.tls.certresolver=letsencrypt + - traefik.http.routers.cms-prod.middlewares=cms-security-headers@file + - traefik.http.routers.cms-prod.service=cms-prod + - traefik.http.routers.cms-preview.rule=Host(`cms.test.example.com`) || Host(`preview.test.example.com`) + - traefik.http.routers.cms-preview.entrypoints=websecure + - traefik.http.routers.cms-preview.tls.certresolver=letsencrypt + - traefik.http.routers.cms-preview.middlewares=cms-security-headers@file + - traefik.http.routers.cms-preview.service=cms-prod + - traefik.http.services.cms-prod.loadbalancer.server.port=8080 + admin: + deploy: + labels: + - traefik.enable=true + - traefik.http.routers.admin.rule=Host(`admin.example.com`) + - traefik.http.routers.admin.entrypoints=websecure + - traefik.http.routers.admin.service=admin + - traefik.http.routers.admin.tls.certresolver=letsencrypt + - traefik.http.routers.admin.middlewares=security-headers@file + - traefik.http.routers.admin-console.rule=Host(`admin.example.com`) && (PathPrefix(`/admin`) || Path(`/login`)) + - traefik.http.routers.admin-console.entrypoints=websecure + - traefik.http.routers.admin-console.service=admin + - traefik.http.routers.admin-console.tls.certresolver=letsencrypt + - traefik.http.routers.admin-console.middlewares=security-headers@file,admin-no-store@file + - traefik.http.routers.admin-console.priority=100 + - traefik.http.services.admin.loadbalancer.server.port=80 + unsupported: + labels: + - traefik.http.routers.unsupported.rule=HostRegexp(`{subdomain:[a-z]+}.example.com`) + - traefik.http.routers.unsupported.entrypoints=websecure + - traefik.http.routers.unsupported.service=unsupported + - traefik.http.services.unsupported.loadbalancer.server.port=9000 + - traefik.http.routers.unsupported.middlewares=plugin-only + - traefik.http.middlewares.plugin-only.plugin.demo.enabled=true diff --git a/apps/dokploy/__test__/caddy/migration/fixtures/middleware-coverage.yml b/apps/dokploy/__test__/caddy/migration/fixtures/middleware-coverage.yml new file mode 100644 index 0000000000..964c992f75 --- /dev/null +++ b/apps/dokploy/__test__/caddy/migration/fixtures/middleware-coverage.yml @@ -0,0 +1,54 @@ +http: + routers: + tool: + rule: "Host(`tools.example.com`) && PathPrefix(`/public`)" + entryPoints: + - websecure + service: tool + tls: + certResolver: letsencrypt + middlewares: + - tool-chain + unsupported-plugin: + rule: "Host(`blocked.example.com`)" + entryPoints: + - websecure + service: tool + tls: + certResolver: customresolver + middlewares: + - plugin-auth + services: + tool: + loadBalancer: + servers: + - url: http://tool:8080 + passHostHeader: false + middlewares: + tool-chain: + chain: + middlewares: + - strip-public + - add-internal + - basic-users + - response-headers + strip-public: + stripPrefix: + prefixes: + - /public + add-internal: + addPrefix: + prefix: /internal + basic-users: + basicAuth: + users: + - admin:$2y$05$abcdef + response-headers: + headers: + customRequestHeaders: + X-Forwarded-Proto: "https" + customResponseHeaders: + X-Test: "true" + plugin-auth: + plugin: + unsupported: {} diff --git a/apps/dokploy/__test__/caddy/migration/fixtures/priority-dynamic.yml b/apps/dokploy/__test__/caddy/migration/fixtures/priority-dynamic.yml new file mode 100644 index 0000000000..ace2ca7a5c --- /dev/null +++ b/apps/dokploy/__test__/caddy/migration/fixtures/priority-dynamic.yml @@ -0,0 +1,58 @@ +# Representative Traefik dynamic-file routes for Caddy migration tests. +http: + routers: + activity-feed: + rule: "Host(`example.com`) && (PathPrefix(`/activity`) || Path(`/.well-known/webfinger`) || Path(`/.well-known/nodeinfo`))" + entryPoints: + - websecure + service: hosted-activity + tls: + certResolver: letsencrypt + middlewares: + - security-headers + priority: 10000 + admin-console: + rule: "(Host(`example.com`) || Host(`www.example.com`)) && PathPrefix(`/admin`)" + entryPoints: + - websecure + service: site + tls: + certResolver: letsencrypt + middlewares: + - security-headers + - admin-no-store + priority: 9000 + site: + rule: "Host(`example.com`) || Host(`www.example.com`)" + entryPoints: + - websecure + service: site + tls: + certResolver: letsencrypt + middlewares: + - security-headers + site-redirect: + rule: "Host(`example.com`) || Host(`www.example.com`)" + entryPoints: + - web + service: site + middlewares: + - redirect-to-https + services: + site: + loadBalancer: + servers: + - url: http://site:8080 + passHostHeader: true + hosted-activity: + loadBalancer: + servers: + - url: https://activity.example.net:443 + passHostHeader: true + middlewares: + admin-no-store: + headers: + customResponseHeaders: + Cache-Control: "private, no-store, no-cache, must-revalidate, max-age=0" + Pragma: "no-cache" + Expires: "0" diff --git a/apps/dokploy/__test__/caddy/migration/prepare.test.ts b/apps/dokploy/__test__/caddy/migration/prepare.test.ts new file mode 100644 index 0000000000..42d736d254 --- /dev/null +++ b/apps/dokploy/__test__/caddy/migration/prepare.test.ts @@ -0,0 +1,595 @@ +import { fs, vol } from "memfs"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +vi.mock("node:fs", () => ({ + ...fs, + default: fs, +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + applications: { findMany: vi.fn() }, + compose: { findMany: vi.fn() }, + }, + }, +})); + +vi.mock("@dokploy/server/services/web-server-settings", () => ({ + getWebServerSettings: vi.fn().mockResolvedValue({ + letsEncryptEmail: "ops@example.com", + }), +})); + +vi.mock("@dokploy/server/utils/docker/domain", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@dokploy/server/utils/docker/domain") + >(); + return { + ...actual, + loadDockerCompose: vi.fn().mockResolvedValue(null), + loadDockerComposeRemote: vi.fn().mockResolvedValue(null), + }; +}); + +const remoteDockerMock = vi.hoisted(() => ({ + listServices: vi.fn().mockResolvedValue([]), + listContainers: vi.fn().mockResolvedValue([]), + listTasks: vi.fn().mockResolvedValue([]), + getContainer: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({ + getRemoteDocker: vi.fn().mockResolvedValue(remoteDockerMock), +})); + +import { paths } from "@dokploy/server/constants"; +import { db } from "@dokploy/server/db"; +import { prepareCaddyMigration } from "@dokploy/server/utils/caddy/migration/prepare"; + +const domain = { + domainId: "domain-1", + host: "app.example.com", + https: true, + port: 3000, + customEntrypoint: null, + path: "/", + serviceName: null, + domainType: "application", + uniqueConfigKey: 1, + createdAt: new Date().toISOString(), + composeId: null, + customCertResolver: null, + applicationId: "app-1", + previewDeploymentId: null, + certificateType: "letsencrypt", + internalPath: "/", + stripPath: false, + middlewares: [], +}; + +const genericComposeFixture = [ + "services:", + " cms:", + " deploy:", + " labels:", + " - traefik.enable=true", + " - traefik.docker.network=dokploy-network", + " - traefik.http.routers.cms-prod.rule=Host(`example.com`) || Host(`www.example.com`)", + " - traefik.http.routers.cms-prod.entrypoints=websecure", + " - traefik.http.routers.cms-prod.tls.certresolver=letsencrypt", + " - traefik.http.routers.cms-prod.middlewares=cms-security-headers@file", + " - traefik.http.routers.cms-prod.service=cms-prod", + " - traefik.http.services.cms-prod.loadbalancer.server.port=8080", + " unsupported:", + " labels:", + " - traefik.http.routers.unsupported.rule=HostRegexp(`{subdomain:[a-z]+}.example.com`)", + " - traefik.http.routers.unsupported.entrypoints=websecure", + " - traefik.http.routers.unsupported.service=unsupported", + " - traefik.http.services.unsupported.loadbalancer.server.port=9000", + " - traefik.http.routers.unsupported.middlewares=plugin-only", + " - traefik.http.middlewares.plugin-only.plugin.demo.enabled=true", +].join("\n"); + +describe("prepareCaddyMigration", () => { + beforeEach(() => { + vol.reset(); + vi.clearAllMocks(); + remoteDockerMock.listServices.mockResolvedValue([]); + remoteDockerMock.listContainers.mockResolvedValue([]); + remoteDockerMock.listTasks.mockResolvedValue([]); + remoteDockerMock.getContainer.mockReturnValue({ + inspect: vi.fn().mockResolvedValue({}), + }); + vi.mocked(db.query.applications.findMany).mockResolvedValue([ + { + applicationId: "app-1", + appName: "test-app", + serverId: null, + domains: [domain], + } as any, + ]); + vi.mocked(db.query.compose.findMany).mockResolvedValue([]); + }); + + test("writes reviewable dry-run artifacts without touching live Caddy config", async () => { + const currentPaths = paths(); + vol.mkdirSync(currentPaths.DYNAMIC_TRAEFIK_PATH, { recursive: true }); + vol.writeFileSync( + `${currentPaths.MAIN_TRAEFIK_PATH}/traefik.yml`, + "entryPoints:\n web:\n address: ':80'\n", + ); + vol.writeFileSync( + `${currentPaths.DYNAMIC_TRAEFIK_PATH}/manual.yml`, + [ + "http:", + " routers:", + " manual:", + " rule: Host(`manual.example.com`)", + " entryPoints: [websecure]", + " service: manual", + " tls:", + " certResolver: letsencrypt", + " services:", + " manual:", + " loadBalancer:", + " servers:", + " - url: http://manual:8080", + ].join("\n"), + ); + + const report = await prepareCaddyMigration(); + + expect(report.status).toBe("prepared"); + expect(report.summary.blockingWarnings).toBe(0); + expect(report.summary.fragments).toBe(2); + expect(report.inputs.dynamicFiles).toHaveLength(1); + expect(vol.existsSync(report.artifactPaths.reportJson)).toBe(true); + expect(vol.existsSync(report.artifactPaths.reportMd)).toBe(true); + expect(vol.existsSync(report.artifactPaths.caddyJson)).toBe(true); + expect(vol.existsSync(currentPaths.CADDY_CONFIG_PATH)).toBe(false); + + const draft = JSON.parse( + vol.readFileSync(report.artifactPaths.caddyJson, "utf8") as string, + ) as any; + expect(draft.apps.tls.automation.policies[0].issuers[0].email).toBe( + "ops@example.com", + ); + const fragmentFiles = vol.readdirSync(report.artifactPaths.fragmentsDir); + expect(fragmentFiles).toEqual( + expect.arrayContaining([ + "application.test-app.1.json", + "migration.traefik-dynamic.manual.json", + ]), + ); + }); + + test("carries existing manual Caddy fragments into the migration draft", async () => { + const currentPaths = paths(); + vol.mkdirSync(currentPaths.CADDY_FRAGMENTS_PATH, { recursive: true }); + vol.writeFileSync( + `${currentPaths.CADDY_FRAGMENTS_PATH}/manual.archive.json`, + JSON.stringify( + { + version: 1, + id: "manual.archive", + source: "manual", + routes: [ + { + id: "archive-404", + source: "manual", + hosts: ["archive.example.com"], + https: true, + upstreams: [], + staticResponse: { statusCode: 404 }, + }, + ], + }, + null, + 2, + ), + ); + + const report = await prepareCaddyMigration(); + + expect(report.summary.blockingWarnings).toBe(0); + expect( + vol.existsSync( + `${report.artifactPaths.fragmentsDir}/manual.archive.json`, + ), + ).toBe(true); + const draft = JSON.parse( + vol.readFileSync(report.artifactPaths.caddyJson, "utf8") as string, + ) as any; + const archiveRoute = draft.apps.http.servers.https.routes.find( + (item: any) => JSON.stringify(item.match).includes("archive.example.com"), + ); + expect(archiveRoute.handle[0]).toMatchObject({ + handler: "static_response", + status_code: 404, + }); + }); + + test("blocks carried manual Caddy fragments that overlap generated migration routes", async () => { + const currentPaths = paths(); + vol.mkdirSync(currentPaths.CADDY_FRAGMENTS_PATH, { recursive: true }); + vol.mkdirSync(currentPaths.DYNAMIC_TRAEFIK_PATH, { recursive: true }); + vol.writeFileSync( + `${currentPaths.CADDY_FRAGMENTS_PATH}/manual.conflict.json`, + JSON.stringify( + { + version: 1, + id: "manual.conflict", + source: "manual", + routes: [ + { + id: "manual-conflict", + source: "manual", + hosts: ["manual.example.com"], + https: true, + upstreams: [], + staticResponse: { statusCode: 404 }, + }, + ], + }, + null, + 2, + ), + ); + vol.writeFileSync( + `${currentPaths.MAIN_TRAEFIK_PATH}/traefik.yml`, + "entryPoints:\n web:\n address: ':80'\n", + ); + vol.writeFileSync( + `${currentPaths.DYNAMIC_TRAEFIK_PATH}/manual.yml`, + [ + "http:", + " routers:", + " manual:", + " rule: Host(`manual.example.com`)", + " entryPoints: [websecure]", + " service: manual", + " tls:", + " certResolver: letsencrypt", + " services:", + " manual:", + " loadBalancer:", + " servers:", + " - url: http://manual:8080", + ].join("\n"), + ); + vi.mocked(db.query.applications.findMany).mockResolvedValue([]); + + const report = await prepareCaddyMigration(); + + expect(report.summary.blockingWarnings).toBe(1); + expect(report.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "conflicting-manual-fragment", + source: "manual.conflict", + blocking: true, + }), + ]), + ); + expect(vol.readFileSync(report.artifactPaths.reportMd, "utf8")).toContain( + "conflicting-manual-fragment", + ); + }); + + test("reports compose-label warnings with source references during dry run", async () => { + const currentPaths = paths(); + vol.mkdirSync(currentPaths.DYNAMIC_TRAEFIK_PATH, { recursive: true }); + vol.writeFileSync( + `${currentPaths.MAIN_TRAEFIK_PATH}/traefik.yml`, + "entryPoints:\n web:\n address: ':80'\n", + ); + vol.writeFileSync( + `${currentPaths.DYNAMIC_TRAEFIK_PATH}/middlewares.yml`, + [ + "http:", + " middlewares:", + " cms-security-headers:", + " headers:", + " stsSeconds: 31536000", + " stsIncludeSubdomains: true", + " stsPreload: true", + " referrerPolicy: strict-origin-when-cross-origin", + " contentTypeNosniff: true", + ].join("\n"), + ); + vi.mocked(db.query.applications.findMany).mockResolvedValue([]); + vi.mocked(db.query.compose.findMany).mockResolvedValue([ + { + appName: "generic-fixture", + serverId: null, + composeFile: genericComposeFixture, + composeType: "docker-compose", + randomize: false, + isolatedDeployment: false, + suffix: null, + domains: [], + } as any, + ]); + + const report = await prepareCaddyMigration(); + + expect(report.inputs.composeFilesScanned).toEqual(["generic-fixture"]); + expect(report.summary.fragments).toBe(1); + expect(report.summary.blockingWarnings).toBeGreaterThan(0); + expect(report.warnings).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "unresolved-middleware", + source: "generic-fixture/cms/deploy.labels", + middlewareName: "cms-security-headers@file", + }), + ]), + ); + expect(report.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "unsupported-matcher", + source: "generic-fixture/unsupported/labels", + routerName: "unsupported", + blocking: true, + }), + ]), + ); + + const reportJson = JSON.parse( + vol.readFileSync(report.artifactPaths.reportJson, "utf8") as string, + ); + expect(reportJson.inputs.composeFilesScanned).toEqual(["generic-fixture"]); + const reportMd = vol.readFileSync( + report.artifactPaths.reportMd, + "utf8", + ) as string; + expect(reportMd).toContain("Blocking warnings"); + expect(reportMd).not.toContain("cms-security-headers@file"); + expect(reportMd).toContain("generic-fixture/unsupported/labels"); + }); + + test("normalizes dynamic-file upstream URLs with default scheme ports", async () => { + const currentPaths = paths(); + vol.mkdirSync(currentPaths.DYNAMIC_TRAEFIK_PATH, { recursive: true }); + vol.writeFileSync( + `${currentPaths.MAIN_TRAEFIK_PATH}/traefik.yml`, + "entryPoints:\n web:\n address: ':80'\n", + ); + vol.writeFileSync( + `${currentPaths.DYNAMIC_TRAEFIK_PATH}/admin.yml`, + [ + "http:", + " routers:", + " admin:", + " rule: Host(`admin.example.com`)", + " entryPoints: [websecure]", + " service: admin", + " tls:", + " certResolver: letsencrypt", + " services:", + " admin:", + " loadBalancer:", + " servers:", + " - url: http://admin", + ].join("\n"), + ); + vi.mocked(db.query.applications.findMany).mockResolvedValue([]); + + const report = await prepareCaddyMigration(); + + expect(report.validation.status).toBe("passed"); + expect(report.summary.blockingWarnings).toBe(0); + expect(report.warnings).toEqual([]); + expect(vol.readFileSync(report.artifactPaths.caddyJson, "utf8")).toContain( + '"dial": "admin:80"', + ); + }); + + test("preserves generated-only compose label IP restrictions before discarding generated labels", async () => { + const currentPaths = paths(); + vol.mkdirSync(currentPaths.DYNAMIC_TRAEFIK_PATH, { recursive: true }); + vol.writeFileSync( + `${currentPaths.MAIN_TRAEFIK_PATH}/traefik.yml`, + "entryPoints:\n web:\n address: ':80'\n", + ); + vol.writeFileSync( + `${currentPaths.DYNAMIC_TRAEFIK_PATH}/middlewares.yml`, + [ + "http:", + " middlewares:", + " internal-allowlist:", + " ipAllowList:", + " sourceRange:", + " - 192.0.2.0/24", + ].join("\n"), + ); + vi.mocked(db.query.applications.findMany).mockResolvedValue([]); + vi.mocked(db.query.compose.findMany).mockResolvedValue([ + { + appName: "sample-dashboard", + serverId: null, + composeFile: [ + "services:", + " dash-api:", + " image: dash", + " labels:", + " - traefik.enable=true", + " - traefik.docker.network=dokploy-network", + " - traefik.http.routers.sample-dashboard-42-websecure.rule=Host(`dashboard.example.com`)", + " - traefik.http.routers.sample-dashboard-42-websecure.entrypoints=websecure", + " - traefik.http.routers.sample-dashboard-42-websecure.tls.certresolver=letsencrypt", + " - traefik.http.routers.sample-dashboard-42-websecure.middlewares=internal-allowlist@file", + " - traefik.http.routers.sample-dashboard-42-websecure.service=sample-dashboard-42-websecure", + " - traefik.http.services.sample-dashboard-42-websecure.loadbalancer.server.port=8000", + ].join("\n"), + composeType: "docker-compose", + randomize: false, + isolatedDeployment: false, + suffix: null, + domains: [ + { + ...domain, + domainId: "dash-domain", + applicationId: null, + composeId: "dash-compose", + domainType: "compose", + host: "dashboard.example.com", + port: 8000, + serviceName: "dash-api", + uniqueConfigKey: 42, + }, + ], + } as any, + ]); + + const report = await prepareCaddyMigration(); + + expect(report.summary.blockingWarnings).toBe(0); + expect(report.warnings).toEqual([ + expect.objectContaining({ + code: "shadowed-route", + blocking: false, + }), + ]); + expect(report.summary.fragments).toBe(1); + const caddyJson = vol.readFileSync(report.artifactPaths.caddyJson, "utf8"); + expect(caddyJson).toContain('"remote_ip"'); + expect(caddyJson).toContain('"status_code": 403'); + }); + + test("skips live Docker Traefik label services with no running tasks", async () => { + const currentPaths = paths(); + vol.mkdirSync(currentPaths.DYNAMIC_TRAEFIK_PATH, { recursive: true }); + vol.writeFileSync( + `${currentPaths.MAIN_TRAEFIK_PATH}/traefik.yml`, + "entryPoints:\n web:\n address: ':80'\n", + ); + vi.mocked(db.query.applications.findMany).mockResolvedValue([]); + vi.mocked(db.query.compose.findMany).mockResolvedValue([]); + remoteDockerMock.listServices.mockResolvedValue([ + { + ID: "stopped-service-id", + Spec: { + Name: "stopped-stack_web", + Mode: { Replicated: { Replicas: 1 } }, + Labels: { + "traefik.http.routers.stopped.rule": "Host(`stopped.example.com`)", + "traefik.http.routers.stopped.entrypoints": "websecure", + "traefik.http.routers.stopped.service": "stopped", + "traefik.http.routers.stopped.tls.certresolver": "letsencrypt", + "traefik.http.services.stopped.loadbalancer.server.port": "8080", + }, + }, + }, + { + ID: "running-service-id", + Spec: { + Name: "running-stack_web", + Mode: { Replicated: { Replicas: 1 } }, + Labels: { + "traefik.http.routers.running.rule": "Host(`running.example.com`)", + "traefik.http.routers.running.entrypoints": "websecure", + "traefik.http.routers.running.service": "running", + "traefik.http.routers.running.tls.certresolver": "letsencrypt", + "traefik.http.services.running.loadbalancer.server.port": "8080", + }, + }, + }, + ]); + remoteDockerMock.listTasks.mockResolvedValue([ + { + ServiceID: "running-service-id", + DesiredState: "running", + Status: { State: "running" }, + }, + ]); + + const report = await prepareCaddyMigration(); + const caddyJson = vol.readFileSync(report.artifactPaths.caddyJson, "utf8"); + + expect(report.summary.blockingWarnings).toBe(0); + expect(caddyJson).toContain("running.example.com"); + expect(caddyJson).toContain("running-stack_web:8080"); + expect(caddyJson).not.toContain("stopped.example.com"); + expect(caddyJson).not.toContain("stopped-stack_web:8080"); + }); + + test("uses inspected container network aliases for reachability checks", async () => { + const currentPaths = paths(); + vol.mkdirSync(currentPaths.DYNAMIC_TRAEFIK_PATH, { recursive: true }); + vol.writeFileSync( + `${currentPaths.MAIN_TRAEFIK_PATH}/traefik.yml`, + "entryPoints:\n web:\n address: ':80'\n", + ); + vol.writeFileSync( + `${currentPaths.DYNAMIC_TRAEFIK_PATH}/admin.yml`, + [ + "http:", + " routers:", + " admin:", + " rule: Host(`admin.example.com`)", + " entryPoints: [websecure]", + " service: admin", + " tls:", + " certResolver: letsencrypt", + " services:", + " admin:", + " loadBalancer:", + " servers:", + " - url: http://admin:80", + ].join("\n"), + ); + vi.mocked(db.query.applications.findMany).mockResolvedValue([]); + vi.mocked(db.query.compose.findMany).mockResolvedValue([]); + remoteDockerMock.listContainers.mockResolvedValue([ + { + Id: "traefik-container", + Names: ["/dokploy-traefik"], + NetworkSettings: { Networks: {} }, + Labels: {}, + }, + { + Id: "admin-container", + Names: ["/admin-console-admin-1"], + NetworkSettings: { Networks: {} }, + Labels: {}, + }, + ]); + remoteDockerMock.getContainer.mockImplementation((id: string) => ({ + inspect: vi.fn().mockResolvedValue( + id === "admin-container" + ? { + NetworkSettings: { + Networks: { + "dokploy-network": { + Aliases: ["admin-console-admin-1", "admin"], + }, + }, + }, + } + : { + NetworkSettings: { + Networks: { + "dokploy-network": { Aliases: ["dokploy-traefik"] }, + }, + }, + }, + ), + })); + + const report = await prepareCaddyMigration(); + + expect(report.summary.blockingWarnings).toBe(0); + expect(report.warnings).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: "unreachable-upstream" }), + ]), + ); + expect(vol.readFileSync(report.artifactPaths.caddyJson, "utf8")).toContain( + '"dial": "admin:80"', + ); + }); +}); diff --git a/apps/dokploy/__test__/caddy/migration/traefik-recovery.test.ts b/apps/dokploy/__test__/caddy/migration/traefik-recovery.test.ts new file mode 100644 index 0000000000..56720c50ae --- /dev/null +++ b/apps/dokploy/__test__/caddy/migration/traefik-recovery.test.ts @@ -0,0 +1,290 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const execAsyncMock = vi.hoisted(() => vi.fn()); +const execAsyncRemoteMock = vi.hoisted(() => vi.fn()); +const initializeTraefikServiceMock = vi.hoisted(() => vi.fn()); +const initializeStandaloneTraefikMock = vi.hoisted(() => vi.fn()); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: execAsyncMock, + execAsyncRemote: execAsyncRemoteMock, +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + compose: { findMany: vi.fn().mockResolvedValue([]) }, + }, + }, +})); + +vi.mock("@dokploy/server/setup/caddy-setup", () => ({ + initializeCaddyService: vi.fn(), + initializeStandaloneCaddy: vi.fn(), +})); + +vi.mock("@dokploy/server/setup/traefik-setup", () => ({ + initializeTraefikService: initializeTraefikServiceMock, + initializeStandaloneTraefik: initializeStandaloneTraefikMock, +})); + +import { + ensureTraefikRunningFromSnapshot, + getDockerResourceSnapshot, +} from "@dokploy/server/services/settings"; + +describe("Traefik rollback recovery", () => { + let resourceTypes: Record; + + beforeEach(() => { + vi.clearAllMocks(); + resourceTypes = { + "dokploy-traefik": "unknown", + dokploy: "service", + }; + initializeTraefikServiceMock.mockImplementation(async () => { + resourceTypes["dokploy-traefik"] = "service"; + }); + initializeStandaloneTraefikMock.mockImplementation(async () => { + resourceTypes["dokploy-traefik"] = "standalone"; + }); + execAsyncMock.mockImplementation(async (command: string) => { + const resourceName = command.match(/RESOURCE_NAME="([^"]+)"/)?.[1]; + if (resourceName) { + return { + stdout: `${resourceTypes[resourceName] ?? "unknown"}\n`, + stderr: "", + }; + } + if (command.includes("docker start dokploy-traefik")) { + throw new Error("No such container: dokploy-traefik"); + } + if (command.includes("docker service inspect dokploy-traefik")) { + return { + stdout: JSON.stringify({ Replicated: { Replicas: 1 } }), + stderr: "", + }; + } + if (command.includes("docker service ps dokploy-traefik")) { + return { stdout: "Running 1 second ago\n", stderr: "" }; + } + if ( + command.includes("docker container inspect dokploy-traefik") && + command.includes("'{{json .}}'") + ) { + return { + stdout: `${JSON.stringify({ + State: { + Running: resourceTypes["dokploy-traefik"] === "standalone", + }, + HostConfig: { Binds: [], RestartPolicy: { Name: "always" } }, + NetworkSettings: { Networks: { "dokploy-network": {} } }, + Config: { Labels: {} }, + })}\n`, + stderr: "", + }; + } + if ( + command.includes("docker container inspect dokploy-traefik") && + command.includes(".State.Running") + ) { + return { stdout: "true\n", stderr: "" }; + } + return { stdout: "", stderr: "" }; + }); + execAsyncRemoteMock.mockImplementation(execAsyncMock); + }); + + test("recreates missing standalone Traefik with captured snapshot metadata", async () => { + await ensureTraefikRunningFromSnapshot({ + resourceName: "dokploy-traefik", + resourceType: "standalone", + running: true, + env: "FOO=bar\nBAZ=qux", + additionalPorts: [ + { targetPort: 8080, publishedPort: 8080, protocol: "tcp" }, + ], + image: "traefik:v3.7.1", + binds: [ + "/etc/dokploy/traefik/traefik.yml:/etc/traefik/traefik.yml:ro", + "/etc/dokploy/traefik/dynamic:/etc/dokploy/traefik/dynamic", + "/etc/dokploy/traefik/acme.json:/letsencrypt/acme.json", + "/var/run/docker.sock:/var/run/docker.sock:ro", + ], + networks: ["dokploy-network"], + labels: { "example.label": "preserved" }, + restartPolicy: { Name: "unless-stopped" }, + }); + + expect(initializeStandaloneTraefikMock).toHaveBeenCalledWith({ + env: ["FOO=bar", "BAZ=qux"], + additionalPorts: [ + { targetPort: 8080, publishedPort: 8080, protocol: "tcp" }, + ], + image: "traefik:v3.7.1", + serverId: undefined, + binds: [ + "/etc/dokploy/traefik/traefik.yml:/etc/traefik/traefik.yml:ro", + "/etc/dokploy/traefik/dynamic:/etc/dokploy/traefik/dynamic", + "/etc/dokploy/traefik/acme.json:/letsencrypt/acme.json", + "/var/run/docker.sock:/var/run/docker.sock:ro", + ], + networks: ["dokploy-network"], + labels: { "example.label": "preserved" }, + serviceMounts: undefined, + restartPolicy: { Name: "unless-stopped" }, + serviceNetworks: [], + servicePlacement: undefined, + serviceLabels: { "example.label": "preserved" }, + serviceEndpointPorts: undefined, + }); + expect(initializeTraefikServiceMock).not.toHaveBeenCalled(); + }); + + test("captures standalone Traefik binds and networks in rollback snapshot", async () => { + resourceTypes["dokploy-traefik"] = "standalone"; + execAsyncMock.mockImplementation(async (command: string) => { + const resourceName = command.match(/RESOURCE_NAME="([^"]+)"/)?.[1]; + if (resourceName) { + return { + stdout: `${resourceTypes[resourceName] ?? "unknown"}\n`, + stderr: "", + }; + } + if (command.includes("docker container inspect dokploy-traefik")) { + if (command.includes("'{{json .}}'")) { + return { + stdout: `${JSON.stringify({ + State: { Running: true }, + HostConfig: { + Binds: [ + "/etc/dokploy/traefik/traefik.yml:/etc/traefik/traefik.yml:ro", + "/etc/dokploy/traefik/dynamic:/etc/dokploy/traefik/dynamic", + "/etc/dokploy/traefik/acme.json:/letsencrypt/acme.json", + "/var/run/docker.sock:/var/run/docker.sock:ro", + ], + RestartPolicy: { Name: "always" }, + }, + NetworkSettings: { Networks: { "dokploy-network": {} } }, + Config: { Labels: { "example.label": "preserved" } }, + })}\n`, + stderr: "", + }; + } + if (command.includes(".Config.Image")) { + return { stdout: "traefik:v3.7.1\n", stderr: "" }; + } + } + if (command.includes("docker container inspect dokploy-traefik")) { + return { stdout: "{}\n", stderr: "" }; + } + return { stdout: "[]\n", stderr: "" }; + }); + + const snapshot = await getDockerResourceSnapshot("dokploy-traefik"); + + expect(snapshot.binds).toEqual([ + "/etc/dokploy/traefik/traefik.yml:/etc/traefik/traefik.yml:ro", + "/etc/dokploy/traefik/dynamic:/etc/dokploy/traefik/dynamic", + "/etc/dokploy/traefik/acme.json:/letsencrypt/acme.json", + "/var/run/docker.sock:/var/run/docker.sock:ro", + ]); + expect(snapshot.networks).toEqual(["dokploy-network"]); + expect(snapshot.labels).toEqual({ "example.label": "preserved" }); + expect(snapshot.restartPolicy).toEqual({ Name: "always" }); + }); + + test("recreates missing Traefik service with captured service shape", async () => { + await ensureTraefikRunningFromSnapshot({ + resourceName: "dokploy-traefik", + resourceType: "service", + running: false, + replicas: 2, + env: "FOO=bar", + additionalPorts: [ + { targetPort: 8080, publishedPort: 8080, protocol: "tcp" }, + ], + image: "traefik:v3.7.1", + mounts: [ + { + Type: "bind", + Source: "/etc/dokploy/traefik/acme.json", + Target: "/letsencrypt/acme.json", + }, + ], + networks: [{ Target: "dokploy-network" }], + labels: { "service.label": "preserved" }, + containerLabels: { "container.label": "preserved" }, + placement: { Constraints: ["node.role==manager"] }, + endpointPorts: [ + { + TargetPort: 443, + PublishedPort: 443, + Protocol: "tcp", + PublishMode: "host", + }, + ], + }); + + expect(initializeTraefikServiceMock).toHaveBeenCalledWith( + expect.objectContaining({ + env: ["FOO=bar"], + replicas: 2, + serviceMounts: [ + { + Type: "bind", + Source: "/etc/dokploy/traefik/acme.json", + Target: "/letsencrypt/acme.json", + }, + ], + serviceNetworks: [{ Target: "dokploy-network" }], + serviceLabels: { "service.label": "preserved" }, + serviceContainerLabels: { "container.label": "preserved" }, + servicePlacement: { Constraints: ["node.role==manager"] }, + serviceEndpointPorts: [ + { + TargetPort: 443, + PublishedPort: 443, + Protocol: "tcp", + PublishMode: "host", + }, + ], + }), + ); + }); + + test("falls back to generic setup when exact recreation fails", async () => { + initializeStandaloneTraefikMock.mockRejectedValueOnce( + new Error("stale mount"), + ); + + await ensureTraefikRunningFromSnapshot({ + resourceName: "dokploy-traefik", + resourceType: "standalone", + running: true, + image: "traefik:v3.7.1", + }); + + expect(initializeStandaloneTraefikMock).toHaveBeenCalledOnce(); + expect(initializeTraefikServiceMock).toHaveBeenCalledWith( + expect.objectContaining({ image: "traefik:v3.7.1" }), + ); + }); + + test("falls back to standalone recreation when no service shape can be inferred", async () => { + resourceTypes.dokploy = "unknown"; + + await ensureTraefikRunningFromSnapshot({ + resourceName: "dokploy-traefik", + resourceType: "unknown", + running: false, + }); + + expect(initializeStandaloneTraefikMock).toHaveBeenCalledWith({ + env: undefined, + additionalPorts: [], + image: undefined, + serverId: undefined, + }); + }); +}); diff --git a/apps/dokploy/__test__/caddy/migration/upstream-preflight.test.ts b/apps/dokploy/__test__/caddy/migration/upstream-preflight.test.ts new file mode 100644 index 0000000000..0ae1d2d7f2 --- /dev/null +++ b/apps/dokploy/__test__/caddy/migration/upstream-preflight.test.ts @@ -0,0 +1,270 @@ +import { fs, vol } from "memfs"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +vi.mock("node:fs", () => ({ + ...fs, + default: fs, +})); + +const execAsyncMock = vi.hoisted(() => vi.fn()); +const execAsyncRemoteMock = vi.hoisted(() => vi.fn()); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: execAsyncMock, + execAsyncRemote: execAsyncRemoteMock, +})); + +import type { CaddyMigrationReport, CaddyRouteFragment } from "@dokploy/server"; +import { + getCaddyMigrationArtifactPaths, + runCaddyMigrationUpstreamPreflight, +} from "@dokploy/server"; + +const createReport = (migrationId: string): CaddyMigrationReport => { + const artifactPaths = getCaddyMigrationArtifactPaths(migrationId); + return { + migrationId, + serverId: null, + createdAt: "2026-05-22T00:00:00.000Z", + updatedAt: "2026-05-22T00:00:00.000Z", + status: "prepared", + sourceProvider: "traefik", + targetProvider: "caddy", + artifactPaths, + inputs: { + traefikStaticConfigPath: "", + traefikStaticConfigFound: true, + dynamicFiles: [], + dbApplicationDomains: 0, + dbComposeDomains: 0, + composeFilesScanned: [], + composeFilesSkipped: [], + }, + summary: { + fragments: 1, + routes: 1, + warnings: 0, + blockingWarnings: 0, + }, + validation: { status: "passed", message: "ok" }, + warnings: [], + events: [], + }; +}; + +const writeFragment = ( + report: CaddyMigrationReport, + fragment: CaddyRouteFragment, +) => { + vol.mkdirSync(report.artifactPaths.fragmentsDir, { recursive: true }); + vol.writeFileSync( + `${report.artifactPaths.fragmentsDir}/${fragment.id}.json`, + `${JSON.stringify(fragment, null, 2)}\n`, + ); +}; + +describe("Caddy migration upstream preflight", () => { + beforeEach(() => { + vol.reset(); + vi.clearAllMocks(); + execAsyncMock.mockResolvedValue({ stdout: "passed\n", stderr: "" }); + execAsyncRemoteMock.mockResolvedValue({ stdout: "passed\n", stderr: "" }); + }); + + test("deduplicates upstream probes while preserving every route reference", async () => { + const report = createReport("caddy-preflight-dedupe"); + writeFragment(report, { + version: 1, + id: "manual", + source: "manual", + routes: [ + { + id: "route-a", + source: "manual", + hosts: ["a.example.com"], + upstreams: ["http://app:3000"], + }, + { + id: "route-b", + source: "manual", + hosts: ["b.example.com"], + upstreams: ["http://app:3000"], + }, + ], + }); + + const preflight = await runCaddyMigrationUpstreamPreflight(report); + + expect(preflight.status).toBe("passed"); + expect(execAsyncMock).toHaveBeenCalledTimes(1); + expect(preflight.checks).toHaveLength(1); + expect(preflight.checks[0]).toMatchObject({ + dial: "app:3000", + host: "app", + port: 3000, + network: "dokploy-network", + status: "passed", + routes: [ + expect.objectContaining({ routeId: "route-a" }), + expect.objectContaining({ routeId: "route-b" }), + ], + }); + }); + + test("reports invalid no-port upstreams without running Docker", async () => { + const report = createReport("caddy-preflight-invalid"); + writeFragment(report, { + version: 1, + id: "manual", + source: "manual", + routes: [ + { + id: "admin", + source: "manual", + hosts: ["admin.example.com"], + upstreams: ["http://admin"], + }, + ], + }); + + const preflight = await runCaddyMigrationUpstreamPreflight(report); + + expect(preflight.status).toBe("failed"); + expect(execAsyncMock).not.toHaveBeenCalled(); + expect(preflight.checks[0]).toMatchObject({ + dial: "http://admin", + network: "dokploy-network", + status: "failed", + reason: expect.stringContaining("explicit port"), + routes: [expect.objectContaining({ routeId: "admin" })], + }); + }); + + test("records DNS and TCP probe failures with route context", async () => { + const report = createReport("caddy-preflight-dns"); + writeFragment(report, { + version: 1, + id: "manual", + source: "manual", + routes: [ + { + id: "missing", + source: "manual", + hosts: ["missing.example.com"], + upstreams: ["http://missing:3000"], + }, + ], + }); + execAsyncMock.mockRejectedValueOnce( + Object.assign(new Error("probe failed"), { stdout: "dns_failed\n" }), + ); + + const preflight = await runCaddyMigrationUpstreamPreflight(report); + + expect(preflight.status).toBe("failed"); + expect(preflight.checks[0]).toMatchObject({ + dial: "missing:3000", + host: "missing", + port: 3000, + network: "dokploy-network", + status: "failed", + reason: "DNS resolution failed", + routes: [expect.objectContaining({ routeId: "missing" })], + }); + }); + + test("probes identical dials separately when route networks differ", async () => { + const report = createReport("caddy-preflight-networks"); + writeFragment(report, { + version: 1, + id: "manual", + source: "manual", + routes: [ + { + id: "shared-a", + source: "manual", + hosts: ["a.example.com"], + upstreams: ["http://web:8080"], + upstreamNetwork: "project-a", + }, + { + id: "shared-b", + source: "manual", + hosts: ["b.example.com"], + upstreams: ["http://web:8080"], + upstreamNetwork: "project-b", + }, + ], + }); + + const preflight = await runCaddyMigrationUpstreamPreflight(report); + + expect(preflight.status).toBe("passed"); + expect(preflight.network).toBe("mixed"); + expect(preflight.networks).toEqual(["project-a", "project-b"]); + expect(preflight.checks).toHaveLength(2); + expect(execAsyncMock).toHaveBeenCalledTimes(2); + expect(execAsyncMock.mock.calls[0]?.[0]).toContain("--network project-a"); + expect(execAsyncMock.mock.calls[1]?.[0]).toContain("--network project-b"); + }); + + test("uses a one-shot container probe on the route network", async () => { + const report = createReport("caddy-preflight-service"); + writeFragment(report, { + version: 1, + id: "manual", + source: "manual", + routes: [ + { + id: "app", + source: "manual", + hosts: ["app.example.com"], + upstreams: ["http://app:3000"], + }, + ], + }); + execAsyncMock.mockResolvedValueOnce({ stdout: "passed\n", stderr: "" }); + + const preflight = await runCaddyMigrationUpstreamPreflight(report); + + expect(preflight.status).toBe("passed"); + expect(preflight.probeMode).toBe("standalone"); + expect(execAsyncMock.mock.calls[0]?.[0]).toContain("docker run --rm"); + expect(execAsyncMock.mock.calls[0]?.[0]).toContain( + "--network dokploy-network", + ); + }); + + test("falls back to a temporary service probe when the overlay network is not attachable", async () => { + const report = createReport("caddy-preflight-service-fallback"); + writeFragment(report, { + version: 1, + id: "manual", + source: "manual", + routes: [ + { + id: "app", + source: "manual", + hosts: ["app.example.com"], + upstreams: ["http://app:3000"], + }, + ], + }); + execAsyncMock + .mockRejectedValueOnce( + Object.assign(new Error("docker run failed"), { + stderr: + "Error response from daemon: network dokploy-network is not manually attachable", + }), + ) + .mockResolvedValueOnce({ stdout: "passed\n", stderr: "" }); + + const preflight = await runCaddyMigrationUpstreamPreflight(report); + + expect(preflight.status).toBe("passed"); + expect(preflight.probeMode).toBe("service"); + expect(execAsyncMock).toHaveBeenCalledTimes(2); + expect(execAsyncMock.mock.calls[0]?.[0]).toContain("docker run --rm"); + expect(execAsyncMock.mock.calls[1]?.[0]).toContain("docker service create"); + }); +}); diff --git a/apps/dokploy/__test__/caddy/setup.test.ts b/apps/dokploy/__test__/caddy/setup.test.ts new file mode 100644 index 0000000000..21d28a7332 --- /dev/null +++ b/apps/dokploy/__test__/caddy/setup.test.ts @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +const validateCaddyConfigWithContainerMock = vi.hoisted(() => vi.fn()); +const ensureDefaultCaddyConfigMock = vi.hoisted(() => vi.fn()); +const getRemoteDockerMock = vi.hoisted(() => vi.fn()); + +vi.mock("@dokploy/server/utils/caddy/config", () => ({ + ensureDefaultCaddyConfig: ensureDefaultCaddyConfigMock, + reloadCaddyAfterValidation: vi.fn(), + validateCaddyConfigWithContainer: validateCaddyConfigWithContainerMock, +})); + +vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({ + getRemoteDocker: getRemoteDockerMock, +})); + +const loadCaddySetup = async () => { + vi.resetModules(); + vi.unstubAllEnvs(); + vi.stubEnv("CADDY_VERSION", ""); + return import("@dokploy/server/setup/caddy-setup"); +}; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); +}); + +describe("Caddy runtime setup", () => { + test("defaults to the pinned Caddy 2.11.3 image tag", async () => { + const { CADDY_VERSION } = await loadCaddySetup(); + + expect(CADDY_VERSION).toBe("2.11.3"); + }); + + test("uses the pinned default image for standalone Caddy", async () => { + const createContainer = vi.fn(); + const start = vi.fn(); + const remove = vi.fn().mockRejectedValue(new Error("missing")); + const docker = { + pull: vi.fn( + (_imageName: string, callback: (error: Error | null) => void) => { + callback(null); + }, + ), + modem: { followProgress: vi.fn() }, + createContainer, + getContainer: vi.fn(() => ({ remove, start })), + }; + getRemoteDockerMock.mockResolvedValue(docker); + ensureDefaultCaddyConfigMock.mockResolvedValue(undefined); + validateCaddyConfigWithContainerMock.mockResolvedValue(undefined); + const { initializeStandaloneCaddy } = await loadCaddySetup(); + + await initializeStandaloneCaddy(); + + expect(docker.pull).toHaveBeenCalledWith( + "caddy:2.11.3", + expect.any(Function), + ); + expect(createContainer).toHaveBeenCalledWith( + expect.objectContaining({ + Image: "caddy:2.11.3", + HostConfig: expect.objectContaining({ + Binds: expect.arrayContaining([ + expect.stringMatching(/\/caddy:\/etc\/caddy$/), + ]), + }), + }), + ); + expect( + (createContainer.mock.calls[0]?.[0] as any).HostConfig.Binds, + ).not.toEqual( + expect.arrayContaining([ + expect.stringMatching( + /\/caddy\/caddy\.json:\/etc\/caddy\/caddy\.json$/, + ), + ]), + ); + }); +}); 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 eda4dace58..e934820bda 100644 --- a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts +++ b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts @@ -18,6 +18,7 @@ type WebServerSettings = typeof webServerSettings.$inferSelect; const baseSettings: WebServerSettings = { id: "", + webServerProvider: "traefik", https: false, certificateType: "none", host: null, diff --git a/apps/dokploy/components/dashboard/application/advanced/traefik/show-traefik-config.tsx b/apps/dokploy/components/dashboard/application/advanced/traefik/show-traefik-config.tsx index 94efbc285c..035e13bb2a 100644 --- a/apps/dokploy/components/dashboard/application/advanced/traefik/show-traefik-config.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/traefik/show-traefik-config.tsx @@ -1,4 +1,5 @@ import { File, Loader2 } from "lucide-react"; +import { AlertBlock } from "@/components/shared/alert-block"; import { CodeEditor } from "@/components/shared/code-editor"; import { Card, @@ -17,7 +18,17 @@ interface Props { export const ShowTraefikConfig = ({ applicationId }: Props) => { const { data: permissions } = api.user.getPermissions.useQuery(); const canRead = permissions?.traefikFiles.read ?? false; - const { data, isPending } = api.application.readTraefikConfig.useQuery( + const { data: application } = api.application.one.useQuery( + { applicationId }, + { enabled: !!applicationId && canRead }, + ); + const { data: activeProvider } = + api.settings.getActiveWebServerProvider.useQuery( + { serverId: application?.serverId || undefined }, + { enabled: canRead && !!application }, + ); + const providerLabel = activeProvider === "caddy" ? "Caddy" : "Traefik"; + const { data, isPending } = api.application.readWebServerConfig.useQuery( { applicationId, }, @@ -30,15 +41,22 @@ export const ShowTraefikConfig = ({ applicationId }: Props) => {
- Traefik + {providerLabel} - Modify the traefik config, in rare cases you may need to add - specific config, be careful because modifying incorrectly can break - traefik and your application + {activeProvider === "caddy" + ? "Review generated Caddy route fragments for this application. Caddy manages certificates for HTTPS domains; Traefik YAML and custom certificate resolvers do not apply." + : "Modify the Traefik config in rare cases. Be careful: invalid Traefik YAML can break the application route."}
+ {activeProvider === "caddy" && ( + + For Caddy, domain changes generate JSON fragments and Caddy handles + ACME certificates automatically. Use Settings → Web Server for the + active Caddy config and migration artifacts. + + )} {isPending ? ( Loading... @@ -48,7 +66,7 @@ export const ShowTraefikConfig = ({ applicationId }: Props) => {
- No traefik config detected + No {providerLabel} config detected
) : ( @@ -60,9 +78,11 @@ export const ShowTraefikConfig = ({ applicationId }: Props) => { disabled className="font-mono" /> -
- -
+ {activeProvider !== "caddy" && ( +
+ +
+ )} )} diff --git a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx index b232591e4b..8fd75b68be 100644 --- a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx +++ b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx @@ -178,6 +178,13 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { const { mutateAsync: generateDomain, isPending: isLoadingGenerate } = api.domain.generateDomain.useMutation(); + const { data: activeProvider, isLoading: isLoadingProvider } = + api.settings.getActiveWebServerProvider.useQuery( + { serverId: application?.serverId || undefined }, + { enabled: !!application }, + ); + const isCaddyProvider = activeProvider === "caddy"; + const { data: canGenerateTraefikMeDomains } = api.domain.canGenerateTraefikMeDomains.useQuery({ serverId: application?.serverId || "", @@ -264,12 +271,15 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { } }, [form, data, isPending, domainId]); - // Separate effect for handling custom cert resolver validation + // Separate effect for handling provider-specific certificate fields useEffect(() => { if (certificateType === "custom") { form.trigger("customCertResolver"); } - }, [certificateType, form]); + if (isCaddyProvider && certificateType !== "custom") { + form.setValue("customCertResolver", undefined); + } + }, [certificateType, form, isCaddyProvider]); const dictionary = { success: domainId ? "Domain Updated" : "Domain Created", @@ -281,6 +291,13 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { }; const onSubmit = async (data: Domain) => { + if (isCaddyProvider && data.certificateType === "custom") { + toast.error( + "Caddy does not support Traefik custom certificate resolvers. Choose Let's Encrypt or None.", + ); + return; + } + await mutateAsync({ domainId, ...(data.domainType === "application" && { @@ -302,6 +319,9 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { await utils.application.readTraefikConfig.invalidate({ applicationId: id, }); + await utils.application.readWebServerConfig.invalidate({ + applicationId: id, + }); } else if (data.domainType === "compose") { await utils.domain.byComposeId.invalidate({ composeId: id, @@ -337,6 +357,15 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { )} + {isCaddyProvider && ( + + This server uses Caddy. Dokploy will generate Caddy route fragments, + Caddy will manage HTTPS certificates for public DNS names, and + Traefik-only custom entrypoints, middleware references, and custom + certificate resolvers are hidden. + + )} +
{ }} /> - ( - -
- Custom Entrypoint - - Use custom entrypoint for domain -
- "web" and/or "websecure" is used by default. -
- -
- - { - field.onChange(checked); - if (!checked) { - form.setValue("customEntrypoint", undefined); - } - }} - /> - -
- )} - /> + {!isCaddyProvider && ( + ( + +
+ Custom Entrypoint + + Use custom entrypoint for domain +
+ "web" and/or "websecure" is used by default. +
+ +
+ + { + field.onChange(checked); + if (!checked) { + form.setValue("customEntrypoint", undefined); + } + }} + /> + +
+ )} + /> + )} - {useCustomEntrypoint && ( + {!isCaddyProvider && useCustomEntrypoint && ( {
HTTPS - Automatically provision SSL Certificate. + {isCaddyProvider + ? "Let Caddy manage HTTPS automatically for this host." + : "Automatically provision SSL Certificate."}
@@ -758,9 +791,15 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { None - Let's Encrypt + {isCaddyProvider + ? "Caddy-managed HTTPS (ACME)" + : "Let's Encrypt"} - Custom + {!isCaddyProvider && ( + + Custom + + )} @@ -769,7 +808,15 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { }} /> - {certificateType === "custom" && ( + {isCaddyProvider && certificateType === "custom" && ( + + This domain uses a Traefik custom certificate resolver. + Caddy does not use resolver names; choose Caddy-managed + HTTPS or None before saving. + + )} + + {!isCaddyProvider && certificateType === "custom" && ( { )} )} - ( - -
- Middlewares - - - -
- ? -
-
- -

- Add Traefik middleware references. Middlewares - must be defined in your Traefik configuration. -

-
-
-
-
-
- {field.value?.map((name, index) => ( - - {name} - { - const newMiddlewares = [...(field.value || [])]; - newMiddlewares.splice(index, 1); - form.setValue("middlewares", newMiddlewares); + {!isCaddyProvider && ( + ( + +
+ Middlewares + + + +
+ ? +
+
+ +

+ Add Traefik middleware references. Middlewares + must be defined in your Traefik configuration. +

+
+
+
+
+
+ {field.value?.map((name, index) => ( + + {name} + { + const newMiddlewares = [...(field.value || [])]; + newMiddlewares.splice(index, 1); + form.setValue("middlewares", newMiddlewares); + }} + /> + + ))} +
+ +
+ { + if (e.key === "Enter") { + e.preventDefault(); + const input = e.currentTarget; + const value = input.value.trim(); + if (value && !field.value?.includes(value)) { + form.setValue("middlewares", [ + ...(field.value || []), + value, + ]); + input.value = ""; + } + } }} /> - - ))} -
- -
- { - if (e.key === "Enter") { - e.preventDefault(); - const input = e.currentTarget; + -
-
- -
- )} - /> + }} + > + Add + +
+ + +
+ )} + /> + )} - diff --git a/apps/dokploy/components/dashboard/settings/servers/actions/show-traefik-actions.tsx b/apps/dokploy/components/dashboard/settings/servers/actions/show-traefik-actions.tsx index 65957a881c..9a1d3c26a4 100644 --- a/apps/dokploy/components/dashboard/settings/servers/actions/show-traefik-actions.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/actions/show-traefik-actions.tsx @@ -21,16 +21,25 @@ interface Props { serverId?: string; } export const ShowTraefikActions = ({ serverId }: Props) => { - const { mutateAsync: reloadTraefik, isPending: reloadTraefikIsLoading } = - api.settings.reloadTraefik.useMutation(); + const { data: activeProvider, isLoading: isLoadingProvider } = + api.settings.getActiveWebServerProvider.useQuery({ serverId }); + const providerLabel = activeProvider === "caddy" ? "Caddy" : "Traefik"; + const resourceName = + activeProvider === "caddy" ? "dokploy-caddy" : "dokploy-traefik"; + + const { mutateAsync: reloadWebServer, isPending: reloadTraefikIsLoading } = + api.settings.reloadWebServer.useMutation(); const { mutateAsync: toggleDashboard, isPending: toggleDashboardIsLoading } = api.settings.toggleDashboard.useMutation(); - const { data: haveTraefikDashboardPortEnabled, refetch: refetchDashboard } = - api.settings.haveTraefikDashboardPortEnabled.useQuery({ + const { data: webServerDashboardState, refetch: refetchDashboard } = + api.settings.getWebServerDashboardState.useQuery({ serverId, }); + const haveTraefikDashboardPortEnabled = + webServerDashboardState?.provider === "traefik" && + webServerDashboardState.enabled; const { execute: executeWithHealthCheck, @@ -50,7 +59,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => { } = useHealthCheckAfterMutation({ initialDelay: 5000, pollInterval: 4000, - successMessage: "Traefik Reloaded", + successMessage: `${providerLabel} reloaded`, }); return ( @@ -58,6 +67,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => { { > @@ -84,12 +95,12 @@ export const ShowTraefikActions = ({ serverId }: Props) => { onClick={async () => { try { await executeReloadWithHealthCheck(() => - reloadTraefik({ serverId }), + reloadWebServer({ serverId }), ); } catch (error) { const errorMessage = (error as Error)?.message || - "Failed to reload Traefik. Please try again."; + `Failed to reload ${providerLabel}. Please try again.`; toast.error(errorMessage); } }} @@ -99,7 +110,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => { Reload @@ -119,54 +130,56 @@ export const ShowTraefikActions = ({ serverId }: Props) => { - - - The Traefik container will be recreated from scratch. This - means the container will be deleted and created again, which - may cause downtime in your applications. - -

- Are you sure you want to{" "} - {haveTraefikDashboardPortEnabled ? "disable" : "enable"} the - Traefik dashboard? -

- - } - onClick={async () => { - try { - await executeWithHealthCheck(() => - toggleDashboard({ - enableDashboard: !haveTraefikDashboardPortEnabled, - serverId: serverId, - }), - ); - } catch (error) { - const errorMessage = - (error as Error)?.message || - "Failed to toggle dashboard. Please check if port 8080 is available."; - toast.error(errorMessage); + {activeProvider === "traefik" && ( + - e.preventDefault()} - className="w-full cursor-pointer space-x-3" + description={ +
+ + The Traefik container will be recreated from scratch. This + means the container will be deleted and created again, which + may cause downtime in your applications. + +

+ Are you sure you want to{" "} + {haveTraefikDashboardPortEnabled ? "disable" : "enable"} the + Traefik dashboard? +

+
+ } + onClick={async () => { + try { + await executeWithHealthCheck(() => + toggleDashboard({ + enableDashboard: !haveTraefikDashboardPortEnabled, + serverId: serverId, + }), + ); + } catch (error) { + const errorMessage = + (error as Error)?.message || + "Failed to toggle dashboard. Please check if port 8080 is available."; + toast.error(errorMessage); + } + }} + disabled={toggleDashboardIsLoading || isHealthCheckExecuting} + type="default" > - - {haveTraefikDashboardPortEnabled ? "Disable" : "Enable"}{" "} - Dashboard - -
-
+ e.preventDefault()} + className="w-full cursor-pointer space-x-3" + > + + {haveTraefikDashboardPortEnabled ? "Disable" : "Enable"}{" "} + Dashboard + + +
+ )} e.preventDefault()} diff --git a/apps/dokploy/components/dashboard/settings/web-server.tsx b/apps/dokploy/components/dashboard/settings/web-server.tsx index a383fbf7d6..ab16e57045 100644 --- a/apps/dokploy/components/dashboard/settings/web-server.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server.tsx @@ -13,7 +13,9 @@ import { ShowDokployActions } from "./servers/actions/show-dokploy-actions"; import { ShowStorageActions } from "./servers/actions/show-storage-actions"; import { ShowTraefikActions } from "./servers/actions/show-traefik-actions"; import { ToggleDockerCleanup } from "./servers/actions/toggle-docker-cleanup"; +import { CaddyMigrationPanel } from "./web-server/caddy-migration-panel"; import { UpdateServer } from "./web-server/update-server"; +import { WebServerProviderSelector } from "./web-server/web-server-provider-selector"; export const WebServer = () => { const { data: webServerSettings } = @@ -42,6 +44,9 @@ export const WebServer = () => { */} + + +
diff --git a/apps/dokploy/components/dashboard/settings/web-server/caddy-migration-panel.tsx b/apps/dokploy/components/dashboard/settings/web-server/caddy-migration-panel.tsx new file mode 100644 index 0000000000..cba2a3e0a1 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/web-server/caddy-migration-panel.tsx @@ -0,0 +1,318 @@ +import type { CaddyMigrationReport } from "@dokploy/server"; +import { AlertTriangle, CheckCircle2, Loader2, RotateCcw } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { AlertBlock } from "@/components/shared/alert-block"; +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"; + +interface Props { + serverId?: string; +} + +const statusLabel = (report: CaddyMigrationReport) => { + if (report.summary.blockingWarnings > 0) { + return "Blocked"; + } + if (report.validation.status !== "passed") { + return "Validation required"; + } + return "Ready"; +}; + +export const CaddyMigrationPanel = ({ serverId }: Props) => { + const [migrationId, setMigrationId] = useState(null); + const [maintenanceConfirmed, setMaintenanceConfirmed] = useState(false); + + const { data: activeProvider } = + api.settings.getActiveWebServerProvider.useQuery({ serverId }); + const utils = api.useUtils(); + const { mutateAsync: prepareMigration, isPending: isPreparing } = + api.settings.prepareCaddyMigration.useMutation(); + const { mutateAsync: applyMigration, isPending: isApplying } = + api.settings.applyCaddyMigration.useMutation(); + const { mutateAsync: rollbackMigration, isPending: isRollingBack } = + api.settings.rollbackCaddyMigration.useMutation(); + const { data: fetchedReport, refetch: refetchReport } = + api.settings.getCaddyMigrationReport.useQuery( + { migrationId: migrationId ?? "", serverId }, + { enabled: !!migrationId }, + ); + const [preparedReport, setPreparedReport] = + useState(null); + const report = fetchedReport ?? preparedReport; + const blockingWarnings = report?.warnings.filter( + (warning) => warning.blocking, + ); + const nonBlockingWarnings = report?.warnings.filter( + (warning) => !warning.blocking, + ); + const isMutating = isPreparing || isApplying || isRollingBack; + const canApply = + !!report && + report.summary.blockingWarnings === 0 && + report.validation.status === "passed" && + maintenanceConfirmed && + !isMutating; + const canRollback = !!report && report.status !== "prepared" && !isMutating; + + const handleDryRun = async () => { + try { + const nextReport = await prepareMigration({ serverId }); + setPreparedReport(nextReport); + setMigrationId(nextReport.migrationId); + setMaintenanceConfirmed(false); + toast.success("Caddy migration dry run prepared"); + } catch (error) { + toast.error( + (error as Error).message || "Error preparing Caddy migration", + ); + } + }; + + const handleApply = async () => { + if (!report) return; + try { + await applyMigration({ + migrationId: report.migrationId, + serverId, + confirmMaintenanceWindow: true, + }); + toast.success("Caddy migration apply started"); + await utils.settings.getActiveWebServerProvider.invalidate({ serverId }); + await refetchReport(); + } catch (error) { + toast.error((error as Error).message || "Error applying Caddy migration"); + } + }; + + const handleRollback = async () => { + if (!report) return; + try { + await rollbackMigration({ migrationId: report.migrationId, serverId }); + toast.success("Caddy rollback started"); + await utils.settings.getActiveWebServerProvider.invalidate({ serverId }); + await refetchReport(); + } catch (error) { + toast.error( + (error as Error).message || "Error rolling back Caddy migration", + ); + } + }; + + return ( + + +
+
+ Caddy migration + + Prepare a reviewable Traefik → Caddy migration before any + maintenance-window cutover. + +
+ +
+
+ + {activeProvider === "caddy" && ( + + Caddy is already the active provider. Dry runs are still useful for + reviewing translated Traefik artifacts before rollback or follow-up + changes. + + )} + + {isPreparing && ( +
+ Preparing migration + artifacts... +
+ )} + + {report && ( +
+
+
+
+ + Dry run {report.migrationId} + + 0 + ? "destructive" + : "secondary" + } + > + {statusLabel(report)} + +
+

+ Created {new Date(report.createdAt).toLocaleString()} · + Status: {report.status} · Validation:{" "} + {report.validation.status} +

+
+
+
+
{report.summary.fragments}
+
Fragments
+
+
+
{report.summary.routes}
+
Routes
+
+
+
+ {report.summary.blockingWarnings}/{report.summary.warnings} +
+
Blocking
+
+
+
+ + {report.validation.message && ( + + {report.validation.message} + + )} + +
+
+
Inputs
+
    +
  • + Traefik static config:{" "} + {report.inputs.traefikStaticConfigFound + ? "found" + : "not found"} +
  • +
  • Dynamic files: {report.inputs.dynamicFiles.length}
  • +
  • + Application domains: {report.inputs.dbApplicationDomains} +
  • +
  • Compose domains: {report.inputs.dbComposeDomains}
  • +
+
+
+
Artifacts
+
    +
  • Report: {report.artifactPaths.reportMd}
  • +
  • Draft Caddy JSON: {report.artifactPaths.caddyJson}
  • +
  • Fragments: {report.artifactPaths.fragmentsDir}
  • +
+
+
+ + {blockingWarnings && blockingWarnings.length > 0 ? ( + +
+
Blocking items
+
    + {blockingWarnings.map((warning, index) => ( +
  • + {warning.source ? `${warning.source}: ` : ""} + {warning.message} +
  • + ))} +
+
+
+ ) : ( + } + > + No blocking migration items were reported. + + )} + + {nonBlockingWarnings && nonBlockingWarnings.length > 0 && ( +
+ + Review non-blocking warnings ({nonBlockingWarnings.length}) + +
    + {nonBlockingWarnings.map((warning, index) => ( +
  • + {warning.source ? `${warning.source}: ` : ""} + {warning.message} +
  • + ))} +
+
+ )} + +
+
+ +
+

+ Apply stops Traefik, starts Caddy on ports 80/443/443 UDP, + and changes the active provider only after cutover checks + pass. Run this during a maintenance window. +

+
+ + setMaintenanceConfirmed(checked === true) + } + /> + +
+
+
+
+ +
+ + +
+
+ )} +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/web-server/edit-traefik-env.tsx b/apps/dokploy/components/dashboard/settings/web-server/edit-traefik-env.tsx index 1312b96c59..b5f57f8bbe 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/edit-traefik-env.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/edit-traefik-env.tsx @@ -40,19 +40,23 @@ interface Props { export const EditTraefikEnv = ({ children, serverId }: Props) => { const [canEdit, setCanEdit] = useState(true); - const { data } = api.settings.readTraefikEnv.useQuery({ + const { data: activeProvider } = + api.settings.getActiveWebServerProvider.useQuery({ serverId }); + const providerLabel = activeProvider === "caddy" ? "Caddy" : "Traefik"; + + const { data } = api.settings.readWebServerEnv.useQuery({ serverId, }); const { mutateAsync, isPending, error, isError } = - api.settings.writeTraefikEnv.useMutation(); + api.settings.writeWebServerEnv.useMutation(); const { execute: executeWithHealthCheck, isExecuting: isHealthCheckExecuting, } = useHealthCheckAfterMutation({ initialDelay: 5000, - successMessage: "Traefik Env Updated", + successMessage: `${providerLabel} env updated`, }); const form = useForm({ @@ -80,7 +84,7 @@ export const EditTraefikEnv = ({ children, serverId }: Props) => { }), ); } catch { - toast.error("Error updating the Traefik env"); + toast.error(`Error updating the ${providerLabel} env`); } }; @@ -109,9 +113,9 @@ export const EditTraefikEnv = ({ children, serverId }: Props) => { {children} - Update Traefik Environment + Update {providerLabel} Environment - Update the traefik environment variables + Update the active web server environment variables. {isError && {error?.message}} @@ -133,14 +137,20 @@ export const EditTraefikEnv = ({ children, serverId }: Props) => { diff --git a/apps/dokploy/components/dashboard/settings/web-server/manage-traefik-ports.tsx b/apps/dokploy/components/dashboard/settings/web-server/manage-traefik-ports.tsx index 6f42c804b5..a935492966 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/manage-traefik-ports.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/manage-traefik-ports.tsx @@ -48,17 +48,21 @@ const PortSchema = z.object({ protocol: z.enum(["tcp", "udp", "sctp"]), }); -const TraefikPortsSchema = z.object({ +const WebServerPortsSchema = z.object({ ports: z.array(PortSchema), }); -type TraefikPortsForm = z.infer; +type WebServerPortsForm = z.infer; export const ManageTraefikPorts = ({ children, serverId }: Props) => { const [open, setOpen] = useState(false); - const form = useForm({ - resolver: zodResolver(TraefikPortsSchema), + const { data: activeProvider } = + api.settings.getActiveWebServerProvider.useQuery({ serverId }); + const providerLabel = activeProvider === "caddy" ? "Caddy" : "Traefik"; + + const form = useForm({ + resolver: zodResolver(WebServerPortsSchema), defaultValues: { ports: [], }, @@ -70,12 +74,12 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => { }); const { data: currentPorts, refetch: refetchPorts } = - api.settings.getTraefikPorts.useQuery({ + api.settings.getWebServerPorts.useQuery({ serverId, }); const { mutateAsync: updatePorts, isPending } = - api.settings.updateTraefikPorts.useMutation(); + api.settings.updateWebServerPorts.useMutation(); const { execute: executeWithHealthCheck, @@ -104,7 +108,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => { append({ targetPort: 0, publishedPort: 0, protocol: "tcp" }); }; - const onSubmit = async (data: TraefikPortsForm) => { + const onSubmit = async (data: WebServerPortsForm) => { try { await executeWithHealthCheck(() => updatePorts({ @@ -114,7 +118,9 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => { ); setOpen(false); } catch (error) { - toast.error((error as Error).message || "Error updating Traefik ports"); + toast.error( + (error as Error).message || `Error updating ${providerLabel} ports`, + ); } }; @@ -132,7 +138,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
- Add or remove additional ports for Traefik + Add or remove additional ports for {providerLabel} {fields.length} port mapping{fields.length !== 1 ? "s" : ""}{" "} configured @@ -300,8 +306,8 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {

All ports are bound directly to the host machine, - allowing Traefik to handle incoming traffic and route - it appropriately to your services. + allowing {providerLabel} to handle incoming traffic + and route it appropriately to your services.

@@ -309,9 +315,9 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => { )} - The Traefik container will be recreated from scratch. This - means the container will be deleted and created again, which - may cause downtime in your applications. + The {providerLabel} resource will be recreated from scratch. + This means the container or service will be deleted and + created again, which may cause downtime in your applications.
diff --git a/apps/dokploy/components/dashboard/settings/web-server/web-server-provider-selector.tsx b/apps/dokploy/components/dashboard/settings/web-server/web-server-provider-selector.tsx new file mode 100644 index 0000000000..7bf3713e3e --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/web-server/web-server-provider-selector.tsx @@ -0,0 +1,101 @@ +import type { WebServerProvider } from "@dokploy/server"; +import { toast } from "sonner"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { api } from "@/utils/api"; + +interface Props { + serverId?: string; +} + +const providerLabels: Record = { + traefik: "Traefik", + caddy: "Caddy", +}; + +export const WebServerProviderSelector = ({ serverId }: Props) => { + const utils = api.useUtils(); + const { data: activeProvider, isLoading } = + api.settings.getActiveWebServerProvider.useQuery({ serverId }); + const { mutateAsync: updateProvider, isPending } = + api.settings.updateActiveWebServerProvider.useMutation(); + + const handleProviderChange = async (provider: WebServerProvider) => { + if (!activeProvider || provider === activeProvider) return; + if (provider === "caddy") { + toast.error( + "Run and apply a Caddy migration dry run to activate Caddy safely.", + ); + return; + } + if (activeProvider === "caddy" && provider === "traefik") { + toast.error("Use the migration rollback action to return to Traefik."); + return; + } + + try { + await updateProvider({ provider, serverId }); + await utils.settings.getActiveWebServerProvider.invalidate({ serverId }); + await utils.settings.getWebServerDashboardState.invalidate({ serverId }); + await utils.settings.readWebServerConfig.invalidate({ serverId }); + toast.success(`Active web server set to ${providerLabels[provider]}`); + } catch (error) { + toast.error( + (error as Error).message || "Error updating active web server provider", + ); + } + }; + + return ( + + + Active provider + + Review the active web server Dokploy uses for provider-aware reload, + configuration, and new domain changes. + + + + + {activeProvider === "traefik" ? ( + + Traefik workflows remain available. Caddy activation is handled by + the migration apply flow below so cutover checks are not bypassed. + + ) : ( + + Caddy is the active provider. Traefik-specific editors remain + available only for existing Traefik configuration review. + + )} + + + ); +}; diff --git a/apps/dokploy/drizzle/0170_web_server_provider.sql b/apps/dokploy/drizzle/0170_web_server_provider.sql new file mode 100644 index 0000000000..24b513d90c --- /dev/null +++ b/apps/dokploy/drizzle/0170_web_server_provider.sql @@ -0,0 +1,3 @@ +CREATE TYPE "public"."webServerProvider" AS ENUM('traefik', 'caddy');--> statement-breakpoint +ALTER TABLE "server" ADD COLUMN "webServerProvider" "webServerProvider" DEFAULT 'traefik' NOT NULL;--> statement-breakpoint +ALTER TABLE "webServerSettings" ADD COLUMN "webServerProvider" "webServerProvider" DEFAULT 'traefik' NOT NULL; \ No newline at end of file diff --git a/apps/dokploy/drizzle/meta/0170_snapshot.json b/apps/dokploy/drizzle/meta/0170_snapshot.json new file mode 100644 index 0000000000..5f624ff410 --- /dev/null +++ b/apps/dokploy/drizzle/meta/0170_snapshot.json @@ -0,0 +1,8356 @@ +{ + "id": "93bfd2e4-aeef-4158-9b26-97da9f79b99b", + "prevId": "f3f22c15-d2d1-4ed1-9561-24aa98e30231", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is2FAEnabled": { + "name": "is2FAEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "resetPasswordToken": { + "name": "resetPasswordToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resetPasswordExpiresAt": { + "name": "resetPasswordExpiresAt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "confirmationToken": { + "name": "confirmationToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "confirmationExpiresAt": { + "name": "confirmationExpiresAt", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.apikey": { + "name": "apikey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "apikey_reference_id_user_id_fk": { + "name": "apikey_reference_id_user_id_fk", + "tableFrom": "apikey", + "tableTo": "user", + "columnsFrom": [ + "reference_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canCreateProjects": { + "name": "canCreateProjects", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canAccessToSSHKeys": { + "name": "canAccessToSSHKeys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canCreateServices": { + "name": "canCreateServices", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canDeleteProjects": { + "name": "canDeleteProjects", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canDeleteServices": { + "name": "canDeleteServices", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canAccessToDocker": { + "name": "canAccessToDocker", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canAccessToAPI": { + "name": "canAccessToAPI", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canAccessToGitProviders": { + "name": "canAccessToGitProviders", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canAccessToTraefikFiles": { + "name": "canAccessToTraefikFiles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canDeleteEnvironments": { + "name": "canDeleteEnvironments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canCreateEnvironments": { + "name": "canCreateEnvironments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "accesedProjects": { + "name": "accesedProjects", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "accessedEnvironments": { + "name": "accessedEnvironments", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "accesedServices": { + "name": "accesedServices", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "accessedGitProviders": { + "name": "accessedGitProviders", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "accessedServers": { + "name": "accessedServers", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + } + }, + "indexes": {}, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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.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 + } + }, + "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.15.4'" + }, + "herokuVersion": { + "name": "herokuVersion", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'24'" + }, + "publishDirectory": { + "name": "publishDirectory", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isStaticSpa": { + "name": "isStaticSpa", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "createEnvFile": { + "name": "createEnvFile", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "registryId": { + "name": "registryId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rollbackRegistryId": { + "name": "rollbackRegistryId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environmentId": { + "name": "environmentId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "githubId": { + "name": "githubId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gitlabId": { + "name": "gitlabId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "giteaId": { + "name": "giteaId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bitbucketId": { + "name": "bitbucketId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serverId": { + "name": "serverId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "buildServerId": { + "name": "buildServerId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "buildRegistryId": { + "name": "buildRegistryId", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "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 + }, + "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": "''" + }, + "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 + }, + "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'" + }, + "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 + } + }, + "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.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.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[]" + } + }, + "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.git_provider": { + "name": "git_provider", + "schema": "", + "columns": { + "gitProviderId": { + "name": "gitProviderId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerType": { + "name": "providerType", + "type": "gitProviderType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organizationId": { + "name": "organizationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sharedWithOrganization": { + "name": "sharedWithOrganization", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "git_provider_organizationId_organization_id_fk": { + "name": "git_provider_organizationId_organization_id_fk", + "tableFrom": "git_provider", + "tableTo": "organization", + "columnsFrom": [ + "organizationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "git_provider_userId_user_id_fk": { + "name": "git_provider_userId_user_id_fk", + "tableFrom": "git_provider", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.gitea": { + "name": "gitea", + "schema": "", + "columns": { + "giteaId": { + "name": "giteaId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "giteaUrl": { + "name": "giteaUrl", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'https://gitea.com'" + }, + "giteaInternalUrl": { + "name": "giteaInternalUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gitProviderId": { + "name": "gitProviderId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'repo,repo:status,read:user,read:org'" + }, + "last_authenticated_at": { + "name": "last_authenticated_at", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "gitea_gitProviderId_git_provider_gitProviderId_fk": { + "name": "gitea_gitProviderId_git_provider_gitProviderId_fk", + "tableFrom": "gitea", + "tableTo": "git_provider", + "columnsFrom": [ + "gitProviderId" + ], + "columnsTo": [ + "gitProviderId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github": { + "name": "github", + "schema": "", + "columns": { + "githubId": { + "name": "githubId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "githubAppName": { + "name": "githubAppName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "githubAppId": { + "name": "githubAppId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "githubClientId": { + "name": "githubClientId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "githubClientSecret": { + "name": "githubClientSecret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "githubInstallationId": { + "name": "githubInstallationId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "githubPrivateKey": { + "name": "githubPrivateKey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "githubWebhookSecret": { + "name": "githubWebhookSecret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gitProviderId": { + "name": "gitProviderId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "github_gitProviderId_git_provider_gitProviderId_fk": { + "name": "github_gitProviderId_git_provider_gitProviderId_fk", + "tableFrom": "github", + "tableTo": "git_provider", + "columnsFrom": [ + "gitProviderId" + ], + "columnsTo": [ + "gitProviderId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.gitlab": { + "name": "gitlab", + "schema": "", + "columns": { + "gitlabId": { + "name": "gitlabId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "gitlabUrl": { + "name": "gitlabUrl", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'https://gitlab.com'" + }, + "gitlabInternalUrl": { + "name": "gitlabInternalUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "application_id": { + "name": "application_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "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 + } + }, + "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 + } + }, + "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 + } + }, + "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.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 + }, + "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 + } + }, + "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 + } + }, + "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.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 + }, + "webServerProvider": { + "name": "webServerProvider", + "type": "webServerProvider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'traefik'" + }, + "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 + }, + "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": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_provider_provider_id_unique": { + "name": "sso_provider_provider_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_tag": { + "name": "project_tag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "projectId": { + "name": "projectId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tagId": { + "name": "tagId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "project_tag_projectId_project_projectId_fk": { + "name": "project_tag_projectId_project_projectId_fk", + "tableFrom": "project_tag", + "tableTo": "project", + "columnsFrom": [ + "projectId" + ], + "columnsTo": [ + "projectId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_tag_tagId_tag_tagId_fk": { + "name": "project_tag_tagId_tag_tagId_fk", + "tableFrom": "project_tag", + "tableTo": "tag", + "columnsFrom": [ + "tagId" + ], + "columnsTo": [ + "tagId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_project_tag": { + "name": "unique_project_tag", + "nullsNotDistinct": false, + "columns": [ + "projectId", + "tagId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tag": { + "name": "tag", + "schema": "", + "columns": { + "tagId": { + "name": "tagId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organizationId": { + "name": "organizationId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "tag_organizationId_organization_id_fk": { + "name": "tag_organizationId_organization_id_fk", + "tableFrom": "tag", + "tableTo": "organization", + "columnsFrom": [ + "organizationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_org_tag_name": { + "name": "unique_org_tag_name", + "nullsNotDistinct": false, + "columns": [ + "organizationId", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "firstName": { + "name": "firstName", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "lastName": { + "name": "lastName", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "isRegistered": { + "name": "isRegistered", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "expirationDate": { + "name": "expirationDate", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "enablePaidFeatures": { + "name": "enablePaidFeatures", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "allowImpersonation": { + "name": "allowImpersonation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enableEnterpriseFeatures": { + "name": "enableEnterpriseFeatures", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "licenseKey": { + "name": "licenseKey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isValidEnterpriseLicense": { + "name": "isValidEnterpriseLicense", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serversQuantity": { + "name": "serversQuantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sendInvoiceNotifications": { + "name": "sendInvoiceNotifications", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "isEnterpriseCloud": { + "name": "isEnterpriseCloud", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trustedOrigins": { + "name": "trustedOrigins", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "bookmarkedTemplates": { + "name": "bookmarkedTemplates", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "ARRAY[]::text[]" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "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 + }, + "webServerProvider": { + "name": "webServerProvider", + "type": "webServerProvider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'traefik'" + }, + "serverIp": { + "name": "serverIp", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "certificateType": { + "name": "certificateType", + "type": "certificateType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "https": { + "name": "https", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "letsEncryptEmail": { + "name": "letsEncryptEmail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sshPrivateKey": { + "name": "sshPrivateKey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enableDockerCleanup": { + "name": "enableDockerCleanup", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "logCleanupCron": { + "name": "logCleanupCron", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'0 0 * * *'" + }, + "metricsConfig": { + "name": "metricsConfig", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"server\":{\"type\":\"Dokploy\",\"refreshRate\":60,\"port\":4500,\"token\":\"\",\"retentionDays\":2,\"cronJob\":\"\",\"urlCallback\":\"\",\"thresholds\":{\"cpu\":0,\"memory\":0}},\"containers\":{\"refreshRate\":60,\"services\":{\"include\":[],\"exclude\":[]}}}'::jsonb" + }, + "whitelabelingConfig": { + "name": "whitelabelingConfig", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"appName\":null,\"appDescription\":null,\"logoUrl\":null,\"faviconUrl\":null,\"customCss\":null,\"loginLogoUrl\":null,\"supportUrl\":null,\"docsUrl\":null,\"errorPageTitle\":null,\"errorPageDescription\":null,\"metaTitle\":null,\"footerText\":null}'::jsonb" + }, + "remoteServersOnly": { + "name": "remoteServersOnly", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "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": {}, + "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.deploymentStatus": { + "name": "deploymentStatus", + "schema": "public", + "values": [ + "running", + "done", + "error", + "cancelled" + ] + }, + "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.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.webServerProvider": { + "name": "webServerProvider", + "schema": "public", + "values": [ + "traefik", + "caddy" + ] + } + }, + "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 573e42b56e..42c4b63771 100644 --- a/apps/dokploy/drizzle/meta/_journal.json +++ b/apps/dokploy/drizzle/meta/_journal.json @@ -1191,6 +1191,13 @@ "when": 1780127552074, "tag": "0169_parched_johnny_storm", "breakpoints": true + }, + { + "idx": 170, + "version": "7", + "when": 1780337191309, + "tag": "0170_web_server_provider", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/dokploy/esbuild.config.ts b/apps/dokploy/esbuild.config.ts index fa053d726c..e590de8254 100644 --- a/apps/dokploy/esbuild.config.ts +++ b/apps/dokploy/esbuild.config.ts @@ -29,6 +29,7 @@ try { "reset-password": "reset-password.ts", "reset-2fa": "reset-2fa.ts", "migrate-auth-secret": "scripts/migrate-auth-secret.ts", + "caddy-migration-rollback": "scripts/caddy-migration-rollback.ts", }, bundle: true, platform: "node", diff --git a/apps/dokploy/package.json b/apps/dokploy/package.json index c271f32bd4..4c24f12bb0 100644 --- a/apps/dokploy/package.json +++ b/apps/dokploy/package.json @@ -20,6 +20,8 @@ "migration:generate": "drizzle-kit generate --config ./server/db/drizzle.config.ts", "migration:run": "tsx -r dotenv/config migration.ts", "manual-migration:run": "tsx -r dotenv/config migrate.ts", + "caddy:migration:rollback": "tsx -r dotenv/config scripts/caddy-migration-rollback.ts", + "caddy:migration:rollback:runtime": "node -r dotenv/config dist/caddy-migration-rollback.mjs", "migration:up": "drizzle-kit up --config ./server/db/drizzle.config.ts", "migration:drop": "drizzle-kit drop --config ./server/db/drizzle.config.ts", "db:push": "drizzle-kit push --config ./server/db/drizzle.config.ts", diff --git a/apps/dokploy/scripts/caddy-migration-rollback.ts b/apps/dokploy/scripts/caddy-migration-rollback.ts new file mode 100644 index 0000000000..40e9936a3f --- /dev/null +++ b/apps/dokploy/scripts/caddy-migration-rollback.ts @@ -0,0 +1,87 @@ +import { pathToFileURL } from "node:url"; +import type { CaddyMigrationReport } from "@dokploy/server"; + +interface RollbackArgs { + migrationId: string; + serverId?: string; +} + +interface CliIo { + stdout: Pick; + stderr: Pick; +} + +const usage = + "Usage: caddy-migration-rollback --migration-id [--server-id ]"; + +export const parseCaddyRollbackArgs = (argv: string[]): RollbackArgs => { + let migrationId = ""; + let serverId: string | undefined; + for (let index = 0; index < argv.length; index++) { + const arg = argv[index]; + if (arg === "--migration-id") { + migrationId = argv[++index] ?? ""; + } else if (arg === "--server-id") { + serverId = argv[++index] || undefined; + } else if (arg === "--help" || arg === "-h") { + throw new Error(usage); + } else { + throw new Error(`Unknown argument "${arg}". ${usage}`); + } + } + if (!migrationId) { + throw new Error(`Missing --migration-id. ${usage}`); + } + return { migrationId, serverId }; +}; + +const isHelpRequest = (argv: string[]) => + argv.some((arg) => arg === "--help" || arg === "-h"); + +const buildOutput = (report: CaddyMigrationReport) => ({ + migrationId: report.migrationId, + status: report.status, + providerTarget: "traefik", + warnings: report.warnings, + summary: report.summary, + reportPath: report.artifactPaths.reportJson, +}); + +export const runCaddyMigrationRollbackCli = async ( + argv = process.argv.slice(2), + io: CliIo = process, +) => { + if (isHelpRequest(argv)) { + io.stdout.write(`${usage}\n`); + return 0; + } + + try { + const args = parseCaddyRollbackArgs(argv); + const { rollbackCaddyMigration } = await import("@dokploy/server"); + const report = await rollbackCaddyMigration(args); + const output = buildOutput(report); + io.stdout.write(`${JSON.stringify(output, null, 2)}\n`); + return report.status === "rolled_back" ? 0 : 1; + } catch (error) { + const message = error instanceof Error ? error.message : "Rollback failed"; + io.stderr.write(`${message}\n`); + io.stdout.write( + `${JSON.stringify( + { + status: "failed", + providerTarget: "traefik", + error: message, + }, + null, + 2, + )}\n`, + ); + return 1; + } +}; + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + const code = await runCaddyMigrationRollbackCli(); + process.exit(code); +} diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index c7b1f8642f..2cb64046e5 100644 --- a/apps/dokploy/server/api/routers/application.ts +++ b/apps/dokploy/server/api/routers/application.ts @@ -1,8 +1,10 @@ import { clearOldDeployments, createApplication, + createCaddyApplicationRouteFragment, deleteAllMiddlewares, findApplicationById, + findDomainsByApplicationId, findEnvironmentById, findGitProviderById, findProjectById, @@ -19,6 +21,7 @@ import { removeMonitoringDirectory, removeService, removeTraefikConfig, + resolveWebServerProvider, startService, startServiceRemote, stopService, @@ -796,6 +799,45 @@ export const applicationRouter = createTRPCRouter({ } return traefikConfig; }), + readWebServerConfig: protectedProcedure + .input(apiFindOneApplication) + .query(async ({ input, ctx }) => { + await checkServicePermissionAndAccess(ctx, input.applicationId, { + traefikFiles: ["read"], + }); + const application = await findApplicationById(input.applicationId); + const provider = await resolveWebServerProvider( + application.serverId || undefined, + ); + + if (provider === "traefik") { + if (application.serverId) { + return await readRemoteConfig( + application.serverId, + application.appName, + ); + } + return readConfig(application.appName); + } + + const domains = await findDomainsByApplicationId(input.applicationId); + const fragments = domains.map((domain) => + createCaddyApplicationRouteFragment( + application as never, + domain as never, + ), + ); + return `${JSON.stringify( + { + provider, + message: + "Generated Caddy route fragments for this application. Caddy manages HTTPS certificates automatically for HTTPS domains; Traefik custom certificate resolvers do not apply.", + fragments, + }, + null, + 2, + )}\n`; + }), dropDeployment: protectedProcedure .input( diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index 51e257ce65..0dde5aaa64 100644 --- a/apps/dokploy/server/api/routers/compose.ts +++ b/apps/dokploy/server/api/routers/compose.ts @@ -1,5 +1,5 @@ import { - addDomainToCompose, + addDomainToComposeForWebServer, clearOldDeployments, cloneCompose, createCommand, @@ -411,7 +411,10 @@ export const composeRouter = createTRPCRouter({ }); const compose = await findComposeById(input.composeId); const domains = await findDomainsByComposeId(input.composeId); - const composeFile = await addDomainToCompose(compose, domains); + const composeFile = await addDomainToComposeForWebServer( + compose, + domains, + ); return stringify(composeFile, { lineWidth: 1000, }); diff --git a/apps/dokploy/server/api/routers/domain.ts b/apps/dokploy/server/api/routers/domain.ts index 8210fcf8a5..290654bd85 100644 --- a/apps/dokploy/server/api/routers/domain.ts +++ b/apps/dokploy/server/api/routers/domain.ts @@ -1,16 +1,21 @@ import { + assertCaddyDomainSupported, createDomain, findApplicationById, + findComposeById, findDomainById, findDomainsByApplicationId, findDomainsByComposeId, findPreviewDeploymentById, findServerById, generateTraefikMeDomain, + getCaddyComposeRouteTargetsForWebServer, getWebServerSettings, - manageDomain, - removeDomain, + manageWebServerDomain, removeDomainById, + writeCaddyComposeRoutesForTargets, + removeWebServerDomain, + resolveWebServerProvider, updateDomainById, validateDomain, } from "@dokploy/server"; @@ -31,6 +36,35 @@ import { apiUpdateDomain, } from "@/server/db/schema"; +const toDomainUpdateFields = (domain: Awaited>) => ({ + host: domain.host, + https: domain.https, + port: domain.port, + customEntrypoint: domain.customEntrypoint, + path: domain.path, + serviceName: domain.serviceName, + domainType: domain.domainType, + customCertResolver: domain.customCertResolver, + certificateType: domain.certificateType, + internalPath: domain.internalPath, + stripPath: domain.stripPath, + middlewares: domain.middlewares, +}); + +const refreshCaddyComposeRoutes = async ( + compose: Awaited>, +) => { + const domains = await findDomainsByComposeId(compose.composeId); + const routeTargets = await getCaddyComposeRouteTargetsForWebServer( + compose, + domains, + "caddy", + ); + if (routeTargets) { + await writeCaddyComposeRoutesForTargets(compose, routeTargets); + } +}; + export const domainRouter = createTRPCRouter({ create: protectedProcedure .input(apiCreateDomain) @@ -46,6 +80,18 @@ export const domainRouter = createTRPCRouter({ }); } const domain = await createDomain(input); + if (domain.composeId) { + const compose = await findComposeById(domain.composeId); + if ((await resolveWebServerProvider(compose.serverId)) === "caddy") { + try { + await refreshCaddyComposeRoutes(compose); + } catch (error) { + await removeDomainById(domain.domainId); + await refreshCaddyComposeRoutes(compose); + throw error; + } + } + } await audit(ctx, { action: "create", resourceType: "domain", @@ -118,6 +164,85 @@ export const domainRouter = createTRPCRouter({ }); } + const nextDomain = { ...currentDomain, ...input }; + if (currentDomain.applicationId) { + const application = await findApplicationById(currentDomain.applicationId); + if ((await resolveWebServerProvider(application.serverId)) === "caddy") { + assertCaddyDomainSupported(nextDomain); + await manageWebServerDomain(application, nextDomain); + try { + const result = await updateDomainById(input.domainId, input); + if (!result) { + throw new Error("Error updating domain"); + } + await audit(ctx, { + action: "update", + resourceType: "domain", + resourceId: result.domainId, + resourceName: result.host, + }); + return result; + } catch (error) { + await manageWebServerDomain(application, currentDomain); + throw error; + } + } + } else if (currentDomain.previewDeploymentId) { + const previewDeployment = await findPreviewDeploymentById( + currentDomain.previewDeploymentId, + ); + const application = await findApplicationById( + previewDeployment.applicationId, + ); + application.appName = previewDeployment.appName; + if ((await resolveWebServerProvider(application.serverId)) === "caddy") { + assertCaddyDomainSupported(nextDomain); + await manageWebServerDomain(application, nextDomain); + try { + const result = await updateDomainById(input.domainId, input); + if (!result) { + throw new Error("Error updating domain"); + } + await audit(ctx, { + action: "update", + resourceType: "domain", + resourceId: result.domainId, + resourceName: result.host, + }); + return result; + } catch (error) { + await manageWebServerDomain(application, currentDomain); + throw error; + } + } + } else if (currentDomain.composeId) { + const compose = await findComposeById(currentDomain.composeId); + if ((await resolveWebServerProvider(compose.serverId)) === "caddy") { + assertCaddyDomainSupported(nextDomain); + const result = await updateDomainById(input.domainId, input); + if (!result) { + throw new Error("Error updating domain"); + } + try { + await refreshCaddyComposeRoutes(compose); + await audit(ctx, { + action: "update", + resourceType: "domain", + resourceId: result.domainId, + resourceName: result.host, + }); + return result; + } catch (error) { + await updateDomainById( + input.domainId, + toDomainUpdateFields(currentDomain), + ); + await refreshCaddyComposeRoutes(compose); + throw error; + } + } + } + const result = await updateDomainById(input.domainId, input); const domain = await findDomainById(input.domainId); await audit(ctx, { @@ -128,7 +253,7 @@ export const domainRouter = createTRPCRouter({ }); if (domain.applicationId) { const application = await findApplicationById(domain.applicationId); - await manageDomain(application, domain); + await manageWebServerDomain(application, domain); } else if (domain.previewDeploymentId) { const previewDeployment = await findPreviewDeploymentById( domain.previewDeploymentId, @@ -137,7 +262,7 @@ export const domainRouter = createTRPCRouter({ previewDeployment.applicationId, ); application.appName = previewDeployment.appName; - await manageDomain(application, domain); + await manageWebServerDomain(application, domain); } return result; }), @@ -176,6 +301,79 @@ export const domainRouter = createTRPCRouter({ }); } + if (domain.applicationId) { + const application = await findApplicationById(domain.applicationId); + if ((await resolveWebServerProvider(application.serverId)) === "caddy") { + await removeWebServerDomain(application, domain.uniqueConfigKey); + try { + const result = await removeDomainById(input.domainId); + await audit(ctx, { + action: "delete", + resourceType: "domain", + resourceId: domain.domainId, + resourceName: domain.host, + }); + return result; + } catch (error) { + await manageWebServerDomain(application, domain); + throw error; + } + } + } else if (domain.previewDeploymentId) { + const previewDeployment = await findPreviewDeploymentById( + domain.previewDeploymentId, + ); + const application = await findApplicationById( + previewDeployment.applicationId, + ); + application.appName = previewDeployment.appName; + if ((await resolveWebServerProvider(application.serverId)) === "caddy") { + await removeWebServerDomain(application, domain.uniqueConfigKey); + try { + const result = await removeDomainById(input.domainId); + await audit(ctx, { + action: "delete", + resourceType: "domain", + resourceId: domain.domainId, + resourceName: domain.host, + }); + return result; + } catch (error) { + await manageWebServerDomain(application, domain); + throw error; + } + } + } else if (domain.composeId) { + const compose = await findComposeById(domain.composeId); + if ((await resolveWebServerProvider(compose.serverId)) === "caddy") { + const domains = await findDomainsByComposeId(compose.composeId); + const remainingDomains = domains.filter( + (item) => item.domainId !== domain.domainId, + ); + const routeTargets = await getCaddyComposeRouteTargetsForWebServer( + compose, + remainingDomains, + "caddy", + ); + try { + if (routeTargets) { + await writeCaddyComposeRoutesForTargets(compose, routeTargets); + } + const result = await removeDomainById(input.domainId); + await audit(ctx, { + action: "delete", + resourceType: "domain", + resourceId: domain.domainId, + resourceName: domain.host, + }); + return result; + } catch (error) { + await refreshCaddyComposeRoutes(compose); + throw error; + } + } + } + const result = await removeDomainById(input.domainId); await audit(ctx, { action: "delete", @@ -186,7 +384,16 @@ export const domainRouter = createTRPCRouter({ if (domain.applicationId) { const application = await findApplicationById(domain.applicationId); - await removeDomain(application, domain.uniqueConfigKey); + await removeWebServerDomain(application, domain.uniqueConfigKey); + } else if (domain.previewDeploymentId) { + const previewDeployment = await findPreviewDeploymentById( + domain.previewDeploymentId, + ); + const application = await findApplicationById( + previewDeployment.applicationId, + ); + application.appName = previewDeployment.appName; + await removeWebServerDomain(application, domain.uniqueConfigKey); } return result; diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts index aff6650f17..e82b6ace26 100644 --- a/apps/dokploy/server/api/routers/settings.ts +++ b/apps/dokploy/server/api/routers/settings.ts @@ -1,4 +1,5 @@ import { + applyCaddyMigration as applyCaddyMigrationCutover, CLEANUP_CRON_JOB, checkGPUStatus, checkPortInUse, @@ -19,12 +20,17 @@ import { getDokployImageTag, getLogCleanupStatus, getUpdateData, + getWebServerPaths, + getWebServerResourceName, getWebServerSettings, IS_CLOUD, parseRawConfig, paths, + prepareCaddyMigration as prepareCaddyMigrationDryRun, prepareEnvironmentVariables, processLogs, + readCaddyConfigFileIfExists, + getCaddyMigrationReport as readCaddyMigrationReport, readConfig, readConfigInPath, readDirectory, @@ -33,20 +39,28 @@ import { readMonitoringConfig, readPorts, recreateDirectory, + reloadCaddyAfterValidation, reloadDockerResource, + resolveWebServerProvider, + rollbackCaddyMigration as rollbackCaddyMigrationCutover, sendDockerCleanupNotifications, setupGPUSupport, spawnAsync, startLogCleanup, stopLogCleanup, updateLetsEncryptEmail, + updateLocalWebServerProvider, + updateRemoteWebServerProvider, updateServerById, + updateServerCaddy, updateServerTraefik, updateWebServerSettings, + type WebServerProvider, writeConfig, writeMainConfig, writeTraefikConfigInPath, writeTraefikSetup, + writeWebServerSetup, } from "@dokploy/server"; import { db } from "@dokploy/server/db"; import { checkPermission } from "@dokploy/server/services/permission"; @@ -82,6 +96,244 @@ import { publicProcedure, } from "../trpc"; +const webServerProviderSchema = z.enum(["traefik", "caddy"]); + +const apiWebServerProvider = z.object({ + provider: webServerProviderSchema, + serverId: z.string().optional(), +}); + +const apiWebServerConfig = z.object({ + webServerConfig: z.string().min(1), + serverId: z.string().optional(), +}); + +const apiReadWebServerFile = z.object({ + path: z.string().min(1), + serverId: z.string().optional(), +}); + +const apiModifyWebServerFile = apiReadWebServerFile.extend({ + webServerConfig: z.string().min(1), +}); + +const apiWriteWebServerEnv = z.object({ + env: z.string(), + serverId: z.string().optional(), +}); + +const apiWebServerPorts = z.object({ + serverId: z.string().optional(), + additionalPorts: z.array( + z.object({ + targetPort: z.number(), + publishedPort: z.number(), + protocol: z.enum(["tcp", "udp", "sctp"]), + }), + ), +}); + +const apiCaddyMigration = z.object({ + serverId: z.string().optional(), +}); + +const apiCaddyMigrationById = apiCaddyMigration.extend({ + migrationId: z.string().min(1), +}); + +const apiApplyCaddyMigration = apiCaddyMigrationById.extend({ + confirmMaintenanceWindow: z.literal(true), +}); + +const ensureServerAccess = async ( + ctx: { session?: { activeOrganizationId?: string | null } | null }, + serverId?: string, +) => { + if (!serverId) return; + const remoteServer = await findServerById(serverId); + if (remoteServer.organizationId !== ctx.session?.activeOrganizationId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } +}; + +const normalizeWebServerPath = (filePath: string) => + filePath.replace(/\\/g, "/").replace(/\/+$/g, ""); + +const isPathWithin = (targetPath: string, basePath: string) => { + const normalizedTarget = normalizeWebServerPath(targetPath); + const normalizedBase = normalizeWebServerPath(basePath); + return ( + normalizedTarget === normalizedBase || + normalizedTarget.startsWith(`${normalizedBase}/`) + ); +}; + +const isCaddyMigrationBackupPath = (filePath: string, serverId?: string) => { + const caddyPaths = paths(!!serverId); + const normalizedPath = normalizeWebServerPath(filePath); + const migrationsPath = normalizeWebServerPath(caddyPaths.CADDY_MIGRATIONS_PATH); + if (!isPathWithin(normalizedPath, migrationsPath)) { + return false; + } + return normalizedPath + .slice(migrationsPath.length) + .split("/") + .filter(Boolean) + .includes("backups"); +}; + +const assertCaddyReadableFilePath = (filePath: string, serverId?: string) => { + const caddyPaths = paths(!!serverId); + const normalizedPath = normalizeWebServerPath(filePath); + if (isCaddyMigrationBackupPath(normalizedPath, serverId)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Caddy migration backups may contain TLS or internal configuration and are not readable from the file editor.", + }); + } + if ( + normalizedPath === normalizeWebServerPath(caddyPaths.CADDY_CONFIG_PATH) || + isPathWithin(normalizedPath, caddyPaths.CADDY_FRAGMENTS_PATH) || + isPathWithin(normalizedPath, caddyPaths.CADDY_MIGRATIONS_PATH) + ) { + return; + } + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Caddy file access is limited to caddy.json, route fragments, and non-backup migration artifacts.", + }); +}; + +const readCaddySafeDirectoryTree = async (serverId?: string) => { + const caddyPaths = paths(!!serverId); + const readOptionalDirectory = async (dirPath: string) => { + try { + return await readDirectory(dirPath, serverId); + } catch (error) { + if ( + error instanceof Error && + (error as NodeJS.ErrnoException).code === "ENOENT" + ) { + return []; + } + throw error; + } + }; + return [ + { + id: caddyPaths.CADDY_CONFIG_PATH, + name: "caddy.json", + type: "file" as const, + }, + { + id: caddyPaths.CADDY_FRAGMENTS_PATH, + name: "fragments", + type: "directory" as const, + children: await readOptionalDirectory(caddyPaths.CADDY_FRAGMENTS_PATH), + }, + { + id: caddyPaths.CADDY_MIGRATIONS_PATH, + name: "migrations", + type: "directory" as const, + children: await readOptionalDirectory(caddyPaths.CADDY_MIGRATIONS_PATH), + }, + ]; +}; + +const resolveWebServerFilePath = ( + filePath: string, + provider: WebServerProvider, + serverId?: string, +) => { + if ( + filePath.includes("../") || + filePath.includes("..\\") || + filePath.includes("\0") || + filePath.includes("\x00") + ) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Invalid path: path traversal or null bytes are not allowed", + }); + } + + const basePath = getWebServerPaths(provider, !!serverId).basePath; + if (filePath.startsWith("/")) { + if (filePath !== basePath && !filePath.startsWith(`${basePath}/`)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Invalid path: outside of active web server directory", + }); + } + return filePath; + } + + return `${basePath}/${filePath.replace(/^\/+/, "")}`; +}; + +const getMainTraefikConfigPath = (serverId?: string) => + `${paths(!!serverId).MAIN_TRAEFIK_PATH}/traefik.yml`; + +const readProviderMainConfig = async ( + provider: WebServerProvider, + serverId?: string, +) => { + if (provider === "caddy") { + return readCaddyConfigFileIfExists({ serverId }); + } + + if (serverId) { + return readConfigInPath(getMainTraefikConfigPath(serverId), serverId); + } + return readMainConfig(); +}; + +const writeProviderMainConfig = async ( + provider: WebServerProvider, + content: string, + serverId?: string, +) => { + if (provider === "caddy") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Caddy caddy.json is generated from route fragments and is read-only. Use Caddy migration/domain actions to update it.", + }); + } + + if (serverId) { + await writeTraefikConfigInPath( + getMainTraefikConfigPath(serverId), + content, + serverId, + ); + return; + } + writeMainConfig(content); +}; + +const reloadWebServerProvider = async ( + provider: WebServerProvider, + serverId?: string, +) => { + if (provider === "caddy") { + await reloadCaddyAfterValidation(serverId); + return; + } + await reloadDockerResource(getWebServerResourceName(provider), serverId); +}; + +const getCaddyLetsEncryptEmailForSetup = async ( + provider: WebServerProvider, + serverId?: string, +) => { + if (provider !== "caddy" || serverId) return undefined; + const settings = await getWebServerSettings(); + return settings?.letsEncryptEmail; +}; + export const settingsRouter = createTRPCRouter({ getWebServerSettings: protectedProcedure.query(async () => { if (IS_CLOUD) { @@ -165,6 +417,134 @@ export const settingsRouter = createTRPCRouter({ }); return true; }), + getActiveWebServerProvider: adminProcedure + .input(apiServerSchema) + .query(async ({ input, ctx }) => { + await ensureServerAccess(ctx, input?.serverId); + return resolveWebServerProvider(input?.serverId); + }), + updateActiveWebServerProvider: adminProcedure + .input(apiWebServerProvider) + .mutation(async ({ input, ctx }) => { + if (input.provider === "caddy") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Caddy can only be activated through the migration apply flow after validation succeeds.", + }); + } + if (input.serverId) { + await ensureServerAccess(ctx, input.serverId); + } + const currentProvider = await resolveWebServerProvider(input.serverId); + if (currentProvider === "caddy" && input.provider === "traefik") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Use the Caddy migration rollback flow to return to Traefik safely.", + }); + } + if (input.serverId) { + await updateRemoteWebServerProvider(input.serverId, input.provider); + } else { + await updateLocalWebServerProvider(input.provider); + } + await audit(ctx, { + action: "update", + resourceType: "settings", + resourceName: "web-server-provider", + }); + return true; + }), + reloadWebServer: adminProcedure + .input(apiServerSchema) + .mutation(async ({ input, ctx }) => { + await ensureServerAccess(ctx, input?.serverId); + const provider = await resolveWebServerProvider(input?.serverId); + void reloadWebServerProvider(provider, input?.serverId).catch((err) => { + console.error("reloadWebServer background:", err); + }); + await audit(ctx, { + action: "reload", + resourceType: "settings", + resourceName: getWebServerResourceName(provider), + }); + return true; + }), + prepareCaddyMigration: adminProcedure + .input(apiCaddyMigration) + .mutation(async ({ input, ctx }) => { + await ensureServerAccess(ctx, input.serverId); + const report = await prepareCaddyMigrationDryRun({ + serverId: input.serverId, + }); + await audit(ctx, { + action: "create", + resourceType: "settings", + resourceName: "caddy-migration-dry-run", + }); + return report; + }), + getCaddyMigrationReport: adminProcedure + .input(apiCaddyMigrationById) + .query(async ({ input, ctx }) => { + await ensureServerAccess(ctx, input.serverId); + return readCaddyMigrationReport({ + migrationId: input.migrationId, + serverId: input.serverId, + }); + }), + applyCaddyMigration: adminProcedure + .input(apiApplyCaddyMigration) + .mutation(async ({ input, ctx }) => { + await ensureServerAccess(ctx, input.serverId); + const report = await readCaddyMigrationReport({ + migrationId: input.migrationId, + serverId: input.serverId, + }); + if (report.summary.blockingWarnings > 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Cannot apply Caddy migration with ${report.summary.blockingWarnings} blocking warning(s)`, + }); + } + if (report.validation.status !== "passed") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Cannot apply Caddy migration because draft validation did not pass", + }); + } + void applyCaddyMigrationCutover({ + migrationId: input.migrationId, + serverId: input.serverId, + }).catch((err) => { + console.error("applyCaddyMigration background:", err); + }); + await audit(ctx, { + action: "update", + resourceType: "settings", + resourceName: "caddy-migration-apply", + }); + return { started: true, migrationId: input.migrationId }; + }), + rollbackCaddyMigration: adminProcedure + .input(apiCaddyMigrationById) + .mutation(async ({ input, ctx }) => { + await ensureServerAccess(ctx, input.serverId); + void rollbackCaddyMigrationCutover({ + migrationId: input.migrationId, + serverId: input.serverId, + }).catch((err) => { + console.error("rollbackCaddyMigration background:", err); + }); + await audit(ctx, { + action: "update", + resourceType: "settings", + resourceName: "caddy-migration-rollback", + }); + return { started: true, migrationId: input.migrationId }; + }), toggleDashboard: adminProcedure .input(apiEnableDashboard) .mutation(async ({ input, ctx }) => { @@ -335,9 +715,14 @@ export const settingsRouter = createTRPCRouter({ }); } - updateServerTraefik(settings, input.host); - if (input.letsEncryptEmail) { - updateLetsEncryptEmail(input.letsEncryptEmail); + const provider = await resolveWebServerProvider(); + if (provider === "caddy") { + await updateServerCaddy(settings, input.host); + } else { + updateServerTraefik(settings, input.host); + if (input.letsEncryptEmail) { + updateLetsEncryptEmail(input.letsEncryptEmail); + } } await audit(ctx, { @@ -513,6 +898,38 @@ export const settingsRouter = createTRPCRouter({ return true; }), + readWebServerConfig: adminProcedure + .input(apiServerSchema) + .query(async ({ input, ctx }) => { + if (IS_CLOUD && !input?.serverId) { + return true; + } + await ensureServerAccess(ctx, input?.serverId); + const provider = await resolveWebServerProvider(input?.serverId); + return readProviderMainConfig(provider, input?.serverId); + }), + + updateWebServerConfig: adminProcedure + .input(apiWebServerConfig) + .mutation(async ({ input, ctx }) => { + if (IS_CLOUD && !input.serverId) { + return true; + } + await ensureServerAccess(ctx, input.serverId); + const provider = await resolveWebServerProvider(input.serverId); + await writeProviderMainConfig( + provider, + input.webServerConfig, + input.serverId, + ); + await audit(ctx, { + action: "update", + resourceType: "settings", + resourceName: "web-server-config", + }); + return true; + }), + readWebServerTraefikConfig: adminProcedure.query(() => { if (IS_CLOUD) { return true; @@ -608,6 +1025,30 @@ export const settingsRouter = createTRPCRouter({ } }), + readWebServerDirectories: protectedProcedure + .input(apiServerSchema) + .query(async ({ ctx, input }) => { + await checkPermission(ctx, { traefikFiles: ["read"] }); + await ensureServerAccess(ctx, input?.serverId); + const provider = await resolveWebServerProvider(input?.serverId); + if (provider === "caddy") { + return readCaddySafeDirectoryTree(input?.serverId); + } + const { basePath } = getWebServerPaths(provider, !!input?.serverId); + try { + const result = await readDirectory(basePath, input?.serverId); + return result || []; + } catch (error) { + if ( + error instanceof Error && + (error as NodeJS.ErrnoException).code === "ENOENT" + ) { + return []; + } + throw error; + } + }), + updateTraefikFile: protectedProcedure .input(apiModifyTraefikConfig) .mutation(async ({ input, ctx }) => { @@ -640,6 +1081,53 @@ export const settingsRouter = createTRPCRouter({ return readConfigInPath(input.path, input.serverId); }), + updateWebServerFile: protectedProcedure + .input(apiModifyWebServerFile) + .mutation(async ({ input, ctx }) => { + await checkPermission(ctx, { traefikFiles: ["write"] }); + await ensureServerAccess(ctx, input.serverId); + const provider = await resolveWebServerProvider(input.serverId); + if (provider === "caddy") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Caddy generated config files are read-only from the file editor.", + }); + } + const filePath = resolveWebServerFilePath( + input.path, + provider, + input.serverId, + ); + await writeTraefikConfigInPath( + filePath, + input.webServerConfig, + input.serverId, + ); + await audit(ctx, { + action: "update", + resourceType: "settings", + resourceName: "web-server-file", + }); + return true; + }), + + readWebServerFile: protectedProcedure + .input(apiReadWebServerFile) + .query(async ({ input, ctx }) => { + await checkPermission(ctx, { traefikFiles: ["read"] }); + await ensureServerAccess(ctx, input.serverId); + const provider = await resolveWebServerProvider(input.serverId); + const filePath = resolveWebServerFilePath( + input.path, + provider, + input.serverId, + ); + if (provider === "caddy") { + assertCaddyReadableFilePath(filePath, input.serverId); + } + return readConfigInPath(filePath, input.serverId); + }), getIp: protectedProcedure.query(async () => { if (IS_CLOUD) { return ""; @@ -761,6 +1249,16 @@ export const settingsRouter = createTRPCRouter({ ); return envVars; }), + readWebServerEnv: adminProcedure + .input(apiServerSchema) + .query(async ({ input, ctx }) => { + await ensureServerAccess(ctx, input?.serverId); + const provider = await resolveWebServerProvider(input?.serverId); + return readEnvironmentVariables( + getWebServerResourceName(provider), + input?.serverId, + ); + }), writeTraefikEnv: adminProcedure .input(z.object({ env: z.string(), serverId: z.string().optional() })) @@ -783,12 +1281,54 @@ export const settingsRouter = createTRPCRouter({ }); return true; }), + writeWebServerEnv: adminProcedure + .input(apiWriteWebServerEnv) + .mutation(async ({ input, ctx }) => { + await ensureServerAccess(ctx, input.serverId); + const provider = await resolveWebServerProvider(input.serverId); + const resourceName = getWebServerResourceName(provider); + const envs = prepareEnvironmentVariables(input.env); + const ports = await readPorts(resourceName, input.serverId); + + void writeWebServerSetup(provider, { + env: envs, + additionalPorts: ports, + serverId: input.serverId, + letsEncryptEmail: await getCaddyLetsEncryptEmailForSetup( + provider, + input.serverId, + ), + }).catch((err) => { + console.error("writeWebServerEnv background writeWebServerSetup:", err); + }); + await audit(ctx, { + action: "update", + resourceType: "settings", + resourceName: "web-server-env", + }); + return true; + }), haveTraefikDashboardPortEnabled: adminProcedure .input(apiServerSchema) .query(async ({ input }) => { const ports = await readPorts("dokploy-traefik", input?.serverId); return ports.some((port) => port.targetPort === 8080); }), + getWebServerDashboardState: adminProcedure + .input(apiServerSchema) + .query(async ({ input, ctx }) => { + await ensureServerAccess(ctx, input?.serverId); + const provider = await resolveWebServerProvider(input?.serverId); + if (provider === "traefik") { + const ports = await readPorts("dokploy-traefik", input?.serverId); + return { + provider, + enabled: ports.some((port) => port.targetPort === 8080), + }; + } + + return { provider, enabled: false }; + }), readStatsLogs: protectedProcedure .meta({ @@ -1081,6 +1621,76 @@ export const settingsRouter = createTRPCRouter({ const ports = await readPorts("dokploy-traefik", input?.serverId); return ports; }), + updateWebServerPorts: adminProcedure + .input(apiWebServerPorts) + .mutation(async ({ input, ctx }) => { + try { + await ensureServerAccess(ctx, input.serverId); + if (IS_CLOUD && !input.serverId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Please set a serverId to update web server ports", + }); + } + const provider = await resolveWebServerProvider(input.serverId); + const resourceName = getWebServerResourceName(provider); + const env = await readEnvironmentVariables( + resourceName, + input.serverId, + ); + + for (const port of input.additionalPorts) { + const portCheck = await checkPortInUse( + port.publishedPort, + input.serverId, + ); + if (portCheck.isInUse) { + throw new TRPCError({ + code: "CONFLICT", + message: `Port ${port.publishedPort} is already in use by ${portCheck.conflictingContainer}`, + }); + } + } + const preparedEnv = prepareEnvironmentVariables(env); + + void writeWebServerSetup(provider, { + env: preparedEnv, + additionalPorts: input.additionalPorts, + serverId: input.serverId, + letsEncryptEmail: await getCaddyLetsEncryptEmailForSetup( + provider, + input.serverId, + ), + }).catch((err) => { + console.error( + "updateWebServerPorts background writeWebServerSetup:", + err, + ); + }); + await audit(ctx, { + action: "update", + resourceType: "settings", + resourceName: "web-server-ports", + }); + return true; + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + error instanceof Error + ? error.message + : "Error updating web server ports", + cause: error, + }); + } + }), + getWebServerPorts: adminProcedure + .input(apiServerSchema) + .query(async ({ input, ctx }) => { + await ensureServerAccess(ctx, input?.serverId); + const provider = await resolveWebServerProvider(input?.serverId); + return readPorts(getWebServerResourceName(provider), input?.serverId); + }), updateLogCleanup: protectedProcedure .input( z.object({ diff --git a/packages/server/src/constants/index.ts b/packages/server/src/constants/index.ts index 706a0dbecb..10964ed6b4 100644 --- a/packages/server/src/constants/index.ts +++ b/packages/server/src/constants/index.ts @@ -90,11 +90,23 @@ export const paths = (isServer = false) => { : path.join(process.cwd(), ".docker"); const MAIN_TRAEFIK_PATH = `${BASE_PATH}/traefik`; const DYNAMIC_TRAEFIK_PATH = `${MAIN_TRAEFIK_PATH}/dynamic`; + const MAIN_CADDY_PATH = `${BASE_PATH}/caddy`; + const CADDY_CONFIG_PATH = `${MAIN_CADDY_PATH}/caddy.json`; + const CADDY_FRAGMENTS_PATH = `${MAIN_CADDY_PATH}/fragments`; + const CADDY_DATA_PATH = `${MAIN_CADDY_PATH}/data`; + const CADDY_CONFIG_DIR_PATH = `${MAIN_CADDY_PATH}/config`; + const CADDY_MIGRATIONS_PATH = `${MAIN_CADDY_PATH}/migrations`; return { BASE_PATH, MAIN_TRAEFIK_PATH, DYNAMIC_TRAEFIK_PATH, + MAIN_CADDY_PATH, + CADDY_CONFIG_PATH, + CADDY_FRAGMENTS_PATH, + CADDY_DATA_PATH, + CADDY_CONFIG_DIR_PATH, + CADDY_MIGRATIONS_PATH, LOGS_PATH: `${BASE_PATH}/logs`, APPLICATIONS_PATH: `${BASE_PATH}/applications`, COMPOSE_PATH: `${BASE_PATH}/compose`, diff --git a/packages/server/src/db/schema/server.ts b/packages/server/src/db/schema/server.ts index 4c8f1fc948..f12186c93c 100644 --- a/packages/server/src/db/schema/server.ts +++ b/packages/server/src/db/schema/server.ts @@ -23,6 +23,7 @@ import { postgres } from "./postgres"; import { redis } from "./redis"; import { schedules } from "./schedule"; import { sshKeys } from "./ssh-key"; +import { webServerProvider } from "./shared"; import { generateAppName } from "./utils"; export const serverStatus = pgEnum("serverStatus", ["active", "inactive"]); export const serverType = pgEnum("serverType", ["deploy", "build"]); @@ -41,6 +42,9 @@ export const server = pgTable("server", { .notNull() .$defaultFn(() => generateAppName("server")), enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false), + webServerProvider: webServerProvider("webServerProvider") + .notNull() + .default("traefik"), createdAt: text("createdAt").notNull(), organizationId: text("organizationId") .notNull() @@ -136,6 +140,7 @@ const createSchema = createInsertSchema(server, { name: z.string().min(1), description: z.string().optional(), serverType: z.enum(["deploy", "build"]).optional(), + webServerProvider: z.enum(["traefik", "caddy"]).optional(), }); export const apiCreateServer = createSchema diff --git a/packages/server/src/db/schema/shared.ts b/packages/server/src/db/schema/shared.ts index 6566968c64..2ab58c79bd 100644 --- a/packages/server/src/db/schema/shared.ts +++ b/packages/server/src/db/schema/shared.ts @@ -14,6 +14,11 @@ export const certificateType = pgEnum("certificateType", [ "custom", ]); +export const webServerProvider = pgEnum("webServerProvider", [ + "traefik", + "caddy", +]); + export const triggerType = pgEnum("triggerType", ["push", "tag"]); export const sqldNode = pgEnum("sqldNode", ["primary", "replica"]); diff --git a/packages/server/src/db/schema/web-server-settings.ts b/packages/server/src/db/schema/web-server-settings.ts index a44f1356a6..7284deca10 100644 --- a/packages/server/src/db/schema/web-server-settings.ts +++ b/packages/server/src/db/schema/web-server-settings.ts @@ -3,7 +3,7 @@ import { boolean, jsonb, pgTable, text, timestamp } from "drizzle-orm/pg-core"; import { createInsertSchema } from "drizzle-zod"; import { nanoid } from "nanoid"; import { z } from "zod"; -import { certificateType } from "./shared"; +import { certificateType, webServerProvider } from "./shared"; export const webServerSettings = pgTable("webServerSettings", { id: text("id") @@ -11,6 +11,9 @@ export const webServerSettings = pgTable("webServerSettings", { .primaryKey() .$defaultFn(() => nanoid()), // Web Server Configuration + webServerProvider: webServerProvider("webServerProvider") + .notNull() + .default("traefik"), serverIp: text("serverIp"), certificateType: certificateType("certificateType").notNull().default("none"), https: boolean("https").notNull().default(false), @@ -124,6 +127,7 @@ const createSchema = createInsertSchema(webServerSettings, { }); export const apiUpdateWebServerSettings = createSchema.partial().extend({ + webServerProvider: z.enum(["traefik", "caddy"]).optional(), serverIp: z.string().optional(), certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(), https: z.boolean().optional(), diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index dd627deaf3..c6649c44b1 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -49,6 +49,7 @@ export * from "./services/ssh-key"; export * from "./services/user"; export * from "./services/volume-backups"; export * from "./services/web-server-settings"; +export * from "./setup/caddy-setup"; export * from "./setup/config-paths"; export * from "./setup/monitoring-setup"; export * from "./setup/postgres-setup"; @@ -85,6 +86,21 @@ export * from "./utils/builders/nixpacks"; export * from "./utils/builders/paketo"; export * from "./utils/builders/static"; export * from "./utils/builders/utils"; +export * from "./utils/caddy/compose"; +export * from "./utils/caddy/config"; +export * from "./utils/caddy/domain"; +export * from "./utils/caddy/migration/apply"; +export * from "./utils/caddy/migration/compose-label-translator"; +export * from "./utils/caddy/migration/dynamic-file-translator"; +export * from "./utils/caddy/migration/files"; +export * from "./utils/caddy/migration/prepare"; +export * from "./utils/caddy/migration/rollback"; +export * from "./utils/caddy/migration/traefik-rule-parser"; +export * from "./utils/caddy/migration/types"; +export * from "./utils/caddy/migration/upstream-preflight"; +export * from "./utils/caddy/types"; +export * from "./utils/caddy/upstream-targets"; +export * from "./utils/caddy/web-server"; export * from "./utils/cluster/upload"; export * from "./utils/crons/enterprise"; export * from "./utils/databases/rebuild"; @@ -134,5 +150,8 @@ export * from "./utils/traefik/types"; export * from "./utils/traefik/web-server"; export * from "./utils/volume-backups/index"; export * from "./utils/watch-paths/should-deploy"; +export * from "./utils/web-server/domain"; +export * from "./utils/web-server/paths"; +export * from "./utils/web-server/providers"; export * from "./verification/send-verification-email"; export * from "./wss/utils"; diff --git a/packages/server/src/services/compose.ts b/packages/server/src/services/compose.ts index 0cec3418ba..efe8d9ce4a 100644 --- a/packages/server/src/services/compose.ts +++ b/packages/server/src/services/compose.ts @@ -11,8 +11,10 @@ import { getBuildComposeCommand } from "@dokploy/server/utils/builders/compose"; import { randomizeSpecificationFile } from "@dokploy/server/utils/docker/compose"; import { cloneCompose, + getCaddyComposeRouteTargetsForWebServer, loadDockerCompose, loadDockerComposeRemote, + writeCaddyComposeRoutesForTargets, } from "@dokploy/server/utils/docker/domain"; import type { ComposeSpecification } from "@dokploy/server/utils/docker/types"; import { sendBuildErrorNotifications } from "@dokploy/server/utils/notifications/build-error"; @@ -266,6 +268,9 @@ export const deployCompose = async ({ } } + const caddyComposeRouteTargets = + await getCaddyComposeRouteTargetsForWebServer(entity, compose.domains); + command = "set -e;"; command += await getBuildComposeCommand(entity); commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`; @@ -275,6 +280,10 @@ export const deployCompose = async ({ await execAsync(commandWithLog); } + if (caddyComposeRouteTargets) { + await writeCaddyComposeRoutesForTargets(entity, caddyComposeRouteTargets); + } + await updateDeploymentStatus(deployment.deploymentId, "done"); await updateCompose(composeId, { composeStatus: "done", @@ -380,6 +389,9 @@ export const rebuildCompose = async ({ } } + const caddyComposeRouteTargets = + await getCaddyComposeRouteTargetsForWebServer(compose, compose.domains); + command = "set -e;"; command += await getBuildComposeCommand(compose); commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`; @@ -389,6 +401,13 @@ export const rebuildCompose = async ({ await execAsync(commandWithLog); } + if (caddyComposeRouteTargets) { + await writeCaddyComposeRoutesForTargets( + compose, + caddyComposeRouteTargets, + ); + } + await updateDeploymentStatus(deployment.deploymentId, "done"); await updateCompose(composeId, { composeStatus: "done", diff --git a/packages/server/src/services/domain.ts b/packages/server/src/services/domain.ts index e1460fd482..e582f4dfa1 100644 --- a/packages/server/src/services/domain.ts +++ b/packages/server/src/services/domain.ts @@ -3,7 +3,7 @@ import { promisify } from "node:util"; import { db } from "@dokploy/server/db"; import { getWebServerSettings } from "@dokploy/server/services/web-server-settings"; import { generateRandomDomain } from "@dokploy/server/templates"; -import { manageDomain } from "@dokploy/server/utils/traefik/domain"; +import { manageWebServerDomain } from "@dokploy/server/utils/web-server/domain"; import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; import type { z } from "zod"; @@ -34,7 +34,7 @@ export const createDomain = async (input: z.infer) => { if (domain.applicationId) { const application = await findApplicationById(domain.applicationId); - await manageDomain(application, domain); + await manageWebServerDomain(application, domain); } return domain; diff --git a/packages/server/src/services/settings.ts b/packages/server/src/services/settings.ts index ecfb7f6de3..5fafa9dd2c 100644 --- a/packages/server/src/services/settings.ts +++ b/packages/server/src/services/settings.ts @@ -7,13 +7,21 @@ import { import { and, eq } from "drizzle-orm"; import semver from "semver"; +import { quote } from "shell-quote"; import { db } from "../db"; import { compose } from "../db/schema"; +import { + type CaddyOptions, + initializeCaddyService, + initializeStandaloneCaddy, +} from "../setup/caddy-setup"; import { initializeStandaloneTraefik, initializeTraefikService, type TraefikOptions, } from "../setup/traefik-setup"; +import type { CaddyMigrationResourceSnapshot } from "../utils/caddy/migration/types"; +import type { WebServerProvider } from "../utils/web-server/providers"; export interface IUpdateData { latestVersion: string | null; updateAvailable: boolean; @@ -311,6 +319,302 @@ export const reloadDockerResource = async ( } }; +const runDockerResourceCommand = async (command: string, serverId?: string) => { + if (serverId) { + return execAsyncRemote(serverId, command); + } + return execAsync(command); +}; + +const readDockerResourceJson = async >( + command: string, + serverId?: string, +): Promise => { + const { stdout } = await runDockerResourceCommand(command, serverId); + const trimmed = stdout.trim(); + return trimmed ? (JSON.parse(trimmed) as T) : undefined; +}; + +const asRecord = (value: unknown): Record | undefined => + value && typeof value === "object" + ? (value as Record) + : undefined; + +const asStringRecord = (value: unknown): Record | undefined => { + const record = asRecord(value); + if (!record) return undefined; + return Object.fromEntries( + Object.entries(record).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); +}; + +const asRecordArray = (value: unknown): Array> | undefined => + Array.isArray(value) + ? value.filter( + (item): item is Record => + !!item && typeof item === "object", + ) + : undefined; + +const asStringArray = (value: unknown): string[] | undefined => + Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") + : undefined; + +const readDockerResourceImage = async ( + resourceName: string, + resourceType: "service" | "standalone", + serverId?: string, +) => { + const command = + resourceType === "service" + ? `docker service inspect ${resourceName} --format '{{.Spec.TaskTemplate.ContainerSpec.Image}}'` + : `docker container inspect ${resourceName} --format '{{.Config.Image}}'`; + const { stdout } = await runDockerResourceCommand(command, serverId); + return stdout.trim() || undefined; +}; + +export const getDockerResourceSnapshot = async ( + resourceName: string, + serverId?: string, +): Promise => { + const resourceType = await getDockerResourceType(resourceName, serverId); + if (resourceType === "unknown") { + return { resourceName, resourceType, running: false }; + } + + if (resourceType === "service") { + const spec = + (await readDockerResourceJson>( + `docker service inspect ${resourceName} --format '{{json .Spec}}'`, + serverId, + ).catch(() => undefined)) ?? {}; + const mode = asRecord(spec.Mode); + const replicated = asRecord(mode?.Replicated); + const replicas = Number(replicated?.Replicas ?? 1); + const taskTemplate = asRecord(spec.TaskTemplate); + const containerSpec = asRecord(taskTemplate?.ContainerSpec); + const endpointSpec = asRecord(spec.EndpointSpec); + return { + resourceName, + resourceType, + replicas, + running: replicas > 0, + env: await readEnvironmentVariables(resourceName, serverId).catch( + () => undefined, + ), + additionalPorts: await readPorts(resourceName, serverId).catch(() => []), + image: await readDockerResourceImage( + resourceName, + resourceType, + serverId, + ).catch(() => undefined), + mounts: asRecordArray(containerSpec?.Mounts), + networks: asRecordArray(taskTemplate?.Networks), + labels: asStringRecord(spec.Labels), + containerLabels: asStringRecord(containerSpec?.Labels), + placement: asRecord(taskTemplate?.Placement), + endpointPorts: asRecordArray(endpointSpec?.Ports), + }; + } + + const inspect = + (await readDockerResourceJson>( + `docker container inspect ${resourceName} --format '{{json .}}'`, + serverId, + ).catch(() => undefined)) ?? {}; + const state = asRecord(inspect.State); + const hostConfig = asRecord(inspect.HostConfig); + const networkSettings = asRecord(inspect.NetworkSettings); + const networksRecord = asRecord(networkSettings?.Networks); + const config = asRecord(inspect.Config); + return { + resourceName, + resourceType, + running: state?.Running === true, + env: await readEnvironmentVariables(resourceName, serverId).catch( + () => undefined, + ), + additionalPorts: await readPorts(resourceName, serverId).catch(() => []), + image: await readDockerResourceImage( + resourceName, + resourceType, + serverId, + ).catch(() => undefined), + binds: asStringArray(hostConfig?.Binds), + networks: networksRecord ? Object.keys(networksRecord) : undefined, + labels: asStringRecord(config?.Labels), + restartPolicy: asRecord(hostConfig?.RestartPolicy), + }; +}; + +export const stopDockerResource = async ( + resourceName: string, + serverId?: string, +) => { + const resourceType = await getDockerResourceType(resourceName, serverId); + if (resourceType === "service") { + await runDockerResourceCommand( + `docker service scale ${resourceName}=0`, + serverId, + ); + return; + } + if (resourceType === "standalone") { + await runDockerResourceCommand(`docker stop ${resourceName}`, serverId); + } +}; + +export const startDockerResourceFromSnapshot = async ( + snapshot: CaddyMigrationResourceSnapshot, + serverId?: string, +) => { + if (!snapshot.running) { + return; + } + if (snapshot.resourceType === "service") { + await runDockerResourceCommand( + `docker service scale ${snapshot.resourceName}=${snapshot.replicas ?? 1}`, + serverId, + ); + return; + } + if (snapshot.resourceType === "standalone") { + await runDockerResourceCommand( + `docker start ${snapshot.resourceName}`, + serverId, + ); + } +}; + +const envStringToArray = (env?: string) => + env && env !== "[redacted]" ? env.split("\n").filter(Boolean) : undefined; + +export const ensureTraefikRunningFromSnapshot = async ( + snapshot?: CaddyMigrationResourceSnapshot, + serverId?: string, +) => { + let restartError: unknown; + if (snapshot?.running) { + try { + await startDockerResourceFromSnapshot(snapshot, serverId); + await waitForDockerResourceRunning("dokploy-traefik", serverId, { + retries: 10, + intervalMs: 1000, + }); + return; + } catch (error) { + restartError = error; + } + } + + const recreateInput: TraefikOptions = { + env: envStringToArray(snapshot?.env), + additionalPorts: snapshot?.additionalPorts ?? [], + image: snapshot?.image, + serverId, + binds: snapshot?.binds, + networks: snapshot?.networks?.filter( + (item): item is string => typeof item === "string", + ), + labels: snapshot?.labels, + restartPolicy: snapshot?.restartPolicy as TraefikOptions["restartPolicy"], + replicas: snapshot?.replicas, + serviceMounts: snapshot?.mounts as TraefikOptions["serviceMounts"], + serviceNetworks: snapshot?.networks?.filter( + (item): item is Record => typeof item === "object", + ) as TraefikOptions["serviceNetworks"], + servicePlacement: snapshot?.placement as TraefikOptions["servicePlacement"], + serviceLabels: snapshot?.labels, + serviceContainerLabels: snapshot?.containerLabels, + serviceEndpointPorts: + snapshot?.endpointPorts as TraefikOptions["serviceEndpointPorts"], + }; + + const runExactRecreate = async () => { + if (snapshot?.resourceType === "service") { + await initializeTraefikService(recreateInput); + await reconnectServicesToTraefik(serverId); + return; + } + if (snapshot?.resourceType === "standalone") { + await initializeStandaloneTraefik(recreateInput); + await reconnectServicesToTraefik(serverId); + return; + } + await writeTraefikSetup(recreateInput); + }; + + try { + try { + await runExactRecreate(); + } catch (exactError) { + if (snapshot?.resourceType === "unknown") { + throw exactError; + } + try { + await writeTraefikSetup(recreateInput); + } catch (fallbackError) { + const exactMessage = + exactError instanceof Error + ? exactError.message + : "exact Traefik recreation failed"; + const fallbackMessage = + fallbackError instanceof Error + ? fallbackError.message + : "generic Traefik recreation failed"; + throw new Error( + `Exact Traefik recreation failed: ${exactMessage}; generic recreation failed: ${fallbackMessage}`, + ); + } + } + await waitForDockerResourceRunning("dokploy-traefik", serverId, { + retries: 20, + intervalMs: 1000, + }); + } catch (error) { + const recreateMessage = + error instanceof Error ? error.message : "Traefik recreation failed"; + if (restartError) { + const restartMessage = + restartError instanceof Error + ? restartError.message + : "Traefik restart failed"; + throw new Error( + `Unable to restore Traefik. Restart failed: ${restartMessage}; recreate failed: ${recreateMessage}`, + ); + } + throw error; + } +}; + +export const waitForDockerResourceRunning = async ( + resourceName: string, + serverId?: string, + options: { retries?: number; intervalMs?: number } = {}, +) => { + const retries = options.retries ?? 20; + const intervalMs = options.intervalMs ?? 1000; + for (let attempt = 0; attempt < retries; attempt++) { + const snapshot = await getDockerResourceSnapshot(resourceName, serverId); + if (snapshot.resourceType === "service" && snapshot.running) { + const { stdout } = await runDockerResourceCommand( + `docker service ps ${resourceName} --filter desired-state=running --format '{{.CurrentState}}' | grep -m1 '^Running' || true`, + serverId, + ); + if (stdout.trim()) { + return snapshot; + } + } else if (snapshot.running) { + return snapshot; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + throw new Error(`Docker resource ${resourceName} did not become running`); +}; + export const readEnvironmentVariables = async ( resourceName: string, serverId?: string, @@ -414,7 +718,7 @@ export const checkPortInUse = async ( ): Promise<{ isInUse: boolean; conflictingContainer?: string }> => { try { // Check if port is in use by a Docker container - const dockerCommand = `docker ps -a --format '{{.Names}}' | grep -v '^dokploy-traefik$' | while read name; do docker port "$name" 2>/dev/null | grep -q ':${port}' && echo "$name" && break; done || true`; + const dockerCommand = `docker ps -a --format '{{.Names}}' | grep -Ev '^(dokploy-traefik|dokploy-caddy)$' | while read name; do docker port "$name" 2>/dev/null | grep -q ':${port}' && echo "$name" && break; done || true`; const { stdout: dockerOut } = serverId ? await execAsyncRemote(serverId, dockerCommand) : await execAsync(dockerCommand); @@ -451,33 +755,79 @@ export const checkPortInUse = async ( } }; -export const writeTraefikSetup = async (input: TraefikOptions) => { +export const writeCaddySetup = async (input: CaddyOptions) => { const resourceType = await getDockerResourceType( - "dokploy-traefik", + "dokploy-caddy", input.serverId, ); - - if (resourceType === "service") { - await initializeTraefikService({ + const traefikResourceType = + resourceType === "unknown" + ? await getDockerResourceType("dokploy-traefik", input.serverId) + : resourceType; + const fallbackResourceType = + traefikResourceType === "unknown" + ? await getDockerResourceType("dokploy", input.serverId) + : traefikResourceType; + const setupType = + fallbackResourceType === "service" ? "service" : "standalone"; + + if (setupType === "service") { + await initializeCaddyService({ env: input.env, additionalPorts: input.additionalPorts, serverId: input.serverId, + letsEncryptEmail: input.letsEncryptEmail, }); - await reconnectServicesToTraefik(input.serverId); - } else if (resourceType === "standalone") { - await initializeStandaloneTraefik({ + await reconnectServicesToWebServer("dokploy-caddy", input.serverId); + } else { + await initializeStandaloneCaddy({ env: input.env, additionalPorts: input.additionalPorts, serverId: input.serverId, + letsEncryptEmail: input.letsEncryptEmail, }); + await reconnectServicesToWebServer("dokploy-caddy", input.serverId); + } +}; + +export const writeWebServerSetup = async ( + provider: WebServerProvider, + input: TraefikOptions | CaddyOptions, +) => { + if (provider === "caddy") { + return writeCaddySetup(input as CaddyOptions); + } + + return writeTraefikSetup(input as TraefikOptions); +}; + +export const writeTraefikSetup = async (input: TraefikOptions) => { + const resourceType = await getDockerResourceType( + "dokploy-traefik", + input.serverId, + ); + const fallbackResourceType = + resourceType === "unknown" + ? await getDockerResourceType("dokploy", input.serverId) + : resourceType; + const setupType = + fallbackResourceType === "service" ? "service" : "standalone"; + + if (setupType === "service") { + await initializeTraefikService(input); await reconnectServicesToTraefik(input.serverId); } else { - throw new Error("Traefik resource type not found"); + await initializeStandaloneTraefik(input); + + await reconnectServicesToTraefik(input.serverId); } }; -export const reconnectServicesToTraefik = async (serverId?: string) => { +export const reconnectServicesToWebServer = async ( + resourceName: "dokploy-traefik" | "dokploy-caddy", + serverId?: string, +) => { const composeResult = await db.query.compose.findMany({ where: and( ...(serverId ? [eq(compose.serverId, serverId)] : []), @@ -491,7 +841,13 @@ export const reconnectServicesToTraefik = async (serverId?: string) => { let commands = ""; for (const compose of composeResult) { - commands += `docker network connect ${compose.appName} $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n`; + const networkName = quote([compose.appName]); + const quotedResourceName = quote([resourceName]); + commands += `if docker service inspect ${quotedResourceName} >/dev/null 2>&1; then\n`; + commands += ` docker service inspect ${quotedResourceName} --format '{{range .Spec.TaskTemplate.Networks}}{{println .Target}}{{end}}' | grep -qx ${networkName} || docker service update --network-add ${networkName} ${quotedResourceName} >/dev/null\n`; + commands += `else\n`; + commands += ` docker network connect ${networkName} $(docker ps --filter "name=${resourceName}" -q) >/dev/null 2>&1 || true\n`; + commands += `fi\n`; } if (serverId) { @@ -500,3 +856,7 @@ export const reconnectServicesToTraefik = async (serverId?: string) => { await execAsync(commands); } }; + +export const reconnectServicesToTraefik = async (serverId?: string) => { + await reconnectServicesToWebServer("dokploy-traefik", serverId); +}; diff --git a/packages/server/src/services/user.ts b/packages/server/src/services/user.ts index 2437517052..8ae5529454 100644 --- a/packages/server/src/services/user.ts +++ b/packages/server/src/services/user.ts @@ -199,6 +199,13 @@ export const canAccessToTraefikFiles = async ( return canAccessToTraefikFiles; }; +export const canAccessToWebServerFiles = async ( + userId: string, + organizationId: string, +) => { + return canAccessToTraefikFiles(userId, organizationId); +}; + export const checkServiceAccess = async ( userId: string, serviceId: string, diff --git a/packages/server/src/services/web-server-settings.ts b/packages/server/src/services/web-server-settings.ts index 289d119c99..152d30874d 100644 --- a/packages/server/src/services/web-server-settings.ts +++ b/packages/server/src/services/web-server-settings.ts @@ -1,5 +1,9 @@ import { db } from "@dokploy/server/db"; -import { webServerSettings } from "@dokploy/server/db/schema"; +import { server, webServerSettings } from "@dokploy/server/db/schema"; +import { + normalizeWebServerProvider, + type WebServerProvider, +} from "@dokploy/server/utils/web-server/providers"; import { eq } from "drizzle-orm"; /** @@ -42,3 +46,54 @@ export const updateWebServerSettings = async ( return updated; }; + +export const getLocalWebServerProvider = async (): Promise => { + const settings = await getWebServerSettings(); + return normalizeWebServerProvider(settings?.webServerProvider); +}; + +export const updateLocalWebServerProvider = async ( + provider: WebServerProvider, +) => { + return updateWebServerSettings({ webServerProvider: provider }); +}; + +export const getRemoteWebServerProvider = async ( + serverId: string, +): Promise => { + const remoteServer = await db.query.server.findFirst({ + where: eq(server.serverId, serverId), + columns: { + webServerProvider: true, + }, + }); + + if (!remoteServer) { + throw new Error(`Server not found: ${serverId}`); + } + + return normalizeWebServerProvider(remoteServer.webServerProvider); +}; + +export const updateRemoteWebServerProvider = async ( + serverId: string, + provider: WebServerProvider, +) => { + const [updated] = await db + .update(server) + .set({ webServerProvider: provider }) + .where(eq(server.serverId, serverId)) + .returning(); + + return updated; +}; + +export const resolveWebServerProvider = async ( + serverId?: string | null, +): Promise => { + if (serverId) { + return getRemoteWebServerProvider(serverId); + } + + return getLocalWebServerProvider(); +}; diff --git a/packages/server/src/setup/caddy-setup.ts b/packages/server/src/setup/caddy-setup.ts new file mode 100644 index 0000000000..4a68a11338 --- /dev/null +++ b/packages/server/src/setup/caddy-setup.ts @@ -0,0 +1,259 @@ +import type { ContainerCreateOptions, CreateServiceOptions } from "dockerode"; +import { paths } from "../constants"; +import { + ensureDefaultCaddyConfig, + reloadCaddyAfterValidation, + validateCaddyConfigWithContainer, +} from "../utils/caddy/config"; +import { getRemoteDocker } from "../utils/servers/remote-docker"; + +export const CADDY_SSL_PORT = + Number.parseInt(process.env.CADDY_SSL_PORT ?? "", 10) || 443; +export const CADDY_PORT = + Number.parseInt(process.env.CADDY_PORT ?? "", 10) || 80; +export const CADDY_HTTP3_PORT = + Number.parseInt(process.env.CADDY_HTTP3_PORT ?? "", 10) || 443; +export const CADDY_VERSION = process.env.CADDY_VERSION || "2.11.3"; + +export interface CaddyOptions { + env?: string[]; + serverId?: string; + additionalPorts?: { + targetPort: number; + publishedPort: number; + protocol?: string; + }[]; + letsEncryptEmail?: string | null; +} + +const getCaddyMounts = (serverId?: string) => { + const { CADDY_CONFIG_DIR_PATH, CADDY_DATA_PATH, MAIN_CADDY_PATH } = paths( + !!serverId, + ); + + return { + MAIN_CADDY_PATH, + binds: [ + `${MAIN_CADDY_PATH}:/etc/caddy`, + `${CADDY_DATA_PATH}:/data`, + `${CADDY_CONFIG_DIR_PATH}:/config`, + ], + serviceMounts: [ + { + Type: "bind" as const, + Source: MAIN_CADDY_PATH, + Target: "/etc/caddy", + }, + { + Type: "bind" as const, + Source: CADDY_DATA_PATH, + Target: "/data", + }, + { + Type: "bind" as const, + Source: CADDY_CONFIG_DIR_PATH, + Target: "/config", + }, + ], + }; +}; + +const buildStandalonePorts = ( + additionalPorts: CaddyOptions["additionalPorts"], +) => { + const exposedPorts: Record = { + [`${CADDY_PORT}/tcp`]: {}, + [`${CADDY_SSL_PORT}/tcp`]: {}, + [`${CADDY_HTTP3_PORT}/udp`]: {}, + }; + + const portBindings: Record> = { + [`${CADDY_PORT}/tcp`]: [{ HostPort: CADDY_PORT.toString() }], + [`${CADDY_SSL_PORT}/tcp`]: [{ HostPort: CADDY_SSL_PORT.toString() }], + [`${CADDY_HTTP3_PORT}/udp`]: [{ HostPort: CADDY_HTTP3_PORT.toString() }], + }; + + for (const port of additionalPorts ?? []) { + const portKey = `${port.targetPort}/${port.protocol ?? "tcp"}`; + exposedPorts[portKey] = {}; + portBindings[portKey] = [{ HostPort: port.publishedPort.toString() }]; + } + + return { exposedPorts, portBindings }; +}; + +const pullImage = async ( + docker: Awaited>, + imageName: string, +) => { + await new Promise((resolve, reject) => { + docker.pull( + imageName, + (error: Error | null, stream?: NodeJS.ReadableStream) => { + if (error) { + reject(error); + return; + } + if (!stream) { + resolve(); + return; + } + docker.modem.followProgress(stream, (progressError?: Error | null) => { + if (progressError) { + reject(progressError); + return; + } + resolve(); + }); + }, + ); + }); +}; + +const buildServicePorts = ( + additionalPorts: CaddyOptions["additionalPorts"], +) => [ + { + TargetPort: 443, + PublishedPort: CADDY_SSL_PORT, + PublishMode: "host" as const, + Protocol: "tcp" as const, + }, + { + TargetPort: 443, + PublishedPort: CADDY_HTTP3_PORT, + PublishMode: "host" as const, + Protocol: "udp" as const, + }, + { + TargetPort: 80, + PublishedPort: CADDY_PORT, + PublishMode: "host" as const, + Protocol: "tcp" as const, + }, + ...(additionalPorts ?? []).map((port) => ({ + TargetPort: port.targetPort, + PublishedPort: port.publishedPort, + Protocol: port.protocol as "tcp" | "udp" | "sctp" | undefined, + PublishMode: "host" as const, + })), +]; + +export const initializeStandaloneCaddy = async ({ + env, + serverId, + additionalPorts = [], + letsEncryptEmail, +}: CaddyOptions = {}) => { + await ensureDefaultCaddyConfig({ serverId, letsEncryptEmail }); + const imageName = `caddy:${CADDY_VERSION}`; + const containerName = "dokploy-caddy"; + const { binds } = getCaddyMounts(serverId); + const { exposedPorts, portBindings } = buildStandalonePorts(additionalPorts); + + const settings: ContainerCreateOptions = { + name: containerName, + Image: imageName, + Cmd: ["caddy", "run", "--config", "/etc/caddy/caddy.json"], + NetworkingConfig: { + EndpointsConfig: { + "dokploy-network": {}, + }, + }, + ExposedPorts: exposedPorts, + HostConfig: { + RestartPolicy: { + Name: "always", + }, + Binds: binds, + PortBindings: portBindings, + }, + Env: env, + }; + + const docker = await getRemoteDocker(serverId); + try { + await pullImage(docker, imageName); + console.log("Caddy Image Pulled ✅"); + } catch (error) { + console.log("Caddy Image Not Found: Pulling ", error); + } + try { + const container = docker.getContainer(containerName); + await container.remove({ force: true }); + await new Promise((resolve) => setTimeout(resolve, 5000)); + } catch {} + + await docker.createContainer(settings); + const newContainer = docker.getContainer(containerName); + await newContainer.start(); + await validateCaddyConfigWithContainer(serverId); + console.log("Caddy Started ✅"); +}; + +export const initializeCaddyService = async ({ + env, + additionalPorts = [], + serverId, + letsEncryptEmail, +}: CaddyOptions) => { + await ensureDefaultCaddyConfig({ serverId, letsEncryptEmail }); + const imageName = `caddy:${CADDY_VERSION}`; + const appName = "dokploy-caddy"; + const { serviceMounts } = getCaddyMounts(serverId); + + const settings: CreateServiceOptions = { + Name: appName, + TaskTemplate: { + ContainerSpec: { + Image: imageName, + Command: ["caddy", "run", "--config", "/etc/caddy/caddy.json"], + Env: env, + Mounts: serviceMounts, + }, + Networks: [{ Target: "dokploy-network" }], + Placement: { + Constraints: ["node.role==manager"], + }, + }, + Mode: { + Replicated: { + Replicas: 1, + }, + }, + EndpointSpec: { + Ports: buildServicePorts(additionalPorts), + }, + }; + const docker = await getRemoteDocker(serverId); + try { + await pullImage(docker, imageName); + console.log("Caddy Image Pulled ✅"); + } catch (error) { + console.log("Caddy Image Not Found: Pulling ", error); + } + try { + const service = docker.getService(appName); + const inspect = await service.inspect(); + + await service.update({ + version: inspect.Version.Index, + ...settings, + TaskTemplate: { + ...settings.TaskTemplate, + ForceUpdate: (inspect.Spec.TaskTemplate.ForceUpdate ?? 0) + 1, + }, + }); + console.log("Caddy Updated ✅"); + } catch { + await docker.createService(settings); + console.log("Caddy Started ✅"); + } +}; + +export const createDefaultCaddyConfig = async (options: CaddyOptions = {}) => { + await ensureDefaultCaddyConfig(options); +}; + +export const validateCaddyConfig = validateCaddyConfigWithContainer; +export const reloadCaddy = reloadCaddyAfterValidation; diff --git a/packages/server/src/setup/traefik-setup.ts b/packages/server/src/setup/traefik-setup.ts index 9fcebe5408..5212aa8f14 100644 --- a/packages/server/src/setup/traefik-setup.ts +++ b/packages/server/src/setup/traefik-setup.ts @@ -22,23 +22,57 @@ export const TRAEFIK_HTTP3_PORT = Number.parseInt(process.env.TRAEFIK_HTTP3_PORT!, 10) || 443; export const TRAEFIK_VERSION = process.env.TRAEFIK_VERSION || "3.6.7"; +type TraefikServiceTaskTemplate = NonNullable< + CreateServiceOptions["TaskTemplate"] +>; +type TraefikServiceContainerTaskTemplate = Extract< + TraefikServiceTaskTemplate, + { ContainerSpec?: unknown } +>; +type TraefikServiceContainerSpec = NonNullable< + TraefikServiceContainerTaskTemplate["ContainerSpec"] +>; +type TraefikServiceEndpointSpec = NonNullable< + CreateServiceOptions["EndpointSpec"] +>; +type TraefikStandaloneHostConfig = NonNullable< + ContainerCreateOptions["HostConfig"] +>; + export interface TraefikOptions { env?: string[]; serverId?: string; + image?: string; additionalPorts?: { targetPort: number; publishedPort: number; protocol?: string; }[]; + binds?: string[]; + networks?: string[]; + labels?: Record; + restartPolicy?: TraefikStandaloneHostConfig["RestartPolicy"]; + replicas?: number; + serviceMounts?: TraefikServiceContainerSpec["Mounts"]; + serviceNetworks?: TraefikServiceTaskTemplate["Networks"]; + servicePlacement?: TraefikServiceTaskTemplate["Placement"]; + serviceLabels?: Record; + serviceContainerLabels?: Record; + serviceEndpointPorts?: TraefikServiceEndpointSpec["Ports"]; } export const initializeStandaloneTraefik = async ({ env, serverId, + image, additionalPorts = [], + binds, + networks, + labels, + restartPolicy, }: TraefikOptions = {}) => { const { MAIN_TRAEFIK_PATH, DYNAMIC_TRAEFIK_PATH } = paths(!!serverId); - const imageName = `traefik:v${TRAEFIK_VERSION}`; + const imageName = image ?? `traefik:v${TRAEFIK_VERSION}`; const containerName = "dokploy-traefik"; const exposedPorts: Record = { @@ -58,6 +92,12 @@ export const initializeStandaloneTraefik = async ({ const enableDashboard = additionalPorts.some( (port) => port.targetPort === 8080, ); + const endpointNetworks = Object.fromEntries( + (networks?.length ? networks : ["dokploy-network"]).map((network) => [ + network, + {}, + ]), + ); if (enableDashboard) { exposedPorts["8080/tcp"] = {}; @@ -74,16 +114,14 @@ export const initializeStandaloneTraefik = async ({ name: containerName, Image: imageName, NetworkingConfig: { - EndpointsConfig: { - "dokploy-network": {}, - }, + EndpointsConfig: endpointNetworks, }, ExposedPorts: exposedPorts, HostConfig: { - RestartPolicy: { + RestartPolicy: restartPolicy ?? { Name: "always", }, - Binds: [ + Binds: binds ?? [ `${MAIN_TRAEFIK_PATH}/traefik.yml:/etc/traefik/traefik.yml`, `${DYNAMIC_TRAEFIK_PATH}:/etc/dokploy/traefik/dynamic`, "/var/run/docker.sock:/var/run/docker.sock", @@ -91,6 +129,7 @@ export const initializeStandaloneTraefik = async ({ PortBindings: portBindings, }, Env: env, + Labels: labels, }; const docker = await getRemoteDocker(serverId); @@ -119,20 +158,30 @@ export const initializeStandaloneTraefik = async ({ export const initializeTraefikService = async ({ env, + image, additionalPorts = [], serverId, + serviceMounts, + serviceNetworks, + servicePlacement, + serviceLabels, + serviceContainerLabels, + serviceEndpointPorts, + replicas, }: TraefikOptions) => { const { MAIN_TRAEFIK_PATH, DYNAMIC_TRAEFIK_PATH } = paths(!!serverId); - const imageName = `traefik:v${TRAEFIK_VERSION}`; + const imageName = image ?? `traefik:v${TRAEFIK_VERSION}`; const appName = "dokploy-traefik"; const settings: CreateServiceOptions = { Name: appName, + Labels: serviceLabels, TaskTemplate: { ContainerSpec: { Image: imageName, Env: env, - Mounts: [ + Labels: serviceContainerLabels, + Mounts: serviceMounts ?? [ { Type: "bind", Source: `${MAIN_TRAEFIK_PATH}/traefik.yml`, @@ -150,18 +199,18 @@ export const initializeTraefikService = async ({ }, ], }, - Networks: [{ Target: "dokploy-network" }], - Placement: { + Networks: serviceNetworks ?? [{ Target: "dokploy-network" }], + Placement: servicePlacement ?? { Constraints: ["node.role==manager"], }, }, Mode: { Replicated: { - Replicas: 1, + Replicas: replicas ?? 1, }, }, EndpointSpec: { - Ports: [ + Ports: serviceEndpointPorts ?? [ { TargetPort: 443, PublishedPort: TRAEFIK_SSL_PORT, diff --git a/packages/server/src/utils/builders/compose.ts b/packages/server/src/utils/builders/compose.ts index 790116cb6d..46ac77ed86 100644 --- a/packages/server/src/utils/builders/compose.ts +++ b/packages/server/src/utils/builders/compose.ts @@ -1,5 +1,6 @@ import { dirname, join } from "node:path"; import { paths } from "@dokploy/server/constants"; +import { resolveWebServerProvider } from "@dokploy/server/services/web-server-settings"; import type { InferResultType } from "@dokploy/server/types/with"; import boxen from "boxen"; import { quote } from "shell-quote"; @@ -9,6 +10,7 @@ import { getEnvironmentVariablesObject, prepareEnvironmentVariables, } from "../docker/utils"; +import { getWebServerResourceName } from "../web-server/providers"; export type ComposeNested = InferResultType< "compose", @@ -23,6 +25,8 @@ export const getBuildComposeCommand = async (compose: ComposeNested) => { const projectPath = join(COMPOSE_PATH, compose.appName, "code"); const exportEnvCommand = getExportEnvCommand(compose); + const provider = await resolveWebServerProvider(compose.serverId); + const webServerResourceName = getWebServerResourceName(provider); const newCompose = await writeDomainsToCompose(compose, domains); const logContent = ` App Name: ${appName} @@ -55,7 +59,7 @@ Compose Type: ${composeType} ✅`; ${compose.isolatedDeployment ? `docker network inspect ${compose.appName} >/dev/null 2>&1 || docker network create ${compose.composeType === "stack" ? "--driver overlay" : ""} --attachable ${compose.appName}` : ""} env -i PATH="$PATH" HOME="$HOME" ${exportEnvCommand} docker ${command.split(" ").join(" ")} 2>&1 || { echo "Error: ❌ Docker command failed"; exit 1; } - ${compose.isolatedDeployment ? `docker network connect ${compose.appName} $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1` : ""} + ${compose.isolatedDeployment ? `docker network connect ${compose.appName} $(docker ps --filter "name=${webServerResourceName}" -q) >/dev/null 2>&1` : ""} echo "Docker Compose Deployed: ✅"; } || { diff --git a/packages/server/src/utils/caddy/compose.ts b/packages/server/src/utils/caddy/compose.ts new file mode 100644 index 0000000000..d1715e0c12 --- /dev/null +++ b/packages/server/src/utils/caddy/compose.ts @@ -0,0 +1,143 @@ +import type { Compose } from "@dokploy/server/services/compose"; +import type { Domain } from "@dokploy/server/services/domain"; +import { getWebServerSettings } from "@dokploy/server/services/web-server-settings"; +import { + compileWriteAndReloadCaddyConfigSafely, + readCaddyRouteFragments, + removeCaddyRouteFragment, + restoreCaddyRouteFragments, + writeCaddyRouteFragment, +} from "./config"; +import { assertCaddyDomainSupported } from "./domain"; +import type { CaddyRouteFragment, CaddyRouteIntent } from "./types"; +import { getCaddyComposeRuntimeTarget } from "./upstream-targets"; + +const CADDY_FRAGMENT_VERSION = 1; + +const toPunycode = (host: string): string => { + try { + return new URL(`http://${host}`).hostname; + } catch { + return host; + } +}; + +export const getCaddyComposeFragmentPrefix = (appName: string) => + `compose.${appName}.`; + +export const getCaddyComposeFragmentId = ( + appName: string, + uniqueConfigKey: number, +) => `${getCaddyComposeFragmentPrefix(appName)}${uniqueConfigKey}`; + +const createCaddyRouteId = (appName: string, uniqueConfigKey: number) => + `${appName}-compose-route-${uniqueConfigKey}`; + +export const createCaddyComposeRouteIntent = ( + compose: Pick, + domain: Domain, + finalServiceName: string, + options: { + upstreamServiceName?: string; + upstreamNetwork?: string | null; + } = {}, +): CaddyRouteIntent => { + assertCaddyDomainSupported(domain); + const publicPath = domain.path && domain.path !== "/" ? domain.path : null; + const internalPath = + domain.internalPath && + domain.internalPath !== "/" && + domain.internalPath !== domain.path + ? domain.internalPath + : null; + const runtimeTarget = getCaddyComposeRuntimeTarget(compose, finalServiceName); + const upstreamServiceName = options.upstreamServiceName ?? runtimeTarget.host; + const upstreamNetwork = options.upstreamNetwork ?? runtimeTarget.network; + + return { + id: createCaddyRouteId(compose.appName, domain.uniqueConfigKey), + source: "dokploy-compose", + hosts: [toPunycode(domain.host)], + pathPrefix: publicPath, + https: domain.https && !domain.customEntrypoint, + upstreams: [`http://${upstreamServiceName}:${domain.port || 80}`], + upstreamNetwork, + transforms: { + stripPrefix: domain.stripPath ? publicPath : null, + addPrefix: internalPath, + }, + }; +}; + +export const createCaddyComposeRouteFragment = ( + compose: Pick, + domain: Domain, + finalServiceName: string, + options: { + upstreamServiceName?: string; + upstreamNetwork?: string | null; + } = {}, +): CaddyRouteFragment => ({ + version: CADDY_FRAGMENT_VERSION, + id: getCaddyComposeFragmentId(compose.appName, domain.uniqueConfigKey), + source: "dokploy-compose", + description: `Dokploy compose route for ${compose.appName}:${domain.uniqueConfigKey}`, + routes: [ + createCaddyComposeRouteIntent(compose, domain, finalServiceName, options), + ], +}); + +const getLocalLetsEncryptEmail = async (serverId?: string | null) => { + if (serverId) return null; + const settings = await getWebServerSettings(); + return settings?.letsEncryptEmail; +}; + +export const writeCaddyComposeRouteFragments = async ( + compose: Compose, + domains: Array<{ domain: Domain; finalServiceName: string }>, +) => { + const serverId = compose.serverId || undefined; + const options = { serverId }; + const fragmentPrefix = getCaddyComposeFragmentPrefix(compose.appName); + const nextFragmentIds = new Set( + domains.map(({ domain }) => + getCaddyComposeFragmentId(compose.appName, domain.uniqueConfigKey), + ), + ); + let changed = false; + + const existingFragments = await readCaddyRouteFragments(options); + try { + for (const fragment of existingFragments) { + if ( + fragment.source === "dokploy-compose" && + fragment.id.startsWith(fragmentPrefix) && + !nextFragmentIds.has(fragment.id) + ) { + await removeCaddyRouteFragment(fragment.id, options); + changed = true; + } + } + + for (const { domain, finalServiceName } of domains) { + await writeCaddyRouteFragment( + createCaddyComposeRouteFragment(compose, domain, finalServiceName), + options, + ); + changed = true; + } + + if (!changed) { + return; + } + + await compileWriteAndReloadCaddyConfigSafely({ + serverId, + letsEncryptEmail: await getLocalLetsEncryptEmail(serverId), + }); + } catch (error) { + await restoreCaddyRouteFragments(existingFragments, options); + throw error; + } +}; diff --git a/packages/server/src/utils/caddy/config.ts b/packages/server/src/utils/caddy/config.ts new file mode 100644 index 0000000000..c550e9a1e2 --- /dev/null +++ b/packages/server/src/utils/caddy/config.ts @@ -0,0 +1,851 @@ +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import * as path from "node:path"; +import { paths } from "@dokploy/server/constants"; +import { + execAsync, + execAsyncRemote, +} from "@dokploy/server/utils/process/execAsync"; +import { quote } from "shell-quote"; +import type { + CaddyCompileOptions, + CaddyFragmentStoreOptions, + CaddyHeaderMap, + CaddyJsonObject, + CaddyRouteFragment, + CaddyRouteIntent, +} from "./types"; + +const CADDY_FRAGMENT_VERSION = 1; +const CADDY_VERSION = process.env.CADDY_VERSION || "2.11.3"; + +const assertSafeFragmentId = (id: string) => { + if ( + !/^[a-zA-Z0-9_.-]+$/.test(id) || + id === "." || + id === ".." || + id.split(".").some((segment) => segment === "") + ) { + throw new Error( + `Invalid Caddy fragment id "${id}". Use letters, numbers, dash, underscore, or dot only; dot path segments are not allowed.`, + ); + } +}; + +const assertPathWithinBase = (basePath: string, targetPath: string) => { + const resolvedBase = path.resolve(basePath); + const resolvedTarget = path.resolve(targetPath); + if ( + resolvedTarget !== resolvedBase && + !resolvedTarget.startsWith(`${resolvedBase}${path.sep}`) + ) { + throw new Error(`Resolved path "${targetPath}" escapes "${basePath}"`); + } +}; + +export const getCaddyFragmentFilePath = ( + fragmentId: string, + isServer = false, +) => { + assertSafeFragmentId(fragmentId); + const fragmentsPath = paths(isServer).CADDY_FRAGMENTS_PATH; + const filePath = path.join(fragmentsPath, `${fragmentId}.json`); + assertPathWithinBase(fragmentsPath, filePath); + return filePath; +}; + +export const getCaddyActiveConfigPath = (isServer = false) => { + return paths(isServer).CADDY_CONFIG_PATH; +}; + +export const validateCaddyRouteIntent = (route: CaddyRouteIntent) => { + if (!route.id) { + throw new Error("Caddy route intent requires an id"); + } + if (!route.hosts.length) { + throw new Error(`Caddy route "${route.id}" requires at least one host`); + } + if ( + !route.upstreams.length && + !route.redirectScheme && + !route.staticResponse + ) { + throw new Error(`Caddy route "${route.id}" requires at least one upstream`); + } + if (route.staticResponse) { + const statusCode = route.staticResponse.statusCode; + if (!Number.isInteger(statusCode) || statusCode < 100 || statusCode > 999) { + throw new Error( + `Caddy route "${route.id}" has invalid static response status code`, + ); + } + } + for (const upstream of route.upstreams) { + if (!upstream.trim()) { + throw new Error(`Caddy route "${route.id}" has an empty upstream`); + } + try { + parseCaddyUpstream(upstream); + } catch (error) { + const message = + error instanceof Error ? error.message : "invalid upstream format"; + throw new Error( + `Caddy route "${route.id}" has invalid upstream "${upstream}": ${message}`, + ); + } + } +}; + +export const validateCaddyRouteFragment = (fragment: CaddyRouteFragment) => { + assertSafeFragmentId(fragment.id); + if (fragment.version !== CADDY_FRAGMENT_VERSION) { + throw new Error( + `Unsupported Caddy fragment version for "${fragment.id}": ${fragment.version}`, + ); + } + for (const route of fragment.routes) { + validateCaddyRouteIntent(route); + } +}; + +const normalizePathPrefix = (prefix?: string | null) => { + if (!prefix || prefix === "/") { + return undefined; + } + return prefix.startsWith("/") ? prefix : `/${prefix}`; +}; + +const normalizeExactPath = (exactPath?: string | null) => { + if (!exactPath) { + return undefined; + } + return exactPath.startsWith("/") ? exactPath : `/${exactPath}`; +}; + +const getPathSpecificity = (route: CaddyRouteIntent) => { + return (route.pathExact ?? route.pathPrefix ?? "").length; +}; + +const routeSourcePrecedence: Record = { + manual: 0, + "traefik-dynamic-file": 0, + "traefik-compose-label": 0, + "dokploy-application": 1, + "dokploy-compose": 1, + "dokploy-dashboard": 1, +}; + +export const sortCaddyRouteIntents = (routes: CaddyRouteIntent[]) => { + return [...routes].sort((a, b) => { + const priorityDiff = (b.priority ?? 0) - (a.priority ?? 0); + if (priorityDiff !== 0) return priorityDiff; + + const specificityDiff = getPathSpecificity(b) - getPathSpecificity(a); + if (specificityDiff !== 0) return specificityDiff; + + const sourcePrecedenceDiff = + routeSourcePrecedence[a.source] - routeSourcePrecedence[b.source]; + if (sourcePrecedenceDiff !== 0) return sourcePrecedenceDiff; + + const sourceDiff = a.source.localeCompare(b.source); + if (sourceDiff !== 0) return sourceDiff; + + return a.id.localeCompare(b.id); + }); +}; + +const createRouteMatcher = (route: CaddyRouteIntent) => { + const matcher: CaddyJsonObject = { + host: route.hosts, + }; + const exactPath = normalizeExactPath(route.pathExact); + const pathPrefix = normalizePathPrefix(route.pathPrefix); + + if (exactPath) { + matcher.path = [exactPath]; + } else if (pathPrefix) { + matcher.path = [`${pathPrefix}*`]; + } + if (route.allowedRemoteIps?.length) { + matcher.remote_ip = { + ranges: route.allowedRemoteIps, + }; + } + + return [matcher]; +}; + +const createRouteMatcherWithoutRemoteIp = (route: CaddyRouteIntent) => { + const { allowedRemoteIps: _allowedRemoteIps, ...unrestrictedRoute } = route; + return createRouteMatcher(unrestrictedRoute); +}; + +const normalizeHeaderValues = (headers: CaddyHeaderMap) => { + return Object.fromEntries( + Object.entries(headers).map(([name, value]) => [ + name, + Array.isArray(value) ? value : [value], + ]), + ); +}; + +const createHeaderHandler = (route: CaddyRouteIntent) => { + const requestHeaders = route.transforms?.requestHeaders; + const responseHeaders = route.transforms?.responseHeaders; + const hasRequestHeaders = + requestHeaders && Object.keys(requestHeaders).length > 0; + const hasResponseHeaders = + responseHeaders && Object.keys(responseHeaders).length > 0; + if (!hasRequestHeaders && !hasResponseHeaders) { + return undefined; + } + + return { + handler: "headers", + ...(hasRequestHeaders && { + request: { + set: normalizeHeaderValues(requestHeaders ?? {}), + }, + }), + ...(hasResponseHeaders && { + response: { + set: normalizeHeaderValues(responseHeaders ?? {}), + }, + }), + }; +}; + +const createRewriteHandlers = (route: CaddyRouteIntent) => { + const handlers: CaddyJsonObject[] = []; + const stripPrefix = normalizePathPrefix(route.transforms?.stripPrefix); + const addPrefix = normalizePathPrefix(route.transforms?.addPrefix); + + if (stripPrefix) { + handlers.push({ + handler: "rewrite", + strip_path_prefix: stripPrefix, + }); + } + + if (addPrefix) { + handlers.push({ + handler: "rewrite", + uri: `${addPrefix}{http.request.uri.path}`, + }); + } + + return handlers; +}; + +const createBasicAuthHandler = (route: CaddyRouteIntent) => { + if (!route.basicAuth?.length) { + return undefined; + } + + return { + handler: "authentication", + providers: { + http_basic: { + accounts: route.basicAuth.map((account) => ({ + username: account.username, + password: account.hash, + })), + }, + }, + }; +}; + +const httpUrlPrefixRegex = /^https?:\/\//i; + +const stripUrlPath = (value: string) => value.split(/[/?#]/, 1)[0] ?? ""; + +const getAuthorityPort = (authority: string) => { + const withoutCredentials = authority.includes("@") + ? authority.slice(authority.lastIndexOf("@") + 1) + : authority; + + if (withoutCredentials.startsWith("[")) { + const closingBracketIndex = withoutCredentials.indexOf("]"); + if (closingBracketIndex === -1) { + return { host: withoutCredentials, port: "" }; + } + const host = withoutCredentials.slice(0, closingBracketIndex + 1); + const remainder = withoutCredentials.slice(closingBracketIndex + 1); + return { + host, + port: remainder.startsWith(":") ? remainder.slice(1) : "", + }; + } + + const separatorIndex = withoutCredentials.lastIndexOf(":"); + if (separatorIndex === -1) { + return { host: withoutCredentials, port: "" }; + } + + return { + host: withoutCredentials.slice(0, separatorIndex), + port: withoutCredentials.slice(separatorIndex + 1), + }; +}; + +const validateExplicitPort = (port: string) => { + if (!port) { + throw new Error("upstream must include an explicit port"); + } + if (!/^\d+$/.test(port)) { + throw new Error(`upstream port "${port}" must be numeric`); + } + const portNumber = Number(port); + if (portNumber < 1 || portNumber > 65535) { + throw new Error(`upstream port "${port}" must be between 1 and 65535`); + } + return port; +}; + +export const parseCaddyUpstream = (upstream: string) => { + const trimmed = upstream.trim(); + if (httpUrlPrefixRegex.test(trimmed)) { + const authority = stripUrlPath(trimmed.replace(httpUrlPrefixRegex, "")); + const { port } = getAuthorityPort(authority); + const explicitPort = validateExplicitPort(port); + const url = new URL(trimmed); + const scheme = url.protocol.replace(":", ""); + if (scheme !== "http" && scheme !== "https") { + throw new Error(`unsupported upstream scheme "${scheme}"`); + } + return { + dial: `${url.hostname}:${explicitPort}`, + scheme, + }; + } + + const { host, port } = getAuthorityPort(trimmed); + const explicitPort = validateExplicitPort(port); + if (!host) { + throw new Error("upstream must include a host"); + } + if (trimmed.includes("/") || trimmed.includes("?") || trimmed.includes("#")) { + throw new Error("raw upstream dials must be host:port only"); + } + return { + dial: `${host}:${explicitPort}`, + scheme: "http", + }; +}; + +const createReverseProxyHandler = (route: CaddyRouteIntent) => { + const parsedUpstreams = route.upstreams.map(parseCaddyUpstream); + const usesTlsTransport = parsedUpstreams.some( + (upstream) => upstream.scheme === "https", + ); + + return { + handler: "reverse_proxy", + upstreams: parsedUpstreams.map((upstream) => ({ dial: upstream.dial })), + ...(usesTlsTransport && { + transport: { + protocol: "http", + tls: {}, + }, + }), + }; +}; + +const createProxyRoute = (route: CaddyRouteIntent) => { + const handlers = [ + createHeaderHandler(route), + createBasicAuthHandler(route), + ...createRewriteHandlers(route), + createReverseProxyHandler(route), + ].filter(Boolean) as CaddyJsonObject[]; + + return { + match: createRouteMatcher(route), + handle: handlers, + terminal: true, + }; +}; + +const createStaticResponseRoute = (route: CaddyRouteIntent) => { + const response = route.staticResponse; + const handlers = [ + createHeaderHandler(route), + { + handler: "static_response", + status_code: response?.statusCode ?? 404, + ...(response?.body && { body: response.body }), + ...(response?.headers && { + headers: normalizeHeaderValues(response.headers), + }), + }, + ].filter(Boolean) as CaddyJsonObject[]; + + return { + match: createRouteMatcher(route), + handle: handlers, + terminal: true, + }; +}; + +const createForbiddenRoute = (route: CaddyRouteIntent) => { + return { + match: createRouteMatcherWithoutRemoteIp(route), + handle: [ + { + handler: "static_response", + status_code: 403, + }, + ], + terminal: true, + }; +}; + +const createRedirectLocation = (route: CaddyRouteIntent) => { + const scheme = route.redirectScheme?.scheme || "https"; + const port = route.redirectScheme?.port + ? `:${route.redirectScheme.port}` + : ""; + return `${scheme}://{http.request.host}${port}{http.request.uri}`; +}; + +const createRedirectRoute = (route: CaddyRouteIntent) => { + return { + match: createRouteMatcher(route), + handle: [ + { + handler: "static_response", + status_code: route.redirectScheme?.permanent === false ? 302 : 308, + headers: { + Location: [createRedirectLocation(route)], + }, + }, + ], + terminal: true, + }; +}; + +const createHttpsRedirectRoute = (route: CaddyRouteIntent) => { + return createRedirectRoute({ + ...route, + allowedRemoteIps: null, + redirectScheme: { scheme: "https", permanent: true }, + }); +}; + +export const flattenCaddyFragments = (fragments: CaddyRouteFragment[] = []) => { + for (const fragment of fragments) { + validateCaddyRouteFragment(fragment); + } + return fragments.flatMap((fragment) => fragment.routes); +}; + +export const compileCaddyConfig = ({ + fragments = [], + routes = [], + letsEncryptEmail, +}: CaddyCompileOptions = {}) => { + const allRoutes = sortCaddyRouteIntents([ + ...flattenCaddyFragments(fragments), + ...routes, + ]); + for (const route of allRoutes) { + validateCaddyRouteIntent(route); + } + + const httpRoutes = allRoutes.map((route) => { + if (route.redirectScheme) { + return createRedirectRoute(route); + } + if (route.staticResponse && !route.https) { + return createStaticResponseRoute(route); + } + return route.https + ? createHttpsRedirectRoute(route) + : createProxyRoute(route); + }); + const httpsRoutes = allRoutes + .filter((route) => route.https && !route.redirectScheme) + .flatMap((route) => { + const proxyRoute = route.staticResponse + ? createStaticResponseRoute(route) + : createProxyRoute(route); + return route.allowedRemoteIps?.length + ? [proxyRoute, createForbiddenRoute(route)] + : [proxyRoute]; + }); + + return { + admin: { + listen: "localhost:2019", + }, + apps: { + ...(letsEncryptEmail && { + tls: { + automation: { + policies: [ + { + issuers: [ + { + module: "acme", + email: letsEncryptEmail, + }, + ], + }, + ], + }, + }, + }), + http: { + servers: { + http: { + listen: [":80"], + routes: httpRoutes, + }, + https: { + listen: [":443"], + routes: httpsRoutes, + }, + }, + }, + }, + }; +}; + +const encodeBase64 = (content: string) => + Buffer.from(content, "utf8").toString("base64"); + +const remoteWriteFileAtomic = async ( + serverId: string, + filePath: string, + content: string, +) => { + const tempPath = `${filePath}.tmp-${Date.now()}`; + const command = [ + `mkdir -p ${quote([path.posix.dirname(filePath)])}`, + `printf %s ${quote([encodeBase64(content)])} | base64 -d > ${quote([tempPath])}`, + `mv ${quote([tempPath])} ${quote([filePath])}`, + ].join(" && "); + await execAsyncRemote(serverId, command); +}; + +export const writeCaddyConfigContent = async ( + content: string, + options: CaddyFragmentStoreOptions = {}, +) => { + const filePath = getCaddyActiveConfigPath(!!options.serverId); + if (options.serverId) { + await remoteWriteFileAtomic(options.serverId, filePath, content); + return; + } + mkdirSync(path.dirname(filePath), { recursive: true }); + const tempPath = `${filePath}.tmp-${Date.now()}`; + writeFileSync(tempPath, content, "utf8"); + renameSync(tempPath, filePath); +}; + +export const writeCaddyConfigFile = async ( + config: CaddyJsonObject, + options: CaddyFragmentStoreOptions = {}, +) => { + await writeCaddyConfigContent( + `${JSON.stringify(config, null, 2)}\n`, + options, + ); +}; + +export const writeCaddyRouteFragment = async ( + fragment: CaddyRouteFragment, + options: CaddyFragmentStoreOptions = {}, +) => { + validateCaddyRouteFragment(fragment); + const filePath = getCaddyFragmentFilePath(fragment.id, !!options.serverId); + const content = `${JSON.stringify(fragment, null, 2)}\n`; + if (options.serverId) { + await remoteWriteFileAtomic(options.serverId, filePath, content); + return; + } + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, content, "utf8"); +}; + +export const removeCaddyRouteFragment = async ( + fragmentId: string, + options: CaddyFragmentStoreOptions = {}, +) => { + const filePath = getCaddyFragmentFilePath(fragmentId, !!options.serverId); + if (options.serverId) { + await execAsyncRemote(options.serverId, `rm -f ${quote([filePath])}`); + return; + } + rmSync(filePath, { force: true }); +}; + +export const restoreCaddyRouteFragments = async ( + fragments: CaddyRouteFragment[], + options: CaddyFragmentStoreOptions = {}, +) => { + const fragmentsPath = paths(!!options.serverId).CADDY_FRAGMENTS_PATH; + if (options.serverId) { + await execAsyncRemote( + options.serverId, + `rm -rf ${quote([fragmentsPath])} && mkdir -p ${quote([fragmentsPath])}`, + ); + } else { + rmSync(fragmentsPath, { recursive: true, force: true }); + mkdirSync(fragmentsPath, { recursive: true }); + } + + for (const fragment of fragments) { + await writeCaddyRouteFragment(fragment, options); + } +}; + +const parseFragmentContent = (fileName: string, content: string) => { + try { + const parsed = JSON.parse(content) as CaddyRouteFragment; + validateCaddyRouteFragment(parsed); + return parsed; + } catch (error) { + throw new Error( + `Invalid Caddy fragment ${fileName}: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } +}; + +export const readCaddyRouteFragments = async ( + options: CaddyFragmentStoreOptions = {}, +) => { + const fragmentsPath = paths(!!options.serverId).CADDY_FRAGMENTS_PATH; + if (options.serverId) { + const command = `if [ -d ${quote([fragmentsPath])} ]; then find ${quote([fragmentsPath])} -maxdepth 1 -type f -name '*.json' | sort | while IFS= read -r file; do printf '%s\n' "---DOKPLOY-CADDY-FILE:$file"; cat "$file"; printf '\n'; done; fi`; + const { stdout } = await execAsyncRemote(options.serverId, command); + const fragments: CaddyRouteFragment[] = []; + for (const block of stdout.split("---DOKPLOY-CADDY-FILE:")) { + if (!block.trim()) continue; + const newlineIndex = block.indexOf("\n"); + if (newlineIndex === -1) continue; + const fileName = block.slice(0, newlineIndex).trim(); + const content = block.slice(newlineIndex + 1).trim(); + fragments.push(parseFragmentContent(fileName, content)); + } + return fragments; + } + + if (!existsSync(fragmentsPath)) { + return []; + } + + return readdirSync(fragmentsPath) + .filter((fileName) => fileName.endsWith(".json")) + .sort() + .map((fileName) => { + const filePath = path.join(fragmentsPath, fileName); + return parseFragmentContent(fileName, readFileSync(filePath, "utf8")); + }); +}; + +export const compileAndWriteCaddyConfig = async ( + options: CaddyFragmentStoreOptions & { + letsEncryptEmail?: string | null; + } = {}, +) => { + const fragments = await readCaddyRouteFragments(options); + const config = compileCaddyConfig({ + fragments, + letsEncryptEmail: options.letsEncryptEmail, + }); + await writeCaddyConfigFile(config, options); + return config; +}; + +export const readCaddyConfigFile = async ( + options: CaddyFragmentStoreOptions = {}, +) => { + const filePath = getCaddyActiveConfigPath(!!options.serverId); + if (options.serverId) { + const { stdout } = await execAsyncRemote( + options.serverId, + `cat ${quote([filePath])}`, + ); + return stdout; + } + return readFileSync(filePath, "utf8"); +}; + +export const readCaddyConfigFileIfExists = async ( + options: CaddyFragmentStoreOptions = {}, +) => { + const filePath = getCaddyActiveConfigPath(!!options.serverId); + if (options.serverId) { + const { stdout } = await execAsyncRemote( + options.serverId, + `if [ -f ${quote([filePath])} ]; then cat ${quote([filePath])}; fi`, + ); + return stdout || null; + } + if (!existsSync(filePath)) { + return null; + } + return readFileSync(filePath, "utf8"); +}; + +export const writeAndReloadCaddyConfigSafely = async ( + config: CaddyJsonObject, + options: CaddyFragmentStoreOptions = {}, +) => { + const previousConfig = await readCaddyConfigFileIfExists(options); + await writeCaddyConfigFile(config, options); + try { + await reloadCaddyAfterValidation(options.serverId); + } catch (error) { + if (previousConfig) { + await writeCaddyConfigContent(previousConfig, options); + } else { + await writeCaddyConfigFile(compileCaddyConfig(), options); + } + throw error; + } +}; + +export const compileWriteAndReloadCaddyConfigSafely = async ( + options: CaddyFragmentStoreOptions & { + letsEncryptEmail?: string | null; + } = {}, +) => { + const fragments = await readCaddyRouteFragments(options); + const config = compileCaddyConfig({ + fragments, + letsEncryptEmail: options.letsEncryptEmail, + }); + await writeAndReloadCaddyConfigSafely(config, options); + return config; +}; + +export const ensureDefaultCaddyConfig = async ( + options: CaddyFragmentStoreOptions & { + letsEncryptEmail?: string | null; + } = {}, +) => { + const caddyPaths = paths(!!options.serverId); + if (options.serverId) { + await execAsyncRemote( + options.serverId, + `mkdir -p ${quote([ + caddyPaths.MAIN_CADDY_PATH, + caddyPaths.CADDY_FRAGMENTS_PATH, + caddyPaths.CADDY_DATA_PATH, + caddyPaths.CADDY_CONFIG_DIR_PATH, + ])}`, + ); + await compileAndWriteCaddyConfig(options); + return; + } + + mkdirSync(caddyPaths.MAIN_CADDY_PATH, { recursive: true }); + mkdirSync(caddyPaths.CADDY_FRAGMENTS_PATH, { recursive: true }); + mkdirSync(caddyPaths.CADDY_DATA_PATH, { recursive: true }); + mkdirSync(caddyPaths.CADDY_CONFIG_DIR_PATH, { recursive: true }); + await compileAndWriteCaddyConfig(options); +}; + +const getCaddyExecTargetCommand = ` +if docker service inspect dokploy-caddy >/dev/null 2>&1; then + docker ps --filter "label=com.docker.swarm.service.name=dokploy-caddy" --format '{{.ID}}' | head -n 1 +else + echo dokploy-caddy +fi`; + +const execInCaddy = async (serverId: string | undefined, command: string) => { + const { stdout } = serverId + ? await execAsyncRemote(serverId, getCaddyExecTargetCommand) + : await execAsync(getCaddyExecTargetCommand); + const target = stdout.trim(); + if (!target) { + throw new Error("Caddy resource is not running"); + } + const dockerExecCommand = `docker exec ${quote([target])} ${command}`; + if (serverId) { + return execAsyncRemote(serverId, dockerExecCommand); + } + return execAsync(dockerExecCommand); +}; + +export const validateCaddyConfigWithContainer = async (serverId?: string) => { + return execInCaddy(serverId, "caddy validate --config /etc/caddy/caddy.json"); +}; + +export const validateCaddyConfigFileWithImage = async ( + configPath: string, + serverId?: string, +) => { + const imageName = `caddy:${CADDY_VERSION}`; + const validationRoot = path.posix.join( + path.posix.dirname(configPath), + ".validate-runtime", + ); + const validationDataPath = path.posix.join(validationRoot, "data"); + const validationConfigPath = path.posix.join(validationRoot, "config"); + const mkdirCommand = `mkdir -p ${quote([ + validationDataPath, + validationConfigPath, + ])}`; + const cleanupCommand = `rm -rf ${quote([validationRoot])}`; + const validateCommand = `docker run --rm --network none ${[ + "-v", + `${configPath}:/etc/caddy/caddy.json:ro`, + "-v", + `${validationDataPath}:/data`, + "-v", + `${validationConfigPath}:/config`, + imageName, + "caddy", + "validate", + "--config", + "/etc/caddy/caddy.json", + ] + .map((part) => quote([part])) + .join(" ")}`; + if (serverId) { + await execAsyncRemote(serverId, mkdirCommand); + try { + return await execAsyncRemote(serverId, validateCommand); + } finally { + await execAsyncRemote(serverId, cleanupCommand).catch(() => undefined); + } + } + await execAsync(mkdirCommand); + try { + return await execAsync(validateCommand); + } finally { + await execAsync(cleanupCommand).catch(() => undefined); + } +}; + +export const reloadCaddyWithContainer = async (serverId?: string) => { + const { stdout } = serverId + ? await execAsyncRemote(serverId, getCaddyExecTargetCommand) + : await execAsync(getCaddyExecTargetCommand); + const target = stdout.trim(); + if (!target) { + throw new Error("Caddy resource is not running"); + } + const dockerExecCommand = `docker exec -w /etc/caddy ${quote([ + target, + ])} caddy reload --config /etc/caddy/caddy.json`; + if (serverId) { + return execAsyncRemote(serverId, dockerExecCommand); + } + return execAsync(dockerExecCommand); +}; + +export const reloadCaddyAfterValidation = async (serverId?: string) => { + await validateCaddyConfigWithContainer(serverId); + return reloadCaddyWithContainer(serverId); +}; diff --git a/packages/server/src/utils/caddy/domain.ts b/packages/server/src/utils/caddy/domain.ts new file mode 100644 index 0000000000..463f472996 --- /dev/null +++ b/packages/server/src/utils/caddy/domain.ts @@ -0,0 +1,145 @@ +import type { Domain } from "@dokploy/server/services/domain"; +import { getWebServerSettings } from "@dokploy/server/services/web-server-settings"; +import type { ApplicationNested } from "../builders"; +import { + compileWriteAndReloadCaddyConfigSafely, + readCaddyRouteFragments, + removeCaddyRouteFragment, + restoreCaddyRouteFragments, + writeCaddyRouteFragment, +} from "./config"; +import type { CaddyRouteFragment, CaddyRouteIntent } from "./types"; +import { DOKPLOY_CADDY_NETWORK } from "./upstream-targets"; + +const CADDY_FRAGMENT_VERSION = 1; + +const toPunycode = (host: string): string => { + try { + return new URL(`http://${host}`).hostname; + } catch { + return host; + } +}; + +export const getCaddyApplicationFragmentId = ( + appName: string, + uniqueConfigKey: number, +) => `application.${appName}.${uniqueConfigKey}`; + +const createCaddyRouteId = (appName: string, uniqueConfigKey: number) => + `${appName}-route-${uniqueConfigKey}`; + +export const getUnsupportedCaddyDomainFieldMessages = (domain: Domain) => { + const messages: string[] = []; + if (domain.customEntrypoint) { + messages.push("custom entrypoints are not supported by Caddy routes"); + } + if (domain.customCertResolver) { + messages.push("custom certificate resolvers are Traefik-specific"); + } + if (domain.certificateType === "custom") { + messages.push("custom certificates are not translated to Caddy yet"); + } + if (domain.middlewares?.length) { + messages.push("Traefik middlewares are not translated to Caddy yet"); + } + return messages; +}; + +export const assertCaddyDomainSupported = (domain: Domain) => { + const messages = getUnsupportedCaddyDomainFieldMessages(domain); + if (messages.length) { + throw new Error( + `Domain "${domain.host}" uses unsupported Caddy fields: ${messages.join("; ")}`, + ); + } +}; + +export const createCaddyApplicationRouteIntent = ( + app: ApplicationNested, + domain: Domain, +): CaddyRouteIntent => { + assertCaddyDomainSupported(domain); + const publicPath = domain.path && domain.path !== "/" ? domain.path : null; + const internalPath = + domain.internalPath && + domain.internalPath !== "/" && + domain.internalPath !== domain.path + ? domain.internalPath + : null; + + return { + id: createCaddyRouteId(app.appName, domain.uniqueConfigKey), + source: "dokploy-application", + hosts: [toPunycode(domain.host)], + pathPrefix: publicPath, + https: domain.https && !domain.customEntrypoint, + upstreams: [`http://${app.appName}:${domain.port || 80}`], + upstreamNetwork: DOKPLOY_CADDY_NETWORK, + transforms: { + stripPrefix: domain.stripPath ? publicPath : null, + addPrefix: internalPath, + }, + }; +}; + +export const createCaddyApplicationRouteFragment = ( + app: ApplicationNested, + domain: Domain, +): CaddyRouteFragment => ({ + version: CADDY_FRAGMENT_VERSION, + id: getCaddyApplicationFragmentId(app.appName, domain.uniqueConfigKey), + source: "dokploy-application", + description: `Dokploy application route for ${app.appName}:${domain.uniqueConfigKey}`, + routes: [createCaddyApplicationRouteIntent(app, domain)], +}); + +const getLocalLetsEncryptEmail = async (serverId?: string | null) => { + if (serverId) return null; + const settings = await getWebServerSettings(); + return settings?.letsEncryptEmail; +}; + +export const manageCaddyDomain = async ( + app: ApplicationNested, + domain: Domain, +) => { + const serverId = app.serverId || undefined; + const options = { serverId }; + const previousFragments = await readCaddyRouteFragments(options); + try { + await writeCaddyRouteFragment( + createCaddyApplicationRouteFragment(app, domain), + options, + ); + await compileWriteAndReloadCaddyConfigSafely({ + serverId, + letsEncryptEmail: await getLocalLetsEncryptEmail(serverId), + }); + } catch (error) { + await restoreCaddyRouteFragments(previousFragments, options); + throw error; + } +}; + +export const removeCaddyDomain = async ( + app: ApplicationNested, + uniqueConfigKey: number, +) => { + const serverId = app.serverId || undefined; + const options = { serverId }; + const previousFragments = await readCaddyRouteFragments(options); + try { + await removeCaddyRouteFragment( + getCaddyApplicationFragmentId(app.appName, uniqueConfigKey), + options, + ); + await compileWriteAndReloadCaddyConfigSafely({ + serverId, + letsEncryptEmail: await getLocalLetsEncryptEmail(serverId), + }); + } catch (error) { + await restoreCaddyRouteFragments(previousFragments, options); + throw error; + } +}; diff --git a/packages/server/src/utils/caddy/migration/apply.ts b/packages/server/src/utils/caddy/migration/apply.ts new file mode 100644 index 0000000000..38c69415f9 --- /dev/null +++ b/packages/server/src/utils/caddy/migration/apply.ts @@ -0,0 +1,444 @@ +import * as path from "node:path"; +import { paths } from "@dokploy/server/constants"; +import { + getDockerResourceSnapshot, + readEnvironmentVariables, + readPorts, + stopDockerResource, + waitForDockerResourceRunning, + writeCaddySetup, +} from "@dokploy/server/services/settings"; +import { + resolveWebServerProvider, + updateLocalWebServerProvider, + updateRemoteWebServerProvider, +} from "@dokploy/server/services/web-server-settings"; +import { + reloadCaddyAfterValidation, + validateCaddyConfigFileWithImage, + validateCaddyConfigWithContainer, +} from "@dokploy/server/utils/caddy/config"; +import { + acquireCaddyMigrationOperationLock, + appendCaddyMigrationEvent, + copyMigrationFileInPlace, + copyMigrationPath, + loadCaddyMigrationReport, + migrationPathExists, + readMigrationTextFileIfExists, + removeMigrationPath, + writeCaddyMigrationReport, + writeMigrationTextFile, +} from "./files"; +import { rollbackCaddyMigration } from "./rollback"; +import type { + CaddyMigrationBackupSummary, + CaddyMigrationFileBackup, + CaddyMigrationReport, + CaddyMigrationResourceSnapshot, +} from "./types"; +import { runCaddyMigrationUpstreamPreflight } from "./upstream-preflight"; + +const updateProviderToCaddy = async (serverId?: string | null) => { + if (serverId) { + await updateRemoteWebServerProvider(serverId, "caddy"); + return; + } + await updateLocalWebServerProvider("caddy"); +}; + +const filterCaddyAdditionalPorts = ( + ports: { targetPort: number; publishedPort: number; protocol?: string }[], +) => + ports.filter( + (port) => + !( + (port.targetPort === 8080 || port.targetPort === 8082) && + (port.protocol ?? "tcp") === "tcp" + ), + ); + +const createFileBackup = async ( + label: string, + source: string, + backupPath: string, + serverId?: string | null, +): Promise => { + const existed = await migrationPathExists(source, serverId); + if (existed) { + await copyMigrationPath(source, backupPath, serverId); + } + return { label, source, backupPath, existed }; +}; + +const redactResourceSnapshot = ( + snapshot: CaddyMigrationResourceSnapshot, +): CaddyMigrationResourceSnapshot => ({ + resourceName: snapshot.resourceName, + resourceType: snapshot.resourceType, + running: snapshot.running, + replicas: snapshot.replicas, + additionalPorts: snapshot.additionalPorts, +}); + +const createMigrationBackups = async ( + report: CaddyMigrationReport, + serverId?: string, +): Promise => { + const currentPaths = paths(!!serverId); + const backupRoot = report.artifactPaths.backupsDir; + const files: CaddyMigrationFileBackup[] = []; + files.push( + await createFileBackup( + "traefik-static", + path.posix.join(currentPaths.MAIN_TRAEFIK_PATH, "traefik.yml"), + path.posix.join(backupRoot, "traefik", "traefik.yml"), + serverId, + ), + ); + files.push( + await createFileBackup( + "traefik-dynamic", + currentPaths.DYNAMIC_TRAEFIK_PATH, + path.posix.join(backupRoot, "traefik", "dynamic"), + serverId, + ), + ); + files.push( + await createFileBackup( + "caddy-config", + currentPaths.CADDY_CONFIG_PATH, + path.posix.join(backupRoot, "caddy", "caddy.json"), + serverId, + ), + ); + files.push( + await createFileBackup( + "caddy-fragments", + currentPaths.CADDY_FRAGMENTS_PATH, + path.posix.join(backupRoot, "caddy", "fragments"), + serverId, + ), + ); + + const [traefikResource, caddyResource] = await Promise.all([ + getDockerResourceSnapshot("dokploy-traefik", serverId), + getDockerResourceSnapshot("dokploy-caddy", serverId), + ]); + const restoreSnapshotPath = path.posix.join( + backupRoot, + "resources.restore.json", + ); + await writeMigrationTextFile( + restoreSnapshotPath, + `${JSON.stringify({ traefikResource, caddyResource }, null, 2)}\n`, + serverId, + ); + + return { + createdAt: new Date().toISOString(), + traefikResource: redactResourceSnapshot(traefikResource), + caddyResource: redactResourceSnapshot(caddyResource), + restoreSnapshotPath, + files, + }; +}; + +const readResourceEnvAndPorts = async ( + resourceName: string, + serverId?: string, +) => { + try { + const [env, additionalPorts] = await Promise.all([ + readEnvironmentVariables(resourceName, serverId), + readPorts(resourceName, serverId), + ]); + return { env, additionalPorts }; + } catch { + return { env: "", additionalPorts: [] }; + } +}; + +const validateAppliedCaddy = async (serverId?: string) => { + await waitForDockerResourceRunning("dokploy-caddy", serverId, { + retries: 30, + intervalMs: 1000, + }); + await validateCaddyConfigWithContainer(serverId); +}; + +const getLetsEncryptEmailFromConfig = async ( + caddyJsonPath: string, + serverId?: string, +) => { + const content = await readMigrationTextFileIfExists(caddyJsonPath, serverId); + if (!content) return null; + try { + const config = JSON.parse(content) as { + apps?: { + tls?: { + automation?: { + policies?: Array<{ + issuers?: Array<{ email?: string }>; + }>; + }; + }; + }; + }; + return ( + config.apps?.tls?.automation?.policies?.[0]?.issuers?.[0]?.email ?? null + ); + } catch { + return null; + } +}; + +const markFailed = async ( + report: CaddyMigrationReport, + message: string, + serverId?: string, +) => { + const warning = { + code: "apply-failed" as const, + message, + blocking: true, + }; + return writeCaddyMigrationReport( + appendCaddyMigrationEvent( + { + ...report, + status: "failed", + warnings: [...report.warnings, warning], + summary: { + ...report.summary, + warnings: report.summary.warnings + 1, + blockingWarnings: report.summary.blockingWarnings + 1, + }, + }, + "apply_failed", + message, + ), + serverId, + ); +}; + +export const applyCaddyMigration = async (input: { + migrationId: string; + serverId?: string; +}) => { + const releaseLock = await acquireCaddyMigrationOperationLock( + input.migrationId, + input.serverId, + ); + try { + return await applyCaddyMigrationUnlocked(input); + } finally { + await releaseLock(); + } +}; + +const applyCaddyMigrationUnlocked = async (input: { + migrationId: string; + serverId?: string; +}) => { + const serverId = input.serverId; + let report = await loadCaddyMigrationReport(input.migrationId, serverId); + if (report.summary.blockingWarnings > 0) { + throw new Error( + `Cannot apply Caddy migration ${input.migrationId}: report has ${report.summary.blockingWarnings} blocking warning(s)`, + ); + } + if (report.validation.status !== "passed") { + throw new Error( + `Cannot apply Caddy migration ${input.migrationId}: draft validation did not pass`, + ); + } + if (report.status !== "prepared") { + throw new Error( + `Cannot apply Caddy migration ${input.migrationId}: report status is ${report.status}`, + ); + } + if ( + !(await migrationPathExists(report.artifactPaths.caddyJson, serverId)) || + !(await migrationPathExists(report.artifactPaths.fragmentsDir, serverId)) + ) { + throw new Error( + `Cannot apply Caddy migration ${input.migrationId}: approved Caddy artifacts are missing`, + ); + } + const provider = await resolveWebServerProvider(serverId); + if (provider !== "traefik") { + throw new Error( + `Cannot apply Caddy migration ${input.migrationId}: active provider is ${provider}`, + ); + } + + report = await writeCaddyMigrationReport( + appendCaddyMigrationEvent( + { ...report, status: "applying" }, + "applying", + "Applying Caddy migration cutover", + ), + serverId, + ); + + const runtimePreflight = await runCaddyMigrationUpstreamPreflight(report, { + serverId, + }); + report = await writeCaddyMigrationReport( + appendCaddyMigrationEvent( + { ...report, runtimePreflight }, + "runtime_preflight", + `Runtime upstream preflight ${runtimePreflight.status}`, + ), + serverId, + ); + if (runtimePreflight.status !== "passed") { + const failedChecks = runtimePreflight.checks.filter( + (check) => check.status === "failed", + ); + const message = + runtimePreflight.status === "failed" + ? `Runtime upstream preflight failed for ${failedChecks.length} upstream check(s)` + : `Runtime upstream preflight did not pass: ${runtimePreflight.status}`; + await markFailed(report, message, serverId); + throw new Error(message); + } + + try { + await validateCaddyConfigFileWithImage( + report.artifactPaths.caddyJson, + serverId, + ); + } catch (error) { + const message = `Pre-stop Caddy runtime validation failed: ${ + error instanceof Error ? error.message : "unknown error" + }`; + await markFailed(report, message, serverId); + throw error; + } + report = await writeCaddyMigrationReport( + appendCaddyMigrationEvent( + { ...report }, + "pre_stop_caddy_validate", + "Prepared Caddy config validated with the runtime Caddy image before stopping Traefik", + ), + serverId, + ); + + try { + const backup = await createMigrationBackups(report, serverId); + report = await writeCaddyMigrationReport( + appendCaddyMigrationEvent( + { ...report, backup }, + "backup_created", + "Backed up Traefik and Caddy configuration before cutover", + ), + serverId, + ); + + const currentPaths = paths(!!serverId); + await removeMigrationPath(currentPaths.CADDY_FRAGMENTS_PATH, serverId); + await copyMigrationPath( + report.artifactPaths.fragmentsDir, + currentPaths.CADDY_FRAGMENTS_PATH, + serverId, + ); + await copyMigrationPath( + report.artifactPaths.caddyJson, + currentPaths.CADDY_CONFIG_PATH, + serverId, + ); + + const caddyRuntime = await readResourceEnvAndPorts( + "dokploy-caddy", + serverId, + ); + const traefikRuntime = await readResourceEnvAndPorts( + "dokploy-traefik", + serverId, + ); + const runtime = (await migrationPathExists( + currentPaths.CADDY_CONFIG_PATH, + serverId, + )) + ? { + env: caddyRuntime.env || undefined, + additionalPorts: caddyRuntime.additionalPorts.length + ? caddyRuntime.additionalPorts + : traefikRuntime.additionalPorts, + } + : { + env: undefined, + additionalPorts: traefikRuntime.additionalPorts, + }; + const caddyAdditionalPorts = filterCaddyAdditionalPorts( + runtime.additionalPorts, + ); + const droppedPorts = runtime.additionalPorts.filter( + (port) => !caddyAdditionalPorts.includes(port), + ); + if (droppedPorts.length) { + report = await writeCaddyMigrationReport( + appendCaddyMigrationEvent( + { ...report }, + "caddy_ports_filtered", + `Dropped Traefik-only additional port(s) for Caddy: ${droppedPorts + .map( + (port) => + `${port.publishedPort}->${port.targetPort}/${port.protocol ?? "tcp"}`, + ) + .join(", ")}`, + ), + serverId, + ); + } + + await stopDockerResource("dokploy-traefik", serverId); + await writeCaddySetup({ + env: runtime.env ? runtime.env.split("\n").filter(Boolean) : undefined, + additionalPorts: caddyAdditionalPorts, + serverId, + letsEncryptEmail: await getLetsEncryptEmailFromConfig( + report.artifactPaths.caddyJson, + serverId, + ), + }); + await copyMigrationFileInPlace( + report.artifactPaths.caddyJson, + currentPaths.CADDY_CONFIG_PATH, + serverId, + ); + await reloadCaddyAfterValidation(serverId); + await validateAppliedCaddy(serverId); + await updateProviderToCaddy(serverId); + + report = await writeCaddyMigrationReport( + appendCaddyMigrationEvent( + { ...report, status: "applied" }, + "applied", + "Caddy migration applied successfully", + ), + serverId, + ); + return report; + } catch (error) { + const message = + error instanceof Error ? error.message : "Caddy migration apply failed"; + report = await markFailed(report, message, serverId); + try { + await rollbackCaddyMigration({ + migrationId: input.migrationId, + serverId, + skipOperationLock: true, + }); + } catch (rollbackError) { + const rollbackMessage = + rollbackError instanceof Error + ? rollbackError.message + : "rollback failed"; + throw new Error(`${message}; rollback also failed: ${rollbackMessage}`); + } + throw error; + } +}; diff --git a/packages/server/src/utils/caddy/migration/compose-label-translator.ts b/packages/server/src/utils/caddy/migration/compose-label-translator.ts new file mode 100644 index 0000000000..4a1d33583f --- /dev/null +++ b/packages/server/src/utils/caddy/migration/compose-label-translator.ts @@ -0,0 +1,575 @@ +import type { Domain } from "@dokploy/server/services/domain"; +import { isDokployGeneratedTraefikLabel } from "../../docker/domain"; +import type { ListOrDict } from "../../docker/types"; +import type { HttpMiddleware } from "../../traefik/file-types"; +import type { CaddyRouteFragment, CaddyRouteIntent } from "../types"; +import { + defaultKnownTraefikFileMiddlewares, + translateTraefikMiddleware, +} from "./dynamic-file-translator"; +import { parseTraefikRule } from "./traefik-rule-parser"; +import type { CaddyMigrationWarning, KnownTraefikMiddlewareMap } from "./types"; + +interface ComposeLabelTranslatorOptions { + sourceFile?: string; + fragmentId?: string; + appName?: string; + serviceName?: string; + upstreamServiceName?: string; + upstreamNetwork?: string | null; + composeType?: "docker-compose" | "stack"; + domains?: Domain[]; + knownMiddlewares?: KnownTraefikMiddlewareMap; + fileMiddlewares?: Record; +} + +interface ParsedRouterLabels { + rule?: string; + entryPoints?: string[]; + middlewares?: string[]; + service?: string; + priority?: number; + tls?: boolean; + tlsCertResolver?: string; +} + +interface ParsedServiceLabels { + port?: number; + scheme?: string; +} + +interface LabelClassification { + label: string; + dokployGenerated: boolean; +} + +const CADDY_FRAGMENT_VERSION = 1; + +const toSafeId = (value: string) => + value + .replace(/\.[^.]+$/, "") + .replace(/[^a-zA-Z0-9_.-]+/g, "-") + .replace(/^-+|-+$/g, "") || "compose-labels"; + +const warning = ( + message: string, + options: ComposeLabelTranslatorOptions & { + routerName?: string; + serviceName?: string; + middlewareName?: string; + label?: string; + code?: CaddyMigrationWarning["code"]; + }, +): CaddyMigrationWarning => ({ + code: options.code ?? "invalid-label", + message, + blocking: true, + source: options.sourceFile, + routerName: options.routerName, + serviceName: options.serviceName, + middlewareName: options.middlewareName, + label: options.label, +}); + +const labelsToStrings = (labels: ListOrDict | undefined): string[] => { + if (!labels) { + return []; + } + if (Array.isArray(labels)) { + return labels.map(String); + } + return Object.entries(labels).map(([key, value]) => + value === null ? key : `${key}=${value}`, + ); +}; + +const splitLabel = (label: string) => { + const separatorIndex = label.indexOf("="); + if (separatorIndex === -1) { + return { key: label, value: undefined }; + } + return { + key: label.slice(0, separatorIndex), + value: label.slice(separatorIndex + 1), + }; +}; + +const splitList = (value: string | undefined) => + (value ?? "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + +const mergeTransforms = ( + target: NonNullable, + next: NonNullable, +) => { + target.requestHeaders = { + ...(target.requestHeaders ?? {}), + ...(next.requestHeaders ?? {}), + }; + target.responseHeaders = { + ...(target.responseHeaders ?? {}), + ...(next.responseHeaders ?? {}), + }; + if (next.stripPrefix) target.stripPrefix = next.stripPrefix; + if (next.addPrefix) target.addPrefix = next.addPrefix; +}; + +const mergeAllowedRemoteIps = (target: string[], next?: string[] | null) => { + if (!next?.length) { + return; + } + for (const range of next) { + if (!target.includes(range)) { + target.push(range); + } + } +}; + +const ensureMiddleware = ( + middlewares: Record>, + name: string, +) => { + middlewares[name] ??= {}; + return middlewares[name]; +}; + +const parseBoolean = (value: string | undefined) => + value === undefined ? undefined : value === "true"; + +const setMiddlewareLabel = ( + middlewares: Record>, + name: string, + suffix: string, + value: string | undefined, +) => { + const middleware = ensureMiddleware(middlewares, name); + const lowerSuffix = suffix.toLowerCase(); + if (lowerSuffix === "stripprefix.prefixes") { + middleware.stripPrefix = { prefixes: splitList(value) }; + return true; + } + if (lowerSuffix === "addprefix.prefix") { + middleware.addPrefix = { prefix: value }; + return true; + } + if (lowerSuffix === "basicauth.users") { + middleware.basicAuth = { + ...(middleware.basicAuth as Record | undefined), + users: splitList(value), + }; + return true; + } + if (lowerSuffix === "basicauth.usersfile") { + middleware.basicAuth = { + ...(middleware.basicAuth as Record | undefined), + usersFile: value, + }; + return true; + } + if (lowerSuffix === "redirectscheme.scheme") { + middleware.redirectScheme = { + ...(middleware.redirectScheme as Record | undefined), + scheme: value, + }; + return true; + } + if (lowerSuffix === "redirectscheme.permanent") { + middleware.redirectScheme = { + ...(middleware.redirectScheme as Record | undefined), + permanent: parseBoolean(value), + }; + return true; + } + if (lowerSuffix === "redirectscheme.port") { + middleware.redirectScheme = { + ...(middleware.redirectScheme as Record | undefined), + port: value, + }; + return true; + } + if (lowerSuffix === "chain.middlewares") { + middleware.chain = { middlewares: splitList(value) }; + return true; + } + if (lowerSuffix.startsWith("ipallowlist.")) { + middleware.ipAllowList = { + ...(middleware.ipAllowList as Record | undefined), + [suffix.slice("ipallowlist.".length)]: lowerSuffix.endsWith("sourcerange") + ? splitList(value) + : value, + }; + return true; + } + if (lowerSuffix.startsWith("ipwhitelist.")) { + middleware.ipWhiteList = { + ...(middleware.ipWhiteList as Record | undefined), + [suffix.slice("ipwhitelist.".length)]: lowerSuffix.endsWith("sourcerange") + ? splitList(value) + : value, + }; + return true; + } + if (lowerSuffix.startsWith("headers.customresponseheaders.")) { + const header = suffix.slice("headers.customresponseheaders.".length); + middleware.headers = { + ...(middleware.headers as Record | undefined), + customResponseHeaders: { + ...(( + middleware.headers as + | Record> + | undefined + )?.customResponseHeaders ?? {}), + [header]: value ?? "", + }, + }; + return true; + } + if (lowerSuffix.startsWith("headers.customrequestheaders.")) { + const header = suffix.slice("headers.customrequestheaders.".length); + middleware.headers = { + ...(middleware.headers as Record | undefined), + customRequestHeaders: { + ...(( + middleware.headers as + | Record> + | undefined + )?.customRequestHeaders ?? {}), + [header]: value ?? "", + }, + }; + return true; + } + return false; +}; + +const parseComposeLabels = ( + labels: string[], + options: ComposeLabelTranslatorOptions, +) => { + const routers: Record = {}; + const services: Record = {}; + const middlewares: Record> = {}; + const warnings: CaddyMigrationWarning[] = []; + const classifications: LabelClassification[] = []; + + for (const label of labels) { + const { key, value } = splitLabel(label); + const dokployGenerated = isDokployGeneratedTraefikLabel(label, { + appName: options.appName, + domains: options.domains, + includeGenericLabels: true, + }); + classifications.push({ label, dokployGenerated }); + + const routerMatch = key.match(/^traefik\.http\.routers\.([^.]+)\.(.+)$/); + if (routerMatch?.[1] && routerMatch[2]) { + const routerName = routerMatch[1]; + const suffix = routerMatch[2]; + routers[routerName] ??= {}; + const lowerSuffix = suffix.toLowerCase(); + if (lowerSuffix === "rule") routers[routerName].rule = value; + else if (lowerSuffix === "entrypoints") { + routers[routerName].entryPoints = splitList(value); + } else if (lowerSuffix === "middlewares") { + routers[routerName].middlewares = splitList(value); + } else if (lowerSuffix === "service") routers[routerName].service = value; + else if (lowerSuffix === "priority") { + const priority = Number(value); + if (Number.isFinite(priority)) routers[routerName].priority = priority; + } else if (lowerSuffix === "tls") { + routers[routerName].tls = parseBoolean(value) ?? value === ""; + } else if (lowerSuffix === "tls.certresolver") { + routers[routerName].tlsCertResolver = value; + } else { + warnings.push( + warning(`Unsupported router label suffix "${suffix}"`, { + ...options, + routerName, + label, + code: "unsupported-router", + }), + ); + } + continue; + } + + const serviceMatch = key.match(/^traefik\.http\.services\.([^.]+)\.(.+)$/); + if (serviceMatch?.[1] && serviceMatch[2]) { + const serviceName = serviceMatch[1]; + const suffix = serviceMatch[2]; + services[serviceName] ??= {}; + const lowerSuffix = suffix.toLowerCase(); + if (lowerSuffix === "loadbalancer.server.port") { + const port = Number(value); + if (Number.isFinite(port)) services[serviceName].port = port; + } else if (lowerSuffix === "loadbalancer.server.scheme") { + services[serviceName].scheme = value; + } else { + warnings.push( + warning(`Unsupported service label suffix "${suffix}"`, { + ...options, + serviceName, + label, + code: "unsupported-service", + }), + ); + } + continue; + } + + const middlewareMatch = key.match( + /^traefik\.http\.middlewares\.([^.]+)\.(.+)$/, + ); + if (middlewareMatch?.[1] && middlewareMatch[2]) { + const middlewareName = middlewareMatch[1]; + const suffix = middlewareMatch[2]; + if (!setMiddlewareLabel(middlewares, middlewareName, suffix, value)) { + warnings.push( + warning(`Unsupported middleware label suffix "${suffix}"`, { + ...options, + middlewareName, + label, + code: "unsupported-middleware", + }), + ); + } + continue; + } + + if (key.startsWith("traefik.http.")) { + warnings.push( + warning(`Unsupported Traefik HTTP label "${key}"`, { + ...options, + label, + code: "invalid-label", + }), + ); + } + } + + return { routers, services, middlewares, warnings, classifications }; +}; + +const isSecurityMiddlewareWarning = (item: CaddyMigrationWarning) => + item.code === "unsupported-security-middleware"; + +const dedupeWarnings = (warnings: CaddyMigrationWarning[]) => { + const seen = new Set(); + return warnings.filter((item) => { + const key = [ + item.code, + item.source ?? "", + item.routerName ?? "", + item.serviceName ?? "", + item.middlewareName ?? "", + item.label ?? "", + item.message, + ].join("\0"); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +}; + +const routerUsesHttps = (router: ParsedRouterLabels) => + Boolean(router.tls) || + Boolean(router.tlsCertResolver) || + Boolean( + router.entryPoints?.includes("websecure") && + !router.entryPoints?.includes("web"), + ); + +const getRouterUpstreams = ( + routerName: string, + router: ParsedRouterLabels, + services: Record, + options: ComposeLabelTranslatorOptions, +) => { + const serviceName = router.service ?? routerName; + const service = services[serviceName] ?? services[routerName]; + if (!service?.port) { + return { + upstreams: [], + warnings: [ + warning( + `Router "${routerName}" references service "${serviceName}" without a migrated port label`, + { + ...options, + routerName, + serviceName, + code: "unresolved-service", + }, + ), + ], + }; + } + const scheme = service.scheme ?? "http"; + const upstreamName = + options.upstreamServiceName ?? options.serviceName ?? serviceName; + return { + upstreams: [`${scheme}://${upstreamName}:${service.port}`], + warnings: [], + }; +}; + +export const translateTraefikComposeLabelsToCaddyFragment = ( + labelsInput: ListOrDict | undefined, + options: ComposeLabelTranslatorOptions = {}, +): { + fragment: CaddyRouteFragment; + routes: CaddyRouteIntent[]; + warnings: CaddyMigrationWarning[]; + classifications: LabelClassification[]; +} => { + const labels = labelsToStrings(labelsInput); + const initialParsed = parseComposeLabels(labels, options); + const generatedSecurityWarnings = initialParsed.classifications.some( + (item) => item.dokployGenerated, + ) + ? translateParsedComposeRoutes(initialParsed, options).warnings.filter( + isSecurityMiddlewareWarning, + ) + : []; + const hasManualLabels = initialParsed.classifications.some( + (item) => !item.dokployGenerated, + ); + const hasGeneratedLabels = initialParsed.classifications.some( + (item) => item.dokployGenerated, + ); + const labelsForTranslation = + hasManualLabels && hasGeneratedLabels + ? labels.filter( + (_, index) => !initialParsed.classifications[index]?.dokployGenerated, + ) + : labels; + const parsed = + labelsForTranslation === labels + ? initialParsed + : parseComposeLabels(labelsForTranslation, options); + const warnings = dedupeWarnings([ + ...parsed.warnings, + ...generatedSecurityWarnings, + ]); + const translatedRoutes = translateParsedComposeRoutes(parsed, options); + warnings.push(...translatedRoutes.warnings); + return { + ...translatedRoutes, + warnings: dedupeWarnings(warnings), + classifications: initialParsed.classifications, + }; +}; + +const translateParsedComposeRoutes = ( + parsed: ReturnType, + options: ComposeLabelTranslatorOptions, +) => { + const warnings: CaddyMigrationWarning[] = []; + const routes: CaddyRouteIntent[] = []; + const middlewares = { + ...(options.fileMiddlewares ?? {}), + ...(parsed.middlewares as Record), + }; + + for (const [routerName, router] of Object.entries(parsed.routers)) { + if (!router.rule) { + warnings.push( + warning(`Router "${routerName}" is missing a rule label`, { + ...options, + routerName, + code: "unsupported-router", + }), + ); + continue; + } + + const rule = parseTraefikRule(router.rule, { + source: options.sourceFile, + routerName, + }); + warnings.push(...rule.warnings); + + const transforms = {}; + const basicAuth: { username: string; hash: string }[] = []; + const allowedRemoteIps: string[] = []; + let redirectScheme: CaddyRouteIntent["redirectScheme"] = null; + for (const middlewareName of router.middlewares ?? []) { + const translated = translateTraefikMiddleware( + middlewareName, + middlewares, + { + sourceFile: options.sourceFile, + routerName, + knownMiddlewares: { + ...defaultKnownTraefikFileMiddlewares, + ...(options.knownMiddlewares ?? {}), + }, + }, + ); + mergeTransforms(transforms, translated.transforms); + basicAuth.push(...translated.basicAuth); + mergeAllowedRemoteIps(allowedRemoteIps, translated.allowedRemoteIps); + if (translated.redirectScheme) { + redirectScheme = translated.redirectScheme; + } + warnings.push(...translated.warnings); + } + + if (router.tlsCertResolver && router.tlsCertResolver !== "letsencrypt") { + warnings.push( + warning( + `Custom certResolver "${router.tlsCertResolver}" has no direct Caddy mapping`, + { + ...options, + routerName, + code: "unsupported-router", + }, + ), + ); + } + + const upstreamResult = redirectScheme + ? { upstreams: [], warnings: [] } + : getRouterUpstreams(routerName, router, parsed.services, options); + warnings.push(...upstreamResult.warnings); + + rule.matches.forEach((match, index) => { + routes.push({ + id: `${toSafeId(options.sourceFile ?? options.appName ?? "compose")}-${routerName}${rule.matches.length > 1 ? `-${index + 1}` : ""}`, + source: "traefik-compose-label", + hosts: match.hosts, + pathPrefix: match.pathPrefix, + pathExact: match.pathExact, + https: redirectScheme ? false : routerUsesHttps(router), + priority: router.priority ?? null, + upstreams: upstreamResult.upstreams, + upstreamNetwork: options.upstreamNetwork, + transforms, + allowedRemoteIps: allowedRemoteIps.length ? allowedRemoteIps : null, + basicAuth: basicAuth.length ? basicAuth : null, + redirectScheme, + }); + }); + } + + const fragment: CaddyRouteFragment = { + version: CADDY_FRAGMENT_VERSION, + id: + options.fragmentId ?? + `migration.traefik-compose-label.${toSafeId( + options.sourceFile ?? options.appName ?? "compose", + )}`, + source: "traefik-compose-label", + description: options.sourceFile + ? `Migrated Traefik compose labels from ${options.sourceFile}` + : "Migrated Traefik compose labels", + routes, + }; + + return { + fragment, + routes, + warnings, + }; +}; diff --git a/packages/server/src/utils/caddy/migration/dynamic-file-translator.ts b/packages/server/src/utils/caddy/migration/dynamic-file-translator.ts new file mode 100644 index 0000000000..cd75b2aa92 --- /dev/null +++ b/packages/server/src/utils/caddy/migration/dynamic-file-translator.ts @@ -0,0 +1,736 @@ +import { parse } from "yaml"; +import type { FileConfig, HttpMiddleware } from "../../traefik/file-types"; +import type { + CaddyHeaderMap, + CaddyRouteFragment, + CaddyRouteIntent, + CaddyRouteTransform, +} from "../types"; +import { parseTraefikRule } from "./traefik-rule-parser"; +import type { + CaddyMiddlewareTranslation, + CaddyMigrationWarning, + KnownTraefikMiddlewareMap, + ResolvedCaddyMiddleware, +} from "./types"; + +interface DynamicTranslatorOptions { + sourceFile?: string; + fragmentId?: string; + knownMiddlewares?: KnownTraefikMiddlewareMap; + fileMiddlewares?: Record; +} + +const CADDY_FRAGMENT_VERSION = 1; +const httpUrlPrefixRegex = /^https?:\/\//i; + +export const defaultKnownTraefikFileMiddlewares: KnownTraefikMiddlewareMap = { + "redirect-to-https": { + redirectScheme: { scheme: "https", permanent: true }, + }, + "security-headers": { + transforms: { + responseHeaders: { + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + }, + }, + }, +}; + +const toSafeId = (value: string) => + value + .replace(/\.[^.]+$/, "") + .replace(/[^a-zA-Z0-9_.-]+/g, "-") + .replace(/^-+|-+$/g, "") || "traefik-dynamic"; + +const warning = ( + message: string, + options: { + source?: string; + routerName?: string; + serviceName?: string; + middlewareName?: string; + code?: CaddyMigrationWarning["code"]; + }, +): CaddyMigrationWarning => ({ + code: options.code ?? "unsupported-middleware", + message, + blocking: true, + source: options.source, + routerName: options.routerName, + serviceName: options.serviceName, + middlewareName: options.middlewareName, +}); + +const asRecord = (value: unknown) => + value && typeof value === "object" ? (value as Record) : {}; + +const mergeHeaders = ( + a?: CaddyHeaderMap | null, + b?: CaddyHeaderMap | null, +) => ({ + ...(a ?? {}), + ...(b ?? {}), +}); + +const mergeTransforms = ( + target: CaddyRouteTransform, + next: CaddyRouteTransform = {}, +) => { + target.requestHeaders = mergeHeaders( + target.requestHeaders, + next.requestHeaders, + ); + target.responseHeaders = mergeHeaders( + target.responseHeaders, + next.responseHeaders, + ); + if (next.stripPrefix) { + target.stripPrefix = next.stripPrefix; + } + if (next.addPrefix) { + target.addPrefix = next.addPrefix; + } +}; + +const mergeAllowedRemoteIps = (target: string[], next?: string[] | null) => { + if (!next?.length) { + return; + } + for (const range of next) { + if (!target.includes(range)) { + target.push(range); + } + } +}; + +const normalizeMiddlewareName = (name: string) => name.replace(/@file$/, ""); + +const knownMiddlewareToTranslation = ( + middleware: Partial & { + responseHeaders?: CaddyHeaderMap; + requestHeaders?: CaddyHeaderMap; + }, +): ResolvedCaddyMiddleware => ({ + transforms: { + ...(middleware.transforms ?? {}), + ...(middleware.requestHeaders || middleware.responseHeaders + ? { + requestHeaders: middleware.requestHeaders, + responseHeaders: middleware.responseHeaders, + } + : {}), + }, + basicAuth: middleware.basicAuth ?? [], + allowedRemoteIps: middleware.allowedRemoteIps ?? null, + redirectScheme: middleware.redirectScheme ?? null, +}); + +const setResponseHeader = ( + transforms: CaddyRouteTransform, + name: string, + value: string, +) => { + transforms.responseHeaders = { + ...(transforms.responseHeaders ?? {}), + [name]: value, + }; +}; + +const translateHeadersMiddleware = (headers: Record) => { + const transforms: CaddyRouteTransform = {}; + const customRequestHeaders = asRecord(headers.customRequestHeaders); + const customResponseHeaders = asRecord(headers.customResponseHeaders); + if (Object.keys(customRequestHeaders).length) { + transforms.requestHeaders = customRequestHeaders as CaddyHeaderMap; + } + if (Object.keys(customResponseHeaders).length) { + transforms.responseHeaders = customResponseHeaders as CaddyHeaderMap; + } + if (headers.frameDeny === true) { + setResponseHeader(transforms, "X-Frame-Options", "DENY"); + } + if (typeof headers.customFrameOptionsValue === "string") { + setResponseHeader( + transforms, + "X-Frame-Options", + headers.customFrameOptionsValue, + ); + } + if (headers.contentTypeNosniff === true) { + setResponseHeader(transforms, "X-Content-Type-Options", "nosniff"); + } + if (headers.browserXssFilter === true) { + setResponseHeader(transforms, "X-XSS-Protection", "1; mode=block"); + } + if (typeof headers.customBrowserXSSValue === "string") { + setResponseHeader( + transforms, + "X-XSS-Protection", + headers.customBrowserXSSValue, + ); + } + if (typeof headers.contentSecurityPolicy === "string") { + setResponseHeader( + transforms, + "Content-Security-Policy", + headers.contentSecurityPolicy, + ); + } + if (typeof headers.referrerPolicy === "string") { + setResponseHeader(transforms, "Referrer-Policy", headers.referrerPolicy); + } + if (typeof headers.permissionsPolicy === "string") { + setResponseHeader( + transforms, + "Permissions-Policy", + headers.permissionsPolicy, + ); + } + if (typeof headers.featurePolicy === "string") { + setResponseHeader(transforms, "Feature-Policy", headers.featurePolicy); + } + if (typeof headers.stsSeconds === "number" && headers.stsSeconds > 0) { + setResponseHeader( + transforms, + "Strict-Transport-Security", + [ + `max-age=${headers.stsSeconds}`, + headers.stsIncludeSubdomains === true ? "includeSubDomains" : null, + headers.stsPreload === true ? "preload" : null, + ] + .filter(Boolean) + .join("; "), + ); + } + return transforms; +}; + +const parseBasicAuthUsers = (users: unknown) => { + if (!Array.isArray(users)) { + return []; + } + return users.flatMap((user) => { + if (typeof user !== "string") { + return []; + } + const separatorIndex = user.indexOf(":"); + if (separatorIndex === -1) { + return []; + } + return [ + { + username: user.slice(0, separatorIndex), + hash: user.slice(separatorIndex + 1), + }, + ]; + }); +}; + +const isLikelyCaddySupportedBasicAuthHash = (hash: string) => + /^\$2[aby]\$/.test(hash); + +const findCaseInsensitiveValue = ( + record: Record, + key: string, +) => { + const lowerKey = key.toLowerCase(); + const entry = Object.entries(record).find( + ([entryKey]) => entryKey.toLowerCase() === lowerKey, + ); + return entry?.[1]; +}; + +const getIpAllowRanges = (middleware: Record) => { + const ipConfig = asRecord(middleware.ipAllowList ?? middleware.ipWhiteList); + const sourceRange = findCaseInsensitiveValue(ipConfig, "sourceRange"); + if (!Array.isArray(sourceRange)) { + return []; + } + return sourceRange + .map((item) => `${item}`.trim()) + .filter((item) => item.length > 0); +}; + +const normalizeTraefikServerUrl = (value: string) => { + if (!httpUrlPrefixRegex.test(value)) { + return value; + } + try { + const url = new URL(value); + if (url.port) { + return value; + } + const scheme = url.protocol.replace(":", ""); + const defaultPort = scheme === "https" ? "443" : "80"; + return `${scheme}://${url.hostname}:${defaultPort}`; + } catch { + return value; + } +}; + +export const translateTraefikMiddleware = ( + middlewareName: string, + middlewares: Record = {}, + options: DynamicTranslatorOptions & { + routerName?: string; + seen?: Set; + } = {}, +): CaddyMiddlewareTranslation => { + const normalizedName = normalizeMiddlewareName(middlewareName); + const source = options.sourceFile; + const seen = options.seen ?? new Set(); + if (seen.has(normalizedName)) { + return { + transforms: {}, + basicAuth: [], + warnings: [ + warning(`Middleware chain cycle at "${normalizedName}"`, { + source, + routerName: options.routerName, + middlewareName: normalizedName, + }), + ], + }; + } + + const knownMiddleware = { + ...defaultKnownTraefikFileMiddlewares, + ...(options.knownMiddlewares ?? {}), + }[normalizedName]; + if (knownMiddleware && !middlewares[normalizedName]) { + return { + ...knownMiddlewareToTranslation(knownMiddleware), + warnings: [], + }; + } + + const middleware = middlewares[normalizedName]; + if (!middleware) { + return { + transforms: {}, + basicAuth: [], + warnings: [ + warning(`Referenced middleware "${middlewareName}" was not found`, { + source, + routerName: options.routerName, + middlewareName, + code: "unresolved-middleware", + }), + ], + }; + } + + const middlewareRecord = middleware as Record; + const transforms: CaddyRouteTransform = {}; + const basicAuth: { username: string; hash: string }[] = []; + const allowedRemoteIps: string[] = []; + const warnings: CaddyMigrationWarning[] = []; + let redirectScheme: CaddyMiddlewareTranslation["redirectScheme"] = null; + + const securityMiddleware = middlewareRecord.ipAllowList + ? "ipAllowList" + : middlewareRecord.ipWhiteList + ? "ipWhiteList" + : null; + if (securityMiddleware) { + const ranges = getIpAllowRanges(middlewareRecord); + if (ranges.length) { + mergeAllowedRemoteIps(allowedRemoteIps, ranges); + } else { + warnings.push( + warning( + `Traefik middleware "${normalizedName}" uses ${securityMiddleware} without a migratable sourceRange`, + { + source, + routerName: options.routerName, + middlewareName: normalizedName, + code: "unsupported-security-middleware", + }, + ), + ); + } + } else if (middlewareRecord.headers) { + mergeTransforms( + transforms, + translateHeadersMiddleware(asRecord(middlewareRecord.headers)), + ); + } else if (middlewareRecord.stripPrefix) { + const prefixes = asRecord(middlewareRecord.stripPrefix).prefixes; + if (Array.isArray(prefixes) && prefixes.length === 1) { + transforms.stripPrefix = `${prefixes[0]}`; + } else { + warnings.push( + warning("stripPrefix migration supports exactly one prefix", { + source, + routerName: options.routerName, + middlewareName: normalizedName, + }), + ); + } + } else if (middlewareRecord.addPrefix) { + const prefix = asRecord(middlewareRecord.addPrefix).prefix; + if (typeof prefix === "string") { + transforms.addPrefix = prefix; + } else { + warnings.push( + warning("addPrefix middleware is missing a string prefix", { + source, + routerName: options.routerName, + middlewareName: normalizedName, + }), + ); + } + } else if (middlewareRecord.basicAuth) { + const basicAuthConfig = asRecord(middlewareRecord.basicAuth); + for (const account of parseBasicAuthUsers(basicAuthConfig.users)) { + if (isLikelyCaddySupportedBasicAuthHash(account.hash)) { + basicAuth.push(account); + continue; + } + warnings.push( + warning( + `basicAuth user "${account.username}" uses a hash format that is not supported by the Caddy migration`, + { + source, + routerName: options.routerName, + middlewareName: normalizedName, + code: "unsupported-security-middleware", + }, + ), + ); + } + if (basicAuthConfig.usersFile) { + warnings.push( + warning("basicAuth usersFile cannot be inlined into Caddy migration", { + source, + routerName: options.routerName, + middlewareName: normalizedName, + }), + ); + } + } else if (middlewareRecord.redirectScheme) { + const redirect = asRecord(middlewareRecord.redirectScheme); + redirectScheme = { + scheme: typeof redirect.scheme === "string" ? redirect.scheme : "https", + permanent: redirect.permanent !== false, + port: typeof redirect.port === "string" ? redirect.port : null, + }; + } else if (middlewareRecord.chain) { + const chain = asRecord(middlewareRecord.chain).middlewares; + if (!Array.isArray(chain)) { + warnings.push( + warning("chain middleware is missing middlewares", { + source, + routerName: options.routerName, + middlewareName: normalizedName, + }), + ); + } else { + const nextSeen = new Set([...seen, normalizedName]); + for (const chainedName of chain) { + const translated = translateTraefikMiddleware( + `${chainedName}`, + middlewares, + { + ...options, + seen: nextSeen, + }, + ); + mergeTransforms(transforms, translated.transforms); + basicAuth.push(...translated.basicAuth); + mergeAllowedRemoteIps(allowedRemoteIps, translated.allowedRemoteIps); + if (translated.redirectScheme) { + redirectScheme = translated.redirectScheme; + } + warnings.push(...translated.warnings); + } + } + } else { + warnings.push( + warning(`Unsupported Traefik middleware "${normalizedName}"`, { + source, + routerName: options.routerName, + middlewareName: normalizedName, + }), + ); + } + + return { + transforms, + basicAuth, + allowedRemoteIps: allowedRemoteIps.length ? allowedRemoteIps : null, + redirectScheme, + warnings, + }; +}; + +const collectRouterMiddlewares = ( + middlewareNames: string[] | undefined, + middlewares: Record, + options: DynamicTranslatorOptions & { routerName: string }, +) => { + const transforms: CaddyRouteTransform = {}; + const basicAuth: { username: string; hash: string }[] = []; + const allowedRemoteIps: string[] = []; + const warnings: CaddyMigrationWarning[] = []; + let redirectScheme: CaddyMiddlewareTranslation["redirectScheme"] = null; + + for (const middlewareName of middlewareNames ?? []) { + const translated = translateTraefikMiddleware( + middlewareName, + middlewares, + options, + ); + mergeTransforms(transforms, translated.transforms); + basicAuth.push(...translated.basicAuth); + mergeAllowedRemoteIps(allowedRemoteIps, translated.allowedRemoteIps); + if (translated.redirectScheme) { + redirectScheme = translated.redirectScheme; + } + warnings.push(...translated.warnings); + } + + return { + transforms, + basicAuth, + allowedRemoteIps: allowedRemoteIps.length ? allowedRemoteIps : null, + redirectScheme, + warnings, + }; +}; + +const getServiceUpstreams = ( + serviceName: string, + config: FileConfig, + options: DynamicTranslatorOptions & { routerName: string }, +) => { + const service = config.http?.services?.[normalizeMiddlewareName(serviceName)]; + if (!service) { + return { + upstreams: [], + warnings: [ + warning(`Referenced service "${serviceName}" was not found`, { + source: options.sourceFile, + routerName: options.routerName, + serviceName, + code: "unresolved-service", + }), + ], + }; + } + const loadBalancer = (service as Record).loadBalancer; + if (!loadBalancer) { + return { + upstreams: [], + warnings: [ + warning(`Service "${serviceName}" is not a loadBalancer service`, { + source: options.sourceFile, + routerName: options.routerName, + serviceName, + code: "unsupported-service", + }), + ], + }; + } + const loadBalancerRecord = asRecord(loadBalancer); + const servers = loadBalancerRecord.servers; + const upstreams = Array.isArray(servers) + ? servers.flatMap((server) => { + const url = asRecord(server).url; + return typeof url === "string" ? [normalizeTraefikServerUrl(url)] : []; + }) + : []; + const warnings: CaddyMigrationWarning[] = []; + if (!upstreams.length) { + warnings.push( + warning(`Service "${serviceName}" has no loadBalancer servers`, { + source: options.sourceFile, + routerName: options.routerName, + serviceName, + code: "unsupported-service", + }), + ); + } + if (loadBalancerRecord.passHostHeader === false) { + warnings.push( + warning("passHostHeader=false has no direct migration mapping yet", { + source: options.sourceFile, + routerName: options.routerName, + serviceName, + code: "unsupported-service", + }), + ); + } + for (const key of [ + "sticky", + "healthCheck", + "responseForwarding", + "serversTransport", + ]) { + if (loadBalancerRecord[key]) { + warnings.push( + warning(`Service option "${key}" is not migrated yet`, { + source: options.sourceFile, + routerName: options.routerName, + serviceName, + code: "unsupported-service", + }), + ); + } + } + return { upstreams, warnings }; +}; + +const routerUsesHttps = (router: Record) => { + if (router.tls) { + return true; + } + const entryPoints = router.entryPoints; + return Array.isArray(entryPoints) + ? entryPoints.includes("websecure") && !entryPoints.includes("web") + : false; +}; + +const isTraefikInternalRouter = ( + routerName: string, + router: Record, +) => { + const service = typeof router.service === "string" ? router.service : ""; + const entryPoints = Array.isArray(router.entryPoints) + ? router.entryPoints.map(String) + : []; + return ( + service === "api@internal" || + (routerName.includes("traefik-dashboard") && + entryPoints.includes("traefik")) + ); +}; + +export const translateTraefikDynamicConfigToCaddyFragment = ( + input: string | FileConfig, + options: DynamicTranslatorOptions = {}, +): { + fragment: CaddyRouteFragment; + routes: CaddyRouteIntent[]; + warnings: CaddyMigrationWarning[]; +} => { + let config: FileConfig; + try { + config = typeof input === "string" ? (parse(input) as FileConfig) : input; + } catch (error) { + const fragment: CaddyRouteFragment = { + version: CADDY_FRAGMENT_VERSION, + id: options.fragmentId ?? "migration.traefik-dynamic.invalid", + source: "traefik-dynamic-file", + routes: [], + }; + return { + fragment, + routes: [], + warnings: [ + warning( + error instanceof Error + ? error.message + : "Invalid Traefik dynamic config", + { source: options.sourceFile, code: "invalid-config" }, + ), + ], + }; + } + + const routes: CaddyRouteIntent[] = []; + const warnings: CaddyMigrationWarning[] = []; + const mergedMiddlewares = { + ...(options.fileMiddlewares ?? {}), + ...(config.http?.middlewares ?? {}), + }; + for (const [routerName, router] of Object.entries( + config.http?.routers ?? {}, + )) { + const routerRecord = router as unknown as Record; + if (isTraefikInternalRouter(routerName, routerRecord)) { + warnings.push({ + code: "unsupported-router", + message: `Skipped Traefik internal router "${routerName}"; Caddy migration does not expose api@internal/dashboard routes`, + blocking: false, + source: options.sourceFile, + routerName, + serviceName: + typeof routerRecord.service === "string" + ? routerRecord.service + : undefined, + }); + continue; + } + const rule = typeof router.rule === "string" ? router.rule : ""; + const parsedRule = parseTraefikRule(rule, { + source: options.sourceFile, + routerName, + }); + warnings.push(...parsedRule.warnings); + + const middlewareResult = collectRouterMiddlewares( + router.middlewares, + mergedMiddlewares, + { ...options, routerName }, + ); + warnings.push(...middlewareResult.warnings); + + const serviceName = router.service; + const serviceResult = middlewareResult.redirectScheme + ? { upstreams: [], warnings: [] } + : getServiceUpstreams(serviceName, config, { ...options, routerName }); + warnings.push(...serviceResult.warnings); + + const tls = asRecord(routerRecord.tls); + if (tls.certResolver && tls.certResolver !== "letsencrypt") { + warnings.push( + warning( + `Custom certResolver "${tls.certResolver}" has no direct Caddy mapping`, + { + source: options.sourceFile, + routerName, + code: "unsupported-router", + }, + ), + ); + } + + parsedRule.matches.forEach((match, index) => { + routes.push({ + id: `${toSafeId(options.sourceFile ?? "dynamic")}-${routerName}${parsedRule.matches.length > 1 ? `-${index + 1}` : ""}`, + source: "traefik-dynamic-file", + hosts: match.hosts, + pathPrefix: match.pathPrefix, + pathExact: match.pathExact, + https: middlewareResult.redirectScheme + ? false + : routerUsesHttps(routerRecord), + priority: router.priority ?? null, + upstreams: serviceResult.upstreams, + transforms: middlewareResult.transforms, + allowedRemoteIps: middlewareResult.allowedRemoteIps, + basicAuth: middlewareResult.basicAuth.length + ? middlewareResult.basicAuth + : null, + redirectScheme: middlewareResult.redirectScheme, + }); + }); + } + + const fragment: CaddyRouteFragment = { + version: CADDY_FRAGMENT_VERSION, + id: + options.fragmentId ?? + `migration.traefik-dynamic.${toSafeId(options.sourceFile ?? "manual")}`, + source: "traefik-dynamic-file", + description: options.sourceFile + ? `Migrated Traefik dynamic file ${options.sourceFile}` + : "Migrated Traefik dynamic config", + routes, + }; + + return { fragment, routes, warnings }; +}; diff --git a/packages/server/src/utils/caddy/migration/files.ts b/packages/server/src/utils/caddy/migration/files.ts new file mode 100644 index 0000000000..b5bcbc7cc6 --- /dev/null +++ b/packages/server/src/utils/caddy/migration/files.ts @@ -0,0 +1,335 @@ +import { randomUUID } from "node:crypto"; +import { + copyFileSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import * as path from "node:path"; +import { paths } from "@dokploy/server/constants"; +import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; +import { quote } from "shell-quote"; +import type { + CaddyMigrationArtifactPaths, + CaddyMigrationReport, +} from "./types"; + +export const assertSafeMigrationId = (migrationId: string) => { + if ( + !/^[a-zA-Z0-9_.-]+$/.test(migrationId) || + migrationId === "." || + migrationId === ".." || + migrationId.split(".").some((segment) => segment === "") + ) { + throw new Error( + `Invalid Caddy migration id "${migrationId}". Use letters, numbers, dash, underscore, or dot only; dot path segments are not allowed.`, + ); + } +}; + +const assertPosixPathWithinBase = (basePath: string, targetPath: string) => { + const resolvedBase = path.posix.resolve(basePath); + const resolvedTarget = path.posix.resolve(targetPath); + if ( + resolvedTarget !== resolvedBase && + !resolvedTarget.startsWith(`${resolvedBase}/`) + ) { + throw new Error(`Resolved path "${targetPath}" escapes "${basePath}"`); + } +}; + +export const createCaddyMigrationId = () => + `caddy-${new Date() + .toISOString() + .replace(/[^0-9]/g, "") + .slice(0, 14)}-${randomUUID().slice(0, 8)}`; + +export const getCaddyMigrationArtifactPaths = ( + migrationId: string, + serverId?: string | null, +): CaddyMigrationArtifactPaths => { + assertSafeMigrationId(migrationId); + const migrationsPath = paths(!!serverId).CADDY_MIGRATIONS_PATH; + const root = path.posix.join(migrationsPath, migrationId); + assertPosixPathWithinBase(migrationsPath, root); + return { + root, + reportJson: path.posix.join(root, "report.json"), + reportMd: path.posix.join(root, "report.md"), + caddyJson: path.posix.join(root, "caddy.json"), + fragmentsDir: path.posix.join(root, "fragments"), + backupsDir: path.posix.join(root, "backups"), + }; +}; + +const encodeBase64 = (content: string) => + Buffer.from(content, "utf8").toString("base64"); + +export const ensureMigrationDirectory = async ( + dirPath: string, + serverId?: string | null, +) => { + if (serverId) { + await execAsyncRemote(serverId, `mkdir -p ${quote([dirPath])}`); + return; + } + mkdirSync(dirPath, { recursive: true }); +}; + +export const writeMigrationTextFile = async ( + filePath: string, + content: string, + serverId?: string | null, +) => { + if (serverId) { + const tempPath = `${filePath}.tmp-${Date.now()}`; + await execAsyncRemote( + serverId, + [ + `mkdir -p ${quote([path.posix.dirname(filePath)])}`, + `printf %s ${quote([encodeBase64(content)])} | base64 -d > ${quote([ + tempPath, + ])}`, + `mv ${quote([tempPath])} ${quote([filePath])}`, + ].join(" && "), + ); + return; + } + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, content, "utf8"); +}; + +export const readMigrationTextFileIfExists = async ( + filePath: string, + serverId?: string | null, +) => { + if (serverId) { + const { stdout } = await execAsyncRemote( + serverId, + `if [ -f ${quote([filePath])} ]; then cat ${quote([filePath])}; fi`, + ); + return stdout || null; + } + if (!existsSync(filePath)) return null; + return readFileSync(filePath, "utf8"); +}; + +export const readRequiredMigrationTextFile = async ( + filePath: string, + serverId?: string | null, +) => { + const content = await readMigrationTextFileIfExists(filePath, serverId); + if (content === null) { + throw new Error(`Migration file not found: ${filePath}`); + } + return content; +}; + +export const listMigrationFiles = async ( + dirPath: string, + serverId?: string | null, + extensions: string[] = [], +) => { + if (serverId) { + const extensionExpression = extensions.length + ? extensions.map((ext) => `-name '*${ext}'`).join(" -o ") + : "-type f"; + const { stdout } = await execAsyncRemote( + serverId, + `if [ -d ${quote([dirPath])} ]; then find ${quote([ + dirPath, + ])} -maxdepth 1 -type f \\( ${extensionExpression} \\) | sort; fi`, + ); + return stdout + .split("\n") + .map((item) => item.trim()) + .filter(Boolean); + } + if (!existsSync(dirPath)) return []; + return readdirSync(dirPath) + .filter((fileName) => + extensions.length + ? extensions.some((ext) => fileName.endsWith(ext)) + : true, + ) + .sort() + .map((fileName) => path.join(dirPath, fileName)); +}; + +export const migrationPathExists = async ( + target: string, + serverId?: string | null, +) => { + if (serverId) { + const { stdout } = await execAsyncRemote( + serverId, + `if [ -e ${quote([target])} ]; then echo yes; fi`, + ); + return stdout.trim() === "yes"; + } + return existsSync(target); +}; + +export const acquireCaddyMigrationOperationLock = async ( + migrationId: string, + serverId?: string | null, +) => { + const artifactPaths = getCaddyMigrationArtifactPaths(migrationId, serverId); + const lockPath = path.posix.join(artifactPaths.root, ".operation.lock"); + const errorMessage = `Caddy migration ${migrationId} already has an apply or rollback operation in progress`; + + if (serverId) { + try { + await execAsyncRemote( + serverId, + `mkdir ${quote([lockPath])} 2>/dev/null || { echo ${quote([ + errorMessage, + ])} >&2; exit 70; }`, + ); + } catch { + throw new Error(errorMessage); + } + } else { + try { + mkdirSync(lockPath); + } catch { + throw new Error(errorMessage); + } + } + + let released = false; + return async () => { + if (released) return; + released = true; + await removeMigrationPath(lockPath, serverId); + }; +}; + +const copyLocalPath = (source: string, destination: string) => { + const stats = statSync(source); + if (stats.isDirectory()) { + mkdirSync(destination, { recursive: true }); + for (const entry of readdirSync(source)) { + copyLocalPath(path.join(source, entry), path.join(destination, entry)); + } + return; + } + mkdirSync(path.dirname(destination), { recursive: true }); + writeFileSync(destination, readFileSync(source)); +}; + +export const copyMigrationPath = async ( + source: string, + destination: string, + serverId?: string | null, +) => { + const tempDestination = `${destination}.tmp-${Date.now()}`; + if (serverId) { + await execAsyncRemote( + serverId, + [ + `test -e ${quote([source])}`, + `rm -rf ${quote([tempDestination])}`, + `mkdir -p ${quote([path.posix.dirname(destination)])}`, + `cp -a ${quote([source])} ${quote([tempDestination])}`, + `rm -rf ${quote([destination])}`, + `mv ${quote([tempDestination])} ${quote([destination])}`, + ].join(" && "), + ); + return; + } + if (!existsSync(source)) { + throw new Error(`Migration source path not found: ${source}`); + } + rmSync(tempDestination, { force: true, recursive: true }); + mkdirSync(path.dirname(destination), { recursive: true }); + copyLocalPath(source, tempDestination); + rmSync(destination, { force: true, recursive: true }); + renameSync(tempDestination, destination); +}; + +export const copyMigrationFileInPlace = async ( + source: string, + destination: string, + serverId?: string | null, +) => { + if (serverId) { + const copyInPlaceCommand = [ + `test -f ${quote([source])}`, + `test -f ${quote([destination])}`, + `cp ${quote([source])} ${quote([destination])}`, + ].join(" && "); + try { + await execAsyncRemote(serverId, copyInPlaceCommand); + return; + } catch { + await copyMigrationPath(source, destination, serverId); + return; + } + } + if ( + existsSync(source) && + statSync(source).isFile() && + existsSync(destination) + ) { + const destinationStats = statSync(destination); + if (destinationStats.isFile()) { + copyFileSync(source, destination); + return; + } + } + await copyMigrationPath(source, destination, serverId); +}; + +export const removeMigrationPath = async ( + target: string, + serverId?: string | null, +) => { + if (serverId) { + await execAsyncRemote(serverId, `rm -rf ${quote([target])}`); + return; + } + rmSync(target, { force: true, recursive: true }); +}; + +export const loadCaddyMigrationReport = async ( + migrationId: string, + serverId?: string | null, +): Promise => { + const artifactPaths = getCaddyMigrationArtifactPaths(migrationId, serverId); + const content = await readRequiredMigrationTextFile( + artifactPaths.reportJson, + serverId, + ); + return JSON.parse(content) as CaddyMigrationReport; +}; + +export const writeCaddyMigrationReport = async ( + report: CaddyMigrationReport, + serverId?: string | null, +) => { + const nextReport = { + ...report, + updatedAt: new Date().toISOString(), + }; + await writeMigrationTextFile( + nextReport.artifactPaths.reportJson, + `${JSON.stringify(nextReport, null, 2)}\n`, + serverId, + ); + return nextReport; +}; + +export const appendCaddyMigrationEvent = ( + report: CaddyMigrationReport, + type: string, + message: string, +): CaddyMigrationReport => ({ + ...report, + events: [...report.events, { at: new Date().toISOString(), type, message }], +}); diff --git a/packages/server/src/utils/caddy/migration/prepare.ts b/packages/server/src/utils/caddy/migration/prepare.ts new file mode 100644 index 0000000000..aa52f57d73 --- /dev/null +++ b/packages/server/src/utils/caddy/migration/prepare.ts @@ -0,0 +1,1014 @@ +import { isIP } from "node:net"; +import * as path from "node:path"; +import { paths } from "@dokploy/server/constants"; +import { db } from "@dokploy/server/db"; +import { applications, compose } from "@dokploy/server/db/schema"; +import { getWebServerSettings } from "@dokploy/server/services/web-server-settings"; +import { createCaddyComposeRouteFragment } from "@dokploy/server/utils/caddy/compose"; +import { + compileCaddyConfig, + parseCaddyUpstream, + readCaddyRouteFragments, +} from "@dokploy/server/utils/caddy/config"; +import { + createCaddyApplicationRouteFragment, + getUnsupportedCaddyDomainFieldMessages, +} from "@dokploy/server/utils/caddy/domain"; +import type { CaddyRouteFragment } from "@dokploy/server/utils/caddy/types"; +import { + DOKPLOY_CADDY_NETWORK, + getCaddyComposeNetworkAlias, + getCaddyComposeRuntimeTarget, +} from "@dokploy/server/utils/caddy/upstream-targets"; +import { + loadDockerCompose, + loadDockerComposeRemote, +} from "@dokploy/server/utils/docker/domain"; +import type { + ComposeSpecification, + ListOrDict, +} from "@dokploy/server/utils/docker/types"; +import { getComposeContainer } from "@dokploy/server/utils/docker/utils"; +import { getRemoteDocker } from "@dokploy/server/utils/servers/remote-docker"; +import type { + FileConfig, + HttpMiddleware, +} from "@dokploy/server/utils/traefik/file-types"; +import { eq, isNull } from "drizzle-orm"; +import { parse } from "yaml"; +import { translateTraefikComposeLabelsToCaddyFragment } from "./compose-label-translator"; +import { translateTraefikDynamicConfigToCaddyFragment } from "./dynamic-file-translator"; +import { + createCaddyMigrationId, + ensureMigrationDirectory, + getCaddyMigrationArtifactPaths, + listMigrationFiles, + readMigrationTextFileIfExists, + writeCaddyMigrationReport, + writeMigrationTextFile, +} from "./files"; +import type { CaddyMigrationReport, CaddyMigrationWarning } from "./types"; + +const CADDY_FRAGMENT_VERSION = 1; + +const warning = ( + message: string, + options: Partial = {}, +): CaddyMigrationWarning => ({ + code: options.code ?? "missing-input", + message, + blocking: options.blocking ?? false, + source: options.source, + routerName: options.routerName, + serviceName: options.serviceName, + middlewareName: options.middlewareName, + label: options.label, +}); + +const safeFragmentIdPart = (value: string) => + value.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "") || "source"; + +const addFragment = ( + fragments: CaddyRouteFragment[], + fragment: CaddyRouteFragment, +) => { + if (!fragment.routes.length) return; + const existing = new Set(fragments.map((item) => item.id)); + let nextFragment = fragment; + let index = 2; + while (existing.has(nextFragment.id)) { + nextFragment = { ...fragment, id: `${fragment.id}.${index}` }; + index++; + } + fragments.push(nextFragment); +}; + +const labelsToStrings = (labels: ListOrDict | undefined) => { + if (!labels) return []; + if (Array.isArray(labels)) return labels.map(String); + return Object.entries(labels).map(([key, value]) => + value === null ? key : `${key}=${value}`, + ); +}; + +const labelsHaveTraefikHttp = (labels: ListOrDict | undefined) => + labelsToStrings(labels).some((label) => label.startsWith("traefik.http.")); + +const isSecurityMiddlewareWarning = (item: CaddyMigrationWarning) => + item.code === "unsupported-security-middleware"; + +const hasTraefikHttpLabelMap = (labels: Record | undefined) => + labels + ? Object.keys(labels).some((label) => label.startsWith("traefik.http.")) + : false; + +const getTraefikNetworkLabel = (labels: Record | undefined) => + labels?.["traefik.swarm.network"] ?? + labels?.["traefik.docker.network"] ?? + DOKPLOY_CADDY_NETWORK; + +const splitDialHost = (dial: string) => { + const index = dial.lastIndexOf(":"); + return index === -1 ? dial : dial.slice(0, index); +}; + +const isDockerLocalHost = (host: string) => !isIP(host) && !host.includes("."); + +const getRunningTaskCounts = (tasks: any[]) => { + const counts = new Map(); + for (const task of tasks) { + const serviceId = task.ServiceID; + if ( + typeof serviceId !== "string" || + task.DesiredState !== "running" || + task.Status?.State !== "running" + ) { + continue; + } + counts.set(serviceId, (counts.get(serviceId) ?? 0) + 1); + } + return counts; +}; + +const getRunningServiceTasks = ( + service: any, + runningTaskCounts?: Map, +) => { + if (runningTaskCounts && typeof service.ID === "string") { + return runningTaskCounts.get(service.ID) ?? 0; + } + const runningTasks = service.ServiceStatus?.RunningTasks; + if (typeof runningTasks === "number") return runningTasks; + const replicas = service.Spec?.Mode?.Replicated?.Replicas; + return typeof replicas === "number" ? replicas : 1; +}; + +const getPrimaryContainerName = (container: any) => { + const names = Array.isArray(container.Names) ? container.Names : []; + return names[0]?.replace(/^\//, "") ?? container.Names?.[0] ?? null; +}; + +const addContainerNetworkAliases = (hosts: Set, container: any) => { + const networks = container.NetworkSettings?.Networks ?? {}; + for (const network of Object.values(networks) as any[]) { + for (const alias of network?.Aliases ?? []) { + if ( + typeof alias === "string" && + alias && + !/^[a-f0-9]{12,64}$/.test(alias) + ) { + hosts.add(alias); + } + } + } +}; + +const inspectContainer = async (docker: any, container: any) => { + const containerId = container.Id ?? container.ID; + if (typeof containerId !== "string" || !containerId) return null; + if (typeof docker.getContainer !== "function") return null; + try { + return await docker.getContainer(containerId).inspect(); + } catch { + return null; + } +}; + +const getLiveDockerTraefikFragments = async ( + serverId: string | undefined, + fileMiddlewares: Record, +) => { + const docker = await getRemoteDocker(serverId); + const hosts = new Set(); + const fragments: CaddyRouteFragment[] = []; + const warnings: CaddyMigrationWarning[] = []; + + const [services, containers, tasks] = await Promise.all([ + docker.listServices().catch(() => []), + docker.listContainers().catch(() => []), + typeof docker.listTasks === "function" + ? docker.listTasks().catch(() => null) + : Promise.resolve(null), + ]); + const runningTaskCounts = Array.isArray(tasks) + ? getRunningTaskCounts(tasks) + : undefined; + + for (const service of services as any[]) { + const serviceName = service.Spec?.Name; + if (typeof serviceName !== "string" || !serviceName) continue; + if (getRunningServiceTasks(service, runningTaskCounts) <= 0) continue; + hosts.add(serviceName); + hosts.add(`tasks.${serviceName}`); + const labels = service.Spec?.Labels as Record | undefined; + if (!hasTraefikHttpLabelMap(labels)) continue; + const translated = translateTraefikComposeLabelsToCaddyFragment(labels, { + sourceFile: `docker-service/${serviceName}`, + appName: serviceName, + serviceName, + upstreamServiceName: serviceName, + upstreamNetwork: getTraefikNetworkLabel(labels), + composeType: "stack", + fileMiddlewares, + }); + warnings.push(...translated.warnings.filter(isSecurityMiddlewareWarning)); + addFragment(fragments, { + ...translated.fragment, + id: `migration.traefik-docker-label.service.${safeFragmentIdPart( + serviceName, + )}`, + }); + } + + for (const container of containers as any[]) { + const containerName = getPrimaryContainerName(container); + if (!containerName) continue; + hosts.add(containerName); + for (const name of container.Names ?? []) { + if (typeof name === "string") hosts.add(name.replace(/^\//, "")); + } + addContainerNetworkAliases(hosts, container); + const inspectedContainer = await inspectContainer(docker, container); + if (inspectedContainer) { + addContainerNetworkAliases(hosts, inspectedContainer); + } + const labels = container.Labels as Record | undefined; + if ( + !hasTraefikHttpLabelMap(labels) || + labels?.["com.docker.swarm.service.name"] + ) { + continue; + } + const translated = translateTraefikComposeLabelsToCaddyFragment(labels, { + sourceFile: `docker-container/${containerName}`, + appName: containerName, + serviceName: containerName, + upstreamServiceName: containerName, + upstreamNetwork: getTraefikNetworkLabel(labels), + fileMiddlewares, + }); + warnings.push(...translated.warnings.filter(isSecurityMiddlewareWarning)); + addFragment(fragments, { + ...translated.fragment, + id: `migration.traefik-docker-label.container.${safeFragmentIdPart( + containerName, + )}`, + }); + } + + return { + fragments, + warnings, + hosts, + canPrune: + hosts.has("dokploy") || + hosts.has("dokploy-traefik") || + fragments.length > 0, + }; +}; + +const getLocalOrRemoteComposeSpec = async ( + composeEntity: typeof compose.$inferSelect, +) => { + const loaded = composeEntity.serverId + ? await loadDockerComposeRemote(composeEntity) + : await loadDockerCompose(composeEntity); + if (loaded) return loaded; + if (composeEntity.composeFile?.trim()) { + return parse(composeEntity.composeFile, { + maxAliasCount: 10000, + }) as ComposeSpecification; + } + return null; +}; + +const resolveFinalComposeServiceName = ( + composeEntity: typeof compose.$inferSelect, + composeSpec: ComposeSpecification | null, + serviceName: string, +) => { + if (!composeSpec?.services) { + return composeEntity.randomize || composeEntity.isolatedDeployment + ? null + : serviceName; + } + if (composeSpec.services[serviceName]) { + return serviceName; + } + const suffix = composeEntity.randomize + ? composeEntity.suffix + : composeEntity.isolatedDeployment + ? composeEntity.suffix || composeEntity.appName + : null; + if (suffix && composeSpec.services[`${serviceName}-${suffix}`]) { + return `${serviceName}-${suffix}`; + } + return null; +}; + +const getServiceNetworkAliases = ( + composeSpec: ComposeSpecification | null, + serviceName: string, + network: string, +) => { + const networks = composeSpec?.services?.[serviceName]?.networks; + if (!networks || Array.isArray(networks)) return []; + const attachment = networks[network]; + return attachment?.aliases ?? []; +}; + +const getComposeServiceContainerName = async ( + composeEntity: typeof compose.$inferSelect, + finalServiceName: string, +) => { + try { + const container = await getComposeContainer( + composeEntity as never, + finalServiceName, + ); + return container?.Names?.[0]?.replace(/^\//, "") ?? null; + } catch { + return null; + } +}; + +const resolveMigrationComposeUpstreamTarget = async ( + composeEntity: typeof compose.$inferSelect, + composeSpec: ComposeSpecification | null, + finalServiceName: string, +) => { + const runtimeTarget = getCaddyComposeRuntimeTarget( + composeEntity, + finalServiceName, + ); + + if ( + composeEntity.composeType === "stack" || + composeEntity.isolatedDeployment + ) { + return runtimeTarget; + } + + const expectedAlias = getCaddyComposeNetworkAlias( + composeEntity.appName, + finalServiceName, + ); + if ( + getServiceNetworkAliases( + composeSpec, + finalServiceName, + DOKPLOY_CADDY_NETWORK, + ).includes(expectedAlias) + ) { + return runtimeTarget; + } + + const containerName = + composeSpec?.services?.[finalServiceName]?.container_name; + if (typeof containerName === "string" && containerName.trim()) { + return { + host: containerName.trim(), + network: DOKPLOY_CADDY_NETWORK, + }; + } + + const runningContainerName = await getComposeServiceContainerName( + composeEntity, + finalServiceName, + ); + if (runningContainerName) { + return { + host: runningContainerName, + network: DOKPLOY_CADDY_NETWORK, + }; + } + + return runtimeTarget; +}; + +const routeCoverageKey = (route: CaddyRouteFragment["routes"][number]) => + route.hosts + .map((host) => + [ + host, + route.pathPrefix ?? "", + route.pathExact ?? "", + route.https ? "https" : "http", + ].join("\0"), + ) + .sort(); + +type MigrationRoute = CaddyRouteFragment["routes"][number]; + +const pathMatchesOverlap = ( + left: Pick, + right: Pick, +) => { + const leftExact = left.pathExact ?? null; + const rightExact = right.pathExact ?? null; + const leftPrefix = left.pathPrefix ?? null; + const rightPrefix = right.pathPrefix ?? null; + + if (!leftExact && !leftPrefix) return true; + if (!rightExact && !rightPrefix) return true; + if (leftExact && rightExact) return leftExact === rightExact; + if (leftExact && rightPrefix) return leftExact.startsWith(rightPrefix); + if (leftPrefix && rightExact) return rightExact.startsWith(leftPrefix); + if (leftPrefix && rightPrefix) { + return ( + leftPrefix.startsWith(rightPrefix) || rightPrefix.startsWith(leftPrefix) + ); + } + return false; +}; + +const routesOverlap = (left: MigrationRoute, right: MigrationRoute) => { + if (left.https !== right.https) return false; + if (!left.hosts.some((host) => right.hosts.includes(host))) return false; + return pathMatchesOverlap(left, right); +}; + +const warnOnManualFragmentConflicts = ( + fragments: CaddyRouteFragment[], + warnings: CaddyMigrationWarning[], +) => { + const manualFragments = fragments.filter( + (fragment) => fragment.source === "manual", + ); + const generatedFragments = fragments.filter( + (fragment) => fragment.source !== "manual", + ); + const seen = new Set(); + + for (const manualFragment of manualFragments) { + for (const manualRoute of manualFragment.routes) { + for (const generatedFragment of generatedFragments) { + for (const generatedRoute of generatedFragment.routes) { + if (!routesOverlap(manualRoute, generatedRoute)) continue; + const key = [ + manualFragment.id, + manualRoute.id, + generatedFragment.id, + generatedRoute.id, + ].join("\0"); + if (seen.has(key)) continue; + seen.add(key); + warnings.push( + warning( + `Manual Caddy route "${manualRoute.id}" overlaps generated migration route "${generatedRoute.id}" for at least one host/path`, + { + code: "conflicting-manual-fragment", + source: manualFragment.id, + blocking: true, + }, + ), + ); + } + } + } + } +}; + +const reconcileDbFallbackRoutes = ( + fragments: CaddyRouteFragment[], + warnings: CaddyMigrationWarning[], +) => { + const migratedCoverage = new Set(); + for (const fragment of fragments) { + if ( + fragment.source !== "traefik-compose-label" && + fragment.source !== "traefik-dynamic-file" + ) { + continue; + } + for (const route of fragment.routes) { + if (route.redirectScheme || !route.upstreams.length) continue; + for (const key of routeCoverageKey(route)) { + migratedCoverage.add(key); + } + } + } + + const reconciled: CaddyRouteFragment[] = []; + for (const fragment of fragments) { + if ( + fragment.source !== "dokploy-compose" && + fragment.source !== "dokploy-application" + ) { + reconciled.push(fragment); + continue; + } + + const nextRoutes = fragment.routes.filter((route) => { + const shadowed = routeCoverageKey(route).some((key) => + migratedCoverage.has(key), + ); + if (shadowed) { + warnings.push( + warning( + `Dropped DB fallback route "${route.id}" because a migrated Traefik route covers the same host/path`, + { + code: "shadowed-route", + source: fragment.id, + blocking: false, + }, + ), + ); + } + return !shadowed; + }); + + if (nextRoutes.length) { + reconciled.push({ ...fragment, routes: nextRoutes }); + } + } + + return reconciled; +}; + +const pruneUnreachableDockerUpstreams = ( + fragments: CaddyRouteFragment[], + warnings: CaddyMigrationWarning[], + reachableDockerHosts: Set, +) => { + const reconciled: CaddyRouteFragment[] = []; + for (const fragment of fragments) { + const nextRoutes: CaddyRouteFragment["routes"] = []; + for (const route of fragment.routes) { + if (route.redirectScheme || !route.upstreams.length) { + nextRoutes.push(route); + continue; + } + const upstreams = route.upstreams.filter((upstream) => { + let host = ""; + try { + host = splitDialHost(parseCaddyUpstream(upstream).dial); + } catch { + return true; + } + if (!isDockerLocalHost(host) || reachableDockerHosts.has(host)) { + return true; + } + warnings.push( + warning( + `Route "${route.id}" upstream "${upstream}" did not match a running Docker service, container, or network alias for "${host}"`, + { + code: "unreachable-upstream", + source: fragment.id, + blocking: true, + }, + ), + ); + return true; + }); + if (upstreams.length) { + nextRoutes.push({ ...route, upstreams }); + } + } + if (nextRoutes.length) { + reconciled.push({ ...fragment, routes: nextRoutes }); + } + } + return reconciled; +}; + +const readTraefikStaticConfig = async (serverId?: string) => { + const configPath = path.posix.join( + paths(!!serverId).MAIN_TRAEFIK_PATH, + "traefik.yml", + ); + const content = await readMigrationTextFileIfExists(configPath, serverId); + return { path: configPath, content }; +}; + +const getLetsEncryptEmail = async (serverId?: string) => { + if (serverId) return null; + const settings = await getWebServerSettings(); + return settings?.letsEncryptEmail ?? null; +}; + +const renderReportMarkdown = (report: CaddyMigrationReport) => { + const blocking = report.warnings.filter((item) => item.blocking); + return [ + `# Caddy Migration ${report.migrationId}`, + "", + `Status: **${report.status}**`, + `Server: ${report.serverId ?? "local"}`, + `Created: ${report.createdAt}`, + "", + "## Summary", + `- Fragments: ${report.summary.fragments}`, + `- Routes: ${report.summary.routes}`, + `- Warnings: ${report.summary.warnings}`, + `- Blocking warnings: ${report.summary.blockingWarnings}`, + `- Validation: ${report.validation.status}${report.validation.message ? ` (${report.validation.message})` : ""}`, + "", + "## Inputs", + `- Traefik static config: ${report.inputs.traefikStaticConfigFound ? report.inputs.traefikStaticConfigPath : "not found"}`, + `- Dynamic files: ${report.inputs.dynamicFiles.length}`, + `- DB application domains: ${report.inputs.dbApplicationDomains}`, + `- DB compose domains: ${report.inputs.dbComposeDomains}`, + `- Compose files scanned: ${report.inputs.composeFilesScanned.length}`, + `- Compose files skipped: ${report.inputs.composeFilesSkipped.length}`, + "", + "## Blocking warnings", + blocking.length + ? blocking + .map( + (item) => + `- [${item.code}] ${item.message}${item.source ? ` (${item.source})` : ""}`, + ) + .join("\n") + : "None", + "", + "## All warnings", + report.warnings.length + ? report.warnings + .map( + (item) => + `- ${item.blocking ? "BLOCKING" : "info"} [${item.code}] ${item.message}${item.source ? ` (${item.source})` : ""}`, + ) + .join("\n") + : "None", + "", + ].join("\n"); +}; + +export const prepareCaddyMigration = async ( + input: { serverId?: string } = {}, +) => { + const serverId = input.serverId; + const migrationId = createCaddyMigrationId(); + const artifactPaths = getCaddyMigrationArtifactPaths(migrationId, serverId); + await ensureMigrationDirectory(artifactPaths.fragmentsDir, serverId); + await ensureMigrationDirectory(artifactPaths.backupsDir, serverId); + + const createdAt = new Date().toISOString(); + let fragments: CaddyRouteFragment[] = []; + const warnings: CaddyMigrationWarning[] = []; + const composeFilesScanned: string[] = []; + const composeFilesSkipped: Array<{ path: string; reason: string }> = []; + const fileMiddlewares: Record = {}; + let reachableDockerHosts = new Set(); + let canPruneUnreachableDockerUpstreams = false; + + try { + const existingManualFragments = ( + await readCaddyRouteFragments({ serverId }) + ).filter((fragment) => fragment.source === "manual"); + for (const fragment of existingManualFragments) { + addFragment(fragments, fragment); + } + } catch (error) { + warnings.push( + warning( + `Unable to read existing manual Caddy fragments: ${ + error instanceof Error ? error.message : "unknown error" + }`, + { + code: "missing-input", + blocking: false, + }, + ), + ); + } + + const staticConfig = await readTraefikStaticConfig(serverId); + const dynamicFileContents: Array<{ + file: string; + content: string; + config?: FileConfig; + }> = []; + if (!staticConfig.content) { + warnings.push( + warning("Traefik static config was not found", { + source: staticConfig.path, + }), + ); + } + + const dynamicFiles = await listMigrationFiles( + paths(!!serverId).DYNAMIC_TRAEFIK_PATH, + serverId, + [".yml", ".yaml"], + ); + for (const dynamicFile of dynamicFiles) { + const content = await readMigrationTextFileIfExists(dynamicFile, serverId); + if (!content) continue; + try { + const config = parse(content) as FileConfig; + Object.assign(fileMiddlewares, config.http?.middlewares ?? {}); + dynamicFileContents.push({ file: dynamicFile, content, config }); + } catch { + dynamicFileContents.push({ file: dynamicFile, content }); + } + } + for (const dynamicFile of dynamicFileContents) { + const translated = translateTraefikDynamicConfigToCaddyFragment( + dynamicFile.config ?? dynamicFile.content, + { + sourceFile: path.posix.basename(dynamicFile.file), + fileMiddlewares, + }, + ); + warnings.push(...translated.warnings); + addFragment(fragments, translated.fragment); + } + try { + const liveDockerLabels = await getLiveDockerTraefikFragments( + serverId, + fileMiddlewares, + ); + reachableDockerHosts = liveDockerLabels.hosts; + canPruneUnreachableDockerUpstreams = liveDockerLabels.canPrune; + warnings.push(...liveDockerLabels.warnings); + for (const fragment of liveDockerLabels.fragments) { + addFragment(fragments, fragment); + } + } catch (error) { + warnings.push( + warning( + `Unable to inspect live Docker Traefik labels: ${ + error instanceof Error ? error.message : "unknown error" + }`, + { + code: "missing-input", + blocking: false, + }, + ), + ); + } + + const appWhere = serverId + ? eq(applications.serverId, serverId) + : isNull(applications.serverId); + const applicationRows = await db.query.applications.findMany({ + where: appWhere, + with: { domains: true }, + }); + let applicationDomainCount = 0; + for (const app of applicationRows) { + for (const domain of app.domains ?? []) { + applicationDomainCount++; + const unsupportedFields = getUnsupportedCaddyDomainFieldMessages(domain); + if (unsupportedFields.length) { + warnings.push( + warning( + `Application domain "${domain.host}" uses unsupported Caddy fields: ${unsupportedFields.join("; ")}`, + { + blocking: true, + code: "unsupported-domain-field", + source: app.appName, + }, + ), + ); + continue; + } + addFragment( + fragments, + createCaddyApplicationRouteFragment(app as never, domain), + ); + } + } + + const composeWhere = serverId + ? eq(compose.serverId, serverId) + : isNull(compose.serverId); + const composeRows = await db.query.compose.findMany({ + where: composeWhere, + with: { domains: true }, + }); + let composeDomainCount = 0; + for (const composeEntity of composeRows) { + let composeSpec: ComposeSpecification | null = null; + try { + composeSpec = await getLocalOrRemoteComposeSpec(composeEntity); + } catch (error) { + composeFilesSkipped.push({ + path: composeEntity.appName, + reason: + error instanceof Error + ? error.message + : "Unable to read compose file", + }); + } + + for (const domain of composeEntity.domains ?? []) { + composeDomainCount++; + const unsupportedFields = getUnsupportedCaddyDomainFieldMessages(domain); + if (unsupportedFields.length) { + warnings.push( + warning( + `Compose domain "${domain.host}" uses unsupported Caddy fields: ${unsupportedFields.join("; ")}`, + { + blocking: true, + code: "unsupported-domain-field", + source: composeEntity.appName, + serviceName: domain.serviceName ?? undefined, + }, + ), + ); + continue; + } + if (!domain.serviceName) { + warnings.push( + warning(`Compose domain "${domain.host}" is missing a service name`, { + blocking: true, + source: composeEntity.appName, + serviceName: domain.serviceName ?? undefined, + }), + ); + continue; + } + const finalServiceName = resolveFinalComposeServiceName( + composeEntity, + composeSpec, + domain.serviceName, + ); + if (!finalServiceName) { + warnings.push( + warning( + `Compose domain "${domain.host}" references service "${domain.serviceName}" that could not be verified in the compose file`, + { + blocking: true, + source: composeEntity.appName, + serviceName: domain.serviceName, + }, + ), + ); + continue; + } + const upstreamTarget = await resolveMigrationComposeUpstreamTarget( + composeEntity, + composeSpec, + finalServiceName, + ); + addFragment( + fragments, + createCaddyComposeRouteFragment( + composeEntity, + domain, + finalServiceName, + { + upstreamServiceName: upstreamTarget.host, + upstreamNetwork: upstreamTarget.network, + }, + ), + ); + } + + if (!composeSpec?.services) { + composeFilesSkipped.push({ + path: composeEntity.appName, + reason: "Compose file not found or has no services", + }); + continue; + } + + composeFilesScanned.push(composeEntity.appName); + for (const [serviceName, service] of Object.entries(composeSpec.services)) { + const labelSources: Array<{ + labels: ListOrDict | undefined; + suffix: string; + }> = [ + { labels: service.labels, suffix: "labels" }, + { labels: service.deploy?.labels, suffix: "deploy.labels" }, + ].filter((labelSource) => labelsHaveTraefikHttp(labelSource.labels)); + if (!labelSources.length) continue; + + const labelFinalServiceName = + resolveFinalComposeServiceName( + composeEntity, + composeSpec, + serviceName, + ) ?? serviceName; + const upstreamTarget = await resolveMigrationComposeUpstreamTarget( + composeEntity, + composeSpec, + labelFinalServiceName, + ); + for (const labelSource of labelSources) { + const translated = translateTraefikComposeLabelsToCaddyFragment( + labelSource.labels, + { + sourceFile: `${composeEntity.appName}/${serviceName}/${labelSource.suffix}`, + appName: composeEntity.appName, + domains: composeEntity.domains ?? [], + serviceName, + upstreamServiceName: upstreamTarget.host, + upstreamNetwork: upstreamTarget.network, + composeType: composeEntity.composeType, + fileMiddlewares, + }, + ); + warnings.push( + ...translated.warnings.filter( + (item) => + !translated.classifications.every( + (classification) => classification.dokployGenerated, + ) || isSecurityMiddlewareWarning(item), + ), + ); + addFragment(fragments, { + ...translated.fragment, + id: `migration.traefik-compose-label.${safeFragmentIdPart( + `${composeEntity.appName}.${serviceName}.${labelSource.suffix}`, + )}`, + }); + } + } + } + + fragments = reconcileDbFallbackRoutes(fragments, warnings); + if (canPruneUnreachableDockerUpstreams && reachableDockerHosts.size) { + fragments = pruneUnreachableDockerUpstreams( + fragments, + warnings, + reachableDockerHosts, + ); + } + warnOnManualFragmentConflicts(fragments, warnings); + + let config: ReturnType; + let validation: CaddyMigrationReport["validation"]; + try { + config = compileCaddyConfig({ + fragments, + letsEncryptEmail: await getLetsEncryptEmail(serverId), + }); + validation = { + status: "passed", + message: + "Draft config compiled successfully; container validation runs during apply", + }; + } catch (error) { + config = compileCaddyConfig(); + const message = + error instanceof Error ? error.message : "Draft Caddy config is invalid"; + warnings.push( + warning(message, { + code: "validation-failed", + blocking: true, + }), + ); + validation = { status: "failed", message }; + } + + for (const fragment of fragments) { + await writeMigrationTextFile( + path.posix.join(artifactPaths.fragmentsDir, `${fragment.id}.json`), + `${JSON.stringify(fragment, null, 2)}\n`, + serverId, + ); + } + await writeMigrationTextFile( + artifactPaths.caddyJson, + `${JSON.stringify(config, null, 2)}\n`, + serverId, + ); + + const report: CaddyMigrationReport = { + migrationId, + serverId: serverId ?? null, + createdAt, + updatedAt: createdAt, + status: "prepared", + sourceProvider: "traefik", + targetProvider: "caddy", + artifactPaths, + inputs: { + traefikStaticConfigPath: staticConfig.path, + traefikStaticConfigFound: Boolean(staticConfig.content), + dynamicFiles, + dbApplicationDomains: applicationDomainCount, + dbComposeDomains: composeDomainCount, + composeFilesScanned, + composeFilesSkipped, + }, + summary: { + fragments: fragments.length, + routes: fragments.reduce( + (total, fragment) => total + fragment.routes.length, + 0, + ), + warnings: warnings.length, + blockingWarnings: warnings.filter((item) => item.blocking).length, + }, + validation, + warnings, + events: [ + { + at: createdAt, + type: "prepared", + message: "Dry-run Caddy migration artifacts generated", + }, + ], + }; + await writeCaddyMigrationReport(report, serverId); + await writeMigrationTextFile( + artifactPaths.reportMd, + renderReportMarkdown(report), + serverId, + ); + return report; +}; diff --git a/packages/server/src/utils/caddy/migration/rollback.ts b/packages/server/src/utils/caddy/migration/rollback.ts new file mode 100644 index 0000000000..f43519b8a1 --- /dev/null +++ b/packages/server/src/utils/caddy/migration/rollback.ts @@ -0,0 +1,238 @@ +import * as path from "node:path"; +import { paths } from "@dokploy/server/constants"; +import { + ensureTraefikRunningFromSnapshot, + stopDockerResource, +} from "@dokploy/server/services/settings"; +import { + updateLocalWebServerProvider, + updateRemoteWebServerProvider, +} from "@dokploy/server/services/web-server-settings"; +import { + acquireCaddyMigrationOperationLock, + appendCaddyMigrationEvent, + copyMigrationPath, + getCaddyMigrationArtifactPaths, + loadCaddyMigrationReport, + migrationPathExists, + readMigrationTextFileIfExists, + removeMigrationPath, + writeCaddyMigrationReport, +} from "./files"; +import type { + CaddyMigrationReport, + CaddyMigrationResourceSnapshot, +} from "./types"; + +const updateProviderToTraefik = async (serverId?: string | null) => { + if (serverId) { + await updateRemoteWebServerProvider(serverId, "traefik"); + return; + } + await updateLocalWebServerProvider("traefik"); +}; + +const restorePathFromBackup = async ( + backupSource: string, + liveDestination: string, + existed?: boolean, + serverId?: string | null, +) => { + if (existed === true) { + if (!(await migrationPathExists(backupSource, serverId))) { + throw new Error( + `Cannot restore ${liveDestination}: backup path is missing at ${backupSource}`, + ); + } + await copyMigrationPath(backupSource, liveDestination, serverId); + return; + } + if (existed === false) { + await removeMigrationPath(liveDestination, serverId); + } +}; + +const loadRestoreSnapshots = async ( + report: CaddyMigrationReport, + serverId?: string | null, +): Promise<{ + traefikResource?: CaddyMigrationResourceSnapshot; + caddyResource?: CaddyMigrationResourceSnapshot; +}> => { + const restoreSnapshotPath = report.backup?.restoreSnapshotPath; + if (!restoreSnapshotPath) { + return { + traefikResource: report.backup?.traefikResource, + caddyResource: report.backup?.caddyResource, + }; + } + const content = await readMigrationTextFileIfExists( + restoreSnapshotPath, + serverId, + ); + if (!content) { + return { + traefikResource: report.backup?.traefikResource, + caddyResource: report.backup?.caddyResource, + }; + } + return JSON.parse(content) as { + traefikResource?: CaddyMigrationResourceSnapshot; + caddyResource?: CaddyMigrationResourceSnapshot; + }; +}; + +export const rollbackCaddyMigration = async (input: { + migrationId: string; + serverId?: string; + skipOperationLock?: boolean; +}) => { + const releaseLock = input.skipOperationLock + ? null + : await acquireCaddyMigrationOperationLock( + input.migrationId, + input.serverId, + ); + try { + return await rollbackCaddyMigrationUnlocked(input); + } finally { + await releaseLock?.(); + } +}; + +const rollbackCaddyMigrationUnlocked = async (input: { + migrationId: string; + serverId?: string; +}) => { + const serverId = input.serverId; + let report = await loadCaddyMigrationReport(input.migrationId, serverId); + if (!report.backup) { + throw new Error( + `Cannot roll back Caddy migration ${input.migrationId}: no backup metadata is available`, + ); + } + report = await writeCaddyMigrationReport( + appendCaddyMigrationEvent( + { ...report, status: "rolling_back" }, + "rolling_back", + "Rolling back Caddy migration to Traefik", + ), + serverId, + ); + + try { + await stopDockerResource("dokploy-caddy", serverId); + + const caddyPaths = paths(!!serverId); + const backup = report.backup; + if (!backup) { + throw new Error( + `Cannot roll back Caddy migration ${input.migrationId}: no backup metadata is available`, + ); + } + const backupRoot = path.posix.join( + report.artifactPaths.backupsDir, + "caddy", + ); + const backupFiles = backup.files ?? []; + const backedUpCaddyConfig = backupFiles.find( + (item) => item.label === "caddy-config", + ); + const backedUpCaddyFragments = backupFiles.find( + (item) => item.label === "caddy-fragments", + ); + const backedUpTraefikStatic = backupFiles.find( + (item) => item.label === "traefik-static", + ); + const backedUpTraefikDynamic = backupFiles.find( + (item) => item.label === "traefik-dynamic", + ); + + await restorePathFromBackup( + backedUpCaddyConfig?.backupPath ?? + path.posix.join(backupRoot, "caddy.json"), + caddyPaths.CADDY_CONFIG_PATH, + backedUpCaddyConfig?.existed, + serverId, + ); + await restorePathFromBackup( + backedUpCaddyFragments?.backupPath ?? + path.posix.join(backupRoot, "fragments"), + caddyPaths.CADDY_FRAGMENTS_PATH, + backedUpCaddyFragments?.existed, + serverId, + ); + await restorePathFromBackup( + backedUpTraefikStatic?.backupPath ?? + path.posix.join( + report.artifactPaths.backupsDir, + "traefik", + "traefik.yml", + ), + path.posix.join(caddyPaths.MAIN_TRAEFIK_PATH, "traefik.yml"), + backedUpTraefikStatic?.existed, + serverId, + ); + await restorePathFromBackup( + backedUpTraefikDynamic?.backupPath ?? + path.posix.join(report.artifactPaths.backupsDir, "traefik", "dynamic"), + caddyPaths.DYNAMIC_TRAEFIK_PATH, + backedUpTraefikDynamic?.existed, + serverId, + ); + + const restoreSnapshots = await loadRestoreSnapshots(report, serverId); + await ensureTraefikRunningFromSnapshot( + restoreSnapshots.traefikResource ?? backup.traefikResource, + serverId, + ); + await updateProviderToTraefik(serverId); + + report = await writeCaddyMigrationReport( + appendCaddyMigrationEvent( + { ...report, status: "rolled_back" }, + "rolled_back", + "Rollback completed; Traefik is the active provider", + ), + serverId, + ); + return report; + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Caddy migration rollback failed"; + const failedReport: CaddyMigrationReport = { + ...report, + status: "failed", + warnings: [ + ...report.warnings, + { + code: "rollback-failed", + message, + blocking: true, + }, + ], + summary: { + ...report.summary, + warnings: report.summary.warnings + 1, + blockingWarnings: report.summary.blockingWarnings + 1, + }, + }; + await writeCaddyMigrationReport( + appendCaddyMigrationEvent(failedReport, "rollback_failed", message), + serverId, + ); + throw error; + } +}; + +export const getCaddyMigrationReport = async (input: { + migrationId: string; + serverId?: string; +}) => loadCaddyMigrationReport(input.migrationId, input.serverId); + +export const getCaddyMigrationPaths = (input: { + migrationId: string; + serverId?: string; +}) => getCaddyMigrationArtifactPaths(input.migrationId, input.serverId); diff --git a/packages/server/src/utils/caddy/migration/traefik-rule-parser.ts b/packages/server/src/utils/caddy/migration/traefik-rule-parser.ts new file mode 100644 index 0000000000..a4b923d55e --- /dev/null +++ b/packages/server/src/utils/caddy/migration/traefik-rule-parser.ts @@ -0,0 +1,337 @@ +import type { CaddyMigrationWarning, TraefikRuleMatch } from "./types"; + +interface ParseOptions { + source?: string; + routerName?: string; +} + +type TokenType = + | "identifier" + | "string" + | "and" + | "or" + | "lparen" + | "rparen" + | "comma"; + +interface Token { + type: TokenType; + value: string; +} + +type RuleNode = + | { type: "matcher"; name: string; args: string[] } + | { type: "and" | "or"; left: RuleNode; right: RuleNode }; + +const warning = ( + message: string, + options: ParseOptions, + code: CaddyMigrationWarning["code"] = "unsupported-rule", +): CaddyMigrationWarning => ({ + code, + message, + blocking: true, + source: options.source, + routerName: options.routerName, +}); + +const tokenizeRule = (rule: string, options: ParseOptions) => { + const tokens: Token[] = []; + let index = 0; + + while (index < rule.length) { + const char = rule[index] ?? ""; + if (/\s/.test(char)) { + index += 1; + continue; + } + if (rule.startsWith("&&", index)) { + tokens.push({ type: "and", value: "&&" }); + index += 2; + continue; + } + if (rule.startsWith("||", index)) { + tokens.push({ type: "or", value: "||" }); + index += 2; + continue; + } + if (char === "(") { + tokens.push({ type: "lparen", value: char }); + index += 1; + continue; + } + if (char === ")") { + tokens.push({ type: "rparen", value: char }); + index += 1; + continue; + } + if (char === ",") { + tokens.push({ type: "comma", value: char }); + index += 1; + continue; + } + if (char === "`" || char === "'" || char === '"') { + const quote = char; + let value = ""; + index += 1; + while (index < rule.length && rule[index] !== quote) { + value += rule[index] ?? ""; + index += 1; + } + if (rule[index] !== quote) { + throw new Error( + warning("Unterminated string in Traefik rule", options).message, + ); + } + index += 1; + tokens.push({ type: "string", value }); + continue; + } + if (/[A-Za-z]/.test(char)) { + let value = ""; + while (index < rule.length && /[A-Za-z0-9_]/.test(rule[index] ?? "")) { + value += rule[index] ?? ""; + index += 1; + } + tokens.push({ type: "identifier", value }); + continue; + } + throw new Error( + warning(`Unsupported token "${char}" in Traefik rule`, options).message, + ); + } + + return tokens; +}; + +class RuleParser { + private index = 0; + + constructor( + private readonly tokens: Token[], + private readonly options: ParseOptions, + ) {} + + parse() { + const expression = this.parseOr(); + if (this.peek()) { + throw new Error( + warning( + `Unexpected token "${this.peek()?.value}" in Traefik rule`, + this.options, + ).message, + ); + } + return expression; + } + + private peek() { + return this.tokens[this.index]; + } + + private consume(type?: TokenType) { + const token = this.tokens[this.index]; + if (!token || (type && token.type !== type)) { + throw new Error( + warning(`Expected ${type ?? "token"} in Traefik rule`, this.options) + .message, + ); + } + this.index += 1; + return token; + } + + private parseOr(): RuleNode { + let node = this.parseAnd(); + while (this.peek()?.type === "or") { + this.consume("or"); + node = { type: "or", left: node, right: this.parseAnd() }; + } + return node; + } + + private parseAnd(): RuleNode { + let node = this.parsePrimary(); + while (this.peek()?.type === "and") { + this.consume("and"); + node = { type: "and", left: node, right: this.parsePrimary() }; + } + return node; + } + + private parsePrimary(): RuleNode { + const token = this.peek(); + if (token?.type === "lparen") { + this.consume("lparen"); + const node = this.parseOr(); + this.consume("rparen"); + return node; + } + if (token?.type !== "identifier") { + throw new Error( + warning("Expected Traefik matcher in rule", this.options).message, + ); + } + + const name = this.consume("identifier").value; + this.consume("lparen"); + const args: string[] = []; + while (this.peek()?.type !== "rparen") { + args.push(this.consume("string").value); + if (this.peek()?.type === "comma") { + this.consume("comma"); + } else if (this.peek()?.type !== "rparen") { + throw new Error( + warning(`Expected comma in ${name} matcher`, this.options).message, + ); + } + } + this.consume("rparen"); + return { type: "matcher", name, args }; + } +} + +const mergeMatches = ( + left: TraefikRuleMatch, + right: TraefikRuleMatch, + warnings: CaddyMigrationWarning[], + options: ParseOptions, +): TraefikRuleMatch | null => { + const hosts = [...new Set([...left.hosts, ...right.hosts])]; + const pathPrefix = left.pathPrefix ?? right.pathPrefix ?? null; + const pathExact = left.pathExact ?? right.pathExact ?? null; + + if ( + left.pathPrefix && + right.pathPrefix && + left.pathPrefix !== right.pathPrefix + ) { + warnings.push( + warning( + `Multiple PathPrefix matchers cannot be represented as one Caddy route: ${left.pathPrefix}, ${right.pathPrefix}`, + options, + ), + ); + return null; + } + if (left.pathExact && right.pathExact && left.pathExact !== right.pathExact) { + warnings.push( + warning( + `Multiple Path matchers cannot be represented as one Caddy route: ${left.pathExact}, ${right.pathExact}`, + options, + ), + ); + return null; + } + if (pathPrefix && pathExact) { + warnings.push( + warning( + `Combined Path and PathPrefix matchers cannot be represented as one Caddy route: ${pathExact}, ${pathPrefix}`, + options, + ), + ); + return null; + } + + return { hosts, pathPrefix, pathExact }; +}; + +const expandRuleNode = ( + node: RuleNode, + warnings: CaddyMigrationWarning[], + options: ParseOptions, +): TraefikRuleMatch[] => { + if (node.type === "matcher") { + if (node.name === "Host") { + return [{ hosts: [...new Set(node.args)] }]; + } + if (node.name === "PathPrefix") { + return node.args.map((pathPrefix) => ({ hosts: [], pathPrefix })); + } + if (node.name === "Path") { + return node.args.map((pathExact) => ({ hosts: [], pathExact })); + } + warnings.push( + warning( + `Unsupported Traefik matcher "${node.name}" in rule`, + options, + "unsupported-matcher", + ), + ); + return []; + } + + const left = expandRuleNode(node.left, warnings, options); + const right = expandRuleNode(node.right, warnings, options); + if (node.type === "or") { + return [...left, ...right]; + } + + const merged: TraefikRuleMatch[] = []; + for (const leftMatch of left) { + for (const rightMatch of right) { + const match = mergeMatches(leftMatch, rightMatch, warnings, options); + if (match) { + merged.push(match); + } + } + } + return merged; +}; + +const normalizeMatches = ( + matches: TraefikRuleMatch[], + warnings: CaddyMigrationWarning[], + options: ParseOptions, +) => { + const grouped = new Map(); + for (const match of matches) { + if (!match.hosts.length) { + warnings.push( + warning("Traefik rule did not include a Host matcher", options), + ); + continue; + } + + const key = `${match.pathPrefix ?? ""}\u0000${match.pathExact ?? ""}`; + const existing = grouped.get(key); + if (existing) { + existing.hosts = [...new Set([...existing.hosts, ...match.hosts])]; + } else { + grouped.set(key, { + ...match, + hosts: [...new Set(match.hosts)], + }); + } + } + return [...grouped.values()]; +}; + +export const parseTraefikRule = ( + rule: string, + options: ParseOptions = {}, +): { matches: TraefikRuleMatch[]; warnings: CaddyMigrationWarning[] } => { + const warnings: CaddyMigrationWarning[] = []; + try { + const tokens = tokenizeRule(rule, options); + const ast = new RuleParser(tokens, options).parse(); + const matches = normalizeMatches( + expandRuleNode(ast, warnings, options), + warnings, + options, + ); + return { matches, warnings }; + } catch (error) { + return { + matches: [], + warnings: [ + warning( + error instanceof Error + ? error.message + : "Failed to parse Traefik rule", + options, + ), + ], + }; + } +}; diff --git a/packages/server/src/utils/caddy/migration/types.ts b/packages/server/src/utils/caddy/migration/types.ts new file mode 100644 index 0000000000..7181be586f --- /dev/null +++ b/packages/server/src/utils/caddy/migration/types.ts @@ -0,0 +1,194 @@ +import type { + CaddyHeaderMap, + CaddyRouteRedirectScheme, + CaddyRouteTransform, +} from "../types"; + +export type CaddyMigrationWarningCode = + | "unsupported-rule" + | "unsupported-matcher" + | "unsupported-router" + | "unsupported-service" + | "unsupported-middleware" + | "unsupported-security-middleware" + | "unsupported-domain-field" + | "unresolved-middleware" + | "unresolved-service" + | "shadowed-route" + | "conflicting-manual-fragment" + | "unreachable-upstream" + | "invalid-label" + | "invalid-config" + | "missing-input" + | "validation-failed" + | "health-check-failed" + | "backup-failed" + | "apply-failed" + | "rollback-failed"; + +export interface CaddyMigrationWarning { + code: CaddyMigrationWarningCode; + message: string; + blocking: boolean; + source?: string; + routerName?: string; + serviceName?: string; + middlewareName?: string; + label?: string; +} + +export interface CaddyMiddlewareTranslation { + transforms: CaddyRouteTransform; + basicAuth: { username: string; hash: string }[]; + allowedRemoteIps?: string[] | null; + redirectScheme?: CaddyRouteRedirectScheme | null; + warnings: CaddyMigrationWarning[]; +} + +export type ResolvedCaddyMiddleware = Omit< + CaddyMiddlewareTranslation, + "warnings" +>; + +export type KnownTraefikMiddlewareMap = Record< + string, + Partial & { + transforms?: CaddyRouteTransform; + basicAuth?: { username: string; hash: string }[]; + responseHeaders?: CaddyHeaderMap; + requestHeaders?: CaddyHeaderMap; + } +>; + +export interface TraefikRuleMatch { + hosts: string[]; + pathPrefix?: string | null; + pathExact?: string | null; +} + +export type CaddyMigrationStatus = + | "prepared" + | "applying" + | "applied" + | "failed" + | "rolling_back" + | "rolled_back"; + +export interface CaddyMigrationArtifactPaths { + root: string; + reportJson: string; + reportMd: string; + caddyJson: string; + fragmentsDir: string; + backupsDir: string; +} + +export interface CaddyMigrationFileBackup { + label: string; + source: string; + backupPath: string; + existed: boolean; +} + +export interface CaddyMigrationResourceSnapshot { + resourceName: string; + resourceType: "service" | "standalone" | "unknown"; + running: boolean; + replicas?: number; + env?: string; + additionalPorts?: { + targetPort: number; + publishedPort: number; + protocol?: string; + }[]; + image?: string; + binds?: string[]; + mounts?: Array>; + networks?: Array>; + labels?: Record; + containerLabels?: Record; + placement?: Record; + endpointPorts?: Array>; + restartPolicy?: Record; +} + +export interface CaddyMigrationBackupSummary { + createdAt: string; + traefikResource?: CaddyMigrationResourceSnapshot; + caddyResource?: CaddyMigrationResourceSnapshot; + restoreSnapshotPath?: string; + files?: CaddyMigrationFileBackup[]; +} + +export interface CaddyMigrationRuntimePreflightRoute { + routeId: string; + routeHosts: string[]; + source: string; + sourceFragment?: string; + upstream: string; + normalizedHost: string; + normalizedPort: number; + network: string; +} + +export interface CaddyMigrationRuntimePreflightCheck { + dial: string; + host: string; + port: number; + network: string; + status: "passed" | "failed"; + reason?: string; + routes: CaddyMigrationRuntimePreflightRoute[]; +} + +export interface CaddyMigrationRuntimePreflight { + status: "passed" | "failed" | "skipped"; + checkedAt?: string; + network: string; + networks?: string[]; + probeMode?: "standalone" | "service"; + probeImage: string; + checks: CaddyMigrationRuntimePreflightCheck[]; +} + +export interface CaddyMigrationReport { + migrationId: string; + serverId: string | null; + createdAt: string; + updatedAt: string; + status: CaddyMigrationStatus; + sourceProvider: "traefik"; + targetProvider: "caddy"; + artifactPaths: CaddyMigrationArtifactPaths; + inputs: { + traefikStaticConfigPath: string; + traefikStaticConfigFound: boolean; + dynamicFiles: string[]; + dbApplicationDomains: number; + dbComposeDomains: number; + composeFilesScanned: string[]; + composeFilesSkipped: Array<{ path: string; reason: string }>; + }; + summary: { + fragments: number; + routes: number; + warnings: number; + blockingWarnings: number; + }; + validation: { + status: "passed" | "failed" | "skipped"; + message?: string; + }; + runtimePreflight?: CaddyMigrationRuntimePreflight; + warnings: CaddyMigrationWarning[]; + backup?: CaddyMigrationBackupSummary; + events: Array<{ + at: string; + type: string; + message: string; + }>; +} + +export const createMigrationWarning = ( + warning: CaddyMigrationWarning, +): CaddyMigrationWarning => warning; diff --git a/packages/server/src/utils/caddy/migration/upstream-preflight.ts b/packages/server/src/utils/caddy/migration/upstream-preflight.ts new file mode 100644 index 0000000000..1c2693e830 --- /dev/null +++ b/packages/server/src/utils/caddy/migration/upstream-preflight.ts @@ -0,0 +1,336 @@ +import { + execAsync, + execAsyncRemote, +} from "@dokploy/server/utils/process/execAsync"; +import { quote } from "shell-quote"; +import { parseCaddyUpstream } from "../config"; +import type { CaddyRouteFragment } from "../types"; +import { DOKPLOY_CADDY_NETWORK } from "../upstream-targets"; +import { listMigrationFiles, readRequiredMigrationTextFile } from "./files"; +import type { + CaddyMigrationReport, + CaddyMigrationRuntimePreflight, + CaddyMigrationRuntimePreflightCheck, + CaddyMigrationRuntimePreflightRoute, +} from "./types"; + +const PROBE_IMAGE = "busybox:1.36"; +const DEFAULT_PROBE_NETWORK = DOKPLOY_CADDY_NETWORK; +type ProbeMode = "standalone" | "service"; + +const runCommand = async (command: string, serverId?: string) => { + if (serverId) { + return execAsyncRemote(serverId, command); + } + return execAsync(command); +}; + +const splitDial = (dial: string) => { + const separatorIndex = dial.lastIndexOf(":"); + if (separatorIndex === -1) { + throw new Error("normalized upstream dial is missing a port"); + } + const host = dial.slice(0, separatorIndex); + const port = Number(dial.slice(separatorIndex + 1)); + if (!host || !Number.isInteger(port)) { + throw new Error("normalized upstream dial is invalid"); + } + return { host, port }; +}; + +const routeRefsFromFragment = ( + fragment: CaddyRouteFragment, +): { + refs: CaddyMigrationRuntimePreflightRoute[]; + invalidChecks: CaddyMigrationRuntimePreflightCheck[]; +} => { + const refs: CaddyMigrationRuntimePreflightRoute[] = []; + const invalidChecks: CaddyMigrationRuntimePreflightCheck[] = []; + for (const route of fragment.routes) { + if (route.redirectScheme) continue; + for (const upstream of route.upstreams) { + let host = ""; + let port = 0; + try { + const parsed = parseCaddyUpstream(upstream); + const normalized = splitDial(parsed.dial); + host = normalized.host; + port = normalized.port; + } catch (error) { + invalidChecks.push({ + dial: upstream, + host: "", + port: 0, + status: "failed", + reason: + error instanceof Error ? error.message : "Invalid upstream format", + routes: [ + { + routeId: route.id, + routeHosts: route.hosts, + source: route.source, + sourceFragment: fragment.id, + upstream, + normalizedHost: "", + normalizedPort: 0, + network: route.upstreamNetwork ?? DEFAULT_PROBE_NETWORK, + }, + ], + network: route.upstreamNetwork ?? DEFAULT_PROBE_NETWORK, + }); + continue; + } + refs.push({ + routeId: route.id, + routeHosts: route.hosts, + source: route.source, + sourceFragment: fragment.id, + upstream, + normalizedHost: host, + normalizedPort: port, + network: route.upstreamNetwork ?? DEFAULT_PROBE_NETWORK, + }); + } + } + return { refs, invalidChecks }; +}; + +const readPreflightInputs = async ( + report: CaddyMigrationReport, + serverId?: string, +) => { + const refs: CaddyMigrationRuntimePreflightRoute[] = []; + const invalidChecks: CaddyMigrationRuntimePreflightCheck[] = []; + const fragmentFiles = await listMigrationFiles( + report.artifactPaths.fragmentsDir, + serverId, + [".json"], + ); + for (const fragmentFile of fragmentFiles) { + const content = await readRequiredMigrationTextFile(fragmentFile, serverId); + const fragment = JSON.parse(content) as CaddyRouteFragment; + const parsed = routeRefsFromFragment(fragment); + refs.push(...parsed.refs); + invalidChecks.push(...parsed.invalidChecks); + } + return { refs, invalidChecks }; +}; + +const probeScript = [ + 'if nc -z -w 3 "$1" "$2" >/dev/null 2>&1; then echo passed; exit 0; fi', + 'if ! nslookup "$1" >/dev/null 2>&1; then echo dns_failed; exit 10; fi', + "echo tcp_failed", + "exit 11", +].join("; "); + +const parseProbeFailure = (error: unknown, output: string) => { + if (output.includes("dns_failed")) { + return "DNS resolution failed"; + } + if (output.includes("tcp_failed")) { + return "TCP connection failed"; + } + if (output) { + return output.split("\n").slice(-3).join("; "); + } + return error instanceof Error ? error.message : "upstream probe failed"; +}; + +const shouldFallbackToServiceProbe = (reason: string) => + [ + /not manually attachable/i, + /not attachable/i, + /network .* not found/i, + /could not find network/i, + /only supported for user defined networks/i, + /error response from daemon/i, + ].some((pattern) => pattern.test(reason)); + +const probeStandaloneUpstream = async ( + host: string, + port: number, + network: string, + serverId?: string, +) => { + const command = [ + "docker run --rm", + `--network ${quote([network])}`, + quote([PROBE_IMAGE]), + "sh -c", + quote([probeScript]), + "sh", + quote([host]), + quote([String(port)]), + ].join(" "); + + try { + const { stdout } = await runCommand(command, serverId); + return stdout.trim() === "passed" + ? null + : stdout.trim() || "upstream probe did not report success"; + } catch (error) { + const output = + `${(error as { stdout?: string }).stdout ?? ""}\n${(error as { stderr?: string }).stderr ?? ""}`.trim(); + return parseProbeFailure(error, output); + } +}; + +const probeServiceUpstream = async ( + host: string, + port: number, + network: string, + serverId?: string, +) => { + const serviceName = `dokploy-caddy-preflight-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + const command = ` +set -e +SERVICE_NAME=${quote([serviceName])} +docker service rm "$SERVICE_NAME" >/dev/null 2>&1 || true +trap 'docker service rm "$SERVICE_NAME" >/dev/null 2>&1 || true' EXIT +docker service create --detach=true --name "$SERVICE_NAME" --restart-condition none --network ${quote([network])} ${quote([PROBE_IMAGE])} sh -c ${quote([probeScript])} sh ${quote([host])} ${quote([String(port)])} >/dev/null +for i in $(seq 1 30); do + LOGS=$(docker service logs --raw "$SERVICE_NAME" 2>&1 || true) + if echo "$LOGS" | grep -Eq '(^|[[:space:]])(passed|dns_failed|tcp_failed)([[:space:]]|$)'; then + echo "$LOGS" + exit 0 + fi + STATE=$(docker service ps "$SERVICE_NAME" --no-trunc --format '{{.CurrentState}} {{.Error}}' 2>&1 | head -n 1 || true) + if echo "$STATE" | grep -Eq 'Failed|Rejected|Complete|Shutdown'; then + echo "$LOGS" + echo "$STATE" + exit 0 + fi + sleep 1 +done +docker service logs --raw "$SERVICE_NAME" 2>&1 || true +exit 12 +`; + + try { + const { stdout } = await runCommand(command, serverId); + return stdout.trim().includes("passed") + ? null + : parseProbeFailure(new Error("upstream service probe failed"), stdout); + } catch (error) { + const output = + `${(error as { stdout?: string }).stdout ?? ""}\n${(error as { stderr?: string }).stderr ?? ""}`.trim(); + return parseProbeFailure(error, output); + } +}; + +const probeUpstream = async ( + host: string, + port: number, + network: string, + serverId?: string, +): Promise<{ failureReason: string | null; probeMode: ProbeMode }> => { + const standaloneFailure = await probeStandaloneUpstream( + host, + port, + network, + serverId, + ); + if (!standaloneFailure) { + return { failureReason: null, probeMode: "standalone" }; + } + if (!shouldFallbackToServiceProbe(standaloneFailure)) { + return { failureReason: standaloneFailure, probeMode: "standalone" }; + } + + const serviceFailure = await probeServiceUpstream( + host, + port, + network, + serverId, + ); + if (!serviceFailure) { + return { failureReason: null, probeMode: "service" }; + } + return { + failureReason: `standalone probe failed (${standaloneFailure}); service probe failed (${serviceFailure})`, + probeMode: "service", + }; +}; + +export const runCaddyMigrationUpstreamPreflight = async ( + report: CaddyMigrationReport, + input: { serverId?: string } = {}, +): Promise => { + const grouped = new Map(); + let refs: CaddyMigrationRuntimePreflightRoute[] = []; + let invalidChecks: CaddyMigrationRuntimePreflightCheck[] = []; + try { + const inputs = await readPreflightInputs(report, input.serverId); + refs = inputs.refs; + invalidChecks = inputs.invalidChecks; + } catch (error) { + return { + status: "failed", + checkedAt: new Date().toISOString(), + network: DEFAULT_PROBE_NETWORK, + networks: [DEFAULT_PROBE_NETWORK], + probeImage: PROBE_IMAGE, + checks: [ + { + dial: "fragment-read", + host: "", + port: 0, + network: DEFAULT_PROBE_NETWORK, + status: "failed", + reason: + error instanceof Error + ? error.message + : "Unable to read Caddy fragments", + routes: [], + }, + ], + }; + } + + for (const ref of refs) { + const dial = `${ref.normalizedHost}:${ref.normalizedPort}`; + const groupKey = `${ref.network}\0${dial}`; + grouped.set(groupKey, { + dial, + host: ref.normalizedHost, + port: ref.normalizedPort, + network: ref.network, + status: "passed", + routes: [...(grouped.get(groupKey)?.routes ?? []), ref], + }); + } + + const checks = [...invalidChecks, ...grouped.values()]; + const probeableChecks = checks.filter((item) => item.port > 0); + let probeMode: ProbeMode = "standalone"; + for (const check of probeableChecks) { + const probeResult = await probeUpstream( + check.host, + check.port, + check.network, + input.serverId, + ); + if (probeResult.probeMode === "service") { + probeMode = "service"; + } + const { failureReason } = probeResult; + if (failureReason) { + check.status = "failed"; + check.reason = failureReason; + } + } + const networks = [...new Set(checks.map((check) => check.network))].sort(); + + return { + status: checks.some((check) => check.status === "failed") + ? "failed" + : "passed", + checkedAt: new Date().toISOString(), + network: + networks.length === 1 ? (networks[0] ?? DEFAULT_PROBE_NETWORK) : "mixed", + networks, + probeMode, + probeImage: PROBE_IMAGE, + checks, + }; +}; diff --git a/packages/server/src/utils/caddy/types.ts b/packages/server/src/utils/caddy/types.ts new file mode 100644 index 0000000000..e665b94402 --- /dev/null +++ b/packages/server/src/utils/caddy/types.ts @@ -0,0 +1,70 @@ +export type CaddyRouteSource = + | "dokploy-application" + | "dokploy-compose" + | "dokploy-dashboard" + | "manual" + | "traefik-dynamic-file" + | "traefik-compose-label"; + +export type CaddyHeaderMap = Record; + +export interface CaddyRouteTransform { + stripPrefix?: string | null; + addPrefix?: string | null; + requestHeaders?: CaddyHeaderMap | null; + responseHeaders?: CaddyHeaderMap | null; +} + +export interface CaddyRouteRedirectScheme { + scheme: string; + permanent?: boolean | null; + port?: string | null; +} + +export interface CaddyStaticResponse { + statusCode: number; + body?: string | null; + headers?: CaddyHeaderMap | null; +} + +export interface CaddyBasicAuthAccount { + username: string; + hash: string; +} + +export interface CaddyRouteIntent { + id: string; + source: CaddyRouteSource; + hosts: string[]; + pathPrefix?: string | null; + pathExact?: string | null; + allowedRemoteIps?: string[] | null; + https?: boolean; + priority?: number | null; + upstreams: string[]; + upstreamNetwork?: string | null; + transforms?: CaddyRouteTransform | null; + basicAuth?: CaddyBasicAuthAccount[] | null; + redirectScheme?: CaddyRouteRedirectScheme | null; + staticResponse?: CaddyStaticResponse | null; +} + +export interface CaddyRouteFragment { + version: 1; + id: string; + source: CaddyRouteSource; + description?: string; + routes: CaddyRouteIntent[]; +} + +export interface CaddyCompileOptions { + fragments?: CaddyRouteFragment[]; + routes?: CaddyRouteIntent[]; + letsEncryptEmail?: string | null; +} + +export type CaddyJsonObject = Record; + +export interface CaddyFragmentStoreOptions { + serverId?: string; +} diff --git a/packages/server/src/utils/caddy/upstream-targets.ts b/packages/server/src/utils/caddy/upstream-targets.ts new file mode 100644 index 0000000000..94a6063fe9 --- /dev/null +++ b/packages/server/src/utils/caddy/upstream-targets.ts @@ -0,0 +1,34 @@ +import type { Compose } from "@dokploy/server/services/compose"; + +export const DOKPLOY_CADDY_NETWORK = "dokploy-network"; + +export const getCaddyComposeNetworkAlias = ( + appName: string, + finalServiceName: string, +) => `${appName}-${finalServiceName}`; + +export const getCaddyComposeRuntimeTarget = ( + compose: Pick, + finalServiceName: string, +) => { + if (compose.composeType === "stack") { + return { + host: `${compose.appName}_${finalServiceName}`, + network: compose.isolatedDeployment + ? compose.appName + : DOKPLOY_CADDY_NETWORK, + }; + } + + if (compose.isolatedDeployment) { + return { + host: finalServiceName, + network: compose.appName, + }; + } + + return { + host: getCaddyComposeNetworkAlias(compose.appName, finalServiceName), + network: DOKPLOY_CADDY_NETWORK, + }; +}; diff --git a/packages/server/src/utils/caddy/web-server.ts b/packages/server/src/utils/caddy/web-server.ts new file mode 100644 index 0000000000..20fe80b204 --- /dev/null +++ b/packages/server/src/utils/caddy/web-server.ts @@ -0,0 +1,60 @@ +import type { webServerSettings } from "@dokploy/server/db/schema/web-server-settings"; +import { + compileWriteAndReloadCaddyConfigSafely, + removeCaddyRouteFragment, + writeCaddyRouteFragment, +} from "./config"; +import type { CaddyRouteFragment, CaddyRouteIntent } from "./types"; +import { DOKPLOY_CADDY_NETWORK } from "./upstream-targets"; + +const CADDY_FRAGMENT_VERSION = 1; +const DASHBOARD_FRAGMENT_ID = "dashboard.dokploy"; + +const toPunycode = (host: string): string => { + try { + return new URL(`http://${host}`).hostname; + } catch { + return host; + } +}; + +export const createCaddyDashboardRouteIntent = ( + settings: typeof webServerSettings.$inferSelect, + host: string, +): CaddyRouteIntent => ({ + id: "dokploy-dashboard", + source: "dokploy-dashboard", + hosts: [toPunycode(host)], + pathPrefix: "/", + https: !!settings.https, + upstreams: [`http://dokploy:${process.env.PORT || 3000}`], + upstreamNetwork: DOKPLOY_CADDY_NETWORK, +}); + +export const createCaddyDashboardRouteFragment = ( + settings: typeof webServerSettings.$inferSelect, + host: string, +): CaddyRouteFragment => ({ + version: CADDY_FRAGMENT_VERSION, + id: DASHBOARD_FRAGMENT_ID, + source: "dokploy-dashboard", + description: "Dokploy dashboard route", + routes: [createCaddyDashboardRouteIntent(settings, host)], +}); + +export const updateServerCaddy = async ( + settings: typeof webServerSettings.$inferSelect, + newHost: string | null, +) => { + if (newHost) { + await writeCaddyRouteFragment( + createCaddyDashboardRouteFragment(settings, newHost), + ); + } else { + await removeCaddyRouteFragment(DASHBOARD_FRAGMENT_ID); + } + + await compileWriteAndReloadCaddyConfigSafely({ + letsEncryptEmail: settings.letsEncryptEmail, + }); +}; diff --git a/packages/server/src/utils/docker/domain.ts b/packages/server/src/utils/docker/domain.ts index 8094f1df2a..cc2b5e6dd3 100644 --- a/packages/server/src/utils/docker/domain.ts +++ b/packages/server/src/utils/docker/domain.ts @@ -3,7 +3,11 @@ import { join } from "node:path"; import { paths } from "@dokploy/server/constants"; import type { Compose } from "@dokploy/server/services/compose"; import type { Domain } from "@dokploy/server/services/domain"; +import { resolveWebServerProvider } from "@dokploy/server/services/web-server-settings"; import { parse, stringify } from "yaml"; +import { writeCaddyComposeRouteFragments } from "../caddy/compose"; +import { assertCaddyDomainSupported } from "../caddy/domain"; +import { getCaddyComposeNetworkAlias } from "../caddy/upstream-targets"; import { execAsyncRemote } from "../process/execAsync"; import { cloneBitbucketRepository } from "../providers/bitbucket"; import { cloneGitRepository } from "../providers/git"; @@ -11,11 +15,13 @@ import { cloneGiteaRepository } from "../providers/gitea"; import { cloneGithubRepository } from "../providers/github"; import { cloneGitlabRepository } from "../providers/gitlab"; import { getCreateComposeFileCommand } from "../providers/raw"; +import type { WebServerProvider } from "../web-server/providers"; import { randomizeDeployableSpecificationFile } from "./collision"; import { randomizeSpecificationFile } from "./compose"; import type { ComposeSpecification, DefinitionsService, + ListOrDict, PropertiesNetworks, } from "./types"; import { encodeBase64 } from "./utils"; @@ -106,12 +112,184 @@ export const readComposeFile = async (compose: Compose) => { return null; }; +export type CaddyComposeRouteTarget = { + domain: Domain; + finalServiceName: string; +}; + +const loadComposeSpecification = async (compose: Compose) => { + if (compose.serverId) { + return loadDockerComposeRemote(compose); + } + return loadDockerCompose(compose); +}; + +const finalizeComposeSpecification = ( + compose: Compose, + composeSpec: ComposeSpecification, +) => { + let result = composeSpec; + + if (compose.isolatedDeployment) { + result = randomizeDeployableSpecificationFile( + result, + compose.isolatedDeploymentsVolume, + compose.suffix || compose.appName, + ); + } else if (compose.randomize) { + result = randomizeSpecificationFile(result, compose.suffix); + } + + return result; +}; + +const resolveFinalServiceName = ( + compose: Compose, + composeSpec: ComposeSpecification, + serviceName: string, +) => { + if (composeSpec.services?.[serviceName]) { + return serviceName; + } + + const suffix = compose.randomize + ? compose.suffix + : compose.isolatedDeployment + ? compose.suffix || compose.appName + : null; + if (suffix) { + const suffixedServiceName = `${serviceName}-${suffix}`; + if (composeSpec.services?.[suffixedServiceName]) { + return suffixedServiceName; + } + } + + return serviceName; +}; + +const addDomainToComposeForCaddyWithRoutes = async ( + compose: Compose, + domains: Domain[], +): Promise<{ + composeSpec: ComposeSpecification | null; + caddyRouteTargets: CaddyComposeRouteTarget[]; +}> => { + const result = await loadComposeSpecification(compose); + if (!result) { + return { composeSpec: null, caddyRouteTargets: [] }; + } + + for (const domain of domains) { + assertCaddyDomainSupported(domain); + } + + const finalizedCompose = finalizeComposeSpecification(compose, result); + const caddyRouteTargets: CaddyComposeRouteTarget[] = []; + + for (const domain of domains) { + const { serviceName } = domain; + if (!serviceName) { + throw new Error(`Domain "${domain.host}" is missing a service name`); + } + + const finalServiceName = resolveFinalServiceName( + compose, + finalizedCompose, + serviceName, + ); + if (!finalizedCompose.services?.[finalServiceName]) { + throw new Error( + `Domain "${domain.host}" is attached to service "${serviceName}" which does not exist in the compose`, + ); + } + + const service = finalizedCompose.services[finalServiceName]; + if (compose.composeType === "docker-compose") { + if (service.labels) { + service.labels = removeDokployGeneratedTraefikLabels(service.labels, { + appName: compose.appName, + domains, + }); + } + } else if (service.deploy?.labels) { + service.deploy.labels = removeDokployGeneratedTraefikLabels( + service.deploy.labels, + { + appName: compose.appName, + domains, + }, + ); + } + + if (!compose.isolatedDeployment) { + service.networks = addDokployNetworkToService(service.networks, { + aliases: + compose.composeType === "docker-compose" + ? [getCaddyComposeNetworkAlias(compose.appName, finalServiceName)] + : undefined, + }); + } + + caddyRouteTargets.push({ domain, finalServiceName }); + } + + if (!compose.isolatedDeployment) { + finalizedCompose.networks = addDokployNetworkToRoot( + finalizedCompose.networks, + ); + } + + return { composeSpec: finalizedCompose, caddyRouteTargets }; +}; + +export const getCaddyComposeRouteTargetsForWebServer = async ( + compose: Compose, + domains: Domain[], + provider?: WebServerProvider, +) => { + const resolvedProvider = + provider ?? (await resolveWebServerProvider(compose.serverId)); + if (resolvedProvider !== "caddy") { + return null; + } + + return (await addDomainToComposeForCaddyWithRoutes(compose, domains)) + .caddyRouteTargets; +}; + +export const writeCaddyComposeRoutesForTargets = async ( + compose: Compose, + caddyRouteTargets: CaddyComposeRouteTarget[], +) => { + await writeCaddyComposeRouteFragments(compose, caddyRouteTargets); +}; + +export const addDomainToComposeForWebServer = async ( + compose: Compose, + domains: Domain[], + provider?: WebServerProvider, +) => { + const resolvedProvider = + provider ?? (await resolveWebServerProvider(compose.serverId)); + if (resolvedProvider === "caddy") { + return (await addDomainToComposeForCaddyWithRoutes(compose, domains)) + .composeSpec; + } + + return addDomainToCompose(compose, domains); +}; + export const writeDomainsToCompose = async ( compose: Compose, domains: Domain[], ) => { try { - const composeConverted = await addDomainToCompose(compose, domains); + const provider = await resolveWebServerProvider(compose.serverId); + const composeConverted = + provider === "caddy" + ? (await addDomainToComposeForCaddyWithRoutes(compose, domains)) + .composeSpec + : await addDomainToCompose(compose, domains); const path = getComposePath(compose); if (!composeConverted) { @@ -347,8 +525,156 @@ export const createDomainLabels = ( return labels; }; +export type DokployTraefikLabelClassifierContext = { + appName?: string; + domains?: Pick[]; + includeGenericLabels?: boolean; +}; + +const getLabelKey = (label: string) => label.split("=")[0] ?? label; + +const getLabelValue = (label: string) => { + const separatorIndex = label.indexOf("="); + return separatorIndex === -1 ? undefined : label.slice(separatorIndex + 1); +}; + +const getDomainEntrypoints = ( + domain: Pick, +) => { + if (domain.customEntrypoint) { + return [domain.customEntrypoint]; + } + return domain.https ? ["web", "websecure"] : ["web"]; +}; + +const isGenericDokployTraefikLabel = (label: string) => { + const key = getLabelKey(label); + const value = getLabelValue(label); + + if (key === "traefik.enable") { + return value === undefined || value === "true"; + } + + if (key === "traefik.docker.network" || key === "traefik.swarm.network") { + return value === undefined || value === "dokploy-network"; + } + + return false; +}; + +const isDomainSpecificDokployTraefikLabel = ( + label: string, + context: DokployTraefikLabelClassifierContext = {}, +) => { + const key = getLabelKey(label); + const { appName, domains } = context; + + if (appName && domains) { + for (const domain of domains) { + for (const entrypoint of getDomainEntrypoints(domain)) { + const routerName = `${appName}-${domain.uniqueConfigKey}-${entrypoint}`; + if ( + key.startsWith(`traefik.http.routers.${routerName}.`) || + key.startsWith(`traefik.http.services.${routerName}.`) + ) { + return true; + } + } + + if ( + key.startsWith( + `traefik.http.middlewares.stripprefix-${appName}-${domain.uniqueConfigKey}.`, + ) || + key.startsWith( + `traefik.http.middlewares.addprefix-${appName}-${domain.uniqueConfigKey}.`, + ) + ) { + return true; + } + } + + return false; + } + + if (appName) { + const appRouterPattern = new RegExp( + `^traefik\\.http\\.(routers|services)\\.${appName.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}.+-(web|websecure)\\.`, + ); + if (appRouterPattern.test(key)) { + return true; + } + } + + return ( + /^traefik\.http\.(routers|services)\.[^.]+-\d+-[^.]+\./.test(key) || + /^traefik\.http\.middlewares\.(stripprefix|addprefix)-.+-\d+\./.test(key) + ); +}; + +export const isDokployGeneratedTraefikLabel = ( + label: string, + context: DokployTraefikLabelClassifierContext = {}, +) => { + return ( + isDomainSpecificDokployTraefikLabel(label, context) || + (context.includeGenericLabels !== false && + isGenericDokployTraefikLabel(label)) + ); +}; + +const stringifyLabelObjectEntry = ( + key: string, + value: string | number | boolean | null, +) => (value === null ? key : `${key}=${value}`); + +export const removeDokployGeneratedTraefikLabels = ( + labels: ListOrDict | undefined, + context: DokployTraefikLabelClassifierContext = {}, +): ListOrDict | undefined => { + if (!labels) { + return labels; + } + + if (Array.isArray(labels)) { + const removedSpecificLabel = labels.some((label) => + isDomainSpecificDokployTraefikLabel(label, context), + ); + return labels.filter((label) => { + if (isDomainSpecificDokployTraefikLabel(label, context)) { + return false; + } + if (removedSpecificLabel && isGenericDokployTraefikLabel(label)) { + return false; + } + return true; + }); + } + + const entries = Object.entries(labels); + const removedSpecificLabel = entries.some(([key, value]) => + isDomainSpecificDokployTraefikLabel( + stringifyLabelObjectEntry(key, value), + context, + ), + ); + + return Object.fromEntries( + entries.filter(([key, value]) => { + const label = stringifyLabelObjectEntry(key, value); + if (isDomainSpecificDokployTraefikLabel(label, context)) { + return false; + } + if (removedSpecificLabel && isGenericDokployTraefikLabel(label)) { + return false; + } + return true; + }), + ); +}; + export const addDokployNetworkToService = ( networkService: DefinitionsService["networks"], + options: { aliases?: string[] } = {}, ) => { let networks = networkService; const network = "dokploy-network"; @@ -364,6 +690,17 @@ export const addDokployNetworkToService = ( if (!networks.includes(defaultNetwork)) { networks.push(defaultNetwork); } + if (options.aliases?.length) { + const nextNetworks: Record = {}; + for (const item of networks) { + nextNetworks[item] = {}; + } + nextNetworks[network] = { + ...nextNetworks[network], + aliases: [...new Set(options.aliases)], + }; + networks = nextNetworks; + } } else if (networks && typeof networks === "object") { if (!(network in networks)) { networks[network] = {}; @@ -371,6 +708,17 @@ export const addDokployNetworkToService = ( if (!(defaultNetwork in networks)) { networks[defaultNetwork] = {}; } + if (options.aliases?.length) { + const current = networks[network]; + const currentAliases = + current && typeof current === "object" && "aliases" in current + ? ((current.aliases as string[] | undefined) ?? []) + : []; + networks[network] = { + ...(current && typeof current === "object" ? current : {}), + aliases: [...new Set([...currentAliases, ...options.aliases])], + }; + } } return networks; diff --git a/packages/server/src/utils/web-server/domain.ts b/packages/server/src/utils/web-server/domain.ts new file mode 100644 index 0000000000..19066863d8 --- /dev/null +++ b/packages/server/src/utils/web-server/domain.ts @@ -0,0 +1,31 @@ +import type { Domain } from "@dokploy/server/services/domain"; +import { resolveWebServerProvider } from "@dokploy/server/services/web-server-settings"; +import type { ApplicationNested } from "../builders"; +import { manageCaddyDomain, removeCaddyDomain } from "../caddy/domain"; +import { manageDomain, removeDomain } from "../traefik/domain"; + +export const manageWebServerDomain = async ( + app: ApplicationNested, + domain: Domain, +) => { + const provider = await resolveWebServerProvider(app.serverId); + + if (provider === "caddy") { + return manageCaddyDomain(app, domain); + } + + return manageDomain(app, domain); +}; + +export const removeWebServerDomain = async ( + app: ApplicationNested, + uniqueConfigKey: number, +) => { + const provider = await resolveWebServerProvider(app.serverId); + + if (provider === "caddy") { + return removeCaddyDomain(app, uniqueConfigKey); + } + + return removeDomain(app, uniqueConfigKey); +}; diff --git a/packages/server/src/utils/web-server/paths.ts b/packages/server/src/utils/web-server/paths.ts new file mode 100644 index 0000000000..1d113bf030 --- /dev/null +++ b/packages/server/src/utils/web-server/paths.ts @@ -0,0 +1,29 @@ +import { paths } from "@dokploy/server/constants"; +import type { WebServerProvider } from "./providers"; + +export const getWebServerPaths = ( + provider: WebServerProvider, + isServer = false, +) => { + const resolvedPaths = paths(isServer); + + if (provider === "caddy") { + return { + provider, + basePath: resolvedPaths.MAIN_CADDY_PATH, + activeConfigPath: resolvedPaths.CADDY_CONFIG_PATH, + fragmentsPath: resolvedPaths.CADDY_FRAGMENTS_PATH, + dataPath: resolvedPaths.CADDY_DATA_PATH, + configDirPath: resolvedPaths.CADDY_CONFIG_DIR_PATH, + migrationsPath: resolvedPaths.CADDY_MIGRATIONS_PATH, + }; + } + + return { + provider, + basePath: resolvedPaths.MAIN_TRAEFIK_PATH, + activeConfigPath: `${resolvedPaths.MAIN_TRAEFIK_PATH}/traefik.yml`, + fragmentsPath: resolvedPaths.DYNAMIC_TRAEFIK_PATH, + certificatesPath: resolvedPaths.CERTIFICATES_PATH, + }; +}; diff --git a/packages/server/src/utils/web-server/providers.ts b/packages/server/src/utils/web-server/providers.ts new file mode 100644 index 0000000000..e5c29623a3 --- /dev/null +++ b/packages/server/src/utils/web-server/providers.ts @@ -0,0 +1,20 @@ +export const WEB_SERVER_PROVIDERS = ["traefik", "caddy"] as const; + +export type WebServerProvider = (typeof WEB_SERVER_PROVIDERS)[number]; + +export const DEFAULT_WEB_SERVER_PROVIDER: WebServerProvider = "traefik"; + +export const isWebServerProvider = ( + provider: unknown, +): provider is WebServerProvider => + typeof provider === "string" && + WEB_SERVER_PROVIDERS.includes(provider as WebServerProvider); + +export const normalizeWebServerProvider = ( + provider: unknown, +): WebServerProvider => + isWebServerProvider(provider) ? provider : DEFAULT_WEB_SERVER_PROVIDER; + +export const getWebServerResourceName = (provider: WebServerProvider) => { + return provider === "caddy" ? "dokploy-caddy" : "dokploy-traefik"; +}; From f8c6d78f8712889d6261a7f2bfcdfb71258ea826 Mon Sep 17 00:00:00 2001 From: Mason James Date: Mon, 1 Jun 2026 14:33:22 -0400 Subject: [PATCH 02/37] chore: format caddy provider changes --- .../application/domains/handle-domain.tsx | 4 ++- apps/dokploy/server/api/routers/domain.ts | 26 ++++++++++++++----- apps/dokploy/server/api/routers/settings.ts | 4 ++- packages/server/src/db/schema/server.ts | 2 +- packages/server/src/services/settings.ts | 8 +++--- .../src/services/web-server-settings.ts | 9 ++++--- 6 files changed, 36 insertions(+), 17 deletions(-) diff --git a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx index 8fd75b68be..564f4fd182 100644 --- a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx +++ b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx @@ -875,7 +875,9 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { { - const newMiddlewares = [...(field.value || [])]; + const newMiddlewares = [ + ...(field.value || []), + ]; newMiddlewares.splice(index, 1); form.setValue("middlewares", newMiddlewares); }} diff --git a/apps/dokploy/server/api/routers/domain.ts b/apps/dokploy/server/api/routers/domain.ts index 290654bd85..7d1bb43ebb 100644 --- a/apps/dokploy/server/api/routers/domain.ts +++ b/apps/dokploy/server/api/routers/domain.ts @@ -13,11 +13,11 @@ import { getWebServerSettings, manageWebServerDomain, removeDomainById, - writeCaddyComposeRoutesForTargets, removeWebServerDomain, resolveWebServerProvider, updateDomainById, validateDomain, + writeCaddyComposeRoutesForTargets, } from "@dokploy/server"; import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission"; import { TRPCError } from "@trpc/server"; @@ -36,7 +36,9 @@ import { apiUpdateDomain, } from "@/server/db/schema"; -const toDomainUpdateFields = (domain: Awaited>) => ({ +const toDomainUpdateFields = ( + domain: Awaited>, +) => ({ host: domain.host, https: domain.https, port: domain.port, @@ -166,8 +168,12 @@ export const domainRouter = createTRPCRouter({ const nextDomain = { ...currentDomain, ...input }; if (currentDomain.applicationId) { - const application = await findApplicationById(currentDomain.applicationId); - if ((await resolveWebServerProvider(application.serverId)) === "caddy") { + const application = await findApplicationById( + currentDomain.applicationId, + ); + if ( + (await resolveWebServerProvider(application.serverId)) === "caddy" + ) { assertCaddyDomainSupported(nextDomain); await manageWebServerDomain(application, nextDomain); try { @@ -195,7 +201,9 @@ export const domainRouter = createTRPCRouter({ previewDeployment.applicationId, ); application.appName = previewDeployment.appName; - if ((await resolveWebServerProvider(application.serverId)) === "caddy") { + if ( + (await resolveWebServerProvider(application.serverId)) === "caddy" + ) { assertCaddyDomainSupported(nextDomain); await manageWebServerDomain(application, nextDomain); try { @@ -303,7 +311,9 @@ export const domainRouter = createTRPCRouter({ if (domain.applicationId) { const application = await findApplicationById(domain.applicationId); - if ((await resolveWebServerProvider(application.serverId)) === "caddy") { + if ( + (await resolveWebServerProvider(application.serverId)) === "caddy" + ) { await removeWebServerDomain(application, domain.uniqueConfigKey); try { const result = await removeDomainById(input.domainId); @@ -327,7 +337,9 @@ export const domainRouter = createTRPCRouter({ previewDeployment.applicationId, ); application.appName = previewDeployment.appName; - if ((await resolveWebServerProvider(application.serverId)) === "caddy") { + if ( + (await resolveWebServerProvider(application.serverId)) === "caddy" + ) { await removeWebServerDomain(application, domain.uniqueConfigKey); try { const result = await removeDomainById(input.domainId); diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts index e82b6ace26..921dee6dc1 100644 --- a/apps/dokploy/server/api/routers/settings.ts +++ b/apps/dokploy/server/api/routers/settings.ts @@ -171,7 +171,9 @@ const isPathWithin = (targetPath: string, basePath: string) => { const isCaddyMigrationBackupPath = (filePath: string, serverId?: string) => { const caddyPaths = paths(!!serverId); const normalizedPath = normalizeWebServerPath(filePath); - const migrationsPath = normalizeWebServerPath(caddyPaths.CADDY_MIGRATIONS_PATH); + const migrationsPath = normalizeWebServerPath( + caddyPaths.CADDY_MIGRATIONS_PATH, + ); if (!isPathWithin(normalizedPath, migrationsPath)) { return false; } diff --git a/packages/server/src/db/schema/server.ts b/packages/server/src/db/schema/server.ts index f12186c93c..db4c3ffda1 100644 --- a/packages/server/src/db/schema/server.ts +++ b/packages/server/src/db/schema/server.ts @@ -22,8 +22,8 @@ import { mysql } from "./mysql"; import { postgres } from "./postgres"; import { redis } from "./redis"; import { schedules } from "./schedule"; -import { sshKeys } from "./ssh-key"; import { webServerProvider } from "./shared"; +import { sshKeys } from "./ssh-key"; import { generateAppName } from "./utils"; export const serverStatus = pgEnum("serverStatus", ["active", "inactive"]); export const serverType = pgEnum("serverType", ["deploy", "build"]); diff --git a/packages/server/src/services/settings.ts b/packages/server/src/services/settings.ts index 5fafa9dd2c..c0005fb098 100644 --- a/packages/server/src/services/settings.ts +++ b/packages/server/src/services/settings.ts @@ -350,7 +350,9 @@ const asStringRecord = (value: unknown): Record | undefined => { ); }; -const asRecordArray = (value: unknown): Array> | undefined => +const asRecordArray = ( + value: unknown, +): Array> | undefined => Array.isArray(value) ? value.filter( (item): item is Record => @@ -845,9 +847,9 @@ export const reconnectServicesToWebServer = async ( const quotedResourceName = quote([resourceName]); commands += `if docker service inspect ${quotedResourceName} >/dev/null 2>&1; then\n`; commands += ` docker service inspect ${quotedResourceName} --format '{{range .Spec.TaskTemplate.Networks}}{{println .Target}}{{end}}' | grep -qx ${networkName} || docker service update --network-add ${networkName} ${quotedResourceName} >/dev/null\n`; - commands += `else\n`; + commands += "else\n"; commands += ` docker network connect ${networkName} $(docker ps --filter "name=${resourceName}" -q) >/dev/null 2>&1 || true\n`; - commands += `fi\n`; + commands += "fi\n"; } if (serverId) { diff --git a/packages/server/src/services/web-server-settings.ts b/packages/server/src/services/web-server-settings.ts index 152d30874d..0b1403f2c9 100644 --- a/packages/server/src/services/web-server-settings.ts +++ b/packages/server/src/services/web-server-settings.ts @@ -47,10 +47,11 @@ export const updateWebServerSettings = async ( return updated; }; -export const getLocalWebServerProvider = async (): Promise => { - const settings = await getWebServerSettings(); - return normalizeWebServerProvider(settings?.webServerProvider); -}; +export const getLocalWebServerProvider = + async (): Promise => { + const settings = await getWebServerSettings(); + return normalizeWebServerProvider(settings?.webServerProvider); + }; export const updateLocalWebServerProvider = async ( provider: WebServerProvider, From a86a412be1b2c9d6453410008a0d6cd6fad39544 Mon Sep 17 00:00:00 2001 From: Mason James Date: Mon, 1 Jun 2026 18:30:19 -0400 Subject: [PATCH 03/37] fix: fail closed on runtime migration errors --- .../__test__/db/runtime-migration.test.ts | 85 +++++++++++++++++++ apps/dokploy/migration.ts | 24 ++---- apps/dokploy/server/db/migration.ts | 23 +---- apps/dokploy/server/db/run-migrations.ts | 29 +++++++ 4 files changed, 124 insertions(+), 37 deletions(-) create mode 100644 apps/dokploy/__test__/db/runtime-migration.test.ts create mode 100644 apps/dokploy/server/db/run-migrations.ts diff --git a/apps/dokploy/__test__/db/runtime-migration.test.ts b/apps/dokploy/__test__/db/runtime-migration.test.ts new file mode 100644 index 0000000000..b7ddbca00e --- /dev/null +++ b/apps/dokploy/__test__/db/runtime-migration.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const db = { id: "db" }; + const sql = { end: vi.fn().mockResolvedValue(undefined) }; + + return { + db, + drizzle: vi.fn(() => db), + logger: { + error: vi.fn(), + log: vi.fn(), + }, + migrate: vi.fn().mockResolvedValue(undefined), + postgres: vi.fn(() => sql), + sql, + }; +}); + +vi.mock("@dokploy/server/db/constants", () => ({ + dbUrl: "postgres://dokploy:test@localhost:5432/dokploy", +})); + +vi.mock("postgres", () => ({ + default: mocks.postgres, +})); + +vi.mock("drizzle-orm/postgres-js", () => ({ + drizzle: mocks.drizzle, +})); + +vi.mock("drizzle-orm/postgres-js/migrator", () => ({ + migrate: mocks.migrate, +})); + +import { runRuntimeMigrations } from "../../server/db/run-migrations"; + +describe("runtime migrations", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.sql.end.mockResolvedValue(undefined); + mocks.migrate.mockResolvedValue(undefined); + }); + + test("runs the default migration folder and closes the connection once", async () => { + await runRuntimeMigrations({ logger: mocks.logger }); + + expect(mocks.postgres).toHaveBeenCalledWith( + "postgres://dokploy:test@localhost:5432/dokploy", + { max: 1 }, + ); + expect(mocks.drizzle).toHaveBeenCalledWith(mocks.sql); + expect(mocks.migrate).toHaveBeenCalledWith(mocks.db, { + migrationsFolder: "drizzle", + }); + expect(mocks.logger.log).toHaveBeenCalledWith("Migration complete"); + expect(mocks.logger.error).not.toHaveBeenCalled(); + expect(mocks.sql.end).toHaveBeenCalledTimes(1); + }); + + test("supports an explicit migration folder", async () => { + await runRuntimeMigrations({ + logger: mocks.logger, + migrationsFolder: "/app/drizzle", + }); + + expect(mocks.migrate).toHaveBeenCalledWith(mocks.db, { + migrationsFolder: "/app/drizzle", + }); + expect(mocks.sql.end).toHaveBeenCalledTimes(1); + }); + + test("rejects migration failures and still closes the connection once", async () => { + const error = new Error("migration boom"); + mocks.migrate.mockRejectedValueOnce(error); + + await expect(runRuntimeMigrations({ logger: mocks.logger })).rejects.toBe( + error, + ); + + expect(mocks.logger.error).toHaveBeenCalledWith("Migration failed", error); + expect(mocks.logger.log).not.toHaveBeenCalled(); + expect(mocks.sql.end).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/dokploy/migration.ts b/apps/dokploy/migration.ts index 984197b2ae..0f077acdfa 100644 --- a/apps/dokploy/migration.ts +++ b/apps/dokploy/migration.ts @@ -1,19 +1,7 @@ -import { dbUrl } from "@dokploy/server/db"; -import { drizzle } from "drizzle-orm/postgres-js"; -import { migrate } from "drizzle-orm/postgres-js/migrator"; -import postgres from "postgres"; +import { runRuntimeMigrations } from "./server/db/run-migrations"; -const sql = postgres(dbUrl, { max: 1 }); -const db = drizzle(sql); - -await migrate(db, { migrationsFolder: "drizzle" }) - .then(() => { - console.log("Migration complete"); - sql.end(); - }) - .catch((error) => { - console.log("Migration failed", error); - }) - .finally(() => { - sql.end(); - }); +try { + await runRuntimeMigrations(); +} catch { + process.exit(1); +} diff --git a/apps/dokploy/server/db/migration.ts b/apps/dokploy/server/db/migration.ts index 8a24afdc50..da381cfea4 100644 --- a/apps/dokploy/server/db/migration.ts +++ b/apps/dokploy/server/db/migration.ts @@ -1,20 +1,5 @@ -import { dbUrl } from "@dokploy/server/db"; -import { drizzle } from "drizzle-orm/postgres-js"; -import { migrate } from "drizzle-orm/postgres-js/migrator"; -import postgres from "postgres"; +import { runRuntimeMigrations } from "./run-migrations"; -const sql = postgres(dbUrl, { max: 1 }); -const db = drizzle(sql); - -export const migration = async () => - await migrate(db, { migrationsFolder: "drizzle" }) - .then(() => { - console.log("Migration complete"); - sql.end(); - }) - .catch((error) => { - console.log("Migration failed", error); - }) - .finally(() => { - sql.end(); - }); +export const migration = async () => { + await runRuntimeMigrations(); +}; diff --git a/apps/dokploy/server/db/run-migrations.ts b/apps/dokploy/server/db/run-migrations.ts new file mode 100644 index 0000000000..202c5fbf24 --- /dev/null +++ b/apps/dokploy/server/db/run-migrations.ts @@ -0,0 +1,29 @@ +import { dbUrl } from "@dokploy/server/db/constants"; +import { drizzle } from "drizzle-orm/postgres-js"; +import { migrate } from "drizzle-orm/postgres-js/migrator"; +import postgres from "postgres"; + +export type RuntimeMigrationLogger = Pick; + +export type RunRuntimeMigrationsOptions = { + logger?: RuntimeMigrationLogger; + migrationsFolder?: string; +}; + +export const runRuntimeMigrations = async ({ + logger = console, + migrationsFolder = "drizzle", +}: RunRuntimeMigrationsOptions = {}) => { + const sql = postgres(dbUrl, { max: 1 }); + + try { + const db = drizzle(sql); + await migrate(db, { migrationsFolder }); + logger.log("Migration complete"); + } catch (error) { + logger.error("Migration failed", error); + throw error; + } finally { + await sql.end(); + } +}; From 94164bf7cc74b13e3c44470744e305c6bfb60e24 Mon Sep 17 00:00:00 2001 From: Mason James Date: Mon, 1 Jun 2026 21:03:06 -0400 Subject: [PATCH 04/37] feat: harden caddy provider domain flows --- .../caddy/application/domain-service.test.ts | 154 + .../__test__/caddy/application/domain.test.ts | 40 +- .../__test__/caddy/certificate-guard.test.ts | 69 + .../__test__/caddy/compose/domain.test.ts | 55 + apps/dokploy/__test__/caddy/config.test.ts | 122 + .../__test__/caddy/dashboard-route.test.ts | 1 + .../caddy/domain-router-lifecycle.test.ts | 254 + .../caddy/migration/apply-rollback.test.ts | 4 + .../__test__/caddy/migration/prepare.test.ts | 4 + .../__test__/caddy/preview-deployment.test.ts | 275 + apps/dokploy/__test__/caddy/setup.test.ts | 1 + .../server/update-server-config.test.ts | 1 + .../application/domains/handle-domain.tsx | 79 +- .../certificates/show-certificates.tsx | 9 +- .../servers/actions/show-traefik-actions.tsx | 12 + .../dashboard/settings/web-server.tsx | 2 + .../caddy-trusted-proxy-settings.tsx | 214 + .../0171_caddy_trusted_proxy_config.sql | 2 + apps/dokploy/drizzle/meta/0171_snapshot.json | 8370 +++++++++++++++++ apps/dokploy/drizzle/meta/_journal.json | 7 + apps/dokploy/server/api/routers/ai.ts | 4 +- apps/dokploy/server/api/routers/compose.ts | 6 +- apps/dokploy/server/api/routers/domain.ts | 58 +- apps/dokploy/server/api/routers/project.ts | 3 +- apps/dokploy/server/api/routers/settings.ts | 105 +- docs/caddy-provider-upstream-readiness.md | 356 + packages/server/src/db/schema/server.ts | 4 + .../src/db/schema/web-server-settings.ts | 13 + packages/server/src/services/certificate.ts | 22 + packages/server/src/services/domain.ts | 58 +- .../server/src/services/preview-deployment.ts | 97 +- packages/server/src/services/settings.ts | 2 + .../src/services/web-server-settings.ts | 140 + packages/server/src/setup/caddy-setup.ts | 24 +- packages/server/src/utils/caddy/compose.ts | 36 +- packages/server/src/utils/caddy/config.ts | 191 +- packages/server/src/utils/caddy/domain.ts | 69 +- .../server/src/utils/caddy/migration/apply.ts | 2 + .../src/utils/caddy/migration/prepare.ts | 10 +- packages/server/src/utils/caddy/types.ts | 21 + packages/server/src/utils/caddy/web-server.ts | 4 + 41 files changed, 10733 insertions(+), 167 deletions(-) create mode 100644 apps/dokploy/__test__/caddy/application/domain-service.test.ts create mode 100644 apps/dokploy/__test__/caddy/certificate-guard.test.ts create mode 100644 apps/dokploy/__test__/caddy/domain-router-lifecycle.test.ts create mode 100644 apps/dokploy/__test__/caddy/preview-deployment.test.ts create mode 100644 apps/dokploy/components/dashboard/settings/web-server/caddy-trusted-proxy-settings.tsx create mode 100644 apps/dokploy/drizzle/0171_caddy_trusted_proxy_config.sql create mode 100644 apps/dokploy/drizzle/meta/0171_snapshot.json create mode 100644 docs/caddy-provider-upstream-readiness.md diff --git a/apps/dokploy/__test__/caddy/application/domain-service.test.ts b/apps/dokploy/__test__/caddy/application/domain-service.test.ts new file mode 100644 index 0000000000..4678fa528a --- /dev/null +++ b/apps/dokploy/__test__/caddy/application/domain-service.test.ts @@ -0,0 +1,154 @@ +import { beforeEach, expect, test, vi } from "vitest"; + +const txInsertMock = vi.hoisted(() => vi.fn()); +const transactionMock = vi.hoisted(() => vi.fn()); +const dbDeleteMock = vi.hoisted(() => vi.fn()); +const domainsFindFirstMock = vi.hoisted(() => vi.fn()); +const domainsFindManyMock = vi.hoisted(() => vi.fn()); + +vi.mock("@dokploy/server/db", () => ({ + db: { + transaction: transactionMock, + query: { + domains: { + findFirst: domainsFindFirstMock, + findMany: domainsFindManyMock, + }, + }, + delete: dbDeleteMock, + }, +})); + +vi.mock("@dokploy/server/services/application", () => ({ + findApplicationById: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/web-server/domain", () => ({ + manageWebServerDomain: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/docker/domain", () => ({ + getCaddyComposeRouteTargetsForWebServer: vi.fn(), + writeCaddyComposeRoutesForTargets: vi.fn(), +})); + +import { findApplicationById } from "@dokploy/server/services/application"; +import { + createComposeDomain, + createDomain, +} from "@dokploy/server/services/domain"; +import { + getCaddyComposeRouteTargetsForWebServer, + writeCaddyComposeRoutesForTargets, +} from "@dokploy/server/utils/docker/domain"; +import { manageWebServerDomain } from "@dokploy/server/utils/web-server/domain"; + +const domain = { + domainId: "domain-1", + applicationId: "app-1", + composeId: null, + previewDeploymentId: null, + host: "example.com", + uniqueConfigKey: 7, +}; + +const composeDomain = { + ...domain, + applicationId: null, + composeId: "compose-1", + domainType: "compose", +}; + +const application = { + applicationId: "app-1", + appName: "my-app", + serverId: null, +}; + +beforeEach(() => { + vi.clearAllMocks(); + txInsertMock.mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([domain]), + }), + }); + transactionMock.mockImplementation(async (callback) => { + const tx = { insert: txInsertMock }; + return callback(tx); + }); + domainsFindFirstMock.mockResolvedValue(domain); + domainsFindManyMock.mockResolvedValue([composeDomain]); + dbDeleteMock.mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([composeDomain]), + }), + }); + vi.mocked(getCaddyComposeRouteTargetsForWebServer).mockResolvedValue([ + {}, + ] as never); + vi.mocked(writeCaddyComposeRoutesForTargets).mockResolvedValue( + undefined as never, + ); + vi.mocked(findApplicationById).mockResolvedValue(application as never); +}); + +test("creates copied new-service application domains through the active web server provider", async () => { + vi.mocked(manageWebServerDomain).mockResolvedValue(undefined as never); + + const created = await createDomain({ + host: " example.com ", + applicationId: "app-1", + domainType: "application", + } as never); + + expect(created).toBe(domain); + expect(txInsertMock).toHaveBeenCalled(); + expect(manageWebServerDomain).toHaveBeenCalledWith(application, domain); +}); + +test("removes application domain rows when provider route creation fails", async () => { + vi.mocked(manageWebServerDomain).mockRejectedValueOnce( + new Error("caddy reload failed") as never, + ); + + await expect( + createDomain({ + host: "example.com", + applicationId: "app-1", + domainType: "application", + } as never), + ).rejects.toThrow("caddy reload failed"); + + expect(manageWebServerDomain).toHaveBeenCalledWith(application, domain); + expect(dbDeleteMock).toHaveBeenCalled(); +}); + +test("removes compose domain rows when Caddy compose route refresh fails after creation", async () => { + txInsertMock.mockReturnValueOnce({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([composeDomain]), + }), + }); + vi.mocked(writeCaddyComposeRoutesForTargets) + .mockRejectedValueOnce(new Error("caddy refresh failed") as never) + .mockResolvedValueOnce(undefined as never); + + await expect( + createComposeDomain( + { + composeId: "compose-1", + appName: "my-compose", + serverId: null, + } as never, + { + host: "example.com", + composeId: "compose-1", + domainType: "compose", + } as never, + "caddy", + ), + ).rejects.toThrow("caddy refresh failed"); + + expect(dbDeleteMock).toHaveBeenCalled(); + expect(writeCaddyComposeRoutesForTargets).toHaveBeenCalledTimes(2); +}); diff --git a/apps/dokploy/__test__/caddy/application/domain.test.ts b/apps/dokploy/__test__/caddy/application/domain.test.ts index 346e16fa84..9b9569e743 100644 --- a/apps/dokploy/__test__/caddy/application/domain.test.ts +++ b/apps/dokploy/__test__/caddy/application/domain.test.ts @@ -3,6 +3,7 @@ import { compileCaddyConfig, createCaddyApplicationRouteFragment, getCaddyApplicationFragmentId, + paths, } from "@dokploy/server"; import { expect, test } from "vitest"; @@ -103,6 +104,31 @@ test("HTTPS application routes redirect HTTP and proxy on HTTPS", () => { }); }); +test("loads uploaded certificates for custom Caddy HTTPS routes", () => { + const fragment = createCaddyApplicationRouteFragment( + app(), + domain({ + https: true, + certificateType: "custom", + customCertResolver: "certificate-uploaded", + }), + ); + const config = compileCaddyConfig({ fragments: [fragment] }); + const tls = (config.apps as any).tls; + const certificatePath = `${paths(false).CERTIFICATES_PATH}/certificate-uploaded`; + + expect(tls.certificates.load_files).toEqual([ + { + certificate: `${certificatePath}/chain.crt`, + key: `${certificatePath}/privkey.key`, + }, + ]); + expect(getServers(config).https.routes[0].handle.at(-1)).toMatchObject({ + handler: "reverse_proxy", + upstreams: [{ dial: "my-app:80" }], + }); +}); + test("punycodes internationalized hosts for Caddy routes", () => { const fragment = createCaddyApplicationRouteFragment( app(), @@ -119,9 +145,21 @@ test("rejects Traefik-only domain features for Caddy routes", () => { domain({ customEntrypoint: "admin", customCertResolver: "internal", - certificateType: "custom", middlewares: ["auth@file"], }), ), ).toThrow("unsupported Caddy fields"); }); + +test("rejects Caddy custom certificate routes without an uploaded certificate", () => { + expect(() => + createCaddyApplicationRouteFragment( + app(), + domain({ + https: true, + certificateType: "custom", + customCertResolver: null, + }), + ), + ).toThrow("custom certificates require an uploaded certificate"); +}); diff --git a/apps/dokploy/__test__/caddy/certificate-guard.test.ts b/apps/dokploy/__test__/caddy/certificate-guard.test.ts new file mode 100644 index 0000000000..277cd54953 --- /dev/null +++ b/apps/dokploy/__test__/caddy/certificate-guard.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, expect, test, vi } from "vitest"; + +const certificatesFindFirstMock = vi.hoisted(() => vi.fn()); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + certificates: { + findFirst: certificatesFindFirstMock, + }, + }, + }, +})); + +import type { Domain } from "@dokploy/server"; +import { assertCaddyDomainCertificateAvailable } from "@dokploy/server/utils/caddy/domain"; + +const domain = (overrides: Partial = {}) => + ({ + domainId: "domain-1", + applicationId: "app-1", + composeId: null, + previewDeploymentId: null, + domainType: "application", + host: "example.com", + path: "/", + internalPath: "/", + stripPath: false, + https: true, + certificateType: "custom", + customCertResolver: "certificate-uploaded", + customEntrypoint: null, + middlewares: null, + port: 3000, + serviceName: null, + uniqueConfigKey: 7, + createdAt: "", + ...overrides, + }) as Domain; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +test("allows uploaded Caddy certificates assigned to the same server", async () => { + certificatesFindFirstMock.mockResolvedValue({ + certificatePath: "certificate-uploaded", + serverId: "server-1", + }); + + await expect( + assertCaddyDomainCertificateAvailable("server-1", domain()), + ).resolves.toBeUndefined(); +}); + +test("rejects missing or cross-server Caddy certificate paths", async () => { + certificatesFindFirstMock.mockResolvedValueOnce(null); + await expect( + assertCaddyDomainCertificateAvailable(null, domain()), + ).rejects.toThrow("is not available for this server"); + + certificatesFindFirstMock.mockResolvedValueOnce({ + certificatePath: "certificate-uploaded", + serverId: "server-2", + }); + await expect( + assertCaddyDomainCertificateAvailable("server-1", domain()), + ).rejects.toThrow("is not available for this server"); +}); diff --git a/apps/dokploy/__test__/caddy/compose/domain.test.ts b/apps/dokploy/__test__/caddy/compose/domain.test.ts index d0ded5fc96..80f2914161 100644 --- a/apps/dokploy/__test__/caddy/compose/domain.test.ts +++ b/apps/dokploy/__test__/caddy/compose/domain.test.ts @@ -8,6 +8,14 @@ vi.mock("node:fs", () => ({ default: fs, })); +const execAsyncMock = vi.hoisted(() => vi.fn()); +const execAsyncRemoteMock = vi.hoisted(() => vi.fn()); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: execAsyncMock, + execAsyncRemote: execAsyncRemoteMock, +})); + import { addDomainToCompose, addDomainToComposeForWebServer, @@ -16,6 +24,8 @@ import { createDomainLabels, isDokployGeneratedTraefikLabel, paths, + readCaddyRouteFragments, + refreshCaddyComposeRoutes, } from "@dokploy/server"; import { beforeEach, describe, expect, test, vi } from "vitest"; @@ -77,6 +87,13 @@ const getServers = (config: ReturnType) => { beforeEach(() => { vol.reset(); + execAsyncMock.mockReset(); + execAsyncRemoteMock.mockReset(); + execAsyncMock.mockResolvedValue({ stdout: "dokploy-caddy\n", stderr: "" }); + execAsyncRemoteMock.mockResolvedValue({ + stdout: "dokploy-caddy\n", + stderr: "", + }); }); describe("Caddy compose route generation", () => { @@ -112,6 +129,44 @@ describe("Caddy compose route generation", () => { }); }); + test("refreshes Caddy compose route fragments for domains created outside the domain router", async () => { + const composeInput = compose(); + const routeDomain = domain({ https: true }); + writeComposeFile(composeInput, { + services: { + web: { image: "nginx" }, + }, + }); + + await refreshCaddyComposeRoutes(composeInput, [routeDomain], "caddy"); + + const fragments = await readCaddyRouteFragments(); + expect(fragments).toHaveLength(1); + expect(fragments[0]).toMatchObject({ + id: "compose.my-compose.3", + source: "dokploy-compose", + }); + const config = compileCaddyConfig({ fragments }); + expect(getServers(config).https.routes[0].handle.at(-1)).toMatchObject({ + handler: "reverse_proxy", + upstreams: [{ dial: "my-compose-web:8080" }], + }); + }); + + test("skips compose route refresh for Traefik provider", async () => { + const composeInput = compose(); + writeComposeFile(composeInput, { + services: { + web: { image: "nginx" }, + }, + }); + + await refreshCaddyComposeRoutes(composeInput, [domain()], "traefik"); + + expect(await readCaddyRouteFragments()).toEqual([]); + expect(execAsyncMock).not.toHaveBeenCalled(); + }); + test("Traefik provider conversion delegates to the existing Traefik label path", async () => { const composeInput = compose(); writeComposeFile(composeInput, { diff --git a/apps/dokploy/__test__/caddy/config.test.ts b/apps/dokploy/__test__/caddy/config.test.ts index f163b40502..8dc8b61856 100644 --- a/apps/dokploy/__test__/caddy/config.test.ts +++ b/apps/dokploy/__test__/caddy/config.test.ts @@ -17,13 +17,17 @@ import { type ApplicationNested, type CaddyRouteFragment, type CaddyRouteIntent, + CLOUDFLARE_TRUSTED_PROXY_RANGES, + caddyTrustedProxySettingsToConfig, compileAndWriteCaddyConfig, compileCaddyConfig, type Domain, getCaddyMigrationArtifactPaths, manageCaddyDomain, + normalizeCaddyTrustedProxySettings, paths, readCaddyRouteFragments, + removeCaddyDomain, validateCaddyConfigFileWithImage, writeCaddyRouteFragment, } from "@dokploy/server"; @@ -76,6 +80,96 @@ test("compiles explicit http and https servers with managed HTTPS redirect", () expect((config.apps as any).tls.automation.policies[0].issuers[0].email).toBe( "ops@example.com", ); + expect(servers.http.trusted_proxies).toBeUndefined(); + expect(servers.https.trusted_proxies).toBeUndefined(); + expect(servers.http.client_ip_headers).toBeUndefined(); + expect(servers.https.client_ip_headers).toBeUndefined(); +}); + +test("compiles Cloudflare trusted proxy settings with safe client IP headers", () => { + const config = compileCaddyConfig({ + routes: [route({ https: true })], + trustedProxies: { + source: "cloudflare", + }, + }); + + const servers = getServers(config); + for (const server of [servers.http, servers.https]) { + expect(server.trusted_proxies).toEqual({ + source: "static", + ranges: [...CLOUDFLARE_TRUSTED_PROXY_RANGES], + }); + expect(server.client_ip_headers).toEqual([ + "CF-Connecting-IP", + "X-Forwarded-For", + ]); + expect(server.trusted_proxies_strict).toBe(1); + } +}); + +test("compiles custom static trusted proxy ranges", () => { + const config = compileCaddyConfig({ + routes: [route()], + trustedProxies: { + source: "static", + ranges: ["192.0.2.0/24", "2001:db8::/32"], + clientIpHeaders: ["X-Forwarded-For"], + strict: false, + }, + }); + + const servers = getServers(config); + expect(servers.http).toMatchObject({ + trusted_proxies: { + source: "static", + ranges: ["192.0.2.0/24", "2001:db8::/32"], + }, + client_ip_headers: ["X-Forwarded-For"], + }); + expect(servers.http.trusted_proxies_strict).toBeUndefined(); + expect(servers.https.trusted_proxies).toEqual(servers.http.trusted_proxies); +}); + +test("normalizes persisted Caddy trusted proxy settings", () => { + const settings = normalizeCaddyTrustedProxySettings({ + mode: "static", + ranges: [" 192.0.2.0/24 ", "192.0.2.0/24"], + clientIpHeaders: [" X-Forwarded-For ", ""], + strict: null, + }); + + expect(settings).toEqual({ + mode: "static", + ranges: ["192.0.2.0/24"], + clientIpHeaders: ["X-Forwarded-For"], + strict: true, + }); + expect(caddyTrustedProxySettingsToConfig(settings)).toEqual({ + source: "static", + ranges: ["192.0.2.0/24"], + clientIpHeaders: ["X-Forwarded-For"], + strict: true, + }); + expect(normalizeCaddyTrustedProxySettings({ mode: "disabled" })).toBeNull(); +}); + +test("rejects invalid trusted proxy settings before writing Caddy config", () => { + expect(() => + compileCaddyConfig({ + trustedProxies: { source: "static", ranges: ["192.0.2.0/33"] }, + }), + ).toThrow("Invalid Caddy trusted proxy prefix"); + + expect(() => + compileCaddyConfig({ + trustedProxies: { + source: "static", + ranges: ["192.0.2.0/24"], + clientIpHeaders: ["X-Forwarded-For", "x-forwarded-for"], + }, + }), + ).toThrow("Duplicate Caddy trusted proxy client IP header"); }); test("sorts routes by priority before path specificity and stable id", () => { @@ -305,6 +399,9 @@ test("validates a config file with the Caddy binary in an isolated runtime conta expect(validateCommand).toContain( "/etc/dokploy/caddy/migrations/test/.validate-runtime/config\\:/config", ); + expect(validateCommand).toContain( + `${paths().CERTIFICATES_PATH}\\:${paths().CERTIFICATES_PATH}\\:ro`, + ); expect(validateCommand).toContain(" caddy validate --config"); expect(validateCommand).not.toContain("caddy\\:2.11.3 validate --config"); }); @@ -352,3 +449,28 @@ test("restores previous fragments when Caddy domain reload fails", async () => { expect(await readCaddyRouteFragments()).toEqual([existingFragment]); }); + +test("restores removed fragments when Caddy domain removal reload fails", async () => { + const existingFragment: CaddyRouteFragment = { + version: 1, + id: "application.my-app.7", + source: "dokploy-application", + routes: [route({ id: "existing", hosts: ["old.example.com"] })], + }; + await writeCaddyRouteFragment(existingFragment); + execAsyncMock.mockImplementation(async (command: string) => { + if (command.includes("caddy validate")) { + throw new Error("validation failed"); + } + return { stdout: "dokploy-caddy\n", stderr: "" }; + }); + + await expect( + removeCaddyDomain( + { appName: "my-app", serverId: null } as ApplicationNested, + 7, + ), + ).rejects.toThrow("validation failed"); + + expect(await readCaddyRouteFragments()).toEqual([existingFragment]); +}); diff --git a/apps/dokploy/__test__/caddy/dashboard-route.test.ts b/apps/dokploy/__test__/caddy/dashboard-route.test.ts index b4669b930a..2eb3f65670 100644 --- a/apps/dokploy/__test__/caddy/dashboard-route.test.ts +++ b/apps/dokploy/__test__/caddy/dashboard-route.test.ts @@ -11,6 +11,7 @@ const settings = (overrides: Partial = {}) => ({ id: "settings-1", webServerProvider: "caddy", + caddyTrustedProxyConfig: null, https: false, certificateType: "none", host: null, diff --git a/apps/dokploy/__test__/caddy/domain-router-lifecycle.test.ts b/apps/dokploy/__test__/caddy/domain-router-lifecycle.test.ts new file mode 100644 index 0000000000..a8bec8702c --- /dev/null +++ b/apps/dokploy/__test__/caddy/domain-router-lifecycle.test.ts @@ -0,0 +1,254 @@ +import { beforeEach, expect, test, vi } from "vitest"; + +vi.mock("@dokploy/server", () => ({ + assertCaddyDomainSupported: vi.fn(), + createComposeDomain: vi.fn(), + createDomain: vi.fn(), + findApplicationById: vi.fn(), + findComposeById: vi.fn(), + findDomainById: vi.fn(), + findDomainsByApplicationId: vi.fn(), + findDomainsByComposeId: vi.fn(), + findPreviewDeploymentById: vi.fn(), + findServerById: vi.fn(), + generateTraefikMeDomain: vi.fn(), + getWebServerSettings: vi.fn(), + manageWebServerDomain: vi.fn(), + refreshCaddyComposeRoutes: vi.fn(), + removeDomainById: vi.fn(), + removeWebServerDomain: vi.fn(), + resolveWebServerProvider: vi.fn(), + updateDomainById: vi.fn(), + validateDomain: vi.fn(), +})); + +vi.mock("@dokploy/server/services/permission", () => ({ + checkServicePermissionAndAccess: vi.fn(), +})); + +vi.mock("@/server/api/utils/audit", () => ({ + audit: vi.fn(), +})); + +import { + findApplicationById, + findComposeById, + findDomainById, + findDomainsByComposeId, + manageWebServerDomain, + refreshCaddyComposeRoutes, + removeDomainById, + removeWebServerDomain, + resolveWebServerProvider, + updateDomainById, +} from "@dokploy/server"; +import { domainRouter } from "@/server/api/routers/domain"; + +const application = { + applicationId: "app-1", + appName: "my-app", + serverId: null, +}; + +const currentDomain = { + domainId: "domain-1", + applicationId: "app-1", + composeId: null, + previewDeploymentId: null, + domainType: "application", + host: "old.example.com", + path: "/", + internalPath: "/", + stripPath: false, + https: true, + certificateType: "letsencrypt", + customCertResolver: null, + customEntrypoint: null, + middlewares: null, + port: 3000, + serviceName: null, + uniqueConfigKey: 7, + createdAt: "", +}; + +const compose = { + composeId: "compose-1", + appName: "my-compose", + serverId: null, +}; + +const currentComposeDomain = { + ...currentDomain, + applicationId: null, + composeId: "compose-1", + domainType: "compose" as const, + serviceName: "web", +}; + +const siblingComposeDomain = { + ...currentComposeDomain, + domainId: "domain-2", + host: "sibling.example.com", + uniqueConfigKey: 8, +}; + +const updateInput = { + domainId: "domain-1", + domainType: "application" as const, + host: "new.example.com", + path: "/", + internalPath: "/", + stripPath: false, + https: true, + certificateType: "letsencrypt" as const, + customCertResolver: null, + customEntrypoint: null, + middlewares: null, + port: 3000, + serviceName: null, +}; + +const caller = domainRouter.createCaller({ + session: { + userId: "user-1", + activeOrganizationId: "org-1", + }, + user: { + id: "user-1", + role: "owner", + ownerId: "user-1", + email: "owner@example.com", + enableEnterpriseFeatures: true, + isValidEnterpriseLicense: true, + }, + req: { headers: {} }, + res: {}, +} as never); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(findDomainById).mockResolvedValue(currentDomain as never); + vi.mocked(findApplicationById).mockResolvedValue(application as never); + vi.mocked(findComposeById).mockResolvedValue(compose as never); + vi.mocked(findDomainsByComposeId).mockResolvedValue([ + currentComposeDomain, + siblingComposeDomain, + ] as never); + vi.mocked(resolveWebServerProvider).mockResolvedValue("caddy"); + vi.mocked(manageWebServerDomain).mockResolvedValue(undefined as never); + vi.mocked(removeWebServerDomain).mockResolvedValue(undefined as never); + vi.mocked(refreshCaddyComposeRoutes).mockResolvedValue(undefined as never); +}); + +test("restores the previous Caddy application route when domain update persistence fails", async () => { + vi.mocked(updateDomainById).mockRejectedValueOnce( + new Error("db update failed") as never, + ); + + await expect(caller.update(updateInput)).rejects.toThrow("db update failed"); + + expect(manageWebServerDomain).toHaveBeenNthCalledWith( + 1, + application, + expect.objectContaining({ + domainId: "domain-1", + host: "new.example.com", + }), + ); + expect(manageWebServerDomain).toHaveBeenNthCalledWith( + 2, + application, + currentDomain, + ); +}); + +test("restores the removed Caddy application route when domain delete persistence fails", async () => { + vi.mocked(removeDomainById).mockRejectedValueOnce( + new Error("db delete failed") as never, + ); + + await expect(caller.delete({ domainId: "domain-1" })).rejects.toThrow( + "db delete failed", + ); + + expect(removeWebServerDomain).toHaveBeenCalledWith(application, 7); + expect(manageWebServerDomain).toHaveBeenCalledWith( + application, + currentDomain, + ); +}); + +test("restores previous compose domain fields when Caddy route refresh fails after update", async () => { + const updatedDomain = { + ...currentComposeDomain, + host: "new.example.com", + }; + vi.mocked(findDomainById).mockResolvedValueOnce( + currentComposeDomain as never, + ); + vi.mocked(updateDomainById) + .mockResolvedValueOnce(updatedDomain as never) + .mockResolvedValueOnce(currentComposeDomain as never); + vi.mocked(refreshCaddyComposeRoutes) + .mockRejectedValueOnce(new Error("caddy refresh failed") as never) + .mockResolvedValueOnce(undefined as never); + + await expect( + caller.update({ + ...updateInput, + domainType: "compose", + serviceName: "web", + }), + ).rejects.toThrow("caddy refresh failed"); + + expect(updateDomainById).toHaveBeenNthCalledWith( + 1, + "domain-1", + expect.objectContaining({ + host: "new.example.com", + domainType: "compose", + serviceName: "web", + }), + ); + expect(updateDomainById).toHaveBeenNthCalledWith( + 2, + "domain-1", + expect.objectContaining({ + host: "old.example.com", + domainType: "compose", + serviceName: "web", + }), + ); + expect(refreshCaddyComposeRoutes).toHaveBeenNthCalledWith( + 2, + compose, + undefined, + "caddy", + ); +}); + +test("restores all compose routes when compose domain delete persistence fails", async () => { + vi.mocked(findDomainById).mockResolvedValueOnce( + currentComposeDomain as never, + ); + vi.mocked(removeDomainById).mockRejectedValueOnce( + new Error("db delete failed") as never, + ); + + await expect(caller.delete({ domainId: "domain-1" })).rejects.toThrow( + "db delete failed", + ); + + expect(refreshCaddyComposeRoutes).toHaveBeenNthCalledWith( + 1, + compose, + [siblingComposeDomain], + "caddy", + ); + expect(refreshCaddyComposeRoutes).toHaveBeenNthCalledWith( + 2, + compose, + undefined, + "caddy", + ); +}); diff --git a/apps/dokploy/__test__/caddy/migration/apply-rollback.test.ts b/apps/dokploy/__test__/caddy/migration/apply-rollback.test.ts index 753ef76f90..3607a5e2c8 100644 --- a/apps/dokploy/__test__/caddy/migration/apply-rollback.test.ts +++ b/apps/dokploy/__test__/caddy/migration/apply-rollback.test.ts @@ -18,6 +18,7 @@ vi.mock("@dokploy/server/services/settings", () => ({ })); vi.mock("@dokploy/server/services/web-server-settings", () => ({ + getCaddyCompileSettings: vi.fn(), resolveWebServerProvider: vi.fn(), updateLocalWebServerProvider: vi.fn(), updateRemoteWebServerProvider: vi.fn(), @@ -111,6 +112,9 @@ describe("applyCaddyMigration", () => { vi.mocked(providerService.resolveWebServerProvider).mockResolvedValue( "traefik", ); + vi.mocked(providerService.getCaddyCompileSettings).mockResolvedValue({ + trustedProxies: null, + }); vi.mocked(settingsService.getDockerResourceSnapshot).mockImplementation( async (resourceName: string) => ({ resourceName, diff --git a/apps/dokploy/__test__/caddy/migration/prepare.test.ts b/apps/dokploy/__test__/caddy/migration/prepare.test.ts index 42d736d254..1bffb710d8 100644 --- a/apps/dokploy/__test__/caddy/migration/prepare.test.ts +++ b/apps/dokploy/__test__/caddy/migration/prepare.test.ts @@ -16,6 +16,10 @@ vi.mock("@dokploy/server/db", () => ({ })); vi.mock("@dokploy/server/services/web-server-settings", () => ({ + getCaddyCompileSettings: vi.fn().mockResolvedValue({ + letsEncryptEmail: "ops@example.com", + trustedProxies: null, + }), getWebServerSettings: vi.fn().mockResolvedValue({ letsEncryptEmail: "ops@example.com", }), diff --git a/apps/dokploy/__test__/caddy/preview-deployment.test.ts b/apps/dokploy/__test__/caddy/preview-deployment.test.ts new file mode 100644 index 0000000000..6ac708ea1e --- /dev/null +++ b/apps/dokploy/__test__/caddy/preview-deployment.test.ts @@ -0,0 +1,275 @@ +import { beforeEach, expect, test, vi } from "vitest"; + +const previewDeploymentRow = { + previewDeploymentId: "preview-1", + applicationId: "app-1", + appName: "preview-my-app-fixedpw", + pullRequestCommentId: "comment-1", + domainId: null, +}; + +const insertedPreviewDeployment = { ...previewDeploymentRow }; + +const dbMock = vi.hoisted(() => ({ + query: { + organization: { findFirst: vi.fn() }, + previewDeployments: { findFirst: vi.fn() }, + }, + insert: vi.fn(), + update: vi.fn(), + delete: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => ({ + db: dbMock, +})); + +vi.mock("@dokploy/server/templates", () => ({ + generatePassword: vi.fn(() => "fixedpw"), +})); + +vi.mock("@dokploy/server/services/application", () => ({ + findApplicationById: vi.fn(), +})); + +vi.mock("@dokploy/server/services/deployment", () => ({ + removeDeploymentsByPreviewDeploymentId: vi.fn(), +})); + +vi.mock("@dokploy/server/services/domain", () => ({ + createDomain: vi.fn(), + removeDomainById: vi.fn(), +})); + +vi.mock("@dokploy/server/services/github", () => ({ + getIssueComment: vi.fn(() => "preview comment"), +})); + +vi.mock("@dokploy/server/services/web-server-settings", () => ({ + getWebServerSettings: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/docker/utils", () => ({ + removeService: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/filesystem/directory", () => ({ + removeDirectoryCode: vi.fn(), +})); + +const createCommentMock = vi.hoisted(() => vi.fn()); +const deleteCommentMock = vi.hoisted(() => vi.fn()); + +vi.mock("@dokploy/server/utils/providers/github", () => ({ + authGithub: vi.fn(() => ({ + rest: { + issues: { + createComment: createCommentMock, + deleteComment: deleteCommentMock, + }, + }, + })), +})); + +vi.mock("@dokploy/server/utils/traefik/application", () => ({ + removeTraefikConfig: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/web-server/domain", () => ({ + manageWebServerDomain: vi.fn(), + removeWebServerDomain: vi.fn(), +})); + +import { findApplicationById } from "@dokploy/server/services/application"; +import { + createDomain, + removeDomainById, +} from "@dokploy/server/services/domain"; +import { + createPreviewDeployment, + removePreviewDeployment, +} from "@dokploy/server/services/preview-deployment"; +import { + manageWebServerDomain, + removeWebServerDomain, +} from "@dokploy/server/utils/web-server/domain"; + +const createApplication = () => ({ + applicationId: "app-1", + appName: "my-app", + name: "My App", + owner: "dokploy", + repository: "dokploy", + github: {}, + serverId: null, + server: { + ipAddress: "192.0.2.10", + }, + previewWildcard: "*.sslip.io", + previewPath: "/", + previewPort: 3000, + previewHttps: true, + previewCertificateType: "letsencrypt", + previewCustomCertResolver: null, + environment: { + project: { + organizationId: "org-1", + }, + }, +}); + +beforeEach(() => { + vi.clearAllMocks(); + Object.assign(insertedPreviewDeployment, previewDeploymentRow); + dbMock.query.organization.findFirst.mockResolvedValue({ ownerId: "user-1" }); + createCommentMock.mockResolvedValue({ data: { id: 12345 } }); + dbMock.insert.mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([insertedPreviewDeployment]), + }), + }); + dbMock.update.mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([]), + }), + }); + dbMock.delete.mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([]), + }), + }); + vi.mocked(removeDomainById).mockResolvedValue(undefined as never); + vi.mocked(removeWebServerDomain).mockResolvedValue(undefined as never); + deleteCommentMock.mockResolvedValue(undefined); +}); + +test("creates preview domains through the active web server provider", async () => { + const application = createApplication(); + const domain = { + domainId: "domain-1", + host: "preview-my-app-fixedpw-192-0-2-10.sslip.io", + uniqueConfigKey: 17, + }; + vi.mocked(findApplicationById).mockResolvedValue(application as never); + vi.mocked(createDomain).mockResolvedValue(domain as never); + + await createPreviewDeployment({ + applicationId: "app-1", + pullRequestId: "pr-1", + pullRequestNumber: "42", + branch: "feature/caddy-preview", + } as never); + + expect(createDomain).toHaveBeenCalledWith( + expect.objectContaining({ + host: "preview-my-app-fixedpw-192-0-2-10.sslip.io", + path: "/", + port: 3000, + https: true, + certificateType: "letsencrypt", + domainType: "preview", + previewDeploymentId: "preview-1", + }), + ); + expect(manageWebServerDomain).toHaveBeenCalledWith( + expect.objectContaining({ + applicationId: "app-1", + appName: "preview-my-app-fixedpw", + }), + domain, + ); +}); + +test("cleans up preview domain state when provider route creation fails", async () => { + const application = createApplication(); + const domain = { + domainId: "domain-1", + host: "preview-my-app-fixedpw-192-0-2-10.sslip.io", + uniqueConfigKey: 17, + }; + vi.mocked(findApplicationById).mockResolvedValue(application as never); + vi.mocked(createDomain).mockResolvedValue(domain as never); + vi.mocked(manageWebServerDomain).mockRejectedValueOnce( + new Error("caddy reload failed") as never, + ); + + await expect( + createPreviewDeployment({ + applicationId: "app-1", + pullRequestId: "pr-1", + pullRequestNumber: "42", + branch: "feature/caddy-preview", + } as never), + ).rejects.toThrow("caddy reload failed"); + + expect(removeDomainById).toHaveBeenCalledWith("domain-1"); + expect(removeWebServerDomain).not.toHaveBeenCalled(); + expect(dbMock.delete).toHaveBeenCalled(); + expect(deleteCommentMock).toHaveBeenCalledWith( + expect.objectContaining({ + owner: "dokploy", + repo: "dokploy", + comment_id: 12345, + }), + ); +}); + +test("removes preview routes when preview domain linking fails after route creation", async () => { + const application = createApplication(); + const domain = { + domainId: "domain-1", + host: "preview-my-app-fixedpw-192-0-2-10.sslip.io", + uniqueConfigKey: 17, + }; + vi.mocked(findApplicationById).mockResolvedValue(application as never); + vi.mocked(createDomain).mockResolvedValue(domain as never); + dbMock.update.mockReturnValueOnce({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockRejectedValue(new Error("db update failed")), + }), + }); + + await expect( + createPreviewDeployment({ + applicationId: "app-1", + pullRequestId: "pr-1", + pullRequestNumber: "42", + branch: "feature/caddy-preview", + } as never), + ).rejects.toThrow("db update failed"); + + expect(removeWebServerDomain).toHaveBeenCalledWith( + expect.objectContaining({ + appName: "preview-my-app-fixedpw", + }), + 17, + ); + expect(removeDomainById).toHaveBeenCalledWith("domain-1"); + expect(dbMock.delete).toHaveBeenCalled(); +}); + +test("removes preview domains through the active web server provider", async () => { + const application = createApplication(); + vi.mocked(findApplicationById).mockResolvedValue(application as never); + dbMock.query.previewDeployments.findFirst.mockResolvedValue({ + ...previewDeploymentRow, + domain: { + domainId: "domain-1", + uniqueConfigKey: 17, + }, + application: { + applicationId: "app-1", + serverId: null, + }, + }); + + await removePreviewDeployment("preview-1"); + + expect(removeWebServerDomain).toHaveBeenCalledWith( + expect.objectContaining({ + applicationId: "app-1", + appName: "preview-my-app-fixedpw", + }), + 17, + ); +}); diff --git a/apps/dokploy/__test__/caddy/setup.test.ts b/apps/dokploy/__test__/caddy/setup.test.ts index 21d28a7332..d336d3bbb4 100644 --- a/apps/dokploy/__test__/caddy/setup.test.ts +++ b/apps/dokploy/__test__/caddy/setup.test.ts @@ -64,6 +64,7 @@ describe("Caddy runtime setup", () => { HostConfig: expect.objectContaining({ Binds: expect.arrayContaining([ expect.stringMatching(/\/caddy:\/etc\/caddy$/), + expect.stringMatching(/\/certificates:.*\/certificates:ro$/), ]), }), }), 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 e934820bda..85beddfc8a 100644 --- a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts +++ b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts @@ -19,6 +19,7 @@ type WebServerSettings = typeof webServerSettings.$inferSelect; const baseSettings: WebServerSettings = { id: "", webServerProvider: "traefik", + caddyTrustedProxyConfig: null, https: false, certificateType: "none", host: null, diff --git a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx index 564f4fd182..cfcd00ee0e 100644 --- a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx +++ b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx @@ -185,6 +185,15 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { ); const isCaddyProvider = activeProvider === "caddy"; + const { data: certificates } = api.certificates.all.useQuery(undefined, { + enabled: isOpen && isCaddyProvider, + }); + const caddyCertificates = (certificates ?? []).filter((certificate) => + application?.serverId + ? certificate.serverId === application.serverId + : !certificate.serverId, + ); + const { data: canGenerateTraefikMeDomains } = api.domain.canGenerateTraefikMeDomains.useQuery({ serverId: application?.serverId || "", @@ -291,13 +300,6 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { }; const onSubmit = async (data: Domain) => { - if (isCaddyProvider && data.certificateType === "custom") { - toast.error( - "Caddy does not support Traefik custom certificate resolvers. Choose Let's Encrypt or None.", - ); - return; - } - await mutateAsync({ domainId, ...(data.domainType === "application" && { @@ -360,9 +362,9 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { {isCaddyProvider && ( This server uses Caddy. Dokploy will generate Caddy route fragments, - Caddy will manage HTTPS certificates for public DNS names, and - Traefik-only custom entrypoints, middleware references, and custom - certificate resolvers are hidden. + Caddy can manage HTTPS certificates for public DNS names or load an + uploaded certificate, and Traefik-only custom entrypoints and + middleware references are hidden. )} @@ -795,11 +797,11 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { ? "Caddy-managed HTTPS (ACME)" : "Let's Encrypt"} - {!isCaddyProvider && ( - - Custom - - )} + + {isCaddyProvider + ? "Uploaded certificate" + : "Custom"} + @@ -809,11 +811,48 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { /> {isCaddyProvider && certificateType === "custom" && ( - - This domain uses a Traefik custom certificate resolver. - Caddy does not use resolver names; choose Caddy-managed - HTTPS or None before saving. - + { + return ( + + Uploaded Certificate + {caddyCertificates.length > 0 ? ( + + ) : ( + + Add an uploaded certificate for this server + before selecting custom HTTPS. + + )} + + + ); + }} + /> )} {!isCaddyProvider && certificateType === "custom" && ( diff --git a/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx b/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx index c69451c895..d158b381bc 100644 --- a/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx +++ b/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx @@ -46,14 +46,13 @@ export const ShowCertificates = () => { Certificates - Create certificates in the Traefik directory + Create uploaded certificates for web server domains - Certificates are created in the Traefik directory. Traefik uses - these certificates to secure your applications. Using invalid - certificates can break your Traefik instance, preventing access to - your applications. + Uploaded certificates can be used by supported web server + providers to secure your applications. Invalid certificates can + break domain access for the services that use them. diff --git a/apps/dokploy/components/dashboard/settings/servers/actions/show-traefik-actions.tsx b/apps/dokploy/components/dashboard/settings/servers/actions/show-traefik-actions.tsx index 9a1d3c26a4..d0ff8c0e2a 100644 --- a/apps/dokploy/components/dashboard/settings/servers/actions/show-traefik-actions.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/actions/show-traefik-actions.tsx @@ -13,6 +13,7 @@ import { } from "@/components/ui/dropdown-menu"; import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation"; import { api } from "@/utils/api"; +import { CaddyTrustedProxySettings } from "../../web-server/caddy-trusted-proxy-settings"; import { EditTraefikEnv } from "../../web-server/edit-traefik-env"; import { ManageTraefikPorts } from "../../web-server/manage-traefik-ports"; import { ShowModalLogs } from "../../web-server/show-modal-logs"; @@ -130,6 +131,17 @@ export const ShowTraefikActions = ({ serverId }: Props) => { + {activeProvider === "caddy" && ( + + e.preventDefault()} + className="cursor-pointer" + > + Trusted Proxies + + + )} + {activeProvider === "traefik" && ( { +
diff --git a/apps/dokploy/components/dashboard/settings/web-server/caddy-trusted-proxy-settings.tsx b/apps/dokploy/components/dashboard/settings/web-server/caddy-trusted-proxy-settings.tsx new file mode 100644 index 0000000000..b1d8ab0a41 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/web-server/caddy-trusted-proxy-settings.tsx @@ -0,0 +1,214 @@ +import { Cloud, ShieldCheck } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { api } from "@/utils/api"; + +type TrustedProxyMode = "disabled" | "cloudflare" | "static"; + +interface Props { + serverId?: string; + children?: React.ReactNode; +} + +const splitList = (value: string) => + Array.from( + new Set( + value + .split(/[\n,]+/) + .map((item) => item.trim()) + .filter((item) => item.length > 0), + ), + ); + +const joinList = (values?: string[] | null) => (values ?? []).join("\n"); + +const modeLabel = (mode?: TrustedProxyMode) => { + switch (mode) { + case "cloudflare": + return "Cloudflare"; + case "static": + return "Static CIDRs"; + default: + return "Disabled"; + } +}; + +export const CaddyTrustedProxySettings = ({ serverId, children }: Props) => { + const [isOpen, setIsOpen] = useState(false); + const [mode, setMode] = useState("disabled"); + const [ranges, setRanges] = useState(""); + const [headers, setHeaders] = useState(""); + const [strict, setStrict] = useState(true); + + const { data: settings, refetch } = + api.settings.getCaddyTrustedProxySettings.useQuery({ serverId }); + const { mutateAsync: updateSettings, isPending } = + api.settings.updateCaddyTrustedProxySettings.useMutation(); + + useEffect(() => { + if (!settings) return; + setMode(settings.mode); + setRanges(joinList(settings.ranges)); + setHeaders(joinList(settings.clientIpHeaders)); + setStrict(settings.strict !== false); + }, [settings]); + + const save = async () => { + try { + await updateSettings({ + serverId, + mode, + ranges: mode === "static" ? splitList(ranges) : [], + clientIpHeaders: splitList(headers), + strict, + }); + await refetch(); + toast.success("Caddy trusted proxy settings updated"); + setIsOpen(false); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Error updating Caddy trusted proxy settings", + ); + } + }; + + const trigger = children ?? ( + + ); + + return ( + + {trigger} + + + Caddy trusted proxies + + Configure which proxy IPs Caddy trusts for client IP headers. + + + +
+
+ + +
+ + {mode === "cloudflare" && ( + +
+ + + Caddy will trust Cloudflare IP ranges and use CF-Connecting-IP + before X-Forwarded-For. + +
+
+ )} + + {mode === "static" && ( +
+ +