From 4855bb882c1f7b924612d75e5ecd383b8e34ea76 Mon Sep 17 00:00:00 2001 From: Justin Bosker Date: Mon, 20 Jul 2026 21:54:36 +0200 Subject: [PATCH] chore: stabilize monorepo build and typescript setup --- manager/package.json | 3 +- manager/src/command.js | 15 + manager/src/config.js | 14 + manager/src/index.js | 37 ++ manager/src/logger.js | 47 +++ manager/src/nameGenerator.js | 40 ++ manager/src/persistence/jsonFileStore.js | 35 ++ manager/src/persistence/persistenceStore.js | 2 + manager/src/portManager.js | 68 ++++ manager/src/rabbit.js | 50 +++ manager/src/serverLifecycle.js | 189 +++++++++ manager/src/serverLifecycleManager.js | 41 ++ manager/src/serverManager.js | 102 +++++ manager/src/serverRegistry.js | 135 +++++++ manager/src/serverRegistry.ts | 2 + manager/src/serverStatusManager.js | 51 +++ manager/src/types.js | 2 + manager/src/wrapperManager.js | 167 ++++++++ manager/src/wrapperRegistry.js | 92 +++++ manager/tsconfig.json | 7 +- package-lock.json | 426 ++------------------ shared/package.json | 3 +- shared/tsconfig.json | 11 +- wrapper/package.json | 3 +- wrapper/src/config.js | 25 ++ wrapper/src/index.js | 155 +++++++ wrapper/src/logger.js | 47 +++ wrapper/src/processManager.js | 79 ++++ wrapper/src/queryManager.js | 44 ++ wrapper/src/statusReporter.js | 49 +++ 30 files changed, 1536 insertions(+), 405 deletions(-) create mode 100644 manager/src/command.js create mode 100644 manager/src/config.js create mode 100644 manager/src/index.js create mode 100644 manager/src/logger.js create mode 100644 manager/src/nameGenerator.js create mode 100644 manager/src/persistence/jsonFileStore.js create mode 100644 manager/src/persistence/persistenceStore.js create mode 100644 manager/src/portManager.js create mode 100644 manager/src/rabbit.js create mode 100644 manager/src/serverLifecycle.js create mode 100644 manager/src/serverLifecycleManager.js create mode 100644 manager/src/serverManager.js create mode 100644 manager/src/serverRegistry.js create mode 100644 manager/src/serverStatusManager.js create mode 100644 manager/src/types.js create mode 100644 manager/src/wrapperManager.js create mode 100644 manager/src/wrapperRegistry.js create mode 100644 wrapper/src/config.js create mode 100644 wrapper/src/index.js create mode 100644 wrapper/src/logger.js create mode 100644 wrapper/src/processManager.js create mode 100644 wrapper/src/queryManager.js create mode 100644 wrapper/src/statusReporter.js diff --git a/manager/package.json b/manager/package.json index 0e93b74..d955981 100644 --- a/manager/package.json +++ b/manager/package.json @@ -4,7 +4,8 @@ "description": "", "main": "index.js", "scripts": { - "start": "ts-node src/index.ts" + "start": "ts-node src/index.ts", + "build": "tsc -p tsconfig.json" }, "keywords": [], "author": "", diff --git a/manager/src/command.js b/manager/src/command.js new file mode 100644 index 0000000..37d6097 --- /dev/null +++ b/manager/src/command.js @@ -0,0 +1,15 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.CommandService = void 0; +const logger_1 = require("./logger"); +const logger = (0, logger_1.getLogger)("CommandService"); +class CommandService { + constructor(channel) { + this.channel = channel; + } + async send(queue, command) { + await this.channel.sendToQueue(queue, Buffer.from(JSON.stringify(command))); + logger.debug("Command sent", command); + } +} +exports.CommandService = CommandService; diff --git a/manager/src/config.js b/manager/src/config.js new file mode 100644 index 0000000..934a33d --- /dev/null +++ b/manager/src/config.js @@ -0,0 +1,14 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.QUERY_PORT_START = exports.GAME_PORT_START = exports.DEFAULT_DESIRED_SERVERS = exports.LIFECYCLE_CRASH_TIMEOUT = exports.LIFECYCLE_SCALE_COOLDOWN = exports.LIFECYCLE_SCALE_DOWN_THRESHOLD = exports.LIFECYCLE_MIN_UPTIME = exports.LIFECYCLE_STARTUP_GRACE_PERIOD = exports.LIFECYCLE_EMPTY_TIMEOUT = exports.LIFECYCLE_CHECK_INTERVAL = exports.RABBITMQ_URL = void 0; +exports.RABBITMQ_URL = "amqp://manager:gigi1337@192.168.1.74"; +exports.LIFECYCLE_CHECK_INTERVAL = 10000; +exports.LIFECYCLE_EMPTY_TIMEOUT = 300000; +exports.LIFECYCLE_STARTUP_GRACE_PERIOD = 60000; +exports.LIFECYCLE_MIN_UPTIME = 300000; +exports.LIFECYCLE_SCALE_DOWN_THRESHOLD = 0.30; +exports.LIFECYCLE_SCALE_COOLDOWN = 60000; +exports.LIFECYCLE_CRASH_TIMEOUT = 30000; +exports.DEFAULT_DESIRED_SERVERS = 1; +exports.GAME_PORT_START = 7777; +exports.QUERY_PORT_START = 27015; diff --git a/manager/src/index.js b/manager/src/index.js new file mode 100644 index 0000000..da0118d --- /dev/null +++ b/manager/src/index.js @@ -0,0 +1,37 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const rabbit_1 = require("./rabbit"); +const wrapperManager_1 = require("./wrapperManager"); +const wrapperRegistry_1 = require("./wrapperRegistry"); +const serverRegistry_1 = require("./serverRegistry"); +const serverManager_1 = require("./serverManager"); +const portManager_1 = require("./portManager"); +const serverStatusManager_1 = require("./serverStatusManager"); +const serverLifecycle_1 = require("./serverLifecycle"); +const logger_1 = require("./logger"); +const logger = (0, logger_1.getLogger)("Index"); +const wrapperRegistry = new wrapperRegistry_1.WrapperRegistry(); +const serverRegistry = new serverRegistry_1.ServerRegistry(); +const portManager = new portManager_1.PortManager(); +async function main() { + const rabbit = new rabbit_1.Rabbit(); + await rabbit.connect(); + logger.info("RabbitMQ-Verbindung hergestellt"); + await portManager.init(); + logger.info("Persistenz initialisiert"); + await wrapperRegistry.init(); + logger.debug("WrapperRegistry initialisiert"); + await serverRegistry.init(); + logger.debug("ServerRegistry initialisiert"); + const wrapperManager = new wrapperManager_1.WrapperManager(rabbit, wrapperRegistry, serverRegistry); + await wrapperManager.start(); + logger.info("WrapperManager erfolgreich gestartet"); + const serverManager = new serverManager_1.ServerManager(rabbit, wrapperRegistry, serverRegistry, portManager); + const lifecycle = new serverLifecycle_1.ServerLifecycle(serverRegistry, serverManager); + lifecycle.start(); + logger.debug("ServerLifecycle gestartet"); + const statusManager = new serverStatusManager_1.ServerStatusManager(rabbit, serverRegistry); + await statusManager.start(); + logger.info("Manager gestartet"); +} +main(); diff --git a/manager/src/logger.js b/manager/src/logger.js new file mode 100644 index 0000000..c408656 --- /dev/null +++ b/manager/src/logger.js @@ -0,0 +1,47 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getLogger = getLogger; +const LEVELS = { + DEBUG: 0, + INFO: 1, + WARN: 2, + ERROR: 3, +}; +const DEFAULT_LOG_LEVEL = "INFO"; +const configuredLevel = (process.env.LOG_LEVEL ?? "").toUpperCase(); +const currentLogLevel = configuredLevel in LEVELS ? configuredLevel : DEFAULT_LOG_LEVEL; +const currentLogLevelValue = LEVELS[currentLogLevel]; +function shouldLog(level) { + return LEVELS[level] >= currentLogLevelValue; +} +function formatMessage(level, component, message) { + return `${new Date().toISOString()} ${level} [${component}] ${message}`; +} +class Logger { + constructor(component) { + this.component = component; + } + debug(message, ...args) { + if (!shouldLog("DEBUG")) + return; + console.log(formatMessage("DEBUG", this.component, message), ...args); + } + info(message, ...args) { + if (!shouldLog("INFO")) + return; + console.log(formatMessage("INFO", this.component, message), ...args); + } + warn(message, ...args) { + if (!shouldLog("WARN")) + return; + console.warn(formatMessage("WARN", this.component, message), ...args); + } + error(message, ...args) { + if (!shouldLog("ERROR")) + return; + console.error(formatMessage("ERROR", this.component, message), ...args); + } +} +function getLogger(component) { + return new Logger(component); +} diff --git a/manager/src/nameGenerator.js b/manager/src/nameGenerator.js new file mode 100644 index 0000000..b611e4b --- /dev/null +++ b/manager/src/nameGenerator.js @@ -0,0 +1,40 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.generateServerName = generateServerName; +const locations = [ + "Crimson", + "Azure", + "Emerald", + "Amber", + "Sapphire", + "Ruby", + "Obsidian", + "Pearl", + "Onyx", + "Ivory", + "Copper", + "Platinum", + "Titanium", + "Neon", + "Shadow", + "Frost", + "Solar", + "Void", + "Storm", + "Ocean" +]; +function generateServerName(existingNames) { + for (const location of locations) { + const name = "[EU] druecktieR | " + location; + if (!existingNames.includes(name)) { + return name; + } + } + /* + Falls alle Namen vergeben sind + */ + return ("[EU] druecktieR | " + + Math.random() + .toString(36) + .substring(2, 6)); +} diff --git a/manager/src/persistence/jsonFileStore.js b/manager/src/persistence/jsonFileStore.js new file mode 100644 index 0000000..4e1dd8b --- /dev/null +++ b/manager/src/persistence/jsonFileStore.js @@ -0,0 +1,35 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.JsonFileStore = void 0; +const fs_1 = require("fs"); +const logger_1 = require("../logger"); +const logger = (0, logger_1.getLogger)("JsonFileStore"); +class JsonFileStore { + constructor(filePath) { + this.filePath = filePath; + } + async load() { + try { + const fileContents = await fs_1.promises.readFile(this.filePath, "utf-8"); + return JSON.parse(fileContents); + } + catch (error) { + if (error.code === "ENOENT") { + return null; + } + logger.error(`Fehler beim Laden der Persistenzdatei ${this.filePath}:`, error); + throw error; + } + } + async save(data) { + try { + const fileContents = JSON.stringify(data, null, 4); + await fs_1.promises.writeFile(this.filePath, fileContents, "utf-8"); + } + catch (error) { + logger.error(`Fehler beim Speichern der Persistenzdatei ${this.filePath}:`, error); + throw error; + } + } +} +exports.JsonFileStore = JsonFileStore; diff --git a/manager/src/persistence/persistenceStore.js b/manager/src/persistence/persistenceStore.js new file mode 100644 index 0000000..c8ad2e5 --- /dev/null +++ b/manager/src/persistence/persistenceStore.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/manager/src/portManager.js b/manager/src/portManager.js new file mode 100644 index 0000000..27288d0 --- /dev/null +++ b/manager/src/portManager.js @@ -0,0 +1,68 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.PortManager = void 0; +const config_1 = require("./config"); +const jsonFileStore_1 = require("./persistence/jsonFileStore"); +const logger_1 = require("./logger"); +const logger = (0, logger_1.getLogger)("PortManager"); +class PortManager { + constructor() { + this.usedPorts = new Set(); + this.usedQueryPorts = new Set(); + this.gamePortStart = config_1.GAME_PORT_START; + this.queryPortStart = config_1.QUERY_PORT_START; + this.persistence = new jsonFileStore_1.JsonFileStore("portManager-state.json"); + } + async init() { + const state = await this.persistence.load(); + if (!state) + return; + this.usedPorts = + new Set(state.usedPorts); + this.usedQueryPorts = + new Set(state.usedQueryPorts); + } + allocate() { + let port = this.gamePortStart; + while (this.usedPorts.has(port)) { + port++; + } + let queryPort = this.queryPortStart; + while (this.usedQueryPorts.has(queryPort)) { + queryPort++; + } + this.usedPorts.add(port); + this.usedQueryPorts.add(queryPort); + logger.debug(`Port erfolgreich reserviert: ${port}`); + logger.debug(`Query-Port erfolgreich reserviert: ${queryPort}`); + this.saveState(); + return { + port, + queryPort + }; + } + release(port, queryPort) { + const removedPort = this.usedPorts.delete(port); + const removedQueryPort = this.usedQueryPorts.delete(queryPort); + if (!removedPort || !removedQueryPort) { + logger.warn(`Unerwarteter Port-Zustand während der Freigabe: port=${port}, queryPort=${queryPort}`); + } + else { + logger.debug(`Port wieder freigegeben: ${port}`); + logger.debug(`Query-Port wieder freigegeben: ${queryPort}`); + } + this.saveState(); + } + saveState() { + const state = { + usedPorts: Array.from(this.usedPorts), + usedQueryPorts: Array.from(this.usedQueryPorts) + }; + this.persistence + .save(state) + .catch(error => { + logger.error("Fehler beim Speichern des PortManager-Zustands:", error); + }); + } +} +exports.PortManager = PortManager; diff --git a/manager/src/rabbit.js b/manager/src/rabbit.js new file mode 100644 index 0000000..e3dbf23 --- /dev/null +++ b/manager/src/rabbit.js @@ -0,0 +1,50 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Rabbit = void 0; +const amqp = __importStar(require("amqplib")); +const config_1 = require("./config"); +const logger_1 = require("./logger"); +const logger = (0, logger_1.getLogger)("Rabbit"); +class Rabbit { + async connect() { + this.connection = + await amqp.connect(config_1.RABBITMQ_URL); + this.channel = + await this.connection.createChannel(); + logger.info("Connected to RabbitMQ"); + } +} +exports.Rabbit = Rabbit; diff --git a/manager/src/serverLifecycle.js b/manager/src/serverLifecycle.js new file mode 100644 index 0000000..3976407 --- /dev/null +++ b/manager/src/serverLifecycle.js @@ -0,0 +1,189 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ServerLifecycle = void 0; +const logger_1 = require("./logger"); +const config_1 = require("./config"); +const logger = (0, logger_1.getLogger)("ServerLifecycle"); +class ServerLifecycle { + constructor(serverRegistry, serverManager, desiredServers = config_1.DEFAULT_DESIRED_SERVERS) { + this.serverRegistry = serverRegistry; + this.serverManager = serverManager; + this.desiredServers = desiredServers; + this.creatingServers = 0; + this.emptyTimeout = config_1.LIFECYCLE_EMPTY_TIMEOUT; + this.lastScaleUp = 0; + this.scaleCooldown = config_1.LIFECYCLE_SCALE_COOLDOWN; + } + start() { + logger.info("Server Lifecycle gestartet"); + setInterval(() => { + this.checkServers(); + }, config_1.LIFECYCLE_CHECK_INTERVAL); + } + setDesiredServers(count) { + logger.debug("Neue Soll Server Anzahl:", count); + this.desiredServers = + count; + } + checkServers() { + const servers = this.serverRegistry.getAll(); + /* + STATUS AKTUALISIEREN + */ + for (const server of servers) { + switch (server.status) { + case "STARTING": + if (server.pid) { + server.status = + "RUNNING"; + server.startedAt = + Date.now(); + server.emptySince = + undefined; + this.serverRegistry.update(server); + logger.info("Server RUNNING:", server.name, "PID:", server.pid); + } + break; + case "RUNNING": + /* + Crash Detection + */ + if (Date.now() - + server.lastSeen + > + config_1.LIFECYCLE_CRASH_TIMEOUT) { + server.status = + "CRASHED"; + this.serverRegistry.update(server); + logger.error("Server abgestürzt:", server.name); + break; + } + /* + Leere Server tracken + */ + if (server.players === 0) { + if (!server.emptySince) { + if (server.startedAt && + Date.now() - + server.startedAt < + config_1.LIFECYCLE_STARTUP_GRACE_PERIOD) { + break; + } + server.emptySince = + Date.now(); + this.serverRegistry.update(server); + logger.debug("Server leer:", server.name); + } + } + else { + if (server.emptySince) { + logger.debug("Server wieder aktiv:", server.name); + } + server.emptySince = + undefined; + this.serverRegistry.update(server); + } + break; + case "CRASHED": + logger.error("Server abgestürzt erkannt:", server.name); + break; + } + } + /* + AKTIVE SERVER + */ + const activeServers = this.serverRegistry + .getAll() + .filter(server => server.status === "RUNNING" || + server.status === "STARTING"); + /* + SLOT AUSLASTUNG + */ + const runningServers = activeServers.filter(server => server.status === "RUNNING"); + const totalPlayers = runningServers.reduce((sum, server) => sum + server.players, 0); + const totalSlots = runningServers.reduce((sum, server) => sum + server.maxPlayers, 0); + const usage = totalSlots > 0 + ? + totalPlayers / totalSlots + : + 0; + logger.debug("Kapazität:", `${totalPlayers}/${totalSlots}`, `${(usage * 100).toFixed(1)}%`); + /* + MINDEST SERVER ANZAHL + */ + const totalActive = activeServers.length + + this.creatingServers; + if (totalActive < + this.desiredServers) { + const missing = this.desiredServers - + totalActive; + this.startServers(missing); + } + /* + SCALE UP BEI 90% + */ + const shouldScale = runningServers.length > 0 && + this.creatingServers === 0 && + usage >= 0.90 && + Date.now() - + this.lastScaleUp + > + this.scaleCooldown; + if (shouldScale) { + logger.info("Auslastung hoch - starte zusätzlichen Server"); + this.lastScaleUp = + Date.now(); + this.startServers(1); + } + /* + LEERE SERVER ENTFERNEN + */ + const removableServers = activeServers + .filter(server => server.status === "RUNNING" && + server.players === 0 && + server.emptySince && + server.startedAt && + Date.now() - + server.startedAt + >= + config_1.LIFECYCLE_MIN_UPTIME && + Date.now() - + server.emptySince + > + this.emptyTimeout && + usage < + config_1.LIFECYCLE_SCALE_DOWN_THRESHOLD) + .sort((a, b) => (a.emptySince ?? 0) - + (b.emptySince ?? 0)); + for (const server of removableServers) { + const activeCount = this.serverRegistry + .getAll() + .filter(s => s.status === "RUNNING" || + s.status === "STARTING") + .length; + if (activeCount <= + this.desiredServers) { + break; + } + logger.info("Stoppe leeren Server:", server.name); + logger.info("Stoppe leeren Server:", server.name); + this.serverManager.stopServer(server.id); + } + } + startServers(count) { + logger.info("Starte Server:", count); + for (let i = 0; i < count; i++) { + this.creatingServers++; + this.serverManager + .createServer() + .then(() => { + this.creatingServers--; + }) + .catch(err => { + this.creatingServers--; + logger.error("Server Erstellung fehlgeschlagen:", err); + }); + } + } +} +exports.ServerLifecycle = ServerLifecycle; diff --git a/manager/src/serverLifecycleManager.js b/manager/src/serverLifecycleManager.js new file mode 100644 index 0000000..0201f01 --- /dev/null +++ b/manager/src/serverLifecycleManager.js @@ -0,0 +1,41 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ServerLifecycleManager = void 0; +class ServerLifecycleManager { + constructor(serverRegistry, rabbit) { + this.serverRegistry = serverRegistry; + this.rabbit = rabbit; + } + start() { + console.log("Server Lifecycle Manager gestartet"); + setInterval(() => { + this.checkServers(); + }, 5000); + } + checkServers() { + const servers = this.serverRegistry.getAll(); + for (const server of servers) { + switch (server.status) { + case "STARTING": + this.checkStarting(server); + break; + case "RUNNING": + this.checkRunning(server); + break; + case "CRASHED": + this.checkOffline(server); + break; + } + } + } + checkStarting(server) { + // kommt später + } + checkRunning(server) { + // kommt später + } + checkOffline(server) { + // kommt später + } +} +exports.ServerLifecycleManager = ServerLifecycleManager; diff --git a/manager/src/serverManager.js b/manager/src/serverManager.js new file mode 100644 index 0000000..3a40099 --- /dev/null +++ b/manager/src/serverManager.js @@ -0,0 +1,102 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ServerManager = void 0; +const logger_1 = require("./logger"); +const crypto_1 = require("crypto"); +const nameGenerator_1 = require("./nameGenerator"); +const logger = (0, logger_1.getLogger)("ServerManager"); +class ServerManager { + constructor(rabbit, wrapperRegistry, serverRegistry, portManager) { + this.rabbit = rabbit; + this.wrapperRegistry = wrapperRegistry; + this.serverRegistry = serverRegistry; + this.portManager = portManager; + } + async createServer() { + /* + freien Wrapper suchen + */ + const wrapper = this.findBestWrapper(); + if (!wrapper) { + logger.warn("Kein Wrapper verfügbar"); + return; + } + const serverId = "srv-" + + (0, crypto_1.randomUUID)() + .slice(0, 8); + const serverName = (0, nameGenerator_1.generateServerName)(this.serverRegistry + .getAll() + .map(server => server.name)); + /* + Ports reservieren + */ + const ports = this.portManager.allocate(); + const server = { + id: serverId, + name: serverName, + wrapperId: wrapper.id, + status: "STARTING", + port: ports.port, + queryPort: ports.queryPort, + players: 0, + maxPlayers: 21, + created: Date.now(), + lastSeen: Date.now() + }; + /* + Registry Eintrag + */ + this.serverRegistry.add(server); + /* + Command an Wrapper + */ + const command = { + type: "server.start", + serverId, + name: serverName, + port: ports.port, + queryPort: ports.queryPort + }; + const sent = this.rabbit.channel.sendToQueue(wrapper.commandQueue, Buffer.from(JSON.stringify(command))); + if (!sent) { + throw new Error("Server Start Command konnte nicht gesendet werden"); + } + logger.info("Starting server:", server.id); + } + async stopServer(serverId) { + const server = this.serverRegistry.get(serverId); + if (!server) { + logger.warn("Server not found:", serverId); + return; + } + const wrapper = this.wrapperRegistry.get(server.wrapperId); + if (!wrapper) { + logger.warn("Wrapper not found:", server.wrapperId); + return; + } + const command = { + type: "server.stop", + serverId + }; + this.rabbit.channel.sendToQueue(wrapper.commandQueue, Buffer.from(JSON.stringify(command))); + server.status = + "STOPPING"; + this.serverRegistry.update(server); + logger.info("Stopping server:", serverId); + } + findBestWrapper() { + const wrappers = this.wrapperRegistry.getAll() + .filter(w => w.online); + if (wrappers.length === 0) + return undefined; + /* + Einfachste Version: + + wenigste Server + */ + wrappers.sort((a, b) => a.servers.length - + b.servers.length); + return wrappers[0]; + } +} +exports.ServerManager = ServerManager; diff --git a/manager/src/serverRegistry.js b/manager/src/serverRegistry.js new file mode 100644 index 0000000..e42b81f --- /dev/null +++ b/manager/src/serverRegistry.js @@ -0,0 +1,135 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ServerRegistry = void 0; +const logger_1 = require("./logger"); +const jsonFileStore_1 = require("./persistence/jsonFileStore"); +const logger = (0, logger_1.getLogger)("ServerRegistry"); +class ServerRegistry { + constructor() { + this.servers = new Map(); + this.persistence = new jsonFileStore_1.JsonFileStore("serverRegistry-state.json"); + } + async init() { + const state = await this.persistence.load(); + if (!state) + return; + for (const serverData of state.servers) { + this.servers.set(serverData.id, { + id: serverData.id, + name: serverData.name, + wrapperId: serverData.wrapperId, + port: serverData.port, + queryPort: serverData.queryPort, + created: serverData.created, + status: "STARTING", + players: 0, + maxPlayers: 0, + lastSeen: Date.now() + }); + } + } + toState() { + return { + servers: Array.from(this.servers.values()).map(server => ({ + id: server.id, + name: server.name, + wrapperId: server.wrapperId, + port: server.port, + queryPort: server.queryPort, + created: server.created + })) + }; + } + async saveState() { + await this.persistence.save(this.toState()); + } + persist() { + void this.saveState(); + } + add(server) { + this.servers.set(server.id, server); + logger.info("Server added:", server.id); + this.persist(); + } + get(id) { + return this.servers.get(id); + } + getAll() { + return Array.from(this.servers.values()); + } + update(server) { + this.servers.set(server.id, server); + this.persist(); + } + remove(id) { + this.servers.delete(id); + logger.info("Server removed:", id); + this.persist(); + } + list() { + logger.info("Server list output"); + const servers = this.getAll(); + if (servers.length === 0) { + logger.info("Keine Server vorhanden"); + return; + } + for (const server of servers) { + logger.info({ + id: server.id, + name: server.name, + wrapper: server.wrapperId, + status: server.status, + port: server.port, + queryPort: server.queryPort, + players: `${server.players}/${server.maxPlayers}` + }); + } + } + syncWrapperStatus(data) { + if (!data.servers) + return; + const runningServerIds = new Set(data.servers.map(srv => srv.id)); + for (const server of this.servers.values()) { + if (server.wrapperId === data.wrapperId && + !runningServerIds.has(server.id)) { + if (server.status === "STOPPING") { + logger.debug("Server cleanly stopped:", server.name); + this.remove(server.id); + } + else if (server.status === "RUNNING") { + server.status = + "CRASHED"; + this.servers.set(server.id, server); + logger.warn("Server crashed:", server.name); + } + } + } + for (const srv of data.servers) { + const server = this.servers.get(srv.id); + if (!server) + continue; + server.pid = + srv.pid; + server.players = + srv.players ?? 0; + server.maxPlayers = + srv.maxPlayers ?? 0; + server.lastSeen = + Date.now(); + const oldStatus = server.status; + if (srv.alive) { + server.status = + "RUNNING"; + } + else if (server.status === "RUNNING") { + server.status = + "CRASHED"; + } + if (oldStatus !== server.status) { + logger.debug("Status change:", server.name, oldStatus, "->", server.status); + } + this.servers.set(server.id, server); + } + } +} +exports.ServerRegistry = ServerRegistry; diff --git a/manager/src/serverRegistry.ts b/manager/src/serverRegistry.ts index 545687c..bd07c52 100644 --- a/manager/src/serverRegistry.ts +++ b/manager/src/serverRegistry.ts @@ -25,6 +25,8 @@ export interface ServerInfo { queryPort:number; + created:number; + pid?:number; diff --git a/manager/src/serverStatusManager.js b/manager/src/serverStatusManager.js new file mode 100644 index 0000000..16fecc9 --- /dev/null +++ b/manager/src/serverStatusManager.js @@ -0,0 +1,51 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ServerStatusManager = void 0; +const logger_1 = require("./logger"); +const logger = (0, logger_1.getLogger)("ServerStatusManager"); +class ServerStatusManager { + constructor(rabbit, registry) { + this.rabbit = rabbit; + this.registry = registry; + } + async start() { + const queue = "server.status"; + await this.rabbit.channel.assertQueue(queue); + logger.info("Warte auf Server Status..."); + this.rabbit.channel.consume(queue, msg => { + if (!msg) + return; + const data = JSON.parse(msg.content.toString()); + switch (data.type) { + case "server.started": + this.serverStarted(data); + break; + case "server.stopped": + this.serverStopped(data); + break; + default: + logger.warn("Unbekannter Server Status:", data); + } + this.rabbit.channel.ack(msg); + }); + } + serverStarted(data) { + const server = this.registry.get(data.serverId); + if (!server) + return; + server.status = + "RUNNING"; + this.registry.update(server); + logger.info("Server started:", data.serverId); + } + serverStopped(data) { + const server = this.registry.get(data.serverId); + if (!server) + return; + server.status = + "CRASHED"; + this.registry.update(server); + logger.info("Server stopped:", data.serverId); + } +} +exports.ServerStatusManager = ServerStatusManager; diff --git a/manager/src/types.js b/manager/src/types.js new file mode 100644 index 0000000..c8ad2e5 --- /dev/null +++ b/manager/src/types.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/manager/src/wrapperManager.js b/manager/src/wrapperManager.js new file mode 100644 index 0000000..40c4ac0 --- /dev/null +++ b/manager/src/wrapperManager.js @@ -0,0 +1,167 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.WrapperManager = void 0; +const logger_1 = require("./logger"); +const crypto_1 = require("crypto"); +const logger = (0, logger_1.getLogger)("WrapperManager"); +class WrapperManager { + constructor(rabbit, registry, serverRegistry) { + this.rabbit = rabbit; + this.registry = registry; + this.serverRegistry = serverRegistry; + } + async start() { + const queue = "wrapper.register"; + await this.rabbit.channel.assertQueue(queue); + logger.info("Warte auf Wrapper..."); + this.rabbit.channel.consume(queue, async (msg) => { + if (!msg) + return; + const data = JSON.parse(msg.content.toString()); + switch (data.type) { + case "wrapper.register": + await this.register(data); + break; + case "wrapper.heartbeat": + this.heartbeat(data); + break; + case "wrapper.status": + logger.debug("Status update received from wrapper-id", data.wrapperId); + this.registry.updateStatus(data); + this.serverRegistry.syncWrapperStatus(data); + break; + default: + logger.warn("Unbekannte Nachricht:", data); + } + this.rabbit.channel.ack(msg); + }); + // Offline Check + setInterval(() => { + this.registry.checkOffline(30000); + }, 5000); + } + async register(data) { + let id = data.wrapperId; + /* + Neue Wrapper bekommen eine ID + */ + if (!id) { + id = + "wrp-" + + (0, crypto_1.randomUUID)() + .slice(0, 8); + logger.debug("Neue Wrapper ID:", id); + } + const existing = this.registry.get(id); + /* + Wrapper war schon bekannt + */ + if (existing) { + existing.online = true; + existing.lastHeartbeat = + Date.now(); + existing.hostname = + data.hostname; + existing.cpu = + data.cpu; + existing.ram = + data.ram; + existing.ramFree = + data.ram; + // servers are updated via wrapper.status, not wrapper.register + this.registry.update(existing); + } + /* + Neuer Wrapper + */ + else { + const commandQueue = `wrapper.${id}.commands`; + await this.rabbit.channel.assertQueue(commandQueue); + const wrapper = { + id, + hostname: data.hostname, + commandQueue, + cpu: data.cpu, + ram: data.ram, + ramFree: data.ram, + servers: [], + online: true, + lastHeartbeat: Date.now() + }; + this.registry.add(wrapper); + } + /* + Antwort zurück an Wrapper + */ + const wrapper = this.registry.get(id); + if (data.replyQueue) { + this.rabbit.channel.sendToQueue(data.replyQueue, Buffer.from(JSON.stringify({ + type: "wrapper.registered", + wrapperId: id, + commandQueue: wrapper?.commandQueue + }))); + } + } + heartbeat(data) { + this.registry.updateHeartbeat(data.wrapperId); + logger.debug("Heartbeat:", data.wrapperId); + } + sendCommand(wrapperId, command) { + const wrapper = this.registry.get(wrapperId); + if (!wrapper) { + throw new Error("Wrapper nicht gefunden"); + } + this.rabbit.channel.sendToQueue(wrapper.commandQueue, Buffer.from(JSON.stringify(command))); + } + getWrappers() { + return this.registry.getAll(); + } + listWrappers() { + const wrappers = this.registry.getAll(); + logger.info("Wrapper Übersicht ausgegeben"); + if (wrappers.length === 0) { + logger.info("Keine Wrapper registriert"); + return; + } + for (const wrapper of wrappers) { + logger.info(` + +ID: +${wrapper.id} + + +Host: +${wrapper.hostname} + + +CPU: +${wrapper.cpu} Cores + + +RAM: +${wrapper.ram} MB + + +RAM frei: +${wrapper.ramFree} MB + + +Server: +${wrapper.servers.length} + + +Online: +${wrapper.online} + + +Heartbeat: +${new Date(wrapper.lastHeartbeat).toLocaleString()} + + +---------------------- + +`); + } + } +} +exports.WrapperManager = WrapperManager; diff --git a/manager/src/wrapperRegistry.js b/manager/src/wrapperRegistry.js new file mode 100644 index 0000000..fe7ad0e --- /dev/null +++ b/manager/src/wrapperRegistry.js @@ -0,0 +1,92 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.WrapperRegistry = void 0; +const logger_1 = require("./logger"); +const jsonFileStore_1 = require("./persistence/jsonFileStore"); +const logger = (0, logger_1.getLogger)("WrapperRegistry"); +class WrapperRegistry { + constructor() { + this.wrappers = new Map(); + this.persistence = new jsonFileStore_1.JsonFileStore("wrapperRegistry-state.json"); + } + async init() { + const state = await this.persistence.load(); + if (!state) + return; + for (const wrapperData of state.wrappers) { + this.wrappers.set(wrapperData.id, { + id: wrapperData.id, + hostname: wrapperData.hostname, + commandQueue: wrapperData.commandQueue, + cpu: wrapperData.cpu, + ram: wrapperData.ram, + ramFree: wrapperData.ram, + online: false, + lastHeartbeat: 0, + servers: [] + }); + } + } + toState() { + return { + wrappers: Array.from(this.wrappers.values()).map(wrapper => ({ + id: wrapper.id, + hostname: wrapper.hostname, + commandQueue: wrapper.commandQueue, + cpu: wrapper.cpu, + ram: wrapper.ram + })) + }; + } + async saveState() { + await this.persistence.save(this.toState()); + } + add(wrapper) { + this.wrappers.set(wrapper.id, wrapper); + logger.info("Wrapper added:", wrapper.id); + void this.saveState(); + } + get(id) { + return this.wrappers.get(id); + } + getAll() { + return Array.from(this.wrappers.values()); + } + updateHeartbeat(id) { + const wrapper = this.wrappers.get(id); + if (!wrapper) + return; + wrapper.lastHeartbeat = + Date.now(); + wrapper.online = + true; + } + update(wrapper) { + this.wrappers.set(wrapper.id, wrapper); + void this.saveState(); + } + checkOffline(timeout) { + const now = Date.now(); + for (const wrapper of this.wrappers.values()) { + if (wrapper.online && + now - wrapper.lastHeartbeat > timeout) { + wrapper.online = false; + logger.warn("Wrapper offline:", wrapper.id); + } + } + } + updateStatus(data) { + const wrapper = this.wrappers.get(data.wrapperId); + if (!wrapper) + return; + wrapper.ramFree = + data.ramFree; + wrapper.servers = + data.servers; + wrapper.lastHeartbeat = + Date.now(); + wrapper.online = + true; + } +} +exports.WrapperRegistry = WrapperRegistry; diff --git a/manager/tsconfig.json b/manager/tsconfig.json index 9621dbb..511552f 100644 --- a/manager/tsconfig.json +++ b/manager/tsconfig.json @@ -5,6 +5,9 @@ "moduleResolution": "node", "esModuleInterop": true, "strict": true, - "skipLibCheck": true + "skipLibCheck": true, + "types": [ + "node" + ] } -} +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7150c81..a43cd7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -86,14 +86,6 @@ "@types/node": "*" } }, - "manager/node_modules/@types/node": { - "version": "26.1.1", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~8.3.0" - } - }, "manager/node_modules/acorn": { "version": "8.17.0", "dev": true, @@ -200,11 +192,6 @@ "node": ">=14.17" } }, - "manager/node_modules/undici-types": { - "version": "8.3.0", - "dev": true, - "license": "MIT" - }, "manager/node_modules/v8-compile-cache-lib": { "version": "3.0.1", "dev": true, @@ -218,344 +205,14 @@ "node": ">=6" } }, - "node_modules/@typescript/typescript-aix-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", - "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", - "cpu": [ - "ppc64" - ], + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", - "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", - "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", - "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", - "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", - "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", - "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-loong64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", - "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-mips64el": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", - "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", - "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-riscv64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", - "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-s390x": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", - "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", - "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", - "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", - "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-openbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", - "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-openbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", - "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-sunos-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", - "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-win32-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", - "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-win32-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", - "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" } }, "node_modules/manager": { @@ -566,40 +223,12 @@ "resolved": "shared", "link": true }, - "node_modules/typescript": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", - "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc" - }, - "engines": { - "node": ">=16.20.0" - }, - "optionalDependencies": { - "@typescript/typescript-aix-ppc64": "7.0.2", - "@typescript/typescript-darwin-arm64": "7.0.2", - "@typescript/typescript-darwin-x64": "7.0.2", - "@typescript/typescript-freebsd-arm64": "7.0.2", - "@typescript/typescript-freebsd-x64": "7.0.2", - "@typescript/typescript-linux-arm": "7.0.2", - "@typescript/typescript-linux-arm64": "7.0.2", - "@typescript/typescript-linux-loong64": "7.0.2", - "@typescript/typescript-linux-mips64el": "7.0.2", - "@typescript/typescript-linux-ppc64": "7.0.2", - "@typescript/typescript-linux-riscv64": "7.0.2", - "@typescript/typescript-linux-s390x": "7.0.2", - "@typescript/typescript-linux-x64": "7.0.2", - "@typescript/typescript-netbsd-arm64": "7.0.2", - "@typescript/typescript-netbsd-x64": "7.0.2", - "@typescript/typescript-openbsd-arm64": "7.0.2", - "@typescript/typescript-openbsd-x64": "7.0.2", - "@typescript/typescript-sunos-x64": "7.0.2", - "@typescript/typescript-win32-arm64": "7.0.2", - "@typescript/typescript-win32-x64": "7.0.2" - } + "license": "MIT" }, "node_modules/wrapper": { "resolved": "wrapper", @@ -609,7 +238,22 @@ "name": "server-manager-shared", "version": "1.0.0", "devDependencies": { - "typescript": "^7.0.2" + "@types/node": "^26.1.1", + "typescript": "^5.7.3" + } + }, + "shared/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" } }, "wrapper": { @@ -617,6 +261,7 @@ "license": "ISC", "dependencies": { "amqplib": "^2.0.1", + "server-manager-shared": "file:../shared", "steam-server-query": "^1.1.3" }, "devDependencies": { @@ -687,14 +332,6 @@ "@types/node": "*" } }, - "wrapper/node_modules/@types/node": { - "version": "26.1.1", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~8.3.0" - } - }, "wrapper/node_modules/acorn": { "version": "8.17.0", "dev": true, @@ -805,11 +442,6 @@ "node": ">=14.17" } }, - "wrapper/node_modules/undici-types": { - "version": "8.3.0", - "dev": true, - "license": "MIT" - }, "wrapper/node_modules/v8-compile-cache-lib": { "version": "3.0.1", "dev": true, diff --git a/shared/package.json b/shared/package.json index e8f6688..2f899d1 100644 --- a/shared/package.json +++ b/shared/package.json @@ -7,6 +7,7 @@ "build": "tsc -p tsconfig.json" }, "devDependencies": { - "typescript": "^7.0.2" + "@types/node": "^26.1.1", + "typescript": "^5.7.3" } } diff --git a/shared/tsconfig.json b/shared/tsconfig.json index dc872aa..957fd2e 100644 --- a/shared/tsconfig.json +++ b/shared/tsconfig.json @@ -8,7 +8,12 @@ "rootDir": "src", "strict": true, "esModuleInterop": true, - "skipLibCheck": true + "skipLibCheck": true, + "types": [ + "node" + ] }, - "include": ["src"] -} + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/wrapper/package.json b/wrapper/package.json index 963fcb3..c2737c5 100644 --- a/wrapper/package.json +++ b/wrapper/package.json @@ -4,7 +4,8 @@ "description": "", "main": "index.js", "scripts": { - "start": "ts-node src/index.ts" + "start": "ts-node src/index.ts", + "build": "tsc -p tsconfig.json" }, "keywords": [], "author": "", diff --git a/wrapper/src/config.js b/wrapper/src/config.js new file mode 100644 index 0000000..f651a69 --- /dev/null +++ b/wrapper/src/config.js @@ -0,0 +1,25 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.SERVER_WORKING_DIRECTORY = exports.SERVER_SCRIPT_PATH = exports.WRAPPER_QUERY_INTERVAL = exports.WRAPPER_STATUS_INTERVAL = exports.WRAPPER_HEARTBEAT_INTERVAL = exports.RABBITMQ_URL = void 0; +exports.loadConfig = loadConfig; +exports.saveConfig = saveConfig; +const fs_1 = __importDefault(require("fs")); +const FILE = "./config.json"; +exports.RABBITMQ_URL = "amqp://manager:gigi1337@192.168.1.74"; +exports.WRAPPER_HEARTBEAT_INTERVAL = 10000; +exports.WRAPPER_STATUS_INTERVAL = 10000; +exports.WRAPPER_QUERY_INTERVAL = 5000; +exports.SERVER_SCRIPT_PATH = "/home/heroes/HeroesOfValorServer.sh"; +exports.SERVER_WORKING_DIRECTORY = "/home/heroes"; +function loadConfig() { + if (!fs_1.default.existsSync(FILE)) { + return {}; + } + return JSON.parse(fs_1.default.readFileSync(FILE, "utf-8")); +} +function saveConfig(config) { + fs_1.default.writeFileSync(FILE, JSON.stringify(config, null, 4)); +} diff --git a/wrapper/src/index.js b/wrapper/src/index.js new file mode 100644 index 0000000..0d4a5a7 --- /dev/null +++ b/wrapper/src/index.js @@ -0,0 +1,155 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const amqp = __importStar(require("amqplib")); +const os_1 = __importDefault(require("os")); +const config_1 = require("./config"); +const processManager_1 = require("./processManager"); +const statusReporter_1 = require("./statusReporter"); +const queryManager_1 = require("./queryManager"); +const logger_1 = require("./logger"); +const logger = (0, logger_1.getLogger)("Wrapper"); +class Wrapper { + constructor() { + this.processManager = new processManager_1.ProcessManager(); + this.queryManager = new queryManager_1.QueryManager(this.processManager); + } + async start() { + const config = (0, config_1.loadConfig)(); + logger.info("Konfiguration geladen"); + if (config.wrapperId) { + this.id = + config.wrapperId; + logger.debug("Starte mit vorhandener ID:", this.id); + } + else { + logger.warn("Keine Wrapper ID vorhanden - neue Registrierung"); + } + this.connection = + await amqp.connect(config_1.RABBITMQ_URL); + this.channel = + await this.connection.createChannel(); + logger.info("RabbitMQ Verbindung hergestellt"); + this.queryManager.start(); + await this.register(); + } + async register() { + const reply = await this.channel.assertQueue("", { + exclusive: true + }); + const replyQueue = reply.queue; + await this.channel.consume(replyQueue, msg => { + if (!msg) + return; + const data = JSON.parse(msg.content.toString()); + logger.debug("Antwort vom Manager:", data.type); + if (data.type === "wrapper.registered") { + this.id = + data.wrapperId; + (0, config_1.saveConfig)({ + wrapperId: this.id + }); + logger.info("Registrierung beim Manager erfolgreich"); + logger.debug("Wrapper ID gespeichert:", this.id); + if (!this.id) { + logger.error("Keine Wrapper ID erhalten!"); + return; + } + this.listenCommands(data.commandQueue); + const reporter = new statusReporter_1.StatusReporter(this.channel, this.processManager, this.queryManager, this.id); + reporter.start(); + } + this.channel.ack(msg); + }); + const message = { + type: "wrapper.register", + // vorhandene ID mitsenden + wrapperId: this.id, + hostname: os_1.default.hostname(), + cpu: os_1.default.cpus().length, + ram: Math.round(os_1.default.totalmem() / 1024 / 1024), + replyQueue + }; + await this.channel.sendToQueue("wrapper.register", Buffer.from(JSON.stringify(message))); + logger.debug("Registrierung gesendet", this.id ?? "ohne ID"); + } + async listenCommands(queue) { + await this.channel.consume(queue, msg => { + if (!msg) + return; + const rawCommand = JSON.parse(msg.content.toString()); + const commandType = rawCommand.type; + switch (commandType) { + case "test": { + const command = rawCommand; + logger.debug("TEST COMMAND:", command.message); + break; + } + case "server.start": { + const command = rawCommand; + this.processManager.startServer(command); + break; + } + case "server.stop": { + const command = rawCommand; + this.processManager.stopServer(command.serverId); + break; + } + default: + logger.warn("Unbekannter Command:", commandType); + } + this.channel.ack(msg); + }); + logger.debug("Warte auf Commands:", queue); + this.heartbeat(); + } + heartbeat() { + logger.debug("Heartbeat gestartet"); + setInterval(() => { + if (!this.id) + return; + const heartbeat = { + type: "wrapper.heartbeat", + wrapperId: this.id + }; + this.channel.sendToQueue("wrapper.register", Buffer.from(JSON.stringify(heartbeat))); + logger.debug("Heartbeat gesendet"); + }, config_1.WRAPPER_HEARTBEAT_INTERVAL); + } +} +new Wrapper().start(); diff --git a/wrapper/src/logger.js b/wrapper/src/logger.js new file mode 100644 index 0000000..c408656 --- /dev/null +++ b/wrapper/src/logger.js @@ -0,0 +1,47 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getLogger = getLogger; +const LEVELS = { + DEBUG: 0, + INFO: 1, + WARN: 2, + ERROR: 3, +}; +const DEFAULT_LOG_LEVEL = "INFO"; +const configuredLevel = (process.env.LOG_LEVEL ?? "").toUpperCase(); +const currentLogLevel = configuredLevel in LEVELS ? configuredLevel : DEFAULT_LOG_LEVEL; +const currentLogLevelValue = LEVELS[currentLogLevel]; +function shouldLog(level) { + return LEVELS[level] >= currentLogLevelValue; +} +function formatMessage(level, component, message) { + return `${new Date().toISOString()} ${level} [${component}] ${message}`; +} +class Logger { + constructor(component) { + this.component = component; + } + debug(message, ...args) { + if (!shouldLog("DEBUG")) + return; + console.log(formatMessage("DEBUG", this.component, message), ...args); + } + info(message, ...args) { + if (!shouldLog("INFO")) + return; + console.log(formatMessage("INFO", this.component, message), ...args); + } + warn(message, ...args) { + if (!shouldLog("WARN")) + return; + console.warn(formatMessage("WARN", this.component, message), ...args); + } + error(message, ...args) { + if (!shouldLog("ERROR")) + return; + console.error(formatMessage("ERROR", this.component, message), ...args); + } +} +function getLogger(component) { + return new Logger(component); +} diff --git a/wrapper/src/processManager.js b/wrapper/src/processManager.js new file mode 100644 index 0000000..2930c79 --- /dev/null +++ b/wrapper/src/processManager.js @@ -0,0 +1,79 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ProcessManager = void 0; +const child_process_1 = require("child_process"); +const config_1 = require("./config"); +const logger_1 = require("./logger"); +const logger = (0, logger_1.getLogger)("ProcessManager"); +class ProcessManager { + constructor() { + this.processes = new Map(); + } + startServer(data) { + logger.debug("Starte Game Server:", data.name); + const args = [ + `-Port=${data.port}`, + `-QueryPort=${data.queryPort}`, + `-ServerName=${data.name}` + ]; + const process = (0, child_process_1.spawn)(config_1.SERVER_SCRIPT_PATH, args, { + cwd: config_1.SERVER_WORKING_DIRECTORY, + stdio: "pipe", + detached: true + }); + process.stdout?.on("data", data => { + logger.debug(`[${data.name}]`, data.toString()); + }); + process.stderr?.on("data", data => { + logger.error("SERVER ERROR:", data.toString()); + }); + process.on("exit", code => { + const server = this.processes.get(data.serverId); + if (server) { + logger.info("Server beendet:", server.name, code); + this.processes.delete(data.serverId); + } + }); + this.processes.set(data.serverId, { + id: data.serverId, + name: data.name, + port: data.port, + queryPort: data.queryPort, + process, + started: Date.now(), + alive: true, + players: 0, + maxPlayers: 0 + }); + logger.info("Server gestartet:", { + id: data.serverId, + pid: process.pid + }); + } + stopServer(id) { + const server = this.processes.get(id); + if (!server) { + logger.warn("Server nicht gefunden:", id); + return; + } + logger.debug("Stoppe Server:", server.name); + process.kill(-server.process.pid, "SIGTERM"); + } + getServers() { + return Array.from(this.processes.values()); + } + getStatus() { + return Array.from(this.processes.values()).map(server => ({ + id: server.id, + name: server.name, + port: server.port, + queryPort: server.queryPort, + pid: server.process.pid, + alive: server.alive, + players: server.players, + maxPlayers: server.maxPlayers, + started: server.started + })); + } +} +exports.ProcessManager = ProcessManager; diff --git a/wrapper/src/queryManager.js b/wrapper/src/queryManager.js new file mode 100644 index 0000000..da14b5e --- /dev/null +++ b/wrapper/src/queryManager.js @@ -0,0 +1,44 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.QueryManager = void 0; +const steam_server_query_1 = require("steam-server-query"); +const config_1 = require("./config"); +const logger_1 = require("./logger"); +const logger = (0, logger_1.getLogger)("QueryManager"); +class QueryManager { + constructor(processManager) { + this.processManager = processManager; + } + start() { + logger.debug("Query Manager gestartet"); + setInterval(() => { + this.updateQueries(); + }, config_1.WRAPPER_QUERY_INTERVAL); + } + async updateQueries() { + const servers = this.processManager.getServers(); + for (const server of servers) { + try { + const info = await (0, steam_server_query_1.queryGameServerInfo)(`127.0.0.1:${server.queryPort}`); + server.players = + info.players; + server.maxPlayers = + info.maxPlayers; + logger.debug("Query erfolgreich:", server.name, `${server.players}/${server.maxPlayers}`); + } + catch (err) { + logger.warn("Query fehlgeschlagen:", server.name); + } + } + } + getStatus() { + return this.processManager + .getServers() + .map(server => ({ + id: server.id, + players: server.players, + maxPlayers: server.maxPlayers + })); + } +} +exports.QueryManager = QueryManager; diff --git a/wrapper/src/statusReporter.js b/wrapper/src/statusReporter.js new file mode 100644 index 0000000..f582189 --- /dev/null +++ b/wrapper/src/statusReporter.js @@ -0,0 +1,49 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.StatusReporter = void 0; +const os_1 = __importDefault(require("os")); +const config_1 = require("./config"); +const logger_1 = require("./logger"); +const logger = (0, logger_1.getLogger)("StatusReporter"); +class StatusReporter { + constructor(channel, processManager, queryManager, wrapperId) { + this.channel = channel; + this.processManager = processManager; + this.queryManager = queryManager; + this.wrapperId = wrapperId; + } + start() { + logger.debug("Status Reporter gestartet"); + setInterval(() => { + this.sendStatus(); + }, config_1.WRAPPER_STATUS_INTERVAL); + } + sendStatus() { + const processes = this.processManager.getStatus(); + const queries = this.queryManager.getStatus(); + const servers = processes.map(server => { + const query = queries.find((q) => q.id === server.id); + return { + ...server, + players: query?.players ?? 0, + maxPlayers: query?.maxPlayers ?? 0 + }; + }); + const status = { + type: "wrapper.status", + wrapperId: this.wrapperId, + servers, + ramFree: Math.round(os_1.default.freemem() + / + 1024 + / + 1024) + }; + this.channel.sendToQueue("wrapper.register", Buffer.from(JSON.stringify(status))); + logger.debug("Status gesendet:", servers.length, "Server"); + } +} +exports.StatusReporter = StatusReporter;