Files
kuber/tests/lib/convert-deployment.test.ts
T
dmgnr 6c8c8e0c1b feat: implement rollback command for deployments
- Added a new `rollback` command to roll back deployments to their previous release.
- Introduced `findRollbackCandidates` and `rollbackDeployment` functions to handle rollback logic.
- Updated CLI command definitions to include the new `rollback` command with an alias.
- Enhanced deployment conversion to use resolved images for buildable services.
- Introduced a new `graph` module to visualize Kubernetes resources and their relationships.
- Added tests for the new rollback functionality and graph rendering.
- Updated package version to 1.3.0.
2026-08-30 23:47:38 +07:00

266 lines
8.1 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import type { ComposeSpecification, Service } from "../../schema/docker.d";
import {
composeToKubernetes,
composeToNamespace,
serviceToDeployment,
serviceToIngress,
serviceToSvc,
} from "../../lib/convert";
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("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" },
},
});
});
});