feat: add support for replica ranges and HorizontalPodAutoscaler integration

This commit is contained in:
2026-09-03 12:02:22 +07:00 Unverified
parent 4ce8ed3e40
commit 8e9d207915
10 changed files with 700 additions and 39 deletions
+2
View File
@@ -13,6 +13,7 @@ describe("resource ordering", () => {
resource("IngressRoute"),
resource("Deployment"),
resource("GarageKey"),
resource("HorizontalPodAutoscaler"),
resource("Service"),
resource("GarageBucket"),
resource("ConfigMap"),
@@ -42,6 +43,7 @@ describe("resource ordering", () => {
"ConfigMap",
"Service",
"Deployment",
"HorizontalPodAutoscaler",
"Ingress",
"IngressRoute",
"Database",
+297
View File
@@ -1,13 +1,32 @@
import { describe, expect, test } from "bun:test";
import type { V1Deployment } from "@kubernetes/client-node";
import type { ComposeSpecification, Service } from "../../schema/docker.d";
import {
composeToKubernetes,
composeToNamespace,
parseReplicaRange,
resolveReplicaSpec,
serviceToDeployment,
serviceToIngress,
serviceToSvc,
} from "../../lib/convert";
function findDeployment(
resources: Awaited<ReturnType<typeof composeToKubernetes>>,
): V1Deployment {
const deployment = resources.find(
(resource) => resource.kind === "Deployment",
) as V1Deployment | undefined;
if (!deployment) throw new Error("No Deployment rendered");
return deployment;
}
function findHpa(resources: Awaited<ReturnType<typeof composeToKubernetes>>) {
return resources.find(
(resource) => resource.kind === "HorizontalPodAutoscaler",
);
}
function compose(service: Service, extra: Partial<ComposeSpecification> = {}) {
return { ...extra, services: { app: service } } as ComposeSpecification;
}
@@ -251,6 +270,284 @@ describe("port and routing conversion", () => {
});
});
describe("replica range parsing", () => {
test("parses a valid min-max range", () => {
expect(parseReplicaRange("2-6")).toEqual({ kind: "range", min: 2, max: 6 });
expect(parseReplicaRange("1-1")).toEqual({ kind: "range", min: 1, max: 1 });
expect(parseReplicaRange(" 3 - 9 ")).toEqual({
kind: "range",
min: 3,
max: 9,
});
});
test("rejects ranges where min exceeds max", () => {
expect(() => parseReplicaRange("6-2")).toThrow(
"Minimum (6) cannot exceed maximum (2)",
);
});
test("rejects non-positive range bounds", () => {
expect(() => parseReplicaRange("0-4")).toThrow(
"Range bounds must be positive integers",
);
});
test("returns undefined for non-range values", () => {
expect(parseReplicaRange("3")).toBeUndefined();
expect(parseReplicaRange(3)).toBeUndefined();
expect(parseReplicaRange("abc")).toBeUndefined();
expect(parseReplicaRange(undefined)).toBeUndefined();
expect(parseReplicaRange("3-")).toBeUndefined();
expect(parseReplicaRange("-6")).toBeUndefined();
});
});
describe("replica spec resolution", () => {
test("prefers a scale range over deploy but only uses scale when set", () => {
expect(
resolveReplicaSpec({ image: "app", scale: "2-6", "deploy": { replicas: 3 } } as Service),
).toEqual({ kind: "range", min: 2, max: 6 });
});
test("uses deploy range when scale is unset", () => {
expect(
resolveReplicaSpec({ image: "app", "deploy": { replicas: "2-6" } } as Service),
).toEqual({ kind: "range", min: 2, max: 6 });
});
test("prefers a fixed scale value over a deploy range", () => {
expect(
resolveReplicaSpec({ image: "app", scale: 3, "deploy": { replicas: "2-6" } } as Service),
).toEqual({ kind: "fixed", value: 3 });
});
test("preserves existing scale over deploy replicas precedence", () => {
expect(
resolveReplicaSpec({ image: "app", scale: 4, "deploy": { replicas: 2 } } as Service),
).toEqual({ kind: "fixed", value: 4 });
expect(
resolveReplicaSpec({ image: "app", "deploy": { replicas: 2 } } as Service),
).toEqual({ kind: "fixed", value: 2 });
});
test("supports a fixed zero replica count via scale and deploy.replicas", () => {
expect(
resolveReplicaSpec({ image: "app", scale: 0 } as Service),
).toEqual({ kind: "fixed", value: 0 });
expect(
resolveReplicaSpec({ image: "app", scale: "0" } as Service),
).toEqual({ kind: "fixed", value: 0 });
expect(
resolveReplicaSpec({ image: "app", "deploy": { replicas: 0 } } as Service),
).toEqual({ kind: "fixed", value: 0 });
expect(
resolveReplicaSpec({ image: "app", "deploy": { replicas: "0" } } as Service),
).toEqual({ kind: "fixed", value: 0 });
});
test("normalizes an equal scale range to a fixed replica spec", () => {
expect(
resolveReplicaSpec({ image: "app", scale: "3-3" } as Service),
).toEqual({ kind: "fixed", value: 3 });
});
test("normalizes an equal deploy range to a fixed replica spec", () => {
expect(
resolveReplicaSpec({ image: "app", "deploy": { replicas: "1-1" } } as Service),
).toEqual({ kind: "fixed", value: 1 });
expect(
resolveReplicaSpec({ image: "app", "deploy": { replicas: "3-3" } } as Service),
).toEqual({ kind: "fixed", value: 3 });
});
test("keeps an unequal range as a range spec", () => {
expect(
resolveReplicaSpec({ image: "app", "deploy": { replicas: "1-4" } } as Service),
).toEqual({ kind: "range", min: 1, max: 4 });
});
test("throws on malformed nonnumeric replica strings", () => {
expect(() =>
resolveReplicaSpec({ image: "app", scale: "fifty" } as Service),
).toThrow("Invalid scale value");
expect(() =>
resolveReplicaSpec({ image: "app", "deploy": { replicas: "bah" } } as Service),
).toThrow("Invalid deploy.replicas value");
});
test("defaults to a single replica", () => {
expect(resolveReplicaSpec({ image: "app" } as Service)).toEqual({
kind: "fixed",
value: 1,
});
});
});
describe("autoscaled (range) conversion", () => {
test("sets deployment replicas to the range min and renders an HPA", async () => {
const service = {
image: "app",
"deploy": { replicas: "2-6" },
} as Service;
const resources = await composeToKubernetes("project", compose(service));
const deployment = findDeployment(resources);
expect(deployment.spec).toMatchObject({ replicas: 2 });
const hpa = findHpa(resources);
expect(hpa).toMatchObject({
apiVersion: "autoscaling/v2",
kind: "HorizontalPodAutoscaler",
metadata: { name: "app", namespace: "project" },
spec: {
scaleTargetRef: { apiVersion: "apps/v1", kind: "Deployment", name: "app" },
minReplicas: 2,
maxReplicas: 6,
metrics: [
{
type: "Resource",
resource: {
name: "cpu",
target: { type: "Utilization", averageUtilization: 80 },
},
},
],
},
});
});
test("injects a CPU request on autoscaled services without one", async () => {
const service = { image: "app", "deploy": { replicas: "1-4" } } as Service;
const resources = await composeToKubernetes("project", compose(service));
const deployment = findDeployment(resources);
expect(
deployment.spec?.template?.spec?.containers?.[0]?.resources?.requests,
).toEqual({ cpu: "100m" });
});
test("does not override an existing CPU request from x-container", async () => {
const service = {
image: "app",
"deploy": { replicas: "1-4" },
"x-container": { resources: { requests: { cpu: "500m" } } },
} as Service;
const resources = await composeToKubernetes("project", compose(service));
const deployment = findDeployment(resources);
expect(
deployment.spec?.template?.spec?.containers?.[0]?.resources?.requests,
).toEqual({ cpu: "500m" });
});
test("does not inject a CPU request on fixed-replica services", async () => {
const service = { image: "app" } as Service;
const resources = await composeToKubernetes("project", compose(service));
const deployment = findDeployment(resources);
expect(
deployment.spec?.template.spec?.containers?.[0]?.resources,
).toBeUndefined();
});
test("treats an equal range as fixed: no HPA and no injected CPU request", async () => {
const service = {
image: "app",
"deploy": { replicas: "1-1" },
} as Service;
const resources = await composeToKubernetes("project", compose(service));
const deployment = findDeployment(resources);
expect(deployment.spec?.replicas).toBe(1);
expect(findHpa(resources)).toBeUndefined();
expect(
deployment.spec?.template.spec?.containers?.[0]?.resources,
).toBeUndefined();
});
test("treats an equal range > 1 as fixed: no HPA or CPU request but keeps spread", async () => {
const service = {
image: "app",
"deploy": { replicas: "3-3" },
} as Service;
const resources = await composeToKubernetes("project", compose(service));
const deployment = findDeployment(resources);
expect(deployment.spec?.replicas).toBe(3);
expect(findHpa(resources)).toBeUndefined();
expect(
deployment.spec?.template.spec?.containers?.[0]?.resources,
).toBeUndefined();
expect(deployment.spec?.template.spec?.topologySpreadConstraints).toEqual([
{
maxSkew: 1,
topologyKey: "kubernetes.io/hostname",
whenUnsatisfiable: "ScheduleAnyway",
labelSelector: { matchLabels: { app: "app" } },
},
]);
});
});
describe("topology spread constraints", () => {
test("adds spread on static replicas > 1", () => {
const deployment = serviceToDeployment(
"project",
"app",
compose({ image: "app", scale: 3 } as Service),
{ image: "app", scale: 3 } as Service,
);
expect(deployment.spec?.template.spec?.topologySpreadConstraints).toEqual([
{
maxSkew: 1,
topologyKey: "kubernetes.io/hostname",
whenUnsatisfiable: "ScheduleAnyway",
labelSelector: { matchLabels: { app: "app" } },
},
]);
});
test("adds spread when range max exceeds one", async () => {
const resources = await composeToKubernetes(
"project",
compose({
image: "app",
"deploy": { replicas: "1-4" },
} as Service),
);
const deployment = findDeployment(resources);
expect(deployment.spec?.template.spec?.topologySpreadConstraints).toEqual([
{
maxSkew: 1,
topologyKey: "kubernetes.io/hostname",
whenUnsatisfiable: "ScheduleAnyway",
labelSelector: { matchLabels: { app: "app" } },
},
]);
});
test("omits spread for single-replica services", () => {
const deployment = serviceToDeployment(
"project",
"app",
compose({ image: "app" } as Service),
{ image: "app" } as Service,
);
expect(
deployment.spec?.template.spec?.topologySpreadConstraints,
).toBeUndefined();
});
test("supports a fixed zero replicas deployment with no HPA or spread", async () => {
const service = { image: "app", "deploy": { replicas: 0 } } as Service;
const resources = await composeToKubernetes("project", compose(service));
const deployment = findDeployment(resources);
expect(deployment.spec?.replicas).toBe(0);
expect(findHpa(resources)).toBeUndefined();
expect(
deployment.spec?.template.spec?.topologySpreadConstraints,
).toBeUndefined();
});
});
describe("namespace conversion", () => {
test("renders a managed namespace", () => {
expect(composeToNamespace("project")).toEqual({