[FEATURE] Adjust resource flow

This commit is contained in:
Dunemask 2023-12-15 21:00:02 -07:00
parent 360dd32860
commit 61bf66c5c1
11 changed files with 202 additions and 86 deletions

View file

@ -3,6 +3,8 @@ data:
rcon-password: UEphT3V2aGJlQjNvc3M0dElwQU5YTUZrSkltR1RsRVl0ZGx3elFqZjJLdVZrZXNtV0hja1VhUUd3bmZDcElpbA==
kind: Secret
metadata:
annotations:
minecluster.dunemask.net/server-name: changeme-server-name
labels:
app: changeme-app-label
name: changeme-rcon-secret

View file

@ -2,6 +2,7 @@ apiVersion: v1
kind: Service
metadata:
annotations:
minecluster.dunemask.net/server-name: changeme-server-name
labels:
app: changeme-app
name: changeme-rcon

View file

@ -1,6 +1,8 @@
apiVersion: apps/v1
kind: Deployment
metadata:
annotations:
minecluster.dunemask.net/server-name: changeme-server-name
name: changeme-name
namespace: changeme-namespace
spec:

View file

@ -1,6 +1,8 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
annotations:
minecluster.dunemask.net/server-name: changeme-server-name
labels:
service: changeme-service-name
name: changeme-pvc-name

View file

@ -4,6 +4,7 @@ metadata:
annotations:
ingress.qumine.io/hostname: changeme-url
ingress.qumine.io/portname: minecraft
minecluster.dunemask.net/server-name: changeme-server-name
labels:
app: changeme-app
name: changeme-name

View file

@ -0,0 +1,105 @@
import k8s from "@kubernetes/client-node";
import { VERB } from "../util/logging.js";
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
const k8sDeps = kc.makeApiClient(k8s.AppsV1Api);
const k8sCore = kc.makeApiClient(k8s.CoreV1Api);
const namespace = process.env.MCL_SERVER_NAMESPACE;
const mineclusterManaged = (o) =>
o.metadata &&
o.metadata.annotations &&
o.metadata.annotations["minecluster.dunemask.net/server-name"] !== undefined;
const serverMatch = (serverName) => (o) =>
o.metadata.annotations["minecluster.dunemask.net/server-name"] === serverName;
export async function getDeployments() {
const deploymentRes = await k8sDeps.listNamespacedDeployment(namespace);
const serverDeployments = deploymentRes.body.items.filter(mineclusterManaged);
return serverDeployments;
}
export async function getServices() {
const serviceRes = await k8sCore.listNamespacedService(namespace);
const serverServices = serviceRes.body.items.filter(mineclusterManaged);
return serverServices;
}
export async function getSecrets() {
const secretRes = await k8sCore.listNamespacedSecret(namespace);
const serverSecrets = secretRes.body.items.filter(mineclusterManaged);
return serverSecrets;
}
export async function getVolumes() {
const volumeRes =
await k8sCore.listNamespacedPersistentVolumeClaim(namespace);
const serverVolumes = volumeRes.body.items.filter(mineclusterManaged);
return serverVolumes;
}
export function getServerAssets(serverName) {
const serverFilter = serverMatch(serverName);
return Promise.all([
getDeployments(),
getServices(),
getSecrets(),
getVolumes(),
])
.then(([deps, svcs, scrts, vols]) => {
const deployments = deps.filter(serverFilter);
const services = svcs.filter(serverFilter);
const secrets = scrts.filter(serverFilter);
const volumes = vols.filter(serverFilter);
if (deployments.length > 1) throw Error("Deployment filter broken!");
if (volumes.length > 1) throw Error("Volume filter broken!");
if (secrets.length > 1) throw Error("Secrets broken!");
const serverAssets = {
deployment: deployments[0],
service: services.find(
(s) => s.metadata.name === `mcl-${serverName}-rcon`,
),
volume: volumes[0],
rconService: services.find(
(s) => s.metadata.name === `mcl-${serverName}-server`,
),
rconSecret: secrets[0],
};
for (var k in serverAssets) if (serverAssets[k]) return serverAssets;
// If no assets exist, return nothing
})
.catch((e) => console.log(e));
}
export async function getDeployment(serverName) {
const servers = await getDeployments();
const serverDeployment = servers.find(
(s) =>
s.metadata.annotations["minecluster.dunemask.net/server-name"] ===
serverName,
);
if (!serverDeployment) {
console.log(servers.map((s) => s.metadata.annotations));
throw Error(`MCL Deployment '${serverName}' could not be found!`);
}
return serverDeployment;
}
export async function scaleDeployment(serverName, scaleUp = false) {
const deployment = await getDeployment(serverName);
if (deployment.spec.replicas === 1 && scaleUp)
return VERB(
"KSC",
`MCL Deployment '${serverName}' is already scaled! Ignoring scale adjustment.`,
);
deployment.spec.replicas = scaleUp ? 1 : 0;
return k8sDeps.replaceNamespacedDeployment(
deployment.metadata.name,
namespace,
deployment,
);
}

