Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions apps/dokploy/__test__/setup/postgres-setup.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>>();
const update = vi.fn<(options: ServiceOptions) => Promise<unknown>>(
async () => undefined,
);
const createService = vi.fn<(options: ServiceOptions) => Promise<unknown>>(
async () => undefined,
);
const getService = vi.fn(() => ({ inspect, update }));
const pullImage = vi.fn<(image: string) => Promise<void>>(
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);
});
});
81 changes: 48 additions & 33 deletions packages/server/src/setup/postgres-setup.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,38 @@
import type { CreateServiceOptions } from "dockerode";
import type { ContainerSpec, CreateServiceOptions } from "dockerode";
import { docker } from "../constants";
import {
POSTGRES_DB,
POSTGRES_USER,
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"],
Expand All @@ -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 ✅");
};