Files
kuber/tests/lib/convert-storage-env.test.ts
T

314 lines
9.2 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,
} 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 default storage placement", () => {
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" } },
diskTag: ["fast"],
replicaCount: 2,
dataLocality: "none",
},
});
});
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" } },
diskTag: ["fast", "archive"],
replicaCount: 3,
dataLocality: "none",
},
});
});
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" } },
diskTag: ["bulk", "archive"],
replicaCount: 3,
dataLocality: "none",
});
});
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 === "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,
);
});
});