feat: add .kuberrc.ts configuration
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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,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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user