Initial Commit

This commit is contained in:
2026-08-08 22:23:27 +07:00 Unverified
commit c08fb36d2e
22 changed files with 6212 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
import { AsyncLocalStorage } from "node:async_hooks";
import type { ComposeSpecification } from "../schema/docker.d";
import { getProject, throwWhen } from "./shared";
import { readCompose, resolveComposeFile } from "./yaml";
type AppContext = {
project: string;
cwd: string;
path(): Promise<string | undefined>;
compose(): Promise<ComposeSpecification>;
};
const appContext = new AsyncLocalStorage<AppContext>();
export function provideContext<T>(fn: () => T): T {
const cwd = process.cwd();
const path = createCachedGetter("composeFile", () => resolveComposeFile(cwd));
return appContext.run(
{
project: getProject(),
cwd,
path,
compose: createCachedGetter("compose", () =>
path()
.then(throwWhen((path): path is undefined => path === undefined))
.then(readCompose),
),
},
fn,
);
}
export function ctx() {
const store = appContext.getStore();
if (!store) throw new Error("Not in context!");
return store;
}
const cache: Record<string, unknown> = {};
export function createCachedGetter<T>(
key: string,
fn: () => Promise<T>,
): () => Promise<T> {
return async () => {
if (key in cache) return cache[key] as T;
const result = await fn();
cache[key] = result;
return result;
};
}
+342
View File
@@ -0,0 +1,342 @@
import type {
V1Container,
V1ContainerPort,
V1Deployment,
V1Volume,
V1VolumeMount,
} from "@kubernetes/client-node";
import type { Service } from "../schema/docker.d";
import { toEnvVars } from "./format";
import { IMAGE_REGISTRY, LABELS } from "../const";
import { deepMerge } from "./shared";
type NormalizedMount = {
name: string;
kind: "bind" | "volume" | "tmpfs";
target: string;
source?: string;
readOnly?: boolean;
subPath?: string;
sizeLimit?: string;
};
type ServiceVolume = NonNullable<Service["volumes"]>[number];
function toPortNumber(value: number | string | undefined): number | undefined {
if (typeof value === "number") return value;
if (!value || value.includes("-")) return;
const port = Number(value);
return Number.isInteger(port) ? port : undefined;
}
function toProtocol(value: string | undefined): "TCP" | "UDP" {
return value?.toUpperCase() === "UDP" ? "UDP" : "TCP";
}
function toBoolean(value: boolean | string | undefined): boolean | undefined {
if (typeof value === "boolean") return value;
if (!value) return;
switch (value.toLowerCase()) {
case "1":
case "true":
case "yes":
case "on":
case "ro":
return true;
case "0":
case "false":
case "no":
case "off":
case "rw":
return false;
}
}
function toKubeName(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 63);
}
function toBindVolumeName(source: string): string {
return toKubeName(`bind-${Bun.hash(source).toString(36)}`);
}
function isBindSource(source: string): boolean {
return (
source.startsWith(".") ||
source.startsWith("/") ||
source.startsWith("~") ||
source.includes("/")
);
}
function parseStringMount(
entry: string,
index: number,
): NormalizedMount | undefined {
const parts = entry.split(":");
if (parts.length === 1) {
return {
name: `volume-${index}`,
kind: "volume",
target: entry,
};
}
const maybeMode = parts.at(-1);
const hasMode = maybeMode === "ro" || maybeMode === "rw";
const target = parts.at(hasMode ? -2 : -1);
const source = parts.slice(0, hasMode ? -2 : -1).join(":");
if (!target) return;
return {
name:
source && isBindSource(source)
? toBindVolumeName(source)
: `volume-${index}`,
kind: source && isBindSource(source) ? "bind" : "volume",
source: source || undefined,
target,
readOnly: hasMode ? maybeMode === "ro" : undefined,
};
}
function toMount(
entry: ServiceVolume,
index: number,
): NormalizedMount | undefined {
if (typeof entry === "string") return parseStringMount(entry, index);
if (!entry.target) return;
if (
entry.type !== "bind" &&
entry.type !== "volume" &&
entry.type !== "tmpfs"
) {
return;
}
return {
name: entry.source
? entry.type === "bind"
? toBindVolumeName(entry.source)
: `${entry.type}-${toKubeName(entry.source) || index}`
: `${entry.type}-${index}`,
kind: entry.type,
source: entry.source,
target: entry.target,
readOnly: toBoolean(entry.read_only),
subPath: entry.volume?.subpath,
sizeLimit:
entry.type === "tmpfs" && entry.tmpfs?.size !== undefined
? String(entry.tmpfs.size)
: undefined,
};
}
function toMounts(service: Service): NormalizedMount[] {
const mounts =
service.volumes?.flatMap((entry, index) => {
const mount = toMount(entry, index);
return mount ? [mount] : [];
}) ?? [];
const tmpfs = (Array.isArray(service.tmpfs) ? service.tmpfs : [service.tmpfs])
.filter((entry): entry is string => Boolean(entry))
.map<NormalizedMount>((target, index) => ({
name: `tmpfs-${mounts.length + index}`,
kind: "tmpfs",
target,
}));
return [...mounts, ...tmpfs];
}
function toVolumeMounts(
mounts: NormalizedMount[],
): V1VolumeMount[] | undefined {
if (mounts.length === 0) return;
return mounts.map((mount) => ({
name: mount.name,
mountPath: mount.target,
readOnly: mount.readOnly,
subPath: mount.subPath,
}));
}
function toVolumes(
project: string,
mounts: NormalizedMount[],
): V1Volume[] | undefined {
if (mounts.length === 0) return;
return mounts.map((mount) => {
if (mount.kind === "bind") {
if (!mount.source) {
return {
name: mount.name,
emptyDir: {},
};
}
return {
name: mount.name,
persistentVolumeClaim: {
claimName: mount.name,
},
};
}
if (mount.kind === "tmpfs") {
return {
name: mount.name,
emptyDir: {
medium: "Memory",
sizeLimit: mount.sizeLimit,
},
};
}
if (!mount.source) {
return {
name: mount.name,
emptyDir: {},
};
}
return {
name: mount.name,
persistentVolumeClaim: {
claimName: toKubeName(`${project}-${mount.source}`),
},
};
});
}
function toContainerPorts(service: Service): V1Container["ports"] {
return service.ports?.flatMap<V1ContainerPort>((entry, index) => {
if (typeof entry === "number") {
return [
{
name: `port-${index}`,
containerPort: entry,
protocol: "TCP" as const,
},
];
}
if (typeof entry === "string") {
const [portSpec, protocolSpec] = entry.split("/");
if (!portSpec) return [];
const segments = portSpec.split(":");
const target = toPortNumber(segments.at(-1));
const published = toPortNumber(segments.at(-2));
if (!target) return [];
return [
{
name: `port-${index}`,
containerPort: target,
hostPort: published,
protocol: toProtocol(protocolSpec),
},
];
}
const target = toPortNumber(entry.target);
if (!target) return [];
return [
{
name: entry.name ?? `port-${index}`,
containerPort: target,
hostPort: toPortNumber(entry.published),
protocol: toProtocol(entry.protocol),
},
];
});
}
export function serviceToDeployment(
project: string,
name: string,
service: Service,
): V1Deployment {
const mounts = toMounts(service);
return deepMerge(
{
apiVersion: "apps/v1",
kind: "Deployment",
metadata: {
name,
namespace: project,
labels: LABELS,
},
spec: {
strategy: {
rollingUpdate: {
maxSurge: "25%",
maxUnavailable: "25%",
},
},
selector: {
matchLabels: { app: name },
},
template: {
metadata: {
labels: {
app: name,
},
},
spec: {
restartPolicy: "Always",
volumes: toVolumes(project, mounts),
containers: [
deepMerge(
{
name,
imagePullPolicy: "Always",
env: toEnvVars(service.environment),
image: `${IMAGE_REGISTRY}/kuber/${project}-${name}:latest`,
command:
service.command instanceof Array
? service.command
: service.command
? service.command.split(" ")
: undefined,
envFrom: service.env_file
? [{ secretRef: { name: `${name}-env` } }]
: undefined,
ports: toContainerPorts(service),
volumeMounts: toVolumeMounts(mounts),
} satisfies V1Container,
service["x-container"] ?? {},
),
],
},
},
},
} satisfies V1Deployment,
service["x-deployment"] ?? {},
);
}
export function serviceToSvc() {}
// todo
// volumesToPvc
// envFromToSecrets
// composeToNamespace
// serviceToSvc
// serviceToIngress
// composeToKubernetes (final function)
+34
View File
@@ -0,0 +1,34 @@
import { Table } from "@cliffy/table";
import type { V1EnvVar } from "@kubernetes/client-node";
import type { Service } from "../schema/docker.d";
function toTable(args: Record<string, string | number | undefined>[]): string {
return new Table(
Object.keys(args[0] ?? {}).map((e) => e.toUpperCase()),
...args.map((e) => Object.values(e)),
).toString();
}
function toEnvVars(
environment: Service["environment"],
): V1EnvVar[] | undefined {
if (!environment) return;
if (Array.isArray(environment)) {
return environment.map((entry) => {
const separator = entry.indexOf("=");
const name = separator === -1 ? entry : entry.slice(0, separator);
return {
name,
value: separator === -1 ? undefined : entry.slice(separator + 1),
};
});
}
return Object.entries(environment).map(([name, value]) => ({
name,
value: value === null ? undefined : String(value),
}));
}
export { toEnvVars, toTable };
+80
View File
@@ -0,0 +1,80 @@
import { AppsV1Api, CoreV1Api, KubeConfig } from "@kubernetes/client-node";
import { ResponseContext } from "@kubernetes/client-node/dist/gen/http/http.js";
import { from } from "@kubernetes/client-node/dist/gen/rxjsStub.js";
import http from "node:http";
import https from "node:https";
const kc = new KubeConfig();
kc.loadFromDefault();
const bunHttpLibrary = {
send(request: {
getUrl(): string;
getHttpMethod(): { toString(): string };
getBody(): unknown;
getHeaders(): Record<string, string>;
getSignal(): AbortSignal | undefined;
getAgent(): http.Agent | https.Agent | undefined;
}) {
const result = new Promise<ResponseContext>((resolve, reject) => {
const url = new URL(request.getUrl());
const transport = url.protocol === "http:" ? http : https;
const req = transport.request(
url,
{
method: request.getHttpMethod().toString(),
headers: request.getHeaders(),
agent: request.getAgent(),
},
(response) => {
const chunks: Buffer[] = [];
response.on("data", (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
response.on("end", () => {
const buffer = Buffer.concat(chunks);
const headers: Record<string, string> = {};
for (const [key, value] of Object.entries(response.headers)) {
if (Array.isArray(value)) headers[key] = value.join(", ");
else if (value !== undefined) headers[key] = String(value);
}
resolve(
new ResponseContext(response.statusCode ?? 0, headers, {
text: async () => buffer.toString("utf8"),
binary: async () => buffer,
}),
);
});
},
);
request.getSignal()?.addEventListener("abort", () => {
req.destroy(new Error("Request aborted"));
});
req.on("error", reject);
const body = request.getBody();
if (body !== undefined && body !== null) req.write(body as string | Uint8Array);
req.end();
});
return from(result);
},
};
const makeApiClient = kc.makeApiClient.bind(kc);
kc.makeApiClient = ((apiClientType) => {
const client = makeApiClient(apiClientType) as unknown as {
api: { configuration: { httpApi: typeof bunHttpLibrary } };
};
client.api.configuration.httpApi = bunHttpLibrary;
return client;
}) as typeof kc.makeApiClient;
export const core = kc.makeApiClient(CoreV1Api);
export const apps = kc.makeApiClient(AppsV1Api);
+60
View File
@@ -0,0 +1,60 @@
import { basename } from "node:path";
import { apps } from "./k8s";
import { feature } from "bun:bundle";
export function getProject() {
if (!feature("prod")) return "kube-system";
const cwd = process.cwd();
return basename(cwd);
}
export async function listDeployments() {
const { items } = await apps.listNamespacedDeployment({
namespace: getProject(),
});
return items;
}
export function throwWhen<T, Narrowed extends T>(
condition: (arg: T) => arg is Narrowed,
): (arg: T) => Exclude<T, Narrowed>;
export function throwWhen<T>(condition: (arg: T) => unknown): (arg: T) => T;
export function throwWhen<T>(condition: (arg: T) => unknown) {
return (arg: T) => {
if (condition(arg)) throw new Error("Condition matched");
return arg;
};
}
export function deepMerge(target: any, source: any): any {
if (
typeof target !== "object" ||
target === null ||
typeof source !== "object" ||
source === null
) {
return source;
}
const result = Array.isArray(target) ? [...target] : { ...target };
for (const key of Object.keys(source)) {
const targetValue = result[key];
const sourceValue = source[key];
if (Array.isArray(targetValue) && Array.isArray(sourceValue)) {
result[key] = [...targetValue, ...sourceValue]; // or use a custom strategy
} else if (
targetValue &&
typeof targetValue === "object" &&
sourceValue &&
typeof sourceValue === "object"
) {
result[key] = deepMerge(targetValue, sourceValue);
} else {
result[key] = sourceValue;
}
}
return result;
}
+32
View File
@@ -0,0 +1,32 @@
import z from "zod";
import { readdir } from "node:fs/promises";
import { join } from "node:path";
import type { ComposeSpecification } from "../schema/docker.d";
import JsonSchema from "../schema/docker.ts";
import { file, YAML } from "bun";
const ComposeFileNames = new Set([
"compose.yml",
"compose.yaml",
"docker-compose.yml",
"docker-compose.yaml",
]);
const ComposeSchema = z.fromJSONSchema(
JsonSchema as unknown as Parameters<typeof z.fromJSONSchema>[0],
) as z.ZodType<ComposeSpecification>;
export function readCompose(path: string): Promise<ComposeSpecification> {
return file(path).text().then(YAML.parse).then(ComposeSchema.parse);
}
export async function resolveComposeFile(
path: string,
): Promise<string | undefined> {
const entries = await readdir(path, { withFileTypes: true });
return entries
.filter((entry) => entry.isFile() && ComposeFileNames.has(entry.name))
.map((entry) => join(path, entry.name))
.sort()[0];
}