This commit is contained in:
Pelle 2026-09-12 13:21:01 +00:00 committed by GitHub
commit a1121a643b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 9418 additions and 4 deletions

View File

@ -0,0 +1,109 @@
import {
createCommand,
getBuildComposeCommand,
} from "@dokploy/server/utils/builders/compose";
import { describe, expect, it } from "vitest";
const base = {
composeType: "docker-compose" as const,
appName: "compose-app",
sourceType: "raw" as const,
composePath: "./docker-compose.yml",
command: "",
mounts: [],
domains: [],
environment: {
environmentId: "env-1",
env: "",
project: {
projectId: "proj-1",
organizationId: "org-1",
env: "",
},
},
};
describe("compose createCommand with serviceScales", () => {
it("adds --scale <service>=<replicas> when serviceScales is provided", () => {
const cmd = createCommand({
...base,
serviceScales: [{ serviceName: "web", replicas: 3 }],
} as any);
expect(cmd).toContain("up -d --build --remove-orphans --scale web=3");
});
it("adds multiple --scale flags sorted by serviceName", () => {
const cmd = createCommand({
...base,
serviceScales: [
{ serviceName: "worker", replicas: 5 },
{ serviceName: "api", replicas: 2 },
],
} as any);
expect(cmd).toContain("--scale api=2 --scale worker=5");
});
it("omits --scale when serviceScales is empty or undefined", () => {
const cmdEmpty = createCommand({
...base,
serviceScales: [],
} as any);
expect(cmdEmpty).not.toContain("--scale");
const cmdUndefined = createCommand({
...base,
} as any);
expect(cmdUndefined).not.toContain("--scale");
});
it("does not add --scale to stack deploy createCommand", () => {
const cmd = createCommand({
...base,
composeType: "stack",
serviceScales: [{ serviceName: "web", replicas: 4 }],
} as any);
expect(cmd).not.toContain("--scale");
});
it("leaves custom command untouched even with serviceScales", () => {
const cmd = createCommand({
...base,
command: "compose -p custom-app up -d",
serviceScales: [{ serviceName: "web", replicas: 3 }],
} as any);
expect(cmd).toBe("compose -p custom-app up -d");
});
});
describe("compose getBuildComposeCommand with Swarm serviceScales", () => {
it("appends docker service scale commands for stack composeType", async () => {
const cmd = await getBuildComposeCommand({
...base,
composeType: "stack",
serviceScales: [
{ serviceName: "web", replicas: 3 },
{ serviceName: "worker", replicas: 2 },
],
} as any);
expect(cmd).toContain("docker stack deploy");
expect(cmd).toContain('docker service scale "compose-app_web=3"');
expect(cmd).toContain('docker service scale "compose-app_worker=2"');
expect(cmd).toContain("Docker service scale failed for web");
});
it("does not append swarm service scale commands for docker-compose type", async () => {
const cmd = await getBuildComposeCommand({
...base,
composeType: "docker-compose",
serviceScales: [{ serviceName: "web", replicas: 3 }],
} as any);
expect(cmd).not.toContain("docker service scale");
expect(cmd).toContain("--scale web=3");
});
});

View File

@ -0,0 +1 @@
ALTER TABLE "compose" ADD COLUMN "serviceScales" jsonb DEFAULT '[]'::jsonb;

File diff suppressed because it is too large Load Diff

View File

@ -1380,6 +1380,13 @@
"when": 1788995453103,
"tag": "0196_worthless_ravenous",
"breakpoints": true
},
{
"idx": 197,
"version": "7",
"when": 1789141420032,
"tag": "0197_breezy_mentallo",
"breakpoints": true
}
]
}

View File