View file

@ -1,22 +1,26 @@
import k8s from "@kubernetes/client-node";
import {
getDeployment,
getDeployments,
getServerAssets,
scaleDeployment,
} from "./k8s-server-control.js";
import { ERR } from "../util/logging.js";
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
const k8sDeps = kc.makeApiClient(k8s.AppsV1Api);
const k8sCore = kc.makeApiClient(k8s.CoreV1Api);
const k8sMetrics = new k8s.Metrics(kc);
const namespace = process.env.MCL_SERVER_NAMESPACE;
async function findDeployment(serverName) {
try {
const deploymentRes = await k8sDeps.listNamespacedDeployment(namespace);
return deploymentRes.body.items.find(
(i) => i.metadata.name === `mcl-${serverName}`,
);
} catch (e) {
ERR("SERVER CONTROL", `Error finding deployment: mcl-${serverName}`);
}
// 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) {
@ -24,19 +28,13 @@ export async function startServer(req, res) {
if (!serverSpec) return res.sendStatus(400);
if (!serverSpec.name) return res.status(400).send("Server name required!");
const { name } = serverSpec;
const dep = await findDeployment(name);
if (!dep || !dep.spec) return res.status(409).send("Server does not exist!");
if (dep.spec.replicas === 1)
return res.status(409).send("Server already started!");
dep.spec.replicas = 1;
k8sDeps
.replaceNamespacedDeployment(`mcl-${name}`, namespace, dep)
.then(() => res.sendStatus(200))
.catch((e) => {
ERR("SERVER CONTROL", e);
res.status(500).send("Error updating server!");
});
try {
await scaleDeployment(name, true);
res.sendStatus(200);
} catch (e) {
ERR("SERVER CONTROL", e);
res.status(500).send(`Error updating server '${name}'!`);
}
}
export async function stopServer(req, res) {
@ -44,34 +42,28 @@ export async function stopServer(req, res) {
if (!serverSpec) return res.sendStatus(400);
if (!serverSpec.name) return res.status(400).send("Server name required!");
const { name } = serverSpec;
const deploymentRes = await k8sDeps.listNamespacedDeployment(namespace);
const dep = deploymentRes.body.items.find(
(i) => i.metadata.name === `mcl-${name}`,
);
if (!dep) return res.status(409).send("Server does not exist!");
if (dep.spec.replicas === 0)
return res.status(409).send("Server already stopped!");
dep.spec.replicas = 0;
k8sDeps.replaceNamespacedDeployment(`mcl-${name}`, namespace, dep);
res.sendStatus(200);
try {
await scaleDeployment(name, false);
res.sendStatus(200);
} catch (e) {
ERR("SERVER CONTROL", e);
res.status(500).send(`Error updating server '${name}'!`);
}
}
export async function serverList(req, res) {
const deploymentRes = await k8sDeps.listNamespacedDeployment(namespace);
const deployments = deploymentRes.body.items.map((i) => i.metadata.name);
// TODO Add an annotation and manage using that
const serverDeployments = deployments.filter((d) => d.startsWith("mcl-"));
res.json(serverDeployments.map((sd) => sd.substring(4)));
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) {
const deploymentRes = await k8sDeps.listNamespacedDeployment(namespace);
const deployments = deploymentRes.body.items;
const serverDeployments = await getDeployments();
const podMetricsResponse = await k8sMetrics.getPodMetrics(namespace);
// TODO Add an annotation and manage using that
const serverDeployments = deployments.filter((d) =>
d.metadata.name.startsWith("mcl-"),
);
var name, metrics, started;
const servers = serverDeployments.map((s) => {
name = s.metadata.name.substring(4);

View file

@ -10,6 +10,8 @@ const k8sDeps = kc.makeApiClient(k8s.AppsV1Api);
const k8sCore = kc.makeApiClient(k8s.CoreV1Api);
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);
@ -29,9 +31,7 @@ function payloadFilter(req, res) {
function createRconSecret(serverSpec) {
const { name } = serverSpec;
const rconYaml = yaml.load(
fs.readFileSync(path.resolve("lib/k8s/configs/rcon-secret.yml"), "utf8"),
);
const rconYaml = loadYaml("lib/k8s/configs/rcon-secret.yml");
// TODO: Dyamic rconPassword
const rconPassword = bcrypt.hashSync(uuidv4(), 10);
@ -39,17 +39,18 @@ function createRconSecret(serverSpec) {
rconYaml.metadata.labels.app = `mcl-${name}-app`;
rconYaml.metadata.name = `mcl-${name}-rcon-secret`;
rconYaml.metadata.namespace = namespace;
rconYaml.metadata.annotations["minecluster.dunemask.net/server-name"] = name;
return rconYaml;
}
function createServerVolume(serverSpec) {
const { name } = serverSpec;
const volumeYaml = yaml.load(
fs.readFileSync(path.resolve("lib/k8s/configs/server-pvc.yml"), "utf8"),
);
const volumeYaml = loadYaml("lib/k8s/configs/server-pvc.yml");
volumeYaml.metadata.labels.service = `mcl-${name}-server`;
volumeYaml.metadata.name = `mcl-${name}-volume`;
volumeYaml.metadata.namespace = namespace;
volumeYaml.metadata.annotations["minecluster.dunemask.net/server-name"] =
name;
volumeYaml.spec.resources.requests.storage = "1Gi"; // TODO: Changeme
return volumeYaml;
}
@ -69,14 +70,11 @@ function createServerDeploy(serverSpec) {
ops,
whitelist,
} = serverSpec;
const deployYaml = yaml.load(
fs.readFileSync(
path.resolve("lib/k8s/configs/server-deployment.yml"),
"utf8",
),
);
const deployYaml = loadYaml("lib/k8s/configs/server-deployment.yml");
deployYaml.metadata.name = `mcl-${name}`;
deployYaml.metadata.namespace = namespace;
deployYaml.metadata.annotations["minecluster.dunemask.net/server-name"] =
name;
deployYaml.spec.replicas = 0; // TODO: User control for autostart
deployYaml.spec.selector.matchLabels.app = `mcl-${name}-app`;
deployYaml.spec.template.metadata.labels.app = `mcl-${name}-app`;
@ -117,25 +115,25 @@ function createServerDeploy(serverSpec) {
function createServerService(serverSpec) {
const { name, url } = serverSpec;
const serviceYaml = yaml.load(
fs.readFileSync(path.resolve("lib/k8s/configs/server-svc.yml"), "utf8"),
);
const serviceYaml = loadYaml("lib/k8s/configs/server-svc.yml");
serviceYaml.metadata.annotations["ingress.qumine.io/hostname"] = url;
serviceYaml.metadata.labels.app = `mcl-${name}-app`;
serviceYaml.metadata.name = `mcl-${name}-server`;
serviceYaml.metadata.namespace = namespace;
serviceYaml.metadata.annotations["minecluster.dunemask.net/server-name"] =
name;
serviceYaml.spec.selector.app = `mcl-${name}-app`;
return serviceYaml;
}
function createRconService(serverSpec) {
const { name, url } = serverSpec;
const rconSvcYaml = yaml.load(
fs.readFileSync(path.resolve("lib/k8s/configs/rcon-svc.yml"), "utf8"),
);
const rconSvcYaml = loadYaml("lib/k8s/configs/rcon-svc.yml");
rconSvcYaml.metadata.labels.app = `mcl-${name}-app`;
rconSvcYaml.metadata.name = `mcl-${name}-rcon`;
rconSvcYaml.metadata.namespace = namespace;
rconSvcYaml.metadata.annotations["minecluster.dunemask.net/server-name"] =
name;
rconSvcYaml.spec.selector.app = `mcl-${name}-app`;
return rconSvcYaml;
}

View file

@ -1,5 +1,6 @@
import k8s from "@kubernetes/client-node";
import { ERR } from "../util/logging.js";
import { getServerAssets } from "./k8s-server-control.js";
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
@ -12,38 +13,47 @@ const deleteError = (res) => (err) => {
ERR("K8S", "An error occurred while deleting a resource", err);
};
async function deleteOnExist(o, fn) {
if (!o) return;
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!");
const { name } = serverSpec;
// Ensure deployment exists
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 does not exist!");
const server = await getServerAssets(name);
if (!server) return res.status(404).send("No Resources for that server were found!");
// Delete in reverse order
const deleteDeploy = k8sDeps.deleteNamespacedDeployment(
`mcl-${serverSpec.name}`,
namespace,
const deleteDeploy = deleteOnExist(
server.deployment,
(name)=> k8sDeps.deleteNamespacedDeployment(name, namespace),
);
const deleteService = k8sCore.deleteNamespacedService(
`mcl-${name}-server`,
namespace,
const deleteService = deleteOnExist(
server.service,
(name) => k8sCore.deleteNamespacedService(name, namespace),
);
const deleteRconService = k8sCore.deleteNamespacedService(
`mcl-${name}-rcon`,
namespace,
const deleteRconService = deleteOnExist(
server.rconService,
(name) => k8sCore.deleteNamespacedService(name, namespace),
);
await deleteDeploy.catch(deleteError(res));
const deleteRconSecret = k8sCore.deleteNamespacedSecret(
`mcl-${name}-rcon-secret`,
namespace,
try {
await deleteDeploy;
} catch (e) {
return deleteError(res)(e);
}
const deleteRconSecret = deleteOnExist(
server.rconSecret,
(name)=> k8sCore.deleteNamespacedSecret(name, namespace),
);
const deleteVolume = k8sCore.deleteNamespacedPersistentVolumeClaim(
`mcl-${name}-volume`,
namespace,
const deleteVolume = deleteOnExist(
server.volume,
(name)=>k8sCore.deleteNamespacedPersistentVolumeClaim(name, namespace),
);
Promise.all([
deleteService,
deleteRconService,

View file

@ -4,6 +4,7 @@ import {
stopServer,
serverList,
getServers,
getServer,
} from "../k8s/server-control.js";
import createServer from "../k8s/server-create.js";
import deleteServer from "../k8s/server-delete.js";
@ -14,6 +15,7 @@ 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);
export default router;

View file

@ -12,12 +12,11 @@ import { useCreateServer, useVersionList } from "@mcl/queries";
const defaultServer = {
version: "latest",
name: "example",
serverType: "VANILLA",
difficulty: "easy",
maxPlayers: "5",
gamemode: "survival",
memory: "1024",
memory: "512",
motd: `\\u00A7e\\u00A7ka\\u00A7l\\u00A7aMine\\u00A76Cluster\\u00A7r\\u00A78\\u00A7b\\u00A7ka`,
};
@ -115,7 +114,9 @@ export default function Create() {
<TextField
label="Name"
onChange={coreUpdate("name")}
helperText="Example: My Survival World"
defaultValue={spec.name}
FormHelperTextProps={{ sx: { ml: 0 } }}
required
/>
<TextField