""" Terraform resource enrichment This module populates the values required for each of the Lambda resources """ import json import logging import os import re from json.decoder import JSONDecodeError from subprocess import CalledProcessError, run from typing import Dict, List, Tuple from samcli.hook_packages.terraform.hooks.prepare.constants import ( CFN_CODE_PROPERTIES, SAM_METADATA_RESOURCE_NAME_ATTRIBUTE, ) from samcli.hook_packages.terraform.hooks.prepare.exceptions import InvalidSamMetadataPropertiesException from samcli.hook_packages.terraform.hooks.prepare.makefile_generator import ( generate_makefile, generate_makefile_rule_for_lambda_resource, ) from samcli.hook_packages.terraform.hooks.prepare.resource_linking import _resolve_resource_attribute from samcli.hook_packages.terraform.hooks.prepare.types import SamMetadataResource from samcli.hook_packages.terraform.lib.utils import ( _calculate_configuration_attribute_value_hash, build_cfn_logical_id, get_sam_metadata_planned_resource_value_attribute, ) from samcli.lib.hook.exceptions import PrepareHookException from samcli.lib.utils.packagetype import IMAGE, ZIP from samcli.lib.utils.resources import AWS_LAMBDA_FUNCTION as CFN_AWS_LAMBDA_FUNCTION from samcli.lib.utils.resources import AWS_LAMBDA_LAYERVERSION as CFN_AWS_LAMBDA_LAYER_VERSION SAM_METADATA_DOCKER_TAG_ATTRIBUTE = "docker_tag" SAM_METADATA_DOCKER_BUILD_ARGS_ATTRIBUTE = "docker_build_args" SAM_METADATA_DOCKER_FILE_ATTRIBUTE = "docker_file" SAM_METADATA_RESOURCE_TYPE_ATTRIBUTE = "resource_type" # check for python 3, 3.7 or above # regex: search for 'Python', whitespace, '3.', digits 7-9 or 2+ digits, any digit or '.' 0+ times PYTHON_VERSION_REGEX = re.compile(r"Python\s*3.([7-9]|\d{2,})[\d.]*") LOG = logging.getLogger(__name__) def enrich_resources_and_generate_makefile( sam_metadata_resources: List[SamMetadataResource], cfn_resources: Dict[str, Dict], output_directory_path: str, terraform_application_dir: str, lambda_resources_to_code_map: Dict, ) -> None: """ Use the sam metadata resources to enrich the mapped resources and to create a Makefile with a rule for each lambda resource to be built. Parameters ---------- sam_metadata_resources: List[SamMetadataResource] The list of sam metadata resources defined in the terraform project. cfn_resources: dict CloudFormation resources output_directory_path: str the output directory path to write the generated metadata and makefile terraform_application_dir: str the terraform project root directory lambda_resources_to_code_map: Dict The map between lambda resources code path, and lambda resources logical ids """ python_command_name = _get_python_command_name() resources_types_enrichment_functions = { "ZIP_LAMBDA_FUNCTION": _enrich_zip_lambda_function, "IMAGE_LAMBDA_FUNCTION": _enrich_image_lambda_function, "LAMBDA_LAYER": _enrich_lambda_layer, } makefile_rules = [] for sam_metadata_resource in sam_metadata_resources: # enrich resource resource_type = get_sam_metadata_planned_resource_value_attribute( sam_metadata_resource.resource, SAM_METADATA_RESOURCE_TYPE_ATTRIBUTE ) sam_metadata_resource_address = sam_metadata_resource.resource.get("address") enrichment_function = resources_types_enrichment_functions.get(resource_type) if enrichment_function is None: raise InvalidSamMetadataPropertiesException( f"The resource type {resource_type} found in the sam metadata resource " f"{sam_metadata_resource_address} is not a correct resource type. The resource type should be one " f"of these values {resources_types_enrichment_functions.keys()}" ) lambda_resources = _get_relevant_cfn_resource( sam_metadata_resource, cfn_resources, lambda_resources_to_code_map ) for cfn_resource, logical_id in lambda_resources: enrichment_function( sam_metadata_resource.resource, cfn_resource, logical_id, terraform_application_dir, output_directory_path, ) # get makefile rule for resource makefile_rule = generate_makefile_rule_for_lambda_resource( sam_metadata_resource, logical_id, terraform_application_dir, python_command_name, output_directory_path ) makefile_rules.append(makefile_rule) # generate makefile LOG.debug("Generate Makefile in %s", output_directory_path) generate_makefile(makefile_rules, output_directory_path) def _enrich_zip_lambda_function( sam_metadata_resource: Dict, cfn_lambda_function: Dict, cfn_lambda_function_logical_id: str, terraform_application_dir: str, output_directory_path: str, ): """ Use the sam metadata resources to enrich the zip lambda function. Parameters ---------- sam_metadata_resource: Dict The sam metadata resource properties cfn_lambda_function: dict CloudFormation lambda function to be enriched cfn_lambda_function_logical_id: str the cloudFormation lambda function to be enriched logical id. output_directory_path: str the output directory path to write the generated metadata and makefile terraform_application_dir: str the terraform project root directory """ sam_metadata_resource_address = sam_metadata_resource.get("address") if not sam_metadata_resource_address: raise PrepareHookException( "Invalid Terraform plan output. The address property should not be null to any terraform resource." ) LOG.debug( "Enrich the ZIP lambda function %s using the metadata properties defined in resource %s", cfn_lambda_function_logical_id, sam_metadata_resource_address, ) _validate_referenced_resource_matches_sam_metadata_type( cfn_lambda_function, sam_metadata_resource, sam_metadata_resource_address, ZIP ) cfn_source_code_path = _get_source_code_path( sam_metadata_resource, sam_metadata_resource_address, terraform_application_dir, "original_source_code", "source_code_property", "source code", ) _set_zip_metadata_resources( cfn_lambda_function, cfn_source_code_path, output_directory_path, terraform_application_dir, CFN_CODE_PROPERTIES[CFN_AWS_LAMBDA_FUNCTION], ) def _enrich_image_lambda_function( sam_metadata_resource: Dict, cfn_lambda_function: Dict, cfn_lambda_function_logical_id: str, terraform_application_dir: str, output_directory_path: str, ): """ Use the sam metadata resources to enrich the image lambda function. Parameters ---------- sam_metadata_resource: Dict The sam metadata resource properties cfn_lambda_function: dict CloudFormation lambda function to be enriched cfn_lambda_function_logical_id: str the cloudFormation lambda function to be enriched logical id. output_directory_path: str the output directory path to write the generated metadata and makefile terraform_application_dir: str the terraform project root directory """ sam_metadata_resource_address = sam_metadata_resource.get("address") if not sam_metadata_resource_address: raise PrepareHookException( "Invalid Terraform plan output. The address property should not be null to any terraform resource." ) cfn_resource_properties = cfn_lambda_function.get("Properties", {}) LOG.debug( "Enrich the IMAGE lambda function %s using the metadata properties defined in resource %s", cfn_lambda_function_logical_id, sam_metadata_resource_address, ) _validate_referenced_resource_matches_sam_metadata_type( cfn_lambda_function, sam_metadata_resource, sam_metadata_resource_address, IMAGE ) cfn_docker_context_path = _get_source_code_path( sam_metadata_resource, sam_metadata_resource_address, terraform_application_dir, "docker_context", "docker_context_property_path", "docker context", ) cfn_docker_file = get_sam_metadata_planned_resource_value_attribute( sam_metadata_resource, SAM_METADATA_DOCKER_FILE_ATTRIBUTE ) cfn_docker_build_args_string = get_sam_metadata_planned_resource_value_attribute( sam_metadata_resource, SAM_METADATA_DOCKER_BUILD_ARGS_ATTRIBUTE ) cfn_docker_build_args = None if cfn_docker_build_args_string: try: LOG.debug("Parse the docker build args %s", cfn_docker_build_args_string) cfn_docker_build_args = json.loads(cfn_docker_build_args_string) if not isinstance(cfn_docker_build_args, dict): raise InvalidSamMetadataPropertiesException( f"The sam metadata resource {sam_metadata_resource_address} should contain a valid json " f"encoded string for the lambda function docker build arguments." ) except JSONDecodeError as exc: raise InvalidSamMetadataPropertiesException( f"The sam metadata resource {sam_metadata_resource_address} should contain a valid json encoded " f"string for the lambda function docker build arguments." ) from exc cfn_docker_tag = get_sam_metadata_planned_resource_value_attribute( sam_metadata_resource, SAM_METADATA_DOCKER_TAG_ATTRIBUTE ) if cfn_resource_properties.get("Code"): cfn_resource_properties.pop("Code") if not cfn_lambda_function.get("Metadata", {}): cfn_lambda_function["Metadata"] = {} cfn_lambda_function["Metadata"]["SkipBuild"] = False cfn_lambda_function["Metadata"]["DockerContext"] = cfn_docker_context_path if cfn_docker_file: cfn_lambda_function["Metadata"]["Dockerfile"] = cfn_docker_file if cfn_docker_tag: cfn_lambda_function["Metadata"]["DockerTag"] = cfn_docker_tag if cfn_docker_build_args: cfn_lambda_function["Metadata"]["DockerBuildArgs"] = cfn_docker_build_args def _enrich_lambda_layer( sam_metadata_resource: Dict, cfn_lambda_layer: Dict, cfn_lambda_layer_logical_id: str, terraform_application_dir: str, output_directory_path: str, ) -> None: """ Use the sam metadata resources to enrich the lambda layer. Parameters ---------- sam_metadata_resource: Dict The sam metadata resource properties cfn_lambda_layer: dict CloudFormation lambda layer to be enriched cfn_lambda_layer_logical_id: str the cloudFormation lambda layer to be enriched logical id. output_directory_path: str the output directory path to write the generated metadata and makefile terraform_application_dir: str the terraform project root directory """ sam_metadata_resource_address = sam_metadata_resource.get("address") if not sam_metadata_resource_address: raise PrepareHookException( "Invalid Terraform plan output. The address property should not be null to any terraform resource." ) _validate_referenced_resource_layer_matches_metadata_type( cfn_lambda_layer, sam_metadata_resource, sam_metadata_resource_address ) LOG.debug( "Enrich the Lambda Layer Version %s using the metadata properties defined in resource %s", cfn_lambda_layer_logical_id, sam_metadata_resource_address, ) cfn_source_code_path = _get_source_code_path( sam_metadata_resource, sam_metadata_resource_address, terraform_application_dir, "original_source_code", "source_code_property", "source code", ) _set_zip_metadata_resources( cfn_lambda_layer, cfn_source_code_path, output_directory_path, terraform_application_dir, CFN_CODE_PROPERTIES[CFN_AWS_LAMBDA_LAYER_VERSION], ) def _validate_referenced_resource_layer_matches_metadata_type( cfn_resource: dict, sam_metadata_resource: dict, sam_metadata_resource_address: str, ) -> None: """ Validate if the resource that match the resource name provided in the sam metadata resource matches the resource type provided in the metadata as well. Parameters ---------- cfn_resource: dict The CFN resource that matches the sam metadata resource name sam_metadata_resource: Dict The sam metadata resource properties sam_metadata_resource_address: str The sam metadata resource address """ cfn_resource_properties = cfn_resource.get("Properties", {}) resource_type = sam_metadata_resource.get(SAM_METADATA_RESOURCE_TYPE_ATTRIBUTE) cfn_resource_type = cfn_resource.get("Type") LOG.debug( "Validate if the referenced resource in sam metadata resource %s is of the expected type %s", sam_metadata_resource_address, resource_type, ) if cfn_resource_type != CFN_AWS_LAMBDA_LAYER_VERSION or not cfn_resource_properties: LOG.error( "The matched resource is of type %s but the type mentioned in the sam metadata resource %s is %s", cfn_resource_type, sam_metadata_resource_address, resource_type, ) raise InvalidSamMetadataPropertiesException( f"The sam metadata resource {sam_metadata_resource_address} is referring to a resource that does not " f"match the resource type {resource_type}." ) def _get_source_code_path( sam_metadata_resource: dict, sam_metadata_resource_address: str, project_root_dir: str, src_code_property_name: str, property_path_property_name: str, src_code_attribute_name: str, ) -> str: """ Validate that sam metadata resource contains the valid metadata properties to get a lambda function or layer source code. Parameters ---------- sam_metadata_resource: Dict The sam metadata resource properties sam_metadata_resource_address: str The sam metadata resource address project_root_dir: str the terraform project root directory path src_code_property_name: str the sam metadata property name that contains the lambda function or layer source code or docker context path property_path_property_name: str the sam metadata property name that contains the property to get the source code value if it was provided as json string src_code_attribute_name: str the lambda function or later source code or docker context to be used to raise the correct exception Returns ------- str The lambda function or layer source code or docker context paths """ LOG.debug( "Extract the %s from the sam metadata resource %s from property %s", src_code_attribute_name, sam_metadata_resource_address, src_code_property_name, ) source_code = get_sam_metadata_planned_resource_value_attribute(sam_metadata_resource, src_code_property_name) source_code_property = get_sam_metadata_planned_resource_value_attribute( sam_metadata_resource, property_path_property_name ) LOG.debug( "The found %s value is %s and property value is %s", src_code_attribute_name, source_code, source_code_property ) if not source_code: raise InvalidSamMetadataPropertiesException( f"The sam metadata resource {sam_metadata_resource_address} " f"should contain the lambda function/lambda layer " f"{src_code_attribute_name} in property {src_code_property_name}" ) if isinstance(source_code, str): try: LOG.debug("Try to decode the %s value in case if it is a encoded JSON string.", src_code_attribute_name) source_code = json.loads(source_code) LOG.debug("The decoded value of the %s value is %s", src_code_attribute_name, source_code) except JSONDecodeError: LOG.debug("Source code value could not be parsed as a JSON object. Handle it as normal string value") cfn_source_code_path = source_code if isinstance(source_code, list): # SAM CLI does not process multiple paths, so we will handle only the first value in this list # The first value can either be a string or dict so update source_code to be the first element of the list LOG.debug( "Process the extracted %s as list, and get the first value as SAM CLI does not support multiple paths", src_code_attribute_name, ) if len(source_code) < 1: raise InvalidSamMetadataPropertiesException( f"The sam metadata resource {sam_metadata_resource_address} " f"should contain the lambda function/lambda layer " f"{src_code_attribute_name} in property {src_code_property_name}, and it should not be an empty list" ) source_code = source_code[0] if not source_code: raise InvalidSamMetadataPropertiesException( f"The sam metadata resource {sam_metadata_resource_address} " f"should contain a valid lambda/lambda layer function " f"{src_code_attribute_name} in property {src_code_property_name}" ) if isinstance(source_code, dict): LOG.debug( "Process the extracted %s as JSON object using the property %s", src_code_attribute_name, source_code_property, ) if not source_code_property: raise InvalidSamMetadataPropertiesException( f"The sam metadata resource {sam_metadata_resource_address} " f"should contain the lambda function/lambda layer " f"{src_code_attribute_name} property in property {property_path_property_name} as the " f"{src_code_property_name} value is an object" ) cfn_source_code_path = source_code.get(source_code_property) if not cfn_source_code_path: LOG.error( "The property %s does not exist in the extracted %s JSON object %s", source_code_property, src_code_attribute_name, source_code, ) raise InvalidSamMetadataPropertiesException( f"The sam metadata resource {sam_metadata_resource_address} " f"should contain a valid lambda function/lambda layer " f"{src_code_attribute_name} property in property {property_path_property_name} as the " f"{src_code_property_name} value is an object" ) else: cfn_source_code_path = source_code LOG.debug("The %s path value is %s", src_code_attribute_name, cfn_source_code_path) if not os.path.isabs(cfn_source_code_path): LOG.debug( "The %s path value is not absoulte value. Get the absolute value based on the root directory %s", src_code_attribute_name, project_root_dir, ) cfn_source_code_path = os.path.normpath(os.path.join(project_root_dir, cfn_source_code_path)) LOG.debug("The calculated absolute path of %s is %s", src_code_attribute_name, cfn_source_code_path) if not isinstance(cfn_source_code_path, str) or not os.path.exists(cfn_source_code_path): LOG.error("The path %s does not exist", cfn_source_code_path) raise InvalidSamMetadataPropertiesException( f"The sam metadata resource {sam_metadata_resource_address} should contain a valid string value for the " f"lambda function/lambda layer {src_code_attribute_name} path" ) return cfn_source_code_path def _get_relevant_cfn_resource( sam_metadata_resource: SamMetadataResource, cfn_resources: Dict[str, Dict], lambda_resources_to_code_map: Dict[str, List[Tuple[Dict, str]]], ) -> List[Tuple[Dict, str]]: """ use the sam metadata resource name property to determine the resource address, and transform the address to logical id to use it to get the cfn_resource. If the metadata resource does not contain a resource name property, so we need to use the resource built artifact path to find tha lambda resources that use the same artifact path Parameters ---------- sam_metadata_resource: SamMetadataResource sam metadata resource that contain extra information about some resource. cfn_resources: Dict CloudFormation resources lambda_resources_to_code_map: Dict The map between lambda resources code path, and lambda resources logical ids Returns ------- List[tuple(Dict, str)] The cfn resources that mentioned in the sam metadata resource, and the resource logical id """ resources_types = { "ZIP_LAMBDA_FUNCTION": "zip", "IMAGE_LAMBDA_FUNCTION": "image", "LAMBDA_LAYER": "layer", } sam_metadata_resource_address = sam_metadata_resource.resource.get("address") resource_name = get_sam_metadata_planned_resource_value_attribute( sam_metadata_resource.resource, SAM_METADATA_RESOURCE_NAME_ATTRIBUTE ) resource_type = get_sam_metadata_planned_resource_value_attribute( sam_metadata_resource.resource, SAM_METADATA_RESOURCE_TYPE_ATTRIBUTE ) if not resource_name: artifact_property_name = ( "built_output_path" if resource_type in ["ZIP_LAMBDA_FUNCTION", "LAMBDA_LAYER"] else "built_image_uri" ) artifact_path_value = get_sam_metadata_planned_resource_value_attribute( sam_metadata_resource.resource, artifact_property_name ) if not artifact_path_value: artifact_path_value = _resolve_resource_attribute( sam_metadata_resource.config_resource, artifact_property_name ) hash_value = ( f"{resources_types[resource_type]}_{_calculate_configuration_attribute_value_hash(artifact_path_value)}" ) lambda_resources = lambda_resources_to_code_map.get(hash_value, []) if not lambda_resources: raise InvalidSamMetadataPropertiesException( f"sam cli expects the sam metadata resource {sam_metadata_resource_address} to contain a resource name " f"that will be enriched using this metadata resource" ) return lambda_resources # the provided resource name will be always a postfix to the module address. The customer could not set a full # address within a module. LOG.debug( "Check if the input resource name %s is a postfix to the current module address %s", resource_name, sam_metadata_resource.current_module_address, ) full_resource_address = ( f"{sam_metadata_resource.current_module_address}.{resource_name}" if sam_metadata_resource.current_module_address else resource_name ) LOG.debug("check if the resource address %s has a relevant cfn resource or not", full_resource_address) logical_id = build_cfn_logical_id(full_resource_address) cfn_resource = cfn_resources.get(logical_id) if cfn_resource: LOG.debug("The CFN resource that match the input resource name %s is %s", resource_name, logical_id) return [(cfn_resource, logical_id)] raise InvalidSamMetadataPropertiesException( f"There is no resource found that match the provided resource name " f"{resource_name}" ) def _set_zip_metadata_resources( resource: dict, cfn_source_code_path: str, output_directory_path: str, terraform_application_dir: str, code_property: str, ) -> None: """ Update the CloudFormation resource metadata with the enrichment properties from the TF resource Parameters ---------- resource: dict The CFN resource that matches the sam metadata resource name cfn_source_code_path: dict Absolute path location of where the original source code resides. output_directory_path: str The directory where to find the Makefile the path to be copied into the temp dir. terraform_application_dir: str The working directory from which to run the Makefile. code_property: The property in the configuration used to denote the code e.g. "Code" or "Content" """ resource_properties = resource.get("Properties", {}) resource_properties[code_property] = cfn_source_code_path if not resource.get("Metadata", {}): resource["Metadata"] = {} resource["Metadata"]["SkipBuild"] = False resource["Metadata"]["BuildMethod"] = "makefile" resource["Metadata"]["ContextPath"] = output_directory_path resource["Metadata"]["WorkingDirectory"] = terraform_application_dir # currently we set the terraform project root directory that contains all the terraform artifacts as the project # directory till we work on the custom hook properties, and add a property for this value. resource["Metadata"]["ProjectRootDirectory"] = terraform_application_dir def _validate_referenced_resource_matches_sam_metadata_type( cfn_resource: dict, sam_metadata_resource: dict, sam_metadata_resource_address: str, expected_package_type: str ) -> None: """ Validate if the resource that match the resource name provided in the sam metadata resource matches the resource type provided in the metadata as well. Parameters ---------- cfn_resource: dict The CFN resource that matches the sam metadata resource name sam_metadata_resource: Dict The sam metadata resource properties sam_metadata_resource_address: str The sam metadata resource address expected_package_type: str The expected lambda function package type. """ cfn_resource_properties = cfn_resource.get("Properties", {}) resource_type = get_sam_metadata_planned_resource_value_attribute( sam_metadata_resource, SAM_METADATA_RESOURCE_TYPE_ATTRIBUTE ) cfn_resource_type = cfn_resource.get("Type") lambda_function_package_type = cfn_resource_properties.get("PackageType", ZIP) LOG.debug( "Validate if the referenced resource in sam metadata resource %s is of the expected type %s", sam_metadata_resource_address, resource_type, ) if ( cfn_resource_type != CFN_AWS_LAMBDA_FUNCTION or not cfn_resource_properties or lambda_function_package_type != expected_package_type ): LOG.error( "The matched resource is of type %s, and package type is %s, but the type mentioned in the sam metadata " "resource %s is %s", cfn_resource_type, lambda_function_package_type, sam_metadata_resource_address, resource_type, ) raise InvalidSamMetadataPropertiesException( f"The sam metadata resource {sam_metadata_resource_address} is referring to a resource that does not " f"match the resource type {resource_type}." ) def _get_python_command_name() -> str: """ Verify that python is installed and return the name of the python command Returns ------- str The name of the python command installed """ command_names_to_try = ["python3", "py3", "python", "py"] for command_name in command_names_to_try: try: run_result = run([command_name, "--version"], check=True, capture_output=True, text=True) except CalledProcessError: pass except OSError: pass else: # check python version if not PYTHON_VERSION_REGEX.match(run_result.stdout): continue return command_name raise PrepareHookException("Python not found. Please ensure that python 3.7 or above is installed.")