feat: improve deployment workflows
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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" } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user