Files
kuber/tests/lib/convert-deployment.test.ts
T

705 lines
22 KiB
TypeScript

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;
}
describe("Deployment conversion", () => {
test("renders image, defaults, command, environment, and labels", () => {
const service = {
image: "nginx:latest",
command: "nginx -g daemon-off",
environment: ["MODE=prod", "EMPTY"],
} as Service;
const deployment = serviceToDeployment(
"project",
"app",
compose(service),
service,
);
const container = deployment.spec?.template.spec?.containers[0];
expect(deployment.metadata).toMatchObject({
name: "app",
namespace: "project",
labels: { "app.kubernetes.io/managed-by": "kuber" },
});
expect(deployment.spec?.replicas).toBe(1);
expect(container).toMatchObject({
name: "app",
image: "nginx:latest",
imagePullPolicy: "Always",
command: ["nginx", "-g", "daemon-off"],
env: [
{ name: "MODE", value: "prod" },
{ name: "EMPTY", value: undefined },
],
});
});
test("uses the resolved image for buildable services and rejects missing ones", () => {
const service = { build: ".", image: "ignored:latest" } as Service;
const digest = `sha256:${"a".repeat(64)}`;
const deployment = serviceToDeployment(
"project",
"web",
compose(service),
service,
process.cwd(),
{},
{},
{ web: `registry.neko-piranha.ts.net/kuber/project-web:latest@${digest}` },
);
expect(deployment.spec?.template.spec?.containers[0]?.image).toBe(
`registry.neko-piranha.ts.net/kuber/project-web:latest@${digest}`,
);
expect(() =>
serviceToDeployment("project", "web", compose(service), service),
).toThrow("Missing resolved build image for service web");
});
test("prefers scale over deploy replicas and accepts command arrays", () => {
const service = {
image: "app",
scale: 4,
deploy: { replicas: 2 },
command: ["server", "--port", "3000"],
} as Service;
const deployment = serviceToDeployment(
"project",
"app",
compose(service),
service,
);
expect(deployment.spec?.replicas).toBe(4);
expect(deployment.spec?.template.spec?.containers[0]?.command).toEqual([
"server",
"--port",
"3000",
]);
});
test("adds amd64 placement constraints", () => {
const service = { image: "app" } as Service;
const deployment = serviceToDeployment(
"project",
"app",
compose(service, { "x-arch": "amd64" }),
service,
);
expect(deployment.spec?.template.spec?.nodeSelector).toEqual({
"kubernetes.io/arch": "amd64",
});
expect(deployment.spec?.template.spec?.tolerations?.[0]).toMatchObject({
key: "arch",
value: "amd64",
});
});
test("merges container and deployment extensions", () => {
const service = {
image: "app",
"x-container": {
securityContext: { readOnlyRootFilesystem: true },
env: [{ name: "EXTRA", value: "yes" }],
},
"x-deployment": {
metadata: { annotations: { owner: "platform" } },
spec: { strategy: { type: "Recreate" } },
},
} as Service;
const deployment = serviceToDeployment(
"project",
"app",
compose(service),
service,
);
expect(deployment.metadata?.annotations).toEqual({ owner: "platform" });
expect(deployment.spec?.strategy?.type).toBe("Recreate");
expect(
deployment.spec?.template.spec?.containers[0]?.securityContext,
).toEqual({
readOnlyRootFilesystem: true,
});
expect(deployment.spec?.template.spec?.containers[0]?.env).toEqual([
{ name: "EXTRA", value: "yes" },
]);
});
test("references an env secret only when one will be rendered", () => {
const service = { image: "app" } as Service;
expect(
serviceToDeployment("project", "app", compose(service), service).spec
?.template.spec?.containers[0]?.envFrom,
).toBeUndefined();
expect(
serviceToDeployment(
"project",
"app",
compose(service),
service,
process.cwd(),
{ GENERATED: "yes" },
).spec?.template.spec?.containers[0]?.envFrom,
).toEqual([{ secretRef: { name: "app-env" } }]);
});
test("maps deploy resource limits and reservations to container resources", () => {
const service = {
image: "app",
deploy: {
resources: {
limits: { cpus: "0.25", memory: "3072M" },
reservations: { cpus: 0.5, memory: "1g" },
},
},
} as Service;
const deployment = serviceToDeployment(
"project",
"app",
compose(service),
service,
);
expect(deployment.spec?.template.spec?.containers[0]?.resources).toEqual({
limits: { cpu: "250m", memory: "3072M" },
requests: { cpu: "500m", memory: "1G" },
});
});
test("normalizes Compose memory units to decimal Kubernetes quantities", () => {
const service = {
image: "app",
deploy: {
resources: {
limits: { cpus: 2, memory: "256M" },
reservations: { cpus: "2.0", memory: "100m" },
},
},
} as Service;
const deployment = serviceToDeployment(
"project",
"app",
compose(service),
service,
);
expect(deployment.spec?.template.spec?.containers[0]?.resources).toEqual({
limits: { cpu: "2", memory: "256M" },
requests: { cpu: "2", memory: "100M" },
});
});
test.each(["1Ki", "1Mi", "1Gi", "1Ti", "1Pi", "1Ei"])(
"preserves Kubernetes binary memory quantity %s",
(memory) => {
const service = {
image: "app",
deploy: {
resources: {
limits: { memory },
},
},
} as Service;
const deployment = serviceToDeployment(
"project",
"app",
compose(service),
service,
);
expect(deployment.spec?.template.spec?.containers[0]?.resources).toEqual({
limits: { memory },
});
},
);
test.each(["", "-1M", "1e3", "1MBi", "1foo"])(
"rejects invalid memory resource quantity %s",
(memory) => {
const service = {
image: "app",
deploy: { resources: { limits: { memory } } },
} as Service;
expect(() =>
serviceToDeployment("project", "app", compose(service), service),
).toThrow("deploy.resources.limits.memory");
},
);
test("rejects invalid CPU resource quantities", () => {
for (const cpus of [
"NaN",
"Infinity",
Number.POSITIVE_INFINITY,
"-0.5",
"0.0005",
]) {
const service = {
image: "app",
deploy: { resources: { limits: { cpus } } },
} as Service;
expect(() =>
serviceToDeployment("project", "app", compose(service), service),
).toThrow("deploy.resources.limits.cpus");
}
});
test("lets x-container resource fields override deploy resources", () => {
const service = {
image: "app",
deploy: {
resources: {
limits: { cpus: "0.5", memory: "256M" },
reservations: { cpus: "0.25", memory: "128M" },
},
},
"x-container": {
resources: { limits: { memory: "768Mi" }, requests: { cpu: "900m" } },
},
} as Service;
const deployment = serviceToDeployment(
"project",
"app",
compose(service),
service,
);
expect(deployment.spec?.template.spec?.containers[0]?.resources).toEqual({
limits: { cpu: "500m", memory: "768Mi" },
requests: { cpu: "900m", memory: "128M" },
});
});
});
describe("port and routing conversion", () => {
test("renders numeric, published, UDP, and exposed ports", () => {
const service = {
image: "app",
ports: [3000, "8080:80", "5353:53/udp"],
expose: [9000, "9001/udp"],
} as Service;
const kubernetesService = serviceToSvc("project", "app", service);
expect(kubernetesService?.spec?.ports).toEqual([
{ name: "port-0", port: 3000, targetPort: 3000, protocol: "TCP" },
{ name: "port-1", port: 8080, targetPort: 80, protocol: "TCP" },
{ name: "port-2", port: 5353, targetPort: 53, protocol: "UDP" },
{ name: "expose-0", port: 9000, targetPort: 9000, protocol: "TCP" },
{ name: "expose-1", port: 9001, targetPort: 9001, protocol: "UDP" },
]);
});
test("renders long-form named ports", () => {
const service = {
ports: [{ name: "http", target: 3000, published: "80", protocol: "tcp" }],
} as Service;
expect(serviceToSvc("project", "app", service)?.spec?.ports).toEqual([
{ name: "http", port: 80, targetPort: 3000, protocol: "TCP" },
]);
});
test("deduplicates service ports by number and protocol", () => {
const service = { ports: ["8080:3000", "8080:4000"] } as Service;
expect(serviceToSvc("project", "app", service)?.spec?.ports).toEqual([
{ name: "port-1", port: 8080, targetPort: 4000, protocol: "TCP" },
]);
});
test("renders host-based ingress and deduplicates hosts", () => {
const service = {
ports: ["app.example.com:3000", "app.example.com:4000"],
} as Service;
const ingress = serviceToIngress("project", "app", service);
expect(ingress?.spec?.rules).toHaveLength(1);
expect(ingress?.spec?.rules?.[0]).toMatchObject({
host: "app.example.com",
http: {
paths: [
{
path: "/",
backend: { service: { name: "app", port: { number: 3000 } } },
},
],
},
});
});
test("renders wildcard hosts for normal and protected routes", async () => {
const service = {
image: "app",
ports: ["*.example.com:3000", "*.secure.example.com:3001:protected"],
} as Service;
const resources = await composeToKubernetes("project", compose(service));
expect(
resources.find((resource) => resource.kind === "Ingress"),
).toMatchObject({
spec: { rules: [{ host: "*.example.com" }] },
});
expect(
resources.find((resource) => resource.kind === "IngressRoute"),
).toMatchObject({
spec: {
routes: [
{
match: "Host(`*.secure.example.com`)",
services: [{ name: "app", port: 3001 }],
},
],
},
});
});
test("does not render ingress for numeric bindings or protected routes", () => {
expect(
serviceToIngress("project", "app", { ports: ["8080:80"] } as Service),
).toBeUndefined();
expect(
serviceToIngress("project", "app", {
ports: ["app.example.com:3000:protected"],
} as Service),
).toBeUndefined();
});
test("rejects protected routes with an empty path list", () => {
expect(() =>
serviceToSvc("project", "app", {
ports: ["app.example.com:3000:protected()"],
} as Service),
).toThrow("Expected one or more paths");
});
});
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("uses a deploy CPU reservation instead of the HPA fallback request", async () => {
const service = {
image: "app",
deploy: {
replicas: "1-4",
resources: { reservations: { cpus: "0.25" } },
},
} as Service;
const resources = await composeToKubernetes("project", compose(service));
const deployment = findDeployment(resources);
expect(
deployment.spec?.template.spec?.containers?.[0]?.resources?.requests,
).toEqual({ cpu: "250m" });
});
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({
apiVersion: "v1",
kind: "Namespace",
metadata: {
name: "project",
labels: { "app.kubernetes.io/managed-by": "kuber" },
},
});
});
});