Source code for emsm.core.worlds

#!/usr/bin/env python3

# The MIT License (MIT)
#
# Copyright (c) 2014-2018 <see AUTHORS.txt>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.


# Modules
# ------------------------------------------------

#std
import time
import shutil
import os
import sys
import subprocess
import shlex
import signal
import collections
import re
import random
import socket
import logging
import io

# third party
import blinker


# Backward compatibility
# ------------------------------------------------

if not hasattr(shlex, "quote"):
    # From the Python3.3 library
    import re
    _find_unsafe = re.compile(r'[^\w@%+=:,./-]', re.ASCII).search
    def _shlex_quote(s):
        """Return a shell-escaped version of the string *s*."""
        if not s:
            return "''"
        if _find_unsafe(s) is None:
            return s

        # use single quotes, and put single quotes into double quotes
        # the string $'b is then quoted as '$'"'"'b'
        return "'" + s.replace("'", "'\"'\"'") + "'"
    shlex.quote = _shlex_quote
    del re

if not hasattr(shlex, "which"):
    shlex.which = lambda s: s

if not hasattr(subprocess, "DEVNULL"):
    subprocess.DEVNULL = os.open(os.devnull, os.O_RDWR)

try:
    FileNotFoundError
    FileExistsError
except NameError:
    FileNotFoundError = OSError
    FileExistsError = OSError


# Data
# ------------------------------------------------

__all__ = [
    "WorldError",
    "WorldStatusError",
    "WorldIsOnlineError",
    "WorldIsOfflineError",
    "WorldStartFailed",
    "WorldStopFailed",
    "WorldCommandTimeout",
    "WorldWrapper",
    "WorldManager"
    ]

log = logging.getLogger(__file__)

_SCREEN = shlex.which("screen")


# Exceptions
# ------------------------------------------------

