import { afterEach, describe, expect, test } from "bun:test"; import type { V1Deployment } from "@kubernetes/client-node"; import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import type { ComposeSpecification, Service } from "../../schema/docker.d"; import { composeToKubernetes, envFromToSecrets, serviceToDeployment, volumesToConfigMaps, volumesToPvc, volumesToStorageClasses, } from "../../lib/convert"; const temporaryDirectories: string[] = []; async function temporaryDirectory(): Promise { const path = await mkdtemp(join(tmpdir(), "kuber-convert-")); temporaryDirectories.push(path); return path; } afterEach(async () => { await Promise.all( temporaryDirectories .splice(0) .map((path) => rm(path, { recursive: true, force: true })), ); }); describe("volume conversion", () => { test("renders named volumes with a default Longhorn storage class", () => { const claims = volumesToPvc("project", { volumes: ["data:/var/lib/data"], } as Service); expect(claims).toHaveLength(1); expect(claims[0]).toMatchObject({ metadata: { name: "project-data", namespace: "project" }, spec: { resources: { requests: { storage: "1Gi" } }, storageClassName: expect.stringMatching( /^kuber-longhorn-[a-f0-9]{12}$/, ), }, }); expect( volumesToStorageClasses({ volumes: ["data:/var/lib/data"] } as Service), ).toEqual([ expect.objectContaining({ kind: "StorageClass", metadata: { name: claims[0]?.spec?.storageClassName, labels: expect.any(Object), }, provisioner: "driver.longhorn.io", parameters: { diskSelector: "fast", numberOfReplicas: "2", dataLocality: "disabled", }, }), ]); }); test("parses inline storage sizing and placement", () => { const [claim] = volumesToPvc("project", { volumes: ["data(20Gi on 3 fast,archive):/data"], } as Service); expect(claim).toMatchObject({ metadata: { name: "project-data" }, spec: { resources: { requests: { storage: "20Gi" } }, storageClassName: expect.stringMatching(/^kuber-longhorn-/), }, }); expect( volumesToStorageClasses({ volumes: ["data(20Gi on 3 fast,archive):/data"], } as Service)[0], ).toMatchObject({ parameters: { diskSelector: "archive,fast", numberOfReplicas: "3", dataLocality: "disabled", }, }); }); test("uses top-level volume extensions", () => { const [claim] = volumesToPvc( "project", { volumes: ["data:/data"] } as Service, process.cwd(), { data: { "x-size": "50Gi", "x-diskTag": ["bulk", "archive"], "x-replicaCount": 3, "x-dataLocality": "none", }, } as ComposeSpecification["volumes"], ); expect(claim?.spec).toMatchObject({ resources: { requests: { storage: "50Gi" } }, storageClassName: expect.stringMatching(/^kuber-longhorn-/), }); }); test("does not create PVCs for anonymous, tmpfs, or managed claims", () => { expect( volumesToPvc("project", { volumes: ["/cache", "postgresql:app", "s3:assets"], tmpfs: ["/tmp"], } as Service), ).toEqual([]); }); test("renders anonymous and tmpfs mounts as emptyDir", () => { const service = { image: "app", volumes: ["/cache"], tmpfs: ["/tmp"], } as Service; const deployment = serviceToDeployment( "project", "app", { services: { app: service } } as ComposeSpecification, service, ); expect(deployment.spec?.template.spec?.volumes).toEqual([ { name: "volume-0", emptyDir: {} }, { name: "tmpfs-1", emptyDir: { medium: "Memory", sizeLimit: undefined } }, ]); }); test("renders long-form tmpfs size and read-only volume subpaths", () => { const service = { image: "app", volumes: [ { type: "tmpfs", target: "/tmp", tmpfs: { size: 1024 } }, { type: "volume", source: "data", target: "/data", read_only: true, volume: { subpath: "nested" }, }, ], } as Service; const deployment = serviceToDeployment( "project", "app", { services: { app: service } } as ComposeSpecification, service, ); expect(deployment.spec?.template.spec?.volumes?.[0]).toEqual({ name: "tmpfs-0", emptyDir: { medium: "Memory", sizeLimit: "1024" }, }); expect( deployment.spec?.template.spec?.containers[0]?.volumeMounts?.[1], ).toMatchObject({ mountPath: "/data", readOnly: true, subPath: "nested", }); }); test("turns file binds into ConfigMaps", async () => { const directory = await temporaryDirectory(); await writeFile(join(directory, "app.conf"), "enabled=true\n"); const service = { image: "app", volumes: ["./app.conf:/etc/app.conf:ro"], } as Service; const configMaps = volumesToConfigMaps("project", service, directory); expect(configMaps).toHaveLength(1); expect(configMaps[0]?.data).toEqual({ "app.conf": "enabled=true\n" }); const deployment = serviceToDeployment( "project", "app", { services: { app: service } } as ComposeSpecification, service, directory, ); expect( deployment.spec?.template.spec?.containers[0]?.volumeMounts?.[0], ).toMatchObject({ mountPath: "/etc/app.conf", readOnly: true, subPath: "app.conf", }); }); test("turns directory binds into PVCs", async () => { const directory = await temporaryDirectory(); await mkdir(join(directory, "uploads")); const claims = volumesToPvc( "project", { volumes: ["./uploads:/uploads"] } as Service, directory, ); expect(claims).toHaveLength(1); expect(claims[0]?.metadata?.name).toStartWith("bind-"); }); }); describe("environment Secret conversion", () => { test("parses env files, ignores comments, and applies generated overrides", async () => { const directory = await temporaryDirectory(); await writeFile( join(directory, ".env"), "# comment\nA=one\nTOKEN=a=b=c\nINVALID\n =ignored\n", ); const [secret] = await envFromToSecrets( "project", "app", { env_file: ".env" } as Service, directory, { A: "generated", EXTRA: "yes" }, ); expect(secret?.stringData).toEqual({ A: "generated", TOKEN: "a=b=c", EXTRA: "yes", }); }); test("allows optional missing env files", async () => { const directory = await temporaryDirectory(); expect( await envFromToSecrets( "project", "app", { env_file: [{ path: "missing.env", required: false }] } as Service, directory, ), ).toEqual([]); }); test("rejects required missing env files", async () => { const directory = await temporaryDirectory(); await expect( envFromToSecrets( "project", "app", { env_file: "missing.env" } as Service, directory, ), ).rejects.toThrow(); }); }); describe("whole Compose conversion", () => { test("deduplicates shared PVCs and includes service-specific generated env", async () => { const compose = { services: { app: { image: "app", volumes: ["data:/data"], ports: [3000] }, worker: { image: "worker", volumes: ["data:/data"] }, }, volumes: { data: {} }, } as ComposeSpecification; const resources = await composeToKubernetes( "project", compose, process.cwd(), { app: { GENERATED: "yes" }, }, ); expect( resources.filter((resource) => resource.kind === "Namespace"), ).toHaveLength(1); expect( resources.filter((resource) => resource.kind === "PersistentVolumeClaim"), ).toHaveLength(1); expect( resources.filter((resource) => resource.kind === "StorageClass"), ).toHaveLength(1); expect( resources.filter((resource) => resource.kind === "Deployment"), ).toHaveLength(2); expect( resources.filter((resource) => resource.kind === "Service"), ).toHaveLength(1); const secret = resources.find((resource) => resource.kind === "Secret"); expect(secret?.metadata?.name).toBe("app-env"); const deployment = resources.find( (resource) => resource.kind === "Deployment" && resource.metadata?.name === "app", ) as V1Deployment | undefined; const checksum = deployment?.spec?.template?.metadata?.annotations?.[ "kuber.astrxl.dev/env-checksum" ]; expect(checksum).toMatch(/^[a-f0-9]{64}$/); const changedResources = await composeToKubernetes( "project", compose, process.cwd(), { app: { GENERATED: "changed" } }, ); const changedDeployment = changedResources.find( (resource) => resource.kind === "Deployment" && resource.metadata?.name === "app", ) as V1Deployment | undefined; expect( changedDeployment?.spec?.template?.metadata?.annotations?.[ "kuber.astrxl.dev/env-checksum" ], ).not.toBe(checksum); }); test("renders protected routes with normalized, deduplicated paths", async () => { const compose = { services: { app: { image: "app", ports: ["app.example.com:3000:protected(api,/admin,api)"], }, }, } as ComposeSpecification; const resources = await composeToKubernetes("project", compose); const route = resources.find( (resource) => resource.kind === "IngressRoute", ) as | ((typeof resources)[number] & { spec?: { routes?: { match: string }[] }; }) | undefined; expect(route?.spec?.routes?.map((entry) => entry.match)).toEqual([ "Host(`app.example.com`) && PathPrefix(`/api`)", "Host(`app.example.com`) && PathPrefix(`/admin`)", ]); expect(resources.some((resource) => resource.kind === "Ingress")).toBe( false, ); }); });