[FEATURE] Adjust more server controllers

This commit is contained in:
Dunemask 2023-12-18 03:55:27 -07:00
parent 62c966a6bd
commit 37e3dc2ae9
16 changed files with 281 additions and 173 deletions

View file

@ -0,0 +1,11 @@
import { listServerFiles } from "../k8s/server-files.js";
import { sendError } from "../util/ExpressClientError.js";
export async function listFiles(req, res) {
const serverSpec = req.body;
if (!serverSpec) return res.sendStatus(400);
if (!serverSpec.name) return res.status(400).send("Server name required!");
listServerFiles(serverSpec)
.then(() => res.sendStatus(200))
.catch(sendError(res));
}

View file

@ -0,0 +1,78 @@
import createServerResources from "../k8s/server-create.js";
import deleteServerResources from "../k8s/server-delete.js";
import { sendError } from "../util/ExpressClientError.js";
import {
startServerContainer,
stopServerContainer,
} from "../k8s/server-control.js";
function payloadFilter(req, res) {
const serverSpec = req.body;
if (!serverSpec) return res.sendStatus(400);
const { name, url, version, serverType, difficulty, gamemode, memory } =
serverSpec;
if (!name) return res.status(400).send("Server name is required!");
if (!url) return res.status(400).send("Server url is required!");
if (!version) return res.status(400).send("Server version is required!");
if (!difficulty)
return res.status(400).send("Server difficulty is required!");
if (!serverType) return res.status(400).send("Server type is required!");
if (!gamemode) return res.status(400).send("Server Gamemode is required!");
if (!memory) return res.status(400).send("Memory is required!");
req.body.name = req.body.name.toLowerCase();
return "filtered";
}
function checkServerName(serverSpec) {
if (!serverSpec) throw new ExpressClientError({ c: 400 });
if (!serverSpec.name)
throw new ExpressClientError({ c: 400, m: "Server name required!" });
}
export function createServer(req, res) {
if (payloadFilter(req, res) !== "filtered") return;
const serverSpec = req.body;
createServerResources(serverSpec)
.then(() => res.sendStatus(200))
.catch(sendError(res));
}
export async function deleteServer(req, res) {
// Ensure spec is safe
const serverSpec = req.body;
try {
checkServerName(serverSpec);
} catch (e) {
return sendError(res)(e);
}
deleteServerResources(serverSpec)
.then(() => res.sendStatus(200))
.catch(sendError(res));
}
export async function startServer(req, res) {
// Ensure spec is safe
const serverSpec = req.body;
try {
checkServerName(serverSpec);
} catch (e) {
return sendError(res)(e);
}
startServerContainer(serverSpec)
.then(() => res.sendStatus(200))
.catch(sendError(res));
}
export async function stopServer(req, res) {
// Ensure spec is safe
const serverSpec = req.body;
try {
checkServerName(serverSpec);
} catch (e) {
return sendError(res)(e);
}
stopServerContainer(serverSpec)
.then(() => res.sendStatus(200))
.catch(sendError(res));
}

View file

@ -0,0 +1,18 @@
import { getDeployments } from "../k8s/k8s-server-control.js";
import { getInstances } from "../k8s/server-control.js";
import { sendError } from "../util/ExpressClientError.js";
export function serverList(req, res) {
getDeployments()
.then((sd) => res.json(sd.map((s) => s.metadata.name.substring(4))))
.catch((e) => {
ERR("SERVER CONTROL", e);
res.status(500).send("Couldn't get server list");
});
}
export function serverInstances(req, res) {
getInstances()
.then((i) => res.json(i))
.catch(sendError(res));
}

View file

