""" Implementation of a base workflow """ import functools import logging import os from collections import namedtuple from enum import Enum from typing import Optional from aws_lambda_builders.actions import ActionFailedError from aws_lambda_builders.architecture import X86_64 from aws_lambda_builders.binary_path import BinaryPath from aws_lambda_builders.exceptions import ( MisMatchRuntimeError, RuntimeValidatorError, WorkflowFailedError, WorkflowUnknownError, ) from aws_lambda_builders.path_resolver import PathResolver from aws_lambda_builders.registry import DEFAULT_REGISTRY from aws_lambda_builders.validator import RuntimeValidator LOG = logging.getLogger(__name__) # Named tuple to express the capabilities supported by the builder. # ``Language`` is the programming language. Ex: Python # ``LangageFramework`` is the framework of particular language. Ex: PIP # ``ApplicationFramework`` is the specific application framework used to write the code. Ex: Chalice Capability = namedtuple("Capability", ["language", "dependency_manager", "application_framework"]) class BuildMode(object): DEBUG = "debug" RELEASE = "release" class BuildDirectory(Enum): SCRATCH = "scratch" ARTIFACTS = "artifacts" SOURCE = "source" class BuildInSourceSupport(Enum): """ Enum to define a workflow's support for building in source. """ # can't build in source directory (e.g. only able to build in temporary or artifacts directories) NOT_SUPPORTED = [False] # can build in source directory but not required to OPTIONALLY_SUPPORTED = [False, True] # only able to build in source directory and not somewhere else EXCLUSIVELY_SUPPORTED = [True] # TODO: Move sanitize out to its own class. def sanitize(func): # pylint: disable=too-many-statements """ sanitize the executable path of the runtime specified by validating it. :param func: Workflow's run method is sanitized """ @functools.wraps(func) def wrapper(self, *args, **kwargs): # pylint: disable=too-many-statements valid_paths = {} invalid_paths = {} validation_errors = [] # NOTE: we need to access binaries to get paths and resolvers, before validating. for binary, binary_checker in self.binaries.items(): invalid_paths[binary] = [] try: exec_paths = ( binary_checker.resolver.exec_paths if not binary_checker.path_provided else binary_checker.binary_path ) except ValueError as ex: raise WorkflowFailedError(workflow_name=self.NAME, action_name="Resolver", reason=str(ex)) for executable_path in exec_paths: try: valid_path = binary_checker.validator.validate(executable_path) if valid_path: valid_paths[binary] = valid_path except MisMatchRuntimeError as ex: LOG.debug("Invalid executable for %s at %s", binary, executable_path, exc_info=str(ex)) invalid_paths[binary].append(executable_path) except RuntimeValidatorError as ex: LOG.debug("Runtime validation error for %s", binary, exc_info=str(ex)) if str(ex) not in validation_errors: validation_errors.append(str(ex)) if valid_paths.get(binary, None): binary_checker.binary_path = valid_paths[binary] break if validation_errors: raise WorkflowFailedError( workflow_name=self.NAME, action_name="Validation", reason="\n".join(validation_errors) ) if len(self.binaries) != len(valid_paths): validation_failed_binaries = set(self.binaries.keys()).difference(valid_paths.keys()) for validation_failed_binary in validation_failed_binaries: message = "Binary validation failed for {0}, searched for {0} in following locations : {1} which did not satisfy constraints for runtime: {2}. Do you have {0} for runtime: {2} on your PATH?".format( validation_failed_binary, invalid_paths[validation_failed_binary], self.runtime ) validation_errors.append(message) raise WorkflowFailedError( workflow_name=self.NAME, action_name="Validation", reason="\n".join(validation_errors) ) func(self, *args, **kwargs) return wrapper class _WorkflowMetaClass(type): """ A metaclass that maintains the registry of loaded builders """ def __new__(mcs, name, bases, class_dict): """ Add the builder to registry when loading the class """ cls = type.__new__(mcs, name, bases, class_dict) # We don't want to register the base classes, so we simply return here. # Also, skip further steps if the class is marked for testing if cls.__name__ == "BaseWorkflow" or cls.__TESTING__: return cls # Validate class variables # All classes must provide a name if not isinstance(cls.NAME, str): raise ValueError("Workflow must provide a valid name") # All workflows must express their capabilities if not isinstance(cls.CAPABILITY, Capability): raise ValueError("Workflow '{}' must register valid capabilities".format(cls.NAME)) # All workflows must define supported values for build in source if not isinstance(cls.BUILD_IN_SOURCE_SUPPORT, BuildInSourceSupport): raise ValueError("Workflow '{}' must define supported values for build in source".format(cls.NAME)) # All workflows must define default build directory if not isinstance(cls.DEFAULT_BUILD_DIR, BuildDirectory): raise ValueError("Workflow '{}' must define default build directory".format(cls.NAME)) LOG.debug("Registering workflow '%s' with capability '%s'", cls.NAME, cls.CAPABILITY) DEFAULT_REGISTRY[cls.CAPABILITY] = cls return cls class BaseWorkflow(object, metaclass=_WorkflowMetaClass): """ Default implementation of the builder workflow. It provides several useful capabilities out-of-box that help minimize the scope of build actions. """ # Set this property if you are in the process of testing a workflow class. This will prevent the class from # being added to registry. __TESTING__ = False NAME = None # Capabilities supported by this builder. Must be an instance of `Capability` named tuple CAPABILITY = None # Optional list of manifests file/folder names supported by this workflow. SUPPORTED_MANIFESTS = [] # Support for building in source, each workflow should define this. BUILD_IN_SOURCE_SUPPORT = None # The directory where the workflow builds/installs by default, each workflow should define this. DEFAULT_BUILD_DIR = None def __init__( self, source_dir, artifacts_dir, scratch_dir, manifest_path, runtime=None, executable_search_paths=None, optimizations=None, options=None, mode=BuildMode.RELEASE, download_dependencies=True, dependencies_dir=None, combine_dependencies=True, architecture=X86_64, is_building_layer=False, experimental_flags=None, build_in_source=None, ): # pylint: disable-msg=too-many-locals """ Initialize the builder with given arguments. These arguments together form the "public API" that each build action must support at the minimum. Parameters ---------- source_dir : str Path to a folder containing the source code artifacts_dir : str Path to a folder where the built artifacts should be placed scratch_dir : str Path to a directory that the workflow can use as scratch space. Workflows are expected to use this directory to write temporary files instead of ``/tmp`` or other OS-specific temp directories. manifest_path : str Path to the dependency manifest runtime : str, optional Optional, name of the AWS Lambda runtime that you are building for. This is sent to the builder for informational purposes, by default None executable_search_paths : list, optional Additional list of paths to search for executables required by the workflow, by default None optimizations : dict, optional dictionary of optimization flags to pass to the build action. **Not supported**, by default None options : dict, optional dictionary of options ot pass to build action. **Not supported**., by default None mode : str, optional Mode the build should produce, by default BuildMode.RELEASE download_dependencies: bool, optional Should download dependencies when building dependencies_dir : str, optional Path to folder the dependencies should be downloaded to combine_dependencies: bool, optional This flag will only be used if dependency_folder is specified. False will not copy dependencies from dependency_folder into build folder architecture : str, optional Architecture type either arm64 or x86_64 for which the build will be based on in AWS lambda, by default X86_64 is_building_layer: bool, optional Boolean flag which will be set True if current build operation is being executed for layers experimental_flags: list, optional List of strings, which will indicate enabled experimental flags for the current build session build_in_source: Optional[bool] Optional, will execute the build operation in the source directory if True. """ self.source_dir = source_dir self.artifacts_dir = artifacts_dir self.scratch_dir = scratch_dir self.manifest_path = manifest_path self.runtime = runtime self.optimizations = optimizations self.options = options self.executable_search_paths = executable_search_paths self.mode = mode self.download_dependencies = download_dependencies self.dependencies_dir = dependencies_dir self.combine_dependencies = combine_dependencies self.architecture = architecture self.is_building_layer = is_building_layer self.experimental_flags = experimental_flags if experimental_flags else [] # this represents where the build/install happens, not the final output directory (that's the artifacts_dir) self.build_dir = self._select_build_dir(build_in_source) # Actions are registered by the subclasses as they seem fit self.actions = [] self._binaries = {} def _select_build_dir(self, build_in_source: Optional[bool]) -> str: """ Returns the build directory for the workflow. """ should_build_in_source = build_in_source if build_in_source not in self.BUILD_IN_SOURCE_SUPPORT.value: # assign default value should_build_in_source = self.DEFAULT_BUILD_DIR == BuildDirectory.SOURCE # only show warning if an unsupported value was explicitly passed in if build_in_source is not None: LOG.warning( 'Workflow %s does not support value "%s" for building in source. Using default value "%s".', self.NAME, build_in_source, should_build_in_source, ) build_directory_mapping = { BuildDirectory.SCRATCH: self.scratch_dir, BuildDirectory.ARTIFACTS: self.artifacts_dir, BuildDirectory.SOURCE: self.source_dir, } return self.source_dir if should_build_in_source else build_directory_mapping.get(self.DEFAULT_BUILD_DIR) def is_supported(self): """ Is the given manifest supported? If the workflow exposes no manifests names, then we it is assumed that we don't have a restriction """ if self.SUPPORTED_MANIFESTS: return os.path.basename(self.manifest_path) in self.SUPPORTED_MANIFESTS return True def get_resolvers(self): """ Non specialized path resolver that just returns the list of executable for the runtime on the path. """ return [ PathResolver( runtime=self.runtime, binary=self.CAPABILITY.language, executable_search_paths=self.executable_search_paths, ) ] def get_validators(self): """ No-op validator that does not validate the runtime_path. """ return [RuntimeValidator(runtime=self.runtime, architecture=self.architecture)] @property def binaries(self): if not self._binaries: resolvers = self.get_resolvers() validators = self.get_validators() self._binaries = { resolver.binary: BinaryPath(resolver=resolver, validator=validator, binary=resolver.binary) for resolver, validator in zip(resolvers, validators) } return self._binaries @binaries.setter def binaries(self, binaries): self._binaries = binaries @sanitize def run(self): """ Actually perform the build by executing registered actions. :raises WorkflowFailedError: If the workflow does not contain any actions or if one of the actions ran into an error :raises WorkflowUnknownError: If one of the actions in the workflow raised an unhandled exception """ LOG.debug("Running workflow '%s'", self.NAME) if not self.actions: raise WorkflowFailedError( workflow_name=self.NAME, action_name=None, reason="Workflow does not have any actions registered" ) for action in self.actions: action_info = "{}:{}".format(self.NAME, action.NAME) LOG.info("Running %s", action_info) try: action.execute() LOG.debug("%s succeeded", action_info) except ActionFailedError as ex: LOG.debug("%s failed", action_info, exc_info=ex) raise WorkflowFailedError(workflow_name=self.NAME, action_name=action.NAME, reason=str(ex)) except Exception as ex: LOG.debug("%s raised unhandled exception", action_info, exc_info=ex) raise WorkflowUnknownError(workflow_name=self.NAME, action_name=action.NAME, reason=str(ex)) def __repr__(self): """ Pretty prints information about this workflow. Sample output: Workflow=MyWorkflow Actions= Name=Action1, Purpose=COPY_SOURCE, Description=Copies source code Name=Action2, Purpose=RESOLVE_DEPENDENCIES, Description=Resolves dependencies Name=Action3, Purpose=COMPILE_SOURCE, Description=Compiles code """ return "Workflow={}\nActions=\n\t{}".format(self.NAME, "\n\t".join(map(str, self.actions)))