feat: initial prototype

This commit is contained in:
2026-02-13 15:52:13 +07:00
parent 134351b326
commit e8dbefde43
140 changed files with 12390 additions and 1369 deletions

View File

@@ -6,21 +6,24 @@
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "bun run src/index.ts",
"watch": "bun --watch run src/index.ts",
"apply-crds": "bun --elide-lines=0 run src/scripts/apply-crds.ts"
"start": "bun dist/index.js",
"dev": "bun --watch src/index.ts",
"watch": "bun --watch src/index.ts",
"apply-crds": "bun src/scripts/apply-crds.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@kubernetes/client-node": "^0.18.0",
"@kubernetes/client-node": "^1.4.0",
"@minikura/api": "workspace:*",
"@minikura/db": "workspace:*",
"dotenv-mono": "^1.3.11",
"node-fetch": "^3.3.2"
"dotenv-mono": "^1.5.1",
"node-fetch": "^3.3.2",
"pg": "^8.11.3",
"yaml": "^2.6.1"
},
"devDependencies": {
"@types/node": "^18.0.0",
"ts-node": "^10.9.1",
"ts-node-dev": "^2.0.0",
"typescript": "^5.0.0"
"@types/node": "^25.0.9",
"tsx": "^4.19.2",
"typescript": "^5.9.3"
}
}
}

View File

@@ -39,7 +39,7 @@ export class ServerController extends BaseController {
console.log(
`Server ${server.id} (${serverId}) has been removed from the database, deleting from Kubernetes...`
);
await deleteServer(serverId, server.id, appsApi, coreApi, this.namespace);
await deleteServer(serverId, appsApi, coreApi, this.namespace);
this.deployedServers.delete(serverId);
}
}

View File

