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

@@ -1,17 +1,19 @@
{
"name": "@minikura/api",
"module": "src/index.ts",
"type": "module",
"exports": "./src/index.ts",
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"dependencies": {
"@elysiajs/eden": "^1.1.3",
"@minikura/backend": "workspace:*",
"elysia": "^1.1.13"
}
"name": "@minikura/api",
"module": "src/index.ts",
"type": "module",
"exports": "./src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"dependencies": {
"@elysiajs/eden": "^1.4.6",
"elysia": "^1.4.22"
}
}

View File

@@ -0,0 +1,4 @@
export const DOMAIN = "minikura.kirameki.cafe";
export const LABEL_PREFIX = DOMAIN;
export const API_GROUP = DOMAIN;

View File

@@ -1,4 +1,7 @@
import { edenTreaty } from "@elysiajs/eden";
import type { App } from "@minikura/backend";
import { treaty } from "@elysiajs/eden";
export const api = edenTreaty<App>("http://localhost:3000");
export const api = treaty("http://localhost:3000");
export * from "./constants";
export * from "./labels";
export * from "./types";

View File

@@ -0,0 +1,10 @@
import { LABEL_PREFIX } from "./constants";
export const labelKeys = {
serverType: `${LABEL_PREFIX}/server-type`,
serverId: `${LABEL_PREFIX}/server-id`,
proxyId: `${LABEL_PREFIX}/proxy-id`,
restartAt: `${LABEL_PREFIX}/restart-at`,
databaseManaged: `${LABEL_PREFIX}/database-managed`,
lastSynced: `${LABEL_PREFIX}/last-synced`,
} as const;

209
packages/api/src/types.ts Normal file
View File

@@ -0,0 +1,209 @@
import type {
EnvVariable as DbEnvVariable,
ReverseProxyWithEnvVars,
ServerWithEnvVars,
} from "@minikura/db";
export type EnvVariable = DbEnvVariable;
export type NormalServer = ServerWithEnvVars;
export type ReverseProxyServer = ReverseProxyWithEnvVars;
export type K8sResource = {
name: string;
kind: string;
namespace?: string;
age: string;
labels?: Record<string, string>;
[key: string]: unknown;
};
export type K8sServiceSummary = {
name: string;
namespace?: string;
type?: string;
clusterIP?: string | null;
externalIP?: string;
ports?: string;
age: string;
labels?: Record<string, string>;
};
export type K8sConfigMapSummary = {
name: string;
namespace?: string;
data: number;
age: string;
labels?: Record<string, string>;
};
export type K8sIngressSummary = {
name: string;
namespace?: string;
className?: string | null;
hosts: string;
address: string;
age: string;
labels?: Record<string, string>;
};
export type K8sServicePort = {
name?: string | null;
protocol?: string | null;
port?: number;
targetPort?: number | string;
nodePort?: number | null;
};
export type K8sServiceInfo = {
name?: string;
namespace?: string;
type?: string;
clusterIP?: string | null;
externalIPs: string[];
loadBalancerIP: string | null;
loadBalancerHostname: string | null;
ports: K8sServicePort[];
selector?: Record<string, string>;
};
export type K8sNodeSummary = {
name?: string;
status: string;
roles: string;
age: string;
version?: string;
internalIP?: string;
externalIP?: string;
hostname?: string;
};
export type CustomResourceSummary = {
name?: string;
namespace?: string;
age: string;
labels?: Record<string, string>;
spec?: Record<string, unknown>;
status?: { phase?: string; [key: string]: unknown };
};
export type K8sStatus = {
initialized: boolean;
};
export type PodInfo = {
name: string;
namespace?: string;
status: string;
ready: string;
restarts: number;
age?: string;
containers?: string[];
nodeName?: string;
ip?: string;
labels?: Record<string, string>;
};
export type PodCondition = {
type?: string;
status?: string;
lastTransitionTime?: string;
};
export type PodContainerStatus = {
name?: string;
ready?: boolean;
restartCount?: number;
state?: Record<string, unknown>;
};
export type PodDetails = PodInfo & {
conditions?: PodCondition[];
containerStatuses?: PodContainerStatus[];
};
export type StatefulSetInfo = {
name: string;
ready: string;
desired: number;
current: number;
updated: number;
age: string;
labels?: Record<string, string>;
};
export type DeploymentInfo = {
name: string;
ready: string;
desired: number;
current: number;
updated: number;
upToDate?: number;
available?: number;
age: string;
labels?: Record<string, string>;
};
export type ConnectionInfo = {
type: string;
connectionString?: string | null;
note?: string | null;
ip?: string;
port?: number | null;
nodeIP?: string;
nodePort?: number;
externalIP?: string;
};
export type CreateServerRequest = {
id: string;
type: "STATEFUL" | "STATELESS";
description?: string | null;
listen_port: number;
service_type?: string;
node_port?: number | null;
env_variables?: EnvVariable[];
memory?: number;
memory_request?: number;
cpu_request?: string;
cpu_limit?: string;
jar_type?: string;
minecraft_version?: string;
jvm_opts?: string;
use_aikar_flags?: boolean;
use_meowice_flags?: boolean;
difficulty?: string;
game_mode?: string;
max_players?: number;
pvp?: boolean;
online_mode?: boolean;
motd?: string | null;
level_seed?: string | null;
level_type?: string | null;
};
export type UpdateServerRequest = {
description?: string | null;
listen_port?: number;
service_type?: string;
node_port?: number | null;
env_variables?: EnvVariable[];
memory?: number;
memory_request?: number;
cpu_request?: string;
cpu_limit?: string;
jar_type?: string;
minecraft_version?: string;
jvm_opts?: string;
use_aikar_flags?: boolean;
use_meowice_flags?: boolean;
difficulty?: string;
game_mode?: string;
max_players?: number;
pvp?: boolean;
online_mode?: boolean;
motd?: string | null;
level_seed?: string | null;
level_type?: string | null;
};

