Compare commits

...

1 Commits

Author SHA1 Message Date
detail-app[bot] e535fd6c69 fix(server): route docker-source Swarm pull through configured registry 2026-09-04 02:46:57 +00:00
4 changed files with 615 additions and 12 deletions
@@ -0,0 +1,239 @@
import * as adminService from "@dokploy/server/services/admin";
import * as applicationService from "@dokploy/server/services/application";
import { rebuildApplication } from "@dokploy/server/services/application";
import * as deploymentService from "@dokploy/server/services/deployment";
import * as builders from "@dokploy/server/utils/builders";
import * as notifications from "@dokploy/server/utils/notifications/build-success";
import * as execProcess from "@dokploy/server/utils/process/execAsync";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@dokploy/server/db", () => {
const createChainableMock = (): any => {
const chain = {
set: vi.fn(() => chain),
where: vi.fn(() => chain),
returning: vi.fn().mockResolvedValue([{}] as any),
from: vi.fn(() => chain),
innerJoin: vi.fn(() => chain),
} as any;
return chain;
};
return {
db: {
select: vi.fn(() => createChainableMock()),
insert: vi.fn(),
update: vi.fn(() => createChainableMock()),
delete: vi.fn(),
query: {
applications: {
findFirst: vi.fn(),
},
patch: {
findMany: vi.fn().mockResolvedValue([]),
},
member: {
findMany: vi.fn().mockResolvedValue([]),
},
},
},
};
});
vi.mock("@dokploy/server/services/application", async () => {
const actual = await vi.importActual<
typeof import("@dokploy/server/services/application")
>("@dokploy/server/services/application");
return {
...actual,
findApplicationById: vi.fn(),
updateApplicationStatus: vi.fn(),
};
});
vi.mock("@dokploy/server/services/admin", () => ({
getDokployUrl: vi.fn(),
}));
vi.mock("@dokploy/server/services/deployment", () => ({
createDeployment: vi.fn(),
updateDeploymentStatus: vi.fn(),
updateDeployment: vi.fn(),
}));
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
execAsync: vi.fn(),
ExecError: class ExecError extends Error {},
}));
vi.mock("@dokploy/server/utils/builders", async () => {
const actual = await vi.importActual<
typeof import("@dokploy/server/utils/builders")
>("@dokploy/server/utils/builders");
return {
...actual,
mechanizeDockerContainer: vi.fn(),
getBuildCommand: vi.fn(),
};
});
vi.mock("@dokploy/server/utils/notifications/build-success", () => ({
sendBuildSuccessNotifications: vi.fn(),
}));
vi.mock("@dokploy/server/utils/notifications/build-error", () => ({
sendBuildErrorNotifications: vi.fn(),
}));
vi.mock("@dokploy/server/services/rollbacks", () => ({
createRollback: vi.fn(),
}));
import { db } from "@dokploy/server/db";
const createMockApplication = (overrides = {}) => ({
applicationId: "test-app-id",
name: "Test App",
appName: "test-app",
sourceType: "docker",
dockerImage: "nginx:latest",
username: null,
password: null,
registryUrl: null,
registry: null,
buildRegistry: null,
rollbackRegistry: null,
rollbackActive: false,
serverId: null,
buildServerId: null,
buildType: "nixpacks",
env: null,
environmentId: "env-id",
environment: {
projectId: "project-id",
env: "",
name: "production",
project: {
name: "Test Project",
organizationId: "org-id",
env: "",
},
},
domains: [],
...overrides,
});
const createMockDeployment = () => ({
deploymentId: "deployment-id",
logPath: "/tmp/test-rebuild.log",
});
describe("rebuildApplication - docker image cache seeding", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
createMockApplication() as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
createMockApplication() as any,
);
vi.mocked(adminService.getDokployUrl).mockResolvedValue(
"http://localhost:3000",
);
vi.mocked(deploymentService.createDeployment).mockResolvedValue(
createMockDeployment() as any,
);
vi.mocked(execProcess.execAsync).mockResolvedValue({
stdout: "",
stderr: "",
} as any);
vi.mocked(builders.mechanizeDockerContainer).mockResolvedValue(
undefined as any,
);
vi.mocked(deploymentService.updateDeploymentStatus).mockResolvedValue(
undefined as any,
);
vi.mocked(applicationService.updateApplicationStatus).mockResolvedValue(
{} as any,
);
vi.mocked(notifications.sendBuildSuccessNotifications).mockResolvedValue(
undefined as any,
);
});
it("prepends `docker pull <dockerImage>` for docker source before the build command", async () => {
vi.mocked(builders.getBuildCommand).mockResolvedValue("#BUILD-COMMAND#");
await rebuildApplication({
applicationId: "test-app-id",
titleLog: "Rebuild",
descriptionLog: "",
});
expect(execProcess.execAsync).toHaveBeenCalledTimes(1);
const fullCommand = vi.mocked(execProcess.execAsync).mock
.calls[0]?.[0] as string;
expect(fullCommand).toContain("set -e");
expect(fullCommand).toContain("docker pull");
expect(fullCommand).toContain("nginx:latest");
const pullIndex = fullCommand.indexOf("docker pull");
const buildIndex = fullCommand.indexOf("#BUILD-COMMAND#");
expect(pullIndex).toBeGreaterThan(-1);
expect(buildIndex).toBeGreaterThan(-1);
expect(pullIndex).toBeLessThan(buildIndex);
});
it("logs the docker-login step before pulling when app-level credentials are set", async () => {
const app = createMockApplication({
dockerImage: "private.example.com/myrepo:v1",
username: "app-user",
password: "app-secret",
registryUrl: "private.example.com",
});
vi.mocked(db.query.applications.findFirst).mockResolvedValue(app as any);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
app as any,
);
vi.mocked(builders.getBuildCommand).mockResolvedValue("#BUILD-COMMAND#");
await rebuildApplication({
applicationId: "test-app-id",
titleLog: "Rebuild",
descriptionLog: "",
});
const fullCommand = vi.mocked(execProcess.execAsync).mock
.calls[0]?.[0] as string;
expect(fullCommand).toContain("docker login");
expect(fullCommand).toContain("private.example.com/myrepo:v1");
expect(fullCommand).toContain("docker pull");
const loginIndex = fullCommand.indexOf("docker login");
const pullIndex = fullCommand.indexOf("docker pull");
expect(loginIndex).toBeLessThan(pullIndex);
});
it("does not prepend `docker pull` for non-docker source rebuilds", async () => {
const app = createMockApplication({ sourceType: "github" });
vi.mocked(db.query.applications.findFirst).mockResolvedValue(app as any);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
app as any,
);
vi.mocked(builders.getBuildCommand).mockResolvedValue("#BUILD-COMMAND#");
await rebuildApplication({
applicationId: "test-app-id",
titleLog: "Rebuild",
descriptionLog: "",
});
const fullCommand = vi.mocked(execProcess.execAsync).mock
.calls[0]?.[0] as string;
expect(fullCommand).not.toContain("docker pull");
expect(fullCommand).toContain("#BUILD-COMMAND#");
});
});
@@ -0,0 +1,361 @@
import type { Registry } from "@dokploy/server/services/registry";
import { findRegistryByIdWithCredentials } from "@dokploy/server/services/registry";
import {
type ApplicationNested,
getAuthConfig,
getImageName,
} from "@dokploy/server/utils/builders";
import {
getRegistryTag,
uploadImageRemoteCommand,
} from "@dokploy/server/utils/cluster/upload";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@dokploy/server/services/registry", async () => {
const actual = await vi.importActual<
typeof import("@dokploy/server/services/registry")
>("@dokploy/server/services/registry");
return {
...actual,
findRegistryByIdWithCredentials: vi.fn(),
};
});
const createMockRegistry = (overrides: Partial<Registry> = {}): Registry =>
({
registryId: "reg-1",
registryName: "Test Registry",
username: "myuser",
password: "reg-password",
registryUrl: "registry.example.com",
registryType: "cloud",
imagePrefix: null,
createdAt: new Date().toISOString(),
organizationId: "org-1",
...overrides,
}) as Registry;
const createApplication = (
overrides: Partial<ApplicationNested> = {},
): ApplicationNested =>
({
appName: "my-app",
sourceType: "docker",
dockerImage: "nginx:latest",
username: null,
password: null,
registryUrl: null,
registry: null,
buildRegistry: null,
rollbackRegistry: null,
rollbackActive: false,
environment: {
project: { env: null },
env: null,
},
...overrides,
}) as unknown as ApplicationNested;
describe("getImageName", () => {
beforeEach(() => {
vi.mocked(findRegistryByIdWithCredentials).mockReset();
});
it("returns the configured-registry tag for docker source + registry", async () => {
const registry = createMockRegistry();
vi.mocked(findRegistryByIdWithCredentials).mockResolvedValue(registry);
const application = createApplication({
registry: { registryId: "reg-1" } as any,
});
const result = await getImageName(application);
expect(findRegistryByIdWithCredentials).toHaveBeenCalledWith("reg-1");
expect(result).toBe("registry.example.com/myuser/nginx:latest");
expect(result).not.toBe("nginx:latest");
});
it("returns the configured buildRegistry tag for docker source + buildRegistry", async () => {
const buildRegistry = createMockRegistry({
registryId: "build-reg-1",
registryUrl: "build.example.com",
});
vi.mocked(findRegistryByIdWithCredentials).mockResolvedValue(buildRegistry);
const application = createApplication({
buildRegistry: { registryId: "build-reg-1" } as any,
});
const result = await getImageName(application);
expect(findRegistryByIdWithCredentials).toHaveBeenCalledWith("build-reg-1");
expect(result).toBe("build.example.com/myuser/nginx:latest");
});
it("prefers registry over buildRegistry for docker source (mirrors upload order)", async () => {
const registry = createMockRegistry({ registryId: "reg-1" });
const buildRegistry = createMockRegistry({
registryId: "build-reg-1",
registryUrl: "build.example.com",
});
vi.mocked(findRegistryByIdWithCredentials).mockResolvedValue(registry);
const application = createApplication({
registry: { registryId: "reg-1" } as any,
buildRegistry: { registryId: "build-reg-1" } as any,
});
const result = await getImageName(application);
expect(findRegistryByIdWithCredentials).toHaveBeenCalledTimes(1);
expect(findRegistryByIdWithCredentials).toHaveBeenCalledWith("reg-1");
expect(result).toBe("registry.example.com/myuser/nginx:latest");
});
it("returns the raw dockerImage when docker source has no registry configured", async () => {
vi.mocked(findRegistryByIdWithCredentials).mockResolvedValue(
createMockRegistry(),
);
const application = createApplication();
const result = await getImageName(application);
expect(findRegistryByIdWithCredentials).not.toHaveBeenCalled();
expect(result).toBe("nginx:latest");
});
it("returns ERROR-NO-IMAGE-PROVIDED when docker source has no dockerImage and no registry", async () => {
const application = createApplication({ dockerImage: null });
const result = await getImageName(application);
expect(result).toBe("ERROR-NO-IMAGE-PROVIDED");
});
it("returns the configured-registry tag for non-docker source + registry", async () => {
const registry = createMockRegistry();
vi.mocked(findRegistryByIdWithCredentials).mockResolvedValue(registry);
const application = createApplication({
sourceType: "github",
registry: { registryId: "reg-1" } as any,
});
const result = await getImageName(application);
expect(result).toBe("registry.example.com/myuser/my-app:latest");
});
it("returns <appName>:latest when non-docker source has no registry", async () => {
const application = createApplication({ sourceType: "github" });
const result = await getImageName(application);
expect(result).toBe("my-app:latest");
expect(findRegistryByIdWithCredentials).not.toHaveBeenCalled();
});
it("uses imagePrefix instead of username when present", async () => {
const registry = createMockRegistry({ imagePrefix: "myorg" });
vi.mocked(findRegistryByIdWithCredentials).mockResolvedValue(registry);
const application = createApplication({
registry: { registryId: "reg-1" } as any,
});
const result = await getImageName(application);
expect(result).toBe("registry.example.com/myorg/nginx:latest");
});
});
describe("getAuthConfig", () => {
beforeEach(() => {
vi.mocked(findRegistryByIdWithCredentials).mockReset();
});
it("returns the configured registry credentials for docker source + registry", async () => {
const registry = createMockRegistry({
password: "reg-secret",
username: "reg-user",
registryUrl: "registry.example.com",
});
vi.mocked(findRegistryByIdWithCredentials).mockResolvedValue(registry);
const application = createApplication({
registry: { registryId: "reg-1" } as any,
});
const result = await getAuthConfig(application);
expect(findRegistryByIdWithCredentials).toHaveBeenCalledWith("reg-1");
expect(result).toEqual({
password: "reg-secret",
username: "reg-user",
serveraddress: "registry.example.com",
});
});
it("returns the configured buildRegistry credentials for docker source + buildRegistry", async () => {
const buildRegistry = createMockRegistry({
registryId: "build-reg-1",
password: "build-secret",
username: "build-user",
registryUrl: "build.example.com",
});
vi.mocked(findRegistryByIdWithCredentials).mockResolvedValue(buildRegistry);
const application = createApplication({
buildRegistry: { registryId: "build-reg-1" } as any,
});
const result = await getAuthConfig(application);
expect(findRegistryByIdWithCredentials).toHaveBeenCalledWith("build-reg-1");
expect(result).toEqual({
password: "build-secret",
username: "build-user",
serveraddress: "build.example.com",
});
});
it("prefers the configured registry over app-level credentials for docker source", async () => {
const registry = createMockRegistry({
password: "reg-secret",
username: "reg-user",
registryUrl: "registry.example.com",
});
vi.mocked(findRegistryByIdWithCredentials).mockResolvedValue(registry);
const application = createApplication({
registry: { registryId: "reg-1" } as any,
username: "app-user",
password: "app-secret",
registryUrl: "docker.io",
});
const result = await getAuthConfig(application);
expect(result).toEqual({
password: "reg-secret",
username: "reg-user",
serveraddress: "registry.example.com",
});
});
it("falls back to app-level credentials for docker source with no configured registry", async () => {
const application = createApplication({
username: "app-user",
password: "app-secret",
registryUrl: "docker.io",
});
const result = await getAuthConfig(application);
expect(findRegistryByIdWithCredentials).not.toHaveBeenCalled();
expect(result).toEqual({
password: "app-secret",
username: "app-user",
serveraddress: "docker.io",
});
});
it("returns undefined for docker source with no registry and no app-level credentials", async () => {
const application = createApplication();
const result = await getAuthConfig(application);
expect(result).toBeUndefined();
});
it("uses empty serveraddress fallback when docker source has app creds but no registryUrl", async () => {
const application = createApplication({
username: "app-user",
password: "app-secret",
registryUrl: null,
});
const result = await getAuthConfig(application);
expect(result).toEqual({
password: "app-secret",
username: "app-user",
serveraddress: "",
});
});
it("returns the configured registry credentials for non-docker source + registry", async () => {
const registry = createMockRegistry({
password: "reg-secret",
username: "reg-user",
registryUrl: "registry.example.com",
});
vi.mocked(findRegistryByIdWithCredentials).mockResolvedValue(registry);
const application = createApplication({
sourceType: "github",
registry: { registryId: "reg-1" } as any,
});
const result = await getAuthConfig(application);
expect(result).toEqual({
password: "reg-secret",
username: "reg-user",
serveraddress: "registry.example.com",
});
});
it("returns undefined for non-docker source with no configured registry", async () => {
const application = createApplication({ sourceType: "github" });
const result = await getAuthConfig(application);
expect(result).toBeUndefined();
expect(findRegistryByIdWithCredentials).not.toHaveBeenCalled();
});
});
describe("push/pull registry consistency for docker source", () => {
beforeEach(() => {
vi.mocked(findRegistryByIdWithCredentials).mockReset();
});
it("getImageName (pull) and uploadImageRemoteCommand (push) target the same registry tag", async () => {
const registry = createMockRegistry({
registryUrl: "registry.example.com",
username: "myuser",
});
vi.mocked(findRegistryByIdWithCredentials).mockResolvedValue(registry);
const application = createApplication({
dockerImage: "nginx:latest",
registry: { registryId: "reg-1" } as any,
});
const expectedTag = getRegistryTag(registry, "nginx:latest");
const pullImage = await getImageName(application);
const pushCommand = await uploadImageRemoteCommand(application);
const unescaped = pushCommand.replace(/\\(.)/g, "$1");
expect(pullImage).toBe(expectedTag);
expect(unescaped).toContain(`docker tag nginx:latest ${expectedTag}`);
expect(unescaped).toContain(`docker push ${expectedTag}`);
expect(pullImage).not.toBe("nginx:latest");
});
it("getImageName (pull) and uploadImageRemoteCommand (push) stay consistent for buildRegistry", async () => {
const buildRegistry = createMockRegistry({
registryId: "build-reg-1",
registryUrl: "build.example.com",
username: "myuser",
});
vi.mocked(findRegistryByIdWithCredentials).mockResolvedValue(buildRegistry);
const application = createApplication({
dockerImage: "myuser/myrepo:1.2.3",
buildRegistry: { registryId: "build-reg-1" } as any,
});
const expectedTag = getRegistryTag(buildRegistry, "myuser/myrepo:1.2.3");
const pullImage = await getImageName(application);
const pushCommand = await uploadImageRemoteCommand(application);
const unescaped = pushCommand.replace(/\\(.)/g, "$1");
expect(pullImage).toBe(expectedTag);
expect(unescaped).toContain(
`docker tag myuser/myrepo:1.2.3 ${expectedTag}`,
);
expect(unescaped).toContain(`docker push ${expectedTag}`);
});
});
+3 -1
View File
@@ -314,7 +314,9 @@ export const rebuildApplication = async ({
try {
let command = "set -e;";
// Check case for docker only
if (application.sourceType === "docker") {
command += await buildRemoteDocker(application);
}
command += await getBuildCommand(application);
const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
if (serverId) {
+12 -11
View File
@@ -196,13 +196,13 @@ export const mechanizeDockerContainer = async (
}
};
const getImageName = async (application: ApplicationNested) => {
export const getImageName = async (application: ApplicationNested) => {
const { appName, sourceType, dockerImage, registry, buildRegistry } =
application;
const imageName = `${appName}:latest`;
if (sourceType === "docker") {
return dockerImage || "ERROR-NO-IMAGE-PROVIDED";
}
const imageName =
sourceType === "docker"
? dockerImage || "ERROR-NO-IMAGE-PROVIDED"
: `${appName}:latest`;
if (registry) {
const r = await findRegistryByIdWithCredentials(registry.registryId);
@@ -226,18 +226,15 @@ export const getAuthConfig = async (application: ApplicationNested) => {
registryUrl,
} = application;
if (sourceType === "docker") {
if (username && password) {
return { password, username, serveraddress: registryUrl || "" };
}
} else if (registry) {
if (registry) {
const r = await findRegistryByIdWithCredentials(registry.registryId);
return {
password: r.password,
username: r.username,
serveraddress: r.registryUrl,
};
} else if (buildRegistry) {
}
if (buildRegistry) {
const r = await findRegistryByIdWithCredentials(buildRegistry.registryId);
return {
password: r.password,
@@ -246,5 +243,9 @@ export const getAuthConfig = async (application: ApplicationNested) => {
};
}
if (sourceType === "docker" && username && password) {
return { password, username, serveraddress: registryUrl || "" };
}
return undefined;
};