@ -0,0 +1,65 @@
// Imports
import k8s from "@kubernetes/client-node";
import { Rcon as RconClient } from "rcon-client";
import stream from "stream";
import { ERR, WARN } from "../../util/logging.js";
// Kubernetes Configuration
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
const k8sCore = kc.makeApiClient(k8s.CoreV1Api);
const namespace = process.env.MCL_SERVER_NAMESPACE;
// Retrieves logs from the minecraft server container
export async function webConsoleLogs(socket) {
const { serverName } = socket.mcs;
const podName = `mcl-${serverName}`;
const containerName = `${podName}-server`;
const podResponse = await k8sCore.listNamespacedPod(namespace);
const pods = podResponse.body.items.map((vp1) => vp1.metadata.name);
const mcsPods = pods.filter((p) => p.startsWith(podName));
if (mcsPods.length === 0)
throw Error(`Could not find a pod that starts with ${podName}`);
if (mcsPods.length > 1)
throw Error(`Multiple pods match the name ${podName}`);
const log = new k8s.Log(kc);
const logStream = new stream.PassThrough();
logStream.on("data", (chunk) =>
socket.emit("push", Buffer.from(chunk).toString()),
);
log
.log(namespace, mcsPods[0], containerName, logStream, {
follow: true,
pretty: false,
timestamps: false,
})
.catch((e) => ERR("CONSOLE CONTROLLER", "Error streaming logs", e));
}
// Creates an RCON connection to the minecraft container
export async function webConsoleRcon(socket) {
if (socket.rconClient)
return VERB("RCON", "Socket already connected to RCON");
const rconSecret = `mcl-${socket.mcs.serverName}-rcon-secret`;
const rconRes = await k8sCore.readNamespacedSecret(rconSecret, namespace);
const rconPassword = Buffer.from(
rconRes.body.data["rcon-password"],
"base64",
).toString("utf8");
const { serverName } = socket.mcs;
const rconHost = `mcl-${serverName}-rcon.${namespace}.svc.cluster.local`;
const rcon = new RconClient({
host: rconHost,
port: 25575,
password: rconPassword,
});
rcon.on("error", (error) => socket.emit("push", error));
try {
await rcon.connect();
} catch (error) {
socket.emit("push", "Could not connect RCON Input to server!");
WARN("RCON", `Could not connect to '${rconHost}'`);
}
socket.rconClient = rcon;
}

View file

