From f732710c7ce07b94a0a6d930defc484efc387ca9 Mon Sep 17 00:00:00 2001 From: Dunemask Date: Fri, 22 Dec 2023 11:58:31 -0700 Subject: [PATCH 01/17] [REV] Remove additional creation options for now --- src/pages/CreateOptions.jsx | 131 +---------------- src/pages/CreateOptionsFull.jsx | 250 ++++++++++++++++++++++++++++++++ 2 files changed, 253 insertions(+), 128 deletions(-) create mode 100644 src/pages/CreateOptionsFull.jsx diff --git a/src/pages/CreateOptions.jsx b/src/pages/CreateOptions.jsx index 1d486f8..2f1bb45 100644 --- a/src/pages/CreateOptions.jsx +++ b/src/pages/CreateOptions.jsx @@ -14,21 +14,16 @@ import { useCreateServer, useVersionList } from "@mcl/queries"; const defaultServer = { version: "latest", serverType: "VANILLA", - difficulty: "easy", - maxPlayers: "5", - gamemode: "survival", memory: "512", - motd: `\\u00A7e\\u00A7ka\\u00A7l\\u00A7aMine\\u00A76Cluster\\u00A7r\\u00A78\\u00A7b\\u00A7ka`, }; export default function Create() { - const [wl, setWl] = useState([]); - const [ops, setOps] = useState([]); const [spec, setSpec] = useState(defaultServer); const nav = useNavigate(); const versionList = useVersionList(); const [versions, setVersions] = useState(["latest"]); const createServer = useCreateServer(spec); + const updateSpec = (attr, val) => { const s = { ...spec }; s[attr] = val; @@ -48,46 +43,6 @@ export default function Create() { const coreUpdate = (attr) => (e) => updateSpec(attr, e.target.value); - function opsAdd(e) { - const opEntry = e.target.innerHTML ?? e.target.value; - if (!opEntry) return; - const newOps = [...ops, opEntry]; - setOps(newOps); - updateSpec("ops", newOps.join(",")); - } - - function whitelistAdd(e) { - const wlEntry = e.target.value; - if (!wlEntry) return; - const newWl = [...wl, wlEntry]; - setWl(newWl); - updateSpec("whitelist", newWl.join(",")); - } - - const opsRemove = - (name, { onDelete: updateAutoComplete }) => - (e) => { - updateAutoComplete(e); - const newOps = [...ops]; - const entryIndex = newOps.indexOf(name); - if (entryIndex === -1) return; - newOps.splice(entryIndex, 1); - setOps(newOps); - updateSpec("ops", newOps.join(",")); - }; - - const whitelistRemove = - (name, { onDelete: updateAutocomplete }) => - (e) => { - updateAutocomplete(e); - const newWl = [...wl]; - const entryIndex = newWl.indexOf(name); - if (entryIndex === -1) return; - newWl.splice(entryIndex, 1); - setWl(newWl); - updateSpec("whitelist", newWl.join(",")); - }; - async function upsertSpec() { if (validateSpec() !== "validated") return; createServer(spec) @@ -97,9 +52,9 @@ export default function Create() { function validateSpec() { console.log("TODO CREATE VALIDATION"); + if (!spec.host) return alertValidationError("Host cannot be blank"); if (!spec.name) return alertValidationError("Name not included"); if (!spec.version) return alertValidationError("Version cannot be blank"); - if (!spec.host) return alertValidationError("Host cannot be blank"); return "validated"; } @@ -124,7 +79,7 @@ export default function Create() { @@ -154,93 +109,13 @@ export default function Create() { Paper Spigot - - Peaceful - Easy - Medium - Hard - - } - renderTags={(value, getTagProps) => - value.map((option, index) => { - const defaultChipProps = getTagProps({ index }); - return ( - - ); - }) - } - /> - } - renderTags={(value, getTagProps) => - value.map((option, index) => { - const defaultChipProps = getTagProps({ index }); - return ( - - ); - }) - } - /> - {/**/} - {/**/} - - - Survival - Creative - Adventure - Spectator - - - {/**/} - diff --git a/src/pages/CreateOptionsFull.jsx b/src/pages/CreateOptionsFull.jsx new file mode 100644 index 0000000..1d486f8 --- /dev/null +++ b/src/pages/CreateOptionsFull.jsx @@ -0,0 +1,250 @@ +import { useState, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import Autocomplete from "@mui/material/Autocomplete"; +import TextField from "@mui/material/TextField"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Chip from "@mui/material/Chip"; +import Select from "@mui/material/Select"; +import MenuItem from "@mui/material/MenuItem"; +import InputLabel from "@mui/material/InputLabel"; +import FormControl from "@mui/material/FormControl"; +import { useCreateServer, useVersionList } from "@mcl/queries"; + +const defaultServer = { + version: "latest", + serverType: "VANILLA", + difficulty: "easy", + maxPlayers: "5", + gamemode: "survival", + memory: "512", + motd: `\\u00A7e\\u00A7ka\\u00A7l\\u00A7aMine\\u00A76Cluster\\u00A7r\\u00A78\\u00A7b\\u00A7ka`, +}; + +export default function Create() { + const [wl, setWl] = useState([]); + const [ops, setOps] = useState([]); + const [spec, setSpec] = useState(defaultServer); + const nav = useNavigate(); + const versionList = useVersionList(); + const [versions, setVersions] = useState(["latest"]); + const createServer = useCreateServer(spec); + const updateSpec = (attr, val) => { + const s = { ...spec }; + s[attr] = val; + setSpec(s); + console.log(s); + }; + + useEffect(() => { + if (!versionList.data) return; + setVersions([ + "latest", + ...versionList.data.versions + .filter(({ type: releaseType }) => releaseType === "release") + .map(({ id }) => id), + ]); + }, [versionList.data]); + + const coreUpdate = (attr) => (e) => updateSpec(attr, e.target.value); + + function opsAdd(e) { + const opEntry = e.target.innerHTML ?? e.target.value; + if (!opEntry) return; + const newOps = [...ops, opEntry]; + setOps(newOps); + updateSpec("ops", newOps.join(",")); + } + + function whitelistAdd(e) { + const wlEntry = e.target.value; + if (!wlEntry) return; + const newWl = [...wl, wlEntry]; + setWl(newWl); + updateSpec("whitelist", newWl.join(",")); + } + + const opsRemove = + (name, { onDelete: updateAutoComplete }) => + (e) => { + updateAutoComplete(e); + const newOps = [...ops]; + const entryIndex = newOps.indexOf(name); + if (entryIndex === -1) return; + newOps.splice(entryIndex, 1); + setOps(newOps); + updateSpec("ops", newOps.join(",")); + }; + + const whitelistRemove = + (name, { onDelete: updateAutocomplete }) => + (e) => { + updateAutocomplete(e); + const newWl = [...wl]; + const entryIndex = newWl.indexOf(name); + if (entryIndex === -1) return; + newWl.splice(entryIndex, 1); + setWl(newWl); + updateSpec("whitelist", newWl.join(",")); + }; + + async function upsertSpec() { + if (validateSpec() !== "validated") return; + createServer(spec) + .then(() => nav("/")) + .catch(alert); + } + + function validateSpec() { + console.log("TODO CREATE VALIDATION"); + if (!spec.name) return alertValidationError("Name not included"); + if (!spec.version) return alertValidationError("Version cannot be blank"); + if (!spec.host) return alertValidationError("Host cannot be blank"); + return "validated"; + } + + function alertValidationError(reason) { + alert(`Could not validate spec because: ${reason}`); + } + + return ( + + + + + + {versions.map((v, k) => ( + + {v} + + ))} + + + Vanilla + Fabric + Paper + Spigot + + + Peaceful + Easy + Medium + Hard + + + } + renderTags={(value, getTagProps) => + value.map((option, index) => { + const defaultChipProps = getTagProps({ index }); + return ( + + ); + }) + } + /> + } + renderTags={(value, getTagProps) => + value.map((option, index) => { + const defaultChipProps = getTagProps({ index }); + return ( + + ); + }) + } + /> + {/**/} + {/**/} + + + Survival + Creative + Adventure + Spectator + + + {/**/} + + + + + + ); +} From e94aca7c96f426adf2cbd29578eddb0f91c64a8a Mon Sep 17 00:00:00 2001 From: Dunemask Date: Fri, 22 Dec 2023 11:59:08 -0700 Subject: [PATCH 02/17] [REV] Adjusted servers table --- lib/controllers/file-controller.js | 10 ++++---- lib/controllers/lifecycle-controller.js | 24 +++++++------------ .../migrations/1_create_servers_table.sql | 8 +++++-- 3 files changed, 19 insertions(+), 23 deletions(-) diff --git a/lib/controllers/file-controller.js b/lib/controllers/file-controller.js index 36aa0b0..7af1a4c 100644 --- a/lib/controllers/file-controller.js +++ b/lib/controllers/file-controller.js @@ -10,7 +10,7 @@ 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!"); + if (!serverSpec.host) return res.status(400).send("Server name required!"); listServerFiles(serverSpec) .then((f) => { const fileData = f.map((fi, i) => ({ @@ -29,7 +29,7 @@ export async function listFiles(req, res) { export async function createFolder(req, res) { const serverSpec = req.body; if (!serverSpec) return res.sendStatus(400); - if (!serverSpec.name) return res.status(400).send("Server name required!"); + if (!serverSpec.host) return res.status(400).send("Server name required!"); if (!serverSpec.path) return res.status(400).send("Path required!"); createServerFolder(serverSpec) .then(() => res.sendStatus(200)) @@ -39,7 +39,7 @@ export async function createFolder(req, res) { export async function deleteItem(req, res) { const serverSpec = req.body; if (!serverSpec) return res.sendStatus(400); - if (!serverSpec.name) return res.status(400).send("Server name required!"); + if (!serverSpec.host) return res.status(400).send("Server name required!"); if (!serverSpec.path) return res.status(400).send("Path required!"); if (serverSpec.isDir === undefined || serverSpec.isDir === null) return res.status(400).send("IsDIr required!"); @@ -50,7 +50,7 @@ export async function deleteItem(req, res) { export async function uploadItem(req, res) { const serverSpec = req.body; - if (!serverSpec.name) return res.status(400).send("Server name required!"); + if (!serverSpec.host) return res.status(400).send("Server name required!"); if (!serverSpec.path) return res.status(400).send("Path required!"); uploadServerItem(serverSpec, req.file) .then(() => res.sendStatus(200)) @@ -59,7 +59,7 @@ export async function uploadItem(req, res) { export async function getItem(req, res) { const serverSpec = req.body; - if (!serverSpec.name) return res.status(400).send("Server name required!"); + if (!serverSpec.host) return res.status(400).send("Server name required!"); if (!serverSpec.path) return res.status(400).send("Path required!"); getServerItem(serverSpec, res) .then(({ ds, ftpTransfer }) => { diff --git a/lib/controllers/lifecycle-controller.js b/lib/controllers/lifecycle-controller.js index 5b0105e..c0d0316 100644 --- a/lib/controllers/lifecycle-controller.js +++ b/lib/controllers/lifecycle-controller.js @@ -6,32 +6,24 @@ import { getServerEntry, } from "../database/queries/server-queries.js"; import { sendError } from "../util/ExpressClientError.js"; -import { - startServerContainer, - stopServerContainer, -} from "../k8s/server-control.js"; import { toggleServer } from "../k8s/k8s-server-control.js"; function payloadFilter(req, res) { const serverSpec = req.body; if (!serverSpec) return res.sendStatus(400); - const { name, host, version, serverType, difficulty, gamemode, memory } = + const { name, host, version, serverType, memory } = serverSpec; if (!name) return res.status(400).send("Server name is required!"); if (!host) return res.status(400).send("Server host 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) { +function checkServerHost(serverSpec) { if (!serverSpec) throw new ExpressClientError({ c: 400 }); - if (!serverSpec.name) + if (!serverSpec.host) throw new ExpressClientError({ c: 400, m: "Server name required!" }); } @@ -39,7 +31,7 @@ export async function createServer(req, res) { if (payloadFilter(req, res) !== "filtered") return; const serverSpec = req.body; try { - const serverSpecs = await getServerEntry(serverSpec.name); + const serverSpecs = await getServerEntry(serverSpec.id); if (serverSpecs.length !== 0) throw Error("Server already exists in DB!"); await createServerResources(serverSpec); await createServerEntry(serverSpec); @@ -53,11 +45,11 @@ export async function deleteServer(req, res) { // Ensure spec is safe const serverSpec = req.body; try { - checkServerName(serverSpec); + checkServerHost(serverSpec); } catch (e) { return sendError(res)(e); } - const deleteEntry = deleteServerEntry(serverSpec.name); + const deleteEntry = deleteServerEntry(serverSpec.id); const deleteResources = deleteServerResources(serverSpec); Promise.all([deleteEntry, deleteResources]) .then(() => res.sendStatus(200)) @@ -68,7 +60,7 @@ export async function startServer(req, res) { // Ensure spec is safe const serverSpec = req.body; try { - checkServerName(serverSpec); + checkServerHost(serverSpec); } catch (e) { return sendError(res)(e); } @@ -82,7 +74,7 @@ export async function stopServer(req, res) { // Ensure spec is safe const serverSpec = req.body; try { - checkServerName(serverSpec); + checkServerHost(serverSpec); } catch (e) { return sendError(res)(e); } diff --git a/lib/database/migrations/1_create_servers_table.sql b/lib/database/migrations/1_create_servers_table.sql index 5306630..efc458b 100644 --- a/lib/database/migrations/1_create_servers_table.sql +++ b/lib/database/migrations/1_create_servers_table.sql @@ -1,12 +1,16 @@ CREATE SEQUENCE servers_id_seq; CREATE TABLE servers ( id bigint NOT NULL DEFAULT nextval('servers_id_seq') PRIMARY KEY, - name varchar(255) DEFAULT NULL, host varchar(255) DEFAULT NULL, + name varchar(255) DEFAULT NULL, version varchar(63) DEFAULT 'latest', server_type varchar(63) DEFAULT 'VANILLA', + cpu varchar(63) DEFAULT '500', memory varchar(63) DEFAULT '512', - CONSTRAINT unique_name UNIQUE(name), + backup_host varchar(255) DEFAULT NULL, + backup_bucket_path varchar(255) DEFAULT NULL, + backup_user varchar(255) DEFAULT NULL, + backup_pass varchar(255) DEFAULT NULL, CONSTRAINT unique_host UNIQUE(host) ); ALTER SEQUENCE servers_id_seq OWNED BY servers.id; \ No newline at end of file From 91587f66b26412bb69eea3d1552d84de5ca4f42a Mon Sep 17 00:00:00 2001 From: Dunemask Date: Fri, 22 Dec 2023 14:45:49 -0700 Subject: [PATCH 03/17] [REV] Switch to use IDS over server names --- lib/controllers/file-controller.js | 10 +-- lib/controllers/lifecycle-controller.js | 34 +++++----- lib/database/queries/server-queries.js | 37 +++++++--- lib/k8s/configs/rcon-secret.yml | 2 +- lib/k8s/configs/rcon-svc.yml | 2 +- lib/k8s/configs/server-deployment.yml | 4 +- lib/k8s/configs/server-pvc.yml | 2 +- lib/k8s/configs/server-svc.yml | 2 +- lib/k8s/k8s-server-control.js | 58 ++++++---------- lib/k8s/server-containers.js | 14 ++-- lib/k8s/server-control.js | 83 +++++++++-------------- lib/k8s/server-create.js | 80 ++++++++++------------ lib/k8s/server-delete.js | 4 +- lib/k8s/server-files.js | 4 +- src/components/files/MineclusterFiles.jsx | 12 ++-- src/components/servers/RconDialog.jsx | 5 +- src/components/servers/RconSocket.js | 4 +- src/components/servers/RconView.jsx | 4 +- src/components/servers/ServerCard.jsx | 12 ++-- src/pages/Home.jsx | 4 +- src/util/queries.js | 40 +++++------ 21 files changed, 196 insertions(+), 221 deletions(-) diff --git a/lib/controllers/file-controller.js b/lib/controllers/file-controller.js index 7af1a4c..1a92c17 100644 --- a/lib/controllers/file-controller.js +++ b/lib/controllers/file-controller.js @@ -10,7 +10,7 @@ import { sendError } from "../util/ExpressClientError.js"; export async function listFiles(req, res) { const serverSpec = req.body; if (!serverSpec) return res.sendStatus(400); - if (!serverSpec.host) return res.status(400).send("Server name required!"); + if (!serverSpec.id) return res.status(400).send("Server id missing!"); listServerFiles(serverSpec) .then((f) => { const fileData = f.map((fi, i) => ({ @@ -29,7 +29,7 @@ export async function listFiles(req, res) { export async function createFolder(req, res) { const serverSpec = req.body; if (!serverSpec) return res.sendStatus(400); - if (!serverSpec.host) return res.status(400).send("Server name required!"); + if (!serverSpec.id) return res.status(400).send("Server id missing!"); if (!serverSpec.path) return res.status(400).send("Path required!"); createServerFolder(serverSpec) .then(() => res.sendStatus(200)) @@ -39,7 +39,7 @@ export async function createFolder(req, res) { export async function deleteItem(req, res) { const serverSpec = req.body; if (!serverSpec) return res.sendStatus(400); - if (!serverSpec.host) return res.status(400).send("Server name required!"); + if (!serverSpec.id) return res.status(400).send("Server id missing!"); if (!serverSpec.path) return res.status(400).send("Path required!"); if (serverSpec.isDir === undefined || serverSpec.isDir === null) return res.status(400).send("IsDIr required!"); @@ -50,7 +50,7 @@ export async function deleteItem(req, res) { export async function uploadItem(req, res) { const serverSpec = req.body; - if (!serverSpec.host) return res.status(400).send("Server name required!"); + if (!serverSpec.id) return res.status(400).send("Server id missing!"); if (!serverSpec.path) return res.status(400).send("Path required!"); uploadServerItem(serverSpec, req.file) .then(() => res.sendStatus(200)) @@ -59,7 +59,7 @@ export async function uploadItem(req, res) { export async function getItem(req, res) { const serverSpec = req.body; - if (!serverSpec.host) return res.status(400).send("Server name required!"); + if (!serverSpec.id) return res.status(400).send("Server id missing!"); if (!serverSpec.path) return res.status(400).send("Path required!"); getServerItem(serverSpec, res) .then(({ ds, ftpTransfer }) => { diff --git a/lib/controllers/lifecycle-controller.js b/lib/controllers/lifecycle-controller.js index c0d0316..d0c4289 100644 --- a/lib/controllers/lifecycle-controller.js +++ b/lib/controllers/lifecycle-controller.js @@ -8,33 +8,35 @@ import { import { sendError } from "../util/ExpressClientError.js"; import { toggleServer } from "../k8s/k8s-server-control.js"; +const dnsRegex = new RegExp( + `^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])(\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9]))*$`, +); + function payloadFilter(req, res) { const serverSpec = req.body; if (!serverSpec) return res.sendStatus(400); - const { name, host, version, serverType, memory } = - serverSpec; + const { name, host, version, serverType, memory } = serverSpec; if (!name) return res.status(400).send("Server name is required!"); if (!host) return res.status(400).send("Server host is required!"); + if (!dnsRegex.test(host)) return res.status(400).send("Hostname invalid!"); if (!version) return res.status(400).send("Server version is required!"); if (!serverType) return res.status(400).send("Server type is required!"); if (!memory) return res.status(400).send("Memory is required!"); return "filtered"; } -function checkServerHost(serverSpec) { +function checkServerId(serverSpec) { if (!serverSpec) throw new ExpressClientError({ c: 400 }); - if (!serverSpec.host) - throw new ExpressClientError({ c: 400, m: "Server name required!" }); + if (!serverSpec.id) + throw new ExpressClientError({ c: 400, m: "Server id missing!" }); } export async function createServer(req, res) { if (payloadFilter(req, res) !== "filtered") return; const serverSpec = req.body; try { - const serverSpecs = await getServerEntry(serverSpec.id); - if (serverSpecs.length !== 0) throw Error("Server already exists in DB!"); - await createServerResources(serverSpec); - await createServerEntry(serverSpec); + const serverEntry = await createServerEntry(serverSpec); + await createServerResources(serverEntry); res.sendStatus(200); } catch (e) { sendError(res)(e); @@ -45,7 +47,7 @@ export async function deleteServer(req, res) { // Ensure spec is safe const serverSpec = req.body; try { - checkServerHost(serverSpec); + checkServerId(serverSpec); } catch (e) { return sendError(res)(e); } @@ -60,12 +62,12 @@ export async function startServer(req, res) { // Ensure spec is safe const serverSpec = req.body; try { - checkServerHost(serverSpec); + checkServerId(serverSpec); } catch (e) { return sendError(res)(e); } - const { name } = serverSpec; - toggleServer(name, true) + const { id } = serverSpec; + toggleServer(id, true) .then(() => res.sendStatus(200)) .catch(sendError(res)); } @@ -74,12 +76,12 @@ export async function stopServer(req, res) { // Ensure spec is safe const serverSpec = req.body; try { - checkServerHost(serverSpec); + checkServerId(serverSpec); } catch (e) { return sendError(res)(e); } - const { name } = serverSpec; - toggleServer(name, false) + const { id } = serverSpec; + toggleServer(id, false) .then(() => res.sendStatus(200)) .catch(sendError(res)); } diff --git a/lib/database/queries/server-queries.js b/lib/database/queries/server-queries.js index d8baa8f..b49acdd 100644 --- a/lib/database/queries/server-queries.js +++ b/lib/database/queries/server-queries.js @@ -9,32 +9,47 @@ const asExpressClientError = (e) => { export async function createServerEntry(serverSpec) { const { name, host, version, serverType: server_type, memory } = serverSpec; - const q = insertQuery(table, { name, host, version, server_type, memory }); + var q = insertQuery(table, { name, host, version, server_type, memory }); + q += "\n RETURNING *"; + try { + const entries = await pg.query(q); + const { + id, + name, + host, + version, + server_type: serverType, + memory, + } = entries[0]; + return { name, id, host, version, serverType, memory }; + } catch (e) { + asExpressClientError(e); + } +} + +export async function deleteServerEntry(serverId) { + if (!serverId) asExpressClientError({ message: "Server ID Required!" }); + const q = deleteQuery(table, { id: serverId }); return pg.query(q).catch(asExpressClientError); } -export async function deleteServerEntry(serverName) { - if (!serverName) asExpressClientError({ message: "Server Name Required!" }); - const q = deleteQuery(table, { name: serverName }); - return pg.query(q).catch(asExpressClientError); -} - -export async function getServerEntry(serverName) { - if (!serverName) asExpressClientError({ message: "Server Name Required!" }); - const q = selectWhereQuery(table, { name: serverName }); +export async function getServerEntry(serverId) { + if (!serverId) asExpressClientError({ message: "Server ID Required!" }); + const q = selectWhereQuery(table, { id: serverId }); try { const serverSpecs = await pg.query(q); if (serverSpecs.length === 0) return []; if (!serverSpecs.length === 1) throw Error("Multiple servers found with the same name!"); const { + id, name, host, version, server_type: serverType, memory, } = serverSpecs[0]; - return { name, host, version, serverType, memory }; + return { name, id, host, version, serverType, memory }; } catch (e) { asExpressClientError(e); } diff --git a/lib/k8s/configs/rcon-secret.yml b/lib/k8s/configs/rcon-secret.yml index ff52e1a..a90c319 100644 --- a/lib/k8s/configs/rcon-secret.yml +++ b/lib/k8s/configs/rcon-secret.yml @@ -4,7 +4,7 @@ data: kind: Secret metadata: annotations: - minecluster.dunemask.net/server-name: changeme-server-name + minecluster.dunemask.net/id: changeme-server-id labels: app: changeme-app-label name: changeme-rcon-secret diff --git a/lib/k8s/configs/rcon-svc.yml b/lib/k8s/configs/rcon-svc.yml index 9a68813..49a7089 100644 --- a/lib/k8s/configs/rcon-svc.yml +++ b/lib/k8s/configs/rcon-svc.yml @@ -2,7 +2,7 @@ apiVersion: v1 kind: Service metadata: annotations: - minecluster.dunemask.net/server-name: changeme-server-name + minecluster.dunemask.net/id: changeme-server-id labels: app: changeme-app name: changeme-rcon diff --git a/lib/k8s/configs/server-deployment.yml b/lib/k8s/configs/server-deployment.yml index 0d5d335..aa31a97 100644 --- a/lib/k8s/configs/server-deployment.yml +++ b/lib/k8s/configs/server-deployment.yml @@ -2,7 +2,7 @@ apiVersion: apps/v1 kind: Deployment metadata: annotations: - minecluster.dunemask.net/server-name: changeme-server-name + minecluster.dunemask.net/id: changeme-server-id name: changeme-name namespace: changeme-namespace spec: @@ -17,7 +17,7 @@ spec: template: metadata: annotations: - minecluster.dunemask.net/server-name: changeme-server-name + minecluster.dunemask.net/id: changeme-server-id labels: app: changeme-app spec: diff --git a/lib/k8s/configs/server-pvc.yml b/lib/k8s/configs/server-pvc.yml index f502e8a..bf21ea4 100644 --- a/lib/k8s/configs/server-pvc.yml +++ b/lib/k8s/configs/server-pvc.yml @@ -2,7 +2,7 @@ apiVersion: v1 kind: PersistentVolumeClaim metadata: annotations: - minecluster.dunemask.net/server-name: changeme-server-name + minecluster.dunemask.net/id: changeme-server-id labels: service: changeme-service-name name: changeme-pvc-name diff --git a/lib/k8s/configs/server-svc.yml b/lib/k8s/configs/server-svc.yml index c7e7fa2..f21db9a 100644 --- a/lib/k8s/configs/server-svc.yml +++ b/lib/k8s/configs/server-svc.yml @@ -4,7 +4,7 @@ metadata: annotations: ingress.qumine.io/hostname: changeme-url ingress.qumine.io/portname: minecraft - minecluster.dunemask.net/server-name: changeme-server-name + minecluster.dunemask.net/id: changeme-server-id labels: app: changeme-app name: changeme-name diff --git a/lib/k8s/k8s-server-control.js b/lib/k8s/k8s-server-control.js index 4e5233d..1581ee1 100644 --- a/lib/k8s/k8s-server-control.js +++ b/lib/k8s/k8s-server-control.js @@ -20,10 +20,10 @@ const loadYaml = (f) => yaml.load(fs.readFileSync(path.resolve(f), "utf8")); const mineclusterManaged = (o) => o.metadata && o.metadata.annotations && - o.metadata.annotations["minecluster.dunemask.net/server-name"] !== undefined; + o.metadata.annotations["minecluster.dunemask.net/id"] !== undefined; -export const serverMatch = (serverName) => (o) => - o.metadata.annotations["minecluster.dunemask.net/server-name"] === serverName; +export const serverMatch = (serverId) => (o) => + o.metadata.annotations["minecluster.dunemask.net/id"] === serverId; export async function getDeployments() { const deploymentRes = await k8sDeps.listNamespacedDeployment(namespace); @@ -50,8 +50,9 @@ export async function getVolumes() { return serverVolumes; } -export function getServerAssets(serverName) { - const serverFilter = serverMatch(serverName); +export function getServerAssets(serverId) { + const serverFilter = serverMatch(serverId); + console.log(serverId); return Promise.all([ getDeployments(), getServices(), @@ -69,13 +70,9 @@ export function getServerAssets(serverName) { if (secrets.length > 1) throw Error("Secrets broken!"); const serverAssets = { deployment: deployments[0], - service: services.find( - (s) => s.metadata.name === `mcl-${serverName}-server`, - ), + service: services.find((s) => s.metadata.name.endsWith("-server")), volume: volumes[0], - rconService: services.find( - (s) => s.metadata.name === `mcl-${serverName}-rcon`, - ), + rconService: services.find((s) => s.metadata.name.endsWith("-rcon")), rconSecret: secrets[0], }; for (var k in serverAssets) if (serverAssets[k]) return serverAssets; @@ -84,30 +81,29 @@ export function getServerAssets(serverName) { .catch((e) => ERR("SERVER ASSETS", e)); } -export async function getDeployment(serverName) { +export async function getDeployment(serverId) { const servers = await getDeployments(); + console.log(servers.map(({ metadata }) => metadata.annotations)); const serverDeployment = servers.find( - (s) => - s.metadata.annotations["minecluster.dunemask.net/server-name"] === - serverName, + (s) => s.metadata.annotations["minecluster.dunemask.net/id"] === serverId, ); if (!serverDeployment) - throw Error(`MCL Deployment '${serverName}' could not be found!`); + throw Error(`MCL Deployment with ID '${serverId}' could not be found!`); return serverDeployment; } -export async function getContainers(serverName) { - const deployment = await getDeployment(serverName); +export async function getContainers(serverId) { + const deployment = await getDeployment(serverId); return deployment.spec.template.spec.containers; } -async function containerControl(serverName, deployment, scaleUp) { +async function containerControl(serverId, deployment, scaleUp) { const { containers } = deployment.spec.template.spec; const depFtp = containers.find((c) => c.name.endsWith("-ftp")); const depServer = containers.find((c) => c.name.endsWith("-server")); const depBackup = containers.find((c) => c.name.endsWith("-backup")); - const serverSpec = await getServerEntry(serverName); + const serverSpec = await getServerEntry(serverId); const ftpContainer = depFtp ?? getFtpContainer(serverSpec); const serverContainer = depServer ?? getCoreServerContainer(serverSpec); const backupContainer = depBackup ?? getBackupContainer(serverSpec); @@ -115,10 +111,10 @@ async function containerControl(serverName, deployment, scaleUp) { return [ftpContainer]; } -export async function toggleServer(serverName, scaleUp = false) { - const deployment = await getDeployment(serverName); +export async function toggleServer(serverId, scaleUp = false) { + const deployment = await getDeployment(serverId); deployment.spec.template.spec.containers = await containerControl( - serverName, + serverId, deployment, scaleUp, ); @@ -128,19 +124,3 @@ export async function toggleServer(serverName, scaleUp = false) { deployment, ); } - -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, - ); -} diff --git a/lib/k8s/server-containers.js b/lib/k8s/server-containers.js index 4694828..a71410f 100644 --- a/lib/k8s/server-containers.js +++ b/lib/k8s/server-containers.js @@ -4,9 +4,9 @@ import yaml from "js-yaml"; const loadYaml = (f) => yaml.load(fs.readFileSync(path.resolve(f), "utf8")); export function getFtpContainer(serverSpec) { - const { name } = serverSpec; + const { mclName } = serverSpec; const ftpContainer = loadYaml("lib/k8s/configs/containers/ftp-server.yml"); - ftpContainer.name = `mcl-${name}-ftp`; + ftpContainer.name = `mcl-${mclName}-ftp`; const ftpPortList = [ { p: 20, n: "ftp-data" }, { p: 21, n: "ftp-commands" }, @@ -22,10 +22,10 @@ export function getFtpContainer(serverSpec) { } export function getCoreServerContainer(serverSpec) { - const { name, version, serverType, memory } = serverSpec; + const { mclName, version, serverType, memory } = serverSpec; const container = loadYaml("lib/k8s/configs/containers/minecraft-server.yml"); // Container Updates - container.name = `mcl-${name}-server`; + container.name = `mcl-${mclName}-server`; container.resources.requests.memory = `${memory}Mi`; const findEnv = (k) => container.env.find(({ name: n }) => n === k); @@ -36,7 +36,7 @@ export function getCoreServerContainer(serverSpec) { updateEnv("VERSION", version); updateEnv("MEMORY", `${memory}M`); // RCON - const rs = `mcl-${name}-rcon-secret`; + const rs = `mcl-${mclName}-rcon-secret`; findEnv("RCON_PASSWORD").valueFrom.secretKeyRef.name = rs; return container; } @@ -50,13 +50,13 @@ export function getServerContainer(serverSpec) { const updateEnv = (k, v) => (findEnv(k).value = v); // Enviornment variables - updateEnv("DIFFICULTY", difficulty); + /*updateEnv("DIFFICULTY", difficulty); updateEnv("MODE", gamemode); updateEnv("MOTD", motd); updateEnv("MAX_PLAYERS", maxPlayers); updateEnv("SEED", seed); updateEnv("OPS", ops); - updateEnv("WHITELIST", whitelist); + updateEnv("WHITELIST", whitelist); */ return container; } diff --git a/lib/k8s/server-control.js b/lib/k8s/server-control.js index bcb5a35..e2b821c 100644 --- a/lib/k8s/server-control.js +++ b/lib/k8s/server-control.js @@ -1,51 +1,42 @@ import k8s from "@kubernetes/client-node"; -import { - getDeployment, - getDeployments, - getServerAssets, - scaleDeployment, -} from "./k8s-server-control.js"; -import { ERR } from "../util/logging.js"; -import ExpressClientError from "../util/ExpressClientError.js"; +import { getDeployments } from "./k8s-server-control.js"; const kc = new k8s.KubeConfig(); kc.loadFromDefault(); const k8sMetrics = new k8s.Metrics(kc); const namespace = process.env.MCL_SERVER_NAMESPACE; -export async function startServerContainer(serverSpec) { - const { name } = serverSpec; - try { - await scaleDeployment(name, true); - } catch (e) { - ERR("SERVER CONTROL", e); - throw new ExpressClientError({ - c: 500, - m: `Error updating server '${name}'!\n`, - }); - } -} +function getServerMetrics(podMetricsRes, serverId, serverAvailable) { + const pod = podMetricsRes.items.find(({ metadata: md }) => { + return ( + md.annotations && + md.annotations["minecluster.dunemask.net/id"] === serverId + ); + }); -export async function stopServerContainer(serverSpec) { - const { name } = serverSpec; - try { - await scaleDeployment(name, false); - } catch (e) { - ERR("SERVER CONTROL", e); - throw new ExpressClientError({ - c: 500, - m: `Error updating server '${name}'!`, - }); + if (serverAvailable && pod) { + const podCpus = pod.containers.map( + ({ usage }) => parseInt(usage.cpu) / 1_000_000, + ); + const podMems = pod.containers.map( + ({ usage }) => parseInt(usage.memory) / 1024, + ); + metrics = { + cpu: Math.ceil(podCpus.reduce((a, b) => a + b)), + memory: Math.ceil(podMems.reduce((a, b) => a + b)), + }; } } export async function getInstances() { const serverDeployments = await getDeployments(); - const podMetricsResponse = await k8sMetrics.getPodMetrics(namespace); - var name, metrics, services, serverAvailable, ftpAvailable; + const podMetricsRes = await k8sMetrics.getPodMetrics(namespace); + var name, serverId, metrics, services, serverAvailable, ftpAvailable; const serverInstances = serverDeployments.map((s) => { - name = s.metadata.annotations["minecluster.dunemask.net/server-name"]; + serverId = s.metadata.annotations["minecluster.dunemask.net/id"]; + name = s.metadata.name; metrics = null; + const { containers } = s.spec.template.spec; services = containers.map(({ name }) => name.split("-").pop()); const serverStatusList = s.status.conditions.map( @@ -57,23 +48,15 @@ export async function getInstances() { ) !== undefined; serverAvailable = services.includes(`server`) && deploymentAvailable; ftpAvailable = services.includes("ftp") && deploymentAvailable; - - const pod = podMetricsResponse.items.find(({ metadata: md }) => { - return md.labels && md.labels.app && md.labels.app === `mcl-${name}-app`; - }); - if (serverAvailable && pod) { - const podCpus = pod.containers.map( - ({ usage }) => parseInt(usage.cpu) / 1_000_000, - ); - const podMems = pod.containers.map( - ({ usage }) => parseInt(usage.memory) / 1024, - ); - metrics = { - cpu: Math.ceil(podCpus.reduce((a, b) => a + b)), - memory: Math.ceil(podMems.reduce((a, b) => a + b)), - }; - } - return { name, metrics, services, serverAvailable, ftpAvailable }; + metrics = getServerMetrics(podMetricsRes, serverId, serverAvailable); + return { + name, + id: serverId, + metrics, + services, + serverAvailable, + ftpAvailable, + }; }); return serverInstances; } diff --git a/lib/k8s/server-create.js b/lib/k8s/server-create.js index 8ef2fe3..4f12993 100644 --- a/lib/k8s/server-create.js +++ b/lib/k8s/server-create.js @@ -4,7 +4,7 @@ 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"; + import { getFtpContainer, getServerContainer, @@ -20,32 +20,31 @@ const namespace = process.env.MCL_SERVER_NAMESPACE; const loadYaml = (f) => yaml.load(fs.readFileSync(path.resolve(f), "utf8")); function createRconSecret(serverSpec) { - const { name } = serverSpec; + const { mclName, id } = 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"); - rconYaml.metadata.labels.app = `mcl-${name}-app`; - rconYaml.metadata.name = `mcl-${name}-rcon-secret`; + rconYaml.metadata.labels.app = `mcl-${mclName}-app`; + rconYaml.metadata.name = `mcl-${mclName}-rcon-secret`; rconYaml.metadata.namespace = namespace; - rconYaml.metadata.annotations["minecluster.dunemask.net/server-name"] = name; + rconYaml.metadata.annotations["minecluster.dunemask.net/id"] = id; return rconYaml; } function createServerVolume(serverSpec) { - const { name } = serverSpec; + const { mclName, id } = serverSpec; const volumeYaml = loadYaml("lib/k8s/configs/server-pvc.yml"); - volumeYaml.metadata.labels.service = `mcl-${name}-server`; - volumeYaml.metadata.name = `mcl-${name}-volume`; + volumeYaml.metadata.labels.service = `mcl-${mclName}-server`; + volumeYaml.metadata.name = `mcl-${mclName}-volume`; volumeYaml.metadata.namespace = namespace; - volumeYaml.metadata.annotations["minecluster.dunemask.net/server-name"] = - name; + volumeYaml.metadata.annotations["minecluster.dunemask.net/id"] = id; volumeYaml.spec.resources.requests.storage = "1Gi"; // TODO: Changeme return volumeYaml; } function createServerDeploy(serverSpec) { - const { name, host } = serverSpec; + const { mclName, id } = serverSpec; const deployYaml = loadYaml("lib/k8s/configs/server-deployment.yml"); const { metadata } = deployYaml; const serverContainer = getServerContainer(serverSpec); @@ -53,19 +52,21 @@ function createServerDeploy(serverSpec) { const ftpContainer = getFtpContainer(serverSpec); // Configure Metadata; - metadata.name = `mcl-${name}`; + metadata.name = `mcl-${mclName}`; metadata.namespace = namespace; - metadata.annotations["minecluster.dunemask.net/server-name"] = name; + metadata.annotations["minecluster.dunemask.net/id"] = id; deployYaml.metadata = metadata; // Configure Lables & Selectors - deployYaml.spec.selector.matchLabels.app = `mcl-${name}-app`; - deployYaml.spec.template.metadata.labels.app = `mcl-${name}-app`; + deployYaml.spec.selector.matchLabels.app = `mcl-${mclName}-app`; + deployYaml.spec.template.metadata.labels.app = `mcl-${mclName}-app`; + deployYaml.spec.template.metadata.annotations["minecluster.dunemask.net/id"] = + id; // Volumes deployYaml.spec.template.spec.volumes.find( ({ name }) => name === "datadir", - ).persistentVolumeClaim.claimName = `mcl-${name}-volume`; + ).persistentVolumeClaim.claimName = `mcl-${mclName}-volume`; // Apply Containers TODO: User control for autostart deployYaml.spec.template.spec.containers.push(serverContainer); @@ -75,17 +76,16 @@ function createServerDeploy(serverSpec) { } function createServerService(serverSpec) { - const { name, host } = serverSpec; + const { mclName, host, id } = serverSpec; const serviceYaml = loadYaml("lib/k8s/configs/server-svc.yml"); serviceYaml.metadata.annotations["ingress.qumine.io/hostname"] = host; serviceYaml.metadata.annotations["mc-router.itzg.me/externalServerName"] = host; - serviceYaml.metadata.labels.app = `mcl-${name}-app`; - serviceYaml.metadata.name = `mcl-${name}-server`; + serviceYaml.metadata.labels.app = `mcl-${mclName}-app`; + serviceYaml.metadata.name = `mcl-${mclName}-server`; serviceYaml.metadata.namespace = namespace; - serviceYaml.metadata.annotations["minecluster.dunemask.net/server-name"] = - name; - serviceYaml.spec.selector.app = `mcl-${name}-app`; + serviceYaml.metadata.annotations["minecluster.dunemask.net/id"] = id; + serviceYaml.spec.selector.app = `mcl-${mclName}-app`; // Port List: const serverPortList = [{ p: 25565, n: "minecraft" }]; @@ -107,32 +107,26 @@ function createServerService(serverSpec) { return serviceYaml; } -function createRconService(serverSpec) { - const { name } = serverSpec; +function createRconService(createSpec) { + const { id, mclName } = createSpec; const rconSvcYaml = loadYaml("lib/k8s/configs/rcon-svc.yml"); - rconSvcYaml.metadata.labels.app = `mcl-${name}-app`; - rconSvcYaml.metadata.name = `mcl-${name}-rcon`; + rconSvcYaml.metadata.labels.app = `mcl-${mclName}-app`; + rconSvcYaml.metadata.name = `mcl-${mclName}-rcon`; rconSvcYaml.metadata.namespace = namespace; - rconSvcYaml.metadata.annotations["minecluster.dunemask.net/server-name"] = - name; - rconSvcYaml.spec.selector.app = `mcl-${name}-app`; + rconSvcYaml.metadata.annotations["minecluster.dunemask.net/id"] = id; + rconSvcYaml.spec.selector.app = `mcl-${mclName}-app`; return rconSvcYaml; } -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}`)) - throw new ExpressClientError({ m: "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`)) - throw new ExpressClientError({ m: "Server PVC already exists!", c: 409 }); - const rconSecret = createRconSecret(serverSpec); - const serverVolume = createServerVolume(serverSpec); - const serverDeploy = createServerDeploy(serverSpec); - const serverService = createServerService(serverSpec); - const rconService = createRconService(serverSpec); +export default async function createServerResources(createSpec) { + createSpec.mclName = `${createSpec.host.replaceAll(".", "-")}-${ + createSpec.id + }`; + const rconSecret = createRconSecret(createSpec); + const serverVolume = createServerVolume(createSpec); + const serverDeploy = createServerDeploy(createSpec); + const serverService = createServerService(createSpec); + const rconService = createRconService(createSpec); k8sCore.createNamespacedPersistentVolumeClaim(namespace, serverVolume); k8sCore.createNamespacedSecret(namespace, rconSecret); k8sCore.createNamespacedService(namespace, serverService); diff --git a/lib/k8s/server-delete.js b/lib/k8s/server-delete.js index e226fbf..75df9f6 100644 --- a/lib/k8s/server-delete.js +++ b/lib/k8s/server-delete.js @@ -22,9 +22,9 @@ function deleteOnExist(o, fn) { } export default async function deleteServerResources(serverSpec) { - const { name } = serverSpec; + const { id } = serverSpec; // Ensure deployment exists - const server = await getServerAssets(name); + const server = await getServerAssets(id); if (!server) throw new ExpressClientError({ c: 404, diff --git a/lib/k8s/server-files.js b/lib/k8s/server-files.js index ec1f7cc..4f04f47 100644 --- a/lib/k8s/server-files.js +++ b/lib/k8s/server-files.js @@ -34,8 +34,8 @@ export async function getFtpClient(serverService) { } export async function useServerFtp(serverSpec, fn) { - const { name } = serverSpec; - const server = await getServerAssets(name); + const { id } = serverSpec; + const server = await getServerAssets(id); if (!server) throw new ExpressClientError({ c: 404, diff --git a/src/components/files/MineclusterFiles.jsx b/src/components/files/MineclusterFiles.jsx index 45bce13..208ec67 100644 --- a/src/components/files/MineclusterFiles.jsx +++ b/src/components/files/MineclusterFiles.jsx @@ -33,14 +33,14 @@ export default function MineclusterFiles(props) { ], [], ); - const { server: serverName } = props; + const { server: serverId } = props; const inputRef = useRef(null); const [dirStack, setDirStack] = useState(["."]); const [files, setFiles] = useState([]); const updateFiles = () => { const dir = dirStack.join("/"); - getServerFiles(serverName, dir).then((f) => { + getServerFiles(serverId, dir).then((f) => { const files = f.map((fi) => ({ ...fi, id: `${dir}/${fi.name}` })); setFiles(files ?? []); }); @@ -70,13 +70,13 @@ export default function MineclusterFiles(props) { function createFolder() { const name = prompt("What is the name of the new folder?"); const path = [...dirStack, name].join("/"); - createServerFolder(serverName, path).then(updateFiles); + createServerFolder(serverId, path).then(updateFiles); } function deleteItems(files) { Promise.all( files.map((f) => - deleteServerItem(serverName, [...dirStack, f.name].join("/"), f.isDir), + deleteServerItem(serverId, [...dirStack, f.name].join("/"), f.isDir), ), ) .catch((e) => console.error("Error deleting some files!", e)) @@ -94,7 +94,7 @@ export default function MineclusterFiles(props) { async function uploadFile(file) { const formData = new FormData(); formData.append("file", file); - formData.append("name", serverName); + formData.append("id", serverId); formData.append("path", [...dirStack, name].join("/")); await fetch("/api/files/upload", { method: "POST", @@ -105,7 +105,7 @@ export default function MineclusterFiles(props) { async function downloadFiles(files) { Promise.all( files.map((f) => - getServerItem(serverName, f.name, [...dirStack, f.name].join("/")), + getServerItem(serverId, f.name, [...dirStack, f.name].join("/")), ), ) .then(() => console.log("Done downloading files!")) diff --git a/src/components/servers/RconDialog.jsx b/src/components/servers/RconDialog.jsx index 5183d1a..c4734b5 100644 --- a/src/components/servers/RconDialog.jsx +++ b/src/components/servers/RconDialog.jsx @@ -16,7 +16,8 @@ export function useRconDialog(isOpen = false) { } export default function RconDialog(props) { - const { serverName, open, dialogToggle } = props; + const { server, open, dialogToggle } = props; + const { name: serverName, id: serverId } = server ?? {}; const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); return ( @@ -33,7 +34,7 @@ export default function RconDialog(props) { RCON - {serverName} - + From d967f6b29cea205bf6a622c74cc778e574be56b4 Mon Sep 17 00:00:00 2001 From: Dunemask Date: Sat, 23 Dec 2023 19:09:32 -0700 Subject: [PATCH 07/17] [FIX] Adjusted Options & RCON --- src/components/servers/RconDialog.jsx | 3 +- src/components/servers/RconSocket.js | 2 ++ src/components/servers/RconView.jsx | 30 ++++++++++++------- src/pages/Create.jsx | 5 ++-- ...reateOptions.jsx => CreateCoreOptions.jsx} | 9 ++---- src/pages/Home.jsx | 1 + 6 files changed, 30 insertions(+), 20 deletions(-) rename src/pages/{CreateOptions.jsx => CreateCoreOptions.jsx} (95%) diff --git a/src/components/servers/RconDialog.jsx b/src/components/servers/RconDialog.jsx index c4734b5..5cb8aad 100644 --- a/src/components/servers/RconDialog.jsx +++ b/src/components/servers/RconDialog.jsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useTheme } from "@mui/material/styles"; import Button from "@mui/material/Button"; @@ -20,6 +20,7 @@ export default function RconDialog(props) { const { name: serverName, id: serverId } = server ?? {}; const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); + return ( console.log("WHOOSPSIE I GUESS?")); } onPush(p) { @@ -17,6 +18,7 @@ export default class RconSocket { } onConnect() { + this.sk.readyState = 1; this.logs = []; } diff --git a/src/components/servers/RconView.jsx b/src/components/servers/RconView.jsx index 854f3bb..8eb1235 100644 --- a/src/components/servers/RconView.jsx +++ b/src/components/servers/RconView.jsx @@ -10,18 +10,28 @@ export default function RconView(props) { const logsRef = useRef(0); const [cmd, setCmd] = useState(""); const [logs, setLogs] = useState([]); - const [rcon, setRcon] = useState({}); + const [rcon, setRcon] = useState(); const updateCmd = (e) => setCmd(e.target.value); - useEffect(function () { - setRcon(new RconSocket(setLogs, serverId)); - return () => { - if (rcon && typeof rcon.disconnect === "function") rcon.disconnect(); - }; - }, []); - useEffect(() => { - logsRef.current.scrollTo(0, logsRef.current.scrollHeight); - }, [rcon.logs]); + const disconnectRcon = () => { + if (!rcon || typeof rcon.disconnect !== "function") return; + rcon.disconnect(); + }; + + useEffect( + function () { + if (!serverId) return; + const rs = new RconSocket(setLogs, serverId); + setRcon(rs); + return disconnectRcon; + }, + [serverId], + ); + + useEffect( + () => logsRef.current.scrollTo(0, logsRef.current.scrollHeight), + [(rcon ?? {}).logs], + ); function sendCommand() { rcon.send(cmd); diff --git a/src/pages/Create.jsx b/src/pages/Create.jsx index b42e3fe..b8e6963 100644 --- a/src/pages/Create.jsx +++ b/src/pages/Create.jsx @@ -1,11 +1,10 @@ import Box from "@mui/material/Box"; -import CreateOptions from "./CreateOptions.jsx"; +import CreateCoreOptions from "./CreateCoreOptions.jsx"; export default function Create() { return ( - {/**/} - + ); diff --git a/src/pages/CreateOptions.jsx b/src/pages/CreateCoreOptions.jsx similarity index 95% rename from src/pages/CreateOptions.jsx rename to src/pages/CreateCoreOptions.jsx index 36fb82f..747712a 100644 --- a/src/pages/CreateOptions.jsx +++ b/src/pages/CreateCoreOptions.jsx @@ -1,20 +1,17 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { useNavigate } from "react-router-dom"; -import TextField from "@mui/material/TextField"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import FormControl from "@mui/material/FormControl"; import { useCreateServer } from "@mcl/queries"; +// Core Options import NameOption from "@mcl/components/server-options/NameOption.jsx"; import HostOption from "@mcl/components/server-options/HostOption.jsx"; - import VersionOption from "@mcl/components/server-options/VersionOption.jsx"; - import ServerTypeOption, { serverTypeOptions, } from "@mcl/components/server-options/ServerTypeOption.jsx"; - import CpuOption, { cpuOptions, } from "@mcl/components/server-options/CpuOption.jsx"; @@ -29,7 +26,7 @@ const defaultServer = { memory: memoryOptions[2], // 1.5GB }; -export default function Create() { +export default function CreateCoreOptions() { const [spec, setSpec] = useState(defaultServer); const nav = useNavigate(); const createServer = useCreateServer(spec); diff --git a/src/pages/Home.jsx b/src/pages/Home.jsx index a906f75..0e80d2a 100644 --- a/src/pages/Home.jsx +++ b/src/pages/Home.jsx @@ -30,6 +30,7 @@ export default function Home() { setServer(s); rconToggle(); }; + return ( From 3cd9577cbf3715bcc8d2f4e57993ee81e161d941 Mon Sep 17 00:00:00 2001 From: Dunemask Date: Tue, 26 Dec 2023 12:26:16 -0700 Subject: [PATCH 08/17] [FIX] Fixed Chonky styling & abused liveness & readiness probes --- .../configs/containers/minecraft-server.yml | 12 ++--- src/components/files/MineclusterFiles.jsx | 4 +- src/components/servers/ServerCard.jsx | 1 - src/css/header.css | 31 ----------- src/nav/MCLMenu.jsx | 54 +++++++++++++++---- src/nav/MCLPages.jsx | 3 ++ src/util/theme.js | 4 ++ 7 files changed, 58 insertions(+), 51 deletions(-) delete mode 100644 src/css/header.css diff --git a/lib/k8s/configs/containers/minecraft-server.yml b/lib/k8s/configs/containers/minecraft-server.yml index 4034e36..63efc13 100644 --- a/lib/k8s/configs/containers/minecraft-server.yml +++ b/lib/k8s/configs/containers/minecraft-server.yml @@ -73,10 +73,10 @@ image: itzg/minecraft-server:latest imagePullPolicy: IfNotPresent livenessProbe: exec: - command: - - mc-health + # command: ["mc-health"] # This is super unsafe... but why not :) + command: ["echo"] failureThreshold: 20 - initialDelaySeconds: 30 + initialDelaySeconds: 5 periodSeconds: 5 successThreshold: 1 timeoutSeconds: 1 @@ -90,10 +90,10 @@ ports: protocol: TCP readinessProbe: exec: - command: - - mc-health + # command: ["mc-health"] # This is super unsafe... but why not :) + command: ["echo"] failureThreshold: 20 - initialDelaySeconds: 30 + initialDelaySeconds: 5 periodSeconds: 5 successThreshold: 1 timeoutSeconds: 1 diff --git a/src/components/files/MineclusterFiles.jsx b/src/components/files/MineclusterFiles.jsx index 93f5698..ee5b26b 100644 --- a/src/components/files/MineclusterFiles.jsx +++ b/src/components/files/MineclusterFiles.jsx @@ -18,8 +18,6 @@ import { getServerItem, } from "@mcl/queries"; -import "@mcl/css/header.css"; - export default function MineclusterFiles(props) { // Chonky configuration setChonkyDefaults({ iconComponent: ChonkyIconFA }); @@ -140,7 +138,6 @@ export default function MineclusterFiles(props) { onChange={uploadFileSelection} multiple /> - + diff --git a/src/components/servers/ServerCard.jsx b/src/components/servers/ServerCard.jsx index e0d5063..c2d5ee8 100644 --- a/src/components/servers/ServerCard.jsx +++ b/src/components/servers/ServerCard.jsx @@ -11,7 +11,6 @@ import Typography from "@mui/material/Typography"; import StopIcon from "@mui/icons-material/Stop"; import TerminalIcon from "@mui/icons-material/Terminal"; import PlayArrowIcon from "@mui/icons-material/PlayArrow"; -import PendingIcon from "@mui/icons-material/Pending"; import DeleteForeverIcon from "@mui/icons-material/DeleteForever"; import EditIcon from "@mui/icons-material/Edit"; import FolderIcon from "@mui/icons-material/Folder"; diff --git a/src/css/header.css b/src/css/header.css deleted file mode 100644 index efa6fa9..0000000 --- a/src/css/header.css +++ /dev/null @@ -1,31 +0,0 @@ -.appbar-items { - font-size: 1.25rem; - font-family: "Roboto", "Helvetica", "Arial", sans-serif; - font-weight: 500; - line-height: 1.6; - letter-spacing: 0.0075em; -} - -.view > header { - transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - box-shadow: - 0px 2px 4px -1px rgba(0, 0, 0, 0.2), - 0px 4px 5px 0px rgba(0, 0, 0, 0.14), - 0px 1px 10px 0px rgba(0, 0, 0, 0.12); - display: flex; - flex-direction: column; - width: 100%; - box-sizing: border-box; - flex-shrink: 0; - position: fixed; - top: 0; - left: auto; - right: 0; - color: rgba(0, 0, 0, 0.87); - z-index: 1302; - background-color: #29985c; -} -.view > header > div > div > a { - height: 40px; - width: 40px; -} diff --git a/src/nav/MCLMenu.jsx b/src/nav/MCLMenu.jsx index 706c971..6108f0a 100644 --- a/src/nav/MCLMenu.jsx +++ b/src/nav/MCLMenu.jsx @@ -13,7 +13,7 @@ import IconButton from "@mui/material/IconButton"; import Typography from "@mui/material/Typography"; import MenuIcon from "@mui/icons-material/Menu"; import Drawer from "@mui/material/Drawer"; -import HomeIcon from "@mui/icons-material/Home"; +import ListItemIcon from "@mui/material/ListItemIcon"; import ListItemText from "@mui/material/ListItemText"; import List from "@mui/material/List"; import ListItemButton from "@mui/material/ListItemButton"; @@ -36,18 +36,52 @@ export default function MCLMenu() { theme.zIndex.modal + 2 - (isDrawer ? 1 : 0); return ( - - + + - - + + - + + + + + {pages.map( + (page, index) => + page.visible && ( + + {page.icon} + + + ), + )} + + + + {navHeader()} - + diff --git a/src/nav/MCLPages.jsx b/src/nav/MCLPages.jsx index d13364e..56ab52e 100644 --- a/src/nav/MCLPages.jsx +++ b/src/nav/MCLPages.jsx @@ -11,17 +11,20 @@ export default [ path: "/mcl/home", icon: , component: , + visible: true, }, { name: "Create", path: "/mcl/create", icon: , component: , + visible: true, }, { name: "Edit", path: "/mcl/files", icon: , component: , + visible: false, }, ]; diff --git a/src/util/theme.js b/src/util/theme.js index 2e9bf46..f694037 100644 --- a/src/util/theme.js +++ b/src/util/theme.js @@ -1,5 +1,9 @@ // Generated using https://zenoo.github.io/mui-theme-creator/ import { createTheme } from "@mui/material/styles"; +import { unstable_ClassNameGenerator as ClassNameGenerator } from "@mui/material/className"; +// This fixes style clashing with Chonky which has not been updated to Material 5 +// see https://github.com/TimboKZ/Chonky/issues/101#issuecomment-1362949314 +ClassNameGenerator.configure((componentName) => `mcl-${componentName}`); const themeOptions = { palette: { From cb118e07c06ca5894a4086c5066563cad0e5e7ef Mon Sep 17 00:00:00 2001 From: Dunemask Date: Tue, 26 Dec 2023 12:33:40 -0700 Subject: [PATCH 09/17] [FIX] Abused liveness & readiness probes --- lib/k8s/configs/containers/ftp-server.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/k8s/configs/containers/ftp-server.yml b/lib/k8s/configs/containers/ftp-server.yml index 759bc20..038cd00 100644 --- a/lib/k8s/configs/containers/ftp-server.yml +++ b/lib/k8s/configs/containers/ftp-server.yml @@ -9,7 +9,7 @@ livenessProbe: exec: command: ["echo"] failureThreshold: 20 -initialDelaySeconds: 30 +initialDelaySeconds: 5 periodSeconds: 5 successThreshold: 1 timeoutSeconds: 1 @@ -19,7 +19,7 @@ readinessProbe: exec: command: ["echo"] failureThreshold: 20 - initialDelaySeconds: 30 + initialDelaySeconds: 5 periodSeconds: 5 successThreshold: 1 timeoutSeconds: 1 From e96c326c1d65dd1fd032c416d05adc45c5ce12fa Mon Sep 17 00:00:00 2001 From: Dunemask Date: Tue, 26 Dec 2023 13:40:22 -0700 Subject: [PATCH 10/17] [FEATURE] Initial File Preview --- src/components/files/FilePreview.jsx | 74 +++++++++++++++++++++++ src/components/files/MineclusterFiles.jsx | 21 +++++-- src/components/servers/RconDialog.jsx | 2 +- src/pages/Files.jsx | 20 +++++- src/util/queries.js | 7 +++ 5 files changed, 117 insertions(+), 7 deletions(-) create mode 100644 src/components/files/FilePreview.jsx diff --git a/src/components/files/FilePreview.jsx b/src/components/files/FilePreview.jsx new file mode 100644 index 0000000..9920a0f --- /dev/null +++ b/src/components/files/FilePreview.jsx @@ -0,0 +1,74 @@ +import { useState, useEffect } from "react"; +import useMediaQuery from "@mui/material/useMediaQuery"; +import { useTheme } from "@mui/material/styles"; +import Button from "@mui/material/Button"; +import DialogTitle from "@mui/material/DialogTitle"; +import DialogContent from "@mui/material/DialogContent"; +import DialogActions from "@mui/material/DialogActions"; +import Dialog from "@mui/material/Dialog"; +import Toolbar from "@mui/material/Toolbar"; + +const textFileTypes = ["properties", "txt", "yaml", "yml", "json", "env"]; +const imageFileTypes = ["png", "jpeg", "jpg"]; + +export const supportedFileTypes = [...textFileTypes, ...imageFileTypes]; + +export function useFilePreview(isOpen = false) { + const [open, setOpen] = useState(isOpen); + const dialogToggle = () => setOpen(!open); + return [open, dialogToggle]; +} + +function TextPreview(props) { + const { fileText } = props; + return
{fileText}
; +} + +export default function FilePreview(props) { + const [fileText, setFileText] = useState(); + const theme = useTheme(); + const fullScreen = useMediaQuery(theme.breakpoints.down("md")); + + const { previewData, open, dialogToggle } = props; + const { fileData, name } = previewData ?? {}; + const ext = name ? name.split(".").pop() : null; + const isTextFile = textFileTypes.includes(ext); + + async function onPreviewChange() { + if (isTextFile) setFileText(await fileData.text()); + } + + useEffect(() => { + onPreviewChange(); + }, [fileData]); + + return ( + + + {name} + + + + + + + + ); +} diff --git a/src/components/files/MineclusterFiles.jsx b/src/components/files/MineclusterFiles.jsx index ee5b26b..31c1389 100644 --- a/src/components/files/MineclusterFiles.jsx +++ b/src/components/files/MineclusterFiles.jsx @@ -17,6 +17,9 @@ import { deleteServerItem, getServerItem, } from "@mcl/queries"; +import { previewServerItem } from "../../util/queries"; + +import { supportedFileTypes } from "./FilePreview.jsx"; export default function MineclusterFiles(props) { // Chonky configuration @@ -31,7 +34,7 @@ export default function MineclusterFiles(props) { ], [], ); - const { server: serverId } = props; + const { server: serverId, changePreview } = props; const inputRef = useRef(null); const [dirStack, setDirStack] = useState(["."]); const [files, setFiles] = useState([]); @@ -65,10 +68,13 @@ export default function MineclusterFiles(props) { const openParentFolder = () => setDirStack(dirStack.slice(0, -1)); - function openFolder(payload) { + function openItem(payload) { const { targetFile: file } = payload; if (file && file.isDir) return setDirStack(file.id.split("/")); - if (file && !file.isDir) return downloadFiles([file]); + if (!file || file.isDir) return; // Ensure file exists or is dir + if (supportedFileTypes.includes(file.name.split(".").pop())) + return previewFile(file); + return downloadFiles([file]); } function createFolder() { @@ -116,6 +122,13 @@ export default function MineclusterFiles(props) { .catch((e) => console.error("Error Downloading files!", e)); } + function previewFile(file) { + const { name } = file; + previewServerItem(serverId, [...dirStack, name].join("/")).then( + (fileData) => changePreview(name, fileData), + ); + } + function fileClick(chonkyEvent) { const { id: clickEvent, payload } = chonkyEvent; if (clickEvent === "open_parent_folder") return openParentFolder(); @@ -126,7 +139,7 @@ export default function MineclusterFiles(props) { if (clickEvent === "delete_files") return deleteItems(chonkyEvent.state.selectedFilesForAction); if (clickEvent !== "open_files") return; // console.log(clickEvent); - openFolder(payload); + openItem(payload); } return ( diff --git a/src/components/servers/RconDialog.jsx b/src/components/servers/RconDialog.jsx index 5cb8aad..899c641 100644 --- a/src/components/servers/RconDialog.jsx +++ b/src/components/servers/RconDialog.jsx @@ -26,7 +26,7 @@ export default function RconDialog(props) { sx={ fullScreen ? {} - : { "& .MuiDialog-paper": { width: "80%", maxHeight: 525 } } + : { "& .mcl-MuiDialog-paper": { width: "80%", maxHeight: 525 } } } maxWidth="xs" open={open} diff --git a/src/pages/Files.jsx b/src/pages/Files.jsx index 2794530..5226176 100644 --- a/src/pages/Files.jsx +++ b/src/pages/Files.jsx @@ -1,20 +1,36 @@ -import { useEffect } from "react"; +import { useState, useEffect } from "react"; import { useSearchParams, useNavigate } from "react-router-dom"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import Toolbar from "@mui/material/Toolbar"; +import FilePreview, { + useFilePreview, +} from "@mcl/components/files/FilePreview.jsx"; import MineclusterFiles from "@mcl/components/files/MineclusterFiles.jsx"; export default function Files() { + const [open, dialogToggle] = useFilePreview(); + const [previewData, setPreviewData] = useState(); const [searchParams] = useSearchParams(); const currentServer = searchParams.get("server"); const nav = useNavigate(); useEffect(() => { if (!currentServer) nav("/"); }, [currentServer]); + + function changePreview(name, fileData) { + setPreviewData({ name, fileData }); + dialogToggle(); + } + return ( - + + ); } diff --git a/src/util/queries.js b/src/util/queries.js index 3db8615..0bb758e 100644 --- a/src/util/queries.js +++ b/src/util/queries.js @@ -50,6 +50,13 @@ export const createServerFolder = async (serverId, path) => export const deleteServerItem = async (serverId, path, isDir) => fetchApiCore("/files/item", { id: serverId, path, isDir }, "DELETE"); +export async function previewServerItem(serverId, path) { + const resp = await fetchApiCore("/files/item", { id: serverId, path }); + if (!resp.status === 200) return console.log("AHHHH"); + const blob = await resp.blob(); + return blob; +} + export const getServerItem = async (serverId, name, path) => fetchApiCore("/files/item", { id: serverId, path }) .then((resp) => From 5f2a94dc14b13927a8ccfdfa748135f2cf1dc897 Mon Sep 17 00:00:00 2001 From: Dunemask Date: Sun, 31 Dec 2023 09:39:06 -0700 Subject: [PATCH 11/17] [FIX] Reinstitutded liveness probe on FTP server --- lib/k8s/configs/containers/ftp-server.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/k8s/configs/containers/ftp-server.yml b/lib/k8s/configs/containers/ftp-server.yml index 038cd00..d902efd 100644 --- a/lib/k8s/configs/containers/ftp-server.yml +++ b/lib/k8s/configs/containers/ftp-server.yml @@ -6,8 +6,8 @@ env: image: garethflowers/ftp-server imagePullPolicy: IfNotPresent livenessProbe: -exec: - command: ["echo"] + exec: + command: ["echo"] failureThreshold: 20 initialDelaySeconds: 5 periodSeconds: 5 From 76b8bf91c99e7c0a0c32169c03b27af3814cdcc5 Mon Sep 17 00:00:00 2001 From: Dunemask Date: Sun, 31 Dec 2023 14:04:44 -0700 Subject: [PATCH 12/17] [FEATURE] RCON Connection Indicator --- lib/controllers/sub-controllers/console-controller.js | 2 +- src/components/servers/RconDialog.jsx | 2 +- src/components/servers/RconSocket.js | 8 ++++++++ src/components/servers/RconView.jsx | 6 +++++- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/lib/controllers/sub-controllers/console-controller.js b/lib/controllers/sub-controllers/console-controller.js index 1bd802a..264146a 100644 --- a/lib/controllers/sub-controllers/console-controller.js +++ b/lib/controllers/sub-controllers/console-controller.js @@ -61,7 +61,7 @@ export async function webConsoleRcon(socket) { try { await rcon.connect(); } catch (error) { - socket.emit("push", "Could not connect RCON Input to server!"); + socket.emit("rcon-error", "Could not connect RCON Input to server!"); WARN("RCON", `Could not connect to '${rconHost}'`); } socket.rconClient = rcon; diff --git a/src/components/servers/RconDialog.jsx b/src/components/servers/RconDialog.jsx index 899c641..579b97b 100644 --- a/src/components/servers/RconDialog.jsx +++ b/src/components/servers/RconDialog.jsx @@ -26,7 +26,7 @@ export default function RconDialog(props) { sx={ fullScreen ? {} - : { "& .mcl-MuiDialog-paper": { width: "80%", maxHeight: 525 } } + : { "& .mcl-MuiDialog-paper": { width: "80%", maxHeight: 555 } } } maxWidth="xs" open={open} diff --git a/src/components/servers/RconSocket.js b/src/components/servers/RconSocket.js index 5648df8..42a1226 100644 --- a/src/components/servers/RconSocket.js +++ b/src/components/servers/RconSocket.js @@ -5,10 +5,13 @@ export default class RconSocket { this.logUpdate = logUpdate; this.sk.on("push", this.onPush.bind(this)); this.sk.on("connect", this.onConnect.bind(this)); + this.sk.on("rcon-error", this.onRconError.bind(this)); this.sk.on("error", () => console.log("WHOOSPSIE I GUESS?")); + this.rconLive = false; } onPush(p) { + this.rconLive = true; this.logs = [...this.logs, p]; this.logUpdate(this.logs); } @@ -17,6 +20,11 @@ export default class RconSocket { this.sk.emit("msg", m); } + onRconError(v) { + this.rconLive = false; + console.log("Server sent" + v); + } + onConnect() { this.sk.readyState = 1; this.logs = []; diff --git a/src/components/servers/RconView.jsx b/src/components/servers/RconView.jsx index 8eb1235..d4c99fd 100644 --- a/src/components/servers/RconView.jsx +++ b/src/components/servers/RconView.jsx @@ -55,8 +55,12 @@ export default function RconView(props) { variant="outlined" value={cmd} onChange={updateCmd} + disabled={!(rcon && rcon.rconLive)} /> - + {rcon && rcon.rconLive && } + {!(rcon && rcon.rconLive) && ( + + )}
); From a5ffe1694eb77230a0fd00358f772d733eeb28d6 Mon Sep 17 00:00:00 2001 From: Dunemask Date: Fri, 5 Jan 2024 15:16:49 -0700 Subject: [PATCH 13/17] [FEATURE] Frontend & Config for backup --- lib/k8s/configs/backup-secret.yml | 11 +++++ .../server-options/BackupBucketOption.jsx | 14 ++++++ .../server-options/BackupHostOption.jsx | 14 ++++++ .../server-options/BackupIdOption.jsx | 14 ++++++ .../server-options/BackupKeyOption.jsx | 14 ++++++ src/pages/CreateCoreOptions.jsx | 48 +++++++++++++++++++ 6 files changed, 115 insertions(+) create mode 100644 lib/k8s/configs/backup-secret.yml create mode 100644 src/components/server-options/BackupBucketOption.jsx create mode 100644 src/components/server-options/BackupHostOption.jsx create mode 100644 src/components/server-options/BackupIdOption.jsx create mode 100644 src/components/server-options/BackupKeyOption.jsx diff --git a/lib/k8s/configs/backup-secret.yml b/lib/k8s/configs/backup-secret.yml new file mode 100644 index 0000000..eae59e3 --- /dev/null +++ b/lib/k8s/configs/backup-secret.yml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Secret +metadata: + annotations: + minecluster.dunemask.net/id: changeme-server-id + labels: + app: changeme-app-label + name: changeme-backup-secret +type: Opaque +data: + rclone.conf: "" diff --git a/src/components/server-options/BackupBucketOption.jsx b/src/components/server-options/BackupBucketOption.jsx new file mode 100644 index 0000000..30a1705 --- /dev/null +++ b/src/components/server-options/BackupBucketOption.jsx @@ -0,0 +1,14 @@ +import TextField from "@mui/material/TextField"; +export default function BackupBucketOption(props) { + const { onChange } = props; + + return ( + + ); +} diff --git a/src/components/server-options/BackupHostOption.jsx b/src/components/server-options/BackupHostOption.jsx new file mode 100644 index 0000000..0730893 --- /dev/null +++ b/src/components/server-options/BackupHostOption.jsx @@ -0,0 +1,14 @@ +import TextField from "@mui/material/TextField"; +export default function BackupHostOption(props) { + const { onChange } = props; + + return ( + + ); +} diff --git a/src/components/server-options/BackupIdOption.jsx b/src/components/server-options/BackupIdOption.jsx new file mode 100644 index 0000000..e36ccff --- /dev/null +++ b/src/components/server-options/BackupIdOption.jsx @@ -0,0 +1,14 @@ +import TextField from "@mui/material/TextField"; +export default function BackupIdOption(props) { + const { onChange } = props; + + return ( + + ); +} diff --git a/src/components/server-options/BackupKeyOption.jsx b/src/components/server-options/BackupKeyOption.jsx new file mode 100644 index 0000000..aa966ff --- /dev/null +++ b/src/components/server-options/BackupKeyOption.jsx @@ -0,0 +1,14 @@ +import TextField from "@mui/material/TextField"; +export default function BackupKeyOption(props) { + const { onChange } = props; + + return ( + + ); +} diff --git a/src/pages/CreateCoreOptions.jsx b/src/pages/CreateCoreOptions.jsx index 747712a..95f1b34 100644 --- a/src/pages/CreateCoreOptions.jsx +++ b/src/pages/CreateCoreOptions.jsx @@ -3,6 +3,9 @@ import { useNavigate } from "react-router-dom"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import FormControl from "@mui/material/FormControl"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import Switch from "@mui/material/Switch"; +import Typography from "@mui/material/Typography"; import { useCreateServer } from "@mcl/queries"; // Core Options @@ -19,6 +22,11 @@ import MemoryOption, { memoryOptions, } from "@mcl/components/server-options/MemoryOption.jsx"; +import BackupHostOption from "@mcl/components/server-options/BackupHostOption.jsx"; +import BackupBucketOption from "@mcl/components/server-options/BackupBucketOption.jsx"; +import BackupIdOption from "@mcl/components/server-options/BackupIdOption.jsx"; +import BackupKeyOption from "@mcl/components/server-options/BackupKeyOption.jsx"; + const defaultServer = { version: "latest", serverType: serverTypeOptions[0], @@ -27,6 +35,7 @@ const defaultServer = { }; export default function CreateCoreOptions() { + const [backupEnabled, setBackupEnabled] = useState(false); const [spec, setSpec] = useState(defaultServer); const nav = useNavigate(); const createServer = useCreateServer(spec); @@ -58,6 +67,8 @@ export default function CreateCoreOptions() { alert(`Could not validate spec because: ${reason}`); } + const toggleBackupEnabled = () => setBackupEnabled(!backupEnabled); + return ( + + } + label="Enable Backups?" + labelPlacement="start" + sx={{ mr: "auto" }} + /> + {backupEnabled && ( + + Backups + + + + + + )} + From b538ab50890fc7711e95a98ae5a73eb5b43d27aa Mon Sep 17 00:00:00 2001 From: Dunemask Date: Thu, 11 Jan 2024 11:21:12 -0700 Subject: [PATCH 14/17] [FEATURE] Initial Backup Interval --- lib/controllers/lifecycle-controller.js | 8 ++++ .../migrations/1_create_servers_table.sql | 5 ++- lib/database/queries/server-queries.js | 24 ++++++++++- .../server-options/BackupIntervalOption.jsx | 42 +++++++++++++++++++ src/pages/CreateCoreOptions.jsx | 2 + 5 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 src/components/server-options/BackupIntervalOption.jsx diff --git a/lib/controllers/lifecycle-controller.js b/lib/controllers/lifecycle-controller.js index d0c4289..861c13e 100644 --- a/lib/controllers/lifecycle-controller.js +++ b/lib/controllers/lifecycle-controller.js @@ -16,12 +16,20 @@ function payloadFilter(req, res) { const serverSpec = req.body; if (!serverSpec) return res.sendStatus(400); const { name, host, version, serverType, memory } = serverSpec; + const { backupHost, backupBucket, backupId, backupKey } = serverSpec; if (!name) return res.status(400).send("Server name is required!"); if (!host) return res.status(400).send("Server host is required!"); if (!dnsRegex.test(host)) return res.status(400).send("Hostname invalid!"); if (!version) return res.status(400).send("Server version is required!"); if (!serverType) return res.status(400).send("Server type is required!"); if (!memory) return res.status(400).send("Memory is required!"); + if (!!backupHost || !!backupBucket || !!backupId || !!backupKey) { + // If any keys are required, all are required + if (!(!!backupHost && !!backupBucket && !!backupId && !!backupKey)) + return res.status(400).send("All backup keys are required!"); + if (!dnsRegex.test(backupHost)) + return res.status(400).send("Backup Host invalid!"); + } return "filtered"; } diff --git a/lib/database/migrations/1_create_servers_table.sql b/lib/database/migrations/1_create_servers_table.sql index efc458b..00bd3b8 100644 --- a/lib/database/migrations/1_create_servers_table.sql +++ b/lib/database/migrations/1_create_servers_table.sql @@ -9,8 +9,9 @@ CREATE TABLE servers ( memory varchar(63) DEFAULT '512', backup_host varchar(255) DEFAULT NULL, backup_bucket_path varchar(255) DEFAULT NULL, - backup_user varchar(255) DEFAULT NULL, - backup_pass varchar(255) DEFAULT NULL, + backup_id varchar(255) DEFAULT NULL, + backup_key varchar(255) DEFAULT NULL, + backup_interval varchar(255) DEFAULT NULL, CONSTRAINT unique_host UNIQUE(host) ); ALTER SEQUENCE servers_id_seq OWNED BY servers.id; \ No newline at end of file diff --git a/lib/database/queries/server-queries.js b/lib/database/queries/server-queries.js index d9e6803..100b0b7 100644 --- a/lib/database/queries/server-queries.js +++ b/lib/database/queries/server-queries.js @@ -10,8 +10,28 @@ const asExpressClientError = (e) => { const getMclName = (host, id) => `${host.replaceAll(".", "-")}-${id}`; export async function createServerEntry(serverSpec) { - const { name, host, version, serverType: server_type, memory } = serverSpec; - var q = insertQuery(table, { name, host, version, server_type, memory }); + const { + name, + host, + version, + serverType: server_type, + memory, + backupHost: backup_host, + backupBucket: backup_bucket_path, + backupId: backup_id, + backupKey: backup_key, + } = serverSpec; + var q = insertQuery(table, { + name, + host, + version, + server_type, + memory, + backup_host, + backup_bucket_path, + backup_id, + backup_key, + }); q += "\n RETURNING *"; try { const entries = await pg.query(q); diff --git a/src/components/server-options/BackupIntervalOption.jsx b/src/components/server-options/BackupIntervalOption.jsx new file mode 100644 index 0000000..0371ea8 --- /dev/null +++ b/src/components/server-options/BackupIntervalOption.jsx @@ -0,0 +1,42 @@ +import { useState } from "react"; +import Box from "@mui/material/Box"; +import MenuItem from "@mui/material/MenuItem"; +import TextField from "@mui/material/TextField"; + +export const backupIntervalStepOptions = ["m", "h", "d"]; +export default function BackupIntervalOption(props) { + const { onChange } = props; + const [interval, setInterval] = useState(1); + const [intervalStep, setIntervalStep] = useState( + backupIntervalStepOptions[2], + ); + + const changeInterval = (e) => console.log(e.target.value); + + return ( + + + + {backupIntervalStepOptions.map((o, i) => ( + + {o} + + ))} + + + ); +} diff --git a/src/pages/CreateCoreOptions.jsx b/src/pages/CreateCoreOptions.jsx index 95f1b34..11ab666 100644 --- a/src/pages/CreateCoreOptions.jsx +++ b/src/pages/CreateCoreOptions.jsx @@ -26,6 +26,7 @@ import BackupHostOption from "@mcl/components/server-options/BackupHostOption.js import BackupBucketOption from "@mcl/components/server-options/BackupBucketOption.jsx"; import BackupIdOption from "@mcl/components/server-options/BackupIdOption.jsx"; import BackupKeyOption from "@mcl/components/server-options/BackupKeyOption.jsx"; +import BackupIntervalOption from "@mcl/components/server-options/BackupIntervalOption.jsx"; const defaultServer = { version: "latest", @@ -118,6 +119,7 @@ export default function CreateCoreOptions() { value={spec.backupKey} onChange={coreUpdate("backupKey")} /> + )} From 7d34fcfce8505b996b3294a9125fee9fa8ea3313 Mon Sep 17 00:00:00 2001 From: Dunemask Date: Sat, 13 Jan 2024 11:58:12 -0700 Subject: [PATCH 15/17] [FEATURE] Backup Database sync --- lib/controllers/lifecycle-controller.js | 21 ++++++++++++++--- lib/database/queries/server-queries.js | 23 ++++++++++++++++++- .../configs/containers/minecraft-backup.yml | 8 +++---- lib/k8s/server-containers.js | 1 + .../server-options/BackupBucketOption.jsx | 3 ++- .../server-options/BackupIntervalOption.jsx | 21 +++++++++++++---- src/pages/CreateCoreOptions.jsx | 17 ++++++++++++-- 7 files changed, 79 insertions(+), 15 deletions(-) diff --git a/lib/controllers/lifecycle-controller.js b/lib/controllers/lifecycle-controller.js index 861c13e..825f63c 100644 --- a/lib/controllers/lifecycle-controller.js +++ b/lib/controllers/lifecycle-controller.js @@ -16,16 +16,31 @@ function payloadFilter(req, res) { const serverSpec = req.body; if (!serverSpec) return res.sendStatus(400); const { name, host, version, serverType, memory } = serverSpec; - const { backupHost, backupBucket, backupId, backupKey } = serverSpec; + const { backupHost, backupBucket, backupId, backupKey, backupInterval } = + serverSpec; if (!name) return res.status(400).send("Server name is required!"); if (!host) return res.status(400).send("Server host is required!"); if (!dnsRegex.test(host)) return res.status(400).send("Hostname invalid!"); if (!version) return res.status(400).send("Server version is required!"); if (!serverType) return res.status(400).send("Server type is required!"); if (!memory) return res.status(400).send("Memory is required!"); - if (!!backupHost || !!backupBucket || !!backupId || !!backupKey) { + if ( + !!backupHost || + !!backupBucket || + !!backupId || + !!backupKey || + !backupInterval + ) { // If any keys are required, all are required - if (!(!!backupHost && !!backupBucket && !!backupId && !!backupKey)) + if ( + !( + !!backupHost && + !!backupBucket && + !!backupId && + !!backupKey && + !!backupInterval + ) + ) return res.status(400).send("All backup keys are required!"); if (!dnsRegex.test(backupHost)) return res.status(400).send("Backup Host invalid!"); diff --git a/lib/database/queries/server-queries.js b/lib/database/queries/server-queries.js index 100b0b7..54a060c 100644 --- a/lib/database/queries/server-queries.js +++ b/lib/database/queries/server-queries.js @@ -20,6 +20,7 @@ export async function createServerEntry(serverSpec) { backupBucket: backup_bucket_path, backupId: backup_id, backupKey: backup_key, + backupInterval: backup_interval, } = serverSpec; var q = insertQuery(table, { name, @@ -31,6 +32,7 @@ export async function createServerEntry(serverSpec) { backup_bucket_path, backup_id, backup_key, + backup_interval, }); q += "\n RETURNING *"; try { @@ -71,9 +73,28 @@ export async function getServerEntry(serverId) { version, server_type: serverType, memory, + backup_host: backupHost, + backup_bucket_path: backupPath, + backup_id: backupId, + backup_key: backupKey, + backup_interval: backupInterval, } = serverSpecs[0]; const mclName = getMclName(host, id); - return { name, mclName, id, host, version, serverType, memory }; + return { + name, + mclName, + id, + host, + version, + serverType, + memory, + backupHost, + backupPath, + backupId, + backupKey, + backupInterval + + }; } catch (e) { asExpressClientError(e); } diff --git a/lib/k8s/configs/containers/minecraft-backup.yml b/lib/k8s/configs/containers/minecraft-backup.yml index 074025a..3caeae8 100644 --- a/lib/k8s/configs/containers/minecraft-backup.yml +++ b/lib/k8s/configs/containers/minecraft-backup.yml @@ -33,20 +33,20 @@ env: - name: DEST_DIR value: /backups - name: LINK_LATEST - value: "false" + value: "true" - name: TAR_COMPRESS_METHOD value: gzip - name: ZSTD_PARAMETERS value: -3 --long=25 --single-thread - name: RCLONE_REMOTE - value: mc-dunemask-net + value: mcl-backup-changeme - name: RCLONE_DEST_DIR - value: /minecraft-backups/deltasmp-backups + value: /mcl/backups/changeme - name: RCLONE_COMPRESS_METHOD value: gzip image: itzg/mc-backup:latest imagePullPolicy: IfNotPresent -name: mcs-deltasmp-minecraft-mc-backup +name: mcl-backup-changeme resources: requests: cpu: 500m diff --git a/lib/k8s/server-containers.js b/lib/k8s/server-containers.js index a71410f..19b4b87 100644 --- a/lib/k8s/server-containers.js +++ b/lib/k8s/server-containers.js @@ -63,5 +63,6 @@ export function getServerContainer(serverSpec) { export function getBackupContainer(serverSpec) { const container = loadYaml("lib/k8s/configs/containers/minecraft-backup.yml"); + console.log(serverSpec); return container; } diff --git a/src/components/server-options/BackupBucketOption.jsx b/src/components/server-options/BackupBucketOption.jsx index 30a1705..bc7139d 100644 --- a/src/components/server-options/BackupBucketOption.jsx +++ b/src/components/server-options/BackupBucketOption.jsx @@ -1,11 +1,12 @@ import TextField from "@mui/material/TextField"; export default function BackupBucketOption(props) { - const { onChange } = props; + const { value, onChange } = props; return ( console.log(e.target.value); + const changeStep = (e) => { + setIntervalStep(e.target.value); + onChange({ target: { value: `${interval}${e.target.value}` } }); + }; + + const changeInterval = (e) => { + setInterval(e.target.value); + onChange({ target: { value: `${e.target.value}${intervalStep}` } }); + }; return ( {backupIntervalStepOptions.map((o, i) => ( - {o} + {backupIntervalStepDisplay[i]} ))} diff --git a/src/pages/CreateCoreOptions.jsx b/src/pages/CreateCoreOptions.jsx index 11ab666..b80b964 100644 --- a/src/pages/CreateCoreOptions.jsx +++ b/src/pages/CreateCoreOptions.jsx @@ -26,7 +26,9 @@ import BackupHostOption from "@mcl/components/server-options/BackupHostOption.js import BackupBucketOption from "@mcl/components/server-options/BackupBucketOption.jsx"; import BackupIdOption from "@mcl/components/server-options/BackupIdOption.jsx"; import BackupKeyOption from "@mcl/components/server-options/BackupKeyOption.jsx"; -import BackupIntervalOption from "@mcl/components/server-options/BackupIntervalOption.jsx"; +import BackupIntervalOption, { + backupIntervalDefault, +} from "@mcl/components/server-options/BackupIntervalOption.jsx"; const defaultServer = { version: "latest", @@ -68,7 +70,18 @@ export default function CreateCoreOptions() { alert(`Could not validate spec because: ${reason}`); } - const toggleBackupEnabled = () => setBackupEnabled(!backupEnabled); + const toggleBackupEnabled = () => { + const s = { ...spec }; + if (!backupEnabled) { + (s.backupInterval = backupIntervalDefault), + (s.backupBucket = `/mcl/server-backups/${( + s.name ?? "my-server" + ).toLowerCase()}`); + } else for (var k in s) if (k.startsWith("backup")) delete s[k]; + setSpec(s); + console.log(s); + setBackupEnabled(!backupEnabled); + }; return ( Date: Sat, 13 Jan 2024 12:03:44 -0700 Subject: [PATCH 16/17] [FIX] Removed excess print --- lib/k8s/server-containers.js | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/k8s/server-containers.js b/lib/k8s/server-containers.js index 19b4b87..a71410f 100644 --- a/lib/k8s/server-containers.js +++ b/lib/k8s/server-containers.js @@ -63,6 +63,5 @@ export function getServerContainer(serverSpec) { export function getBackupContainer(serverSpec) { const container = loadYaml("lib/k8s/configs/containers/minecraft-backup.yml"); - console.log(serverSpec); return container; } From 1a79ea7960ff8c641865ceac6dc4fcbfedce22e9 Mon Sep 17 00:00:00 2001 From: Dunemask Date: Mon, 15 Jan 2024 13:07:13 -0700 Subject: [PATCH 17/17] [FEATURE] Massively increased loading time --- README.md | 3 ++ lib/controllers/lifecycle-controller.js | 3 +- .../migrations/1_create_servers_table.sql | 1 + lib/database/queries/server-queries.js | 28 +++++++++++-- lib/k8s/configs/containers/ftp-server.yml | 26 ++++++------ .../configs/containers/minecraft-server.yml | 26 +++++------- lib/k8s/configs/server-deployment.yml | 14 +++---- lib/k8s/k8s-server-control.js | 37 +++++++++++----- lib/k8s/server-containers.js | 11 +++++ lib/k8s/server-create.js | 42 ++++++++++++++++++- lib/k8s/server-delete.js | 5 +++ lib/k8s/server-status.js | 2 +- src/pages/CreateCoreOptions.jsx | 2 +- 13 files changed, 145 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 3348a0b..ed786b8 100644 --- a/README.md +++ b/README.md @@ -2,3 +2,6 @@ Minecluster or MCL is a web interface used to manage multiple instance of Minecraft Servers in Kubernetes. This app is built to be an all in one for self-hosting Minecraft server. It uses rendered helm charts based on itzg/minecraft-server More info coming soon. + +## ⚠ Warning ⚠ +Development is very active and there is no garuntee for compatability or migration across versions 1/15/24 diff --git a/lib/controllers/lifecycle-controller.js b/lib/controllers/lifecycle-controller.js index 825f63c..96073ed 100644 --- a/lib/controllers/lifecycle-controller.js +++ b/lib/controllers/lifecycle-controller.js @@ -24,12 +24,13 @@ function payloadFilter(req, res) { if (!version) return res.status(400).send("Server version is required!"); if (!serverType) return res.status(400).send("Server type is required!"); if (!memory) return res.status(400).send("Memory is required!"); + // TODO: Impliment non creation time backups if ( !!backupHost || !!backupBucket || !!backupId || !!backupKey || - !backupInterval + !!backupInterval ) { // If any keys are required, all are required if ( diff --git a/lib/database/migrations/1_create_servers_table.sql b/lib/database/migrations/1_create_servers_table.sql index 00bd3b8..2f22691 100644 --- a/lib/database/migrations/1_create_servers_table.sql +++ b/lib/database/migrations/1_create_servers_table.sql @@ -7,6 +7,7 @@ CREATE TABLE servers ( server_type varchar(63) DEFAULT 'VANILLA', cpu varchar(63) DEFAULT '500', memory varchar(63) DEFAULT '512', + backup_enabled BOOLEAN DEFAULT FALSE, backup_host varchar(255) DEFAULT NULL, backup_bucket_path varchar(255) DEFAULT NULL, backup_id varchar(255) DEFAULT NULL, diff --git a/lib/database/queries/server-queries.js b/lib/database/queries/server-queries.js index 54a060c..15bf2ac 100644 --- a/lib/database/queries/server-queries.js +++ b/lib/database/queries/server-queries.js @@ -28,6 +28,7 @@ export async function createServerEntry(serverSpec) { version, server_type, memory, + backup_enabled: !!backup_interval, // We already verified the payload, so any backup key will work backup_host, backup_bucket_path, backup_id, @@ -44,9 +45,29 @@ export async function createServerEntry(serverSpec) { version, server_type: serverType, memory, + backup_enabled: backupEnabled, + backup_host: backupHost, + backup_bucket_path: backupPath, + backup_id: backupId, + backup_key: backupKey, + backup_interval: backupInterval, } = entries[0]; const mclName = getMclName(host, id); - return { name, mclName, id, host, version, serverType, memory }; + return { + name, + mclName, + id, + host, + version, + serverType, + memory, + backupEnabled, + backupHost, + backupPath, + backupId, + backupKey, + backupInterval, + }; } catch (e) { asExpressClientError(e); } @@ -73,6 +94,7 @@ export async function getServerEntry(serverId) { version, server_type: serverType, memory, + backup_enabled: backupEnabled, backup_host: backupHost, backup_bucket_path: backupPath, backup_id: backupId, @@ -88,12 +110,12 @@ export async function getServerEntry(serverId) { version, serverType, memory, + backupEnabled, backupHost, backupPath, backupId, backupKey, - backupInterval - + backupInterval, }; } catch (e) { asExpressClientError(e); diff --git a/lib/k8s/configs/containers/ftp-server.yml b/lib/k8s/configs/containers/ftp-server.yml index d902efd..aade99b 100644 --- a/lib/k8s/configs/containers/ftp-server.yml +++ b/lib/k8s/configs/containers/ftp-server.yml @@ -6,23 +6,21 @@ env: image: garethflowers/ftp-server imagePullPolicy: IfNotPresent livenessProbe: - exec: - command: ["echo"] -failureThreshold: 20 -initialDelaySeconds: 5 -periodSeconds: 5 -successThreshold: 1 -timeoutSeconds: 1 + exec: { command: ["echo"] } + failureThreshold: 20 + initialDelaySeconds: 0 + periodSeconds: 5 + successThreshold: 1 + timeoutSeconds: 1 name: changeme-name-ftp ports: [] # Programatically add all the ports for easier readability, Ports include: 20,21,40000-400009 readinessProbe: - exec: - command: ["echo"] - failureThreshold: 20 - initialDelaySeconds: 5 - periodSeconds: 5 - successThreshold: 1 - timeoutSeconds: 1 + exec: { command: ["echo"] } + failureThreshold: 20 + initialDelaySeconds: 0 + periodSeconds: 5 + successThreshold: 1 + timeoutSeconds: 1 resources: requests: cpu: 50m diff --git a/lib/k8s/configs/containers/minecraft-server.yml b/lib/k8s/configs/containers/minecraft-server.yml index 63efc13..56e9b1b 100644 --- a/lib/k8s/configs/containers/minecraft-server.yml +++ b/lib/k8s/configs/containers/minecraft-server.yml @@ -72,12 +72,10 @@ env: image: itzg/minecraft-server:latest imagePullPolicy: IfNotPresent livenessProbe: - exec: - # command: ["mc-health"] # This is super unsafe... but why not :) - command: ["echo"] - failureThreshold: 20 - initialDelaySeconds: 5 - periodSeconds: 5 + exec: { command: [mc-health] } + failureThreshold: 200 + initialDelaySeconds: 30 + periodSeconds: 3 successThreshold: 1 timeoutSeconds: 1 name: changeme-name-server @@ -88,15 +86,13 @@ ports: - containerPort: 25575 name: rcon protocol: TCP -readinessProbe: - exec: - # command: ["mc-health"] # This is super unsafe... but why not :) - command: ["echo"] - failureThreshold: 20 - initialDelaySeconds: 5 - periodSeconds: 5 - successThreshold: 1 - timeoutSeconds: 1 +# readinessProbe: # Disabling this allows for users to manipulate files even if the container is starting +# exec: {command: [mc-health]} +# failureThreshold: 200 +# initialDelaySeconds: 30 +# periodSeconds: 3 +# successThreshold: 1 +# timeoutSeconds: 1 resources: requests: cpu: 500m diff --git a/lib/k8s/configs/server-deployment.yml b/lib/k8s/configs/server-deployment.yml index aa31a97..275013a 100644 --- a/lib/k8s/configs/server-deployment.yml +++ b/lib/k8s/configs/server-deployment.yml @@ -35,10 +35,10 @@ spec: claimName: changeme-pvc-name - emptyDir: {} name: backupdir - - name: rclone-config - secret: - defaultMode: 420 - items: - - key: rclone.conf - path: rclone.conf - secretName: rclone-config + # - name: rclone-config + # secret: + # defaultMode: 420 + # items: + # - key: rclone.conf + # path: rclone.conf + # secretName: rclone-config diff --git a/lib/k8s/k8s-server-control.js b/lib/k8s/k8s-server-control.js index 40a6788..ec40b90 100644 --- a/lib/k8s/k8s-server-control.js +++ b/lib/k8s/k8s-server-control.js @@ -66,13 +66,18 @@ export function getServerAssets(serverId) { 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!"); + if (secrets.length > 2) throw Error("Secrets broken!"); const serverAssets = { deployment: deployments[0], service: services.find((s) => s.metadata.name.endsWith("-server")), volume: volumes[0], rconService: services.find((s) => s.metadata.name.endsWith("-rcon")), - rconSecret: secrets[0], + rconSecret: secrets.find((s) => + s.metadata.name.endsWith("-rcon-secret"), + ), + backupSecret: secrets.find((s) => + s.metadata.name.endsWith("-backup-secret"), + ), }; for (var k in serverAssets) if (serverAssets[k]) return serverAssets; // If no assets exist, return nothing @@ -96,26 +101,36 @@ export async function getContainers(serverId) { return deployment.spec.template.spec.containers; } -async function containerControl(serverId, deployment, scaleUp) { +async function containerControl(serverSpec, deployment, scaleUp) { const { containers } = deployment.spec.template.spec; const depFtp = containers.find((c) => c.name.endsWith("-ftp")); const depServer = containers.find((c) => c.name.endsWith("-server")); const depBackup = containers.find((c) => c.name.endsWith("-backup")); - const serverSpec = await getServerEntry(serverId); const ftpContainer = depFtp ?? getFtpContainer(serverSpec); const serverContainer = depServer ?? getCoreServerContainer(serverSpec); const backupContainer = depBackup ?? getBackupContainer(serverSpec); - if (scaleUp) return [ftpContainer, serverContainer]; + if (scaleUp && serverSpec.backupEnabled) + return [ftpContainer, serverContainer, backupContainer]; + else if (scaleUp) return [ftpContainer, serverContainer]; return [ftpContainer]; } +export function terminationControl(containers) { + return containers.length > 1 ? 30 /*seconds*/ : 1 /*seconds */; +} + export async function toggleServer(serverId, scaleUp = false) { - const deployment = await getDeployment(serverId); - deployment.spec.template.spec.containers = await containerControl( - serverId, - deployment, - scaleUp, - ); + const [deployment, serverSpec] = await Promise.all([ + getDeployment(serverId), + getServerEntry(serverId), + ]); + const containers = await containerControl(serverSpec, deployment, scaleUp); + const ts = terminationControl(containers); + + // Speed up container termination if not running a server + deployment.spec.template.spec.terminationGracePeriodSeconds = ts; + deployment.spec.template.spec.containers = containers; + return k8sDeps.replaceNamespacedDeployment( deployment.metadata.name, namespace, diff --git a/lib/k8s/server-containers.js b/lib/k8s/server-containers.js index a71410f..0192d2a 100644 --- a/lib/k8s/server-containers.js +++ b/lib/k8s/server-containers.js @@ -62,6 +62,17 @@ export function getServerContainer(serverSpec) { } export function getBackupContainer(serverSpec) { + const { mclName, backupEnabled, backupPath } = serverSpec; const container = loadYaml("lib/k8s/configs/containers/minecraft-backup.yml"); + if (!backupEnabled) return; + const findEnv = (k) => container.env.find(({ name: n }) => n === k); + const updateEnv = (k, v) => (findEnv(k).value = v); + updateEnv("RCLONE_REMOTE", `${mclName}-backup`); + updateEnv("RCLONE_DEST_DIR", backupPath); + container.name = `mcl-${mclName}-backup`; + // RCON + const rs = `mcl-${mclName}-rcon-secret`; + findEnv("RCON_PASSWORD").valueFrom.secretKeyRef.name = rs; + return container; } diff --git a/lib/k8s/server-create.js b/lib/k8s/server-create.js index 3cfd7a3..c1c77ba 100644 --- a/lib/k8s/server-create.js +++ b/lib/k8s/server-create.js @@ -19,6 +19,28 @@ const namespace = process.env.MCL_SERVER_NAMESPACE; const loadYaml = (f) => yaml.load(fs.readFileSync(path.resolve(f), "utf8")); +function createBackupSecret(serverSpec) { + if (!serverSpec.backupEnabled) return; // If backup not defined, don't create RCLONE secret + const { mclName, id, backupId, backupKey, backupHost } = serverSpec; + const backupYaml = loadYaml("lib/k8s/configs/backup-secret.yml"); + backupYaml.metadata.labels.app = `mcl-${mclName}-app`; + backupYaml.metadata.name = `mcl-${mclName}-backup-secret`; + backupYaml.metadata.namespace = namespace; + backupYaml.metadata.annotations["minecluster.dunemask.net/id"] = id; + const rcloneConfig = [ + `[${mclName}-backup]`, + "type = s3", + "provider = Minio", + "env_auth = false", + `access_key_id = ${backupId}`, + `secret_access_key = ${backupKey}`, + `endpoint = ${backupHost}`, + `acl = private`, + ].join("\n"); + backupYaml.data["rclone.conf"] = Buffer.from(rcloneConfig).toString("base64"); + return backupYaml; +} + function createRconSecret(serverSpec) { const { mclName, id } = serverSpec; const rconYaml = loadYaml("lib/k8s/configs/rcon-secret.yml"); @@ -39,12 +61,12 @@ function createServerVolume(serverSpec) { volumeYaml.metadata.name = `mcl-${mclName}-volume`; volumeYaml.metadata.namespace = namespace; volumeYaml.metadata.annotations["minecluster.dunemask.net/id"] = id; - volumeYaml.spec.resources.requests.storage = "1Gi"; // TODO: Changeme + volumeYaml.spec.resources.requests.storage = "5Gi"; // TODO: Changeme return volumeYaml; } function createServerDeploy(serverSpec) { - const { mclName, id } = serverSpec; + const { mclName, id, backupEnabled } = serverSpec; const deployYaml = loadYaml("lib/k8s/configs/server-deployment.yml"); const { metadata } = deployYaml; const serverContainer = getServerContainer(serverSpec); @@ -57,6 +79,8 @@ function createServerDeploy(serverSpec) { metadata.annotations["minecluster.dunemask.net/id"] = id; deployYaml.metadata = metadata; + deployYaml.spec.template.spec.terminationGracePeriodSeconds = 1; + // Configure Lables & Selectors deployYaml.spec.selector.matchLabels.app = `mcl-${mclName}-app`; deployYaml.spec.template.metadata.labels.app = `mcl-${mclName}-app`; @@ -68,6 +92,18 @@ function createServerDeploy(serverSpec) { ({ name }) => name === "datadir", ).persistentVolumeClaim.claimName = `mcl-${mclName}-volume`; + // Backups + if (backupEnabled) { + deployYaml.spec.template.spec.volumes.push({ + name: "rclone-config", + secret: { + defaultMode: 420, + items: [{ key: "rclone.conf", path: "rclone.conf" }], + secretName: `mcl-${mclName}-backup-secret`, + }, + }); + } + // Apply Containers TODO: User control for autostart // deployYaml.spec.template.spec.containers.push(serverContainer); deployYaml.spec.template.spec.containers.push(ftpContainer); @@ -119,12 +155,14 @@ function createRconService(createSpec) { } export default async function createServerResources(createSpec) { + const backupSecret = createBackupSecret(createSpec); const rconSecret = createRconSecret(createSpec); const serverVolume = createServerVolume(createSpec); const serverDeploy = createServerDeploy(createSpec); const serverService = createServerService(createSpec); const rconService = createRconService(createSpec); k8sCore.createNamespacedPersistentVolumeClaim(namespace, serverVolume); + if (!!backupSecret) k8sCore.createNamespacedSecret(namespace, backupSecret); k8sCore.createNamespacedSecret(namespace, rconSecret); k8sCore.createNamespacedService(namespace, serverService); k8sCore.createNamespacedService(namespace, rconService); diff --git a/lib/k8s/server-delete.js b/lib/k8s/server-delete.js index 75df9f6..c47b383 100644 --- a/lib/k8s/server-delete.js +++ b/lib/k8s/server-delete.js @@ -47,6 +47,10 @@ export default async function deleteServerResources(serverSpec) { const deleteRconSecret = deleteOnExist(server.rconSecret, (name) => k8sCore.deleteNamespacedSecret(name, namespace), ); + + const deleteBackupSecret = deleteOnExist(server.backupSecret, (name) => + k8sCore.deleteNamespacedSecret(name, namespace), + ); const deleteVolume = deleteOnExist(server.volume, (name) => k8sCore.deleteNamespacedPersistentVolumeClaim(name, namespace), ); @@ -55,6 +59,7 @@ export default async function deleteServerResources(serverSpec) { deleteService, deleteRconService, deleteRconSecret, + deleteBackupSecret, deleteVolume, ]).catch(deleteError); } diff --git a/lib/k8s/server-status.js b/lib/k8s/server-status.js index c2b59af..6317d8e 100644 --- a/lib/k8s/server-status.js +++ b/lib/k8s/server-status.js @@ -56,7 +56,7 @@ export async function getInstances() { const { ftpAvailable, serverAvailable, services } = getServerStatus(s); metrics = getServerMetrics(podMetricsRes, serverId, serverAvailable); return { - name: entry.name, + name: !!entry ? entry.name : "Unknown", id: serverId, metrics, services, diff --git a/src/pages/CreateCoreOptions.jsx b/src/pages/CreateCoreOptions.jsx index b80b964..ee8b92f 100644 --- a/src/pages/CreateCoreOptions.jsx +++ b/src/pages/CreateCoreOptions.jsx @@ -54,7 +54,7 @@ export default function CreateCoreOptions() { async function upsertSpec() { if (validateSpec() !== "validated") return; createServer(spec) - .then(() => nav("/")) + // .then(() => nav("/")) .catch(alert); }