diff --git a/README.md b/README.md index b86c936..0a78de6 100644 --- a/README.md +++ b/README.md @@ -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 `: 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. diff --git a/command/export.ts b/command/export.ts index eb36dd9..9ba7e94 100644 --- a/command/export.ts +++ b/command/export.ts @@ -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[0]): string { +function toDocumentYaml( + resources: Parameters[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)); diff --git a/command/main.ts b/command/main.ts index 18b6185..72729ce 100644 --- a/command/main.ts +++ b/command/main.ts @@ -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, diff --git a/command/up.ts b/command/up.ts index 5a2ef01..3cb9306 100644 --- a/command/up.ts +++ b/command/up.ts @@ -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( @@ -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, { - progress: (message) => { - task.output = message; + const result = await buildServices( + project, + taskCtx.compose!, + cwd, + { + progress: (message) => { + task.output = message; + }, + stream: task.stdout(), }, - 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 }, ); diff --git a/index.ts b/index.ts index 65a875d..27b0800 100644 --- a/index.ts +++ b/index.ts @@ -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) => { diff --git a/lib/build.ts b/lib/build.ts index 59ad049..ae5e134 100644 --- a/lib/build.ts +++ b/lib/build.ts @@ -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(); + +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,39 +593,51 @@ export async function buildServices( compose: ComposeSpecification, cwd = process.cwd(), reporter?: BuildReporter, + options: BuildOptions = {}, ): Promise { - return withComposeArch(compose, async () => { - if ( - !Object.values(compose.services ?? {}).some((service) => service.build) - ) { - return { built: [], changed: [] }; - } + 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, + }, + }; - const repoRoot = await getRepoRoot(cwd); - const localBuilder = isOnRemoteBuilder(); - const buildRoot = localBuilder - ? repoRoot - : `${REMOTE_BUILD_ROOT}/${basename(repoRoot)}`; - const plans = getBuildPlans(project, compose, cwd, repoRoot, buildRoot); + return buildRuntime.run(runtime, () => + withComposeArch(compose, async () => { + if ( + !Object.values(compose.services ?? {}).some((service) => service.build) + ) { + return { built: [], changed: [] }; + } - if (plans.length === 0) return { built: [], changed: [] }; + const repoRoot = await getRepoRoot(cwd); + const localBuilder = isOnRemoteBuilder(); + const buildRoot = localBuilder + ? repoRoot + : `${getBuildRuntime().builders.remoteRoot}/${basename(repoRoot)}`; + const plans = getBuildPlans(project, compose, cwd, repoRoot, buildRoot); - if (!localBuilder) { - await syncRemoteRepo(repoRoot, buildRoot, reporter); - } + if (plans.length === 0) return { built: [], changed: [] }; - 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); - } + if (!localBuilder) { + await syncRemoteRepo(repoRoot, buildRoot, reporter); + } - return { - built: plans.map((plan) => plan.name), - changed, - }; - }); + 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 { + built: plans.map((plan) => plan.name), + changed, + }; + }), + ); } diff --git a/lib/config.ts b/lib/config.ts new file mode 100644 index 0000000..dcb4bdd --- /dev/null +++ b/lib/config.ts @@ -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; + 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; + 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 { + 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, + }; +} diff --git a/lib/context.ts b/lib/context.ts index 87e88be..2ff5a5c 100644 --- a/lib/context.ts +++ b/lib/context.ts @@ -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; + hookContext(): Promise; compose(): Promise; }; const appContext = new AsyncLocalStorage(); -export function provideContext(fn: () => T): T { +export async function provideContext( + fn: () => T, + configPath?: string, +): Promise> { 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, ); diff --git a/lib/convert.ts b/lib/convert.ts index 0408521..43e301d 100644 --- a/lib/convert.ts +++ b/lib/convert.ts @@ -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 = {}, 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> = {}, + registry = DEFAULT_REGISTRY, ): Promise { const resources = new Map(); @@ -1137,6 +1141,7 @@ export async function composeToKubernetes( cwd, serviceEnv[name] ?? {}, compose.volumes, + registry, ); const envSecret = envSecrets[0]; if (envSecret) { diff --git a/lib/render.ts b/lib/render.ts index 19564f3..8effa84 100644 --- a/lib/render.ts +++ b/lib/render.ts @@ -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 { 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); } diff --git a/lib/shared.ts b/lib/shared.ts index f21c043..f38c89a 100644 --- a/lib/shared.ts +++ b/lib/shared.ts @@ -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) { diff --git a/package.json b/package.json index 9ce5414..4e5498e 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/tests/command/metadata.test.ts b/tests/command/metadata.test.ts index 13fcb7b..8a1da56 100644 --- a/tests/command/metadata.test.ts +++ b/tests/command/metadata.test.ts @@ -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", () => { diff --git a/tests/lib/config.test.ts b/tests/lib/config.test.ts new file mode 100644 index 0000000..4f80e5c --- /dev/null +++ b/tests/lib/config.test.ts @@ -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"); + }); +}); diff --git a/types.d.ts b/types.d.ts new file mode 100644 index 0000000..3a6b692 --- /dev/null +++ b/types.d.ts @@ -0,0 +1,72 @@ +import type { ComposeSpecification } from "./schema/docker.js"; + +export type { ComposeSpecification } from "./schema/docker.js"; + +export type MaybePromise = T | Promise; + +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; + annotations?: Record; + }; + [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; + /** Runs before build eligibility is evaluated when builds are enabled. */ + preBuild?( + compose: ComposeSpecification, + context: KuberHookContext, + ): MaybePromise; + postBuild?( + result: KuberBuildResult, + context: KuberHookContext, + ): MaybePromise; + /** Runs after manifest rendering and before reconciliation planning. */ + postRender?( + resources: KuberResource[], + context: KuberHookContext, + ): MaybePromise; + /** Runs after all desired resources have been applied. */ + postApply?( + resources: KuberResource[], + context: KuberHookContext, + ): MaybePromise; +}