343 lines
10 KiB
TypeScript
343 lines
10 KiB
TypeScript
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<string> {
|
|
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,
|
|
);
|
|
});
|
|
});
|