Skip to content

Commit 2676a46

Browse files
Support Fn::ForEach intrinsic function (#8096)
Allow "aws cloudformation package" to succeed when a Resource is not a key-value pair.
1 parent 585db84 commit 2676a46

File tree

4 files changed

+176
-3
lines changed

4 files changed

+176
-3
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"type": "enhancement",
3+
"category": "``cloudformation package``",
4+
"description": "Add support for intrinsic Fn:ForEach (fixes `#8075 <https://github.com/aws/aws-cli/issues/8075>`__)"
5+
}

awscli/customizations/cloudformation/artifact_exporter.py

+12-3
Original file line numberDiff line numberDiff line change
@@ -659,7 +659,18 @@ def export(self):
659659

660660
self.template_dict = self.export_global_artifacts(self.template_dict)
661661

662-
for resource_id, resource in self.template_dict["Resources"].items():
662+
self.export_resources(self.template_dict["Resources"])
663+
664+
return self.template_dict
665+
666+
def export_resources(self, resource_dict):
667+
for resource_id, resource in resource_dict.items():
668+
669+
if resource_id.startswith("Fn::ForEach::"):
670+
if not isinstance(resource, list) or len(resource) != 3:
671+
raise exceptions.InvalidForEachIntrinsicFunctionError(resource_id=resource_id)
672+
self.export_resources(resource[2])
673+
continue
663674

664675
resource_type = resource.get("Type", None)
665676
resource_dict = resource.get("Properties", None)
@@ -671,5 +682,3 @@ def export(self):
671682
# Export code resources
672683
exporter = exporter_class(self.uploader)
673684
exporter.export(resource_id, resource_dict, self.template_dir)
674-
675-
return self.template_dict

awscli/customizations/cloudformation/exceptions.py

+4
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,7 @@ class DeployBucketRequiredError(CloudFormationCommandError):
5353
"via an S3 Bucket. Please add the --s3-bucket parameter to your "
5454
"command. The local template will be copied to that S3 bucket and "
5555
"then deployed.")
56+
57+
58+
class InvalidForEachIntrinsicFunctionError(CloudFormationCommandError):
59+
fmt = 'The value of {resource_id} has an invalid "Fn::ForEach::" format: Must be a list of three entries'

tests/unit/customizations/cloudformation/test_artifact_exporter.py

+155
Original file line numberDiff line numberDiff line change
@@ -1016,6 +1016,161 @@ def test_template_export(self, yaml_parse_mock):
10161016
resource_type2_instance.export.assert_called_once_with(
10171017
"Resource2", mock.ANY, template_dir)
10181018

