mirror of
https://github.com/YuzuZensai/Minikura.git
synced 2026-09-13 18:59:25 +00:00
♻️ refactor: migrate to Go Kubernetes operator
This commit is contained in:
@@ -2,6 +2,7 @@ import { PrismaReverseProxyRepository } from "../infrastructure/repositories/pri
|
||||
import { PrismaServerRepository } from "../infrastructure/repositories/prisma/server.repository.impl";
|
||||
import { PrismaUserRepository } from "../infrastructure/repositories/prisma/user.repository.impl";
|
||||
import { K8sService } from "../services/k8s";
|
||||
import { OperatorResourceSync } from "../services/operator-resource-sync";
|
||||
import { WebSocketService } from "../services/websocket";
|
||||
import { ReverseProxyService } from "./services/reverse-proxy.service";
|
||||
import { ServerService } from "./services/server.service";
|
||||
@@ -12,9 +13,10 @@ const serverRepo = new PrismaServerRepository();
|
||||
const reverseProxyRepo = new PrismaReverseProxyRepository();
|
||||
const webSocketService = new WebSocketService();
|
||||
const k8sService = new K8sService();
|
||||
const operatorResourceSync = new OperatorResourceSync();
|
||||
|
||||
export const userService = new UserService(userRepo);
|
||||
export const serverService = new ServerService(serverRepo, k8sService);
|
||||
export const reverseProxyService = new ReverseProxyService(reverseProxyRepo);
|
||||
export const serverService = new ServerService(serverRepo, k8sService, operatorResourceSync);
|
||||
export const reverseProxyService = new ReverseProxyService(reverseProxyRepo, operatorResourceSync);
|
||||
export const wsService = webSocketService;
|
||||
export { k8sService };
|
||||
export { k8sService, operatorResourceSync };
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
ReverseProxyRepository,
|
||||
ReverseProxyUpdateInput,
|
||||
} from "../../domain/repositories/reverse-proxy.repository";
|
||||
import type { OperatorResourceSync } from "../../services/operator-resource-sync";
|
||||
import type { IReverseProxyService } from "../interfaces/reverse-proxy.service.interface";
|
||||
import { BaseCrudService } from "./base-crud.service";
|
||||
|
||||
@@ -26,7 +27,10 @@ export class ReverseProxyService
|
||||
>
|
||||
implements IReverseProxyService
|
||||
{
|
||||
constructor(reverseProxyRepo: ReverseProxyRepository) {
|
||||
constructor(
|
||||
reverseProxyRepo: ReverseProxyRepository,
|
||||
private operatorResourceSync: OperatorResourceSync
|
||||
) {
|
||||
super(
|
||||
reverseProxyRepo,
|
||||
{
|
||||
@@ -65,4 +69,14 @@ export class ReverseProxyService
|
||||
deleteReverseProxy(id: string) {
|
||||
return this.delete(id);
|
||||
}
|
||||
|
||||
override async setEnvVariable(proxyId: string, key: string, value: string): Promise<void> {
|
||||
await super.setEnvVariable(proxyId, key, value);
|
||||
await this.operatorResourceSync.syncReverseProxyById(proxyId);
|
||||
}
|
||||
|
||||
override async deleteEnvVariable(proxyId: string, key: string): Promise<void> {
|
||||
await super.deleteEnvVariable(proxyId, key);
|
||||
await this.operatorResourceSync.syncReverseProxyById(proxyId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ import type {
|
||||
ServerUpdateInput,
|
||||
} from "../../domain/repositories/server.repository";
|
||||
import type { K8sService } from "../../services/k8s";
|
||||
import {
|
||||
type OperatorResourceSync,
|
||||
operatorResourceName,
|
||||
} from "../../services/operator-resource-sync";
|
||||
import type { IServerService } from "../interfaces/server.service.interface";
|
||||
import { BaseCrudService } from "./base-crud.service";
|
||||
|
||||
@@ -29,7 +33,8 @@ export class ServerService
|
||||
{
|
||||
constructor(
|
||||
serverRepo: ServerRepository,
|
||||
private k8sService: K8sService
|
||||
private k8sService: K8sService,
|
||||
private operatorResourceSync: OperatorResourceSync
|
||||
) {
|
||||
super(
|
||||
serverRepo,
|
||||
@@ -70,9 +75,19 @@ export class ServerService
|
||||
return this.delete(id);
|
||||
}
|
||||
|
||||
override async setEnvVariable(serverId: string, key: string, value: string): Promise<void> {
|
||||
await super.setEnvVariable(serverId, key, value);
|
||||
await this.operatorResourceSync.syncServerById(serverId);
|
||||
}
|
||||
|
||||
override async deleteEnvVariable(serverId: string, key: string): Promise<void> {
|
||||
await super.deleteEnvVariable(serverId, key);
|
||||
await this.operatorResourceSync.syncServerById(serverId);
|
||||
}
|
||||
|
||||
async getConnectionInfo(serverId: string) {
|
||||
await this.getServerById(serverId);
|
||||
const serviceName = `minecraft-${serverId}`;
|
||||
const serviceName = `minecraft-${operatorResourceName(serverId)}`;
|
||||
return this.k8sService.getServerConnectionInfo(serviceName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ import { terminalRoutes } from "./routes/terminal";
|
||||
import { userRoutes } from "./routes/users";
|
||||
|
||||
import "./infrastructure/event-handlers";
|
||||
import { operatorResourceSync } from "./application/di-container";
|
||||
|
||||
operatorResourceSync.start();
|
||||
|
||||
const app = new Elysia({ adapter: node() })
|
||||
.use(errorHandler)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "./reverse-proxy-event.handler";
|
||||
import "./server-event.handler";
|
||||
import "./user-event.handler";
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { operatorResourceSync, wsService } from "../../application/di-container";
|
||||
import {
|
||||
ReverseProxyCreatedEvent,
|
||||
ReverseProxyDeletedEvent,
|
||||
ReverseProxyUpdatedEvent,
|
||||
} from "../../domain/events/reverse-proxy-lifecycle.events";
|
||||
import { eventBus } from "../event-bus";
|
||||
import { logger } from "../logger";
|
||||
|
||||
eventBus.subscribe(ReverseProxyCreatedEvent, async (event) => {
|
||||
logger.info(
|
||||
{ proxyId: event.proxyId, proxyType: event.proxyType },
|
||||
"Reverse proxy created event"
|
||||
);
|
||||
wsService.broadcast("create", event.proxyType, event.proxyId);
|
||||
await operatorResourceSync.syncReverseProxyById(event.proxyId);
|
||||
});
|
||||
|
||||
eventBus.subscribe(ReverseProxyUpdatedEvent, async (event) => {
|
||||
logger.info({ proxyId: event.proxyId }, "Reverse proxy updated event");
|
||||
wsService.broadcast("update", "reverse-proxy", event.proxyId);
|
||||
await operatorResourceSync.syncReverseProxyById(event.proxyId);
|
||||
});
|
||||
|
||||
eventBus.subscribe(ReverseProxyDeletedEvent, async (event) => {
|
||||
logger.info({ proxyId: event.proxyId }, "Reverse proxy deleted event");
|
||||
wsService.broadcast("delete", "reverse-proxy", event.proxyId);
|
||||
await operatorResourceSync.deleteReverseProxy(event.proxyId);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { wsService } from "../../application/di-container";
|
||||
import { operatorResourceSync, wsService } from "../../application/di-container";
|
||||
import {
|
||||
ServerCreatedEvent,
|
||||
ServerDeletedEvent,
|
||||
@@ -10,14 +10,17 @@ import { logger } from "../logger";
|
||||
eventBus.subscribe(ServerCreatedEvent, async (event) => {
|
||||
logger.info({ serverId: event.serverId, serverType: event.serverType }, "Server created event");
|
||||
wsService.broadcast("create", event.serverType, event.serverId);
|
||||
await operatorResourceSync.syncServerById(event.serverId);
|
||||
});
|
||||
|
||||
eventBus.subscribe(ServerUpdatedEvent, async (event) => {
|
||||
logger.info({ serverId: event.serverId }, "Server updated event");
|
||||
wsService.broadcast("update", "server", event.serverId);
|
||||
await operatorResourceSync.syncServerById(event.serverId);
|
||||
});
|
||||
|
||||
eventBus.subscribe(ServerDeletedEvent, async (event) => {
|
||||
logger.info({ serverId: event.serverId }, "Server deleted event");
|
||||
wsService.broadcast("delete", "server", event.serverId);
|
||||
await operatorResourceSync.deleteServer(event.serverId);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
import * as k8s from "@kubernetes/client-node";
|
||||
import { prisma, type ReverseProxyWithEnvVars, type ServerWithEnvVars } from "@minikura/db";
|
||||
import { buildKubeConfig } from "@minikura/shared/kube-auth";
|
||||
import { logger } from "../infrastructure/logger";
|
||||
|
||||
const API_GROUP = "minikura.kirameki.cafe";
|
||||
const API_VERSION = "v1alpha1";
|
||||
const FIELD_MANAGER = "minikura-backend";
|
||||
const SYNC_INTERVAL_MS = 30_000;
|
||||
|
||||
type CustomResource = {
|
||||
apiVersion: string;
|
||||
kind: string;
|
||||
metadata: {
|
||||
name: string;
|
||||
namespace: string;
|
||||
labels: Record<string, string>;
|
||||
resourceVersion?: string;
|
||||
};
|
||||
spec: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function operatorResourceName(id: string): string {
|
||||
const normalized = id
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9.-]+/g, "-")
|
||||
.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, "")
|
||||
.slice(0, 63);
|
||||
if (!normalized) throw new Error(`Cannot derive a Kubernetes resource name from ${id}`);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function serviceType(type: string): "ClusterIP" | "NodePort" | "LoadBalancer" {
|
||||
if (type === "NODE_PORT") return "NodePort";
|
||||
if (type === "LOAD_BALANCER") return "LoadBalancer";
|
||||
return "ClusterIP";
|
||||
}
|
||||
|
||||
function labels(id: string): Record<string, string> {
|
||||
return {
|
||||
"app.kubernetes.io/managed-by": FIELD_MANAGER,
|
||||
"minikura.kirameki.cafe/database-id": id.slice(0, 63),
|
||||
};
|
||||
}
|
||||
|
||||
export class OperatorResourceSync {
|
||||
private readonly namespace = process.env.KUBERNETES_NAMESPACE || "minikura";
|
||||
private coreApi?: k8s.CoreV1Api;
|
||||
private customObjectsApi?: k8s.CustomObjectsApi;
|
||||
private syncing = false;
|
||||
|
||||
constructor() {
|
||||
try {
|
||||
const kubeConfig = buildKubeConfig();
|
||||
this.coreApi = kubeConfig.makeApiClient(k8s.CoreV1Api);
|
||||
this.customObjectsApi = kubeConfig.makeApiClient(k8s.CustomObjectsApi);
|
||||
} catch (error) {
|
||||
logger.warn({ err: error }, "Operator resource synchronization is unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
start(): void {
|
||||
void this.syncAll();
|
||||
const timer = setInterval(() => void this.syncAll(), SYNC_INTERVAL_MS);
|
||||
timer.unref();
|
||||
}
|
||||
|
||||
async syncAll(): Promise<void> {
|
||||
if (this.syncing || !this.coreApi || !this.customObjectsApi) return;
|
||||
this.syncing = true;
|
||||
try {
|
||||
const [servers, proxies] = await Promise.all([
|
||||
prisma.server.findMany({ include: { env_variables: true } }),
|
||||
prisma.reverseProxyServer.findMany({ include: { env_variables: true } }),
|
||||
]);
|
||||
await Promise.all([
|
||||
...servers.map((server) => this.syncServer(server)),
|
||||
...proxies.map((proxy) => this.syncReverseProxy(proxy)),
|
||||
]);
|
||||
await Promise.all([
|
||||
this.deleteStaleResources(
|
||||
"minecraftservers",
|
||||
servers.map((server) => operatorResourceName(server.id))
|
||||
),
|
||||
this.deleteStaleResources(
|
||||
"reverseproxyservers",
|
||||
proxies.map((proxy) => operatorResourceName(proxy.id))
|
||||
),
|
||||
]);
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Failed to synchronize operator resources");
|
||||
} finally {
|
||||
this.syncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async syncServerById(id: string): Promise<void> {
|
||||
if (!this.coreApi || !this.customObjectsApi) return;
|
||||
const server = await prisma.server.findUnique({
|
||||
where: { id },
|
||||
include: { env_variables: true },
|
||||
});
|
||||
if (server) await this.syncServer(server);
|
||||
}
|
||||
|
||||
async syncReverseProxyById(id: string): Promise<void> {
|
||||
if (!this.coreApi || !this.customObjectsApi) return;
|
||||
const proxy = await prisma.reverseProxyServer.findUnique({
|
||||
where: { id },
|
||||
include: { env_variables: true },
|
||||
});
|
||||
if (proxy) await this.syncReverseProxy(proxy);
|
||||
}
|
||||
|
||||
async deleteServer(id: string): Promise<void> {
|
||||
if (!this.customObjectsApi) return;
|
||||
await this.deleteResource("minecraftservers", operatorResourceName(id));
|
||||
}
|
||||
|
||||
async deleteReverseProxy(id: string): Promise<void> {
|
||||
if (!this.customObjectsApi) return;
|
||||
await this.deleteResource("reverseproxyservers", operatorResourceName(id));
|
||||
}
|
||||
|
||||
private async syncServer(server: ServerWithEnvVars): Promise<void> {
|
||||
const name = operatorResourceName(server.id);
|
||||
const secretName = `${name}-api-key`;
|
||||
await this.upsertSecret(secretName, server.api_key, labels(server.id));
|
||||
await this.upsertResource("minecraftservers", {
|
||||
apiVersion: `${API_GROUP}/${API_VERSION}`,
|
||||
kind: "MinecraftServer",
|
||||
metadata: { name, namespace: this.namespace, labels: labels(server.id) },
|
||||
spec: {
|
||||
type: server.type,
|
||||
description: server.description ?? undefined,
|
||||
listenPort: server.listen_port,
|
||||
serviceType: serviceType(server.service_type),
|
||||
nodePort: server.node_port ?? undefined,
|
||||
jarType: server.jar_type,
|
||||
minecraftVersion: server.minecraft_version,
|
||||
resources: {
|
||||
memoryLimitMB: server.memory,
|
||||
memoryRequestMB: server.memory_request,
|
||||
cpuRequest: server.cpu_request ?? undefined,
|
||||
cpuLimit: server.cpu_limit ?? undefined,
|
||||
},
|
||||
jvm: {
|
||||
opts: server.jvm_opts ?? undefined,
|
||||
useAikarFlags: server.use_aikar_flags,
|
||||
useMeowIceFlags: server.use_meowice_flags,
|
||||
heapPercent: 80,
|
||||
},
|
||||
properties: {
|
||||
difficulty: server.difficulty,
|
||||
gameMode: server.game_mode,
|
||||
maxPlayers: server.max_players,
|
||||
pvp: server.pvp,
|
||||
onlineMode: server.online_mode,
|
||||
motd: server.motd ?? undefined,
|
||||
levelSeed: server.level_seed ?? undefined,
|
||||
levelType: server.level_type ?? undefined,
|
||||
},
|
||||
env: server.env_variables.map((entry) => ({ name: entry.key, value: entry.value })),
|
||||
apiKeySecretRef: secretName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async syncReverseProxy(proxy: ReverseProxyWithEnvVars): Promise<void> {
|
||||
const name = operatorResourceName(proxy.id);
|
||||
const secretName = `${name}-api-key`;
|
||||
await this.upsertSecret(secretName, proxy.api_key, labels(proxy.id));
|
||||
await this.upsertResource("reverseproxyservers", {
|
||||
apiVersion: `${API_GROUP}/${API_VERSION}`,
|
||||
kind: "ReverseProxyServer",
|
||||
metadata: { name, namespace: this.namespace, labels: labels(proxy.id) },
|
||||
spec: {
|
||||
type: proxy.type,
|
||||
description: proxy.description ?? undefined,
|
||||
externalAddress: proxy.external_address,
|
||||
externalPort: proxy.external_port,
|
||||
listenPort: proxy.listen_port,
|
||||
serviceType: serviceType(proxy.service_type),
|
||||
nodePort: proxy.node_port ?? undefined,
|
||||
resources: {
|
||||
memoryLimitMB: proxy.memory,
|
||||
memoryRequestMB: proxy.memory,
|
||||
cpuRequest: proxy.cpu_request ?? undefined,
|
||||
cpuLimit: proxy.cpu_limit ?? undefined,
|
||||
},
|
||||
jvm: { heapPercent: 80 },
|
||||
env: proxy.env_variables.map((entry) => ({ name: entry.key, value: entry.value })),
|
||||
apiKeySecretRef: secretName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async upsertResource(plural: string, resource: CustomResource): Promise<void> {
|
||||
if (!this.customObjectsApi) return;
|
||||
try {
|
||||
const existing = (await this.customObjectsApi.getNamespacedCustomObject({
|
||||
group: API_GROUP,
|
||||
version: API_VERSION,
|
||||
namespace: this.namespace,
|
||||
plural,
|
||||
name: resource.metadata.name,
|
||||
})) as { metadata?: { resourceVersion?: string } };
|
||||
resource.metadata.resourceVersion = existing.metadata?.resourceVersion;
|
||||
await this.customObjectsApi.replaceNamespacedCustomObject({
|
||||
group: API_GROUP,
|
||||
version: API_VERSION,
|
||||
namespace: this.namespace,
|
||||
plural,
|
||||
name: resource.metadata.name,
|
||||
body: resource,
|
||||
fieldManager: FIELD_MANAGER,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!this.isNotFound(error)) throw error;
|
||||
await this.customObjectsApi.createNamespacedCustomObject({
|
||||
group: API_GROUP,
|
||||
version: API_VERSION,
|
||||
namespace: this.namespace,
|
||||
plural,
|
||||
body: resource,
|
||||
fieldManager: FIELD_MANAGER,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async upsertSecret(
|
||||
name: string,
|
||||
apiKey: string,
|
||||
resourceLabels: Record<string, string>
|
||||
): Promise<void> {
|
||||
if (!this.coreApi) return;
|
||||
const secret: k8s.V1Secret = {
|
||||
metadata: { name, namespace: this.namespace, labels: resourceLabels },
|
||||
stringData: { "api-key": apiKey },
|
||||
type: "Opaque",
|
||||
};
|
||||
try {
|
||||
const existing = await this.coreApi.readNamespacedSecret({ name, namespace: this.namespace });
|
||||
secret.metadata = {
|
||||
...secret.metadata,
|
||||
resourceVersion: existing.metadata?.resourceVersion,
|
||||
};
|
||||
await this.coreApi.replaceNamespacedSecret({
|
||||
name,
|
||||
namespace: this.namespace,
|
||||
body: secret,
|
||||
fieldManager: FIELD_MANAGER,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!this.isNotFound(error)) throw error;
|
||||
await this.coreApi.createNamespacedSecret({
|
||||
namespace: this.namespace,
|
||||
body: secret,
|
||||
fieldManager: FIELD_MANAGER,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteStaleResources(plural: string, expectedNames: string[]): Promise<void> {
|
||||
if (!this.customObjectsApi) return;
|
||||
const response = (await this.customObjectsApi.listNamespacedCustomObject({
|
||||
group: API_GROUP,
|
||||
version: API_VERSION,
|
||||
namespace: this.namespace,
|
||||
plural,
|
||||
labelSelector: `app.kubernetes.io/managed-by=${FIELD_MANAGER}`,
|
||||
})) as { items?: Array<{ metadata?: { name?: string } }> };
|
||||
const expected = new Set(expectedNames);
|
||||
await Promise.all(
|
||||
(response.items ?? [])
|
||||
.map((item) => item.metadata?.name)
|
||||
.filter((name): name is string => !!name && !expected.has(name))
|
||||
.map((name) => this.deleteResource(plural, name))
|
||||
);
|
||||
}
|
||||
|
||||
private async deleteResource(plural: string, name: string): Promise<void> {
|
||||
if (!this.customObjectsApi) return;
|
||||
try {
|
||||
await this.customObjectsApi.deleteNamespacedCustomObject({
|
||||
group: API_GROUP,
|
||||
version: API_VERSION,
|
||||
namespace: this.namespace,
|
||||
plural,
|
||||
name,
|
||||
propagationPolicy: "Foreground",
|
||||
});
|
||||
} catch (error) {
|
||||
if (!this.isNotFound(error)) throw error;
|
||||
}
|
||||
await this.deleteSecret(`${name}-api-key`);
|
||||
}
|
||||
|
||||
private async deleteSecret(name: string): Promise<void> {
|
||||
if (!this.coreApi) return;
|
||||
try {
|
||||
await this.coreApi.deleteNamespacedSecret({ name, namespace: this.namespace });
|
||||
} catch (error) {
|
||||
if (!this.isNotFound(error)) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private isNotFound(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
(("code" in error && error.code === 404) ||
|
||||
("response" in error &&
|
||||
typeof error.response === "object" &&
|
||||
error.response !== null &&
|
||||
"statusCode" in error.response &&
|
||||
error.response.statusCode === 404))
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user