@@ -8,7 +8,7 @@ export async function createReverseProxyServer(
server: ReverseProxyConfig,
appsApi: k8s.AppsV1Api,
coreApi: k8s.CoreV1Api,
networkingApi: k8s.NetworkingV1Api,
_networkingApi: k8s.NetworkingV1Api,
namespace: string
): Promise<void> {
console.log(`Creating reverse proxy server ${server.id} in namespace '${namespace}'`);
@@ -34,19 +34,17 @@ export async function createReverseProxyServer(
};
try {
await coreApi.createNamespacedConfigMap(namespace, configMap);
await coreApi.createNamespacedConfigMap({ namespace, body: configMap });
console.log(`Created ConfigMap for reverse proxy server ${server.id}`);
} catch (error: any) {
// Conflict, update it
if (error.response?.statusCode === 409) {
await coreApi.replaceNamespacedConfigMap(`${serverName}-config`, namespace, configMap);
await coreApi.replaceNamespacedConfigMap({ name: `${serverName}-config`, namespace, body: configMap });
console.log(`Updated ConfigMap for reverse proxy server ${server.id}`);
} else {
throw error;
}
}
// Create Service for the reverse proxy - Always LoadBalancer for now
const service = {
apiVersion: "v1",
kind: "Service",
@@ -76,19 +74,17 @@ export async function createReverseProxyServer(
};
try {
await coreApi.createNamespacedService(namespace, service);
await coreApi.createNamespacedService({ namespace, body: service });
console.log(`Created Service for reverse proxy server ${server.id}`);
} catch (error: any) {
// Conflict, update it
if (error.response?.statusCode === 409) {
await coreApi.replaceNamespacedService(serverName, namespace, service);
await coreApi.replaceNamespacedService({ name: serverName, namespace, body: service });
console.log(`Updated Service for reverse proxy server ${server.id}`);
} else {
throw error;
}
}
// Create Deployment
const deployment = {
apiVersion: "apps/v1",
kind: "Deployment",
@@ -170,12 +166,11 @@ export async function createReverseProxyServer(
};
try {
await appsApi.createNamespacedDeployment(namespace, deployment);
await appsApi.createNamespacedDeployment({ namespace, body: deployment });
console.log(`Created Deployment for reverse proxy server ${server.id}`);
} catch (error: any) {
// Conflict, update it
if (error.response?.statusCode === 409) {
await appsApi.replaceNamespacedDeployment(serverName, namespace, deployment);
await appsApi.replaceNamespacedDeployment({ name: serverName, namespace, body: deployment });
console.log(`Updated Deployment for reverse proxy server ${server.id}`);
} else {
throw error;
@@ -194,7 +189,7 @@ export async function deleteReverseProxyServer(
const name = `${serverType}-${proxyId}`;
try {
await appsApi.deleteNamespacedDeployment(name, namespace);
await appsApi.deleteNamespacedDeployment({ name, namespace });
console.log(`Deleted Deployment for reverse proxy server ${proxyId}`);
} catch (error: any) {
if (error.response?.statusCode !== 404) {
@@ -203,7 +198,7 @@ export async function deleteReverseProxyServer(
}
try {
await coreApi.deleteNamespacedService(name, namespace);
await coreApi.deleteNamespacedService({ name, namespace });
console.log(`Deleted Service for reverse proxy server ${proxyId}`);
} catch (error: any) {
if (error.response?.statusCode !== 404) {
@@ -212,7 +207,7 @@ export async function deleteReverseProxyServer(
}
try {
await coreApi.deleteNamespacedConfigMap(`${name}-config`, namespace);
await coreApi.deleteNamespacedConfigMap({ name: `${name}-config`, namespace });
console.log(`Deleted ConfigMap for reverse proxy server ${proxyId}`);
} catch (error: any) {
if (error.response?.statusCode !== 404) {

View File

@@ -1,14 +1,14 @@
import type * as k8s from "@kubernetes/client-node";
import { ServerType } from "@minikura/db";
import { LABEL_PREFIX } from "../config/constants";
import { calculateJavaMemory, convertToK8sFormat } from "../utils/memory";
import type { ServerConfig } from "../types";
import { calculateJavaMemory, convertToK8sFormat } from "../utils/memory";
export async function createServer(
server: ServerConfig,
appsApi: k8s.AppsV1Api,
coreApi: k8s.CoreV1Api,
networkingApi: k8s.NetworkingV1Api,
_networkingApi: k8s.NetworkingV1Api,
namespace: string
): Promise<void> {
const serverName = `minecraft-${server.id}`;
@@ -32,12 +32,15 @@ export async function createServer(
};
try {
await coreApi.createNamespacedConfigMap(namespace, configMap);
await coreApi.createNamespacedConfigMap({ namespace, body: configMap });
console.log(`Created ConfigMap for server ${server.id}`);
} catch (err: any) {
// Conflict, update it
if (err.response?.statusCode === 409) {
await coreApi.replaceNamespacedConfigMap(`${serverName}-config`, namespace, configMap);
await coreApi.replaceNamespacedConfigMap({
name: `${serverName}-config`,
namespace,
body: configMap,
});
console.log(`Updated ConfigMap for server ${server.id}`);
} else {
throw err;
@@ -68,17 +71,16 @@ export async function createServer(
name: "minecraft",
},
],
type: "ClusterIP", // Always ClusterIP for regular servers
type: "ClusterIP",
},
};
try {
await coreApi.createNamespacedService(namespace, service);
await coreApi.createNamespacedService({ namespace, body: service });
console.log(`Created Service for server ${server.id}`);
} catch (err: any) {
// Conflict, update it
if (err.response?.statusCode === 409) {
await coreApi.replaceNamespacedService(serverName, namespace, service);
await coreApi.replaceNamespacedService({ name: serverName, namespace, body: service });
console.log(`Updated Service for server ${server.id}`);
} else {
throw err;
@@ -205,12 +207,11 @@ async function createDeployment(
};
try {
await appsApi.createNamespacedDeployment(namespace, deployment);
await appsApi.createNamespacedDeployment({ namespace, body: deployment });
console.log(`Created Deployment for server ${server.id}`);
} catch (err: any) {
if (err.response?.statusCode === 409) {
// Deployment already exists, update it
await appsApi.replaceNamespacedDeployment(serverName, namespace, deployment);
await appsApi.replaceNamespacedDeployment({ name: serverName, namespace, body: deployment });
console.log(`Updated Deployment for server ${server.id}`);
} else {
throw err;
@@ -351,12 +352,15 @@ async function createStatefulSet(
};
try {
await appsApi.createNamespacedStatefulSet(namespace, statefulSet);
await appsApi.createNamespacedStatefulSet({ namespace, body: statefulSet });
console.log(`Created StatefulSet for server ${server.id}`);
} catch (err: any) {
if (err.response?.statusCode === 409) {
// StatefulSet already exists, update it
await appsApi.replaceNamespacedStatefulSet(serverName, namespace, statefulSet);
await appsApi.replaceNamespacedStatefulSet({
name: serverName,
namespace,
body: statefulSet,
});
console.log(`Updated StatefulSet for server ${server.id}`);
} else {
throw err;
@@ -366,15 +370,14 @@ async function createStatefulSet(
export async function deleteServer(
serverId: string,
serverId2: string,
appsApi: k8s.AppsV1Api,
coreApi: k8s.CoreV1Api,
namespace: string
): Promise<void> {
const serverName = `minecraft-${serverId2}`;
const serverName = `minecraft-${serverId}`;
try {
await appsApi.deleteNamespacedDeployment(serverName, namespace);
await appsApi.deleteNamespacedDeployment({ name: serverName, namespace });
console.log(`Deleted Deployment for server ${serverName}`);
} catch (err: any) {
if (err.response?.statusCode !== 404) {
@@ -383,7 +386,7 @@ export async function deleteServer(
}
try {
await appsApi.deleteNamespacedStatefulSet(serverName, namespace);
await appsApi.deleteNamespacedStatefulSet({ name: serverName, namespace });
console.log(`Deleted StatefulSet for server ${serverName}`);
} catch (err: any) {
if (err.response?.statusCode !== 404) {
@@ -392,7 +395,7 @@ export async function deleteServer(
}
try {
await coreApi.deleteNamespacedService(serverName, namespace);
await coreApi.deleteNamespacedService({ name: serverName, namespace });
console.log(`Deleted Service for server ${serverName}`);
} catch (err: any) {
if (err.response?.statusCode !== 404) {
@@ -401,7 +404,7 @@ export async function deleteServer(
}
try {
await coreApi.deleteNamespacedConfigMap(`${serverName}-config`, namespace);
await coreApi.deleteNamespacedConfigMap({ name: `${serverName}-config`, namespace });
console.log(`Deleted ConfigMap for server ${serverName}`);
} catch (err: any) {
if (err.response?.statusCode !== 404) {

View File

@@ -0,0 +1,83 @@
import pg from "pg";
export class NotificationService {
private pgClient: pg.Client | null = null;
private handlers = new Map<string, Set<(payload: unknown) => void | Promise<void>>>();
async connect(connectionString: string): Promise<void> {
if (!connectionString) {
throw new Error("Database connection string is required");
}
console.log("\n[NotificationService] Connecting to PostgreSQL...");
this.pgClient = new pg.Client({ connectionString });
await this.pgClient.connect();
this.pgClient.on("notification", async (msg) => {
const handlers = this.handlers.get(msg.channel);
if (!handlers) return;
try {
const payload = msg.payload ? JSON.parse(msg.payload) : {};
console.log(
`\n[NotificationService] Received notification on channel '${msg.channel}':`,
payload
);
for (const handler of handlers) {
try {
await handler(payload);
} catch (err) {
console.error(
`[NotificationService] Error in handler for channel '${msg.channel}':`,
err
);
}
}
} catch (err) {
console.error(`[NotificationService] Failed to parse notification payload:`, err);
}
});
console.log("[NotificationService] Connected successfully");
}
async listen(
channel: string,
handler: (payload: unknown) => void | Promise<void>
): Promise<void> {
if (!this.pgClient) {
throw new Error("NotificationService not connected");
}
if (!this.handlers.has(channel)) {
this.handlers.set(channel, new Set());
await this.pgClient.query(`LISTEN ${channel}`);
console.log(`[NotificationService] Listening on channel: ${channel}`);
}
this.handlers.get(channel)!.add(handler);
}
async unlisten(channel: string): Promise<void> {
if (!this.pgClient) return;
this.handlers.delete(channel);
await this.pgClient.query(`UNLISTEN ${channel}`);
console.log(`[NotificationService] Stopped listening on channel: ${channel}`);
}
async disconnect(): Promise<void> {
if (!this.pgClient) return;
console.log("\n[NotificationService] Disconnecting...");
await this.pgClient.end();
this.pgClient = null;
this.handlers.clear();
console.log("[NotificationService] Disconnected");
}
isConnected(): boolean {
return this.pgClient !== null;
}
}

View File

@@ -27,11 +27,10 @@ async function registerCRDs(k8sClient: KubernetesClient): Promise<void> {
console.log("Registering CRDs...");
try {
await apiExtensionsClient.createCustomResourceDefinition(MINECRAFT_SERVER_CRD);
await apiExtensionsClient.createCustomResourceDefinition({ body: MINECRAFT_SERVER_CRD });
console.log(`MinecraftServer CRD created successfully (${API_GROUP}/${API_VERSION})`);
} catch (error: any) {
if (error.response?.statusCode === 409) {
// TODO: Handle conflict
console.log("MinecraftServer CRD already exists");
} else {
console.error("Error creating MinecraftServer CRD:", error);
@@ -39,11 +38,10 @@ async function registerCRDs(k8sClient: KubernetesClient): Promise<void> {
}
try {
await apiExtensionsClient.createCustomResourceDefinition(REVERSE_PROXY_SERVER_CRD);
await apiExtensionsClient.createCustomResourceDefinition({ body: REVERSE_PROXY_SERVER_CRD });
console.log(`ReverseProxyServer CRD created successfully (${API_GROUP}/${API_VERSION})`);
} catch (error: any) {
if (error.response?.statusCode === 409) {
// TODO: Handle conflict
console.log("ReverseProxyServer CRD already exists");
} else {
console.error("Error creating ReverseProxyServer CRD:", error);

View File

@@ -0,0 +1,9 @@
export const getErrorMessage = (error: unknown): string => {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error;
}
return "Unknown error";
};

View File

@@ -0,0 +1,108 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { KubeConfig } from "@kubernetes/client-node";
import { spawnSync } from "bun";
import YAML from "yaml";
type KubeConfigDoc = {
users?: Array<{ name: string; user: { token?: string } }>;
contexts?: Array<{
name: string;
context: { cluster: string; user: string; namespace?: string };
}>;
clusters?: Array<{ name: string }>;
};
import { NAMESPACE } from "../config/constants";
const SA_NAME = process.env.K8S_SA_NAME || "minikura-operator";
const TOKEN_DURATION_HOURS = Number(process.env.K8S_TOKEN_DURATION_HOURS || 24);
const TOKEN_REFRESH_MIN = Number(process.env.K8S_TOKEN_REFRESH_MIN || 60);
function kubeconfigPath(): string {
return process.env.KUBECONFIG || `${process.env.HOME || process.env.USERPROFILE}/.kube/config`;
}
function refreshSaToken(): void {
const duration = `${TOKEN_DURATION_HOURS}h`;
const args = ["kubectl", "-n", NAMESPACE, "create", "token", SA_NAME, "--duration", duration];
if (process.env.KUBERNETES_SKIP_TLS_VERIFY === "true") {
args.push("--insecure-skip-tls-verify");
}
const proc = spawnSync(args);
if (proc.exitCode !== 0) {
console.error("[kube-auth] kubectl create token failed:", proc.stderr.toString());
return;
}
const token = proc.stdout.toString().trim();
const kcPath = kubeconfigPath();
if (!existsSync(kcPath)) {
console.error("[kube-auth] kubeconfig not found at:", kcPath);
return;
}
const doc = YAML.parse(readFileSync(kcPath, "utf8")) as KubeConfigDoc;
let user = doc.users?.find((existingUser) => existingUser.name === SA_NAME);
if (!user) {
user = { name: SA_NAME, user: {} };
if (!doc.users) doc.users = [];
doc.users.push(user);
}
user.user = { token };
let ctx = doc.contexts?.find((context) => context.name === "bun-local-operator");
if (!ctx) {
const clusterName = doc.clusters?.[0]?.name || "default";
ctx = {
name: "bun-local-operator",
context: {
cluster: clusterName,
user: SA_NAME,
namespace: NAMESPACE,
},
};
if (!doc.contexts) doc.contexts = [];
doc.contexts.push(ctx);
} else {
ctx.context.user = SA_NAME;
ctx.context.namespace = NAMESPACE;
}
writeFileSync(kcPath, YAML.stringify(doc));
console.log(
`[kube-auth] kubeconfig updated with fresh token for ${SA_NAME} (expires in ${duration})`
);
}
export function buildKubeConfig(): KubeConfig {
const kc = new KubeConfig();
const isInCluster =
process.env.KUBERNETES_SERVICE_HOST &&
existsSync("/var/run/secrets/kubernetes.io/serviceaccount/token");
if (isInCluster) {
console.log("[kube-auth] Running in-cluster, loading from service account");
kc.loadFromCluster();
return kc;
}
console.log("[kube-auth] Running locally, using ServiceAccount token auth");
refreshSaToken();
setInterval(refreshSaToken, TOKEN_REFRESH_MIN * 60_000);
kc.loadFromDefault();
try {
kc.setCurrentContext("bun-local-operator");
} catch (error) {
console.warn("[kube-auth] Could not set bun-local-operator context, using default");
}
return kc;
}

View File

@@ -1,16 +1,11 @@
/**
* Memory utility functions for Kubernetes resources
*/
export function calculateJavaMemory(memory: number | string, factor: number): string {
if (typeof memory === "number") {
const calculatedValue = Math.round(memory * factor);
return `${calculatedValue}M`;
}
/**
* Calculate memory for Java (lower than what's requested to account for JVM overhead)
* @param memoryString Memory string in format like "512M" or "1G"
* @param factor Multiplicative factor to apply (e.g., 0.8 for 80%)
* @returns Calculated memory string in same format
*/
export function calculateJavaMemory(memoryString: string, factor: number): string {
const match = memoryString.match(/^(\d+)([MG])$/i);
if (!match) return "512M"; // Default if format is not recognized
const match = memory.match(/^(\d+)([MG])$/i);
if (!match) return "512M";
const [, valueStr, unit] = match;
const value = parseInt(valueStr, 10);
@@ -19,14 +14,13 @@ export function calculateJavaMemory(memoryString: string, factor: number): strin
return `${calculatedValue}${unit.toUpperCase()}`;
}
/**
* Convert memory string to Kubernetes format (e.g., "1G" -> "1Gi")
* @param memoryString Memory string in format like "512M" or "1G"
* @returns Memory string in Kubernetes format
*/
export function convertToK8sFormat(memoryString: string): string {
const match = memoryString.match(/^(\d+)([MG])$/i);
if (!match) return "1Gi"; // Default if format is not recognized
export function convertToK8sFormat(memory: number | string): string {
if (typeof memory === "number") {
return `${memory}Mi`;
}
const match = memory.match(/^(\d+)([MG])$/i);
if (!match) return "1Gi";
const [, valueStr, unit] = match;

View File

@@ -38,7 +38,7 @@ export async function registerRBACResources(k8sClient: KubernetesClient): Promis
async function registerNamespace(k8sClient: KubernetesClient): Promise<void> {
try {
const coreApi = k8sClient.getCoreApi();
await coreApi.createNamespace(minikuraNamespace);
await coreApi.createNamespace({ body: minikuraNamespace });
console.log(`Created namespace ${minikuraNamespace.metadata.name}`);
} catch (error: any) {
if (error.response?.statusCode === 409) {
@@ -55,10 +55,10 @@ async function registerNamespace(k8sClient: KubernetesClient): Promise<void> {
async function registerServiceAccount(k8sClient: KubernetesClient): Promise<void> {
try {
const coreApi = k8sClient.getCoreApi();
await coreApi.createNamespacedServiceAccount(
minikuraServiceAccount.metadata.namespace,
minikuraServiceAccount
);
await coreApi.createNamespacedServiceAccount({
namespace: minikuraServiceAccount.metadata.namespace,
body: minikuraServiceAccount
});
console.log(`Created service account ${minikuraServiceAccount.metadata.name}`);
} catch (error: any) {
if (error.response?.statusCode === 409) {
@@ -74,10 +74,9 @@ async function registerServiceAccount(k8sClient: KubernetesClient): Promise<void
*/
async function registerClusterRole(k8sClient: KubernetesClient): Promise<void> {
try {
// TODO: I can't get this working with the k8s client, so I'm using fetch directly, fix later
const kc = k8sClient.getKubeConfig();
const opts = {};
kc.applyToRequest(opts as any);
const opts: any = {};
await kc.applyToHTTPSOptions(opts);
// Get cluster URL
const cluster = kc.getCurrentCluster();
@@ -128,10 +127,9 @@ async function registerClusterRole(k8sClient: KubernetesClient): Promise<void> {
*/
async function registerClusterRoleBinding(k8sClient: KubernetesClient): Promise<void> {
try {
// We need to use the raw client for cluster roles
const kc = k8sClient.getKubeConfig();
const opts = {};
kc.applyToRequest(opts as any);
const opts: any = {};
await kc.applyToHTTPSOptions(opts);
// Get cluster URL
const cluster = kc.getCurrentCluster();
@@ -212,11 +210,11 @@ export async function registerOperatorDeployment(
await k8sClient
.getAppsApi()
.replaceNamespacedDeployment(
deployment.metadata.name,
deployment.metadata.namespace,
deployment
);
.replaceNamespacedDeployment({
name: deployment.metadata.name,
namespace: deployment.metadata.namespace,
body: deployment
});
console.log(`Updated deployment ${deployment.metadata.name}`);
} else {
throw error;

View File

@@ -0,0 +1,19 @@
import type { ServiceType } from "@minikura/db";
export function mapServiceType(
serviceType?: ServiceType | null,
defaultType: string = "ClusterIP"
): string {
if (!serviceType) return defaultType;
switch (serviceType) {
case "CLUSTER_IP":
return "ClusterIP";
case "NODE_PORT":
return "NodePort";
case "LOAD_BALANCER":
return "LoadBalancer";
default:
return defaultType;
}
}