diff --git a/.changes/next-release/enhancement-cloudformationpackage-39279.json b/.changes/next-release/enhancement-cloudformationpackage-39279.json new file mode 100644 index 000000000000..329081a2ec00 --- /dev/null +++ b/.changes/next-release/enhancement-cloudformationpackage-39279.json @@ -0,0 +1,5 @@ +{ + "type": "enhancement", + "category": "``cloudformation package``", + "description": "Add support for intrinsic Fn:ForEach (fixes `#8075 `__)" +} diff --git a/awscli/customizations/cloudformation/artifact_exporter.py b/awscli/customizations/cloudformation/artifact_exporter.py index 9bb150660c02..64eb5a06e1a4 100644 --- a/awscli/customizations/cloudformation/artifact_exporter.py +++ b/awscli/customizations/cloudformation/artifact_exporter.py @@ -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) @@ -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 diff --git a/awscli/customizations/cloudformation/exceptions.py b/awscli/customizations/cloudformation/exceptions.py index a31cf25ea492..b2625cdd27f9 100644 --- a/awscli/customizations/cloudformation/exceptions.py +++ b/awscli/customizations/cloudformation/exceptions.py @@ -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' diff --git a/tests/unit/customizations/cloudformation/test_artifact_exporter.py b/tests/unit/customizations/cloudformation/test_artifact_exporter.py index 93df4297d660..1b071101cc7e 100644 --- a/tests/unit/customizations/cloudformation/test_artifact_exporter.py +++ b/tests/unit/customizations/cloudformation/test_artifact_exporter.py @@ -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