feat: improve deployment workflows

This commit is contained in:
2026-08-29 06:38:37 +00:00 Unverified
parent a1705487f2
commit 6ab5123f00
23 changed files with 1392 additions and 111 deletions
+84
View File
@@ -0,0 +1,84 @@
import { describe, expect, test } from "bun:test";
import { db } from "../../command/db";
import { down } from "../../command/down";
import { exec } from "../../command/exec";
import { exportCommand } from "../../command/export";
import { logs } from "../../command/logs";
import { initializeCompletion, main } from "../../command/main";
import { ps } from "../../command/ps";
import { restart } from "../../command/restart";
import { start } from "../../command/start";
import { stop } from "../../command/stop";
import { up } from "../../command/up";
type Command = {
meta?: { name?: string; description?: string };
args?: Record<string, Record<string, unknown>>;
subCommands?: Record<string, Command>;
run?: unknown;
};
describe("CLI command definitions", () => {
const commands = [
["up", "Create and start deployments", up],
["start", "Start deployments without rebuilding images", start],
["stop", "Scale managed deployments to zero", stop],
["restart", "Roll out a restart for managed deployments", restart],
["down", "Delete managed resources except ingress and PVCs", down],
["ps", "List deployments", ps],
["logs", "Show deployment logs", logs],
["exec", "Execute a command inside a deployment pod", exec],
["export", "Write rendered manifests to a YAML file", exportCommand],
["db", "Inspect managed postgres databases", db],
] as const;
test.each(commands)(
"defines %s command metadata",
(name, description, command) => {
const definition = command as Command;
expect(definition.meta).toEqual({ name, description });
if (name !== "db") expect(typeof definition.run).toBe("function");
},
);
test("defines build, cleanup, listing, logging, and export flags", () => {
expect((up as Command).args?.build).toMatchObject({
type: "boolean",
alias: "b",
default: true,
});
expect((down as Command).args?.full).toMatchObject({
type: "boolean",
alias: "f",
});
expect((ps as Command).args?.all).toMatchObject({
type: "boolean",
alias: "a",
});
expect((logs as Command).args?.follow).toMatchObject({
type: "boolean",
alias: "f",
});
expect((exportCommand as Command).args?.output).toMatchObject({
type: "string",
alias: "o",
});
});
test("defines both database inspection subcommands", () => {
const subCommands = (db as Command).subCommands;
expect(Object.keys(subCommands ?? {}).sort()).toEqual(["creds", "ls"]);
expect(subCommands?.ls?.meta?.name).toBe("ls");
expect(subCommands?.creds?.meta?.name).toBe("creds");
expect(typeof subCommands?.ls?.run).toBe("function");
expect(typeof subCommands?.creds?.run).toBe("function");
});
test("registers shell completion on the root command", async () => {
await initializeCompletion();
expect((await Promise.resolve(main.meta))?.name).toBe("kuber");
expect(
Object.keys((await Promise.resolve(main.subCommands)) ?? {}),
).toContain("complete");
});
});
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, test } from "bun:test";
import { getDeploymentNames, mergeServiceEnv } from "../../command/up";
import { buildServices, imageDigestChanged } from "../../lib/build";
import type { KubernetesResource } from "../../lib/convert";
import type { ComposeSpecification } from "../../schema/docker.d";
describe("up task selection", () => {
test("merges generated service environments without dropping providers", () => {
expect(
mergeServiceEnv(
{
app: { DATABASE_URL: "postgresql://database" },
worker: { EXISTING: "yes" },
},
{
app: { AWS_ACCESS_KEY_ID: "key" },
},
),
).toEqual({
app: {
DATABASE_URL: "postgresql://database",
AWS_ACCESS_KEY_ID: "key",
},
worker: { EXISTING: "yes" },
});
});
test("selects only named Deployment resources for rollout waiting", () => {
const resources = [
{
apiVersion: "apps/v1",
kind: "Deployment",
metadata: { name: "app" },
},
{
apiVersion: "apps/v1",
kind: "Deployment",
metadata: {},
},
{ apiVersion: "v1", kind: "Service", metadata: { name: "app" } },
] as KubernetesResource[];
expect(getDeploymentNames(resources)).toEqual(["app"]);
});
test("reports no built deployments without invoking build infrastructure", async () => {
expect(
await buildServices(
"project",
{
services: { app: { image: "nginx:latest" } },
} as ComposeSpecification,
process.cwd(),
),
).toEqual({ built: [], changed: [] });
});
test("restarts only for changed or unknown image digests", () => {
const digest = `sha256:${"a".repeat(64)}`;
expect(imageDigestChanged(digest, digest)).toBe(false);
expect(imageDigestChanged(digest, `sha256:${"b".repeat(64)}`)).toBe(true);
expect(imageDigestChanged(undefined, digest)).toBe(true);
expect(imageDigestChanged(digest, undefined)).toBe(true);
});
});
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test";
import { db } from "../../command/db";
import { exec } from "../../command/exec";
type RunnableCommand = {
run?: (context: { args: { _: string[] } }) => Promise<unknown>;
subCommands?: Record<string, RunnableCommand>;
};
describe("CLI argument validation", () => {
test("exec requires a deployment", async () => {
await expect(
(exec as RunnableCommand).run?.({ args: { _: [] } }),
).rejects.toThrow("Deployment name is required");
});
test("exec requires a command", async () => {
await expect(
(exec as RunnableCommand).run?.({ args: { _: ["app"] } }),
).rejects.toThrow("Command is required");
});
test("database credentials require a service", async () => {
await expect(
(db as RunnableCommand).subCommands?.creds?.run?.({ args: { _: [] } }),
).rejects.toThrow("Service name is required");
});
});
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, test } from "bun:test";
import type { KubernetesObject } from "@kubernetes/client-node";
import { getResourceKey, sortResources } from "../../lib/apply";
function resource(kind: string, name = kind): KubernetesObject {
return { apiVersion: "v1", kind, metadata: { name, namespace: "project" } };
}
describe("resource ordering", () => {
test("sorts creation dependencies in deterministic order", () => {
const input = [
resource("Database"),
resource("IngressRoute"),
resource("Deployment"),
resource("GarageKey"),
resource("Service"),
resource("GarageBucket"),
resource("ConfigMap"),
resource("Secret"),
resource("PersistentVolumeClaim"),
resource("Namespace"),
resource("Ingress"),
];
expect(sortResources(input).map((item) => item.kind)).toEqual([
"Namespace",
"GarageBucket",
"GarageKey",
"PersistentVolumeClaim",
"Secret",
"ConfigMap",
"Service",
"Deployment",
"Ingress",
"IngressRoute",
"Database",
]);
expect(input[0]?.kind).toBe("Database");
});
test("preserves order for unknown resource kinds", () => {
expect(
sortResources([resource("First"), resource("Second")]).map(
(item) => item.kind,
),
).toEqual(["First", "Second"]);
});
test("builds keys with kind, namespace, and name", () => {
expect(getResourceKey(resource("Secret", "app-env"))).toBe(
"Secret:project:app-env",
);
expect(
getResourceKey({
apiVersion: "v1",
kind: "Namespace",
metadata: { name: "app" },
}),
).toBe("Namespace::app");
});
});
+257
View File
@@ -0,0 +1,257 @@
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 registry image for buildable services", () => {
const service = { build: ".", image: "ignored:latest" } as Service;
const deployment = serviceToDeployment(
"project",
"web",
compose(service),
service,
);
expect(deployment.spec?.template.spec?.containers[0]?.image).toBe(
"registry.neko-piranha.ts.net/kuber/project-web:latest",
);
});
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" },
},
});
});
});
+313
View File
@@ -0,0 +1,313 @@
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,
);
});
});
+165
View File
@@ -0,0 +1,165 @@
import { describe, expect, test } from "bun:test";
import type { V1Pod } from "@kubernetes/client-node";
import type { ComposeSpecification } from "../../schema/docker.d";
import {
getComposeArch,
getComposeArchPlacement,
resolveComposeArch,
withComposeArch,
} from "../../lib/arch";
import { createCachedGetter, ctx } from "../../lib/context";
import { toEnvVars, toTable } from "../../lib/format";
import { deepMerge, getPodContainerName, throwWhen } from "../../lib/shared";
describe("architecture selection", () => {
test("defaults to arm64 and recognizes amd64", () => {
expect(resolveComposeArch({} as ComposeSpecification)).toBe("arm64");
expect(
resolveComposeArch({ "x-arch": "arm64" } as ComposeSpecification),
).toBe("arm64");
expect(
resolveComposeArch({ "x-arch": "amd64" } as ComposeSpecification),
).toBe("amd64");
});
test("provides amd64 placement and no arm64 constraints", () => {
expect(getComposeArchPlacement({} as ComposeSpecification)).toEqual({});
expect(
getComposeArchPlacement({ "x-arch": "amd64" } as ComposeSpecification),
).toEqual({
nodeSelector: { "kubernetes.io/arch": "amd64" },
tolerations: [
{
key: "arch",
operator: "Equal",
value: "amd64",
effect: "NoExecute",
},
],
});
});
test("scopes architecture through synchronous and asynchronous work", async () => {
expect(getComposeArch()).toBe("arm64");
expect(
withComposeArch({ "x-arch": "amd64" } as ComposeSpecification, () =>
getComposeArch(),
),
).toBe("amd64");
await withComposeArch(
{ "x-arch": "amd64" } as ComposeSpecification,
async () => {
await Promise.resolve();
expect(getComposeArch()).toBe("amd64");
},
);
expect(getComposeArch()).toBe("arm64");
});
});
describe("format helpers", () => {
test("converts array environments and preserves embedded equals", () => {
expect(toEnvVars(["EMPTY", "A=one", "TOKEN=a=b=c"])).toEqual([
{ name: "EMPTY", value: undefined },
{ name: "A", value: "one" },
{ name: "TOKEN", value: "a=b=c" },
]);
});
test("converts object environment scalar values", () => {
expect(
toEnvVars({ STRING: "yes", NUMBER: 2, BOOL: false, NIL: null }),
).toEqual([
{ name: "STRING", value: "yes" },
{ name: "NUMBER", value: "2" },
{ name: "BOOL", value: "false" },
{ name: "NIL", value: undefined },
]);
expect(toEnvVars(undefined)).toBeUndefined();
});
test("renders uppercase table headers and rows", () => {
const table = toTable([{ name: "app", replicas: 2 }]);
expect(table).toContain("NAME");
expect(table).toContain("REPLICAS");
expect(table).toContain("app");
expect(table).toContain("2");
});
});
describe("shared helpers", () => {
test("deep merges objects and concatenates arrays without mutation", () => {
const target = { nested: { left: 1 }, list: [1], untouched: true };
const source = { nested: { right: 2 }, list: [2], added: "yes" };
expect(deepMerge(target, source)).toEqual({
nested: { left: 1, right: 2 },
list: [1, 2],
untouched: true,
added: "yes",
});
expect(target).toEqual({ nested: { left: 1 }, list: [1], untouched: true });
});
test.each([
[1, 2, 2],
[{ value: 1 }, null, null],
[null, { value: 2 }, { value: 2 }],
])("replaces non-mergeable values", (target, source, expected) => {
expect(deepMerge(target, source)).toEqual(expected);
});
test("throwWhen passes unmatched values and throws matched values", () => {
const rejectEmpty = throwWhen(
(value: string) => value.length === 0,
"empty",
);
expect(rejectEmpty("value")).toBe("value");
expect(() => rejectEmpty("")).toThrow("empty");
});
test("gets the first pod container or gives a useful error", () => {
expect(
getPodContainerName({
metadata: { name: "app-1" },
spec: { containers: [{ name: "app" }] },
} as V1Pod),
).toBe("app");
expect(() =>
getPodContainerName({ metadata: { name: "empty" } } as V1Pod),
).toThrow("No container found in pod empty");
});
});
describe("context helpers", () => {
test("rejects access outside an application context", () => {
expect(() => ctx()).toThrow("Not in context");
});
test("cached getters execute once and share an in-flight result", async () => {
let calls = 0;
const getter = createCachedGetter(
`test-${crypto.randomUUID()}`,
async () => {
calls += 1;
return "value";
},
);
expect(await Promise.all([getter(), getter()])).toEqual(["value", "value"]);
expect(await getter()).toBe("value");
expect(calls).toBe(1);
});
test("failed getter calls can retry", async () => {
let calls = 0;
const getter = createCachedGetter(
`test-${crypto.randomUUID()}`,
async () => {
calls += 1;
if (calls === 1) throw new Error("temporary");
return "recovered";
},
);
await expect(getter()).rejects.toThrow("temporary");
expect(await getter()).toBe("recovered");
});
});
+97
View File
@@ -0,0 +1,97 @@
import { describe, expect, test } from "bun:test";
import type { ComposeSpecification, Service } from "../../schema/docker.d";
import {
buildDatabaseUrl,
getComposePostgresClaims,
getServicePostgresClaim,
isPostgresVolumeEntry,
} from "../../lib/database";
describe("managed PostgreSQL claims", () => {
test("supports short and explicit syntax", () => {
expect(
getServicePostgresClaim("app", {
volumes: ["postgresql:app"],
} as Service),
).toEqual({
service: "app",
username: "app",
database: "app",
secretName: "postgres-app",
});
expect(
getServicePostgresClaim("app", {
volumes: ["postgresql:user/database"],
} as Service),
).toEqual({
service: "app",
username: "user",
database: "database",
secretName: "postgres-user",
});
});
test.each([
"postgresql:",
"postgresql:user/",
"postgresql:/database",
"postgresql:user/database/extra",
"postgresql:user:database",
])("rejects malformed declaration %s", (entry) => {
expect(() =>
getServicePostgresClaim("app", { volumes: [entry] } as Service),
).toThrow("Use postgresql:<name> or postgresql:<user>/<database>");
});
test("ignores unrelated entries and rejects duplicate declarations", () => {
expect(
getServicePostgresClaim("app", {
volumes: ["cache:/cache"],
} as Service),
).toBeUndefined();
expect(isPostgresVolumeEntry("cache:/cache")).toBe(false);
expect(() =>
getServicePostgresClaim("app", {
volumes: ["postgresql:one", "postgresql:two"],
} as Service),
).toThrow("declares multiple postgres volumes");
});
test("allows one owner to share a database", () => {
const compose = {
services: {
app: { volumes: ["postgresql:user/database"] },
worker: { volumes: ["postgresql:user/database"] },
},
} as ComposeSpecification;
expect(getComposePostgresClaims(compose)).toHaveLength(2);
});
test("rejects conflicting database owners", () => {
const compose = {
services: {
app: { volumes: ["postgresql:one/shared"] },
worker: { volumes: ["postgresql:two/shared"] },
},
} as ComposeSpecification;
expect(() => getComposePostgresClaims(compose)).toThrow(
"Database shared is claimed by both one and two",
);
});
test("escapes every connection URL component", () => {
expect(
buildDatabaseUrl(
{
service: "app",
username: "user@host",
database: "my/database",
secretName: "postgres-user",
},
{ username: "user@host", password: "p:a/ss?#" },
),
).toBe(
"postgresql://user%40host:p%3Aa%2Fss%3F%[email protected]:5432/my%2Fdatabase",
);
});
});
+92
View File
@@ -0,0 +1,92 @@
import { describe, expect, test } from "bun:test";
import type { ComposeSpecification, Service } from "../../schema/docker.d";
import { serviceToDeployment, volumesToPvc } from "../../lib/convert";
import {
getComposeS3Claims,
getServiceS3Claim,
isS3VolumeEntry,
} from "../../lib/storage";
describe("managed S3 claims", () => {
test("uses the same key and bucket for the short syntax", () => {
expect(
getServiceS3Claim("app", { volumes: ["s3:assets"] } as Service),
).toEqual({ service: "app", key: "assets", bucket: "assets" });
});
test("supports explicit key and bucket names", () => {
expect(
getServiceS3Claim("app", {
volumes: ["s3:app-key/shared-assets"],
} as Service),
).toEqual({ service: "app", key: "app-key", bucket: "shared-assets" });
});
test.each([
"s3:",
"s3:key/",
"s3:/bucket",
"s3:key/bucket/extra",
"s3:key:bucket",
])("rejects malformed declaration %s", (entry) => {
expect(() =>
getServiceS3Claim("app", { volumes: [entry] } as Service),
).toThrow("Use s3:<name> or s3:<key>/<bucket>");
});
test("ignores unrelated and long-form volumes", () => {
const service = {
volumes: ["cache:/cache", { type: "volume", target: "/data" }],
} as Service;
expect(getServiceS3Claim("app", service)).toBeUndefined();
expect(isS3VolumeEntry("cache:/cache")).toBe(false);
});
test("rejects multiple declarations on one service", () => {
expect(() =>
getServiceS3Claim("app", {
volumes: ["s3:one", "s3:two"],
} as Service),
).toThrow("declares multiple S3 volumes");
});
test("allows services to share the same key and bucket", () => {
const compose = {
services: {
app: { volumes: ["s3:shared/assets"] },
worker: { volumes: ["s3:shared/assets"] },
},
} as ComposeSpecification;
expect(getComposeS3Claims(compose)).toHaveLength(2);
});
test("rejects one key targeting different buckets", () => {
const compose = {
services: {
app: { volumes: ["s3:shared/one"] },
worker: { volumes: ["s3:shared/two"] },
},
} as ComposeSpecification;
expect(() => getComposeS3Claims(compose)).toThrow(
"S3 key shared is claimed for both one and two",
);
});
test("does not render S3 declarations as filesystem volumes", () => {
const service = { image: "example", volumes: ["s3:assets"] } as Service;
expect(volumesToPvc("project", service)).toEqual([]);
const deployment = serviceToDeployment(
"project",
"app",
{ services: { app: service } } as ComposeSpecification,
service,
process.cwd(),
{ AWS_ACCESS_KEY_ID: "test" },
);
expect(deployment.spec?.template.spec?.volumes).toBeUndefined();
expect(deployment.spec?.template.spec?.containers[0]?.envFrom).toEqual([
{ secretRef: { name: "app-env" } },
]);
});
});
+72
View File
@@ -0,0 +1,72 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { readCompose, resolveComposeFile } from "../../lib/yaml";
const temporaryDirectories: string[] = [];
async function temporaryDirectory(): Promise<string> {
const path = await mkdtemp(join(tmpdir(), "kuber-test-"));
temporaryDirectories.push(path);
return path;
}
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((path) => rm(path, { recursive: true, force: true })),
);
});
describe("Compose file discovery", () => {
test("finds supported names and chooses lexical precedence", async () => {
const directory = await temporaryDirectory();
await Promise.all([
writeFile(join(directory, "docker-compose.yml"), "services: {}\n"),
writeFile(join(directory, "compose.yaml"), "services: {}\n"),
writeFile(join(directory, "ignored.yml"), "services: {}\n"),
]);
expect(await resolveComposeFile(directory)).toBe(
join(directory, "compose.yaml"),
);
});
test("ignores directories with Compose-looking names", async () => {
const directory = await temporaryDirectory();
await mkdir(join(directory, "compose.yml"));
expect(await resolveComposeFile(directory)).toBeUndefined();
});
test("returns undefined when no supported file exists", async () => {
const directory = await temporaryDirectory();
await writeFile(join(directory, "stack.yml"), "services: {}\n");
expect(await resolveComposeFile(directory)).toBeUndefined();
});
});
describe("Compose parsing", () => {
test("parses and validates a minimal Compose project", async () => {
const directory = await temporaryDirectory();
const path = join(directory, "compose.yml");
await writeFile(path, "services:\n app:\n image: nginx:latest\n");
expect(await readCompose(path)).toMatchObject({
services: { app: { image: "nginx:latest" } },
});
});
test("rejects malformed YAML", async () => {
const directory = await temporaryDirectory();
const path = join(directory, "compose.yml");
await writeFile(path, "services: [\n");
await expect(readCompose(path)).rejects.toThrow();
});
test("rejects schema-invalid service values", async () => {
const directory = await temporaryDirectory();
const path = join(directory, "compose.yml");
await writeFile(path, "services:\n app: 42\n");
await expect(readCompose(path)).rejects.toThrow();
});
});