feat: add .kuberrc.ts configuration

This commit is contained in:
2026-08-30 18:46:25 +07:00 Unverified
parent 32ec172335
commit 716246659c
15 changed files with 682 additions and 93 deletions
+63
View File
@@ -69,6 +69,14 @@ bun run index.ts restart
bun run index.ts db ls
```
All commands accept `--config` to use a configuration file other than
`.kuberrc.ts`:
```bash
kuber --config deploy/production.kuberrc.ts up
kuber up --config deploy/production.kuberrc.ts
```
### Shell Completion
Generate and load completions for your shell:
@@ -95,6 +103,61 @@ For a permanent setup, write the generated script to a file and source it from y
- `db ls`: list managed Postgres claims declared in the current Compose file
- `db creds <service>`: print the generated connection details for a managed Postgres claim
## Configuration
Kuber optionally loads `.kuberrc.ts` from the working directory. The file must
default export an object satisfying the published `KuberConfig` type:
```ts
import type { KuberConfig } from "@dmgnr/kuber";
export default {
project: "my-app",
composeFile: "compose.production.yml",
registry: "registry.example.com",
builders: {
amd64: "kuber@amd-builder",
arm64: "kuber@arm-builder",
remoteRoot: "kuber-build",
},
rolloutTimeoutMs: 10 * 60_000,
async compose(compose) {
const app = compose.services?.app;
if (app && !Array.isArray(app.environment)) {
app.environment ??= {};
app.environment.NEXT_PUBLIC_BUILD_ID =
await Bun.$`git rev-parse --short HEAD`
.text()
.then((value) => value.trim());
}
},
} satisfies KuberConfig;
```
Operational defaults:
- `project`: current working directory name
- `composeFile`: the first recognized Compose filename in the working directory
- `registry`: `registry.neko-piranha.ts.net`
- `builders.amd64`: `kuber@astral-th`
- `builders.arm64`: `kuber@astral`
- `builders.remoteRoot`: `kuber-build`
- `rolloutTimeoutMs`: `300000`
Configuration hooks can be synchronous or asynchronous and receive mutable
values:
- `compose(compose, context)`: once after parsing and validation; affects every command that reads Compose
- `preBuild(compose, context)`: before build eligibility is evaluated when builds are enabled
- `postBuild(result, context)`: after images are built; `result` contains `built` and `changed` service names
- `postRender(resources, context)`: after rendering and before reconciliation planning; also runs for `export`
- `postApply(resources, context)`: after desired resources are successfully applied
Hook context contains the resolved `cwd`, `project`, `composeFile`, and optional
`configFile`. A hook error aborts the command and is reported by the normal CLI
error handler.
## Compose Conventions
`kuber` supports a few project-specific Compose conventions on top of normal service translation.
+21 -3
View File
@@ -1,11 +1,14 @@
import { defineCommand } from "citty";
import { resolve } from "node:path";
import { YAML } from "bun";
import type { KuberResource } from "../types";
import { sortResources } from "../lib/apply";
import { ctx } from "../lib/context";
import { renderResources } from "../lib/render";
function toDocumentYaml(resources: Parameters<typeof sortResources>[0]): string {
function toDocumentYaml(
resources: Parameters<typeof sortResources>[0],
): string {
return `${sortResources(resources)
.map((resource) => YAML.stringify(resource, null, 2).trimEnd())
.join("\n---\n")}\n`;
@@ -24,8 +27,23 @@ export const exportCommand = defineCommand({
},
},
async run({ args }) {
const { project, compose, cwd } = ctx();
const resources = await renderResources(project, await compose(), cwd);
const {
project,
compose,
cwd,
config,
hookContext: getHookContext,
} = ctx();
const resources = await renderResources(
project,
await compose(),
cwd,
config.registry,
);
await config.postRender?.(
resources as KuberResource[],
await getHookContext(),
);
const outputPath = resolve(cwd, args.output || `${project}.yaml`);
await Bun.write(outputPath, toDocumentYaml(resources));
+7 -1
View File
@@ -14,9 +14,15 @@ import { up } from "./up";
export const main = defineCommand({
meta: {
name: "kuber",
version: "1.0.0",
version: "1.1.0",
description: "Docker Compose -> K8s translation layer",
},
args: {
config: {
type: "string",
description: "Path to a kuber configuration file",
},
},
subCommands: {
db,
down,
+31 -4
View File
@@ -1,6 +1,7 @@
import { defineCommand } from "citty";
import { Listr } from "listr2";
import type { ComposeSpecification } from "../schema/docker.d";
import type { KuberResource } from "../types";
import { ctx } from "../lib/context";
import {
assertManagedNamespace,
@@ -45,7 +46,7 @@ export function getDeploymentNames(resources: KubernetesResource[]): string[] {
}
export async function runUp(build: boolean) {
const { project, compose, cwd } = ctx();
const { project, compose, cwd, config, hookContext: getHookContext } = ctx();
await assertManagedNamespace(project);
const taskCtx = await new Listr<UpContext>(
@@ -54,6 +55,9 @@ export async function runUp(build: boolean) {
title: "Read compose",
task: async (taskCtx, task) => {
taskCtx.compose = await compose();
if (build) {
await config.preBuild?.(taskCtx.compose, await getHookContext());
}
task.output = `${Object.keys(taskCtx.compose.services ?? {}).length} services`;
},
},
@@ -69,13 +73,20 @@ export async function runUp(build: boolean) {
(service) => service.build,
),
task: async (taskCtx, task) => {
const result = await buildServices(project, taskCtx.compose!, cwd, {
const result = await buildServices(
project,
taskCtx.compose!,
cwd,
{
progress: (message) => {
task.output = message;
},
stream: task.stdout(),
});
},
config,
);
taskCtx.changedDeployments = result.changed;
await config.postBuild?.(result, await getHookContext());
task.output = `Built ${result.built.length} image${result.built.length === 1 ? "" : "s"}, ${result.changed.length} changed`;
},
},
@@ -110,6 +121,11 @@ export async function runUp(build: boolean) {
taskCtx.compose!,
cwd,
taskCtx.serviceEnv,
config.registry,
);
await config.postRender?.(
taskCtx.resources as KuberResource[],
await getHookContext(),
);
task.output = `${taskCtx.resources.length} resources`;
},
@@ -139,6 +155,16 @@ export async function runUp(build: boolean) {
);
},
},
{
title: "Run post-apply hook",
enabled: () => Boolean(config.postApply),
task: async (taskCtx) => {
await config.postApply?.(
taskCtx.resources! as KuberResource[],
await getHookContext(),
);
},
},
],
{ rendererOptions: { collapseErrors: false } },
).run();
@@ -172,7 +198,8 @@ export async function runUp(build: boolean) {
return task.newListr(
deployments.map((name) => ({
title: `Deployment ${name}`,
task: () => waitForDeploymentRollout(name),
task: () =>
waitForDeploymentRollout(name, config.rolloutTimeoutMs),
})),
{ concurrent: false, exitOnError: true },
);
+3 -1
View File
@@ -3,6 +3,7 @@
import { createMain } from "citty";
import { initializeCompletion, main } from "./command/main";
import { provideContext } from "./lib/context";
import { extractConfigArgument } from "./lib/config";
import z, { ZodError } from "zod";
function formatUnknownError(error: unknown): string {
@@ -23,7 +24,8 @@ function formatUnknownError(error: unknown): string {
async function run() {
await initializeCompletion();
const cli = createMain(main);
await provideContext(() => cli());
const { configPath, rawArgs } = extractConfigArgument(process.argv.slice(2));
await provideContext(() => cli({ rawArgs }), configPath);
}
run().catch((e) => {
+52 -22
View File
@@ -1,21 +1,40 @@
import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
import { cp, mkdir, mkdtemp, rm } from "node:fs/promises";
import { hostname, tmpdir } from "node:os";
import { AsyncLocalStorage } from "node:async_hooks";
import type { Writable } from "node:stream";
import type { ComposeSpecification, Service } from "../schema/docker.d";
import { IMAGE_REGISTRY } from "../const";
import { getComposeArch, withComposeArch } from "./arch";
import { DEFAULT_BUILDERS, DEFAULT_REGISTRY } from "./config";
const REMOTE_BUILDER_ARM = "kuber@astral";
const REMOTE_BUILDER_AMD = "kuber@astral-th";
const REMOTE_BUILD_ROOT = "kuber-build";
export type BuildOptions = {
registry?: string;
builders?: {
amd64?: string;
arm64?: string;
remoteRoot?: string;
};
};
const RemoteBuilder = {
amd64: REMOTE_BUILDER_AMD,
arm64: REMOTE_BUILDER_ARM,
} as const;
type BuildRuntime = {
registry: string;
builders: {
amd64: string;
arm64: string;
remoteRoot: string;
};
};
type RemoteBuilder = (typeof RemoteBuilder)[keyof typeof RemoteBuilder];
const buildRuntime = new AsyncLocalStorage<BuildRuntime>();
function getBuildRuntime(): BuildRuntime {
return (
buildRuntime.getStore() ?? {
registry: DEFAULT_REGISTRY,
builders: DEFAULT_BUILDERS,
}
);
}
type BuildPlan = {
name: string;
@@ -136,7 +155,7 @@ function resolveBuildPlan(
return {
name,
image: `${IMAGE_REGISTRY}/kuber/${project}-${name}:latest`,
image: `${getBuildRuntime().registry}/kuber/${project}-${name}:latest`,
context: `${buildRoot}/${contextRelative === "" ? "." : contextRelative}`,
dockerfile: dockerfileRelative
? `${buildRoot}/${dockerfileRelative}`
@@ -146,10 +165,9 @@ function resolveBuildPlan(
};
}
function getRemoteBuilder(): RemoteBuilder {
return getComposeArch() === "amd64"
? RemoteBuilder.amd64
: RemoteBuilder.arm64;
function getRemoteBuilder(): string {
const builders = getBuildRuntime().builders;
return getComposeArch() === "amd64" ? builders.amd64 : builders.arm64;
}
function isOnRemoteBuilder(): boolean {
@@ -384,11 +402,11 @@ async function syncRemoteRepo(
if (remoteHead !== headSha) {
await reporter?.progress?.("Transferring latest git bundle");
const bundlePath = `${tempDir}/repo.bundle`;
const remoteBundle = `${REMOTE_BUILD_ROOT}/${basename(repoRoot)}.bundle`;
const remoteBundle = `${getBuildRuntime().builders.remoteRoot}/${basename(repoRoot)}.bundle`;
await Bun.$.cwd(repoRoot)`git bundle create ${bundlePath} HEAD`;
await runWithOutput(
ssh(`mkdir -p ${shellQuote(REMOTE_BUILD_ROOT)}`),
ssh(`mkdir -p ${shellQuote(getBuildRuntime().builders.remoteRoot)}`),
reporter,
);
await runWithOutput(scp(bundlePath, remoteBundle), reporter);
@@ -422,7 +440,7 @@ async function syncRemoteRepo(
if (diff.trim().length > 0) {
await reporter?.progress?.("Applying local git diff on remote builder");
const diffPath = `${tempDir}/repo.diff`;
const remoteDiff = `${REMOTE_BUILD_ROOT}/${basename(repoRoot)}.diff`;
const remoteDiff = `${getBuildRuntime().builders.remoteRoot}/${basename(repoRoot)}.diff`;
await Bun.write(diffPath, diff);
await runWithOutput(scp(diffPath, remoteDiff), reporter);
await runWithOutput(
@@ -449,7 +467,7 @@ async function syncRemoteRepo(
}
const untrackedArchive = `${tempDir}/untracked.tar`;
const remoteUntrackedArchive = `${REMOTE_BUILD_ROOT}/${basename(repoRoot)}-untracked.tar`;
const remoteUntrackedArchive = `${getBuildRuntime().builders.remoteRoot}/${basename(repoRoot)}-untracked.tar`;
await Bun.$`tar -C ${untrackedDir} -cf ${untrackedArchive} .`;
await runWithOutput(
scp(untrackedArchive, remoteUntrackedArchive),
@@ -490,7 +508,7 @@ async function syncRemoteRepo(
}
const dotenvArchive = `${tempDir}/dotenv.tar`;
const remoteDotenvArchive = `${REMOTE_BUILD_ROOT}/${basename(repoRoot)}-dotenv.tar`;
const remoteDotenvArchive = `${getBuildRuntime().builders.remoteRoot}/${basename(repoRoot)}-dotenv.tar`;
await Bun.$`tar -C ${dotenvDir} -cf ${dotenvArchive} .`;
await runWithOutput(scp(dotenvArchive, remoteDotenvArchive), reporter);
await runWithOutput(
@@ -575,8 +593,19 @@ export async function buildServices(
compose: ComposeSpecification,
cwd = process.cwd(),
reporter?: BuildReporter,
options: BuildOptions = {},
): Promise<BuildResult> {
return withComposeArch(compose, async () => {
const runtime: BuildRuntime = {
registry: options.registry ?? DEFAULT_REGISTRY,
builders: {
amd64: options.builders?.amd64 ?? DEFAULT_BUILDERS.amd64,
arm64: options.builders?.arm64 ?? DEFAULT_BUILDERS.arm64,
remoteRoot: options.builders?.remoteRoot ?? DEFAULT_BUILDERS.remoteRoot,
},
};
return buildRuntime.run(runtime, () =>
withComposeArch(compose, async () => {
if (
!Object.values(compose.services ?? {}).some((service) => service.build)
) {
@@ -587,7 +616,7 @@ export async function buildServices(
const localBuilder = isOnRemoteBuilder();
const buildRoot = localBuilder
? repoRoot
: `${REMOTE_BUILD_ROOT}/${basename(repoRoot)}`;
: `${getBuildRuntime().builders.remoteRoot}/${basename(repoRoot)}`;
const plans = getBuildPlans(project, compose, cwd, repoRoot, buildRoot);
if (plans.length === 0) return { built: [], changed: [] };
@@ -609,5 +638,6 @@ export async function buildServices(
built: plans.map((plan) => plan.name),
changed,
};
});
}),
);
}
+192
View File
@@ -0,0 +1,192 @@
import { basename, isAbsolute, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import type { KuberConfig, KuberHookContext } from "../types";
import { IMAGE_REGISTRY } from "../const";
const DEFAULT_CONFIG_FILE = ".kuberrc.ts";
export const DEFAULT_REGISTRY = IMAGE_REGISTRY;
export const DEFAULT_BUILDERS = {
amd64: "kuber@astral-th",
arm64: "kuber@astral",
remoteRoot: "kuber-build",
} as const;
export const DEFAULT_ROLLOUT_TIMEOUT_MS = 300_000;
type Hooks = Pick<
KuberConfig,
"compose" | "preBuild" | "postBuild" | "postRender" | "postApply"
>;
export type ResolvedKuberConfig = Hooks & {
project: string;
composeFile?: string;
registry: string;
builders: {
amd64: string;
arm64: string;
remoteRoot: string;
};
rolloutTimeoutMs: number;
configFile?: string;
};
function requireNonEmptyString(value: unknown, field: string): string {
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`${field} must be a non-empty string`);
}
return value.trim();
}
function validateConfig(config: unknown, file: string): KuberConfig {
if (!config || typeof config !== "object" || Array.isArray(config)) {
throw new Error(`${file} must default export a configuration object`);
}
const value = config as Record<string, unknown>;
for (const field of [
"compose",
"preBuild",
"postBuild",
"postRender",
"postApply",
]) {
if (value[field] !== undefined && typeof value[field] !== "function") {
throw new Error(`${field} in ${file} must be a function`);
}
}
if (value.project !== undefined)
requireNonEmptyString(value.project, "project");
if (value.composeFile !== undefined)
requireNonEmptyString(value.composeFile, "composeFile");
if (value.registry !== undefined)
requireNonEmptyString(value.registry, "registry");
if (value.builders !== undefined) {
if (
!value.builders ||
typeof value.builders !== "object" ||
Array.isArray(value.builders)
) {
throw new Error(`builders in ${file} must be an object`);
}
const builders = value.builders as Record<string, unknown>;
for (const field of ["amd64", "arm64", "remoteRoot"]) {
if (builders[field] !== undefined) {
requireNonEmptyString(builders[field], `builders.${field}`);
}
}
}
if (
value.rolloutTimeoutMs !== undefined &&
(typeof value.rolloutTimeoutMs !== "number" ||
!Number.isFinite(value.rolloutTimeoutMs) ||
value.rolloutTimeoutMs <= 0)
) {
throw new Error(`rolloutTimeoutMs in ${file} must be a positive number`);
}
return config as KuberConfig;
}
export function extractConfigArgument(args: string[]): {
configPath?: string;
rawArgs: string[];
} {
let configPath: string | undefined;
const rawArgs: string[] = [];
for (let index = 0; index < args.length; index += 1) {
const argument = args[index]!;
if (argument === "--") {
rawArgs.push(...args.slice(index));
break;
}
if (argument.startsWith("--config=")) {
const value = argument.slice("--config=".length);
if (!value) throw new Error("--config requires a path");
if (configPath) throw new Error("--config may only be specified once");
configPath = value;
continue;
}
if (argument === "--config") {
const value = args[index + 1];
if (!value || value.startsWith("-")) {
throw new Error(`${argument} requires a path`);
}
if (configPath) throw new Error("--config may only be specified once");
configPath = value;
index += 1;
continue;
}
rawArgs.push(argument);
}
return { configPath, rawArgs };
}
export function resolveConfigArgument(args: string[]): string | undefined {
return extractConfigArgument(args).configPath;
}
export async function loadConfig(
cwd: string,
requestedPath?: string,
): Promise<ResolvedKuberConfig> {
const configFile = resolve(cwd, requestedPath ?? DEFAULT_CONFIG_FILE);
const exists = await Bun.file(configFile).exists();
if (!exists && requestedPath) {
throw new Error(`Config file cannot be found: ${configFile}`);
}
const raw = exists
? validateConfig(
(await import(pathToFileURL(configFile).href)).default,
configFile,
)
: {};
const builders = raw.builders ?? {};
const composeFile = raw.composeFile?.trim();
const registry = (raw.registry?.trim() ?? DEFAULT_REGISTRY).replace(
/\/+$/,
"",
);
if (!registry) throw new Error(`registry in ${configFile} cannot be empty`);
return {
project: raw.project?.trim() ?? basename(cwd),
composeFile: composeFile
? isAbsolute(composeFile)
? composeFile
: resolve(cwd, composeFile)
: undefined,
registry,
builders: {
amd64: builders.amd64?.trim() ?? DEFAULT_BUILDERS.amd64,
arm64: builders.arm64?.trim() ?? DEFAULT_BUILDERS.arm64,
remoteRoot: builders.remoteRoot?.trim() ?? DEFAULT_BUILDERS.remoteRoot,
},
rolloutTimeoutMs: raw.rolloutTimeoutMs ?? DEFAULT_ROLLOUT_TIMEOUT_MS,
configFile: exists ? configFile : undefined,
compose: raw.compose,
preBuild: raw.preBuild,
postBuild: raw.postBuild,
postRender: raw.postRender,
postApply: raw.postApply,
};
}
export function hookContext(
config: ResolvedKuberConfig,
cwd: string,
composeFile: string,
): KuberHookContext {
return {
cwd,
project: config.project,
composeFile,
configFile: config.configFile,
};
}
+31 -15
View File
@@ -1,35 +1,51 @@
import { AsyncLocalStorage } from "node:async_hooks";
import type { ComposeSpecification } from "../schema/docker.d";
import { getProject, throwWhen } from "./shared";
import type { KuberHookContext } from "../types";
import { hookContext, loadConfig, type ResolvedKuberConfig } from "./config";
import { readCompose, resolveComposeFile } from "./yaml";
type AppContext = {
project: string;
cwd: string;
config: ResolvedKuberConfig;
path(): Promise<string | undefined>;
hookContext(): Promise<KuberHookContext>;
compose(): Promise<ComposeSpecification>;
};
const appContext = new AsyncLocalStorage<AppContext>();
export function provideContext<T>(fn: () => T): T {
export async function provideContext<T>(
fn: () => T,
configPath?: string,
): Promise<Awaited<T>> {
const cwd = process.cwd();
const path = createCachedGetter("composeFile", () => resolveComposeFile(cwd));
return appContext.run(
const config = await loadConfig(cwd, configPath);
const path = createCachedGetter("composeFile", () =>
config.composeFile
? Promise.resolve(config.composeFile)
: resolveComposeFile(cwd),
);
const getHookContext = createCachedGetter("hookContext", async () => {
const composeFile = await path();
if (!composeFile) throw new Error("Compose file cannot be found");
return hookContext(config, cwd, composeFile);
});
const compose = createCachedGetter("compose", async () => {
const context = await getHookContext();
const value = await readCompose(context.composeFile);
await config.compose?.(value, context);
return value;
});
return await appContext.run(
{
project: getProject(),
project: config.project,
cwd,
config,
path,
compose: createCachedGetter("compose", () =>
path()
.then(
throwWhen(
(path): path is undefined => path === undefined,
"Compose file cannot be found",
),
)
.then(readCompose),
),
hookContext: getHookContext,
compose,
},
fn,
);
+8 -3
View File
@@ -19,8 +19,9 @@ import { readFile } from "node:fs/promises";
import { basename, resolve } from "node:path";
import type { ComposeSpecification } from "../schema/docker.d";
import type { Service } from "../schema/docker.d";
import { IMAGE_REGISTRY, LABELS } from "../const";
import { LABELS } from "../const";
import { getComposeArchPlacement } from "./arch";
import { DEFAULT_REGISTRY } from "./config";
import { isPostgresVolumeEntry } from "./database";
import { toEnvVars } from "./format";
import { deepMerge } from "./shared";
@@ -80,8 +81,9 @@ function toDeploymentImage(
project: string,
name: string,
service: Service,
registry: string,
): string | undefined {
if (service.build) return `${IMAGE_REGISTRY}/kuber/${project}-${name}:latest`;
if (service.build) return `${registry}/kuber/${project}-${name}:latest`;
return service.image;
}
@@ -843,6 +845,7 @@ export function serviceToDeployment(
cwd = process.cwd(),
extraEnv: Record<string, string> = {},
volumes: ComposeVolumes = {},
registry = DEFAULT_REGISTRY,
): V1Deployment {
const mounts = toMounts(service, cwd, volumes);
const ports = toPorts(service);
@@ -885,7 +888,7 @@ export function serviceToDeployment(
name,
imagePullPolicy: "Always",
env: toEnvVars(service.environment),
image: toDeploymentImage(project, name, service),
image: toDeploymentImage(project, name, service, registry),
command:
service.command instanceof Array
? service.command
@@ -1095,6 +1098,7 @@ export async function composeToKubernetes(
compose: ComposeSpecification,
cwd = process.cwd(),
serviceEnv: Record<string, Record<string, string>> = {},
registry = DEFAULT_REGISTRY,
): Promise<KubernetesResource[]> {
const resources = new Map<string, KubernetesResource>();
@@ -1137,6 +1141,7 @@ export async function composeToKubernetes(
cwd,
serviceEnv[name] ?? {},
compose.volumes,
registry,
);
const envSecret = envSecrets[0];
if (envSecret) {
+3 -1
View File
@@ -2,11 +2,13 @@ import type { ComposeSpecification } from "../schema/docker.d";
import { composeToKubernetes, type KubernetesResource } from "./convert";
import { reconcilePostgresClaims } from "./database";
import { reconcileS3Claims } from "./storage";
import { DEFAULT_REGISTRY } from "./config";
export async function renderResources(
project: string,
compose: ComposeSpecification,
cwd = process.cwd(),
registry = DEFAULT_REGISTRY,
): Promise<KubernetesResource[]> {
const postgresEnv = await reconcilePostgresClaims(project, compose);
const s3Env = await reconcileS3Claims(project, compose);
@@ -14,5 +16,5 @@ export async function renderResources(
for (const [service, environment] of Object.entries(s3Env)) {
serviceEnv[service] = { ...serviceEnv[service], ...environment };
}
return composeToKubernetes(project, compose, cwd, serviceEnv);
return composeToKubernetes(project, compose, cwd, serviceEnv, registry);
}
+2 -3
View File
@@ -1,13 +1,12 @@
import { basename } from "node:path";
import type { V1Deployment, V1Pod } from "@kubernetes/client-node";
import { LABELS } from "../const";
import { ctx } from "./context";
import { apps, core, objectApi } from "./k8s";
const ManagedBySelector = `app.kubernetes.io/managed-by=${LABELS["app.kubernetes.io/managed-by"]}`;
export function getProject() {
const cwd = process.cwd();
return basename(cwd);
return ctx().project;
}
export async function listDeployments(labelSelector?: string) {
+14 -11
View File
@@ -1,16 +1,23 @@
{
"name": "@dmgnr/kuber",
"version": "1.0.0",
"version": "1.1.0",
"description": "Docker Compose to Kubernetes translation layer",
"type": "module",
"bin": {
"kuber": "dist/index.js"
},
"files": [
"dist/index.js"
"dist/index.js",
"types.d.ts",
"schema/docker.d.ts"
],
"engines": {
"bun": ">=1.0.0"
"type": "module",
"types": "types.d.ts",
"scripts": {
"build": "bun build index.ts --target bun --minify --outdir dist",
"prepack": "bun run build",
"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"
},
"devDependencies": {
"@bomb.sh/tab": "^0.0.22",
@@ -25,11 +32,7 @@
"typescript": "^5",
"zod": "^4.4.3"
},
"scripts": {
"build": "bun build index.ts --target bun --minify --outdir dist",
"prepack": "bun run build",
"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"
"engines": {
"bun": ">=1.0.0"
}
}
+3
View File
@@ -63,6 +63,9 @@ describe("CLI command definitions", () => {
type: "string",
alias: "o",
});
expect((main as Command).args?.config).toMatchObject({
type: "string",
});
});
test("defines both database inspection subcommands", () => {
+151
View File
@@ -0,0 +1,151 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { V1Deployment } from "@kubernetes/client-node";
import {
DEFAULT_BUILDERS,
DEFAULT_REGISTRY,
DEFAULT_ROLLOUT_TIMEOUT_MS,
extractConfigArgument,
loadConfig,
resolveConfigArgument,
} from "../../lib/config";
import { composeToKubernetes } from "../../lib/convert";
const directories: string[] = [];
async function temporaryDirectory() {
const directory = await mkdtemp(join(tmpdir(), "kuber-config-"));
directories.push(directory);
return directory;
}
afterEach(async () => {
await Promise.all(
directories
.splice(0)
.map((directory) => rm(directory, { recursive: true, force: true })),
);
});
describe("kuber config", () => {
test("uses operational defaults when .kuberrc.ts is absent", async () => {
const cwd = await temporaryDirectory();
expect(await loadConfig(cwd)).toMatchObject({
project: cwd.split("/").at(-1),
registry: DEFAULT_REGISTRY,
builders: DEFAULT_BUILDERS,
rolloutTimeoutMs: DEFAULT_ROLLOUT_TIMEOUT_MS,
configFile: undefined,
});
});
test("loads TypeScript from a custom config path", async () => {
const cwd = await temporaryDirectory();
const configFile = join(cwd, "production.kuberrc.ts");
await Bun.write(
configFile,
`export default {
project: "configured-project",
composeFile: "deploy/compose.yml",
registry: "registry.example.com/",
builders: { amd64: "user@amd", remoteRoot: "build-root" },
rolloutTimeoutMs: 42_000,
async preBuild() {},
};`,
);
const config = await loadConfig(cwd, "production.kuberrc.ts");
expect(config).toMatchObject({
project: "configured-project",
composeFile: join(cwd, "deploy", "compose.yml"),
registry: "registry.example.com",
builders: {
amd64: "user@amd",
arm64: DEFAULT_BUILDERS.arm64,
remoteRoot: "build-root",
},
rolloutTimeoutMs: 42_000,
configFile,
});
expect(config.preBuild).toBeFunction();
});
test("requires explicit config paths to exist", async () => {
const cwd = await temporaryDirectory();
await expect(loadConfig(cwd, "missing.ts")).rejects.toThrow(
"Config file cannot be found",
);
});
test("requires a default object export", async () => {
const cwd = await temporaryDirectory();
await Bun.write(join(cwd, ".kuberrc.ts"), "export const project = 'bad';");
await expect(loadConfig(cwd)).rejects.toThrow(
"must default export a configuration object",
);
});
test("validates hook and operational values", async () => {
const cwd = await temporaryDirectory();
await Bun.write(
join(cwd, ".kuberrc.ts"),
"export default { preBuild: true, rolloutTimeoutMs: -1 };",
);
await expect(loadConfig(cwd)).rejects.toThrow("preBuild");
});
test("uses the configured registry in rendered build images", async () => {
const resources = await composeToKubernetes(
"project",
{ services: { app: { build: "." } } },
process.cwd(),
{},
"registry.example.com/team",
);
const deployment = resources.find(
(resource) => resource.kind === "Deployment",
) as V1Deployment | undefined;
expect(deployment?.spec?.template.spec?.containers[0]?.image).toBe(
"registry.example.com/team/kuber/project-app:latest",
);
});
});
describe("global config argument", () => {
test.each([
[["--config", "one.ts", "up"], "one.ts"],
[["up", "--config", "two.ts"], "two.ts"],
[["--config=three.ts", "up"], "three.ts"],
])("reads a config path from %j", (args, expected) => {
expect(resolveConfigArgument(args)).toBe(expected);
});
test("rejects a config option without a path", () => {
expect(() => resolveConfigArgument(["up", "--config"])).toThrow(
"requires a path",
);
});
test("does not parse config arguments after --", () => {
expect(
resolveConfigArgument(["exec", "app", "--", "--config", "x.ts"]),
).toBeUndefined();
});
test("removes the global option before subcommand dispatch", () => {
expect(
extractConfigArgument(["logs", "--config", "production.ts"]),
).toEqual({
configPath: "production.ts",
rawArgs: ["logs"],
});
});
test("rejects duplicate config options", () => {
expect(() =>
extractConfigArgument(["--config=one.ts", "--config=two.ts"]),
).toThrow("only be specified once");
});
});
Vendored
+72
View File
@@ -0,0 +1,72 @@
import type { ComposeSpecification } from "./schema/docker.js";
export type { ComposeSpecification } from "./schema/docker.js";
export type MaybePromise<T> = T | Promise<T>;
export interface KuberHookContext {
cwd: string;
project: string;
composeFile: string;
configFile?: string;
}
export interface KuberBuildResult {
built: string[];
changed: string[];
}
export interface KuberResource {
apiVersion?: string;
kind?: string;
metadata?: {
name?: string;
namespace?: string;
labels?: Record<string, string>;
annotations?: Record<string, string>;
};
[key: string]: unknown;
}
export interface KuberBuildersConfig {
amd64?: string;
arm64?: string;
remoteRoot?: string;
}
export interface KuberConfig {
/** Kubernetes namespace/project name. Defaults to the working directory name. */
project?: string;
/** Compose file path, resolved relative to the working directory. */
composeFile?: string;
/** Container registry hostname or path, without a trailing slash. */
registry?: string;
builders?: KuberBuildersConfig;
/** Maximum time to wait for each deployment rollout. */
rolloutTimeoutMs?: number;
/** Runs once after the Compose file is parsed and validated. */
compose?(
compose: ComposeSpecification,
context: KuberHookContext,
): MaybePromise<void>;
/** Runs before build eligibility is evaluated when builds are enabled. */
preBuild?(
compose: ComposeSpecification,
context: KuberHookContext,
): MaybePromise<void>;
postBuild?(
result: KuberBuildResult,
context: KuberHookContext,
): MaybePromise<void>;
/** Runs after manifest rendering and before reconciliation planning. */
postRender?(
resources: KuberResource[],
context: KuberHookContext,
): MaybePromise<void>;
/** Runs after all desired resources have been applied. */
postApply?(
resources: KuberResource[],
context: KuberHookContext,
): MaybePromise<void>;
}