563 lines
18 KiB
TypeScript
563 lines
18 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" } }]);
|
|
});
|
|
});
|
|
|
|
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("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" },
|
|
},
|
|
});
|
|
});
|
|
});
|