|
| 1 | +import copy |
1 | 2 | import json
|
2 | 3 | import logging
|
3 | 4 | import os
|
| 5 | +import re |
4 | 6 | from copy import deepcopy
|
5 |
| -from typing import Dict, Optional, Type, Union |
| 7 | +from dataclasses import dataclass |
| 8 | +from typing import Any, Callable, Dict, Optional, Type, Union |
6 | 9 |
|
7 | 10 | import boto3
|
8 | 11 | from botocore.exceptions import ClientError
|
|
12 | 15 | from localstack.aws.connect import connect_to
|
13 | 16 | from localstack.services.cloudformation.engine.policy_loader import create_policy_loader
|
14 | 17 | from localstack.services.cloudformation.engine.template_deployer import resolve_refs_recursively
|
| 18 | +from localstack.services.cloudformation.engine.validations import ValidationError |
15 | 19 | from localstack.services.cloudformation.stores import get_cloudformation_store
|
16 | 20 | from localstack.utils import testutil
|
17 | 21 | from localstack.utils.objects import recurse_object
|
|
26 | 30 | TransformResult = Union[dict, str]
|
27 | 31 |
|
28 | 32 |
|
| 33 | +@dataclass |
| 34 | +class ResolveRefsRecursivelyContext: |
| 35 | + account_id: str |
| 36 | + region_name: str |
| 37 | + stack_name: str |
| 38 | + resources: dict |
| 39 | + mappings: dict |
| 40 | + conditions: dict |
| 41 | + parameters: dict |
| 42 | + |
| 43 | + def resolve(self, value: Any) -> Any: |
| 44 | + return resolve_refs_recursively( |
| 45 | + self.account_id, |
| 46 | + self.region_name, |
| 47 | + self.stack_name, |
| 48 | + self.resources, |
| 49 | + self.mappings, |
| 50 | + self.conditions, |
| 51 | + self.parameters, |
| 52 | + value, |
| 53 | + ) |
| 54 | + |
| 55 | + |
29 | 56 | class Transformer:
|
30 | 57 | """Abstract class for Fn::Transform intrinsic functions"""
|
31 | 58 |
|
@@ -155,7 +182,20 @@ def apply_global_transformations(
|
155 | 182 | account_id, region_name, processed_template, stack_parameters
|
156 | 183 | )
|
157 | 184 | elif transformation["Name"] == EXTENSIONS_TRANSFORM:
|
158 |
| - continue |
| 185 | + resolve_context = ResolveRefsRecursivelyContext( |
| 186 | + account_id, |
| 187 | + region_name, |
| 188 | + stack_name, |
| 189 | + resources, |
| 190 | + mappings, |
| 191 | + conditions, |
| 192 | + stack_parameters, |
| 193 | + ) |
| 194 | + |
| 195 | + processed_template = apply_language_extensions_transform( |
| 196 | + processed_template, |
| 197 | + resolve_context, |
| 198 | + ) |
159 | 199 | elif transformation["Name"] == SECRETSMANAGER_TRANSFORM:
|
160 | 200 | # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/transform-aws-secretsmanager.html
|
161 | 201 | LOG.warning("%s is not yet supported. Ignoring.", SECRETSMANAGER_TRANSFORM)
|
@@ -269,6 +309,144 @@ def execute_macro(
|
269 | 309 | return result.get("fragment")
|
270 | 310 |
|
271 | 311 |
|
| 312 | +def apply_language_extensions_transform( |
| 313 | + template: dict, |
| 314 | + resolve_context: ResolveRefsRecursivelyContext, |
| 315 | +) -> dict: |
| 316 | + """ |
| 317 | + Resolve language extensions constructs |
| 318 | + """ |
| 319 | + |
| 320 | + def _visit(obj, path, **_): |
| 321 | + # Fn::ForEach |
| 322 | + # TODO: can this be used in non-resource positions? |
| 323 | + if isinstance(obj, dict) and any("Fn::ForEach" in key for key in obj): |
| 324 | + newobj = {} |
| 325 | + for key in obj: |
| 326 | + if "Fn::ForEach" not in key: |
| 327 | + newobj[key] = obj[key] |
| 328 | + continue |
| 329 | + |
| 330 | + new_entries = expand_fn_foreach(obj[key], resolve_context) |
| 331 | + newobj.update(**new_entries) |
| 332 | + return newobj |
| 333 | + # Fn::Length |
| 334 | + elif isinstance(obj, dict) and "Fn::Length" in obj: |
| 335 | + value = obj["Fn::Length"] |
| 336 | + if isinstance(value, dict): |
| 337 | + value = resolve_context.resolve(value) |
| 338 | + |
| 339 | + if isinstance(value, list): |
| 340 | + # TODO: what if one of the elements was AWS::NoValue? |
| 341 | + # no conversion required |
| 342 | + return len(value) |
| 343 | + elif isinstance(value, str): |
| 344 | + length = len(value.split(",")) |
| 345 | + return length |
| 346 | + return obj |
| 347 | + elif isinstance(obj, dict) and "Fn::ToJsonString" in obj: |
| 348 | + # TODO: is the default representation ok here? |
| 349 | + return json.dumps(obj["Fn::ToJsonString"], default=str, separators=(",", ":")) |
| 350 | + |
| 351 | + # reference |
| 352 | + return obj |
| 353 | + |
| 354 | + return recurse_object(template, _visit) |
| 355 | + |
| 356 | + |
| 357 | +def expand_fn_foreach( |
| 358 | + foreach_defn: list, |
| 359 | + resolve_context: ResolveRefsRecursivelyContext, |
| 360 | + extra_replace_mapping: dict | None = None, |
| 361 | +) -> dict: |
| 362 | + if len(foreach_defn) != 3: |
| 363 | + raise ValidationError( |
| 364 | + f"Fn::ForEach: invalid number of arguments, expected 3 got {len(foreach_defn)}" |
| 365 | + ) |
| 366 | + output = {} |
| 367 | + iteration_name, iteration_value, template = foreach_defn |
| 368 | + if not isinstance(iteration_name, str): |
| 369 | + raise ValidationError( |
| 370 | + f"Fn::ForEach: incorrect type for iteration name '{iteration_name}', expected str" |
| 371 | + ) |
| 372 | + if isinstance(iteration_value, dict): |
| 373 | + # we have a reference |
| 374 | + if "Ref" in iteration_value: |
| 375 | + iteration_value = resolve_context.resolve(iteration_value) |
| 376 | + else: |
| 377 | + raise NotImplementedError( |
| 378 | + f"Fn::Transform: intrinsic {iteration_value} not supported in this position yet" |
| 379 | + ) |
| 380 | + if not isinstance(iteration_value, list): |
| 381 | + raise ValidationError( |
| 382 | + f"Fn::ForEach: incorrect type for iteration variables '{iteration_value}', expected list" |
| 383 | + ) |
| 384 | + |
| 385 | + if not isinstance(template, dict): |
| 386 | + raise ValidationError( |
| 387 | + f"Fn::ForEach: incorrect type for template '{template}', expected dict" |
| 388 | + ) |
| 389 | + |
| 390 | + # TODO: locations other than resources |
| 391 | + replace_template_value = "${" + iteration_name + "}" |
| 392 | + for variable in iteration_value: |
| 393 | + # there might be multiple children, which could themselves be a `Fn::ForEach` call |
| 394 | + for logical_resource_id_template in template: |
| 395 | + if logical_resource_id_template.startswith("Fn::ForEach"): |
| 396 | + result = expand_fn_foreach( |
| 397 | + template[logical_resource_id_template], |
| 398 | + resolve_context, |
| 399 | + {iteration_name: variable}, |
| 400 | + ) |
| 401 | + output.update(**result) |
| 402 | + continue |
| 403 | + |
| 404 | + if replace_template_value not in logical_resource_id_template: |
| 405 | + raise ValidationError("Fn::ForEach: no placeholder in logical resource id") |
| 406 | + |
| 407 | + def gen_visit(variable: str) -> Callable: |
| 408 | + def _visit(obj: Any, path: Any): |
| 409 | + if isinstance(obj, dict) and "Ref" in obj: |
| 410 | + ref_variable = obj["Ref"] |
| 411 | + if ref_variable == iteration_name: |
| 412 | + return variable |
| 413 | + elif isinstance(obj, dict) and "Fn::Sub" in obj: |
| 414 | + arguments = recurse_object(obj["Fn::Sub"], _visit) |
| 415 | + if isinstance(arguments, str): |
| 416 | + # simple case |
| 417 | + # TODO: can this reference anything outside of the template? |
| 418 | + result = arguments |
| 419 | + variables_found = re.findall("\\${([^}]+)}", arguments) |
| 420 | + for var in variables_found: |
| 421 | + if var == iteration_name: |
| 422 | + result = result.replace(f"${{{var}}}", variable) |
| 423 | + return result |
| 424 | + else: |
| 425 | + raise NotImplementedError |
| 426 | + elif isinstance(obj, dict) and "Fn::Join" in obj: |
| 427 | + # first visit arguments |
| 428 | + arguments = recurse_object( |
| 429 | + obj["Fn::Join"], |
| 430 | + _visit, |
| 431 | + ) |
| 432 | + separator, items = arguments |
| 433 | + return separator.join(items) |
| 434 | + return obj |
| 435 | + |
| 436 | + return _visit |
| 437 | + |
| 438 | + logical_resource_id = logical_resource_id_template.replace( |
| 439 | + replace_template_value, variable |
| 440 | + ) |
| 441 | + for key, value in (extra_replace_mapping or {}).items(): |
| 442 | + logical_resource_id = logical_resource_id.replace("${" + key + "}", value) |
| 443 | + resource_body = copy.deepcopy(template[logical_resource_id_template]) |
| 444 | + body = recurse_object(resource_body, gen_visit(variable)) |
| 445 | + output[logical_resource_id] = body |
| 446 | + |
| 447 | + return output |
| 448 | + |
| 449 | + |
272 | 450 | def apply_serverless_transformation(
|
273 | 451 | account_id: str, region_name: str, parsed_template: dict, template_parameters: dict
|
274 | 452 | ) -> Optional[str]:
|
|
0 commit comments