samcli/commands/_utils/template.py (139 lines of code) (raw):
"""
Utilities to manipulate template
"""
import itertools
import os
import pathlib
import jmespath
import yaml
from botocore.utils import set_value_from_jmespath
from samcli.commands.exceptions import UserException
from samcli.lib.samlib.resource_metadata_normalizer import ASSET_PATH_METADATA_KEY, ResourceMetadataNormalizer
from samcli.lib.utils import graphql_api
from samcli.lib.utils.packagetype import IMAGE, ZIP
from samcli.lib.utils.resources import (
AWS_LAMBDA_FUNCTION,
AWS_SERVERLESS_FUNCTION,
AWS_SERVERLESS_GRAPHQLAPI,
METADATA_WITH_LOCAL_PATHS,
RESOURCES_WITH_LOCAL_PATHS,
get_packageable_resource_paths,
)
from samcli.yamlhelper import yaml_dump, yaml_parse
class TemplateNotFoundException(UserException):
pass
class TemplateFailedParsingException(UserException):
pass
def get_template_data(template_file):
"""
Read the template file, parse it as JSON/YAML and return the template as a dictionary.
Parameters
----------
template_file : string
Path to the template to read
Returns
-------
Template data as a dictionary
"""
if not pathlib.Path(template_file).exists():
raise TemplateNotFoundException("Template file not found at {}".format(template_file))
with open(template_file, "r", encoding="utf-8") as fp:
try:
return yaml_parse(fp.read())
except (ValueError, yaml.YAMLError) as ex:
raise TemplateFailedParsingException("Failed to parse template: {}".format(str(ex))) from ex
def move_template(src_template_path, dest_template_path, template_dict):
"""
Move the SAM/CloudFormation template from ``src_template_path`` to ``dest_template_path``. For convenience, this
method accepts a dictionary of template data ``template_dict`` that will be written to the destination instead of
reading from the source file.
SAM/CloudFormation template can contain certain properties whose value is a relative path to a local file/folder.
This path is always relative to the template's location. Before writing the template to ``dest_template_path`,
we will update these paths to be relative to the new location.
This methods updates resource properties supported by ``aws cloudformation package`` command:
https://docs.aws.amazon.com/cli/latest/reference/cloudformation/package.html
You must use this method if you are reading a template from one location, modifying it, and writing it back to a
different location.
Parameters
----------
src_template_path : str
Path to the original location of the template
dest_template_path : str
Path to the destination location where updated template should be written to
template_dict : dict
Dictionary containing template contents. This dictionary will be updated & written to ``dest`` location.
"""
original_root = os.path.dirname(src_template_path)
new_root = os.path.dirname(dest_template_path)
# Next up, we will be writing the template to a different location. Before doing so, we should
# update any relative paths in the template to be relative to the new location.
modified_template = _update_relative_paths(template_dict, original_root, new_root)
# if a stack only has image functions, the directory for that directory won't be created.
# here we make sure the directory the destination template file to write to exists.
os.makedirs(os.path.dirname(dest_template_path), exist_ok=True)
with open(dest_template_path, "w") as fp:
fp.write(yaml_dump(modified_template))
def _update_relative_paths(template_dict, original_root, new_root):
"""
SAM/CloudFormation template can contain certain properties whose value is a relative path to a local file/folder.
This path is usually relative to the template's location. If the template is being moved from original location
``original_root`` to new location ``new_root``, use this method to update these paths to be
relative to ``new_root``.
After this method is complete, it is safe to write the template to ``new_root`` without
breaking any relative paths.
This methods updates resource properties supported by ``aws cloudformation package`` command:
https://docs.aws.amazon.com/cli/latest/reference/cloudformation/package.html
If a property is either an absolute path or a S3 URI, this method will not update them.
Parameters
----------
template_dict : dict
Dictionary containing template contents. This dictionary will be updated & written to ``dest`` location.
original_root : str
Path to the directory where all paths were originally set relative to. This is usually the directory
containing the template originally
new_root : str
Path to the new directory that all paths set relative to after this method completes.
Returns
-------
Updated dictionary
"""
for resource_type, properties in template_dict.get("Metadata", {}).items():
if resource_type not in METADATA_WITH_LOCAL_PATHS:
# Unknown resource. Skipping
continue
for path_prop_name in METADATA_WITH_LOCAL_PATHS[resource_type]:
path = properties.get(path_prop_name)
updated_path = _resolve_relative_to(path, original_root, new_root)
if not updated_path:
# This path does not need to get updated
continue
properties[path_prop_name] = updated_path
for _, resource in template_dict.get("Resources", {}).items():
resource_type = resource.get("Type")
if resource_type not in RESOURCES_WITH_LOCAL_PATHS:
# Unknown resource. Skipping
continue
for path_prop_name in RESOURCES_WITH_LOCAL_PATHS[resource_type]:
properties = resource.get("Properties", {})
if (
resource_type in [AWS_SERVERLESS_FUNCTION, AWS_LAMBDA_FUNCTION]
and properties.get("PackageType", ZIP) == IMAGE
):
if not properties.get("ImageUri"):
continue
resolved_image_archive_path = _resolve_relative_to(properties.get("ImageUri"), original_root, new_root)
if not resolved_image_archive_path or not pathlib.Path(resolved_image_archive_path).is_file():
continue
# SAM GraphQLApi has many instances of CODE_ARTIFACT_PROPERTY and all of them must be updated
if resource_type == AWS_SERVERLESS_GRAPHQLAPI and path_prop_name == graphql_api.CODE_ARTIFACT_PROPERTY:
# to be able to set different nested properties to S3 uri, paths are necessary
# jmespath doesn't provide that functionality, thus custom implementation
paths_values = graphql_api.find_all_paths_and_values(path_prop_name, properties)
for property_path, property_value in paths_values:
updated_path = _resolve_relative_to(property_value, original_root, new_root)
if not updated_path:
# This path does not need to get updated
continue
set_value_from_jmespath(properties, property_path, updated_path)
path = jmespath.search(path_prop_name, properties)
updated_path = _resolve_relative_to(path, original_root, new_root)
if not updated_path:
# This path does not need to get updated
continue
set_value_from_jmespath(properties, path_prop_name, updated_path)
metadata = resource.get("Metadata", {})
if ASSET_PATH_METADATA_KEY in metadata:
path = metadata.get(ASSET_PATH_METADATA_KEY, "")
updated_path = _resolve_relative_to(path, original_root, new_root)
if not updated_path:
# This path does not need to get updated
continue
metadata[ASSET_PATH_METADATA_KEY] = updated_path
# AWS::Includes can be anywhere within the template dictionary. Hence we need to recurse through the
# dictionary in a separate method to find and update relative paths in there
template_dict = _update_aws_include_relative_path(template_dict, original_root, new_root)
return template_dict
def _update_aws_include_relative_path(template_dict, original_root, new_root):
"""
Update relative paths in "AWS::Include" directive. This directive can be present at any part of the template,
and not just within resources.
"""
for key, val in template_dict.items():
if key == "Fn::Transform":
if isinstance(val, dict) and val.get("Name") == "AWS::Include":
path = val.get("Parameters", {}).get("Location", {})
updated_path = _resolve_relative_to(path, original_root, new_root)
if not updated_path:
# This path does not need to get updated
continue
val["Parameters"]["Location"] = updated_path
# Recurse through all dictionary values
elif isinstance(val, dict):
_update_aws_include_relative_path(val, original_root, new_root)
elif isinstance(val, list):
for item in val:
if isinstance(item, dict):
_update_aws_include_relative_path(item, original_root, new_root)
return template_dict
def _resolve_relative_to(path, original_root, new_root):
"""
If the given ``path`` is a relative path, then assume it is relative to ``original_root``. This method will
update the path to be resolve it relative to ``new_root`` and return.
Examples
-------
# Assume a file called template.txt at location /tmp/original/root/template.txt expressed as relative path
# We are trying to update it to be relative to /tmp/new/root instead of the /tmp/original/root
>>> result = _resolve_relative_to("template.txt", \
"/tmp/original/root", \
"/tmp/new/root")
>>> result
../../original/root/template.txt
Returns
-------
Updated path if the given path is a relative path. None, if the path is not a relative path.
"""
if (
not isinstance(path, str)
or path.startswith("s3://")
or path.startswith("http://")
or path.startswith("https://")
or os.path.isabs(path)
):
# Value is definitely NOT a relative path. It is either a S3 URi or Absolute path or not a string at all
return None
# Value is definitely a relative path. Change it relative to the destination directory
return os.path.relpath(
# Resolve the paths to take care of symlinks
os.path.normpath(os.path.join(pathlib.Path(original_root).resolve(), path)),
pathlib.Path(new_root).resolve(), # Absolute original path w.r.t ``original_root``
) # Resolve the original path with respect to ``new_root``
def get_template_parameters(template_file):
"""
Get Parameters from a template file.
Parameters
----------
template_file : string
Path to the template to read
Returns
-------
Template Parameters as a dictionary
"""
template_dict = get_template_data(template_file=template_file)
ResourceMetadataNormalizer.normalize(template_dict, True)
return template_dict.get("Parameters", dict())
def get_template_artifacts_format(template_file):
"""
Get a list of template artifact formats based on PackageType wherever the underlying resource
have the actual need to be packaged.
:param template_file:
:return: list of artifact formats
"""
template_dict = get_template_data(template_file=template_file)
# Get a list of Resources where the artifacts format matter for packaging.
packageable_resources = get_packageable_resource_paths()
artifacts = []
for _, resource in template_dict.get("Resources", {}).items():
# First check if the resources are part of package-able resource types.
if resource.get("Type") in packageable_resources.keys():
# Flatten list of locations per resource type.
locations = list(itertools.chain(*packageable_resources.get(resource.get("Type"))))
for location in locations:
properties = resource.get("Properties", {})
# Search for package-able location within resource properties.
if jmespath.search(location, properties):
artifacts.append(properties.get("PackageType", ZIP))
return artifacts
def get_template_function_resource_ids(template_file, artifact):
"""
Get a list of function logical ids from template file.
Function resource types include
AWS::Lambda::Function
AWS::Serverless::Function
:param template_file: template file location.
:param artifact: artifact of type IMAGE or ZIP
:return: list of artifact formats
"""
template_dict = get_template_data(template_file=template_file)
_function_resource_ids = []
for resource_id, resource in template_dict.get("Resources", {}).items():
if resource.get("Properties", {}).get("PackageType", ZIP) == artifact and resource.get("Type") in [
AWS_SERVERLESS_FUNCTION,
AWS_LAMBDA_FUNCTION,
]:
_function_resource_ids.append(resource_id)
return _function_resource_ids