Skip to content

Commit

Permalink
Support Fn::ForEach intrinsic function (#8096)
Browse files Browse the repository at this point in the history
Allow "aws cloudformation package" to succeed when a Resource is not a key-value pair.
  • Loading branch information
arthurboghossian authored Dec 14, 2023
1 parent 585db84 commit 2676a46
Show file tree
Hide file tree
Showing 4 changed files with 176 additions and 3 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"type": "enhancement",
"category": "``cloudformation package``",
"description": "Add support for intrinsic Fn:ForEach (fixes `#8075 <https://github.com/aws/aws-cli/issues/8075>`__)"
}
15 changes: 12 additions & 3 deletions awscli/customizations/cloudformation/artifact_exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -659,7 +659,18 @@ def export(self):

self.template_dict = self.export_global_artifacts(self.template_dict)

for resource_id, resource in self.template_dict["Resources"].items():
self.export_resources(self.template_dict["Resources"])

return self.template_dict

def export_resources(self, resource_dict):
for resource_id, resource in resource_dict.items():

if resource_id.startswith("Fn::ForEach::"):
if not isinstance(resource, list) or len(resource) != 3:
raise exceptions.InvalidForEachIntrinsicFunctionError(resource_id=resource_id)
self.export_resources(resource[2])
continue

resource_type = resource.get("Type", None)
resource_dict = resource.get("Properties", None)
Expand All @@ -671,5 +682,3 @@ def export(self):
# Export code resources
exporter = exporter_class(self.uploader)
exporter.export(resource_id, resource_dict, self.template_dir)

return self.template_dict
4 changes: 4 additions & 0 deletions awscli/customizations/cloudformation/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,7 @@ class DeployBucketRequiredError(CloudFormationCommandError):
"via an S3 Bucket. Please add the --s3-bucket parameter to your "
"command. The local template will be copied to that S3 bucket and "
"then deployed.")


class InvalidForEachIntrinsicFunctionError(CloudFormationCommandError):
fmt = 'The value of {resource_id} has an invalid "Fn::ForEach::" format: Must be a list of three entries'
155 changes: 155 additions & 0 deletions tests/unit/customizations/cloudformation/test_artifact_exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1016,6 +1016,161 @@ def test_template_export(self, yaml_parse_mock):
resource_type2_instance.export.assert_called_once_with(
"Resource2", mock.ANY, template_dir)

@mock.patch("awscli.customizations.cloudformation.artifact_exporter.yaml_parse")
def test_template_export_foreach_valid(self, yaml_parse_mock):
parent_dir = os.path.sep
template_dir = os.path.join(parent_dir, 'foo', 'bar')
template_path = os.path.join(template_dir, 'path')
template_str = self.example_yaml_template()

resource_type1_class = mock.Mock()
resource_type1_class.RESOURCE_TYPE = "resource_type1"
resource_type1_instance = mock.Mock()
resource_type1_class.return_value = resource_type1_instance
resource_type2_class = mock.Mock()
resource_type2_class.RESOURCE_TYPE = "resource_type2"
resource_type2_instance = mock.Mock()
resource_type2_class.return_value = resource_type2_instance

resources_to_export = [
resource_type1_class,
resource_type2_class
]

properties = {"foo": "bar"}
template_dict = {
"Resources": {
"Resource1": {
"Type": "resource_type1",
"Properties": properties
},
"Resource2": {
"Type": "resource_type2",
"Properties": properties
},
"Resource3": {
"Type": "some-other-type",
"Properties": properties
},
"Fn::ForEach::OuterLoopName": [
"Identifier1",
["4", "5"],
{
"Fn::ForEach::InnerLoopName": [
"Identifier2",
["6", "7"],
{
"Resource${Identifier1}${Identifier2}": {
"Type": "resource_type2",
"Properties": properties
}
}
],
"Resource${Identifier1}": {
"Type": "resource_type1",
"Properties": properties
}
}
]
}
}

open_mock = mock.mock_open()
yaml_parse_mock.return_value = template_dict

# Patch the file open method to return template string
with mock.patch(
"awscli.customizations.cloudformation.artifact_exporter.open",
open_mock(read_data=template_str)) as open_mock:

template_exporter = Template(
template_path, parent_dir, self.s3_uploader_mock,
resources_to_export)
exported_template = template_exporter.export()
self.assertEqual(exported_template, template_dict)

open_mock.assert_called_once_with(
make_abs_path(parent_dir, template_path), "r")

self.assertEqual(1, yaml_parse_mock.call_count)

resource_type1_class.assert_called_with(self.s3_uploader_mock)
self.assertEqual(
resource_type1_instance.export.call_args_list,
[
mock.call("Resource1", properties, template_dir),
mock.call("Resource${Identifier1}", properties, template_dir)
]
)
resource_type2_class.assert_called_with(self.s3_uploader_mock)
self.assertEqual(
resource_type2_instance.export.call_args_list,
[
mock.call("Resource2", properties, template_dir),
mock.call("Resource${Identifier1}${Identifier2}", properties, template_dir)
]
)

@mock.patch("awscli.customizations.cloudformation.artifact_exporter.yaml_parse")
def test_template_export_foreach_invalid(self, yaml_parse_mock):
parent_dir = os.path.sep
template_dir = os.path.join(parent_dir, 'foo', 'bar')
template_path = os.path.join(template_dir, 'path')
template_str = self.example_yaml_template()

resource_type1_class = mock.Mock()
resource_type1_class.RESOURCE_TYPE = "resource_type1"
resource_type1_instance = mock.Mock()
resource_type1_class.return_value = resource_type1_instance
resource_type2_class = mock.Mock()
resource_type2_class.RESOURCE_TYPE = "resource_type2"
resource_type2_instance = mock.Mock()
resource_type2_class.return_value = resource_type2_instance

resources_to_export = [
resource_type1_class,
resource_type2_class
]

properties = {"foo": "bar"}
template_dict = {
"Resources": {
"Resource1": {
"Type": "resource_type1",
"Properties": properties
},
"Resource2": {
"Type": "resource_type2",
"Properties": properties
},
"Resource3": {
"Type": "some-other-type",
"Properties": properties
},
"Fn::ForEach::OuterLoopName": [
"Identifier1",
{
"Resource${Identifier1}": {
}
}
]
}
}

open_mock = mock.mock_open()
yaml_parse_mock.return_value = template_dict

# Patch the file open method to return template string
with mock.patch(
"awscli.customizations.cloudformation.artifact_exporter.open",
open_mock(read_data=template_str)) as open_mock:
template_exporter = Template(
template_path, parent_dir, self.s3_uploader_mock,
resources_to_export)
with self.assertRaises(exceptions.InvalidForEachIntrinsicFunctionError):
template_exporter.export()


@mock.patch("awscli.customizations.cloudformation.artifact_exporter.yaml_parse")
def test_template_global_export(self, yaml_parse_mock):
parent_dir = os.path.sep
Expand Down

0 comments on commit 2676a46

Please sign in to comment.