@ -88,6 +88,11 @@ export async function getDeployment(serverName) {
return serverDeployment;
}
export async function getContainers(serverName) {
const deployment = await getDeployment(serverName);
return deployment.spec.template.spec.containers;
}
export async function toggleServer(serverName, scaleUp = false) {
const deployment = await getDeployment(serverName);
const { containers } = deployment.spec.template.spec;

View file

@ -1,33 +0,0 @@
import stream from "stream";
import k8s from "@kubernetes/client-node";
import { ERR } from "../util/logging.js";
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
const k8sApi = kc.makeApiClient(k8s.CoreV1Api);
export default async function liveLogging(socket, serverNamespace) {
const { serverName } = socket.mcs;
const podName = `mcl-${serverName}`;
const containerName = `${podName}-server`;
const podResponse = await k8sApi.listNamespacedPod(serverNamespace);
const pods = podResponse.body.items.map((vp1) => vp1.metadata.name);
const mcsPods = pods.filter((p) => p.startsWith(podName));
if (mcsPods.length === 0)
throw Error(`Could not find a pod that starts with ${podName}`);
if (mcsPods.length > 1)
throw Error(`Multiple pods match the name ${podName}`);
const log = new k8s.Log(kc);
const logStream = new stream.PassThrough();
logStream.on("data", (chunk) =>
socket.emit("push", Buffer.from(chunk).toString()),
);
log
.log(serverNamespace, mcsPods[0], containerName, logStream, {
follow: true,
pretty: false,
timestamps: false,
})
.catch((e) => ERR("K8S", e));
}

View file

@ -6,6 +6,7 @@ import {
scaleDeployment,
} from "./k8s-server-control.js";
import { ERR } from "../util/logging.js";
import ExpressClientError from "../util/ExpressClientError.js";
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
@ -13,61 +14,38 @@ const k8sMetrics = new k8s.Metrics(kc);
const k8sDeps = kc.makeApiClient(k8s.AppsV1Api);
const namespace = process.env.MCL_SERVER_NAMESPACE;
// Gets the all assets for the server
export async function getServer(req, res) {
const serverSpec = req.body;
if (!serverSpec) return res.sendStatus(400);
if (!serverSpec.name) return res.status(400).send("Server name required!");
const { name } = serverSpec;
getServerAssets(name)
.then((server) => res.status(200).json(server))
.catch((e) => res.status(500).send(e));
}
export async function startServer(req, res) {
const serverSpec = req.body;
if (!serverSpec) return res.sendStatus(400);
if (!serverSpec.name) return res.status(400).send("Server name required!");
export async function startServerContainer(serverSpec) {
const { name } = serverSpec;
try {
await scaleDeployment(name, true);
res.sendStatus(200);
} catch (e) {
ERR("SERVER CONTROL", e);
res.status(500).send(`Error updating server '${name}'!`);
throw new ExpressClientError({
c: 500,
m: `Error updating server '${name}'!\n`,
});
}
}
export async function stopServer(req, res) {
const serverSpec = req.body;
if (!serverSpec) return res.sendStatus(400);
if (!serverSpec.name) return res.status(400).send("Server name required!");
export async function stopServerContainer(serverSpec) {
const { name } = serverSpec;
try {
await scaleDeployment(name, false);
res.sendStatus(200);
} catch (e) {
ERR("SERVER CONTROL", e);
res.status(500).send(`Error updating server '${name}'!`);
throw new ExpressClientError({
c: 500,
m: `Error updating server '${name}'!`,
});
}
}
export function serverList(req, res) {
getDeployments()
.then((sd) => res.json(sd.map((s) => s.metadata.name.substring(4))))
.catch((e) => {
ERR("SERVER CONTROL", e);
res.status(500).send("Couldn't get server list");
});
}
export async function getServers(req, res) {
export async function getInstances() {
const serverDeployments = await getDeployments();
const podMetricsResponse = await k8sMetrics.getPodMetrics(namespace);
var name, metrics, started;
const servers = serverDeployments.map((s) => {
name = s.metadata.name.substring(4);
const serverInstances = serverDeployments.map((s) => {
name = s.metadata.annotations["minecluster.dunemask.net/server-name"];
metrics = null;
started = !!s.spec.replicas;
const pod = podMetricsResponse.items.find(({ metadata: md }) => {
@ -85,18 +63,22 @@ export async function getServers(req, res) {
memory: Math.ceil(podMems.reduce((a, b) => a + b)),
};
}
return { name, metrics, started };
});
return serverInstances;
}
export async function getNamespaceMetrics() {
const serverInstances = await getInstances();
var clusterMetrics = { cpu: 0, memory: 0 };
if (servers.length > 1) {
const clusterCpu = servers
const clusterCpu = serverInstances
.map(({ metrics }) => (metrics ? metrics.cpu : 0))
.reduce((a, b) => a + b);
const clusterMem = servers
const clusterMem = serverInstances
.map(({ metrics }) => (metrics ? metrics.memory : 0))
.reduce((a, b) => a + b);
clusterMetrics = { cpu: clusterCpu, memory: clusterMem };
}
res.json({ servers, clusterMetrics });
return clusterMetrics;
}

View file

@ -4,6 +4,8 @@ import k8s from "@kubernetes/client-node";
import yaml from "js-yaml";
import fs from "node:fs";
import path from "node:path";
import ExpressClientError from "../util/ExpressClientError.js";
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
const k8sDeps = kc.makeApiClient(k8s.AppsV1Api);
@ -12,27 +14,9 @@ const namespace = process.env.MCL_SERVER_NAMESPACE;
const loadYaml = (f) => yaml.load(fs.readFileSync(path.resolve(f), "utf8"));
function payloadFilter(req, res) {
const serverSpec = req.body;
if (!serverSpec) return res.sendStatus(400);
const { name, url, version, serverType, difficulty, gamemode, memory } =
serverSpec;
if (!name) return res.status(400).send("Server name is required!");
if (!url) return res.status(400).send("Server url is required!");
if (!version) return res.status(400).send("Server version is required!");
if (!difficulty)
return res.status(400).send("Server difficulty is required!");
if (!serverType) return res.status(400).send("Server type is required!");
if (!gamemode) return res.status(400).send("Server Gamemode is required!");
if (!memory) return res.status(400).send("Memory is required!");
req.body.name = req.body.name.toLowerCase();
return "filtered";
}
function createRconSecret(serverSpec) {
const { name } = serverSpec;
const rconYaml = loadYaml("lib/k8s/configs/rcon-secret.yml");
// TODO: Dyamic rconPassword
const rconPassword = bcrypt.hashSync(uuidv4(), 10);
rconYaml.data["rcon-password"] = Buffer.from(rconPassword).toString("base64");
@ -84,7 +68,6 @@ function getServerContainer(serverSpec) {
motd,
maxPlayers,
seed,
modpack,
ops,
whitelist,
} = serverSpec;
@ -195,17 +178,15 @@ function createRconService(serverSpec) {
return rconSvcYaml;
}
export default async function createServer(req, res) {
if (payloadFilter(req, res) !== "filtered") return;
const serverSpec = req.body;
export default async function createServerResources(serverSpec) {
const deploymentRes = await k8sDeps.listNamespacedDeployment(namespace);
const deployments = deploymentRes.body.items.map((i) => i.metadata.name);
if (deployments.includes(`mcl-${serverSpec.name}`))
return res.status(409).send("Server already exists!");
throw new ExpressClientError("Server already exists!", { c: 409 });
const pvcRes = await k8sCore.listNamespacedPersistentVolumeClaim(namespace);
const pvcs = pvcRes.body.items.map((i) => i.metadata.name);
if (pvcs.includes(`mcl-${serverSpec.name}-volume`))
return res.status(409).send("Server PVC already exists!");
throw new ExpressClientError("Server PVC already exists!", { c: 409 });
const rconSecret = createRconSecret(serverSpec);
const serverVolume = createServerVolume(serverSpec);
const serverDeploy = createServerDeploy(serverSpec);
@ -216,6 +197,4 @@ export default async function createServer(req, res) {
k8sCore.createNamespacedService(namespace, serverService);
k8sCore.createNamespacedService(namespace, rconService);
k8sDeps.createNamespacedDeployment(namespace, serverDeploy);
res.sendStatus(200);
}

View file

@ -1,6 +1,7 @@
import k8s from "@kubernetes/client-node";
import { ERR } from "../util/logging.js";
import { getServerAssets } from "./k8s-server-control.js";
import ExpressClientError from "../util/ExpressClientError.js";
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
@ -8,24 +9,27 @@ const k8sDeps = kc.makeApiClient(k8s.AppsV1Api);
const k8sCore = kc.makeApiClient(k8s.CoreV1Api);
const namespace = process.env.MCL_SERVER_NAMESPACE;
const deleteError = (res) => (err) => {
res.status(500).send("Error deleting a resource!");
const deleteError = (err) => {
ERR("K8S", "An error occurred while deleting a resource", err);
throw new ExpressClientError({
c: 500,
m: "Error deleting a resource!\n" + err,
});
};
function deleteOnExist(o, fn) {
if (o) return fn(o.metadata.name);
}
export default async function deleteServer(req, res) {
const serverSpec = req.body;
if (!serverSpec) return res.sendStatus(400);
if (!serverSpec.name) return res.status(400).send("Server name required!");
export default async function deleteServerResources(serverSpec) {
const { name } = serverSpec;
// Ensure deployment exists
const server = await getServerAssets(name);
if (!server)
return res.status(404).send("No Resources for that server were found!");
throw new ExpressClientError({
c: 404,
m: "No Resources for that server were found!",
});
// Delete in reverse order
const deleteDeploy = deleteOnExist(server.deployment, (name) =>
@ -38,7 +42,7 @@ export default async function deleteServer(req, res) {
const deleteRconService = deleteOnExist(server.rconService, (name) =>
k8sCore.deleteNamespacedService(name, namespace),
);
if (deleteDeploy) await deleteDeploy.catch(deleteError(res));
if (deleteDeploy) await deleteDeploy.catch(deleteError);
const deleteRconSecret = deleteOnExist(server.rconSecret, (name) =>
k8sCore.deleteNamespacedSecret(name, namespace),
@ -47,12 +51,10 @@ export default async function deleteServer(req, res) {
k8sCore.deleteNamespacedPersistentVolumeClaim(name, namespace),
);
Promise.all([
return Promise.all([
deleteService,
deleteRconService,
deleteRconSecret,
deleteVolume,
])
.then(() => res.sendStatus(200))
.catch(deleteError(res));
]).catch(deleteError);
}

View file

@ -1,6 +1,7 @@
import ftp from "basic-ftp";
import { ERR } from "../util/logging.js";
import { getServerAssets } from "./k8s-server-control.js";
import ExpressClientError from "../util/ExpressClientError.js";
const namespace = process.env.MCL_SERVER_NAMESPACE;
@ -15,29 +16,33 @@ export async function useFtp(serverService) {
return client;
}
const handleError = (res) => (e) => {
const handleError = (e) => {
ERR("SERVER FILES", "Error occurred while preforming FTP operation!", e);
res.status(500).send("Error occurred while performing FTP operation!");
throw new ExpressClientError({
c: 500,
m: "Error occurred while performing FTP operation!",
});
};
export async function listFiles(req, res) {
const serverSpec = req.body;
if (!serverSpec) return res.sendStatus(400);
if (!serverSpec.name) return res.status(400).send("Server name required!");
export async function listServerFiles(serverSpec) {
const { name } = serverSpec;
const server = await getServerAssets(name);
if (!server)
return res.status(404).send("No Resources for that server were found!");
throw new ExpressClientError({
c: 404,
m: "No resources for that server were found!",
});
if (!server.service)
return res
.status(409)
.send("Service doesn't exist, please contact your hosting provider!");
// client.ftp.verbose = true;
const client = await useFtp(server.service).catch(handleError(res));
if (!client) return;
throw new ExpressClientError({
c: 409,
m: "Service doesn't exist, please contact your hosting provider!",
});
// FTP Operations;
const client = await useFtp(server.service).catch(handleError);
await client
.list()
.then((f) => res.json(f))
.catch(handleError(res));
.catch(handleError);
client.close();
}

View file

@ -1,5 +1,5 @@
import { Router, json as jsonMiddleware } from "express";
import { listFiles } from "../k8s/server-files.js";
import { listFiles } from "../controllers/file-controller.js";
const router = Router();
router.use(jsonMiddleware());
router.post("/list", listFiles);

View file

@ -1,13 +1,14 @@
import { Router, json as jsonMiddleware } from "express";
import {
createServer,
deleteServer,
startServer,
stopServer,
} from "../controllers/lifecycle-controller.js";
import {
serverInstances,
serverList,
getServers,
getServer,
} from "../k8s/server-control.js";
import createServer from "../k8s/server-create.js";
import deleteServer from "../k8s/server-delete.js";
} from "../controllers/status-controller.js";
const router = Router();
router.use(jsonMiddleware());
// Routes
@ -15,7 +16,6 @@ router.post("/create", createServer);
router.delete("/delete", deleteServer);
router.post("/start", startServer);
router.post("/stop", stopServer);
// router.post("/get", getServer) // SHOULD BE DISABLED EXCEPT FOR DEBUGGING;
router.get("/list", serverList);
router.get("/instances", getServers);
router.get("/instances", serverInstances);
export default router;

View file

@ -1,33 +0,0 @@
import k8s from "@kubernetes/client-node";
import { Rcon as RconClient } from "rcon-client";
import { ERR, WARN } from "../util/logging.js";
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
const k8sCore = kc.makeApiClient(k8s.CoreV1Api);
const namespace = process.env.MCL_SERVER_NAMESPACE;
export default async function rconInterface(socket) {
if (socket.rconClient)
return VERB("RCON", "Socket already connected to RCON");
const rconSecret = `mcl-${socket.mcs.serverName}-rcon-secret`;
const rconRes = await k8sCore.readNamespacedSecret(rconSecret, namespace);
const rconPassword = Buffer.from(
rconRes.body.data["rcon-password"],
"base64",
).toString("utf8");
const { serverName } = socket.mcs;
const rconHost = `mcl-${serverName}-rcon.${namespace}.svc.cluster.local`;
const rcon = new RconClient({
host: rconHost,
port: 25575,
password: rconPassword,
});
rcon.on("error", (error) => socket.emit("push", error));
try {
await rcon.connect();
} catch (error) {
socket.emit("push", "Could not connect RCON Input to server!");
WARN("RCON", `Could not connect to '${rconHost}'`);
}
socket.rconClient = rcon;
}

View file

@ -1,9 +1,9 @@
import { Server as Skio } from "socket.io";
import { VERB, WARN, ERR } from "../util/logging.js";
import liveLogging from "../k8s/live-logging.js";
import rconInterface from "./rcon.js";
const namespace = process.env.MCL_SERVER_NAMESPACE;
import {
webConsoleLogs,
webConsoleRcon,
} from "../controllers/sub-controllers/console-controller.js";
async function rconSend(socket, m) {
if (!socket.rconClient)
@ -20,8 +20,8 @@ const socketConnect = async (io, socket) => {
VERB("WS", "Websocket connecting");
socket.mcs = { serverName: socket.handshake.query.serverName };
try {
await liveLogging(socket, namespace);
await rconInterface(socket);
await webConsoleLogs(socket);
await webConsoleRcon(socket);
socket.on("msg", (m) => rconSend(socket, m));
} catch (err) {
ERR("SOCKETS", err);

View file

@ -0,0 +1,28 @@
import { VERB } from "./logging.js";
export default class ExpressClientError extends Error {
constructor(message, clientOptions = {}) {
var msg;
if (typeof message === "object" && message.m !== undefined) msg = message.m;
else if (typeof message === "object") msg = "Unknown Express Client Error!";
super(msg);
if (typeof message === "object") this.clientOptions = message;
else this.clientOptions = { message: msg, ...clientOptions };
}
sendError(res) {
if (!this.clientOptions.m && this.clientOptions.c)
res.sendStatus(this.clientOptions.c);
else res.status(this.clientOptions.c ?? 500).send(this.toString());
}
toString() {
return this.message;
}
}
export const sendError = (res) => (e) => {
VERB("V", e);
if (e instanceof ExpressClientError) e.sendError(res);
else res.status(500).send(e);
};