From d14f043e7cd5fe5cecfc3f417551d93b532d8dda Mon Sep 17 00:00:00 2001 From: Yuzu Date: Tue, 17 Feb 2026 18:12:02 +0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20topology,=20and=20improves?= =?UTF-8?q?=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .devcontainer/docker-compose.yml | 5 +- .devcontainer/post-create.sh | 25 +- .devcontainer/setup-k8s-token.sh | 103 ---- .env.example | 12 - apps/backend/package.json | 24 +- apps/backend/src/application/di-container.ts | 4 +- .../interfaces/k8s.service.interface.ts | 59 ++ .../reverse-proxy.service.interface.ts | 16 + .../interfaces/server.service.interface.ts | 17 + .../interfaces/user.service.interface.ts | 12 + .../application/services/base-crud.service.ts | 82 +++ .../services/reverse-proxy.service.ts | 98 ++-- .../application/services/server.service.ts | 99 ++-- .../src/application/services/user.service.ts | 3 +- apps/backend/src/domain/errors/base.error.ts | 12 +- .../events/reverse-proxy-lifecycle.events.ts | 7 +- .../domain/repositories/server.repository.ts | 2 +- .../domain/value-objects/server-config.vo.ts | 2 +- apps/backend/src/index.ts | 14 +- apps/backend/src/infrastructure/event-bus.ts | 5 +- .../infrastructure/event-handlers/index.ts | 4 +- .../event-handlers/server-event.handler.ts | 9 +- .../event-handlers/user-event.handler.ts | 10 +- apps/backend/src/infrastructure/logger.ts | 5 + .../prisma/user.repository.impl.ts | 1 - apps/backend/src/lib/errors.ts | 9 - apps/backend/src/lib/fetch-patch.ts | 52 -- apps/backend/src/lib/kube-auth.ts | 107 ---- apps/backend/src/lib/middleware.ts | 29 - apps/backend/src/lib/service-utils.ts | 33 -- .../auth-guards.ts} | 0 .../src/{lib => middleware}/auth-plugin.ts | 0 apps/backend/src/{lib => middleware}/auth.ts | 2 +- .../src/{lib => middleware}/error-handler.ts | 5 +- .../src/{lib => middleware}/zod-validator.ts | 0 apps/backend/src/routes/bootstrap.ts | 9 +- apps/backend/src/routes/k8s.ts | 203 +++---- ...verse-proxy.routes.ts => reverse-proxy.ts} | 2 + apps/backend/src/routes/servers.ts | 2 + apps/backend/src/routes/terminal.ts | 50 +- apps/backend/src/routes/users.ts | 2 +- apps/backend/src/schemas/server.schema.ts | 7 +- .../src/services/__tests__/session.test.ts | 235 -------- .../src/services/__tests__/user.test.ts | 272 ---------- apps/backend/src/services/k8s.ts | 446 +++++---------- .../kubernetes/operations/base.operations.ts | 40 ++ .../operations/cluster.operations.ts | 39 ++ .../operations/custom-resource.operations.ts | 55 ++ .../services/kubernetes/operations/index.ts | 6 + .../operations/network.operations.ts | 89 +++ .../kubernetes/operations/pod.operations.ts | 72 +++ .../operations/workload.operations.ts | 27 + .../services/{k8s => kubernetes}/resources.ts | 22 +- apps/backend/src/services/websocket.ts | 11 +- apps/web/app/bootstrap/page.tsx | 31 +- apps/web/app/dashboard/k8s/page.tsx | 20 +- .../web/app/dashboard/servers/create/page.tsx | 14 +- .../app/dashboard/servers/edit/[id]/page.tsx | 20 +- apps/web/app/dashboard/servers/page.tsx | 39 +- apps/web/app/dashboard/topology/page.tsx | 89 +++ apps/web/app/dashboard/users/page.tsx | 26 +- apps/web/app/login/page.tsx | 2 +- apps/web/components/dashboard-layout.tsx | 33 +- apps/web/components/terminal.tsx | 27 +- .../topology/controls/node-details-panel.tsx | 341 ++++++++++++ .../topology/controls/topology-toolbar.tsx | 159 ++++++ .../topology/layouts/hierarchical-layout.ts | 83 +++ .../components/topology/nodes/k8s-node.tsx | 142 +++++ .../components/topology/nodes/node-types.ts | 9 + .../components/topology/nodes/proxy-node.tsx | 206 +++++++ .../components/topology/nodes/server-node.tsx | 210 +++++++ .../components/topology/topology-canvas.tsx | 128 +++++ apps/web/components/ui/accordion.tsx | 32 +- apps/web/components/ui/avatar.tsx | 4 +- apps/web/components/ui/badge.tsx | 4 +- apps/web/components/ui/button.tsx | 4 +- apps/web/components/ui/card.tsx | 2 +- apps/web/components/ui/checkbox.tsx | 20 +- apps/web/components/ui/dialog.tsx | 4 +- apps/web/components/ui/dropdown-menu.tsx | 4 +- apps/web/components/ui/form.tsx | 11 +- apps/web/components/ui/input.tsx | 2 +- apps/web/components/ui/label.tsx | 4 +- apps/web/components/ui/popover.tsx | 31 ++ apps/web/components/ui/select.tsx | 4 +- apps/web/components/ui/separator.tsx | 4 +- apps/web/components/ui/sheet.tsx | 4 +- apps/web/components/ui/sidebar.tsx | 6 +- apps/web/components/ui/skeleton.tsx | 2 +- apps/web/components/ui/switch.tsx | 29 + apps/web/components/ui/table.tsx | 2 +- apps/web/components/ui/tabs.tsx | 38 +- apps/web/components/ui/textarea.tsx | 5 +- apps/web/components/ui/tooltip.tsx | 4 +- apps/web/hooks/use-graph-layout.ts | 163 ++++++ apps/web/hooks/use-k8s-resources.ts | 20 +- apps/web/hooks/use-server-list.ts | 20 +- apps/web/hooks/use-server-logs.ts | 24 +- apps/web/hooks/use-topology-data.ts | 170 ++++++ apps/web/lib/{api.ts => api-client.ts} | 0 apps/web/lib/api-helpers.ts | 11 +- apps/web/lib/auth-client.ts | 2 +- apps/web/lib/{utils.ts => cn.ts} | 2 +- apps/web/lib/k8s-metrics.ts | 72 +++ apps/web/lib/topology-types.ts | 100 ++++ apps/web/lib/topology-utils.ts | 365 +++++++++++++ apps/web/next-env.d.ts | 2 +- apps/web/package.json | 3 + bun.lockb | Bin 232096 -> 248784 bytes k8s/backend-rbac.yaml | 82 --- k8s/rbac/dev-rbac.yaml | 48 -- package.json | 3 +- packages/api/package.json | 3 +- packages/api/src/index.ts | 4 - packages/db/src/models/reverse-proxy.ts | 6 +- packages/k8s-operator/package.json | 12 +- packages/k8s-operator/src/config/constants.ts | 13 +- .../src/config/resource-defaults.ts | 16 + .../src/controllers/base-controller.ts | 38 +- .../controllers/reverse-proxy-controller.ts | 42 +- .../src/controllers/server-controller.ts | 42 +- packages/k8s-operator/src/crds/rbac.ts | 21 +- packages/k8s-operator/src/index.ts | 66 ++- .../src/resources/reverseProxyServer.ts | 57 +- packages/k8s-operator/src/resources/server.ts | 62 ++- .../k8s-operator/src/scripts/apply-crds.ts | 8 +- .../src/services/notification.service.ts | 29 +- packages/k8s-operator/src/types/index.ts | 17 +- packages/k8s-operator/src/types/k8s-types.ts | 25 + .../k8s-operator/src/utils/crd-registrar.ts | 512 ++++++++++++------ packages/k8s-operator/src/utils/errors.ts | 9 - packages/k8s-operator/src/utils/k8s-client.ts | 54 +- packages/k8s-operator/src/utils/kube-auth.ts | 108 ---- packages/k8s-operator/src/utils/logger.ts | 5 + .../k8s-operator/src/utils/rbac-registrar.ts | 106 ++-- packages/shared/package.json | 23 + packages/shared/src/errors.ts | 28 + packages/shared/src/index.ts | 3 + packages/shared/src/kube-auth.ts | 24 + packages/shared/src/logger.ts | 33 ++ packages/shared/tsconfig.json | 22 + scripts/install.sh | 93 +--- scripts/refresh-k8s-token.sh | 74 --- scripts/setup-rbac.sh | 101 ---- scripts/show-k8s-tokens.sh | 76 --- 145 files changed, 4213 insertions(+), 2861 deletions(-) delete mode 100755 .devcontainer/setup-k8s-token.sh create mode 100644 apps/backend/src/application/interfaces/k8s.service.interface.ts create mode 100644 apps/backend/src/application/interfaces/reverse-proxy.service.interface.ts create mode 100644 apps/backend/src/application/interfaces/server.service.interface.ts create mode 100644 apps/backend/src/application/interfaces/user.service.interface.ts create mode 100644 apps/backend/src/application/services/base-crud.service.ts create mode 100644 apps/backend/src/infrastructure/logger.ts delete mode 100644 apps/backend/src/lib/errors.ts delete mode 100644 apps/backend/src/lib/fetch-patch.ts delete mode 100644 apps/backend/src/lib/kube-auth.ts delete mode 100644 apps/backend/src/lib/middleware.ts delete mode 100644 apps/backend/src/lib/service-utils.ts rename apps/backend/src/{lib/authorization.ts => middleware/auth-guards.ts} (100%) rename apps/backend/src/{lib => middleware}/auth-plugin.ts (100%) rename apps/backend/src/{lib => middleware}/auth.ts (100%) rename apps/backend/src/{lib => middleware}/error-handler.ts (73%) rename apps/backend/src/{lib => middleware}/zod-validator.ts (100%) rename apps/backend/src/routes/{reverse-proxy.routes.ts => reverse-proxy.ts} (95%) delete mode 100644 apps/backend/src/services/__tests__/session.test.ts delete mode 100644 apps/backend/src/services/__tests__/user.test.ts create mode 100644 apps/backend/src/services/kubernetes/operations/base.operations.ts create mode 100644 apps/backend/src/services/kubernetes/operations/cluster.operations.ts create mode 100644 apps/backend/src/services/kubernetes/operations/custom-resource.operations.ts create mode 100644 apps/backend/src/services/kubernetes/operations/index.ts create mode 100644 apps/backend/src/services/kubernetes/operations/network.operations.ts create mode 100644 apps/backend/src/services/kubernetes/operations/pod.operations.ts create mode 100644 apps/backend/src/services/kubernetes/operations/workload.operations.ts rename apps/backend/src/services/{k8s => kubernetes}/resources.ts (93%) create mode 100644 apps/web/app/dashboard/topology/page.tsx create mode 100644 apps/web/components/topology/controls/node-details-panel.tsx create mode 100644 apps/web/components/topology/controls/topology-toolbar.tsx create mode 100644 apps/web/components/topology/layouts/hierarchical-layout.ts create mode 100644 apps/web/components/topology/nodes/k8s-node.tsx create mode 100644 apps/web/components/topology/nodes/node-types.ts create mode 100644 apps/web/components/topology/nodes/proxy-node.tsx create mode 100644 apps/web/components/topology/nodes/server-node.tsx create mode 100644 apps/web/components/topology/topology-canvas.tsx create mode 100644 apps/web/components/ui/popover.tsx create mode 100644 apps/web/components/ui/switch.tsx create mode 100644 apps/web/hooks/use-graph-layout.ts create mode 100644 apps/web/hooks/use-topology-data.ts rename apps/web/lib/{api.ts => api-client.ts} (100%) rename apps/web/lib/{utils.ts => cn.ts} (72%) create mode 100644 apps/web/lib/k8s-metrics.ts create mode 100644 apps/web/lib/topology-types.ts create mode 100644 apps/web/lib/topology-utils.ts delete mode 100644 k8s/backend-rbac.yaml delete mode 100644 k8s/rbac/dev-rbac.yaml create mode 100644 packages/k8s-operator/src/config/resource-defaults.ts create mode 100644 packages/k8s-operator/src/types/k8s-types.ts delete mode 100644 packages/k8s-operator/src/utils/errors.ts delete mode 100644 packages/k8s-operator/src/utils/kube-auth.ts create mode 100644 packages/k8s-operator/src/utils/logger.ts create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/errors.ts create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/kube-auth.ts create mode 100644 packages/shared/src/logger.ts create mode 100644 packages/shared/tsconfig.json delete mode 100755 scripts/refresh-k8s-token.sh delete mode 100755 scripts/setup-rbac.sh delete mode 100755 scripts/show-k8s-tokens.sh diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 1b5368f..c33cc64 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -20,7 +20,7 @@ services: - "6443:6443" # k3s API - "25565:25565" # minecraft - "25577:25577" # velocity - - "30000:32767" # NodePort range + - "30000-32767:30000-32767" # NodePort range volumes: - "../:/workspace" - "/sys/fs/cgroup:/sys/fs/cgroup:rw" @@ -32,8 +32,9 @@ services: environment: - KUBECONFIG=/home/dev/.kube/config - DATABASE_URL=postgresql://postgres:postgres@db:5432/minikura?sslmode=disable + - WEB_URL=http://localhost:3001 + - NEXT_PUBLIC_API_URL=http://localhost:3000 - KUBERNETES_NAMESPACE=minikura - - KUBERNETES_SKIP_TLS_VERIFY=true - ENABLE_CRD_REFLECTION=true db: diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 689ad99..9c58ec6 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -25,20 +25,35 @@ mkdir -p /home/dev/.kube until [ -f /etc/rancher/k3s/k3s.yaml ]; do sleep 1; done sudo cp /etc/rancher/k3s/k3s.yaml /home/dev/.kube/config sudo chown dev:dev /home/dev/.kube/config +chmod 600 /home/dev/.kube/config -# Wait for node -echo "==> Waiting for node..." +# Allow k3s self-signed certs +kubectl config set-cluster default --insecure-skip-tls-verify=true + +# Wait for k3s API server to be fully ready +echo "==> Waiting for k3s API server..." +for i in {1..60}; do + kubectl get nodes --request-timeout=2s >/dev/null 2>&1 && break + echo " Attempt $i/60..." + sleep 1 +done +sleep 2 # Extra buffer for stability + +# Verify k3s is actually working +echo "==> Verifying k3s..." +kubectl get nodes || { echo "[ERROR] k3s not responding properly"; exit 1; } + +# Wait for node to be Ready +echo "==> Waiting for node to be Ready..." for i in {1..30}; do kubectl get nodes 2>/dev/null | grep -q " Ready" && break sleep 2 done # Create namespace +echo "==> Creating minikura namespace..." kubectl create namespace minikura --dry-run=client -o yaml | kubectl apply -f - 2>/dev/null || true -# Uncomment the line below if you need service account token in .env -# bash /workspace/.devcontainer/setup-k8s-token.sh - # Install dependencies echo "==> Installing dependencies..." cd /workspace diff --git a/.devcontainer/setup-k8s-token.sh b/.devcontainer/setup-k8s-token.sh deleted file mode 100755 index c554352..0000000 --- a/.devcontainer/setup-k8s-token.sh +++ /dev/null @@ -1,103 +0,0 @@ -#!/bin/bash -set -e - -NAMESPACE="minikura" -SERVICE_ACCOUNT="minikura-backend" -SECRET_NAME="minikura-backend-token" - -echo "==> Setting up Kubernetes service account..." - -# Create service account if it doesn't exist -kubectl create serviceaccount $SERVICE_ACCOUNT -n $NAMESPACE --dry-run=client -o yaml | kubectl apply -f - 2>/dev/null || true - -# Create RBAC role -cat < Waiting for token to be generated..." -sleep 3 - -# Get the token -TOKEN=$(kubectl get secret $SECRET_NAME -n $NAMESPACE -o jsonpath='{.data.token}' 2>/dev/null | base64 -d) - -if [ -z "$TOKEN" ]; then - echo "[ERROR] Failed to retrieve service account token" - exit 1 -fi - -echo "" -echo "==============================================" -echo "[OK] Service Account Token Retrieved" -echo "==============================================" -echo "Service Account: $SERVICE_ACCOUNT" -echo "Namespace: $NAMESPACE" -echo "Token: ${TOKEN:0:50}...${TOKEN: -20}" -echo "" - -# Update .env file with the new token -ENV_FILE="/workspace/.env" - -if [ -f "$ENV_FILE" ]; then - # Check if token line exists - if grep -q "^KUBERNETES_SERVICE_ACCOUNT_TOKEN=" "$ENV_FILE"; then - # Update existing token - sed -i "s|^KUBERNETES_SERVICE_ACCOUNT_TOKEN=.*|KUBERNETES_SERVICE_ACCOUNT_TOKEN=\"$TOKEN\"|" "$ENV_FILE" - echo "[OK] Updated KUBERNETES_SERVICE_ACCOUNT_TOKEN in .env" - else - # Add token to end of file - echo "KUBERNETES_SERVICE_ACCOUNT_TOKEN=\"$TOKEN\"" >> "$ENV_FILE" - echo "[OK] Added KUBERNETES_SERVICE_ACCOUNT_TOKEN to .env" - fi -else - echo "[WARNING] .env file not found at $ENV_FILE" -fi - -echo "==============================================" -echo "" diff --git a/.env.example b/.env.example index f5b66d3..1ffd3a8 100644 --- a/.env.example +++ b/.env.example @@ -10,20 +10,8 @@ WEB_URL="http://localhost:3001" NEXT_PUBLIC_API_URL="http://localhost:3000" # Kubernetes Configuration -# The Kubernetes namespace where resources will be created KUBERNETES_NAMESPACE="minikura" -# Skip TLS certificate verification for Kubernetes API -KUBERNETES_SKIP_TLS_VERIFY="true" - -# Node.js TLS rejection control (needed when KUBERNETES_SKIP_TLS_VERIFY is true) -NODE_TLS_REJECT_UNAUTHORIZED="0" - -# Optional: Service account token for Kubernetes authentication -# By default, uses ~/.kube/config (local development or in-cluster config) -# Only set this for production deployments outside the cluster -# KUBERNETES_SERVICE_ACCOUNT_TOKEN="your-token-here" - # Kubernetes Operator Configuration # Enable CRD reflection to automatically sync database state to Kubernetes Custom Resources # Set to "false" to disable automatic CRD creation from database entries diff --git a/apps/backend/package.json b/apps/backend/package.json index 8d5f37e..4e82443 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -4,33 +4,35 @@ "type": "module", "exports": "./src/index.ts", "scripts": { - "dev": "bun --watch src/index.ts", - "build": "bun build src/index.ts --target bun --outdir ./dist", - "start": "NODE_ENV=production bun dist/index.js", + "dev": "tsx watch src/index.ts", + "dev:bun": "bun --watch src/index.ts", + "build": "tsc", + "start": "NODE_ENV=production node dist/index.js", "test": "bun test", "typecheck": "tsc --noEmit", "lint": "biome lint .", "format": "biome format --write ." }, "devDependencies": { - "@types/bcryptjs": "^3.0.0", - "@types/bun": "^1.3.6" + "@types/bun": "^1.3.6", + "@types/node": "^25.0.9", + "tsx": "^4.19.2" }, "peerDependencies": { "typescript": "^5.0.0" }, "dependencies": { - "@elysiajs/cors": "1.1.1", - "@elysiajs/swagger": "1.1.3", + "@elysiajs/node": "^1.4.5", "@kubernetes/client-node": "^1.4.0", + "@minikura/api": "workspace:*", "@minikura/db": "workspace:*", - "@sinclair/typebox": "^0.34.47", + "@minikura/shared": "workspace:*", "@types/ws": "^8.18.1", - "argon2": "^0.44.0", - "bcryptjs": "^3.0.3", "better-auth": "^1.4.13", - "dotenv": "^17.2.3", "elysia": "^1.4.22", + "pino": "^10.3.1", + "pino-http": "^11.0.0", + "pino-pretty": "^13.1.3", "undici": "^7.18.2", "ws": "^8.19.0", "yaml": "^2.8.2", diff --git a/apps/backend/src/application/di-container.ts b/apps/backend/src/application/di-container.ts index afccc58..5e79544 100644 --- a/apps/backend/src/application/di-container.ts +++ b/apps/backend/src/application/di-container.ts @@ -12,9 +12,11 @@ const userRepo = new PrismaUserRepository(); const serverRepo = new PrismaServerRepository(); const reverseProxyRepo = new PrismaReverseProxyRepository(); const webSocketService = new WebSocketService(); +const k8sService = new K8sService(); // Application layer export const userService = new UserService(userRepo); -export const serverService = new ServerService(serverRepo, K8sService.getInstance()); +export const serverService = new ServerService(serverRepo, k8sService); export const reverseProxyService = new ReverseProxyService(reverseProxyRepo); export const wsService = webSocketService; +export { k8sService }; diff --git a/apps/backend/src/application/interfaces/k8s.service.interface.ts b/apps/backend/src/application/interfaces/k8s.service.interface.ts new file mode 100644 index 0000000..41de0a4 --- /dev/null +++ b/apps/backend/src/application/interfaces/k8s.service.interface.ts @@ -0,0 +1,59 @@ +import type * as k8s from "@kubernetes/client-node"; +import type { CustomResourceSummary } from "@minikura/api"; + +export interface IK8sService { + // Initialization + isInitialized(): boolean; + getConnectionInfo(): { + initialized: boolean; + currentContext?: string; + cluster?: string; + namespace: string; + }; + + // Pods + getPods(): Promise; + getPodsByLabel(labelSelector: string): Promise; + getPodInfo(podName: string): Promise; + getPodLogs( + podName: string, + options?: { + container?: string; + tailLines?: number; + timestamps?: boolean; + sinceSeconds?: number; + } + ): Promise; + getPodMetrics(namespace?: string): Promise; + + // Workloads + getDeployments(): Promise; + getStatefulSets(): Promise; + + // Network + getServices(): Promise; + getIngresses(): Promise; + getServiceInfo(serviceName: string): Promise; + getServerConnectionInfo(serviceName: string): Promise; + + // Configuration + getConfigMaps(): Promise; + + // Custom Resources + getCustomResources( + group: string, + version: string, + plural: string + ): Promise; + getMinecraftServers(): Promise; + getReverseProxyServers(): Promise; + + // Cluster + getNodes(): Promise; + getNodeMetrics(): Promise; + + // Low-level access + getKubeConfig(): k8s.KubeConfig; + getCoreApi(): k8s.CoreV1Api; + getNamespace(): string; +} diff --git a/apps/backend/src/application/interfaces/reverse-proxy.service.interface.ts b/apps/backend/src/application/interfaces/reverse-proxy.service.interface.ts new file mode 100644 index 0000000..087c96f --- /dev/null +++ b/apps/backend/src/application/interfaces/reverse-proxy.service.interface.ts @@ -0,0 +1,16 @@ +import type { EnvVariable, ReverseProxyWithEnvVars } from "@minikura/db"; +import type { + ReverseProxyCreateInput, + ReverseProxyUpdateInput, +} from "../../domain/repositories/reverse-proxy.repository"; + +export interface IReverseProxyService { + getAllReverseProxies(omitSensitive?: boolean): Promise; + getReverseProxyById(id: string, omitSensitive?: boolean): Promise; + createReverseProxy(input: ReverseProxyCreateInput): Promise; + updateReverseProxy(id: string, input: ReverseProxyUpdateInput): Promise; + deleteReverseProxy(id: string): Promise; + setEnvVariable(proxyId: string, key: string, value: string): Promise; + getEnvVariables(proxyId: string): Promise; + deleteEnvVariable(proxyId: string, key: string): Promise; +} diff --git a/apps/backend/src/application/interfaces/server.service.interface.ts b/apps/backend/src/application/interfaces/server.service.interface.ts new file mode 100644 index 0000000..5698726 --- /dev/null +++ b/apps/backend/src/application/interfaces/server.service.interface.ts @@ -0,0 +1,17 @@ +import type { EnvVariable, ServerWithEnvVars } from "@minikura/db"; +import type { + ServerCreateInput, + ServerUpdateInput, +} from "../../domain/repositories/server.repository"; + +export interface IServerService { + getAllServers(omitSensitive?: boolean): Promise; + getServerById(id: string, omitSensitive?: boolean): Promise; + createServer(input: ServerCreateInput): Promise; + updateServer(id: string, input: ServerUpdateInput): Promise; + deleteServer(id: string): Promise; + setEnvVariable(serverId: string, key: string, value: string): Promise; + getEnvVariables(serverId: string): Promise; + deleteEnvVariable(serverId: string, key: string): Promise; + getConnectionInfo(serverId: string): Promise; +} diff --git a/apps/backend/src/application/interfaces/user.service.interface.ts b/apps/backend/src/application/interfaces/user.service.interface.ts new file mode 100644 index 0000000..7929fbd --- /dev/null +++ b/apps/backend/src/application/interfaces/user.service.interface.ts @@ -0,0 +1,12 @@ +import type { UpdateSuspensionInput, UpdateUserInput, User } from "@minikura/db"; + +export interface IUserService { + getUserById(id: string): Promise; + getUserByEmail(email: string): Promise; + getAllUsers(): Promise; + updateUser(id: string, input: UpdateUserInput): Promise; + updateSuspension(id: string, input: UpdateSuspensionInput): Promise; + suspendUser(id: string, suspendedUntil?: Date | null): Promise; + unsuspendUser(id: string): Promise; + deleteUser(requestingUserId: string, targetUserId: string): Promise; +} diff --git a/apps/backend/src/application/services/base-crud.service.ts b/apps/backend/src/application/services/base-crud.service.ts new file mode 100644 index 0000000..d07d438 --- /dev/null +++ b/apps/backend/src/application/services/base-crud.service.ts @@ -0,0 +1,82 @@ +import type { EnvVariable } from "@minikura/db"; +import { ConflictError, NotFoundError } from "../../domain/errors/base.error"; +import { eventBus } from "../../infrastructure/event-bus"; + +export abstract class BaseCrudService< + TEntity, + TCreateInput, + TUpdateInput, + TRepository extends { + findAll(omitSensitive?: boolean): Promise; + findById(id: string, omitSensitive?: boolean): Promise; + exists(id: string): Promise; + create(input: TCreateInput): Promise; + update(id: string, input: TUpdateInput): Promise; + delete(id: string): Promise; + setEnvVariable(entityId: string, key: string, value: string): Promise; + getEnvVariables(entityId: string): Promise; + deleteEnvVariable(entityId: string, key: string): Promise; + }, + TEvents extends { + created: new (id: string, type: any, input: TCreateInput) => any; + updated: new (id: string, input: TUpdateInput) => any; + deleted: new (id: string) => any; + }, +> { + constructor( + protected repository: TRepository, + protected events: TEvents, + protected entityName: string + ) {} + + protected abstract getEntityType(input: TCreateInput): any; + protected abstract getInputId(input: TCreateInput): string; + + async getAll(omitSensitive = false): Promise { + return this.repository.findAll(omitSensitive); + } + + async getById(id: string, omitSensitive = false): Promise { + const entity = await this.repository.findById(id, omitSensitive); + if (!entity) { + throw new NotFoundError(this.entityName, id); + } + return entity; + } + + async create(input: TCreateInput): Promise { + const id = this.getInputId(input); + const existing = await this.repository.exists(id); + if (existing) { + throw new ConflictError(this.entityName, id); + } + + const entity = await this.repository.create(input); + const type = this.getEntityType(input); + await eventBus.publish(new this.events.created(id, type, input)); + return entity; + } + + async update(id: string, input: TUpdateInput): Promise { + const entity = await this.repository.update(id, input); + await eventBus.publish(new this.events.updated(id, input)); + return entity; + } + + async delete(id: string): Promise { + await this.repository.delete(id); + await eventBus.publish(new this.events.deleted(id)); + } + + async setEnvVariable(entityId: string, key: string, value: string): Promise { + await this.repository.setEnvVariable(entityId, key, value); + } + + async getEnvVariables(entityId: string): Promise { + return this.repository.getEnvVariables(entityId); + } + + async deleteEnvVariable(entityId: string, key: string): Promise { + await this.repository.deleteEnvVariable(entityId, key); + } +} diff --git a/apps/backend/src/application/services/reverse-proxy.service.ts b/apps/backend/src/application/services/reverse-proxy.service.ts index 25e7326..ee719e7 100644 --- a/apps/backend/src/application/services/reverse-proxy.service.ts +++ b/apps/backend/src/application/services/reverse-proxy.service.ts @@ -1,5 +1,4 @@ -import type { EnvVariable, ReverseProxyWithEnvVars } from "@minikura/db"; -import { ConflictError, NotFoundError } from "../../domain/errors/base.error"; +import type { ReverseProxyWithEnvVars } from "@minikura/db"; import { ReverseProxyCreatedEvent, ReverseProxyDeletedEvent, @@ -10,71 +9,60 @@ import type { ReverseProxyRepository, ReverseProxyUpdateInput, } from "../../domain/repositories/reverse-proxy.repository"; -import { eventBus } from "../../infrastructure/event-bus"; +import type { IReverseProxyService } from "../interfaces/reverse-proxy.service.interface"; +import { BaseCrudService } from "./base-crud.service"; -export class ReverseProxyService { - constructor(private reverseProxyRepo: ReverseProxyRepository) {} - - async getAllReverseProxies( - omitSensitive = false, - ): Promise { - return this.reverseProxyRepo.findAll(omitSensitive); - } - - async getReverseProxyById( - id: string, - omitSensitive = false, - ): Promise { - const proxy = await this.reverseProxyRepo.findById(id, omitSensitive); - if (!proxy) { - throw new NotFoundError("ReverseProxyServer", id); +export class ReverseProxyService + extends BaseCrudService< + ReverseProxyWithEnvVars, + ReverseProxyCreateInput, + ReverseProxyUpdateInput, + ReverseProxyRepository, + { + created: typeof ReverseProxyCreatedEvent; + updated: typeof ReverseProxyUpdatedEvent; + deleted: typeof ReverseProxyDeletedEvent; } - return proxy; - } - - async createReverseProxy( - input: ReverseProxyCreateInput, - ): Promise { - const id = typeof input.id === "string" ? input.id : String(input.id); - const existing = await this.reverseProxyRepo.exists(id); - if (existing) { - throw new ConflictError("ReverseProxyServer", id); - } - - const proxy = await this.reverseProxyRepo.create(input); - await eventBus.publish( - new ReverseProxyCreatedEvent(proxy.id, proxy.type, input), + > + implements IReverseProxyService +{ + constructor(reverseProxyRepo: ReverseProxyRepository) { + super( + reverseProxyRepo, + { + created: ReverseProxyCreatedEvent, + updated: ReverseProxyUpdatedEvent, + deleted: ReverseProxyDeletedEvent, + }, + "ReverseProxyServer" ); - return proxy; } - async updateReverseProxy( - id: string, - input: ReverseProxyUpdateInput, - ): Promise { - const proxy = await this.reverseProxyRepo.update(id, input); - await eventBus.publish(new ReverseProxyUpdatedEvent(id, input)); - return proxy; + protected getEntityType(input: ReverseProxyCreateInput) { + return input.type || "VELOCITY"; } - async deleteReverseProxy(id: string): Promise { - await this.reverseProxyRepo.delete(id); - await eventBus.publish(new ReverseProxyDeletedEvent(id)); + protected getInputId(input: ReverseProxyCreateInput): string { + return typeof input.id === "string" ? input.id : String(input.id); } - async setEnvVariable( - proxyId: string, - key: string, - value: string, - ): Promise { - await this.reverseProxyRepo.setEnvVariable(proxyId, key, value); + getAllReverseProxies(omitSensitive = false) { + return this.getAll(omitSensitive); } - async getEnvVariables(proxyId: string): Promise { - return this.reverseProxyRepo.getEnvVariables(proxyId); + getReverseProxyById(id: string, omitSensitive = false) { + return this.getById(id, omitSensitive); } - async deleteEnvVariable(proxyId: string, key: string): Promise { - await this.reverseProxyRepo.deleteEnvVariable(proxyId, key); + createReverseProxy(input: ReverseProxyCreateInput) { + return this.create(input); + } + + updateReverseProxy(id: string, input: ReverseProxyUpdateInput) { + return this.update(id, input); + } + + deleteReverseProxy(id: string) { + return this.delete(id); } } diff --git a/apps/backend/src/application/services/server.service.ts b/apps/backend/src/application/services/server.service.ts index af83457..26930a9 100644 --- a/apps/backend/src/application/services/server.service.ts +++ b/apps/backend/src/application/services/server.service.ts @@ -1,5 +1,4 @@ -import type { EnvVariable, ServerWithEnvVars } from "@minikura/db"; -import { ConflictError, NotFoundError } from "../../domain/errors/base.error"; +import type { ServerWithEnvVars } from "@minikura/db"; import { ServerCreatedEvent, ServerDeletedEvent, @@ -10,71 +9,65 @@ import type { ServerRepository, ServerUpdateInput, } from "../../domain/repositories/server.repository"; -import { eventBus } from "../../infrastructure/event-bus"; import type { K8sService } from "../../services/k8s"; +import type { IServerService } from "../interfaces/server.service.interface"; +import { BaseCrudService } from "./base-crud.service"; -export class ServerService { +export class ServerService + extends BaseCrudService< + ServerWithEnvVars, + ServerCreateInput, + ServerUpdateInput, + ServerRepository, + { + created: typeof ServerCreatedEvent; + updated: typeof ServerUpdatedEvent; + deleted: typeof ServerDeletedEvent; + } + > + implements IServerService +{ constructor( - private serverRepo: ServerRepository, - private k8sService: K8sService, - ) {} - - async getAllServers(omitSensitive = false): Promise { - return this.serverRepo.findAll(omitSensitive); - } - - async getServerById( - id: string, - omitSensitive = false, - ): Promise { - const server = await this.serverRepo.findById(id, omitSensitive); - if (!server) { - throw new NotFoundError("Server", id); - } - return server; - } - - async createServer(input: ServerCreateInput): Promise { - const existing = await this.serverRepo.exists(input.id); - if (existing) { - throw new ConflictError("Server", input.id); - } - - const server = await this.serverRepo.create(input); - await eventBus.publish( - new ServerCreatedEvent(server.id, server.type, input), + serverRepo: ServerRepository, + private k8sService: K8sService + ) { + super( + serverRepo, + { + created: ServerCreatedEvent, + updated: ServerUpdatedEvent, + deleted: ServerDeletedEvent, + }, + "Server" ); - return server; } - async updateServer( - id: string, - input: ServerUpdateInput, - ): Promise { - const server = await this.serverRepo.update(id, input); - await eventBus.publish(new ServerUpdatedEvent(id, input)); - return server; + protected getEntityType(input: ServerCreateInput) { + return input.type; } - async deleteServer(id: string): Promise { - await this.serverRepo.delete(id); - await eventBus.publish(new ServerDeletedEvent(id)); + protected getInputId(input: ServerCreateInput): string { + return input.id; } - async setEnvVariable( - serverId: string, - key: string, - value: string, - ): Promise { - await this.serverRepo.setEnvVariable(serverId, key, value); + getAllServers(omitSensitive = false) { + return this.getAll(omitSensitive); } - async getEnvVariables(serverId: string): Promise { - return this.serverRepo.getEnvVariables(serverId); + getServerById(id: string, omitSensitive = false) { + return this.getById(id, omitSensitive); } - async deleteEnvVariable(serverId: string, key: string): Promise { - await this.serverRepo.deleteEnvVariable(serverId, key); + createServer(input: ServerCreateInput) { + return this.create(input); + } + + updateServer(id: string, input: ServerUpdateInput) { + return this.update(id, input); + } + + deleteServer(id: string) { + return this.delete(id); } async getConnectionInfo(serverId: string) { diff --git a/apps/backend/src/application/services/user.service.ts b/apps/backend/src/application/services/user.service.ts index c3738de..eb15e94 100644 --- a/apps/backend/src/application/services/user.service.ts +++ b/apps/backend/src/application/services/user.service.ts @@ -6,8 +6,9 @@ import { } from "../../domain/events/server-lifecycle.events"; import type { UserRepository } from "../../domain/repositories/user.repository"; import { eventBus } from "../../infrastructure/event-bus"; +import type { IUserService } from "../interfaces/user.service.interface"; -export class UserService { +export class UserService implements IUserService { constructor(private userRepo: UserRepository) {} async getUserById(id: string): Promise { diff --git a/apps/backend/src/domain/errors/base.error.ts b/apps/backend/src/domain/errors/base.error.ts index ac6d6fd..d087532 100644 --- a/apps/backend/src/domain/errors/base.error.ts +++ b/apps/backend/src/domain/errors/base.error.ts @@ -32,9 +32,7 @@ export class ConflictError extends DomainError { readonly statusCode = 409; constructor(resource: string, identifier?: string) { - super( - identifier ? `${resource} already exists: ${identifier}` : `${resource} already exists` - ); + super(identifier ? `${resource} already exists: ${identifier}` : `${resource} already exists`); } } @@ -59,17 +57,9 @@ export class ForbiddenError extends DomainError { export class ValidationError extends DomainError { readonly code = "VALIDATION_ERROR"; readonly statusCode = 400; - - constructor(message: string) { - super(message); - } } export class BusinessRuleError extends DomainError { readonly code = "BUSINESS_RULE_VIOLATION"; readonly statusCode = 422; - - constructor(message: string) { - super(message); - } } diff --git a/apps/backend/src/domain/events/reverse-proxy-lifecycle.events.ts b/apps/backend/src/domain/events/reverse-proxy-lifecycle.events.ts index 14c7492..91710c4 100644 --- a/apps/backend/src/domain/events/reverse-proxy-lifecycle.events.ts +++ b/apps/backend/src/domain/events/reverse-proxy-lifecycle.events.ts @@ -1,6 +1,9 @@ -import { DomainEvent } from "./domain-event"; import type { ReverseProxyType } from "../entities/enums"; -import type { ReverseProxyCreateInput, ReverseProxyUpdateInput } from "../repositories/reverse-proxy.repository"; +import type { + ReverseProxyCreateInput, + ReverseProxyUpdateInput, +} from "../repositories/reverse-proxy.repository"; +import { DomainEvent } from "./domain-event"; export class ReverseProxyCreatedEvent extends DomainEvent { constructor( diff --git a/apps/backend/src/domain/repositories/server.repository.ts b/apps/backend/src/domain/repositories/server.repository.ts index 9714843..e957296 100644 --- a/apps/backend/src/domain/repositories/server.repository.ts +++ b/apps/backend/src/domain/repositories/server.repository.ts @@ -1,4 +1,4 @@ -import type { EnvVariable, Server, ServerWithEnvVars } from "@minikura/db"; +import type { EnvVariable, ServerWithEnvVars } from "@minikura/db"; import type { z } from "zod"; import type { createServerSchema, updateServerSchema } from "../../schemas/server.schema"; diff --git a/apps/backend/src/domain/value-objects/server-config.vo.ts b/apps/backend/src/domain/value-objects/server-config.vo.ts index 3dcfccc..f87bbb0 100644 --- a/apps/backend/src/domain/value-objects/server-config.vo.ts +++ b/apps/backend/src/domain/value-objects/server-config.vo.ts @@ -28,7 +28,7 @@ export class ServerConfig { } getJvmArgs(): string { - const args: string[] = ["-Xmx" + this.memory + "M"]; + const args: string[] = [`-Xmx${this.memory}M`]; if (this.jvmOpts) { args.push(this.jvmOpts); diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 6589d83..64f1da3 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -3,12 +3,14 @@ import { dotenvLoad } from "dotenv-mono"; dotenvLoad(); import { Elysia } from "elysia"; -import { auth } from "./lib/auth"; -import { authPlugin } from "./lib/auth-plugin"; -import { errorHandler } from "./lib/error-handler"; +import { node } from "@elysiajs/node"; +import { logger } from "./infrastructure/logger"; +import { auth } from "./middleware/auth"; +import { authPlugin } from "./middleware/auth-plugin"; +import { errorHandler } from "./middleware/error-handler"; import { bootstrapRoutes } from "./routes/bootstrap"; import { k8sRoutes } from "./routes/k8s"; -import { reverseProxyRoutes } from "./routes/reverse-proxy.routes"; +import { reverseProxyRoutes } from "./routes/reverse-proxy"; import { serverRoutes } from "./routes/servers"; import { terminalRoutes } from "./routes/terminal"; import { userRoutes } from "./routes/users"; @@ -16,7 +18,7 @@ import { userRoutes } from "./routes/users"; // Register event handlers import "./infrastructure/event-handlers"; -const app = new Elysia() +const app = new Elysia({ adapter: node() }) .use(errorHandler) .onRequest(({ set }) => { const origin = process.env.WEB_URL || "http://localhost:3001"; @@ -37,5 +39,5 @@ const app = new Elysia() export type App = typeof app; app.listen(3000, () => { - console.log("Server running on http://localhost:3000"); + logger.info({ port: 3000, url: "http://localhost:3000" }, "Backend API server started"); }); diff --git a/apps/backend/src/infrastructure/event-bus.ts b/apps/backend/src/infrastructure/event-bus.ts index 45ba608..5d77d0e 100644 --- a/apps/backend/src/infrastructure/event-bus.ts +++ b/apps/backend/src/infrastructure/event-bus.ts @@ -1,4 +1,5 @@ import type { DomainEvent } from "../domain/events/domain-event"; +import { logger } from "./logger"; type EventHandler = (event: T) => void | Promise; @@ -14,7 +15,7 @@ export class EventBus { if (!this.handlers.has(eventName)) { this.handlers.set(eventName, new Set()); } - this.handlers.get(eventName)!.add(handler as EventHandler); + this.handlers.get(eventName)?.add(handler as EventHandler); return () => { this.handlers.get(eventName)?.delete(handler as EventHandler); }; @@ -28,7 +29,7 @@ export class EventBus { try { await handler(event); } catch (error) { - console.error(`[EventBus] Error in handler for ${eventName}:`, error); + logger.error({ err: error, eventName }, "Error executing event handler"); } } } diff --git a/apps/backend/src/infrastructure/event-handlers/index.ts b/apps/backend/src/infrastructure/event-handlers/index.ts index 66b2a9b..8ac022a 100644 --- a/apps/backend/src/infrastructure/event-handlers/index.ts +++ b/apps/backend/src/infrastructure/event-handlers/index.ts @@ -1,4 +1,6 @@ import "./server-event.handler"; import "./user-event.handler"; -console.log("[EventBus] All event handlers registered"); +import { logger } from "../logger"; + +logger.debug("All domain event handlers registered"); diff --git a/apps/backend/src/infrastructure/event-handlers/server-event.handler.ts b/apps/backend/src/infrastructure/event-handlers/server-event.handler.ts index e3b6322..c444ed7 100644 --- a/apps/backend/src/infrastructure/event-handlers/server-event.handler.ts +++ b/apps/backend/src/infrastructure/event-handlers/server-event.handler.ts @@ -1,22 +1,23 @@ +import { wsService } from "../../application/di-container"; import { ServerCreatedEvent, ServerDeletedEvent, ServerUpdatedEvent, } from "../../domain/events/server-lifecycle.events"; import { eventBus } from "../event-bus"; -import { wsService } from "../../application/di-container"; +import { logger } from "../logger"; eventBus.subscribe(ServerCreatedEvent, async (event) => { - console.log(`[Event] Server created: ${event.serverId} (${event.serverType})`); + logger.info({ serverId: event.serverId, serverType: event.serverType }, "Server created event"); wsService.broadcast("create", event.serverType, event.serverId); }); eventBus.subscribe(ServerUpdatedEvent, async (event) => { - console.log(`[Event] Server updated: ${event.serverId}`); + logger.info({ serverId: event.serverId }, "Server updated event"); wsService.broadcast("update", "server", event.serverId); }); eventBus.subscribe(ServerDeletedEvent, async (event) => { - console.log(`[Event] Server deleted: ${event.serverId}`); + logger.info({ serverId: event.serverId }, "Server deleted event"); wsService.broadcast("delete", "server", event.serverId); }); diff --git a/apps/backend/src/infrastructure/event-handlers/user-event.handler.ts b/apps/backend/src/infrastructure/event-handlers/user-event.handler.ts index 0373795..55d2e03 100644 --- a/apps/backend/src/infrastructure/event-handlers/user-event.handler.ts +++ b/apps/backend/src/infrastructure/event-handlers/user-event.handler.ts @@ -3,17 +3,19 @@ import { UserUnsuspendedEvent, } from "../../domain/events/server-lifecycle.events"; import { eventBus } from "../event-bus"; +import { logger } from "../logger"; eventBus.subscribe(UserSuspendedEvent, async (event) => { if (event.suspendedUntil) { - console.log( - `[Event] User suspended: ${event.userId} until ${event.suspendedUntil.toISOString()}` + logger.warn( + { userId: event.userId, suspendedUntil: event.suspendedUntil.toISOString() }, + "User suspended with expiry" ); } else { - console.log(`[Event] User suspended: ${event.userId} indefinitely`); + logger.warn({ userId: event.userId }, "User suspended indefinitely"); } }); eventBus.subscribe(UserUnsuspendedEvent, async (event) => { - console.log(`[Event] User unsuspended: ${event.userId}`); + logger.info({ userId: event.userId }, "User unsuspended"); }); diff --git a/apps/backend/src/infrastructure/logger.ts b/apps/backend/src/infrastructure/logger.ts new file mode 100644 index 0000000..c8eca2c --- /dev/null +++ b/apps/backend/src/infrastructure/logger.ts @@ -0,0 +1,5 @@ +import { createLogger } from "@minikura/shared"; + +export { createLogger }; + +export const logger = createLogger("backend-api"); diff --git a/apps/backend/src/infrastructure/repositories/prisma/user.repository.impl.ts b/apps/backend/src/infrastructure/repositories/prisma/user.repository.impl.ts index 5655eb4..83ad3ce 100644 --- a/apps/backend/src/infrastructure/repositories/prisma/user.repository.impl.ts +++ b/apps/backend/src/infrastructure/repositories/prisma/user.repository.impl.ts @@ -1,5 +1,4 @@ import { prisma, type UpdateSuspensionInput, type UpdateUserInput, type User } from "@minikura/db"; -import { UserRole } from "../../../domain/entities/enums"; import type { UserRepository } from "../../../domain/repositories/user.repository"; export class PrismaUserRepository implements UserRepository { diff --git a/apps/backend/src/lib/errors.ts b/apps/backend/src/lib/errors.ts deleted file mode 100644 index 8e75ef0..0000000 --- a/apps/backend/src/lib/errors.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const getErrorMessage = (error: unknown): string => { - if (error instanceof Error) { - return error.message; - } - if (typeof error === "string") { - return error; - } - return "Unknown error"; -}; diff --git a/apps/backend/src/lib/fetch-patch.ts b/apps/backend/src/lib/fetch-patch.ts deleted file mode 100644 index c38c6c4..0000000 --- a/apps/backend/src/lib/fetch-patch.ts +++ /dev/null @@ -1,52 +0,0 @@ -import * as https from "node:https"; -import * as k8s from "@kubernetes/client-node"; -import { Agent as UndiciAgent } from "undici"; - -let clientCert: string | undefined; -let clientKey: string | undefined; -let caCert: string | undefined; - -try { - const kc = new k8s.KubeConfig(); - kc.loadFromDefault(); - const user = kc.getCurrentUser(); - const cluster = kc.getCurrentCluster(); - - if (user?.certData) { - clientCert = Buffer.from(user.certData, "base64").toString(); - } - if (user?.keyData) { - clientKey = Buffer.from(user.keyData, "base64").toString(); - } - if (cluster?.caData) { - caCert = Buffer.from(cluster.caData, "base64").toString(); - } - - if (clientCert && clientKey) { - const OriginalAgent = https.Agent; - type UndiciOptions = ConstructorParameters[0]; - const httpsModule = https as typeof https & { Agent: typeof https.Agent }; - - httpsModule.Agent = class PatchedAgent extends OriginalAgent { - constructor(options?: https.AgentOptions) { - super(options); - - const undiciOptions: UndiciOptions = { - connect: { - cert: clientCert, - key: clientKey, - ca: caCert, - rejectUnauthorized: process.env.KUBERNETES_SKIP_TLS_VERIFY !== "true", - }, - }; - - const patched = this as unknown as { _undiciAgent?: UndiciAgent }; - patched._undiciAgent = new UndiciAgent(undiciOptions); - } - }; - - console.log("Patched https.Agent to use undici with client certificates"); - } -} catch (err) { - console.warn("Failed to patch https.Agent for Kubernetes:", err); -} diff --git a/apps/backend/src/lib/kube-auth.ts b/apps/backend/src/lib/kube-auth.ts deleted file mode 100644 index 1ae269a..0000000 --- a/apps/backend/src/lib/kube-auth.ts +++ /dev/null @@ -1,107 +0,0 @@ -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 }>; -}; - -const SA_NAME = process.env.K8S_SA_NAME || "minikura-backend"; -const NAMESPACE = process.env.KUBERNETES_NAMESPACE || "minikura"; -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"); - if (!ctx) { - const clusterName = doc.clusters?.[0]?.name || "default"; - ctx = { - name: "bun-local", - 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"); - } catch (_error) { - console.warn("[kube-auth] Could not set bun-local context, using default"); - } - - return kc; -} diff --git a/apps/backend/src/lib/middleware.ts b/apps/backend/src/lib/middleware.ts deleted file mode 100644 index f6ede4b..0000000 --- a/apps/backend/src/lib/middleware.ts +++ /dev/null @@ -1,29 +0,0 @@ -type AuthContext = { - user?: { role?: string | null } | null; - set: { status?: number | string; headers?: unknown }; - params: Record; - query: Record; - [key: string]: unknown; -}; - -type ErrorResponse = { error: string }; - -export const requireAuth = (handler: (context: T) => R) => { - return (context: T): R | ErrorResponse => { - if (!context.user) { - context.set.status = 401; - return { error: "Unauthorized" }; - } - return handler(context); - }; -}; - -export const requireAdmin = (handler: (context: T) => R) => { - return (context: T): R | ErrorResponse => { - if (!context.user || context.user.role !== "admin") { - context.set.status = 403; - return { error: "Admin access required" }; - } - return handler(context); - }; -}; diff --git a/apps/backend/src/lib/service-utils.ts b/apps/backend/src/lib/service-utils.ts deleted file mode 100644 index c49fa48..0000000 --- a/apps/backend/src/lib/service-utils.ts +++ /dev/null @@ -1,33 +0,0 @@ -export function createSensitiveFieldSelector>( - fields: T -): T & { api_key?: false } { - return { - ...fields, - api_key: false, - } as T & { api_key?: false }; -} - -export function pickDefined, K extends keyof T>( - source: T, - keys: K[] -): Partial> { - return keys.reduce( - (result, key) => { - if (source[key] !== undefined) { - result[key] = source[key]; - } - return result; - }, - {} as Partial> - ); -} - -export function generateApiKey(prefix: string): string { - const crypto = require("node:crypto"); - let token = crypto.randomBytes(64).toString("hex"); - token = token - .split("") - .map((char: string) => (Math.random() > 0.5 ? char.toUpperCase() : char)) - .join(""); - return `${prefix}${token}`; -} diff --git a/apps/backend/src/lib/authorization.ts b/apps/backend/src/middleware/auth-guards.ts similarity index 100% rename from apps/backend/src/lib/authorization.ts rename to apps/backend/src/middleware/auth-guards.ts diff --git a/apps/backend/src/lib/auth-plugin.ts b/apps/backend/src/middleware/auth-plugin.ts similarity index 100% rename from apps/backend/src/lib/auth-plugin.ts rename to apps/backend/src/middleware/auth-plugin.ts diff --git a/apps/backend/src/lib/auth.ts b/apps/backend/src/middleware/auth.ts similarity index 100% rename from apps/backend/src/lib/auth.ts rename to apps/backend/src/middleware/auth.ts index 363a915..43fb650 100644 --- a/apps/backend/src/lib/auth.ts +++ b/apps/backend/src/middleware/auth.ts @@ -1,6 +1,6 @@ +import { prisma } from "@minikura/db"; import { betterAuth } from "better-auth"; import { prismaAdapter } from "better-auth/adapters/prisma"; -import { prisma } from "@minikura/db"; import { admin, openAPI } from "better-auth/plugins"; export const auth = betterAuth({ diff --git a/apps/backend/src/lib/error-handler.ts b/apps/backend/src/middleware/error-handler.ts similarity index 73% rename from apps/backend/src/lib/error-handler.ts rename to apps/backend/src/middleware/error-handler.ts index d8e768f..147ccf8 100644 --- a/apps/backend/src/lib/error-handler.ts +++ b/apps/backend/src/middleware/error-handler.ts @@ -1,9 +1,11 @@ import type { Elysia } from "elysia"; import { DomainError } from "../domain/errors/base.error"; +import { logger } from "../infrastructure/logger"; export const errorHandler = (app: Elysia) => { return app.onError(({ error, set }) => { if (error instanceof DomainError) { + logger.warn({ code: error.code, message: error.message }, "Domain error occurred"); set.status = error.statusCode; return { success: false, @@ -13,6 +15,7 @@ export const errorHandler = (app: Elysia) => { } if (error instanceof Error && (error.name === "ValidationError" || error.name === "ZodError")) { + logger.warn({ err: error, message: error.message }, "Validation error"); set.status = 400; return { success: false, @@ -21,7 +24,7 @@ export const errorHandler = (app: Elysia) => { }; } - console.error("Unhandled error:", error); + logger.error({ err: error }, "Unhandled error in API request"); set.status = 500; return { diff --git a/apps/backend/src/lib/zod-validator.ts b/apps/backend/src/middleware/zod-validator.ts similarity index 100% rename from apps/backend/src/lib/zod-validator.ts rename to apps/backend/src/middleware/zod-validator.ts diff --git a/apps/backend/src/routes/bootstrap.ts b/apps/backend/src/routes/bootstrap.ts index 006d214..da9a202 100644 --- a/apps/backend/src/routes/bootstrap.ts +++ b/apps/backend/src/routes/bootstrap.ts @@ -1,7 +1,8 @@ import { prisma } from "@minikura/db"; +import { getErrorMessage } from "@minikura/shared/errors"; import { Elysia } from "elysia"; -import { auth } from "../lib/auth"; -import { getErrorMessage } from "../lib/errors"; +import { logger } from "../infrastructure/logger"; +import { auth } from "../middleware/auth"; import { bootstrapSchema } from "../schemas/bootstrap.schema"; export const bootstrapRoutes = new Elysia({ prefix: "/bootstrap" }) @@ -37,14 +38,14 @@ export const bootstrapRoutes = new Elysia({ prefix: "/bootstrap" }) }); if (!result.user) { - console.error("No user in response:", result); + logger.error({ result }, "No user in bootstrap response"); set.status = 500; return { message: "Failed to create user" }; } return { success: true }; } catch (err: unknown) { - console.error("Bootstrap setup error:", err); + logger.error({ err }, "Bootstrap setup failed"); set.status = 500; return { message: getErrorMessage(err) }; } diff --git a/apps/backend/src/routes/k8s.ts b/apps/backend/src/routes/k8s.ts index a1b93f8..b31027b 100644 --- a/apps/backend/src/routes/k8s.ts +++ b/apps/backend/src/routes/k8s.ts @@ -1,135 +1,80 @@ +import { labelKeys } from "@minikura/api"; import { Elysia } from "elysia"; -import { authPlugin } from "../lib/auth-plugin"; -import { requireAuth } from "../lib/middleware"; -import { K8sService } from "../services/k8s"; +import { k8sService } from "../application/di-container"; +import { requireAuth } from "../middleware/auth-guards"; +import { authPlugin } from "../middleware/auth-plugin"; export const k8sRoutes = new Elysia({ prefix: "/k8s" }) .use(authPlugin) - .get( - "/status", - requireAuth(async () => { - const k8sService = K8sService.getInstance(); - return k8sService.getConnectionInfo(); - }) - ) - .get( - "/pods", - requireAuth(async () => { - const k8sService = K8sService.getInstance(); - return await k8sService.getPods(); - }) - ) - .get( - "/deployments", - requireAuth(async () => { - const k8sService = K8sService.getInstance(); - return await k8sService.getDeployments(); - }) - ) - .get( - "/statefulsets", - requireAuth(async () => { - const k8sService = K8sService.getInstance(); - return await k8sService.getStatefulSets(); - }) - ) - .get( - "/services", - requireAuth(async () => { - const k8sService = K8sService.getInstance(); - return await k8sService.getServices(); - }) - ) - .get( - "/configmaps", - requireAuth(async () => { - const k8sService = K8sService.getInstance(); - return await k8sService.getConfigMaps(); - }) - ) - .get( - "/ingresses", - requireAuth(async () => { - const k8sService = K8sService.getInstance(); - return await k8sService.getIngresses(); - }) - ) - .get( - "/minecraft-servers", - requireAuth(async () => { - const k8sService = K8sService.getInstance(); - return await k8sService.getMinecraftServers(); - }) - ) - .get( - "/reverse-proxy-servers", - requireAuth(async () => { - const k8sService = K8sService.getInstance(); - return await k8sService.getReverseProxyServers(); - }) - ) - .get( - "/pods/:podName", - requireAuth(async ({ params }) => { - const k8sService = K8sService.getInstance(); - return await k8sService.getPodInfo(params.podName); - }) - ) - .get( - "/pods/:podName/logs", - requireAuth(async ({ params, query, set }) => { - const k8sService = K8sService.getInstance(); - const options = { - container: query.container as string | undefined, - tailLines: query.tailLines ? parseInt(query.tailLines as string, 10) : 1000, - timestamps: query.timestamps === "true", - sinceSeconds: query.sinceSeconds ? parseInt(query.sinceSeconds as string, 10) : undefined, - }; - const logs = await k8sService.getPodLogs(params.podName, options); + .use(requireAuth) + .get("/status", async () => { + return k8sService.getConnectionInfo(); + }) + .get("/pods", async () => { + return await k8sService.getPods(); + }) + .get("/deployments", async () => { + return await k8sService.getDeployments(); + }) + .get("/statefulsets", async () => { + return await k8sService.getStatefulSets(); + }) + .get("/services", async () => { + return await k8sService.getServices(); + }) + .get("/configmaps", async () => { + return await k8sService.getConfigMaps(); + }) + .get("/ingresses", async () => { + return await k8sService.getIngresses(); + }) + .get("/minecraft-servers", async () => { + return await k8sService.getMinecraftServers(); + }) + .get("/reverse-proxy-servers", async () => { + return await k8sService.getReverseProxyServers(); + }) + .get("/pods/:podName", async ({ params }) => { + return await k8sService.getPodInfo(params.podName); + }) + .get("/pods/:podName/logs", async ({ params, query, set }) => { + const options = { + container: query.container as string | undefined, + tailLines: query.tailLines ? parseInt(query.tailLines as string, 10) : 1000, + timestamps: query.timestamps === "true", + sinceSeconds: query.sinceSeconds ? parseInt(query.sinceSeconds as string, 10) : undefined, + }; + const logs = await k8sService.getPodLogs(params.podName, options); - // Return as plain text - const headers = (set.headers ?? {}) as Record; - headers["content-type"] = "text/plain"; - set.headers = headers; - return logs; - }) - ) - .get( - "/servers/:serverId/pods", - requireAuth(async ({ params }) => { - const k8sService = K8sService.getInstance(); - const labelSelector = `minikura.kirameki.cafe/server-id=${params.serverId}`; - return await k8sService.getPodsByLabel(labelSelector); - }) - ) - .get( - "/reverse-proxy/:serverId/pods", - requireAuth(async ({ params }) => { - const k8sService = K8sService.getInstance(); - // Reverse proxy servers use either 'velocity-{id}' or 'bungeecord-{id}' pattern - // We need to check both patterns or use the proxy-id label - const labelSelector = `minikura.kirameki.cafe/proxy-id=${params.serverId}`; - return await k8sService.getPodsByLabel(labelSelector); - }) - ) - .get( - "/services/:serviceName", - requireAuth(async ({ params }) => { - const k8sService = K8sService.getInstance(); - return await k8sService.getServiceInfo(params.serviceName); - }) - ) - .get( - "/services/:serviceName/connection-info", - requireAuth(async ({ params }) => { - const k8sService = K8sService.getInstance(); - return await k8sService.getServerConnectionInfo(params.serviceName); - }) - ) - .get( - "/nodes", - requireAuth(async () => { - const k8sService = K8sService.getInstance(); - return await k8sService.getNodes(); - }) + // Return as plain text + const headers = (set.headers ?? {}) as Record; + headers["content-type"] = "text/plain"; + set.headers = headers; + return logs; + }) + .get("/servers/:serverId/pods", async ({ params }) => { + const labelSelector = `${labelKeys.serverId}=${params.serverId}`; + return await k8sService.getPodsByLabel(labelSelector); + }) + .get("/reverse-proxy/:serverId/pods", async ({ params }) => { + const labelSelector = `${labelKeys.proxyId}=${params.serverId}`; + return await k8sService.getPodsByLabel(labelSelector); + }) + .get("/services/:serviceName", async ({ params }) => { + return await k8sService.getServiceInfo(params.serviceName); + }) + .get("/services/:serviceName/connection-info", async ({ params }) => { + return await k8sService.getServerConnectionInfo(params.serviceName); + }) + .get("/nodes", async () => { + return await k8sService.getNodes(); + }) + .group("/metrics", (app) => + app + .get("/pods", async () => { + return await k8sService.getPodMetrics(); + }) + .get("/nodes", async () => { + return await k8sService.getNodeMetrics(); + }) ); diff --git a/apps/backend/src/routes/reverse-proxy.routes.ts b/apps/backend/src/routes/reverse-proxy.ts similarity index 95% rename from apps/backend/src/routes/reverse-proxy.routes.ts rename to apps/backend/src/routes/reverse-proxy.ts index c865f34..ff09ad5 100644 --- a/apps/backend/src/routes/reverse-proxy.routes.ts +++ b/apps/backend/src/routes/reverse-proxy.ts @@ -1,6 +1,7 @@ import { Elysia } from "elysia"; import { z } from "zod"; import { reverseProxyService } from "../application/di-container"; +import { requireAuth } from "../middleware/auth-guards"; import { createReverseProxySchema, updateReverseProxySchema } from "../schemas/server.schema"; const envVariableSchema = z.object({ @@ -9,6 +10,7 @@ const envVariableSchema = z.object({ }); export const reverseProxyRoutes = new Elysia({ prefix: "/reverse-proxy" }) + .use(requireAuth) .get("/", async () => { return await reverseProxyService.getAllReverseProxies(false); }) diff --git a/apps/backend/src/routes/servers.ts b/apps/backend/src/routes/servers.ts index f132d4b..654c54b 100644 --- a/apps/backend/src/routes/servers.ts +++ b/apps/backend/src/routes/servers.ts @@ -1,6 +1,7 @@ import { Elysia } from "elysia"; import { z } from "zod"; import { serverService, wsService } from "../application/di-container"; +import { requireAuth } from "../middleware/auth-guards"; import { createServerSchema, updateServerSchema } from "../schemas/server.schema"; import type { WebSocketClient } from "../services/websocket"; @@ -23,6 +24,7 @@ export const serverRoutes = new Elysia({ prefix: "/servers" }) }, message() {}, }) + .use(requireAuth) .get("/", async () => { return await serverService.getAllServers(false); }) diff --git a/apps/backend/src/routes/terminal.ts b/apps/backend/src/routes/terminal.ts index d8ecef9..cbedd16 100644 --- a/apps/backend/src/routes/terminal.ts +++ b/apps/backend/src/routes/terminal.ts @@ -1,7 +1,7 @@ -import * as k8s from "@kubernetes/client-node"; +import { getErrorMessage } from "@minikura/shared/errors"; import { Elysia } from "elysia"; -import { getErrorMessage } from "../lib/errors"; -import { K8sService } from "../services/k8s"; +import { k8sService } from "../application/di-container"; +import { logger } from "../infrastructure/logger"; type TerminalWsData = { query?: Record; @@ -32,7 +32,7 @@ export const terminalRoutes = new Elysia({ prefix: "/terminal" }).ws("/exec", { const shell = ws.data.query?.shell || "/bin/sh"; const mode = ws.data.query?.mode || "shell"; - console.log( + logger.debug( `Opening terminal for pod: ${podName}, container: ${container}, shell: ${shell}, mode: ${mode}` ); @@ -48,7 +48,6 @@ export const terminalRoutes = new Elysia({ prefix: "/terminal" }).ws("/exec", { } try { - const k8sService = K8sService.getInstance(); if (!k8sService.isInitialized()) { ws.send( JSON.stringify({ @@ -94,7 +93,7 @@ export const terminalRoutes = new Elysia({ prefix: "/terminal" }).ws("/exec", { .replace("https://", "wss://") .replace("http://", "ws://"); - console.log(`Connecting to Kubernetes: ${wsUrl}`); + logger.debug(`Connecting to Kubernetes: ${wsUrl}`); const headers: Record = { Connection: "Upgrade", @@ -107,10 +106,10 @@ export const terminalRoutes = new Elysia({ prefix: "/terminal" }).ws("/exec", { }; if (user?.token) { - headers["Authorization"] = `Bearer ${user.token}`; + headers.Authorization = `Bearer ${user.token}`; } else if (user?.username && user?.password) { const auth = Buffer.from(`${user.username}:${user.password}`).toString("base64"); - headers["Authorization"] = `Basic ${auth}`; + headers.Authorization = `Basic ${auth}`; } const tlsOptions: BunTlsOptions = { @@ -132,7 +131,7 @@ export const terminalRoutes = new Elysia({ prefix: "/terminal" }).ws("/exec", { ws.data.k8sWs = k8sWs; k8sWs.onopen = async () => { - console.log(`Connected to Kubernetes ${isAttach ? "attach" : "exec"}`); + logger.debug(`Connected to Kubernetes ${isAttach ? "attach" : "exec"}`); if (isAttach) { try { @@ -149,7 +148,7 @@ export const terminalRoutes = new Elysia({ prefix: "/terminal" }).ws("/exec", { ws.send( JSON.stringify({ type: "output", - data: line + "\r\n", + data: `${line}\r\n`, }) ); } @@ -162,7 +161,7 @@ export const terminalRoutes = new Elysia({ prefix: "/terminal" }).ws("/exec", { }) ); } catch (logError) { - console.error("Failed to fetch historical logs:", logError); + logger.error("Failed to fetch historical logs:", logError); ws.send( JSON.stringify({ type: "ready", @@ -202,13 +201,18 @@ export const terminalRoutes = new Elysia({ prefix: "/terminal" }).ws("/exec", { ws.send(JSON.stringify({ type: "output", data })); return; } else { - console.log("Unknown data type:", typeof data, "constructor:", data?.constructor?.name); + logger.debug( + "Unknown data type:", + typeof data, + "constructor:", + data?.constructor?.name + ); buffer = new Uint8Array(data); } processBuffer(buffer); } catch (err) { - console.error("Error processing Kubernetes message:", err); + logger.error("Error processing Kubernetes message:", err); } }; @@ -223,13 +227,13 @@ export const terminalRoutes = new Elysia({ prefix: "/terminal" }).ws("/exec", { if (channel === 1 || channel === 2) { ws.send(JSON.stringify({ type: "output", data: message })); } else if (channel === 3) { - console.error("Kubernetes error channel:", message); + logger.error("Kubernetes error channel:", message); ws.send(JSON.stringify({ type: "error", data: message })); } } k8sWs.onerror = (error: Event) => { - console.error("Kubernetes WebSocket error:", error); + logger.error("Kubernetes WebSocket error:", error); const message = getErrorMessage(error); ws.send( JSON.stringify({ @@ -240,7 +244,7 @@ export const terminalRoutes = new Elysia({ prefix: "/terminal" }).ws("/exec", { }; k8sWs.onclose = (event: CloseEvent) => { - console.log(`Kubernetes WebSocket closed: ${event.code} ${event.reason}`); + logger.debug(`Kubernetes WebSocket closed: ${event.code} ${event.reason}`); ws.send( JSON.stringify({ type: "close", @@ -250,9 +254,9 @@ export const terminalRoutes = new Elysia({ prefix: "/terminal" }).ws("/exec", { ws.close(); }; } catch (error: unknown) { - console.error("Error setting up terminal:", error); + logger.error("Error setting up terminal:", error); if (error instanceof Error) { - console.error("Error stack:", error.stack); + logger.error("Error stack:", error.stack); } ws.send( JSON.stringify({ @@ -274,12 +278,12 @@ export const terminalRoutes = new Elysia({ prefix: "/terminal" }).ws("/exec", { const k8sWs = ws.data.k8sWs; if (!k8sWs || k8sWs.readyState !== WebSocket.OPEN) { - console.error("Kubernetes WebSocket not ready, state:", k8sWs?.readyState); + logger.error("Kubernetes WebSocket not ready, state:", k8sWs?.readyState); return; } if (data.type === "input") { - console.log("Sending input to k8s:", data.data); + logger.debug("Sending input to k8s:", data.data); const encoder = new TextEncoder(); const textData = encoder.encode(data.data); const buffer = new Uint8Array(1 + textData.length); @@ -299,7 +303,7 @@ export const terminalRoutes = new Elysia({ prefix: "/terminal" }).ws("/exec", { k8sWs.send(buffer.buffer); } } catch (error: unknown) { - console.error("Error handling terminal message:", error); + logger.error("Error handling terminal message:", error); ws.send( JSON.stringify({ type: "error", @@ -310,7 +314,7 @@ export const terminalRoutes = new Elysia({ prefix: "/terminal" }).ws("/exec", { }, close: (ws: TerminalWs) => { - console.log("Client WebSocket closed"); + logger.debug("Client WebSocket closed"); const k8sWs = ws.data.k8sWs; if (k8sWs && k8sWs.readyState === WebSocket.OPEN) { k8sWs.close(); @@ -324,7 +328,7 @@ function parseTerminalMessage(message: unknown): TerminalMessage | null { const parsed = JSON.parse(message) as unknown; return isTerminalMessage(parsed) ? parsed : null; } catch { - console.error("Failed to parse message as JSON:", message); + logger.error("Failed to parse message as JSON:", message); return null; } } diff --git a/apps/backend/src/routes/users.ts b/apps/backend/src/routes/users.ts index 1ccac36..f0338d0 100644 --- a/apps/backend/src/routes/users.ts +++ b/apps/backend/src/routes/users.ts @@ -1,7 +1,7 @@ import type { UpdateUserInput } from "@minikura/db"; import { Elysia } from "elysia"; import { userService } from "../application/di-container"; -import { requireAdmin, requireAuth } from "../lib/authorization"; +import { requireAdmin, requireAuth } from "../middleware/auth-guards"; export const userRoutes = new Elysia({ prefix: "/users" }) .use(requireAdmin) diff --git a/apps/backend/src/schemas/server.schema.ts b/apps/backend/src/schemas/server.schema.ts index ab37d14..05834c5 100644 --- a/apps/backend/src/schemas/server.schema.ts +++ b/apps/backend/src/schemas/server.schema.ts @@ -1,4 +1,9 @@ -import { MinecraftServerJarType, ReverseProxyServerType, ServerType, ServiceType } from "@minikura/db"; +import { + MinecraftServerJarType, + ReverseProxyServerType, + ServerType, + ServiceType, +} from "@minikura/db"; import { z } from "zod"; import { GameMode, ServerDifficulty } from "../domain/entities/enums"; diff --git a/apps/backend/src/services/__tests__/session.test.ts b/apps/backend/src/services/__tests__/session.test.ts deleted file mode 100644 index 2f9b7f1..0000000 --- a/apps/backend/src/services/__tests__/session.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { describe, it, expect, beforeEach, mock } from "bun:test"; -import { SessionService } from "../session"; - -// Create mock functions -const mockSessionFindUnique = mock(); -const mockServerFindUnique = mock(); -const mockReverseProxyFindUnique = mock(); -const mockIsUserSuspended = mock(); - -// Mock the prisma client -mock.module("@minikura/db", () => ({ - prisma: { - session: { - findUnique: mockSessionFindUnique, - }, - server: { - findUnique: mockServerFindUnique, - }, - reverseProxyServer: { - findUnique: mockReverseProxyFindUnique, - }, - }, - isUserSuspended: mockIsUserSuspended, -})); - -// Import mocked modules -await import("@minikura/db"); - -describe("SessionService", () => { - beforeEach(() => { - mockSessionFindUnique.mockClear(); - mockServerFindUnique.mockClear(); - mockReverseProxyFindUnique.mockClear(); - mockIsUserSuspended.mockClear(); - }); - - describe("validate", () => { - it("should return INVALID for non-existent session", async () => { - mockSessionFindUnique.mockResolvedValue(null); - - const result = await SessionService.validate("invalid-token"); - - expect(result.status).toBe(SessionService.SESSION_STATUS.INVALID); - expect(result.session).toBeNull(); - }); - - it("should return EXPIRED for expired session", async () => { - const expiredDate = new Date(Date.now() - 1000); - mockSessionFindUnique.mockResolvedValue({ - id: "session-1", - token: "token-1", - expiresAt: expiredDate, - createdAt: new Date(), - updatedAt: new Date(), - ipAddress: null, - userAgent: null, - userId: "user-1", - user: { - id: "user-1", - name: "Test User", - email: "test@example.com", - emailVerified: true, - image: null, - role: "user", - isSuspended: false, - suspendedUntil: null, - }, - }); - - const result = await SessionService.validate("expired-token"); - - expect(result.status).toBe(SessionService.SESSION_STATUS.EXPIRED); - }); - - it("should return USER_SUSPENDED for suspended user", async () => { - const futureDate = new Date(Date.now() + 1000000); - mockIsUserSuspended.mockReturnValue(true); - - mockSessionFindUnique.mockResolvedValue({ - id: "session-1", - token: "token-1", - expiresAt: futureDate, - createdAt: new Date(), - updatedAt: new Date(), - ipAddress: null, - userAgent: null, - userId: "user-1", - user: { - id: "user-1", - name: "Test User", - email: "test@example.com", - emailVerified: true, - image: null, - role: "user", - isSuspended: true, - suspendedUntil: null, - }, - }); - - const result = await SessionService.validate("valid-token"); - - expect(result.status).toBe(SessionService.SESSION_STATUS.USER_SUSPENDED); - }); - - it("should return VALID for valid session with active user", async () => { - const futureDate = new Date(Date.now() + 1000000); - mockIsUserSuspended.mockReturnValue(false); - - mockSessionFindUnique.mockResolvedValue({ - id: "session-1", - token: "token-1", - expiresAt: futureDate, - createdAt: new Date(), - updatedAt: new Date(), - ipAddress: null, - userAgent: null, - userId: "user-1", - user: { - id: "user-1", - name: "Test User", - email: "test@example.com", - emailVerified: true, - image: null, - role: "user", - isSuspended: false, - suspendedUntil: null, - }, - }); - - const result = await SessionService.validate("valid-token"); - - expect(result.status).toBe(SessionService.SESSION_STATUS.VALID); - expect(result.session).toBeDefined(); - }); - - it("should handle temporary suspension correctly", async () => { - const futureDate = new Date(Date.now() + 1000000); - const pastSuspensionDate = new Date(Date.now() - 1000); - - // User was suspended but suspension has expired - mockIsUserSuspended.mockReturnValue(false); - - mockSessionFindUnique.mockResolvedValue({ - id: "session-1", - token: "token-1", - expiresAt: futureDate, - createdAt: new Date(), - updatedAt: new Date(), - ipAddress: null, - userAgent: null, - userId: "user-1", - user: { - id: "user-1", - name: "Test User", - email: "test@example.com", - emailVerified: true, - image: null, - role: "user", - isSuspended: true, - suspendedUntil: pastSuspensionDate, - }, - }); - - const result = await SessionService.validate("valid-token"); - - expect(result.status).toBe(SessionService.SESSION_STATUS.VALID); - }); - }); - - describe("validateApiKey", () => { - it("should return INVALID for invalid API key format", async () => { - const result = await SessionService.validateApiKey("invalid-key"); - - expect(result.status).toBe(SessionService.SESSION_STATUS.INVALID); - expect(result.server).toBeNull(); - }); - - it("should return INVALID when reverse proxy server not found", async () => { - mockReverseProxyFindUnique.mockResolvedValue(null); - - const result = await SessionService.validateApiKey( - "minikura_reverse_proxy_server_api_key_invalid" - ); - - expect(result.status).toBe(SessionService.SESSION_STATUS.INVALID); - expect(result.server).toBeNull(); - }); - - it("should return valid reverse proxy server for valid API key", async () => { - const mockServer = { - id: "server-1", - subdomain: "test", - api_key: "minikura_reverse_proxy_server_api_key_valid", - type: "VELOCITY", - description: null, - memory: "1G", - external_address: "test.example.com", - external_port: 25565, - listen_port: 25577, - createdAt: new Date(), - updatedAt: new Date(), - }; - - mockReverseProxyFindUnique.mockResolvedValue(mockServer); - - const result = await SessionService.validateApiKey( - "minikura_reverse_proxy_server_api_key_valid" - ); - - expect(result.status).toBe(SessionService.SESSION_STATUS.VALID); - expect(result.server?.id).toBe(mockServer.id); - }); - - it("should return valid server for valid server API key", async () => { - const mockServer = { - id: "server-1", - name: "Test Server", - api_key: "minikura_server_api_key_valid", - type: "STATEFUL", - description: null, - memory: "2G", - listen_port: 25565, - createdAt: new Date(), - updatedAt: new Date(), - }; - - mockServerFindUnique.mockResolvedValue(mockServer); - - const result = await SessionService.validateApiKey("minikura_server_api_key_valid"); - - expect(result.status).toBe(SessionService.SESSION_STATUS.VALID); - expect(result.server?.id).toBe(mockServer.id); - }); - }); -}); diff --git a/apps/backend/src/services/__tests__/user.test.ts b/apps/backend/src/services/__tests__/user.test.ts deleted file mode 100644 index 74adc6e..0000000 --- a/apps/backend/src/services/__tests__/user.test.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { beforeEach, describe, expect, it, mock } from "bun:test"; -import { UserService } from "../user"; - -// Create mock functions -const mockFindUnique = mock(); -const mockFindMany = mock(); -const mockUpdate = mock(); -const mockDelete = mock(); -const mockIsUserSuspended = mock(); - -// Mock the prisma client -mock.module("@minikura/db", () => ({ - prisma: { - user: { - findUnique: mockFindUnique, - findMany: mockFindMany, - update: mockUpdate, - delete: mockDelete, - }, - }, - isUserSuspended: mockIsUserSuspended, -})); - -// Import mocked modules -await import("@minikura/db"); - -describe("UserService", () => { - beforeEach(() => { - mockFindUnique.mockClear(); - mockFindMany.mockClear(); - mockUpdate.mockClear(); - mockDelete.mockClear(); - mockIsUserSuspended.mockClear(); - }); - - describe("getUserByEmail", () => { - it("should return user when found", async () => { - const mockUser = { - id: "user-1", - name: "Test User", - email: "test@example.com", - emailVerified: true, - image: null, - role: "user", - banned: false, - isSuspended: false, - suspendedUntil: null, - createdAt: new Date(), - updatedAt: new Date(), - }; - - mockFindUnique.mockResolvedValue(mockUser); - - const result = await UserService.getUserByEmail("test@example.com"); - - expect(mockFindUnique).toHaveBeenCalledWith({ - where: { email: "test@example.com" }, - }); - expect(result).toEqual(mockUser); - }); - - it("should return null when user not found", async () => { - mockFindUnique.mockResolvedValue(null); - - const result = await UserService.getUserByEmail("notfound@example.com"); - - expect(result).toBeNull(); - }); - }); - - describe("getUserById", () => { - it("should return user when found", async () => { - const mockUser = { - id: "user-1", - name: "Test User", - email: "test@example.com", - emailVerified: true, - image: null, - role: "user", - banned: false, - isSuspended: false, - suspendedUntil: null, - createdAt: new Date(), - updatedAt: new Date(), - }; - - mockFindUnique.mockResolvedValue(mockUser); - - const result = await UserService.getUserById("user-1"); - - expect(result).toEqual(mockUser); - }); - }); - - describe("getAllUsersWithSuspension", () => { - it("should return all users with suspension info", async () => { - const mockUsers = [ - { - id: "user-1", - name: "User 1", - email: "user1@example.com", - emailVerified: true, - image: null, - role: "user", - banned: false, - isSuspended: false, - suspendedUntil: null, - createdAt: new Date(), - }, - { - id: "user-2", - name: "User 2", - email: "user2@example.com", - emailVerified: true, - image: null, - role: "user", - banned: false, - isSuspended: true, - suspendedUntil: new Date(Date.now() + 86400000), - createdAt: new Date(), - }, - ]; - - mockFindMany.mockResolvedValue(mockUsers); - - const result = await UserService.getAllUsersWithSuspension(); - - expect(result).toEqual(mockUsers); - expect(result).toHaveLength(2); - }); - }); - - describe("updateUser", () => { - it("should update user basic information", async () => { - const mockUpdatedUser = { - id: "user-1", - name: "Updated Name", - email: "test@example.com", - emailVerified: true, - image: null, - role: "admin", - createdAt: new Date(), - }; - - mockUpdate.mockResolvedValue(mockUpdatedUser); - - const result = await UserService.updateUser("user-1", { - name: "Updated Name", - role: "admin", - }); - - expect(result).toEqual(mockUpdatedUser); - }); - }); - - describe("suspendUser", () => { - it("should suspend user indefinitely when no expiration provided", async () => { - const mockSuspendedUser = { - id: "user-1", - name: "Test User", - email: "test@example.com", - emailVerified: true, - image: null, - role: "user", - isSuspended: true, - suspendedUntil: null, - createdAt: new Date(), - }; - - mockUpdate.mockResolvedValue(mockSuspendedUser); - - const result = await UserService.suspendUser("user-1"); - - expect(result.isSuspended).toBe(true); - expect(result.suspendedUntil).toBeNull(); - }); - - it("should suspend user until specific date", async () => { - const suspendUntil = new Date(Date.now() + 86400000); // 1 day from now - - const mockSuspendedUser = { - id: "user-1", - name: "Test User", - email: "test@example.com", - emailVerified: true, - image: null, - role: "user", - isSuspended: true, - suspendedUntil: suspendUntil, - createdAt: new Date(), - }; - - mockUpdate.mockResolvedValue(mockSuspendedUser); - - const result = await UserService.suspendUser("user-1", suspendUntil); - - expect(result.isSuspended).toBe(true); - expect(result.suspendedUntil).toEqual(suspendUntil); - }); - }); - - describe("unsuspendUser", () => { - it("should unsuspend a user", async () => { - const mockUnsuspendedUser = { - id: "user-1", - name: "Test User", - email: "test@example.com", - emailVerified: true, - image: null, - role: "user", - isSuspended: false, - suspendedUntil: null, - createdAt: new Date(), - }; - - mockUpdate.mockResolvedValue(mockUnsuspendedUser); - - const result = await UserService.unsuspendUser("user-1"); - - expect(result.isSuspended).toBe(false); - expect(result.suspendedUntil).toBeNull(); - }); - }); - - describe("deleteUser", () => { - it("should delete a user", async () => { - mockDelete.mockResolvedValue({}); - - await UserService.deleteUser("user-1"); - - expect(mockDelete).toHaveBeenCalledWith({ - where: { id: "user-1" }, - }); - }); - }); - - describe("checkSuspension", () => { - it("should return true when user is suspended", async () => { - const mockUser = { - isSuspended: true, - suspendedUntil: null, - }; - - mockFindUnique.mockResolvedValue(mockUser); - mockIsUserSuspended.mockReturnValue(true); - - const result = await UserService.checkSuspension("user-1"); - - expect(result).toBe(true); - }); - - it("should return false when user is not suspended", async () => { - const mockUser = { - isSuspended: false, - suspendedUntil: null, - }; - - mockFindUnique.mockResolvedValue(mockUser); - mockIsUserSuspended.mockReturnValue(false); - - const result = await UserService.checkSuspension("user-1"); - - expect(result).toBe(false); - }); - - it("should throw error when user not found", async () => { - mockFindUnique.mockResolvedValue(null); - - await expect(UserService.checkSuspension("user-1")).rejects.toThrow("User not found"); - }); - }); -}); diff --git a/apps/backend/src/services/k8s.ts b/apps/backend/src/services/k8s.ts index 6ab6fc4..b059f4e 100644 --- a/apps/backend/src/services/k8s.ts +++ b/apps/backend/src/services/k8s.ts @@ -1,13 +1,20 @@ import * as k8s from "@kubernetes/client-node"; import type { CustomResourceSummary } from "@minikura/api"; -import { K8sResources } from "./k8s/resources"; +import { API_GROUP } from "@minikura/api"; +import { buildKubeConfig } from "@minikura/shared/kube-auth"; +import type { IK8sService } from "../application/interfaces/k8s.service.interface"; +import { logger } from "../infrastructure/logger"; +import { ClusterOperations } from "./kubernetes/operations/cluster.operations"; +import { CustomResourceOperations } from "./kubernetes/operations/custom-resource.operations"; +import { NetworkOperations } from "./kubernetes/operations/network.operations"; +import { PodOperations } from "./kubernetes/operations/pod.operations"; +import { WorkloadOperations } from "./kubernetes/operations/workload.operations"; +import { K8sResources } from "./kubernetes/resources"; -const CUSTOM_RESOURCE_GROUP = "minikura.kirameki.cafe"; const CUSTOM_RESOURCE_VERSION = "v1alpha1"; -export class K8sService { - private static instance: K8sService; - private kc: k8s.KubeConfig; +export class K8sService implements IK8sService { + private kc!: k8s.KubeConfig; private coreApi!: k8s.CoreV1Api; private appsApi!: k8s.AppsV1Api; private customObjectsApi!: k8s.CustomObjectsApi; @@ -16,65 +23,30 @@ export class K8sService { private initialized: boolean = false; private resources!: K8sResources; - private constructor() { - this.kc = new k8s.KubeConfig(); + private podOps!: PodOperations; + private clusterOps!: ClusterOperations; + private customResourceOps!: CustomResourceOperations; + + constructor() { this.namespace = process.env.KUBERNETES_NAMESPACE || "minikura"; try { - this.setupConfig(); + this.kc = buildKubeConfig(); this.initializeClients(); + this.initializeOperations(); this.resources = new K8sResources( this.coreApi, this.appsApi, this.networkingApi, - this.namespace, + this.namespace ); this.initialized = true; - } catch (_error) { + } catch (error) { + logger.error({ err: error }, "Failed to initialize Kubernetes client"); this.initialized = false; } } - private setupConfig(): void { - const isBun = typeof Bun !== "undefined"; - - if (isBun) { - const { buildKubeConfig } = require("../lib/kube-auth"); - this.kc = buildKubeConfig(); - return; - } - - try { - this.kc.loadFromDefault(); - console.log("Loaded Kubernetes config from default location"); - } catch (err) { - console.warn( - "Failed to load Kubernetes config from default location:", - err, - ); - } - - if (!this.kc.getCurrentContext()) { - try { - this.kc.loadFromCluster(); - console.log("Loaded Kubernetes config from cluster"); - } catch (err) { - console.warn("Failed to load Kubernetes config from cluster:", err); - } - } - - if (!this.kc.getCurrentContext()) { - throw new Error( - "Failed to setup Kubernetes client - no valid configuration found", - ); - } - - const currentCluster = this.kc.getCurrentCluster(); - if (currentCluster) { - console.log(`Connecting to Kubernetes server: ${currentCluster.server}`); - } - } - private initializeClients(): void { this.coreApi = this.kc.makeApiClient(k8s.CoreV1Api); this.appsApi = this.kc.makeApiClient(k8s.AppsV1Api); @@ -82,11 +54,12 @@ export class K8sService { this.networkingApi = this.kc.makeApiClient(k8s.NetworkingV1Api); } - static getInstance(): K8sService { - if (!K8sService.instance) { - K8sService.instance = new K8sService(); - } - return K8sService.instance; + private initializeOperations(): void { + this.podOps = new PodOperations(this.coreApi, this.namespace); + this.workloadOps = new WorkloadOperations(this.appsApi, this.namespace); + this.networkOps = new NetworkOperations(this.coreApi, this.networkingApi, this.namespace); + this.clusterOps = new ClusterOperations(this.coreApi, this.customObjectsApi, this.namespace); + this.customResourceOps = new CustomResourceOperations(this.customObjectsApi, this.namespace); } isInitialized(): boolean { @@ -118,147 +91,56 @@ export class K8sService { } async getPods() { - if (!this.initialized) { - throw new Error("Kubernetes client not initialized"); - } - - try { - return await this.resources.listPods(); - } catch (error: unknown) { - console.error("Error fetching pods:", error); - throw new Error(`Failed to fetch pods: ${getErrorMessage(error)}`); - } + this.ensureInitialized(); + return this.resources.listPods(); } async getDeployments() { - if (!this.initialized) { - throw new Error("Kubernetes client not initialized"); - } - - try { - return await this.resources.listDeployments(); - } catch (error: unknown) { - console.error("Error fetching deployments:", error); - throw new Error(`Failed to fetch deployments: ${getErrorMessage(error)}`); - } + this.ensureInitialized(); + return this.resources.listDeployments(); } async getStatefulSets() { - if (!this.initialized) { - throw new Error("Kubernetes client not initialized"); - } - - try { - return await this.resources.listStatefulSets(); - } catch (error: unknown) { - console.error("Error fetching statefulsets:", error); - throw new Error( - `Failed to fetch statefulsets: ${getErrorMessage(error)}`, - ); - } + this.ensureInitialized(); + return this.resources.listStatefulSets(); } async getServices() { - if (!this.initialized) { - throw new Error("Kubernetes client not initialized"); - } - - try { - return await this.resources.listServices(); - } catch (error: unknown) { - console.error("Error fetching services:", error); - throw new Error(`Failed to fetch services: ${getErrorMessage(error)}`); - } + this.ensureInitialized(); + return this.resources.listServices(); } async getConfigMaps() { - if (!this.initialized) { - throw new Error("Kubernetes client not initialized"); - } - - try { - return await this.resources.listConfigMaps(); - } catch (error: unknown) { - console.error("Error fetching configmaps:", error); - throw new Error(`Failed to fetch configmaps: ${getErrorMessage(error)}`); - } + this.ensureInitialized(); + return this.resources.listConfigMaps(); } async getIngresses() { + this.ensureInitialized(); + return this.resources.listIngresses(); + } + + private ensureInitialized(): void { if (!this.initialized) { throw new Error("Kubernetes client not initialized"); } - - try { - return await this.resources.listIngresses(); - } catch (error: unknown) { - console.error("Error fetching ingresses:", error); - throw new Error(`Failed to fetch ingresses: ${getErrorMessage(error)}`); - } } async getCustomResources( group: string, version: string, - plural: string, + plural: string ): Promise { - if (!this.initialized) { - throw new Error("Kubernetes client not initialized"); - } - - try { - type CustomResourceItem = { - metadata?: { - name?: string; - namespace?: string; - creationTimestamp?: string; - labels?: Record; - }; - spec?: Record; - status?: { phase?: string; [key: string]: unknown }; - }; - - const response = await this.customObjectsApi.listNamespacedCustomObject({ - group, - version, - namespace: this.namespace, - plural, - }); - const body = response as unknown as { - items?: CustomResourceItem[]; - body?: { items?: CustomResourceItem[] }; - }; - const items = body.items ?? body.body?.items ?? []; - return items.map((item) => ({ - name: item.metadata?.name, - namespace: item.metadata?.namespace, - age: getAge(item.metadata?.creationTimestamp), - labels: item.metadata?.labels, - spec: item.spec, - status: item.status, - })); - } catch (error: unknown) { - console.error(`Error fetching custom resources ${plural}:`, error); - throw new Error( - `Failed to fetch custom resources: ${getErrorMessage(error)}`, - ); - } + this.ensureInitialized(); + return this.customResourceOps.listCustomResources(group, version, plural); } async getMinecraftServers() { - return this.getCustomResources( - CUSTOM_RESOURCE_GROUP, - CUSTOM_RESOURCE_VERSION, - "minecraftservers", - ); + return this.getCustomResources(API_GROUP, CUSTOM_RESOURCE_VERSION, "minecraftservers"); } async getReverseProxyServers() { - return this.getCustomResources( - CUSTOM_RESOURCE_GROUP, - CUSTOM_RESOURCE_VERSION, - "reverseproxyservers", - ); + return this.getCustomResources(API_GROUP, CUSTOM_RESOURCE_VERSION, "reverseproxyservers"); } async getPodLogs( @@ -268,150 +150,82 @@ export class K8sService { tailLines?: number; timestamps?: boolean; sinceSeconds?: number; - }, + } ) { - if (!this.initialized) { - throw new Error("Kubernetes client not initialized"); - } - - try { - const response = await this.coreApi.readNamespacedPodLog({ - name: podName, - namespace: this.namespace, - container: options?.container, - tailLines: options?.tailLines, - timestamps: options?.timestamps, - sinceSeconds: options?.sinceSeconds, - }); - return response; - } catch (error: unknown) { - console.error(`Error fetching logs for pod ${podName}:`, error); - throw new Error(`Failed to fetch pod logs: ${getErrorMessage(error)}`); - } + this.ensureInitialized(); + return this.podOps.getPodLogs(podName, options); } async getPodsByLabel(labelSelector: string) { - if (!this.initialized) { - throw new Error("Kubernetes client not initialized"); - } - - try { - return await this.resources.listPodsByLabel(labelSelector); - } catch (error: unknown) { - console.error("Error fetching pods by label:", error); - throw new Error( - `Failed to fetch pods by label: ${getErrorMessage(error)}`, - ); - } + this.ensureInitialized(); + return this.resources.listPodsByLabel(labelSelector); } async getPodInfo(podName: string) { - if (!this.initialized) { - throw new Error("Kubernetes client not initialized"); - } - - try { - return await this.resources.getPodInfo(podName); - } catch (error: unknown) { - console.error(`Error fetching pod ${podName}:`, error); - throw new Error(`Failed to fetch pod info: ${getErrorMessage(error)}`); - } + this.ensureInitialized(); + return this.resources.getPodInfo(podName); } async getServiceInfo(serviceName: string) { - if (!this.initialized) { - throw new Error("Kubernetes client not initialized"); - } - - try { - return await this.resources.getServiceInfo(serviceName); - } catch (error: unknown) { - console.error(`Error fetching service ${serviceName}:`, error); - throw new Error( - `Failed to fetch service info: ${getErrorMessage(error)}`, - ); - } + this.ensureInitialized(); + return this.resources.getServiceInfo(serviceName); } async getNodes() { - if (!this.initialized) { - throw new Error("Kubernetes client not initialized"); - } - - try { - return await this.resources.listNodes(); - } catch (error: unknown) { - console.error("Error fetching nodes:", error); - throw new Error(`Failed to fetch nodes: ${getErrorMessage(error)}`); - } + this.ensureInitialized(); + return this.resources.listNodes(); } async getServerConnectionInfo(serviceName: string) { - if (!this.initialized) { - throw new Error("Kubernetes client not initialized"); - } - - try { - const service = await this.getServiceInfo(serviceName); - const nodes = await this.getNodes(); - - if (service.type === "ClusterIP") { - return { - type: "ClusterIP", - ip: service.clusterIP, - port: service.ports[0]?.port || null, - connectionString: - service.clusterIP && service.ports[0]?.port - ? `${service.clusterIP}:${service.ports[0].port}` - : null, - note: "Only accessible within the cluster", - }; - } - - if (service.type === "NodePort") { - const nodeIP = nodes[0]?.externalIP || nodes[0]?.internalIP; - const nodePort = service.ports[0]?.nodePort; - return { - type: "NodePort", - nodeIP, - nodePort, - port: service.ports[0]?.port || null, - connectionString: nodeIP && nodePort ? `${nodeIP}:${nodePort}` : null, - note: - nodeIP && !nodes[0]?.externalIP - ? "Using internal IP (may not be accessible from outside the cluster network)" - : "Accessible from any node in the cluster", - }; - } - - if (service.type === "LoadBalancer") { - const externalIP = - service.loadBalancerIP || service.loadBalancerHostname; - return { - type: "LoadBalancer", - externalIP, - port: service.ports[0]?.port || null, - connectionString: - externalIP && service.ports[0]?.port - ? `${externalIP}:${service.ports[0].port}` - : null, - note: !externalIP ? "LoadBalancer IP pending" : null, - }; - } + this.ensureInitialized(); + const service = await this.getServiceInfo(serviceName); + const nodes = await this.getNodes(); + if (service.type === "ClusterIP") { return { - type: service.type, - note: "Unknown service type", + type: "ClusterIP", + ip: service.clusterIP, + port: service.ports[0]?.port || null, + connectionString: + service.clusterIP && service.ports[0]?.port + ? `${service.clusterIP}:${service.ports[0].port}` + : null, + note: "Only accessible within the cluster", }; - } catch (error: unknown) { - console.error( - `Error fetching connection info for service ${serviceName}:`, - error, - ); - throw new Error( - `Failed to fetch connection info: ${getErrorMessage(error)}`, - ); } + + if (service.type === "NodePort") { + const nodeIP = nodes[0]?.externalIP || nodes[0]?.internalIP; + const nodePort = service.ports[0]?.nodePort; + return { + type: "NodePort", + nodeIP, + nodePort, + port: service.ports[0]?.port || null, + connectionString: nodeIP && nodePort ? `${nodeIP}:${nodePort}` : null, + note: + nodeIP && !nodes[0]?.externalIP + ? "Using internal IP (may not be accessible from outside the cluster network)" + : "Accessible from any node in the cluster", + }; + } + + if (service.type === "LoadBalancer") { + const externalIP = service.loadBalancerIP || service.loadBalancerHostname; + return { + type: "LoadBalancer", + externalIP, + port: service.ports[0]?.port || null, + connectionString: + externalIP && service.ports[0]?.port ? `${externalIP}:${service.ports[0].port}` : null, + note: !externalIP ? "LoadBalancer IP pending" : null, + }; + } + + return { + type: service.type, + note: "Unknown service type", + }; } getKubeConfig(): k8s.KubeConfig { @@ -425,31 +239,35 @@ export class K8sService { getNamespace(): string { return this.namespace; } -} -function getAge(timestamp: Date | string | undefined): string { - if (!timestamp) return "unknown"; - const now = new Date(); - const created = new Date(timestamp); - const diff = now.getTime() - created.getTime(); - - const seconds = Math.floor(diff / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - - if (days > 0) return `${days}d`; - if (hours > 0) return `${hours}h`; - if (minutes > 0) return `${minutes}m`; - return `${seconds}s`; -} - -function getErrorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; + async getPodMetrics(namespace?: string) { + this.ensureInitialized(); + return this.executeOperationSafe( + () => this.podOps.getPodMetrics(this.customObjectsApi, namespace), + { items: [] }, + "Error fetching pod metrics" + ); } - if (typeof error === "string") { - return error; + + async getNodeMetrics() { + this.ensureInitialized(); + return this.executeOperationSafe( + () => this.clusterOps.getNodeMetrics(), + { items: [] }, + "Error fetching node metrics" + ); + } + + private async executeOperationSafe( + operation: () => Promise, + defaultValue: T, + errorContext: string + ): Promise { + try { + return await operation(); + } catch (error: unknown) { + logger.error({ err: error, context: errorContext }, "K8s operation failed"); + return defaultValue; + } } - return "Unknown error"; } diff --git a/apps/backend/src/services/kubernetes/operations/base.operations.ts b/apps/backend/src/services/kubernetes/operations/base.operations.ts new file mode 100644 index 0000000..17bf08d --- /dev/null +++ b/apps/backend/src/services/kubernetes/operations/base.operations.ts @@ -0,0 +1,40 @@ +import { getErrorMessage } from "@minikura/shared/errors"; +import { logger } from "../../../infrastructure/logger"; + +export abstract class BaseK8sOperations { + protected namespace: string; + + constructor(namespace: string) { + this.namespace = namespace; + } + + protected async executeOperation( + operation: () => Promise, + errorContext: string + ): Promise { + try { + return await operation(); + } catch (error: unknown) { + const errorMessage = getErrorMessage(error); + logger.error({ err: error, context: errorContext }, "K8s operation failed"); + throw new Error(`${errorContext}: ${errorMessage}`); + } + } + + protected async executeOperationSafe( + operation: () => Promise, + defaultValue: T, + errorContext: string + ): Promise { + try { + return await operation(); + } catch (error: unknown) { + logger.error({ err: error, context: errorContext }, "K8s operation failed (safe mode)"); + return defaultValue; + } + } + + getNamespace(): string { + return this.namespace; + } +} diff --git a/apps/backend/src/services/kubernetes/operations/cluster.operations.ts b/apps/backend/src/services/kubernetes/operations/cluster.operations.ts new file mode 100644 index 0000000..42ff2cf --- /dev/null +++ b/apps/backend/src/services/kubernetes/operations/cluster.operations.ts @@ -0,0 +1,39 @@ +import type * as k8s from "@kubernetes/client-node"; +import { BaseK8sOperations } from "./base.operations"; + +export class ClusterOperations extends BaseK8sOperations { + constructor( + private coreApi: k8s.CoreV1Api, + private customObjectsApi: k8s.CustomObjectsApi, + namespace: string + ) { + super(namespace); + } + + async listNodes() { + return this.executeOperation( + () => this.coreApi.listNode().then((r) => r.items), + "Failed to fetch nodes" + ); + } + + async getNodeMetrics() { + return this.executeOperation( + () => + this.customObjectsApi.listClusterCustomObject({ + group: "metrics.k8s.io", + version: "v1beta1", + plural: "nodes", + }), + "Failed to fetch node metrics" + ); + } + + async listConfigMaps(namespace?: string) { + const ns = namespace || this.namespace; + return this.executeOperation( + () => this.coreApi.listNamespacedConfigMap({ namespace: ns }).then((r) => r.items), + "Failed to fetch configmaps" + ); + } +} diff --git a/apps/backend/src/services/kubernetes/operations/custom-resource.operations.ts b/apps/backend/src/services/kubernetes/operations/custom-resource.operations.ts new file mode 100644 index 0000000..f1e3158 --- /dev/null +++ b/apps/backend/src/services/kubernetes/operations/custom-resource.operations.ts @@ -0,0 +1,55 @@ +import type * as k8s from "@kubernetes/client-node"; +import type { CustomResourceSummary } from "@minikura/api"; +import { getAge } from "@minikura/shared/errors"; +import { BaseK8sOperations } from "./base.operations"; + +interface CustomResourceItem { + kind?: string; + metadata?: { + name?: string; + namespace?: string; + creationTimestamp?: string; + labels?: Record; + }; + spec?: Record; + status?: { phase?: string; [key: string]: unknown }; +} + +interface CustomResourceList { + items?: CustomResourceItem[]; +} + +export class CustomResourceOperations extends BaseK8sOperations { + constructor( + private customObjectsApi: k8s.CustomObjectsApi, + namespace: string + ) { + super(namespace); + } + + async listCustomResources( + group: string, + version: string, + plural: string + ): Promise { + return this.executeOperation(async () => { + const response = await this.customObjectsApi.listNamespacedCustomObject({ + group, + version, + namespace: this.namespace, + plural, + }); + + const items = (response as unknown as CustomResourceList).items || []; + + return items.map((item) => ({ + name: item.metadata?.name ?? "", + namespace: item.metadata?.namespace ?? this.namespace, + age: getAge(item.metadata?.creationTimestamp), + labels: item.metadata?.labels, + spec: item.spec ?? {}, + status: item.status ?? {}, + })); + }, `Failed to fetch custom resources (${plural})`); + } +} diff --git a/apps/backend/src/services/kubernetes/operations/index.ts b/apps/backend/src/services/kubernetes/operations/index.ts new file mode 100644 index 0000000..e2ca181 --- /dev/null +++ b/apps/backend/src/services/kubernetes/operations/index.ts @@ -0,0 +1,6 @@ +export { BaseK8sOperations } from "./base.operations"; +export { ClusterOperations } from "./cluster.operations"; +export { CustomResourceOperations } from "./custom-resource.operations"; +export { NetworkOperations } from "./network.operations"; +export { PodOperations } from "./pod.operations"; +export { WorkloadOperations } from "./workload.operations"; diff --git a/apps/backend/src/services/kubernetes/operations/network.operations.ts b/apps/backend/src/services/kubernetes/operations/network.operations.ts new file mode 100644 index 0000000..1bb1e92 --- /dev/null +++ b/apps/backend/src/services/kubernetes/operations/network.operations.ts @@ -0,0 +1,89 @@ +import type * as k8s from "@kubernetes/client-node"; +import { BaseK8sOperations } from "./base.operations"; + +export class NetworkOperations extends BaseK8sOperations { + constructor( + private coreApi: k8s.CoreV1Api, + private networkingApi: k8s.NetworkingV1Api, + namespace: string + ) { + super(namespace); + } + + async listServices() { + return this.executeOperation( + () => this.coreApi.listNamespacedService({ namespace: this.namespace }).then((r) => r.items), + "Failed to fetch services" + ); + } + + async listIngresses() { + return this.executeOperation( + () => + this.networkingApi + .listNamespacedIngress({ namespace: this.namespace }) + .then((r) => r.items), + "Failed to fetch ingresses" + ); + } + + async getServiceInfo(serviceName: string) { + return this.executeOperation( + () => this.coreApi.readNamespacedService({ name: serviceName, namespace: this.namespace }), + `Failed to fetch service info for ${serviceName}` + ); + } + + async getServerConnectionInfo(serviceName: string) { + return this.executeOperation(async () => { + const service = await this.coreApi.readNamespacedService({ + name: serviceName, + namespace: this.namespace, + }); + + const serviceType = service.spec?.type || "ClusterIP"; + const ports = service.spec?.ports || []; + + let host: string; + let externalHost: string | undefined; + + switch (serviceType) { + case "LoadBalancer": { + const ingress = service.status?.loadBalancer?.ingress?.[0]; + host = + ingress?.hostname || + ingress?.ip || + `${serviceName}.${this.namespace}.svc.cluster.local`; + externalHost = ingress?.hostname || ingress?.ip; + break; + } + + case "NodePort": + host = `${serviceName}.${this.namespace}.svc.cluster.local`; + externalHost = ""; + break; + + default: + host = `${serviceName}.${this.namespace}.svc.cluster.local`; + externalHost = undefined; + } + + const portMappings = ports.map((port) => ({ + name: port.name, + port: port.port, + targetPort: port.targetPort, + nodePort: port.nodePort, + protocol: port.protocol || "TCP", + })); + + return { + serviceName, + namespace: this.namespace, + serviceType, + internalHost: host, + externalHost, + ports: portMappings, + }; + }, `Failed to fetch connection info for service ${serviceName}`); + } +} diff --git a/apps/backend/src/services/kubernetes/operations/pod.operations.ts b/apps/backend/src/services/kubernetes/operations/pod.operations.ts new file mode 100644 index 0000000..9e79c4b --- /dev/null +++ b/apps/backend/src/services/kubernetes/operations/pod.operations.ts @@ -0,0 +1,72 @@ +import type * as k8s from "@kubernetes/client-node"; +import { BaseK8sOperations } from "./base.operations"; + +export class PodOperations extends BaseK8sOperations { + constructor( + private coreApi: k8s.CoreV1Api, + namespace: string + ) { + super(namespace); + } + + async listPods() { + return this.executeOperation( + () => this.coreApi.listNamespacedPod({ namespace: this.namespace }).then((r) => r.items), + "Failed to fetch pods" + ); + } + + async listPodsByLabel(labelSelector: string) { + return this.executeOperation( + () => + this.coreApi + .listNamespacedPod({ namespace: this.namespace, labelSelector }) + .then((r) => r.items), + "Failed to fetch pods by label" + ); + } + + async getPodInfo(podName: string) { + return this.executeOperation( + () => this.coreApi.readNamespacedPod({ name: podName, namespace: this.namespace }), + `Failed to fetch pod info for ${podName}` + ); + } + + async getPodLogs( + podName: string, + options?: { + container?: string; + tailLines?: number; + timestamps?: boolean; + sinceSeconds?: number; + } + ): Promise { + return this.executeOperation( + () => + this.coreApi.readNamespacedPodLog({ + name: podName, + namespace: this.namespace, + container: options?.container, + tailLines: options?.tailLines, + timestamps: options?.timestamps, + sinceSeconds: options?.sinceSeconds, + }), + `Failed to fetch logs for pod ${podName}` + ); + } + + async getPodMetrics(customObjectsApi: k8s.CustomObjectsApi, namespace?: string) { + const ns = namespace || this.namespace; + return this.executeOperation( + () => + customObjectsApi.listNamespacedCustomObject({ + group: "metrics.k8s.io", + version: "v1beta1", + namespace: ns, + plural: "pods", + }), + "Failed to fetch pod metrics" + ); + } +} diff --git a/apps/backend/src/services/kubernetes/operations/workload.operations.ts b/apps/backend/src/services/kubernetes/operations/workload.operations.ts new file mode 100644 index 0000000..a424914 --- /dev/null +++ b/apps/backend/src/services/kubernetes/operations/workload.operations.ts @@ -0,0 +1,27 @@ +import type * as k8s from "@kubernetes/client-node"; +import { BaseK8sOperations } from "./base.operations"; + +export class WorkloadOperations extends BaseK8sOperations { + constructor( + private appsApi: k8s.AppsV1Api, + namespace: string + ) { + super(namespace); + } + + async listDeployments() { + return this.executeOperation( + () => + this.appsApi.listNamespacedDeployment({ namespace: this.namespace }).then((r) => r.items), + "Failed to fetch deployments" + ); + } + + async listStatefulSets() { + return this.executeOperation( + () => + this.appsApi.listNamespacedStatefulSet({ namespace: this.namespace }).then((r) => r.items), + "Failed to fetch statefulsets" + ); + } +} diff --git a/apps/backend/src/services/k8s/resources.ts b/apps/backend/src/services/kubernetes/resources.ts similarity index 93% rename from apps/backend/src/services/k8s/resources.ts rename to apps/backend/src/services/kubernetes/resources.ts index 352fcbb..080f9e3 100644 --- a/apps/backend/src/services/k8s/resources.ts +++ b/apps/backend/src/services/kubernetes/resources.ts @@ -11,6 +11,7 @@ import type { PodInfo, StatefulSetInfo, } from "@minikura/api"; +import { getAge } from "@minikura/shared/errors"; export class K8sResources { constructor( @@ -216,7 +217,9 @@ export class K8sResources { const internalIP = addresses.find((address) => address.type === "InternalIP")?.address; const externalIP = addresses.find((address) => address.type === "ExternalIP")?.address; const hostname = addresses.find((address) => address.type === "Hostname")?.address; - const readyCondition = node.status?.conditions?.find((condition) => condition.type === "Ready"); + const readyCondition = node.status?.conditions?.find( + (condition) => condition.type === "Ready" + ); return { name: node.metadata?.name, @@ -252,20 +255,3 @@ function mapPodInfo(pod: k8s.V1Pod): PodInfo { nodeName: pod.spec?.nodeName, }; } - -function getAge(timestamp: Date | string | undefined): string { - if (!timestamp) return "unknown"; - const now = new Date(); - const created = new Date(timestamp); - const diff = now.getTime() - created.getTime(); - - const seconds = Math.floor(diff / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - - if (days > 0) return `${days}d`; - if (hours > 0) return `${hours}h`; - if (minutes > 0) return `${minutes}m`; - return `${seconds}s`; -} diff --git a/apps/backend/src/services/websocket.ts b/apps/backend/src/services/websocket.ts index 87e60ba..2ab8df2 100644 --- a/apps/backend/src/services/websocket.ts +++ b/apps/backend/src/services/websocket.ts @@ -1,3 +1,5 @@ +import { logger } from "../infrastructure/logger"; + export type WebSocketClient = { send: (message: string) => void; }; @@ -14,14 +16,12 @@ export class WebSocketService implements IWebSocketService { addClient(client: WebSocketClient): void { this.clients.add(client); - console.log(`[WebSocket] Client connected (total: ${this.clients.size})`); + logger.debug({ totalClients: this.clients.size }, "WebSocket client connected"); } removeClient(client: WebSocketClient): void { this.clients.delete(client); - console.log( - `[WebSocket] Client disconnected (total: ${this.clients.size})`, - ); + logger.debug({ totalClients: this.clients.size }, "WebSocket client disconnected"); } broadcast(action: string, serverType: string, serverId: string): void { @@ -33,6 +33,7 @@ export class WebSocketService implements IWebSocketService { timestamp: new Date().toISOString(), }); + // Send to all connected clients, removing any that fail let failedClients = 0; this.clients.forEach((client) => { try { @@ -44,7 +45,7 @@ export class WebSocketService implements IWebSocketService { }); if (failedClients > 0) { - console.log(`[WebSocket] Removed ${failedClients} failed clients`); + logger.warn({ failedClients }, "Removed failed WebSocket clients"); } } diff --git a/apps/web/app/bootstrap/page.tsx b/apps/web/app/bootstrap/page.tsx index d3827d3..5aedee2 100644 --- a/apps/web/app/bootstrap/page.tsx +++ b/apps/web/app/bootstrap/page.tsx @@ -4,16 +4,10 @@ import { Loader2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { api } from "@/lib/api"; +import { api } from "@/lib/api-client"; export default function BootstrapPage() { const router = useRouter(); @@ -29,8 +23,7 @@ export default function BootstrapPage() { if (data && !data.needsSetup) { router.replace("/login"); } - } catch (err) { - console.error("Failed to check bootstrap status:", err); + } catch (_err) { } finally { setCheckingStatus(false); } @@ -77,7 +70,7 @@ export default function BootstrapPage() { } else { setError("Failed to create admin user"); } - } catch (err) { + } catch (_err) { setError("Failed to connect to server"); } finally { setLoading(false); @@ -96,9 +89,7 @@ export default function BootstrapPage() {
- - Welcome to Minikura - + Welcome to Minikura Create your admin account to get started @@ -107,13 +98,7 @@ export default function BootstrapPage() {
- +
@@ -147,9 +132,7 @@ export default function BootstrapPage() { required />
- {error && ( -
{error}
- )} + {error &&
{error}
} diff --git a/apps/web/app/dashboard/k8s/page.tsx b/apps/web/app/dashboard/k8s/page.tsx index 865e53e..600e7a8 100644 --- a/apps/web/app/dashboard/k8s/page.tsx +++ b/apps/web/app/dashboard/k8s/page.tsx @@ -1,6 +1,5 @@ "use client"; -import { AlertCircle, CheckCircle2, XCircle } from "lucide-react"; import type { CustomResourceSummary, DeploymentInfo, @@ -10,6 +9,7 @@ import type { PodInfo, StatefulSetInfo, } from "@minikura/api"; +import { AlertCircle, CheckCircle2, XCircle } from "lucide-react"; import { useEffect, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; @@ -23,7 +23,7 @@ import { TableRow, } from "@/components/ui/table"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { api } from "@/lib/api"; +import { api } from "@/lib/api-client"; export default function K8sResourcesPage() { const [status, setStatus] = useState(null); @@ -64,50 +64,34 @@ export default function K8sResourcesPage() { if (statusRes.status === "fulfilled" && statusRes.value.data) { setStatus(statusRes.value.data as K8sStatus); - } else if (statusRes.status === "rejected") { - console.error("Failed to fetch status:", statusRes.reason); } if (podsRes.status === "fulfilled" && podsRes.value.data) { setPods(podsRes.value.data as PodInfo[]); - } else if (podsRes.status === "rejected") { - console.error("Failed to fetch pods:", podsRes.reason); } if (deploymentsRes.status === "fulfilled" && deploymentsRes.value.data) { setDeployments(deploymentsRes.value.data as DeploymentInfo[]); - } else if (deploymentsRes.status === "rejected") { - console.error("Failed to fetch deployments:", deploymentsRes.reason); } if (statefulSetsRes.status === "fulfilled" && statefulSetsRes.value.data) { setStatefulSets(statefulSetsRes.value.data as StatefulSetInfo[]); - } else if (statefulSetsRes.status === "rejected") { - console.error("Failed to fetch statefulsets:", statefulSetsRes.reason); } if (servicesRes.status === "fulfilled" && servicesRes.value.data) { setServices(servicesRes.value.data as K8sServiceSummary[]); - } else if (servicesRes.status === "rejected") { - console.error("Failed to fetch services:", servicesRes.reason); } if (configMapsRes.status === "fulfilled" && configMapsRes.value.data) { setConfigMaps(configMapsRes.value.data as K8sConfigMapSummary[]); - } else if (configMapsRes.status === "rejected") { - console.error("Failed to fetch configmaps:", configMapsRes.reason); } if (minecraftServersRes.status === "fulfilled" && minecraftServersRes.value.data) { setMinecraftServers(minecraftServersRes.value.data as CustomResourceSummary[]); - } else if (minecraftServersRes.status === "rejected") { - console.error("Failed to fetch minecraft servers:", minecraftServersRes.reason); } if (reverseProxyServersRes.status === "fulfilled" && reverseProxyServersRes.value.data) { setReverseProxyServers(reverseProxyServersRes.value.data as CustomResourceSummary[]); - } else if (reverseProxyServersRes.status === "rejected") { - console.error("Failed to fetch reverse proxy servers:", reverseProxyServersRes.reason); } } catch (err: unknown) { const errorMessage = diff --git a/apps/web/app/dashboard/servers/create/page.tsx b/apps/web/app/dashboard/servers/create/page.tsx index 3c2e370..f6c48e6 100644 --- a/apps/web/app/dashboard/servers/create/page.tsx +++ b/apps/web/app/dashboard/servers/create/page.tsx @@ -6,7 +6,15 @@ import { useRouter } from "next/navigation"; import { ServerForm, type ServerFormData } from "@/components/server-form"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { api } from "@/lib/api"; +import { api } from "@/lib/api-client"; + +const toDifficultyUppercase = (value: string): "PEACEFUL" | "EASY" | "NORMAL" | "HARD" => { + return value.toUpperCase() as "PEACEFUL" | "EASY" | "NORMAL" | "HARD"; +}; + +const toModeUppercase = (value: string): "SURVIVAL" | "CREATIVE" | "ADVENTURE" | "SPECTATOR" => { + return value.toUpperCase() as "SURVIVAL" | "CREATIVE" | "ADVENTURE" | "SPECTATOR"; +}; export default function CreateServerPage() { const router = useRouter(); @@ -121,8 +129,8 @@ export default function CreateServerPage() { jvm_opts: data.jvmOpts || undefined, use_aikar_flags: data.useAikarFlags || undefined, use_meowice_flags: data.useMeowiceFlags || undefined, - difficulty: data.difficulty, - game_mode: data.mode, + difficulty: toDifficultyUppercase(data.difficulty), + game_mode: toModeUppercase(data.mode), max_players: data.maxPlayers ? Number(data.maxPlayers) : undefined, pvp: data.pvp, online_mode: data.onlineMode, diff --git a/apps/web/app/dashboard/servers/edit/[id]/page.tsx b/apps/web/app/dashboard/servers/edit/[id]/page.tsx index 20ca62e..8ce21a3 100644 --- a/apps/web/app/dashboard/servers/edit/[id]/page.tsx +++ b/apps/web/app/dashboard/servers/edit/[id]/page.tsx @@ -7,7 +7,7 @@ import { useEffect, useState } from "react"; import { ServerForm, type ServerFormData, type ServerType } from "@/components/server-form"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { api } from "@/lib/api"; +import { api } from "@/lib/api-client"; import { getReverseProxyApi } from "@/lib/api-helpers"; export default function EditServerPage() { @@ -48,8 +48,7 @@ export default function EditServerPage() { setError("Server not found"); setLoading(false); - } catch (err) { - console.error("Failed to fetch server:", err); + } catch (_err) { setError("Failed to load server data"); setLoading(false); } @@ -81,11 +80,12 @@ export default function EditServerPage() { return "survival"; }; - const toServerType = (value?: string | null): ServerType => { - if (value === "VANILLA" || value === "CUSTOM") { - return value; - } - return "PAPER"; + const toDifficultyUppercase = (value: string): "PEACEFUL" | "EASY" | "NORMAL" | "HARD" => { + return value.toUpperCase() as "PEACEFUL" | "EASY" | "NORMAL" | "HARD"; + }; + + const toModeUppercase = (value: string): "SURVIVAL" | "CREATIVE" | "ADVENTURE" | "SPECTATOR" => { + return value.toUpperCase() as "SURVIVAL" | "CREATIVE" | "ADVENTURE" | "SPECTATOR"; }; const parseEnvVariables = (envVars?: Array<{ key: string; value: string }>) => { @@ -205,8 +205,8 @@ export default function EditServerPage() { jvm_opts: data.jvmOpts || undefined, use_aikar_flags: data.useAikarFlags || undefined, use_meowice_flags: data.useMeowiceFlags || undefined, - difficulty: data.difficulty, - game_mode: data.mode, + difficulty: toDifficultyUppercase(data.difficulty), + game_mode: toModeUppercase(data.mode), max_players: data.maxPlayers ? Number(data.maxPlayers) : undefined, pvp: data.pvp, online_mode: data.onlineMode, diff --git a/apps/web/app/dashboard/servers/page.tsx b/apps/web/app/dashboard/servers/page.tsx index fd3f11c..679ea34 100644 --- a/apps/web/app/dashboard/servers/page.tsx +++ b/apps/web/app/dashboard/servers/page.tsx @@ -1,5 +1,6 @@ "use client"; +import type { ConnectionInfo, NormalServer, PodInfo, ReverseProxyServer } from "@minikura/api"; import { AlertCircle, Check, @@ -25,15 +26,6 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { Table, TableBody, @@ -42,14 +34,10 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Textarea } from "@/components/ui/textarea"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import type { ConnectionInfo, NormalServer, PodInfo, ReverseProxyServer } from "@minikura/api"; +import { api } from "@/lib/api-client"; import { getReverseProxyApi } from "@/lib/api-helpers"; -import { api } from "@/lib/api"; -// Server status component function ServerStatusCell({ serverId, type }: { serverId: string; type: "normal" | "proxy" }) { const [pods, setPods] = useState([]); const [loading, setLoading] = useState(true); @@ -59,14 +47,13 @@ function ServerStatusCell({ serverId, type }: { serverId: string; type: "normal" try { const endpoint = type === "normal" - ? api.api.k8s["servers"]({ serverId }).pods.get + ? api.api.k8s.servers({ serverId }).pods.get : api.api.k8s["reverse-proxy"]({ serverId }).pods.get; const res = await endpoint(); if (res.data) { setPods(res.data as PodInfo[]); } - } catch (error) { - console.error("Failed to fetch pods:", error); + } catch (_error) { } finally { setLoading(false); } @@ -93,9 +80,7 @@ function ServerStatusCell({ serverId, type }: { serverId: string; type: "normal" } const allRunning = pods.every((pod) => pod.status === "Running"); - const readyCount = pods.filter( - (pod) => pod.ready === "1/1" || pod.ready === "1/1" || pod.ready === "1/1" - ).length; + const readyCount = pods.filter((pod) => pod.ready === "1/1").length; return (
@@ -111,7 +96,6 @@ function ServerStatusCell({ serverId, type }: { serverId: string; type: "normal" ); } -// Connection info component function ConnectionInfoCell({ serverId, type }: { serverId: string; type: "normal" | "proxy" }) { const [connectionInfo, setConnectionInfo] = useState(null); const [loading, setLoading] = useState(true); @@ -129,8 +113,7 @@ function ConnectionInfoCell({ serverId, type }: { serverId: string; type: "norma if (res.data) { setConnectionInfo(res.data as ConnectionInfo); } - } catch (error) { - console.error("Failed to fetch connection info:", error); + } catch (_error) { } finally { setLoading(false); } @@ -213,8 +196,7 @@ export default function ServersPage() { if (proxyRes.data) { setReverseProxies(proxyRes.data as unknown as ReverseProxyServer[]); } - } catch (error) { - console.error("Failed to fetch servers:", error); + } catch (_error) { } finally { setLoading(false); } @@ -236,9 +218,7 @@ export default function ServersPage() { } await fetchServers(); setDeleteTarget(null); - } catch (error) { - console.error("Failed to delete server:", error); - } + } catch (_error) {} }; if (loading) { @@ -270,7 +250,6 @@ export default function ServersPage() {
- {/* Normal Servers */}
@@ -360,7 +339,6 @@ export default function ServersPage() { - {/* Reverse Proxy Servers */}
@@ -448,7 +426,6 @@ export default function ServersPage() { - {/* Delete Confirmation Dialog */} setDeleteTarget(null)}> diff --git a/apps/web/app/dashboard/topology/page.tsx b/apps/web/app/dashboard/topology/page.tsx new file mode 100644 index 0000000..7338cbb --- /dev/null +++ b/apps/web/app/dashboard/topology/page.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { Network, RefreshCw } from "lucide-react"; +import { TopologyCanvas } from "@/components/topology/topology-canvas"; +import { useTopologyData } from "@/hooks/use-topology-data"; + +export default function TopologyPage() { + const { graph, loading, error } = useTopologyData(); + + if (loading) { + return ( +
+
+

Network Topology

+

+ Real-time server infrastructure, proxy connections, and Kubernetes nodes +

+
+
+
+ +

Loading topology...

+
+
+
+ ); + } + + if (error) { + return ( +
+
+

Network Topology

+

+ Real-time server infrastructure, proxy connections, and Kubernetes nodes +

+
+
+
+

Error loading topology

+

{error}

+

Auto-retrying...

+
+
+
+ ); + } + + if (!graph || graph.nodes.length === 0) { + return ( +
+
+

Network Topology

+

+ Real-time server infrastructure, proxy connections, and Kubernetes nodes +

+
+
+
+

No servers or infrastructure found

+

+ Create a server to see it appear in the topology +

+
+
+
+ ); + } + + return ( +
+
+
+
+ +
+
+

Network Topology

+

+ Real-time server infrastructure, proxy connections, and Kubernetes nodes +

+
+
+
+ + +
+ ); +} diff --git a/apps/web/app/dashboard/users/page.tsx b/apps/web/app/dashboard/users/page.tsx index 76f0dea..96ace06 100644 --- a/apps/web/app/dashboard/users/page.tsx +++ b/apps/web/app/dashboard/users/page.tsx @@ -2,7 +2,6 @@ import { Ban, CheckCircle, Edit, Trash2 } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; -import { getUserApi } from "@/lib/api-helpers"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; @@ -31,7 +30,8 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { api } from "@/lib/api"; +import { api } from "@/lib/api-client"; +import { getUserApi } from "@/lib/api-helpers"; import { useSession } from "@/lib/auth-client"; type User = { @@ -59,8 +59,7 @@ export default function UsersPage() { if (data && typeof data === "object" && "users" in data) { setUsers(data.users as User[]); } - } catch (error) { - console.error("Failed to fetch users:", error); + } catch (_error) { } finally { setLoading(false); } @@ -88,9 +87,7 @@ export default function UsersPage() { await fetchUsers(); setEditingUser(null); } - } catch (error) { - console.error("Failed to update user:", error); - } + } catch (_error) {} }; const handleSuspend = async (e: React.FormEvent) => { @@ -110,9 +107,7 @@ export default function UsersPage() { await fetchUsers(); setSuspendingUser(null); } - } catch (error) { - console.error("Failed to suspend user:", error); - } + } catch (_error) {} }; const handleUnsuspend = async (userId: string) => { @@ -125,9 +120,7 @@ export default function UsersPage() { if (!error) { await fetchUsers(); } - } catch (error) { - console.error("Failed to unsuspend user:", error); - } + } catch (_error) {} }; const handleDelete = async () => { @@ -140,9 +133,7 @@ export default function UsersPage() { await fetchUsers(); setDeleteUser(null); } - } catch (error) { - console.error("Failed to delete user:", error); - } + } catch (_error) {} }; const isUserSuspended = (user: User): boolean => { @@ -252,7 +243,6 @@ export default function UsersPage() { - {/* Edit Dialog */} setEditingUser(null)}> @@ -288,7 +278,6 @@ export default function UsersPage() { - {/* Suspend Dialog */} setSuspendingUser(null)}> @@ -324,7 +313,6 @@ export default function UsersPage() { - {/* Delete Dialog */} setDeleteUser(null)}> diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx index edd936f..036a694 100644 --- a/apps/web/app/login/page.tsx +++ b/apps/web/app/login/page.tsx @@ -41,7 +41,7 @@ export default function LoginPage() { } else { router.push("/dashboard"); } - } catch (err) { + } catch (_err) { setError("Failed to connect to server"); } finally { setLoading(false); diff --git a/apps/web/components/dashboard-layout.tsx b/apps/web/components/dashboard-layout.tsx index 1642f50..7ba3250 100644 --- a/apps/web/components/dashboard-layout.tsx +++ b/apps/web/components/dashboard-layout.tsx @@ -1,13 +1,6 @@ "use client"; -import { - Loader2, - LogOut, - Network, - Server, - Settings, - Users, -} from "lucide-react"; +import { GitGraph, Loader2, LogOut, Network, Server, Settings, Users } from "lucide-react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useEffect } from "react"; @@ -39,11 +32,10 @@ import { signOut, useSession } from "@/lib/auth-client"; const menuItems = [ { href: "/dashboard/users", icon: Users, label: "Users" }, { href: "/dashboard/servers", icon: Server, label: "Servers" }, + { href: "/dashboard/topology", icon: GitGraph, label: "Network" }, ]; -const k8sMenuItems = [ - { href: "/dashboard/k8s", icon: Network, label: "Resources" }, -]; +const k8sMenuItems = [{ href: "/dashboard/k8s", icon: Network, label: "Resources" }]; export function DashboardLayout({ children }: { children: React.ReactNode }) { const pathname = usePathname(); @@ -96,10 +88,7 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) { {menuItems.map((item) => ( - + {item.label} @@ -116,10 +105,7 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) { {k8sMenuItems.map((item) => ( - + {item.label} @@ -134,18 +120,13 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
- diff --git a/apps/web/components/terminal.tsx b/apps/web/components/terminal.tsx index f315a7d..61eff43 100644 --- a/apps/web/components/terminal.tsx +++ b/apps/web/components/terminal.tsx @@ -50,8 +50,7 @@ export function Terminal({ const term = new XTerm({ cursorBlink: true, fontSize: 14, - fontFamily: - 'JetBrains Mono, Fira Code, Menlo, Monaco, "Courier New", monospace', + fontFamily: 'JetBrains Mono, Fira Code, Menlo, Monaco, "Courier New", monospace', fontWeight: "normal", fontWeightBold: "bold", letterSpacing: 0, @@ -106,9 +105,7 @@ export function Terminal({ try { const ligaturesAddon = new LigaturesAddon(); term.loadAddon(ligaturesAddon); - } catch (e) { - console.warn("Ligatures addon not available:", e); - } + } catch (_e) {} fitAddon.fit(); @@ -120,10 +117,7 @@ export function Terminal({ try { const webglAddon = new WebglAddon(); term.loadAddon(webglAddon); - console.log("WebGL renderer loaded successfully"); - } catch (e) { - console.warn("WebGL renderer not available, using canvas fallback:", e); - } + } catch (_e) {} }, 100); term.attachCustomKeyEventHandler((event) => { @@ -141,10 +135,9 @@ export function Terminal({ wsRef.current = ws; ws.onopen = () => { - console.log("WebSocket connected"); setConnected(true); term.writeln( - `\r\n\x1b[1;32mConnecting to ${mode === "attach" ? "container" : "shell"}...\x1b[0m\r\n`, + `\r\n\x1b[1;32mConnecting to ${mode === "attach" ? "container" : "shell"}...\x1b[0m\r\n` ); const { cols, rows } = term; @@ -166,20 +159,16 @@ export function Terminal({ term.writeln(`\r\n\x1b[1;33m${message.data}\x1b[0m\r\n`); setConnected(false); } - } catch (err) { - console.error("Error parsing WebSocket message:", err); - } + } catch (_err) {} }; - ws.onerror = (err) => { - console.error("WebSocket error:", err); + ws.onerror = (_err) => { term.writeln("\r\n\x1b[1;31mWebSocket error\x1b[0m\r\n"); setError("Connection error"); setConnected(false); }; ws.onclose = () => { - console.log("WebSocket closed"); term.writeln("\r\n\x1b[1;33mConnection closed\x1b[0m\r\n"); setConnected(false); }; @@ -220,9 +209,7 @@ export function Terminal({
)} {error && ( -
- {error} -
+
{error}
)} + + +
+
+

Show/Hide

+
+
+ + toggleFilter("showServers")} + /> +
+
+ + toggleFilter("showProxies")} + /> +
+
+ + toggleFilter("showK8sNodes")} + /> +
+
+ + toggleFilter("showConnections")} + /> +
+
+
+
+
+ +
+ ); +} diff --git a/apps/web/components/topology/layouts/hierarchical-layout.ts b/apps/web/components/topology/layouts/hierarchical-layout.ts new file mode 100644 index 0000000..d83fe25 --- /dev/null +++ b/apps/web/components/topology/layouts/hierarchical-layout.ts @@ -0,0 +1,83 @@ +import type { TopologyEdge, TopologyNode } from "@/lib/topology-types"; + +const LAYOUT_CONFIG = { + TIER_SPACING: 300, // Vertical spacing between proxy and server tiers + NODE_SPACING: 250, // Horizontal spacing between nodes + START_X: 150, // Left padding + START_Y: 100, // Top padding +} as const; + +export function applyHierarchicalLayout( + nodes: TopologyNode[], + edges: TopologyEdge[] +): { nodes: TopologyNode[]; edges: TopologyEdge[] } { + const proxyNodes = nodes.filter((n) => n.data.type === "proxy"); + const serverNodes = nodes.filter((n) => n.data.type === "server"); + + const layoutedNodes: TopologyNode[] = []; + + const maxNodesInTier = Math.max(proxyNodes.length, serverNodes.length); + const tierWidth = maxNodesInTier * LAYOUT_CONFIG.NODE_SPACING; + + const proxyOffsetX = (tierWidth - proxyNodes.length * LAYOUT_CONFIG.NODE_SPACING) / 2; + proxyNodes.forEach((node, index) => { + const x = LAYOUT_CONFIG.START_X + proxyOffsetX + index * LAYOUT_CONFIG.NODE_SPACING; + const y = LAYOUT_CONFIG.START_Y; + + layoutedNodes.push({ + ...node, + position: { x, y }, + }); + }); + + const serverOffsetX = (tierWidth - serverNodes.length * LAYOUT_CONFIG.NODE_SPACING) / 2; + serverNodes.forEach((node, index) => { + const x = LAYOUT_CONFIG.START_X + serverOffsetX + index * LAYOUT_CONFIG.NODE_SPACING; + const y = LAYOUT_CONFIG.START_Y + LAYOUT_CONFIG.TIER_SPACING; + + layoutedNodes.push({ + ...node, + position: { x, y }, + }); + }); + + return { + nodes: layoutedNodes, + edges, + }; +} + +export function applyGridLayout( + nodes: TopologyNode[], + edges: TopologyEdge[] +): { nodes: TopologyNode[]; edges: TopologyEdge[] } { + const columns = Math.ceil(Math.sqrt(nodes.length)); + + const layoutedNodes = nodes.map((node, index) => ({ + ...node, + position: { + x: LAYOUT_CONFIG.START_X + (index % columns) * LAYOUT_CONFIG.NODE_SPACING, + y: LAYOUT_CONFIG.START_Y + Math.floor(index / columns) * LAYOUT_CONFIG.TIER_SPACING, + }, + })); + + return { + nodes: layoutedNodes, + edges, + }; +} + +export function layoutTopologyGraph( + nodes: TopologyNode[], + edges: TopologyEdge[] +): { nodes: TopologyNode[]; edges: TopologyEdge[] } { + if (nodes.length === 0) { + return { nodes: [], edges: [] }; + } + + if (nodes.length < 20) { + return applyHierarchicalLayout(nodes, edges); + } + + return applyGridLayout(nodes, edges); +} diff --git a/apps/web/components/topology/nodes/k8s-node.tsx b/apps/web/components/topology/nodes/k8s-node.tsx new file mode 100644 index 0000000..0b58ffe --- /dev/null +++ b/apps/web/components/topology/nodes/k8s-node.tsx @@ -0,0 +1,142 @@ +import type { NodeProps } from "@xyflow/react"; +import { Handle, Position } from "@xyflow/react"; +import { Box, Cpu, HardDrive, Network, Server as ServerIcon } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/cn"; +import type { K8sNodeMetadata, TopologyNodeData } from "@/lib/topology-types"; + +export function K8sNodeComponent({ data, selected }: NodeProps) { + const nodeData = data as TopologyNodeData; + const metadata = nodeData.metadata as K8sNodeMetadata; + const { node, podCount, serverPods, proxyPods, health, metrics } = metadata; + + const getStatusBadge = () => { + switch (health) { + case "healthy": + return Healthy; + case "degraded": + return Degraded; + case "unhealthy": + return Unhealthy; + default: + return ( + + Unknown + + ); + } + }; + + return ( +
+ + +
+
+
+
+ +
+
+

{node.name || "Unknown"}

+

Kubernetes Node

+
+
+ {getStatusBadge()} +
+ +
+ + {node.status} + + {node.version && ( + + {node.version} + + )} + + {node.roles} + +
+ +
+ Total Pods + {podCount} +
+ +
+
+ + Server Pods + + {serverPods.length} +
+
+ + Proxy Pods + + {proxyPods.length} +
+
+ + {metrics && (metrics.cpuUsage || metrics.memoryUsage) && ( +
+ {metrics.cpuUsage && ( +
+ + CPU + + {metrics.cpuUsage} +
+ )} + {metrics.memoryUsage && ( +
+ + Memory + + {metrics.memoryUsage} +
+ )} +
+ )} + +
+
+ Age + {node.age} +
+ {node.hostname && ( +
+ Hostname + {node.hostname} +
+ )} +
+ + {(node.internalIP || node.externalIP) && ( +
+ {node.internalIP && ( +
+ Internal IP + {node.internalIP} +
+ )} + {node.externalIP && ( +
+ External IP + {node.externalIP} +
+ )} +
+ )} +
+ + +
+ ); +} diff --git a/apps/web/components/topology/nodes/node-types.ts b/apps/web/components/topology/nodes/node-types.ts new file mode 100644 index 0000000..05bc425 --- /dev/null +++ b/apps/web/components/topology/nodes/node-types.ts @@ -0,0 +1,9 @@ +import { K8sNodeComponent } from "./k8s-node"; +import { ProxyNode } from "./proxy-node"; +import { ServerNode } from "./server-node"; + +export const nodeTypes = { + server: ServerNode, + proxy: ProxyNode, + "k8s-node": K8sNodeComponent, +}; diff --git a/apps/web/components/topology/nodes/proxy-node.tsx b/apps/web/components/topology/nodes/proxy-node.tsx new file mode 100644 index 0000000..869d752 --- /dev/null +++ b/apps/web/components/topology/nodes/proxy-node.tsx @@ -0,0 +1,206 @@ +import type { NodeProps } from "@xyflow/react"; +import { Handle, Position } from "@xyflow/react"; +import { Box, Check, Copy, Cpu, Globe, HardDrive, Network } from "lucide-react"; +import { useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/cn"; +import type { ProxyMetadata, TopologyNodeData } from "@/lib/topology-types"; + +export function ProxyNode({ data, selected }: NodeProps) { + const nodeData = data as TopologyNodeData | TopologyNodeData; + const metadata = nodeData.metadata as ProxyMetadata | ProxyMetadata; + const { proxy, readyPods, podCount, health, connectedServers } = metadata; + + const k8sNodes = "k8sNodes" in metadata ? metadata.k8sNodes : []; + const connectionInfo = "connectionInfo" in metadata ? metadata.connectionInfo : null; + const metrics = "metrics" in metadata ? metadata.metrics : undefined; + + const [copied, setCopied] = useState(false); + + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation(); + if (connectionInfo?.connectionString) { + await navigator.clipboard.writeText(connectionInfo.connectionString); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + const getStatusBadge = () => { + switch (health) { + case "healthy": + return Healthy; + case "degraded": + return Degraded; + case "unhealthy": + return Unhealthy; + default: + return ( + + Unknown + + ); + } + }; + + return ( +
+ + +
+
+
+
+ +
+
+

{proxy.id}

+ {proxy.description && ( +

{proxy.description}

+ )} +
+
+ {getStatusBadge()} +
+ +
+ + {proxy.type} + + {connectionInfo && ( + + {connectionInfo.type} + + )} +
+ +
+ Pods + + {readyPods}/{podCount} + +
+ + {proxy.node_port && ( +
+
+ + NodePort + + {proxy.node_port} +
+
+ )} + + {connectionInfo?.connectionString && ( +
+
+ Address +
+
+ + {connectionInfo.connectionString} + + +
+ {connectionInfo.note && ( +

{connectionInfo.note}

+ )} +
+ )} + +
+
+ + Memory + + + {metrics?.memoryUsage && ( + {metrics.memoryUsage} / + )} + {proxy.memory}MB + +
+
+ + CPU + + + {metrics?.cpuUsage && {metrics.cpuUsage} / } + + {proxy.cpu_request || "N/A"}/{proxy.cpu_limit || "N/A"} + + +
+
+ + {"pods" in metadata && metadata.pods && metadata.pods.length > 0 && ( +
+
+ Restarts + sum + (p.restarts || 0), 0) > 0 + ? "text-yellow-600" + : "text-green-600" + )} + > + {metadata.pods.reduce((sum, p) => sum + (p.restarts || 0), 0)} + +
+ {metadata.pods[0]?.age && ( +
+ Age + {metadata.pods[0].age} +
+ )} + {metadata.pods[0]?.ip && ( +
+ Pod IP + {metadata.pods[0].ip} +
+ )} +
+ Routing To + {connectedServers.length} servers +
+ {k8sNodes.length > 0 && ( +
+ + K8s Node + + {k8sNodes[0]} +
+ )} +
+ )} +
+ + +
+ ); +} diff --git a/apps/web/components/topology/nodes/server-node.tsx b/apps/web/components/topology/nodes/server-node.tsx new file mode 100644 index 0000000..247a460 --- /dev/null +++ b/apps/web/components/topology/nodes/server-node.tsx @@ -0,0 +1,210 @@ +import type { NodeProps } from "@xyflow/react"; +import { Handle, Position } from "@xyflow/react"; +import { Box, Check, Copy, Cpu, HardDrive, Server } from "lucide-react"; +import { useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/cn"; +import type { ServerMetadata, TopologyNodeData } from "@/lib/topology-types"; + +export function ServerNode({ data, selected }: NodeProps) { + const nodeData = data as TopologyNodeData | TopologyNodeData; + const metadata = nodeData.metadata as ServerMetadata | ServerMetadata; + const { server, readyPods, podCount, health } = metadata; + + const k8sNodes = "k8sNodes" in metadata ? metadata.k8sNodes : []; + const connectedProxies = "connectedProxies" in metadata ? metadata.connectedProxies : []; + const connectionInfo = "connectionInfo" in metadata ? metadata.connectionInfo : null; + const metrics = "metrics" in metadata ? metadata.metrics : undefined; + + const [copied, setCopied] = useState(false); + + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation(); + if (connectionInfo?.connectionString) { + await navigator.clipboard.writeText(connectionInfo.connectionString); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + const getStatusBadge = () => { + switch (health) { + case "healthy": + return Healthy; + case "degraded": + return Degraded; + case "unhealthy": + return Unhealthy; + default: + return ( + + Unknown + + ); + } + }; + + return ( +
+ + +
+
+
+
+ +
+
+

{server.id}

+ {server.description && ( +

{server.description}

+ )} +
+
+ {getStatusBadge()} +
+ +
+ + {server.jar_type} + + + MC {server.minecraft_version} + + + {server.type} + + {connectionInfo && ( + + {connectionInfo.type} + + )} +
+ +
+ Pods + + {readyPods}/{podCount} + +
+ +
+
+ + Memory + + + {metrics?.memoryUsage && ( + {metrics.memoryUsage} / + )} + + {server.memory_request}/{server.memory}MB + + +
+
+ + CPU + + + {metrics?.cpuUsage && {metrics.cpuUsage} / } + + {server.cpu_request || "N/A"}/{server.cpu_limit || "N/A"} + + +
+
+ + {connectionInfo?.connectionString && ( +
+
+ + {connectionInfo.connectionString} + + +
+ {connectionInfo.note && ( +

{connectionInfo.note}

+ )} +
+ )} + + {"pods" in metadata && metadata.pods && metadata.pods.length > 0 && ( +
+
+ Restarts + sum + (p.restarts || 0), 0) > 0 + ? "text-yellow-600" + : "text-green-600" + )} + > + {metadata.pods.reduce((sum, p) => sum + (p.restarts || 0), 0)} + +
+ {metadata.pods[0]?.age && ( +
+ Age + {metadata.pods[0].age} +
+ )} + {metadata.pods[0]?.ip && ( +
+ Pod IP + {metadata.pods[0].ip} +
+ )} +
+ )} + + {(k8sNodes.length > 0 || connectedProxies.length > 0) && ( +
+ {connectedProxies.length > 0 && ( +
+ Exposed By + + {connectedProxies.length} proxies + +
+ )} + {k8sNodes.length > 0 && ( +
+ + K8s Node + + {k8sNodes[0]} +
+ )} +
+ )} +
+ + +
+ ); +} diff --git a/apps/web/components/topology/topology-canvas.tsx b/apps/web/components/topology/topology-canvas.tsx new file mode 100644 index 0000000..5fc80d5 --- /dev/null +++ b/apps/web/components/topology/topology-canvas.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { Background, BackgroundVariant, Controls, MiniMap, Panel, ReactFlow } from "@xyflow/react"; +import { useCallback, useMemo, useState } from "react"; +import "@xyflow/react/dist/style.css"; +import { useGraphLayout } from "@/hooks/use-graph-layout"; +import type { TopologyFilters, TopologyGraph, TopologyNodeData } from "@/lib/topology-types"; +import { filterEdges, filterNodes } from "@/lib/topology-utils"; +import { NodeDetailsPanel } from "./controls/node-details-panel"; +import { TopologyToolbar } from "./controls/topology-toolbar"; +import { nodeTypes } from "./nodes/node-types"; + +interface TopologyCanvasProps { + graph: TopologyGraph; +} + +export function TopologyCanvas({ graph }: TopologyCanvasProps) { + const [selectedNode, setSelectedNode] = useState(null); + const [filters, setFilters] = useState({ + showServers: true, + showProxies: true, + showK8sNodes: true, + showConnections: true, + searchQuery: "", + }); + + const filteredNodes = useMemo(() => { + return filterNodes(graph.nodes, filters); + }, [graph.nodes, filters]); + + const filteredEdges = useMemo(() => { + if (!filters.showConnections) return []; + const visibleNodeIds = new Set(filteredNodes.map((node) => node.id)); + return filterEdges(graph.edges, visibleNodeIds); + }, [graph.edges, filteredNodes, filters.showConnections]); + + const { nodes, edges } = useGraphLayout({ + nodes: filteredNodes, + edges: filteredEdges, + }); + + const onNodeClick = useCallback((_event: React.MouseEvent, node: any) => { + setSelectedNode(node.data as TopologyNodeData); + }, []); + + const onPaneClick = useCallback(() => { + setSelectedNode(null); + }, []); + + const handleCloseDetails = useCallback(() => { + setSelectedNode(null); + }, []); + + return ( +
+ + + + { + const data = node.data as TopologyNodeData; + const colors = { + healthy: "#22c55e", + degraded: "#eab308", + unhealthy: "#ef4444", + unknown: "#94a3b8", + }; + return colors[data.status] || "#94a3b8"; + }} + maskColor="rgba(0, 0, 0, 0.05)" + className="bg-white/80 backdrop-blur-sm border shadow-lg" + /> + + +
+ +
+
+
+ + +
+ ); +} diff --git a/apps/web/components/ui/accordion.tsx b/apps/web/components/ui/accordion.tsx index 24c788c..91c6a78 100644 --- a/apps/web/components/ui/accordion.tsx +++ b/apps/web/components/ui/accordion.tsx @@ -1,24 +1,20 @@ -"use client" +"use client"; -import * as React from "react" -import * as AccordionPrimitive from "@radix-ui/react-accordion" -import { ChevronDown } from "lucide-react" +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDown } from "lucide-react"; +import * as React from "react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/cn"; -const Accordion = AccordionPrimitive.Root +const Accordion = AccordionPrimitive.Root; const AccordionItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - -)) -AccordionItem.displayName = "AccordionItem" + +)); +AccordionItem.displayName = "AccordionItem"; const AccordionTrigger = React.forwardRef< React.ElementRef, @@ -37,8 +33,8 @@ const AccordionTrigger = React.forwardRef< -)) -AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; const AccordionContent = React.forwardRef< React.ElementRef, @@ -51,8 +47,8 @@ const AccordionContent = React.forwardRef< >
{children}
-)) +)); -AccordionContent.displayName = AccordionPrimitive.Content.displayName +AccordionContent.displayName = AccordionPrimitive.Content.displayName; -export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/apps/web/components/ui/avatar.tsx b/apps/web/components/ui/avatar.tsx index 7ebfb60..6de1c4e 100644 --- a/apps/web/components/ui/avatar.tsx +++ b/apps/web/components/ui/avatar.tsx @@ -1,9 +1,9 @@ "use client"; -import type * as React from "react"; import * as AvatarPrimitive from "@radix-ui/react-avatar"; +import type * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "@/lib/cn"; function Avatar({ className, ...props }: React.ComponentProps) { return ( diff --git a/apps/web/components/ui/badge.tsx b/apps/web/components/ui/badge.tsx index 5931782..ce5e877 100644 --- a/apps/web/components/ui/badge.tsx +++ b/apps/web/components/ui/badge.tsx @@ -1,8 +1,8 @@ -import type * as React from "react"; import { Slot } from "@radix-ui/react-slot"; import { cva, type VariantProps } from "class-variance-authority"; +import type * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "@/lib/cn"; const badgeVariants = cva( "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", diff --git a/apps/web/components/ui/button.tsx b/apps/web/components/ui/button.tsx index 4e63c2b..a03d6ca 100644 --- a/apps/web/components/ui/button.tsx +++ b/apps/web/components/ui/button.tsx @@ -1,8 +1,8 @@ -import type * as React from "react"; import { Slot } from "@radix-ui/react-slot"; import { cva, type VariantProps } from "class-variance-authority"; +import type * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "@/lib/cn"; const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", diff --git a/apps/web/components/ui/card.tsx b/apps/web/components/ui/card.tsx index e82e91a..b3fc811 100644 --- a/apps/web/components/ui/card.tsx +++ b/apps/web/components/ui/card.tsx @@ -1,6 +1,6 @@ import type * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "@/lib/cn"; function Card({ className, ...props }: React.ComponentProps<"div">) { return ( diff --git a/apps/web/components/ui/checkbox.tsx b/apps/web/components/ui/checkbox.tsx index df61a13..5d8b05a 100644 --- a/apps/web/components/ui/checkbox.tsx +++ b/apps/web/components/ui/checkbox.tsx @@ -1,10 +1,10 @@ -"use client" +"use client"; -import * as React from "react" -import * as CheckboxPrimitive from "@radix-ui/react-checkbox" -import { Check } from "lucide-react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { Check } from "lucide-react"; +import * as React from "react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/cn"; const Checkbox = React.forwardRef< React.ElementRef, @@ -18,13 +18,11 @@ const Checkbox = React.forwardRef< )} {...props} > - + -)) -Checkbox.displayName = CheckboxPrimitive.Root.displayName +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; -export { Checkbox } +export { Checkbox }; diff --git a/apps/web/components/ui/dialog.tsx b/apps/web/components/ui/dialog.tsx index 6c54cc8..34e4f53 100644 --- a/apps/web/components/ui/dialog.tsx +++ b/apps/web/components/ui/dialog.tsx @@ -1,10 +1,10 @@ "use client"; -import type * as React from "react"; import * as DialogPrimitive from "@radix-ui/react-dialog"; import { XIcon } from "lucide-react"; +import type * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "@/lib/cn"; function Dialog({ ...props }: React.ComponentProps) { return ; diff --git a/apps/web/components/ui/dropdown-menu.tsx b/apps/web/components/ui/dropdown-menu.tsx index 191591d..e82352d 100644 --- a/apps/web/components/ui/dropdown-menu.tsx +++ b/apps/web/components/ui/dropdown-menu.tsx @@ -1,10 +1,10 @@ "use client"; -import type * as React from "react"; import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; +import type * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "@/lib/cn"; function DropdownMenu({ ...props }: React.ComponentProps) { return ; diff --git a/apps/web/components/ui/form.tsx b/apps/web/components/ui/form.tsx index a1a1444..6ed0f9a 100644 --- a/apps/web/components/ui/form.tsx +++ b/apps/web/components/ui/form.tsx @@ -1,20 +1,19 @@ "use client"; -import * as React from "react"; import type * as LabelPrimitive from "@radix-ui/react-label"; import { Slot } from "@radix-ui/react-slot"; +import * as React from "react"; import { Controller, - FormProvider, - useFormContext, - useFormState, type ControllerProps, type FieldPath, type FieldValues, + FormProvider, + useFormContext, + useFormState, } from "react-hook-form"; - -import { cn } from "@/lib/utils"; import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/cn"; const Form = FormProvider; diff --git a/apps/web/components/ui/input.tsx b/apps/web/components/ui/input.tsx index ea55253..de7e989 100644 --- a/apps/web/components/ui/input.tsx +++ b/apps/web/components/ui/input.tsx @@ -1,6 +1,6 @@ import type * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "@/lib/cn"; function Input({ className, type, ...props }: React.ComponentProps<"input">) { return ( diff --git a/apps/web/components/ui/label.tsx b/apps/web/components/ui/label.tsx index b285beb..d2f3637 100644 --- a/apps/web/components/ui/label.tsx +++ b/apps/web/components/ui/label.tsx @@ -1,9 +1,9 @@ "use client"; -import type * as React from "react"; import * as LabelPrimitive from "@radix-ui/react-label"; +import type * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "@/lib/cn"; function Label({ className, ...props }: React.ComponentProps) { return ( diff --git a/apps/web/components/ui/popover.tsx b/apps/web/components/ui/popover.tsx new file mode 100644 index 0000000..77dcf4f --- /dev/null +++ b/apps/web/components/ui/popover.tsx @@ -0,0 +1,31 @@ +"use client"; + +import * as PopoverPrimitive from "@radix-ui/react-popover"; +import * as React from "react"; + +import { cn } from "@/lib/cn"; + +const Popover = PopoverPrimitive.Root; + +const PopoverTrigger = PopoverPrimitive.Trigger; + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export { Popover, PopoverTrigger, PopoverContent }; diff --git a/apps/web/components/ui/select.tsx b/apps/web/components/ui/select.tsx index 4a75739..e9690ba 100644 --- a/apps/web/components/ui/select.tsx +++ b/apps/web/components/ui/select.tsx @@ -1,10 +1,10 @@ "use client"; -import type * as React from "react"; import * as SelectPrimitive from "@radix-ui/react-select"; import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; +import type * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "@/lib/cn"; function Select({ ...props }: React.ComponentProps) { return ; diff --git a/apps/web/components/ui/separator.tsx b/apps/web/components/ui/separator.tsx index cad39bb..813b952 100644 --- a/apps/web/components/ui/separator.tsx +++ b/apps/web/components/ui/separator.tsx @@ -1,9 +1,9 @@ "use client"; -import type * as React from "react"; import * as SeparatorPrimitive from "@radix-ui/react-separator"; +import type * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "@/lib/cn"; function Separator({ className, diff --git a/apps/web/components/ui/sheet.tsx b/apps/web/components/ui/sheet.tsx index bbcf72c..030d471 100644 --- a/apps/web/components/ui/sheet.tsx +++ b/apps/web/components/ui/sheet.tsx @@ -1,10 +1,10 @@ "use client"; -import type * as React from "react"; import * as SheetPrimitive from "@radix-ui/react-dialog"; import { XIcon } from "lucide-react"; +import type * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "@/lib/cn"; function Sheet({ ...props }: React.ComponentProps) { return ; diff --git a/apps/web/components/ui/sidebar.tsx b/apps/web/components/ui/sidebar.tsx index 99cbc13..369a5f5 100644 --- a/apps/web/components/ui/sidebar.tsx +++ b/apps/web/components/ui/sidebar.tsx @@ -16,7 +16,7 @@ import { } from "@/components/ui/sheet"; import { Skeleton } from "@/components/ui/skeleton"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { cn } from "@/lib/utils"; +import { cn } from "@/lib/cn"; const SIDEBAR_COOKIE_NAME = "sidebar_state"; const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; @@ -82,7 +82,7 @@ function SidebarProvider({ } else { setOpen((open) => !open); } - }, [setOpen, setOpenMobile]); + }, [setOpen]); React.useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -107,7 +107,7 @@ function SidebarProvider({ setOpenMobile, toggleSidebar, }), - [state, open, setOpen, openMobile, setOpenMobile, toggleSidebar] + [state, open, setOpen, openMobile, toggleSidebar] ); return ( diff --git a/apps/web/components/ui/skeleton.tsx b/apps/web/components/ui/skeleton.tsx index 0168998..9ad0438 100644 --- a/apps/web/components/ui/skeleton.tsx +++ b/apps/web/components/ui/skeleton.tsx @@ -1,4 +1,4 @@ -import { cn } from "@/lib/utils"; +import { cn } from "@/lib/cn"; function Skeleton({ className, ...props }: React.ComponentProps<"div">) { return ( diff --git a/apps/web/components/ui/switch.tsx b/apps/web/components/ui/switch.tsx new file mode 100644 index 0000000..53ebe1b --- /dev/null +++ b/apps/web/components/ui/switch.tsx @@ -0,0 +1,29 @@ +"use client"; + +import * as SwitchPrimitives from "@radix-ui/react-switch"; +import * as React from "react"; + +import { cn } from "@/lib/cn"; + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +Switch.displayName = SwitchPrimitives.Root.displayName; + +export { Switch }; diff --git a/apps/web/components/ui/table.tsx b/apps/web/components/ui/table.tsx index 41858d1..07a9a2e 100644 --- a/apps/web/components/ui/table.tsx +++ b/apps/web/components/ui/table.tsx @@ -2,7 +2,7 @@ import type * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "@/lib/cn"; function Table({ className, ...props }: React.ComponentProps<"table">) { return ( diff --git a/apps/web/components/ui/tabs.tsx b/apps/web/components/ui/tabs.tsx index 497ba5e..5e6ebaa 100644 --- a/apps/web/components/ui/tabs.tsx +++ b/apps/web/components/ui/tabs.tsx @@ -1,27 +1,21 @@ -"use client" +"use client"; -import * as React from "react" -import * as TabsPrimitive from "@radix-ui/react-tabs" +import * as TabsPrimitive from "@radix-ui/react-tabs"; +import type * as React from "react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/cn"; -function Tabs({ - className, - ...props -}: React.ComponentProps) { +function Tabs({ className, ...props }: React.ComponentProps) { return ( - ) + ); } -function TabsList({ - className, - ...props -}: React.ComponentProps) { +function TabsList({ className, ...props }: React.ComponentProps) { return ( - ) + ); } -function TabsTrigger({ - className, - ...props -}: React.ComponentProps) { +function TabsTrigger({ className, ...props }: React.ComponentProps) { return ( - ) + ); } -function TabsContent({ - className, - ...props -}: React.ComponentProps) { +function TabsContent({ className, ...props }: React.ComponentProps) { return ( - ) + ); } -export { Tabs, TabsList, TabsTrigger, TabsContent } +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/apps/web/components/ui/textarea.tsx b/apps/web/components/ui/textarea.tsx index 0f3eac6..b1f91c0 100644 --- a/apps/web/components/ui/textarea.tsx +++ b/apps/web/components/ui/textarea.tsx @@ -1,9 +1,8 @@ import * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "@/lib/cn"; -export interface TextareaProps - extends React.TextareaHTMLAttributes {} +export interface TextareaProps extends React.TextareaHTMLAttributes {} const Textarea = React.forwardRef( ({ className, ...props }, ref) => { diff --git a/apps/web/components/ui/tooltip.tsx b/apps/web/components/ui/tooltip.tsx index 21f4b2d..2fbaae0 100644 --- a/apps/web/components/ui/tooltip.tsx +++ b/apps/web/components/ui/tooltip.tsx @@ -1,9 +1,9 @@ "use client"; -import type * as React from "react"; import * as TooltipPrimitive from "@radix-ui/react-tooltip"; +import type * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "@/lib/cn"; function TooltipProvider({ delayDuration = 0, diff --git a/apps/web/hooks/use-graph-layout.ts b/apps/web/hooks/use-graph-layout.ts new file mode 100644 index 0000000..5e1c453 --- /dev/null +++ b/apps/web/hooks/use-graph-layout.ts @@ -0,0 +1,163 @@ +"use client"; + +import { Position } from "@xyflow/react"; +import { useMemo } from "react"; +import type { + ProxyMetadata, + ServerMetadata, + TopologyEdge, + TopologyNode, +} from "@/lib/topology-types"; + +interface LayoutInput { + nodes: TopologyNode[]; + edges: TopologyEdge[]; +} + +export function useGraphLayout({ nodes, edges }: LayoutInput) { + const layoutedNodes = useMemo(() => { + const k8sNodes = nodes.filter((n) => n.data.type === "k8s-node"); + const serverNodes = nodes.filter((n) => n.data.type === "server"); + const proxyNodes = nodes.filter((n) => n.data.type === "proxy"); + + const nodeSpacing = { + x: 350, + y: 350, + }; + + const nodeWidth = 320; + const layoutedNodesList: TopologyNode[] = []; + + const k8sNodeGroups = new Map(); + const orphanedNodes: TopologyNode[] = []; + + const appNodes = [...serverNodes, ...proxyNodes]; + + appNodes.forEach((node) => { + const metadata = node.data.metadata as ServerMetadata | ProxyMetadata; + const k8sNodeNames = metadata.k8sNodes || []; + + if (k8sNodeNames.length === 0) { + orphanedNodes.push(node); + } else { + const k8sNodeName = k8sNodeNames[0]; + const k8sNodeId = `k8s-node-${k8sNodeName}`; + if (!k8sNodeGroups.has(k8sNodeId)) { + k8sNodeGroups.set(k8sNodeId, []); + } + k8sNodeGroups.get(k8sNodeId)?.push(node); + } + }); + + const k8sWithApps = k8sNodes.filter((k8s) => { + const group = k8sNodeGroups.get(k8s.id) || []; + return group.length > 0; + }); + + const k8sWithoutApps = k8sNodes.filter((k8s) => { + const group = k8sNodeGroups.get(k8s.id) || []; + return group.length === 0; + }); + + const k8sNodeWidths = new Map(); + k8sWithApps.forEach((k8sNode) => { + const group = k8sNodeGroups.get(k8sNode.id) || []; + const width = Math.max(nodeWidth, group.length * nodeWidth); + k8sNodeWidths.set(k8sNode.id, width); + }); + + let currentX = 0; + const k8sNodePositions = new Map(); + + k8sWithApps.forEach((k8sNode) => { + const width = k8sNodeWidths.get(k8sNode.id)!; + const centerX = currentX + width / 2; + + k8sNodePositions.set(k8sNode.id, { x: centerX, width }); + + layoutedNodesList.push({ + ...k8sNode, + position: { + x: centerX, + y: 0, + }, + targetPosition: Position.Top, + sourcePosition: Position.Bottom, + }); + + currentX += width + nodeSpacing.x; + }); + + k8sWithoutApps.forEach((k8sNode) => { + const centerX = currentX + nodeWidth / 2; + + k8sNodePositions.set(k8sNode.id, { x: centerX, width: nodeWidth }); + + layoutedNodesList.push({ + ...k8sNode, + position: { + x: centerX, + y: 0, + }, + targetPosition: Position.Top, + sourcePosition: Position.Bottom, + }); + + currentX += nodeWidth + nodeSpacing.x; + }); + + k8sNodeGroups.forEach((group, k8sNodeId) => { + const k8sPos = k8sNodePositions.get(k8sNodeId); + if (!k8sPos) return; + + const groupWidth = group.length * nodeWidth; + const startX = k8sPos.x - groupWidth / 2 + nodeWidth / 2; + + group.forEach((node, index) => { + const xPos = startX + index * nodeWidth; + + layoutedNodesList.push({ + ...node, + position: { + x: xPos, + y: nodeSpacing.y, + }, + targetPosition: Position.Top, + sourcePosition: Position.Bottom, + }); + }); + }); + + orphanedNodes.forEach((node, index) => { + const xPos = currentX + index * nodeWidth; + + layoutedNodesList.push({ + ...node, + position: { + x: xPos, + y: nodeSpacing.y, + }, + targetPosition: Position.Top, + sourcePosition: Position.Bottom, + }); + }); + + if (layoutedNodesList.length > 0) { + const minX = Math.min(...layoutedNodesList.map((n) => n.position.x)); + const maxX = Math.max(...layoutedNodesList.map((n) => n.position.x)); + const totalWidth = maxX - minX; + const offsetX = -totalWidth / 2; + + layoutedNodesList.forEach((node) => { + node.position.x += offsetX; + }); + } + + return layoutedNodesList; + }, [nodes]); + + return { + nodes: layoutedNodes, + edges, + }; +} diff --git a/apps/web/hooks/use-k8s-resources.ts b/apps/web/hooks/use-k8s-resources.ts index edba808..802ee75 100644 --- a/apps/web/hooks/use-k8s-resources.ts +++ b/apps/web/hooks/use-k8s-resources.ts @@ -10,7 +10,7 @@ import type { } from "@minikura/api"; import { LABEL_PREFIX } from "@minikura/api"; import { useCallback, useEffect, useState } from "react"; -import { api } from "@/lib/api"; +import { api } from "@/lib/api-client"; export function useK8sResources() { const [statefulSets, setStatefulSets] = useState([]); @@ -19,15 +19,15 @@ export function useK8sResources() { const [configMaps, setConfigMaps] = useState([]); const [minecraftServers, setMinecraftServers] = useState([]); const [reverseProxyServers, setReverseProxyServers] = useState([]); - const [status, setStatus] = useState({ initialized: false }); + const [status, _setStatus] = useState({ initialized: false }); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const fetchData = useCallback(async () => { try { const [ - statusRes, - podsRes, + _statusRes, + _podsRes, deploymentsRes, statefulSetsRes, servicesRes, @@ -47,38 +47,26 @@ export function useK8sResources() { if (statefulSetsRes.status === "fulfilled" && statefulSetsRes.value.data) { setStatefulSets(statefulSetsRes.value.data as StatefulSetInfo[]); - } else if (statefulSetsRes.status === "rejected") { - console.error("Failed to fetch statefulsets:", statefulSetsRes.reason); } if (deploymentsRes.status === "fulfilled" && deploymentsRes.value.data) { setDeployments(deploymentsRes.value.data as DeploymentInfo[]); - } else if (deploymentsRes.status === "rejected") { - console.error("Failed to fetch deployments:", deploymentsRes.reason); } if (servicesRes.status === "fulfilled" && servicesRes.value.data) { setServices(servicesRes.value.data as K8sServiceSummary[]); - } else if (servicesRes.status === "rejected") { - console.error("Failed to fetch services:", servicesRes.reason); } if (configMapsRes.status === "fulfilled" && configMapsRes.value.data) { setConfigMaps(configMapsRes.value.data as K8sConfigMapSummary[]); - } else if (configMapsRes.status === "rejected") { - console.error("Failed to fetch configmaps:", configMapsRes.reason); } if (minecraftServersRes.status === "fulfilled" && minecraftServersRes.value.data) { setMinecraftServers(minecraftServersRes.value.data as CustomResourceSummary[]); - } else if (minecraftServersRes.status === "rejected") { - console.error("Failed to fetch minecraft servers:", minecraftServersRes.reason); } if (reverseProxyServersRes.status === "fulfilled" && reverseProxyServersRes.value.data) { setReverseProxyServers(reverseProxyServersRes.value.data as CustomResourceSummary[]); - } else if (reverseProxyServersRes.status === "rejected") { - console.error("Failed to fetch reverse proxy servers:", reverseProxyServersRes.reason); } } catch (err: unknown) { const errorMessage = diff --git a/apps/web/hooks/use-server-list.ts b/apps/web/hooks/use-server-list.ts index fa39a4b..f8c09a7 100644 --- a/apps/web/hooks/use-server-list.ts +++ b/apps/web/hooks/use-server-list.ts @@ -2,8 +2,8 @@ import type { NormalServer, ReverseProxyServer } from "@minikura/api"; import { useCallback, useEffect, useState } from "react"; +import { api } from "@/lib/api-client"; import { getReverseProxyApi } from "@/lib/api-helpers"; -import { api } from "@/lib/api"; export function useServerList() { const [normalServers, setNormalServers] = useState([]); @@ -23,8 +23,7 @@ export function useServerList() { if (proxyRes.data) { setReverseProxies(proxyRes.data as unknown as ReverseProxyServer[]); } - } catch (error) { - console.error("Failed to fetch servers:", error); + } catch (_error) { } finally { setLoading(false); } @@ -32,17 +31,12 @@ export function useServerList() { const deleteServer = useCallback( async (id: string, type: "normal" | "proxy") => { - try { - if (type === "normal") { - await api.api.servers({ id }).delete(); - } else { - await getReverseProxyApi()({ id }).delete(); - } - await fetchServers(); - } catch (error) { - console.error("Failed to delete server:", error); - throw error; + if (type === "normal") { + await api.api.servers({ id }).delete(); + } else { + await getReverseProxyApi()({ id }).delete(); } + await fetchServers(); }, [fetchServers] ); diff --git a/apps/web/hooks/use-server-logs.ts b/apps/web/hooks/use-server-logs.ts index 8d802bd..5a21c70 100644 --- a/apps/web/hooks/use-server-logs.ts +++ b/apps/web/hooks/use-server-logs.ts @@ -1,14 +1,9 @@ "use client"; -import type { - ConnectionInfo, - DeploymentInfo, - PodInfo, - StatefulSetInfo, -} from "@minikura/api"; +import type { ConnectionInfo, DeploymentInfo, PodInfo, StatefulSetInfo } from "@minikura/api"; import { labelKeys } from "@minikura/api"; import { useCallback, useState } from "react"; -import { api } from "@/lib/api"; +import { api } from "@/lib/api-client"; export function useServerLogs(serverId: string) { const [pods, setPods] = useState([]); @@ -23,8 +18,7 @@ export function useServerLogs(serverId: string) { if (response.data) { setPods(response.data as PodInfo[]); } - } catch (error) { - console.error("Failed to fetch pods:", error); + } catch (_error) { } finally { setLoading(false); } @@ -42,9 +36,7 @@ export function useServerLogs(serverId: string) { setStatefulSetInfo(serverStatefulSet); } } - } catch (error) { - console.error("Failed to fetch StatefulSet info:", error); - } + } catch (_error) {} }, [serverId]); const fetchDeploymentInfo = useCallback(async () => { @@ -59,9 +51,7 @@ export function useServerLogs(serverId: string) { setDeploymentInfo(serverDeployment); } } - } catch (error) { - console.error("Failed to fetch Deployment info:", error); - } + } catch (_error) {} }, [serverId]); const fetchConnectionInfo = useCallback(async () => { @@ -70,9 +60,7 @@ export function useServerLogs(serverId: string) { if (response.data) { setConnectionInfo(response.data as ConnectionInfo); } - } catch (error) { - console.error("Failed to fetch connection info:", error); - } + } catch (_error) {} }, [serverId]); const refreshAll = useCallback(async () => { diff --git a/apps/web/hooks/use-topology-data.ts b/apps/web/hooks/use-topology-data.ts new file mode 100644 index 0000000..ac239ab --- /dev/null +++ b/apps/web/hooks/use-topology-data.ts @@ -0,0 +1,170 @@ +"use client"; + +import type { ConnectionInfo, K8sNodeSummary, PodInfo } from "@minikura/api"; +import { useCallback, useEffect, useState } from "react"; +import { api } from "@/lib/api-client"; +import { getReverseProxyApi } from "@/lib/api-helpers"; +import type { TopologyGraph } from "@/lib/topology-types"; +import { buildTopologyGraph } from "@/lib/topology-utils"; +import { useServerList } from "./use-server-list"; + +export function useTopologyData() { + const { normalServers, reverseProxies, loading: serversLoading } = useServerList(); + + const [graph, setGraph] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [isInitialLoad, setIsInitialLoad] = useState(true); + + const fetchTopologyData = useCallback( + async (isRefresh = false) => { + try { + if (!isRefresh) { + setLoading(true); + } + setError(null); + + const nodesResponse = await api.api.k8s.nodes.get(); + const k8sNodes = (nodesResponse.data as K8sNodeSummary[]) || []; + + const serverPodPromises = normalServers.map(async (server) => { + try { + const response = await api.api.k8s.servers({ serverId: server.id }).pods.get(); + return { + serverId: server.id, + pods: (response.data as PodInfo[]) || [], + }; + } catch (_err) { + return { serverId: server.id, pods: [] }; + } + }); + + const proxyPodPromises = reverseProxies.map(async (proxy) => { + try { + const response = await api.api.k8s["reverse-proxy"]({ + serverId: proxy.id, + }).pods.get(); + return { + serverId: proxy.id, + pods: (response.data as PodInfo[]) || [], + }; + } catch (_err) { + return { serverId: proxy.id, pods: [] }; + } + }); + + const [serverPodsResults, proxyPodsResults] = await Promise.all([ + Promise.all(serverPodPromises), + Promise.all(proxyPodPromises), + ]); + + const serverPodsMap = new Map(); + for (const result of serverPodsResults) { + serverPodsMap.set(result.serverId, result.pods); + } + + const proxyPodsMap = new Map(); + for (const result of proxyPodsResults) { + proxyPodsMap.set(result.serverId, result.pods); + } + + const serverConnectionPromises = normalServers.map(async (server) => { + try { + const response = await api.api.servers({ id: server.id })["connection-info"].get(); + return { + serverId: server.id, + connectionInfo: response.data as ConnectionInfo, + }; + } catch (_err) { + return { serverId: server.id, connectionInfo: null }; + } + }); + + const reverseProxyApi = getReverseProxyApi(); + const proxyConnectionPromises = reverseProxies.map(async (proxy) => { + try { + const response = await reverseProxyApi({ id: proxy.id })["connection-info"].get(); + return { + serverId: proxy.id, + connectionInfo: response.data as ConnectionInfo, + }; + } catch (_err) { + return { serverId: proxy.id, connectionInfo: null }; + } + }); + + const [serverConnectionResults, proxyConnectionResults] = await Promise.all([ + Promise.all(serverConnectionPromises), + Promise.all(proxyConnectionPromises), + ]); + + const serverConnectionMap = new Map(); + for (const result of serverConnectionResults) { + serverConnectionMap.set(result.serverId, result.connectionInfo); + } + + const proxyConnectionMap = new Map(); + for (const result of proxyConnectionResults) { + proxyConnectionMap.set(result.serverId, result.connectionInfo); + } + + let podMetrics: any = { items: [] }; + let nodeMetrics: any = { items: [] }; + try { + const [podMetricsRes, nodeMetricsRes] = await Promise.all([ + api.api.k8s.metrics.pods.get(), + api.api.k8s.metrics.nodes.get(), + ]); + podMetrics = podMetricsRes.data || { items: [] }; + nodeMetrics = nodeMetricsRes.data || { items: [] }; + } catch (_err) {} + + const topologyGraph = buildTopologyGraph({ + servers: normalServers, + proxies: reverseProxies, + serverPods: serverPodsMap, + proxyPods: proxyPodsMap, + k8sNodes, + serverConnections: serverConnectionMap, + proxyConnections: proxyConnectionMap, + podMetrics, + nodeMetrics, + }); + + setGraph(topologyGraph); + setIsInitialLoad(false); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Failed to fetch topology data"; + setError(errorMessage); + } finally { + if (!isRefresh) { + setLoading(false); + } + } + }, + [normalServers, reverseProxies] + ); + + useEffect(() => { + if (!serversLoading) { + fetchTopologyData(); + } + }, [serversLoading, fetchTopologyData]); + + useEffect(() => { + if (serversLoading || isInitialLoad) return; + + const intervalId = setInterval(() => { + fetchTopologyData(true); + }, 5000); + + return () => clearInterval(intervalId); + }, [serversLoading, isInitialLoad, fetchTopologyData]); + + return { + graph, + loading: loading || serversLoading, + error, + refresh: fetchTopologyData, + }; +} diff --git a/apps/web/lib/api.ts b/apps/web/lib/api-client.ts similarity index 100% rename from apps/web/lib/api.ts rename to apps/web/lib/api-client.ts diff --git a/apps/web/lib/api-helpers.ts b/apps/web/lib/api-helpers.ts index 984489a..7e340f3 100644 --- a/apps/web/lib/api-helpers.ts +++ b/apps/web/lib/api-helpers.ts @@ -1,8 +1,10 @@ -import { api } from "@/lib/api"; +import { api } from "@/lib/api-client"; type ReverseProxyApi = { get: () => Promise<{ data?: unknown }>; - (params: { id: string }): { + (params: { + id: string; + }): { delete: () => Promise<{ data?: unknown; error?: unknown }>; "connection-info": { get: () => Promise<{ data?: unknown }> }; }; @@ -10,7 +12,10 @@ type ReverseProxyApi = { type UserSuspensionApi = { suspension: { - patch: (body: { isSuspended: boolean; suspendedUntil: string | null }) => Promise<{ error?: unknown }>; + patch: (body: { + isSuspended: boolean; + suspendedUntil: string | null; + }) => Promise<{ error?: unknown }>; }; }; diff --git a/apps/web/lib/auth-client.ts b/apps/web/lib/auth-client.ts index 5c532af..0299423 100644 --- a/apps/web/lib/auth-client.ts +++ b/apps/web/lib/auth-client.ts @@ -1,5 +1,5 @@ -import { createAuthClient } from "better-auth/react"; import { adminClient } from "better-auth/client/plugins"; +import { createAuthClient } from "better-auth/react"; export const authClient = createAuthClient({ baseURL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000", diff --git a/apps/web/lib/utils.ts b/apps/web/lib/cn.ts similarity index 72% rename from apps/web/lib/utils.ts rename to apps/web/lib/cn.ts index a5ef193..365058c 100644 --- a/apps/web/lib/utils.ts +++ b/apps/web/lib/cn.ts @@ -1,4 +1,4 @@ -import { clsx, type ClassValue } from "clsx"; +import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { diff --git a/apps/web/lib/k8s-metrics.ts b/apps/web/lib/k8s-metrics.ts new file mode 100644 index 0000000..5b98fdf --- /dev/null +++ b/apps/web/lib/k8s-metrics.ts @@ -0,0 +1,72 @@ +export interface ResourceMetrics { + cpuUsage?: string; + cpuUsagePercent?: number; + memoryUsage?: string; + memoryUsagePercent?: number; +} + +/** Convert CPU nanoseconds (e.g. "123456789n") to millicores (e.g. "123m") */ +export function parseCpuUsage(cpuNano: string): string | undefined { + const usageNano = Number.parseInt(cpuNano.replace("n", ""), 10); + if (Number.isNaN(usageNano)) return undefined; + + const usageMilli = Math.round(usageNano / 1_000_000); + return `${usageMilli}m`; +} + +export function calculateCpuPercent(cpuNano: string, capacityNano: string): number | undefined { + const usageNano = Number.parseInt(cpuNano.replace("n", ""), 10); + const capNano = Number.parseInt(capacityNano.replace("n", ""), 10); + + if (Number.isNaN(usageNano) || Number.isNaN(capNano) || capNano === 0) { + return undefined; + } + + return Math.round((usageNano / capNano) * 100); +} + +/** Convert memory kibibytes (e.g. "1024Ki") to mebibytes (e.g. "1Mi") */ +export function parseMemoryUsage(memoryKi: string): string | undefined { + const usageKi = Number.parseInt(memoryKi.replace("Ki", ""), 10); + if (Number.isNaN(usageKi)) return undefined; + + const usageMi = Math.round(usageKi / 1024); + return `${usageMi}Mi`; +} + +export function calculateMemoryPercent(memoryKi: string, capacityKi: string): number | undefined { + const usageKi = Number.parseInt(memoryKi.replace("Ki", ""), 10); + const capKi = Number.parseInt(capacityKi.replace("Ki", ""), 10); + + if (Number.isNaN(usageKi) || Number.isNaN(capKi) || capKi === 0) { + return undefined; + } + + return Math.round((usageKi / capKi) * 100); +} + +/** Parse raw K8s metrics into a standardized format with optional percentages */ +export function parseK8sMetrics( + cpuUsage?: string, + memoryUsage?: string, + cpuCapacity?: string, + memoryCapacity?: string +): ResourceMetrics { + const result: ResourceMetrics = {}; + + if (cpuUsage) { + result.cpuUsage = parseCpuUsage(cpuUsage); + if (cpuCapacity) { + result.cpuUsagePercent = calculateCpuPercent(cpuUsage, cpuCapacity); + } + } + + if (memoryUsage) { + result.memoryUsage = parseMemoryUsage(memoryUsage); + if (memoryCapacity) { + result.memoryUsagePercent = calculateMemoryPercent(memoryUsage, memoryCapacity); + } + } + + return result; +} diff --git a/apps/web/lib/topology-types.ts b/apps/web/lib/topology-types.ts new file mode 100644 index 0000000..426683e --- /dev/null +++ b/apps/web/lib/topology-types.ts @@ -0,0 +1,100 @@ +import type { + ConnectionInfo, + K8sNodeSummary, + NormalServer, + PodInfo, + ReverseProxyServer, +} from "@minikura/api"; +import type { Edge, Node } from "@xyflow/react"; + +export type HealthStatus = "healthy" | "degraded" | "unhealthy" | "unknown"; + +export type NodeType = "server" | "proxy" | "k8s-node"; + +export type EdgeType = "proxy-to-server" | "pod-to-node"; + +export interface ResourceMetrics { + cpuUsage?: string; + memoryUsage?: string; + cpuUsagePercent?: number; + memoryUsagePercent?: number; +} + +export interface K8sNodeMetadata { + node: K8sNodeSummary; + podCount: number; + serverPods: string[]; // Pod names of servers + proxyPods: string[]; // Pod names of proxies + health: HealthStatus; + metrics?: ResourceMetrics; +} + +export interface ServerMetadata { + server: NormalServer; + podCount: number; + readyPods: number; + pods: PodInfo[]; + health: HealthStatus; + connectedProxies: string[]; // IDs of reverse proxies pointing to this server + k8sNodes: string[]; // Names of K8s nodes running this server's pods + connectionInfo?: ConnectionInfo | null; + metrics?: ResourceMetrics; +} + +export interface ProxyMetadata { + proxy: ReverseProxyServer; + podCount: number; + readyPods: number; + pods: PodInfo[]; + health: HealthStatus; + connectedServers: string[]; // IDs of servers this proxy routes to + k8sNodes: string[]; // Names of K8s nodes running this proxy's pods + connectionInfo?: ConnectionInfo | null; + metrics?: ResourceMetrics; +} + +export type NodeMetadata = ServerMetadata | ProxyMetadata | K8sNodeMetadata; + +export interface TopologyNodeData extends Record { + id: string; + type: NodeType; + label: string; + status: HealthStatus; + metadata: NodeMetadata; +} + +export interface TopologyEdgeData extends Record { + id: string; + source: string; + target: string; + type: EdgeType; + label?: string; + animated?: boolean; +} + +export type TopologyNode = Node; +export type TopologyEdge = Edge; + +export interface TopologyGraph { + nodes: TopologyNode[]; + edges: TopologyEdge[]; + metadata: { + totalServers: number; + totalProxies: number; + totalK8sNodes: number; + totalConnections: number; + healthySystems: number; + degradedSystems: number; + unhealthySystems: number; + }; +} + +export interface TopologyFilters { + showServers: boolean; + showProxies: boolean; + showK8sNodes: boolean; + showConnections: boolean; + searchQuery: string; + filterByStatus?: HealthStatus; + filterByType?: string; +} diff --git a/apps/web/lib/topology-utils.ts b/apps/web/lib/topology-utils.ts new file mode 100644 index 0000000..0cc427c --- /dev/null +++ b/apps/web/lib/topology-utils.ts @@ -0,0 +1,365 @@ +import type { + ConnectionInfo, + K8sNodeSummary, + NormalServer, + PodInfo, + ReverseProxyServer, +} from "@minikura/api"; +import { parseK8sMetrics } from "./k8s-metrics"; +import type { + HealthStatus, + K8sNodeMetadata, + ProxyMetadata, + ResourceMetrics, + ServerMetadata, + TopologyEdge, + TopologyGraph, + TopologyNode, +} from "./topology-types"; + +function parsePodReady(ready: string): { ready: number; total: number } { + const [readyStr, totalStr] = ready.split("/"); + return { + ready: parseInt(readyStr, 10) || 0, + total: parseInt(totalStr, 10) || 0, + }; +} + +function calculateHealthStatus(readyPods: number, totalPods: number): HealthStatus { + if (totalPods === 0) return "unknown"; + if (readyPods === totalPods) return "healthy"; + if (readyPods > 0) return "degraded"; + return "unhealthy"; +} + +function getPodMetrics(podName: string, podMetrics: any): ResourceMetrics | undefined { + if (!podMetrics?.items) return undefined; + + const metric = podMetrics.items.find((m: any) => m.metadata?.name === podName); + if (!metric?.containers?.[0]?.usage) return undefined; + + const usage = metric.containers[0].usage; + return parseK8sMetrics(usage.cpu, usage.memory); +} + +function getNodeMetrics(nodeName: string, nodeMetrics: any): ResourceMetrics | undefined { + if (!nodeMetrics?.items) return undefined; + + const metric = nodeMetrics.items.find((m: any) => m.metadata?.name === nodeName); + if (!metric?.usage) return undefined; + + return parseK8sMetrics(metric.usage.cpu, metric.usage.memory); +} + +interface BuildEnhancedGraphInput { + servers: NormalServer[]; + proxies: ReverseProxyServer[]; + serverPods: Map; + proxyPods: Map; + k8sNodes: K8sNodeSummary[]; + serverConnections?: Map; + proxyConnections?: Map; + podMetrics?: any; + nodeMetrics?: any; +} + +function parseProxyServerConnections( + _proxy: ReverseProxyServer, + allServers: NormalServer[] +): string[] { + return allServers.map((s) => s.id); +} + +export function buildTopologyGraph(input: BuildEnhancedGraphInput): TopologyGraph { + const { + servers, + proxies, + serverPods, + proxyPods, + k8sNodes, + serverConnections, + proxyConnections, + podMetrics, + nodeMetrics, + } = input; + + const nodes: TopologyNode[] = []; + const edges: TopologyEdge[] = []; + + let healthySystems = 0; + let degradedSystems = 0; + let unhealthySystems = 0; + + const serverToProxies = new Map(); + const proxyToServers = new Map(); + const serverToK8sNodes = new Map(); + const proxyToK8sNodes = new Map(); + const k8sNodeToPods = new Map(); + + for (const node of k8sNodes) { + if (node.name) { + k8sNodeToPods.set(node.name, { servers: [], proxies: [] }); + } + } + + for (const proxy of proxies) { + const connectedServerIds = parseProxyServerConnections(proxy, servers); + proxyToServers.set(proxy.id, connectedServerIds); + + for (const serverId of connectedServerIds) { + const existing = serverToProxies.get(serverId) || []; + existing.push(proxy.id); + serverToProxies.set(serverId, existing); + } + } + + for (const [serverId, pods] of serverPods.entries()) { + const nodeNames = new Set(); + for (const pod of pods) { + if (pod.nodeName) { + nodeNames.add(pod.nodeName); + const nodeData = k8sNodeToPods.get(pod.nodeName); + if (nodeData) { + nodeData.servers.push(pod.name); + } + } + } + serverToK8sNodes.set(serverId, Array.from(nodeNames)); + } + + for (const [proxyId, pods] of proxyPods.entries()) { + const nodeNames = new Set(); + for (const pod of pods) { + if (pod.nodeName) { + nodeNames.add(pod.nodeName); + const nodeData = k8sNodeToPods.get(pod.nodeName); + if (nodeData) { + nodeData.proxies.push(pod.name); + } + } + } + proxyToK8sNodes.set(proxyId, Array.from(nodeNames)); + } + + for (const server of servers) { + const pods = serverPods.get(server.id) || []; + const readyPods = pods.filter((p) => { + const { ready, total } = parsePodReady(p.ready); + return ready === total && p.status.toLowerCase() === "running"; + }).length; + const health = calculateHealthStatus(readyPods, pods.length); + + if (health === "healthy") healthySystems++; + else if (health === "degraded") degradedSystems++; + else if (health === "unhealthy") unhealthySystems++; + + const podMetric = pods[0] ? getPodMetrics(pods[0].name, podMetrics) : undefined; + + const metadata: ServerMetadata = { + server, + podCount: pods.length, + readyPods, + pods, + health, + connectedProxies: serverToProxies.get(server.id) || [], + k8sNodes: serverToK8sNodes.get(server.id) || [], + connectionInfo: serverConnections?.get(server.id) || null, + metrics: podMetric, + }; + + nodes.push({ + id: `server-${server.id}`, + type: "server", + position: { x: 0, y: 0 }, + data: { + id: server.id, + type: "server", + label: server.id, + status: health, + metadata, + }, + }); + } + + for (const proxy of proxies) { + const pods = proxyPods.get(proxy.id) || []; + const readyPods = pods.filter((p) => { + const { ready, total } = parsePodReady(p.ready); + return ready === total && p.status.toLowerCase() === "running"; + }).length; + const health = calculateHealthStatus(readyPods, pods.length); + + if (health === "healthy") healthySystems++; + else if (health === "degraded") degradedSystems++; + else if (health === "unhealthy") unhealthySystems++; + + const connectedServers = proxyToServers.get(proxy.id) || []; + const proxyPodMetric = pods[0] ? getPodMetrics(pods[0].name, podMetrics) : undefined; + + const metadata: ProxyMetadata = { + proxy, + podCount: pods.length, + readyPods, + pods, + health, + connectedServers, + k8sNodes: proxyToK8sNodes.get(proxy.id) || [], + connectionInfo: proxyConnections?.get(proxy.id) || null, + metrics: proxyPodMetric, + }; + + nodes.push({ + id: `proxy-${proxy.id}`, + type: "proxy", + position: { x: 0, y: 0 }, + data: { + id: proxy.id, + type: "proxy", + label: proxy.id, + status: health, + metadata, + }, + }); + + for (const serverId of connectedServers) { + edges.push({ + id: `edge-proxy-${proxy.id}-server-${serverId}`, + source: `proxy-${proxy.id}`, + target: `server-${serverId}`, + type: "smoothstep", + animated: false, + data: { + id: `edge-proxy-${proxy.id}-server-${serverId}`, + source: `proxy-${proxy.id}`, + target: `server-${serverId}`, + type: "proxy-to-server", + label: "routes to", + animated: false, + }, + }); + } + } + + for (const k8sNode of k8sNodes) { + if (!k8sNode.name) continue; + + const nodePods = k8sNodeToPods.get(k8sNode.name); + const podCount = (nodePods?.servers.length || 0) + (nodePods?.proxies.length || 0); + + let nodeHealth: HealthStatus = "healthy"; + if (k8sNode.status.toLowerCase() !== "ready") { + nodeHealth = "unhealthy"; + } + + const k8sNodeMetric = getNodeMetrics(k8sNode.name, nodeMetrics); + + const metadata: K8sNodeMetadata = { + node: k8sNode, + podCount, + serverPods: nodePods?.servers || [], + proxyPods: nodePods?.proxies || [], + health: nodeHealth, + metrics: k8sNodeMetric, + }; + + nodes.push({ + id: `k8s-node-${k8sNode.name}`, + type: "k8s-node", + position: { x: 0, y: 0 }, + data: { + id: k8sNode.name, + type: "k8s-node", + label: k8sNode.name, + status: nodeHealth, + metadata, + }, + }); + + for (const [serverId, k8sNodeNames] of serverToK8sNodes.entries()) { + if (k8sNodeNames.includes(k8sNode.name)) { + edges.push({ + id: `edge-k8s-${k8sNode.name}-server-${serverId}`, + source: `k8s-node-${k8sNode.name}`, + target: `server-${serverId}`, + type: "smoothstep", + animated: false, + data: { + id: `edge-k8s-${k8sNode.name}-server-${serverId}`, + source: `k8s-node-${k8sNode.name}`, + target: `server-${serverId}`, + type: "pod-to-node", + label: "hosts", + animated: false, + }, + }); + } + } + + for (const [proxyId, k8sNodeNames] of proxyToK8sNodes.entries()) { + if (k8sNodeNames.includes(k8sNode.name)) { + edges.push({ + id: `edge-k8s-${k8sNode.name}-proxy-${proxyId}`, + source: `k8s-node-${k8sNode.name}`, + target: `proxy-${proxyId}`, + type: "smoothstep", + animated: false, + data: { + id: `edge-k8s-${k8sNode.name}-proxy-${proxyId}`, + source: `k8s-node-${k8sNode.name}`, + target: `proxy-${proxyId}`, + type: "pod-to-node", + label: "hosts", + animated: false, + }, + }); + } + } + } + + return { + nodes, + edges, + metadata: { + totalServers: servers.length, + totalProxies: proxies.length, + totalK8sNodes: k8sNodes.length, + totalConnections: edges.length, + healthySystems, + degradedSystems, + unhealthySystems, + }, + }; +} + +export function filterNodes( + nodes: TopologyNode[], + filters: { + showServers: boolean; + showProxies: boolean; + showK8sNodes: boolean; + searchQuery: string; + filterByStatus?: HealthStatus; + } +): TopologyNode[] { + return nodes.filter((node) => { + const { type, label, status } = node.data; + + if (type === "server" && !filters.showServers) return false; + if (type === "proxy" && !filters.showProxies) return false; + if (type === "k8s-node" && !filters.showK8sNodes) return false; + + if (filters.searchQuery && !label.toLowerCase().includes(filters.searchQuery.toLowerCase())) { + return false; + } + + if (filters.filterByStatus && status !== filters.filterByStatus) { + return false; + } + + return true; + }); +} + +export function filterEdges(edges: TopologyEdge[], visibleNodeIds: Set): TopologyEdge[] { + return edges.filter((edge) => visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target)); +} diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/package.json b/apps/web/package.json index 70dd949..1bb4778 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -29,9 +29,11 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/postcss": "^4.1.18", @@ -44,6 +46,7 @@ "@xterm/addon-web-links": "^0.12.0", "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^6.0.0", + "@xyflow/react": "^12.0.0", "autoprefixer": "^10.4.23", "better-auth": "^1.4.13", "class-variance-authority": "^0.7.1", diff --git a/bun.lockb b/bun.lockb index 735c22ffefca5e678f915ab8210e30e90c97b084..50e52d4370ad00f3745b150d5f6cecd5fa35bac5 100755 GIT binary patch delta 54650 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 a{x`uv)E%aIpQxfMPRHk3^!yXm^8W!>_e&=L delta 45005 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 diff --git a/k8s/backend-rbac.yaml b/k8s/backend-rbac.yaml deleted file mode 100644 index 3631ed3..0000000 --- a/k8s/backend-rbac.yaml +++ /dev/null @@ -1,82 +0,0 @@ -apiVersion: v1 -kind: ServiceAccount -metadata: - name: minikura-backend - namespace: minikura ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: minikura-backend-role - namespace: minikura -rules: - # Pods - read access and log access - - apiGroups: [""] - resources: ["pods"] - verbs: ["get", "list", "watch"] - - apiGroups: [""] - resources: ["pods/log"] - verbs: ["get", "list"] - # Pods - exec and attach access for terminal connections - - apiGroups: [""] - resources: ["pods/exec", "pods/attach"] - verbs: ["create", "get"] - # Services - read access - - apiGroups: [""] - resources: ["services"] - verbs: ["get", "list", "watch"] - # ConfigMaps - read access - - apiGroups: [""] - resources: ["configmaps"] - verbs: ["get", "list", "watch"] - # Deployments and StatefulSets - read access - - apiGroups: ["apps"] - resources: ["deployments", "statefulsets"] - verbs: ["get", "list", "watch"] - # Ingresses - read access - - apiGroups: ["networking.k8s.io"] - resources: ["ingresses"] - verbs: ["get", "list", "watch"] - # Custom Resources - read access - - apiGroups: ["minikura.kirameki.cafe"] - resources: ["minecraftservers", "reverseproxyservers"] - verbs: ["get", "list", "watch"] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: minikura-backend-rolebinding - namespace: minikura -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: minikura-backend-role -subjects: - - kind: ServiceAccount - name: minikura-backend - namespace: minikura ---- -# ClusterRole for cluster-scoped resources (nodes) -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: minikura-backend-clusterrole -rules: - # Nodes - read access for getting node IPs - - apiGroups: [""] - resources: ["nodes"] - verbs: ["get", "list"] ---- -# ClusterRoleBinding to grant the ServiceAccount access to cluster-scoped resources -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: minikura-backend-clusterrolebinding -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: minikura-backend-clusterrole -subjects: - - kind: ServiceAccount - name: minikura-backend - namespace: minikura diff --git a/k8s/rbac/dev-rbac.yaml b/k8s/rbac/dev-rbac.yaml deleted file mode 100644 index 71cc769..0000000 --- a/k8s/rbac/dev-rbac.yaml +++ /dev/null @@ -1,48 +0,0 @@ -apiVersion: v1 -kind: ServiceAccount -metadata: - name: minikura-dev - namespace: minikura ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: minikura-dev-role -rules: - - apiGroups: [""] - resources: ["services", "pods", "nodes"] - verbs: ["get", "list", "watch"] - - apiGroups: [""] - resources: ["pods/log"] - verbs: ["get", "list"] - - apiGroups: ["apps"] - resources: ["deployments", "statefulsets"] - verbs: ["get", "list", "watch"] - - apiGroups: ["networking.k8s.io"] - resources: ["ingresses"] - verbs: ["get", "list", "watch"] - - apiGroups: ["minikura.kirameki.cafe"] - resources: ["minecraftservers", "reverseproxyservers"] - verbs: ["get", "list", "watch"] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: minikura-dev-rolebinding -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: minikura-dev-role -subjects: - - kind: ServiceAccount - name: minikura-dev - namespace: minikura ---- -apiVersion: v1 -kind: Secret -metadata: - name: minikura-dev-token - namespace: minikura - annotations: - kubernetes.io/service-account.name: minikura-dev -type: kubernetes.io/service-account-token diff --git a/package.json b/package.json index 5816b59..103938c 100644 --- a/package.json +++ b/package.json @@ -25,12 +25,11 @@ "k8s:build": "bun --filter @minikura/k8s-operator build", "k8s:start": "bun --filter @minikura/k8s-operator start", "k8s:crd": "bun --filter @minikura/k8s-operator apply-crds", - "k8s:token": "bash scripts/show-k8s-tokens.sh", - "k8s:rbac": "bash scripts/setup-rbac.sh", "install": "bash scripts/install.sh" }, "devDependencies": { "@biomejs/biome": "^2.3.11", + "@sinclair/typebox": "^0.34.48", "concurrently": "^9.2.1", "turbo": "^2.7.4" }, diff --git a/packages/api/package.json b/packages/api/package.json index a32d5c0..a7eb26d 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -13,7 +13,6 @@ "typescript": "^5.0.0" }, "dependencies": { - "@elysiajs/eden": "^1.4.6", - "elysia": "^1.4.22" + "@minikura/db": "workspace:*" } } diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 34d2c8d..8ec1aff 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,7 +1,3 @@ -import { treaty } from "@elysiajs/eden"; - -export const api = treaty("http://localhost:3000"); - export * from "./constants"; export * from "./labels"; export * from "./types"; diff --git a/packages/db/src/models/reverse-proxy.ts b/packages/db/src/models/reverse-proxy.ts index 4155f68..8f25cca 100644 --- a/packages/db/src/models/reverse-proxy.ts +++ b/packages/db/src/models/reverse-proxy.ts @@ -1,8 +1,4 @@ -import type { - CustomEnvironmentVariable, - Prisma, - ReverseProxyServer as PrismaReverseProxyServer, -} from "../generated/prisma"; +import type { Prisma, ReverseProxyServer as PrismaReverseProxyServer } from "../generated/prisma"; export type ReverseProxyWithEnvVars = Prisma.ReverseProxyServerGetPayload<{ include: { env_variables: true }; diff --git a/packages/k8s-operator/package.json b/packages/k8s-operator/package.json index 3933cc7..d230c6b 100644 --- a/packages/k8s-operator/package.json +++ b/packages/k8s-operator/package.json @@ -6,10 +6,11 @@ "type": "module", "scripts": { "build": "tsc", - "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", + "start": "node dist/index.js", + "dev": "tsx watch src/index.ts", + "dev:bun": "bun --watch src/index.ts", + "watch": "tsx watch src/index.ts", + "apply-crds": "tsx src/scripts/apply-crds.ts", "typecheck": "tsc --noEmit" }, "dependencies": { @@ -19,6 +20,9 @@ "dotenv-mono": "^1.5.1", "node-fetch": "^3.3.2", "pg": "^8.11.3", + "pino": "^10.3.1", + "pino-pretty": "^13.1.3", + "undici": "^7.18.2", "yaml": "^2.6.1" }, "devDependencies": { diff --git a/packages/k8s-operator/src/config/constants.ts b/packages/k8s-operator/src/config/constants.ts index 581fbc6..fb2c0a8 100644 --- a/packages/k8s-operator/src/config/constants.ts +++ b/packages/k8s-operator/src/config/constants.ts @@ -1,18 +1,14 @@ import { dotenvLoad } from "dotenv-mono"; -const dotenv = dotenvLoad(); -export const API_GROUP = "minikura.kirameki.cafe"; +const _dotenv = dotenvLoad(); + +export { API_GROUP, LABEL_PREFIX } from "@minikura/api"; export const API_VERSION = "v1alpha1"; export const KUBERNETES_NAMESPACE_ENV = process.env.KUBERNETES_NAMESPACE; export const NAMESPACE = process.env.KUBERNETES_NAMESPACE || "minikura"; export const ENABLE_CRD_REFLECTION = process.env.ENABLE_CRD_REFLECTION === "true"; -export const SKIP_TLS_VERIFY = process.env.KUBERNETES_SKIP_TLS_VERIFY === "true"; - -if (SKIP_TLS_VERIFY) { - process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; -} // Resource types export const RESOURCE_TYPES = { @@ -30,9 +26,6 @@ export const RESOURCE_TYPES = { }, }; -// Kubernetes resource label prefixes -export const LABEL_PREFIX = "minikura.kirameki.cafe"; - // Polling intervals (in milliseconds) export const SYNC_INTERVAL = 30 * 1000; // 30 seconds diff --git a/packages/k8s-operator/src/config/resource-defaults.ts b/packages/k8s-operator/src/config/resource-defaults.ts new file mode 100644 index 0000000..71ac6c3 --- /dev/null +++ b/packages/k8s-operator/src/config/resource-defaults.ts @@ -0,0 +1,16 @@ +export const RESOURCE_DEFAULTS = { + server: { + memory: "1G", + javaMemoryFactor: 0.8, + }, + proxy: { + memory: "512M", + javaMemoryFactor: 0.8, + }, +} as const; + +// 80% of container memory goes to JVM heap; 20% headroom +export const JAVA_MEMORY_FACTOR = 0.8; + +export const DEFAULT_SERVER_MEMORY = "1G"; +export const DEFAULT_PROXY_MEMORY = "512M"; diff --git a/packages/k8s-operator/src/controllers/base-controller.ts b/packages/k8s-operator/src/controllers/base-controller.ts index b4848c1..92f7a8b 100644 --- a/packages/k8s-operator/src/controllers/base-controller.ts +++ b/packages/k8s-operator/src/controllers/base-controller.ts @@ -1,57 +1,53 @@ import type { PrismaClient } from "@minikura/db"; -import { KubernetesClient } from "../utils/k8s-client"; +import type { Logger } from "pino"; import { SYNC_INTERVAL } from "../config/constants"; +import { KubernetesClient } from "../utils/k8s-client"; +import { createLogger } from "../utils/logger"; export abstract class BaseController { protected prisma: PrismaClient; protected k8sClient: KubernetesClient; protected namespace: string; + protected logger: Logger; private intervalId: ReturnType | null = null; constructor(prisma: PrismaClient, namespace: string) { this.prisma = prisma; this.k8sClient = KubernetesClient.getInstance(); this.namespace = namespace; + this.logger = createLogger({ controller: this.getControllerName() }); } - /** - * Start watching for changes in the database and syncing to Kubernetes - */ public startWatching(): void { - console.log(`Starting to watch for changes in ${this.getControllerName()}...`); + this.logger.info( + { namespace: this.namespace, syncInterval: SYNC_INTERVAL }, + "Starting controller watch loop" + ); - // Initial sync this.syncResources().catch((err) => { - console.error(`Error during initial sync of ${this.getControllerName()}:`, err); + this.logger.error({ err }, "Error during initial resource synchronization"); }); - // Polling interval for changes - // TODO: Maybe there's a better way to do this this.intervalId = setInterval(() => { this.syncResources().catch((err) => { - console.error(`Error syncing ${this.getControllerName()}:`, err); + this.logger.error({ err }, "Error during periodic resource synchronization"); }); }, SYNC_INTERVAL); + + this.logger.debug( + { intervalMs: SYNC_INTERVAL }, + "Polling interval established for resource synchronization" + ); } - /** - * Stop watching for changes - */ public stopWatching(): void { if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = null; - console.log(`Stopped watching for changes in ${this.getControllerName()}`); + this.logger.info("Controller watch loop stopped"); } } - /** - * Get a name for this controller for logging purposes - */ protected abstract getControllerName(): string; - - /** - * Sync resources from database to Kubernetes - */ protected abstract syncResources(): Promise; } diff --git a/packages/k8s-operator/src/controllers/reverse-proxy-controller.ts b/packages/k8s-operator/src/controllers/reverse-proxy-controller.ts index 1382ef9..783e2fc 100644 --- a/packages/k8s-operator/src/controllers/reverse-proxy-controller.ts +++ b/packages/k8s-operator/src/controllers/reverse-proxy-controller.ts @@ -1,11 +1,10 @@ -import type { PrismaClient } from "@minikura/db"; -import type { ReverseProxyServer, CustomEnvironmentVariable } from "@minikura/db"; -import { BaseController } from "./base-controller"; -import type { ReverseProxyConfig } from "../types"; +import type { CustomEnvironmentVariable, ReverseProxyServer } from "@minikura/db"; import { createReverseProxyServer, deleteReverseProxyServer, } from "../resources/reverseProxyServer"; +import type { ReverseProxyConfig } from "../types"; +import { BaseController } from "./base-controller"; type ReverseProxyWithEnvVars = ReverseProxyServer & { env_variables: CustomEnvironmentVariable[]; @@ -14,10 +13,6 @@ type ReverseProxyWithEnvVars = ReverseProxyServer & { export class ReverseProxyController extends BaseController { private deployedProxies = new Map(); - constructor(prisma: PrismaClient, namespace: string) { - super(prisma, namespace); - } - protected getControllerName(): string { return "ReverseProxyController"; } @@ -36,25 +31,32 @@ export class ReverseProxyController extends BaseController { const currentProxyIds = new Set(proxies.map((proxy) => proxy.id)); - // Delete reverse proxy servers that are no longer in the database for (const [proxyId, proxy] of this.deployedProxies.entries()) { if (!currentProxyIds.has(proxyId)) { - console.log( - `Reverse proxy server ${proxy.id} (${proxyId}) has been removed from the database, deleting from Kubernetes...` + this.logger.info( + { proxyId, proxyType: proxy.type }, + "Reverse proxy removed from database, deleting K8s resources" ); await deleteReverseProxyServer(proxy.id, proxy.type, appsApi, coreApi, this.namespace); this.deployedProxies.delete(proxyId); } } - // Create or update reverse proxy servers that are in the database for (const proxy of proxies) { const deployedProxy = this.deployedProxies.get(proxy.id); - // If proxy doesn't exist yet or has been updated if (!deployedProxy || this.hasProxyChanged(deployedProxy, proxy)) { - console.log( - `${!deployedProxy ? "Creating" : "Updating"} reverse proxy server ${proxy.id} (${proxy.id}) in Kubernetes...` + const action = !deployedProxy ? "Creating" : "Updating"; + this.logger.info( + { + proxyId: proxy.id, + proxyType: proxy.type, + action: action.toLowerCase(), + externalAddress: proxy.external_address, + externalPort: proxy.external_port, + listenPort: proxy.listen_port, + }, + `${action} reverse proxy server in Kubernetes` ); const proxyConfig: ReverseProxyConfig = { @@ -66,6 +68,7 @@ export class ReverseProxyController extends BaseController { apiKey: proxy.api_key, type: proxy.type, memory: proxy.memory, + service_type: proxy.service_type, env_variables: proxy.env_variables?.map((ev) => ({ key: ev.key, value: ev.value, @@ -80,12 +83,11 @@ export class ReverseProxyController extends BaseController { this.namespace ); - // Update cache this.deployedProxies.set(proxy.id, { ...proxy }); } } } catch (error) { - console.error("Error syncing reverse proxy servers:", error); + this.logger.error({ err: error }, "Failed to sync reverse proxy servers to Kubernetes"); throw error; } } @@ -94,20 +96,20 @@ export class ReverseProxyController extends BaseController { oldProxy: ReverseProxyWithEnvVars, newProxy: ReverseProxyWithEnvVars ): boolean { - // Check basic properties const basicPropsChanged = oldProxy.external_address !== newProxy.external_address || oldProxy.external_port !== newProxy.external_port || oldProxy.listen_port !== newProxy.listen_port || - oldProxy.description !== newProxy.description; + oldProxy.description !== newProxy.description || + oldProxy.service_type !== newProxy.service_type; if (basicPropsChanged) return true; - // Check if environment variables have changed const oldEnvVars = oldProxy.env_variables || []; const newEnvVars = newProxy.env_variables || []; if (oldEnvVars.length !== newEnvVars.length) return true; + for (const newEnv of newEnvVars) { const oldEnv = oldEnvVars.find((e) => e.key === newEnv.key); if (!oldEnv || oldEnv.value !== newEnv.value) { diff --git a/packages/k8s-operator/src/controllers/server-controller.ts b/packages/k8s-operator/src/controllers/server-controller.ts index 81d0c13..32c9eb6 100644 --- a/packages/k8s-operator/src/controllers/server-controller.ts +++ b/packages/k8s-operator/src/controllers/server-controller.ts @@ -1,8 +1,7 @@ -import { type PrismaClient, ServerType } from "@minikura/db"; -import type { Server, CustomEnvironmentVariable } from "@minikura/db"; -import { BaseController } from "./base-controller"; -import type { ServerConfig } from "../types"; +import type { CustomEnvironmentVariable, Server } from "@minikura/db"; import { createServer, deleteServer } from "../resources/server"; +import type { ServerConfig } from "../types"; +import { BaseController } from "./base-controller"; type ServerWithEnvVars = Server & { env_variables: CustomEnvironmentVariable[]; @@ -11,10 +10,6 @@ type ServerWithEnvVars = Server & { export class ServerController extends BaseController { private deployedServers = new Map(); - constructor(prisma: PrismaClient, namespace: string) { - super(prisma, namespace); - } - protected getControllerName(): string { return "ServerController"; } @@ -33,25 +28,31 @@ export class ServerController extends BaseController { const currentServerIds = new Set(servers.map((server) => server.id)); - // Delete servers that are no longer in the database for (const [serverId, server] of this.deployedServers.entries()) { if (!currentServerIds.has(serverId)) { - console.log( - `Server ${server.id} (${serverId}) has been removed from the database, deleting from Kubernetes...` + this.logger.info( + { serverId, serverName: server.id }, + "Server removed from database, deleting K8s resources" ); await deleteServer(serverId, appsApi, coreApi, this.namespace); this.deployedServers.delete(serverId); } } - // Create or update servers that are in the database for (const server of servers) { const deployedServer = this.deployedServers.get(server.id); - // If server doesn't exist yet or has been updated if (!deployedServer || this.hasServerChanged(deployedServer, server)) { - console.log( - `${!deployedServer ? "Creating" : "Updating"} server ${server.id} (${server.id}) in Kubernetes...` + const action = !deployedServer ? "Creating" : "Updating"; + this.logger.info( + { + serverId: server.id, + serverType: server.type, + action: action.toLowerCase(), + memory: server.memory, + port: server.listen_port, + }, + `${action} Minecraft server in Kubernetes` ); const serverConfig: ServerConfig = { @@ -61,6 +62,7 @@ export class ServerController extends BaseController { description: server.description, listen_port: server.listen_port, memory: server.memory, + service_type: server.service_type, env_variables: server.env_variables?.map((ev) => ({ key: ev.key, value: ev.value, @@ -69,33 +71,29 @@ export class ServerController extends BaseController { await createServer(serverConfig, appsApi, coreApi, networkingApi, this.namespace); - // Update cache this.deployedServers.set(server.id, { ...server }); } } } catch (error) { - console.error("Error syncing servers:", error); + this.logger.error({ err: error }, "Failed to sync servers to Kubernetes"); throw error; } } private hasServerChanged(oldServer: ServerWithEnvVars, newServer: ServerWithEnvVars): boolean { - // Check basic properties const basicPropsChanged = oldServer.type !== newServer.type || oldServer.listen_port !== newServer.listen_port || - oldServer.description !== newServer.description; + oldServer.description !== newServer.description || + oldServer.service_type !== newServer.service_type; if (basicPropsChanged) return true; - // Check if environment variables have changed const oldEnvVars = oldServer.env_variables || []; const newEnvVars = newServer.env_variables || []; - // Check if the number of env vars has changed if (oldEnvVars.length !== newEnvVars.length) return true; - // Check if any of the existing env vars have changed for (const newEnv of newEnvVars) { const oldEnv = oldEnvVars.find((e) => e.key === newEnv.key); if (!oldEnv || oldEnv.value !== newEnv.value) { diff --git a/packages/k8s-operator/src/crds/rbac.ts b/packages/k8s-operator/src/crds/rbac.ts index c467895..3619a87 100644 --- a/packages/k8s-operator/src/crds/rbac.ts +++ b/packages/k8s-operator/src/crds/rbac.ts @@ -1,8 +1,5 @@ -import { NAMESPACE } from "../config/constants"; +import { API_GROUP, NAMESPACE } from "../config/constants"; -/** - * Namespace definition - */ export const minikuraNamespace = { apiVersion: "v1", kind: "Namespace", @@ -11,9 +8,6 @@ export const minikuraNamespace = { }, }; -/** - * Service account - */ export const minikuraServiceAccount = { apiVersion: "v1", kind: "ServiceAccount", @@ -23,9 +17,6 @@ export const minikuraServiceAccount = { }, }; -/** - * Cluster role - */ export const minikuraClusterRole = { apiVersion: "rbac.authorization.k8s.io/v1", kind: "ClusterRole", @@ -54,21 +45,18 @@ export const minikuraClusterRole = { verbs: ["get", "list", "watch", "create", "update", "patch", "delete"], }, { - apiGroups: ["minikura.kirameki.cafe"], + apiGroups: [API_GROUP], resources: ["minecraftservers", "velocityproxies"], verbs: ["get", "list", "watch", "create", "update", "patch", "delete"], }, { - apiGroups: ["minikura.kirameki.cafe"], + apiGroups: [API_GROUP], resources: ["minecraftservers/status", "velocityproxies/status"], verbs: ["get", "update", "patch"], }, ], }; -/** - * Cluster role binding - */ export const minikuraClusterRoleBinding = { apiVersion: "rbac.authorization.k8s.io/v1", kind: "ClusterRoleBinding", @@ -89,9 +77,6 @@ export const minikuraClusterRoleBinding = { }, }; -/** - * Deployment for the Minikura operator - */ export const minikuraOperatorDeployment = { apiVersion: "apps/v1", kind: "Deployment", diff --git a/packages/k8s-operator/src/index.ts b/packages/k8s-operator/src/index.ts index 1388b93..66bbc17 100644 --- a/packages/k8s-operator/src/index.ts +++ b/packages/k8s-operator/src/index.ts @@ -1,20 +1,24 @@ import { dotenvLoad } from "dotenv-mono"; -const dotenv = dotenvLoad(); -import { NAMESPACE, KUBERNETES_NAMESPACE_ENV, ENABLE_CRD_REFLECTION } from "./config/constants"; +const _dotenv = dotenvLoad(); + import { prisma } from "@minikura/db"; -import { KubernetesClient } from "./utils/k8s-client"; -import { ServerController } from "./controllers/server-controller"; +import { ENABLE_CRD_REFLECTION, NAMESPACE } from "./config/constants"; import { ReverseProxyController } from "./controllers/reverse-proxy-controller"; +import { ServerController } from "./controllers/server-controller"; import { setupCRDRegistration } from "./utils/crd-registrar"; +import { KubernetesClient } from "./utils/k8s-client"; +import { logger } from "./utils/logger"; async function main() { - console.log("Starting Minikura Kubernetes Operator..."); - console.log(`Using namespace: ${NAMESPACE}`); + logger.info( + { namespace: NAMESPACE, crdReflection: ENABLE_CRD_REFLECTION }, + "Starting Minikura Kubernetes Operator" + ); try { const k8sClient = KubernetesClient.getInstance(); - console.log("Connected to Kubernetes cluster"); + logger.info({ namespace: NAMESPACE }, "Successfully connected to Kubernetes cluster"); const serverController = new ServerController(prisma, NAMESPACE); const reverseProxyController = new ReverseProxyController(prisma, NAMESPACE); @@ -23,49 +27,55 @@ async function main() { reverseProxyController.startWatching(); if (ENABLE_CRD_REFLECTION) { - console.log("CRD reflection enabled - will create CRDs to reflect database state"); + logger.info("CRD reflection enabled - will create Custom Resources to mirror database state"); try { await setupCRDRegistration(prisma, k8sClient, NAMESPACE); + logger.info("CRD registration completed successfully"); } catch (error: any) { - console.error(`Failed to set up CRD registration: ${error.message}`); - if (error.response) { - console.error(`Response status: ${error.response.statusCode}`); - console.error(`Response body: ${JSON.stringify(error.response.body)}`); - } - console.error("Continuing operation without CRD reflection"); - console.log( - "Kubernetes resources will still be created/updated, but CRD reflection is disabled" + logger.error( + { + err: error, + message: error.message, + statusCode: error.response?.statusCode, + body: error.response?.body, + }, + "Failed to setup CRD registration, continuing without CRD reflection" + ); + logger.warn( + "Kubernetes resources (Deployments, Services) will still be created, but Custom Resources will not be reflected" ); } } - console.log("Minikura Kubernetes Operator is running"); + logger.info("Minikura Kubernetes Operator is now running and watching for changes"); process.on("SIGINT", gracefulShutdown); process.on("SIGTERM", gracefulShutdown); function gracefulShutdown() { - console.log("Shutting down operator gracefully..."); + logger.info("Received shutdown signal, shutting down gracefully"); serverController.stopWatching(); reverseProxyController.stopWatching(); prisma.$disconnect(); - console.log("Resources released, exiting..."); + logger.info("All resources released, exiting process"); process.exit(0); } } catch (error: any) { - console.error(`Failed to start Minikura Kubernetes Operator: ${error.message}`); - if (error.response) { - console.error(`Response status: ${error.response.statusCode}`); - console.error(`Response body: ${JSON.stringify(error.response.body)}`); - } - if (error.stack) { - console.error(`Stack trace: ${error.stack}`); - } + logger.fatal( + { + err: error, + message: error.message, + statusCode: error.response?.statusCode, + body: error.response?.body, + stack: error.stack, + }, + "Failed to start Minikura Kubernetes Operator" + ); process.exit(1); } } main().catch((error) => { - console.error("Unhandled error:", error); + logger.fatal({ err: error }, "Unhandled error in main process"); process.exit(1); }); diff --git a/packages/k8s-operator/src/resources/reverseProxyServer.ts b/packages/k8s-operator/src/resources/reverseProxyServer.ts index 655aad2..da3c9d8 100644 --- a/packages/k8s-operator/src/resources/reverseProxyServer.ts +++ b/packages/k8s-operator/src/resources/reverseProxyServer.ts @@ -1,8 +1,11 @@ import type * as k8s from "@kubernetes/client-node"; import type { ReverseProxyServerType } from "@minikura/db"; import { LABEL_PREFIX } from "../config/constants"; -import { calculateJavaMemory, convertToK8sFormat } from "../utils/memory"; +import { DEFAULT_PROXY_MEMORY, JAVA_MEMORY_FACTOR } from "../config/resource-defaults"; import type { ReverseProxyConfig } from "../types"; +import { logger } from "../utils/logger"; +import { calculateJavaMemory, convertToK8sFormat } from "../utils/memory"; +import { mapServiceType } from "../utils/service-type"; export async function createReverseProxyServer( server: ReverseProxyConfig, @@ -11,7 +14,10 @@ export async function createReverseProxyServer( _networkingApi: k8s.NetworkingV1Api, namespace: string ): Promise { - console.log(`Creating reverse proxy server ${server.id} in namespace '${namespace}'`); + logger.debug( + { proxyId: server.id, proxyType: server.type, namespace }, + "Creating reverse proxy server" + ); const serverType = server.type.toLowerCase(); const serverName = `${serverType}-${server.id}`; @@ -35,11 +41,15 @@ export async function createReverseProxyServer( try { await coreApi.createNamespacedConfigMap({ namespace, body: configMap }); - console.log(`Created ConfigMap for reverse proxy server ${server.id}`); + logger.debug({ proxyId: server.id, resource: "ConfigMap" }, "Created ConfigMap"); } catch (error: any) { - if (error.response?.statusCode === 409) { - await coreApi.replaceNamespacedConfigMap({ name: `${serverName}-config`, namespace, body: configMap }); - console.log(`Updated ConfigMap for reverse proxy server ${server.id}`); + if (error.code === 409) { + await coreApi.replaceNamespacedConfigMap({ + name: `${serverName}-config`, + namespace, + body: configMap, + }); + logger.debug({ proxyId: server.id, resource: "ConfigMap" }, "Updated ConfigMap"); } else { throw error; } @@ -69,17 +79,17 @@ export async function createReverseProxyServer( name: "minecraft", }, ], - type: "LoadBalancer", + type: mapServiceType(server.service_type, "LoadBalancer"), }, }; try { await coreApi.createNamespacedService({ namespace, body: service }); - console.log(`Created Service for reverse proxy server ${server.id}`); + logger.debug({ proxyId: server.id, resource: "Service" }, "Created Service"); } catch (error: any) { - if (error.response?.statusCode === 409) { + if (error.code === 409) { await coreApi.replaceNamespacedService({ name: serverName, namespace, body: service }); - console.log(`Updated Service for reverse proxy server ${server.id}`); + logger.debug({ proxyId: server.id, resource: "Service" }, "Updated Service"); } else { throw error; } @@ -134,7 +144,10 @@ export async function createReverseProxyServer( }, { name: "MEMORY", - value: calculateJavaMemory(server.memory || "512M", 0.8), + value: calculateJavaMemory( + server.memory || DEFAULT_PROXY_MEMORY, + JAVA_MEMORY_FACTOR + ), }, ...(server.env_variables || []).map((ev) => ({ name: ev.key, @@ -150,11 +163,11 @@ export async function createReverseProxyServer( }, resources: { requests: { - memory: convertToK8sFormat(server.memory || "512M"), + memory: convertToK8sFormat(server.memory || DEFAULT_PROXY_MEMORY), cpu: "250m", }, limits: { - memory: convertToK8sFormat(server.memory || "512M"), + memory: convertToK8sFormat(server.memory || DEFAULT_PROXY_MEMORY), cpu: "500m", }, }, @@ -167,11 +180,11 @@ export async function createReverseProxyServer( try { await appsApi.createNamespacedDeployment({ namespace, body: deployment }); - console.log(`Created Deployment for reverse proxy server ${server.id}`); + logger.debug({ proxyId: server.id, resource: "Deployment" }, "Created Deployment"); } catch (error: any) { - if (error.response?.statusCode === 409) { + if (error.code === 409) { await appsApi.replaceNamespacedDeployment({ name: serverName, namespace, body: deployment }); - console.log(`Updated Deployment for reverse proxy server ${server.id}`); + logger.debug({ proxyId: server.id, resource: "Deployment" }, "Updated Deployment"); } else { throw error; } @@ -190,28 +203,28 @@ export async function deleteReverseProxyServer( try { await appsApi.deleteNamespacedDeployment({ name, namespace }); - console.log(`Deleted Deployment for reverse proxy server ${proxyId}`); + logger.debug({ proxyId, resource: "Deployment" }, "Deleted Deployment"); } catch (error: any) { if (error.response?.statusCode !== 404) { - console.error(`Error deleting Deployment for reverse proxy server ${proxyId}:`, error); + logger.error({ err: error, proxyId, resource: "Deployment" }, "Failed to delete Deployment"); } } try { await coreApi.deleteNamespacedService({ name, namespace }); - console.log(`Deleted Service for reverse proxy server ${proxyId}`); + logger.debug({ proxyId, resource: "Service" }, "Deleted Service"); } catch (error: any) { if (error.response?.statusCode !== 404) { - console.error(`Error deleting Service for reverse proxy server ${proxyId}:`, error); + logger.error({ err: error, proxyId, resource: "Service" }, "Failed to delete Service"); } } try { await coreApi.deleteNamespacedConfigMap({ name: `${name}-config`, namespace }); - console.log(`Deleted ConfigMap for reverse proxy server ${proxyId}`); + logger.debug({ proxyId, resource: "ConfigMap" }, "Deleted ConfigMap"); } catch (error: any) { if (error.response?.statusCode !== 404) { - console.error(`Error deleting ConfigMap for reverse proxy server ${proxyId}:`, error); + logger.error({ err: error, proxyId, resource: "ConfigMap" }, "Failed to delete ConfigMap"); } } } diff --git a/packages/k8s-operator/src/resources/server.ts b/packages/k8s-operator/src/resources/server.ts index 1875011..47ff998 100644 --- a/packages/k8s-operator/src/resources/server.ts +++ b/packages/k8s-operator/src/resources/server.ts @@ -1,8 +1,11 @@ import type * as k8s from "@kubernetes/client-node"; import { ServerType } from "@minikura/db"; import { LABEL_PREFIX } from "../config/constants"; +import { DEFAULT_SERVER_MEMORY, JAVA_MEMORY_FACTOR } from "../config/resource-defaults"; import type { ServerConfig } from "../types"; +import { logger } from "../utils/logger"; import { calculateJavaMemory, convertToK8sFormat } from "../utils/memory"; +import { mapServiceType } from "../utils/service-type"; export async function createServer( server: ServerConfig, @@ -33,15 +36,15 @@ export async function createServer( try { await coreApi.createNamespacedConfigMap({ namespace, body: configMap }); - console.log(`Created ConfigMap for server ${server.id}`); + logger.debug({ serverId: server.id, resource: "ConfigMap" }, "Created ConfigMap"); } catch (err: any) { - if (err.response?.statusCode === 409) { + if (err.code === 409) { await coreApi.replaceNamespacedConfigMap({ name: `${serverName}-config`, namespace, body: configMap, }); - console.log(`Updated ConfigMap for server ${server.id}`); + logger.debug({ serverId: server.id, resource: "ConfigMap" }, "Updated ConfigMap"); } else { throw err; } @@ -71,17 +74,20 @@ export async function createServer( name: "minecraft", }, ], - type: "ClusterIP", + type: mapServiceType(server.service_type), }, }; try { await coreApi.createNamespacedService({ namespace, body: service }); - console.log(`Created Service for server ${server.id}`); + logger.debug( + { serverId: server.id, resource: "Service", port: server.listen_port }, + "Created Service" + ); } catch (err: any) { - if (err.response?.statusCode === 409) { + if (err.code === 409) { await coreApi.replaceNamespacedService({ name: serverName, namespace, body: service }); - console.log(`Updated Service for server ${server.id}`); + logger.debug({ serverId: server.id, resource: "Service" }, "Updated Service"); } else { throw err; } @@ -149,7 +155,10 @@ async function createDeployment( }, { name: "MEMORY", - value: calculateJavaMemory(server.memory || "1G", 0.8), + value: calculateJavaMemory( + server.memory || DEFAULT_SERVER_MEMORY, + JAVA_MEMORY_FACTOR + ), }, { name: "OPS", @@ -183,11 +192,11 @@ async function createDeployment( }, resources: { requests: { - memory: convertToK8sFormat(server.memory || "1G"), + memory: convertToK8sFormat(server.memory || DEFAULT_SERVER_MEMORY), cpu: "250m", }, limits: { - memory: convertToK8sFormat(server.memory || "1G"), + memory: convertToK8sFormat(server.memory || DEFAULT_SERVER_MEMORY), cpu: "500m", }, }, @@ -208,11 +217,11 @@ async function createDeployment( try { await appsApi.createNamespacedDeployment({ namespace, body: deployment }); - console.log(`Created Deployment for server ${server.id}`); + logger.debug({ serverId: server.id, resource: "Deployment" }, "Created Deployment"); } catch (err: any) { - if (err.response?.statusCode === 409) { + if (err.code === 409) { await appsApi.replaceNamespacedDeployment({ name: serverName, namespace, body: deployment }); - console.log(`Updated Deployment for server ${server.id}`); + logger.debug({ serverId: server.id, resource: "Deployment" }, "Updated Deployment"); } else { throw err; } @@ -275,7 +284,10 @@ async function createStatefulSet( }, { name: "MEMORY", - value: calculateJavaMemory(server.memory || "1G", 0.8), + value: calculateJavaMemory( + server.memory || DEFAULT_SERVER_MEMORY, + JAVA_MEMORY_FACTOR + ), }, { name: "OPS", @@ -353,15 +365,15 @@ async function createStatefulSet( try { await appsApi.createNamespacedStatefulSet({ namespace, body: statefulSet }); - console.log(`Created StatefulSet for server ${server.id}`); + logger.debug({ serverId: server.id, resource: "StatefulSet" }, "Created StatefulSet"); } catch (err: any) { - if (err.response?.statusCode === 409) { + if (err.code === 409) { await appsApi.replaceNamespacedStatefulSet({ name: serverName, namespace, body: statefulSet, }); - console.log(`Updated StatefulSet for server ${server.id}`); + logger.debug({ serverId: server.id, resource: "StatefulSet" }, "Updated StatefulSet"); } else { throw err; } @@ -378,37 +390,37 @@ export async function deleteServer( try { await appsApi.deleteNamespacedDeployment({ name: serverName, namespace }); - console.log(`Deleted Deployment for server ${serverName}`); + logger.debug({ serverName, resource: "Deployment" }, "Deleted Deployment"); } catch (err: any) { if (err.response?.statusCode !== 404) { - console.error(`Error deleting Deployment for server ${serverName}:`, err); + logger.error({ err, serverName, resource: "Deployment" }, "Failed to delete Deployment"); } } try { await appsApi.deleteNamespacedStatefulSet({ name: serverName, namespace }); - console.log(`Deleted StatefulSet for server ${serverName}`); + logger.debug({ serverName, resource: "StatefulSet" }, "Deleted StatefulSet"); } catch (err: any) { if (err.response?.statusCode !== 404) { - console.error(`Error deleting StatefulSet for server ${serverName}:`, err); + logger.error({ err, serverName, resource: "StatefulSet" }, "Failed to delete StatefulSet"); } } try { await coreApi.deleteNamespacedService({ name: serverName, namespace }); - console.log(`Deleted Service for server ${serverName}`); + logger.debug({ serverName, resource: "Service" }, "Deleted Service"); } catch (err: any) { if (err.response?.statusCode !== 404) { - console.error(`Error deleting Service for server ${serverName}:`, err); + logger.error({ err, serverName, resource: "Service" }, "Failed to delete Service"); } } try { await coreApi.deleteNamespacedConfigMap({ name: `${serverName}-config`, namespace }); - console.log(`Deleted ConfigMap for server ${serverName}`); + logger.debug({ serverName, resource: "ConfigMap" }, "Deleted ConfigMap"); } catch (err: any) { if (err.response?.statusCode !== 404) { - console.error(`Error deleting ConfigMap for server ${serverName}:`, err); + logger.error({ err, serverName, resource: "ConfigMap" }, "Failed to delete ConfigMap"); } } } diff --git a/packages/k8s-operator/src/scripts/apply-crds.ts b/packages/k8s-operator/src/scripts/apply-crds.ts index 40f2bcb..e905ba4 100644 --- a/packages/k8s-operator/src/scripts/apply-crds.ts +++ b/packages/k8s-operator/src/scripts/apply-crds.ts @@ -1,9 +1,9 @@ -import { KubernetesClient } from "../utils/k8s-client"; -import { registerRBACResources } from "../utils/rbac-registrar"; -import { setupCRDRegistration } from "../utils/crd-registrar"; -import { NAMESPACE } from "../config/constants"; import { PrismaClient } from "@minikura/db"; import { dotenvLoad } from "dotenv-mono"; +import { NAMESPACE } from "../config/constants"; +import { setupCRDRegistration } from "../utils/crd-registrar"; +import { KubernetesClient } from "../utils/k8s-client"; +import { registerRBACResources } from "../utils/rbac-registrar"; dotenvLoad(); diff --git a/packages/k8s-operator/src/services/notification.service.ts b/packages/k8s-operator/src/services/notification.service.ts index 997530a..092202e 100644 --- a/packages/k8s-operator/src/services/notification.service.ts +++ b/packages/k8s-operator/src/services/notification.service.ts @@ -1,5 +1,8 @@ +import { createLogger } from "@minikura/shared"; import pg from "pg"; +const logger = createLogger("notification-service"); + export class NotificationService { private pgClient: pg.Client | null = null; private handlers = new Map void | Promise>>(); @@ -9,7 +12,7 @@ export class NotificationService { throw new Error("Database connection string is required"); } - console.log("\n[NotificationService] Connecting to PostgreSQL..."); + logger.info("Connecting to PostgreSQL"); this.pgClient = new pg.Client({ connectionString }); await this.pgClient.connect(); @@ -19,27 +22,21 @@ export class NotificationService { try { const payload = msg.payload ? JSON.parse(msg.payload) : {}; - console.log( - `\n[NotificationService] Received notification on channel '${msg.channel}':`, - payload - ); + logger.info({ channel: msg.channel, payload }, "Received notification"); for (const handler of handlers) { try { await handler(payload); } catch (err) { - console.error( - `[NotificationService] Error in handler for channel '${msg.channel}':`, - err - ); + logger.error({ err, channel: msg.channel }, "Error in notification handler"); } } } catch (err) { - console.error(`[NotificationService] Failed to parse notification payload:`, err); + logger.error({ err }, "Failed to parse notification payload"); } }); - console.log("[NotificationService] Connected successfully"); + logger.info("Connected to PostgreSQL successfully"); } async listen( @@ -53,10 +50,10 @@ export class NotificationService { if (!this.handlers.has(channel)) { this.handlers.set(channel, new Set()); await this.pgClient.query(`LISTEN ${channel}`); - console.log(`[NotificationService] Listening on channel: ${channel}`); + logger.info({ channel }, "Listening on channel"); } - this.handlers.get(channel)!.add(handler); + this.handlers.get(channel)?.add(handler); } async unlisten(channel: string): Promise { @@ -64,17 +61,17 @@ export class NotificationService { this.handlers.delete(channel); await this.pgClient.query(`UNLISTEN ${channel}`); - console.log(`[NotificationService] Stopped listening on channel: ${channel}`); + logger.info({ channel }, "Stopped listening on channel"); } async disconnect(): Promise { if (!this.pgClient) return; - console.log("\n[NotificationService] Disconnecting..."); + logger.info("Disconnecting from PostgreSQL"); await this.pgClient.end(); this.pgClient = null; this.handlers.clear(); - console.log("[NotificationService] Disconnected"); + logger.info("Disconnected from PostgreSQL"); } isConnected(): boolean { diff --git a/packages/k8s-operator/src/types/index.ts b/packages/k8s-operator/src/types/index.ts index 297ed46..eb4be62 100644 --- a/packages/k8s-operator/src/types/index.ts +++ b/packages/k8s-operator/src/types/index.ts @@ -1,9 +1,7 @@ import type { - ServerType, - ReverseProxyServerType, - Server as PrismaServer, - ReverseProxyServer as PrismaReverseProxyServer, CustomEnvironmentVariable, + ReverseProxyServer as PrismaReverseProxyServer, + Server as PrismaServer, } from "@minikura/db"; // Base interface @@ -21,7 +19,7 @@ export interface CustomResource { export type ServerConfig = Pick< PrismaServer, - "id" | "description" | "type" | "listen_port" | "memory" + "id" | "description" | "type" | "listen_port" | "memory" | "service_type" > & { apiKey: string; env_variables?: Array>; @@ -51,7 +49,14 @@ export interface MinecraftServerCRD extends CustomResource { export type ReverseProxyConfig = Pick< PrismaReverseProxyServer, - "id" | "description" | "external_address" | "external_port" | "listen_port" | "type" | "memory" + | "id" + | "description" + | "external_address" + | "external_port" + | "listen_port" + | "type" + | "memory" + | "service_type" > & { apiKey: string; env_variables?: Array>; diff --git a/packages/k8s-operator/src/types/k8s-types.ts b/packages/k8s-operator/src/types/k8s-types.ts new file mode 100644 index 0000000..1bbe9e6 --- /dev/null +++ b/packages/k8s-operator/src/types/k8s-types.ts @@ -0,0 +1,25 @@ +import type * as k8s from "@kubernetes/client-node"; + +export interface K8sApiError extends Error { + code?: number; + body?: string; + headers?: Record; +} + +export interface CustomResourceResponse { + metadata?: k8s.V1ObjectMeta; + spec?: T; + status?: Record; + body?: CustomResourceResponse; +} + +export interface CustomResourceListResponse { + items?: CustomResourceResponse[]; + body?: { + items?: CustomResourceResponse[]; + }; +} + +export function isK8sApiError(error: unknown): error is K8sApiError { + return error instanceof Error && ("code" in error || "body" in error || "headers" in error); +} diff --git a/packages/k8s-operator/src/utils/crd-registrar.ts b/packages/k8s-operator/src/utils/crd-registrar.ts index 0f8da8d..46171df 100644 --- a/packages/k8s-operator/src/utils/crd-registrar.ts +++ b/packages/k8s-operator/src/utils/crd-registrar.ts @@ -1,61 +1,134 @@ +import type * as k8s from "@kubernetes/client-node"; import type { PrismaClient } from "@minikura/db"; -import type { Server, ReverseProxyServer, CustomEnvironmentVariable } from "@minikura/db"; -import type { KubernetesClient } from "./k8s-client"; import { API_GROUP, API_VERSION, LABEL_PREFIX } from "../config/constants"; -import { MINECRAFT_SERVER_CRD } from "../crds/server"; import { REVERSE_PROXY_SERVER_CRD } from "../crds/reverseProxy"; +import { MINECRAFT_SERVER_CRD } from "../crds/server"; +import type { CustomResourceListResponse, CustomResourceResponse } from "../types/k8s-types"; +import { isK8sApiError } from "../types/k8s-types"; +import type { KubernetesClient } from "./k8s-client"; +import { logger } from "./logger"; + +async function retryWithBackoff( + operation: () => Promise, + options: { + maxRetries?: number; + initialDelay?: number; + maxDelay?: number; + operationName?: string; + } = {} +): Promise { + const { + maxRetries = 5, + initialDelay = 1000, + maxDelay = 10000, + operationName = "operation", + } = options; + + let lastError: Error | undefined; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await operation(); + } catch (error: unknown) { + lastError = error as Error; + + const is429 = isK8sApiError(error) && error.code === 429; + const isStorageInitializing = + isK8sApiError(error) && error.body?.includes("storage is (re)initializing"); + + if (!is429 && !isStorageInitializing) { + throw error; + } + + if (attempt === maxRetries) { + logger.error({ operationName, maxRetries }, "Operation failed after max retries"); + throw error; + } + + let delay = initialDelay * 2 ** attempt; + if (isK8sApiError(error) && error.headers?.["retry-after"]) { + const retryAfter = parseInt(error.headers["retry-after"], 10); + if (!Number.isNaN(retryAfter)) { + delay = retryAfter * 1000; + } + } + delay = Math.min(delay, maxDelay); + + const errorMessage = error instanceof Error ? error.message : String(error); + logger.warn( + { + operationName, + attempt: attempt + 1, + maxAttempts: maxRetries + 1, + delayMs: delay, + errorMessage, + }, + "Operation failed, retrying with backoff" + ); + + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + throw lastError; +} -/** - * Sets up the CRD registration and starts a reflector to sync database state to CRDs - */ export async function setupCRDRegistration( prisma: PrismaClient, k8sClient: KubernetesClient, namespace: string ): Promise { await registerCRDs(k8sClient); + + logger.info("Waiting for Kubernetes storage to stabilize after CRD registration"); + await new Promise((resolve) => setTimeout(resolve, 2000)); + await startCRDReflector(prisma, k8sClient, namespace); } -/** - * Registers the CRDs with the Kubernetes API - */ async function registerCRDs(k8sClient: KubernetesClient): Promise { try { const apiExtensionsClient = k8sClient.getApiExtensionsApi(); - console.log("Registering CRDs..."); + logger.info( + { apiGroup: API_GROUP, apiVersion: API_VERSION }, + "Registering Custom Resource Definitions" + ); try { await apiExtensionsClient.createCustomResourceDefinition({ body: MINECRAFT_SERVER_CRD }); - console.log(`MinecraftServer CRD created successfully (${API_GROUP}/${API_VERSION})`); + logger.info( + { crd: "MinecraftServer", apiGroup: API_GROUP, apiVersion: API_VERSION }, + "CRD created successfully" + ); } catch (error: any) { - if (error.response?.statusCode === 409) { - console.log("MinecraftServer CRD already exists"); + if (error.code === 409) { + logger.debug("MinecraftServer CRD already exists, skipping creation"); } else { - console.error("Error creating MinecraftServer CRD:", error); + logger.error({ err: error }, "Failed to create MinecraftServer CRD"); + throw error; } } try { await apiExtensionsClient.createCustomResourceDefinition({ body: REVERSE_PROXY_SERVER_CRD }); - console.log(`ReverseProxyServer CRD created successfully (${API_GROUP}/${API_VERSION})`); + logger.info( + { crd: "ReverseProxyServer", apiGroup: API_GROUP, apiVersion: API_VERSION }, + "CRD created successfully" + ); } catch (error: any) { - if (error.response?.statusCode === 409) { - console.log("ReverseProxyServer CRD already exists"); + if (error.code === 409) { + logger.debug("ReverseProxyServer CRD already exists, skipping creation"); } else { - console.error("Error creating ReverseProxyServer CRD:", error); + logger.error({ err: error }, "Failed to create ReverseProxyServer CRD"); + throw error; } } } catch (error) { - console.error("Error registering CRDs:", error); + logger.error({ err: error }, "Failed to register CRDs"); throw error; } } -/** - * Starts a reflector to sync database state to CRDs - */ async function startCRDReflector( prisma: PrismaClient, k8sClient: KubernetesClient, @@ -63,13 +136,11 @@ async function startCRDReflector( ): Promise { const customObjectsApi = k8sClient.getCustomObjectsApi(); - // Keep track which server IDs have corresponding CRs - const reflectedMinecraftServers = new Map(); // DB ID -> CR name - const reflectedReverseProxyServers = new Map(); // DB ID -> CR name + const reflectedMinecraftServers = new Map(); + const reflectedReverseProxyServers = new Map(); - console.log("Starting CRD reflector..."); + logger.info("Starting CRD reflector to sync database state to custom resources"); - // Initial sync to create CRs that reflect the DB state await syncDBtoCRDs( prisma, customObjectsApi, @@ -78,8 +149,6 @@ async function startCRDReflector( reflectedReverseProxyServers ); - // Polling interval to check for changes in the DB - // TODO: Make this listener instead setInterval(async () => { await syncDBtoCRDs( prisma, @@ -91,18 +160,15 @@ async function startCRDReflector( }, 30 * 1000); } -/** - * Synchronizes database state to CRDs - */ async function syncDBtoCRDs( prisma: PrismaClient, - customObjectsApi: any, + customObjectsApi: k8s.CustomObjectsApi, namespace: string, reflectedMinecraftServers: Map, reflectedReverseProxyServers: Map ): Promise { try { - console.log(`[${new Date().toISOString()}] Starting CRD sync operation...`); + logger.debug("Starting CRD sync operation"); await syncMinecraftServers(prisma, customObjectsApi, namespace, reflectedMinecraftServers); await syncReverseProxyServers( prisma, @@ -110,18 +176,15 @@ async function syncDBtoCRDs( namespace, reflectedReverseProxyServers ); - console.log(`[${new Date().toISOString()}] CRD sync operation completed`); + logger.debug("CRD sync operation completed successfully"); } catch (error) { - console.error(`[${new Date().toISOString()}] Error syncing database to CRDs:`, error); + logger.error({ err: error }, "Failed to sync database to CRDs"); } } -/** - * Synchronizes Minecraft server objects from the database to CRDs - */ async function syncMinecraftServers( prisma: PrismaClient, - customObjectsApi: any, + customObjectsApi: k8s.CustomObjectsApi, namespace: string, reflectedMinecraftServers: Map ): Promise { @@ -130,43 +193,48 @@ async function syncMinecraftServers( let existingCRs: any[] = []; try { - const response = await customObjectsApi.listNamespacedCustomObject( - API_GROUP, - API_VERSION, - namespace, - "minecraftservers" + const response = await retryWithBackoff( + () => + customObjectsApi.listNamespacedCustomObject({ + group: API_GROUP, + version: API_VERSION, + namespace, + plural: "minecraftservers", + }), + { + maxRetries: 5, + initialDelay: 1000, + operationName: "List MinecraftServer CRs", + } ); - existingCRs = (response.body as any).items || []; + const listResponse = response as unknown as CustomResourceListResponse; + existingCRs = listResponse.body?.items || listResponse.items || []; } catch (error) { - console.error("Error listing MinecraftServer CRs:", error); - // TODO: Potentially better error handling here - // For now, continue anyway - it might just be that none exist yet + logger.error( + { err: error }, + "Failed to list MinecraftServer custom resources, assuming none exist" + ); + existingCRs = []; } - // Map CR names to their corresponding DB IDs const existingCRMap = new Map(); - // Map of CR names to their resourceVersions for updates const crResourceVersions = new Map(); for (const cr of existingCRs) { const internalId = cr.status?.internalId; if (internalId) { existingCRMap.set(internalId, cr.metadata.name); - // Store the resourceVersion for later if (cr.metadata?.resourceVersion) { crResourceVersions.set(cr.metadata.name, cr.metadata.resourceVersion); } } } - // Refresh tracking map reflectedMinecraftServers.clear(); - // Create or update CRs for each server for (const server of servers) { const crName = existingCRMap.get(server.id) || `${server.id.toLowerCase()}`; - // Build the CR object const serverCR: { apiVersion: string; kind: string; @@ -194,97 +262,148 @@ async function syncMinecraftServers( description: server.description, listen_port: server.listen_port, type: server.type, - memory: server.memory, + memory: `${server.memory}M`, }, status: { phase: "Running", message: "Managed by database", internalId: server.id, - apiKey: "[REDACTED]", // Don't expose actual API key + apiKey: "[REDACTED]", lastSyncedAt: new Date().toISOString(), }, }; try { - if (existingCRMap.has(server.id)) { - // Update existing CR - - // Get the current resource first - const crName = existingCRMap.get(server.id)!; - + const existingCRName = existingCRMap.get(server.id); + if (existingCRName) { try { - // Get the existing resource to get the current resourceVersion - const existingResource = await customObjectsApi.getNamespacedCustomObject( - API_GROUP, - API_VERSION, + const existingResource = await customObjectsApi.getNamespacedCustomObject({ + group: API_GROUP, + version: API_VERSION, namespace, - "minecraftservers", - crName - ); + plural: "minecraftservers", + name: existingCRName, + }); - // Extract the resourceVersion from the existing resource - if (existingResource?.body?.metadata?.resourceVersion) { - // Add the resourceVersion to our custom resource - serverCR.metadata.resourceVersion = existingResource.body.metadata.resourceVersion; + const resourceResponse = existingResource as CustomResourceResponse; + const resource = resourceResponse.body || resourceResponse; + if (resource?.metadata?.resourceVersion) { + serverCR.metadata.resourceVersion = resource.metadata.resourceVersion; } - // Now update with the correct resourceVersion - await customObjectsApi.replaceNamespacedCustomObject( - API_GROUP, - API_VERSION, + await customObjectsApi.replaceNamespacedCustomObject({ + group: API_GROUP, + version: API_VERSION, namespace, - "minecraftservers", - crName, - serverCR + plural: "minecraftservers", + name: existingCRName, + body: serverCR, + }); + logger.debug( + { crName: existingCRName, serverId: server.id }, + "Updated MinecraftServer custom resource" ); - console.log(`Updated MinecraftServer CR ${crName} for server ${server.id}`); } catch (error) { - console.error(`Error getting/updating MinecraftServer CR for ${server.id}:`, error); + logger.error( + { err: error, serverId: server.id }, + "Failed to get/update MinecraftServer custom resource" + ); } } else { - // Create new CR - await customObjectsApi.createNamespacedCustomObject( - API_GROUP, - API_VERSION, - namespace, - "minecraftservers", - serverCR - ); - console.log(`Created MinecraftServer CR ${crName} for server ${server.id}`); + try { + await customObjectsApi.createNamespacedCustomObject({ + group: API_GROUP, + version: API_VERSION, + namespace, + plural: "minecraftservers", + body: serverCR, + }); + logger.debug( + { crName, serverId: server.id }, + "Created MinecraftServer custom resource" + ); + } catch (createError: any) { + if (createError.code === 409) { + logger.debug({ crName }, "MinecraftServer CR already exists, updating instead"); + try { + const existingResource = await customObjectsApi.getNamespacedCustomObject({ + group: API_GROUP, + version: API_VERSION, + namespace, + plural: "minecraftservers", + name: crName, + }); + + const resourceResponse = existingResource as CustomResourceResponse; + let resource = resourceResponse.body || resourceResponse; + if (!resource?.metadata && resourceResponse.metadata) { + resource = resourceResponse; + } + + if (resource?.metadata?.resourceVersion) { + serverCR.metadata.resourceVersion = resource.metadata.resourceVersion; + + await customObjectsApi.replaceNamespacedCustomObject({ + group: API_GROUP, + version: API_VERSION, + namespace, + plural: "minecraftservers", + name: crName, + body: serverCR, + }); + logger.debug( + { crName, serverId: server.id }, + "Updated existing MinecraftServer custom resource" + ); + } else { + logger.error({ crName }, "Cannot update CR: no resourceVersion in response"); + } + } catch (updateError) { + logger.error( + { err: updateError, crName }, + "Failed to update MinecraftServer custom resource" + ); + throw updateError; + } + } else { + throw createError; + } + } } - // Remember this mapping reflectedMinecraftServers.set(server.id, crName); } catch (error) { - console.error(`Error creating/updating MinecraftServer CR for ${server.id}:`, error); + logger.error( + { err: error, serverId: server.id }, + "Failed to create/update MinecraftServer custom resource" + ); } } - // Delete CRs for servers that no longer exist for (const [dbId, crName] of existingCRMap.entries()) { if (!servers.some((s) => s.id === dbId)) { try { - await customObjectsApi.deleteNamespacedCustomObject( - API_GROUP, - API_VERSION, + await customObjectsApi.deleteNamespacedCustomObject({ + group: API_GROUP, + version: API_VERSION, namespace, - "minecraftservers", - crName + plural: "minecraftservers", + name: crName, + }); + logger.info( + { crName, serverId: dbId }, + "Deleted MinecraftServer CR for removed database record" ); - console.log(`Deleted MinecraftServer CR ${crName} for removed server ID ${dbId}`); } catch (error) { - console.error(`Error deleting MinecraftServer CR ${crName}:`, error); + logger.error({ err: error, crName }, "Failed to delete MinecraftServer custom resource"); } } } } catch (error) { - console.error("Error syncing Minecraft servers to CRDs:", error); + logger.error({ err: error }, "Failed to sync Minecraft servers to custom resources"); } } -/** - * Synchronizes Reverse Proxy server objects from the database to CRDs - */ async function syncReverseProxyServers( prisma: PrismaClient, customObjectsApi: any, @@ -300,43 +419,48 @@ async function syncReverseProxyServers( let existingCRs: any[] = []; try { - const response = await customObjectsApi.listNamespacedCustomObject( - API_GROUP, - API_VERSION, - namespace, - "reverseproxyservers" + const response = await retryWithBackoff( + () => + customObjectsApi.listNamespacedCustomObject({ + group: API_GROUP, + version: API_VERSION, + namespace, + plural: "reverseproxyservers", + }), + { + maxRetries: 5, + initialDelay: 1000, + operationName: "List ReverseProxyServer CRs", + } ); - existingCRs = (response.body as any).items || []; + const listResponse = response as unknown as CustomResourceListResponse; + existingCRs = listResponse.body?.items || listResponse.items || []; } catch (error) { - console.error("Error listing ReverseProxyServer CRs:", error); - // TODO: Potentially better error handling here - // For now, continue anyway - it might just be that none exist yet + logger.error( + { err: error }, + "Failed to list ReverseProxyServer custom resources, assuming none exist" + ); + existingCRs = []; } - // Map CR names to their corresponding DB IDs const existingCRMap = new Map(); - // Map of CR names to their resourceVersions for updates const crResourceVersions = new Map(); for (const cr of existingCRs) { const internalId = cr.status?.internalId; if (internalId) { existingCRMap.set(internalId, cr.metadata.name); - // Store the resourceVersion for later if (cr.metadata?.resourceVersion) { crResourceVersions.set(cr.metadata.name, cr.metadata.resourceVersion); } } } - // Refresh tracking map reflectedReverseProxyServers.clear(); - // Create or update CRs for each proxy for (const proxy of proxies) { const crName = existingCRMap.get(proxy.id) || `${proxy.id.toLowerCase()}`; - // Build the CR object const proxyCR: { apiVersion: string; kind: string; @@ -366,7 +490,7 @@ async function syncReverseProxyServers( external_port: proxy.external_port, listen_port: proxy.listen_port, type: proxy.type, - memory: proxy.memory, + memory: `${proxy.memory}M`, environmentVariables: proxy.env_variables?.map((ev) => ({ key: ev.key, value: ev.value, @@ -376,84 +500,134 @@ async function syncReverseProxyServers( phase: "Running", message: "Managed by database", internalId: proxy.id, - apiKey: "[REDACTED]", // Don't expose actual API key + apiKey: "[REDACTED]", lastSyncedAt: new Date().toISOString(), }, }; try { - if (existingCRMap.has(proxy.id)) { - // Update existing CR - - // Get the current resource first - const crName = existingCRMap.get(proxy.id)!; - + const existingCRName = existingCRMap.get(proxy.id); + if (existingCRName) { try { - // Get the existing resource to get the current resourceVersion - const existingResource = await customObjectsApi.getNamespacedCustomObject( - API_GROUP, - API_VERSION, + const existingResource = await customObjectsApi.getNamespacedCustomObject({ + group: API_GROUP, + version: API_VERSION, namespace, - "reverseproxyservers", - crName - ); + plural: "reverseproxyservers", + name: existingCRName, + }); - // Extract the resourceVersion from the existing resource - if (existingResource?.body?.metadata?.resourceVersion) { - // Add the resourceVersion to our custom resource - proxyCR.metadata.resourceVersion = existingResource.body.metadata.resourceVersion; + const resourceResponse = existingResource as CustomResourceResponse; + const resource = resourceResponse.body || resourceResponse; + if (resource?.metadata?.resourceVersion) { + proxyCR.metadata.resourceVersion = resource.metadata.resourceVersion; } - // Now update with the correct resourceVersion - await customObjectsApi.replaceNamespacedCustomObject( - API_GROUP, - API_VERSION, + await customObjectsApi.replaceNamespacedCustomObject({ + group: API_GROUP, + version: API_VERSION, namespace, - "reverseproxyservers", - crName, - proxyCR + plural: "reverseproxyservers", + name: existingCRName, + body: proxyCR, + }); + logger.debug( + { crName: existingCRName, proxyId: proxy.id }, + "Updated ReverseProxyServer custom resource" ); - console.log(`Updated ReverseProxyServer CR ${crName} for proxy ${proxy.id}`); } catch (error) { - console.error(`Error getting/updating ReverseProxyServer CR for ${proxy.id}:`, error); + logger.error( + { err: error, proxyId: proxy.id }, + "Failed to get/update ReverseProxyServer custom resource" + ); } } else { - // Create new CR - await customObjectsApi.createNamespacedCustomObject( - API_GROUP, - API_VERSION, - namespace, - "reverseproxyservers", - proxyCR - ); - console.log(`Created ReverseProxyServer CR ${crName} for proxy ${proxy.id}`); + try { + await customObjectsApi.createNamespacedCustomObject({ + group: API_GROUP, + version: API_VERSION, + namespace, + plural: "reverseproxyservers", + body: proxyCR, + }); + logger.debug( + { crName, proxyId: proxy.id }, + "Created ReverseProxyServer custom resource" + ); + } catch (createError: any) { + if (createError.code === 409) { + logger.debug({ crName }, "ReverseProxyServer CR already exists, updating instead"); + try { + const existingResource = await customObjectsApi.getNamespacedCustomObject({ + group: API_GROUP, + version: API_VERSION, + namespace, + plural: "reverseproxyservers", + name: crName, + }); + + const resource = existingResource.body as any; + if (resource?.metadata?.resourceVersion) { + proxyCR.metadata.resourceVersion = resource.metadata.resourceVersion; + } + + await customObjectsApi.replaceNamespacedCustomObject({ + group: API_GROUP, + version: API_VERSION, + namespace, + plural: "reverseproxyservers", + name: crName, + body: proxyCR, + }); + logger.debug( + { crName, proxyId: proxy.id }, + "Updated existing ReverseProxyServer custom resource" + ); + } catch (updateError) { + logger.error( + { err: updateError, crName }, + "Failed to update ReverseProxyServer custom resource" + ); + throw updateError; + } + } else { + throw createError; + } + } } - // Remember this mapping reflectedReverseProxyServers.set(proxy.id, crName); } catch (error) { - console.error(`Error creating/updating ReverseProxyServer CR for ${proxy.id}:`, error); + logger.error( + { err: error, proxyId: proxy.id }, + "Failed to create/update ReverseProxyServer custom resource" + ); } } - // Delete CRs for proxies that no longer exist for (const [dbId, crName] of existingCRMap.entries()) { if (!proxies.some((p) => p.id === dbId)) { try { - await customObjectsApi.deleteNamespacedCustomObject( - API_GROUP, - API_VERSION, + await customObjectsApi.deleteNamespacedCustomObject({ + group: API_GROUP, + version: API_VERSION, namespace, - "reverseproxyservers", - crName + plural: "reverseproxyservers", + name: crName, + }); + logger.info( + { crName, proxyId: dbId }, + "Deleted ReverseProxyServer CR for removed database record" ); - console.log(`Deleted ReverseProxyServer CR ${crName} for removed proxy ID ${dbId}`); } catch (error) { - console.error(`Error deleting ReverseProxyServer CR ${crName}:`, error); + logger.error( + { err: error, crName }, + "Failed to delete ReverseProxyServer custom resource" + ); } } } } catch (error) { - console.error("Error syncing Reverse Proxy servers to CRDs:", error); + logger.error({ err: error }, "Failed to sync reverse proxy servers to custom resources"); } } diff --git a/packages/k8s-operator/src/utils/errors.ts b/packages/k8s-operator/src/utils/errors.ts deleted file mode 100644 index 8e75ef0..0000000 --- a/packages/k8s-operator/src/utils/errors.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const getErrorMessage = (error: unknown): string => { - if (error instanceof Error) { - return error.message; - } - if (typeof error === "string") { - return error; - } - return "Unknown error"; -}; diff --git a/packages/k8s-operator/src/utils/k8s-client.ts b/packages/k8s-operator/src/utils/k8s-client.ts index 969e4a9..22e7493 100644 --- a/packages/k8s-operator/src/utils/k8s-client.ts +++ b/packages/k8s-operator/src/utils/k8s-client.ts @@ -1,5 +1,6 @@ import * as k8s from "@kubernetes/client-node"; -import { SKIP_TLS_VERIFY, NAMESPACE } from "../config/constants"; +import { buildKubeConfig } from "@minikura/shared/kube-auth"; +import { logger } from "./logger"; export class KubernetesClient { private static instance: KubernetesClient; @@ -11,13 +12,7 @@ export class KubernetesClient { private apiExtensionsApi!: k8s.ApiextensionsV1Api; private constructor() { - if (SKIP_TLS_VERIFY) { - console.log("Disabling TLS certificate validation"); - process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; - } - - this.kc = new k8s.KubeConfig(); - this.setupConfig(); + this.kc = buildKubeConfig(); this.initializeClients(); } @@ -28,34 +23,6 @@ export class KubernetesClient { return KubernetesClient.instance; } - private setupConfig(): void { - try { - this.kc.loadFromDefault(); - console.log("Loaded Kubernetes config from default location"); - } catch (err) { - console.warn("Failed to load Kubernetes config from default location:", err); - } - - // Running in a cluster, try to load in-cluster config - if (!this.kc.getCurrentContext()) { - try { - this.kc.loadFromCluster(); - console.log("Loaded Kubernetes config from cluster"); - } catch (err) { - console.warn("Failed to load Kubernetes config from cluster:", err); - } - } - - if (!this.kc.getCurrentContext()) { - throw new Error("Failed to setup Kubernetes client - no valid configuration found"); - } - - const currentCluster = this.kc.getCurrentCluster(); - if (currentCluster) { - console.log(`Connecting to Kubernetes server: ${currentCluster.server}`); - } - } - private initializeClients(): void { this.appsApi = this.kc.makeApiClient(k8s.AppsV1Api); this.coreApi = this.kc.makeApiClient(k8s.CoreV1Api); @@ -89,12 +56,15 @@ export class KubernetesClient { } async handleApiError(error: any, context: string): Promise { - console.error(`Kubernetes API error (${context}):`, error?.message || error); - - if (error?.response) { - console.error(`Response status: ${error.response.statusCode}`); - console.error(`Response body: ${JSON.stringify(error.response.body)}`); - } + logger.error( + { + context, + message: error?.message, + statusCode: error?.response?.statusCode, + body: error?.response?.body, + }, + "Kubernetes API error" + ); throw error; } diff --git a/packages/k8s-operator/src/utils/kube-auth.ts b/packages/k8s-operator/src/utils/kube-auth.ts deleted file mode 100644 index b0835ff..0000000 --- a/packages/k8s-operator/src/utils/kube-auth.ts +++ /dev/null @@ -1,108 +0,0 @@ -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; -} diff --git a/packages/k8s-operator/src/utils/logger.ts b/packages/k8s-operator/src/utils/logger.ts new file mode 100644 index 0000000..5242a08 --- /dev/null +++ b/packages/k8s-operator/src/utils/logger.ts @@ -0,0 +1,5 @@ +import { createLogger } from "@minikura/shared"; + +export { createLogger }; + +export const logger = createLogger("k8s-operator"); diff --git a/packages/k8s-operator/src/utils/rbac-registrar.ts b/packages/k8s-operator/src/utils/rbac-registrar.ts index 6a65655..ba6e01e 100644 --- a/packages/k8s-operator/src/utils/rbac-registrar.ts +++ b/packages/k8s-operator/src/utils/rbac-registrar.ts @@ -1,84 +1,80 @@ -import type { KubernetesClient } from "./k8s-client"; +import fetch from "node-fetch"; import { - minikuraNamespace, - minikuraServiceAccount, minikuraClusterRole, minikuraClusterRoleBinding, + minikuraNamespace, minikuraOperatorDeployment, + minikuraServiceAccount, } from "../crds/rbac"; -import fetch from "node-fetch"; +import type { KubernetesClient } from "./k8s-client"; +import { logger } from "./logger"; -/** - * Registers all RBAC resources required - * @param k8sClient The Kubernetes client instance - */ export async function registerRBACResources(k8sClient: KubernetesClient): Promise { try { - console.log("Starting RBAC resources registration..."); + logger.info("Starting RBAC resources registration"); await registerNamespace(k8sClient); await registerServiceAccount(k8sClient); await registerClusterRole(k8sClient); await registerClusterRoleBinding(k8sClient); - console.log("RBAC resources registration completed successfully"); + logger.info("RBAC resources registration completed successfully"); } catch (error: any) { - console.error("Error registering RBAC resources:", error.message); + logger.error( + { + err: error, + message: error.message, + statusCode: error.response?.statusCode, + body: error.response?.body, + }, + "Error registering RBAC resources" + ); if (error.response) { - console.error(`Response status: ${error.response.statusCode}`); - console.error(`Response body: ${JSON.stringify(error.response.body)}`); } throw error; } } -/** - * Registers the namespace - */ async function registerNamespace(k8sClient: KubernetesClient): Promise { try { const coreApi = k8sClient.getCoreApi(); await coreApi.createNamespace({ body: minikuraNamespace }); - console.log(`Created namespace ${minikuraNamespace.metadata.name}`); + logger.info({ namespace: minikuraNamespace.metadata.name }, "Created namespace"); } catch (error: any) { - if (error.response?.statusCode === 409) { - console.log(`Namespace ${minikuraNamespace.metadata.name} already exists`); + if (error.code === 409) { + logger.debug({ namespace: minikuraNamespace.metadata.name }, "Namespace already exists"); } else { throw error; } } } -/** - * Registers the service account - */ async function registerServiceAccount(k8sClient: KubernetesClient): Promise { try { const coreApi = k8sClient.getCoreApi(); await coreApi.createNamespacedServiceAccount({ namespace: minikuraServiceAccount.metadata.namespace, - body: minikuraServiceAccount + body: minikuraServiceAccount, }); - console.log(`Created service account ${minikuraServiceAccount.metadata.name}`); + logger.info( + { serviceAccount: minikuraServiceAccount.metadata.name }, + "Created service account" + ); } catch (error: any) { - if (error.response?.statusCode === 409) { - console.log(`Service account ${minikuraServiceAccount.metadata.name} already exists`); + if (error.code === 409) { + logger.debug(`Service account ${minikuraServiceAccount.metadata.name} already exists`); } else { throw error; } } } -/** - * Registers the cluster role - */ async function registerClusterRole(k8sClient: KubernetesClient): Promise { try { const kc = k8sClient.getKubeConfig(); const opts: any = {}; await kc.applyToHTTPSOptions(opts); - // Get cluster URL const cluster = kc.getCurrentCluster(); if (!cluster) { throw new Error("No active cluster found in KubeConfig"); @@ -99,9 +95,9 @@ async function registerClusterRole(k8sClient: KubernetesClient): Promise { ); if (response.ok) { - console.log(`Created cluster role ${minikuraClusterRole.metadata.name}`); + logger.debug(`Created cluster role ${minikuraClusterRole.metadata.name}`); } else if (response.status === 409) { - console.log(`Cluster role ${minikuraClusterRole.metadata.name} already exists`); + logger.debug(`Cluster role ${minikuraClusterRole.metadata.name} already exists`); } else { const text = await response.text(); throw new Error( @@ -109,35 +105,29 @@ async function registerClusterRole(k8sClient: KubernetesClient): Promise { ); } } catch (error: any) { - // If the error message contains "already exists", that's OK if (error.message?.includes("already exists") || error.message?.includes("409")) { - console.log(`Cluster role ${minikuraClusterRole.metadata.name} already exists`); + logger.debug(`Cluster role ${minikuraClusterRole.metadata.name} already exists`); } else { throw error; } } } catch (error: any) { - console.error(`Error registering cluster role:`, error.message); + logger.error(`Error registering cluster role:`, error.message); throw error; } } -/** - * Registers the Minikura cluster role binding - */ async function registerClusterRoleBinding(k8sClient: KubernetesClient): Promise { try { const kc = k8sClient.getKubeConfig(); const opts: any = {}; await kc.applyToHTTPSOptions(opts); - // Get cluster URL const cluster = kc.getCurrentCluster(); if (!cluster) { throw new Error("No active cluster found in KubeConfig"); } - // Create the cluster role binding const { default: fetch } = await import("node-fetch"); try { @@ -155,9 +145,9 @@ async function registerClusterRoleBinding(k8sClient: KubernetesClient): Promise< ); if (response.ok) { - console.log(`Created cluster role binding ${minikuraClusterRoleBinding.metadata.name}`); + logger.debug(`Created cluster role binding ${minikuraClusterRoleBinding.metadata.name}`); } else if (response.status === 409) { - console.log( + logger.debug( `Cluster role binding ${minikuraClusterRoleBinding.metadata.name} already exists` ); } else { @@ -167,10 +157,8 @@ async function registerClusterRoleBinding(k8sClient: KubernetesClient): Promise< ); } } catch (error: any) { - // If the error message contains "already exists" - // TODO: Potentially better error handling here if (error.message?.includes("already exists") || error.message?.includes("409")) { - console.log( + logger.debug( `Cluster role binding ${minikuraClusterRoleBinding.metadata.name} already exists` ); } else { @@ -178,44 +166,36 @@ async function registerClusterRoleBinding(k8sClient: KubernetesClient): Promise< } } } catch (error: any) { - console.error(`Error registering cluster role binding:`, error.message); + logger.error(`Error registering cluster role binding:`, error.message); throw error; } } -/** - * Registers the Minikura operator deployment - * Note: This requires the secret to be created first - */ export async function registerOperatorDeployment( k8sClient: KubernetesClient, registryUrl: string ): Promise { try { - // Replace the registry URL placeholder, for future use const deployment = JSON.parse( JSON.stringify(minikuraOperatorDeployment).replace("${REGISTRY_URL}", registryUrl) ); const appsApi = k8sClient.getAppsApi(); await appsApi.createNamespacedDeployment(deployment.metadata.namespace, deployment); - console.log(`Created deployment ${deployment.metadata.name}`); + logger.debug(`Created deployment ${deployment.metadata.name}`); } catch (error: any) { - if (error.response?.statusCode === 409) { - console.log(`Deployment ${minikuraOperatorDeployment.metadata.name} already exists`); - // Update the deployment if it already exists + if (error.code === 409) { + logger.debug(`Deployment ${minikuraOperatorDeployment.metadata.name} already exists`); const deployment = JSON.parse( JSON.stringify(minikuraOperatorDeployment).replace("${REGISTRY_URL}", registryUrl) ); - await k8sClient - .getAppsApi() - .replaceNamespacedDeployment({ - name: deployment.metadata.name, - namespace: deployment.metadata.namespace, - body: deployment - }); - console.log(`Updated deployment ${deployment.metadata.name}`); + await k8sClient.getAppsApi().replaceNamespacedDeployment({ + name: deployment.metadata.name, + namespace: deployment.metadata.namespace, + body: deployment, + }); + logger.debug(`Updated deployment ${deployment.metadata.name}`); } else { throw error; } diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..f8d7beb --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,23 @@ +{ + "name": "@minikura/shared", + "version": "1.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./kube-auth": "./src/kube-auth.ts", + "./errors": "./src/errors.ts", + "./logger": "./src/logger.ts", + "./*": "./src/*.ts" + }, + "dependencies": { + "@kubernetes/client-node": "^1.4.0", + "pino": "^10.3.1", + "pino-pretty": "^13.1.3", + "undici": "^7.18.2", + "yaml": "^2.7.0" + }, + "devDependencies": { + "@types/node": "^25.0.9", + "typescript": "^5.9.3" + } +} diff --git a/packages/shared/src/errors.ts b/packages/shared/src/errors.ts new file mode 100644 index 0000000..dcf58af --- /dev/null +++ b/packages/shared/src/errors.ts @@ -0,0 +1,28 @@ +export function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (typeof error === "string") { + return error; + } + return String(error); +} + +/** Returns age string like "3d", "12h", "5m" */ +export function getAge(timestamp: Date | string | undefined): string { + if (!timestamp) return "unknown"; + + const now = new Date(); + const created = new Date(timestamp); + const diff = now.getTime() - created.getTime(); + + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) return `${days}d`; + if (hours > 0) return `${hours}h`; + if (minutes > 0) return `${minutes}m`; + return `${seconds}s`; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..929813e --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,3 @@ +export * from "./errors"; +export * from "./kube-auth"; +export * from "./logger"; diff --git a/packages/shared/src/kube-auth.ts b/packages/shared/src/kube-auth.ts new file mode 100644 index 0000000..432521b --- /dev/null +++ b/packages/shared/src/kube-auth.ts @@ -0,0 +1,24 @@ +import { existsSync } from "node:fs"; +import { KubeConfig } from "@kubernetes/client-node"; +import { createLogger } from "./logger"; + +const logger = createLogger("kube-auth"); + +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) { + logger.info("Running in-cluster, loading from service account"); + kc.loadFromCluster(); + } else { + logger.info("Running locally, loading from kubeconfig"); + kc.loadFromDefault(); + logger.info({ context: kc.getCurrentContext() }, "Using context"); + } + + return kc; +} diff --git a/packages/shared/src/logger.ts b/packages/shared/src/logger.ts new file mode 100644 index 0000000..6250e04 --- /dev/null +++ b/packages/shared/src/logger.ts @@ -0,0 +1,33 @@ +import pino from "pino"; + +export function createLogger(component: string): pino.Logger; +export function createLogger( + context: Record, +): pino.Logger; +export function createLogger( + componentOrContext: string | Record, +): pino.Logger { + const isString = typeof componentOrContext === "string"; + const baseContext = isString + ? { component: componentOrContext } + : componentOrContext; + + return pino({ + level: process.env.LOG_LEVEL || "info", + transport: + process.env.NODE_ENV !== "production" + ? { + target: "pino-pretty", + options: { + colorize: true, + translateTime: "HH:MM:ss.l", + ignore: "pid,hostname", + singleLine: false, + }, + } + : undefined, + base: baseContext, + }); +} + +export const logger = createLogger("shared"); diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..f2af597 --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "allowJs": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/scripts/install.sh b/scripts/install.sh index 0bae185..b9ad722 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,6 +1,5 @@ #!/bin/bash # Minikura Installer -# This script installs Minikura on a Kubernetes cluster set -e @@ -16,103 +15,47 @@ echo "" # Check prerequisites echo "-> Checking prerequisites..." if ! command -v kubectl &> /dev/null; then - echo "[ERROR] kubectl not found. Please install kubectl first." - exit 1 + echo "[WARN] kubectl not found. Skipping k8s setup." + echo "[INFO] Install kubectl and run 'bash scripts/install.sh' manually when ready." + exit 0 fi if ! kubectl cluster-info &> /dev/null; then - echo "[ERROR] Cannot connect to Kubernetes cluster. Please check your kubeconfig." - exit 1 + echo "[WARN] Cannot connect to Kubernetes cluster. Skipping k8s setup." + echo "[INFO] Run 'bash scripts/install.sh' manually when cluster is ready." + exit 0 fi echo "[OK] kubectl found" echo "[OK] Connected to Kubernetes cluster" echo "" -# Step 1: Create namespace +# Create namespace echo "-> Creating namespace: $NAMESPACE" -if kubectl get namespace $NAMESPACE &>/dev/null; then - echo "[OK] Namespace already exists" -else - kubectl create namespace $NAMESPACE - echo "[OK] Namespace created" -fi +kubectl create namespace $NAMESPACE --dry-run=client -o yaml | kubectl apply -f - echo "" -# Step 2: Apply RBAC -echo "-> Setting up RBAC (Service Accounts & Permissions)" -kubectl apply -f "$PROJECT_ROOT/k8s/rbac/dev-rbac.yaml" +# Apply RBAC (single SA for both backend and operator) +echo "-> Setting up RBAC (minikura-operator ServiceAccount)" kubectl apply -f "$PROJECT_ROOT/k8s/rbac/operator-rbac.yaml" echo "[OK] RBAC configured" -echo " • minikura-dev (read-only)" -echo " • minikura-operator (full access)" echo "" -# Step 3: Wait for tokens -echo "-> Waiting for service account tokens..." -sleep 3 - -DEV_TOKEN="" -OPERATOR_TOKEN="" -for i in {1..10}; do - DEV_TOKEN=$(kubectl get secret minikura-dev-token -n $NAMESPACE -o jsonpath='{.data.token}' 2>/dev/null | base64 -d || echo "") - OPERATOR_TOKEN=$(kubectl get secret minikura-operator-token -n $NAMESPACE -o jsonpath='{.data.token}' 2>/dev/null | base64 -d || echo "") - - if [ -n "$DEV_TOKEN" ] && [ -n "$OPERATOR_TOKEN" ]; then - break - fi - - sleep 2 -done - -if [ -z "$OPERATOR_TOKEN" ]; then - echo "[WARNING] Tokens not ready yet. You may need to run 'bun run k8s:token' later." -else - echo "[OK] Service account tokens generated" -fi +# CRD info +echo "-> Custom Resource Definitions" +echo " CRDs are auto-created when the operator starts (ENABLE_CRD_REFLECTION=true)" echo "" -# Step 4: CRD Information -echo "-> Custom Resource Definitions (CRDs)" -echo " CRDs will be automatically created when the operator starts" -echo " with ENABLE_CRD_REFLECTION=true (default)" -echo "" -echo " The operator will create:" -echo " • MinecraftServer CRD" -echo " • ReverseProxyServer CRD" -echo "" - -# Step 5: Configuration echo "╔════════════════════════════════════════════════╗" echo "║ Installation Complete ║" echo "╚════════════════════════════════════════════════╝" echo "" -echo "Kubernetes resources created:" +echo "Resources created:" echo " [OK] Namespace: $NAMESPACE" -echo " [OK] Service Accounts: minikura-dev, minikura-operator" -echo " [OK] RBAC: ClusterRoles and ClusterRoleBindings" -echo " [OK] Tokens: Ready for authentication" +echo " [OK] ServiceAccount: minikura-operator" +echo " [OK] ClusterRole + ClusterRoleBinding" echo "" echo "Next steps:" -echo "" -echo "1. Configure environment variables (.env):" -echo " KUBERNETES_NAMESPACE=\"$NAMESPACE\"" -echo " KUBERNETES_SKIP_TLS_VERIFY=\"true\" # For local dev only" -echo " KUBERNETES_SERVICE_ACCOUNT_TOKEN=\"\"" -echo "" -echo " To get the token automatically:" -echo " $ bun run k8s:token" -echo "" -echo "2. Start the operator:" -echo " $ bun run k8s:dev" -echo "" -echo " The operator will automatically:" -echo " - Create CRDs (MinecraftServer, ReverseProxyServer)" -echo " - Sync database state to Kubernetes" -echo " - Watch for changes and update resources" -echo "" -echo "3. Start the backend:" -echo " $ bun run dev" -echo "" -echo "For production deployment, see docs/DEPLOYMENT.md" +echo " bun run dev - Start backend + web" +echo " bun run k8s:dev - Start K8s operator" echo "" diff --git a/scripts/refresh-k8s-token.sh b/scripts/refresh-k8s-token.sh deleted file mode 100755 index a11e459..0000000 --- a/scripts/refresh-k8s-token.sh +++ /dev/null @@ -1,74 +0,0 @@ -#!/bin/bash -# Quick script to refresh the Kubernetes service account token -# Run this if you need to regenerate or check the token - -set -e - -NAMESPACE="${KUBERNETES_NAMESPACE:-minikura}" -SERVICE_ACCOUNT="minikura-backend" -SECRET_NAME="minikura-backend-token" - -echo "[INFO] Checking Kubernetes service account token..." -echo "" - -# Check if service account exists -if ! kubectl get serviceaccount $SERVICE_ACCOUNT -n $NAMESPACE &>/dev/null; then - echo "[ERROR] Service account '$SERVICE_ACCOUNT' not found in namespace '$NAMESPACE'" - echo "" - echo "Run the full setup script:" - echo " bash .devcontainer/setup-k8s-token.sh" - exit 1 -fi - -# Check if secret exists -if ! kubectl get secret $SECRET_NAME -n $NAMESPACE &>/dev/null; then - echo "[ERROR] Secret '$SECRET_NAME' not found in namespace '$NAMESPACE'" - echo "" - echo "Run the full setup script:" - echo " bash .devcontainer/setup-k8s-token.sh" - exit 1 -fi - -# Get the token -TOKEN=$(kubectl get secret $SECRET_NAME -n $NAMESPACE -o jsonpath='{.data.token}' 2>/dev/null | base64 -d) - -if [ -z "$TOKEN" ]; then - echo "[ERROR] Failed to retrieve service account token" - exit 1 -fi - -echo "==============================================" -echo "[OK] Service Account Token" -echo "==============================================" -echo "Service Account: $SERVICE_ACCOUNT" -echo "Namespace: $NAMESPACE" -echo "" -echo "Token (first 50 chars): ${TOKEN:0:50}..." -echo "Token (last 20 chars): ...${TOKEN: -20}" -echo "" -echo "Full token:" -echo "$TOKEN" -echo "" - -# Update .env file if it exists -ENV_FILE="$(pwd)/.env" - -if [ -f "$ENV_FILE" ]; then - echo "==============================================" - echo "[UPDATE] Updating .env file" - echo "==============================================" - - if grep -q "^KUBERNETES_SERVICE_ACCOUNT_TOKEN=" "$ENV_FILE"; then - sed -i "s|^KUBERNETES_SERVICE_ACCOUNT_TOKEN=.*|KUBERNETES_SERVICE_ACCOUNT_TOKEN=\"$TOKEN\"|" "$ENV_FILE" - echo "[OK] Updated KUBERNETES_SERVICE_ACCOUNT_TOKEN in .env" - else - echo "KUBERNETES_SERVICE_ACCOUNT_TOKEN=\"$TOKEN\"" >> "$ENV_FILE" - echo "[OK] Added KUBERNETES_SERVICE_ACCOUNT_TOKEN to .env" - fi - echo "" -fi - -echo "==============================================" -echo "[WARNING] Remember to restart your backend/operator" -echo "==============================================" -echo "" diff --git a/scripts/setup-rbac.sh b/scripts/setup-rbac.sh deleted file mode 100755 index cb5ffa6..0000000 --- a/scripts/setup-rbac.sh +++ /dev/null @@ -1,101 +0,0 @@ -#!/bin/bash -# Setup RBAC for Minikura -# This script creates service accounts with appropriate permissions -# for the backend (read-only) and operator (read/write) - -set -e - -NAMESPACE="${KUBERNETES_NAMESPACE:-minikura}" - -echo "================================================" -echo " Minikura Kubernetes RBAC Setup" -echo "================================================" -echo "" -echo "Namespace: $NAMESPACE" -echo "" - -# Create namespace if it doesn't exist -if ! kubectl get namespace $NAMESPACE &>/dev/null; then - echo "Creating namespace: $NAMESPACE" - kubectl create namespace $NAMESPACE - echo "[OK] Namespace created" -else - echo "[OK] Namespace already exists" -fi -echo "" - -# Apply RBAC manifests -echo "================================================" -echo " Creating Service Accounts and RBAC" -echo "================================================" -echo "" - -# Dev (Backend) RBAC -echo "1. Backend Service Account (minikura-dev)" -echo " - Read-only access to cluster resources" -kubectl apply -f k8s/rbac/dev-rbac.yaml -echo " [OK] Created" -echo "" - -# Operator RBAC -echo "2. Operator Service Account (minikura-operator)" -echo " - Full read/write access to cluster resources" -kubectl apply -f k8s/rbac/operator-rbac.yaml -echo " [OK] Created" -echo "" - -# Wait for tokens to be generated -echo "================================================" -echo " Waiting for Tokens" -echo "================================================" -echo "" -echo "Kubernetes needs a few seconds to generate tokens..." -sleep 5 - -# Get tokens -DEV_TOKEN=$(kubectl get secret minikura-dev-token -n $NAMESPACE -o jsonpath='{.data.token}' 2>/dev/null | base64 -d) -OPERATOR_TOKEN=$(kubectl get secret minikura-operator-token -n $NAMESPACE -o jsonpath='{.data.token}' 2>/dev/null | base64 -d) - -if [ -z "$DEV_TOKEN" ] || [ -z "$OPERATOR_TOKEN" ]; then - echo "[WARNING] Tokens not ready yet. Wait a moment and run:" - echo " bun run k8s:token" - echo "" -else - echo "[OK] Tokens generated successfully" - echo "" -fi - -# Update .env file -ENV_FILE=".env" - -if [ -f "$ENV_FILE" ] && [ -n "$OPERATOR_TOKEN" ]; then - echo "================================================" - echo " Updating .env" - echo "================================================" - echo "" - - if grep -q "^KUBERNETES_SERVICE_ACCOUNT_TOKEN=" "$ENV_FILE"; then - sed -i "s|^KUBERNETES_SERVICE_ACCOUNT_TOKEN=.*|KUBERNETES_SERVICE_ACCOUNT_TOKEN=\"$OPERATOR_TOKEN\"|" "$ENV_FILE" - echo "[OK] Updated KUBERNETES_SERVICE_ACCOUNT_TOKEN" - else - echo "KUBERNETES_SERVICE_ACCOUNT_TOKEN=\"$OPERATOR_TOKEN\"" >> "$ENV_FILE" - echo "[OK] Added KUBERNETES_SERVICE_ACCOUNT_TOKEN" - fi - echo "" -fi - -echo "================================================" -echo " [OK] Setup Complete" -echo "================================================" -echo "" -echo "Service accounts created:" -echo " • minikura-dev (backend) - read-only" -echo " • minikura-operator - full access" -echo "" -echo "To view tokens:" -echo " bun run k8s:token" -echo "" -echo "Next steps:" -echo " 1. Restart backend: bun run dev" -echo " 2. Restart operator: bun run k8s:dev" -echo "" diff --git a/scripts/show-k8s-tokens.sh b/scripts/show-k8s-tokens.sh deleted file mode 100755 index 0b473ab..0000000 --- a/scripts/show-k8s-tokens.sh +++ /dev/null @@ -1,76 +0,0 @@ -#!/bin/bash -# Script to display all Kubernetes service account tokens for Bun -# Bun has issues with TLS client certificates, so we use bearer tokens instead - -set -e - -NAMESPACE="${KUBERNETES_NAMESPACE:-minikura}" - -echo "================================================" -echo " Kubernetes Service Account Tokens for Bun" -echo "================================================" -echo "" - -# Backend token -echo "1. Backend Token (minikura-dev - read-only)" -echo " Service Account: minikura-dev" -echo " Permissions: Read services, pods, deployments, etc." -echo "" -BACKEND_TOKEN=$(kubectl get secret minikura-dev-token -n $NAMESPACE -o jsonpath='{.data.token}' 2>/dev/null | base64 -d) -if [ -z "$BACKEND_TOKEN" ]; then - echo " [ERROR] Token not found. Run: bash .devcontainer/setup-k8s-token.sh" -else - echo " Token: ${BACKEND_TOKEN:0:50}...${BACKEND_TOKEN: -20}" -fi -echo "" - -# Operator token -echo "2. Operator Token (minikura-operator - read/write)" -echo " Service Account: minikura-operator" -echo " Permissions: Full control over resources" -echo "" -OPERATOR_TOKEN=$(kubectl get secret minikura-operator-token -n $NAMESPACE -o jsonpath='{.data.token}' 2>/dev/null | base64 -d) -if [ -z "$OPERATOR_TOKEN" ]; then - echo " [ERROR] Token not found. Creating service account..." - bash .devcontainer/setup-k8s-token.sh - OPERATOR_TOKEN=$(kubectl get secret minikura-operator-token -n $NAMESPACE -o jsonpath='{.data.token}' 2>/dev/null | base64 -d) -fi -if [ -n "$OPERATOR_TOKEN" ]; then - echo " Token: ${OPERATOR_TOKEN:0:50}...${OPERATOR_TOKEN: -20}" -fi -echo "" - -# Update .env file -ENV_FILE="$(pwd)/.env" - -if [ -f "$ENV_FILE" ] && [ -n "$BACKEND_TOKEN" ]; then - echo "================================================" - echo " Updating .env file" - echo "================================================" - - if grep -q "^KUBERNETES_SERVICE_ACCOUNT_TOKEN=" "$ENV_FILE"; then - # Backend and operator use the same token for now (operator has more permissions) - # In production, you'd want separate tokens - sed -i "s|^KUBERNETES_SERVICE_ACCOUNT_TOKEN=.*|KUBERNETES_SERVICE_ACCOUNT_TOKEN=\"$OPERATOR_TOKEN\"|" "$ENV_FILE" - echo "[OK] Updated KUBERNETES_SERVICE_ACCOUNT_TOKEN (using operator token)" - else - echo "KUBERNETES_SERVICE_ACCOUNT_TOKEN=\"$OPERATOR_TOKEN\"" >> "$ENV_FILE" - echo "[OK] Added KUBERNETES_SERVICE_ACCOUNT_TOKEN (using operator token)" - fi - echo "" -fi - -echo "================================================" -echo " Usage" -echo "================================================" -echo "Both backend and operator will use the operator token from .env" -echo "The token is automatically detected when running with Bun." -echo "" -echo "To see full tokens:" -echo " kubectl get secret minikura-dev-token -n $NAMESPACE -o jsonpath='{.data.token}' | base64 -d" -echo " kubectl get secret minikura-operator-token -n $NAMESPACE -o jsonpath='{.data.token}' | base64 -d" -echo "" -echo "[WARNING] Restart backend and operator after updating tokens:" -echo " bun run dev" -echo " bun run k8s:dev" -echo "================================================"