mirror of
https://github.com/YuzuZensai/Minikura.git
synced 2026-09-13 18:59:25 +00:00
✨ feat: add plugin registry and server operations
This commit is contained in:
@@ -29,12 +29,21 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
minio-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
environment:
|
environment:
|
||||||
- KUBECONFIG=/home/dev/.kube/config
|
- KUBECONFIG=/home/dev/.kube/config
|
||||||
- DATABASE_URL=postgresql://postgres:postgres@db:5432/minikura?sslmode=disable
|
- DATABASE_URL=postgresql://postgres:postgres@db:5432/minikura?sslmode=disable
|
||||||
- WEB_URL=http://localhost:3001
|
- WEB_URL=http://localhost:3001
|
||||||
- API_URL=http://localhost:3000
|
- API_URL=http://localhost:3000
|
||||||
- KUBERNETES_NAMESPACE=minikura
|
- KUBERNETES_NAMESPACE=minikura
|
||||||
|
- PLUGIN_REGISTRY_USER_AGENT=Minikura/1.0 (devcontainer)
|
||||||
|
- S3_ENDPOINT=http://minio:9000
|
||||||
|
- S3_REGION=us-east-1
|
||||||
|
- S3_BUCKET=minikura-plugins
|
||||||
|
- S3_ACCESS_KEY_ID=minikura
|
||||||
|
- S3_SECRET_ACCESS_KEY=minikura-development
|
||||||
|
- S3_FORCE_PATH_STYLE=true
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:17
|
image: postgres:17
|
||||||
@@ -53,6 +62,36 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
minio:
|
||||||
|
image: minio/minio:RELEASE.2025-07-23T15-54-02Z
|
||||||
|
restart: unless-stopped
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: minikura
|
||||||
|
MINIO_ROOT_PASSWORD: minikura-development
|
||||||
|
volumes:
|
||||||
|
- minio-data:/data
|
||||||
|
ports:
|
||||||
|
- "9002:9000"
|
||||||
|
- "9003:9001"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mc", "ready", "local"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
minio-init:
|
||||||
|
image: minio/mc:RELEASE.2025-07-21T05-28-08Z
|
||||||
|
depends_on:
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
entrypoint: ["/bin/sh", "-c"]
|
||||||
|
command:
|
||||||
|
- >-
|
||||||
|
mc alias set local http://minio:9000 minikura minikura-development &&
|
||||||
|
mc mb --ignore-existing local/minikura-plugins
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
|
minio-data:
|
||||||
vscode-server:
|
vscode-server:
|
||||||
|
|||||||
@@ -91,6 +91,35 @@ kubectl wait --for=condition=Ready node --all --timeout=120s \
|
|||||||
echo "==> Creating minikura namespace..."
|
echo "==> Creating minikura namespace..."
|
||||||
kubectl create namespace minikura --dry-run=client -o yaml | kubectl apply -f - 2>/dev/null || true
|
kubectl create namespace minikura --dry-run=client -o yaml | kubectl apply -f - 2>/dev/null || true
|
||||||
|
|
||||||
|
# Pods run inside k3s while the backend runs in the outer devcontainer. Expose
|
||||||
|
# the node address under the same service name used by production manifests.
|
||||||
|
echo "==> Exposing the development backend to k3s workloads..."
|
||||||
|
K3S_NODE_IP=$(kubectl get nodes -o jsonpath='{.items[0].status.addresses[?(@.type=="InternalIP")].address}')
|
||||||
|
kubectl apply -f - <<EOF
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: minikura-backend
|
||||||
|
namespace: minikura
|
||||||
|
spec:
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 3000
|
||||||
|
targetPort: 3000
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Endpoints
|
||||||
|
metadata:
|
||||||
|
name: minikura-backend
|
||||||
|
namespace: minikura
|
||||||
|
subsets:
|
||||||
|
- addresses:
|
||||||
|
- ip: ${K3S_NODE_IP}
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 3000
|
||||||
|
EOF
|
||||||
|
|
||||||
echo "==> Installing CRDs..."
|
echo "==> Installing CRDs..."
|
||||||
make -C /workspace/operator install-crds 2>/dev/null \
|
make -C /workspace/operator install-crds 2>/dev/null \
|
||||||
|| echo "[WARN] CRD install failed; run 'bun run operator:crds'"
|
|| echo "[WARN] CRD install failed; run 'bun run operator:crds'"
|
||||||
|
|||||||
@@ -17,3 +17,15 @@ MINIKURA_OPERATOR_BACKEND_URL="http://minikura-backend:3000/api"
|
|||||||
|
|
||||||
# Comma-separated download URLs for RedisBungee and the shaded Minikura Velocity plugin JAR
|
# Comma-separated download URLs for RedisBungee and the shaded Minikura Velocity plugin JAR
|
||||||
# MINIKURA_VELOCITY_PLUGIN_URL="https://example.com/redisbungee.jar,https://example.com/minikura-velocity.jar"
|
# MINIKURA_VELOCITY_PLUGIN_URL="https://example.com/redisbungee.jar,https://example.com/minikura-velocity.jar"
|
||||||
|
|
||||||
|
# Plugin registry and S3-compatible artifact storage
|
||||||
|
PLUGIN_REGISTRY_USER_AGENT="Minikura/1.0 (https://github.com/YuzuZensai/Minikura)"
|
||||||
|
# The devcontainer supplies http://minio:9000 internally and exposes it on host port 9002.
|
||||||
|
S3_ENDPOINT="http://localhost:9000"
|
||||||
|
S3_REGION="us-east-1"
|
||||||
|
S3_BUCKET="minikura-plugins"
|
||||||
|
S3_ACCESS_KEY_ID="minikura"
|
||||||
|
S3_SECRET_ACCESS_KEY="minikura-development"
|
||||||
|
S3_FORCE_PATH_STYLE="true"
|
||||||
|
# URL reachable by Minecraft pods. Defaults to MINIKURA_OPERATOR_BACKEND_URL.
|
||||||
|
# MINIKURA_PLUGIN_DOWNLOAD_BASE_URL="http://minikura-backend:3000/api"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"typescript": "^7.0.2"
|
"typescript": "^7.0.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.1109.0",
|
||||||
"@elysiajs/node": "^1.4.5",
|
"@elysiajs/node": "^1.4.5",
|
||||||
"@kubernetes/client-node": "^1.4.0",
|
"@kubernetes/client-node": "^1.4.0",
|
||||||
"@minikura/api": "workspace:*",
|
"@minikura/api": "workspace:*",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { PrismaServerRepository } from "../infrastructure/repositories/prisma/se
|
|||||||
import { PrismaUserRepository } from "../infrastructure/repositories/prisma/user.repository.impl";
|
import { PrismaUserRepository } from "../infrastructure/repositories/prisma/user.repository.impl";
|
||||||
import { K8sService } from "../services/k8s";
|
import { K8sService } from "../services/k8s";
|
||||||
import { OperatorResourceSync } from "../services/operator-resource-sync";
|
import { OperatorResourceSync } from "../services/operator-resource-sync";
|
||||||
|
import { PluginRegistryService } from "../services/plugin-registry";
|
||||||
import { WebSocketService } from "../services/websocket";
|
import { WebSocketService } from "../services/websocket";
|
||||||
import { ReverseProxyService } from "./services/reverse-proxy.service";
|
import { ReverseProxyService } from "./services/reverse-proxy.service";
|
||||||
import { ServerService } from "./services/server.service";
|
import { ServerService } from "./services/server.service";
|
||||||
@@ -14,9 +15,10 @@ const reverseProxyRepo = new PrismaReverseProxyRepository();
|
|||||||
const webSocketService = new WebSocketService();
|
const webSocketService = new WebSocketService();
|
||||||
const k8sService = new K8sService();
|
const k8sService = new K8sService();
|
||||||
const operatorResourceSync = new OperatorResourceSync();
|
const operatorResourceSync = new OperatorResourceSync();
|
||||||
|
const pluginRegistryService = new PluginRegistryService();
|
||||||
|
|
||||||
export const userService = new UserService(userRepo);
|
export const userService = new UserService(userRepo);
|
||||||
export const serverService = new ServerService(serverRepo, k8sService);
|
export const serverService = new ServerService(serverRepo, k8sService);
|
||||||
export const reverseProxyService = new ReverseProxyService(reverseProxyRepo, k8sService);
|
export const reverseProxyService = new ReverseProxyService(reverseProxyRepo, k8sService);
|
||||||
export const wsService = webSocketService;
|
export const wsService = webSocketService;
|
||||||
export { k8sService, operatorResourceSync };
|
export { k8sService, operatorResourceSync, pluginRegistryService };
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface IK8sService {
|
|||||||
getPods(): Promise<any[]>;
|
getPods(): Promise<any[]>;
|
||||||
getPodsByLabel(labelSelector: string): Promise<any[]>;
|
getPodsByLabel(labelSelector: string): Promise<any[]>;
|
||||||
getPodInfo(podName: string): Promise<any>;
|
getPodInfo(podName: string): Promise<any>;
|
||||||
|
restartPod(podName: string): Promise<void>;
|
||||||
getPodLogs(
|
getPodLogs(
|
||||||
podName: string,
|
podName: string,
|
||||||
options?: {
|
options?: {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { errorHandler } from "./middleware/error-handler";
|
|||||||
import { bootstrapRoutes } from "./routes/bootstrap";
|
import { bootstrapRoutes } from "./routes/bootstrap";
|
||||||
import { k8sRoutes } from "./routes/k8s";
|
import { k8sRoutes } from "./routes/k8s";
|
||||||
import { pluginRoutes } from "./routes/plugin";
|
import { pluginRoutes } from "./routes/plugin";
|
||||||
|
import { registryDownloadRoutes, registryRoutes } from "./routes/registry";
|
||||||
import { reverseProxyRoutes } from "./routes/reverse-proxy";
|
import { reverseProxyRoutes } from "./routes/reverse-proxy";
|
||||||
import { serverRoutes } from "./routes/servers";
|
import { serverRoutes } from "./routes/servers";
|
||||||
import { terminalRoutes } from "./routes/terminal";
|
import { terminalRoutes } from "./routes/terminal";
|
||||||
@@ -37,15 +38,16 @@ const app = new Elysia({ adapter: node() })
|
|||||||
})
|
})
|
||||||
.all("/auth/*", ({ request }) => auth.handler(request))
|
.all("/auth/*", ({ request }) => auth.handler(request))
|
||||||
.use(bootstrapRoutes)
|
.use(bootstrapRoutes)
|
||||||
|
.group("/api", (app) => app.use(registryDownloadRoutes).use(terminalRoutes))
|
||||||
.use(authPlugin)
|
.use(authPlugin)
|
||||||
.group("/api", (app) =>
|
.group("/api", (app) =>
|
||||||
app
|
app
|
||||||
.use(userRoutes)
|
.use(userRoutes)
|
||||||
.use(serverRoutes)
|
.use(serverRoutes)
|
||||||
.use(reverseProxyRoutes)
|
.use(reverseProxyRoutes)
|
||||||
|
.use(registryRoutes)
|
||||||
.use(pluginRoutes)
|
.use(pluginRoutes)
|
||||||
.use(k8sRoutes)
|
.use(k8sRoutes)
|
||||||
.use(terminalRoutes)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export type App = typeof app;
|
export type App = typeof app;
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export class PrismaServerRepository implements ServerRepository {
|
|||||||
level_seed: input.level_seed ?? null,
|
level_seed: input.level_seed ?? null,
|
||||||
level_type: input.level_type ?? null,
|
level_type: input.level_type ?? null,
|
||||||
api_key: token,
|
api_key: token,
|
||||||
|
running: input.running ?? true,
|
||||||
env_variables: input.env_variables
|
env_variables: input.env_variables
|
||||||
? {
|
? {
|
||||||
create: input.env_variables.map((ev) => ({
|
create: input.env_variables.map((ev) => ({
|
||||||
@@ -135,6 +136,7 @@ export class PrismaServerRepository implements ServerRepository {
|
|||||||
motd: input.motd,
|
motd: input.motd,
|
||||||
level_seed: input.level_seed,
|
level_seed: input.level_seed,
|
||||||
level_type: input.level_type,
|
level_type: input.level_type,
|
||||||
|
running: input.running,
|
||||||
},
|
},
|
||||||
include: { env_variables: true },
|
include: { env_variables: true },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { Elysia, t } from "elysia";
|
||||||
|
import { pluginRegistryService, operatorResourceSync } from "../application/di-container";
|
||||||
|
import { assertAdmin, requireAuth } from "../middleware/auth-guards";
|
||||||
|
|
||||||
|
const providerSchema = t.Union([t.Literal("MODRINTH"), t.Literal("HANGAR")]);
|
||||||
|
const platformSchema = t.Union([t.Literal("PAPER"), t.Literal("FOLIA"), t.Literal("VELOCITY")]);
|
||||||
|
const versionSchema = t.Object({
|
||||||
|
provider: providerSchema,
|
||||||
|
projectId: t.String({ minLength: 1 }),
|
||||||
|
projectName: t.Optional(t.String({ minLength: 1 })),
|
||||||
|
versionId: t.String({ minLength: 1 }),
|
||||||
|
version: t.String({ minLength: 1 }),
|
||||||
|
platform: platformSchema,
|
||||||
|
minecraftVersions: t.Array(t.String()),
|
||||||
|
filename: t.String({ minLength: 1 }),
|
||||||
|
size: t.Integer({ minimum: 1 }),
|
||||||
|
sha256: t.Nullable(t.String({ minLength: 64, maxLength: 64 })),
|
||||||
|
downloadUrl: t.String({ format: "uri" }),
|
||||||
|
license: t.Nullable(t.String()),
|
||||||
|
description: t.Optional(t.String()),
|
||||||
|
author: t.Optional(t.String()),
|
||||||
|
iconUrl: t.Optional(t.Nullable(t.String())),
|
||||||
|
projectUrl: t.Optional(t.String({ format: "uri" })),
|
||||||
|
categories: t.Optional(t.Array(t.String())),
|
||||||
|
updatedAt: t.Optional(t.Nullable(t.String())),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const registryDownloadRoutes = new Elysia({ prefix: "/registry/artifacts" }).get(
|
||||||
|
"/:token/download",
|
||||||
|
async ({ params }) => {
|
||||||
|
const download = await pluginRegistryService.download(params.token);
|
||||||
|
if (download.response) return download.response;
|
||||||
|
if (download.redirect) return Response.redirect(download.redirect, 302);
|
||||||
|
throw new Error("Plugin artifact download is unavailable");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const registryRoutes = new Elysia({ prefix: "/registry" })
|
||||||
|
.use(requireAuth)
|
||||||
|
.get(
|
||||||
|
"/search",
|
||||||
|
async ({ query, user }) => {
|
||||||
|
assertAdmin(user);
|
||||||
|
return pluginRegistryService.search(
|
||||||
|
query.query ?? "",
|
||||||
|
query.platform,
|
||||||
|
query.minecraftVersion
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query: t.Object({
|
||||||
|
query: t.Optional(t.String({ default: "" })),
|
||||||
|
platform: platformSchema,
|
||||||
|
minecraftVersion: t.Optional(t.String()),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"/versions",
|
||||||
|
async ({ query, user }) => {
|
||||||
|
assertAdmin(user);
|
||||||
|
return pluginRegistryService.versions(
|
||||||
|
query.provider,
|
||||||
|
query.projectId,
|
||||||
|
query.platform,
|
||||||
|
query.minecraftVersion
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query: t.Object({
|
||||||
|
provider: providerSchema,
|
||||||
|
projectId: t.String({ minLength: 1 }),
|
||||||
|
platform: platformSchema,
|
||||||
|
minecraftVersion: t.Optional(t.String()),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.get("/artifacts", async ({ user }) => {
|
||||||
|
assertAdmin(user);
|
||||||
|
return pluginRegistryService.listArtifacts();
|
||||||
|
})
|
||||||
|
.post(
|
||||||
|
"/artifacts",
|
||||||
|
async ({ body, user }) => {
|
||||||
|
assertAdmin(user);
|
||||||
|
return pluginRegistryService.register(body);
|
||||||
|
},
|
||||||
|
{ body: versionSchema }
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
"/artifacts/upload",
|
||||||
|
async ({ body, user }) => {
|
||||||
|
assertAdmin(user);
|
||||||
|
return pluginRegistryService.upload(body.file);
|
||||||
|
},
|
||||||
|
{ body: t.Object({ file: t.File({ type: "application/java-archive" }) }) }
|
||||||
|
)
|
||||||
|
.delete("/library/:artifactId", async ({ params, user }) => {
|
||||||
|
assertAdmin(user);
|
||||||
|
const serverIds = await pluginRegistryService.deleteArtifact(params.artifactId);
|
||||||
|
await Promise.all(serverIds.map((serverId) => operatorResourceSync.syncServerById(serverId)));
|
||||||
|
return { success: true };
|
||||||
|
})
|
||||||
|
.get("/servers/:serverId/plugins", async ({ params, user }) => {
|
||||||
|
assertAdmin(user);
|
||||||
|
return pluginRegistryService.list(params.serverId);
|
||||||
|
})
|
||||||
|
.put(
|
||||||
|
"/servers/:serverId/plugins",
|
||||||
|
async ({ params, body, user }) => {
|
||||||
|
assertAdmin(user);
|
||||||
|
await pluginRegistryService.reconcile(params.serverId, body.artifactIds);
|
||||||
|
await operatorResourceSync.syncServerById(params.serverId);
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
{ body: t.Object({ artifactIds: t.Array(t.String()) }) }
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
"/servers/:serverId/plugins",
|
||||||
|
async ({ params, body, user }) => {
|
||||||
|
assertAdmin(user);
|
||||||
|
const registered = await pluginRegistryService.register(body);
|
||||||
|
const artifact = await pluginRegistryService.deploy(params.serverId, registered.id);
|
||||||
|
await operatorResourceSync.syncServerById(params.serverId);
|
||||||
|
return artifact;
|
||||||
|
},
|
||||||
|
{ body: versionSchema }
|
||||||
|
)
|
||||||
|
.post("/servers/:serverId/deployments/:artifactId", async ({ params, user }) => {
|
||||||
|
assertAdmin(user);
|
||||||
|
const artifact = await pluginRegistryService.deploy(params.serverId, params.artifactId);
|
||||||
|
await operatorResourceSync.syncServerById(params.serverId);
|
||||||
|
return artifact;
|
||||||
|
})
|
||||||
|
.delete("/servers/:serverId/installations/:installationId", async ({ params, user }) => {
|
||||||
|
assertAdmin(user);
|
||||||
|
await pluginRegistryService.remove(params.serverId, params.installationId);
|
||||||
|
await operatorResourceSync.syncServerById(params.serverId);
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { labelKeys } from "@minikura/api";
|
||||||
import { Elysia } from "elysia";
|
import { Elysia } from "elysia";
|
||||||
import { serverService, wsService } from "../application/di-container";
|
import { k8sService, serverService, wsService } from "../application/di-container";
|
||||||
import { bearerToken, findApiKeyOwner } from "../middleware/api-key";
|
import { bearerToken, findApiKeyOwner } from "../middleware/api-key";
|
||||||
import { assertAdmin, requireAuth } from "../middleware/auth-guards";
|
import { assertAdmin, requireAuth } from "../middleware/auth-guards";
|
||||||
import {
|
import {
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
updateServerSchema,
|
updateServerSchema,
|
||||||
} from "../schemas/server.schema";
|
} from "../schemas/server.schema";
|
||||||
import type { WebSocketClient } from "../services/websocket";
|
import type { WebSocketClient } from "../services/websocket";
|
||||||
|
import { operatorResourceName } from "../services/operator-resource-sync";
|
||||||
|
|
||||||
export const serverRoutes = new Elysia({ prefix: "/servers" })
|
export const serverRoutes = new Elysia({ prefix: "/servers" })
|
||||||
.ws("/ws", {
|
.ws("/ws", {
|
||||||
@@ -79,4 +81,28 @@ export const serverRoutes = new Elysia({ prefix: "/servers" })
|
|||||||
assertAdmin(user);
|
assertAdmin(user);
|
||||||
await serverService.deleteEnvVariable(params.id, params.key);
|
await serverService.deleteEnvVariable(params.id, params.key);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
})
|
||||||
|
|
||||||
|
.post("/:id/actions/start", async ({ params, user }) => {
|
||||||
|
assertAdmin(user);
|
||||||
|
return await serverService.updateServer(params.id, { running: true });
|
||||||
|
})
|
||||||
|
|
||||||
|
.post("/:id/actions/stop", async ({ params, user }) => {
|
||||||
|
assertAdmin(user);
|
||||||
|
return await serverService.updateServer(params.id, { running: false });
|
||||||
|
})
|
||||||
|
|
||||||
|
.post("/:id/actions/restart", async ({ params, user }) => {
|
||||||
|
assertAdmin(user);
|
||||||
|
const server = await serverService.getServerById(params.id);
|
||||||
|
if (!server.running) {
|
||||||
|
await serverService.updateServer(params.id, { running: true });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
const pods = await k8sService.getPodsByLabel(
|
||||||
|
`${labelKeys.serverId}=${operatorResourceName(params.id)}`
|
||||||
|
);
|
||||||
|
await Promise.all(pods.map((pod) => k8sService.restartPod(pod.name)));
|
||||||
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|||||||
+275
-262
@@ -1,12 +1,15 @@
|
|||||||
|
import { isUserSuspended } from "@minikura/db";
|
||||||
import { getErrorMessage } from "@minikura/shared/errors";
|
import { getErrorMessage } from "@minikura/shared/errors";
|
||||||
import { Elysia } from "elysia";
|
import type { Elysia } from "elysia";
|
||||||
|
import WebSocket, { type RawData } from "ws";
|
||||||
import { k8sService } from "../application/di-container";
|
import { k8sService } from "../application/di-container";
|
||||||
import { logger } from "../infrastructure/logger";
|
import { logger } from "../infrastructure/logger";
|
||||||
import { requireAdmin } from "../middleware/auth-guards";
|
import { auth } from "../middleware/auth";
|
||||||
|
|
||||||
type TerminalWsData = {
|
type TerminalWsData = {
|
||||||
query?: Record<string, string>;
|
query?: Record<string, string>;
|
||||||
k8sWs?: WebSocket;
|
k8sWs?: WebSocket;
|
||||||
|
request: Request;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TerminalWs = {
|
type TerminalWs = {
|
||||||
@@ -19,307 +22,317 @@ type TerminalMessage =
|
|||||||
| { type: "input"; data: string }
|
| { type: "input"; data: string }
|
||||||
| { type: "resize"; cols: number; rows: number };
|
| { type: "resize"; cols: number; rows: number };
|
||||||
|
|
||||||
type BunTlsOptions = {
|
type TlsOptions = {
|
||||||
rejectUnauthorized: boolean;
|
rejectUnauthorized: boolean;
|
||||||
cert?: string;
|
cert?: string;
|
||||||
key?: string;
|
key?: string;
|
||||||
ca?: string;
|
ca?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const terminalRoutes = new Elysia({ prefix: "/terminal" }).use(requireAdmin).ws("/exec", {
|
export const terminalRoutes = (app: Elysia) =>
|
||||||
open: async (ws: TerminalWs) => {
|
app.ws("/terminal/exec", {
|
||||||
const podName = ws.data.query?.podName;
|
open: async (ws: TerminalWs) => {
|
||||||
const container = ws.data.query?.container;
|
const session = await auth.api.getSession({ headers: ws.data.request.headers });
|
||||||
const shell = ws.data.query?.shell || "/bin/sh";
|
const user = session?.user;
|
||||||
const mode = ws.data.query?.mode || "shell";
|
const suspended =
|
||||||
|
user &&
|
||||||
|
((user as unknown as { banned?: boolean }).banned === true ||
|
||||||
|
isUserSuspended(
|
||||||
|
user as unknown as { isSuspended: boolean; suspendedUntil: Date | null }
|
||||||
|
));
|
||||||
|
|
||||||
logger.debug(
|
if (user?.role !== "admin" || suspended) {
|
||||||
`Opening terminal for pod: ${podName}, container: ${container}, shell: ${shell}, mode: ${mode}`
|
ws.send(JSON.stringify({ type: "error", data: "Admin access required" }));
|
||||||
);
|
ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!podName) {
|
const podName = ws.data.query?.podName;
|
||||||
ws.send(
|
const container = ws.data.query?.container;
|
||||||
JSON.stringify({
|
const shell = ws.data.query?.shell || "/bin/sh";
|
||||||
type: "error",
|
const mode = ws.data.query?.mode || "shell";
|
||||||
data: "Pod name is required",
|
|
||||||
})
|
logger.debug(
|
||||||
|
`Opening terminal for pod: ${podName}, container: ${container}, shell: ${shell}, mode: ${mode}`
|
||||||
);
|
);
|
||||||
ws.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
if (!podName) {
|
||||||
if (!k8sService.isInitialized()) {
|
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "error",
|
type: "error",
|
||||||
data: "Kubernetes client not initialized",
|
data: "Pod name is required",
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
ws.close();
|
ws.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const kc = k8sService.getKubeConfig();
|
try {
|
||||||
const namespace = k8sService.getNamespace();
|
if (!k8sService.isInitialized()) {
|
||||||
const cluster = kc.getCurrentCluster();
|
|
||||||
const user = kc.getCurrentUser();
|
|
||||||
|
|
||||||
if (!cluster) {
|
|
||||||
throw new Error("No current cluster configured");
|
|
||||||
}
|
|
||||||
|
|
||||||
const server = cluster.server;
|
|
||||||
const isAttach = mode === "attach";
|
|
||||||
const apiPath = isAttach
|
|
||||||
? `/api/v1/namespaces/${namespace}/pods/${podName}/attach`
|
|
||||||
: `/api/v1/namespaces/${namespace}/pods/${podName}/exec`;
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
stdout: "true",
|
|
||||||
stderr: "true",
|
|
||||||
stdin: "true",
|
|
||||||
tty: "true",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!isAttach) {
|
|
||||||
params.append("command", shell);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (container) {
|
|
||||||
params.append("container", container);
|
|
||||||
}
|
|
||||||
|
|
||||||
const wsUrl = `${server}${apiPath}?${params.toString()}`
|
|
||||||
.replace("https://", "wss://")
|
|
||||||
.replace("http://", "ws://");
|
|
||||||
|
|
||||||
logger.debug(`Connecting to Kubernetes: ${wsUrl}`);
|
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
Connection: "Upgrade",
|
|
||||||
Upgrade: "websocket",
|
|
||||||
"Sec-WebSocket-Version": "13",
|
|
||||||
"Sec-WebSocket-Key": Buffer.from(Math.random().toString())
|
|
||||||
.toString("base64")
|
|
||||||
.substring(0, 24),
|
|
||||||
"Sec-WebSocket-Protocol": "v4.channel.k8s.io",
|
|
||||||
};
|
|
||||||
|
|
||||||
if (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}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tlsOptions: BunTlsOptions = {
|
|
||||||
rejectUnauthorized: cluster.skipTLSVerify !== true,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (user?.certData) {
|
|
||||||
tlsOptions.cert = Buffer.from(user.certData, "base64").toString();
|
|
||||||
}
|
|
||||||
if (user?.keyData) {
|
|
||||||
tlsOptions.key = Buffer.from(user.keyData, "base64").toString();
|
|
||||||
}
|
|
||||||
if (cluster.caData) {
|
|
||||||
tlsOptions.ca = Buffer.from(cluster.caData, "base64").toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
const wsOptions = { headers, tls: tlsOptions };
|
|
||||||
const k8sWs = new WebSocket(wsUrl, wsOptions as unknown as string | string[]);
|
|
||||||
ws.data.k8sWs = k8sWs;
|
|
||||||
|
|
||||||
k8sWs.onopen = async () => {
|
|
||||||
logger.debug(`Connected to Kubernetes ${isAttach ? "attach" : "exec"}`);
|
|
||||||
|
|
||||||
if (isAttach) {
|
|
||||||
try {
|
|
||||||
const coreApi = k8sService.getCoreApi();
|
|
||||||
const logs = await coreApi.readNamespacedPodLog({
|
|
||||||
name: podName,
|
|
||||||
namespace: namespace,
|
|
||||||
container: container,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (logs) {
|
|
||||||
const lines = logs.split("\n");
|
|
||||||
for (const line of lines) {
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: "output",
|
|
||||||
data: `${line}\r\n`,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: "ready",
|
|
||||||
data: "Attached to container (showing logs since start)",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} catch (logError) {
|
|
||||||
logger.error({ err: logError }, "Failed to fetch historical logs");
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: "ready",
|
|
||||||
data: "Attached to container",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "ready",
|
type: "error",
|
||||||
data: "Shell ready",
|
data: "Kubernetes client not initialized",
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
ws.close();
|
||||||
};
|
|
||||||
|
|
||||||
k8sWs.onmessage = (event: MessageEvent) => {
|
|
||||||
try {
|
|
||||||
const data = event.data;
|
|
||||||
|
|
||||||
let buffer: Uint8Array;
|
|
||||||
|
|
||||||
if (data instanceof Uint8Array) {
|
|
||||||
buffer = data;
|
|
||||||
} else if (data instanceof ArrayBuffer) {
|
|
||||||
buffer = new Uint8Array(data);
|
|
||||||
} else if (Buffer.isBuffer(data)) {
|
|
||||||
buffer = new Uint8Array(data);
|
|
||||||
} else if (data instanceof Blob) {
|
|
||||||
data.arrayBuffer().then((ab) => {
|
|
||||||
const uint8 = new Uint8Array(ab);
|
|
||||||
processBuffer(uint8);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
} else if (typeof data === "string") {
|
|
||||||
ws.send(JSON.stringify({ type: "output", data }));
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
logger.debug(
|
|
||||||
{ dataType: typeof data, constructor: data?.constructor?.name },
|
|
||||||
"Unknown data type"
|
|
||||||
);
|
|
||||||
buffer = new Uint8Array(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
processBuffer(buffer);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error({ err }, "Error processing Kubernetes message");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function processBuffer(buffer: Uint8Array): void {
|
|
||||||
if (buffer.length === 0) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const channel = buffer[0];
|
const kc = k8sService.getKubeConfig();
|
||||||
const message = new TextDecoder().decode(buffer.slice(1));
|
const namespace = k8sService.getNamespace();
|
||||||
|
const cluster = kc.getCurrentCluster();
|
||||||
|
const user = kc.getCurrentUser();
|
||||||
|
|
||||||
if (channel === 1 || channel === 2) {
|
if (!cluster) {
|
||||||
ws.send(JSON.stringify({ type: "output", data: message }));
|
throw new Error("No current cluster configured");
|
||||||
} else if (channel === 3) {
|
|
||||||
logger.error({ message }, "Kubernetes error channel");
|
|
||||||
ws.send(JSON.stringify({ type: "error", data: message }));
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
k8sWs.onerror = (error: Event) => {
|
const server = cluster.server;
|
||||||
logger.error({ err: error }, "Kubernetes WebSocket error");
|
const isConsole = mode === "console";
|
||||||
const message = getErrorMessage(error);
|
const apiPath = `/api/v1/namespaces/${namespace}/pods/${podName}/exec`;
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
stdout: "true",
|
||||||
|
stderr: "true",
|
||||||
|
stdin: "true",
|
||||||
|
tty: "true",
|
||||||
|
});
|
||||||
|
|
||||||
|
const command = isConsole
|
||||||
|
? [
|
||||||
|
"/bin/sh",
|
||||||
|
"-c",
|
||||||
|
'tail -n 0 -F /data/logs/latest.log & tail_pid=$!; trap "kill $tail_pid" EXIT; cat > /tmp/minikura-console',
|
||||||
|
]
|
||||||
|
: [shell];
|
||||||
|
for (const part of command) {
|
||||||
|
params.append("command", part);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (container) {
|
||||||
|
params.append("container", container);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wsUrl = `${server}${apiPath}?${params.toString()}`
|
||||||
|
.replace("https://", "wss://")
|
||||||
|
.replace("http://", "ws://");
|
||||||
|
|
||||||
|
logger.debug(`Connecting to Kubernetes: ${wsUrl}`);
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tlsOptions: TlsOptions = {
|
||||||
|
rejectUnauthorized: cluster.skipTLSVerify !== true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (user?.certData) {
|
||||||
|
tlsOptions.cert = Buffer.from(user.certData, "base64").toString();
|
||||||
|
}
|
||||||
|
if (user?.keyData) {
|
||||||
|
tlsOptions.key = Buffer.from(user.keyData, "base64").toString();
|
||||||
|
}
|
||||||
|
if (cluster.caData) {
|
||||||
|
tlsOptions.ca = Buffer.from(cluster.caData, "base64").toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const k8sWs = new WebSocket(wsUrl, "v4.channel.k8s.io", {
|
||||||
|
headers,
|
||||||
|
...tlsOptions,
|
||||||
|
});
|
||||||
|
ws.data.k8sWs = k8sWs;
|
||||||
|
|
||||||
|
k8sWs.on("open", async () => {
|
||||||
|
logger.debug(`Connected to Kubernetes ${isConsole ? "console" : "shell"}`);
|
||||||
|
|
||||||
|
if (isConsole) {
|
||||||
|
try {
|
||||||
|
const coreApi = k8sService.getCoreApi();
|
||||||
|
const logs = await coreApi.readNamespacedPodLog({
|
||||||
|
name: podName,
|
||||||
|
namespace: namespace,
|
||||||
|
container: container,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (logs) {
|
||||||
|
const lines = logs.split("\n");
|
||||||
|
for (const line of lines) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "output",
|
||||||
|
data: `${line}\r\n`,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "ready",
|
||||||
|
data: "Minecraft console ready",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (logError) {
|
||||||
|
logger.error({ err: logError }, "Failed to fetch historical logs");
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "ready",
|
||||||
|
data: "Minecraft console ready",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "ready",
|
||||||
|
data: "Shell ready",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
k8sWs.on("message", (data: RawData) => {
|
||||||
|
try {
|
||||||
|
let buffer: Uint8Array;
|
||||||
|
|
||||||
|
if (data instanceof Uint8Array) {
|
||||||
|
buffer = data;
|
||||||
|
} else if (data instanceof ArrayBuffer) {
|
||||||
|
buffer = new Uint8Array(data);
|
||||||
|
} else if (Buffer.isBuffer(data)) {
|
||||||
|
buffer = new Uint8Array(data);
|
||||||
|
} else if (Array.isArray(data)) {
|
||||||
|
buffer = new Uint8Array(Buffer.concat(data));
|
||||||
|
} else {
|
||||||
|
logger.debug({ dataType: typeof data }, "Unknown data type");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
processBuffer(buffer);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, "Error processing Kubernetes message");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function processBuffer(buffer: Uint8Array): void {
|
||||||
|
if (buffer.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = buffer[0];
|
||||||
|
const message = new TextDecoder().decode(buffer.slice(1));
|
||||||
|
|
||||||
|
if (channel === 1 || channel === 2) {
|
||||||
|
ws.send(JSON.stringify({ type: "output", data: message }));
|
||||||
|
} else if (channel === 3) {
|
||||||
|
try {
|
||||||
|
const status = JSON.parse(message) as { status?: string; message?: string };
|
||||||
|
if (status.status !== "Success") {
|
||||||
|
ws.send(JSON.stringify({ type: "error", data: status.message || message }));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ws.send(JSON.stringify({ type: "error", data: message }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
k8sWs.on("error", (error: Error) => {
|
||||||
|
logger.error({ err: error }, "Kubernetes WebSocket error");
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
data: `Connection error: ${message}`,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
k8sWs.on("close", (code: number, reason: Buffer) => {
|
||||||
|
const closeReason = reason.toString();
|
||||||
|
logger.debug(`Kubernetes WebSocket closed: ${code} ${closeReason}`);
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "close",
|
||||||
|
data: closeReason || `Connection closed (${code})`,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
ws.close();
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logger.error({ err: error }, "Error setting up terminal");
|
||||||
|
if (error instanceof Error) {
|
||||||
|
logger.error({ stack: error.stack }, "Error stack");
|
||||||
|
}
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "error",
|
type: "error",
|
||||||
data: `Connection error: ${message}`,
|
data: `Failed to connect: ${getErrorMessage(error)}`,
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
k8sWs.onclose = (event: CloseEvent) => {
|
|
||||||
logger.debug(`Kubernetes WebSocket closed: ${event.code} ${event.reason}`);
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: "close",
|
|
||||||
data: event.reason || "Connection closed",
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
ws.close();
|
ws.close();
|
||||||
};
|
|
||||||
} catch (error: unknown) {
|
|
||||||
logger.error({ err: error }, "Error setting up terminal");
|
|
||||||
if (error instanceof Error) {
|
|
||||||
logger.error({ stack: error.stack }, "Error stack");
|
|
||||||
}
|
}
|
||||||
ws.send(
|
},
|
||||||
JSON.stringify({
|
|
||||||
type: "error",
|
|
||||||
data: `Failed to connect: ${getErrorMessage(error)}`,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
ws.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
message: async (ws: TerminalWs, message: unknown) => {
|
message: async (ws: TerminalWs, message: unknown) => {
|
||||||
try {
|
try {
|
||||||
const data = parseTerminalMessage(message);
|
const data = parseTerminalMessage(message);
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const k8sWs = ws.data.k8sWs;
|
||||||
|
|
||||||
|
if (!k8sWs || k8sWs.readyState !== WebSocket.OPEN) {
|
||||||
|
logger.error({ readyState: k8sWs?.readyState }, "Kubernetes WebSocket not ready");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === "input") {
|
||||||
|
logger.debug({ input: data.data }, "Sending input to k8s");
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const textData = encoder.encode(data.data);
|
||||||
|
const buffer = new Uint8Array(1 + textData.length);
|
||||||
|
buffer[0] = 0;
|
||||||
|
buffer.set(textData, 1);
|
||||||
|
k8sWs.send(buffer.buffer);
|
||||||
|
} else if (data.type === "resize") {
|
||||||
|
const resizeMsg = JSON.stringify({
|
||||||
|
Width: data.cols,
|
||||||
|
Height: data.rows,
|
||||||
|
});
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const textData = encoder.encode(resizeMsg);
|
||||||
|
const buffer = new Uint8Array(1 + textData.length);
|
||||||
|
buffer[0] = 4;
|
||||||
|
buffer.set(textData, 1);
|
||||||
|
k8sWs.send(buffer.buffer);
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logger.error({ err: error }, "Error handling terminal message");
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
data: `Error: ${getErrorMessage(error)}`,
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
close: (ws: TerminalWs) => {
|
||||||
|
logger.debug("Client WebSocket closed");
|
||||||
const k8sWs = ws.data.k8sWs;
|
const k8sWs = ws.data.k8sWs;
|
||||||
|
if (k8sWs && k8sWs.readyState === WebSocket.OPEN) {
|
||||||
if (!k8sWs || k8sWs.readyState !== WebSocket.OPEN) {
|
k8sWs.close();
|
||||||
logger.error({ readyState: k8sWs?.readyState }, "Kubernetes WebSocket not ready");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
if (data.type === "input") {
|
});
|
||||||
logger.debug({ input: data.data }, "Sending input to k8s");
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
const textData = encoder.encode(data.data);
|
|
||||||
const buffer = new Uint8Array(1 + textData.length);
|
|
||||||
buffer[0] = 0;
|
|
||||||
buffer.set(textData, 1);
|
|
||||||
k8sWs.send(buffer.buffer);
|
|
||||||
} else if (data.type === "resize") {
|
|
||||||
const resizeMsg = JSON.stringify({
|
|
||||||
Width: data.cols,
|
|
||||||
Height: data.rows,
|
|
||||||
});
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
const textData = encoder.encode(resizeMsg);
|
|
||||||
const buffer = new Uint8Array(1 + textData.length);
|
|
||||||
buffer[0] = 4;
|
|
||||||
buffer.set(textData, 1);
|
|
||||||
k8sWs.send(buffer.buffer);
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
logger.error({ err: error }, "Error handling terminal message");
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: "error",
|
|
||||||
data: `Error: ${getErrorMessage(error)}`,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
close: (ws: TerminalWs) => {
|
|
||||||
logger.debug("Client WebSocket closed");
|
|
||||||
const k8sWs = ws.data.k8sWs;
|
|
||||||
if (k8sWs && k8sWs.readyState === WebSocket.OPEN) {
|
|
||||||
k8sWs.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function parseTerminalMessage(message: unknown): TerminalMessage | null {
|
function parseTerminalMessage(message: unknown): TerminalMessage | null {
|
||||||
if (typeof message === "string") {
|
if (typeof message === "string") {
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export const createServerSchema = z.object({
|
|||||||
motd: z.string().optional(),
|
motd: z.string().optional(),
|
||||||
level_seed: z.string().optional(),
|
level_seed: z.string().optional(),
|
||||||
level_type: z.string().optional(),
|
level_type: z.string().optional(),
|
||||||
|
running: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateServerSchema = createServerSchema.omit({ id: true, type: true }).partial();
|
export const updateServerSchema = createServerSchema.omit({ id: true, type: true }).partial();
|
||||||
|
|||||||
@@ -162,6 +162,15 @@ export class K8sService implements IK8sService {
|
|||||||
return this.resources.getPodInfo(podName);
|
return this.resources.getPodInfo(podName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async restartPod(podName: string): Promise<void> {
|
||||||
|
this.ensureInitialized();
|
||||||
|
await this.coreApi.deleteNamespacedPod({
|
||||||
|
name: podName,
|
||||||
|
namespace: this.namespace,
|
||||||
|
propagationPolicy: "Foreground",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async getServiceInfo(serviceName: string) {
|
async getServiceInfo(serviceName: string) {
|
||||||
this.ensureInitialized();
|
this.ensureInitialized();
|
||||||
return this.resources.getServiceInfo(serviceName);
|
return this.resources.getServiceInfo(serviceName);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { API_GROUP } from "@minikura/api";
|
|||||||
import { prisma, type ReverseProxyWithEnvVars, type ServerWithEnvVars } from "@minikura/db";
|
import { prisma, type ReverseProxyWithEnvVars, type ServerWithEnvVars } from "@minikura/db";
|
||||||
import { buildKubeConfig } from "@minikura/shared/kube-auth";
|
import { buildKubeConfig } from "@minikura/shared/kube-auth";
|
||||||
import { logger } from "../infrastructure/logger";
|
import { logger } from "../infrastructure/logger";
|
||||||
|
import { PluginRegistryService } from "./plugin-registry";
|
||||||
|
|
||||||
const API_VERSION = "v1alpha1";
|
const API_VERSION = "v1alpha1";
|
||||||
const FIELD_MANAGER = "minikura-backend";
|
const FIELD_MANAGER = "minikura-backend";
|
||||||
@@ -50,6 +51,7 @@ export class OperatorResourceSync {
|
|||||||
private coreApi?: k8s.CoreV1Api;
|
private coreApi?: k8s.CoreV1Api;
|
||||||
private customObjectsApi?: k8s.CustomObjectsApi;
|
private customObjectsApi?: k8s.CustomObjectsApi;
|
||||||
private syncing = false;
|
private syncing = false;
|
||||||
|
private readonly pluginRegistry = new PluginRegistryService();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
try {
|
try {
|
||||||
@@ -72,7 +74,9 @@ export class OperatorResourceSync {
|
|||||||
this.syncing = true;
|
this.syncing = true;
|
||||||
try {
|
try {
|
||||||
const [servers, proxies] = await Promise.all([
|
const [servers, proxies] = await Promise.all([
|
||||||
prisma.server.findMany({ include: { env_variables: true } }),
|
prisma.server.findMany({
|
||||||
|
include: { env_variables: true, plugins: { include: { artifact: true } } },
|
||||||
|
}),
|
||||||
prisma.reverseProxyServer.findMany({ include: { env_variables: true } }),
|
prisma.reverseProxyServer.findMany({ include: { env_variables: true } }),
|
||||||
]);
|
]);
|
||||||
const syncResults = await Promise.allSettled([
|
const syncResults = await Promise.allSettled([
|
||||||
@@ -106,10 +110,10 @@ export class OperatorResourceSync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async syncServerById(id: string): Promise<void> {
|
async syncServerById(id: string): Promise<void> {
|
||||||
this.requireClients();
|
if (!this.coreApi || !this.customObjectsApi) return;
|
||||||
const server = await prisma.server.findUnique({
|
const server = await prisma.server.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: { env_variables: true },
|
include: { env_variables: true, plugins: { include: { artifact: true } } },
|
||||||
});
|
});
|
||||||
if (server) await this.syncServer(server);
|
if (server) await this.syncServer(server);
|
||||||
}
|
}
|
||||||
@@ -133,7 +137,11 @@ export class OperatorResourceSync {
|
|||||||
await this.deleteResource("reverseproxyservers", operatorResourceName(id));
|
await this.deleteResource("reverseproxyservers", operatorResourceName(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async syncServer(server: ServerWithEnvVars): Promise<void> {
|
private async syncServer(
|
||||||
|
server: ServerWithEnvVars & {
|
||||||
|
plugins?: Array<{ enabled: boolean; artifact: { download_token: string } }>;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
const name = operatorResourceName(server.id);
|
const name = operatorResourceName(server.id);
|
||||||
const secretName = `mc-${name}-api-key`;
|
const secretName = `mc-${name}-api-key`;
|
||||||
await this.upsertSecret(secretName, server.api_key, labels(server.id));
|
await this.upsertSecret(secretName, server.api_key, labels(server.id));
|
||||||
@@ -147,6 +155,7 @@ export class OperatorResourceSync {
|
|||||||
},
|
},
|
||||||
spec: {
|
spec: {
|
||||||
type: server.type,
|
type: server.type,
|
||||||
|
running: server.running,
|
||||||
description: server.description ?? undefined,
|
description: server.description ?? undefined,
|
||||||
listenPort: server.listen_port,
|
listenPort: server.listen_port,
|
||||||
serviceType: serviceType(server.service_type),
|
serviceType: serviceType(server.service_type),
|
||||||
@@ -163,7 +172,7 @@ export class OperatorResourceSync {
|
|||||||
opts: server.jvm_opts ?? undefined,
|
opts: server.jvm_opts ?? undefined,
|
||||||
useAikarFlags: server.use_aikar_flags,
|
useAikarFlags: server.use_aikar_flags,
|
||||||
useMeowIceFlags: server.use_meowice_flags,
|
useMeowIceFlags: server.use_meowice_flags,
|
||||||
heapPercent: 80,
|
heapPercent: 60,
|
||||||
},
|
},
|
||||||
properties: {
|
properties: {
|
||||||
difficulty: server.difficulty,
|
difficulty: server.difficulty,
|
||||||
@@ -175,13 +184,29 @@ export class OperatorResourceSync {
|
|||||||
levelSeed: server.level_seed ?? undefined,
|
levelSeed: server.level_seed ?? undefined,
|
||||||
levelType: server.level_type ?? undefined,
|
levelType: server.level_type ?? undefined,
|
||||||
},
|
},
|
||||||
env: server.env_variables.map((entry) => ({ name: entry.key, value: entry.value })),
|
env: this.serverEnvironment(server),
|
||||||
apiKeySecretRef: secretName,
|
apiKeySecretRef: secretName,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await this.deleteSecret(`${name}-api-key`);
|
await this.deleteSecret(`${name}-api-key`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private serverEnvironment(
|
||||||
|
server: ServerWithEnvVars & {
|
||||||
|
plugins?: Array<{ enabled: boolean; artifact: { download_token: string } }>;
|
||||||
|
}
|
||||||
|
): Array<{ name: string; value: string }> {
|
||||||
|
const environment = new Map(server.env_variables.map((entry) => [entry.key, entry.value]));
|
||||||
|
const registryUrls = (server.plugins ?? [])
|
||||||
|
.filter((plugin) => plugin.enabled)
|
||||||
|
.map((plugin) => this.pluginRegistry.artifactUrl(plugin.artifact.download_token));
|
||||||
|
if (registryUrls.length > 0) {
|
||||||
|
const existing = environment.get("PLUGINS");
|
||||||
|
environment.set("PLUGINS", [existing, ...registryUrls].filter(Boolean).join(","));
|
||||||
|
}
|
||||||
|
return [...environment].map(([name, value]) => ({ name, value }));
|
||||||
|
}
|
||||||
|
|
||||||
private async syncReverseProxy(proxy: ReverseProxyWithEnvVars): Promise<void> {
|
private async syncReverseProxy(proxy: ReverseProxyWithEnvVars): Promise<void> {
|
||||||
const name = operatorResourceName(proxy.id);
|
const name = operatorResourceName(proxy.id);
|
||||||
const secretName = `rp-${name}-api-key`;
|
const secretName = `rp-${name}-api-key`;
|
||||||
|
|||||||
@@ -0,0 +1,541 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import {
|
||||||
|
CreateBucketCommand,
|
||||||
|
DeleteObjectCommand,
|
||||||
|
GetObjectCommand,
|
||||||
|
HeadBucketCommand,
|
||||||
|
PutObjectCommand,
|
||||||
|
S3Client,
|
||||||
|
} from "@aws-sdk/client-s3";
|
||||||
|
import { type PluginArtifact, type PluginProvider, prisma } from "@minikura/db";
|
||||||
|
import { NotFoundError, ValidationError } from "../domain/errors/base.error";
|
||||||
|
|
||||||
|
const USER_AGENT = process.env.PLUGIN_REGISTRY_USER_AGENT || "Minikura/1.0 (plugin registry)";
|
||||||
|
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
|
||||||
|
|
||||||
|
export type RegistryProject = {
|
||||||
|
provider: "MODRINTH" | "HANGAR";
|
||||||
|
projectId: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
iconUrl: string | null;
|
||||||
|
downloads: number;
|
||||||
|
license: string | null;
|
||||||
|
author: string;
|
||||||
|
categories: string[];
|
||||||
|
minecraftVersions: string[];
|
||||||
|
updatedAt: string | null;
|
||||||
|
projectUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RegistryVersion = {
|
||||||
|
provider: "MODRINTH" | "HANGAR";
|
||||||
|
projectId: string;
|
||||||
|
projectName?: string;
|
||||||
|
versionId: string;
|
||||||
|
version: string;
|
||||||
|
platform: string;
|
||||||
|
minecraftVersions: string[];
|
||||||
|
filename: string;
|
||||||
|
size: number;
|
||||||
|
sha256: string | null;
|
||||||
|
downloadUrl: string;
|
||||||
|
license: string | null;
|
||||||
|
description?: string;
|
||||||
|
author?: string;
|
||||||
|
iconUrl?: string | null;
|
||||||
|
projectUrl?: string;
|
||||||
|
categories?: string[];
|
||||||
|
updatedAt?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ModrinthSearchResponse = {
|
||||||
|
hits: Array<{
|
||||||
|
project_id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon_url?: string;
|
||||||
|
downloads: number;
|
||||||
|
license?: string;
|
||||||
|
author: string;
|
||||||
|
categories: string[];
|
||||||
|
versions: string[];
|
||||||
|
date_modified: string;
|
||||||
|
slug?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ModrinthVersion = {
|
||||||
|
id: string;
|
||||||
|
version_number: string;
|
||||||
|
version_type: string;
|
||||||
|
game_versions: string[];
|
||||||
|
loaders: string[];
|
||||||
|
files: Array<{
|
||||||
|
filename: string;
|
||||||
|
size: number;
|
||||||
|
url: string;
|
||||||
|
primary: boolean;
|
||||||
|
hashes: { sha512?: string; sha1?: string };
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HangarProject = {
|
||||||
|
namespace: { owner: string; slug: string };
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
stats: { downloads: number };
|
||||||
|
settings?: { license?: { type?: string; name?: string } };
|
||||||
|
category?: string;
|
||||||
|
lastUpdated?: string;
|
||||||
|
memberNames?: string[] | null;
|
||||||
|
supportedPlatforms?: Record<string, string[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HangarVersion = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
channel: { name: string };
|
||||||
|
downloads: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
fileInfo: { name: string; sizeBytes: number; sha256Hash: string };
|
||||||
|
downloadUrl: string | null;
|
||||||
|
externalUrl: string | null;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
platformDependencies: Record<string, string[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function s3Client(): S3Client {
|
||||||
|
return new S3Client({
|
||||||
|
region: process.env.S3_REGION || "us-east-1",
|
||||||
|
endpoint: process.env.S3_ENDPOINT,
|
||||||
|
forcePathStyle: process.env.S3_FORCE_PATH_STYLE === "true",
|
||||||
|
credentials:
|
||||||
|
process.env.S3_ACCESS_KEY_ID && process.env.S3_SECRET_ACCESS_KEY
|
||||||
|
? {
|
||||||
|
accessKeyId: process.env.S3_ACCESS_KEY_ID,
|
||||||
|
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registryFetch<T>(url: string): Promise<T> {
|
||||||
|
const response = await fetch(url, { headers: { "User-Agent": USER_AGENT } });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Plugin provider request failed (${response.status})`);
|
||||||
|
}
|
||||||
|
return (await response.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function modrinthLoader(platform: string): string {
|
||||||
|
if (platform === "VELOCITY") return "velocity";
|
||||||
|
if (platform === "FOLIA") return "folia";
|
||||||
|
return "paper";
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PluginRegistryService {
|
||||||
|
async search(query: string, platform: string, minecraftVersion?: string) {
|
||||||
|
const loader = modrinthLoader(platform);
|
||||||
|
const facets = [["project_type:plugin"], [`categories:${loader}`]];
|
||||||
|
if (minecraftVersion && minecraftVersion !== "LATEST") {
|
||||||
|
facets.push([`versions:${minecraftVersion}`]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const modrinthUrl = new URL("https://api.modrinth.com/v2/search");
|
||||||
|
modrinthUrl.searchParams.set("query", query);
|
||||||
|
modrinthUrl.searchParams.set("limit", "20");
|
||||||
|
modrinthUrl.searchParams.set("index", "downloads");
|
||||||
|
modrinthUrl.searchParams.set("facets", JSON.stringify(facets));
|
||||||
|
|
||||||
|
const hangarUrl = new URL("https://hangar.papermc.io/api/v1/projects");
|
||||||
|
if (query.trim()) hangarUrl.searchParams.set("query", query);
|
||||||
|
hangarUrl.searchParams.set("limit", "20");
|
||||||
|
hangarUrl.searchParams.set("sort", "-downloads");
|
||||||
|
hangarUrl.searchParams.set("platform", platform === "FOLIA" ? "PAPER" : platform);
|
||||||
|
|
||||||
|
const [modrinth, hangar] = await Promise.allSettled([
|
||||||
|
registryFetch<ModrinthSearchResponse>(modrinthUrl.toString()),
|
||||||
|
registryFetch<{ result: HangarProject[] }>(hangarUrl.toString()),
|
||||||
|
]);
|
||||||
|
const projects: RegistryProject[] = [];
|
||||||
|
|
||||||
|
if (modrinth.status === "fulfilled") {
|
||||||
|
projects.push(
|
||||||
|
...modrinth.value.hits.map((project) => ({
|
||||||
|
provider: "MODRINTH" as const,
|
||||||
|
projectId: project.project_id,
|
||||||
|
name: project.title,
|
||||||
|
description: project.description,
|
||||||
|
iconUrl: project.icon_url ?? null,
|
||||||
|
downloads: project.downloads,
|
||||||
|
license: project.license ?? null,
|
||||||
|
author: project.author,
|
||||||
|
categories: project.categories,
|
||||||
|
minecraftVersions: project.versions,
|
||||||
|
updatedAt: project.date_modified,
|
||||||
|
projectUrl: `https://modrinth.com/plugin/${project.slug ?? project.project_id}`,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (hangar.status === "fulfilled") {
|
||||||
|
projects.push(
|
||||||
|
...hangar.value.result.map((project) => ({
|
||||||
|
provider: "HANGAR" as const,
|
||||||
|
projectId: `${project.namespace.owner}/${project.namespace.slug}`,
|
||||||
|
name: project.name,
|
||||||
|
description: project.description,
|
||||||
|
iconUrl: project.avatarUrl ?? null,
|
||||||
|
downloads: project.stats.downloads,
|
||||||
|
license: project.settings?.license?.type ?? project.settings?.license?.name ?? null,
|
||||||
|
author: project.memberNames?.[0] ?? project.namespace.owner,
|
||||||
|
categories: project.category ? [project.category] : [],
|
||||||
|
minecraftVersions: Object.values(project.supportedPlatforms ?? {}).flat(),
|
||||||
|
updatedAt: project.lastUpdated ?? null,
|
||||||
|
projectUrl: `https://hangar.papermc.io/${project.namespace.owner}/${project.namespace.slug}`,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return projects.sort((a, b) => b.downloads - a.downloads);
|
||||||
|
}
|
||||||
|
|
||||||
|
async versions(
|
||||||
|
provider: "MODRINTH" | "HANGAR",
|
||||||
|
projectId: string,
|
||||||
|
platform: string,
|
||||||
|
minecraftVersion?: string
|
||||||
|
): Promise<RegistryVersion[]> {
|
||||||
|
if (provider === "MODRINTH") {
|
||||||
|
const url = new URL(
|
||||||
|
`https://api.modrinth.com/v2/project/${encodeURIComponent(projectId)}/version`
|
||||||
|
);
|
||||||
|
url.searchParams.set("loaders", JSON.stringify([modrinthLoader(platform)]));
|
||||||
|
if (minecraftVersion && minecraftVersion !== "LATEST") {
|
||||||
|
url.searchParams.set("game_versions", JSON.stringify([minecraftVersion]));
|
||||||
|
}
|
||||||
|
url.searchParams.set("include_changelog", "false");
|
||||||
|
const versions = await registryFetch<ModrinthVersion[]>(url.toString());
|
||||||
|
return versions
|
||||||
|
.filter((version) => version.version_type === "release")
|
||||||
|
.flatMap((version): RegistryVersion[] => {
|
||||||
|
const file = version.files.find((candidate) => candidate.primary) ?? version.files[0];
|
||||||
|
return file
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
provider,
|
||||||
|
projectId,
|
||||||
|
versionId: version.id,
|
||||||
|
version: version.version_number,
|
||||||
|
platform,
|
||||||
|
minecraftVersions: version.game_versions,
|
||||||
|
filename: file.filename,
|
||||||
|
size: file.size,
|
||||||
|
sha256: null,
|
||||||
|
downloadUrl: file.url,
|
||||||
|
license: null,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [owner, slug] = projectId.split("/", 2);
|
||||||
|
if (!owner || !slug) throw new ValidationError("Invalid Hangar project ID");
|
||||||
|
const url = new URL(
|
||||||
|
`https://hangar.papermc.io/api/v1/projects/${encodeURIComponent(owner)}/${encodeURIComponent(slug)}/versions`
|
||||||
|
);
|
||||||
|
url.searchParams.set("limit", "100");
|
||||||
|
const response = await registryFetch<{ result: HangarVersion[] }>(url.toString());
|
||||||
|
const resolvedPlatform = platform === "FOLIA" ? "PAPER" : platform;
|
||||||
|
return response.result
|
||||||
|
.filter((version) => version.channel.name.toLowerCase() === "release")
|
||||||
|
.filter(
|
||||||
|
(version) =>
|
||||||
|
!minecraftVersion ||
|
||||||
|
minecraftVersion === "LATEST" ||
|
||||||
|
version.platformDependencies[resolvedPlatform]?.includes(minecraftVersion)
|
||||||
|
)
|
||||||
|
.flatMap((version) => {
|
||||||
|
const download = version.downloads[resolvedPlatform];
|
||||||
|
const downloadUrl = download?.downloadUrl ?? download?.externalUrl;
|
||||||
|
if (!download || !downloadUrl) return [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
provider,
|
||||||
|
projectId,
|
||||||
|
versionId: String(version.id),
|
||||||
|
version: version.name,
|
||||||
|
platform,
|
||||||
|
minecraftVersions: version.platformDependencies[resolvedPlatform] ?? [],
|
||||||
|
filename: download.fileInfo.name,
|
||||||
|
size: download.fileInfo.sizeBytes,
|
||||||
|
sha256: download.fileInfo.sha256Hash,
|
||||||
|
downloadUrl,
|
||||||
|
license: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async register(input: RegistryVersion): Promise<PluginArtifact> {
|
||||||
|
this.validateProviderUrl(input.provider, input.downloadUrl);
|
||||||
|
const sha256 = input.sha256 ?? (await this.hashRemoteFile(input.downloadUrl, input.size));
|
||||||
|
const artifact = await prisma.pluginArtifact.upsert({
|
||||||
|
where: {
|
||||||
|
provider_provider_version_id_platform_filename: {
|
||||||
|
provider: input.provider,
|
||||||
|
provider_version_id: input.versionId,
|
||||||
|
platform: input.platform,
|
||||||
|
filename: input.filename,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
source_url: input.downloadUrl,
|
||||||
|
sha256,
|
||||||
|
size: input.size,
|
||||||
|
name: input.projectName ?? input.projectId,
|
||||||
|
license: input.license,
|
||||||
|
description: input.description,
|
||||||
|
author: input.author,
|
||||||
|
icon_url: input.iconUrl,
|
||||||
|
project_url: input.projectUrl,
|
||||||
|
categories: input.categories ?? [],
|
||||||
|
provider_updated_at: input.updatedAt ? new Date(input.updatedAt) : null,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
provider: input.provider,
|
||||||
|
provider_project_id: input.projectId,
|
||||||
|
provider_version_id: input.versionId,
|
||||||
|
name: input.projectName ?? input.projectId,
|
||||||
|
version: input.version,
|
||||||
|
platform: input.platform,
|
||||||
|
minecraft_versions: input.minecraftVersions,
|
||||||
|
filename: input.filename,
|
||||||
|
size: input.size,
|
||||||
|
sha256,
|
||||||
|
source_url: input.downloadUrl,
|
||||||
|
license: input.license,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return artifact;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deploy(serverId: string, artifactId: string): Promise<PluginArtifact> {
|
||||||
|
const [server, artifact] = await Promise.all([
|
||||||
|
prisma.server.findUnique({ where: { id: serverId } }),
|
||||||
|
prisma.pluginArtifact.findUnique({ where: { id: artifactId } }),
|
||||||
|
]);
|
||||||
|
if (!server) throw new NotFoundError("Server", serverId);
|
||||||
|
if (!artifact) throw new NotFoundError("Plugin artifact", artifactId);
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.serverPlugin.deleteMany({
|
||||||
|
where: {
|
||||||
|
server_id: serverId,
|
||||||
|
artifact: {
|
||||||
|
provider: artifact.provider,
|
||||||
|
provider_project_id: artifact.provider_project_id,
|
||||||
|
id: { not: artifact.id },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.serverPlugin.upsert({
|
||||||
|
where: { server_id_artifact_id: { server_id: serverId, artifact_id: artifact.id } },
|
||||||
|
update: { enabled: true },
|
||||||
|
create: { server_id: serverId, artifact_id: artifact.id },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return artifact;
|
||||||
|
}
|
||||||
|
|
||||||
|
async upload(file: File): Promise<PluginArtifact> {
|
||||||
|
if (!file.name.toLowerCase().endsWith(".jar")) {
|
||||||
|
throw new ValidationError("Plugin upload must be a JAR file");
|
||||||
|
}
|
||||||
|
if (file.size === 0 || file.size > MAX_UPLOAD_BYTES) {
|
||||||
|
throw new ValidationError("Plugin upload must be between 1 byte and 100 MiB");
|
||||||
|
}
|
||||||
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
if (bytes[0] !== 0x50 || bytes[1] !== 0x4b) {
|
||||||
|
throw new ValidationError("Plugin upload is not a valid JAR archive");
|
||||||
|
}
|
||||||
|
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||||
|
const bucket = this.bucket();
|
||||||
|
const client = s3Client();
|
||||||
|
await this.ensureBucket(client, bucket);
|
||||||
|
const objectKey = `artifacts/sha256/${sha256.slice(0, 2)}/${sha256}.jar`;
|
||||||
|
await client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: bucket,
|
||||||
|
Key: objectKey,
|
||||||
|
Body: bytes,
|
||||||
|
ContentType: "application/java-archive",
|
||||||
|
Metadata: { sha256, filename: file.name },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const artifact = await prisma.pluginArtifact.upsert({
|
||||||
|
where: {
|
||||||
|
provider_provider_version_id_platform_filename: {
|
||||||
|
provider: "UPLOAD",
|
||||||
|
provider_version_id: sha256,
|
||||||
|
platform: "SERVER",
|
||||||
|
filename: file.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: { object_key: objectKey },
|
||||||
|
create: {
|
||||||
|
provider: "UPLOAD",
|
||||||
|
provider_project_id: sha256,
|
||||||
|
provider_version_id: sha256,
|
||||||
|
name: file.name.replace(/\.jar$/i, ""),
|
||||||
|
version: "uploaded",
|
||||||
|
platform: "SERVER",
|
||||||
|
minecraft_versions: [],
|
||||||
|
filename: file.name,
|
||||||
|
size: file.size,
|
||||||
|
sha256,
|
||||||
|
storage_mode: "S3",
|
||||||
|
object_key: objectKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return artifact;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listArtifacts() {
|
||||||
|
return prisma.pluginArtifact.findMany({
|
||||||
|
include: { server_plugins: { select: { server_id: true } } },
|
||||||
|
orderBy: { created_at: "desc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteArtifact(artifactId: string): Promise<string[]> {
|
||||||
|
const artifact = await prisma.pluginArtifact.findUnique({
|
||||||
|
where: { id: artifactId },
|
||||||
|
include: { server_plugins: { select: { server_id: true } } },
|
||||||
|
});
|
||||||
|
if (!artifact) throw new NotFoundError("Plugin artifact", artifactId);
|
||||||
|
|
||||||
|
if (artifact.storage_mode === "S3" && artifact.object_key) {
|
||||||
|
await s3Client().send(
|
||||||
|
new DeleteObjectCommand({ Bucket: this.bucket(), Key: artifact.object_key })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.serverPlugin.deleteMany({ where: { artifact_id: artifactId } }),
|
||||||
|
prisma.pluginArtifact.delete({ where: { id: artifactId } }),
|
||||||
|
]);
|
||||||
|
return [...new Set(artifact.server_plugins.map((plugin) => plugin.server_id))];
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(serverId: string) {
|
||||||
|
return prisma.serverPlugin.findMany({
|
||||||
|
where: { server_id: serverId },
|
||||||
|
include: { artifact: true },
|
||||||
|
orderBy: { created_at: "asc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async reconcile(serverId: string, artifactIds: string[]): Promise<void> {
|
||||||
|
const server = await prisma.server.findUnique({ where: { id: serverId } });
|
||||||
|
if (!server) throw new NotFoundError("Server", serverId);
|
||||||
|
const uniqueIds = [...new Set(artifactIds)];
|
||||||
|
const artifacts = await prisma.pluginArtifact.findMany({ where: { id: { in: uniqueIds } } });
|
||||||
|
if (artifacts.length !== uniqueIds.length) {
|
||||||
|
throw new ValidationError("One or more plugin artifacts do not exist");
|
||||||
|
}
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.serverPlugin.deleteMany({
|
||||||
|
where: { server_id: serverId, artifact_id: { notIn: uniqueIds } },
|
||||||
|
}),
|
||||||
|
...uniqueIds.map((artifactId) =>
|
||||||
|
prisma.serverPlugin.upsert({
|
||||||
|
where: { server_id_artifact_id: { server_id: serverId, artifact_id: artifactId } },
|
||||||
|
update: { enabled: true },
|
||||||
|
create: { server_id: serverId, artifact_id: artifactId },
|
||||||
|
})
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(serverId: string, installationId: string): Promise<void> {
|
||||||
|
const result = await prisma.serverPlugin.deleteMany({
|
||||||
|
where: { id: installationId, server_id: serverId },
|
||||||
|
});
|
||||||
|
if (result.count === 0) throw new NotFoundError("Server plugin", installationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async download(token: string): Promise<{ redirect?: string; response?: Response }> {
|
||||||
|
const artifact = await prisma.pluginArtifact.findUnique({ where: { download_token: token } });
|
||||||
|
if (!artifact) throw new NotFoundError("Plugin artifact");
|
||||||
|
if (artifact.storage_mode === "REMOTE" && artifact.source_url) {
|
||||||
|
return { redirect: artifact.source_url };
|
||||||
|
}
|
||||||
|
if (!artifact.object_key) throw new NotFoundError("Plugin artifact object");
|
||||||
|
const object = await s3Client().send(
|
||||||
|
new GetObjectCommand({
|
||||||
|
Bucket: this.bucket(),
|
||||||
|
Key: artifact.object_key,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if (!object.Body) throw new NotFoundError("Plugin artifact object");
|
||||||
|
return {
|
||||||
|
response: new Response(object.Body.transformToWebStream(), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": object.ContentType || "application/java-archive",
|
||||||
|
"Content-Length": String(object.ContentLength ?? artifact.size),
|
||||||
|
"Content-Disposition": `attachment; filename="${artifact.filename.replaceAll('"', "")}"`,
|
||||||
|
ETag: `"${artifact.sha256}"`,
|
||||||
|
"Cache-Control": "private, max-age=300, immutable",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
artifactUrl(token: string): string {
|
||||||
|
const baseUrl =
|
||||||
|
process.env.MINIKURA_PLUGIN_DOWNLOAD_BASE_URL ||
|
||||||
|
process.env.MINIKURA_OPERATOR_BACKEND_URL ||
|
||||||
|
"http://minikura-backend:3000/api";
|
||||||
|
return `${baseUrl.replace(/\/$/, "")}/registry/artifacts/${token}/download`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async hashRemoteFile(url: string, expectedSize: number): Promise<string> {
|
||||||
|
if (expectedSize > MAX_UPLOAD_BYTES) throw new ValidationError("Plugin artifact is too large");
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { "User-Agent": USER_AGENT },
|
||||||
|
redirect: "follow",
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new ValidationError("Unable to download plugin artifact");
|
||||||
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||||
|
if (bytes.byteLength !== expectedSize)
|
||||||
|
throw new ValidationError("Plugin artifact size changed");
|
||||||
|
return createHash("sha256").update(bytes).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateProviderUrl(provider: PluginProvider, value: string): void {
|
||||||
|
const url = new URL(value);
|
||||||
|
const allowed =
|
||||||
|
provider === "MODRINTH"
|
||||||
|
? url.protocol === "https:" && url.hostname === "cdn.modrinth.com"
|
||||||
|
: provider === "HANGAR"
|
||||||
|
? url.protocol === "https:" && url.hostname === "hangarcdn.papermc.io"
|
||||||
|
: false;
|
||||||
|
if (!allowed)
|
||||||
|
throw new ValidationError("Plugin artifact URL is not from the selected provider");
|
||||||
|
}
|
||||||
|
|
||||||
|
private bucket(): string {
|
||||||
|
return process.env.S3_BUCKET || "minikura-plugins";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureBucket(client: S3Client, bucket: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await client.send(new HeadBucketCommand({ Bucket: bucket }));
|
||||||
|
} catch {
|
||||||
|
await client.send(new CreateBucketCommand({ Bucket: bucket }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { PluginRegistry } from "@/components/plugin-registry";
|
||||||
|
|
||||||
|
export default function PluginsPage() {
|
||||||
|
return <PluginRegistry />;
|
||||||
|
}
|
||||||
@@ -35,6 +35,11 @@ export default function CreateServerPage() {
|
|||||||
throw new Error(errorMsg);
|
throw new Error(errorMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pluginResponse = await api.api.registry
|
||||||
|
.servers({ serverId: payload.id })
|
||||||
|
.plugins.put({ artifactIds: data.registryArtifactIds });
|
||||||
|
if (pluginResponse.error) throw new Error("Server created, but plugin deployment failed");
|
||||||
|
|
||||||
router.push("/dashboard/servers");
|
router.push("/dashboard/servers");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,13 @@ export default function EditServerPage() {
|
|||||||
throw new Error(errorMsg);
|
throw new Error(errorMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (resourceKind === "server") {
|
||||||
|
const pluginResponse = await api.api.registry
|
||||||
|
.servers({ serverId })
|
||||||
|
.plugins.put({ artifactIds: data.registryArtifactIds });
|
||||||
|
if (pluginResponse.error) throw new Error("Server updated, but plugin deployment failed");
|
||||||
|
}
|
||||||
|
|
||||||
router.push("/dashboard/servers");
|
router.push("/dashboard/servers");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { NormalServer, PodInfo } from "@minikura/api";
|
||||||
|
import { ArrowLeft, Play, RefreshCw, ServerIcon, Square } from "lucide-react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { PageHeader, PageShell, StatePanel } from "@/components/page-layout";
|
||||||
|
import { SectionCard } from "@/components/section-card";
|
||||||
|
import { Terminal } from "@/components/terminal";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
|
||||||
|
export default function ManageServerPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const serverId = useParams<{ id: string }>().id;
|
||||||
|
const [server, setServer] = useState<NormalServer | null>(null);
|
||||||
|
const [pods, setPods] = useState<PodInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [acting, setActing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
const [serverResponse, podsResponse] = await Promise.all([
|
||||||
|
api.api.servers({ id: serverId }).get(),
|
||||||
|
api.api.k8s.servers({ serverId }).pods.get(),
|
||||||
|
]);
|
||||||
|
if (serverResponse.error) throw serverResponse.error;
|
||||||
|
if (podsResponse.error) throw podsResponse.error;
|
||||||
|
setServer(serverResponse.data as NormalServer);
|
||||||
|
setPods((podsResponse.data ?? []) as PodInfo[]);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void Promise.all([
|
||||||
|
api.api.servers({ id: serverId }).get(),
|
||||||
|
api.api.k8s.servers({ serverId }).pods.get(),
|
||||||
|
])
|
||||||
|
.then(([serverResponse, podsResponse]) => {
|
||||||
|
if (serverResponse.error) throw serverResponse.error;
|
||||||
|
if (podsResponse.error) throw podsResponse.error;
|
||||||
|
setServer(serverResponse.data as NormalServer);
|
||||||
|
setPods((podsResponse.data ?? []) as PodInfo[]);
|
||||||
|
})
|
||||||
|
.catch(() => setError("Unable to load server operations"))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [serverId]);
|
||||||
|
|
||||||
|
const action = async (kind: "start" | "stop" | "restart") => {
|
||||||
|
setActing(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await api.api.servers({ id: serverId }).actions[kind].post();
|
||||||
|
if (response.error) throw response.error;
|
||||||
|
await refresh();
|
||||||
|
} catch {
|
||||||
|
setError(`Failed to ${kind} server`);
|
||||||
|
} finally {
|
||||||
|
setActing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading)
|
||||||
|
return <StatePanel loading title="Loading server operations..." className="min-h-[50vh]" />;
|
||||||
|
if (!server) return <StatePanel title="Server not found" tone="error" />;
|
||||||
|
|
||||||
|
const pod = pods.find((candidate) => candidate.status === "Running") ?? pods[0];
|
||||||
|
const running = server.running !== false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="Workloads / Operations"
|
||||||
|
title={server.id}
|
||||||
|
description="Live console access and workload lifecycle controls."
|
||||||
|
leading={
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => router.push("/dashboard/servers")}>
|
||||||
|
<ArrowLeft className="size-5" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant={running ? "default" : "secondary"}>
|
||||||
|
{running ? "Desired: running" : "Stopped"}
|
||||||
|
</Badge>
|
||||||
|
{running ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={acting || !pod}
|
||||||
|
onClick={() => void action("restart")}
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-4" /> Restart
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" disabled={acting} onClick={() => void action("stop")}>
|
||||||
|
<Square className="size-4" /> Stop
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button disabled={acting} onClick={() => void action("start")}>
|
||||||
|
<Play className="size-4" /> Start
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
className="flex-row items-center justify-start"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="border-l-2 border-destructive pl-3 text-sm text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<div className="border bg-card p-4">
|
||||||
|
<p className="font-mono text-[10px] uppercase text-muted-foreground">Pod</p>
|
||||||
|
<p className="mt-1 truncate font-bold">{pod?.name ?? "Not scheduled"}</p>
|
||||||
|
</div>
|
||||||
|
<div className="border bg-card p-4">
|
||||||
|
<p className="font-mono text-[10px] uppercase text-muted-foreground">Runtime</p>
|
||||||
|
<p className="mt-1 font-bold">
|
||||||
|
{server.jar_type} {server.minecraft_version}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="border bg-card p-4">
|
||||||
|
<p className="font-mono text-[10px] uppercase text-muted-foreground">Status</p>
|
||||||
|
<p className="mt-1 font-bold">
|
||||||
|
{pod ? `${pod.status} · ${pod.ready} ready` : running ? "Starting" : "Stopped"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionCard
|
||||||
|
title="Console"
|
||||||
|
description="Attach to Minecraft output, send server commands, or open a container shell."
|
||||||
|
icon={<ServerIcon className="size-5 text-primary" />}
|
||||||
|
headerAction={
|
||||||
|
<Button variant="ghost" size="sm" disabled={acting} onClick={() => void refresh()}>
|
||||||
|
<RefreshCw className="size-4" /> Refresh
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
contentClassName="p-0"
|
||||||
|
>
|
||||||
|
{!pod ? (
|
||||||
|
<StatePanel
|
||||||
|
title={running ? "Waiting for the Minecraft pod" : "Server is stopped"}
|
||||||
|
description={
|
||||||
|
running
|
||||||
|
? "Refresh once Kubernetes has scheduled the workload."
|
||||||
|
: "Start the server to access logs and console."
|
||||||
|
}
|
||||||
|
className="m-6 min-h-72"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Tabs defaultValue="console" className="gap-0">
|
||||||
|
<TabsList className="mx-5 mt-4 sm:mx-6">
|
||||||
|
<TabsTrigger value="console">Live Console</TabsTrigger>
|
||||||
|
<TabsTrigger value="shell">Container Shell</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="console" className="h-[34rem] bg-black p-2">
|
||||||
|
<Terminal
|
||||||
|
key={`${pod.name}-console`}
|
||||||
|
podName={pod.name}
|
||||||
|
container="minecraft"
|
||||||
|
mode="console"
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="shell" className="h-[34rem] bg-black p-2">
|
||||||
|
<Terminal
|
||||||
|
key={`${pod.name}-shell`}
|
||||||
|
podName={pod.name}
|
||||||
|
container="minecraft"
|
||||||
|
mode="shell"
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -65,6 +65,9 @@ export default function ServersPage() {
|
|||||||
type="normal"
|
type="normal"
|
||||||
servers={normalServers}
|
servers={normalServers}
|
||||||
onEdit={isAdmin ? (id) => router.push(`/dashboard/servers/edit/${id}`) : undefined}
|
onEdit={isAdmin ? (id) => router.push(`/dashboard/servers/edit/${id}`) : undefined}
|
||||||
|
onManage={
|
||||||
|
isAdmin ? (id) => router.push(`/dashboard/servers/manage/${id}`) : undefined
|
||||||
|
}
|
||||||
onDelete={isAdmin ? (id) => setDeleteTarget({ id, type: "normal" }) : undefined}
|
onDelete={isAdmin ? (id) => setDeleteTarget({ id, type: "normal" }) : undefined}
|
||||||
/>
|
/>
|
||||||
</ResourceSection>
|
</ResourceSection>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { GitGraph, LogOut, type LucideIcon, Network, Server, Users } from "lucide-react";
|
import { GitGraph, LogOut, type LucideIcon, Network, Package, Server, Users } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
@@ -55,6 +55,13 @@ const navigation: NavigationGroup[] = [
|
|||||||
adminOnly: true,
|
adminOnly: true,
|
||||||
},
|
},
|
||||||
{ href: "/dashboard/servers", icon: Server, label: "Servers", context: "Workloads" },
|
{ href: "/dashboard/servers", icon: Server, label: "Servers", context: "Workloads" },
|
||||||
|
{
|
||||||
|
href: "/dashboard/plugins",
|
||||||
|
icon: Package,
|
||||||
|
label: "Plugins",
|
||||||
|
context: "Registry",
|
||||||
|
adminOnly: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: "/dashboard/topology",
|
href: "/dashboard/topology",
|
||||||
icon: GitGraph,
|
icon: GitGraph,
|
||||||
@@ -94,8 +101,10 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
|
|||||||
(pathname === "/dashboard/users" ||
|
(pathname === "/dashboard/users" ||
|
||||||
pathname.startsWith("/dashboard/topology") ||
|
pathname.startsWith("/dashboard/topology") ||
|
||||||
pathname.startsWith("/dashboard/k8s") ||
|
pathname.startsWith("/dashboard/k8s") ||
|
||||||
|
pathname.startsWith("/dashboard/plugins") ||
|
||||||
pathname.startsWith("/dashboard/servers/create") ||
|
pathname.startsWith("/dashboard/servers/create") ||
|
||||||
pathname.startsWith("/dashboard/servers/edit"))
|
pathname.startsWith("/dashboard/servers/edit") ||
|
||||||
|
pathname.startsWith("/dashboard/servers/manage"))
|
||||||
) {
|
) {
|
||||||
router.replace("/dashboard/servers");
|
router.replace("/dashboard/servers");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,557 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { HardDriveUpload, LoaderCircle, Package, PackageSearch, Trash2 } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||||
|
import { PageHeader, PageShell, StatePanel } from "@/components/page-layout";
|
||||||
|
import { SectionCard } from "@/components/section-card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
|
||||||
|
type Project = {
|
||||||
|
provider: "MODRINTH" | "HANGAR";
|
||||||
|
projectId: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
iconUrl: string | null;
|
||||||
|
downloads: number;
|
||||||
|
license: string | null;
|
||||||
|
author: string;
|
||||||
|
categories: string[];
|
||||||
|
minecraftVersions: string[];
|
||||||
|
updatedAt: string | null;
|
||||||
|
projectUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Version = {
|
||||||
|
provider: "MODRINTH" | "HANGAR";
|
||||||
|
projectId: string;
|
||||||
|
projectName?: string;
|
||||||
|
versionId: string;
|
||||||
|
version: string;
|
||||||
|
platform: "PAPER" | "FOLIA" | "VELOCITY";
|
||||||
|
minecraftVersions: string[];
|
||||||
|
filename: string;
|
||||||
|
size: number;
|
||||||
|
sha256: string | null;
|
||||||
|
downloadUrl: string;
|
||||||
|
license: string | null;
|
||||||
|
description?: string;
|
||||||
|
author?: string;
|
||||||
|
iconUrl?: string | null;
|
||||||
|
projectUrl?: string;
|
||||||
|
categories?: string[];
|
||||||
|
updatedAt?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Artifact = {
|
||||||
|
id: string;
|
||||||
|
provider: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
filename: string;
|
||||||
|
description: string | null;
|
||||||
|
author: string | null;
|
||||||
|
icon_url: string | null;
|
||||||
|
project_url: string | null;
|
||||||
|
categories: string[];
|
||||||
|
server_plugins: Array<{ server_id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PluginRegistry() {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [versions, setVersions] = useState<Version[]>([]);
|
||||||
|
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
||||||
|
const [artifacts, setArtifacts] = useState<Artifact[]>([]);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Artifact | null>(null);
|
||||||
|
const platform = "PAPER" as const;
|
||||||
|
|
||||||
|
const loadArtifacts = async () => {
|
||||||
|
const response = await api.api.registry.artifacts.get();
|
||||||
|
if (response.error) throw response.error;
|
||||||
|
setArtifacts((response.data ?? []) as Artifact[]);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void api.api.registry.artifacts
|
||||||
|
.get()
|
||||||
|
.then(({ data, error: responseError }) => {
|
||||||
|
if (responseError) throw responseError;
|
||||||
|
setArtifacts((data ?? []) as Artifact[]);
|
||||||
|
})
|
||||||
|
.catch((loadError) =>
|
||||||
|
setError(loadError instanceof Error ? loadError.message : "Failed to load registry")
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (projects.length > 0) return;
|
||||||
|
void api.api.registry.search
|
||||||
|
.get({
|
||||||
|
query: {
|
||||||
|
query: "",
|
||||||
|
platform,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(({ data, error: responseError }) => {
|
||||||
|
if (responseError) throw responseError;
|
||||||
|
setProjects((data ?? []) as Project[]);
|
||||||
|
})
|
||||||
|
.catch((loadError) =>
|
||||||
|
setError(loadError instanceof Error ? loadError.message : "Failed to load top plugins")
|
||||||
|
);
|
||||||
|
}, [platform, projects.length]);
|
||||||
|
|
||||||
|
const search = async () => {
|
||||||
|
if (!query.trim()) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
setSelectedProject(null);
|
||||||
|
setVersions([]);
|
||||||
|
try {
|
||||||
|
const response = await api.api.registry.search.get({
|
||||||
|
query: {
|
||||||
|
query,
|
||||||
|
platform,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (response.error) throw response.error;
|
||||||
|
setProjects((response.data ?? []) as Project[]);
|
||||||
|
} catch (searchError) {
|
||||||
|
setError(searchError instanceof Error ? searchError.message : "Search failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectProject = async (project: Project) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
setSelectedProject(project);
|
||||||
|
try {
|
||||||
|
const response = await api.api.registry.versions.get({
|
||||||
|
query: {
|
||||||
|
provider: project.provider,
|
||||||
|
projectId: project.projectId,
|
||||||
|
platform,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (response.error) throw response.error;
|
||||||
|
setVersions((response.data ?? []) as Version[]);
|
||||||
|
} catch (versionError) {
|
||||||
|
setError(versionError instanceof Error ? versionError.message : "Failed to load versions");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const store = async (version: Version) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await api.api.registry.artifacts.post({
|
||||||
|
...version,
|
||||||
|
projectName: selectedProject?.name,
|
||||||
|
description: selectedProject?.description,
|
||||||
|
author: selectedProject?.author,
|
||||||
|
iconUrl: selectedProject?.iconUrl,
|
||||||
|
projectUrl: selectedProject?.projectUrl,
|
||||||
|
categories: selectedProject?.categories,
|
||||||
|
updatedAt: selectedProject?.updatedAt,
|
||||||
|
});
|
||||||
|
if (response.error) throw response.error;
|
||||||
|
await loadArtifacts();
|
||||||
|
} catch (installError) {
|
||||||
|
setError(installError instanceof Error ? installError.message : "Registry import failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const upload = async (file: File | undefined) => {
|
||||||
|
if (!file) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await api.api.registry.artifacts.upload.post({ file });
|
||||||
|
if (response.error) throw response.error;
|
||||||
|
await loadArtifacts();
|
||||||
|
} catch (uploadError) {
|
||||||
|
setError(uploadError instanceof Error ? uploadError.message : "Upload failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteArtifact = async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await api.api.registry.library({ artifactId: deleteTarget.id }).delete();
|
||||||
|
if (response.error) throw response.error;
|
||||||
|
setDeleteTarget(null);
|
||||||
|
await loadArtifacts();
|
||||||
|
} catch (deleteError) {
|
||||||
|
setError(deleteError instanceof Error ? deleteError.message : "Failed to delete artifact");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="Artifacts"
|
||||||
|
title="Plugin Registry"
|
||||||
|
description="Discover releases and maintain a global plugin artifact library. Assign plugins from a server's Mods/Plugins settings."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tabs defaultValue="discover" className="gap-5">
|
||||||
|
<TabsList className="w-full sm:w-fit">
|
||||||
|
<TabsTrigger value="discover">
|
||||||
|
<PackageSearch className="size-4" /> Discover
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="library">
|
||||||
|
<Package className="size-4" /> Library
|
||||||
|
<span className="ml-1 rounded-full bg-background/15 px-1.5 py-0.5 text-[9px]">
|
||||||
|
{artifacts.length}
|
||||||
|
</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="discover">
|
||||||
|
<SectionCard
|
||||||
|
title="Catalog"
|
||||||
|
description="Browse Paper-compatible plugins from Modrinth and Hangar"
|
||||||
|
icon={<PackageSearch className="size-5 text-primary" />}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
void search();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Search ViaVersion, LuckPerms, CoreProtect..."
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={busy || !query.trim()}
|
||||||
|
onClick={() => void search()}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<LoaderCircle className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<PackageSearch className="size-4" />
|
||||||
|
)}
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="border-l-2 border-destructive pl-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedProject ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between border-b pb-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{selectedProject.iconUrl ? (
|
||||||
|
<span
|
||||||
|
role="img"
|
||||||
|
aria-label={`${selectedProject.name} icon`}
|
||||||
|
className="size-12 rounded-sm border bg-cover bg-center"
|
||||||
|
style={{ backgroundImage: `url(${selectedProject.iconUrl})` }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid size-12 place-items-center border bg-muted">
|
||||||
|
<Package className="size-5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="font-bold">{selectedProject.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
by {selectedProject.author} ·{" "}
|
||||||
|
{selectedProject.downloads.toLocaleString()} downloads
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 max-w-3xl text-sm text-muted-foreground">
|
||||||
|
{selectedProject.description}
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||||
|
<Badge variant="outline">{selectedProject.provider}</Badge>
|
||||||
|
{selectedProject.license && (
|
||||||
|
<Badge variant="secondary">{selectedProject.license}</Badge>
|
||||||
|
)}
|
||||||
|
{selectedProject.categories.slice(0, 4).map((category) => (
|
||||||
|
<Badge key={category} variant="secondary">
|
||||||
|
{category}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="button" variant="outline" size="sm" asChild>
|
||||||
|
<a href={selectedProject.projectUrl} target="_blank" rel="noreferrer">
|
||||||
|
Provider page
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedProject(null)}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!busy && versions.length === 0 && (
|
||||||
|
<StatePanel title="No compatible release found" className="min-h-32" />
|
||||||
|
)}
|
||||||
|
{versions.slice(0, 15).map((version) => (
|
||||||
|
<div
|
||||||
|
key={`${version.provider}-${version.versionId}-${version.platform}`}
|
||||||
|
className="flex items-center justify-between gap-3 border bg-background px-4 py-3"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-bold">{version.version}</p>
|
||||||
|
<p className="truncate font-mono text-[10px] text-muted-foreground">
|
||||||
|
{version.filename} · {(version.size / 1024 / 1024).toFixed(1)} MiB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void store(version)}
|
||||||
|
>
|
||||||
|
Store
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : projects.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{!query.trim() && (
|
||||||
|
<div className="flex items-end justify-between border-b pb-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-bold">Top plugins</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Popular projects ranked by downloads
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline">{projects.length} projects</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
{projects.map((project, index) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={`${project.provider}-${project.projectId}`}
|
||||||
|
className="group relative space-y-3 border bg-background p-4 text-left transition-colors hover:border-primary"
|
||||||
|
onClick={() => void selectProject(project)}
|
||||||
|
>
|
||||||
|
{!query.trim() && (
|
||||||
|
<span className="absolute right-3 top-3 font-mono text-2xl font-black text-muted-foreground/20">
|
||||||
|
{String(index + 1).padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex items-start gap-3 pr-8">
|
||||||
|
{project.iconUrl ? (
|
||||||
|
<span
|
||||||
|
role="img"
|
||||||
|
aria-label={`${project.name} icon`}
|
||||||
|
className="size-12 shrink-0 rounded-sm border bg-cover bg-center"
|
||||||
|
style={{ backgroundImage: `url(${project.iconUrl})` }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid size-12 shrink-0 place-items-center border bg-muted">
|
||||||
|
<Package className="size-5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-bold group-hover:text-primary">
|
||||||
|
{project.name}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
by {project.author}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="line-clamp-2 text-sm text-muted-foreground">
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<Badge variant="outline">{project.provider}</Badge>
|
||||||
|
{project.categories.slice(0, 2).map((category) => (
|
||||||
|
<Badge key={category} variant="secondary">
|
||||||
|
{category}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-3 font-mono text-[10px] uppercase text-muted-foreground">
|
||||||
|
<span>{project.downloads.toLocaleString()} downloads</span>
|
||||||
|
<span>{project.license ?? "License unspecified"}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<StatePanel
|
||||||
|
title="Search the public catalog"
|
||||||
|
description="Results combine Modrinth and PaperMC Hangar."
|
||||||
|
icon={<Package className="size-7" />}
|
||||||
|
className="min-h-56"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="library" className="space-y-6">
|
||||||
|
<SectionCard
|
||||||
|
title="Artifact Library"
|
||||||
|
description="Provider releases and private JARs stored independently of server deployments."
|
||||||
|
icon={<Package className="size-5 text-primary" />}
|
||||||
|
headerAction={
|
||||||
|
<Button type="button" size="sm" asChild disabled={busy}>
|
||||||
|
<label className="cursor-pointer">
|
||||||
|
<HardDriveUpload className="size-4" /> Upload private JAR
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".jar,application/java-archive"
|
||||||
|
className="sr-only"
|
||||||
|
onChange={(event) => void upload(event.target.files?.[0])}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{artifacts.length === 0 ? (
|
||||||
|
<StatePanel
|
||||||
|
title="Your library is empty"
|
||||||
|
description="Store a release from Discover or upload a private plugin JAR."
|
||||||
|
icon={<Package className="size-7" />}
|
||||||
|
className="min-h-52"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{artifacts.map((artifact) => (
|
||||||
|
<article
|
||||||
|
key={artifact.id}
|
||||||
|
className="flex min-h-40 flex-col justify-between gap-4 border bg-background p-4 transition-colors hover:border-foreground/40"
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
{artifact.icon_url ? (
|
||||||
|
<span
|
||||||
|
role="img"
|
||||||
|
aria-label={`${artifact.name} icon`}
|
||||||
|
className="size-10 shrink-0 rounded-sm border bg-cover bg-center"
|
||||||
|
style={{ backgroundImage: `url(${artifact.icon_url})` }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid size-10 shrink-0 place-items-center border bg-muted">
|
||||||
|
<Package className="size-4" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Badge variant={artifact.provider === "UPLOAD" ? "secondary" : "outline"}>
|
||||||
|
{artifact.provider === "UPLOAD" ? "Private" : artifact.provider}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="truncate font-bold">{artifact.name}</h3>
|
||||||
|
{artifact.author && (
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
by {artifact.author}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="truncate font-mono text-[10px] text-muted-foreground">
|
||||||
|
{artifact.version} · {artifact.filename}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{artifact.description && (
|
||||||
|
<p className="line-clamp-2 text-sm text-muted-foreground">
|
||||||
|
{artifact.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{artifact.categories.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{artifact.categories.slice(0, 3).map((category) => (
|
||||||
|
<Badge key={category} variant="secondary">
|
||||||
|
{category}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Deployed to {artifact.server_plugins.length}{" "}
|
||||||
|
{artifact.server_plugins.length === 1 ? "server" : "servers"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2 border-t pt-3">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Assign from Create/Edit Server
|
||||||
|
</span>
|
||||||
|
{artifact.project_url && (
|
||||||
|
<Button type="button" variant="ghost" size="sm" asChild>
|
||||||
|
<a href={artifact.project_url} target="_blank" rel="noreferrer">
|
||||||
|
Source
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setDeleteTarget(artifact)}
|
||||||
|
aria-label={`Delete ${artifact.name} from registry`}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(deleteTarget)}
|
||||||
|
title="Delete Plugin Artifact"
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
Delete <strong>{deleteTarget?.name}</strong> from the global registry? This removes it
|
||||||
|
from every server and permanently deletes private S3 content.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
confirmLabel="Delete Artifact"
|
||||||
|
onConfirm={deleteArtifact}
|
||||||
|
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||||
|
/>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { AdvancedPanel } from "@/components/server-form/advanced-panel";
|
import { AdvancedPanel } from "@/components/server-form/advanced-panel";
|
||||||
import { AutomationPanel } from "@/components/server-form/automation-panel";
|
import { AutomationPanel } from "@/components/server-form/automation-panel";
|
||||||
import { BasicPanel } from "@/components/server-form/basic-panel";
|
import { BasicPanel } from "@/components/server-form/basic-panel";
|
||||||
@@ -14,6 +14,7 @@ import type { ServerFormData, UpdateServerField } from "@/components/server-form
|
|||||||
import { WorldPanel } from "@/components/server-form/world-panel";
|
import { WorldPanel } from "@/components/server-form/world-panel";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
Difficulty,
|
Difficulty,
|
||||||
@@ -87,6 +88,7 @@ export function ServerForm({
|
|||||||
autostopTimeoutInit: initialData?.autostopTimeoutInit || "1800",
|
autostopTimeoutInit: initialData?.autostopTimeoutInit || "1800",
|
||||||
autostopPeriod: initialData?.autostopPeriod || "10",
|
autostopPeriod: initialData?.autostopPeriod || "10",
|
||||||
removeOldPlugins: initialData?.removeOldPlugins ?? false,
|
removeOldPlugins: initialData?.removeOldPlugins ?? false,
|
||||||
|
registryArtifactIds: initialData?.registryArtifactIds ?? [],
|
||||||
timezone: initialData?.timezone || "UTC",
|
timezone: initialData?.timezone || "UTC",
|
||||||
uid: initialData?.uid || "1000",
|
uid: initialData?.uid || "1000",
|
||||||
gid: initialData?.gid || "1000",
|
gid: initialData?.gid || "1000",
|
||||||
@@ -101,6 +103,22 @@ export function ServerForm({
|
|||||||
});
|
});
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialData?.id) return;
|
||||||
|
void api.api.registry
|
||||||
|
.servers({ serverId: initialData.id })
|
||||||
|
.plugins.get()
|
||||||
|
.then(({ data, error: responseError }) => {
|
||||||
|
if (responseError) throw responseError;
|
||||||
|
const plugins = (data ?? []) as Array<{ artifact_id: string }>;
|
||||||
|
setFormData((previous) => ({
|
||||||
|
...previous,
|
||||||
|
registryArtifactIds: plugins.map((plugin) => plugin.artifact_id),
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.catch(() => setError("Failed to load deployed registry plugins"));
|
||||||
|
}, [initialData?.id]);
|
||||||
|
|
||||||
const updateField: UpdateServerField = (key, value) => {
|
const updateField: UpdateServerField = (key, value) => {
|
||||||
setFormData((previous) => ({ ...previous, [key]: value }));
|
setFormData((previous) => ({ ...previous, [key]: value }));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Package } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
import type { ServerFormPanelProps } from "./types";
|
||||||
|
|
||||||
|
type Artifact = {
|
||||||
|
id: string;
|
||||||
|
provider: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
filename: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ArtifactSelector({ formData, updateField }: ServerFormPanelProps) {
|
||||||
|
const [artifacts, setArtifacts] = useState<Artifact[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void api.api.registry.artifacts
|
||||||
|
.get()
|
||||||
|
.then(({ data, error: responseError }) => {
|
||||||
|
if (responseError) throw responseError;
|
||||||
|
setArtifacts((data ?? []) as Artifact[]);
|
||||||
|
})
|
||||||
|
.catch((loadError) =>
|
||||||
|
setError(loadError instanceof Error ? loadError.message : "Failed to load plugins")
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggle = (artifactId: string, checked: boolean) => {
|
||||||
|
updateField(
|
||||||
|
"registryArtifactIds",
|
||||||
|
checked
|
||||||
|
? [...new Set([...formData.registryArtifactIds, artifactId])]
|
||||||
|
: formData.registryArtifactIds.filter((id) => id !== artifactId)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 border p-4">
|
||||||
|
<div className="flex items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold">Plugin Library</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Select stored artifacts to deploy when this server is saved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline">{formData.registryArtifactIds.length} selected</Badge>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
{artifacts.length === 0 ? (
|
||||||
|
<div className="flex items-center gap-3 border border-dashed p-4 text-sm text-muted-foreground">
|
||||||
|
<Package className="size-5" /> Store or upload plugins from the global Plugins page first.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid max-h-72 gap-2 overflow-y-auto md:grid-cols-2">
|
||||||
|
{artifacts.map((artifact) => {
|
||||||
|
const checked = formData.registryArtifactIds.includes(artifact.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={artifact.id}
|
||||||
|
className="flex items-start gap-3 border bg-background p-3 hover:border-primary"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id={`artifact-${artifact.id}`}
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(value) => toggle(artifact.id, value === true)}
|
||||||
|
/>
|
||||||
|
<label htmlFor={`artifact-${artifact.id}`} className="min-w-0 cursor-pointer">
|
||||||
|
<span className="block truncate text-sm font-bold">{artifact.name}</span>
|
||||||
|
<span className="block truncate font-mono text-[10px] text-muted-foreground">
|
||||||
|
{artifact.provider} · {artifact.version} · {artifact.filename}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { TabsContent } from "@/components/ui/tabs";
|
import { TabsContent } from "@/components/ui/tabs";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { CheckboxField, Field } from "./fields";
|
import { CheckboxField, Field } from "./fields";
|
||||||
|
import { ArtifactSelector } from "./artifact-selector";
|
||||||
import type { ServerFormPanelProps } from "./types";
|
import type { ServerFormPanelProps } from "./types";
|
||||||
|
|
||||||
export function ModsPanel({ formData, updateField }: ServerFormPanelProps) {
|
export function ModsPanel({ formData, updateField }: ServerFormPanelProps) {
|
||||||
@@ -11,6 +12,9 @@ export function ModsPanel({ formData, updateField }: ServerFormPanelProps) {
|
|||||||
{formData.type === "CUSTOM" && (
|
{formData.type === "CUSTOM" && (
|
||||||
<FormNotice>Mods/plugins automation is intended for Vanilla/Paper workflows.</FormNotice>
|
<FormNotice>Mods/plugins automation is intended for Vanilla/Paper workflows.</FormNotice>
|
||||||
)}
|
)}
|
||||||
|
{["PAPER", "SPIGOT", "PURPUR"].includes(formData.type) && (
|
||||||
|
<ArtifactSelector formData={formData} updateField={updateField} />
|
||||||
|
)}
|
||||||
<Field
|
<Field
|
||||||
id="plugins"
|
id="plugins"
|
||||||
label="Plugins"
|
label="Plugins"
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export interface ServerFormData {
|
|||||||
autostopTimeoutInit: string;
|
autostopTimeoutInit: string;
|
||||||
autostopPeriod: string;
|
autostopPeriod: string;
|
||||||
plugins?: string;
|
plugins?: string;
|
||||||
|
registryArtifactIds: string[];
|
||||||
removeOldPlugins: boolean;
|
removeOldPlugins: boolean;
|
||||||
spigetResources?: string;
|
spigetResources?: string;
|
||||||
paperBuild?: string;
|
paperBuild?: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { NormalServer, ReverseProxyServer } from "@minikura/api";
|
import type { NormalServer, ReverseProxyServer } from "@minikura/api";
|
||||||
import { Pencil, Trash2 } from "lucide-react";
|
import { Pencil, SquareTerminal, Trash2 } from "lucide-react";
|
||||||
import { DataTable, type DataTableColumn } from "@/components/data-table";
|
import { DataTable, type DataTableColumn } from "@/components/data-table";
|
||||||
import { TableActions } from "@/components/section-card";
|
import { TableActions } from "@/components/section-card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -12,6 +12,7 @@ type ServerTableProps =
|
|||||||
type: "normal";
|
type: "normal";
|
||||||
servers: NormalServer[];
|
servers: NormalServer[];
|
||||||
onEdit?: (id: string) => void;
|
onEdit?: (id: string) => void;
|
||||||
|
onManage?: (id: string) => void;
|
||||||
onDelete?: (id: string) => void;
|
onDelete?: (id: string) => void;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@@ -26,15 +27,27 @@ function RowActions({
|
|||||||
kind,
|
kind,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onManage,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
onEdit?: (id: string) => void;
|
onEdit?: (id: string) => void;
|
||||||
onDelete?: (id: string) => void;
|
onDelete?: (id: string) => void;
|
||||||
|
onManage?: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
if (!onEdit && !onDelete) return null;
|
if (!onEdit && !onDelete && !onManage) return null;
|
||||||
return (
|
return (
|
||||||
<TableActions>
|
<TableActions>
|
||||||
|
{onManage && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onManage(id)}
|
||||||
|
aria-label={`Manage ${kind} ${id}`}
|
||||||
|
>
|
||||||
|
<SquareTerminal />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{onEdit && (
|
{onEdit && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -106,6 +119,7 @@ export function ServerTable(props: ServerTableProps) {
|
|||||||
id={server.id}
|
id={server.id}
|
||||||
kind="server"
|
kind="server"
|
||||||
onEdit={props.onEdit}
|
onEdit={props.onEdit}
|
||||||
|
onManage={props.onManage}
|
||||||
onDelete={props.onDelete}
|
onDelete={props.onDelete}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,12 +2,10 @@
|
|||||||
|
|
||||||
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
||||||
import { FitAddon } from "@xterm/addon-fit";
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
import { ImageAddon } from "@xterm/addon-image";
|
|
||||||
import { LigaturesAddon } from "@xterm/addon-ligatures";
|
import { LigaturesAddon } from "@xterm/addon-ligatures";
|
||||||
import { SearchAddon } from "@xterm/addon-search";
|
import { SearchAddon } from "@xterm/addon-search";
|
||||||
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||||
import { WebglAddon } from "@xterm/addon-webgl";
|
|
||||||
import { Terminal as XTerm } from "@xterm/xterm";
|
import { Terminal as XTerm } from "@xterm/xterm";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
@@ -16,7 +14,7 @@ type TerminalProps = {
|
|||||||
podName: string;
|
podName: string;
|
||||||
container: string;
|
container: string;
|
||||||
shell?: string;
|
shell?: string;
|
||||||
mode?: "shell" | "attach";
|
mode?: "shell" | "console";
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -89,14 +87,12 @@ export function Terminal({
|
|||||||
const searchAddon = new SearchAddon();
|
const searchAddon = new SearchAddon();
|
||||||
const clipboardAddon = new ClipboardAddon();
|
const clipboardAddon = new ClipboardAddon();
|
||||||
const unicode11Addon = new Unicode11Addon();
|
const unicode11Addon = new Unicode11Addon();
|
||||||
const imageAddon = new ImageAddon();
|
|
||||||
|
|
||||||
term.loadAddon(fitAddon);
|
term.loadAddon(fitAddon);
|
||||||
term.loadAddon(webLinksAddon);
|
term.loadAddon(webLinksAddon);
|
||||||
term.loadAddon(searchAddon);
|
term.loadAddon(searchAddon);
|
||||||
term.loadAddon(clipboardAddon);
|
term.loadAddon(clipboardAddon);
|
||||||
term.loadAddon(unicode11Addon);
|
term.loadAddon(unicode11Addon);
|
||||||
term.loadAddon(imageAddon);
|
|
||||||
|
|
||||||
term.unicode.activeVersion = "11";
|
term.unicode.activeVersion = "11";
|
||||||
|
|
||||||
@@ -113,13 +109,6 @@ export function Terminal({
|
|||||||
fitAddonRef.current = fitAddon;
|
fitAddonRef.current = fitAddon;
|
||||||
searchAddonRef.current = searchAddon;
|
searchAddonRef.current = searchAddon;
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
try {
|
|
||||||
const webglAddon = new WebglAddon();
|
|
||||||
term.loadAddon(webglAddon);
|
|
||||||
} catch (_e) {}
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
term.attachCustomKeyEventHandler((event) => {
|
term.attachCustomKeyEventHandler((event) => {
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key === "f") {
|
if ((event.ctrlKey || event.metaKey) && event.key === "f") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -130,14 +119,19 @@ export function Terminal({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
const wsUrl = `${protocol}//${window.location.hostname}:3000/api/terminal/exec?podName=${encodeURIComponent(podName)}&container=${encodeURIComponent(container)}&shell=${encodeURIComponent(shell)}&mode=${mode}`;
|
const wsUrl = new URL("/api/terminal/exec", window.location.origin);
|
||||||
|
wsUrl.protocol = protocol;
|
||||||
|
wsUrl.searchParams.set("podName", podName);
|
||||||
|
wsUrl.searchParams.set("container", container);
|
||||||
|
wsUrl.searchParams.set("shell", shell);
|
||||||
|
wsUrl.searchParams.set("mode", mode);
|
||||||
const ws = new WebSocket(wsUrl);
|
const ws = new WebSocket(wsUrl);
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
setConnected(true);
|
setConnected(true);
|
||||||
term.writeln(
|
term.writeln(
|
||||||
`\r\n\x1b[1;32mConnecting to ${mode === "attach" ? "container" : "shell"}...\x1b[0m\r\n`
|
`\r\n\x1b[1;32mConnecting to ${mode === "console" ? "Minecraft console" : "shell"}...\x1b[0m\r\n`
|
||||||
);
|
);
|
||||||
|
|
||||||
const { cols, rows } = term;
|
const { cols, rows } = term;
|
||||||
@@ -168,8 +162,11 @@ export function Terminal({
|
|||||||
setConnected(false);
|
setConnected(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = (event) => {
|
||||||
term.writeln("\r\n\x1b[1;33mConnection closed\x1b[0m\r\n");
|
const detail =
|
||||||
|
event.reason ||
|
||||||
|
(event.code === 1006 ? "Connection closed unexpectedly" : "Connection closed");
|
||||||
|
term.writeln(`\r\n\x1b[1;33m${detail} (${event.code})\x1b[0m\r\n`);
|
||||||
setConnected(false);
|
setConnected(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"apps/backend": {
|
"apps/backend": {
|
||||||
"name": "@minikura/backend",
|
"name": "@minikura/backend",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.1109.0",
|
||||||
"@elysiajs/node": "^1.4.5",
|
"@elysiajs/node": "^1.4.5",
|
||||||
"@kubernetes/client-node": "^1.4.0",
|
"@kubernetes/client-node": "^1.4.0",
|
||||||
"@minikura/api": "workspace:*",
|
"@minikura/api": "workspace:*",
|
||||||
@@ -152,6 +153,42 @@
|
|||||||
"packages": {
|
"packages": {
|
||||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||||
|
|
||||||
|
"@aws-sdk/checksums": ["@aws-sdk/checksums@3.1000.27", "", { "dependencies": { "@aws-sdk/core": "^3.977.7", "@aws-sdk/types": "^3.974.3", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-insWOqKKNUrbN/dohEG7BJ0U5GkyqhjbMb/NHNaLUtq+7my2M8C4EnZZZoxMmXRqCC+P9dEr+KyJA2JGGzoKLg=="],
|
||||||
|
|
||||||
|
"@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1109.0", "", { "dependencies": { "@aws-sdk/checksums": "^3.1000.27", "@aws-sdk/core": "^3.977.7", "@aws-sdk/credential-provider-node": "^3.972.79", "@aws-sdk/middleware-sdk-s3": "^3.972.73", "@aws-sdk/signature-v4-multi-region": "^3.996.44", "@aws-sdk/types": "^3.974.3", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-iPWzBeGkAe5H5+dBBGCOdIT4uMhpu12OK+nFnDzjvOChVnHIws4LeoG7Yl0kJHSRWZnNEkVcF4vQYTJny0e5xA=="],
|
||||||
|
|
||||||
|
"@aws-sdk/core": ["@aws-sdk/core@3.977.7", "", { "dependencies": { "@aws-sdk/types": "^3.974.3", "@aws-sdk/xml-builder": "^3.972.38", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.31.1", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-I88Iov89NVmjSmJLKSv7Cn9M2J+a2942OkA8nZCbz+sl4ZeY4zEOcoLOrbt1GRfQ8zEQKnjAJdXixA3J/p1fDQ=="],
|
||||||
|
|
||||||
|
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.68", "", { "dependencies": { "@aws-sdk/core": "^3.977.7", "@aws-sdk/types": "^3.974.3", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-2a20A/IdNOwUvaDq91iqqS7BA0XlNMfW3iLGZGZLJv0EbUqhSxB0PIx4rQQqssvWj1uXImb3/UCCdHz/+1dOiA=="],
|
||||||
|
|
||||||
|
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.70", "", { "dependencies": { "@aws-sdk/core": "^3.977.7", "@aws-sdk/types": "^3.974.3", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-0yRem2Fs52r/Nn6UAqIlpjexfaYj8ziEozOe9tamtAVT/5bzFLKx8O2r7MaRqgS3hGKHIa1Jij9nKHSsNnb04A=="],
|
||||||
|
|
||||||
|
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.13", "", { "dependencies": { "@aws-sdk/core": "^3.977.7", "@aws-sdk/credential-provider-env": "^3.972.68", "@aws-sdk/credential-provider-http": "^3.972.70", "@aws-sdk/credential-provider-login": "^3.972.75", "@aws-sdk/credential-provider-process": "^3.972.68", "@aws-sdk/credential-provider-sso": "^3.973.12", "@aws-sdk/credential-provider-web-identity": "^3.972.74", "@aws-sdk/nested-clients": "^3.997.42", "@aws-sdk/types": "^3.974.3", "@smithy/core": "^3.31.1", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-2M39DE02XpYYaSWYk/4AsImXYUU/1L2xmTMLUpMMWq7DfLv191/vCRy3baKtdr45AkJQyVgSjmuVOLm15SwrRQ=="],
|
||||||
|
|
||||||
|
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.75", "", { "dependencies": { "@aws-sdk/core": "^3.977.7", "@aws-sdk/nested-clients": "^3.997.42", "@aws-sdk/types": "^3.974.3", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-jaTESuJlQsoUZ44f/i2puyPt8VlF/dMMJ9HM3cStYtk7eKX4N9UWi83OLixUkoOJH3BwWlPLCq9YIK9nfWhVBg=="],
|
||||||
|
|
||||||
|
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.79", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.68", "@aws-sdk/credential-provider-http": "^3.972.70", "@aws-sdk/credential-provider-ini": "^3.973.13", "@aws-sdk/credential-provider-process": "^3.972.68", "@aws-sdk/credential-provider-sso": "^3.973.12", "@aws-sdk/credential-provider-web-identity": "^3.972.74", "@aws-sdk/types": "^3.974.3", "@smithy/core": "^3.31.1", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-RIw5dof1EHkWubrZzPC941CDtnFG1iAXsxbFgLkhdYZXHc4icU13c/uxSMI0J5eUx9bxa7LjfpdjfClBB1QsDA=="],
|
||||||
|
|
||||||
|
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.68", "", { "dependencies": { "@aws-sdk/core": "^3.977.7", "@aws-sdk/types": "^3.974.3", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-nLP3Pda2MQTFJ25hKBMmUuB9Uv+bTZQNlufbeCwklP549Vwnkd8bRLJoCKp5k6xjmdyptrPrOfGOhN0mKuca8A=="],
|
||||||
|
|
||||||
|
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.12", "", { "dependencies": { "@aws-sdk/core": "^3.977.7", "@aws-sdk/nested-clients": "^3.997.42", "@aws-sdk/token-providers": "3.1108.0", "@aws-sdk/types": "^3.974.3", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-EmgyyHn+f9WCcelp3L/vci+LGbX8GigWaVphRArjVo5Pktkr9YnLy/mQ6VDkDyBD72dtfRNTgHmD2ts4rTDXKQ=="],
|
||||||
|
|
||||||
|
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.74", "", { "dependencies": { "@aws-sdk/core": "^3.977.7", "@aws-sdk/nested-clients": "^3.997.42", "@aws-sdk/types": "^3.974.3", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-0YfczxGXF3RjGj8z7QG/Ho2HnLGKDHfPSHiTs47UU1U/+mmwISDN+rvGKt2zh+3FX8NdT4xd95LGBGyhQw2dgQ=="],
|
||||||
|
|
||||||
|
"@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.73", "", { "dependencies": { "@aws-sdk/core": "^3.977.7", "@aws-sdk/signature-v4-multi-region": "^3.996.44", "@aws-sdk/types": "^3.974.3", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-oy7sRA5HvHcAvkcKX6F8RI240jcOf3c8y/Gqjs9qemIibdKQqGBIi0uwa+47ZRYqGLpdEO28TQU4G73yUzo06Q=="],
|
||||||
|
|
||||||
|
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.42", "", { "dependencies": { "@aws-sdk/core": "^3.977.7", "@aws-sdk/signature-v4-multi-region": "^3.996.44", "@aws-sdk/types": "^3.974.3", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-XWRyon2MTHXD/zMoo0Mbge6Vwf+iE0qQaM/RyGO6NfZ9WukCFiQL27nQVZjYy2JwSIg+iXZxKOX95OBXqlSM4w=="],
|
||||||
|
|
||||||
|
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.44", "", { "dependencies": { "@aws-sdk/types": "^3.974.3", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-ZSfQ35Qn4MhSY+A0Whyr+KBx+wJKZUyBsOrjB2pSHOafRzbFe47T8XcXM8hZqUAC69qnqIy0C9ArxTuud0CC2w=="],
|
||||||
|
|
||||||
|
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1108.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.7", "@aws-sdk/nested-clients": "^3.997.42", "@aws-sdk/types": "^3.974.3", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-rI80zxDxGJ6904eC/YbjkdjY6JdaZvQ01kOmrMvw7cFQGIHo27fhnIVbMSVDS4T6foQImjxYSRoOu/uSJscXDw=="],
|
||||||
|
|
||||||
|
"@aws-sdk/types": ["@aws-sdk/types@3.974.3", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-ECAqfpNsef+7MO8qtR0h9KcFIBAygaE7Cm6UOiQl+ft+uVap+1G7bNEjs4mdJE2OnA4m6k7i8peH8uGIAsOMGw=="],
|
||||||
|
|
||||||
|
"@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.38", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-grf7mzfVxBS5AlsuTvBN7uDpzqohFww9fRPCO+EBSUdvtsYMcPSKdz54h/7XiscqNcUM1Ae1MF7JLHmiYYuzbQ=="],
|
||||||
|
|
||||||
|
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="],
|
||||||
|
|
||||||
"@better-auth/core": ["@better-auth/core@1.6.27", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.4.0", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-A6/mQW4AT2kSHCRZDh9+k8jPUqnCUUCymzBgIroK0l60wLioMtJdcmZiTwrAn6KVUw+4XWw64MgaRn7LOie3wg=="],
|
"@better-auth/core": ["@better-auth/core@1.6.27", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.4.0", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-A6/mQW4AT2kSHCRZDh9+k8jPUqnCUUCymzBgIroK0l60wLioMtJdcmZiTwrAn6KVUw+4XWw64MgaRn7LOie3wg=="],
|
||||||
|
|
||||||
"@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.6.27", "", { "peerDependencies": { "@better-auth/core": "^1.6.27", "@better-auth/utils": "0.4.2", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["drizzle-orm"] }, "sha512-BDJ02ra/fji/ah3aIpxjjNOu/JQVex0UisxS4S0vGZZyiAs+DL24mn3/iovyD5dWB3u3NaGg1n8zpTOntiIXcg=="],
|
"@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.6.27", "", { "peerDependencies": { "@better-auth/core": "^1.6.27", "@better-auth/utils": "0.4.2", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["drizzle-orm"] }, "sha512-BDJ02ra/fji/ah3aIpxjjNOu/JQVex0UisxS4S0vGZZyiAs+DL24mn3/iovyD5dWB3u3NaGg1n8zpTOntiIXcg=="],
|
||||||
@@ -540,6 +577,18 @@
|
|||||||
|
|
||||||
"@sinclair/typebox": ["@sinclair/typebox@0.34.52", "", {}, "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw=="],
|
"@sinclair/typebox": ["@sinclair/typebox@0.34.52", "", {}, "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw=="],
|
||||||
|
|
||||||
|
"@smithy/core": ["@smithy/core@3.32.0", "", { "dependencies": { "@smithy/types": "^4.17.0", "tslib": "^2.6.2" } }, "sha512-NAiCSC78fzbNIEWoheoF74Ob5ZorLijCHpMY26Fqvqg/+9LuyIqMfHDg2p8Yk1rqOyowtiL3y7WX0AW+teL6zw=="],
|
||||||
|
|
||||||
|
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.5.0", "", { "dependencies": { "@smithy/core": "^3.32.0", "@smithy/types": "^4.17.0", "tslib": "^2.6.2" } }, "sha512-2jsPi+7Zv2hSzD9IXR9D7DTqSn7mv4XalzRm+bESh53jiaUS3NKEUbpQFTJP0HhQy9qzZvluxQ3yS24zdRrqsA=="],
|
||||||
|
|
||||||
|
"@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.7.0", "", { "dependencies": { "@smithy/core": "^3.32.0", "@smithy/types": "^4.17.0", "tslib": "^2.6.2" } }, "sha512-W/exA8T0LEzCQtJ02w4IzaEQPIspgarqZprb7W8FwnYiDowgCrjl2fTQ6FvuSSUnJORuepBF81abmBJwqh+0XQ=="],
|
||||||
|
|
||||||
|
"@smithy/node-http-handler": ["@smithy/node-http-handler@4.10.0", "", { "dependencies": { "@smithy/core": "^3.32.0", "@smithy/types": "^4.17.0", "tslib": "^2.6.2" } }, "sha512-nrh7VxqzPQS/ip1hS293aI/OAWDWARQvjUxCfuKhyrfHa2gTdk28066RNeWLI1uuoHXaKAkOF8IcSAHuOp0+SA=="],
|
||||||
|
|
||||||
|
"@smithy/signature-v4": ["@smithy/signature-v4@5.7.0", "", { "dependencies": { "@smithy/core": "^3.32.0", "@smithy/types": "^4.17.0", "tslib": "^2.6.2" } }, "sha512-hCynhm22wMJ8wTF9crcwu8mxggtUrSLLJgDcGUvYFBqpofxycYJCGKOMYg4xtPPFtgNiDJSYmhsWLTrcU/g59Q=="],
|
||||||
|
|
||||||
|
"@smithy/types": ["@smithy/types@4.17.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Aw4joiM0ZdErpo39lCj8phT2lxoiKZV+KZzBxnnQhWVtU2Is/WffQSL04uUWRcXUse9Ln8vXZK6V/FwqRVnQpg=="],
|
||||||
|
|
||||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
||||||
@@ -788,6 +837,8 @@
|
|||||||
|
|
||||||
"better-result": ["better-result@2.10.0", "", {}, "sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw=="],
|
"better-result": ["better-result@2.10.0", "", {}, "sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw=="],
|
||||||
|
|
||||||
|
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
|
||||||
|
|
||||||
"browserslist": ["browserslist@4.28.8", "", { "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", "electron-to-chromium": "^1.5.402", "node-releases": "^2.0.53", "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA=="],
|
"browserslist": ["browserslist@4.28.8", "", { "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", "electron-to-chromium": "^1.5.402", "node-releases": "^2.0.53", "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||||
@@ -1332,7 +1383,7 @@
|
|||||||
|
|
||||||
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
|
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
|
||||||
|
|
||||||
"tslib": ["tslib@2.7.0", "", {}, "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA=="],
|
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
"tsx": ["tsx@4.23.12", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q=="],
|
"tsx": ["tsx@4.23.12", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q=="],
|
||||||
|
|
||||||
@@ -1394,8 +1445,6 @@
|
|||||||
|
|
||||||
"zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
|
"zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
|
||||||
|
|
||||||
"@emnapi/runtime/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
|
||||||
|
|
||||||
"@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
|
"@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
|
||||||
|
|
||||||
"@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
|
"@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
|
||||||
@@ -1420,8 +1469,6 @@
|
|||||||
|
|
||||||
"@radix-ui/react-toggle/@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
|
"@radix-ui/react-toggle/@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
|
||||||
|
|
||||||
"@swc/helpers/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
|
||||||
@@ -1446,8 +1493,6 @@
|
|||||||
|
|
||||||
"@visx/vendor/d3-array": ["d3-array@3.2.1", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ=="],
|
"@visx/vendor/d3-array": ["d3-array@3.2.1", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ=="],
|
||||||
|
|
||||||
"aria-hidden/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
|
||||||
|
|
||||||
"better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="],
|
"better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="],
|
||||||
|
|
||||||
"bun-types/@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="],
|
"bun-types/@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="],
|
||||||
@@ -1470,15 +1515,7 @@
|
|||||||
|
|
||||||
"rc9/defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="],
|
"rc9/defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="],
|
||||||
|
|
||||||
"react-remove-scroll/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"rxjs/tslib": ["tslib@2.7.0", "", {}, "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA=="],
|
||||||
|
|
||||||
"react-remove-scroll-bar/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
|
||||||
|
|
||||||
"react-style-singleton/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
|
||||||
|
|
||||||
"use-callback-ref/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
|
||||||
|
|
||||||
"use-sidecar/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
|
||||||
|
|
||||||
"vite/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
|
"vite/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
|
||||||
|
|
||||||
|
|||||||
@@ -60,13 +60,16 @@ type JVMOptions struct {
|
|||||||
|
|
||||||
// +kubebuilder:validation:Minimum=1
|
// +kubebuilder:validation:Minimum=1
|
||||||
// +kubebuilder:validation:Maximum=100
|
// +kubebuilder:validation:Maximum=100
|
||||||
// +kubebuilder:default=80
|
// +kubebuilder:default=60
|
||||||
HeapPercent int32 `json:"heapPercent,omitempty"`
|
HeapPercent int32 `json:"heapPercent,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MinecraftServerSpec struct {
|
type MinecraftServerSpec struct {
|
||||||
Type ServerKind `json:"type"`
|
Type ServerKind `json:"type"`
|
||||||
|
|
||||||
|
// +kubebuilder:default=true
|
||||||
|
Running *bool `json:"running,omitempty"`
|
||||||
|
|
||||||
// +optional
|
// +optional
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,11 @@ func (in *MinecraftServerList) DeepCopyObject() runtime.Object {
|
|||||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
func (in *MinecraftServerSpec) DeepCopyInto(out *MinecraftServerSpec) {
|
func (in *MinecraftServerSpec) DeepCopyInto(out *MinecraftServerSpec) {
|
||||||
*out = *in
|
*out = *in
|
||||||
|
if in.Running != nil {
|
||||||
|
in, out := &in.Running, &out.Running
|
||||||
|
*out = new(bool)
|
||||||
|
**out = **in
|
||||||
|
}
|
||||||
out.Resources = in.Resources
|
out.Resources = in.Resources
|
||||||
out.JVM = in.JVM
|
out.JVM = in.JVM
|
||||||
in.Properties.DeepCopyInto(&out.Properties)
|
in.Properties.DeepCopyInto(&out.Properties)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1
|
|||||||
kind: CustomResourceDefinition
|
kind: CustomResourceDefinition
|
||||||
metadata:
|
metadata:
|
||||||
annotations:
|
annotations:
|
||||||
controller-gen.kubebuilder.io/version: v0.20.0
|
controller-gen.kubebuilder.io/version: v0.21.0
|
||||||
name: minecraftservers.minikura.kirameki.cafe
|
name: minecraftservers.minikura.kirameki.cafe
|
||||||
spec:
|
spec:
|
||||||
group: minikura.kirameki.cafe
|
group: minikura.kirameki.cafe
|
||||||
@@ -220,7 +220,7 @@ spec:
|
|||||||
jvm:
|
jvm:
|
||||||
properties:
|
properties:
|
||||||
heapPercent:
|
heapPercent:
|
||||||
default: 80
|
default: 60
|
||||||
format: int32
|
format: int32
|
||||||
maximum: 100
|
maximum: 100
|
||||||
minimum: 1
|
minimum: 1
|
||||||
@@ -300,6 +300,9 @@ spec:
|
|||||||
minimum: 256
|
minimum: 256
|
||||||
type: integer
|
type: integer
|
||||||
type: object
|
type: object
|
||||||
|
running:
|
||||||
|
default: true
|
||||||
|
type: boolean
|
||||||
serviceType:
|
serviceType:
|
||||||
default: ClusterIP
|
default: ClusterIP
|
||||||
enum:
|
enum:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1
|
|||||||
kind: CustomResourceDefinition
|
kind: CustomResourceDefinition
|
||||||
metadata:
|
metadata:
|
||||||
annotations:
|
annotations:
|
||||||
controller-gen.kubebuilder.io/version: v0.20.0
|
controller-gen.kubebuilder.io/version: v0.21.0
|
||||||
name: reverseproxyservers.minikura.kirameki.cafe
|
name: reverseproxyservers.minikura.kirameki.cafe
|
||||||
spec:
|
spec:
|
||||||
group: minikura.kirameki.cafe
|
group: minikura.kirameki.cafe
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const (
|
|||||||
MinecraftImage = "itzg/minecraft-server"
|
MinecraftImage = "itzg/minecraft-server"
|
||||||
ProxyImage = "itzg/mc-proxy:latest"
|
ProxyImage = "itzg/mc-proxy:latest"
|
||||||
ContainerPort = 25565
|
ContainerPort = 25565
|
||||||
DefaultHeapPercent = 80
|
DefaultHeapPercent = 60
|
||||||
DefaultMemoryLimitMB = 2048
|
DefaultMemoryLimitMB = 2048
|
||||||
MinHeapMB = 256
|
MinHeapMB = 256
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ func TestHeapMB(t *testing.T) {
|
|||||||
heapPercent int32
|
heapPercent int32
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{"default percent when unset", 2048, 0, "1638M"},
|
{"default percent when unset", 2048, 0, "1228M"},
|
||||||
{"explicit percent", 2048, 50, "1024M"},
|
{"explicit percent", 2048, 50, "1024M"},
|
||||||
{"out of range falls back", 1024, 150, "819M"},
|
{"out of range falls back", 1024, 150, "614M"},
|
||||||
{"floor applies to tiny limits", 128, 80, "256M"},
|
{"floor applies to tiny limits", 128, 80, "256M"},
|
||||||
{"full allocation", 1000, 100, "1000M"},
|
{"full allocation", 1000, 100, "1000M"},
|
||||||
{"zero limit uses default memory", 0, 80, "1638M"},
|
{"zero limit uses default memory", 0, 60, "1228M"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -113,8 +113,12 @@ func minecraftPodSpec(mc *v1alpha1.MinecraftServer, stateful bool) corev1.PodSpe
|
|||||||
|
|
||||||
return corev1.PodSpec{
|
return corev1.PodSpec{
|
||||||
Containers: []corev1.Container{{
|
Containers: []corev1.Container{{
|
||||||
Name: "minecraft",
|
Name: "minecraft",
|
||||||
Image: MinecraftImage,
|
Image: MinecraftImage,
|
||||||
|
Command: []string{"/bin/sh", "-c"},
|
||||||
|
Args: []string{
|
||||||
|
"rm -f /tmp/minikura-console && mkfifo /tmp/minikura-console && exec 3<>/tmp/minikura-console && exec /start <&3",
|
||||||
|
},
|
||||||
Ports: []corev1.ContainerPort{{
|
Ports: []corev1.ContainerPort{{
|
||||||
Name: "minecraft",
|
Name: "minecraft",
|
||||||
ContainerPort: ContainerPort,
|
ContainerPort: ContainerPort,
|
||||||
@@ -132,10 +136,14 @@ func MinecraftDeployment(mc *v1alpha1.MinecraftServer) *appsv1.Deployment {
|
|||||||
name := ServerName(mc.Name)
|
name := ServerName(mc.Name)
|
||||||
labels := ServerLabels(mc)
|
labels := ServerLabels(mc)
|
||||||
|
|
||||||
|
replicas := int32(1)
|
||||||
|
if mc.Spec.Running != nil && !*mc.Spec.Running {
|
||||||
|
replicas = 0
|
||||||
|
}
|
||||||
return &appsv1.Deployment{
|
return &appsv1.Deployment{
|
||||||
ObjectMeta: ObjectMeta(name, mc.Namespace, labels),
|
ObjectMeta: ObjectMeta(name, mc.Namespace, labels),
|
||||||
Spec: appsv1.DeploymentSpec{
|
Spec: appsv1.DeploymentSpec{
|
||||||
Replicas: ptr(int32(1)),
|
Replicas: ptr(replicas),
|
||||||
Selector: &metav1.LabelSelector{MatchLabels: SelectorLabels(name)},
|
Selector: &metav1.LabelSelector{MatchLabels: SelectorLabels(name)},
|
||||||
Template: corev1.PodTemplateSpec{
|
Template: corev1.PodTemplateSpec{
|
||||||
ObjectMeta: metav1.ObjectMeta{Labels: labels},
|
ObjectMeta: metav1.ObjectMeta{Labels: labels},
|
||||||
@@ -158,11 +166,15 @@ func MinecraftStatefulSet(mc *v1alpha1.MinecraftServer) (*appsv1.StatefulSet, er
|
|||||||
return nil, fmt.Errorf("invalid storageSize %q: %w", size, err)
|
return nil, fmt.Errorf("invalid storageSize %q: %w", size, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
replicas := int32(1)
|
||||||
|
if mc.Spec.Running != nil && !*mc.Spec.Running {
|
||||||
|
replicas = 0
|
||||||
|
}
|
||||||
return &appsv1.StatefulSet{
|
return &appsv1.StatefulSet{
|
||||||
ObjectMeta: ObjectMeta(name, mc.Namespace, labels),
|
ObjectMeta: ObjectMeta(name, mc.Namespace, labels),
|
||||||
Spec: appsv1.StatefulSetSpec{
|
Spec: appsv1.StatefulSetSpec{
|
||||||
ServiceName: name,
|
ServiceName: name,
|
||||||
Replicas: ptr(int32(1)),
|
Replicas: ptr(replicas),
|
||||||
Selector: &metav1.LabelSelector{MatchLabels: SelectorLabels(name)},
|
Selector: &metav1.LabelSelector{MatchLabels: SelectorLabels(name)},
|
||||||
Template: corev1.PodTemplateSpec{
|
Template: corev1.PodTemplateSpec{
|
||||||
ObjectMeta: metav1.ObjectMeta{Labels: labels},
|
ObjectMeta: metav1.ObjectMeta{Labels: labels},
|
||||||
|
|||||||
@@ -153,6 +153,37 @@ func TestStatelessHasNoDataVolume(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMinecraftUsesPersistentConsolePipe(t *testing.T) {
|
||||||
|
podSpec := minecraftPodSpec(testServer(), true)
|
||||||
|
container := podSpec.Containers[0]
|
||||||
|
|
||||||
|
if len(container.Command) != 2 || container.Command[0] != "/bin/sh" {
|
||||||
|
t.Fatalf("command = %v, want shell wrapper", container.Command)
|
||||||
|
}
|
||||||
|
if len(container.Args) != 1 || container.Args[0] != "rm -f /tmp/minikura-console && mkfifo /tmp/minikura-console && exec 3<>/tmp/minikura-console && exec /start <&3" {
|
||||||
|
t.Fatalf("args = %v, want persistent console pipe", container.Args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoppedServerHasZeroReplicas(t *testing.T) {
|
||||||
|
mc := testServer()
|
||||||
|
stopped := false
|
||||||
|
mc.Spec.Running = &stopped
|
||||||
|
|
||||||
|
dep := MinecraftDeployment(mc)
|
||||||
|
if dep.Spec.Replicas == nil || *dep.Spec.Replicas != 0 {
|
||||||
|
t.Fatalf("deployment replicas = %v, want 0", dep.Spec.Replicas)
|
||||||
|
}
|
||||||
|
|
||||||
|
sts, err := MinecraftStatefulSet(mc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if sts.Spec.Replicas == nil || *sts.Spec.Replicas != 0 {
|
||||||
|
t.Fatalf("statefulset replicas = %v, want 0", sts.Spec.Replicas)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMinecraftConfigMap(t *testing.T) {
|
func TestMinecraftConfigMap(t *testing.T) {
|
||||||
cm := MinecraftConfigMap(testServer())
|
cm := MinecraftConfigMap(testServer())
|
||||||
if cm.Name != "minecraft-smp-config" {
|
if cm.Name != "minecraft-smp-config" {
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ export type CreateServerRequest = {
|
|||||||
motd?: string | null;
|
motd?: string | null;
|
||||||
level_seed?: string | null;
|
level_seed?: string | null;
|
||||||
level_type?: string | null;
|
level_type?: string | null;
|
||||||
|
running?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UpdateServerRequest = {
|
export type UpdateServerRequest = {
|
||||||
@@ -208,4 +209,5 @@ export type UpdateServerRequest = {
|
|||||||
motd?: string | null;
|
motd?: string | null;
|
||||||
level_seed?: string | null;
|
level_seed?: string | null;
|
||||||
level_type?: string | null;
|
level_type?: string | null;
|
||||||
|
running?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -125,6 +125,17 @@ enum MinecraftServerJarType {
|
|||||||
FOLIA
|
FOLIA
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum PluginProvider {
|
||||||
|
MODRINTH
|
||||||
|
HANGAR
|
||||||
|
UPLOAD
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PluginStorageMode {
|
||||||
|
REMOTE
|
||||||
|
S3
|
||||||
|
}
|
||||||
|
|
||||||
model Server {
|
model Server {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
type ServerType
|
type ServerType
|
||||||
@@ -137,7 +148,9 @@ model Server {
|
|||||||
service_type ServiceType @default(CLUSTER_IP)
|
service_type ServiceType @default(CLUSTER_IP)
|
||||||
node_port Int?
|
node_port Int?
|
||||||
env_variables CustomEnvironmentVariable[] @relation("ServerEnvVars")
|
env_variables CustomEnvironmentVariable[] @relation("ServerEnvVars")
|
||||||
|
plugins ServerPlugin[]
|
||||||
api_key String @unique
|
api_key String @unique
|
||||||
|
running Boolean @default(true)
|
||||||
|
|
||||||
jar_type MinecraftServerJarType @default(VANILLA)
|
jar_type MinecraftServerJarType @default(VANILLA)
|
||||||
minecraft_version String @default("LATEST")
|
minecraft_version String @default("LATEST")
|
||||||
@@ -159,6 +172,49 @@ model Server {
|
|||||||
updated_at DateTime @updatedAt
|
updated_at DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model PluginArtifact {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
provider PluginProvider
|
||||||
|
provider_project_id String
|
||||||
|
provider_version_id String
|
||||||
|
name String
|
||||||
|
version String
|
||||||
|
platform String
|
||||||
|
minecraft_versions String[]
|
||||||
|
filename String
|
||||||
|
size Int
|
||||||
|
sha256 String
|
||||||
|
source_url String?
|
||||||
|
storage_mode PluginStorageMode @default(REMOTE)
|
||||||
|
object_key String?
|
||||||
|
download_token String @unique @default(cuid())
|
||||||
|
license String?
|
||||||
|
description String?
|
||||||
|
author String?
|
||||||
|
icon_url String?
|
||||||
|
project_url String?
|
||||||
|
categories String[]
|
||||||
|
provider_updated_at DateTime?
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
server_plugins ServerPlugin[]
|
||||||
|
|
||||||
|
@@unique([provider, provider_version_id, platform, filename])
|
||||||
|
@@index([sha256])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ServerPlugin {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
server_id String
|
||||||
|
artifact_id String
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
server Server @relation(fields: [server_id], references: [id], onDelete: Cascade)
|
||||||
|
artifact PluginArtifact @relation(fields: [artifact_id], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
|
@@unique([server_id, artifact_id])
|
||||||
|
@@index([server_id, enabled])
|
||||||
|
}
|
||||||
|
|
||||||
model CustomEnvironmentVariable {
|
model CustomEnvironmentVariable {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
key String
|
key String
|
||||||
|
|||||||
Reference in New Issue
Block a user