def tail_file(self, remove_offset: bool = True) -> Iterable: log_file = get_server_path(self.config.server_log) offset_file = get_server_path(".log_offset") if remove_offset: self.delete_offset() return Pygtail(log_file, offset_file=offset_file)
def install( self, force: bool, beta: bool, old: bool, enable: bool, minecraft_version: str, *args, **kwargs, ) -> int: """ installs a specific version of Minecraft """ latest, versions = self._get_minecraft_versions(beta, old) if minecraft_version is None: minecraft_version = latest elif minecraft_version not in versions: raise click.BadParameter( "could not find minecraft version", self.context, get_param_obj(self.context, "minecraft_version"), ) self.logger.debug("minecraft version:") self.logger.debug(versions[minecraft_version]) jar_dir = get_server_path("jars") jar_file = f"minecraft_server.{minecraft_version}.jar" jar_path = os.path.join(jar_dir, jar_file) if os.path.isdir(jar_dir): if os.path.isfile(jar_path): if force: os.remove(jar_path) else: raise click.BadParameter( f"minecraft v{minecraft_version} already installed", self.context, get_param_obj(self.context, "minecraft_version"), ) else: os.makedirs(jar_dir) self.logger.info(f"downloading v{minecraft_version}...") version = get_json(versions[minecraft_version]["url"]) download_file( version["downloads"]["server"]["url"], jar_path, sha1=version["downloads"]["server"]["sha1"], ) self.logger.success(f"minecraft v{minecraft_version} installed") link_path = get_server_path(self.config.server_jar) if not os.path.isfile(link_path) or enable: return self.invoke( self.enable, minecraft_version=minecraft_version, ) return STATUS_SUCCESS
def _check_steam_for_update(self, app_id: str, branch: str): manifest_file = get_server_path( ["steamapps", f"appmanifest_{app_id}.acf"]) if not os.path.isfile(manifest_file): self.logger.debug("No local manifet") return True manifest = None with open(manifest_file, "r") as f: manifest = acf.load(f) stdout = self.run_command( (f"{self.config.steamcmd_path} +app_info_update 1 " f"+app_info_print {app_id} +quit"), redirect_output=True, ) index = stdout.find(f'"{app_id}"') app_info = acf.loads(stdout[index:]) try: current_buildid = app_info[app_id]["depots"]["branches"][branch][ "buildid"] except KeyError: self.logger.debug("Failed to parse remote manifest") return True self.logger.debug(f"current: {manifest['AppState']['buildid']}") self.logger.debug(f"latest: {current_buildid}") return manifest["AppState"]["buildid"] != current_buildid
def get_logger() -> logging.getLoggerClass(): logging.setLoggerClass(ClickLogger) logger = logging.getLogger("gs_manager") if not logger.hasHandlers(): logger.setLevel(logging.DEBUG) log_dir = get_server_path("logs") if not os.path.isdir(log_dir): os.mkdir(log_dir) log_path = os.path.join(log_dir, "gs_manager.log") log_file = None try: log_file = open(log_path, "a") except PermissionError: log_file = open(os.devnull, "w") formatter = logging.Formatter( "%(asctime)s - %(name)s - %(levelname)s - %(message)s") handler = logging.StreamHandler(log_file) handler.setFormatter(formatter) logger.addHandler(handler) logger.propagate = False logger.click_debug = click.get_current_context().params["debug"] return logger
def start( self, no_verify: bool, foreground: bool, accept_eula: bool, add_property: List[Dict[str, str]], remove_property: List[str], *args, **kwargs, ) -> int: """ starts Minecraft server """ if add_property or remove_property: for prop in add_property: self.config.mc.update(prop) for prop in remove_property: del self.config.mc[prop] self.config.save_mc() if accept_eula: eula_path = get_server_path("eula.txt") with open(eula_path, "w") as f: f.write("eula=true") return self.invoke( super().start, no_verify=no_verify, foreground=foreground, )
def restore(self, list_backups: bool, num: int, backup_num: int, *args, **kwargs) -> int: """ edits a server file with your default editor """ backup_folder = os.path.join(self.config.backup_location, "backups") restore_folder = os.path.join(self.config.backup_location, "restore") backups = [] if os.path.isdir(backup_folder): for backup in os.listdir(backup_folder): if backup.startswith(self.backup_name): backups.append(backup) backups = sorted(backups) if list_backups: if num >= 0: backups = backups[:num] for index, backup in enumerate(backups): self.logger.info(f"{index:2}: {backup}") return STATUS_SUCCESS if backup_num > len(backups): self.logger.error(f"Backup {backup_num} does not exist") return STATUS_FAILED if self.is_running(): self.logger.error(f"{self.server_name} is still running") return STATUS_FAILED self.logger.info("Cleaning up previous restore...") for old_backup in os.listdir(self.config.backup_location): if old_backup.endswith(".tar.gz"): os.remove(os.path.join(self.config.backup_location, old_backup)) if os.path.isdir(restore_folder): rmtree(restore_folder) os.mkdir(restore_folder) self.logger.info("Extacting backup...") backup_file = os.path.join(self.config.backup_location, backups[backup_num]) copyfile(os.path.join(backup_folder, backups[backup_num]), backup_file) with tarfile.open(backup_file) as tar: tar.extractall(path=restore_folder) config_file = os.path.join(restore_folder, DEFAULT_CONFIG) if os.path.isfile(config_file): os.remove(config_file) self.logger.info("Restoring backup...") copy_tree( os.path.join(restore_folder, self.config.backup_directory), get_server_path(self.config.backup_directory), preserve_times=1, ) return STATUS_SUCCESS
def save_starbound(self) -> None: config_path = get_server_path(["storage", "starbound_server.config"]) with open(config_path, "w") as config_file: json.dump(self.starbound, config_file, indent=4, sort_keys=True) self._starbound_config = None self.starbound
def _was_running_from_disk(self): was_running = False start_servers = get_server_path(".start_servers") if os.path.exists(start_servers): with open(start_servers, "r") as f: was_running = f.read().strip().split(",") os.remove(start_servers) return was_running
def save_mc(self) -> None: property_path = get_server_path("server.properties") server_property_string = "" for key, value in self.mc.items(): server_property_string += f"{key}={value}\n" with open(property_path, "w") as f: f.write(server_property_string) self._mc_config = None self.mc
def validate(value) -> str: # do not validate file paths inside of service dir if context is # not active yet try: click.get_current_context() except RuntimeError: return value if not os.path.isdir(get_server_path(value)): raise ValueError(f"{value} does not exist") return value
def backup(self, *args, **kwargs) -> int: """ edits a server file with your default editor """ backup_folder = os.path.join(self.config.backup_location, "backups") timestamp = (datetime.now().isoformat(timespec="minutes").replace( ":", "-")) backup_file = f"{self.backup_name}_{timestamp}.tar.gz" os.makedirs(backup_folder, exist_ok=True) if self._command_exists("save_command"): self.logger.info(f"Saving servers...") current_instance = self.config.instance_name multi_instance = self.config.multi_instance self.set_instance(None, False) self.invoke( self.command, command_string=self.config.save_command, do_print=False, parallel=True, current_instance="@all", ) self.set_instance(current_instance, multi_instance) self.logger.info(f"Making server backup ({backup_file})...") with tarfile.open(os.path.join(backup_folder, backup_file), "w:gz") as tar: tar.add( get_server_path(self.config.backup_directory), arcname=self.config.backup_directory, ) tar.add(self.config.config_path, arcname=DEFAULT_CONFIG) for path in self.config.backup_extra_paths: if os.path.exists(path): tar.add(path, os.path.basename(path)) else: self.logger.warning(f"{path} does not exist") old_backups = [] now = time.time() for backup in os.listdir(backup_folder): abs_path = os.path.join(backup_folder, backup) if os.stat(abs_path).st_mtime < now - 7 * 86400: old_backups.append(abs_path) if len(old_backups) > 0: self.logger.info(f"Deleting {len(old_backups)} old backups...") for backup in old_backups: os.remove(backup) return STATUS_SUCCESS
def enable(self, force: bool, minecraft_version: str, *args, **kwargs) -> int: """ enables a specific version of Minecraft """ if self.is_running(): self.logger.error(f"{self.server_name} is still running") return STATUS_FAILED jar_dir = get_server_path("jars") jar_file = f"minecraft_server.{minecraft_version}.jar" jar_path = os.path.join(jar_dir, jar_file) link_path = get_server_path(self.config.server_jar) if not os.path.isfile(jar_path): raise click.BadParameter( f"minecraft v{minecraft_version} is not installed", self.context, get_param_obj(self.context, "minecraft_version"), ) if not (os.path.islink(link_path) or force or not os.path.isfile(link_path)): raise click.ClickException( f"{self.config.server_jar} is not a symbolic link, " "use -f to override") if os.path.isfile(link_path): if os.path.realpath(link_path) == jar_path: raise click.BadParameter( f"minecraft v{minecraft_version} already enabled", self.context, get_param_obj(self.context, "minecraft_version"), ) os.remove(link_path) self.run_command(f"ln -s {jar_path} {link_path}") self.logger.success(f"minecraft v{minecraft_version} enabled") return STATUS_SUCCESS
def versions(self, beta: bool, old: bool, installed: bool, num: int, *args, **kwargs) -> int: """ lists versions of Minecraft """ jar_dir = get_server_path("jars") installed_versions = [] for root, dirs, files in os.walk(jar_dir): for filename in files: if filename.endswith(".jar"): parts = filename.split(".") installed_versions.append(".".join(parts[1:-1])) if installed: if num > 0: installed_versions = installed_versions[:num] for version in installed_versions: self.logger.info(f"{version} (installed)") else: latest, versions = self._get_minecraft_versions(beta, old) display_versions = [] if old: display_versions = [ v["id"] for v in versions.values() if v["type"].startswith("old_") ] elif beta: display_versions = [ v["id"] for v in versions.values() if v["type"].startswith("snapshot") ] else: display_versions = versions.keys() if num > 0: display_versions = display_versions[:num] for version in display_versions: extra = "" if version == latest: extra = "(latest)" if version in installed_versions: if extra == "": extra = "(installed)" else: extra = "(latest,installed)" self.logger.info(f"{version} {extra}") return STATUS_SUCCESS
def edit(self, force: bool, edit_path: str, *args, **kwargs) -> int: """ edits a server file with your default editor """ if not force and self.is_running(): self.logger.warning(f"{self.server_name} is still running") return STATUS_PARTIAL_FAIL file_path = get_server_path(edit_path) editor = os.environ.get("EDITOR") or "vim" self.run_command( f"{editor} {file_path}", redirect_output=False, ) return STATUS_SUCCESS
def _stop_servers(self, was_running, reason: Optional[str] = None): current_instance = self.config.instance_name multi_instance = self.config.multi_instance if reason is None: reason = "Updates found" if self._command_exists("say_command"): self.logger.info("notifying users...") self.set_instance(None, False) self.invoke( self.say, command_string=f"{reason}. Server restarting in 5 minutes", do_print=False, parallel=True, current_instances=f"@each:{','.join(was_running)}", ) self._wait(300 - self.config.pre_stop) if self._command_exists("save_command"): self.logger.info("saving servers...") self.set_instance(None, False) self.invoke( self.command, command_string=self.config.save_command, do_print=False, parallel=True, current_instances=f"@each:{','.join(was_running)}", ) self.set_instance(None, False) self.invoke( self.stop, force=False, reason="New updates found.", verb="restarting", parallel=True, current_instances=f"@each:{','.join(was_running)}", ) self.set_instance(current_instance, multi_instance) with open(get_server_path(".start_servers"), "w") as f: if isinstance(was_running, bool): f.write("default") else: f.write(",".join(was_running))
def starbound(self) -> dict: if self._starbound_config is None: self._starbound_config = {} config_path = get_server_path( ["storage", "starbound_server.config"]) if not os.path.isfile(config_path): self.logger.warn("could not find starbound_server.config for " "Starbound server") return {} with open(config_path) as config_file: self._starbound_config = json.load(config_file) self._update_config() return self._starbound_config
def mc(self) -> Dict[str, str]: if self._mc_config is None: self._mc_config = {} config_path = get_server_path("server.properties") if not os.path.isfile(config_path): raise click.ClickException( "could not find server.properties for Minecraft server") lines = [] with open(config_path) as config_file: for line in config_file: if not line.startswith("#"): lines.append(line) self._mc_config = KeyValuePairsType.validate(lines) return self._mc_config
def start_command(self) -> str: if self._start_command is None: config_args = _make_command_args(self.ark_config) server_command = get_server_path( ["ShooterGame", "Binaries", "Linux", "ShooterGameServer"]) command = ("{} {} -server -servergamelog -log " "-servergamelogincludetribelogs").format( server_command, config_args, ) if "automanagedmods" in command: raise click.BadParameter( "-automanagedmods option is not supported") self._start_command = command return self._start_command
def install( self, allow_run: bool, force: bool, stop: bool, restart: bool, app_id: Optional[int] = None, *args, **kwargs, ) -> int: """ installs/validates/updates the ARK server """ status = self.invoke( super().install, app_id=app_id, allow_run=allow_run, force=force, stop=stop, restart=restart, ) self.logger.debug("super status: {}".format(status)) if status == STATUS_SUCCESS: steamcmd_dir = get_server_path( ["Engine", "Binaries", "ThirdParty", "SteamCMD", "Linux"]) steamcmd_path = os.path.join(steamcmd_dir, "steamcmd.sh") if not os.path.isdir(steamcmd_dir): os.makedirs(steamcmd_dir, exist_ok=True) if not os.path.isfile(steamcmd_path): self.logger.info("installing Steam locally for ARK...") old_path = os.getcwd() os.chdir(steamcmd_dir) filename = download_file(STEAM_DOWNLOAD_URL) self.run_command("tar -xf {}".format(filename)) os.remove(os.path.join(steamcmd_dir, filename)) self.run_command("{} +quit".format(steamcmd_path)) os.chdir(old_path) self.logger.success("Steam installed successfully") return status
def start( self, no_verify: bool, foreground: bool, start_command: Optional[str] = None, *args, **kwargs, ) -> int: """ starts gameserver """ if self.is_running(): self.logger.warning(f"{self.server_name} is already running") return STATUS_PARTIAL_FAIL self._delete_pid_file() self.logger.info(f"starting {self.server_name}...", nl=False) command = start_command or self.config.start_command popen_kwargs = {} if self.config.spawn_process and not foreground: log_file_path = get_server_path( ["logs", f"{self.backup_name}.log"]) command = f"nohup {command}" popen_kwargs = { "return_process": True, "redirect_output": False, "stdin": DEVNULL, "stderr": STDOUT, "stdout": PIPE, "start_new_session": True, } elif foreground: popen_kwargs = { "redirect_output": False, } try: response = self.run_command( command, cwd=get_server_path(self.config.start_directory), **popen_kwargs, ) except CalledProcessError: self.logger.error("unexpected error from server") if foreground: return if self.config.spawn_process: self.run_command( f"cat > {log_file_path}", return_process=True, redirect_output=False, stdin=response.stdout, stderr=DEVNULL, stdout=DEVNULL, ) if self.config.wait_start > 0: time.sleep(self.config.wait_start) self._find_pid() if no_verify: return STATUS_SUCCESS return self._startup_check()
def workshop_download( self, allow_run: bool, force: bool, stop: bool, restart: bool, *args, **kwargs, ) -> int: """ downloads Steam workshop items """ was_running = False if not force: needs_update = self._check_steam_for_update( str(self.config.workshop_id), "public") if not needs_update: self.logger.success( f"{self.config.workshop_id} is already on latest version") self._start_servers(restart, was_running) return STATUS_SUCCESS if not allow_run: was_running = self.is_running(check_all=True) if was_running: if not (restart or stop): self.logger.warning( f"at least once instance of {self.config.app_id} " "is still running") return STATUS_PARTIAL_FAIL self._stop_servers(was_running, reason="Updates found for workshop app") status = self.invoke( self.install, app_id=self.config.workshop_id, allow_run=True, force=force, ) if not status == STATUS_SUCCESS: return status if len(self.config.workshop_items) == 0: self.logger.warning("\nno workshop items selected for install") return STATUS_PARTIAL_FAIL mods_to_update = [] manifest_file = get_server_path([ "steamapps", "workshop", f"appworkshop_{self.config.workshop_id}.acf", ], ) if not force and os.path.isfile(manifest_file): manifest = None with open(manifest_file, "r") as f: manifest = acf.load(f) self.logger.info("checking for updates for workshop items...") with click.progressbar(self.config.workshop_items) as bar: for workshop_item in bar: workshop_item = str(workshop_item) if (workshop_item not in manifest["AppWorkshop"] ["WorkshopItemsInstalled"]): mods_to_update.append(workshop_item) continue last_update_time = int( manifest["AppWorkshop"]["WorkshopItemsInstalled"] [workshop_item]["timeupdated"]) try: latest_metadata = self._get_published_file( workshop_item) except requests.HTTPError: self.logger.error( "\ncould not query Steam for updates") return STATUS_FAILED newest_update_time = int( latest_metadata["response"]["publishedfiledetails"][0] ["time_updated"]) if last_update_time < newest_update_time: mods_to_update.append(workshop_item) else: mods_to_update = self.config.workshop_items if len(mods_to_update) == 0: self.logger.success("all workshop items already up to date") self._start_servers(restart, was_running) return STATUS_SUCCESS self.logger.info("downloading workshop items...") with click.progressbar(mods_to_update) as bar: for workshop_item in bar: try: self.run_command( (f"{self.config.steamcmd_path} " f"{self._steam_login()} +force_install_dir " f"{self.config.server_path} " "+workshop_download_item " f"{self.config.workshop_id} {workshop_item} +quit")) except CalledProcessError: self.logger.error("\nfailed to validate workshop items") return STATUS_FAILED self.logger.success("\nvalidated workshop items") self._start_servers(restart, was_running) return STATUS_SUCCESS
def workshop_download( self, allow_run: bool, force: bool, stop: bool, restart: bool, *args, **kwargs, ) -> int: """ downloads and installs ARK mods """ status = self.invoke( super().workshop_download, allow_run=True, force=force, stop=stop, restart=False, ) self.logger.debug("super status: {}".format(status)) if status == STATUS_SUCCESS: mod_path = get_server_path(["ShooterGame", "Content", "Mods"]) base_src_dir = get_server_path([ "steamapps", "workshop", "content", str(self.config.workshop_id), ]) mods_to_update = [] manifest_file = get_server_path([ "steamapps", "workshop", f"appworkshop_{self.config.workshop_id}.acf", ]) if not force and os.path.isfile(manifest_file): manifest = None with open(manifest_file, "r") as f: manifest = acf.load(f) for workshop_item in self.config.workshop_items: workshop_item = str(workshop_item) mod_dir = os.path.join(mod_path, str(workshop_item)) mod_file = os.path.join(mod_path, "{}.mod".format(workshop_item)) if not os.path.isfile(mod_file) or ( workshop_item not in manifest["AppWorkshop"] ["WorkshopItemsInstalled"]): mods_to_update.append(workshop_item) continue last_update_time = int( manifest["AppWorkshop"]["WorkshopItemsInstalled"] [workshop_item]["timeupdated"]) last_extract_time = os.path.getctime(mod_file) if last_update_time > last_extract_time: mods_to_update.append(workshop_item) else: mods_to_update = self.config.workshop_items mods_to_update = self.str_mods(mods_to_update) if len(mods_to_update) == 0: was_running = self.is_running("@any") # automatically check for any servers shutdown by install if not was_running: self._start_servers(restart, was_running) return STATUS_SUCCESS self.logger.info( f"{len(mods_to_update)} mod(s) need to be extracted: " f"{','.join(mods_to_update)}") was_running = self.is_running("@any") if was_running: if not (restart or stop): self.logger.warning( (f"at least once instance of {self.config.app_id}" " is still running")) return STATUS_PARTIAL_FAIL self._stop_servers( was_running, reason=(f"Updates found for {len(mods_to_update)} " f"mod(s): {','.join(mods_to_update)}"), ) self.logger.info("extracting mods...") with click.progressbar(mods_to_update) as bar: for workshop_item in bar: src_dir = os.path.join(base_src_dir, str(workshop_item)) branch_dir = os.path.join( src_dir, "{}NoEditor".format(self.config.workshop_branch), ) mod_dir = os.path.join(mod_path, str(workshop_item)) mod_file = os.path.join(mod_path, "{}.mod".format(workshop_item)) if not os.path.isdir(src_dir): self.logger.error( "could not find workshop item: {}".format( self.config.workshop_id)) return STATUS_FAILED elif os.path.isdir(branch_dir): src_dir = branch_dir if os.path.isdir(mod_dir): self.logger.debug( "removing old mod_dir of {}...".format( workshop_item)) shutil.rmtree(mod_dir) if os.path.isfile(mod_file): self.logger.debug( "removing old mod_file of {}...".format( workshop_item)) os.remove(mod_file) self.logger.debug("copying {}...".format(workshop_item)) shutil.copytree(src_dir, mod_dir) if not self._create_mod_file(mod_dir, mod_file, workshop_item): self.logger.error( "could not create .mod file for {}".format( workshop_item)) return STATUS_FAILED if not self._extract_files(mod_dir): return STATUS_FAILED self.logger.success("workshop items successfully installed") if status == STATUS_SUCCESS: self._start_servers(restart, was_running) return status
def _get_pid_file_path(self) -> str: return get_server_path(self._get_pid_filename())
def delete_offset(self): offset_file = get_server_path(".log_offset") if os.path.isfile(offset_file): os.remove(offset_file)