from typing import Optional, Any, Dict, List from phi.docker.api_client import DockerApiClient from phi.docker.resource.base import DockerResource from phi.cli.console import print_info, console from phi.utils.log import logger class DockerImage(DockerResource): resource_type: str = "Image" # Docker image name, usually as repo/image name: str # Docker image tag tag: Optional[str] = None # Path to the directory containing the Dockerfile path: Optional[str] = None # Path to the Dockerfile within the build context dockerfile: Optional[str] = None # Print the build log print_build_log: bool = True # Push the image to the registry. Similar to the docker push command. push_image: bool = False print_push_output: bool = False # Remove intermediate containers. # The docker build command defaults to --rm=true, # The docker api kept the old default of False to preserve backward compatibility rm: Optional[bool] = True # Always remove intermediate containers, even after unsuccessful builds forcerm: Optional[bool] = None # HTTP timeout timeout: Optional[int] = None # Downloads any updates to the FROM image in Dockerfiles pull: Optional[bool] = None # Skips docker cache when set to True # i.e. rebuilds all layes of the image skip_docker_cache: Optional[bool] = None # A dictionary of build arguments buildargs: Optional[Dict[str, Any]] = None # A dictionary of limits applied to each container created by the build process. Valid keys: # memory (int): set memory limit for build # memswap (int): Total memory (memory + swap), -1 to disable swap # cpushares (int): CPU shares (relative weight) # cpusetcpus (str): CPUs in which to allow execution, e.g. "0-3", "0,1" container_limits: Optional[Dict[str, Any]] = None # Size of /dev/shm in bytes. The size must be greater than 0. If omitted the system uses 64MB shmsize: Optional[int] = None # A dictionary of labels to set on the image labels: Optional[Dict[str, Any]] = None # A list of images used for build cache resolution cache_from: Optional[List[Any]] = None # Name of the build-stage to build in a multi-stage Dockerfile target: Optional[str] = None # networking mode for the run commands during build network_mode: Optional[str] = None # Squash the resulting images layers into a single layer. squash: Optional[bool] = None # Extra hosts to add to /etc/hosts in building containers, as a mapping of hostname to IP address. extra_hosts: Optional[Dict[str, Any]] = None # Platform in the format os[/arch[/variant]]. platform: Optional[str] = None # List of platforms to use for build, uses buildx_image if multi-platform build is enabled. platforms: Optional[List[str]] = None # Isolation technology used during build. Default: None. isolation: Optional[str] = None # If True, and if the docker client configuration file (~/.docker/config.json by default) # contains a proxy configuration, the corresponding environment variables # will be set in the container being built. use_config_proxy: Optional[bool] = None # Set skip_delete=True so that the image is not deleted when the `phi ws down` command is run skip_delete: bool = True image_build_id: Optional[str] = None # Set use_cache to False so image is always built use_cache: bool = False def get_image_str(self) -> str: if self.tag: return f"{self.name}:{self.tag}" return f"{self.name}:latest" def get_resource_name(self) -> str: return self.get_image_str() def buildx(self, docker_client: Optional[DockerApiClient] = None) -> Optional[Any]: """Builds the image using buildx Args: docker_client: The DockerApiClient for the current cluster Options: https://docs.docker.com/engine/reference/commandline/buildx_build/#options """ try: import subprocess tag = self.get_image_str() nocache = self.skip_docker_cache or self.force pull = self.pull or self.force print_info(f"Building image: {tag}") if self.path is not None: print_info(f"\t path: {self.path}") if self.dockerfile is not None: print_info(f" dockerfile: {self.dockerfile}") print_info(f" platforms: {self.platforms}") logger.debug(f"nocache: {nocache}") logger.debug(f"pull: {pull}") command = ["docker", "buildx", "build"] # Add tag command.extend(["--tag", tag]) # Add dockerfile option, if set if self.dockerfile is not None: command.extend(["--file", self.dockerfile]) # Add build arguments if self.buildargs: for key, value in self.buildargs.items(): command.extend(["--build-arg", f"{key}={value}"]) # Add no-cache option, if set if nocache: command.append("--no-cache") if not self.rm: command.append("--rm=false") if self.platforms: command.append("--platform={}".format(",".join(self.platforms))) if self.pull: command.append("--pull") if self.push_image: command.append("--push") else: command.append("--load") # Add path if self.path is not None: command.append(self.path) # Run the command logger.debug("Running command: {}".format(" ".join(command))) result = subprocess.run(command) # Handling output and errors if result.returncode == 0: print_info("Docker image built successfully.") return True # _docker_client = docker_client or self.get_docker_client() # return self._read(docker_client=_docker_client) else: logger.error("Error in building Docker image:") return False except Exception as e: logger.error(e) return None def build_image(self, docker_client: DockerApiClient) -> Optional[Any]: if self.platforms is not None: logger.debug("Using buildx for multi-platform build") return self.buildx(docker_client=docker_client) from docker import DockerClient from docker.errors import BuildError, APIError from rich import box from rich.live import Live from rich.table import Table print_info(f"Building image: {self.get_image_str()}") nocache = self.skip_docker_cache or self.force pull = self.pull or self.force if self.path is not None: print_info(f"\t path: {self.path}") if self.dockerfile is not None: print_info(f" dockerfile: {self.dockerfile}") logger.debug(f"platform: {self.platform}") logger.debug(f"nocache: {nocache}") logger.debug(f"pull: {pull}") last_status = None last_build_log = None build_log_output: List[Any] = [] build_step_progress: List[str] = [] build_log_to_show_on_error: List[str] = [] try: _api_client: DockerClient = docker_client.api_client build_stream = _api_client.api.build( tag=self.get_image_str(), path=self.path, dockerfile=self.dockerfile, nocache=nocache, rm=self.rm, forcerm=self.forcerm, timeout=self.timeout, pull=pull, buildargs=self.buildargs, container_limits=self.container_limits, shmsize=self.shmsize, labels=self.labels, cache_from=self.cache_from, target=self.target, network_mode=self.network_mode, squash=self.squash, extra_hosts=self.extra_hosts, platform=self.platform, isolation=self.isolation, use_config_proxy=self.use_config_proxy, decode=True, ) with Live(transient=True, console=console) as live_log: for build_log in build_stream: if build_log != last_build_log: last_build_log = build_log build_log_output.append(build_log) build_status: str = build_log.get("status") if build_status is not None: _status = build_status.lower() if _status in ( "waiting", "downloading", "extracting", "verifying checksum", "pulling fs layer", ): continue if build_status != last_status: logger.debug(build_status) last_status = build_status if build_log.get("error", None) is not None: live_log.stop() logger.error(build_log_output[-50:]) logger.error(build_log["error"]) logger.error(f"Image build failed: {self.get_image_str()}") return None stream = build_log.get("stream", None) if stream is None or stream == "\n": continue stream = stream.strip() if "Step" in stream and self.print_build_log: build_step_progress = [] print_info(stream) else: build_step_progress.append(stream) if len(build_step_progress) > 10: build_step_progress.pop(0) build_log_to_show_on_error.append(stream) if len(build_log_to_show_on_error) > 50: build_log_to_show_on_error.pop(0) if "error" in stream.lower(): print(stream) live_log.stop() # Render error table error_table = Table(show_edge=False, show_header=False, show_lines=False) for line in build_log_to_show_on_error: error_table.add_row(line, style="dim") error_table.add_row(stream, style="bold red") console.print(error_table) return None if build_log.get("aux", None) is not None: logger.debug("build_log['aux'] :{}".format(build_log["aux"])) self.image_build_id = build_log.get("aux", {}).get("ID") # Render table table = Table(show_edge=False, show_header=False, show_lines=False) for line in build_step_progress: table.add_row(line, style="dim") live_log.update(table) if self.push_image: print_info(f"Pushing {self.get_image_str()}") with Live(transient=True, console=console) as live_log: push_status = {} last_push_progress = None for push_output in _api_client.images.push( repository=self.name, tag=self.tag, stream=True, decode=True, ): _id = push_output.get("id", None) _status = push_output.get("status", None) _progress = push_output.get("progress", None) if _id is not None and _status is not None: push_status[_id] = { "status": _status, "progress": _progress, } if push_output.get("error", None) is not None: logger.error(push_output["error"]) logger.error(f"Push failed for {self.get_image_str()}") logger.error("If you are using a private registry, make sure you are logged in") return None if self.print_push_output and push_output.get("status", None) in ( "Pushing", "Pushed", ): current_progress = push_output.get("progress", None) if current_progress != last_push_progress: print_info(current_progress) last_push_progress = current_progress if push_output.get("aux", {}).get("Size", 0) > 0: print_info(f"Push complete: {push_output.get('aux', {})}") # Render table table = Table(box=box.ASCII2) table.add_column("Layer", justify="center") table.add_column("Status", justify="center") table.add_column("Progress", justify="center") for layer, layer_status in push_status.items(): table.add_row( layer, layer_status["status"], layer_status["progress"], style="dim", ) live_log.update(table) return self._read(docker_client) except TypeError as type_error: logger.error(type_error) except BuildError as build_error: logger.error(build_error) except APIError as api_err: logger.error(api_err) except Exception as e: logger.error(e) return None def _create(self, docker_client: DockerApiClient) -> bool: """Creates the image Args: docker_client: The DockerApiClient for the current cluster """ logger.debug("Creating: {}".format(self.get_resource_name())) try: image_object = self.build_image(docker_client) if image_object is not None: return True return False # if image_object is not None and isinstance(image_object, Image): # logger.debug("Image built: {}".format(image_object)) # self.active_resource = image_object # return True except Exception as e: logger.exception(e) logger.error("Error while creating image: {}".format(e)) raise def _read(self, docker_client: DockerApiClient) -> Any: """Returns an Image object if available Args: docker_client: The DockerApiClient for the current cluster """ from docker import DockerClient from docker.models.images import Image from docker.errors import ImageNotFound, NotFound logger.debug("Reading: {}".format(self.get_image_str())) try: _api_client: DockerClient = docker_client.api_client image_object: Optional[List[Image]] = _api_client.images.get(name=self.get_image_str()) if image_object is not None and isinstance(image_object, Image): logger.debug("Image found: {}".format(image_object)) self.active_resource = image_object return image_object except (NotFound, ImageNotFound): logger.debug(f"Image {self.tag} not found") return None def _update(self, docker_client: DockerApiClient) -> bool: """Updates the Image Args: docker_client: The DockerApiClient for the current cluster """ logger.debug("Updating: {}".format(self.get_resource_name())) return self._create(docker_client=docker_client) def _delete(self, docker_client: DockerApiClient) -> bool: """Deletes the Image Args: docker_client: The DockerApiClient for the current cluster """ from docker import DockerClient from docker.models.images import Image logger.debug("Deleting: {}".format(self.get_resource_name())) image_object: Optional[Image] = self._read(docker_client) # Return True if there is no image to delete if image_object is None: logger.debug("No image to delete") return True # Delete Image try: self.active_resource = None logger.debug("Deleting image: {}".format(self.tag)) _api_client: DockerClient = docker_client.api_client _api_client.images.remove(image=self.tag, force=True) return True except Exception as e: logger.exception("Error while deleting image: {}".format(e)) return False def create(self, docker_client: DockerApiClient) -> bool: # If self.force then always create container if not self.force: # If use_cache is True and image is active then return True if self.use_cache and self.is_active(docker_client=docker_client): print_info(f"{self.get_resource_type()}: {self.get_resource_name()} already exists") return True resource_created = self._create(docker_client=docker_client) if resource_created: print_info(f"{self.get_resource_type()}: {self.get_resource_name()} created") return True logger.error(f"Failed to create {self.get_resource_type()}: {self.get_resource_name()}") return False