"""Implementation of the docker builder.""" from __future__ import annotations import logging import os from typing import Any import wandb import wandb.docker as docker from wandb.sdk.launch.agent.job_status_tracker import JobAndRunStatusTracker from wandb.sdk.launch.builder.abstract import AbstractBuilder, registry_from_uri from wandb.sdk.launch.environment.abstract import AbstractEnvironment from wandb.sdk.launch.registry.abstract import AbstractRegistry from .._project_spec import EntryPoint, LaunchProject from ..errors import LaunchDockerError, LaunchError from ..registry.anon import AnonynmousRegistry from ..registry.local_registry import LocalRegistry from ..utils import ( LOG_PREFIX, event_loop_thread_exec, warn_failed_packages_from_build_logs, ) from .build import _WANDB_DOCKERFILE_NAME, validate_docker_installation from .context_manager import BuildContextManager _logger = logging.getLogger(__name__) class DockerBuilder(AbstractBuilder): """Builds a docker image for a project. Attributes: builder_config (Dict[str, Any]): The builder config. """ builder_type = "docker" target_platform = "linux/amd64" def __init__( self, environment: AbstractEnvironment, registry: AbstractRegistry, config: dict[str, Any], ): """Initialize a DockerBuilder. Arguments: environment (AbstractEnvironment): The environment to use. registry (AbstractRegistry): The registry to use. Raises: LaunchError: If docker is not installed """ self.environment = environment # Docker builder doesn't actually use this. self.registry = registry self.config = config @classmethod def from_config( cls, config: dict[str, Any], environment: AbstractEnvironment, registry: AbstractRegistry, ) -> DockerBuilder: """Create a DockerBuilder from a config. Arguments: config (Dict[str, Any]): The config. registry (AbstractRegistry): The registry to use. verify (bool, optional): Whether to verify the functionality of the builder. login (bool, optional): Whether to login to the registry. Returns: DockerBuilder: The DockerBuilder. """ # If the user provided a destination URI in the builder config # we use that as the registry. image_uri = config.get("destination") if image_uri: if registry is not None: wandb.termwarn( f"{LOG_PREFIX}Overriding registry from registry config" f" with {image_uri} from builder config." ) registry = registry_from_uri(image_uri) return cls(environment, registry, config) async def verify(self) -> None: """Verify the builder.""" await validate_docker_installation() async def login(self) -> None: """Login to the registry.""" if isinstance(self.registry, LocalRegistry): _logger.info(f"{LOG_PREFIX}No registry configured, skipping login.") elif isinstance(self.registry, AnonynmousRegistry): _logger.info(f"{LOG_PREFIX}Anonymous registry, skipping login.") else: username, password = await self.registry.get_username_password() login = event_loop_thread_exec(docker.login) await login(username, password, self.registry.uri) async def build_image( self, launch_project: LaunchProject, entrypoint: EntryPoint, job_tracker: JobAndRunStatusTracker | None = None, ) -> str: """Build the image for the given project. Arguments: launch_project (LaunchProject): The project to build. entrypoint (EntryPoint): The entrypoint to use. """ await self.verify() await self.login() build_context_manager = BuildContextManager(launch_project=launch_project) build_ctx_path, image_tag = build_context_manager.create_build_context("docker") dockerfile = os.path.join(build_ctx_path, _WANDB_DOCKERFILE_NAME) repository = None if not self.registry else await self.registry.get_repo_uri() # if repo is set, use the repo name as the image name if repository: image_uri = f"{repository}:{image_tag}" # otherwise, base the image name off of the source # which the launch_project checks in image_name else: image_uri = f"{launch_project.image_name}:{image_tag}" if ( not launch_project.build_required() and await self.registry.check_image_exists(image_uri) ): return image_uri _logger.info( f"image {image_uri} does not already exist in repository, building." ) try: output = await event_loop_thread_exec(docker.build)( tags=[image_uri], file=dockerfile, context_path=build_ctx_path, platform=self.config.get("platform"), ) warn_failed_packages_from_build_logs( output, image_uri, launch_project.api, job_tracker ) except docker.DockerError as e: if job_tracker: job_tracker.set_err_stage("build") raise LaunchDockerError(f"Error communicating with docker client: {e}") try: os.remove(build_ctx_path) except Exception: _msg = f"{LOG_PREFIX}Temporary docker context file {build_ctx_path} was not deleted." _logger.info(_msg) if repository: reg, tag = image_uri.split(":") wandb.termlog(f"{LOG_PREFIX}Pushing image {image_uri}") push_resp = await event_loop_thread_exec(docker.push)(reg, tag) if push_resp is None: raise LaunchError("Failed to push image to repository") elif ( launch_project.resource == "sagemaker" and f"The push refers to repository [{repository}]" not in push_resp ): raise LaunchError(f"Unable to push image to ECR, response: {push_resp}") return image_uri