feat: improve deployment workflows
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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({
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { db } from "../../command/db";
|
||||
import { down } from "../../command/down";
|
||||
import { exec } from "../../command/exec";
|
||||
import { exportCommand } from "../../command/export";
|
||||
import { logs } from "../../command/logs";
|
||||
import { initializeCompletion, main } from "../../command/main";
|
||||
import { ps } from "../../command/ps";
|
||||
import { restart } from "../../command/restart";
|
||||
import { start } from "../../command/start";
|
||||
import { stop } from "../../command/stop";
|
||||
import { up } from "../../command/up";
|
||||
|
||||
type Command = {
|
||||
meta?: { name?: string; description?: string };
|
||||
args?: Record<string, Record<string, unknown>>;
|
||||
subCommands?: Record<string, Command>;
|
||||
run?: unknown;
|
||||
};
|
||||
|
||||
describe("CLI command definitions", () => {
|
||||
const commands = [
|
||||
["up", "Create and start deployments", up],
|
||||
["start", "Start deployments without rebuilding images", start],
|
||||
["stop", "Scale managed deployments to zero", stop],
|
||||
["restart", "Roll out a restart for managed deployments", restart],
|
||||
["down", "Delete managed resources except ingress and PVCs", down],
|
||||
["ps", "List deployments", ps],
|
||||
["logs", "Show deployment logs", logs],
|
||||
["exec", "Execute a command inside a deployment pod", exec],
|
||||
["export", "Write rendered manifests to a YAML file", exportCommand],
|
||||
["db", "Inspect managed postgres databases", db],
|
||||
] as const;
|
||||
|
||||
test.each(commands)(
|
||||
"defines %s command metadata",
|
||||
(name, description, command) => {
|
||||
const definition = command as Command;
|
||||
expect(definition.meta).toEqual({ name, description });
|
||||
if (name !== "db") expect(typeof definition.run).toBe("function");
|
||||
},
|
||||
);
|
||||
|
||||
test("defines build, cleanup, listing, logging, and export flags", () => {
|
||||
expect((up as Command).args?.build).toMatchObject({
|
||||
type: "boolean",
|
||||
alias: "b",
|
||||
default: true,
|
||||
});
|
||||
expect((down as Command).args?.full).toMatchObject({
|
||||
type: "boolean",
|
||||
alias: "f",
|
||||
});
|
||||
expect((ps as Command).args?.all).toMatchObject({
|
||||
type: "boolean",
|
||||
alias: "a",
|
||||
});
|
||||
expect((logs as Command).args?.follow).toMatchObject({
|
||||
type: "boolean",
|
||||
alias: "f",
|
||||
});
|
||||
expect((exportCommand as Command).args?.output).toMatchObject({
|
||||
type: "string",
|
||||
alias: "o",
|
||||
});
|
||||
});
|
||||
|
||||
test("defines both database inspection subcommands", () => {
|
||||
const subCommands = (db as Command).subCommands;
|
||||
expect(Object.keys(subCommands ?? {}).sort()).toEqual(["creds", "ls"]);
|
||||
expect(subCommands?.ls?.meta?.name).toBe("ls");
|
||||
expect(subCommands?.creds?.meta?.name).toBe("creds");
|
||||
expect(typeof subCommands?.ls?.run).toBe("function");
|
||||
expect(typeof subCommands?.creds?.run).toBe("function");
|
||||
});
|
||||
|
||||
test("registers shell completion on the root command", async () => {
|
||||
await initializeCompletion();
|
||||
expect((await Promise.resolve(main.meta))?.name).toBe("kuber");
|
||||
expect(
|
||||
Object.keys((await Promise.resolve(main.subCommands)) ?? {}),
|
||||
).toContain("complete");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { getDeploymentNames, mergeServiceEnv } from "../../command/up";
|
||||
import { buildServices, imageDigestChanged } from "../../lib/build";
|
||||
import type { KubernetesResource } from "../../lib/convert";
|
||||
import type { ComposeSpecification } from "../../schema/docker.d";
|
||||
|
||||
describe("up task selection", () => {
|
||||
test("merges generated service environments without dropping providers", () => {
|
||||
expect(
|
||||
mergeServiceEnv(
|
||||
{
|
||||
app: { DATABASE_URL: "postgresql://database" },
|
||||
worker: { EXISTING: "yes" },
|
||||
},
|
||||
{
|
||||
app: { AWS_ACCESS_KEY_ID: "key" },
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
app: {
|
||||
DATABASE_URL: "postgresql://database",
|
||||
AWS_ACCESS_KEY_ID: "key",
|
||||
},
|
||||
worker: { EXISTING: "yes" },
|
||||
});
|
||||
});
|
||||
|
||||
test("selects only named Deployment resources for rollout waiting", () => {
|
||||
const resources = [
|
||||
{
|
||||
apiVersion: "apps/v1",
|
||||
kind: "Deployment",
|
||||
metadata: { name: "app" },
|
||||
},
|
||||
{
|
||||
apiVersion: "apps/v1",
|
||||
kind: "Deployment",
|
||||
metadata: {},
|
||||
},
|
||||
{ apiVersion: "v1", kind: "Service", metadata: { name: "app" } },
|
||||
] as KubernetesResource[];
|
||||
expect(getDeploymentNames(resources)).toEqual(["app"]);
|
||||
});
|
||||
|
||||
test("reports no built deployments without invoking build infrastructure", async () => {
|
||||
expect(
|
||||
await buildServices(
|
||||
"project",
|
||||
{
|
||||
services: { app: { image: "nginx:latest" } },
|
||||
} as ComposeSpecification,
|
||||
process.cwd(),
|
||||
),
|
||||
).toEqual({ built: [], changed: [] });
|
||||
});
|
||||
|
||||
test("restarts only for changed or unknown image digests", () => {
|
||||
const digest = `sha256:${"a".repeat(64)}`;
|
||||
expect(imageDigestChanged(digest, digest)).toBe(false);
|
||||
expect(imageDigestChanged(digest, `sha256:${"b".repeat(64)}`)).toBe(true);
|
||||
expect(imageDigestChanged(undefined, digest)).toBe(true);
|
||||
expect(imageDigestChanged(digest, undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { db } from "../../command/db";
|
||||
import { exec } from "../../command/exec";
|
||||
|
||||
type RunnableCommand = {
|
||||
run?: (context: { args: { _: string[] } }) => Promise<unknown>;
|
||||
subCommands?: Record<string, RunnableCommand>;
|
||||
};
|
||||
|
||||
describe("CLI argument validation", () => {
|
||||
test("exec requires a deployment", async () => {
|
||||
await expect(
|
||||
(exec as RunnableCommand).run?.({ args: { _: [] } }),
|
||||
).rejects.toThrow("Deployment name is required");
|
||||
});
|
||||
|
||||
test("exec requires a command", async () => {
|
||||
await expect(
|
||||
(exec as RunnableCommand).run?.({ args: { _: ["app"] } }),
|
||||
).rejects.toThrow("Command is required");
|
||||
});
|
||||
|
||||
test("database credentials require a service", async () => {
|
||||
await expect(
|
||||
(db as RunnableCommand).subCommands?.creds?.run?.({ args: { _: [] } }),
|
||||
).rejects.toThrow("Service name is required");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { KubernetesObject } from "@kubernetes/client-node";
|
||||
import { getResourceKey, sortResources } from "../../lib/apply";
|
||||
|
||||
function resource(kind: string, name = kind): KubernetesObject {
|
||||
return { apiVersion: "v1", kind, metadata: { name, namespace: "project" } };
|
||||
}
|
||||
|
||||
describe("resource ordering", () => {
|
||||
test("sorts creation dependencies in deterministic order", () => {
|
||||
const input = [
|
||||
resource("Database"),
|
||||
resource("IngressRoute"),
|
||||
resource("Deployment"),
|
||||
resource("GarageKey"),
|
||||
resource("Service"),
|
||||
resource("GarageBucket"),
|
||||
resource("ConfigMap"),
|
||||
resource("Secret"),
|
||||
resource("PersistentVolumeClaim"),
|
||||
resource("Namespace"),
|
||||
resource("Ingress"),
|
||||
];
|
||||
expect(sortResources(input).map((item) => item.kind)).toEqual([
|
||||
"Namespace",
|
||||
"GarageBucket",
|
||||
"GarageKey",
|
||||
"PersistentVolumeClaim",
|
||||
"Secret",
|
||||
"ConfigMap",
|
||||
"Service",
|
||||
"Deployment",
|
||||
"Ingress",
|
||||
"IngressRoute",
|
||||
"Database",
|
||||
]);
|
||||
expect(input[0]?.kind).toBe("Database");
|
||||
});
|
||||
|
||||
test("preserves order for unknown resource kinds", () => {
|
||||
expect(
|
||||
sortResources([resource("First"), resource("Second")]).map(
|
||||
(item) => item.kind,
|
||||
),
|
||||
).toEqual(["First", "Second"]);
|
||||
});
|
||||
|
||||
test("builds keys with kind, namespace, and name", () => {
|
||||
expect(getResourceKey(resource("Secret", "app-env"))).toBe(
|
||||
"Secret:project:app-env",
|
||||
);
|
||||
expect(
|
||||
getResourceKey({
|
||||
apiVersion: "v1",
|
||||
kind: "Namespace",
|
||||
metadata: { name: "app" },
|
||||
}),
|
||||
).toBe("Namespace::app");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { ComposeSpecification, Service } from "../../schema/docker.d";
|
||||
import {
|
||||
composeToKubernetes,
|
||||
composeToNamespace,
|
||||
serviceToDeployment,
|
||||
serviceToIngress,
|
||||
serviceToSvc,
|
||||
} from "../../lib/convert";
|
||||
|
||||
function compose(service: Service, extra: Partial<ComposeSpecification> = {}) {
|
||||
return { ...extra, services: { app: service } } as ComposeSpecification;
|
||||
}
|
||||
|
||||
describe("Deployment conversion", () => {
|
||||
test("renders image, defaults, command, environment, and labels", () => {
|
||||
const service = {
|
||||
image: "nginx:latest",
|
||||
command: "nginx -g daemon-off",
|
||||
environment: ["MODE=prod", "EMPTY"],
|
||||
} as Service;
|
||||
const deployment = serviceToDeployment(
|
||||
"project",
|
||||
"app",
|
||||
compose(service),
|
||||
service,
|
||||
);
|
||||
const container = deployment.spec?.template.spec?.containers[0];
|
||||
|
||||
expect(deployment.metadata).toMatchObject({
|
||||
name: "app",
|
||||
namespace: "project",
|
||||
labels: { "app.kubernetes.io/managed-by": "kuber" },
|
||||
});
|
||||
expect(deployment.spec?.replicas).toBe(1);
|
||||
expect(container).toMatchObject({
|
||||
name: "app",
|
||||
image: "nginx:latest",
|
||||
imagePullPolicy: "Always",
|
||||
command: ["nginx", "-g", "daemon-off"],
|
||||
env: [
|
||||
{ name: "MODE", value: "prod" },
|
||||
{ name: "EMPTY", value: undefined },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("uses the registry image for buildable services", () => {
|
||||
const service = { build: ".", image: "ignored:latest" } as Service;
|
||||
const deployment = serviceToDeployment(
|
||||
"project",
|
||||
"web",
|
||||
compose(service),
|
||||
service,
|
||||
);
|
||||
expect(deployment.spec?.template.spec?.containers[0]?.image).toBe(
|
||||
"registry.neko-piranha.ts.net/kuber/project-web:latest",
|
||||
);
|
||||
});
|
||||
|
||||
test("prefers scale over deploy replicas and accepts command arrays", () => {
|
||||
const service = {
|
||||
image: "app",
|
||||
scale: 4,
|
||||
deploy: { replicas: 2 },
|
||||
command: ["server", "--port", "3000"],
|
||||
} as Service;
|
||||
const deployment = serviceToDeployment(
|
||||
"project",
|
||||
"app",
|
||||
compose(service),
|
||||
service,
|
||||
);
|
||||
expect(deployment.spec?.replicas).toBe(4);
|
||||
expect(deployment.spec?.template.spec?.containers[0]?.command).toEqual([
|
||||
"server",
|
||||
"--port",
|
||||
"3000",
|
||||
]);
|
||||
});
|
||||
|
||||
test("adds amd64 placement constraints", () => {
|
||||
const service = { image: "app" } as Service;
|
||||
const deployment = serviceToDeployment(
|
||||
"project",
|
||||
"app",
|
||||
compose(service, { "x-arch": "amd64" }),
|
||||
service,
|
||||
);
|
||||
expect(deployment.spec?.template.spec?.nodeSelector).toEqual({
|
||||
"kubernetes.io/arch": "amd64",
|
||||
});
|
||||
expect(deployment.spec?.template.spec?.tolerations?.[0]).toMatchObject({
|
||||
key: "arch",
|
||||
value: "amd64",
|
||||
});
|
||||
});
|
||||
|
||||
test("merges container and deployment extensions", () => {
|
||||
const service = {
|
||||
image: "app",
|
||||
"x-container": {
|
||||
securityContext: { readOnlyRootFilesystem: true },
|
||||
env: [{ name: "EXTRA", value: "yes" }],
|
||||
},
|
||||
"x-deployment": {
|
||||
metadata: { annotations: { owner: "platform" } },
|
||||
spec: { strategy: { type: "Recreate" } },
|
||||
},
|
||||
} as Service;
|
||||
const deployment = serviceToDeployment(
|
||||
"project",
|
||||
"app",
|
||||
compose(service),
|
||||
service,
|
||||
);
|
||||
expect(deployment.metadata?.annotations).toEqual({ owner: "platform" });
|
||||
expect(deployment.spec?.strategy?.type).toBe("Recreate");
|
||||
expect(
|
||||
deployment.spec?.template.spec?.containers[0]?.securityContext,
|
||||
).toEqual({
|
||||
readOnlyRootFilesystem: true,
|
||||
});
|
||||
expect(deployment.spec?.template.spec?.containers[0]?.env).toEqual([
|
||||
{ name: "EXTRA", value: "yes" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("references an env secret only when one will be rendered", () => {
|
||||
const service = { image: "app" } as Service;
|
||||
expect(
|
||||
serviceToDeployment("project", "app", compose(service), service).spec
|
||||
?.template.spec?.containers[0]?.envFrom,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
serviceToDeployment(
|
||||
"project",
|
||||
"app",
|
||||
compose(service),
|
||||
service,
|
||||
process.cwd(),
|
||||
{ GENERATED: "yes" },
|
||||
).spec?.template.spec?.containers[0]?.envFrom,
|
||||
).toEqual([{ secretRef: { name: "app-env" } }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("port and routing conversion", () => {
|
||||
test("renders numeric, published, UDP, and exposed ports", () => {
|
||||
const service = {
|
||||
image: "app",
|
||||
ports: [3000, "8080:80", "5353:53/udp"],
|
||||
expose: [9000, "9001/udp"],
|
||||
} as Service;
|
||||
const kubernetesService = serviceToSvc("project", "app", service);
|
||||
expect(kubernetesService?.spec?.ports).toEqual([
|
||||
{ name: "port-0", port: 3000, targetPort: 3000, protocol: "TCP" },
|
||||
{ name: "port-1", port: 8080, targetPort: 80, protocol: "TCP" },
|
||||
{ name: "port-2", port: 5353, targetPort: 53, protocol: "UDP" },
|
||||
{ name: "expose-0", port: 9000, targetPort: 9000, protocol: "TCP" },
|
||||
{ name: "expose-1", port: 9001, targetPort: 9001, protocol: "UDP" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("renders long-form named ports", () => {
|
||||
const service = {
|
||||
ports: [{ name: "http", target: 3000, published: "80", protocol: "tcp" }],
|
||||
} as Service;
|
||||
expect(serviceToSvc("project", "app", service)?.spec?.ports).toEqual([
|
||||
{ name: "http", port: 80, targetPort: 3000, protocol: "TCP" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("deduplicates service ports by number and protocol", () => {
|
||||
const service = { ports: ["8080:3000", "8080:4000"] } as Service;
|
||||
expect(serviceToSvc("project", "app", service)?.spec?.ports).toEqual([
|
||||
{ name: "port-1", port: 8080, targetPort: 4000, protocol: "TCP" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("renders host-based ingress and deduplicates hosts", () => {
|
||||
const service = {
|
||||
ports: ["app.example.com:3000", "app.example.com:4000"],
|
||||
} as Service;
|
||||
const ingress = serviceToIngress("project", "app", service);
|
||||
expect(ingress?.spec?.rules).toHaveLength(1);
|
||||
expect(ingress?.spec?.rules?.[0]).toMatchObject({
|
||||
host: "app.example.com",
|
||||
http: {
|
||||
paths: [
|
||||
{
|
||||
path: "/",
|
||||
backend: { service: { name: "app", port: { number: 3000 } } },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("renders wildcard hosts for normal and protected routes", async () => {
|
||||
const service = {
|
||||
image: "app",
|
||||
ports: ["*.example.com:3000", "*.secure.example.com:3001:protected"],
|
||||
} as Service;
|
||||
const resources = await composeToKubernetes("project", compose(service));
|
||||
|
||||
expect(
|
||||
resources.find((resource) => resource.kind === "Ingress"),
|
||||
).toMatchObject({
|
||||
spec: { rules: [{ host: "*.example.com" }] },
|
||||
});
|
||||
expect(
|
||||
resources.find((resource) => resource.kind === "IngressRoute"),
|
||||
).toMatchObject({
|
||||
spec: {
|
||||
routes: [
|
||||
{
|
||||
match: "Host(`*.secure.example.com`)",
|
||||
services: [{ name: "app", port: 3001 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("does not render ingress for numeric bindings or protected routes", () => {
|
||||
expect(
|
||||
serviceToIngress("project", "app", { ports: ["8080:80"] } as Service),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
serviceToIngress("project", "app", {
|
||||
ports: ["app.example.com:3000:protected"],
|
||||
} as Service),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("rejects protected routes with an empty path list", () => {
|
||||
expect(() =>
|
||||
serviceToSvc("project", "app", {
|
||||
ports: ["app.example.com:3000:protected()"],
|
||||
} as Service),
|
||||
).toThrow("Expected one or more paths");
|
||||
});
|
||||
});
|
||||
|
||||
describe("namespace conversion", () => {
|
||||
test("renders a managed namespace", () => {
|
||||
expect(composeToNamespace("project")).toEqual({
|
||||
apiVersion: "v1",
|
||||
kind: "Namespace",
|
||||
metadata: {
|
||||
name: "project",
|
||||
labels: { "app.kubernetes.io/managed-by": "kuber" },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import type { V1Deployment } from "@kubernetes/client-node";
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import type { ComposeSpecification, Service } from "../../schema/docker.d";
|
||||
import {
|
||||
composeToKubernetes,
|
||||
envFromToSecrets,
|
||||
serviceToDeployment,
|
||||
volumesToConfigMaps,
|
||||
volumesToPvc,
|
||||
} from "../../lib/convert";
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
async function temporaryDirectory(): Promise<string> {
|
||||
const path = await mkdtemp(join(tmpdir(), "kuber-convert-"));
|
||||
temporaryDirectories.push(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories
|
||||
.splice(0)
|
||||
.map((path) => rm(path, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe("volume conversion", () => {
|
||||
test("renders named volumes with default storage placement", () => {
|
||||
const claims = volumesToPvc("project", {
|
||||
volumes: ["data:/var/lib/data"],
|
||||
} as Service);
|
||||
expect(claims).toHaveLength(1);
|
||||
expect(claims[0]).toMatchObject({
|
||||
metadata: { name: "project-data", namespace: "project" },
|
||||
spec: {
|
||||
resources: { requests: { storage: "1Gi" } },
|
||||
diskTag: ["fast"],
|
||||
replicaCount: 2,
|
||||
dataLocality: "none",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("parses inline storage sizing and placement", () => {
|
||||
const [claim] = volumesToPvc("project", {
|
||||
volumes: ["data(20Gi on 3 fast,archive):/data"],
|
||||
} as Service);
|
||||
expect(claim).toMatchObject({
|
||||
metadata: { name: "project-data" },
|
||||
spec: {
|
||||
resources: { requests: { storage: "20Gi" } },
|
||||
diskTag: ["fast", "archive"],
|
||||
replicaCount: 3,
|
||||
dataLocality: "none",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("uses top-level volume extensions", () => {
|
||||
const [claim] = volumesToPvc(
|
||||
"project",
|
||||
{ volumes: ["data:/data"] } as Service,
|
||||
process.cwd(),
|
||||
{
|
||||
data: {
|
||||
"x-size": "50Gi",
|
||||
"x-diskTag": ["bulk", "archive"],
|
||||
"x-replicaCount": 3,
|
||||
"x-dataLocality": "none",
|
||||
},
|
||||
} as ComposeSpecification["volumes"],
|
||||
);
|
||||
expect(claim?.spec).toMatchObject({
|
||||
resources: { requests: { storage: "50Gi" } },
|
||||
diskTag: ["bulk", "archive"],
|
||||
replicaCount: 3,
|
||||
dataLocality: "none",
|
||||
});
|
||||
});
|
||||
|
||||
test("does not create PVCs for anonymous, tmpfs, or managed claims", () => {
|
||||
expect(
|
||||
volumesToPvc("project", {
|
||||
volumes: ["/cache", "postgresql:app", "s3:assets"],
|
||||
tmpfs: ["/tmp"],
|
||||
} as Service),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("renders anonymous and tmpfs mounts as emptyDir", () => {
|
||||
const service = {
|
||||
image: "app",
|
||||
volumes: ["/cache"],
|
||||
tmpfs: ["/tmp"],
|
||||
} as Service;
|
||||
const deployment = serviceToDeployment(
|
||||
"project",
|
||||
"app",
|
||||
{ services: { app: service } } as ComposeSpecification,
|
||||
service,
|
||||
);
|
||||
expect(deployment.spec?.template.spec?.volumes).toEqual([
|
||||
{ name: "volume-0", emptyDir: {} },
|
||||
{ name: "tmpfs-1", emptyDir: { medium: "Memory", sizeLimit: undefined } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("renders long-form tmpfs size and read-only volume subpaths", () => {
|
||||
const service = {
|
||||
image: "app",
|
||||
volumes: [
|
||||
{ type: "tmpfs", target: "/tmp", tmpfs: { size: 1024 } },
|
||||
{
|
||||
type: "volume",
|
||||
source: "data",
|
||||
target: "/data",
|
||||
read_only: true,
|
||||
volume: { subpath: "nested" },
|
||||
},
|
||||
],
|
||||
} as Service;
|
||||
const deployment = serviceToDeployment(
|
||||
"project",
|
||||
"app",
|
||||
{ services: { app: service } } as ComposeSpecification,
|
||||
service,
|
||||
);
|
||||
expect(deployment.spec?.template.spec?.volumes?.[0]).toEqual({
|
||||
name: "tmpfs-0",
|
||||
emptyDir: { medium: "Memory", sizeLimit: "1024" },
|
||||
});
|
||||
expect(
|
||||
deployment.spec?.template.spec?.containers[0]?.volumeMounts?.[1],
|
||||
).toMatchObject({
|
||||
mountPath: "/data",
|
||||
readOnly: true,
|
||||
subPath: "nested",
|
||||
});
|
||||
});
|
||||
|
||||
test("turns file binds into ConfigMaps", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
await writeFile(join(directory, "app.conf"), "enabled=true\n");
|
||||
const service = {
|
||||
image: "app",
|
||||
volumes: ["./app.conf:/etc/app.conf:ro"],
|
||||
} as Service;
|
||||
const configMaps = volumesToConfigMaps("project", service, directory);
|
||||
expect(configMaps).toHaveLength(1);
|
||||
expect(configMaps[0]?.data).toEqual({ "app.conf": "enabled=true\n" });
|
||||
const deployment = serviceToDeployment(
|
||||
"project",
|
||||
"app",
|
||||
{ services: { app: service } } as ComposeSpecification,
|
||||
service,
|
||||
directory,
|
||||
);
|
||||
expect(
|
||||
deployment.spec?.template.spec?.containers[0]?.volumeMounts?.[0],
|
||||
).toMatchObject({
|
||||
mountPath: "/etc/app.conf",
|
||||
readOnly: true,
|
||||
subPath: "app.conf",
|
||||
});
|
||||
});
|
||||
|
||||
test("turns directory binds into PVCs", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
await mkdir(join(directory, "uploads"));
|
||||
const claims = volumesToPvc(
|
||||
"project",
|
||||
{ volumes: ["./uploads:/uploads"] } as Service,
|
||||
directory,
|
||||
);
|
||||
expect(claims).toHaveLength(1);
|
||||
expect(claims[0]?.metadata?.name).toStartWith("bind-");
|
||||
});
|
||||
});
|
||||
|
||||
describe("environment Secret conversion", () => {
|
||||
test("parses env files, ignores comments, and applies generated overrides", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
await writeFile(
|
||||
join(directory, ".env"),
|
||||
"# comment\nA=one\nTOKEN=a=b=c\nINVALID\n =ignored\n",
|
||||
);
|
||||
const [secret] = await envFromToSecrets(
|
||||
"project",
|
||||
"app",
|
||||
{ env_file: ".env" } as Service,
|
||||
directory,
|
||||
{ A: "generated", EXTRA: "yes" },
|
||||
);
|
||||
expect(secret?.stringData).toEqual({
|
||||
A: "generated",
|
||||
TOKEN: "a=b=c",
|
||||
EXTRA: "yes",
|
||||
});
|
||||
});
|
||||
|
||||
test("allows optional missing env files", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
expect(
|
||||
await envFromToSecrets(
|
||||
"project",
|
||||
"app",
|
||||
{ env_file: [{ path: "missing.env", required: false }] } as Service,
|
||||
directory,
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("rejects required missing env files", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
await expect(
|
||||
envFromToSecrets(
|
||||
"project",
|
||||
"app",
|
||||
{ env_file: "missing.env" } as Service,
|
||||
directory,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("whole Compose conversion", () => {
|
||||
test("deduplicates shared PVCs and includes service-specific generated env", async () => {
|
||||
const compose = {
|
||||
services: {
|
||||
app: { image: "app", volumes: ["data:/data"], ports: [3000] },
|
||||
worker: { image: "worker", volumes: ["data:/data"] },
|
||||
},
|
||||
volumes: { data: {} },
|
||||
} as ComposeSpecification;
|
||||
const resources = await composeToKubernetes(
|
||||
"project",
|
||||
compose,
|
||||
process.cwd(),
|
||||
{
|
||||
app: { GENERATED: "yes" },
|
||||
},
|
||||
);
|
||||
expect(
|
||||
resources.filter((resource) => resource.kind === "Namespace"),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
resources.filter((resource) => resource.kind === "PersistentVolumeClaim"),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
resources.filter((resource) => resource.kind === "Deployment"),
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
resources.filter((resource) => resource.kind === "Service"),
|
||||
).toHaveLength(1);
|
||||
const secret = resources.find((resource) => resource.kind === "Secret");
|
||||
expect(secret?.metadata?.name).toBe("app-env");
|
||||
const deployment = resources.find(
|
||||
(resource) =>
|
||||
resource.kind === "Deployment" && resource.metadata?.name === "app",
|
||||
) as V1Deployment | undefined;
|
||||
const checksum =
|
||||
deployment?.spec?.template?.metadata?.annotations?.[
|
||||
"kuber.astrxl.dev/env-checksum"
|
||||
];
|
||||
expect(checksum).toMatch(/^[a-f0-9]{64}$/);
|
||||
|
||||
const changedResources = await composeToKubernetes(
|
||||
"project",
|
||||
compose,
|
||||
process.cwd(),
|
||||
{ app: { GENERATED: "changed" } },
|
||||
);
|
||||
const changedDeployment = changedResources.find(
|
||||
(resource) =>
|
||||
resource.kind === "Deployment" && resource.metadata?.name === "app",
|
||||
) as V1Deployment | undefined;
|
||||
expect(
|
||||
changedDeployment?.spec?.template?.metadata?.annotations?.[
|
||||
"kuber.astrxl.dev/env-checksum"
|
||||
],
|
||||
).not.toBe(checksum);
|
||||
});
|
||||
|
||||
test("renders protected routes with normalized, deduplicated paths", async () => {
|
||||
const compose = {
|
||||
services: {
|
||||
app: {
|
||||
image: "app",
|
||||
ports: ["app.example.com:3000:protected(api,/admin,api)"],
|
||||
},
|
||||
},
|
||||
} as ComposeSpecification;
|
||||
const resources = await composeToKubernetes("project", compose);
|
||||
const route = resources.find(
|
||||
(resource) => resource.kind === "IngressRoute",
|
||||
) as
|
||||
| ((typeof resources)[number] & {
|
||||
spec?: { routes?: { match: string }[] };
|
||||
})
|
||||
| undefined;
|
||||
expect(route?.spec?.routes?.map((entry) => entry.match)).toEqual([
|
||||
"Host(`app.example.com`) && PathPrefix(`/api`)",
|
||||
"Host(`app.example.com`) && PathPrefix(`/admin`)",
|
||||
]);
|
||||
expect(resources.some((resource) => resource.kind === "Ingress")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { V1Pod } from "@kubernetes/client-node";
|
||||
import type { ComposeSpecification } from "../../schema/docker.d";
|
||||
import {
|
||||
getComposeArch,
|
||||
getComposeArchPlacement,
|
||||
resolveComposeArch,
|
||||
withComposeArch,
|
||||
} from "../../lib/arch";
|
||||
import { createCachedGetter, ctx } from "../../lib/context";
|
||||
import { toEnvVars, toTable } from "../../lib/format";
|
||||
import { deepMerge, getPodContainerName, throwWhen } from "../../lib/shared";
|
||||
|
||||
describe("architecture selection", () => {
|
||||
test("defaults to arm64 and recognizes amd64", () => {
|
||||
expect(resolveComposeArch({} as ComposeSpecification)).toBe("arm64");
|
||||
expect(
|
||||
resolveComposeArch({ "x-arch": "arm64" } as ComposeSpecification),
|
||||
).toBe("arm64");
|
||||
expect(
|
||||
resolveComposeArch({ "x-arch": "amd64" } as ComposeSpecification),
|
||||
).toBe("amd64");
|
||||
});
|
||||
|
||||
test("provides amd64 placement and no arm64 constraints", () => {
|
||||
expect(getComposeArchPlacement({} as ComposeSpecification)).toEqual({});
|
||||
expect(
|
||||
getComposeArchPlacement({ "x-arch": "amd64" } as ComposeSpecification),
|
||||
).toEqual({
|
||||
nodeSelector: { "kubernetes.io/arch": "amd64" },
|
||||
tolerations: [
|
||||
{
|
||||
key: "arch",
|
||||
operator: "Equal",
|
||||
value: "amd64",
|
||||
effect: "NoExecute",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("scopes architecture through synchronous and asynchronous work", async () => {
|
||||
expect(getComposeArch()).toBe("arm64");
|
||||
expect(
|
||||
withComposeArch({ "x-arch": "amd64" } as ComposeSpecification, () =>
|
||||
getComposeArch(),
|
||||
),
|
||||
).toBe("amd64");
|
||||
await withComposeArch(
|
||||
{ "x-arch": "amd64" } as ComposeSpecification,
|
||||
async () => {
|
||||
await Promise.resolve();
|
||||
expect(getComposeArch()).toBe("amd64");
|
||||
},
|
||||
);
|
||||
expect(getComposeArch()).toBe("arm64");
|
||||
});
|
||||
});
|
||||
|
||||
describe("format helpers", () => {
|
||||
test("converts array environments and preserves embedded equals", () => {
|
||||
expect(toEnvVars(["EMPTY", "A=one", "TOKEN=a=b=c"])).toEqual([
|
||||
{ name: "EMPTY", value: undefined },
|
||||
{ name: "A", value: "one" },
|
||||
{ name: "TOKEN", value: "a=b=c" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("converts object environment scalar values", () => {
|
||||
expect(
|
||||
toEnvVars({ STRING: "yes", NUMBER: 2, BOOL: false, NIL: null }),
|
||||
).toEqual([
|
||||
{ name: "STRING", value: "yes" },
|
||||
{ name: "NUMBER", value: "2" },
|
||||
{ name: "BOOL", value: "false" },
|
||||
{ name: "NIL", value: undefined },
|
||||
]);
|
||||
expect(toEnvVars(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("renders uppercase table headers and rows", () => {
|
||||
const table = toTable([{ name: "app", replicas: 2 }]);
|
||||
expect(table).toContain("NAME");
|
||||
expect(table).toContain("REPLICAS");
|
||||
expect(table).toContain("app");
|
||||
expect(table).toContain("2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shared helpers", () => {
|
||||
test("deep merges objects and concatenates arrays without mutation", () => {
|
||||
const target = { nested: { left: 1 }, list: [1], untouched: true };
|
||||
const source = { nested: { right: 2 }, list: [2], added: "yes" };
|
||||
expect(deepMerge(target, source)).toEqual({
|
||||
nested: { left: 1, right: 2 },
|
||||
list: [1, 2],
|
||||
untouched: true,
|
||||
added: "yes",
|
||||
});
|
||||
expect(target).toEqual({ nested: { left: 1 }, list: [1], untouched: true });
|
||||
});
|
||||
|
||||
test.each([
|
||||
[1, 2, 2],
|
||||
[{ value: 1 }, null, null],
|
||||
[null, { value: 2 }, { value: 2 }],
|
||||
])("replaces non-mergeable values", (target, source, expected) => {
|
||||
expect(deepMerge(target, source)).toEqual(expected);
|
||||
});
|
||||
|
||||
test("throwWhen passes unmatched values and throws matched values", () => {
|
||||
const rejectEmpty = throwWhen(
|
||||
(value: string) => value.length === 0,
|
||||
"empty",
|
||||
);
|
||||
expect(rejectEmpty("value")).toBe("value");
|
||||
expect(() => rejectEmpty("")).toThrow("empty");
|
||||
});
|
||||
|
||||
test("gets the first pod container or gives a useful error", () => {
|
||||
expect(
|
||||
getPodContainerName({
|
||||
metadata: { name: "app-1" },
|
||||
spec: { containers: [{ name: "app" }] },
|
||||
} as V1Pod),
|
||||
).toBe("app");
|
||||
expect(() =>
|
||||
getPodContainerName({ metadata: { name: "empty" } } as V1Pod),
|
||||
).toThrow("No container found in pod empty");
|
||||
});
|
||||
});
|
||||
|
||||
describe("context helpers", () => {
|
||||
test("rejects access outside an application context", () => {
|
||||
expect(() => ctx()).toThrow("Not in context");
|
||||
});
|
||||
|
||||
test("cached getters execute once and share an in-flight result", async () => {
|
||||
let calls = 0;
|
||||
const getter = createCachedGetter(
|
||||
`test-${crypto.randomUUID()}`,
|
||||
async () => {
|
||||
calls += 1;
|
||||
return "value";
|
||||
},
|
||||
);
|
||||
expect(await Promise.all([getter(), getter()])).toEqual(["value", "value"]);
|
||||
expect(await getter()).toBe("value");
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
test("failed getter calls can retry", async () => {
|
||||
let calls = 0;
|
||||
const getter = createCachedGetter(
|
||||
`test-${crypto.randomUUID()}`,
|
||||
async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) throw new Error("temporary");
|
||||
return "recovered";
|
||||
},
|
||||
);
|
||||
await expect(getter()).rejects.toThrow("temporary");
|
||||
expect(await getter()).toBe("recovered");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { ComposeSpecification, Service } from "../../schema/docker.d";
|
||||
import {
|
||||
buildDatabaseUrl,
|
||||
getComposePostgresClaims,
|
||||
getServicePostgresClaim,
|
||||
isPostgresVolumeEntry,
|
||||
} from "../../lib/database";
|
||||
|
||||
describe("managed PostgreSQL claims", () => {
|
||||
test("supports short and explicit syntax", () => {
|
||||
expect(
|
||||
getServicePostgresClaim("app", {
|
||||
volumes: ["postgresql:app"],
|
||||
} as Service),
|
||||
).toEqual({
|
||||
service: "app",
|
||||
username: "app",
|
||||
database: "app",
|
||||
secretName: "postgres-app",
|
||||
});
|
||||
expect(
|
||||
getServicePostgresClaim("app", {
|
||||
volumes: ["postgresql:user/database"],
|
||||
} as Service),
|
||||
).toEqual({
|
||||
service: "app",
|
||||
username: "user",
|
||||
database: "database",
|
||||
secretName: "postgres-user",
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
"postgresql:",
|
||||
"postgresql:user/",
|
||||
"postgresql:/database",
|
||||
"postgresql:user/database/extra",
|
||||
"postgresql:user:database",
|
||||
])("rejects malformed declaration %s", (entry) => {
|
||||
expect(() =>
|
||||
getServicePostgresClaim("app", { volumes: [entry] } as Service),
|
||||
).toThrow("Use postgresql:<name> or postgresql:<user>/<database>");
|
||||
});
|
||||
|
||||
test("ignores unrelated entries and rejects duplicate declarations", () => {
|
||||
expect(
|
||||
getServicePostgresClaim("app", {
|
||||
volumes: ["cache:/cache"],
|
||||
} as Service),
|
||||
).toBeUndefined();
|
||||
expect(isPostgresVolumeEntry("cache:/cache")).toBe(false);
|
||||
expect(() =>
|
||||
getServicePostgresClaim("app", {
|
||||
volumes: ["postgresql:one", "postgresql:two"],
|
||||
} as Service),
|
||||
).toThrow("declares multiple postgres volumes");
|
||||
});
|
||||
|
||||
test("allows one owner to share a database", () => {
|
||||
const compose = {
|
||||
services: {
|
||||
app: { volumes: ["postgresql:user/database"] },
|
||||
worker: { volumes: ["postgresql:user/database"] },
|
||||
},
|
||||
} as ComposeSpecification;
|
||||
expect(getComposePostgresClaims(compose)).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("rejects conflicting database owners", () => {
|
||||
const compose = {
|
||||
services: {
|
||||
app: { volumes: ["postgresql:one/shared"] },
|
||||
worker: { volumes: ["postgresql:two/shared"] },
|
||||
},
|
||||
} as ComposeSpecification;
|
||||
expect(() => getComposePostgresClaims(compose)).toThrow(
|
||||
"Database shared is claimed by both one and two",
|
||||
);
|
||||
});
|
||||
|
||||
test("escapes every connection URL component", () => {
|
||||
expect(
|
||||
buildDatabaseUrl(
|
||||
{
|
||||
service: "app",
|
||||
username: "user@host",
|
||||
database: "my/database",
|
||||
secretName: "postgres-user",
|
||||
},
|
||||
{ username: "user@host", password: "p:a/ss?#" },
|
||||
),
|
||||
).toBe(
|
||||
"postgresql://user%40host:p%3Aa%2Fss%3F%[email protected]:5432/my%2Fdatabase",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
@@ -0,0 +1,72 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { readCompose, resolveComposeFile } from "../../lib/yaml";
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
async function temporaryDirectory(): Promise<string> {
|
||||
const path = await mkdtemp(join(tmpdir(), "kuber-test-"));
|
||||
temporaryDirectories.push(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories
|
||||
.splice(0)
|
||||
.map((path) => rm(path, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe("Compose file discovery", () => {
|
||||
test("finds supported names and chooses lexical precedence", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
await Promise.all([
|
||||
writeFile(join(directory, "docker-compose.yml"), "services: {}\n"),
|
||||
writeFile(join(directory, "compose.yaml"), "services: {}\n"),
|
||||
writeFile(join(directory, "ignored.yml"), "services: {}\n"),
|
||||
]);
|
||||
expect(await resolveComposeFile(directory)).toBe(
|
||||
join(directory, "compose.yaml"),
|
||||
);
|
||||
});
|
||||
|
||||
test("ignores directories with Compose-looking names", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
await mkdir(join(directory, "compose.yml"));
|
||||
expect(await resolveComposeFile(directory)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined when no supported file exists", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
await writeFile(join(directory, "stack.yml"), "services: {}\n");
|
||||
expect(await resolveComposeFile(directory)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Compose parsing", () => {
|
||||
test("parses and validates a minimal Compose project", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const path = join(directory, "compose.yml");
|
||||
await writeFile(path, "services:\n app:\n image: nginx:latest\n");
|
||||
expect(await readCompose(path)).toMatchObject({
|
||||
services: { app: { image: "nginx:latest" } },
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects malformed YAML", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const path = join(directory, "compose.yml");
|
||||
await writeFile(path, "services: [\n");
|
||||
await expect(readCompose(path)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("rejects schema-invalid service values", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const path = join(directory, "compose.yml");
|
||||
await writeFile(path, "services:\n app: 42\n");
|
||||
await expect(readCompose(path)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user