Skip to content

Commit e666c8f

Browse files
Add support for 'Fn::ForEach' intrinsic function.
Inspired by aws/aws-cli#8096.
1 parent dd8056a commit e666c8f

File tree

10 files changed

+636
-2
lines changed

10 files changed

+636
-2
lines changed

samtranslator/parser/parser.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from samtranslator.plugins import LifeCycleEvents
1010
from samtranslator.plugins.sam_plugins import SamPlugins
1111
from samtranslator.public.sdk.template import SamTemplate
12+
from samtranslator.utils.utils import safe_dict
1213
from samtranslator.validator.value_validator import sam_expect
1314

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

35-
if not all(isinstance(sam_resource, dict) for sam_resource in sam_template["Resources"].values()):
36+
if not all(isinstance(sam_resource, dict) for sam_resource in safe_dict(sam_template["Resources"]).values()):
3637
raise InvalidDocumentException(
3738
[
3839
InvalidTemplateException(

samtranslator/sdk/template.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import Any, Dict, Iterator, Optional, Set, Tuple, Union
66

77
from samtranslator.sdk.resource import SamResource
8+
from samtranslator.utils.utils import safe_dict
89

910

1011
class SamTemplate:
@@ -30,7 +31,7 @@ def iterate(self, resource_types: Optional[Set[str]] = None) -> Iterator[Tuple[s
3031
"""
3132
if resource_types is None:
3233
resource_types = set()
33-
for logicalId, resource_dict in self.resources.items():
34+
for logicalId, resource_dict in safe_dict(self.resources).items():
3435
resource = SamResource(resource_dict)
3536
needs_filter = resource.valid()
3637
if resource_types:

samtranslator/utils/utils.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,39 @@ def dict_deep_set(d: Any, path: str, value: Any) -> None:
7171
if not isinstance(d, dict):
7272
raise InvalidValueType(relative_path)
7373
d[_path_nodes[0]] = value
74+
75+
def namespace_prefix(prefix: str, string: str):
76+
"""
77+
Joins `prefix` and `string` separated by `"::"` if neither is empty.
78+
79+
Returns the non empty one if only one is empty
80+
Returns `""` if both are empty
81+
"""
82+
return "::".join(filter(None, [prefix, string]))
83+
84+
def safe_dict(input_dict, namespace = None):
85+
"""
86+
Manipulates entries to support usage of `Fn::ForEach` intrinsic function in
87+
resources dicts.
88+
89+
Recursively searches for array entries with keys starting with
90+
`Fn::ForEach::` and replaces them with the provided resource fragments.
91+
92+
To support embedded usage of `Fn::ForEach` intrinsic function, resource
93+
fragment keys are prefixed with provided unique loop name
94+
"""
95+
output_dict = {}
96+
for_each_function = "Fn::ForEach::"
97+
98+
for k, v in input_dict.items():
99+
recurse = False
100+
if isinstance(k, str) and k.startswith(for_each_function):
101+
if isinstance(v, list) and len(v) == 3:
102+
recurse = True
103+
104+
if recurse:
105+
output_dict = output_dict | safe_dict(v[2], namespace_prefix(namespace, k.removeprefix(for_each_function)))
106+
else:
107+
output_dict[namespace_prefix(namespace, str(k))] = v
108+
109+
return output_dict

tests/schema/test_validate_schema.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
# TODO: Support globals (e.g. somehow make all fields of a model optional only for Globals)
5959
"api_with_custom_base_path",
6060
"function_with_tracing", # TODO: intentionally skip this tests to cover incorrect scenarios
61+
"intrinsic_for_each_resource", # intrinsic forEach loop is not supported
6162
]
6263

6364

tests/sdk/test_template.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ def setUp(self):
1515
"Api": {"Type": "AWS::Serverless::Api"},
1616
"Layer": {"Type": "AWS::Serverless::LayerVersion"},
1717
"NonSam": {"Type": "AWS::Lambda::Function"},
18+
"Fn::ForEach::LambdaFunctions": [ "FunctionName", ["1", "2"], { "${FunctioName}": {"Type": "AWS::Lambda::Function", "a": "b"}}],
19+
"Fn::ForEach::ServerlessFunctions": ["FunctionName", ["3", "4"], {"${FunctionName}": {"Type": "AWS::Serverless::Function", "a": "b"}}],
1820
},
1921
}
2022

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

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

4347
actual = [(id, resource.to_dict()) for id, resource in template.iterate({type})]
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
Mappings:
2+
TemplateLinksPolicies:
3+
Template1Link1:
4+
template: Template1
5+
principal:
6+
type: "User"
7+
id: "user1"
8+
resource:
9+
type: "Resource"
10+
id: "resource1"
11+
Template1Link2:
12+
template: Template2
13+
principal:
14+
type: "User"
15+
id: "user2"
16+
resource:
17+
type: "Resource"
18+
id: "resource2"
19+
20+
Parameters:
21+
Environment:
22+
Type: String
23+
Project:
24+
Type: String
25+
26+
Resources:
27+
PolicyStore:
28+
Type: AWS::VerifiedPermissions::PolicyStore
29+
Properties:
30+
Description: !Sub "AVP Policy store for ${Project}-${Environment}"
31+
Schema:
32+
CedarJson:
33+
Fn::ToJsonString:
34+
Fn::Transform:
35+
Name: AWS::Include
36+
Parameters:
37+
Location: policy-store-schema.json
38+
ValidationSettings:
39+
Mode: STRICT
40+
41+
Template1:
42+
Type: AWS::VerifiedPermissions::PolicyTemplate
43+
Properties:
44+
PolicyStoreId: !Ref PolicyStore
45+
Description: "AVP Template."
46+
Statement: >
47+
permit(
48+
principal in ?principal,
49+
action == Action::"action",
50+
resource == ?resource
51+
);
52+
53+
'Fn::ForEach::TemplateLinked':
54+
- TemplateKey
55+
- {"Fn::FindInMap": ["TemplateLinksPolicies"]}
56+
- '${TemplateKey}':
57+
Type: AWS::VerifiedPermissions::Policy
58+
Properties:
59+
PolicyStoreId: !Ref PolicyStore
60+
Definition:
61+
TemplateLinked:
62+
PolicyTemplateId: {"Fn::GetAtt": [{"Fn::FindInMap": ["TemplateLinksPolicies", "${TemplateKey}", "template"]}, "PolicyTemplateId"]}
63+
Principal:
64+
EntityType: {"Fn::FindInMap": ["TemplateLinksPolicies", '${TemplateKey}', "principal", "type"]}
65+
EntityId: {"Fn::FindInMap": ["TemplateLinksPolicies", '${TemplateKey}', "principal", "id"]}
66+
Resource:
67+
EntityType: {"Fn::FindInMap": ["TemplateLinksPolicies", "${TemplateKey}", "resource", "type"]}
68+
EntityId: {"Fn::FindInMap": ["TemplateLinksPolicies", "${TemplateKey}", "resource", "id"]}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
{
2+
"Mappings": {
3+
"TemplateLinksPolicies": {
4+
"Template1Link1": {
5+
"template": "Template1",
6+
"principal": {
7+
"type": "User",
8+
"id": "user1"
9+
},
10+
"resource": {
11+
"type": "Resource",
12+
"id": "resource1"
13+
}
14+
},
15+
"Template1Link2": {
16+
"template": "Template2",
17+
"principal": {
18+
"type": "User",
19+
"id": "user2"
20+
},
21+
"resource": {
22+
"type": "Resource",
23+
"id": "resource2"
24+
}
25+
}
26+
}
27+
},
28+
"Parameters": {
29+
"Environment": {
30+
"Type": "String"
31+
},
32+
"Project": {
33+
"Type": "String"
34+
}
35+
},
36+
"Resources": {
37+
"PolicyStore": {
38+
"Type": "AWS::VerifiedPermissions::PolicyStore",
39+
"Properties": {
40+
"Description": {
41+
"Fn::Sub": "AVP Policy store for ${Project}-${Environment}"
42+
},
43+
"Schema": {
44+
"CedarJson": {
45+
"Fn::ToJsonString": {
46+
"Fn::Transform": {
47+
"Name": "AWS::Include",
48+
"Parameters": {
49+
"Location": "policy-store-schema.json"
50+
}
51+
}
52+
}
53+
}
54+
},
55+
"ValidationSettings": {
56+
"Mode": "STRICT"
57+
}
58+
}
59+
},
60+
"Template1": {
61+
"Type": "AWS::VerifiedPermissions::PolicyTemplate",
62+
"Properties": {
63+
"PolicyStoreId": {
64+
"Ref": "PolicyStore"
65+
},
66+
"Description": "AVP Template.",
67+
"Statement": "permit(\n principal in ?principal,\n action == Action::\"action\",\n resource == ?resource\n );\n"
68+
}
69+
},
70+
"Fn::ForEach::TemplateLinked": [
71+
"TemplateKey",
72+
{
73+
"Fn::FindInMap": [
74+
"TemplateLinksPolicies"
75+
]
76+
},
77+
{
78+
"${TemplateKey}": {
79+
"Type": "AWS::VerifiedPermissions::Policy",
80+
"Properties": {
81+
"PolicyStoreId": {
82+
"Ref": "PolicyStore"
83+
},
84+
"Definition": {
85+
"TemplateLinked": {
86+
"PolicyTemplateId": {
87+
"Fn::GetAtt": [
88+
{
89+
"Fn::FindInMap": [
90+
"TemplateLinksPolicies",
91+
"${TemplateKey}",
92+
"template"
93+
]
94+
},
95+
"PolicyTemplateId"
96+
]
97+
},
98+
"Principal": {
99+
"EntityType": {
100+
"Fn::FindInMap": [
101+
"TemplateLinksPolicies",
102+
"${TemplateKey}",
103+
"principal",
104+
"type"
105+
]
106+
},
107+
"EntityId": {
108+
"Fn::FindInMap": [
109+
"TemplateLinksPolicies",
110+
"${TemplateKey}",
111+
"principal",
112+
"id"
113+
]
114+
}
115+
},
116+
"Resource": {
117+
"EntityType": {
118+
"Fn::FindInMap": [
119+
"TemplateLinksPolicies",
120+
"${TemplateKey}",
121+
"resource",
122+
"type"
123+
]
124+
},
125+
"EntityId": {
126+
"Fn::FindInMap": [
127+
"TemplateLinksPolicies",
128+
"${TemplateKey}",
129+
"resource",
130+
"id"
131+
]
132+
}
133+
}
134+
}
135+
}
136+
}
137+
}
138+
}
139+
]
140+
}
141+
}

0 commit comments

Comments
 (0)