View File

@@ -58,6 +58,7 @@ build/Release
node_modules/
jspm_packages/
src/generated/prisma/
# Snowpack dependency directory (https://snowpack.dev/)

View File

@@ -1,24 +1,28 @@
{
"name": "@minikura/db",
"module": "src/index.ts",
"type": "module",
"exports": "./src/index.ts",
"scripts": {
"generate": "prisma generate",
"studio": "bun with-env prisma studio",
"push": "bun with-env prisma db push",
"reset": "bun with-env prisma migrate reset --force",
"with-env": "dotenv -e ../../.env --"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"dependencies": {
"@prisma/client": "5.20.0",
"dotenv-cli": "^7.4.2",
"prisma": "^5.20.0"
}
"name": "@minikura/db",
"module": "src/index.ts",
"type": "module",
"exports": "./src/index.ts",
"scripts": {
"generate": "prisma generate",
"studio": "bun with-env prisma studio",
"push": "bun with-env prisma db push",
"reset": "bun with-env prisma migrate reset --force",
"typecheck": "tsc --noEmit",
"with-env": "dotenv -e ../../.env --"
},
"devDependencies": {
"@types/bun": "latest",
"@types/pg": "^8.16.0"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"dependencies": {
"@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "7.2.0",
"dotenv-cli": "^11.0.0",
"pg": "^8.17.1",
"prisma": "^7.2.0"
}
}

View File

@@ -0,0 +1,9 @@
import path from "node:path";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: path.join(__dirname, "prisma/schema.prisma"),
datasource: {
url: process.env.DATABASE_URL!,
},
});

View File

