diff --git a/exegol/manager/ExegolController.py b/exegol/manager/ExegolController.py index 09163f3a..28b7aba4 100644 --- a/exegol/manager/ExegolController.py +++ b/exegol/manager/ExegolController.py @@ -3,6 +3,7 @@ try: import docker + import podman import git import requests import urllib3 @@ -85,5 +86,5 @@ def main(): logger.critical(f"A critical error occurred while running this git command: {' '.join(git_error.command)}") except Exception: print_exception_banner() - console.print_exception(show_locals=True, suppress=[docker, requests, git, urllib3, http]) + console.print_exception(show_locals=True, suppress=[docker, podman, requests, git, urllib3, http]) exit(1) diff --git a/exegol/manager/ExegolManager.py b/exegol/manager/ExegolManager.py index f609a8e9..73a06940 100644 --- a/exegol/manager/ExegolManager.py +++ b/exegol/manager/ExegolManager.py @@ -77,8 +77,9 @@ def start(cls): if not container.isNew(): # Check and warn user if some parameters don't apply to the current session cls.__checkUselessParameters() + docker_utils_instance = DockerUtils() container.start() - container.spawnShell() + container.spawnShell(docker_utils_instance.get_container_runtime()) @classmethod def exec(cls): @@ -114,7 +115,8 @@ def restart(cls): container.stop(timeout=5) container.start() logger.success(f"Container [green]{container.name}[/green] successfully restarted!") - container.spawnShell() + docker_utils_instance = DockerUtils() + container.spawnShell(docker_utils_instance.get_container_runtime()) @classmethod def install(cls): diff --git a/exegol/model/ContainerConfig.py b/exegol/model/ContainerConfig.py index 03bdd87a..942c5df5 100644 --- a/exegol/model/ContainerConfig.py +++ b/exegol/model/ContainerConfig.py @@ -10,8 +10,9 @@ from pathlib import Path, PurePath from typing import Optional, List, Dict, Union, Tuple, cast -from docker.models.containers import Container +from docker.models.containers import Container as DockerContainer from docker.types import Mount +from podman.domain.containers import Container as PodmanContainer from rich.prompt import Prompt from exegol.config.ConstantConfig import ConstantConfig @@ -81,7 +82,7 @@ class ExegolEnv(Enum): ExegolMetadata.comment.value: ["setComment", "getComment"], ExegolMetadata.password.value: ["setPasswd", "getPasswd"]} - def __init__(self, container: Optional[Container] = None): + def __init__(self, container: Optional[Union[DockerContainer, PodmanContainer]] = None): """Container config default value""" self.hostname = "" self.__enable_gui: bool = False @@ -132,7 +133,7 @@ def __init__(self, container: Optional[Container] = None): # ===== Config parsing section ===== - def __parseContainerConfig(self, container: Container): + def __parseContainerConfig(self, container: Union[DockerContainer, PodmanContainer]): """Parse Docker object to setup self configuration""" # Reset default attributes self.__passwd = None diff --git a/exegol/model/ExegolContainer.py b/exegol/model/ExegolContainer.py index e5d699d5..47bd7f34 100644 --- a/exegol/model/ExegolContainer.py +++ b/exegol/model/ExegolContainer.py @@ -6,7 +6,10 @@ from typing import Optional, Dict, Sequence, Tuple, Union from docker.errors import NotFound, ImageNotFound, APIError -from docker.models.containers import Container +from docker.models.containers import Container as DockerContainer + +from podman.errors import NotFound as PodmanNotFound, ImageNotFound as PodmanImageNotFound, APIError as PodmanAPIError +from podman.domain.containers import Container as PodmanContainer from exegol.config.EnvInfo import EnvInfo from exegol.console.ExegolPrompt import Confirm @@ -24,36 +27,36 @@ 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}") - self.__container: Container = docker_container - self.__id: str = docker_container.id + def __init__(self, container_obj: Union[DockerContainer, PodmanContainer], model: Optional[ExegolContainerTemplate] = None): + logger.debug(f"Loading container: {container_obj.name}") + self.__container: Container = container_obj + self.__id: str = container_obj.id self.__xhost_applied = False if model is None: image_name = "" try: # Try to find the attached docker image - docker_image = docker_container.image - except ImageNotFound: + docker_image = container_obj.image + except (ImageNotFound, PodmanImageNotFound): # If it is not found, the user has probably forcibly deleted it manually logger.warning(f"Some images were forcibly removed by docker when they were used by existing containers!") - logger.error(f"The '{docker_container.name}' containers might not work properly anymore and should also be deleted and recreated with a new image.") + logger.error(f"The '{container_obj.name}' containers might not work properly anymore and should also be deleted and recreated with a new image.") docker_image = None image_name = "[red bold]BROKEN[/red bold]" # Create Exegol container from an existing docker container - super().__init__(docker_container.name, - config=ContainerConfig(docker_container), + super().__init__(container_obj.name, + config=ContainerConfig(container_obj), image=ExegolImage(name=image_name, docker_image=docker_image), - hostname=docker_container.attrs.get('Config', {}).get('Hostname'), + hostname=container_obj.attrs.get('Config', {}).get('Hostname'), new_container=False) - self.image.syncContainerData(docker_container) + self.image.syncContainerData(container_obj) # 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. self.__new_container = False else: # Create Exegol container from a newly created docker container with its object template. - super().__init__(docker_container.name, - config=ContainerConfig(docker_container), + super().__init__(container_obj.name, + config=ContainerConfig(container_obj), # Rebuild config from docker object to update workspace path image=model.image, hostname=model.config.hostname, @@ -121,7 +124,7 @@ def __start_container(self): start_date = datetime.now() try: self.__container.start() - except APIError as e: + except (APIError, PodmanAPIError) as e: logger.debug(e) logger.critical(f"Docker raise a critical error when starting the container [green]{self.name}[/green], error message is: {e.explanation}") if not self.config.legacy_entrypoint: # TODO improve startup compatibility check @@ -151,7 +154,7 @@ def stop(self, timeout: int = 10): with console.status(f"Waiting to stop ({timeout}s timeout)", spinner_style="blue"): self.__container.stop(timeout=timeout) - def spawnShell(self): + def spawnShell(self, container_runtime: str = None): """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()}") @@ -165,7 +168,7 @@ def spawnShell(self): options = "" if len(envs) > 0: options += f" -e {' -e '.join(envs)}" - cmd = f"docker exec{options} -ti {self.getFullId()} {self.config.getShellCommand()}" + cmd = f"{container_runtime} exec{options} -ti {self.getFullId()} {self.config.getShellCommand()}" logger.debug(f"Opening shell with: {cmd}") os.system(cmd) # Docker SDK doesn't support (yet) stdin properly @@ -230,7 +233,7 @@ def remove(self): try: self.__container.remove() logger.success(f"Container {self.name} successfully removed.") - except NotFound: + except (NotFound, PodmanNotFound): logger.error( f"The container {self.name} has already been removed (probably created as a temporary container).") @@ -319,7 +322,7 @@ def postCreateSetup(self, is_temporary: bool = False): self.__start_container() try: self.__updatePasswd() - except APIError as e: + except (APIError, PodmanAPIError) as e: if "is not running" in e.explanation: logger.critical("An unexpected error occurred. Exegol cannot start the container after its creation...") diff --git a/exegol/model/ExegolImage.py b/exegol/model/ExegolImage.py index 596b88d3..74008dfc 100644 --- a/exegol/model/ExegolImage.py +++ b/exegol/model/ExegolImage.py @@ -1,8 +1,10 @@ from datetime import datetime from typing import Optional, List, Dict, Any, Union -from docker.models.containers import Container -from docker.models.images import Image +from docker.models.containers import Container as DockerContainer +from docker.models.images import Image as DockerImage +from podman.domain.containers import Container as PodmanContainer +from podman.domain.images import Image as PodmanImage from rich.status import Status from exegol.config.DataCache import DataCache @@ -24,7 +26,7 @@ def __init__(self, dockerhub_data: Optional[Dict[str, Any]] = None, meta_img: Optional[MetaImages] = None, image_id: Optional[str] = None, - docker_image: Optional[Image] = None, + docker_image: Optional[Union[DockerImage, PodmanImage]] = None, isUpToDate: bool = False): """Docker image default value""" # Prepare parameters @@ -36,7 +38,7 @@ def __init__(self, version_parsed = MetaImages.tagNameParsing(name) self.__version_specific = bool(version_parsed) # Init attributes - self.__image: Optional[Image] = docker_image + self.__image: Optional[Union[DockerImage, PodmanImage]] = docker_image self.__name: str = name self.__alt_name: str = '' self.__arch = "" @@ -150,7 +152,7 @@ def resetDockerImage(self): self.__build_date = "[bright_black]N/A[/bright_black]" self.__disk_size = "[bright_black]N/A[/bright_black]" - def setDockerObject(self, docker_image: Image): + def setDockerObject(self, docker_image: Union[DockerImage, PodmanImage]): """Docker object setter. Parse object to set up self configuration.""" self.__image = docker_image # When a docker image exist, image is locally installed @@ -164,9 +166,9 @@ def setDockerObject(self, docker_image: Image): self.__build_date = self.__image.labels.get('org.exegol.build_date', '[bright_black]N/A[/bright_black]') # Check if local image is sync with remote digest id (check up-to-date status) if self.__profile_digest: - self.__is_update = self.__profile_digest == self.__parseDigest(docker_image) + self.__is_update = self.__profile_digest in self.__parseDigest(docker_image) # unlike docker, podman associates multiple digests to an image else: - self.__is_update = self.__digest == self.__parseDigest(docker_image) + self.__is_update = self.__digest in self.__parseDigest(docker_image) # unlike docker, podman associates multiple digests to an image # If this image is remote, set digest ID self.__is_remote = not (len(self.__image.attrs["RepoDigests"]) == 0 and self.__checkLocalLabel()) if self.__is_remote: @@ -202,7 +204,7 @@ def setMetaImage(self, meta: MetaImages, status: Status): if meta.meta_id: self.__setLatestRemoteId(meta.meta_id) # Check if local image is sync with remote digest id (check up-to-date status) - self.__is_update = self.__digest == self.__profile_digest + self.__is_update = self.__profile_digest in self.__digest # unlike docker, podman associates multiple digests to an image if not self.__digest and meta.is_latest and meta.meta_id: # If the digest is lost (multiple same image installed locally) fallback to meta id (only if latest) self.__setDigest(meta.meta_id) @@ -230,7 +232,7 @@ def __labelVersionParsing(self): self.__profile_version = self.__image_version @classmethod - def parseAliasTagName(cls, image: Image) -> str: + def parseAliasTagName(cls, image: Union[DockerImage, PodmanImage]) -> str: """Create a tag name alias from labels when image's tag is lost""" return image.labels.get("org.exegol.tag", "") + "-" + image.labels.get("org.exegol.version", "v?") @@ -247,7 +249,7 @@ def syncStatus(self): else: self.__custom_status = "" - def syncContainerData(self, container: Container): + def syncContainerData(self, container: Union[DockerImage, PodmanImage]): """Synchronization between the container and the image. If the image has been updated, the tag is lost, but it is saved in the properties of the container that still uses it.""" @@ -293,7 +295,7 @@ def autoLoad(self, from_cache: bool = True) -> 'ExegolImage': self.__setLatestRemoteId(remote_digest) if self.__digest: # Compare current and remote latest digest for up-to-date status - self.__is_update = self.__digest == self.__profile_digest + self.__is_update = self.__profile_digest in self.__digest # unlike docker, podman associates multiple digests to an image if version is not None: # Set latest remote version self.__setLatestVersion(version) @@ -352,7 +354,7 @@ def __mergeMetaImages(cls, images: List[MetaImages]): pass @classmethod - def mergeImages(cls, remote_images: List[MetaImages], local_images: List[Image], status: Status) -> List['ExegolImage']: + def mergeImages(cls, remote_images: List[MetaImages], local_images: List[Union[DockerImage, PodmanImage]], status: Status) -> List['ExegolImage']: """Compare and merge local images and remote images. Use case to process : - up-to-date : "Version specific" image can use exact digest_id matching. Latest image must match corresponding tag @@ -479,7 +481,7 @@ def __eq__(self, other): """Operation == overloading for ExegolImage object""" # How to compare two ExegolImage if type(other) is ExegolImage: - return self.__name == other.__name and self.__digest == other.__digest and self.__arch == other.__arch + return self.__name == other.__name and other.__digest in self.__digest and self.__arch == other.__arch # How to compare ExegolImage with str elif type(other) is str: return self.__name == other @@ -531,19 +533,26 @@ def getType(self) -> str: """Image type getter""" return "remote" if self.__is_remote else "local" - def __setDigest(self, digest: Optional[str]): + def __setDigest(self, digests: Optional[List[str]]): """Remote image digest setter""" - if digest is not None: - self.__digest = digest + if digests is not None and isinstance(digests, list): + self.__digest = digests # Store the entire list + elif isinstance(digests, str): # Handle backward compatibility + self.__digest = [digests] # Convert single digest to a list + else: + self.__digest = None # No digest @staticmethod - def __parseDigest(docker_image: Image) -> str: - """Parse the remote image digest ID. - Return digest id from the docker object.""" + def __parseDigest(docker_image: Union[DockerImage, PodmanImage]) -> List[str]: + """Parse the remote image digest IDs. + Return a list of digest IDs from the docker object. + Note that a list is returned because Podman allows + multiple digests to be associated with an image. """ + digests = [] for digest_id in docker_image.attrs["RepoDigests"]: - if digest_id.startswith(ConstantConfig.IMAGE_NAME): # Find digest id from the right repository - return digest_id.split('@')[1] - return "" + if ConstantConfig.IMAGE_NAME in digest_id: + digests.append(digest_id.split('@')[1]) + return digests def getRemoteId(self) -> str: """Remote digest getter""" @@ -558,9 +567,14 @@ def getLatestRemoteId(self) -> str: return self.__profile_digest def __setImageId(self, image_id: Optional[str]): - """Local image id setter""" + """Local image id setter for both Docker and Podman""" if image_id is not None: - self.__image_id = image_id.split(":")[1][:12] + # Check if the image_id contains a colon (as in Docker's format) + if ":" in image_id: + self.__image_id = image_id.split(":")[1][:12] + else: + # For Podman, where image_id does not contain the 'sha256:' prefix + self.__image_id = image_id[:12] def getLocalId(self) -> str: """Local id getter""" diff --git a/exegol/model/MetaImages.py b/exegol/model/MetaImages.py index 779fac5d..fc73ce74 100644 --- a/exegol/model/MetaImages.py +++ b/exegol/model/MetaImages.py @@ -1,6 +1,7 @@ from typing import Optional, Set, Union -from docker.models.images import Image +from docker.models.images import Image as DockerImage +from podman.domain.images import Image as PodmanImage from exegol.utils.ExeLog import logger from exegol.utils.WebUtils import WebUtils @@ -58,13 +59,13 @@ def tagNameParsing(tag_name: str) -> str: return version @staticmethod - def parseArch(docker_image: Union[dict, Image]) -> str: + def parseArch(docker_image: Union[dict, DockerImage, PodmanImage]) -> str: """Parse and format arch in dockerhub style from registry dict struct. Return arch in format 'arch/variant'.""" arch_key = "architecture" variant_key = "variant" - # Support Docker image struct with specific dict key - if type(docker_image) is Image: + # Support Docker and Podman image struct with specific dict key + if isinstance(docker_image, (DockerImage, PodmanImage)): docker_image = docker_image.attrs arch_key = "Architecture" variant_key = "Variant" diff --git a/exegol/utils/ContainerLogStream.py b/exegol/utils/ContainerLogStream.py index f5dc6b30..b3619a98 100644 --- a/exegol/utils/ContainerLogStream.py +++ b/exegol/utils/ContainerLogStream.py @@ -1,15 +1,16 @@ import time from datetime import datetime, timedelta -from typing import Optional +from typing import Optional, Union -from docker.models.containers import Container +from docker.models.containers import Container as DockerContainer +from podman.domain.containers import Container as PodmanContainer from exegol.utils.ExeLog import logger class ContainerLogStream: - def __init__(self, container: Container, start_date: Optional[datetime] = None, timeout: int = 5): + def __init__(self, container: Union[DockerContainer, PodmanContainer], start_date: Optional[datetime] = None, timeout: int = 5): # Container to extract logs from self.__container = container # Fetch more logs from this datetime diff --git a/exegol/utils/DockerUtils.py b/exegol/utils/DockerUtils.py index 11000c56..0119cb68 100644 --- a/exegol/utils/DockerUtils.py +++ b/exegol/utils/DockerUtils.py @@ -1,4 +1,6 @@ +import logging import os +import subprocess from datetime import datetime from time import sleep from typing import List, Optional, Union, cast @@ -7,8 +9,15 @@ import requests.exceptions from docker import DockerClient from docker.errors import APIError, DockerException, NotFound, ImageNotFound -from docker.models.images import Image -from docker.models.volumes import Volume +from docker.models.images import Image as DockerImage +from docker.models.volumes import Volume as DockerVolume + +import podman +from podman import PodmanClient +from podman.errors import APIError as PodmanAPIError, DockerException as PodmanException, NotFound as PodmanNotFound, ImageNotFound as PodmanImageNotFound +from podman.domain.images import Image as PodmanImage +from podman.domain.volumes import Volume as PodmanVolume + from requests import ReadTimeout from rich.status import Status @@ -34,37 +43,71 @@ class DockerUtils(metaclass=MetaSingleton): def __init__(self): - """Utility class between exegol and the Docker SDK""" + """Utility class to manage interactions between exegol and Docker or Podman.""" + self.__client = None + self.__daemon_info = None + self.container_runtime = None # Will be set to either 'docker' or 'podman' + + # List of exceptions that could be raised by both Docker and Podman + connection_exceptions = (DockerException, PodmanException, APIError, PodmanAPIError) try: - # Connect Docker SDK to the local docker instance. - # Docker connection setting is loaded from the user environment variables. - self.__client: DockerClient = docker.from_env() + try: + if (client := self.__connect_to_docker()): + self.__client = client + except DockerException: + logger.debug("Docker not available, trying to connect to Podman...") + + if (client := self.__connect_to_podman()): + self.__client = client + # Check if the docker daemon is serving linux container self.__daemon_info = self.__client.info() if self.__daemon_info.get("OSType", "linux").lower() != "linux": logger.critical( f"Docker daemon is not serving linux container ! Docker OS Type is: {self.__daemon_info.get('OSType', 'linux')}") EnvInfo.initData(self.__daemon_info) - except DockerException as err: - if 'ConnectionRefusedError' in str(err): - 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(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") - elif 'PermissionError' in str(err): - logger.critical(f"Docker is installed on your host but you don't have the permission to interact with it. Exiting.{os.linesep}" - f" Check documentation for help: https://exegol.readthedocs.io/en/latest/getting-started/install.html#optional-run-exegol-with-appropriate-privileges") - else: - logger.error(err) - logger.critical( - "Unable to connect to docker (from env config). Is docker operational and accessible? on your machine? " - "Exiting.") - except (ReadTimeout, requests.exceptions.ConnectionError): - logger.critical("Docker daemon seems busy, Exegol receives timeout response. Try again later.") + + except connection_exceptions as err: + self.__handle_connection_error(err) + except Exception as err: + logger.error(f"Unexpected error: {err}") + self.__images: Optional[List[ExegolImage]] = None self.__containers: Optional[List[ExegolContainer]] = None + def get_container_runtime(self): + """Returns the current container runtime.""" + return self.container_runtime + + def __connect_to_docker(self): + """Attempts to connect to Docker.""" + self.container_runtime = "docker" + client = docker.from_env() + logger.info("Connected to Docker.") + return client + + def __connect_to_podman(self): + """Attempts to connect to Podman.""" + self.container_runtime = "podman" + client = podman.from_env() + client.ping() # Check if the Podman service is reachable + logger.info("Connected to Podman.") + return client + + def __handle_connection_error(self, err): + """Handles connection errors for both Docker and Podman.""" + if 'ConnectionRefusedError' in str(err) or 'HEAD operation failed' in str(err) or 'APIError' in str(err): + logger.critical(f"Unable to connect to docker or podman. Is one of them 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(f"Unable to connect to {self.get_container_runtime()}. Is it 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") + elif 'PermissionError' in str(err): + logger.critical(f"{self.get_container_runtime().capitalize()} is installed on your host but you don't have permission to interact with it. Exiting.{os.linesep}" + f" Check documentation for help: https://exegol.readthedocs.io/en/latest/getting-started/install.html#optional-run-exegol-with-appropriate-privileges") + else: + logger.critical(f"Unable to connect to docker or podman. Is one of them operational and accessible? Exiting.") + def clearCache(self): """Remove class's images and containers data cache Only needed if the list has to be updated in the same runtime at a later moment""" @@ -84,7 +127,7 @@ def listContainers(self) -> List[ExegolContainer]: self.__containers = [] try: docker_containers = self.__client.containers.list(all=True, filters={"name": "exegol-"}) - except APIError as err: + except (APIError, PodmanAPIError) as err: logger.debug(err) logger.critical(err.explanation) # Not reachable, critical logging will exit @@ -141,7 +184,7 @@ def createContainer(self, model: ExegolContainerTemplate, temporary: bool = Fals docker_args["auto_remove"] = temporary try: container = docker_create_function(**docker_args) - except APIError as err: + except (APIError, PodmanAPIError) as err: message = err.explanation.decode('utf-8').replace('[', '\\[') if type(err.explanation) is bytes else err.explanation if message is not None: message = message.replace('[', '\\[') @@ -173,7 +216,7 @@ def getContainer(self, tag: str) -> ExegolContainer: try: # Fetch potential container match from DockerSDK container = self.__client.containers.list(all=True, filters={"name": f"exegol-{tag}"}) - except APIError as err: + except (APIError, PodmanAPIError) as err: logger.debug(err) logger.critical(err.explanation) # Not reachable, critical logging will exit @@ -200,7 +243,7 @@ def getContainer(self, tag: str) -> ExegolContainer: # # # Volumes Section # # # - def __loadDockerVolume(self, volume_path: str, volume_name: str) -> Volume: + def __loadDockerVolume(self, volume_path: str, volume_name: str) -> Union[DockerVolume, PodmanVolume]: """Load or create a docker volume for exegol containers (must be created before the container, SDK limitation) Return the docker volume object""" @@ -217,7 +260,7 @@ def __loadDockerVolume(self, volume_path: str, volume_name: str) -> Volume: try: self.__client.api.remove_volume(name=volume_name) raise NotFound('Volume must be reloaded') - except APIError as e: + except (APIError, PodmanAPIError) as e: if e.status_code == 409: logger.warning("The path of the volume specified by the user is not the same as in the existing docker volume. " "The user path will be [red]ignored[/red] as long as the docker volume already exists.") @@ -228,7 +271,7 @@ def __loadDockerVolume(self, volume_path: str, volume_name: str) -> Volume: 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: + except (NotFound, PodmanNotFound): try: # Creating a docker volume bind to a host path # Docker volume are more easily shared by container @@ -237,7 +280,7 @@ def __loadDockerVolume(self, volume_path: str, volume_name: str) -> Volume: driver_opts={'o': 'bind', 'device': volume_path, 'type': 'none'}) - except APIError as err: + except (APIError, PodmanAPIError) as err: logger.error(f"Error while creating docker volume '{volume_name}'.") logger.debug(err) logger.critical(err.explanation) @@ -245,7 +288,7 @@ def __loadDockerVolume(self, volume_path: str, volume_name: str) -> Volume: 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: + except (APIError, PodmanAPIError) as err: logger.critical(f"Unexpected error by Docker SDK : {err}") return None # type: ignore except ReadTimeout: @@ -313,7 +356,7 @@ def getInstalledImage(self, tag: str) -> ExegolImage: try: docker_local_image = self.__client.images.get(f"{ConstantConfig.IMAGE_NAME}:{tag}") # DockerSDK image get is an exact matching, no need to add more check - except APIError as err: + except (APIError, PodmanAPIError) as err: if err.status_code == 404: # try to find it in recovery mode logger.verbose("Unable to find your image. Trying to find in recovery mode.") @@ -345,14 +388,14 @@ def getInstalledImage(self, tag: str) -> ExegolImage: logger.critical(f"The desired image is not installed or do not exist ({ConstantConfig.IMAGE_NAME}:{tag}). Exiting.") return # type: ignore - def __listLocalImages(self, tag: Optional[str] = None) -> List[Image]: + def __listLocalImages(self, tag: Optional[str] = None) -> List[Union[DockerImage, PodmanImage]]: """List local docker images already installed. Return a list of docker images objects""" logger.debug("Fetching local image tags, digests (and other attributes)") try: image_name = ConstantConfig.IMAGE_NAME + ("" if tag is None else f":{tag}") - images = self.__client.images.list(image_name, filters={"dangling": False}) - except APIError as err: + images = self.__client.images.list(name=image_name, filters={"dangling": False}) + except (APIError, PodmanAPIError) as err: logger.debug(err) logger.critical(err.explanation) # Not reachable, critical logging will exit @@ -366,7 +409,7 @@ def __listLocalImages(self, tag: Optional[str] = None) -> List[Image]: for img in images: # len tags = 0 handle exegol images (nightly image lost their tag after update) if len(img.attrs.get('RepoTags', [])) == 0 or \ - ConstantConfig.IMAGE_NAME in [repo_tag.split(':')[0] for repo_tag in img.attrs.get("RepoTags", [])]: + any(ConstantConfig.IMAGE_NAME in repo_tag.split(':')[0] for repo_tag in img.attrs.get("RepoTags", [])): result.append(img) ids.add(img.id) @@ -383,7 +426,7 @@ def __listLocalImages(self, tag: Optional[str] = None) -> List[Image]: ids.add(img.id) return result - def __findLocalRecoveryImages(self, include_untag: bool = False) -> List[Image]: + def __findLocalRecoveryImages(self, include_untag: bool = False) -> List[Union[DockerImage, PodmanImage]]: """This method try to recovery untagged docker images. Set include_untag option to recover images with a valid RepoDigest (no not dangling) but without tag.""" try: @@ -391,7 +434,7 @@ def __findLocalRecoveryImages(self, include_untag: bool = False) -> List[Image]: recovery_images = self.__client.images.list(filters={"dangling": True}) if include_untag: recovery_images += self.__client.images.list(ConstantConfig.IMAGE_NAME, filters={"dangling": False}) - except APIError as err: + except (APIError, PodmanAPIError) as err: logger.debug(f"Error occurred in recovery mode: {err}") return [] except ReadTimeout: @@ -454,7 +497,7 @@ def __findImageMatch(self, remote_image: ExegolImage): remote_id = remote_image.getRemoteId() try: docker_image = self.__client.images.get(f"{ConstantConfig.IMAGE_NAME}@{remote_id}") - except ImageNotFound: + except (ImageNotFound, PodmanImageNotFound): raise ObjectNotFound except ReadTimeout: logger.critical("Received a timeout error, Docker is busy... Unable to find a specific image, retry later.") @@ -476,18 +519,38 @@ def downloadImage(self, image: ExegolImage, install_mode: bool = False) -> bool: 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( - self.__client.api.pull(repository=ConstantConfig.IMAGE_NAME, - tag=name, - stream=True, - decode=True, - platform="linux/" + image.getArch())) + if self.get_container_runtime() == "docker": + pull_method = self.__client.api.pull + repository = ConstantConfig.IMAGE_NAME + download_kwargs = { + "repository": repository, + "tag": name, + "stream": True, + "decode": True, + "platform": "linux/" + image.getArch() + } + elif self.get_container_runtime() == "podman": + pull_method = self.__client.images.pull + repository = "docker.io/" + ConstantConfig.IMAGE_NAME + download_kwargs = { + "repository": repository, + "tag": name, + "stream": True, + "decode": True, + "platform": "linux/" + image.getArch(), + "progress_bar": False # Include progress_bar only for Podman + } + else: + raise RuntimeError("Unsupported daemon: " + self.get_container_runtime()) + + ExegolTUI.downloadDockerLayer(pull_method(**download_kwargs)) + 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: self.removeImage(image, upgrade_mode=not install_mode) return True - except APIError as err: + except (APIError, PodmanAPIError) as err: if err.status_code == 500: logger.error(f"Error: {err.explanation}") logger.error(f"Error while contacting docker registry. Aborting.") @@ -506,11 +569,15 @@ def downloadVersionTag(self, image: ExegolImage) -> Union[ExegolImage, str]: logger.critical("It's not possible to download a docker image in offline mode ...") return "" try: - image = self.__client.images.pull(repository=ConstantConfig.IMAGE_NAME, + if self.get_container_runtime() == "docker": + repository = ConstantConfig.IMAGE_NAME + elif self.get_container_runtime() == "podman": + repository = "docker.io/" + ConstantConfig.IMAGE_NAME + image = self.__client.images.pull(repository=repository, tag=image.getLatestVersionName(), platform="linux/" + image.getArch()) return ExegolImage(docker_image=image, isUpToDate=True) - except APIError as err: + except (APIError, PodmanAPIError) as err: if err.status_code == 500: return f"error while contacting docker registry: {err.explanation}" elif err.status_code == 404: @@ -540,7 +607,7 @@ def removeImage(self, image: ExegolImage, upgrade_mode: bool = False) -> bool: logger.verbose(f"Removing {'previous ' if upgrade_mode else ''}image [green]{image.getName()}[/green]...") logger.success(f"{'Previous d' if upgrade_mode else 'D'}ocker image successfully removed.") return True - except APIError as err: + except (APIError, PodmanAPIError) as err: # Handle docker API error code logger.verbose(err.explanation) if err.status_code == 409: @@ -573,7 +640,7 @@ def __remove_image(self, image_name: str) -> bool: try: _ = self.__client.images.get(image_name) # DockerSDK image getter is an exact matching, no need to add more check - except APIError as err: + except (APIError, PodmanAPIError) as err: if err.status_code == 404: return True else: @@ -616,7 +683,7 @@ def buildImage(self, tag: str, build_profile: Optional[str] = None, build_docker pull=True, decode=True)) logger.success(f"Exegol image successfully built") - except APIError as err: + except (APIError, PodmanAPIError) as err: logger.debug(f"Error: {err}") if err.status_code == 500: logger.error(f"Error: {err.explanation}") diff --git a/requirements.txt b/requirements.txt index cf72eb36..084cb7e4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ docker~=7.1.0 +podman~=5.2.0 requests~=2.32.3 rich~=13.7.1 GitPython~=3.1.43 diff --git a/setup.py b/setup.py index 317fcf33..7039c14e 100644 --- a/setup.py +++ b/setup.py @@ -56,6 +56,7 @@ ], install_requires=[ 'docker~=7.1.0', + 'podman~=5.2.0', 'requests~=2.32.3', 'rich~=13.7.1', 'GitPython~=3.1.43',