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
+31 -1
View File
@@ -45,6 +45,13 @@ During development:
bun run index.ts up
```
Run the dedicated unit suite and type checks:
```bash
bun run test
bun run typecheck
```
Other useful commands:
```bash
@@ -58,9 +65,20 @@ bun run index.ts restart
bun run index.ts db ls
```
### Shell Completion
Generate and load completions for your shell:
```bash
source <(kuber complete zsh)
source <(kuber complete bash)
```
For a permanent setup, write the generated script to a file and source it from your shell configuration. Fish and PowerShell are also supported through `kuber complete fish` and `kuber complete powershell`.
## Commands
- `up`: build images if needed, render manifests, apply them, restart deployments, and wait for rollout
- `up`: build images if needed, render manifests, apply them, restart deployments whose image content changed, and wait for rollout
- `start`: same as `up` but skips image builds
- `stop`: scale managed deployments to zero
- `restart`: roll out a restart across managed deployments
@@ -92,6 +110,16 @@ services:
That produces a Kubernetes `Ingress` rule for `somedomain.astrxl.dev` pointing at the service port for container port `3000`.
Single-level wildcard subdomains are supported. Quote wildcard entries so YAML does not treat the leading `*` as an alias:
```yml
services:
app:
ports:
- "*.astrxl.dev:3000"
- "*.secure.astrxl.dev:3001:protected"
```
Protected routes use the kuber dialect and render Traefik `IngressRoute` resources instead of plain Kubernetes `Ingress`:
```yml
@@ -250,6 +278,8 @@ Precedence:
## Building
Image builds and image-digest checks run through the selected SSH builder. If a pushed image has the same content fingerprint as the existing registry image, `up` does not restart that deployment.
To build distributable binaries:
```bash
+3
View File
@@ -5,6 +5,7 @@
"": {
"name": "kuber",
"dependencies": {
"@bomb.sh/tab": "^0.0.22",
"@cliffy/table": "npm:@jsr/cliffy__table",
"@kubernetes/client-node": "^1.4.0",
"citty": "^0.2.2",
@@ -25,6 +26,8 @@
"packages": {
"@apidevtools/json-schema-ref-parser": ["@apidevtools/[email protected]", "", { "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.0" } }, "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ=="],
"@bomb.sh/tab": ["@bomb.sh/[email protected]", "", { "peerDependencies": { "cac": "^6.7.14 || ^7.0.0", "citty": "^0.1.6 || ^0.2.0", "commander": "^13.1.0 || ^14.0.0 || ^15.0.0" }, "optionalPeers": ["cac", "citty", "commander"], "bin": { "tab": "dist/bin/cli.mjs" } }, "sha512-l5IuWpV7szqG4MM6A7I+qPMmP2qPMq2TD4veIXaOH0sD53dV0Tv8WZPARp5O5Kp4+y4fnVCl3VL1HZKNghO1yg=="],
"@cliffy/table": ["@jsr/[email protected]", "https://npm.jsr.io/~/11/@jsr/cliffy__table/1.2.1.tgz", { "dependencies": { "@jsr/std__fmt": "^1.0.10" } }, "sha512-C/edvcMwtnbFx0zyoeGRcC+ZRvGYqxEhRi19HpfbfiYS4ZErKjEwMJmavO/DN7Wxbk34UpJP+UQKL7nKe+dYkA=="],
"@jsdevtools/ono": ["@jsdevtools/[email protected]", "", {}, "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="],
+2 -2
View File
@@ -45,11 +45,11 @@ export const down = defineCommand({
});
}
if (resources.length === 0) return;
await new Listr([
{
title: "Delete resources",
skip: () =>
resources.length > 0 ? false : "No managed resources to delete",
task: (_ctx, task) => {
const ordered = sortResources(resources).reverse();
+39
View File
@@ -0,0 +1,39 @@
import tab from "@bomb.sh/tab/citty";
import { defineCommand } from "citty";
import { db } from "./db";
import { down } from "./down";
import { exec } from "./exec";
import { exportCommand } from "./export";
import { logs } from "./logs";
import { ps } from "./ps";
import { restart } from "./restart";
import { start } from "./start";
import { stop } from "./stop";
import { up } from "./up";
export const main = defineCommand({
meta: {
name: "kuber",
version: "1.0.0",
description: "Docker Compose -> K8s translation layer",
},
subCommands: {
db,
down,
export: exportCommand,
exec,
logs,
ps,
restart,
start,
stop,
up,
},
});
let completion: ReturnType<typeof tab> | undefined;
export function initializeCompletion() {
completion ??= tab(main);
return completion;
}
+1 -2
View File
@@ -15,12 +15,11 @@ export const restart = defineCommand({
async run() {
await assertManagedNamespace(getProject());
const deployments = await listManagedDeployments();
if (deployments.length === 0) return;
await new Listr([
{
title: "Restart deployments",
skip: () =>
deployments.length > 0 ? false : "No managed deployments found",
task: (_ctx, task) => {
task.output = `${deployments.length} deployments queued`;
return task.newListr(
+6 -3
View File
@@ -1,7 +1,11 @@
import { defineCommand } from "citty";
import { Listr } from "listr2";
import { assertManagedNamespace } from "../lib/apply";
import { getProject, listManagedDeployments, scaleDeployment } from "../lib/shared";
import {
getProject,
listManagedDeployments,
scaleDeployment,
} from "../lib/shared";
export const stop = defineCommand({
meta: {
@@ -11,12 +15,11 @@ export const stop = defineCommand({
async run() {
await assertManagedNamespace(getProject());
const deployments = await listManagedDeployments();
if (deployments.length === 0) return;
await new Listr([
{
title: "Scale deployments",
skip: () =>
deployments.length > 0 ? false : "No managed deployments found",
task: (_ctx, task) => {
task.output = `${deployments.length} deployments queued`;
return task.newListr(
+41 -38
View File
@@ -20,12 +20,13 @@ import { restartDeployment, waitForDeploymentRollout } from "../lib/shared";
type UpContext = {
compose?: ComposeSpecification;
changedDeployments?: string[];
serviceEnv?: Record<string, Record<string, string>>;
resources?: KubernetesResource[];
staleResources?: KubernetesResource[];
};
function mergeServiceEnv(
export function mergeServiceEnv(
current: Record<string, Record<string, string>> | undefined,
next: Record<string, Record<string, string>>,
): Record<string, Record<string, string>> {
@@ -36,7 +37,7 @@ function mergeServiceEnv(
return merged;
}
function getDeploymentNames(resources: KubernetesResource[]): string[] {
export function getDeploymentNames(resources: KubernetesResource[]): string[] {
return resources
.filter((resource) => resource.kind === "Deployment")
.map((resource) => resource.metadata?.name)
@@ -47,7 +48,7 @@ export async function runUp(build: boolean) {
const { project, compose, cwd } = ctx();
await assertManagedNamespace(project);
await new Listr<UpContext>(
const taskCtx = await new Listr<UpContext>(
[
{
title: "Read compose",
@@ -62,29 +63,26 @@ export async function runUp(build: boolean) {
outputBar: 10,
persistentOutput: true,
},
enabled: () => build,
skip: (taskCtx) =>
Object.values(taskCtx.compose?.services ?? {}).some(
enabled: async () =>
build &&
Object.values((await compose()).services ?? {}).some(
(service) => service.build,
)
? false
: "No buildable services",
),
task: async (taskCtx, task) => {
const built = await buildServices(project, taskCtx.compose!, cwd, {
const result = await buildServices(project, taskCtx.compose!, cwd, {
progress: (message) => {
task.output = message;
},
stream: task.stdout(),
});
task.output = `Built ${built} image${built === 1 ? "" : "s"}`;
taskCtx.changedDeployments = result.changed;
task.output = `Built ${result.built.length} image${result.built.length === 1 ? "" : "s"}, ${result.changed.length} changed`;
},
},
{
title: "Reconcile databases",
skip: (taskCtx) =>
getComposePostgresClaims(taskCtx.compose!).length > 0
? false
: "No managed postgres volumes",
enabled: async () =>
getComposePostgresClaims(await compose()).length > 0,
task: async (taskCtx, task) => {
taskCtx.serviceEnv = mergeServiceEnv(
taskCtx.serviceEnv,
@@ -95,10 +93,7 @@ export async function runUp(build: boolean) {
},
{
title: "Reconcile S3 storage",
skip: (taskCtx) =>
getComposeS3Claims(taskCtx.compose!).length > 0
? false
: "No managed S3 volumes",
enabled: async () => getComposeS3Claims(await compose()).length > 0,
task: async (taskCtx, task) => {
taskCtx.serviceEnv = mergeServiceEnv(
taskCtx.serviceEnv,
@@ -144,14 +139,19 @@ export async function runUp(build: boolean) {
);
},
},
{
title: "Restart deployments",
task: (taskCtx, task) => {
const deployments = getDeploymentNames(taskCtx.resources!);
],
{ rendererOptions: { collapseErrors: false } },
).run();
task.output = `${deployments.length} deployments queued`;
const changedDeployments = taskCtx.changedDeployments ?? [];
if (changedDeployments.length > 0) {
await new Listr([
{
title: "Restart changed deployments",
task: (_taskCtx, task) => {
task.output = `${changedDeployments.length} deployments queued`;
return task.newListr(
deployments.map((name) => ({
changedDeployments.map((name) => ({
title: `Deployment ${name}`,
task: () => restartDeployment(name),
})),
@@ -159,11 +159,15 @@ export async function runUp(build: boolean) {
);
},
},
]).run();
}
const deployments = getDeploymentNames(taskCtx.resources!);
if (deployments.length > 0) {
await new Listr([
{
title: "Wait for rollout",
task: (taskCtx, task) => {
const deployments = getDeploymentNames(taskCtx.resources!);
task: (_taskCtx, task) => {
task.output = `${deployments.length} deployments queued`;
return task.newListr(
deployments.map((name) => ({
@@ -174,15 +178,15 @@ export async function runUp(build: boolean) {
);
},
},
]).run();
}
if (taskCtx.staleResources && taskCtx.staleResources.length > 0) {
const resources = sortResources(taskCtx.staleResources).reverse();
await new Listr([
{
title: "Delete stale resources",
skip: (taskCtx) =>
taskCtx.staleResources && taskCtx.staleResources.length > 0
? false
: "No stale resources",
task: (taskCtx, task) => {
const resources = sortResources(taskCtx.staleResources!).reverse();
task: (_taskCtx, task) => {
task.output = `${resources.length} resources queued`;
return task.newListr(
resources.map((resource) => ({
@@ -193,9 +197,8 @@ export async function runUp(build: boolean) {
);
},
},
],
{ rendererOptions: { collapseErrors: false } },
).run();
]).run();
}
}
export const up = defineCommand({
+8 -31
View File
@@ -1,14 +1,5 @@
import { defineCommand, runMain } from "citty";
import { db } from "./command/db";
import { down } from "./command/down";
import { exportCommand } from "./command/export";
import { exec } from "./command/exec";
import { logs } from "./command/logs";
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";
import { createMain } from "citty";
import { initializeCompletion, main } from "./command/main";
import { provideContext } from "./lib/context";
import z, { ZodError } from "zod";
@@ -27,27 +18,13 @@ function formatUnknownError(error: unknown): string {
}
}
const main = defineCommand({
meta: {
name: "kuber",
version: "1.0.0",
description: "Docker Compose -> K8s translation layer",
},
subCommands: {
db,
down,
export: exportCommand,
exec,
logs,
ps,
restart,
start,
stop,
up,
},
});
async function run() {
await initializeCompletion();
const cli = createMain(main);
await provideContext(() => cli());
}
provideContext(() => runMain(main)).catch((e) => {
run().catch((e) => {
console.error(formatUnknownError(e));
process.exitCode = 1;
});
+52 -5
View File
@@ -32,6 +32,11 @@ type BuildReporter = {
stream?: Writable;
};
export type BuildResult = {
built: string[];
changed: string[];
};
type SpawnResult = {
exitCode: number;
stdout: string;
@@ -266,6 +271,39 @@ function scp(localPath: string, remotePath: string) {
});
}
async function getRemoteImageDigest(
image: string,
): Promise<string | undefined> {
const inspectCommand = [
"docker",
"buildx",
"imagetools",
"inspect",
image,
"--format",
"{{json .Image}}",
]
.map(shellQuote)
.join(" ");
try {
const result = await runWithOutput(
ssh(`set -euo pipefail; ${inspectCommand} | sha256sum`),
);
const digest = /^([a-f0-9]{64})\s/.exec(result.stdout)?.[1];
return digest ? `sha256:${digest}` : undefined;
} catch {
return;
}
}
export function imageDigestChanged(
before: string | undefined,
after: string | undefined,
): boolean {
return !before || !after || before !== after;
}
async function getRepoRoot(cwd: string): Promise<string> {
return Bun.$.cwd(cwd)`git rev-parse --show-toplevel`
.text()
@@ -537,10 +575,12 @@ export async function buildServices(
compose: ComposeSpecification,
cwd = process.cwd(),
reporter?: BuildReporter,
): Promise<number> {
): Promise<BuildResult> {
return withComposeArch(compose, async () => {
if (!Object.values(compose.services ?? {}).some((service) => service.build)) {
return 0;
if (
!Object.values(compose.services ?? {}).some((service) => service.build)
) {
return { built: [], changed: [] };
}
const repoRoot = await getRepoRoot(cwd);
@@ -550,17 +590,24 @@ export async function buildServices(
: `${REMOTE_BUILD_ROOT}/${basename(repoRoot)}`;
const plans = getBuildPlans(project, compose, cwd, repoRoot, buildRoot);
if (plans.length === 0) return 0;
if (plans.length === 0) return { built: [], changed: [] };
if (!localBuilder) {
await syncRemoteRepo(repoRoot, buildRoot, reporter);
}
const changed: string[] = [];
for (const plan of plans) {
const before = await getRemoteImageDigest(plan.image);
if (localBuilder) await buildLocal(plan, reporter);
else await buildRemote(plan, reporter);
const after = await getRemoteImageDigest(plan.image);
if (imageDigestChanged(before, after)) changed.push(plan.name);
}
return plans.length;
return {
built: plans.map((plan) => plan.name),
changed,
};
});
}
+8 -7
View File
@@ -41,15 +41,16 @@ export function ctx() {
return store;
}
const cache: Record<string, unknown> = {};
export function createCachedGetter<T>(
key: string,
_key: string,
fn: () => Promise<T>,
): () => Promise<T> {
return async () => {
if (key in cache) return cache[key] as T;
const result = await fn();
cache[key] = result;
return result;
let cached: Promise<T> | undefined;
return () => {
cached ??= fn().catch((error) => {
cached = undefined;
throw error;
});
return cached;
};
}
+23 -3
View File
@@ -13,6 +13,7 @@ import type {
V1Volume,
V1VolumeMount,
} from "@kubernetes/client-node";
import { createHash } from "node:crypto";
import { existsSync, readFileSync, statSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { basename, resolve } from "node:path";
@@ -578,7 +579,11 @@ function toPorts(service: Service): NormalizedPort[] {
}
if (typeof entry === "string") {
const [rawPortSpec, protocolSpec] = entry.split("/");
const protocolMatch = /\/(tcp|udp)$/i.exec(entry);
const rawPortSpec = protocolMatch
? entry.slice(0, -protocolMatch[0].length)
: entry;
const protocolSpec = protocolMatch?.[1];
if (!rawPortSpec) return [];
const { portSpec, routingKind, paths } = parsePortRoute(rawPortSpec);
@@ -1053,6 +1058,13 @@ export async function envFromToSecrets(
];
}
function checksumSecret(secret: V1Secret): string {
const entries = Object.entries(secret.stringData ?? {}).sort(
([left], [right]) => left.localeCompare(right),
);
return createHash("sha256").update(JSON.stringify(entries)).digest("hex");
}
export function composeToNamespace(project: string): V1Namespace {
return {
apiVersion: "v1",
@@ -1103,13 +1115,14 @@ export async function composeToKubernetes(
resources.set(getResourceKey(configMap), configMap);
}
for (const secret of await envFromToSecrets(
const envSecrets = await envFromToSecrets(
project,
name,
service,
cwd,
serviceEnv[name] ?? {},
)) {
);
for (const secret of envSecrets) {
resources.set(getResourceKey(secret), secret);
}
@@ -1125,6 +1138,13 @@ export async function composeToKubernetes(
serviceEnv[name] ?? {},
compose.volumes,
);
const envSecret = envSecrets[0];
if (envSecret) {
deployment.spec!.template.metadata!.annotations = {
...deployment.spec?.template.metadata?.annotations,
"kuber.astrxl.dev/env-checksum": checksumSecret(envSecret),
};
}
resources.set(getResourceKey(deployment), deployment);
const ingress = serviceToIngress(project, name, service);
+1 -1
View File
@@ -7,7 +7,7 @@ import { objectApi } from "./k8s";
export const DATABASE_NAMESPACE = "database";
export const DATABASE_CLUSTER = "postgres";
export const DATABASE_HOST = `${DATABASE_CLUSTER}-rw.${DATABASE_NAMESPACE}.svc.cluster.local`;
export const DATABASE_HOST = `c.${DATABASE_NAMESPACE}.svc.cluster.local`;
export const DATABASE_PORT = 5432;
export const DATABASE_PROJECT_LABEL = "kuber.dev/project";
export const DATABASE_SERVICE_LABEL = "kuber.dev/service";
+3
View File
@@ -13,6 +13,7 @@
"typescript": "^5"
},
"dependencies": {
"@bomb.sh/tab": "^0.0.22",
"@cliffy/table": "npm:@jsr/cliffy__table",
"@kubernetes/client-node": "^1.4.0",
"citty": "^0.2.2",
@@ -20,6 +21,8 @@
"zod": "^4.4.3"
},
"scripts": {
"test": "bun test tests",
"typecheck": "tsc --noEmit",
"prod": "bun run build && cp dist/kuber ~/.bun/bin/ && scp dist/kuber-arm64 root@astral:/usr/bin/kuber"
}
}
+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",
);
});
});
@@ -1,21 +1,17 @@
import { describe, expect, test } from "bun:test";
import type { ComposeSpecification, Service } from "../schema/docker.d";
import { serviceToDeployment, volumesToPvc } from "./convert";
import type { ComposeSpecification, Service } from "../../schema/docker.d";
import { serviceToDeployment, volumesToPvc } from "../../lib/convert";
import {
getComposeS3Claims,
getServiceS3Claim,
isS3VolumeEntry,
} from "./storage";
} 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",
});
).toEqual({ service: "app", key: "assets", bucket: "assets" });
});
test("supports explicit key and bucket names", () => {
@@ -23,17 +19,30 @@ describe("managed S3 claims", () => {
getServiceS3Claim("app", {
volumes: ["s3:app-key/shared-assets"],
} as Service),
).toEqual({
service: "app",
key: "app-key",
bucket: "shared-assets",
});
).toEqual({ service: "app", key: "app-key", bucket: "shared-assets" });
});
test("rejects malformed and duplicate declarations", () => {
test.each([
"s3:",
"s3:key/",
"s3:/bucket",
"s3:key/bucket/extra",
"s3:key:bucket",
])("rejects malformed declaration %s", (entry) => {
expect(() =>
getServiceS3Claim("app", { volumes: ["s3:"] } as Service),
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"],
@@ -41,6 +50,16 @@ describe("managed S3 claims", () => {
).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: {
@@ -48,7 +67,6 @@ describe("managed S3 claims", () => {
worker: { volumes: ["s3:shared/two"] },
},
} as ComposeSpecification;
expect(() => getComposeS3Claims(compose)).toThrow(
"S3 key shared is claimed for both one and two",
);
@@ -56,8 +74,6 @@ describe("managed S3 claims", () => {
test("does not render S3 declarations as filesystem volumes", () => {
const service = { image: "example", volumes: ["s3:assets"] } as Service;
expect(isS3VolumeEntry("s3:assets")).toBe(true);
expect(volumesToPvc("project", service)).toEqual([]);
const deployment = serviceToDeployment(
+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();
});
});