1019+
@mock.patch("awscli.customizations.cloudformation.artifact_exporter.yaml_parse")
1020+
def test_template_export_foreach_valid(self, yaml_parse_mock):
1021+
parent_dir = os.path.sep
1022+
template_dir = os.path.join(parent_dir, 'foo', 'bar')
1023+
template_path = os.path.join(template_dir, 'path')
1024+
template_str = self.example_yaml_template()
1025+
1026+
resource_type1_class = mock.Mock()
1027+
resource_type1_class.RESOURCE_TYPE = "resource_type1"
1028+
resource_type1_instance = mock.Mock()
1029+
resource_type1_class.return_value = resource_type1_instance
1030+
resource_type2_class = mock.Mock()
1031+
resource_type2_class.RESOURCE_TYPE = "resource_type2"
1032+
resource_type2_instance = mock.Mock()
1033+
resource_type2_class.return_value = resource_type2_instance
1034+
1035+
resources_to_export = [
1036+
resource_type1_class,
1037+
resource_type2_class
1038+
]
1039+
1040+
properties = {"foo": "bar"}
1041+
template_dict = {
1042+
"Resources": {
1043+
"Resource1": {
1044+
"Type": "resource_type1",
1045+
"Properties": properties
1046+
},
1047+
"Resource2": {
1048+
"Type": "resource_type2",
1049+
"Properties": properties
1050+
},
1051+
"Resource3": {
1052+
"Type": "some-other-type",
1053+
"Properties": properties
1054+
},
1055+
"Fn::ForEach::OuterLoopName": [
1056+
"Identifier1",
1057+
["4", "5"],
1058+
{
1059+
"Fn::ForEach::InnerLoopName": [
1060+
"Identifier2",
1061+
["6", "7"],
1062+
{
1063+
"Resource${Identifier1}${Identifier2}": {
1064+
"Type": "resource_type2",
1065+
"Properties": properties
1066+
}
1067+
}
1068+
],
1069+
"Resource${Identifier1}": {
1070+
"Type": "resource_type1",
1071+
"Properties": properties
1072+
}
1073+
}
1074+
]
1075+
}
1076+
}
1077+
1078+
open_mock = mock.mock_open()
1079+
yaml_parse_mock.return_value = template_dict
1080+
1081+
# Patch the file open method to return template string
1082+
with mock.patch(
1083+
"awscli.customizations.cloudformation.artifact_exporter.open",
1084+
open_mock(read_data=template_str)) as open_mock:
1085+
1086+
template_exporter = Template(
1087+
template_path, parent_dir, self.s3_uploader_mock,
1088+
resources_to_export)
1089+
exported_template = template_exporter.export()
1090+
self.assertEqual(exported_template, template_dict)
1091+
1092+
open_mock.assert_called_once_with(
1093+
make_abs_path(parent_dir, template_path), "r")
1094+
1095+
self.assertEqual(1, yaml_parse_mock.call_count)
1096+
1097+
resource_type1_class.assert_called_with(self.s3_uploader_mock)
1098+
self.assertEqual(
1099+
resource_type1_instance.export.call_args_list,
1100+
[
1101+
mock.call("Resource1", properties, template_dir),
1102+
mock.call("Resource${Identifier1}", properties, template_dir)
1103+
]
1104+
)
1105+
resource_type2_class.assert_called_with(self.s3_uploader_mock)
1106+
self.assertEqual(
1107+
resource_type2_instance.export.call_args_list,
1108+
[
1109+
mock.call("Resource2", properties, template_dir),
1110+
mock.call("Resource${Identifier1}${Identifier2}", properties, template_dir)
1111+
]
1112+
)
1113+
1114+
@mock.patch("awscli.customizations.cloudformation.artifact_exporter.yaml_parse")
1115+
def test_template_export_foreach_invalid(self, yaml_parse_mock):
1116+
parent_dir = os.path.sep
1117+
template_dir = os.path.join(parent_dir, 'foo', 'bar')
1118+
template_path = os.path.join(template_dir, 'path')
1119+
template_str = self.example_yaml_template()
1120+
1121+
resource_type1_class = mock.Mock()
1122+
resource_type1_class.RESOURCE_TYPE = "resource_type1"
1123+
resource_type1_instance = mock.Mock()
1124+
resource_type1_class.return_value = resource_type1_instance
1125+
resource_type2_class = mock.Mock()
1126+
resource_type2_class.RESOURCE_TYPE = "resource_type2"
1127+
resource_type2_instance = mock.Mock()
1128+
resource_type2_class.return_value = resource_type2_instance
1129+
1130+
resources_to_export = [
1131+
resource_type1_class,
1132+
resource_type2_class
1133+
]
1134+
1135+
properties = {"foo": "bar"}
1136+
template_dict = {
1137+
"Resources": {
1138+
"Resource1": {
1139+
"Type": "resource_type1",
1140+
"Properties": properties
1141+
},
1142+
"Resource2": {
1143+
"Type": "resource_type2",
1144+
"Properties": properties
1145+
},
1146+
"Resource3": {
1147+
"Type": "some-other-type",
1148+
"Properties": properties
1149+
},
1150+
"Fn::ForEach::OuterLoopName": [
1151+
"Identifier1",
1152+
{
1153+
"Resource${Identifier1}": {
1154+
}
1155+
}
1156+
]
1157+
}
1158+
}
1159+
1160+
open_mock = mock.mock_open()
1161+
yaml_parse_mock.return_value = template_dict
1162+
1163+
# Patch the file open method to return template string
1164+
with mock.patch(
1165+
"awscli.customizations.cloudformation.artifact_exporter.open",
1166+
open_mock(read_data=template_str)) as open_mock:
1167+
template_exporter = Template(
1168+
template_path, parent_dir, self.s3_uploader_mock,
1169+
resources_to_export)
1170+
with self.assertRaises(exceptions.InvalidForEachIntrinsicFunctionError):
1171+
template_exporter.export()
1172+
1173+
10191174
@mock.patch("awscli.customizations.cloudformation.artifact_exporter.yaml_parse")
10201175
def test_template_global_export(self, yaml_parse_mock):
10211176
parent_dir = os.path.sep

0 commit comments

Comments
 (0)