diff --git a/.changes/1.32.2.json b/.changes/1.32.2.json new file mode 100644 index 000000000000..58482956ed6b --- /dev/null +++ b/.changes/1.32.2.json @@ -0,0 +1,37 @@ +[ + { + "category": "``cloudformation package``", + "description": "Add support for intrinsic Fn:ForEach (fixes `#8075 `__)", + "type": "enhancement" + }, + { + "category": "``cloud9``", + "description": "Updated Cloud9 API documentation for AL2023 release", + "type": "api-change" + }, + { + "category": "``connect``", + "description": "Adds relatedContactId field to StartOutboundVoiceContact API input. Introduces PauseContact API and ResumeContact API for Task contacts. Adds pause duration, number of pauses, timestamps for last paused and resumed events to DescribeContact API response. Adds new Rule type and new Rule action.", + "type": "api-change" + }, + { + "category": "``connectcases``", + "description": "Increase number of fields that can be included in CaseEventIncludedData from 50 to 200", + "type": "api-change" + }, + { + "category": "``kms``", + "description": "Documentation updates for AWS Key Management Service", + "type": "api-change" + }, + { + "category": "``rds``", + "description": "Updates Amazon RDS documentation by adding code examples", + "type": "api-change" + }, + { + "category": "``sagemaker``", + "description": "This release 1) introduces a new API: DeleteCompilationJob , and 2) adds InfraCheckConfig for Create/Describe training job API", + "type": "api-change" + } +] \ No newline at end of file diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 544ef770d536..cce93bcca5c3 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +1.32.2 +====== + +* enhancement:``cloudformation package``: Add support for intrinsic Fn:ForEach (fixes `#8075 `__) +* api-change:``cloud9``: Updated Cloud9 API documentation for AL2023 release +* api-change:``connect``: Adds relatedContactId field to StartOutboundVoiceContact API input. Introduces PauseContact API and ResumeContact API for Task contacts. Adds pause duration, number of pauses, timestamps for last paused and resumed events to DescribeContact API response. Adds new Rule type and new Rule action. +* api-change:``connectcases``: Increase number of fields that can be included in CaseEventIncludedData from 50 to 200 +* api-change:``kms``: Documentation updates for AWS Key Management Service +* api-change:``rds``: Updates Amazon RDS documentation by adding code examples +* api-change:``sagemaker``: This release 1) introduces a new API: DeleteCompilationJob , and 2) adds InfraCheckConfig for Create/Describe training job API + + 1.32.1 ====== diff --git a/awscli/__init__.py b/awscli/__init__.py index 2fdbb4d35bec..cb0fec889763 100644 --- a/awscli/__init__.py +++ b/awscli/__init__.py @@ -17,7 +17,7 @@ """ import os -__version__ = '1.32.1' +__version__ = '1.32.2' # # Get our data path to be added to botocore's search path 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/doc/source/conf.py b/doc/source/conf.py index efa4c85d9bb3..4699b250c017 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -52,7 +52,7 @@ # The short X.Y version. version = '1.32' # The full version, including alpha/beta/rc tags. -release = '1.32.1' +release = '1.32.2' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/setup.cfg b/setup.cfg index 87427fab6a86..cad99ea0df1f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,7 +3,7 @@ universal = 0 [metadata] requires_dist = - botocore==1.34.1 + botocore==1.34.2 docutils>=0.10,<0.17 s3transfer>=0.9.0,<0.10.0 PyYAML>=3.10,<6.1 diff --git a/setup.py b/setup.py index 584e19786acc..b9158f354c60 100644 --- a/setup.py +++ b/setup.py @@ -24,7 +24,7 @@ def find_version(*file_paths): install_requires = [ - 'botocore==1.34.1', + 'botocore==1.34.2', 'docutils>=0.10,<0.17', 's3transfer>=0.9.0,<0.10.0', 'PyYAML>=3.10,<6.1', 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