From bc2487e3df965a35addd20948ea5d4fa70c3ebcc Mon Sep 17 00:00:00 2001 From: davi_xavier <47609623+davirxavier@users.noreply.github.com> Date: Thu, 17 Aug 2023 20:18:13 -0300 Subject: [PATCH 1/3] Add backend support for server hibernation feature --- .../controllers/dummy_servers_controller.py | 84 ++++ app/classes/controllers/servers_controller.py | 85 +++- app/classes/models/servers.py | 12 + app/classes/shared/dummy_servers.py | 72 +++ app/classes/shared/helpers.py | 1 + app/classes/shared/server.py | 451 +++++++++--------- app/classes/shared/tasks.py | 14 + .../20230814130904_close_when_inactive_col.py | 22 + main.py | 1 + 9 files changed, 518 insertions(+), 224 deletions(-) create mode 100644 app/classes/controllers/dummy_servers_controller.py create mode 100644 app/classes/shared/dummy_servers.py create mode 100644 app/migrations/20230814130904_close_when_inactive_col.py diff --git a/app/classes/controllers/dummy_servers_controller.py b/app/classes/controllers/dummy_servers_controller.py new file mode 100644 index 000000000..c4ccfb002 --- /dev/null +++ b/app/classes/controllers/dummy_servers_controller.py @@ -0,0 +1,84 @@ +import asyncio +import logging +from typing import Dict, Callable + +from twisted.internet.defer import Deferred +from twisted.internet.tcp import Port + +from app.classes.shared.dummy_servers import DummyServers, DummyServerFactory +from app.classes.shared.singleton import Singleton + +logger = logging.getLogger(__name__) + + +class DummyServersController(metaclass=Singleton): + """Controller for dummy servers.""" + + def __init__(self): + self.dummy_servers = DummyServers() + self.dummy_by_id: Dict[int, Port] = {} + + def create_dummy_server( + self, + server_id: int, + server_port: int, + hibernation_motd: str, + wake_up_message: str, + online_mode: bool = True, + max_players: int = 1, + player_connected_callback: Callable = None, + ): + """ Starts a new dummy server with the defined configuration. + Parameters: + server_id: The real server id. + server_port: The port to bind the dummy server. + hibernation_motd: The server description in the multiplayer menu. + wake_up_message: The message shown to the player when they connect. + online_mode: Enable online mode for the dummy server. + max_players: The max players allowed. + player_connected_callback: The callback for when a player connects to the server. Will be called + immediately after the "Connecting to server..." message appears to a player that is joining. + """ + + async def do_create(): + factory = DummyServerFactory() + + factory.motd = hibernation_motd + factory.close_message = wake_up_message + factory.max_players = max_players + factory.online_mode = online_mode + factory.player_connected_callback = player_connected_callback + + self.dummy_by_id[int(server_id)] = self.dummy_servers.reactor.listenTCP( + server_port, + factory, + interface="0.0.0.0" + ) + logger.info(f"Created dummy server in port {server_port}.") + + asyncio.run_coroutine_threadsafe(do_create(), self.dummy_servers.loop) + + def stop_dummy_server(self, server_id, port_freed_callback: Callable = None): + """ Stops a dummy server for the server_id specified and frees up the used port. + Parameters: + server_id: The real server id. + port_freed_callback: The function that will be called when the port used by the dummy server + is completely freed. + """ + + async def do_stop(): + port: Port = self.dummy_by_id.pop(int(server_id), None) + if port: + deferred: Deferred = port.loseConnection() + if deferred and port_freed_callback: + deferred.addCallback(lambda ret: port_freed_callback()) + deferred.addErrback(lambda err: logger.error("Error while trying to stop dummy server: ", err)) + elif port_freed_callback: + port_freed_callback() + elif port_freed_callback: + port_freed_callback() + + asyncio.run_coroutine_threadsafe(do_stop(), self.dummy_servers.loop) + + def is_dummy_server_running(self, server_id): + return int(server_id) in self.dummy_by_id diff --git a/app/classes/controllers/servers_controller.py b/app/classes/controllers/servers_controller.py index ca6c8d222..eceb74b07 100644 --- a/app/classes/controllers/servers_controller.py +++ b/app/classes/controllers/servers_controller.py @@ -1,3 +1,4 @@ +import datetime import os import logging import time @@ -5,6 +6,7 @@ import json import pathlib import typing as t +from app.classes.controllers.dummy_servers_controller import DummyServersController from app.classes.controllers.roles_controller import RolesController from app.classes.shared.file_helpers import FileHelpers @@ -36,6 +38,10 @@ class ServersController(metaclass=Singleton): self.management_helper = management_helper self.servers_list = [] self.stats = Stats(self.helper, self) + self.dummy_servers = DummyServersController() + self.inactive_server_timeout = self.helper.get_setting( + "inactive_server_timeout", 10 + ) # ********************************************************************************** # Generic Servers Methods @@ -98,11 +104,28 @@ class ServersController(metaclass=Singleton): @staticmethod def update_server(server_obj): ret = HelperServers.update_server(server_obj) - server_instance: ServerInstance = ServersController().get_server_instance_by_id( + servers_controller = ServersController() + server_instance: ServerInstance = servers_controller.get_server_instance_by_id( server_obj.server_id ) server_instance.update_server_instance() + # Check if there's the need to open/close a dummy server when updating + is_running = bool(server_instance.stats_helper.get_server_stats()["running"]) + if ( + not is_running + and not servers_controller.dummy_servers.is_dummy_server_running( + server_obj.server_id + ) + ): + servers_controller.check_inactive_servers() + # Restart dummy server in case the server port was updated while it's running + elif not is_running: + servers_controller.dummy_servers.stop_dummy_server( + server_obj.server_id, + lambda: servers_controller.run_dummy_server(server_instance), + ) + return ret def get_history_stats(self, server_id, days): @@ -136,6 +159,8 @@ class ServersController(metaclass=Singleton): return server.stats_helper.get_import_status() def remove_server(self, server_id): + self.dummy_servers.stop_dummy_server(server_id) + roles_list = PermissionsServers.get_roles_from_server(server_id) for role in roles_list: role_id = role.role_id @@ -579,3 +604,61 @@ class ServersController(metaclass=Singleton): log_file_path, logs_delete_after ): os.remove(log_file_path) + + def run_dummy_server(self, server_obj: ServerInstance): + self.dummy_servers.create_dummy_server( + server_obj.server_id, + server_obj.server_object.server_port, + hibernation_motd=server_obj.server_object.hibernation_motd or "", + wake_up_message=server_obj.server_object.wake_up_message or "", + online_mode=False, + max_players=1, + player_connected_callback=lambda: server_obj.start_server(None), + ) + + def check_inactive_servers(self): + logger.info("Checking inactive servers.") + + servers = self.servers_list + for server in servers: + server_obj: ServerInstance = server["server_obj"] + stats = server_obj.get_servers_stats() + + is_running = bool(stats["running"]) + close_when_inactive = server_obj.server_object.close_when_inactive + + if is_running and close_when_inactive and int(stats["online"]) == 0: + running_diff = datetime.datetime.now(datetime.timezone.utc).replace( + tzinfo=None + ) - datetime.datetime.strptime(stats["started"], "%Y-%m-%d %H:%M:%S") + + if (running_diff.total_seconds() / 60) > self.inactive_server_timeout: + logger.info( + "No players running for server {0} after {1} minute timeout, stopping and running " + "dummy server on its port ({2}).".format( + server_obj.server_id, + self.inactive_server_timeout, + server_obj.server_object.server_port, + ) + ) + + self.stop_server(server_obj.server_id) + self.run_dummy_server(server_obj) + pass + elif ( + not is_running + and close_when_inactive + and not self.dummy_servers.is_dummy_server_running(server_obj.server_id) + ): + logger.info( + "Server {0} has close_when_inactive activated, running new dummy server on its port ({1}).".format( + server_obj.server_id, server_obj.server_object.server_port + ) + ) + self.run_dummy_server(server_obj) + elif ( + not is_running + and not close_when_inactive + and self.dummy_servers.is_dummy_server_running(server_obj.server_id) + ): + self.dummy_servers.stop_dummy_server(server_obj.server_id) diff --git a/app/classes/models/servers.py b/app/classes/models/servers.py index a83fd0a2a..ec21751c6 100644 --- a/app/classes/models/servers.py +++ b/app/classes/models/servers.py @@ -42,6 +42,9 @@ class Servers(BaseModel): created_by = IntegerField(default=-100) shutdown_timeout = IntegerField(default=60) ignored_exits = CharField(default="0") + close_when_inactive = BooleanField(default=False) + hibernation_motd = CharField(default=None, null=True) + wake_up_message = CharField(default=None, null=True) class Meta: table_name = "servers" @@ -71,6 +74,9 @@ class HelperServers: created_by: int, server_port: int = 25565, server_host: str = "127.0.0.1", + close_when_inactive: bool = False, + hibernation_motd: str = None, + wake_up_message: str = None, ) -> int: """Create a server in the database @@ -87,6 +93,9 @@ class HelperServers: server_port: The port the server will be monitored on, defaults to 25565 server_host: The host the server will be monitored on, defaults to 127.0.0.1 show_status: Should Crafty show this server on the public status page + close_when_inactive: Should Crafty close this server when inactive and run it again when someone tries to connect + hibernation_motd: Motd that should be shown in dummy server when hibernating + wake_up_message: Message sent to the player when they try to connect to a dummy server when hibernating Returns: int: The new server's id @@ -111,6 +120,9 @@ class HelperServers: Servers.backup_path: backup_path, Servers.type: server_type, Servers.created_by: created_by, + Servers.close_when_inactive: close_when_inactive, + Servers.hibernation_motd: hibernation_motd, + Servers.wake_up_message: wake_up_message, } ).execute() diff --git a/app/classes/shared/dummy_servers.py b/app/classes/shared/dummy_servers.py new file mode 100644 index 000000000..439b63078 --- /dev/null +++ b/app/classes/shared/dummy_servers.py @@ -0,0 +1,72 @@ +import logging +import sys +import threading +import asyncio +from typing import Callable + +from quarry.net.server import ServerFactory, ServerProtocol +from twisted.internet import asyncioreactor + +from app.classes.shared.singleton import Singleton + +logger = logging.getLogger(__name__) + + +class DummyServers(metaclass=Singleton): + """Manager for the dummy server thread""" + + def __init__(self): + self.thread = threading.Thread( + daemon=True, + target=self.run_thread, + name="dummy_servers_thread", + ) + self.reactor: asyncioreactor.AsyncioSelectorReactor | None = None + self.loop: asyncio.AbstractEventLoop | None = None + + def init_servers(self): + self.thread.start() + + def run_thread(self): + if "twisted.internet.reactor" in sys.modules: + del sys.modules["twisted.internet.reactor"] + + self.loop = asyncio.new_event_loop() + asyncioreactor.install(self.loop) + + from twisted.internet import reactor + self.reactor = reactor + self.reactor.run(installSignalHandlers=False) + + +class DummyServerProtocol(ServerProtocol): + """Class that implements what to do after client connects to dummy server.""" + + def player_joined(self): + logger.info("Player connected in server. Stopping dummy server.") + ServerProtocol.player_joined(self) + self.close(self.factory.close_message) + + if self.factory.player_connected_callback: + self.factory.player_connected_callback() + + +class DummyServerFactory(ServerFactory): + """Factory class for a dummy server.""" + + def __init__( + self, + motd: str = "", + close_message: str = "", + online_mode: bool = False, + max_players: int = 20, + player_connected_callback: Callable = None, + ): + super().__init__() + self.protocol = DummyServerProtocol + self.motd = motd + self.close_message = close_message + self.online_mode = online_mode + self.max_players = max_players + self.player_connected_callback = player_connected_callback + diff --git a/app/classes/shared/helpers.py b/app/classes/shared/helpers.py index 489115ae9..c9efff83e 100644 --- a/app/classes/shared/helpers.py +++ b/app/classes/shared/helpers.py @@ -481,6 +481,7 @@ class Helpers: "monitored_mounts": mounts, "dir_size_poll_freq_minutes": 5, "crafty_logs_delete_after_days": 0, + "inactive_server_timeout": 10, } def get_all_settings(self): diff --git a/app/classes/shared/server.py b/app/classes/shared/server.py index c1a11158a..c44c59abb 100644 --- a/app/classes/shared/server.py +++ b/app/classes/shared/server.py @@ -21,6 +21,7 @@ from tzlocal.utils import ZoneInfoNotFoundError from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.jobstores.base import JobLookupError +from app.classes.controllers.dummy_servers_controller import DummyServersController from app.classes.minecraft.stats import Stats from app.classes.minecraft.mc_ping import ping, ping_bedrock from app.classes.models.servers import HelperServers, Servers @@ -133,6 +134,7 @@ class ServerInstance: self.server_object = HelperServers.get_server_obj(self.server_id) self.stats_helper = HelperServerStats(self.server_id) self.last_backup_failed = False + self.dummy_servers = DummyServersController() try: with open( os.path.join(self.server_object.path, "db_stats", "players_cache.json"), @@ -314,280 +316,283 @@ class ServerInstance: Console.critical(f"Unable to write/access {self.server_path}") def start_server(self, user_id, forge_install=False): - if not user_id: - user_lang = self.helper.get_setting("language") - else: - user_lang = HelperUsers.get_user_lang_by_id(user_id) - - # Checks if user is currently attempting to move global server - # dir - if self.helper.dir_migration: - self.helper.websocket_helper.broadcast_user( - user_id, - "send_start_error", - { - "error": self.helper.translation.translate( - "error", - "migration", - user_lang, - ) - }, - ) - return False + def do_start(): + if not user_id: + user_lang = self.helper.get_setting("language") + else: + user_lang = HelperUsers.get_user_lang_by_id(user_id) - if self.stats_helper.get_import_status() and not forge_install: - if user_id: + # Checks if user is currently attempting to move global server + # dir + if self.helper.dir_migration: self.helper.websocket_helper.broadcast_user( user_id, "send_start_error", { "error": self.helper.translation.translate( - "error", "not-downloaded", user_lang + "error", + "migration", + user_lang, ) }, ) - return False + return False - logger.info( - f"Start command detected. Reloading settings from DB for server {self.name}" - ) - self.setup_server_run_command() - # fail safe in case we try to start something already running - if self.check_running(): - logger.error("Server is already running - Cancelling Startup") - Console.error("Server is already running - Cancelling Startup") - return False - if self.check_update(): - logger.error("Server is updating. Terminating startup.") - return False + if self.stats_helper.get_import_status() and not forge_install: + if user_id: + self.helper.websocket_helper.broadcast_user( + user_id, + "send_start_error", + { + "error": self.helper.translation.translate( + "error", "not-downloaded", user_lang + ) + }, + ) + return False - logger.info(f"Launching Server {self.name} with command {self.server_command}") - Console.info(f"Launching Server {self.name} with command {self.server_command}") + logger.info( + f"Start command detected. Reloading settings from DB for server {self.name}" + ) + self.setup_server_run_command() + # fail safe in case we try to start something already running + if self.check_running(): + logger.error("Server is already running - Cancelling Startup") + Console.error("Server is already running - Cancelling Startup") + return False + if self.check_update(): + logger.error("Server is updating. Terminating startup.") + return False - # Checks for eula. Creates one if none detected. - # If EULA is detected and not set to true we offer to set it true. - e_flag = False - if Helpers.check_file_exists(os.path.join(self.settings["path"], "eula.txt")): - with open( - os.path.join(self.settings["path"], "eula.txt"), "r", encoding="utf-8" - ) as f: - line = f.readline().lower() - e_flag = line in [ - "eula=true", - "eula = true", - "eula= true", - "eula =true", - ] - # If this is a forge installer we're running we can bypass the eula checks. - if forge_install is True: - e_flag = True - if not e_flag and self.settings["type"] == "minecraft-java": - if user_id: - self.helper.websocket_helper.broadcast_user( - user_id, "send_eula_bootbox", {"id": self.server_id} - ) + logger.info(f"Launching Server {self.name} with command {self.server_command}") + Console.info(f"Launching Server {self.name} with command {self.server_command}") + + # Checks for eula. Creates one if none detected. + # If EULA is detected and not set to true we offer to set it true. + e_flag = False + if Helpers.check_file_exists(os.path.join(self.settings["path"], "eula.txt")): + with open( + os.path.join(self.settings["path"], "eula.txt"), "r", encoding="utf-8" + ) as f: + line = f.readline().lower() + e_flag = line in [ + "eula=true", + "eula = true", + "eula= true", + "eula =true", + ] + # If this is a forge installer we're running we can bypass the eula checks. + if forge_install is True: + e_flag = True + if not e_flag and self.settings["type"] == "minecraft-java": + if user_id: + self.helper.websocket_helper.broadcast_user( + user_id, "send_eula_bootbox", {"id": self.server_id} + ) + else: + logger.error( + "Autostart failed due to EULA being false. " + "Agree not sent due to auto start." + ) + return False + if Helpers.is_os_windows(): + logger.info("Windows Detected") else: - logger.error( - "Autostart failed due to EULA being false. " - "Agree not sent due to auto start." - ) - return False - if Helpers.is_os_windows(): - logger.info("Windows Detected") - else: - logger.info("Unix Detected") - - logger.info( - f"Starting server in {self.server_path} with command: {self.server_command}" - ) - - # checks to make sure file is openable (downloaded) and exists. - try: - with open( - os.path.join( - self.server_path, - HelperServers.get_server_data_by_id(self.server_id)["executable"], - ), - "r", - encoding="utf-8", - ): - # Can open the file - pass + logger.info("Unix Detected") - except: - if user_id: - self.helper.websocket_helper.broadcast_user( - user_id, - "send_start_error", - { - "error": self.helper.translation.translate( - "error", "not-downloaded", user_lang - ) - }, - ) - return - - if ( - not Helpers.is_os_windows() - and HelperServers.get_server_type_by_id(self.server_id) - == "minecraft-bedrock" - ): logger.info( - f"Bedrock and Unix detected for server {self.name}. " - f"Switching to appropriate execution string" + f"Starting server in {self.server_path} with command: {self.server_command}" ) - my_env = os.environ - my_env["LD_LIBRARY_PATH"] = self.server_path + + # checks to make sure file is openable (downloaded) and exists. try: - self.process = subprocess.Popen( - self.server_command, - cwd=self.server_path, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - env=my_env, - ) - except Exception as ex: - logger.error( - f"Server {self.name} failed to start with error code: {ex}" - ) + with open( + os.path.join( + self.server_path, + HelperServers.get_server_data_by_id(self.server_id)["executable"], + ), + "r", + encoding="utf-8", + ): + # Can open the file + pass + + except: if user_id: self.helper.websocket_helper.broadcast_user( user_id, "send_start_error", { "error": self.helper.translation.translate( - "error", "start-error", user_lang - ).format(self.name, ex) + "error", "not-downloaded", user_lang + ) }, ) - if forge_install: - # Reset import status if failed while forge installing - self.stats_helper.finish_import() - return False + return - else: - try: - self.process = subprocess.Popen( - self.server_command, - cwd=self.server_path, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, + if ( + not Helpers.is_os_windows() + and HelperServers.get_server_type_by_id(self.server_id) + == "minecraft-bedrock" + ): + logger.info( + f"Bedrock and Unix detected for server {self.name}. " + f"Switching to appropriate execution string" ) - except Exception as ex: - # Checks for java on initial fail - if not self.helper.detect_java(): + my_env = os.environ + my_env["LD_LIBRARY_PATH"] = self.server_path + try: + self.process = subprocess.Popen( + self.server_command, + cwd=self.server_path, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + env=my_env, + ) + except Exception as ex: + logger.error( + f"Server {self.name} failed to start with error code: {ex}" + ) if user_id: self.helper.websocket_helper.broadcast_user( user_id, "send_start_error", { "error": self.helper.translation.translate( - "error", "noJava", user_lang - ).format(self.name) + "error", "start-error", user_lang + ).format(self.name, ex) }, ) + if forge_install: + # Reset import status if failed while forge installing + self.stats_helper.finish_import() return False - logger.error( - f"Server {self.name} failed to start with error code: {ex}" + + else: + try: + self.process = subprocess.Popen( + self.server_command, + cwd=self.server_path, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + except Exception as ex: + # Checks for java on initial fail + if not self.helper.detect_java(): + if user_id: + self.helper.websocket_helper.broadcast_user( + user_id, + "send_start_error", + { + "error": self.helper.translation.translate( + "error", "noJava", user_lang + ).format(self.name) + }, + ) + return False + logger.error( + f"Server {self.name} failed to start with error code: {ex}" + ) + if user_id: + self.helper.websocket_helper.broadcast_user( + user_id, + "send_start_error", + { + "error": self.helper.translation.translate( + "error", "start-error", user_lang + ).format(self.name, ex) + }, + ) + if forge_install: + # Reset import status if failed while forge installing + self.stats_helper.finish_import() + return False + + out_buf = ServerOutBuf(self.helper, self.process, self.server_id) + + logger.debug(f"Starting virtual terminal listener for server {self.name}") + threading.Thread( + target=out_buf.check, daemon=True, name=f"{self.server_id}_virtual_terminal" + ).start() + + self.is_crashed = False + self.stats_helper.server_crash_reset() + + self.start_time = str(datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")) + + if self.process.poll() is None: + logger.info(f"Server {self.name} running with PID {self.process.pid}") + Console.info(f"Server {self.name} running with PID {self.process.pid}") + self.is_crashed = False + self.stats_helper.server_crash_reset() + self.record_server_stats() + check_internet_thread = threading.Thread( + target=self.check_internet_thread, + daemon=True, + args=( + user_id, + user_lang, + ), + name=f"{self.name}_Internet", ) - if user_id: + check_internet_thread.start() + # Checks if this is the servers first run. + if self.stats_helper.get_first_run(): + self.stats_helper.set_first_run() + loc_server_port = self.stats_helper.get_server_stats()["server_port"] + # Sends port reminder message. self.helper.websocket_helper.broadcast_user( user_id, "send_start_error", { "error": self.helper.translation.translate( - "error", "start-error", user_lang - ).format(self.name, ex) + "error", "portReminder", user_lang + ).format(self.name, loc_server_port) }, ) - if forge_install: - # Reset import status if failed while forge installing - self.stats_helper.finish_import() - return False - - out_buf = ServerOutBuf(self.helper, self.process, self.server_id) - - logger.debug(f"Starting virtual terminal listener for server {self.name}") - threading.Thread( - target=out_buf.check, daemon=True, name=f"{self.server_id}_virtual_terminal" - ).start() - - self.is_crashed = False - self.stats_helper.server_crash_reset() - - self.start_time = str(datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")) - - if self.process.poll() is None: - logger.info(f"Server {self.name} running with PID {self.process.pid}") - Console.info(f"Server {self.name} running with PID {self.process.pid}") - self.is_crashed = False - self.stats_helper.server_crash_reset() - self.record_server_stats() - check_internet_thread = threading.Thread( - target=self.check_internet_thread, - daemon=True, - args=( - user_id, - user_lang, - ), - name=f"{self.name}_Internet", - ) - check_internet_thread.start() - # Checks if this is the servers first run. - if self.stats_helper.get_first_run(): - self.stats_helper.set_first_run() - loc_server_port = self.stats_helper.get_server_stats()["server_port"] - # Sends port reminder message. - self.helper.websocket_helper.broadcast_user( - user_id, - "send_start_error", - { - "error": self.helper.translation.translate( - "error", "portReminder", user_lang - ).format(self.name, loc_server_port) - }, - ) - server_users = PermissionsServers.get_server_user_list(self.server_id) - for user in server_users: - if user != user_id: + server_users = PermissionsServers.get_server_user_list(self.server_id) + for user in server_users: + if user != user_id: + self.helper.websocket_helper.broadcast_user( + user, "send_start_reload", {} + ) + else: + server_users = PermissionsServers.get_server_user_list(self.server_id) + for user in server_users: self.helper.websocket_helper.broadcast_user( user, "send_start_reload", {} ) else: - server_users = PermissionsServers.get_server_user_list(self.server_id) - for user in server_users: - self.helper.websocket_helper.broadcast_user( - user, "send_start_reload", {} - ) - else: - logger.warning( - f"Server PID {self.process.pid} died right after starting " - f"- is this a server config issue?" - ) - Console.critical( - f"Server PID {self.process.pid} died right after starting " - f"- is this a server config issue?" - ) + logger.warning( + f"Server PID {self.process.pid} died right after starting " + f"- is this a server config issue?" + ) + Console.critical( + f"Server PID {self.process.pid} died right after starting " + f"- is this a server config issue?" + ) - if self.settings["crash_detection"]: - logger.info( - f"Server {self.name} has crash detection enabled " - f"- starting watcher task" - ) - Console.info( - f"Server {self.name} has crash detection enabled " - f"- starting watcher task" - ) + if self.settings["crash_detection"]: + logger.info( + f"Server {self.name} has crash detection enabled " + f"- starting watcher task" + ) + Console.info( + f"Server {self.name} has crash detection enabled " + f"- starting watcher task" + ) - self.server_scheduler.add_job( - self.detect_crash, "interval", seconds=30, id=f"c_{self.server_id}" - ) + self.server_scheduler.add_job( + self.detect_crash, "interval", seconds=30, id=f"c_{self.server_id}" + ) + + # If this is a forge install we'll call the watcher to do the things + if forge_install: + self.forge_install_watcher() - # If this is a forge install we'll call the watcher to do the things - if forge_install: - self.forge_install_watcher() + self.dummy_servers.stop_dummy_server(self.server_id, port_freed_callback=do_start) def check_internet_thread(self, user_id, user_lang): if user_id: diff --git a/app/classes/shared/tasks.py b/app/classes/shared/tasks.py index acdc1cac6..773bd7d26 100644 --- a/app/classes/shared/tasks.py +++ b/app/classes/shared/tasks.py @@ -16,6 +16,7 @@ from app.classes.models.management import HelpersManagement from app.classes.models.users import HelperUsers from app.classes.controllers.users_controller import UsersController from app.classes.shared.console import Console +from app.classes.shared.dummy_servers import DummyServers from app.classes.shared.file_helpers import FileHelpers from app.classes.shared.helpers import Helpers from app.classes.shared.main_controller import Controller @@ -57,6 +58,8 @@ class TasksManager: self.users_controller: UsersController = self.controller.users + self.dummy_server_manager = DummyServers() + self.webserver_thread = threading.Thread( target=self.tornado.run_tornado, daemon=True, name="tornado_thread" ) @@ -81,6 +84,9 @@ class TasksManager: self.reload_schedule_from_db() + def start_dummy_servers(self): + self.dummy_server_manager.init_servers() + def get_main_thread_run_status(self): return self.main_thread_exiting @@ -203,6 +209,14 @@ class TasksManager: # self.scheduler.add_job( # self.scheduler.print_jobs, "interval", seconds=10, id="-1" # ) + self.controller.servers.check_inactive_servers() + self.scheduler.add_job( + self.controller.servers.check_inactive_servers, + "interval", + minutes=1, + id="check_inactive_servers", + start_date=datetime.datetime.now(), + ) # load schedules from DB for schedule in schedules: diff --git a/app/migrations/20230814130904_close_when_inactive_col.py b/app/migrations/20230814130904_close_when_inactive_col.py new file mode 100644 index 000000000..795a1fe39 --- /dev/null +++ b/app/migrations/20230814130904_close_when_inactive_col.py @@ -0,0 +1,22 @@ +# Generated by database migrator +import peewee + +from app.classes.models.servers import Servers + + +def migrate(migrator, db): + migrator.add_columns( + Servers, close_when_inactive=peewee.BooleanField(default=False) + ) + migrator.add_columns( + Servers, hibernation_motd=peewee.CharField(default=None, null=True) + ) + migrator.add_columns( + Servers, wake_up_message=peewee.CharField(default=None, null=True) + ) + + +def rollback(migrator, db): + migrator.drop_columns( + Servers, ["close_when_inactive", "hibernation_motd", "wake_up_message"] + ) diff --git a/main.py b/main.py index 2338517b0..b2573aec8 100644 --- a/main.py +++ b/main.py @@ -173,6 +173,7 @@ if __name__ == "__main__": import3 = Import3(helper, controller) tasks_manager = TasksManager(helper, controller) tasks_manager.start_webserver() + tasks_manager.start_dummy_servers() def signal_handler(signum, _frame): if not args.daemon: -- GitLab From f24ded31934121e610907f853f220da1908eaf61 Mon Sep 17 00:00:00 2001 From: davi_xavier <47609623+davirxavier@users.noreply.github.com> Date: Thu, 17 Aug 2023 21:04:59 -0300 Subject: [PATCH 2/3] Add frontend support for server hibernation feature --- app/classes/controllers/servers_controller.py | 6 ++- app/classes/web/panel_handler.py | 3 ++ .../web/routes/api/servers/server/index.py | 6 +++ .../templates/panel/server_config.html | 50 ++++++++++++++++++- app/translations/en_EN.json | 7 ++- app/translations/pt_BR.json | 7 ++- requirements.txt | 1 + 7 files changed, 76 insertions(+), 4 deletions(-) diff --git a/app/classes/controllers/servers_controller.py b/app/classes/controllers/servers_controller.py index eceb74b07..53a11718f 100644 --- a/app/classes/controllers/servers_controller.py +++ b/app/classes/controllers/servers_controller.py @@ -103,6 +103,10 @@ class ServersController(metaclass=Singleton): @staticmethod def update_server(server_obj): + if not server_obj.close_when_inactive: + server_obj.hibernation_motd = None + server_obj.wake_up_message = None + ret = HelperServers.update_server(server_obj) servers_controller = ServersController() server_instance: ServerInstance = servers_controller.get_server_instance_by_id( @@ -123,7 +127,7 @@ class ServersController(metaclass=Singleton): elif not is_running: servers_controller.dummy_servers.stop_dummy_server( server_obj.server_id, - lambda: servers_controller.run_dummy_server(server_instance), + port_freed_callback=servers_controller.check_inactive_servers, ) return ret diff --git a/app/classes/web/panel_handler.py b/app/classes/web/panel_handler.py index 20c76c1a9..d4a810012 100644 --- a/app/classes/web/panel_handler.py +++ b/app/classes/web/panel_handler.py @@ -536,6 +536,9 @@ class PanelHandler(BaseHandler): "auto_start": server_temp_obj["auto_start"], "crash_detection": server_temp_obj["crash_detection"], "show_status": server_temp_obj["show_status"], + "close_when_inactive": server_temp_obj["close_when_inactive"], + "hibernation_motd": server_temp_obj["hibernation_motd"], + "wake_up_message": server_temp_obj["wake_up_message"], "ignored_exits": server_temp_obj["ignored_exits"], }, "running": False, diff --git a/app/classes/web/routes/api/servers/server/index.py b/app/classes/web/routes/api/servers/server/index.py index afe02a0b7..2570c1af2 100644 --- a/app/classes/web/routes/api/servers/server/index.py +++ b/app/classes/web/routes/api/servers/server/index.py @@ -21,6 +21,9 @@ server_patch_schema = { "auto_start": {"type": "boolean"}, "auto_start_delay": {"type": "integer", "minimum": 0}, "crash_detection": {"type": "boolean"}, + "close_when_inactive": {"type": "boolean"}, + "hibernation_motd": {"type": "string"}, + "wake_up_message": {"type": "string"}, "stop_command": {"type": "string"}, "executable_update_url": {"type": "string"}, "server_ip": {"type": "string", "minLength": 1}, @@ -42,6 +45,9 @@ basic_server_patch_schema = { "auto_start": {"type": "boolean"}, "auto_start_delay": {"type": "integer", "minimum": 0}, "crash_detection": {"type": "boolean"}, + "close_when_inactive": {"type": "boolean"}, + "hibernation_motd": {"type": "string"}, + "wake_up_message": {"type": "string"}, "stop_command": {"type": "string"}, "shutdown_timeout": {"type": "integer"}, "logs_delete_after": {"type": "integer", "minimum": 0}, diff --git a/app/frontend/templates/panel/server_config.html b/app/frontend/templates/panel/server_config.html index 913a20003..54c464ab7 100644 --- a/app/frontend/templates/panel/server_config.html +++ b/app/frontend/templates/panel/server_config.html @@ -166,6 +166,35 @@ +