diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 8fb37d07..1812ea5c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,6 +1,6 @@ name: 🐞 Bug report [WRAPPER] description: Report a bug in Exegol WRAPPER to help us improve it -title: "[BUG] " +title: "<title>" labels: - bug body: diff --git a/.github/workflows/entrypoint_nightly.yml b/.github/workflows/entrypoint_nightly.yml index 9219d6ef..7c07ac77 100644 --- a/.github/workflows/entrypoint_nightly.yml +++ b/.github/workflows/entrypoint_nightly.yml @@ -15,6 +15,10 @@ jobs: build-n-publish: name: Build and publish Python 🐍 distributions to TestPyPI 📦 runs-on: ubuntu-latest + environment: nightly + permissions: + # IMPORTANT: this permission is mandatory for trusted publishing + id-token: write needs: test steps: - uses: actions/checkout@master @@ -33,6 +37,5 @@ jobs: - name: Publish distribution 📦 to Test PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: - password: ${{ secrets.TEST_PYPI_API_TOKEN }} repository-url: https://test.pypi.org/legacy/ skip-existing: true diff --git a/.github/workflows/entrypoint_prerelease.yml b/.github/workflows/entrypoint_prerelease.yml index 81ce57c2..1e201c0d 100644 --- a/.github/workflows/entrypoint_prerelease.yml +++ b/.github/workflows/entrypoint_prerelease.yml @@ -4,19 +4,38 @@ on: pull_request: branches: - "master" - paths-ignore: + paths-ignore: # not always respected. See https://github.com/actions/runner/issues/2324#issuecomment-1703345084 - ".github/**" - "**.md" jobs: - test: + preprod_test: + name: Pre-prod code testing + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + with: + submodules: false + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.10" + - name: Find spawn.sh script version + run: egrep '^# Spawn Version:[0-9ab]+$' ./exegol/utils/imgsync/spawn.sh | cut -d ':' -f2 + - name: Check for prod readiness of spawn.sh script version + run: egrep '^# Spawn Version:[0-9]+$' ./exegol/utils/imgsync/spawn.sh + - name: Check package version (alpha and beta version cannot be released) + run: python3 -c 'from exegol.config.ConstantConfig import ConstantConfig; print(ConstantConfig.version); exit(any(c in ConstantConfig.version for c in ["a", "b"]))' + + code_test: name: Python tests and checks + needs: preprod_test uses: ./.github/workflows/sub_testing.yml build: name: Build Python 🐍 distributions runs-on: ubuntu-latest - needs: test + needs: code_test steps: - uses: actions/checkout@master with: diff --git a/.github/workflows/entrypoint_pull_request.yml b/.github/workflows/entrypoint_pull_request.yml index de2b2fa4..59d06ea0 100644 --- a/.github/workflows/entrypoint_pull_request.yml +++ b/.github/workflows/entrypoint_pull_request.yml @@ -7,7 +7,7 @@ on: pull_request: branches-ignore: - "master" - paths-ignore: + paths-ignore: # not always respected. See https://github.com/actions/runner/issues/2324#issuecomment-1703345084 - ".github/**" - "**.md" push: diff --git a/.github/workflows/entrypoint_release.yml b/.github/workflows/entrypoint_release.yml index 7cf0aa28..722cffd2 100644 --- a/.github/workflows/entrypoint_release.yml +++ b/.github/workflows/entrypoint_release.yml @@ -13,6 +13,10 @@ jobs: build-n-publish: name: Build and publish Python 🐍 distributions to PyPI 📦 runs-on: ubuntu-latest + environment: release + permissions: + # IMPORTANT: this permission is mandatory for trusted publishing + id-token: write needs: test steps: - uses: actions/checkout@master @@ -31,10 +35,7 @@ jobs: - name: Publish distribution 📦 to Test PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: - password: ${{ secrets.TEST_PYPI_API_TOKEN }} repository-url: https://test.pypi.org/legacy/ skip-existing: true - name: Publish distribution 📦 to PyPI (prod) uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/sub_testing.yml b/.github/workflows/sub_testing.yml index b7ed4829..de5cc5a5 100644 --- a/.github/workflows/sub_testing.yml +++ b/.github/workflows/sub_testing.yml @@ -14,11 +14,15 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: "3.10" + python-version: "3.12" - name: Install requirements run: python -m pip install --user mypy types-requests types-PyYAML - - name: Run code analysis + - name: Run code analysis (package) run: mypy ./exegol/ --ignore-missing-imports --check-untyped-defs --pretty # TODO add --disallow-untyped-defs + - name: Run code analysis (source) + run: mypy ./exegol.py --ignore-missing-imports --check-untyped-defs --pretty # TODO add --disallow-untyped-defs + - name: Find spawn.sh script version + run: egrep '^# Spawn Version:[0-9ab]+$' ./exegol/utils/imgsync/spawn.sh | cut -d ':' -f2 compatibility: name: Compatibility checks @@ -27,7 +31,7 @@ jobs: strategy: fail-fast: false matrix: - version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] os: [win32, linux, darwin] steps: - uses: actions/checkout@master @@ -36,7 +40,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: "3.10" + python-version: "3.12" - name: Install requirements run: python -m pip install --user mypy types-requests types-PyYAML - name: Check python compatibility for ${{ matrix.os }}/${{ matrix.version }} diff --git a/README.md b/README.md index 2a906778..6f156230 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ <br><br> <a target="_blank" rel="noopener noreferrer" href="https://pypi.org/project/Exegol" title=""><img src="https://img.shields.io/pypi/v/Exegol?color=informational" alt="pip package version"></a> <img alt="Python3.7" src="https://img.shields.io/badge/Python-3.7+-informational"> - <a target="_blank" rel="noopener noreferrer" href="https://pepy.tech/project/exegol" title=""><img src="https://static.pepy.tech/personalized-badge/exegol?period=total&units=international_system&left_color=grey&right_color=brightgreen&left_text=Downloads" alt="pip stats"></a> + <img alt="latest commit on master" src="https://img.shields.io/docker/pulls/nwodtuhs/exegol.svg?label=downloads"> <br><br> <img alt="latest commit on master" src="https://img.shields.io/github/last-commit/ThePorgs/Exegol/master?label=latest%20release"> <img alt="latest commit on dev" src="https://img.shields.io/github/last-commit/ThePorgs/Exegol/dev?label=latest%20dev"> @@ -37,7 +37,7 @@ # Getting started -You can refer to the [Exegol documentations](https://exegol.readthedocs.io/en/latest/getting-started/install.html). +You can refer to the [Exegol documentation](https://exegol.readthedocs.io/en/latest/getting-started/install.html). > Full documentation homepage: https://exegol.rtfd.io/. diff --git a/exegol-docker-build b/exegol-docker-build index ad28264b..ef1bc9cc 160000 --- a/exegol-docker-build +++ b/exegol-docker-build @@ -1 +1 @@ -Subproject commit ad28264bd1698f45d522866d0033fb53942dbd98 +Subproject commit ef1bc9cc98632a3bc7a02c97a9f7855f4bccbde4 diff --git a/exegol-resources b/exegol-resources index 4c0dee13..fd97ffe9 160000 --- a/exegol-resources +++ b/exegol-resources @@ -1 +1 @@ -Subproject commit 4c0dee13ad16fa42947c1677dbd02a1f4456df90 +Subproject commit fd97ffe9b10fd18cf13f57864e8a04fc68c0e43a diff --git a/exegol/config/ConstantConfig.py b/exegol/config/ConstantConfig.py index c5c00b3c..6742d634 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.5" + version: str = "4.3.0" # Exegol documentation link documentation: str = "https://exegol.rtfd.io/" @@ -15,6 +15,10 @@ class ConstantConfig: # Path of the Dockerfile build_context_path_obj: Path build_context_path: str + # Path of the entrypoint.sh + entrypoint_context_path_obj: Path + # Path of the spawn.sh + spawn_context_path_obj: Path # Exegol config directory exegol_config_path: Path = Path().home() / ".exegol" # Docker Desktop for mac config file @@ -33,13 +37,12 @@ class ConstantConfig: EXEGOL_RESOURCES_REPO: str = "https://github.com/ThePorgs/Exegol-resources.git" @classmethod - def findBuildContextPath(cls) -> Path: - """Find the right path to the build context from Exegol docker images. + def findResourceContextPath(cls, resource_folder: str, source_path: str) -> Path: + """Find the right path to the resources context from Exegol package. Support source clone installation and pip package (venv / user / global context)""" - dockerbuild_folder_name = "exegol-docker-build" - local_src = cls.src_root_path_obj / dockerbuild_folder_name - if local_src.is_dir(): - # If exegol is clone from github, build context is accessible from root src + local_src = cls.src_root_path_obj / source_path + if local_src.is_dir() or local_src.is_file(): + # If exegol is clone from GitHub, build context is accessible from root src return local_src else: # If install from pip @@ -51,13 +54,16 @@ def findBuildContextPath(cls) -> Path: possible_locations.append(Path(loc).parent.parent.parent) # Find a good match for test in possible_locations: - context_path = test / dockerbuild_folder_name + context_path = test / resource_folder if context_path.is_dir(): return context_path # Detect a venv context - return Path(site.PREFIXES[0]) / dockerbuild_folder_name + return Path(site.PREFIXES[0]) / resource_folder # Dynamically built attribute must be set after class initialization -ConstantConfig.build_context_path_obj = ConstantConfig.findBuildContextPath() +ConstantConfig.build_context_path_obj = ConstantConfig.findResourceContextPath("exegol-docker-build", "exegol-docker-build") ConstantConfig.build_context_path = str(ConstantConfig.build_context_path_obj) + +ConstantConfig.entrypoint_context_path_obj = ConstantConfig.findResourceContextPath("exegol-imgsync", "exegol/utils/imgsync/entrypoint.sh") +ConstantConfig.spawn_context_path_obj = ConstantConfig.findResourceContextPath("exegol-imgsync", "exegol/utils/imgsync/spawn.sh") diff --git a/exegol/config/DataCache.py b/exegol/config/DataCache.py index cee1309f..08d355d5 100644 --- a/exegol/config/DataCache.py +++ b/exegol/config/DataCache.py @@ -59,6 +59,21 @@ def get_images_data(self) -> ImagesCacheModel: def update_image_cache(self, images: List): """Refresh image cache data""" - cache_images = [ImageCacheModel(img.getName(), img.getLatestVersion(), img.getLatestRemoteId(), "local" if img.isLocal() else "remote") for img in images] + logger.debug("Updating image cache data") + cache_images = [] + for img in images: + name = img.getName() + version = img.getLatestVersion() + remoteid = img.getLatestRemoteId() + type = "local" if img.isLocal() else "remote" + logger.debug(f"└── {name} (version: {version})\t→ ({type}) {remoteid}") + cache_images.append( + ImageCacheModel( + name, + version, + remoteid, + type + ) + ) self.__cache_data.images = ImagesCacheModel(cache_images) self.save_updates() diff --git a/exegol/config/EnvInfo.py b/exegol/config/EnvInfo.py index ff54b9fa..d0eb1a17 100644 --- a/exegol/config/EnvInfo.py +++ b/exegol/config/EnvInfo.py @@ -1,8 +1,5 @@ import json import platform -import re -import shutil -import subprocess from enum import Enum from typing import Optional, Any, List @@ -24,7 +21,7 @@ class DockerEngine(Enum): """Dictionary class for static Docker engine name""" WLS2 = "WSL2" HYPERV = "Hyper-V" - MAC = "Docker desktop" + DOCKER_DESKTOP = "Docker desktop" ORBSTACK = "Orbstack" LINUX = "Kernel" @@ -88,8 +85,8 @@ def initData(cls, docker_info): cls.__docker_host_os = cls.HostOs.WINDOWS elif cls.__is_docker_desktop: # If docker desktop is detected but not a Windows engine/kernel, it's (probably) a mac - cls.__docker_engine = cls.DockerEngine.MAC - cls.__docker_host_os = cls.HostOs.MAC + cls.__docker_engine = cls.DockerEngine.DOCKER_DESKTOP + cls.__docker_host_os = cls.HostOs.MAC if cls.is_mac_shell else cls.HostOs.LINUX elif is_orbstack: # Orbstack is only available on Mac cls.__docker_engine = cls.DockerEngine.ORBSTACK @@ -99,6 +96,9 @@ def initData(cls, docker_info): cls.__docker_engine = cls.DockerEngine.LINUX cls.__docker_host_os = cls.HostOs.LINUX + if cls.__docker_engine == cls.DockerEngine.DOCKER_DESKTOP and cls.__docker_host_os == cls.HostOs.LINUX: + logger.warning(f"Using Docker Desktop on Linux is not officially supported !") + @classmethod def getHostOs(cls) -> HostOs: """Return Host OS @@ -114,24 +114,8 @@ def getWindowsRelease(cls) -> str: if cls.is_windows_shell: # From a Windows shell, python supply an approximate (close enough) version of windows cls.__windows_release = platform.win32_ver()[1] - elif cls.current_platform == "WSL": - # From a WSL shell, we must create a process to retrieve the host's version - # Find version using MS-DOS command 'ver' - if not shutil.which("cmd.exe"): - logger.critical("cmd.exe is not accessible from your WSL environment!") - proc = subprocess.Popen(["cmd.exe", "/c", "ver"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) - proc.wait() - assert proc.stdout is not None - # Try to match Windows version - matches = re.search(r"version (\d+\.\d+\.\d+)(\.\d*)?", proc.stdout.read().decode('utf-8')) - if matches: - # Select match 1 and apply to the attribute - cls.__windows_release = matches.group(1) - else: - # If there is any match, fallback to empty - cls.__windows_release = "" else: - cls.__windows_release = "" + cls.__windows_release = "Unknown" return cls.__windows_release @classmethod diff --git a/exegol/config/UserConfig.py b/exegol/config/UserConfig.py index 8fe503c4..c32b873d 100644 --- a/exegol/config/UserConfig.py +++ b/exegol/config/UserConfig.py @@ -13,6 +13,7 @@ class UserConfig(DataFileUtils, metaclass=MetaSingleton): # Static choices start_shell_options = {'zsh', 'bash', 'tmux'} shell_logging_method_options = {'script', 'asciinema'} + desktop_available_proto = {'http', 'vnc'} def __init__(self): # Defaults User config @@ -25,11 +26,15 @@ def __init__(self): self.default_start_shell: str = "zsh" self.shell_logging_method: str = "asciinema" self.shell_logging_compress: bool = True + self.desktop_default_enable: bool = False + self.desktop_default_localhost: bool = True + self.desktop_default_proto: str = "http" super().__init__("config.yml", "yml") def _build_file_content(self): config = f"""# Exegol configuration +# Full documentation: https://exegol.readthedocs.io/en/latest/exegol-wrapper/advanced-uses.html#id1 # Volume path can be changed at any time but existing containers will not be affected by the update volumes: @@ -63,11 +68,22 @@ def _build_file_content(self): # Enable automatic compression of log files (with gzip) enable_log_compression: {self.shell_logging_compress} + + # Configure your Exegol Desktop + desktop: + # Enables or not the desktop mode by default + # If this attribute is set to True, then using the CLI --desktop option will be inverted and will DISABLE the feature + enabled_by_default: {self.desktop_default_enable} + + # Default desktop protocol,can be "http", or "vnc" (additional protocols to come in the future, check online documentation for updates). + default_protocol: {self.desktop_default_proto} + + # Desktop service is exposed on localhost by default. If set to true, services will be exposed on localhost (127.0.0.1) otherwise it will be exposed on 0.0.0.0. This setting can be overwritten with --desktop-config + localhost_by_default: {self.desktop_default_localhost} """ # TODO handle default image selection # TODO handle default start container - # TODO add custom build profiles path return config @staticmethod @@ -105,6 +121,12 @@ def _process_data(self): self.shell_logging_method = self._load_config_str(shell_logging_data, 'logging_method', self.shell_logging_method, choices=self.shell_logging_method_options) self.shell_logging_compress = self._load_config_bool(shell_logging_data, 'enable_log_compression', self.shell_logging_compress) + # Desktop section + desktop_data = config_data.get("desktop", {}) + self.desktop_default_enable = self._load_config_bool(desktop_data, 'enabled_by_default', self.desktop_default_enable) + self.desktop_default_proto = self._load_config_str(desktop_data, 'default_proto', self.desktop_default_proto, choices=self.desktop_available_proto) + self.desktop_default_localhost = self._load_config_bool(desktop_data, 'localhost_by_default', self.desktop_default_localhost) + def get_configs(self) -> List[str]: """User configs getter each options""" configs = [ @@ -118,6 +140,9 @@ def get_configs(self) -> List[str]: f"Default start shell: [blue]{self.default_start_shell}[/blue]", f"Shell logging method: [blue]{self.shell_logging_method}[/blue]", f"Shell logging compression: {boolFormatter(self.shell_logging_compress)}", + f"Desktop enabled by default: {boolFormatter(self.desktop_default_enable)}", + f"Desktop default protocol: [blue]{self.desktop_default_proto}[/blue]", + f"Desktop default host: [blue]{'localhost' if self.desktop_default_localhost else '0.0.0.0'}[/blue]", ] # TUI can't be called from here to avoid circular importation return configs diff --git a/exegol/console/ExegolPrompt.py b/exegol/console/ExegolPrompt.py index c7c4fa8f..015e67f6 100644 --- a/exegol/console/ExegolPrompt.py +++ b/exegol/console/ExegolPrompt.py @@ -3,7 +3,7 @@ def Confirm(question: str, default: bool) -> bool: """Quick function to format rich Confirmation and options on every exegol interaction""" - default_text = "[bright_magenta][Y/n][/bright_magenta]" if default else "[bright_magenta]\[y/N][/bright_magenta]" + default_text = "[bright_magenta][Y/n][/bright_magenta]" if default else "[bright_magenta][y/N][/bright_magenta]" formatted_question = f"[bold blue][?][/bold blue] {question} {default_text}" return rich.prompt.Confirm.ask( formatted_question, diff --git a/exegol/console/TUI.py b/exegol/console/TUI.py index 70b43b0b..e1469b82 100644 --- a/exegol/console/TUI.py +++ b/exegol/console/TUI.py @@ -145,7 +145,7 @@ def buildDockerImage(build_stream: Generator): else: logger.raw(stream_text, level=ExeLog.ADVANCED) if ': FROM ' in stream_text: - logger.info("Downloading docker image") + logger.info("Downloading base image") ExegolTUI.downloadDockerLayer(build_stream, quick_exit=True) if logfile is not None: logfile.close() @@ -213,9 +213,9 @@ def __buildImageTable(table: Table, data: Sequence[ExegolImage], safe_key: bool image.getRealSize(), image.getBuildDate(), image.getStatus()) else: if safe_key: - table.add_row(str(i + 1), image.getDisplayName(), image.getSize(), image.getStatus()) + table.add_row(str(i + 1), image.getDisplayName(), image.getRealSize(), image.getStatus()) else: - table.add_row(image.getDisplayName(), image.getSize(), image.getStatus()) + table.add_row(image.getDisplayName(), image.getRealSize(), image.getStatus()) @staticmethod def __buildContainerTable(table: Table, data: Sequence[ExegolContainer], safe_key: bool = False): @@ -396,8 +396,27 @@ def selectFromList(cls, @classmethod def printContainerRecap(cls, container: ExegolContainerTemplate): + """ + Build and print a rich table with every configuration of the container + :param container: Exegol container to print the table of + :return: + """ # Load the image status if it is not already set. container.image.autoLoad() + + recap = cls.__buildContainerRecapTable(container) + + logger.empty_line() + console.print(recap) + logger.empty_line() + + @staticmethod + def __buildContainerRecapTable(container: ExegolContainerTemplate): + """ + Build a rich table to recap in detail the configuration of a specified ExegolContainerTemplate or ExegolContainer + :param container: The container to fetch config from + :return: A rich table fully built + """ # Fetch data devices = container.config.getTextDevices(logger.isEnabledFor(ExeLog.VERBOSE)) envs = container.config.getTextEnvs(logger.isEnabledFor(ExeLog.VERBOSE)) @@ -407,12 +426,13 @@ def printContainerRecap(cls, container: ExegolContainerTemplate): volumes = container.config.getTextMounts(logger.isEnabledFor(ExeLog.VERBOSE)) creation_date = container.config.getTextCreationDate() comment = container.config.getComment() + passwd = container.config.getPasswd() # Color code privilege_color = "bright_magenta" path_color = "magenta" - logger.empty_line() + # Build table recap = Table(border_style="grey35", box=box.SQUARE, title_justify="left", show_header=True) recap.title = "[not italic]:white_medium_star: [/not italic][gold3][g]Container summary[/g][/gold3]" # Header @@ -429,9 +449,12 @@ def printContainerRecap(cls, container: ExegolContainerTemplate): # Main features if comment: recap.add_row("[bold blue]Comment[/bold blue]", comment) + if passwd: + recap.add_row(f"[bold blue]Credentials[/bold blue]", f"[deep_sky_blue3]{container.config.getUsername()}[/deep_sky_blue3] : [deep_sky_blue3]{passwd}[/deep_sky_blue3]") + recap.add_row("[bold blue]Desktop[/bold blue]", container.config.getDesktopConfig()) if creation_date: recap.add_row("[bold blue]Creation date[/bold blue]", creation_date) - recap.add_row("[bold blue]GUI[/bold blue]", boolFormatter(container.config.isGUIEnable())) + recap.add_row("[bold blue]X11[/bold blue]", boolFormatter(container.config.isGUIEnable())) recap.add_row("[bold blue]Network[/bold blue]", container.config.getTextNetworkMode()) recap.add_row("[bold blue]Timezone[/bold blue]", boolFormatter(container.config.isTimezoneShared())) recap.add_row("[bold blue]Exegol resources[/bold blue]", boolFormatter(container.config.isExegolResourcesEnable()) + @@ -466,8 +489,7 @@ def printContainerRecap(cls, container: ExegolContainerTemplate): recap.add_row("[bold blue]Systctls[/bold blue]", os.linesep.join( [f"[{privilege_color}]{key}[/{privilege_color}] = {getColor(value)[0]}{value}{getColor(value)[1]}" for key, value in sysctls.items()])) - console.print(recap) - logger.empty_line() + return recap @classmethod def __isInteractionAllowed(cls): diff --git a/exegol/console/cli/ExegolCompleter.py b/exegol/console/cli/ExegolCompleter.py index bd3c62bc..e2beca5c 100644 --- a/exegol/console/cli/ExegolCompleter.py +++ b/exegol/console/cli/ExegolCompleter.py @@ -1,7 +1,10 @@ from argparse import Namespace +from pathlib import Path from typing import Tuple +from exegol.config.ConstantConfig import ConstantConfig from exegol.config.DataCache import DataCache +from exegol.config.UserConfig import UserConfig from exegol.manager.UpdateManager import UpdateManager from exegol.utils.DockerUtils import DockerUtils @@ -57,13 +60,34 @@ def BuildProfileCompleter(prefix: str, parsed_args: Namespace, **kwargs) -> Tupl # The build profile completer must be trigger only when an image name have been set by user if parsed_args is not None and parsed_args.imagetag is None: return () - data = list(UpdateManager.listBuildProfiles().keys()) + + # Default build path + build_path = ConstantConfig.build_context_path_obj + # Handle custom build path + if parsed_args is not None and parsed_args.build_path is not None: + custom_build_path = Path(parsed_args.build_path).expanduser().absolute() + # Check if we have a directory or a file to select the project directory + if not custom_build_path.is_dir(): + custom_build_path = custom_build_path.parent + build_path = custom_build_path + + # Find profile list + data = list(UpdateManager.listBuildProfiles(profiles_path=build_path).keys()) for obj in data: if prefix and not obj.lower().startswith(prefix.lower()): data.remove(obj) return tuple(data) +def DesktopConfigCompleter(prefix: str, **kwargs) -> Tuple[str, ...]: + options = list(UserConfig.desktop_available_proto) + for obj in options: + if prefix and not obj.lower().startswith(prefix.lower()): + options.remove(obj) + # TODO add interface enum + return tuple(options) + + def VoidCompleter(**kwargs) -> Tuple: """No option to auto-complet""" return () diff --git a/exegol/console/cli/actions/ExegolParameters.py b/exegol/console/cli/actions/ExegolParameters.py index 8ba3b9df..cbe0addb 100644 --- a/exegol/console/cli/actions/ExegolParameters.py +++ b/exegol/console/cli/actions/ExegolParameters.py @@ -4,6 +4,7 @@ from exegol.manager.ExegolManager import ExegolManager from exegol.manager.UpdateManager import UpdateManager from exegol.utils.ExeLog import logger +from exegol.config.ConstantConfig import ConstantConfig class Start(Command, ContainerCreation, ContainerSpawnShell): @@ -21,9 +22,10 @@ def __init__(self): "Create a container [blue]test[/blue] with a custom shared workspace": "exegol start [blue]test[/blue] [bright_blue]full[/bright_blue] -w [magenta]./project/pentest/[/magenta]", "Create a container [blue]test[/blue] sharing the current working directory": "exegol start [blue]test[/blue] [bright_blue]full[/bright_blue] -cwd", "Create a container [blue]htb[/blue] with a VPN": "exegol start [blue]htb[/blue] [bright_blue]full[/bright_blue] --vpn [magenta]~/vpn/[/magenta][bright_magenta]lab_Dramelac.ovpn[/bright_magenta]", - "Create a container [blue]app[/blue] with custom volume": "exegol start [blue]app[/blue] [bright_blue]full[/bright_blue] -V [bright_magenta]'/var/app/:/app/'[/bright_magenta]", + "Create a container [blue]app[/blue] with custom volume": "exegol start [blue]app[/blue] [bright_blue]full[/bright_blue] -V [bright_magenta]/var/app/[/bright_magenta]:[bright_magenta]/app/[/bright_magenta]", + "Create a container [blue]app[/blue] with custom volume in [blue]ReadOnly[/blue]": "exegol start [blue]app[/blue] [bright_blue]full[/bright_blue] -V [bright_magenta]/var/app/[/bright_magenta]:[bright_magenta]/app/[/bright_magenta]:[blue]ro[/blue]", "Get a [blue]tmux[/blue] shell": "exegol start --shell [blue]tmux[/blue]", - "Share a specific [blue]hardware device[/blue] (like Proxmark)": "exegol start -d /dev/ttyACM0", + "Share a specific [blue]hardware device[/blue] [bright_black](e.g. Proxmark)[/bright_black]": "exegol start -d /dev/ttyACM0", "Share every [blue]USB device[/blue] connected to the host": "exegol start -d /dev/bus/usb/", } @@ -76,7 +78,6 @@ def __init__(self): # Create container build arguments self.build_profile = Option("build_profile", metavar="BUILD_PROFILE", - choices=UpdateManager.listBuildProfiles().keys(), nargs="?", action="store", help="Select the build profile used to create a local image.", @@ -86,10 +87,16 @@ def __init__(self): metavar="LOGFILE_PATH", action="store", help="Write image building logs to a file.") + self.build_path = Option("--build-path", + dest="build_path", + metavar="DOCKERFILES_PATH", + action="store", + help=f"Path to the dockerfiles and sources.") # Create group parameter for container selection self.groupArgs.append(GroupArg({"arg": self.build_profile, "required": False}, {"arg": self.build_log, "required": False}, + {"arg": self.build_path, "required": False}, title="[bold cyan]Build[/bold cyan] [blue]specific options[/blue]")) self._usages = { diff --git a/exegol/console/cli/actions/GenericParameters.py b/exegol/console/cli/actions/GenericParameters.py index d824348c..ca584b30 100644 --- a/exegol/console/cli/actions/GenericParameters.py +++ b/exegol/console/cli/actions/GenericParameters.py @@ -3,7 +3,7 @@ from argcomplete.completers import EnvironCompleter, DirectoriesCompleter, FilesCompleter from exegol.config.UserConfig import UserConfig -from exegol.console.cli.ExegolCompleter import ContainerCompleter, ImageCompleter, VoidCompleter +from exegol.console.cli.ExegolCompleter import ContainerCompleter, ImageCompleter, VoidCompleter, DesktopConfigCompleter from exegol.console.cli.actions.Command import Option, GroupArg @@ -150,7 +150,7 @@ def __init__(self, groupArgs: List[GroupArg]): action="store_false", default=True, dest="X11", - help="Disable display sharing to run GUI-based applications (default: [green]Enabled[/green])") + help="Disable X11 sharing to run GUI-based applications (default: [green]Enabled[/green])") self.my_resources = Option("--disable-my-resources", action="store_false", default=True, @@ -191,7 +191,7 @@ def __init__(self, groupArgs: List[GroupArg]): action="append", default=[], dest="volumes", - help="Share a new volume between host and exegol (format: --volume /path/on/host/:/path/in/container/)") + help="Share a new volume between host and exegol (format: --volume /path/on/host/:/path/in/container/[blue][:ro|rw][/blue])") self.ports = Option("-p", "--port", action="append", default=[], @@ -225,18 +225,6 @@ def __init__(self, groupArgs: List[GroupArg]): action="append", help="Add host [default not bold]device(s)[/default not bold] at the container creation (example: -d /dev/ttyACM0 -d /dev/bus/usb/)") - self.vpn = Option("--vpn", - dest="vpn", - default=None, - action="store", - help="Setup an OpenVPN connection at the container creation (example: --vpn /home/user/vpn/conf.ovpn)", - completer=FilesCompleter(["ovpn"], directories=True)) - self.vpn_auth = Option("--vpn-auth", - dest="vpn_auth", - default=None, - action="store", - help="Enter the credentials with a file (first line: username, second line: password) to establish the VPN connection automatically (example: --vpn-auth /home/user/vpn/auth.txt)") - self.comment = Option("--comment", dest="comment", action="store", @@ -260,6 +248,35 @@ def __init__(self, groupArgs: List[GroupArg]): {"arg": self.comment, "required": False}, title="[blue]Container creation options[/blue]")) + self.vpn = Option("--vpn", + dest="vpn", + default=None, + action="store", + help="Setup an OpenVPN connection at the container creation (example: --vpn /home/user/vpn/conf.ovpn)", + completer=FilesCompleter(["ovpn"], directories=True)) + self.vpn_auth = Option("--vpn-auth", + dest="vpn_auth", + default=None, + action="store", + help="Enter the credentials with a file (first line: username, second line: password) to establish the VPN connection automatically (example: --vpn-auth /home/user/vpn/auth.txt)") + groupArgs.append(GroupArg({"arg": self.vpn, "required": False}, {"arg": self.vpn_auth, "required": False}, title="[blue]Container creation VPN options[/blue]")) + + self.desktop = Option("--desktop", + dest="desktop", + action="store_true", + default=False, + help=f"Enable or disable the Exegol desktop feature (default: {'[green]Enabled[/green]' if UserConfig().desktop_default_enable else '[red]Disabled[/red]'})") + self.desktop_config = Option("--desktop-config", + dest="desktop_config", + default="", + action="store", + help=f"Configure your exegol desktop ([blue]{'[/blue] or [blue]'.join(UserConfig.desktop_available_proto)}[/blue]) and its exposure " + f"(format: [blue]proto[:ip[:port]][/blue]) " + f"(default: [blue]{UserConfig().desktop_default_proto}[/blue]:[blue]{'127.0.0.1' if UserConfig().desktop_default_localhost else '0.0.0.0'}[/blue]:[blue]<random>[/blue])", + completer=DesktopConfigCompleter) + groupArgs.append(GroupArg({"arg": self.desktop, "required": False}, + {"arg": self.desktop_config, "required": False}, + title="[blue]Container creation Desktop options[/blue] [spring_green1](beta)[/spring_green1]")) diff --git a/exegol/manager/ExegolController.py b/exegol/manager/ExegolController.py index 0a1a23b2..169594b1 100644 --- a/exegol/manager/ExegolController.py +++ b/exegol/manager/ExegolController.py @@ -1,9 +1,11 @@ try: - from git.exc import GitCommandError + import docker + import requests + import git + from exegol.utils.ExeLog import logger, ExeLog, console from exegol.console.cli.ParametersManager import ParametersManager from exegol.console.cli.actions.ExegolParameters import Command - from exegol.utils.ExeLog import logger, ExeLog, console except ModuleNotFoundError as e: print("Mandatory dependencies are missing:", e) print("Please install them with python3 -m pip install --upgrade -r requirements.txt") @@ -60,11 +62,11 @@ def main(): except KeyboardInterrupt: logger.empty_line() logger.info("Exiting") - except GitCommandError as e: + except git.exc.GitCommandError as git_error: print_exception_banner() - error = e.stderr.strip().split(": ")[-1].strip("'") - logger.critical(f"A critical error occurred while running this git command: {' '.join(e.command)} => {error}") + error = git_error.stderr.strip().split(": ")[-1].strip("'") + logger.critical(f"A critical error occurred while running this git command: {' '.join(git_error.command)} => {error}") except Exception: print_exception_banner() - console.print_exception(show_locals=True) + console.print_exception(show_locals=True, suppress=[docker, requests, git]) exit(1) diff --git a/exegol/manager/ExegolManager.py b/exegol/manager/ExegolManager.py index 82a03485..efba442d 100644 --- a/exegol/manager/ExegolManager.py +++ b/exegol/manager/ExegolManager.py @@ -21,7 +21,7 @@ from exegol.model.ExegolModules import ExegolModules from exegol.model.SelectableInterface import SelectableInterface from exegol.utils.DockerUtils import DockerUtils -from exegol.utils.ExeLog import logger, ExeLog, console +from exegol.utils.ExeLog import logger, ExeLog class ExegolManager: @@ -95,7 +95,7 @@ def exec(cls): container.stop(timeout=2) else: # Command is passed at container creation in __createTmpContainer() - logger.success(f"Command executed as entrypoint of the container [green]'{container.hostname}'[/green]") + logger.success(f"Command executed as entrypoint of the container {container.getDisplayName()}") else: container = cast(ExegolContainer, cls.__loadOrCreateContainer(override_container=ParametersManager().selector)) container.exec(command=ParametersManager().exec, as_daemon=ParametersManager().daemon) @@ -108,7 +108,7 @@ def stop(cls): container = cls.__loadOrCreateContainer(multiple=True, must_exist=True) assert container is not None and type(container) is list for c in container: - c.stop(timeout=2) + c.stop(timeout=5) @classmethod def restart(cls): @@ -212,16 +212,16 @@ def print_version(cls): logger.warning("You are currently using a [orange3]Beta[/orange3] version of Exegol, which may be unstable.") logger.debug(f"Pip installation: {boolFormatter(ConstantConfig.pip_installed)}") logger.debug(f"Git source installation: {boolFormatter(ConstantConfig.git_source_installation)}") - logger.debug(f"Host OS: {EnvInfo.getHostOs()} [bright_black]({EnvInfo.getDockerEngine()})[/bright_black]") + logger.debug(f"Host OS: {EnvInfo.getHostOs().value} [bright_black]({EnvInfo.getDockerEngine().value})[/bright_black]") logger.debug(f"Arch: {EnvInfo.arch}") if EnvInfo.arch != EnvInfo.raw_arch: logger.debug(f"Raw arch: {EnvInfo.raw_arch}") if EnvInfo.isWindowsHost(): logger.debug(f"Windows release: {EnvInfo.getWindowsRelease()}") logger.debug(f"Python environment: {EnvInfo.current_platform}") - logger.debug(f"Docker engine: {str(EnvInfo.getDockerEngine()).upper()}") + logger.debug(f"Docker engine: {EnvInfo.getDockerEngine().value}") logger.debug(f"Docker desktop: {boolFormatter(EnvInfo.isDockerDesktop())}") - logger.debug(f"Shell type: {EnvInfo.getShellType()}") + logger.debug(f"Shell type: {EnvInfo.getShellType().value}") if not UpdateManager.isUpdateTag() and UserConfig().auto_check_updates: UpdateManager.checkForWrapperUpdate() if UpdateManager.isUpdateTag(): @@ -238,7 +238,6 @@ def print_sponsors(cls): """We thank [link=https://www.capgemini.com/fr-fr/carrieres/offres-emploi/][blue]Capgemini[/blue][/link] for supporting the project [bright_black](helping with dev)[/bright_black] :pray:""") logger.success("""We thank [link=https://www.hackthebox.com/][green]HackTheBox[/green][/link] for sponsoring the [bright_black]multi-arch[/bright_black] support :green_heart:""") - @classmethod def __loadOrInstallImage(cls, override_image: Optional[str] = None, @@ -460,11 +459,11 @@ def __prepareContainerConfig(cls): if ParametersManager().exegol_resources: config.enableExegolResources() if ParametersManager().log: - config.enableShellLogging() + config.enableShellLogging(ParametersManager().log_method, + UserConfig().shell_logging_compress ^ ParametersManager().log_compress) if ParametersManager().workspace_path: if ParametersManager().mount_current_dir: - logger.warning( - f'Workspace conflict detected (-cwd cannot be use with -w). Using: {ParametersManager().workspace_path}') + logger.warning(f'Workspace conflict detected (-cwd cannot be use with -w). Using: {ParametersManager().workspace_path}') config.setWorkspaceShare(ParametersManager().workspace_path) elif ParametersManager().mount_current_dir: config.enableCwdShare() @@ -484,6 +483,8 @@ def __prepareContainerConfig(cls): if ParametersManager().envs is not None: for env in ParametersManager().envs: config.addRawEnv(env) + if UserConfig().desktop_default_enable ^ ParametersManager().desktop: + config.enableDesktop(ParametersManager().desktop_config) if ParametersManager().comment: config.addComment(ParametersManager().comment) return config @@ -530,8 +531,7 @@ def __createTmpContainer(cls, image_name: Optional[str] = None) -> ExegolContain if ParametersManager().daemon: # Using formatShellCommand to support zsh aliases exec_payload, str_cmd = ExegolContainer.formatShellCommand(ParametersManager().exec, entrypoint_mode=True) - config.setLegacyContainerCommand(f"zsh -c '{exec_payload}'") - config.setContainerCommand("cmd", "zsh", "-c", exec_payload) + config.entrypointRunCmd() config.addEnv("CMD", str_cmd) config.addEnv("DISABLE_AUTO_UPDATE", "true") # Workspace must be disabled for temporary container because host directory is never deleted @@ -540,8 +540,11 @@ def __createTmpContainer(cls, image_name: Optional[str] = None) -> ExegolContain image: ExegolImage = cast(ExegolImage, cls.__loadOrInstallImage(override_image=image_name)) model = ExegolContainerTemplate(name, config, image, hostname=ParametersManager().hostname) + # Mount entrypoint as a volume (because in tmp mode the container is created with run instead of create method) + model.config.addVolume(str(ConstantConfig.entrypoint_context_path_obj), "/.exegol/entrypoint.sh", must_exist=True, read_only=True) + container = DockerUtils.createContainer(model, temporary=True) - container.postCreateSetup() + container.postCreateSetup(is_temporary=True) return container @classmethod diff --git a/exegol/manager/UpdateManager.py b/exegol/manager/UpdateManager.py index aa6df1f2..f13699c5 100644 --- a/exegol/manager/UpdateManager.py +++ b/exegol/manager/UpdateManager.py @@ -1,6 +1,7 @@ import re from datetime import datetime, timedelta from typing import Optional, Dict, cast, Tuple, Sequence +from pathlib import Path, PurePath from rich.prompt import Prompt @@ -92,7 +93,7 @@ def updateImage(cls, tag: Optional[str] = None, install_mode: bool = False) -> O def __askToBuild(cls, tag: str) -> Optional[ExegolImage]: """Build confirmation process and image building""" # Need confirmation from the user before starting building. - if ParametersManager().build_profile is not None or \ + if ParametersManager().build_profile is not None or ParametersManager().build_path is not None or \ Confirm("Do you want to build locally a custom image?", default=False): return cls.buildAndLoad(tag) return None @@ -172,15 +173,13 @@ def __updateGit(gitUtils: GitUtils) -> bool: if selected_branch is not None and selected_branch != current_branch: gitUtils.checkout(selected_branch) # git pull - gitUtils.update() - logger.empty_line() - return True + return gitUtils.update() @classmethod def checkForWrapperUpdate(cls) -> bool: """Check if there is an exegol wrapper update available. Return true if an update is available.""" - logger.debug(f"Last wrapper update check: {DataCache().get_wrapper_data().metadata.get_last_check()}") + logger.debug(f"Last wrapper update check: {DataCache().get_wrapper_data().metadata.get_last_check_text()}") # Skipping update check if DataCache().get_wrapper_data().metadata.is_outdated() and not ParametersManager().offline_mode: logger.debug("Running update check") @@ -264,7 +263,7 @@ def display_current_version(): if re.search(r'[a-z]', ConstantConfig.version, re.IGNORECASE): module = ExegolModules().getWrapperGit(fast_load=True) if module.isAvailable: - commit_version = f" [bright_black]\[{str(module.get_current_commit())[:8]}][/bright_black]" + commit_version = f" [bright_black][{str(module.get_current_commit())[:8]}][/bright_black]" return f"[blue]v{ConstantConfig.version}[/blue]{commit_version}" @classmethod @@ -291,7 +290,7 @@ def isUpdateTag(cls) -> bool: def display_latest_version(cls) -> str: 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"[bright_black][{last_version}][/bright_black]" return f"[blue]v{last_version}[/blue]" @classmethod @@ -308,17 +307,20 @@ def __buildSource(cls, build_name: Optional[str] = None) -> str: """build user process : Ask user is he want to update the git source (to get new& updated build profiles), User choice a build name (if not supplied) + User select the path to the dockerfiles (only from CLI parameter) User select a build profile Start docker image building Return the name of the built image""" - # Ask to update git - try: - if ExegolModules().getSourceGit().isAvailable and not ExegolModules().getSourceGit().isUpToDate() and \ - Confirm("Do you want to update image sources (in order to update local build profiles)?", default=True): - cls.updateImageSource() - except AssertionError: - # Catch None git object assertions - logger.warning("Git update is [orange3]not available[/orange3]. Skipping.") + # Don't force update source if using a custom build_path + if ParametersManager().build_path is None: + # Ask to update git + try: + if ExegolModules().getSourceGit().isAvailable and not ExegolModules().getSourceGit().isUpToDate() and \ + Confirm("Do you want to update image sources (in order to update local build profiles)?", default=True): + cls.updateImageSource() + except AssertionError: + # Catch None git object assertions + logger.warning("Git update is [orange3]not available[/orange3]. Skipping.") # Choose tag name blacklisted_build_name = ["stable", "full"] while build_name is None or build_name in blacklisted_build_name: @@ -326,8 +328,25 @@ def __buildSource(cls, build_name: Optional[str] = None) -> str: logger.error("This name is reserved and cannot be used for local build. Please choose another one.") build_name = Prompt.ask("[bold blue][?][/bold blue] Choice a name for your build", default="local") + + # Choose dockerfiles path + # Selecting the default path + build_path = ConstantConfig.build_context_path_obj + if ParametersManager().build_path is not None: + custom_build_path = Path(ParametersManager().build_path).expanduser().absolute() + # Check if we have a directory or a file to select the project directory + if not custom_build_path.is_dir(): + custom_build_path = custom_build_path.parent + # Check if there is Dockerfile profiles + if (custom_build_path / "Dockerfile").is_file() or len(list(custom_build_path.glob("*.dockerfile"))) > 0: + # There is at least one Dockerfile + build_path = custom_build_path + else: + logger.critical(f"The directory {custom_build_path.absolute()} doesn't contain any Dockerfile profile.") + logger.debug(f"Using {build_path} as path for dockerfiles") + # Choose dockerfile - profiles = cls.listBuildProfiles() + profiles = cls.listBuildProfiles(profiles_path=build_path) build_profile: Optional[str] = ParametersManager().build_profile build_dockerfile: Optional[str] = None if build_profile is not None: @@ -340,7 +359,7 @@ def __buildSource(cls, build_name: Optional[str] = None) -> str: title="[not italic]:dog: [/not italic][gold3]Profile[/gold3]")) logger.debug(f"Using {build_profile} build profile ({build_dockerfile})") # Docker Build - DockerUtils.buildImage(build_name, build_profile, build_dockerfile) + DockerUtils.buildImage(tag=build_name, build_profile=build_profile, build_dockerfile=build_dockerfile, dockerfile_path=build_path.as_posix()) return build_name @classmethod @@ -350,14 +369,16 @@ def buildAndLoad(cls, tag: str): return DockerUtils.getInstalledImage(build_name) @classmethod - def listBuildProfiles(cls) -> Dict: + def listBuildProfiles(cls, profiles_path: Path = ConstantConfig.build_context_path_obj) -> Dict: """List every build profiles available locally Return a dict of options {"key = profile name": "value = dockerfile full name"}""" # Default stable profile - profiles = {"full": "Dockerfile"} + profiles = {} + if (profiles_path / "Dockerfile").is_file(): + profiles["full"] = "Dockerfile" # List file *.dockerfile is the build context directory - logger.debug(f"Loading build profile from {ConstantConfig.build_context_path}") - docker_files = list(ConstantConfig.build_context_path_obj.glob("*.dockerfile")) + logger.debug(f"Loading build profile from {profiles_path}") + docker_files = list(profiles_path.glob("*.dockerfile")) for file in docker_files: # Convert every file to the dict format filename = file.name diff --git a/exegol/model/CacheModels.py b/exegol/model/CacheModels.py index e419313f..1a4ee6e8 100644 --- a/exegol/model/CacheModels.py +++ b/exegol/model/CacheModels.py @@ -20,6 +20,9 @@ def update_last_check(self): def get_last_check(self) -> datetime.datetime: return datetime.datetime.strptime(self.last_check, self.__TIME_FORMAT) + def get_last_check_text(self) -> str: + return self.last_check + def is_outdated(self, days: int = 15, hours: int = 0): """Check if the cache must be considered as expired.""" now = datetime.datetime.now() diff --git a/exegol/model/ContainerConfig.py b/exegol/model/ContainerConfig.py index 1164a584..154fef5f 100644 --- a/exegol/model/ContainerConfig.py +++ b/exegol/model/ContainerConfig.py @@ -1,7 +1,12 @@ +import errno import logging import os +import random import re +import socket +import string from datetime import datetime +from enum import Enum from pathlib import Path, PurePath from typing import Optional, List, Dict, Union, Tuple, cast @@ -9,38 +14,63 @@ from docker.types import Mount from rich.prompt import Prompt +from exegol.config.ConstantConfig import ConstantConfig +from exegol.config.EnvInfo import EnvInfo +from exegol.config.UserConfig import UserConfig from exegol.console.ConsoleFormat import boolFormatter, getColor from exegol.console.ExegolPrompt import Confirm from exegol.console.cli.ParametersManager import ParametersManager from exegol.exceptions.ExegolExceptions import ProtocolNotSupported, CancelOperation from exegol.model.ExegolModules import ExegolModules from exegol.utils import FsUtils -from exegol.config.EnvInfo import EnvInfo from exegol.utils.ExeLog import logger, ExeLog from exegol.utils.GuiUtils import GuiUtils -from exegol.config.UserConfig import UserConfig class ContainerConfig: """Configuration class of an exegol container""" # Default hardcoded value - __default_entrypoint_legacy = "bash" - __default_entrypoint = ["/.exegol/entrypoint.sh"] - __default_cmd = ["default"] + __default_entrypoint = ["/bin/bash", "/.exegol/entrypoint.sh"] __default_shm_size = "64M" # Reference static config data __static_gui_envs = {"_JAVA_AWT_WM_NONREPARENTING": "1", "QT_X11_NO_MITSHM": "1"} - - # Label features (wrapper method to enable the feature / label name) - __label_features = {"enableShellLogging": "org.exegol.feature.shell_logging"} - # Label metadata (label name / [wrapper attribute to set the value, getter method to update labels]) - __label_metadata = {"org.exegol.metadata.creation_date": ["creation_date", "getCreationDate"], - "org.exegol.metadata.comment": ["comment", "getComment"]} + __default_desktop_port = {"http": 6080, "vnc": 5900} + + class ExegolFeatures(Enum): + shell_logging = "org.exegol.feature.shell_logging" + desktop = "org.exegol.feature.desktop" + + class ExegolMetadata(Enum): + creation_date = "org.exegol.metadata.creation_date" + comment = "org.exegol.metadata.comment" + password = "org.exegol.metadata.passwd" + + class ExegolEnv(Enum): + # feature + exegol_name = "EXEGOL_NAME" # Supply the name of the container to itself when overriding the hostname + randomize_service_port = "EXEGOL_RANDOMIZE_SERVICE_PORTS" # Enable the randomize port feature when using exegol is network host mode + # config + user_shell = "EXEGOL_START_SHELL" # Set the default shell to use + exegol_user = "EXEGOL_USERNAME" # Select the username of the container + shell_logging_method = "EXEGOL_START_SHELL_LOGGING" # Enable and select the shell logging method + shell_logging_compress = "EXEGOL_START_SHELL_COMPRESS" # Configure if the logs must be compressed at the end of the shell + desktop_protocol = "EXEGOL_DESKTOP_PROTO" # Configure which desktop module must be started + desktop_host = "EXEGOL_DESKTOP_HOST" # Select the host / ip to expose the desktop service on (container side) + desktop_port = "EXEGOL_DESKTOP_PORT" # Select the port to expose the desktop service on (container side) + + # Label features (label name / wrapper method to enable the feature) + __label_features = {ExegolFeatures.shell_logging.value: "enableShellLogging", + ExegolFeatures.desktop.value: "configureDesktop"} + # Label metadata (label name / [setter method to set the value, getter method to update labels]) + __label_metadata = {ExegolMetadata.creation_date.value: ["setCreationDate", "getCreationDate"], + ExegolMetadata.comment.value: ["setComment", "getComment"], + ExegolMetadata.password.value: ["setPasswd", "getPasswd"]} def __init__(self, container: Optional[Container] = None): """Container config default value""" + self.hostname = "" self.__enable_gui: bool = False self.__share_timezone: bool = False self.__my_resources: bool = False @@ -48,6 +78,7 @@ def __init__(self, container: Optional[Container] = None): self.__exegol_resources: bool = False self.__network_host: bool = True self.__privileged: bool = False + self.__wrapper_start_enabled: bool = False self.__mounts: List[Mount] = [] self.__devices: List[str] = [] self.__capabilities: List[str] = [] @@ -55,35 +86,49 @@ def __init__(self, container: Optional[Container] = None): self.__envs: Dict[str, str] = {} self.__labels: Dict[str, str] = {} self.__ports: Dict[str, Optional[Union[int, Tuple[str, int], List[int], List[Dict[str, Union[int, str]]]]]] = {} + self.__extra_host: Dict[str, str] = {} self.interactive: bool = True self.tty: bool = True self.shm_size: str = self.__default_shm_size self.__workspace_custom_path: Optional[str] = None self.__workspace_dedicated_path: Optional[str] = None self.__disable_workspace: bool = False - self.__container_command_legacy: Optional[str] = None - self.__container_command: List[str] = self.__default_cmd self.__container_entrypoint: List[str] = self.__default_entrypoint self.__vpn_path: Optional[Union[Path, PurePath]] = None self.__shell_logging: bool = False - self.__start_delegate_mode: bool = False + # Entrypoint features + self.legacy_entrypoint: bool = True + self.__vpn_parameters: Optional[str] = None + self.__run_cmd: bool = False + self.__endless_container: bool = True + self.__desktop_proto: Optional[str] = None + self.__desktop_host: Optional[str] = None + self.__desktop_port: Optional[int] = None # Metadata attributes - self.creation_date: Optional[str] = None - self.comment: Optional[str] = None + self.__creation_date: Optional[str] = None + self.__comment: Optional[str] = None + self.__username: str = "root" + self.__passwd: Optional[str] = self.generateRandomPassword() if container is not None: self.__parseContainerConfig(container) + else: + self.__wrapper_start_enabled = True + self.addVolume(str(ConstantConfig.spawn_context_path_obj), "/.exegol/spawn.sh", read_only=True, must_exist=True) + + # ===== Config parsing section ===== def __parseContainerConfig(self, container: Container): """Parse Docker object to setup self configuration""" + # Reset default attributes + self.__passwd = None # Container Config section container_config = container.attrs.get("Config", {}) self.tty = container_config.get("Tty", True) self.__parseEnvs(container_config.get("Env", [])) self.__parseLabels(container_config.get("Labels", {})) self.interactive = container_config.get("OpenStdin", True) - # If entrypoint is set on the image, considering the presence of start.sh script for delegates features - self.__start_delegate_mode = container.attrs['Config']['Entrypoint'] is not None + self.legacy_entrypoint = container_config.get("Entrypoint") is None self.__enable_gui = False for env in self.__envs: if "DISPLAY" in env: @@ -96,14 +141,14 @@ def __parseContainerConfig(self, container: Container): caps = host_config.get("CapAdd", []) if caps is not None: self.__capabilities = caps - logger.debug(f"Capabilities : {self.__capabilities}") + logger.debug(f"└── Capabilities : {self.__capabilities}") self.__sysctls = host_config.get("Sysctls", {}) devices = host_config.get("Devices", []) if devices is not None: for device in devices: self.__devices.append( f"{device.get('PathOnHost', '?')}:{device.get('PathInContainer', '?')}:{device.get('CgroupPermissions', '?')}") - logger.debug(f"Load devices : {self.__devices}") + logger.debug(f"└── Load devices : {self.__devices}") # Volumes section self.__share_timezone = False @@ -118,7 +163,7 @@ def __parseContainerConfig(self, container: Container): def __parseEnvs(self, envs: List[str]): """Parse envs object syntax""" for env in envs: - logger.debug(f"Parsing envs : {env}") + logger.debug(f"└── Parsing envs : {env}") # Removing " and ' at the beginning and the end of the string before splitting key / value self.addRawEnv(env.strip("'").strip('"')) @@ -127,21 +172,21 @@ def __parseLabels(self, labels: Dict[str, str]): for key, value in labels.items(): if not key.startswith("org.exegol."): continue - logger.debug(f"Parsing label : {key}") + logger.debug(f"└── Parsing label : {key}") if key.startswith("org.exegol.metadata."): # Find corresponding feature and attributes - for label, refs in self.__label_metadata.items(): - if label == key: - # reflective set of the metadata attribute (set metadata value to the corresponding attribute) - setattr(self, refs[0], value) - break + refs = self.__label_metadata.get(key) # Setter + if refs is not None: + # reflective execution of setter method (set metadata value to the corresponding attribute) + getattr(self, refs[0])(value) elif key.startswith("org.exegol.feature."): # Find corresponding feature and attributes - for attribute, label in self.__label_features.items(): - if label == key: - # reflective execution of the feature enable method (add label & set attributes) - getattr(self, attribute)() - break + enable_function = self.__label_features.get(key) + if enable_function is not None: + # reflective execution of the feature enable method (add label & set attributes) + if value == "Enabled": + value = "" + getattr(self, enable_function)(value) def __parseMounts(self, mounts: Optional[List[Dict]], name: str): """Parse Mounts object""" @@ -149,7 +194,7 @@ def __parseMounts(self, mounts: Optional[List[Dict]], name: str): mounts = [] self.__disable_workspace = True for share in mounts: - logger.debug(f"Parsing mount : {share}") + logger.debug(f"└── Parsing mount : {share}") src_path: Optional[PurePath] = None obj_path: PurePath if share.get('Type', 'volume') == "volume": @@ -179,14 +224,15 @@ def __parseMounts(self, mounts: Optional[List[Dict]], name: str): # Workspace are always bind mount assert src_path is not None obj_path = cast(PurePath, src_path) - logger.debug(f"Loading workspace volume source : {obj_path}") + logger.debug(f"└── Loading workspace volume source : {obj_path}") self.__disable_workspace = False + # TODO use label to identify manage workspace and support cross env removing if obj_path is not None and obj_path.name == name and \ (obj_path.parent.name == "shared-data-volumes" or obj_path.parent == UserConfig().private_volume_path): # Check legacy path and new custom path - logger.debug("Private workspace detected") + logger.debug("└── Private workspace detected") self.__workspace_dedicated_path = str(obj_path) else: - logger.debug("Custom workspace detected") + logger.debug("└── Custom workspace detected") self.__workspace_custom_path = str(obj_path) # TODO remove support for previous container elif "/vpn" in share.get('Destination', '') or "/.exegol/vpn" in share.get('Destination', ''): @@ -194,7 +240,11 @@ def __parseMounts(self, mounts: Optional[List[Dict]], name: str): assert src_path is not None obj_path = cast(PurePath, src_path) self.__vpn_path = obj_path - logger.debug(f"Loading VPN config: {self.__vpn_path.name}") + logger.debug(f"└── Loading VPN config: {self.__vpn_path.name}") + elif "/.exegol/spawn.sh" in share.get('Destination', ''): + self.__wrapper_start_enabled = True + + # ===== Feature section ===== def interactiveConfig(self, container_name: str) -> List[str]: """Interactive procedure allowing the user to configure its new container""" @@ -220,16 +270,26 @@ def interactiveConfig(self, container_name: str) -> List[str]: self.setWorkspaceShare(workspace_path) command_options.append(f"-w {workspace_path}") - # GUI Config + # X11 sharing (GUI) config if self.__enable_gui: - if Confirm("Do you want to [orange3]disable[/orange3] [blue]GUI[/blue]?", False): + if Confirm("Do you want to [orange3]disable[/orange3] [blue]X11[/blue] (i.e. GUI apps)?", False): self.__disableGUI() - elif Confirm("Do you want to [green]enable[/green] [blue]GUI[/blue]?", False): + elif Confirm("Do you want to [green]enable[/green] [blue]X11[/blue] (i.e. GUI apps)?", False): self.enableGUI() # Command builder info if not self.__enable_gui: command_options.append("--disable-X11") + # Desktop Config + if self.isDesktopEnabled(): + if Confirm("Do you want to [orange3]disable[/orange3] [blue]Desktop[/blue]?", False): + self.__disableDesktop() + elif Confirm("Do you want to [green]enable[/green] [blue]Desktop[/blue]?", False): + self.enableDesktop() + # Command builder info + if self.isDesktopEnabled(): + command_options.append("--desktop") + # Timezone config if self.__share_timezone: if Confirm("Do you want to [orange3]remove[/orange3] your [blue]shared timezone[/blue] config?", False): @@ -275,7 +335,7 @@ def interactiveConfig(self, container_name: str) -> List[str]: if Confirm("Do you want to [orange3]disable[/orange3] automatic [blue]shell logging[/blue]?", False): self.__disableShellLogging() elif Confirm("Do you want to [green]enable[/green] automatic [blue]shell logging[/blue]?", False): - self.enableShellLogging() + self.enableShellLogging(UserConfig().shell_logging_method, UserConfig().shell_logging_compress) # Command builder info if self.__shell_logging: command_options.append("--log") @@ -301,7 +361,7 @@ def interactiveConfig(self, container_name: str) -> List[str]: def enableGUI(self): """Procedure to enable GUI feature""" if not GuiUtils.isGuiAvailable(): - logger.error("GUI feature is [red]not available[/red] on your environment. [orange3]Skipping[/orange3].") + logger.error("X11 feature (i.e. GUI apps) is [red]not available[/red] on your environment. [orange3]Skipping[/orange3].") return if not self.__enable_gui: logger.verbose("Config: Enabling display sharing") @@ -319,7 +379,7 @@ def enableGUI(self): self.__enable_gui = True def __disableGUI(self): - """Procedure to enable GUI feature (Only for interactive config)""" + """Procedure to disable X11 (GUI) feature (Only for interactive config)""" if self.__enable_gui: self.__enable_gui = False logger.verbose("Config: Disabling display sharing") @@ -365,13 +425,6 @@ def __disableSharedTimezone(self): self.removeVolume("/etc/timezone") self.removeVolume("/etc/localtime") - def setPrivileged(self, status: bool = True): - """Set container as privileged""" - logger.verbose(f"Config: Setting container privileged as {status}") - if status: - logger.warning("Setting container as privileged (this exposes the host to security risks)") - self.__privileged = status - def enableMyResources(self): """Procedure to enable shared volume feature""" # TODO test my resources cross shell source (WSL / PSH) on Windows @@ -412,43 +465,118 @@ def disableExegolResources(self): self.__exegol_resources = False self.removeVolume(container_path='/opt/resources') - def enableShellLogging(self): + def enableShellLogging(self, log_method: str, compress_mode: Optional[bool] = None): """Procedure to enable exegol shell logging feature""" if not self.__shell_logging: logger.verbose("Config: Enabling shell logging") self.__shell_logging = True - self.addLabel(self.__label_features.get('enableShellLogging', 'org.exegol.error'), "Enabled") - - def addComment(self, comment): - """Procedure to add comment to a container""" - if not self.comment: - logger.verbose("Config: Adding comment to container info") - self.comment = comment - self.addLabel("org.exegol.metadata.comment", comment) + self.addEnv(self.ExegolEnv.shell_logging_method.value, log_method) + if compress_mode is not None: + self.addEnv(self.ExegolEnv.shell_logging_compress.value, str(compress_mode)) + self.addLabel(self.ExegolFeatures.shell_logging.value, log_method) def __disableShellLogging(self): """Procedure to disable exegol shell logging feature""" if self.__shell_logging: logger.verbose("Config: Disabling shell logging") self.__shell_logging = False - self.removeLabel(self.__label_features.get('enableShellLogging', 'org.exegol.error')) + self.removeEnv(self.ExegolEnv.shell_logging_method.value) + self.removeEnv(self.ExegolEnv.shell_logging_compress.value) + self.removeLabel(self.ExegolFeatures.shell_logging.value) + + def isDesktopEnabled(self): + return self.__desktop_proto is not None + + def enableDesktop(self, desktop_config: str = ""): + """Procedure to enable exegol desktop feature""" + if not self.isDesktopEnabled(): + logger.verbose("Config: Enabling exegol desktop") + self.configureDesktop(desktop_config, create_mode=True) + assert self.__desktop_proto is not None + assert self.__desktop_host is not None + assert self.__desktop_port is not None + self.addLabel(self.ExegolFeatures.desktop.value, f"{self.__desktop_proto}:{self.__desktop_host}:{self.__desktop_port}") + # Env var are used to send these parameter to the desktop-start script + self.addEnv(self.ExegolEnv.desktop_protocol.value, self.__desktop_proto) + self.addEnv(self.ExegolEnv.exegol_user.value, self.getUsername()) + + if self.__network_host: + self.addEnv(self.ExegolEnv.desktop_host.value, self.__desktop_host) + self.addEnv(self.ExegolEnv.desktop_port.value, str(self.__desktop_port)) + else: + # If we do not specify the host to the container it will automatically choose eth0 interface + # Using default port for the service + self.addEnv(self.ExegolEnv.desktop_port.value, str(self.__default_desktop_port.get(self.__desktop_proto))) + # Exposing desktop service + self.addPort(port_host=self.__desktop_port, port_container=self.__default_desktop_port[self.__desktop_proto], host_ip=self.__desktop_host) + + def configureDesktop(self, desktop_config: str, create_mode: bool = False): + """Configure the exegol desktop feature from user parameters. + Accepted format: 'mode:host:port' + """ + self.__desktop_proto = UserConfig().desktop_default_proto + self.__desktop_host = "127.0.0.1" if UserConfig().desktop_default_localhost else "0.0.0.0" + + for i, data in enumerate(desktop_config.split(":")): + if not data: + continue + if i == 0: # protocol + logger.debug(f"Desktop proto set: {data}") + data = data.lower() + if data in UserConfig.desktop_available_proto: + self.__desktop_proto = data + else: + logger.critical(f"The desktop mode '{data}' is not supported. Please choose a supported mode: [green]{', '.join(UserConfig.desktop_available_proto)}[/green].") + elif i == 1 and data: # host + logger.debug(f"Desktop host set: {data}") + self.__desktop_host = data + elif i == 2: # port + logger.debug(f"Desktop port set: {data}") + try: + self.__desktop_port = int(data) + except ValueError: + logger.critical(f"Invalid desktop port: '{data}' is not a valid port.") + else: + logger.critical(f"Your configuration is invalid, please use the following format:[green]mode:host:port[/green]") + + if self.__desktop_port is None: + logger.debug(f"Desktop port will be set automatically") + self.__desktop_port = self.__findAvailableRandomPort(self.__desktop_host) + + if create_mode: + # Check if the port is available + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + try: + s.bind((self.__desktop_host, self.__desktop_port)) + except socket.error as e: + if e.errno == errno.EADDRINUSE: + logger.critical(f"The port {self.__desktop_host}:{self.__desktop_port} is already in use !") + elif e.errno == errno.EADDRNOTAVAIL: + logger.critical(f"The network {self.__desktop_host}:{self.__desktop_port} is not available !") + else: + logger.critical(f"The supplied network configuration {self.__desktop_host}:{self.__desktop_port} is not available ! ([{e.errno}] {e})") + + def __disableDesktop(self): + """Procedure to disable exegol desktop feature""" + if self.isDesktopEnabled(): + logger.verbose("Config: Disabling shell logging") + assert self.__desktop_proto is not None + if not self.__network_host: + self.__removePort(self.__default_desktop_port[self.__desktop_proto]) + self.__desktop_proto = None + self.__desktop_host = None + self.__desktop_port = None + self.removeLabel(self.ExegolFeatures.desktop.value) + self.removeEnv(self.ExegolEnv.desktop_protocol.value) + self.removeEnv(self.ExegolEnv.exegol_user.value) + self.removeEnv(self.ExegolEnv.desktop_host.value) + self.removeEnv(self.ExegolEnv.desktop_port.value) def enableCwdShare(self): """Procedure to share Current Working Directory with the /workspace of the container""" self.__workspace_custom_path = os.getcwd() logger.verbose(f"Config: Sharing current workspace directory {self.__workspace_custom_path}") - def setWorkspaceShare(self, host_directory): - """Procedure to share a specific directory with the /workspace of the container""" - path = Path(host_directory).expanduser().absolute() - try: - if not path.is_dir() and path.exists(): - logger.critical("The specified workspace is not a directory!") - except PermissionError as e: - logger.critical(f"Unable to use the supplied workspace directory: {e}") - logger.verbose(f"Config: Sharing workspace directory {path}") - self.__workspace_custom_path = str(path) - def enableVPN(self, config_path: Optional[str] = None): """Configure a VPN profile for container startup""" # Check host mode : custom (allows you to isolate the VPN connection from the host's network) @@ -473,12 +601,38 @@ def enableVPN(self, config_path: Optional[str] = None): # Add tun device, this device is needed to create VPN tunnels self.__addDevice("/dev/net/tun", mknod=True) # Sharing VPN configuration with the container - ovpn_parameters = self.__prepareVpnVolumes(config_path) - # Execution of the VPN daemon at container startup - if ovpn_parameters is not None: - vpn_cmd_legacy = f"bash -c 'mkdir -p /var/log/exegol; openvpn --log-append /var/log/exegol/vpn.log {ovpn_parameters}; bash'" - self.setLegacyContainerCommand(vpn_cmd_legacy) - self.setContainerCommand("ovpn", ovpn_parameters) + self.__vpn_parameters = self.__prepareVpnVolumes(config_path) + + def __disableVPN(self) -> bool: + """Remove a VPN profile for container startup (Only for interactive config)""" + if self.__vpn_path: + logger.verbose('Removing VPN configuration') + self.__vpn_path = None + self.__vpn_parameters = None + self.__removeCapability("NET_ADMIN") + self.__removeSysctl("net.ipv6.conf.all.disable_ipv6") + self.removeDevice("/dev/net/tun") + # Try to remove each possible volume + self.removeVolume(container_path="/.exegol/vpn/auth/creds.txt") + self.removeVolume(container_path="/.exegol/vpn/config/client.ovpn") + self.removeVolume(container_path="/.exegol/vpn/config") + return True + return False + + def disableDefaultWorkspace(self): + """Allows you to disable the default workspace volume""" + # If a custom workspace is not define, disable workspace + if self.__workspace_custom_path is None: + self.__disable_workspace = True + + def addComment(self, comment): + """Procedure to add comment to a container""" + if not self.__comment: + logger.verbose("Config: Adding comment to container info") + self.__comment = comment + self.addLabel(self.ExegolMetadata.comment.value, comment) + + # ===== Functional / technical methods section ===== def __prepareVpnVolumes(self, config_path: Optional[str]) -> Optional[str]: """Volumes must be prepared to share OpenVPN configuration files with the container. @@ -554,28 +708,6 @@ def __checkVPNConfigDNS(vpn_path: Union[str, Path]): logger.info("Press enter to continue or Ctrl+C to cancel the operation") input() - def __disableVPN(self) -> bool: - """Remove a VPN profile for container startup (Only for interactive config)""" - if self.__vpn_path: - logger.verbose('Removing VPN configuration') - self.__vpn_path = None - self.__removeCapability("NET_ADMIN") - self.__removeSysctl("net.ipv6.conf.all.disable_ipv6") - self.removeDevice("/dev/net/tun") - # Try to remove each possible volume - self.removeVolume(container_path="/.exegol/vpn/auth/creds.txt") - self.removeVolume(container_path="/.exegol/vpn/config/client.ovpn") - self.removeVolume(container_path="/.exegol/vpn/config") - self.__restoreEntrypoint() - return True - return False - - def disableDefaultWorkspace(self): - """Allows you to disable the default workspace volume""" - # If a custom workspace is not define, disable workspace - if self.__workspace_custom_path is None: - self.__disable_workspace = True - def prepareShare(self, share_name: str): """Add workspace share before container creation""" for mount in self.__mounts: @@ -596,10 +728,75 @@ 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(): + if directory_path.is_dir() and len(list(directory_path.iterdir())) == 0: + logger.info("Rollback: removing dedicated workspace directory") directory_path.rmdir() + else: + logger.warning("Rollback: the workspace directory isn't empty, it will NOT be removed automatically") + + def entrypointRunCmd(self, endless_mode=False): + """Enable the run_cmd feature of the entrypoint. This feature execute the command stored in the $CMD container environment variables. + The endless_mode parameter can specify if the container must stay alive after command execution or not""" + self.__run_cmd = True + self.__endless_container = endless_mode + + def getEntrypointCommand(self) -> Tuple[Optional[List[str]], Union[List[str], str]]: + """Get container entrypoint/command arguments. + The default container_entrypoint is '/bin/bash /.exegol/entrypoint.sh' and the default container_command is ['load_setups', 'endless'].""" + entrypoint_actions = [] + if self.__my_resources: + entrypoint_actions.append("load_setups") + if self.isDesktopEnabled(): + entrypoint_actions.append("desktop") + if self.__vpn_path is not None: + entrypoint_actions.append(f"ovpn {self.__vpn_parameters}") + if self.__run_cmd: + entrypoint_actions.append("run_cmd") + if self.__endless_container: + entrypoint_actions.append("endless") + else: + entrypoint_actions.append("finish") + return self.__container_entrypoint, entrypoint_actions + + def getShellCommand(self) -> str: + """Get container command for opening a new shell""" + # Use a spawn.sh script to handle features with the wrapper + return "/.exegol/spawn.sh" + + @staticmethod + def generateRandomPassword(length: int = 30) -> str: + """ + Generate a new random password. + """ + charset = string.ascii_letters + string.digits + return ''.join(random.choice(charset) for i in range(length)) + + @staticmethod + def __findAvailableRandomPort(interface: str = 'localhost') -> int: + """Find an available random port. Using the socket system to """ + logger.debug(f"Attempting to bind to interface {interface}") + with socket.socket() as sock: + try: + sock.bind((interface, 0)) # Using port 0 let the system decide for a random port + except OSError as e: + logger.critical(f"Unable to bind a port to the interface {interface} ({e})") + random_port = sock.getsockname()[1] + logger.debug(f"Found available port {random_port}") + return random_port + + # ===== Apply config section ===== + + def setWorkspaceShare(self, host_directory): + """Procedure to share a specific directory with the /workspace of the container""" + path = Path(host_directory).expanduser().absolute() + try: + if not path.is_dir() and path.exists(): + logger.critical("The specified workspace is not a directory!") + except PermissionError as e: + logger.critical(f"Unable to use the supplied workspace directory: {e}") + logger.verbose(f"Config: Sharing workspace directory {path}") + self.__workspace_custom_path = str(path) def setNetworkMode(self, host_mode: Optional[bool]): """Set container's network mode, true for host, false for bridge""" @@ -615,21 +812,12 @@ def setNetworkMode(self, host_mode: Optional[bool]): host_mode = False self.__network_host = host_mode - def setContainerCommand(self, entrypoint_function: str, *parameters: str): - """Set the entrypoint command of the container. This command is executed at each startup. - This parameter is applied to the container at creation.""" - self.__container_command = [entrypoint_function] + list(parameters) - - def setLegacyContainerCommand(self, cmd: str): - """Set the entrypoint command of the container. This command is executed at each startup. - This parameter is applied to the container at creation. - This method is legacy, before the entrypoint exist (support images before 3.x.x).""" - self.__container_command_legacy = cmd - - def __restoreEntrypoint(self): - """Restore container's entrypoint to its default configuration""" - self.__container_command_legacy = None - self.__container_command = self.__default_cmd + def setPrivileged(self, status: bool = True): + """Set container as privileged""" + logger.verbose(f"Config: Setting container privileged as {status}") + if status: + logger.warning("Setting container as privileged (this exposes the host to security risks)") + self.__privileged = status def addCapability(self, cap_string: str): """Add a linux capability to the container""" @@ -672,12 +860,24 @@ def getNetworkMode(self) -> str: """Network mode, docker term getter""" return "host" if self.__network_host else "bridge" - def getTextNetworkMode(self) -> str: - """Network mode, text getter""" - network_mode = "host" if self.__network_host else "bridge" - if self.__vpn_path: - network_mode += " with VPN" - return network_mode + def setExtraHost(self, host: str, ip: str): + """Add or update an extra host to resolv inside the container.""" + self.__extra_host[host] = ip + + def removeExtraHost(self, host: str) -> bool: + """Remove an extra host to resolv inside the container. + Return true if the host was register in the extra_host configuration.""" + return self.__extra_host.pop(host, None) is not None + + def getExtraHost(self): + """Return the extra_host configuration for the container. + Ensure in shared host environment that the container hostname will be correctly resolved to localhost. + Return a dictionary of host and matching IP""" + self.__extra_host = {} + # When using host network mode, you need to add an extra_host to resolve $HOSTNAME + if self.__network_host: + self.setExtraHost(self.hostname, '127.0.0.1') + return self.__extra_host def getPrivileged(self) -> bool: """Privileged getter""" @@ -695,42 +895,12 @@ def getWorkingDir(self) -> str: """Get default container's default working directory path""" return "/" if self.__disable_workspace else "/workspace" - def getEntrypointCommand(self, image_entrypoint: Optional[Union[str, List[str]]]) -> Tuple[Optional[List[str]], Union[List[str], str]]: - """Get container entrypoint/command arguments. - This method support legacy configuration.""" - if image_entrypoint is None: - # Legacy mode - if self.__container_command_legacy is None: - return [self.__default_entrypoint_legacy], [] - return None, self.__container_command_legacy - else: - return self.__container_entrypoint, self.__container_command - - def getShellCommand(self) -> str: - """Get container command for opening a new shell""" - # If shell logging was enabled at container creation, it'll always be enabled for every shell. - # If not, it can be activated per shell basis - if self.__shell_logging or ParametersManager().log: - if self.__start_delegate_mode: - # Use a start.sh script to handle the feature with the tools and feature corresponding to the image version - # Start shell_logging feature using the user's specified method with the configured default shell w/ or w/o compression at the end - return f"/.exegol/start.sh shell_logging {ParametersManager().log_method} {ParametersManager().shell} {UserConfig().shell_logging_compress ^ ParametersManager().log_compress}" - else: - # Legacy command support - if ParametersManager().log_method != "script": - logger.warning("Your image version does not allow customization of the shell logging method. Using legacy script method.") - compression_cmd = '' - if UserConfig().shell_logging_compress ^ ParametersManager().log_compress: - compression_cmd = 'echo "Compressing logs, please wait..."; gzip $filelog; ' - return f"bash -c 'umask 007; mkdir -p /workspace/logs/; filelog=/workspace/logs/$(date +%d-%m-%Y_%H-%M-%S)_shell.log; script -qefac {ParametersManager().shell} $filelog; {compression_cmd}exit'" - return ParametersManager().shell - def getHostWorkspacePath(self) -> str: """Get private volume path (None if not set)""" if self.__workspace_custom_path: return FsUtils.resolvStrPath(self.__workspace_custom_path) elif self.__workspace_dedicated_path: - return FsUtils.resolvStrPath(self.__workspace_dedicated_path) + return self.getPrivateVolumePath() return "not found :(" def getPrivateVolumePath(self) -> str: @@ -785,9 +955,15 @@ def addVolume(self, if EnvInfo.isMacHost(): # Add support for /etc path_match = str(path) + if path_match.startswith("/opt/") and EnvInfo.isOrbstack(): + msg = f"{EnvInfo.getDockerEngine().value} cannot mount directory from [magenta]/opt/[/magenta] host path." + if path_match.endswith("entrypoint.sh") or path_match.endswith("spawn.sh"): + msg += " Your exegol installation cannot be stored under this directory." + logger.critical(msg) + raise CancelOperation(msg) if path_match.startswith("/etc/"): if EnvInfo.isOrbstack(): - raise CancelOperation(f"Orbstack doesn't support sharing /etc files with the container") + raise CancelOperation(f"{EnvInfo.getDockerEngine().value} doesn't support sharing [magenta]/etc[/magenta] files with the container") path_match = path_match.replace("/etc/", "/private/etc/") if EnvInfo.isDockerDesktop(): match = False @@ -829,34 +1005,6 @@ def addVolume(self, mount = Mount(container_path, host_path, read_only=read_only, type=volume_type) self.__mounts.append(mount) - def addRawVolume(self, volume_string): - """Add a volume to the container configuration from raw text input. - Expected format is: /source/path:/target/mount:rw""" - logger.debug(f"Parsing raw volume config: {volume_string}") - parsing = re.match(r'^((\w:)?([\\/][\w .,:\-|()&;]*)+):(([\\/][\w .,\-|()&;]*)+)(:(ro|rw))?$', - volume_string) - if parsing: - host_path = parsing.group(1) - container_path = parsing.group(4) - mode = parsing.group(7) - if mode is None or mode == "rw": - readonly = False - elif mode == "ro": - readonly = True - else: - logger.error(f"Error on volume config, mode: {mode} not recognized.") - readonly = False - logger.debug( - f"Adding a volume from '{host_path}' to '{container_path}' as {'readonly' if readonly else 'read/write'}") - try: - self.addVolume(host_path, container_path, readonly) - except CancelOperation as e: - logger.error(f"The following volume couldn't be created [magenta]{volume_string}[/magenta]. {e}") - if not Confirm("Do you want to continue without this volume ?", False): - exit(0) - else: - logger.critical(f"Volume '{volume_string}' cannot be parsed. Exiting.") - def removeVolume(self, host_path: Optional[str] = None, container_path: Optional[str] = None) -> bool: """Remove a volume from the container configuration (Only before container creation)""" if host_path is None and container_path is None: @@ -893,14 +1041,6 @@ def __addDevice(self, perm += 'm' self.__devices.append(f"{device_source}:{device_dest}:{perm}") - def addUserDevice(self, user_device_config: str): - """Add a device from a user parameters""" - if EnvInfo.isDockerDesktop(): - logger.warning("Docker desktop (Windows & macOS) does not support USB device passthrough.") - logger.verbose("Official doc: https://docs.docker.com/desktop/faqs/#can-i-pass-through-a-usb-device-to-a-container") - logger.critical("Device configuration cannot be applied, aborting operation.") - self.__addDevice(user_device_config) - def removeDevice(self, device_source: str) -> bool: """Remove a device from the container configuration (Only before container creation)""" for i in range(len(self.__devices)): @@ -916,7 +1056,7 @@ def getDevices(self) -> List[str]: return self.__devices def addEnv(self, key: str, value: str): - """Add an environment variable to the container configuration""" + """Add or update an environment variable to the container configuration""" self.__envs[key] = value def removeEnv(self, key: str) -> bool: @@ -928,32 +1068,19 @@ def removeEnv(self, key: str) -> bool: # When the Key is not present in the dictionary return False - def addRawEnv(self, env: str): - """Parse and add an environment variable from raw user input""" - key, value = self.__parseUserEnv(env) - self.addEnv(key, value) - def getEnvs(self) -> Dict[str, str]: """Envs config getter""" + # When using host network mode, service port must be randomized to avoid conflict between services and container + if self.__network_host: + self.addEnv(self.ExegolEnv.randomize_service_port.value, "true") return self.__envs - @classmethod - def __parseUserEnv(cls, env: str) -> Tuple[str, str]: - env_args = env.split('=') - key = env_args[0] - if len(env_args) < 2: - value = os.getenv(env, '') - if not value: - logger.critical(f"Incorrect env syntax ({env}). Please use this format: KEY=value") - else: - logger.success(f"Using system value for env {env}.") - else: - value = '='.join(env_args[1:]) - return key, value - def getShellEnvs(self) -> List[str]: """Overriding envs when opening a shell""" result = [] + # Select default shell to use + result.append(f"{self.ExegolEnv.user_shell.value}={ParametersManager().shell}") + # Share X11 (GUI Display) config if self.__enable_gui: current_display = GuiUtils.getDisplayEnv() # If the default DISPLAY environment in the container is not the same as the DISPLAY of the user's session, @@ -963,6 +1090,12 @@ def getShellEnvs(self) -> List[str]: # but exegol can be launched from remote access via ssh with X11 forwarding # (Be careful, an .Xauthority file may be needed). result.append(f"DISPLAY={current_display}") + # Handle shell logging + # If shell logging was enabled at container creation, it'll always be enabled for every shell. + # If not, it can be activated per shell basic + if self.__shell_logging or ParametersManager().log: + result.append(f"{self.ExegolEnv.shell_logging_method.value}={ParametersManager().log_method}") + result.append(f"{self.ExegolEnv.shell_logging_compress.value}={UserConfig().shell_logging_compress ^ ParametersManager().log_compress}") # Overwrite env from user parameters user_envs = ParametersManager().envs if user_envs is not None: @@ -972,6 +1105,27 @@ def getShellEnvs(self) -> List[str]: result.append(f"{key}={value}") return result + def addPort(self, + port_host: int, + port_container: Union[int, str], + protocol: str = 'tcp', + host_ip: str = '0.0.0.0'): + """Add port NAT config, only applicable on bridge network mode.""" + if self.__network_host: + logger.warning("Port sharing is configured, disabling the host network mode.") + self.setNetworkMode(False) + if protocol.lower() not in ['tcp', 'udp', 'sctp']: + raise ProtocolNotSupported(f"Unknown protocol '{protocol}'") + logger.debug(f"Adding port {host_ip}:{port_host} -> {port_container}/{protocol}") + self.__ports[f"{port_container}/{protocol}"] = (host_ip, port_host) + + def getPorts(self) -> Dict[str, Optional[Union[int, Tuple[str, int], List[int], List[Dict[str, Union[int, str]]]]]]: + """Ports config getter""" + return self.__ports + + def __removePort(self, container_port: Union[int, str], protocol: str = 'tcp'): + self.__ports.pop(f"{container_port}/{protocol}", None) + def addLabel(self, key: str, value: str): """Add a custom label to the container configuration""" self.__labels[key] = value @@ -988,38 +1142,96 @@ def removeLabel(self, key: str) -> bool: def getLabels(self) -> Dict[str, str]: """Labels config getter""" # Update metadata (from getter method) to the labels (on container creation) - for label_name, refs in self.__label_metadata.items(): + for label_name, refs in self.__label_metadata.items(): # Getter data = getattr(self, refs[1])() if data is not None: self.addLabel(label_name, data) return self.__labels + def isWrapperStartShared(self) -> bool: + """Return True if the /.exegol/spawn.sh is a volume from the up-to-date wrapper script.""" + return self.__wrapper_start_enabled + + # ===== Metadata labels getter / setter section ===== + + def setCreationDate(self, creation_date: str): + """Set the container creation date parsed from the labels of an existing container.""" + self.__creation_date = creation_date + def getCreationDate(self) -> str: """Get container creation date. If the creation has not been set before, init as right now.""" - if self.creation_date is None: - self.creation_date = datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ') - return self.creation_date + if self.__creation_date is None: + self.__creation_date = datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ') + return self.__creation_date - def getVpnName(self): - """Get VPN Config name""" - if self.__vpn_path is None: - return "[bright_black]N/A[/bright_black] " - return f"[deep_sky_blue3]{self.__vpn_path.name}[/deep_sky_blue3]" + def setComment(self, comment: str): + """Set the container comment parsed from the labels of an existing container.""" + self.__comment = comment - def addPort(self, - port_host: int, - port_container: Union[int, str], - protocol: str = 'tcp', - host_ip: str = '0.0.0.0'): - """Add port NAT config, only applicable on bridge network mode.""" - if self.__network_host: - logger.warning("Port sharing is configured, disabling the host network mode.") - self.setNetworkMode(False) - if protocol.lower() not in ['tcp', 'udp', 'sctp']: - raise ProtocolNotSupported(f"Unknown protocol '{protocol}'") - logger.debug(f"Adding port {host_ip}:{port_host} -> {port_container}/{protocol}") - self.__ports[f"{port_container}/{protocol}"] = (host_ip, port_host) + def getComment(self) -> Optional[str]: + """Get the container comment. + If no comment has been supplied, returns None.""" + return self.__comment + + def setPasswd(self, passwd: str): + """ + Set the container root password parsed from the labels of an existing container. + This secret data can be stored inside labels because it is accessible only from the docker socket + which give direct access to the container anyway without password. + """ + self.__passwd = passwd + + def getPasswd(self) -> Optional[str]: + """ + Get the container password. + """ + return self.__passwd + + def getUsername(self) -> str: + """ + Get the container username. + """ + return self.__username + + # ===== User parameter parsing section ===== + + def addRawVolume(self, volume_string): + """Add a volume to the container configuration from raw text input. + Expected format is: /source/path:/target/mount:rw""" + logger.debug(f"Parsing raw volume config: {volume_string}") + # TODO support relative path + parsing = re.match(r'^((\w:)?([\\/][\w .,:\-|()&;]*)+):(([\\/][\w .,\-|()&;]*)+)(:(ro|rw))?$', + volume_string) + if parsing: + host_path = parsing.group(1) + container_path = parsing.group(4) + mode = parsing.group(7) + if mode is None or mode == "rw": + readonly = False + elif mode == "ro": + readonly = True + else: + logger.error(f"Error on volume config, mode: {mode} not recognized.") + readonly = False + logger.debug( + f"Adding a volume from '{host_path}' to '{container_path}' as {'readonly' if readonly else 'read/write'}") + try: + self.addVolume(host_path, container_path, read_only=readonly) + except CancelOperation as e: + logger.error(f"The following volume couldn't be created [magenta]{volume_string}[/magenta]. {e}") + if not Confirm("Do you want to continue without this volume ?", False): + exit(0) + else: + logger.critical(f"Volume '{volume_string}' cannot be parsed. Exiting.") + + def addUserDevice(self, user_device_config: str): + """Add a device from a user parameters""" + if EnvInfo.isDockerDesktop(): + logger.warning("Docker desktop (Windows & macOS) does not support USB device passthrough.") + logger.verbose("Official doc: https://docs.docker.com/desktop/faqs/#can-i-pass-through-a-usb-device-to-a-container") + logger.critical("Device configuration cannot be applied, aborting operation.") + self.__addDevice(user_device_config) def addRawPort(self, user_test_port: str): """Add port config from user input. @@ -1043,18 +1255,37 @@ def addRawPort(self, user_test_port: str): return self.addPort(host_port, container_port, protocol=protocol, host_ip=host_ip) - def getPorts(self) -> Dict[str, Optional[Union[int, Tuple[str, int], List[int], List[Dict[str, Union[int, str]]]]]]: - """Ports config getter""" - return self.__ports + def addRawEnv(self, env: str): + """Parse and add an environment variable from raw user input""" + key, value = self.__parseUserEnv(env) + self.addEnv(key, value) + + @classmethod + def __parseUserEnv(cls, env: str) -> Tuple[str, str]: + env_args = env.split('=') + key = env_args[0] + if len(env_args) < 2: + value = os.getenv(env, '') + if not value: + logger.critical(f"Incorrect env syntax ({env}). Please use this format: KEY=value") + else: + logger.success(f"Using system value for env {env}.") + else: + value = '='.join(env_args[1:]) + return key, value + + # ===== Display / text formatting section ===== def getTextFeatures(self, verbose: bool = False) -> str: - """Text formatter for features configurations (Privileged, GUI, Network, Timezone, Shares) + """Text formatter for features configurations (Privileged, X11, Network, Timezone, Shares) Print config only if they are different from their default config (or print everything in verbose mode)""" result = "" if verbose or self.__privileged: result += f"{getColor(not self.__privileged)[0]}Privileged: {'On :fire:' if self.__privileged else '[green]Off :heavy_check_mark:[/green]'}{getColor(not self.__privileged)[1]}{os.linesep}" + if verbose or self.isDesktopEnabled(): + result += f"{getColor(self.isDesktopEnabled())[0]}Desktop: {self.getDesktopConfig()}{getColor(self.isDesktopEnabled())[1]}{os.linesep}" if verbose or not self.__enable_gui: - result += f"{getColor(self.__enable_gui)[0]}GUI: {boolFormatter(self.__enable_gui)}{getColor(self.__enable_gui)[1]}{os.linesep}" + result += f"{getColor(self.__enable_gui)[0]}X11: {boolFormatter(self.__enable_gui)}{getColor(self.__enable_gui)[1]}{os.linesep}" if verbose or not self.__network_host: result += f"[green]Network mode: [/green]{self.getTextNetworkMode()}{os.linesep}" if self.__vpn_path is not None: @@ -1072,27 +1303,47 @@ def getTextFeatures(self, verbose: bool = False) -> str: return "[i][bright_black]Default configuration[/bright_black][/i]" return result + def getVpnName(self) -> str: + """Get VPN Config name""" + if self.__vpn_path is None: + return "[bright_black]N/A[/bright_black] " + return f"[deep_sky_blue3]{self.__vpn_path.name}[/deep_sky_blue3]" + + def getDesktopConfig(self) -> str: + """Get Desktop feature status / config""" + if not self.isDesktopEnabled(): + return boolFormatter(False) + config = (f"{self.__desktop_proto}://" + f"{'localhost' if self.__desktop_host == '127.0.0.1' else self.__desktop_host}:{self.__desktop_port}") + return f"[link={config}][deep_sky_blue3]{config}[/deep_sky_blue3][/link]" + + def getTextNetworkMode(self) -> str: + """Network mode, text getter""" + network_mode = "host" if self.__network_host else "bridge" + if self.__vpn_path: + network_mode += " with VPN" + return network_mode + def getTextCreationDate(self) -> str: """Get the container creation date. If the creation date has not been supplied on the container, return empty string.""" - if self.creation_date is None: + if self.__creation_date is None: return "" - return datetime.strptime(self.creation_date, "%Y-%m-%dT%H:%M:%SZ").strftime("%d/%m/%Y %H:%M") - - def getComment(self) -> Optional[str]: - """Get the container comment. - If no comment has been supplied, returns None.""" - return self.comment + return datetime.strptime(self.__creation_date, "%Y-%m-%dT%H:%M:%SZ").strftime("%d/%m/%Y %H:%M") def getTextMounts(self, verbose: bool = False) -> str: """Text formatter for Mounts configurations. The verbose mode does not exclude technical volumes.""" result = '' + hidden_mounts = ['/tmp/.X11-unix', '/opt/resources', '/etc/localtime', + '/etc/timezone', '/my-resources', '/opt/my-resources', + '/.exegol/entrypoint.sh', '/.exegol/spawn.sh'] for mount in self.__mounts: - # Blacklist technical mount - if not verbose and mount.get('Target') in ['/tmp/.X11-unix', '/opt/resources', '/etc/localtime', - '/etc/timezone', '/my-resources', '/opt/my-resources']: + # Not showing technical mounts + if not verbose and mount.get('Target') in hidden_mounts: continue - result += f"{mount.get('Source')} :right_arrow: {mount.get('Target')} {'(RO)' if mount.get('ReadOnly') else ''}{os.linesep}" + read_only_text = f"[bright_black](RO)[/bright_black] " if verbose else '' + read_write_text = f"[orange3](RW)[/orange3] " if verbose else '' + result += f"{read_only_text if mount.get('ReadOnly') else read_write_text}{mount.get('Source')} :right_arrow: {mount.get('Target')}{os.linesep}" return result def getTextDevices(self, verbose: bool = False) -> str: @@ -1114,7 +1365,7 @@ def getTextEnvs(self, verbose: bool = False) -> str: result = '' for k, v in self.__envs.items(): # Blacklist technical variables, only shown in verbose - if not verbose and k in list(self.__static_gui_envs.keys()) + ["DISPLAY", "PATH"]: + if not verbose and k in list(self.__static_gui_envs.keys()) + [v.value for v in self.ExegolEnv] + ["DISPLAY", "PATH"]: continue result += f"{k}={v}{os.linesep}" return result diff --git a/exegol/model/ExegolContainer.py b/exegol/model/ExegolContainer.py index 05563be1..57ac0eaa 100644 --- a/exegol/model/ExegolContainer.py +++ b/exegol/model/ExegolContainer.py @@ -1,25 +1,28 @@ import os import shutil -from typing import Optional, Dict, Sequence, Tuple +from datetime import datetime +from typing import Optional, Dict, Sequence, Tuple, Union -from docker.errors import NotFound, ImageNotFound +from docker.errors import NotFound, ImageNotFound, APIError from docker.models.containers import Container +from exegol.config.EnvInfo import EnvInfo from exegol.console.ExegolPrompt import Confirm from exegol.console.cli.ParametersManager import ParametersManager from exegol.model.ContainerConfig import ContainerConfig from exegol.model.ExegolContainerTemplate import ExegolContainerTemplate from exegol.model.ExegolImage import ExegolImage from exegol.model.SelectableInterface import SelectableInterface -from exegol.config.EnvInfo import EnvInfo +from exegol.utils.ContainerLogStream import ContainerLogStream from exegol.utils.ExeLog import logger, console +from exegol.utils.imgsync.ImageScriptSync import ImageScriptSync class ExegolContainer(ExegolContainerTemplate, SelectableInterface): """Class of an exegol container already create in docker""" def __init__(self, docker_container: Container, model: Optional[ExegolContainerTemplate] = None): - logger.debug(f"== Loading container : {docker_container.name}") + logger.debug(f"Loading container: {docker_container.name}") self.__container: Container = docker_container self.__id: str = docker_container.id self.__xhost_applied = False @@ -38,7 +41,8 @@ def __init__(self, docker_container: Container, model: Optional[ExegolContainerT super().__init__(docker_container.name, config=ContainerConfig(docker_container), image=ExegolImage(name=image_name, docker_image=docker_image), - hostname=docker_container.attrs.get('Config', {}).get('Hostname')) + hostname=docker_container.attrs.get('Config', {}).get('Hostname'), + new_container=False) self.image.syncContainerData(docker_container) # At this stage, the container image object has an unknown status because no synchronization with a registry has been done. # This could be done afterwards (with container.image.autoLoad()) if necessary because it takes time. @@ -49,7 +53,8 @@ def __init__(self, docker_container: Container, model: Optional[ExegolContainerT config=ContainerConfig(docker_container), # Rebuild config from docker object to update workspace path image=model.image, - hostname=model.hostname) + hostname=model.config.hostname, + new_container=False) self.__new_container = True self.image.syncStatus() @@ -101,9 +106,36 @@ def start(self): """Start the docker container""" if not self.isRunning(): logger.info(f"Starting container {self.name}") - self.preStartSetup() - with console.status(f"Waiting to start {self.name}", spinner_style="blue"): - self.__container.start() + self.__preStartSetup() + self.__start_container() + + def __start_container(self): + """ + This method start the container and display startup status update to the user. + :return: + """ + with console.status(f"Waiting to start {self.name}", spinner_style="blue") as progress: + start_date = datetime.utcnow() + self.__container.start() + if not self.config.legacy_entrypoint: # TODO improve startup compatibility check + try: + # Try to find log / startup messages. Will time out after 2 seconds if the image don't support status update through container logs. + for line in ContainerLogStream(self.__container, start_date=start_date, timeout=2): + # Once the last log "READY" is received, the startup sequence is over and the execution can continue + if line == "READY": + break + elif line.startswith('[W]'): + line = line.replace('[W]', '') + logger.warning(line) + elif line.startswith('[E]'): + line = line.replace('[E]', '') + logger.error(line) + else: + logger.verbose(line) + progress.update(status=f"[blue][Startup][/blue] {line}") + except KeyboardInterrupt: + # User can cancel startup logging with ctrl+C + logger.warning("User skip startup status updates. Spawning a shell now.") def stop(self, timeout: int = 10): """Stop the docker container""" @@ -114,6 +146,7 @@ def stop(self, timeout: int = 10): def spawnShell(self): """Spawn a shell on the docker container""" + self.__check_start_version() logger.info(f"Location of the exegol workspace on the host : {self.config.getHostWorkspacePath()}") for device in self.config.getDevices(): logger.info(f"Shared host device: {device.split(':')[0]}") @@ -133,7 +166,7 @@ def spawnShell(self): # environment=self.config.getShellEnvs()) # logger.debug(result) - def exec(self, command: Sequence[str], as_daemon: bool = True, quiet: bool = False, is_tmp: bool = False): + def exec(self, command: Union[str, Sequence[str]], as_daemon: bool = True, quiet: bool = False, is_tmp: bool = False): """Execute a command / process on the docker container. Set as_daemon to not follow the command stream and detach the execution Set quiet to disable logs message @@ -162,18 +195,19 @@ def exec(self, command: Sequence[str], as_daemon: bool = True, quiet: bool = Fal logger.warning("Exiting this command does [red]NOT[/red] stop the process in the container") @staticmethod - def formatShellCommand(command: Sequence[str], quiet: bool = False, entrypoint_mode: bool = False) -> Tuple[str, str]: + def formatShellCommand(command: Union[str, Sequence[str]], quiet: bool = False, entrypoint_mode: bool = False) -> Tuple[str, str]: """Generic method to format a shell command and support zsh aliases. Set quiet to disable any logging here. Set entrypoint_mode to start the command with the entrypoint.sh config loader. - The first return argument is the payload to execute with every pre-routine for zsh. - The second return argument is the command itself in str format.""" # Using base64 to escape special characters - str_cmd = ' '.join(command) + str_cmd = command if type(command) is str else ' '.join(command) + # str_cmd = str_cmd.replace('"', '\\"') # This fix shoudn' be necessary plus it can alter data like passwd if not quiet: logger.success(f"Command received: {str_cmd}") # ZSH pre-routine: Load zsh aliases and call eval to force aliases interpretation - cmd = f'autoload -Uz compinit; compinit; source ~/.zshrc; eval $CMD' + cmd = f'autoload -Uz compinit; compinit; source ~/.zshrc; eval "$CMD"' if not entrypoint_mode: # For direct execution, the full command must be supplied not just the zsh argument cmd = f"zsh -c '{cmd}'" @@ -227,7 +261,7 @@ def __removeVolume(self): except PermissionError: logger.info(f"Deleting the workspace files from the [green]{self.name}[/green] container as root") # If the host can't remove the container's file and folders, the rm command is exec from the container itself as root - self.exec(["rm", "-rf", "/workspace"], as_daemon=False, quiet=True) + self.exec("rm -rf /workspace", as_daemon=False, quiet=True) try: shutil.rmtree(volume_path) except PermissionError: @@ -238,23 +272,51 @@ def __removeVolume(self): return logger.success("Private workspace volume removed successfully") - def preStartSetup(self): + def __preStartSetup(self): """ Operation to be performed before starting a container :return: """ self.__applyXhostACL() - def postCreateSetup(self): + def __check_start_version(self): + """ + Check spawn.sh up-to-date status and update the script if needed + :return: + """ + # Up-to-date container have the script shared over a volume + # But legacy container must be checked and the code must be pushed + if not self.config.isWrapperStartShared(): + # If the spawn.sh if not shared, the version must be compared and the script updated + current_start = ImageScriptSync.getCurrentStartVersion() + # Try to parse the spawn version of the container. If an alpha or beta version is in use, the script will always be updated. + spawn_parsing_cmd = ["/bin/bash", "-c", "egrep '^# Spawn Version:[0-9]+$' /.exegol/spawn.sh 2&>/dev/null || echo ':0' | cut -d ':' -f2"] + container_version = self.__container.exec_run(spawn_parsing_cmd).output.decode("utf-8").strip() + if current_start != container_version: + logger.debug(f"Updating spawn.sh script from version {container_version} to version {current_start}") + self.__container.put_archive("/", ImageScriptSync.getImageSyncTarData(include_spawn=True)) + + def postCreateSetup(self, is_temporary: bool = False): """ Operation to be performed after creating a container :return: """ self.__applyXhostACL() + # if not a temporary container, apply custom config + if not is_temporary: + # Update entrypoint script in the container + self.__container.put_archive("/", ImageScriptSync.getImageSyncTarData(include_entrypoint=True)) + if self.__container.status.lower() == "created": + self.__start_container() + try: + self.__updatePasswd() + except APIError as e: + if "is not running" in e.explanation: + logger.critical("An unexpected error occurred. Exegol cannot start the container after its creation...") def __applyXhostACL(self): """ - If GUI is enabled, allow X11 access on host ACL (if not already allowed) for linux and mac. + If X11 (GUI) is enabled, allow X11 access on host ACL (if not already allowed) for linux and mac. On Windows host, WSLg X11 don't have xhost ACL. :return: """ @@ -271,6 +333,15 @@ def __applyXhostACL(self): with console.status(f"Starting XQuartz...", spinner_style="blue"): os.system(f"xhost + localhost > /dev/null") else: - logger.debug(f"Adding xhost ACL to local:{self.hostname}") + logger.debug(f"Adding xhost ACL to local:{self.config.hostname}") # add linux local ACL - os.system(f"xhost +local:{self.hostname} > /dev/null") + os.system(f"xhost +local:{self.config.hostname} > /dev/null") + + def __updatePasswd(self): + """ + If configured, update the password of the user inside the container. + :return: + """ + if self.config.getPasswd() is not None: + logger.debug(f"Updating the {self.config.getUsername()} password inside the container") + self.exec(f"echo '{self.config.getUsername()}:{self.config.getPasswd()}' | chpasswd", quiet=True) diff --git a/exegol/model/ExegolContainerTemplate.py b/exegol/model/ExegolContainerTemplate.py index 213f4ae3..b0f3b678 100644 --- a/exegol/model/ExegolContainerTemplate.py +++ b/exegol/model/ExegolContainerTemplate.py @@ -11,7 +11,7 @@ class ExegolContainerTemplate: """Exegol template class used to create a new container""" - def __init__(self, name: Optional[str], config: ContainerConfig, image: ExegolImage, hostname: Optional[str] = None): + def __init__(self, name: Optional[str], config: ContainerConfig, image: ExegolImage, hostname: Optional[str] = None, new_container: bool = True): 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 @@ -20,12 +20,14 @@ def __init__(self, name: Optional[str], config: ContainerConfig, image: ExegolIm name = name.lower() self.container_name: str = name if name.startswith("exegol-") else f'exegol-{name}' self.name: str = name.replace('exegol-', '') - if hostname: - self.hostname: str = hostname - else: - self.hostname = self.container_name self.image: ExegolImage = image self.config: ContainerConfig = config + if hostname: + self.config.hostname = hostname + if new_container: + self.config.addEnv(ContainerConfig.ExegolEnv.exegol_name.value, self.container_name) + else: + self.config.hostname = self.container_name def __str__(self): """Default object text formatter, debug only""" @@ -41,6 +43,6 @@ def rollback(self): def getDisplayName(self) -> str: """Getter of the container's name for TUI purpose""" - if self.container_name != self.hostname: - return f"{self.name} [bright_black]({self.hostname})[/bright_black]" + if self.container_name != self.config.hostname: + return f"{self.name} [bright_black]({self.config.hostname})[/bright_black]" return self.name diff --git a/exegol/model/ExegolImage.py b/exegol/model/ExegolImage.py index ffa67df0..5bb3c1d3 100644 --- a/exegol/model/ExegolImage.py +++ b/exegol/model/ExegolImage.py @@ -51,6 +51,7 @@ def __init__(self, self.__build_date = "[bright_black]N/A[/bright_black]" # Remote image size self.__dl_size: str = "[bright_black]N/A[/bright_black]" + self.__remote_est_size: str = "[bright_black]N/A[/bright_black]" # Local uncompressed image's size self.__disk_size: str = "[bright_black]N/A[/bright_black]" # Remote image ID @@ -73,7 +74,8 @@ def __init__(self, if dockerhub_data: self.__is_remote = True self.__setArch(MetaImages.parseArch(dockerhub_data)) - self.__dl_size = self.__processSize(dockerhub_data.get("size", 0)) + self.__dl_size = self.__processSize(size=dockerhub_data.get("size", 0)) + self.__remote_est_size = self.__processSize(size=dockerhub_data.get("size", 0), compression_factor=2.6) if meta_img and meta_img.meta_id is not None: self.__setDigest(meta_img.meta_id) self.__setLatestRemoteId(meta_img.meta_id) # Meta id is always the latest one @@ -190,7 +192,8 @@ def setMetaImage(self, meta: MetaImages): if fetch_version: meta.version = fetch_version if dockerhub_data is not None: - self.__dl_size = self.__processSize(dockerhub_data.get("size", 0)) + self.__dl_size = self.__processSize(size=dockerhub_data.get("size", 0)) + self.__remote_est_size = self.__processSize(size=dockerhub_data.get("size", 0), compression_factor=2.5) self.__setLatestVersion(meta.version) if meta.meta_id: self.__setLatestRemoteId(meta.meta_id) @@ -216,7 +219,6 @@ def __labelVersionParsing(self): """Fallback version parsing using image's label (if exist). This method can only be used if version has not been provided from the image's tag.""" if "N/A" in self.__image_version and self.__image is not None: - logger.debug("Try to retrieve image version from labels") version_label = self.__image.labels.get("org.exegol.version") if version_label is not None: self.__setImageVersion(version_label, source_tag=False) @@ -354,6 +356,7 @@ def mergeImages(cls, remote_images: List[MetaImages], local_images: List[Image]) - unknown : no internet connection = no information from the registry - not install : other remote images without any match Return a list of ordered ExegolImage.""" + logger.debug("Comparing and merging local and remote images data") results = [] latest_installed: List[str] = [] cls.__mergeMetaImages(remote_images) @@ -362,7 +365,8 @@ def mergeImages(cls, remote_images: List[MetaImages], local_images: List[Image]) for r_img in remote_images: remote_img_dict[r_img.name] = r_img - # Find a match for each local image + # Searching a match for each local image + logger.debug("Searching a match for each image installed") for img in local_images: current_local_img = ExegolImage(docker_image=img) # quick handle of local images @@ -454,12 +458,12 @@ def __reorderImages(cls, images: List['ExegolImage']) -> List['ExegolImage']: return result @staticmethod - def __processSize(size: int, precision: int = 1) -> str: + def __processSize(size: int, precision: int = 1, compression_factor: float = 1) -> str: """Text formatter from size number to human-readable size.""" # https://stackoverflow.com/a/32009595 suffixes = ["B", "KB", "MB", "GB", "TB"] suffix_index = 0 - calc: float = size + calc: float = size * compression_factor while calc > 1024 and suffix_index < 4: suffix_index += 1 # increment the index of the suffix calc = calc / 1024 # apply the division @@ -511,7 +515,7 @@ def getStatus(self, include_version: bool = True) -> str: if self.getLatestVersion(): status += f" (v.{self.getImageVersion()} :arrow_right: v.{self.getLatestVersion()})" else: - status += f" (v.{self.getImageVersion()})" + status += f" (currently v.{self.getImageVersion()})" status += "[/orange3]" return status else: @@ -565,8 +569,12 @@ def __setRealSize(self, value: int): self.__disk_size = self.__processSize(value) def getRealSize(self) -> str: - """On-Disk size getter""" - return self.__disk_size + """Image size getter. If the image is installed, return the on-disk size, otherwise return the remote size""" + return self.__disk_size if self.__is_install else f"[bright_black]~{self.__remote_est_size}[/bright_black]" + + def getRealSizeRaw(self) -> str: + """Image size getter without color. If the image is installed, return the on-disk size, otherwise return the remote size""" + return self.__disk_size if self.__is_install else self.__remote_est_size def getDownloadSize(self) -> str: """Remote size getter""" @@ -574,10 +582,6 @@ def getDownloadSize(self) -> str: return "local" return self.__dl_size - def getSize(self) -> str: - """Image size getter. If the image is installed, return the on-disk size, otherwise return the remote size""" - return self.__disk_size if self.__is_install else f"[bright_black]{self.__dl_size} (compressed)[/bright_black]" - def getEntrypointConfig(self) -> Optional[Union[str, List[str]]]: """Image's entrypoint configuration getter. Exegol images before 3.x.x don't have any entrypoint set (because /.exegol/entrypoint.sh don't exist yet. In this case, this getter will return None.""" @@ -585,7 +589,10 @@ def getEntrypointConfig(self) -> Optional[Union[str, List[str]]]: def getBuildDate(self): """Build date getter""" - if "N/A" not in self.__build_date.upper(): + if not self.__build_date: + # Handle empty string + return "[bright_black]N/A[/bright_black]" + elif "N/A" not in self.__build_date.upper(): return datetime.strptime(self.__build_date, "%Y-%m-%dT%H:%M:%SZ").strftime("%d/%m/%Y %H:%M") else: return self.__build_date diff --git a/exegol/utils/ContainerLogStream.py b/exegol/utils/ContainerLogStream.py new file mode 100644 index 00000000..51b278f8 --- /dev/null +++ b/exegol/utils/ContainerLogStream.py @@ -0,0 +1,72 @@ +import asyncio +import concurrent.futures +import threading +import time +from datetime import datetime, timedelta +from typing import Union, List, Any, Optional + +from docker.models.containers import Container +from docker.types import CancellableStream + +from exegol.utils.ExeLog import logger + + +class ContainerLogStream: + + def __init__(self, container: Container, start_date: Optional[datetime] = None, timeout: int = 5): + # Container to extract logs from + self.__container = container + # Fetch more logs from this datetime + self.__start_date: datetime = datetime.utcnow() if start_date is None else start_date + self.__since_date = self.__start_date + self.__until_date: Optional[datetime] = None + # The data stream is returned from the docker SDK. It can contain multiple line at the same. + self.__data_stream = None + self.__line_buffer = b'' + + # Enable timeout if > 0. Passed timeout_date, the iterator will stop. + self.__enable_timeout = timeout > 0 + self.__timeout_date: datetime = self.__since_date + timedelta(seconds=timeout) + + # Hint message flag + self.__tips_sent = False + self.__tips_timedelta = self.__start_date + timedelta(seconds=15) + + def __iter__(self): + return self + + def __next__(self): + """Get the next line of the stream""" + if self.__until_date is None: + self.__until_date = datetime.utcnow() + while True: + # The data stream is fetch from the docker SDK once empty. + if self.__data_stream is None: + # The 'follow' mode cannot be used because there is no timeout mechanism and will stuck the process forever + self.__data_stream = self.__container.logs(stream=True, follow=False, since=self.__since_date, until=self.__until_date) + assert self.__data_stream is not None + # Parsed the data stream to extract characters and merge them into a line. + for streamed_char in self.__data_stream: + # When detecting an end of line, the buffer is returned as a single line. + if (streamed_char == b'\r' or streamed_char == b'\n') and len(self.__line_buffer) > 0: + line = self.__line_buffer.decode('utf-8').strip() + self.__line_buffer = b"" + return line + else: + self.__enable_timeout = False # disable timeout if the container is up-to-date and support console logging + self.__line_buffer += streamed_char # add characters to the line buffer + # When the data stream is empty, check if a timeout condition apply + if self.__enable_timeout and self.__until_date >= self.__timeout_date: + logger.debug("Container log stream timed-out") + raise StopIteration + elif not self.__tips_sent and self.__until_date >= self.__tips_timedelta: + self.__tips_sent = True + logger.info("Your start-up sequence takes time, your my-resource setup configuration may be significant.") + logger.info("[orange3][Tips][/orange3] If you want to skip startup update, " + "you can use [green]CTRL+C[/green] and spawn a shell immediately. " + "[blue](Startup sequence will continue in background)[/blue]") + # Prepare the next iteration to fetch next logs + self.__data_stream = None + self.__since_date = self.__until_date + time.sleep(0.5) # Wait for more logs + self.__until_date = datetime.utcnow() diff --git a/exegol/utils/DockerUtils.py b/exegol/utils/DockerUtils.py index ae2752c5..4f9e5089 100644 --- a/exegol/utils/DockerUtils.py +++ b/exegol/utils/DockerUtils.py @@ -9,7 +9,10 @@ from docker.models.volumes import Volume from requests import ReadTimeout +from exegol.config.ConstantConfig import ConstantConfig from exegol.config.DataCache import DataCache +from exegol.config.EnvInfo import EnvInfo +from exegol.config.UserConfig import UserConfig from exegol.console.TUI import ExegolTUI from exegol.console.cli.ParametersManager import ParametersManager from exegol.exceptions.ExegolExceptions import ObjectNotFound @@ -17,10 +20,7 @@ from exegol.model.ExegolContainerTemplate import ExegolContainerTemplate from exegol.model.ExegolImage import ExegolImage from exegol.model.MetaImages import MetaImages -from exegol.config.ConstantConfig import ConstantConfig -from exegol.config.EnvInfo import EnvInfo from exegol.utils.ExeLog import logger, console, ExeLog -from exegol.config.UserConfig import UserConfig from exegol.utils.WebUtils import WebUtils @@ -41,11 +41,11 @@ class DockerUtils: EnvInfo.initData(__daemon_info) except DockerException as err: if 'ConnectionRefusedError' in str(err): - logger.critical("Unable to connect to docker (from env config). Is docker running on your machine? " - "Exiting.") + logger.critical(f"Unable to connect to docker (from env config). Is docker running on your machine? Exiting.{os.linesep}" + f" Check documentation for help: https://exegol.readthedocs.io/en/latest/getting-started/faq.html#unable-to-connect-to-docker") elif 'FileNotFoundError' in str(err): - logger.critical("Unable to connect to docker. Is docker installed on your machine? " - "Exiting.") + logger.critical(f"Unable to connect to docker. Is docker installed on your machine? Exiting.{os.linesep}" + f" Check documentation for help: https://exegol.readthedocs.io/en/latest/getting-started/faq.html#unable-to-connect-to-docker") else: logger.error(err) logger.critical( @@ -81,6 +81,9 @@ def listContainers(cls) -> List[ExegolContainer]: logger.critical(err.explanation) # Not reachable, critical logging will exit return # type: ignore + except ReadTimeout: + logger.critical("Received a timeout error, Docker is busy... Unable to list containers, retry later.") + return # type: ignore for container in docker_containers: cls.__containers.append(ExegolContainer(container)) return cls.__containers @@ -98,36 +101,44 @@ def createContainer(cls, model: ExegolContainerTemplate, temporary: bool = False docker_volume = cls.__loadDockerVolume(volume_path=volume['Source'], volume_name=volume['Target']) if docker_volume is None: logger.warning(f"Error while creating docker volume '{volume['Target']}'") - entrypoint, command = model.config.getEntrypointCommand(model.image.getEntrypointConfig()) + entrypoint, command = model.config.getEntrypointCommand() logger.debug(f"Entrypoint: {entrypoint}") logger.debug(f"Cmd: {command}") + # The 'create' function must be called to create a container without starting it + # in order to hot patch the entrypoint.sh with wrapper features (the container will be started after postCreateSetup) + docker_create_function = cls.__client.containers.create + docker_args = {"image": model.image.getDockerRef(), + "entrypoint": entrypoint, + "command": command, + "detach": True, + "name": model.container_name, + "hostname": model.config.hostname, + "extra_hosts": model.config.getExtraHost(), + "devices": model.config.getDevices(), + "environment": model.config.getEnvs(), + "labels": model.config.getLabels(), + "network_mode": model.config.getNetworkMode(), + "ports": model.config.getPorts(), + "privileged": model.config.getPrivileged(), + "cap_add": model.config.getCapabilities(), + "sysctls": model.config.getSysctls(), + "shm_size": model.config.shm_size, + "stdin_open": model.config.interactive, + "tty": model.config.tty, + "mounts": model.config.getVolumes(), + "working_dir": model.config.getWorkingDir()} + if temporary: + # Only the 'run' function support the "remove" parameter + docker_create_function = cls.__client.containers.run + docker_args["remove"] = temporary + docker_args["auto_remove"] = temporary try: - container = cls.__client.containers.run(model.image.getDockerRef(), - entrypoint=entrypoint, - command=command, - detach=True, - name=model.container_name, - hostname=model.hostname, - extra_hosts={model.hostname: '127.0.0.1'}, - devices=model.config.getDevices(), - environment=model.config.getEnvs(), - labels=model.config.getLabels(), - network_mode=model.config.getNetworkMode(), - ports=model.config.getPorts(), - privileged=model.config.getPrivileged(), - cap_add=model.config.getCapabilities(), - sysctls=model.config.getSysctls(), - shm_size=model.config.shm_size, - stdin_open=model.config.interactive, - tty=model.config.tty, - mounts=model.config.getVolumes(), - remove=temporary, - auto_remove=temporary, - working_dir=model.config.getWorkingDir()) + container = docker_create_function(**docker_args) except APIError as err: message = err.explanation.decode('utf-8').replace('[', '\\[') if type(err.explanation) is bytes else err.explanation - message = message.replace('[', '\\[') - logger.error(message) + if message is not None: + message = message.replace('[', '\\[') + logger.error(message) logger.debug(err) model.rollback() try: @@ -209,6 +220,9 @@ def __loadDockerVolume(cls, volume_path: str, volume_name: str) -> Volume: logger.debug(e.explanation) else: raise NotFound('Volume must be reloaded') + except ReadTimeout: + logger.error(f"Received a timeout error, Docker is busy... Volume {volume_name} cannot be automatically removed. Please, retry later the following command:{os.linesep}" + f" [orange3]docker volume rm {volume_name}[/orange3]") except NotFound: try: # Creating a docker volume bind to a host path @@ -223,9 +237,15 @@ def __loadDockerVolume(cls, volume_path: str, volume_name: str) -> Volume: logger.debug(err) logger.critical(err.explanation) return None # type: ignore + except ReadTimeout: + logger.critical(f"Received a timeout error, Docker is busy... Volume {volume_name} cannot be created.") + return # type: ignore except APIError as err: logger.critical(f"Unexpected error by Docker SDK : {err}") return None # type: ignore + except ReadTimeout: + logger.critical("Received a timeout error, Docker is busy... Unable to enumerate volume, retry later.") + return None # type: ignore return volume # # # Image Section # # # @@ -304,6 +324,9 @@ def getInstalledImage(cls, tag: str) -> ExegolImage: else: logger.critical(f"Error on image loading: {err}") return # type: ignore + except ReadTimeout: + logger.critical("Received a timeout error, Docker is busy... Unable to list images, retry later.") + return # type: ignore return ExegolImage(docker_image=docker_local_image).autoLoad() else: for img in cls.__images: @@ -329,6 +352,9 @@ def __listLocalImages(cls, tag: Optional[str] = None) -> List[Image]: logger.critical(err.explanation) # Not reachable, critical logging will exit return # type: ignore + except ReadTimeout: + logger.critical("Received a timeout error, Docker is busy... Unable to list local images, retry later.") + return # type: ignore # Filter out image non-related to the right repository result = [] ids = set() @@ -364,6 +390,9 @@ def __findLocalRecoveryImages(cls, include_untag: bool = False) -> List[Image]: except APIError as err: logger.debug(f"Error occurred in recovery mode: {err}") return [] + except ReadTimeout: + logger.critical("Received a timeout error, Docker is busy... Unable to enumerate lost images, retry later.") + return # type: ignore result = [] id_list = set() for img in recovery_images: @@ -425,6 +454,9 @@ def __findImageMatch(cls, remote_image: ExegolImage): docker_image = cls.__client.images.get(f"{ConstantConfig.IMAGE_NAME}@{remote_id}") except ImageNotFound: raise ObjectNotFound + except ReadTimeout: + logger.critical("Received a timeout error, Docker is busy... Unable to find a specific image, retry later.") + return # type: ignore remote_image.resetDockerImage() remote_image.setDockerObject(docker_image) @@ -439,7 +471,8 @@ def downloadImage(cls, image: ExegolImage, install_mode: bool = False) -> bool: logger.info(f"{'Installing' if install_mode else 'Updating'} exegol image : {image.getName()}") name = image.updateCheck() if name is not None: - logger.info(f"Starting download. Please wait, this might be (very) long.") + logger.info(f"Pulling compressed image, starting a [cyan1]~{image.getDownloadSize()}[/cyan1] download :satellite:") + logger.info(f"Once downloaded and uncompressed, the image will take [cyan1]~{image.getRealSizeRaw()}[/cyan1] on disk :floppy_disk:") logger.debug(f"Downloading {ConstantConfig.IMAGE_NAME}:{name} ({image.getArch()})") try: ExegolTUI.downloadDockerLayer( @@ -448,7 +481,7 @@ def downloadImage(cls, image: ExegolImage, install_mode: bool = False) -> bool: stream=True, decode=True, platform="linux/" + image.getArch())) - logger.success(f"Image successfully updated") + logger.success(f"Image successfully {'installed' if install_mode else 'updated'}") # Remove old image if not install_mode and image.isInstall() and UserConfig().auto_remove_images: cls.removeImage(image, upgrade_mode=not install_mode) @@ -462,6 +495,8 @@ def downloadImage(cls, image: ExegolImage, install_mode: bool = False) -> bool: else: logger.debug(f"Error: {err}") logger.critical(f"An error occurred while downloading this image: {err.explanation}") + except ReadTimeout: + logger.critical(f"Received a timeout error, Docker is busy... Unable to download {name} image, retry later.") return False @classmethod @@ -483,6 +518,10 @@ def downloadVersionTag(cls, image: ExegolImage) -> Union[ExegolImage, str]: else: logger.debug(f"Error: {err}") return f"en unknown error occurred while downloading this image : {err.explanation}" + except ReadTimeout: + logger.critical(f"Received a timeout error, Docker is busy... Unable to download an image tag, retry later the following command:{os.linesep}" + f" [orange3]docker pull --platform linux/{image.getArch()} {ConstantConfig.IMAGE_NAME}:{image.getLatestVersionName()}[/orange3].") + return # type: ignore @classmethod def removeImage(cls, image: ExegolImage, upgrade_mode: bool = False) -> bool: @@ -520,7 +559,7 @@ def removeImage(cls, image: ExegolImage, upgrade_mode: bool = False) -> bool: return False @classmethod - def buildImage(cls, tag: str, build_profile: Optional[str] = None, build_dockerfile: Optional[str] = None): + def buildImage(cls, tag: str, build_profile: Optional[str] = None, build_dockerfile: Optional[str] = None, dockerfile_path: str = ConstantConfig.build_context_path): """Build a docker image from source""" if ParametersManager().offline_mode: logger.critical("It's not possible to build a docker image in offline mode. The build process need access to internet ...") @@ -529,8 +568,8 @@ def buildImage(cls, tag: str, build_profile: Optional[str] = None, build_dockerf if build_profile is None or build_dockerfile is None: build_profile = "full" build_dockerfile = "Dockerfile" - logger.info("Starting build. Please wait, this might be [bold](very)[/bold] long.") - logger.verbose(f"Creating build context from [gold]{ConstantConfig.build_context_path}[/gold] with " + logger.info("Starting build. Please wait, this will be long.") + logger.verbose(f"Creating build context from [gold]{dockerfile_path}[/gold] with " f"[green][b]{build_profile}[/b][/green] profile ({ParametersManager().arch}).") if EnvInfo.arch != ParametersManager().arch: logger.warning("Building an image for a different host architecture can cause unexpected problems and slowdowns!") @@ -539,7 +578,7 @@ def buildImage(cls, tag: str, build_profile: Optional[str] = None, build_dockerf # tag is the name of the final build # dockerfile is the Dockerfile filename ExegolTUI.buildDockerImage( - cls.__client.api.build(path=ConstantConfig.build_context_path, + cls.__client.api.build(path=dockerfile_path, dockerfile=build_dockerfile, tag=f"{ConstantConfig.IMAGE_NAME}:{tag}", buildargs={"TAG": f"{build_profile}", @@ -559,3 +598,6 @@ def buildImage(cls, tag: str, build_profile: Optional[str] = None, build_dockerf logger.debug(f"Error: {err}") else: logger.critical(f"An error occurred while building this image : {err}") + except ReadTimeout: + logger.critical("Received a timeout error, Docker is busy... Unable to build the local image, retry later.") + return # type: ignore diff --git a/exegol/utils/FsUtils.py b/exegol/utils/FsUtils.py index 73ddcb58..2e1267c7 100644 --- a/exegol/utils/FsUtils.py +++ b/exegol/utils/FsUtils.py @@ -2,7 +2,7 @@ import re import stat import subprocess -from pathlib import Path, PurePosixPath, PurePath +from pathlib import Path, PurePath from typing import Optional from exegol.config.EnvInfo import EnvInfo @@ -20,7 +20,7 @@ def parseDockerVolumePath(source: str) -> PurePath: return src_path else: # Remove docker mount path if exist - return PurePosixPath(source.replace('/run/desktop/mnt/host', '')) + return PurePath(source.replace('/run/desktop/mnt/host', '')) def resolvPath(path: Path) -> str: @@ -68,7 +68,12 @@ def setGidPermission(root_folder: Path): perm_alert = True for sub_item in root_folder.rglob('*'): # Find every subdirectory - if not sub_item.is_dir(): + try: + if not sub_item.is_dir(): + continue + except PermissionError: + if not sub_item.is_symlink(): + logger.error(f"Permission denied when trying to resolv {str(sub_item)}") continue # If the permission is already set, skip if sub_item.stat().st_mode & stat.S_ISGID: @@ -82,6 +87,6 @@ def setGidPermission(root_folder: Path): if perm_alert: logger.warning(f"In order to share files between your host and exegol (without changing the permission), you can run [orange3]manually[/orange3] this command from your [red]host[/red]:") logger.empty_line() - logger.raw(f"sudo chgrp -R $(id -g) {root_folder} && sudo find {root_folder} -type d -exec chmod g+rws {{}} \;", level=logging.WARNING) + logger.raw(f"sudo chgrp -R $(id -g) {root_folder} && sudo find {root_folder} -type d -exec chmod g+rws {{}} \\;", level=logging.WARNING) logger.empty_line() logger.empty_line() diff --git a/exegol/utils/GitUtils.py b/exegol/utils/GitUtils.py index 5a3af577..868d7b56 100644 --- a/exegol/utils/GitUtils.py +++ b/exegol/utils/GitUtils.py @@ -44,7 +44,7 @@ def __init__(self, elif sys.platform == "win32": # Skip next platform specific code (temp fix for mypy static code analysis) pass - elif not EnvInfo.is_windows_shell and test_git_dir.lstat().st_uid != os.getuid(): + elif not EnvInfo.is_windows_shell and os.getuid() != 0 and test_git_dir.lstat().st_uid != os.getuid(): raise PermissionError(test_git_dir.owner()) except ReferenceError: if self.__git_name == "wrapper": @@ -288,8 +288,6 @@ def __initSubmodules(self): logger.verbose(f"Git {self.getName()} init submodules") # These modules are init / updated manually blacklist_heavy_modules = ["exegol-resources"] - # Submodules dont have depth submodule limits - depth_limit = not self.__is_submodule if self.__gitRepo is None: return with console.status(f"Initialization of git submodules", spinner_style="blue") as s: @@ -299,9 +297,9 @@ def __initSubmodules(self): logger.error(f"Unable to find any git submodule from '{self.getName()}' repository. Check the path in the file {self.__repo_path / '.git'}") return for current_sub in submodules: + logger.debug(f"Loading repo submodules: {current_sub}") # Submodule update are skipped if blacklisted or if the depth limit is set - if current_sub.name in blacklist_heavy_modules or \ - (depth_limit and ('/' in current_sub.name or '\\' in current_sub.name)): + if current_sub.name in blacklist_heavy_modules: continue s.update(status=f"Downloading git submodules [green]{current_sub.name}[/green]") from git.exc import GitCommandError diff --git a/exegol/utils/GuiUtils.py b/exegol/utils/GuiUtils.py index a597428e..62ee1f95 100644 --- a/exegol/utils/GuiUtils.py +++ b/exegol/utils/GuiUtils.py @@ -7,15 +7,15 @@ from pathlib import Path from typing import Optional +from exegol.config.EnvInfo import EnvInfo from exegol.console.ExegolPrompt import Confirm from exegol.exceptions.ExegolExceptions import CancelOperation -from exegol.config.EnvInfo import EnvInfo from exegol.utils.ExeLog import logger, console class GuiUtils: """This utility class allows determining if the current system supports the GUI - from the information of the system.""" + from the information of the system (through X11 sharing).""" __distro_name = "" default_x11_path = "/tmp/.X11-unix" @@ -26,7 +26,7 @@ def isGuiAvailable(cls) -> bool: Check if the host OS can support GUI application with X11 sharing :return: bool """ - # GUI was not supported on Windows before WSLg + # GUI (X11 sharing) was not supported on Windows before WSLg if EnvInfo.isWindowsHost(): return cls.__windowsGuiChecks() elif EnvInfo.isMacHost(): @@ -48,9 +48,9 @@ def getX11SocketPath(cls) -> Optional[str]: # Mount point from a WSL shell context return f"/mnt/wslg/.X11-unix" else: - # From a Windows context, a WSL distro should have been supply during GUI checks + # From a Windows context, a WSL distro should have been supply during GUI (X11 sharing) checks logger.debug(f"No WSL distro have been previously found: '{cls.__distro_name}'") - raise CancelOperation("Exegol tried to create a container with GUI support on a Windows host " + raise CancelOperation("Exegol tried to create a container with X11 sharing on a Windows host " "without having performed the availability tests before.") elif EnvInfo.isMacHost(): # Docker desktop don't support UNIX socket through volume, we are using XQuartz over the network until then @@ -68,10 +68,11 @@ def getDisplayEnv(cls) -> str: # xquartz Mac mode return "host.docker.internal:0" - # Add ENV check is case of user don't have it, which will mess up GUI if fallback does not work + # Add ENV check is case of user don't have it, which will mess up GUI (X11 sharing) if fallback does not work # @see https://github.com/ThePorgs/Exegol/issues/148 - if os.getenv("DISPLAY") is None: - logger.warning("The DISPLAY environment variable is not set on your host. This can prevent GUI apps to start") + if not EnvInfo.is_windows_shell: + if os.getenv("DISPLAY") is None: + logger.warning("The DISPLAY environment variable is not set on your host. This can prevent GUI apps to start through X11 sharing") # DISPLAY var is fetch from the current user environment. If it doesn't exist, using ':0'. return os.getenv('DISPLAY', ":0") @@ -81,7 +82,7 @@ def getDisplayEnv(cls) -> str: @classmethod def __macGuiChecks(cls) -> bool: """ - Procedure to check if the Mac host supports GUI with docker through XQuartz + Procedure to check if the Mac host supports GUI (X11 sharing) with docker through XQuartz :return: bool """ if not cls.__isXQuartzInstalled(): @@ -170,27 +171,27 @@ def __startXQuartz() -> bool: @classmethod def __windowsGuiChecks(cls) -> bool: """ - Procedure to check if the Windows host supports GUI with docker through WSLg + Procedure to check if the Windows host supports GUI (X11 sharing) with docker through WSLg :return: bool """ logger.debug("Testing WSLg availability") - # WSL + WSLg must be available on the Windows host for the GUI to work + # WSL + WSLg must be available on the Windows host for the GUI to work through X11 sharing if not cls.__wsl_available(): - logger.error("WSL is [orange3]not available[/orange3] on your system. GUI is not supported.") + logger.error("WSL is [orange3]not available[/orange3] on your system. X11 sharing is not supported.") return False # Only WSL2 support WSLg if EnvInfo.getDockerEngine() != EnvInfo.DockerEngine.WLS2: - logger.error("Docker must be run with [orange3]WSL2[/orange3] engine in order to support GUI applications.") + logger.error("Docker must be run with [orange3]WSL2[/orange3] engine in order to support X11 sharing (i.e. GUI apps).") return False logger.debug("WSL is [green]available[/green] and docker is using WSL2") - # X11 GUI socket can only be shared from a WSL (to find WSLg mount point) + # X11 socket can only be shared from a WSL (to find WSLg mount point) if EnvInfo.current_platform != "WSL": logger.debug("Exegol is running from a Windows context (e.g. Powershell), a WSL instance must be found to share WSLg X11 socket") cls.__distro_name = cls.__find_wsl_distro() logger.debug(f"Set WSL Distro as: '{cls.__distro_name}'") - # If no WSL is found, propose to continue without GUI + # If no WSL is found, propose to continue without GUI (X11 sharing) if not cls.__distro_name and not Confirm( - "Do you want to continue [orange3]without[/orange3] GUI support ?", default=True): + "Do you want to continue [orange3]without[/orange3] X11 sharing (i.e. GUI support)?", default=True): raise KeyboardInterrupt else: logger.debug("Using current WSL context for X11 socket sharing") @@ -199,12 +200,12 @@ def __windowsGuiChecks(cls) -> bool: return True elif cls.__wslg_eligible(): logger.info("[green]WSLg[/green] is available on your system but [orange3]not installed[/orange3].") - logger.info("Make sure, [green]WSLg[/green] is installed on your Windows by running " - "'wsl --update' as [orange3]admin[/orange3].") - return True + logger.info("Make sure, your Windows is [green]up-to-date[/green] and [green]WSLg[/green] is installed on " + "your host by running 'wsl --update' as [orange3]admin[/orange3].") + return False logger.debug("WSLg is [orange3]not available[/orange3]") logger.warning("Display sharing is [orange3]not supported[/orange3] on your version of Windows. " - "You need to upgrade to [turquoise2]Windows 11[/turquoise2].") + "You need to upgrade to [turquoise2]Windows 10+[/turquoise2].") return False @staticmethod @@ -262,10 +263,12 @@ def __wslg_installed(cls) -> bool: :return: bool """ if EnvInfo.current_platform == "WSL": - if Path("/mnt/host/wslg/versions.txt").is_file(): + if (Path("/mnt/host/wslg/versions.txt").is_file() or + Path("/mnt/wslg/versions.txt").is_file()): return True else: - if cls.__wsl_test("/mnt/host/wslg/versions.txt", name=cls.__distro_name): + if (cls.__wsl_test("/mnt/host/wslg/versions.txt", name=cls.__distro_name) or + cls.__wsl_test("/mnt/wslg/versions.txt", name=cls.__distro_name)): return True logger.debug("WSLg check failed.. Trying a fallback check method.") return cls.__wsl_test("/mnt/host/wslg/versions.txt") or cls.__wsl_test("/mnt/wslg/versions.txt", name=None) @@ -276,19 +279,18 @@ def __wslg_eligible() -> bool: Check if the current Windows version support WSLg :return: """ + if EnvInfo.current_platform == "WSL": + # WSL is only available on Windows 10 & 11 so WSLg can be installed. + return True try: os_version_raw, _, build_number_raw = EnvInfo.getWindowsRelease().split('.')[:3] except ValueError: logger.debug(f"Impossible to find the version of windows: '{EnvInfo.getWindowsRelease()}'") - logger.error("Exegol can't know if your [orange3]version of Windows[/orange3] can support dockerized GUIs.") + logger.error("Exegol can't know if your [orange3]version of Windows[/orange3] can support dockerized GUIs (X11 sharing).") return False - # Available from Windows 10 Build 21364 - # Available from Windows 11 Build 22000 + # Available for Windows 10 & 11 os_version = int(os_version_raw) - build_number = int(build_number_raw) - if os_version == 10 and build_number >= 21364: - return True - elif os_version > 10: + if os_version >= 10: return True return False @@ -297,7 +299,7 @@ def __find_wsl_distro(cls) -> str: distro_name = "" # these distros cannot be used to load WSLg socket blacklisted_distro = ["docker-desktop", "docker-desktop-data"] - ret = subprocess.Popen(["C:\Windows\system32\wsl.exe", "-l"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + ret = subprocess.Popen(["C:\\Windows\\system32\\wsl.exe", "-l"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) # Wait for WSL process to end ret.wait() if ret.returncode == 0: @@ -325,7 +327,7 @@ def __find_wsl_distro(cls) -> str: while not cls.__check_wsl_docker_integration(name): eligible = False logger.warning( - f"The '{name}' WSL distribution could be used to [green]enable the GUI[/green] on exegol but the docker integration is [orange3]not enabled[/orange3].") + f"The '{name}' WSL distribution can be used to [green]enable X11 sharing[/green] (i.e. GUI apps) on exegol but the docker integration is [orange3]not enabled[/orange3].") if not Confirm( f"Do you want to [red]manually[/red] enable docker integration for WSL '{name}'?", default=True): @@ -351,7 +353,7 @@ def __find_wsl_distro(cls) -> str: @classmethod def __create_default_wsl(cls) -> bool: logger.info("Creating Ubuntu WSL distribution. Please wait.") - ret = subprocess.Popen(["C:\Windows\system32\wsl.exe", "--install", "-d", "Ubuntu"], stderr=subprocess.PIPE) + ret = subprocess.Popen(["C:\\Windows\\system32\\wsl.exe", "--install", "-d", "Ubuntu"], stderr=subprocess.PIPE) ret.wait() logger.info("Please follow installation instructions on the new window.") if ret.returncode != 0: @@ -367,7 +369,7 @@ def __create_default_wsl(cls) -> bool: if docker_settings is not None and docker_settings.get("enableIntegrationWithDefaultWslDistro", False): logger.verbose("Set WSL Ubuntu as default to automatically enable docker integration") # Set new WSL distribution as default to start it and enable docker integration - ret = subprocess.Popen(["C:\Windows\system32\wsl.exe", "-s", "Ubuntu"], stderr=subprocess.PIPE) + ret = subprocess.Popen(["C:\\Windows\\system32\\wsl.exe", "-s", "Ubuntu"], stderr=subprocess.PIPE) ret.wait() # Wait for the docker integration (10 try, 1 sec apart) with console.status("Waiting for the activation of the docker integration", spinner_style="blue"): diff --git a/exegol/utils/WebUtils.py b/exegol/utils/WebUtils.py index e43a437c..47ce893d 100644 --- a/exegol/utils/WebUtils.py +++ b/exegol/utils/WebUtils.py @@ -127,7 +127,10 @@ 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.content.decode('utf-8')}") + if e.response is not None: + logger.error(f"Response error: {e.response.content.decode('utf-8')}") + else: + logger.error(f"Response error: {e}") except requests.exceptions.ConnectionError as err: logger.debug(f"Error: {err}") error_re = re.search(r"\[Errno [-\d]+]\s?([^']*)('\))+\)*", str(err)) diff --git a/exegol/utils/imgsync/ImageScriptSync.py b/exegol/utils/imgsync/ImageScriptSync.py new file mode 100644 index 00000000..646f0762 --- /dev/null +++ b/exegol/utils/imgsync/ImageScriptSync.py @@ -0,0 +1,61 @@ +import io +import tarfile + +from exegol.config.ConstantConfig import ConstantConfig +from exegol.utils.ExeLog import logger + + +class ImageScriptSync: + + @staticmethod + def getCurrentStartVersion(): + """Find the current version of the spawn.sh script.""" + with open(ConstantConfig.spawn_context_path_obj, 'r') as file: + for line in file.readlines(): + if line.startswith('# Spawn Version:'): + return line.split(':')[-1].strip() + logger.critical(f"The spawn.sh version cannot be found, check your exegol setup! {ConstantConfig.spawn_context_path_obj}") + + @staticmethod + def getImageSyncTarData(include_entrypoint: bool = False, include_spawn: bool = False): + """The purpose of this class is to generate and overwrite scripts like the entrypoint or spawn.sh inside exegol containers + to integrate the latest features, whatever the version of the image.""" + + # Create tar file + stream = io.BytesIO() + with tarfile.open(fileobj=stream, mode='w|') as entry_tar: + + # Load entrypoint data + if include_entrypoint: + entrypoint_script_path = ConstantConfig.entrypoint_context_path_obj + logger.debug(f"Entrypoint script path: {str(entrypoint_script_path)}") + if not entrypoint_script_path.is_file(): + logger.critical("Unable to find the entrypoint script! Your Exegol installation is probably broken...") + return None + with open(entrypoint_script_path, 'rb') as f: + raw = f.read() + data = io.BytesIO(initial_bytes=raw) + + # Import file to tar object + info = tarfile.TarInfo(name="/.exegol/entrypoint.sh") + info.size = len(raw) + info.mode = 0o500 + entry_tar.addfile(info, fileobj=data) + + # Load start data + if include_spawn: + spawn_script_path = ConstantConfig.spawn_context_path_obj + logger.debug(f"Spawn script path: {str(spawn_script_path)}") + if not spawn_script_path.is_file(): + logger.error("Unable to find the spawn script! Your Exegol installation is probably broken...") + return None + with open(spawn_script_path, 'rb') as f: + raw = f.read() + data = io.BytesIO(initial_bytes=raw) + + # Import file to tar object + info = tarfile.TarInfo(name="/.exegol/spawn.sh") + info.size = len(raw) + info.mode = 0o500 + entry_tar.addfile(info, fileobj=data) + return stream.getvalue() diff --git a/exegol/utils/imgsync/__init__.py b/exegol/utils/imgsync/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/exegol/utils/imgsync/entrypoint.sh b/exegol/utils/imgsync/entrypoint.sh new file mode 100755 index 00000000..d5a2262b --- /dev/null +++ b/exegol/utils/imgsync/entrypoint.sh @@ -0,0 +1,125 @@ +#!/bin/bash +# SIGTERM received (the container is stopping, every process must be gracefully stopped before the timeout). +trap shutdown SIGTERM + +function exegol_init() { + usermod -s "/.exegol/spawn.sh" root > /dev/null +} + +# Function specific +function load_setups() { + # Load custom setups (supported setups, and user setup) + [[ -d "/var/log/exegol" ]] || mkdir -p /var/log/exegol + if [[ ! -f "/.exegol/.setup.lock" ]]; then + # Execute initial setup if lock file doesn't exist + echo >/.exegol/.setup.lock + # Run my-resources script. Logs starting with '[exegol]' will be print to the console and report back to the user through the wrapper. + if [ -f /.exegol/load_supported_setups.sh ]; then + echo "Installing [green]my-resources[/green] custom setup ..." + /.exegol/load_supported_setups.sh |& tee /var/log/exegol/load_setups.log | grep -i '^\[exegol]' | sed "s/^\[exegol\]\s*//gi" + [ -f /var/log/exegol/load_setups.log ] && echo "Compressing [green]my-resources[/green] logs" && gzip /var/log/exegol/load_setups.log && echo "My-resources loaded" + else + echo "[W]Your exegol image doesn't support my-resources custom setup!" + fi + fi +} + +function finish() { + echo "READY" +} + +function endless() { + # Start action / endless + finish + # Entrypoint for the container, in order to have a process hanging, to keep the container alive + # Alternative to running bash/zsh/whatever as entrypoint, which is longer to start and to stop and to very clean + # shellcheck disable=SC2162 + read -u 2 # read from stderr => endlessly wait effortlessly +} + +function shutdown() { + # Shutting down the container. + # Sending SIGTERM to all interactive process for proper closing + pgrep vnc && desktop-stop # Stop webui desktop if started TODO improve desktop shutdown + # shellcheck disable=SC2046 + kill $(pgrep -f -- openvpn | grep -vE '^1$') 2>/dev/null + # shellcheck disable=SC2046 + kill $(pgrep -x -f -- zsh) 2>/dev/null + # shellcheck disable=SC2046 + kill $(pgrep -x -f -- -zsh) 2>/dev/null + # shellcheck disable=SC2046 + kill $(pgrep -x -f -- bash) 2>/dev/null + # shellcheck disable=SC2046 + kill $(pgrep -x -f -- -bash) 2>/dev/null + # Wait for every active process to exit (e.g: shell logging compression, VPN closing, WebUI) + WAIT_LIST="$(pgrep -f "(.log|spawn.sh|vnc)" | grep -vE '^1$')" + for i in $WAIT_LIST; do + # Waiting for: $i PID process to exit + tail --pid="$i" -f /dev/null + done + exit 0 +} + +function _resolv_docker_host() { + # On docker desktop host, resolving the host.docker.internal before starting a VPN connection for GUI applications + DOCKER_IP=$(getent ahostsv4 host.docker.internal | head -n1 | awk '{ print $1 }') + if [[ "$DOCKER_IP" ]]; then + # Add docker internal host resolution to the hosts file to preserve access to the X server + echo "$DOCKER_IP host.docker.internal" >>/etc/hosts + # If the container share the host networks, no need to add a static mapping + ip route list match "$DOCKER_IP" table all | grep -v default || ip route add "$DOCKER_IP/32" $(ip route list | grep default | head -n1 | grep -Eo '(via [0-9]+\.[0-9]+\.[0-9]+\.[0-9]+ )?dev [a-zA-Z0-9]+') || echo '[W]Exegol cannot add a static route to resolv your host X11 server. GUI applications may not work.' + fi +} + +function ovpn() { + [[ "$DISPLAY" == *"host.docker.internal"* ]] && _resolv_docker_host + if ! command -v openvpn &> /dev/null + then + echo '[E]Your exegol image does not support the VPN feature' + else + # Starting openvpn as a job with '&' to be able to receive SIGTERM signal and close everything properly + echo "Starting [green]VPN[/green]" + openvpn --log-append /var/log/exegol/vpn.log "$@" & + sleep 2 # Waiting 2 seconds for the VPN to start before continuing + fi + +} + +function run_cmd() { + /bin/zsh -c "autoload -Uz compinit; compinit; source ~/.zshrc; eval \"$CMD\"" +} + +function desktop() { + if command -v desktop-start &> /dev/null + then + echo "Starting Exegol [green]desktop[/green] with [blue]${EXEGOL_DESKTOP_PROTO}[/blue]" + desktop-start &>> ~/.vnc/startup.log # Disable logging + sleep 2 # Waiting 2 seconds for the Desktop to start before continuing + else + echo '[E]Your exegol image does not support the Desktop features' + fi +} + +##### How "echo" works here with exegol ##### +# +# Every message printed here will be displayed to the console logs of the container +# The container logs will be displayed by the wrapper to the user at startup through a progress animation (and a verbose line if -v is set) +# The logs written to ~/banner.txt will be printed to the user through the .zshrc file on each new session (until the file is removed). +# Using 'tee -a' after a command will save the output to a file AND to the console logs. +# +############################################# +echo "Starting exegol" +exegol_init + +### Argument parsing + +# Par each parameter +for arg in "$@"; do + # Check if the function exist + FUNCTION_NAME=$(echo "$arg" | cut -d ' ' -f 1) + if declare -f "$FUNCTION_NAME" > /dev/null; then + $arg + else + echo "The function '$arg' doesn't exist." + fi +done diff --git a/exegol/utils/imgsync/spawn.sh b/exegol/utils/imgsync/spawn.sh new file mode 100755 index 00000000..6283b390 --- /dev/null +++ b/exegol/utils/imgsync/spawn.sh @@ -0,0 +1,66 @@ +#!/bin/bash + +# DO NOT CHANGE the syntax or text of the following line, only increment the version number +# Spawn Version:2 +# The spawn version allow the wrapper to compare the current version of the spawn.sh inside the container compare to the one on the current wrapper version. +# On new container, this file is automatically updated through a docker volume +# For legacy container, this version is fetch and the file updated if needed. + +function shell_logging() { + # First parameter is the method to use for shell logging (default to script) + local method=$1 + # The second parameter is the shell command to use for the user + local user_shell=$2 + # The third enable compression at the end of the session + local compress=$3 + + # Test if the command is supported on the current image + if ! command -v "$method" &> /dev/null + then + echo "Shell logging with $method is not supported by this image version, try with a newer one." + $user_shell + exit 0 + fi + + # Logging shell using $method and spawn a $user_shell shell + + umask 007 + mkdir -p /workspace/logs/ + local filelog + filelog="/workspace/logs/$(date +%d-%m-%Y_%H-%M-%S)_shell.${method}" + + case $method in + "asciinema") + # echo "Run using asciinema" + asciinema rec -i 2 --stdin --quiet --command "$user_shell" --title "$(hostname | sed 's/^exegol-/\[EXEGOL\] /') $(date '+%d/%m/%Y %H:%M:%S')" "$filelog" + ;; + + "script") + # echo "Run using script" + script -qefac "$user_shell" "$filelog" + ;; + + *) + echo "Unknown '$method' shell logging method, using 'script' as default shell logging method." + script -qefac "$user_shell" "$filelog" + ;; + esac + + if [[ "$compress" = 'True' ]]; then + echo 'compressing logs, please wait...' + gzip "$filelog" + fi + exit 0 +} + +# Find default user shell to use from env var +user_shell=${EXEGOL_START_SHELL:-"/bin/zsh"} + +# If shell logging is enable, the method to use is stored in env var +if [ "$EXEGOL_START_SHELL_LOGGING" ]; then + shell_logging "$EXEGOL_START_SHELL_LOGGING" "$user_shell" "$EXEGOL_START_SHELL_COMPRESS" +else + $user_shell +fi + +exit 0 diff --git a/requirements.txt b/requirements.txt index bc41ff5f..26970b65 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ -docker~=6.1.3 +docker~=7.0.0 requests>=2.31.0 -rich~=13.4.2 -GitPython~=3.1.29 -PyYAML>=6.0 -argcomplete~=3.1.1 \ No newline at end of file +rich~=13.7.0 +GitPython~=3.1.40 +PyYAML>=6.0.1 +argcomplete~=3.2.1 \ No newline at end of file diff --git a/setup.py b/setup.py index 2ec07207..4bc34d53 100644 --- a/setup.py +++ b/setup.py @@ -10,6 +10,7 @@ long_description = (here / 'README.md').read_text(encoding='utf-8') # Additional non-code data used by Exegol to build local docker image from source +## exegol-docker-build Dockerfiles source_directory = "exegol-docker-build" data_files_dict = {source_directory: [f"{source_directory}/Dockerfile"] + [str(profile) for profile in pathlib.Path(source_directory).rglob('*.dockerfile')]} data_files = [] @@ -22,6 +23,10 @@ if data_files_dict.get(key) is None: data_files_dict[key] = [] data_files_dict[key].append(str(path)) +## exegol scripts pushed from the wrapper +data_files_dict["exegol-imgsync"] = ["exegol/utils/imgsync/entrypoint.sh", + "exegol/utils/imgsync/spawn.sh"] + # Dict to tuple for k, v in data_files_dict.items(): data_files.append((k, v)) @@ -45,16 +50,17 @@ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "Operating System :: OS Independent", ], install_requires=[ - 'docker~=6.1.3', + 'docker~=7.0.0', 'requests>=2.31.0', - 'rich~=13.4.2', + 'rich~=13.7.0', 'PyYAML', - 'GitPython', - 'argcomplete~=3.1.1' + 'GitPython~=3.1.40', + 'argcomplete~=3.2.1' ], packages=find_packages(exclude=["tests"]), include_package_data=True, diff --git a/tests/test_exegol.py b/tests/test_exegol.py index 1687cd35..391312ae 100644 --- a/tests/test_exegol.py +++ b/tests/test_exegol.py @@ -2,4 +2,4 @@ def test_version(): - assert __version__ == '4.2.5' + assert __version__ == '4.3.0'