Skip to content
Open
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
58 changes: 58 additions & 0 deletions apps/dokploy/__test__/server/removeSwarmService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { removeSwarmService } from "@dokploy/server";
import { beforeEach, describe, expect, it, vi } from "vitest";

const { execAsyncMock, execAsyncRemoteMock } = vi.hoisted(() => ({
execAsyncMock: vi.fn(),
execAsyncRemoteMock: vi.fn(),
}));

vi.mock("@dokploy/server/utils/process/execAsync", () => ({
execAsync: execAsyncMock,
execAsyncRemote: execAsyncRemoteMock,
}));

describe("removeSwarmService", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("removes exactly the requested service locally", async () => {
execAsyncMock.mockResolvedValue({ stdout: "", stderr: "" });

await removeSwarmService("abcdefghijklmnopqrstuvwxy");

expect(execAsyncMock).toHaveBeenCalledWith(
"docker service rm abcdefghijklmnopqrstuvwxy",
);
expect(execAsyncRemoteMock).not.toHaveBeenCalled();
});

it("dispatches removal to the selected remote server", async () => {
execAsyncRemoteMock.mockResolvedValue({ stdout: "", stderr: "" });

await removeSwarmService("abcdefghijklmnopqrstuvwxy", "server-456");

expect(execAsyncRemoteMock).toHaveBeenCalledWith(
"server-456",
"docker service rm abcdefghijklmnopqrstuvwxy",
);
expect(execAsyncMock).not.toHaveBeenCalled();
});

it("rejects option-like input without calling an executor", async () => {
await expect(removeSwarmService("--help")).rejects.toThrow(
"Invalid Docker Swarm service ID: expected 25 lowercase alphanumeric characters.",
);
expect(execAsyncMock).not.toHaveBeenCalled();
expect(execAsyncRemoteMock).not.toHaveBeenCalled();
});

it("propagates local command failures", async () => {
const error = new Error("docker service rm failed");
execAsyncMock.mockRejectedValue(error);

await expect(removeSwarmService("abcdefghijklmnopqrstuvwxy")).rejects.toBe(
error,
);
});
});
30 changes: 30 additions & 0 deletions apps/dokploy/server/api/routers/swarm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ import {
getNodeApplications,
getNodeInfo,
getSwarmNodes,
removeSwarmService,
swarmServiceIdRegex,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { audit } from "@/server/api/utils/audit";
import { createTRPCRouter, withPermission } from "../trpc";
import { containerIdRegex } from "./docker";

Expand Down Expand Up @@ -35,6 +38,33 @@ export const swarmRouter = createTRPCRouter({
.query(async ({ input }) => {
return getNodeApplications(input.serverId);
}),
removeService: withPermission("docker", "read")
.input(
z.object({
serviceId: z
.string()
.regex(
swarmServiceIdRegex,
"Invalid Docker Swarm service ID: expected 25 lowercase alphanumeric characters.",
),
serverId: z.string().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
await removeSwarmService(input.serviceId, input.serverId);
await audit(ctx, {
action: "delete",
resourceType: "docker",
resourceId: input.serviceId,
resourceName: input.serviceId,
});
}),
getAppInfos: withPermission("server", "read")
.meta({
openapi: {
Expand Down
19 changes: 19 additions & 0 deletions packages/server/src/services/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,25 @@ import {
execAsyncRemote,
} from "@dokploy/server/utils/process/execAsync";

export const swarmServiceIdRegex = /^[a-z0-9]{25}$/;

export const removeSwarmService = async (
serviceId: string,
serverId?: string | null,
) => {
if (!swarmServiceIdRegex.test(serviceId)) {
throw new Error(
"Invalid Docker Swarm service ID: expected 25 lowercase alphanumeric characters.",
);
}

const command = `docker service rm ${serviceId}`;
if (serverId) {
return await execAsyncRemote(serverId, command);
}
return await execAsync(command);
};

export const getContainers = async (serverId?: string | null) => {
try {
const command =
Expand Down