Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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