diff --git a/apps/dokploy/__test__/setup/postgres-setup.test.ts b/apps/dokploy/__test__/setup/postgres-setup.test.ts new file mode 100644 index 00000000000..72610f23932 --- /dev/null +++ b/apps/dokploy/__test__/setup/postgres-setup.test.ts @@ -0,0 +1,147 @@ +import { initializePostgres } from "@dokploy/server/setup/postgres-setup"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type ServiceOptions = { + version?: number; + TaskTemplate?: { + ContainerSpec?: { + Image?: string; + Env?: string[]; + Mounts?: Array<{ Type?: string; Source?: string; Target?: string }>; + }; + }; +}; + +const { + getServiceMock, + inspectMock, + updateMock, + createServiceMock, + pullImageMock, +} = vi.hoisted(() => { + const inspect = vi.fn<() => Promise>(); + const update = vi.fn<(options: ServiceOptions) => Promise>( + async () => undefined, + ); + const createService = vi.fn<(options: ServiceOptions) => Promise>( + async () => undefined, + ); + const getService = vi.fn(() => ({ inspect, update })); + const pullImage = vi.fn<(image: string) => Promise>( + async () => undefined, + ); + return { + getServiceMock: getService, + inspectMock: inspect, + updateMock: update, + createServiceMock: createService, + pullImageMock: pullImage, + }; +}); + +vi.mock("@dokploy/server/constants", () => ({ + docker: { + getService: getServiceMock, + createService: createServiceMock, + }, +})); + +vi.mock("@dokploy/server/utils/docker/utils", () => ({ + pullImage: pullImageMock, +})); + +const serviceNotFound = () => + Object.assign(new Error("service not found"), { statusCode: 404 }); + +describe("initializePostgres", () => { + beforeEach(() => { + vi.clearAllMocks(); + pullImageMock.mockResolvedValue(undefined); + createServiceMock.mockResolvedValue(undefined); + inspectMock.mockRejectedValue(serviceNotFound()); + }); + + it("pulls postgres 18.6 and creates the service with PGDATA when none exists", async () => { + await initializePostgres(); + + expect(pullImageMock).toHaveBeenCalledWith("postgres:18.6"); + expect(updateMock).not.toHaveBeenCalled(); + expect(createServiceMock).toHaveBeenCalledTimes(1); + + const options = createServiceMock.mock.calls[0]?.[0]; + const spec = options?.TaskTemplate?.ContainerSpec; + expect(spec?.Image).toBe("postgres:18.6"); + expect(spec?.Env).toContain("PGDATA=/var/lib/postgresql/data"); + expect(spec?.Mounts).toEqual([ + expect.objectContaining({ Target: "/var/lib/postgresql/data" }), + ]); + }); + + it("creates the service from a local image when the pull fails", async () => { + pullImageMock.mockRejectedValue( + Object.assign(new Error("getaddrinfo ENOTFOUND registry.test"), { + code: "ENOTFOUND", + }), + ); + + await expect(initializePostgres()).resolves.toBeUndefined(); + + expect(pullImageMock).toHaveBeenCalledWith("postgres:18.6"); + expect(createServiceMock).toHaveBeenCalledTimes(1); + + const options = createServiceMock.mock.calls[0]?.[0]; + const spec = options?.TaskTemplate?.ContainerSpec; + expect(spec?.Image).toBe("postgres:18.6"); + expect(spec?.Env).toContain("PGDATA=/var/lib/postgresql/data"); + }); + + it("leaves an existing service unchanged, including its secrets and mounts", async () => { + inspectMock.mockResolvedValue({ + Version: { Index: 7 }, + Spec: { + TaskTemplate: { + ContainerSpec: { + Image: "postgres:16", + Env: ["POSTGRES_PASSWORD_FILE=/run/secrets/pg"], + Secrets: [{ SecretName: "pg", SecretID: "test-secret-id" }], + Mounts: [ + { + Type: "volume", + Source: "test-postgres-volume", + Target: "/var/lib/postgresql", + }, + ], + }, + }, + }, + }); + + await expect(initializePostgres()).resolves.toBeUndefined(); + + expect(updateMock).not.toHaveBeenCalled(); + expect(createServiceMock).not.toHaveBeenCalled(); + expect(pullImageMock).not.toHaveBeenCalled(); + }); + + it("rethrows an inspect error that is not a 404", async () => { + inspectMock.mockRejectedValue( + Object.assign(new Error("connection refused"), { statusCode: 500 }), + ); + + await expect(initializePostgres()).rejects.toThrow("connection refused"); + + expect(pullImageMock).not.toHaveBeenCalled(); + expect(createServiceMock).not.toHaveBeenCalled(); + }); + + it("resolves when createService reports the service already exists (409)", async () => { + createServiceMock.mockRejectedValueOnce( + Object.assign(new Error("service already exists"), { statusCode: 409 }), + ); + + await expect(initializePostgres()).resolves.toBeUndefined(); + + expect(pullImageMock).toHaveBeenCalledWith("postgres:18.6"); + expect(createServiceMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/server/src/setup/postgres-setup.ts b/packages/server/src/setup/postgres-setup.ts index ddf5a3d5fd7..9a05e075f7b 100644 --- a/packages/server/src/setup/postgres-setup.ts +++ b/packages/server/src/setup/postgres-setup.ts @@ -1,4 +1,4 @@ -import type { CreateServiceOptions } from "dockerode"; +import type { ContainerSpec, CreateServiceOptions } from "dockerode"; import { docker } from "../constants"; import { POSTGRES_DB, @@ -6,28 +6,33 @@ import { resolvePostgresPassword, } from "../db/constants"; import { pullImage } from "../utils/docker/utils"; +import { PINNED_VERSIONS } from "./component-versions"; export const initializePostgres = async () => { - const imageName = "postgres:16"; + const imageName = `postgres:${PINNED_VERSIONS.postgres}`; const containerName = "dokploy-postgres"; const postgresPassword = resolvePostgresPassword({ allowDatabaseUrl: true }); + const containerSpec: ContainerSpec = { + Image: imageName, + Env: [ + `POSTGRES_USER=${POSTGRES_USER}`, + `POSTGRES_DB=${POSTGRES_DB}`, + `POSTGRES_PASSWORD=${postgresPassword}`, + // The PostgreSQL 18 image otherwise keeps data in a version directory + // under /var/lib/postgresql; this keeps the mount path existing volumes use. + "PGDATA=/var/lib/postgresql/data", + ], + Mounts: [ + { + Type: "volume", + Source: "dokploy-postgres", + Target: "/var/lib/postgresql/data", + }, + ], + }; const settings: CreateServiceOptions = { Name: containerName, TaskTemplate: { - ContainerSpec: { - Image: imageName, - Env: [ - `POSTGRES_USER=${POSTGRES_USER}`, - `POSTGRES_DB=${POSTGRES_DB}`, - `POSTGRES_PASSWORD=${postgresPassword}`, - ], - Mounts: [ - { - Type: "volume", - Source: "dokploy-postgres", - Target: "/var/lib/postgresql/data", - }, - ], - }, + ContainerSpec: containerSpec, Networks: [{ Target: "dokploy-network" }], Placement: { Constraints: ["node.role==manager"], @@ -51,25 +56,35 @@ export const initializePostgres = async () => { }, }), }; + + const service = docker.getService(containerName); + const inspect = await service.inspect().catch((error: any) => { + if (error?.statusCode === 404) { + return undefined; + } + throw error; + }); + + if (inspect) { + console.log("Postgres service already exists, leaving it unchanged"); + return; + } + try { await pullImage(imageName); - - const service = docker.getService(containerName); - const inspect = await service.inspect(); - await service.update({ - version: Number.parseInt(inspect.Version.Index, 10), - ...settings, - }); - console.log("Postgres Started ✅"); } catch (_) { - try { - await docker.createService(settings); - } catch (error: any) { - if (error?.statusCode !== 409) { - throw error; - } - console.log("Postgres service already exists, continuing..."); + // Offline hosts can still run an image that is already present locally. + console.warn( + `Could not pull ${imageName}; a local copy will be used if one exists.`, + ); + } + try { + await docker.createService(settings); + } catch (error: any) { + if (error?.statusCode !== 409) { + throw error; } - console.log("Postgres Not Found: Starting ✅"); + console.log("Postgres service already exists, continuing..."); } + console.log("Postgres Not Found: Starting ✅"); };