Initial Commit
This commit is contained in:
@@ -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
@@ -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)
|
||||
@@ -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
@@ -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);
|
||||
@@ -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
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user