From 6ab5123f0083148bd81b4c9ed669919edccec992 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Sat, 29 Aug 2026 06:38:37 +0000 Subject: [PATCH] feat: improve deployment workflows --- README.md | 32 ++- bun.lock | 3 + command/down.ts | 4 +- command/main.ts | 39 ++++ command/restart.ts | 3 +- command/stop.ts | 9 +- command/up.ts | 79 +++---- index.ts | 39 +--- lib/build.ts | 57 ++++- lib/context.ts | 15 +- lib/convert.ts | 26 ++- lib/database.ts | 2 +- package.json | 3 + tests/command/metadata.test.ts | 84 +++++++ tests/command/up.test.ts | 64 ++++++ tests/command/validation.test.ts | 28 +++ tests/lib/apply.test.ts | 60 +++++ tests/lib/convert-deployment.test.ts | 257 +++++++++++++++++++++ tests/lib/convert-storage-env.test.ts | 313 ++++++++++++++++++++++++++ tests/lib/core.test.ts | 165 ++++++++++++++ tests/lib/database.test.ts | 97 ++++++++ {lib => tests/lib}/storage.test.ts | 52 +++-- tests/lib/yaml.test.ts | 72 ++++++ 23 files changed, 1392 insertions(+), 111 deletions(-) create mode 100644 command/main.ts create mode 100644 tests/command/metadata.test.ts create mode 100644 tests/command/up.test.ts create mode 100644 tests/command/validation.test.ts create mode 100644 tests/lib/apply.test.ts create mode 100644 tests/lib/convert-deployment.test.ts create mode 100644 tests/lib/convert-storage-env.test.ts create mode 100644 tests/lib/core.test.ts create mode 100644 tests/lib/database.test.ts rename {lib => tests/lib}/storage.test.ts (58%) create mode 100644 tests/lib/yaml.test.ts diff --git a/README.md b/README.md index acc2756..d425bf9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/bun.lock b/bun.lock index 5fa225e..915a8f9 100644 --- a/bun.lock +++ b/bun.lock @@ -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/json-schema-ref-parser@11.9.3", "", { "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/tab@0.0.22", "", { "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/cliffy__table@1.2.1", "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/ono@7.1.3", "", {}, "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="], diff --git a/command/down.ts b/command/down.ts index 8d0901a..02ec099 100644 --- a/command/down.ts +++ b/command/down.ts @@ -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(); diff --git a/command/main.ts b/command/main.ts new file mode 100644 index 0000000..18b6185 --- /dev/null +++ b/command/main.ts @@ -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 | undefined; + +export function initializeCompletion() { + completion ??= tab(main); + return completion; +} diff --git a/command/restart.ts b/command/restart.ts index 7ac0b14..596369f 100644 --- a/command/restart.ts +++ b/command/restart.ts @@ -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( diff --git a/command/stop.ts b/command/stop.ts index 8f90443..ea59aae 100644 --- a/command/stop.ts +++ b/command/stop.ts @@ -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( diff --git a/command/up.ts b/command/up.ts index 8e73bc4..5a2ef01 100644 --- a/command/up.ts +++ b/command/up.ts @@ -20,12 +20,13 @@ import { restartDeployment, waitForDeploymentRollout } from "../lib/shared"; type UpContext = { compose?: ComposeSpecification; + changedDeployments?: string[]; serviceEnv?: Record>; resources?: KubernetesResource[]; staleResources?: KubernetesResource[]; }; -function mergeServiceEnv( +export function mergeServiceEnv( current: Record> | undefined, next: Record>, ): Record> { @@ -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( + const taskCtx = await new Listr( [ { 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({ diff --git a/index.ts b/index.ts index 4968197..44ef10a 100644 --- a/index.ts +++ b/index.ts @@ -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; }); diff --git a/lib/build.ts b/lib/build.ts index 710c577..59ad049 100644 --- a/lib/build.ts +++ b/lib/build.ts @@ -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 { + 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 { 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 { +): Promise { 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, + }; }); } diff --git a/lib/context.ts b/lib/context.ts index 9ef5f88..87e88be 100644 --- a/lib/context.ts +++ b/lib/context.ts @@ -41,15 +41,16 @@ export function ctx() { return store; } -const cache: Record = {}; export function createCachedGetter( - key: string, + _key: string, fn: () => Promise, ): () => Promise { - return async () => { - if (key in cache) return cache[key] as T; - const result = await fn(); - cache[key] = result; - return result; + let cached: Promise | undefined; + return () => { + cached ??= fn().catch((error) => { + cached = undefined; + throw error; + }); + return cached; }; } diff --git a/lib/convert.ts b/lib/convert.ts index 78404ad..0408521 100644 --- a/lib/convert.ts +++ b/lib/convert.ts @@ -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); diff --git a/lib/database.ts b/lib/database.ts index ada0ec8..0f78aae 100644 --- a/lib/database.ts +++ b/lib/database.ts @@ -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"; diff --git a/package.json b/package.json index 77accbe..c987d3c 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/tests/command/metadata.test.ts b/tests/command/metadata.test.ts new file mode 100644 index 0000000..13fcb7b --- /dev/null +++ b/tests/command/metadata.test.ts @@ -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>; + subCommands?: Record; + 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"); + }); +}); diff --git a/tests/command/up.test.ts b/tests/command/up.test.ts new file mode 100644 index 0000000..931f9e0 --- /dev/null +++ b/tests/command/up.test.ts @@ -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); + }); +}); diff --git a/tests/command/validation.test.ts b/tests/command/validation.test.ts new file mode 100644 index 0000000..2e3b9a4 --- /dev/null +++ b/tests/command/validation.test.ts @@ -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; + subCommands?: Record; +}; + +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"); + }); +}); diff --git a/tests/lib/apply.test.ts b/tests/lib/apply.test.ts new file mode 100644 index 0000000..8ed6035 --- /dev/null +++ b/tests/lib/apply.test.ts @@ -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"); + }); +}); diff --git a/tests/lib/convert-deployment.test.ts b/tests/lib/convert-deployment.test.ts new file mode 100644 index 0000000..ab56413 --- /dev/null +++ b/tests/lib/convert-deployment.test.ts @@ -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 = {}) { + 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" }, + }, + }); + }); +}); diff --git a/tests/lib/convert-storage-env.test.ts b/tests/lib/convert-storage-env.test.ts new file mode 100644 index 0000000..b01996a --- /dev/null +++ b/tests/lib/convert-storage-env.test.ts @@ -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 { + 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, + ); + }); +}); diff --git a/tests/lib/core.test.ts b/tests/lib/core.test.ts new file mode 100644 index 0000000..29d0a97 --- /dev/null +++ b/tests/lib/core.test.ts @@ -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"); + }); +}); diff --git a/tests/lib/database.test.ts b/tests/lib/database.test.ts new file mode 100644 index 0000000..5554273 --- /dev/null +++ b/tests/lib/database.test.ts @@ -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: or postgresql:/"); + }); + + 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%23@c.database.svc.cluster.local:5432/my%2Fdatabase", + ); + }); +}); diff --git a/lib/storage.test.ts b/tests/lib/storage.test.ts similarity index 58% rename from lib/storage.test.ts rename to tests/lib/storage.test.ts index 690a351..1568c58 100644 --- a/lib/storage.test.ts +++ b/tests/lib/storage.test.ts @@ -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: or s3:/"); + }); + + 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( diff --git a/tests/lib/yaml.test.ts b/tests/lib/yaml.test.ts new file mode 100644 index 0000000..9a4598e --- /dev/null +++ b/tests/lib/yaml.test.ts @@ -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 { + 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(); + }); +});