[docs]class WorldError(Exception): """ Base class for all other exceptions in this module. """ pass
[docs]class WorldStatusError(WorldError): """ Raised, if a task can not be done because of the current status of the world (online or not online). """ def __init__(self, world, is_online): self.world = world self.is_online = is_online return None def __str__(self): if self.is_online: temp = "The world '{}' is online!" else: temp = "The world '{}' is offline!" temp = temp.format(self.world.name()) return temp
[docs]class WorldIsOnlineError(WorldStatusError): """ Raised if a world is online but should be offline. """ def __init__(self, world): WorldStatusError.__init__(self, world, True) return None
[docs]class WorldIsOfflineError(WorldStatusError): """ Raised if a world is offline but should be online. """ def __init__(self, world): WorldStatusError.__init__(self, world, False) return None
[docs]class WorldStartFailed(WorldError): """ Raised if the world start failed. """ def __init__(self, world): self.world = world return None def __str__(self): temp = "The start of the world '{}' failed!"\ .format(self.world.name()) return temp
[docs]class WorldStopFailed(WorldError): """ Raised if the world stop failed. """ def __init__(self, world): self.world = world return None def __str__(self): temp = "The stop of the world '{}' failed!"\ .format(self.world.name()) return temp
[docs]class WorldCommandTimeout(WorldError): """ Raised, when the server did not react in x seconds. """ def __init__(self, world = ""): self.world = world return None def __str__(self): temp = "The world '{}' did not react!"\ .format(self.world.name()) return temp
# Classes # ------------------------------------------------
[docs]class WorldWrapper(object): """ Provides methods to handle a minecraft world like :meth:`start`, :meth:`stop`, :meth:`restart`, ... The WorldWrapper is initialised using the configuration options in the section with the name *name* in the :file:`server.conf` configuration file. """ # Screen prefix for the minecraft-server sessions. # DO NOT CHANGE THIS VALUE IF A WORLD IS ONLINE! _SCREEN_PREFIX = "minecraft_" #: Signal, that is emitted when a world has been uninstalled. world_uninstalled = blinker.signal("world_uninstalled") #: Signal, that is emitted when a world is about to start. world_about_to_start = blinker.signal("world_about_to_start") #: Signal, that is emitted when a world has been started. world_started = blinker.signal("world_started") #: Signal, that is emitted when a world could not be started. world_start_failed = blinker.signal("world_start_failed") #: Signal, that is emitted when a world is about to be stopped. world_about_to_stop = blinker.signal("world_about_to_stop") #: Signal, that is emitted when a world has been stopped. world_stopped = blinker.signal("world_stopped") #: Signal, that is emitted when a world could not be stopped. world_stop_failed = blinker.signal("world_stop_failed") def __init__(self, app, name): """ """ log.info("initialising world '{}' ...".format(name)) self._app = app # The name of the world. self._name = name # The whole dedicated configuration file. self._world_conf = app.conf().world(name) # Get the configuration section. self._conf = self._world_conf["world"] self._check_conf() # The ServerWrapper for the server that powers this world. self._server = app.server().get(self._conf["server"]) if not self._server.is_installed(): self._server.install() # The directory that contains the world data. self._directory = app.paths().world(name) return None def _check_conf(self): """ Checks if the configuration contains only valid values and types (float, int, ...). :raises ValueError: If a configration option has an invalid value. :raises TypeError: If a configuration option has an invalid type. """ # stop timeout if not self._conf["stop_timeout"].isdecimal(): raise TypeError("{} - conf:stop_timeout is not a positive integer"\ .format(self._name)) # stop delay if not self._conf["stop_delay"].isdecimal(): raise TypeError("{} - conf:stop_delay is not a positive integer"\ .format(self._name)) # server if not self._conf["server"] in self._app.server().get_names(): raise ValueError("{} - conf:server does not exist"\ .format(self._name)) return None
[docs] def worldpath_to_ospath(self, rel_path): """ Converts *rel_path*, that is relative to the root directory of the minecraft world, into the absolute path of the operating system. **Example:** .. code-block:: python >>> # I assume the EMSM root is: "/home/minecraft" >>> foo.name() "foo" >>> foo.worldpath_to_ospath("server.properties") "/opt/minecraft/worlds/foo/server.properties" .. seealso:: * :meth:`directory` """ return os.path.join(self._directory, rel_path)
[docs] def conf(self): """ The configuration section of this world in the :file:`name.world.conf` configuration file:: .. code-block:: ini # morpheus.world.conf [world] server = vanilla 1.8 .. seealso:: * :class:`~emsm.core.conf.WorldConfiguration` """ return self._conf
[docs] def server(self): """ The :class:`~emsm.core.server.ServerWrapper` for the server that runs this world. """ return self._server
[docs] def set_server(self, server): """ Changes the server that runs this world. The world has to be offline. :param emsm.core.server.ServerWrapper server: The new server :raises WorldIsOnlineError* if the world is online. """ if self.is_online(): raise WorldIsOnlineError(self) # Break, if we have nothing to do. if server is self._server: return None self._server = server self._conf["server"] = server.name() log.info("assigned '{}' server to the world '{}'."\ .format(server.name(), self._name) ) return None
[docs] def name(self): """ The name of the world. This is the name of the configuration section in :file:`worlds.conf` and the folder name in the :file:`worlds` directory. """ return self._name
[docs] def screen_name(self): """ Returns the name of the screen sessions that run the server of this world. """ return WorldWrapper._SCREEN_PREFIX + self._name
[docs] def directory(self): """ Returns the directory that contains all world data generated by the minecraft server. If the world is run by the *mojang* minecraft server, this directory contains the :file:`server.properties`, :file:`whitelist.json`, ... files. """ return self._directory
[docs] def address(self): """ Returns the binding (ip, port) of the world. If those values can not be retrieved, ``(None, None)`` is returned. """ return self._server.world_address(self)
[docs] def latest_log(self): """ Returns the log of the world since the last start. If the logfile does not exist, an empty string will be returned. """ # Matches all lines in the log, that signalize the start of # a server. re_start_line = self._server.log_start_re() log_path = os.path.abspath( os.path.join(self._directory, self._server.log_path()) ) try: last_log = io.StringIO() with open(log_path) as log: for line in log: if re.match(re_start_line, line): last_log = io.StringIO() last_log.write(line) last_log = last_log.getvalue() except (FileNotFoundError, IOError) as err: last_log = str() return last_log
[docs] def pids(self): """ Returns a list with the pids of the screen sessions with the name :meth:`screen_name`. """ # Get sessions # XXX: screen -ls seems to exit always with the exit code 1. # so it's convenient to use gestatusoutput. status, output = subprocess.getstatusoutput("screen -ls") # Example output (without the '>' char): # # > foo@bar:~$ screen -ls # > There is a screen on: # > 20405.minecraft_barz (07/08/13 14:42:15) (Detached) # > 1 Socket in /var/run/screen/S-foo. # Filter the PIDs re_pid = re.compile( "^\s*(\d+?).{screen_name}".format(screen_name=self.screen_name()), re.MULTILINE ) pids = re.findall(re_pid, output) pids = [int(pid) for pid in pids] return pids
[docs] def is_online(self): """ Returns ``True`` if the world is currently running. """ return bool(self.pids())
[docs] def is_offline(self): """ Returns ``True`` if the world is currently **not** running. """ return not self.is_online()
[docs] def send_command(self, server_cmd): """ Sends the given command to all screen sessions with the world's screen name. :raises WorldIsOfflineError: if the world is offline. .. warning:: There is no guarantee, that the server reacted to the command. """ pids = self.pids() # Break if the world is offline. if not pids: raise WorldIsOfflineError(self) # Translate the server command for *cross-server* support. server_cmd = self._server.translate_command(server_cmd) # Quote the command. # The '\n' simulates pressing the ENTER key in a screen session. server_cmd += "\n\n" server_cmd = shlex.quote(server_cmd) # Send the command to the server. for pid in pids: sys_cmd = "screen -S {0}.{1} -p 0 -X stuff {2}"\ .format(pid, shlex.quote(self.screen_name()), server_cmd) sys_cmd = shlex.split(sys_cmd) subprocess.call(sys_cmd) return None
[docs] def send_command_get_output(self, server_cmd, timeout=10, poll_intervall=0.2): """ Like :meth:`send_commmand` but checks every *poll_intervall* seconds, if content has been added to the logfile and returns the change. If no change could be detected after *timeout* seconds, an error will be raised. :raises WorldIsOfflineError: if the world is offline. :raises WorldCommandTimeout: if the world did not react within *timeout* seconds. """ log_path = os.path.abspath( os.path.join(self._directory, self._server.log_path()) ) # Save the current size of the logfile to detect changes. try: with open(log_path) as log: log.seek(0, 2) offset = log.tell() except (FileNotFoundError, IOError): offset = 0 # Send the command. self.send_command(server_cmd) # Parse the logfile for a change. start_time = time.time() output = str() while (not output) and time.time() - start_time < timeout: time.sleep(poll_intervall) try: with open(log_path) as log: log.seek(offset, 0) output = log.read() except (FileNotFoundError, IOError): break if not output: raise WorldCommandTimeout(self) return output
[docs] def open_console(self): """ Opens **all** screen sessions whichs pid is in :meth:`pids`. :raises WorldIsOfflineError: if the world is offline. """ pids = self.pids() # Break if the world is offline. if not pids: raise WorldIsOfflineError(self) # Open all world screen sessions (one for each found pid). for pid in pids: sys_cmd = "screen -x {pid}".format(pid=pid) try: subprocess.check_call(shlex.split(sys_cmd)) except subprocess.CalledProcessError as error: # It's probably not the terminal of the user, # so try this one. sys_cmd = "script -c {} /dev/null"\ .format(shlex.quote(sys_cmd)) subprocess.check_call( shlex.split(sys_cmd), stdin = sys.stdin, stdout = sys.stdout, stderr = sys.stderr ) return None
[docs] def is_installed(self): """ Returns ``True`` if the :meth:`directory` of the world exists, otherwise ``False``. .. seealso:: * :meth:`directory` * :meth:`install` * :meth:`uninstall` """ return os.path.exists(self._directory) \ and os.path.isdir(self._directory)
[docs] def install(self): """ Creates the directory of the world. .. seealso:: * meth:`directory` """ try: os.makedirs(self._directory, exist_ok=True) # XXX: Fixes an error with sudo / su except FileExistsError: pass return None
[docs] def uninstall(self): """ Stops the world and removes the world directory. .. seealso:: * :meth:`kill_processes` * :meth:`directory` """ self.kill_processes() # Try 5 times to remove the directory. This is necessary, if the # world was online and fixes a problem with *server.log.lck*. for i in range(5): try: shutil.rmtree(self._directory) except FileExistsError: time.sleep(0.5) else: break # Remove the configuration. self._world_conf.remove() # Emit the corresponing signal to this event. WorldWrapper.world_uninstalled.send(self) return None
[docs] def start(self, wait_check_time=0.1): """ Starts the world if the world is offline. If the world is already online, nothing happens. **Signals:** * :attr:`world_about_to_start` * :attr:`world_started` * :attr:`world_start_failed` :param float wait_check_time: Time waited, before checking if the server actually started. :raises WorldStartFailed: if the world could not be started. """ global _SCREEN # Break if the world is already online. if self.is_online(): return None WorldWrapper.world_about_to_start.send(self) # We need to change the current working directory to the world's # directory so that the server starts in the correct environment. old_wd = os.getcwd() os.chdir(self.directory()) try: if os.path.samefile(os.getcwd(), self.directory()): sys_cmd = "{screen} -dmS {screen_name} {start_cmd}".format( screen = _SCREEN, screen_name = shlex.quote(self.screen_name()), start_cmd = self._server.start_cmd(self.name()) ) # Check if a screenrc file should be used. # # 1.) Check if it has been defined in the world's configuration # file. # 2.) Check if a screenrc path is given in the global # configuration. screenrc_path = None if self._world_conf.has_option("emsm", "screenrc"): screenrc_path = self._world_conf["emsm"]["screenrc"] if (not screenrc_path) \ and self._app.conf().main().has_option("emsm", "screenrc"): screenrc_path = self._app.conf().main()["emsm"]["screenrc"] if screenrc_path: sys_cmd += " -c {}".format(shlex.quote(screenrc_path)) # Fire off the start command. sys_cmd = shlex.split(sys_cmd) subprocess.call(sys_cmd) finally: # We may have not the rights to change back. # E.g.: The EMSM was invoked in /root, but we dropped privileges. try: os.chdir(old_wd) except OSError: pass # Check if the world is really online. time.sleep(wait_check_time) if not self.is_online(): WorldWrapper.world_start_failed.send(self) raise WorldStartFailed(self) WorldWrapper.world_started.send(self) return None
[docs] def kill_processes(self): """ Kills all processes with a pid in :meth:`pids`. **Signals:** * :attr:`world_about_to_stop` * :attr:`world_stopped` * :attr:`world_stop_failed` :raises WorldStopFailed: if the process could not be killed. .. seealso:: * :meth:`pids` """ pids = self.pids() # Break if the world is already offline. if not pids: return None # Kill all processes. WorldWrapper.world_about_to_stop.send(self) for pid in pids: os.kill(pid, signal.SIGTERM) # Check if the world is now offline. if self.is_online(): WorldWrapper.world_stop_failed.send(self) raise WorldStopFailed(self) WorldWrapper.world_stopped.send(self) return None
[docs] def stop(self, force_stop=False, message=None, delay=None, timeout=None): """ Stops the server. :param str message: Send to the world before the :meth:`stop` command is executed. :param float delay: Time in seconds that is waited between seding the *message* and executing the :meth`stop` command. :param float timeout: Maximum time in seconds waited for the server stop after executing the :meth:`stop` command. :param bool force_stop: If true and the server could not be stopped, :meth:`kill_processes` is called. **Signals:** * :attr:`world_about_to_stop` * :attr:`world_stopped` * :attr:`world_stop_failed` :raises WorldStopFailed: if the world could not be stopped. .. seealso:: * :meth:`kill_processes` * :meth:`is_offline` """ # Break if the world is already offline. if self.is_offline(): return None WorldWrapper.world_about_to_stop.send(self) # Get the default parameter values from the configuration. if message is None: message = self._conf["stop_message"] if delay is None: delay = int(self._conf["stop_delay"]) if timeout is None: timeout = int(self._conf["stop_timeout"]) # Send the stop_message. for line in message.split("\n"): line = line.strip() self.send_command("say {}".format(line)) # Save the world and wait delay seconds to make sure the # world is saved and the stop_message can be read. self.send_command("save-all") time.sleep(delay) # Stop the world. self.send_command("stop") start_time = time.time() while self.is_online() and time.time() - start_time < timeout: time.sleep(0.25) # Force the stop if necessary. if force_stop: self.kill_processes() # Check if the world is offline. if self.is_online(): WorldWrapper.world_stop_failed.send(self) raise WorldStopFailed(self) WorldWrapper.world_stopped.send(self) return None
[docs] def restart(self, force_restart=False, stop_args=None): """ Restarts the server. :param bool force_restart: Forces the stop of the server by calling :meth:`kill_processes`` if necessary. :param dict stop_args: If provided, these values are passed to :meth:`stop`. **Signals:** * :attr:`world_about_to_stop` * :attr:`world_stopped` * :attr:`world_stop_failed` * :attr:`world_about_to_start` * :attr:`world_started` * :attr:`world_start_failed` :raises WorldStopFailed: if the world could not be stopped. :raises WorldStartFailed: if the world could not be restarted. .. seealso:: * :meth:`stop` * :meth:`start` """ if stop_args is None: stop_args = dict() self.stop(force_stop=force_restart, **stop_args) self.start() return None
[docs]class WorldManager(object): """ Works as a container for the :class:`WorldWrapper` instances. """ def __init__(self, app): self._app = app # Maps the name of the world to the world wrapper # world.name() => world self._worlds = dict() WorldWrapper.world_uninstalled.connect(self._remove) return None
[docs] def load_worlds(self): """ Loads all worlds declared in the :file:`worlds.conf` configuration file. .. seealso:: * :class:`~emsm.core.conf.WorldsConfiguration` """ world_names = self._app.conf().list_worlds() for name in world_names: world = WorldWrapper(self._app, name) self._worlds[world.name()] = world # Make sure the folder exists. if not world.is_installed(): world.install() return None
# container # -------------------------------------------- def _remove(self, world): """ Removes the :class:`WorldWrapper` *world* from the internal map. """ if world.name() in self._worlds: del self._worlds[world.name()] return None
[docs] def get(self, worldname): """ Returns the :class:`WorldWrapper` for the world with the name *worldname* or ``None`` if there is no world with that name. """ return self._worlds.get(worldname)
[docs] def get_all(self): """ Returns a list with all loaded worlds. """ return list(self._worlds.values())
[docs] def get_by_pred(self, pred=None): """ Filters the worlds using the predicate *pred*. **Example:** .. code-block:: python >>> # All running worlds >>> wm.get_by_pred(lambda w: w.is_online()) ... .. seealso:: * :meth:`get_all` """ return list(filter(pred, self._worlds.values()))
[docs] def get_selected(self): """ Returns all worlds that have been selected per command line argument. .. seealso:: * :meth:`emsm.core.argparse_.ArgumentParser.args` """ args = self._app.argparser().args() selected_worlds = args.worlds all_worlds = args.all_worlds if all_worlds: return list(self._worlds.values()) else: return [self._worlds[world] for world in selected_worlds]
[docs] def get_names(self): """ Returns a list with the names of all worlds. """ return list(self._worlds.keys())