@ -28,6 +28,7 @@ import {
removeComposeDirectory,
removeDeploymentsByComposeId,
removeDomainById,
scaleComposeService,
startCompose,
stopCompose,
updateCompose,
@ -66,6 +67,7 @@ import {
apiRandomizeCompose,
apiRedeployCompose,
apiSaveEnvironmentVariablesCompose,
apiScaleComposeService,
apiUpdateCompose,
compose as composeTable,
environments,
@ -544,6 +546,26 @@ export const composeRouter = createTRPCRouter({
});
return true;
}),
scaleService: protectedProcedure
.input(apiScaleComposeService)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
deployment: ["create"],
});
const updated = await scaleComposeService(
input.composeId,
input.serviceName,
input.replicas,
);
const compose = await findComposeById(input.composeId);
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: input.composeId,
resourceName: `${compose.name} (${input.serviceName} -> ${input.replicas} replicas)`,
});
return updated;
}),
getDefaultCommand: protectedProcedure
.input(apiFindCompose)
.query(async ({ input, ctx }) => {

View File

@ -132,6 +132,14 @@ export const compose = pgTable("compose", {
}>
>()
.default([]),
serviceScales: jsonb("serviceScales")
.$type<
Array<{
serviceName: string;
replicas: number;
}>
>()
.default([]),
});
export const composeRelations = relations(compose, ({ one, many }) => ({
@ -208,6 +216,17 @@ const createSchema = createInsertSchema(compose, {
}),
)
.optional(),
serviceScales: z
.array(
z.object({
serviceName: z
.string()
.min(1)
.regex(/^[a-zA-Z0-9._-]+$/, "Invalid service name"),
replicas: z.number().int().min(0),
}),
)
.optional(),
});
export const apiCreateCompose = createSchema.pick({
@ -285,3 +304,13 @@ export const apiRandomizeCompose = createSchema
suffix: z.string().optional(),
composeId: z.string().min(1),
});
export const apiScaleComposeService = z.object({
composeId: z.string().min(1),
serviceName: z
.string()
.min(1)
.regex(/^[a-zA-Z0-9._-]+$/, "Invalid service name"),
replicas: z.number().int().min(0),
});

View File

@ -1,4 +1,4 @@
import { join } from "node:path";
import { dirname, join } from "node:path";
import { paths } from "@dokploy/server/constants";
import { db } from "@dokploy/server/db";
import {
@ -583,3 +583,49 @@ export const stopCompose = async (composeId: string) => {
return true;
};
export const scaleComposeService = async (
composeId: string,
serviceName: string,
replicas: number,
) => {
const compose = await findComposeById(composeId);
const existingScales = compose.serviceScales || [];
const updatedScales = existingScales.filter(
(s) => s.serviceName !== serviceName,
);
updatedScales.push({ serviceName, replicas });
await updateCompose(composeId, {
serviceScales: updatedScales,
});
if (compose.composeType === "docker-compose") {
const { COMPOSE_PATH } = paths(!!compose.serverId);
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
const path =
compose.sourceType === "raw"
? "docker-compose.yml"
: compose.composePath || "docker-compose.yml";
const projectDirectoryFlag = `--project-directory ${quote([projectPath])} `;
const envFileFlag = compose.createEnvFile
? `--env-file ${quote([join(dirname(compose.composePath || "docker-compose.yml"), ".env")])} `
: "";
const scaleCommand = `cd ${quote([projectPath])} && env -i PATH="$PATH" HOME="$HOME" docker compose -p ${quote([compose.appName])} ${projectDirectoryFlag}${envFileFlag}-f ${quote([path])} up -d --no-build --scale ${quote([`${serviceName}=${replicas}`])} ${quote([serviceName])}`;
if (compose.serverId) {
await execAsyncRemote(compose.serverId, scaleCommand);
} else {
await execAsync(scaleCommand);
}
} else if (compose.composeType === "stack") {
const swarmScaleCommand = `docker service scale ${quote([`${compose.appName}_${serviceName}=${replicas}`])}`;
if (compose.serverId) {
await execAsyncRemote(compose.serverId, swarmScaleCommand);
} else {
await execAsync(swarmScaleCommand);
}
}
return { success: true, serviceName, replicas };
};

View File

@ -64,6 +64,19 @@ 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.composeType === "stack" &&
compose.serviceScales &&
compose.serviceScales.length > 0
? [...compose.serviceScales]
.sort((a, b) => a.serviceName.localeCompare(b.serviceName))
.map(
(s) =>
`docker service scale "${compose.appName}_${s.serviceName}=${s.replicas}" 2>&1 || { echo "Error: ❌ Docker service scale failed for ${s.serviceName}"; exit 1; };`,
)
.join("\n\t\t")
: ""
}
echo "Docker Compose Deployed: ✅";
} || {
@ -139,8 +152,18 @@ export const createCommand = (compose: ComposeNested, projectPath?: string) => {
const envFileFlag = compose.createEnvFile
? `--env-file ${quote([join(dirname(compose.composePath || "docker-compose.yml"), ".env")])} `
: "";
const scaleFlags =
compose.serviceScales && compose.serviceScales.length > 0
? `${[...compose.serviceScales]
.sort((a, b) => a.serviceName.localeCompare(b.serviceName))
.map((scale) => `--scale ${scale.serviceName}=${scale.replicas}`)
.join(" ")} `
: "";
const pullFlag = compose.pullImages ? " --pull always" : "";
command = `compose -p ${quote([appName])} ${projectDirectoryFlag}${envFileFlag}-f ${quote([path])} up -d --build --remove-orphans${pullFlag}`;
command =
`compose -p ${quote([appName])} ${projectDirectoryFlag}${envFileFlag}-f ${quote([path])} up -d --build --remove-orphans ${scaleFlags}${pullFlag}`
.replace(/\s+/g, " ")
.trim();
} else if (composeType === "stack") {
command = `stack deploy -c ${quote([path])} ${quote([appName])} --prune --with-registry-auth`;
}
@ -194,8 +217,8 @@ const getExportEnvCommand = (compose: ComposeNested) => {
const envVars = getEnvironmentVariablesObject(
compose.env,
compose.environment.project.env,
compose.environment.env,
compose.environment?.project?.env,
compose.environment?.env,
);
const exports = Object.entries(envVars)
.map(([key, value]) => `${key}=${quote([value])}`)