Skip to content

Commit

Permalink
Add support for 'Fn::ForEach' intrinsic function.
Browse files Browse the repository at this point in the history
Inspired by aws/aws-cli#8096.
  • Loading branch information
smasset-veolia committed Aug 20, 2024
1 parent 1de5aa3 commit 0a0472f
Show file tree
Hide file tree
Showing 10 changed files with 636 additions and 2 deletions.
3 changes: 2 additions & 1 deletion samtranslator/parser/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from samtranslator.plugins import LifeCycleEvents
from samtranslator.plugins.sam_plugins import SamPlugins
from samtranslator.public.sdk.template import SamTemplate
from samtranslator.utils.utils import safe_dict
from samtranslator.validator.value_validator import sam_expect

LOG = logging.getLogger(__name__)
Expand All @@ -32,7 +33,7 @@ def validate_datatypes(sam_template): # type: ignore[no-untyped-def]
):
raise InvalidDocumentException([InvalidTemplateException("'Resources' section is required")])

if not all(isinstance(sam_resource, dict) for sam_resource in sam_template["Resources"].values()):
if not all(isinstance(sam_resource, dict) for sam_resource in safe_dict(sam_template["Resources"]).values()):
raise InvalidDocumentException(
[
InvalidTemplateException(
Expand Down
3 changes: 2 additions & 1 deletion samtranslator/sdk/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from typing import Any, Dict, Iterator, Optional, Set, Tuple, Union

from samtranslator.sdk.resource import SamResource
from samtranslator.utils.utils import safe_dict


class SamTemplate:
Expand All @@ -30,7 +31,7 @@ def iterate(self, resource_types: Optional[Set[str]] = None) -> Iterator[Tuple[s
"""
if resource_types is None:
resource_types = set()
for logicalId, resource_dict in self.resources.items():
for logicalId, resource_dict in safe_dict(self.resources).items():
resource = SamResource(resource_dict)
needs_filter = resource.valid()
if resource_types:
Expand Down
36 changes: 36 additions & 0 deletions samtranslator/utils/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,39 @@ def dict_deep_set(d: Any, path: str, value: Any) -> None:
if not isinstance(d, dict):
raise InvalidValueType(relative_path)
d[_path_nodes[0]] = value

def namespace_prefix(prefix: str, string: str):
"""
Joins `prefix` and `string` separated by `"::"` if neither is empty.
Returns the non empty one if only one is empty
Returns `""` if both are empty
"""
return "::".join(filter(None, [prefix, string]))

def safe_dict(input_dict, namespace = None):
"""
Manipulates entries to support usage of `Fn::ForEach` intrinsic function in
resources dicts.
Recursively searches for array entries with keys starting with
`Fn::ForEach::` and replaces them with the provided resource fragments.
To support embedded usage of `Fn::ForEach` intrinsic function, resource
fragment keys are prefixed with provided unique loop name
"""
output_dict = {}
for_each_function = "Fn::ForEach::"

for k, v in input_dict.items():
recurse = False
if isinstance(k, str) and k.startswith(for_each_function):
if isinstance(v, list) and len(v) == 3:
recurse = True

if recurse:
output_dict = output_dict | safe_dict(v[2], namespace_prefix(namespace, k.removeprefix(for_each_function)))
else:
output_dict[namespace_prefix(namespace, str(k))] = v

return output_dict
1 change: 1 addition & 0 deletions tests/schema/test_validate_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
# TODO: Support globals (e.g. somehow make all fields of a model optional only for Globals)
"api_with_custom_base_path",
"function_with_tracing", # TODO: intentionally skip this tests to cover incorrect scenarios
"intrinsic_for_each_resource", # intrinsic forEach loop is not supported
]


Expand Down
4 changes: 4 additions & 0 deletions tests/sdk/test_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ def setUp(self):
"Api": {"Type": "AWS::Serverless::Api"},
"Layer": {"Type": "AWS::Serverless::LayerVersion"},
"NonSam": {"Type": "AWS::Lambda::Function"},
"Fn::ForEach::LambdaFunctions": [ "FunctionName", ["1", "2"], { "${FunctioName}": {"Type": "AWS::Lambda::Function", "a": "b"}}],
"Fn::ForEach::ServerlessFunctions": ["FunctionName", ["3", "4"], {"${FunctionName}": {"Type": "AWS::Serverless::Function", "a": "b"}}],
},
}

Expand All @@ -26,6 +28,7 @@ def test_iterate_must_yield_sam_resources_only(self):
("Function2", {"Type": "AWS::Serverless::Function", "a": "b", "Properties": {}}),
("Api", {"Type": "AWS::Serverless::Api", "Properties": {}}),
("Layer", {"Type": "AWS::Serverless::LayerVersion", "Properties": {}}),
("ServerlessFunctions::${FunctionName}", {"Type": "AWS::Serverless::Function", "a": "b", "Properties": {}}),
]

actual = [(id, resource.to_dict()) for id, resource in template.iterate()]
Expand All @@ -38,6 +41,7 @@ def test_iterate_must_filter_by_resource_type(self):
expected = [
("Function1", {"Type": "AWS::Serverless::Function", "DependsOn": "SomeOtherResource", "Properties": {}}),
("Function2", {"Type": "AWS::Serverless::Function", "a": "b", "Properties": {}}),
("ServerlessFunctions::${FunctionName}", {"Type": "AWS::Serverless::Function", "a": "b", "Properties": {}}),
]

actual = [(id, resource.to_dict()) for id, resource in template.iterate({type})]
Expand Down
68 changes: 68 additions & 0 deletions tests/translator/input/intrinsic_for_each_resource.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
Mappings:
TemplateLinksPolicies:
Template1Link1:
template: Template1
principal:
type: "User"
id: "user1"
resource:
type: "Resource"
id: "resource1"
Template1Link2:
template: Template2
principal:
type: "User"
id: "user2"
resource:
type: "Resource"
id: "resource2"

Parameters:
Environment:
Type: String
Project:
Type: String

Resources:
PolicyStore:
Type: AWS::VerifiedPermissions::PolicyStore
Properties:
Description: !Sub "AVP Policy store for ${Project}-${Environment}"
Schema:
CedarJson:
Fn::ToJsonString:
Fn::Transform:
Name: AWS::Include
Parameters:
Location: policy-store-schema.json
ValidationSettings:
Mode: STRICT

Template1:
Type: AWS::VerifiedPermissions::PolicyTemplate
Properties:
PolicyStoreId: !Ref PolicyStore
Description: "AVP Template."
Statement: >
permit(
principal in ?principal,
action == Action::"action",
resource == ?resource
);
'Fn::ForEach::TemplateLinked':
- TemplateKey
- {"Fn::FindInMap": ["TemplateLinksPolicies"]}
- '${TemplateKey}':
Type: AWS::VerifiedPermissions::Policy
Properties:
PolicyStoreId: !Ref PolicyStore
Definition:
TemplateLinked:
PolicyTemplateId: {"Fn::GetAtt": [{"Fn::FindInMap": ["TemplateLinksPolicies", "${TemplateKey}", "template"]}, "PolicyTemplateId"]}
Principal:
EntityType: {"Fn::FindInMap": ["TemplateLinksPolicies", '${TemplateKey}', "principal", "type"]}
EntityId: {"Fn::FindInMap": ["TemplateLinksPolicies", '${TemplateKey}', "principal", "id"]}
Resource:
EntityType: {"Fn::FindInMap": ["TemplateLinksPolicies", "${TemplateKey}", "resource", "type"]}
EntityId: {"Fn::FindInMap": ["TemplateLinksPolicies", "${TemplateKey}", "resource", "id"]}
141 changes: 141 additions & 0 deletions tests/translator/output/aws-cn/intrinsic_for_each_resource.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
{
"Mappings": {
"TemplateLinksPolicies": {
"Template1Link1": {
"template": "Template1",
"principal": {
"type": "User",
"id": "user1"
},
"resource": {
"type": "Resource",
"id": "resource1"
}
},
"Template1Link2": {
"template": "Template2",
"principal": {
"type": "User",
"id": "user2"
},
"resource": {
"type": "Resource",
"id": "resource2"
}
}
}
},
"Parameters": {
"Environment": {
"Type": "String"
},
"Project": {
"Type": "String"
}
},
"Resources": {
"PolicyStore": {
"Type": "AWS::VerifiedPermissions::PolicyStore",
"Properties": {
"Description": {
"Fn::Sub": "AVP Policy store for ${Project}-${Environment}"
},
"Schema": {
"CedarJson": {
"Fn::ToJsonString": {
"Fn::Transform": {
"Name": "AWS::Include",
"Parameters": {
"Location": "policy-store-schema.json"
}
}
}
}
},
"ValidationSettings": {
"Mode": "STRICT"
}
}
},
"Template1": {
"Type": "AWS::VerifiedPermissions::PolicyTemplate",
"Properties": {
"PolicyStoreId": {
"Ref": "PolicyStore"
},
"Description": "AVP Template.",
"Statement": "permit(\n principal in ?principal,\n action == Action::\"action\",\n resource == ?resource\n );\n"
}
},
"Fn::ForEach::TemplateLinked": [
"TemplateKey",
{
"Fn::FindInMap": [
"TemplateLinksPolicies"
]
},
{
"${TemplateKey}": {
"Type": "AWS::VerifiedPermissions::Policy",
"Properties": {
"PolicyStoreId": {
"Ref": "PolicyStore"
},
"Definition": {
"TemplateLinked": {
"PolicyTemplateId": {
"Fn::GetAtt": [
{
"Fn::FindInMap": [
"TemplateLinksPolicies",
"${TemplateKey}",
"template"
]
},
"PolicyTemplateId"
]
},
"Principal": {
"EntityType": {
"Fn::FindInMap": [
"TemplateLinksPolicies",
"${TemplateKey}",
"principal",
"type"
]
},
"EntityId": {
"Fn::FindInMap": [
"TemplateLinksPolicies",
"${TemplateKey}",
"principal",
"id"
]
}
},
"Resource": {
"EntityType": {
"Fn::FindInMap": [
"TemplateLinksPolicies",
"${TemplateKey}",
"resource",
"type"
]
},
"EntityId": {
"Fn::FindInMap": [
"TemplateLinksPolicies",
"${TemplateKey}",
"resource",
"id"
]
}
}
}
}
}
}
}
]
}
}
Loading

0 comments on commit 0a0472f

Please sign in to comment.