-
Notifications
You must be signed in to change notification settings - Fork 1.2k
/
Copy pathtemplate.py
339 lines (264 loc) · 13.3 KB
/
template.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
"""
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