Source code for runway.cfngin.hooks.awslambda.docker

"""Docker logic for the awslambda hook."""
from __future__ import annotations

import logging
import os
import platform
from typing import (
    TYPE_CHECKING,
    Any,
    ClassVar,
    Dict,
    Iterator,
    List,
    Optional,
    Type,
    TypeVar,
    Union,
    cast,
)

from docker import DockerClient
from docker.errors import DockerException, ImageNotFound
from docker.models.images import Image
from docker.types import Mount

from ...._logging import PrefixAdaptor
from ....compat import cached_property
from ....exceptions import DockerConnectionRefusedError, DockerExecFailedError
from .constants import AWS_SAM_BUILD_IMAGE_PREFIX, DEFAULT_IMAGE_NAME, DEFAULT_IMAGE_TAG

if TYPE_CHECKING:
    from pathlib import Path

    from ...._logging import RunwayLogger
    from ....context import CfnginContext, RunwayContext
    from .base_classes import Project
    from .models.args import AwsLambdaHookArgs, DockerOptions

LOGGER = cast("RunwayLogger", logging.getLogger(__name__))


_T = TypeVar("_T")


[docs]class DockerDependencyInstaller: """Docker dependency installer.""" CACHE_DIR: ClassVar[str] = "/var/task/cache_dir" """Mount path where dependency managers can cache data.""" DEPENDENCY_DIR: ClassVar[str] = "/var/task/lambda" """Mount path were dependencies will be installed to within the Docker container. Other files can be moved to this directory to be included in the deployment package. """ PROJECT_DIR: ClassVar[str] = "/var/task/project" """Mount path where the project directory is available within the Docker container.""" client: DockerClient """Docker client.""" ctx: Union[CfnginContext, RunwayContext] """Context object.""" options: DockerOptions """Hook arguments specific to Docker."""
[docs] def __init__( self, project: Project[AwsLambdaHookArgs], *, client: Optional[DockerClient] = None, context: Optional[Union[CfnginContext, RunwayContext]] = None, ) -> None: """Instantiate class. This is a low-level method that requires the user to implement error handling. It is recommended to use :meth:`~runway.cfngin.hooks.awslambda.docker.DockerDependencyInstaller.from_project` instead of instantiating this class directly. Args: project: awslambda project. client: Pre-configured :class:`docker.client.DockerClient`. context: CFNgin or Runway context object. """ context = context or project.ctx self._docker_logger = PrefixAdaptor("docker", LOGGER, "[{prefix}] {msg}") self.client = client or DockerClient.from_env(environment=context.env.vars) self.ctx = context self.options = project.args.docker self.project = project
@cached_property def bind_mounts(self) -> List[Mount]: """Bind mounts that will be used by the container.""" mounts = [ Mount( target=self.DEPENDENCY_DIR, source=str(self.project.dependency_directory), type="bind", ), Mount( target=self.PROJECT_DIR, source=str(self.project.project_root), type="bind", ), ] if self.project.cache_dir: mounts.append( Mount( target=self.CACHE_DIR, source=str(self.project.cache_dir), type="bind", ) ) return mounts @cached_property def environment_variables(self) -> Dict[str, str]: """Environment variables to pass to the Docker container. This is a subset of the environment variables stored in the context object as some will cause issues if they are passed. """ return {k: v for k, v in self.ctx.env.vars.items() if k.startswith("DOCKER")} @cached_property def image(self) -> Union[Image, str]: """Docker image that will be used. Raises: ValueError: Insufficient data to determine the desired Docker image. """ if self.options.file: return self.build_image(self.options.file, name=self.options.name) if self.options.image: return self.pull_image(self.options.image, force=self.options.pull) if self.project.args.runtime: return self.pull_image( f"{AWS_SAM_BUILD_IMAGE_PREFIX}{self.project.args.runtime}:latest", force=self.options.pull, ) raise ValueError("docker.file, docker.image, or runtime is required") @cached_property # pylint error is python3.7 only def install_commands(self) -> List[str]: """Commands to run to install dependencies.""" return [] @cached_property def post_install_commands(self) -> List[str]: """Commands to run after dependencies have been installed.""" cmds = [ *[ # wildcards need to exist outside of the quotes to work # needs to be wrapped in `sh -c` to resolve wildcard f"sh -c 'cp -v \"{extra_file.rstrip('*')}\"* \"{self.DEPENDENCY_DIR}\"'" if extra_file.endswith("*") else f'sh -c \'cp -v "{extra_file}" "{self.DEPENDENCY_DIR}"\'' for extra_file in self.options.extra_files ], ] if platform.system() != "Windows": # methods only exist on POSIX systems gid, uid = os.getgid(), os.getuid() # pylint: disable=no-member cmds.append( f"chown -R {uid}:{gid} {self.DEPENDENCY_DIR}", ) if self.project.cache_dir: cmds.append(f"chown -R {uid}:{gid} {self.CACHE_DIR}") return cmds @cached_property def pre_install_commands(self) -> List[str]: """Commands to run before dependencies have been installed.""" cmds = [ f"chown -R 0:0 {self.DEPENDENCY_DIR}", ] if self.project.cache_dir: cmds.append(f"chown -R 0:0 {self.CACHE_DIR}") return cmds @cached_property # pylint error is python3.7 only def runtime(self) -> Optional[str]: """AWS Lambda runtime determined from the Docker container.""" return None
[docs] def build_image( self, docker_file: Path, *, name: Optional[str] = None, tag: Optional[str] = None, ) -> Image: """Build Docker image from Dockerfile. This method is exposed as a low-level interface. :attr:`~runway.cfngin.hooks.awslambda.docker.DockerDependencyInstaller.image` should be used in place for this for most cases. Args: docker_file: Path to the Dockerfile to build. This path should be absolute, must exist, and must be a file. name: Name of the Docker image. The name should not contain a tag. If not provided, a default value is use. tag: Tag to apply to the image after it is built. If not provided, a default value of ``latest`` is used. Returns: Object representing the image that was built. """ image, log_stream = self.client.images.build( dockerfile=docker_file.name, forcerm=True, path=str(docker_file.parent), pull=self.options.pull, ) self.log_docker_msg_dict(log_stream) image.tag(name or DEFAULT_IMAGE_NAME, tag=tag or DEFAULT_IMAGE_TAG) image.reload() LOGGER.info("built docker image %s (%s)", ", ".join(image.tags), image.id) return image
[docs] def log_docker_msg_bytes( self, stream: Iterator[bytes], *, level: int = logging.INFO ) -> List[str]: """Log Docker output message from blocking generator that return bytes. Args: stream: Blocking generator that returns log messages as bytes. level: Log level to use when logging messages. Returns: List of log messages. """ result: List[str] = [] for raw_msg in stream: msg = raw_msg.decode().strip() result.append(msg) self._docker_logger.log(level, msg) return result
[docs] def log_docker_msg_dict( self, stream: Iterator[Dict[str, Any]], *, level: int = logging.INFO ) -> List[str]: """Log Docker output message from blocking generator that return dict. Args: stream: Blocking generator that returns log messages as a dict. level: Log level to use when logging messages. Returns: list of log messages. """ result: List[str] = [] for raw_msg in stream: for key in ["stream", "status"]: if key in raw_msg: msg = raw_msg[key].strip() result.append(msg) self._docker_logger.log(level, msg) break return result
[docs] def install(self) -> None: """Install dependencies using Docker. Commands are run as they are defined in the following cached properties: - :attr:`~runway.cfngin.hooks.awslambda.docker.DockerDependencyInstaller.pre_install_commands` - :attr:`~runway.cfngin.hooks.awslambda.docker.DockerDependencyInstaller.install_commands` - :attr:`~runway.cfngin.hooks.awslambda.docker.DockerDependencyInstaller.post_install_commands` """ # noqa for cmd in self.pre_install_commands: self.run_command(cmd) for cmd in self.install_commands: self.run_command(cmd) for cmd in self.post_install_commands: self.run_command(cmd)
[docs] def pull_image(self, name: str, *, force: bool = True) -> Image: """Pull a Docker image from a repository if it does not exist locally. This method is exposed as a low-level interface. :attr:`~runway.cfngin.hooks.awslambda.docker.DockerDependencyInstaller.image` should be used in place for this for most cases. Args: name: Name of the Docker image including tag. force: Always pull the image even if it exists locally. This will ensure that the latest version is always used. Returns: Object representing the image found locally or pulled from a repository. """ try: if not force: return self.client.images.get(name) LOGGER.info("pulling docker image %s...", name) except ImageNotFound: LOGGER.info("image not found; pulling docker image %s...", name) return self.client.images.pull(repository=name)
[docs] def run_command(self, command: str, *, level: int = logging.INFO) -> List[str]: """Execute equivalent of ``docker container run``. Args: command: Command to be run. level: Log level to use when logging messages. Raises: DockerExecFailedError: Docker container returned a non-zero exit code. Returns: List of log messages. """ LOGGER.verbose("running command with docker: %s", command) container = self.client.containers.create( command=command, detach=True, environment=self.environment_variables, image=self.image, mounts=self.bind_mounts, working_dir=self.PROJECT_DIR, ) try: container.start() return self.log_docker_msg_bytes( container.logs(stderr=True, stdout=True, stream=True), level=level ) finally: response = container.wait() container.remove(force=True) # always remove container if response.get("StatusCode", 0) != 0: raise DockerExecFailedError(response)
[docs] @classmethod def from_project( cls: Type[_T], project: Project[AwsLambdaHookArgs] ) -> Optional[_T]: """Instantiate class from a project. High-level method that wraps instantiation in error handling. Args: project: Project being processed. Returns: Object to handle dependency installation with Docker if Docker is available and not disabled. Raises: DockerConnectionRefused: Docker is not install or is unreachable. """ if project.args.docker.disabled: return None try: client = DockerClient.from_env(environment=project.ctx.env.vars) if client.ping(): return cls(project, client=client) except DockerException as exc: # This might be too broad but, it is the only repeated substring # between operating systems in similar states. if "Error while fetching server API version" in str(exc): raise DockerConnectionRefusedError from exc raise # ping failed but method did not return false for some reason raise DockerConnectionRefusedError