diff --git a/README.md b/README.md index d39af743..2a906778 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,9 @@ Black Hat Asia 2023 + + Black Hat USA 2023 +

Join us on Discord

diff --git a/exegol-docker-build b/exegol-docker-build index 4b8aa946..ad28264b 160000 --- a/exegol-docker-build +++ b/exegol-docker-build @@ -1 +1 @@ -Subproject commit 4b8aa9464301674e20195876f31ccc90284d97cc +Subproject commit ad28264bd1698f45d522866d0033fb53942dbd98 diff --git a/exegol/config/ConstantConfig.py b/exegol/config/ConstantConfig.py index 0c7b194a..c5c00b3c 100644 --- a/exegol/config/ConstantConfig.py +++ b/exegol/config/ConstantConfig.py @@ -5,7 +5,7 @@ class ConstantConfig: """Constant parameters information""" # Exegol Version - version: str = "4.2.4" + version: str = "4.2.5" # Exegol documentation link documentation: str = "https://exegol.rtfd.io/" diff --git a/exegol/config/DataCache.py b/exegol/config/DataCache.py index 87c60474..cee1309f 100644 --- a/exegol/config/DataCache.py +++ b/exegol/config/DataCache.py @@ -8,7 +8,7 @@ class DataCache(DataFileUtils, metaclass=MetaSingleton): """This class allows loading cached information defined configurations - Exemple of data: + Example of data: { wrapper: { update: { diff --git a/exegol/console/TUI.py b/exegol/console/TUI.py index 1d70bb51..70b43b0b 100644 --- a/exegol/console/TUI.py +++ b/exegol/console/TUI.py @@ -422,7 +422,7 @@ def printContainerRecap(cls, container: ExegolContainerTemplate): container_info_header += f" - v.{container.image.getImageVersion()}" if "Unknown" not in container.image.getStatus(): container_info_header += f" ({container.image.getStatus(include_version=False)})" - if container.image.getArch() != EnvInfo.arch or logger.isEnabledFor(ExeLog.VERBOSE): + if container.image.getArch().split('/')[0] != EnvInfo.arch or logger.isEnabledFor(ExeLog.VERBOSE): color = ConsoleFormat.getArchColor(container.image.getArch()) container_info_header += f" [{color}]({container.image.getArch()})[/{color}]" recap.add_column(container_info_header) diff --git a/exegol/manager/UpdateManager.py b/exegol/manager/UpdateManager.py index f802be36..aa6df1f2 100644 --- a/exegol/manager/UpdateManager.py +++ b/exegol/manager/UpdateManager.py @@ -143,7 +143,7 @@ def __updateGit(gitUtils: GitUtils) -> bool: if current_branch is None: logger.warning("HEAD is detached. Please checkout to an existing branch.") current_branch = "unknown" - if logger.isEnabledFor(ExeLog.VERBOSE) or current_branch not in ["master", "main"]: + if logger.isEnabledFor(ExeLog.VERBOSE): available_branches = gitUtils.listBranch() # Ask to checkout only if there is more than one branch available if len(available_branches) > 1: @@ -289,7 +289,10 @@ def isUpdateTag(cls) -> bool: @classmethod def display_latest_version(cls) -> str: - return f"[blue]v{DataCache().get_wrapper_data().last_version}[/blue]" + last_version = DataCache().get_wrapper_data().last_version + if len(last_version) == 8 and '.' not in last_version: + return f"[bright_black]\[{last_version}][/bright_black]" + return f"[blue]v{last_version}[/blue]" @classmethod def __untagUpdateAvailable(cls, current_version: Optional[str] = None): diff --git a/exegol/model/ContainerConfig.py b/exegol/model/ContainerConfig.py index 2357ab50..1164a584 100644 --- a/exegol/model/ContainerConfig.py +++ b/exegol/model/ContainerConfig.py @@ -588,10 +588,19 @@ def prepareShare(self, share_name: str): # Skip default volume workspace if disabled return else: - # Add shared-data-volumes private workspace bind volume + # Add dedicated private workspace bind volume volume_path = str(UserConfig().private_volume_path.joinpath(share_name)) self.addVolume(volume_path, '/workspace', enable_sticky_group=True) + def rollback_preparation(self, share_name: str): + """Undo preparation in case of container creation failure""" + if self.__workspace_custom_path is None and not self.__disable_workspace: + # Remove dedicated workspace volume + logger.info("Rollback: removing dedicated workspace directory") + directory_path = UserConfig().private_volume_path.joinpath(share_name) + if directory_path.is_dir(): + directory_path.rmdir() + def setNetworkMode(self, host_mode: Optional[bool]): """Set container's network mode, true for host, false for bridge""" if host_mode is None: @@ -852,7 +861,7 @@ def removeVolume(self, host_path: Optional[str] = None, container_path: Optional """Remove a volume from the container configuration (Only before container creation)""" if host_path is None and container_path is None: # This is a dev problem - raise ReferenceError('At least one parameter must be set') + raise ValueError('At least one parameter must be set') for i in range(len(self.__mounts)): # For each Mount object compare the host_path if supplied or the container_path si supplied if host_path is not None and self.__mounts[i].get("Source") == host_path: diff --git a/exegol/model/ExegolContainer.py b/exegol/model/ExegolContainer.py index d2cfccf6..05563be1 100644 --- a/exegol/model/ExegolContainer.py +++ b/exegol/model/ExegolContainer.py @@ -212,6 +212,9 @@ def __removeVolume(self): list_files = [] else: return + except FileNotFoundError: + logger.debug("This workspace has already been removed.") + return try: if len(list_files) > 0: # Directory is not empty diff --git a/exegol/model/ExegolContainerTemplate.py b/exegol/model/ExegolContainerTemplate.py index e39260c1..213f4ae3 100644 --- a/exegol/model/ExegolContainerTemplate.py +++ b/exegol/model/ExegolContainerTemplate.py @@ -3,6 +3,7 @@ from rich.prompt import Prompt +from exegol.config.EnvInfo import EnvInfo from exegol.model.ContainerConfig import ContainerConfig from exegol.model.ExegolImage import ExegolImage @@ -14,6 +15,9 @@ def __init__(self, name: Optional[str], config: ContainerConfig, image: ExegolIm if name is None: name = Prompt.ask("[bold blue][?][/bold blue] Enter the name of your new exegol container", default="default") assert name is not None + if (EnvInfo.isWindowsHost() or EnvInfo.isMacHost()) and not name.startswith("exegol-"): + # Force container as lowercase because the filesystem of windows / mac are case-insensitive => https://github.com/ThePorgs/Exegol/issues/167 + name = name.lower() self.container_name: str = name if name.startswith("exegol-") else f'exegol-{name}' self.name: str = name.replace('exegol-', '') if hostname: @@ -31,6 +35,10 @@ def prepare(self): """Prepare the model before creating the docker container""" self.config.prepareShare(self.name) + def rollback(self): + """Rollback change in case of container creation fail.""" + self.config.rollback_preparation(self.name) + def getDisplayName(self) -> str: """Getter of the container's name for TUI purpose""" if self.container_name != self.hostname: diff --git a/exegol/model/ExegolImage.py b/exegol/model/ExegolImage.py index f5c46728..ffa67df0 100644 --- a/exegol/model/ExegolImage.py +++ b/exegol/model/ExegolImage.py @@ -616,7 +616,7 @@ def getName(self) -> str: def getDisplayName(self) -> str: """Image's display name getter""" result = self.__alt_name if self.__alt_name else self.__name - if self.getArch() != ParametersManager().arch or logger.isEnabledFor(ExeLog.VERBOSE): + if self.getArch().split('/')[0] != ParametersManager().arch or logger.isEnabledFor(ExeLog.VERBOSE): color = ConsoleFormat.getArchColor(self.getArch()) result += f" [{color}]({self.getArch()})[/{color}]" return result diff --git a/exegol/utils/DataFileUtils.py b/exegol/utils/DataFileUtils.py index 4c3a541f..cfedc5d4 100644 --- a/exegol/utils/DataFileUtils.py +++ b/exegol/utils/DataFileUtils.py @@ -62,16 +62,18 @@ def _build_file_content(self) -> str: This fonction build the default file content. Called when the file doesn't exist yet or have been upgrade and need to be updated. :return: """ - raise NotImplementedError( - f"The '_build_default_file' method hasn't been implemented in the '{self.__class__}' class.") + raise NotImplementedError(f"The '_build_default_file' method hasn't been implemented in the '{self.__class__}' class.") def _create_config_file(self): """ Create or overwrite the file content to the default / current value depending on the '_build_default_file' that must be redefined in child class. :return: """ - with open(self._file_path, 'w') as file: - file.write(self._build_file_content()) + try: + with open(self._file_path, 'w') as file: + file.write(self._build_file_content()) + except PermissionError as e: + logger.critical(f"Unable to open the file '{self._file_path}' ({e}). Please fix your file permissions or run exegol with the correct rights.") def _parse_config(self): data: Dict = {} diff --git a/exegol/utils/DockerUtils.py b/exegol/utils/DockerUtils.py index a65f3fec..ae2752c5 100644 --- a/exegol/utils/DockerUtils.py +++ b/exegol/utils/DockerUtils.py @@ -125,8 +125,20 @@ def createContainer(cls, model: ExegolContainerTemplate, temporary: bool = False auto_remove=temporary, working_dir=model.config.getWorkingDir()) except APIError as err: - logger.error(err.explanation.decode('utf-8') if type(err.explanation) is bytes else err.explanation) + message = err.explanation.decode('utf-8').replace('[', '\\[') if type(err.explanation) is bytes else err.explanation + message = message.replace('[', '\\[') + logger.error(message) logger.debug(err) + model.rollback() + try: + container = cls.__client.containers.list(all=True, filters={"name": model.container_name}) + if container is not None and len(container) > 0: + for c in container: + if c.name == model.container_name: # Search for exact match + container[0].remove() + logger.debug("Container removed") + except Exception: + pass logger.critical("Error while creating exegol container. Exiting.") # Not reachable, critical logging will exit return # type: ignore @@ -151,6 +163,13 @@ def getContainer(cls, tag: str) -> ExegolContainer: return # type: ignore # Check if there is at least 1 result. If no container was found, raise ObjectNotFound. if container is None or len(container) == 0: + # Handle case-insensitive OS + if EnvInfo.isWindowsHost() or EnvInfo.isMacHost(): + # First try to fetch the container as-is (for retroactive support with old container with uppercase characters) + # If the user's input didn't match any container, try to force the name in lowercase if not already tried + lowered_tag = tag.lower() + if lowered_tag != tag: + return cls.getContainer(lowered_tag) raise ObjectNotFound # Filter results with exact name matching for c in container: diff --git a/exegol/utils/GuiUtils.py b/exegol/utils/GuiUtils.py index c385fc1a..a597428e 100644 --- a/exegol/utils/GuiUtils.py +++ b/exegol/utils/GuiUtils.py @@ -2,6 +2,7 @@ import os import shutil import subprocess +import sys import time from pathlib import Path from typing import Optional @@ -92,6 +93,9 @@ def __macGuiChecks(cls) -> bool: # Notify user to change configuration logger.error("XQuartz does not allow network connections. " "You need to manually change the configuration to 'Allow connections from network clients'") + # Add sys.platform check to exclude windows env (fix for mypy static code analysis) + if sys.platform != "win32" and os.getuid() == 0: + logger.warning("You are running exegol as [red]root[/red]! The root user cannot check in the user context whether XQuartz is properly configured or not.") return False # Check if XQuartz is started, check is dir exist and if there is at least one socket diff --git a/exegol/utils/WebUtils.py b/exegol/utils/WebUtils.py index 7aabee7a..e43a437c 100644 --- a/exegol/utils/WebUtils.py +++ b/exegol/utils/WebUtils.py @@ -92,7 +92,7 @@ def getRemoteVersion(cls, tag: str) -> Optional[str]: response = cls.__runRequest(url, service_name="Docker Registry", headers=manifest_headers, method="GET") version: Optional[str] = None if response is not None and response.status_code == 200: - data = json.loads(response.text) + data = json.loads(response.content.decode("utf-8")) # Parse metadata of the current image from v1 schema metadata = json.loads(data.get("history", [])[0]['v1Compatibility']) # Find version label and extract data @@ -106,7 +106,7 @@ def runJsonRequest(cls, url: str, service_name: str, headers: Optional[Dict] = N return None data = cls.__runRequest(url, service_name, headers, method, data, retry_count) if data is not None and data.status_code == 200: - data = json.loads(data.text) + data = json.loads(data.content.decode("utf-8")) elif data is not None: logger.error(f"Error during web request to {service_name} ({data.status_code}) on {url}") if data.status_code == 404 and service_name == "Dockerhub": @@ -127,7 +127,7 @@ def __runRequest(cls, url: str, service_name: str, headers: Optional[Dict] = Non response = requests.request(method=method, url=url, timeout=(5, 10), verify=ParametersManager().verify, headers=headers, data=data) return response except requests.exceptions.HTTPError as e: - logger.error(f"Response error: {e.response.text}") + logger.error(f"Response error: {e.response.content.decode('utf-8')}") except requests.exceptions.ConnectionError as err: logger.debug(f"Error: {err}") error_re = re.search(r"\[Errno [-\d]+]\s?([^']*)('\))+\)*", str(err)) diff --git a/tests/test_exegol.py b/tests/test_exegol.py index b98740f9..1687cd35 100644 --- a/tests/test_exegol.py +++ b/tests/test_exegol.py @@ -2,4 +2,4 @@ def test_version(): - assert __version__ == '4.2.4' + assert __version__ == '4.2.5'