@@ -1,12 +1,74 @@
generator client {
provider = "prisma-client-js"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// Better Auth Models
model User {
id String @id
name String
email String @unique
emailVerified Boolean @default(false) @map("email_verified")
image String?
role String @default("user")
isSuspended Boolean @default(false) @map("is_suspended")
suspendedUntil DateTime? @map("suspended_until")
banned Boolean @default(false) @map("banned")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
sessions Session[]
accounts Account[]
@@map("user")
}
model Session {
id String @id
expiresAt DateTime @map("expires_at")
token String @unique
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
ipAddress String? @map("ip_address")
userAgent String? @map("user_agent")
userId String @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("session")
}
model Account {
id String @id
accountId String @map("account_id")
providerId String @map("provider_id")
userId String @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accessToken String? @map("access_token")
refreshToken String? @map("refresh_token")
idToken String? @map("id_token")
expiresAt DateTime? @map("expires_at")
password String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("account")
}
model Verification {
id String @id
identifier String
value String
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("verification")
}
// Application Models
enum ServerType {
STATEFUL
STATELESS
@@ -17,6 +79,26 @@ enum ReverseProxyServerType {
BUNGEECORD
}
enum ServiceType {
CLUSTER_IP
NODE_PORT
LOAD_BALANCER
}
enum GameMode {
SURVIVAL
CREATIVE
ADVENTURE
SPECTATOR
}
enum ServerDifficulty {
PEACEFUL
EASY
NORMAL
HARD
}
model ReverseProxyServer {
id String @id @default(cuid())
type ReverseProxyServerType
@@ -24,43 +106,62 @@ model ReverseProxyServer {
external_address String
external_port Int
listen_port Int @default(25565)
memory String @default("512M")
memory Int @default(512) // Memory in MB
cpu_request String? @default("250m") // CPU request, e.g., "250m", "1"
cpu_limit String? @default("500m") // CPU limit, e.g., "500m", "2"
service_type ServiceType @default(LOAD_BALANCER)
node_port Int?
api_key String @unique
env_variables CustomEnvironmentVariable[] @relation("ReverseProxyServerEnvVars")
created_at DateTime @default(now())
updated_at DateTime @updatedAt
}
enum MinecraftServerJarType {
VANILLA
PAPER
SPIGOT
PURPUR
FABRIC
FORGE
FOLIA
}
model Server {
id String @id @default(cuid())
type ServerType
description String?
listen_port Int @default(25565)
memory String @default("1G")
env_variables CustomEnvironmentVariable[] @relation("ServerEnvVars")
api_key String @unique
created_at DateTime @default(now())
updated_at DateTime @updatedAt
}
id String @id @default(cuid())
type ServerType
description String?
listen_port Int @default(25565)
memory Int @default(2048) // Memory limit in MB
memory_request Int @default(1024) // Memory request in MB
cpu_request String? @default("500m") // CPU request, e.g., "500m", "1"
cpu_limit String? @default("2") // CPU limit, e.g., "2", "500m"
service_type ServiceType @default(CLUSTER_IP)
node_port Int?
env_variables CustomEnvironmentVariable[] @relation("ServerEnvVars")
api_key String @unique
model User {
id String @id @default(cuid())
username String @unique
password String
sessions Session[]
created_at DateTime @default(now())
updated_at DateTime @updatedAt
}
// Minecraft specific configurations
jar_type MinecraftServerJarType @default(VANILLA)
minecraft_version String @default("LATEST") // e.g., "LATEST", "1.20.4", "SNAPSHOT"
model Session {
id String @id @default(cuid())
token String @unique
user_id String
user User @relation(fields: [user_id], references: [id])
revoked Boolean @default(false)
expires_at DateTime
created_at DateTime @default(now())
updated_at DateTime @updatedAt
// JVM Options
jvm_opts String? // Custom JVM options
use_aikar_flags Boolean @default(false)
use_meowice_flags Boolean @default(false)
// Server Properties (common ones)
difficulty ServerDifficulty @default(EASY)
game_mode GameMode @default(SURVIVAL)
max_players Int @default(20)
pvp Boolean @default(true)
online_mode Boolean @default(true)
motd String?
level_seed String?
level_type String? // default, flat, largeBiomes, amplified
created_at DateTime @default(now())
updated_at DateTime @updatedAt
}
model CustomEnvironmentVariable {

View File

@@ -1,5 +1,33 @@
import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import { Pool } from "pg";
import { PrismaClient } from "./generated/prisma";
export * from "@prisma/client";
export * from "./generated/prisma";
export type {
ReverseProxyCreateInput,
ReverseProxyServer,
ReverseProxyUpdateInput,
ReverseProxyWithEnvVars,
} from "./models/reverse-proxy";
export type {
EnvVariable,
Server,
ServerCreateInput,
ServerUpdateInput,
ServerWithEnvVars,
} from "./models/server";
export const prisma = new PrismaClient();
export type { SessionWithUser } from "./models/session";
export { isSessionExpired } from "./models/session";
export type {
CreateUserInput,
UpdateSuspensionInput,
UpdateUserInput,
} from "./models/user";
export { isUserSuspended } from "./models/user";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaPg(pool);
export const prisma = new PrismaClient({ adapter });

View File

@@ -0,0 +1,15 @@
import type {
CustomEnvironmentVariable,
Prisma,
ReverseProxyServer as PrismaReverseProxyServer,
} from "../generated/prisma";
export type ReverseProxyWithEnvVars = Prisma.ReverseProxyServerGetPayload<{
include: { env_variables: true };
}>;
export type ReverseProxyCreateInput = Prisma.ReverseProxyServerCreateInput;
export type ReverseProxyUpdateInput = Prisma.ReverseProxyServerUpdateInput;
export type ReverseProxyServer = PrismaReverseProxyServer;

View File

@@ -0,0 +1,17 @@
import type {
CustomEnvironmentVariable,
Prisma,
Server as PrismaServer,
} from "../generated/prisma";
export type ServerWithEnvVars = Prisma.ServerGetPayload<{
include: { env_variables: true };
}>;
export type ServerCreateInput = Prisma.ServerCreateInput;
export type ServerUpdateInput = Prisma.ServerUpdateInput;
export type EnvVariable = Pick<CustomEnvironmentVariable, "key" | "value">;
export type Server = PrismaServer;

View File

@@ -0,0 +1,11 @@
import type { Prisma, Session as PrismaSession } from "../generated/prisma";
export type Session = PrismaSession;
export type SessionWithUser = Prisma.SessionGetPayload<{
include: { user: true };
}>;
export function isSessionExpired(session: Pick<Session, "expiresAt">): boolean {
return session.expiresAt < new Date();
}

View File

@@ -0,0 +1,21 @@
import type { Prisma, User as PrismaUser } from "../generated/prisma";
export type User = PrismaUser;
export type CreateUserInput = Prisma.UserCreateInput;
export type UpdateUserInput = Prisma.UserUpdateInput;
export type UpdateSuspensionInput = Prisma.UserUpdateInput;
export function isUserSuspended(user: Pick<PrismaUser, "isSuspended" | "suspendedUntil">): boolean {
if (!user.isSuspended) {
return false;
}
if (user.suspendedUntil && user.suspendedUntil <= new Date()) {
return false;
}
return true;
}

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;
}
}