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>, ): 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>) { return resources.find( (resource) => resource.kind === "HorizontalPodAutoscaler", ); } function compose(service: Service, extra: Partial = {}) { 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" }, }, }); }); });