summaryrefslogtreecommitdiffstats
path: root/ansible_collections/amazon/aws/tests/unit
diff options
context:
space:
mode:
Diffstat (limited to 'ansible_collections/amazon/aws/tests/unit')
-rw-r--r--ansible_collections/amazon/aws/tests/unit/compat/__init__.py0
-rw-r--r--ansible_collections/amazon/aws/tests/unit/compat/builtins.py33
-rw-r--r--ansible_collections/amazon/aws/tests/unit/compat/mock.py122
-rw-r--r--ansible_collections/amazon/aws/tests/unit/compat/unittest.py38
-rw-r--r--ansible_collections/amazon/aws/tests/unit/constraints.txt7
-rw-r--r--ansible_collections/amazon/aws/tests/unit/mock/loader.py116
-rw-r--r--ansible_collections/amazon/aws/tests/unit/mock/path.py8
-rw-r--r--ansible_collections/amazon/aws/tests/unit/mock/procenv.py90
-rw-r--r--ansible_collections/amazon/aws/tests/unit/mock/vault_helper.py39
-rw-r--r--ansible_collections/amazon/aws/tests/unit/mock/yaml_helper.py124
-rw-r--r--ansible_collections/amazon/aws/tests/unit/module_utils/arn/test_is_outpost_arn.py27
-rw-r--r--ansible_collections/amazon/aws/tests/unit/module_utils/arn/test_parse_aws_arn.py95
-rw-r--r--ansible_collections/amazon/aws/tests/unit/module_utils/botocore/test_is_boto3_error_code.py214
-rw-r--r--ansible_collections/amazon/aws/tests/unit/module_utils/botocore/test_is_boto3_error_message.py145
-rw-r--r--ansible_collections/amazon/aws/tests/unit/module_utils/botocore/test_normalize_boto3_result.py59
-rw-r--r--ansible_collections/amazon/aws/tests/unit/module_utils/cloud/test_backoff_iterator.py45
-rw-r--r--ansible_collections/amazon/aws/tests/unit/module_utils/cloud/test_cloud_retry.py236
-rw-r--r--ansible_collections/amazon/aws/tests/unit/module_utils/cloud/test_decorator_generation.py156
-rw-r--r--ansible_collections/amazon/aws/tests/unit/module_utils/cloud/test_retries_found.py34
-rw-r--r--ansible_collections/amazon/aws/tests/unit/module_utils/cloud/test_retry_func.py129
-rw-r--r--ansible_collections/amazon/aws/tests/unit/module_utils/conftest.py81
-rw-r--r--ansible_collections/amazon/aws/tests/unit/module_utils/elbv2/test_prune.py188
-rw-r--r--ansible_collections/amazon/aws/tests/unit/module_utils/modules/ansible_aws_module/test_fail_json_aws.py330
-rw-r--r--ansible_collections/amazon/aws/tests/unit/module_utils/modules/ansible_aws_module/test_minimal_versions.py191
-rw-r--r--ansible_collections/amazon/aws/tests/unit/module_utils/modules/ansible_aws_module/test_require_at_least.py220
-rw-r--r--ansible_collections/amazon/aws/tests/unit/module_utils/policy/test_compare_policies.py339
-rw-r--r--ansible_collections/amazon/aws/tests/unit/module_utils/retries/test_awsretry.py96
-rw-r--r--ansible_collections/amazon/aws/tests/unit/module_utils/test_elbv2.py214
-rw-r--r--ansible_collections/amazon/aws/tests/unit/module_utils/test_iam.py300
-rw-r--r--ansible_collections/amazon/aws/tests/unit/module_utils/test_rds.py805
-rw-r--r--ansible_collections/amazon/aws/tests/unit/module_utils/test_s3.py86
-rw-r--r--ansible_collections/amazon/aws/tests/unit/module_utils/test_tagging.py203
-rw-r--r--ansible_collections/amazon/aws/tests/unit/module_utils/test_tower.py40
-rw-r--r--ansible_collections/amazon/aws/tests/unit/module_utils/transformation/test_ansible_dict_to_boto3_filter_list.py73
-rw-r--r--ansible_collections/amazon/aws/tests/unit/module_utils/transformation/test_map_complex_type.py100
-rw-r--r--ansible_collections/amazon/aws/tests/unit/module_utils/transformation/test_scrub_none_parameters.py88
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/inventory/test_aws_ec2.py514
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/conftest.py31
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/ec2_instance/test_build_run_instance_spec.py126
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/ec2_instance/test_determine_iam_role.py102
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/a.pem31
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/b.pem47
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/chain-1.0.cert121
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/chain-1.1.cert69
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/chain-1.2.cert113
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/chain-1.3.cert124
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/chain-1.4.cert86
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/chain-4.cert121
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/simple-chain-a.cert18
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/simple-chain-b.cert18
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/thezip.zipbin0 -> 162 bytes
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/.gitkeep0
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.CreateStack_1.json17
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DeleteStack_1.json16
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStackEvents_1.json38
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStackEvents_2.json80
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStackEvents_3.json80
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStackEvents_4.json80
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStackEvents_5.json80
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStackEvents_6.json100
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStackEvents_7.json119
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStacks_1.json40
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStacks_2.json39
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStacks_3.json39
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStacks_4.json39
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStacks_5.json39
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStacks_6.json39
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStacks_7.json45
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.CreateStack_1.json17
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DeleteStack_1.json16
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStackEvents_1.json39
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStackEvents_2.json83
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStackEvents_3.json83
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStackEvents_4.json83
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStackEvents_5.json83
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStackEvents_6.json104
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStackEvents_7.json124
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStacks_1.json40
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStacks_2.json39
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStacks_3.json39
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStacks_4.json39
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStacks_5.json39
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStacks_6.json39
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStacks_7.json45
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/delete_nonexistent_stack/cloudformation.DescribeStackEvents_1.json22
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/delete_nonexistent_stack/cloudformation.DescribeStackEvents_2.json22
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/delete_nonexistent_stack/cloudformation.DescribeStacks_1.json22
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/get_nonexistent_stack/cloudformation.DescribeStacks_1.json22
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/invalid_template_json/cloudformation.CreateStack_1.json22
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.CreateStack_1.json17
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStackEvents_1.json38
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStackEvents_2.json101
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStackEvents_3.json121
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStackEvents_4.json180
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStackEvents_5.json180
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStacks_1.json42
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStacks_2.json41
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStacks_3.json52
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStacks_4.json51
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStacks_5.json50
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_do_nothing/cloudformation.CreateStack_1.json17
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_do_nothing/cloudformation.DeleteStack_1.json16
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_do_nothing/cloudformation.DescribeStackEvents_1.json38
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_do_nothing/cloudformation.DescribeStackEvents_2.json121
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_do_nothing/cloudformation.DescribeStacks_1.json42
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_do_nothing/cloudformation.DescribeStacks_2.json42
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_rollback/cloudformation.CreateStack_1.json17
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_rollback/cloudformation.DeleteStack_1.json16
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_rollback/cloudformation.DescribeStackEvents_1.json38
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_rollback/cloudformation.DescribeStackEvents_2.json121
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_rollback/cloudformation.DescribeStackEvents_3.json180
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_rollback/cloudformation.DescribeStacks_1.json42
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_rollback/cloudformation.DescribeStacks_2.json52
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_rollback/cloudformation.DescribeStacks_3.json51
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/test_cloudformation.py227
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/test_ec2_ami.py44
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/test_ec2_key.py654
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/test_ec2_security_group.py83
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/test_ec2_vpc_dhcp_option.py71
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/test_kms_key.py82
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/test_lambda_layer.py493
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/test_lambda_layer_info.py358
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/test_s3_object.py29
-rw-r--r--ansible_collections/amazon/aws/tests/unit/plugins/modules/utils.py50
-rw-r--r--ansible_collections/amazon/aws/tests/unit/requirements.txt5
-rw-r--r--ansible_collections/amazon/aws/tests/unit/utils/amazon_placebo_fixtures.py213
126 files changed, 12479 insertions, 0 deletions
diff --git a/ansible_collections/amazon/aws/tests/unit/compat/__init__.py b/ansible_collections/amazon/aws/tests/unit/compat/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/compat/__init__.py
diff --git a/ansible_collections/amazon/aws/tests/unit/compat/builtins.py b/ansible_collections/amazon/aws/tests/unit/compat/builtins.py
new file mode 100644
index 000000000..349d310e8
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/compat/builtins.py
@@ -0,0 +1,33 @@
+# (c) 2014, Toshio Kuratomi <tkuratomi@ansible.com>
+#
+# This file is part of Ansible
+#
+# Ansible is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Ansible is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
+
+# Make coding more python3-ish
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+#
+# Compat for python2.7
+#
+
+# One unittest needs to import builtins via __import__() so we need to have
+# the string that represents it
+try:
+ import __builtin__ # pylint: disable=unused-import
+except ImportError:
+ BUILTINS = 'builtins'
+else:
+ BUILTINS = '__builtin__'
diff --git a/ansible_collections/amazon/aws/tests/unit/compat/mock.py b/ansible_collections/amazon/aws/tests/unit/compat/mock.py
new file mode 100644
index 000000000..0972cd2e8
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/compat/mock.py
@@ -0,0 +1,122 @@
+# (c) 2014, Toshio Kuratomi <tkuratomi@ansible.com>
+#
+# This file is part of Ansible
+#
+# Ansible is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Ansible is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
+
+# Make coding more python3-ish
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+'''
+Compat module for Python3.x's unittest.mock module
+'''
+import sys
+
+# Python 2.7
+
+# Note: Could use the pypi mock library on python3.x as well as python2.x. It
+# is the same as the python3 stdlib mock library
+
+try:
+ # Allow wildcard import because we really do want to import all of mock's
+ # symbols into this compat shim
+ # pylint: disable=wildcard-import,unused-wildcard-import
+ from unittest.mock import *
+except ImportError:
+ # Python 2
+ # pylint: disable=wildcard-import,unused-wildcard-import
+ try:
+ from mock import *
+ except ImportError:
+ print('You need the mock library installed on python2.x to run tests')
+
+
+# Prior to 3.4.4, mock_open cannot handle binary read_data
+if sys.version_info >= (3,) and sys.version_info < (3, 4, 4):
+ file_spec = None
+
+ def _iterate_read_data(read_data):
+ # Helper for mock_open:
+ # Retrieve lines from read_data via a generator so that separate calls to
+ # readline, read, and readlines are properly interleaved
+ sep = b'\n' if isinstance(read_data, bytes) else '\n'
+ data_as_list = [l + sep for l in read_data.split(sep)]
+
+ if data_as_list[-1] == sep:
+ # If the last line ended in a newline, the list comprehension will have an
+ # extra entry that's just a newline. Remove this.
+ data_as_list = data_as_list[:-1]
+ else:
+ # If there wasn't an extra newline by itself, then the file being
+ # emulated doesn't have a newline to end the last line remove the
+ # newline that our naive format() added
+ data_as_list[-1] = data_as_list[-1][:-1]
+
+ for line in data_as_list:
+ yield line
+
+ def mock_open(mock=None, read_data=''):
+ """
+ A helper function to create a mock to replace the use of `open`. It works
+ for `open` called directly or used as a context manager.
+
+ The `mock` argument is the mock object to configure. If `None` (the
+ default) then a `MagicMock` will be created for you, with the API limited
+ to methods or attributes available on standard file handles.
+
+ `read_data` is a string for the `read` methoddline`, and `readlines` of the
+ file handle to return. This is an empty string by default.
+ """
+ def _readlines_side_effect(*args, **kwargs):
+ if handle.readlines.return_value is not None:
+ return handle.readlines.return_value
+ return list(_data)
+
+ def _read_side_effect(*args, **kwargs):
+ if handle.read.return_value is not None:
+ return handle.read.return_value
+ return type(read_data)().join(_data)
+
+ def _readline_side_effect():
+ if handle.readline.return_value is not None:
+ while True:
+ yield handle.readline.return_value
+ for line in _data:
+ yield line
+
+ global file_spec
+ if file_spec is None:
+ import _io
+ file_spec = list(set(dir(_io.TextIOWrapper)).union(set(dir(_io.BytesIO))))
+
+ if mock is None:
+ mock = MagicMock(name='open', spec=open)
+
+ handle = MagicMock(spec=file_spec)
+ handle.__enter__.return_value = handle
+
+ _data = _iterate_read_data(read_data)
+
+ handle.write.return_value = None
+ handle.read.return_value = None
+ handle.readline.return_value = None
+ handle.readlines.return_value = None
+
+ handle.read.side_effect = _read_side_effect
+ handle.readline.side_effect = _readline_side_effect()
+ handle.readlines.side_effect = _readlines_side_effect
+
+ mock.return_value = handle
+ return mock
diff --git a/ansible_collections/amazon/aws/tests/unit/compat/unittest.py b/ansible_collections/amazon/aws/tests/unit/compat/unittest.py
new file mode 100644
index 000000000..98f08ad6a
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/compat/unittest.py
@@ -0,0 +1,38 @@
+# (c) 2014, Toshio Kuratomi <tkuratomi@ansible.com>
+#
+# This file is part of Ansible
+#
+# Ansible is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Ansible is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
+
+# Make coding more python3-ish
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+'''
+Compat module for Python2.7's unittest module
+'''
+
+import sys
+
+# Allow wildcard import because we really do want to import all of
+# unittests's symbols into this compat shim
+# pylint: disable=wildcard-import,unused-wildcard-import
+if sys.version_info < (2, 7):
+ try:
+ # Need unittest2 on python2.6
+ from unittest2 import *
+ except ImportError:
+ print('You need unittest2 installed on python2.6.x to run tests')
+else:
+ from unittest import *
diff --git a/ansible_collections/amazon/aws/tests/unit/constraints.txt b/ansible_collections/amazon/aws/tests/unit/constraints.txt
new file mode 100644
index 000000000..cd546e7c2
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/constraints.txt
@@ -0,0 +1,7 @@
+# Specifically run tests against the oldest versions that we support
+boto3==1.18.0
+botocore==1.21.0
+
+# AWS CLI has `botocore==` dependencies, provide the one that matches botocore
+# to avoid needing to download over a years worth of awscli wheels.
+awscli==1.20.0
diff --git a/ansible_collections/amazon/aws/tests/unit/mock/loader.py b/ansible_collections/amazon/aws/tests/unit/mock/loader.py
new file mode 100644
index 000000000..00a584127
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/mock/loader.py
@@ -0,0 +1,116 @@
+# (c) 2012-2014, Michael DeHaan <michael.dehaan@gmail.com>
+#
+# This file is part of Ansible
+#
+# Ansible is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Ansible is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
+
+# Make coding more python3-ish
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import os
+
+from ansible.errors import AnsibleParserError
+from ansible.parsing.dataloader import DataLoader
+from ansible.module_utils._text import to_bytes, to_text
+
+
+class DictDataLoader(DataLoader):
+
+ def __init__(self, file_mapping=None):
+ file_mapping = {} if file_mapping is None else file_mapping
+ assert type(file_mapping) == dict
+
+ super(DictDataLoader, self).__init__()
+
+ self._file_mapping = file_mapping
+ self._build_known_directories()
+ self._vault_secrets = None
+
+ def load_from_file(self, path, cache=True, unsafe=False):
+ path = to_text(path)
+ if path in self._file_mapping:
+ return self.load(self._file_mapping[path], path)
+ return None
+
+ # TODO: the real _get_file_contents returns a bytestring, so we actually convert the
+ # unicode/text it's created with to utf-8
+ def _get_file_contents(self, file_name):
+ file_name = to_text(file_name)
+ if file_name in self._file_mapping:
+ return (to_bytes(self._file_mapping[file_name]), False)
+ else:
+ raise AnsibleParserError("file not found: %s" % file_name)
+
+ def path_exists(self, path):
+ path = to_text(path)
+ return path in self._file_mapping or path in self._known_directories
+
+ def is_file(self, path):
+ path = to_text(path)
+ return path in self._file_mapping
+
+ def is_directory(self, path):
+ path = to_text(path)
+ return path in self._known_directories
+
+ def list_directory(self, path):
+ ret = []
+ path = to_text(path)
+ for x in (list(self._file_mapping.keys()) + self._known_directories):
+ if x.startswith(path):
+ if os.path.dirname(x) == path:
+ ret.append(os.path.basename(x))
+ return ret
+
+ def is_executable(self, path):
+ # FIXME: figure out a way to make paths return true for this
+ return False
+
+ def _add_known_directory(self, directory):
+ if directory not in self._known_directories:
+ self._known_directories.append(directory)
+
+ def _build_known_directories(self):
+ self._known_directories = []
+ for path in self._file_mapping:
+ dirname = os.path.dirname(path)
+ while dirname not in ('/', ''):
+ self._add_known_directory(dirname)
+ dirname = os.path.dirname(dirname)
+
+ def push(self, path, content):
+ rebuild_dirs = False
+ if path not in self._file_mapping:
+ rebuild_dirs = True
+
+ self._file_mapping[path] = content
+
+ if rebuild_dirs:
+ self._build_known_directories()
+
+ def pop(self, path):
+ if path in self._file_mapping:
+ del self._file_mapping[path]
+ self._build_known_directories()
+
+ def clear(self):
+ self._file_mapping = dict()
+ self._known_directories = []
+
+ def get_basedir(self):
+ return os.getcwd()
+
+ def set_vault_secrets(self, vault_secrets):
+ self._vault_secrets = vault_secrets
diff --git a/ansible_collections/amazon/aws/tests/unit/mock/path.py b/ansible_collections/amazon/aws/tests/unit/mock/path.py
new file mode 100644
index 000000000..8de2aec25
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/mock/path.py
@@ -0,0 +1,8 @@
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+from ansible_collections.amazon.aws.tests.unit.compat.mock import MagicMock
+from ansible.utils.path import unfrackpath
+
+
+mock_unfrackpath_noop = MagicMock(spec_set=unfrackpath, side_effect=lambda x, *args, **kwargs: x)
diff --git a/ansible_collections/amazon/aws/tests/unit/mock/procenv.py b/ansible_collections/amazon/aws/tests/unit/mock/procenv.py
new file mode 100644
index 000000000..273959e4b
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/mock/procenv.py
@@ -0,0 +1,90 @@
+# (c) 2016, Matt Davis <mdavis@ansible.com>
+# (c) 2016, Toshio Kuratomi <tkuratomi@ansible.com>
+#
+# This file is part of Ansible
+#
+# Ansible is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Ansible is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
+
+# Make coding more python3-ish
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import sys
+import json
+
+from contextlib import contextmanager
+from io import BytesIO, StringIO
+from ansible_collections.amazon.aws.tests.unit.compat import unittest
+from ansible.module_utils.six import PY3
+from ansible.module_utils._text import to_bytes
+
+
+@contextmanager
+def swap_stdin_and_argv(stdin_data='', argv_data=tuple()):
+ """
+ context manager that temporarily masks the test runner's values for stdin and argv
+ """
+ real_stdin = sys.stdin
+ real_argv = sys.argv
+
+ if PY3:
+ fake_stream = StringIO(stdin_data)
+ fake_stream.buffer = BytesIO(to_bytes(stdin_data))
+ else:
+ fake_stream = BytesIO(to_bytes(stdin_data))
+
+ try:
+ sys.stdin = fake_stream
+ sys.argv = argv_data
+
+ yield
+ finally:
+ sys.stdin = real_stdin
+ sys.argv = real_argv
+
+
+@contextmanager
+def swap_stdout():
+ """
+ context manager that temporarily replaces stdout for tests that need to verify output
+ """
+ old_stdout = sys.stdout
+
+ if PY3:
+ fake_stream = StringIO()
+ else:
+ fake_stream = BytesIO()
+
+ try:
+ sys.stdout = fake_stream
+
+ yield fake_stream
+ finally:
+ sys.stdout = old_stdout
+
+
+class ModuleTestCase(unittest.TestCase):
+ def setUp(self, module_args=None):
+ if module_args is None:
+ module_args = {'_ansible_remote_tmp': '/tmp', '_ansible_keep_remote_files': False}
+
+ args = json.dumps(dict(ANSIBLE_MODULE_ARGS=module_args))
+
+ # unittest doesn't have a clean place to use a context manager, so we have to enter/exit manually
+ self.stdin_swap = swap_stdin_and_argv(stdin_data=args)
+ self.stdin_swap.__enter__()
+
+ def tearDown(self):
+ # unittest doesn't have a clean place to use a context manager, so we have to enter/exit manually
+ self.stdin_swap.__exit__(None, None, None)
diff --git a/ansible_collections/amazon/aws/tests/unit/mock/vault_helper.py b/ansible_collections/amazon/aws/tests/unit/mock/vault_helper.py
new file mode 100644
index 000000000..dcce9c784
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/mock/vault_helper.py
@@ -0,0 +1,39 @@
+# Ansible is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Ansible is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
+
+# Make coding more python3-ish
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+from ansible.module_utils._text import to_bytes
+
+from ansible.parsing.vault import VaultSecret
+
+
+class TextVaultSecret(VaultSecret):
+ '''A secret piece of text. ie, a password. Tracks text encoding.
+
+ The text encoding of the text may not be the default text encoding so
+ we keep track of the encoding so we encode it to the same bytes.'''
+
+ def __init__(self, text, encoding=None, errors=None, _bytes=None):
+ super(TextVaultSecret, self).__init__()
+ self.text = text
+ self.encoding = encoding or 'utf-8'
+ self._bytes = _bytes
+ self.errors = errors or 'strict'
+
+ @property
+ def bytes(self):
+ '''The text encoded with encoding, unless we specifically set _bytes.'''
+ return self._bytes or to_bytes(self.text, encoding=self.encoding, errors=self.errors)
diff --git a/ansible_collections/amazon/aws/tests/unit/mock/yaml_helper.py b/ansible_collections/amazon/aws/tests/unit/mock/yaml_helper.py
new file mode 100644
index 000000000..1ef172159
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/mock/yaml_helper.py
@@ -0,0 +1,124 @@
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import io
+import yaml
+
+from ansible.module_utils.six import PY3
+from ansible.parsing.yaml.loader import AnsibleLoader
+from ansible.parsing.yaml.dumper import AnsibleDumper
+
+
+class YamlTestUtils(object):
+ """Mixin class to combine with a unittest.TestCase subclass."""
+ def _loader(self, stream):
+ """Vault related tests will want to override this.
+
+ Vault cases should setup a AnsibleLoader that has the vault password."""
+ return AnsibleLoader(stream)
+
+ def _dump_stream(self, obj, stream, dumper=None):
+ """Dump to a py2-unicode or py3-string stream."""
+ if PY3:
+ return yaml.dump(obj, stream, Dumper=dumper)
+ else:
+ return yaml.dump(obj, stream, Dumper=dumper, encoding=None)
+
+ def _dump_string(self, obj, dumper=None):
+ """Dump to a py2-unicode or py3-string"""
+ if PY3:
+ return yaml.dump(obj, Dumper=dumper)
+ else:
+ return yaml.dump(obj, Dumper=dumper, encoding=None)
+
+ def _dump_load_cycle(self, obj):
+ # Each pass though a dump or load revs the 'generation'
+ # obj to yaml string
+ string_from_object_dump = self._dump_string(obj, dumper=AnsibleDumper)
+
+ # wrap a stream/file like StringIO around that yaml
+ stream_from_object_dump = io.StringIO(string_from_object_dump)
+ loader = self._loader(stream_from_object_dump)
+ # load the yaml stream to create a new instance of the object (gen 2)
+ obj_2 = loader.get_data()
+
+ # dump the gen 2 objects directory to strings
+ string_from_object_dump_2 = self._dump_string(obj_2,
+ dumper=AnsibleDumper)
+
+ # The gen 1 and gen 2 yaml strings
+ self.assertEqual(string_from_object_dump, string_from_object_dump_2)
+ # the gen 1 (orig) and gen 2 py object
+ self.assertEqual(obj, obj_2)
+
+ # again! gen 3... load strings into py objects
+ stream_3 = io.StringIO(string_from_object_dump_2)
+ loader_3 = self._loader(stream_3)
+ obj_3 = loader_3.get_data()
+
+ string_from_object_dump_3 = self._dump_string(obj_3, dumper=AnsibleDumper)
+
+ self.assertEqual(obj, obj_3)
+ # should be transitive, but...
+ self.assertEqual(obj_2, obj_3)
+ self.assertEqual(string_from_object_dump, string_from_object_dump_3)
+
+ def _old_dump_load_cycle(self, obj):
+ '''Dump the passed in object to yaml, load it back up, dump again, compare.'''
+ stream = io.StringIO()
+
+ yaml_string = self._dump_string(obj, dumper=AnsibleDumper)
+ self._dump_stream(obj, stream, dumper=AnsibleDumper)
+
+ yaml_string_from_stream = stream.getvalue()
+
+ # reset stream
+ stream.seek(0)
+
+ loader = self._loader(stream)
+ # loader = AnsibleLoader(stream, vault_password=self.vault_password)
+ obj_from_stream = loader.get_data()
+
+ stream_from_string = io.StringIO(yaml_string)
+ loader2 = self._loader(stream_from_string)
+ # loader2 = AnsibleLoader(stream_from_string, vault_password=self.vault_password)
+ obj_from_string = loader2.get_data()
+
+ stream_obj_from_stream = io.StringIO()
+ stream_obj_from_string = io.StringIO()
+
+ if PY3:
+ yaml.dump(obj_from_stream, stream_obj_from_stream, Dumper=AnsibleDumper)
+ yaml.dump(obj_from_stream, stream_obj_from_string, Dumper=AnsibleDumper)
+ else:
+ yaml.dump(obj_from_stream, stream_obj_from_stream, Dumper=AnsibleDumper, encoding=None)
+ yaml.dump(obj_from_stream, stream_obj_from_string, Dumper=AnsibleDumper, encoding=None)
+
+ yaml_string_stream_obj_from_stream = stream_obj_from_stream.getvalue()
+ yaml_string_stream_obj_from_string = stream_obj_from_string.getvalue()
+
+ stream_obj_from_stream.seek(0)
+ stream_obj_from_string.seek(0)
+
+ if PY3:
+ yaml_string_obj_from_stream = yaml.dump(obj_from_stream, Dumper=AnsibleDumper)
+ yaml_string_obj_from_string = yaml.dump(obj_from_string, Dumper=AnsibleDumper)
+ else:
+ yaml_string_obj_from_stream = yaml.dump(obj_from_stream, Dumper=AnsibleDumper, encoding=None)
+ yaml_string_obj_from_string = yaml.dump(obj_from_string, Dumper=AnsibleDumper, encoding=None)
+
+ assert yaml_string == yaml_string_obj_from_stream
+ assert yaml_string == yaml_string_obj_from_stream == yaml_string_obj_from_string
+ assert (yaml_string == yaml_string_obj_from_stream == yaml_string_obj_from_string == yaml_string_stream_obj_from_stream ==
+ yaml_string_stream_obj_from_string)
+ assert obj == obj_from_stream
+ assert obj == obj_from_string
+ assert obj == yaml_string_obj_from_stream
+ assert obj == yaml_string_obj_from_string
+ assert obj == obj_from_stream == obj_from_string == yaml_string_obj_from_stream == yaml_string_obj_from_string
+ return {'obj': obj,
+ 'yaml_string': yaml_string,
+ 'yaml_string_from_stream': yaml_string_from_stream,
+ 'obj_from_stream': obj_from_stream,
+ 'obj_from_string': obj_from_string,
+ 'yaml_string_obj_from_string': yaml_string_obj_from_string}
diff --git a/ansible_collections/amazon/aws/tests/unit/module_utils/arn/test_is_outpost_arn.py b/ansible_collections/amazon/aws/tests/unit/module_utils/arn/test_is_outpost_arn.py
new file mode 100644
index 000000000..7c2e21eb2
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/module_utils/arn/test_is_outpost_arn.py
@@ -0,0 +1,27 @@
+# (c) 2022 Red Hat Inc.
+#
+# This file is part of Ansible
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import pytest
+
+from ansible_collections.amazon.aws.plugins.module_utils.arn import is_outpost_arn
+
+outpost_arn_test_inputs = [
+ ("arn:aws:outposts:us-east-1:123456789012:outpost/op-1234567890abcdef0", True),
+ ("arn:aws:outposts:us-east-1:123456789012:outpost/op-1234567890abcdef0123", False),
+ ("arn:aws:outpost:us-east-1:123456789012:outpost/op-1234567890abcdef0", False),
+ ("ars:aws:outposts:us-east-1:123456789012:outpost/op-1234567890abcdef0", False),
+ ("arn:was:outposts:us-east-1:123456789012:outpost/ op-1234567890abcdef0", False),
+ ("arn:aws:outpost:us-east-1: 123456789012:outpost/ op-1234567890abcdef0", False),
+ ("ars:aws:outposts:us-east-1: 123456789012:outpost/ op-1234567890abcdef0", False),
+ ("arn:was:outposts:us-east-1: 123456789012:outpost/ op-1234567890abcdef0", False),
+]
+
+
+@pytest.mark.parametrize("outpost_arn, result", outpost_arn_test_inputs)
+def test_is_outpost_arn(outpost_arn, result):
+ assert is_outpost_arn(outpost_arn) == result
diff --git a/ansible_collections/amazon/aws/tests/unit/module_utils/arn/test_parse_aws_arn.py b/ansible_collections/amazon/aws/tests/unit/module_utils/arn/test_parse_aws_arn.py
new file mode 100644
index 000000000..87dada4a9
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/module_utils/arn/test_parse_aws_arn.py
@@ -0,0 +1,95 @@
+# (c) 2022 Red Hat Inc.
+#
+# This file is part of Ansible
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import pytest
+
+from ansible_collections.amazon.aws.plugins.module_utils.arn import parse_aws_arn
+
+arn_bad_values = [
+ ("arn:aws:outpost:us-east-1: 123456789012:outpost/op-1234567890abcdef0"),
+ ("arn:aws:out post:us-east-1:123456789012:outpost/op-1234567890abcdef0"),
+ ("arn:aws:outpost:us east 1:123456789012:outpost/op-1234567890abcdef0"),
+ ("invalid:aws:outpost:us-east-1:123456789012:outpost/op-1234567890abcdef0"),
+ ("arn:junk:outpost:us-east-1:123456789012:outpost/op-1234567890abcdef0"),
+ ("arn:aws:outpost:us-east-1:junk:outpost/op-1234567890abcdef0"),
+]
+
+arn_good_values = [
+ # Play about with partition name in valid ways
+ dict(partition='aws', service='outpost', region='us-east-1', account_id='123456789012',
+ resource='outpost/op-1234567890abcdef0'),
+ dict(partition='aws-gov', service='outpost', region='us-gov-east-1', account_id='123456789012',
+ resource='outpost/op-1234567890abcdef0'),
+ dict(partition='aws-cn', service='outpost', region='us-east-1', account_id='123456789012',
+ resource='outpost/op-1234567890abcdef0'),
+ # Start the account ID with 0s, it's a 12 digit *string*, if someone treats
+ # it as an integer the leading 0s can disappear.
+ dict(partition='aws-cn', service='outpost', region='us-east-1', account_id='000123000123',
+ resource='outpost/op-1234567890abcdef0'),
+ # S3 doesn't "need" region/account_id as bucket names are globally unique
+ dict(partition='aws', service='s3', region='', account_id='', resource='bucket/object'),
+ # IAM is a 'global' service, so the ARNs don't have regions
+ dict(partition='aws', service='iam', region='', account_id='123456789012',
+ resource='policy/foo/bar/PolicyName'),
+ dict(partition='aws', service='iam', region='', account_id='123456789012',
+ resource='instance-profile/ExampleProfile'),
+ dict(partition='aws', service='iam', region='', account_id='123456789012', resource='root'),
+ # Some examples with different regions
+ dict(partition='aws', service='sqs', region='eu-west-3', account_id='123456789012',
+ resource='example-queue'),
+ dict(partition='aws', service='sqs', region='us-gov-east-1', account_id='123456789012',
+ resource='example-queue'),
+ dict(partition='aws', service='sqs', region='sa-east-1', account_id='123456789012',
+ resource='example-queue'),
+ dict(partition='aws', service='sqs', region='ap-northeast-2', account_id='123456789012',
+ resource='example-queue'),
+ dict(partition='aws', service='sqs', region='ca-central-1', account_id='123456789012',
+ resource='example-queue'),
+ # Some more unusual service names
+ dict(partition='aws', service='network-firewall', region='us-east-1', account_id='123456789012',
+ resource='stateful-rulegroup/ExampleDomainList'),
+ dict(partition='aws', service='resource-groups', region='us-east-1', account_id='123456789012',
+ resource='group/group-name'),
+ # A special case for resources AWS curate
+ dict(partition='aws', service='network-firewall', region='us-east-1', account_id='aws-managed',
+ resource='stateful-rulegroup/BotNetCommandAndControlDomainsActionOrder'),
+ dict(partition='aws', service='iam', region='', account_id='aws',
+ resource='policy/AWSDirectConnectReadOnlyAccess'),
+ # Examples merged in from test_arn.py
+ dict(partition="aws-us-gov", service="iam", region="", account_id="0123456789",
+ resource="role/foo-role"),
+ dict(partition="aws", service='iam', region="", account_id="123456789012",
+ resource="user/dev/*"),
+ dict(partition="aws", service="iam", region="", account_id="123456789012",
+ resource="user:test"),
+ dict(partition="aws-cn", service="iam", region="", account_id="123456789012",
+ resource="user:test"),
+ dict(partition="aws", service="iam", region="", account_id="123456789012",
+ resource="user"),
+ dict(partition="aws", service="s3", region="", account_id="",
+ resource="my_corporate_bucket/*"),
+ dict(partition="aws", service="s3", region="", account_id="",
+ resource="my_corporate_bucket/Development/*"),
+ dict(partition="aws", service="rds", region="es-east-1", account_id="000000000000",
+ resource="snapshot:rds:my-db-snapshot"),
+ dict(partition="aws", service="cloudformation", region="us-east-1", account_id="012345678901",
+ resource="changeSet/Ansible-StackName-c6884247ede41eb0"),
+]
+
+
+@pytest.mark.parametrize("arn", arn_bad_values)
+def test_parse_aws_arn_bad_values(arn):
+ # Make sure we get the expected 'None' for various 'bad' ARNs.
+ assert parse_aws_arn(arn) is None
+
+
+@pytest.mark.parametrize("result", arn_good_values)
+def test_parse_aws_arn_good_values(result):
+ # Something of a cheat, but build the ARN from the result we expect
+ arn = 'arn:{partition}:{service}:{region}:{account_id}:{resource}'.format(**result)
+ assert parse_aws_arn(arn) == result
diff --git a/ansible_collections/amazon/aws/tests/unit/module_utils/botocore/test_is_boto3_error_code.py b/ansible_collections/amazon/aws/tests/unit/module_utils/botocore/test_is_boto3_error_code.py
new file mode 100644
index 000000000..627ae4cb3
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/module_utils/botocore/test_is_boto3_error_code.py
@@ -0,0 +1,214 @@
+# -*- coding: utf-8 -*-
+# (c) 2020 Red Hat Inc.
+#
+# This file is part of Ansible
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import pytest
+
+try:
+ import botocore
+except ImportError:
+ # Handled by HAS_BOTO3
+ pass
+
+from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code
+from ansible_collections.amazon.aws.plugins.module_utils.botocore import HAS_BOTO3
+
+if not HAS_BOTO3:
+ pytestmark = pytest.mark.skip("test_is_boto3_error_code.py requires the python modules 'boto3' and 'botocore'")
+
+
+class TestIsBoto3ErrorCode():
+
+ def _make_denied_exception(self):
+ return botocore.exceptions.ClientError(
+ {
+ "Error": {
+ "Code": "AccessDenied",
+ "Message": "User: arn:aws:iam::123456789012:user/ExampleUser "
+ + "is not authorized to perform: iam:GetUser on resource: user ExampleUser"
+ },
+ "ResponseMetadata": {
+ "RequestId": "01234567-89ab-cdef-0123-456789abcdef"
+ }
+ }, 'getUser')
+
+ def _make_unexpected_exception(self):
+ return botocore.exceptions.ClientError(
+ {
+ "Error": {
+ "Code": "SomeThingWentWrong",
+ "Message": "Boom!"
+ },
+ "ResponseMetadata": {
+ "RequestId": "01234567-89ab-cdef-0123-456789abcdef"
+ }
+ }, 'someCall')
+
+ def _make_encoded_exception(self):
+ return botocore.exceptions.ClientError(
+ {
+ "Error": {
+ "Code": "PermissionDenied",
+ "Message": "You are not authorized to perform this operation. Encoded authorization failure message: " +
+ "fEwXX6llx3cClm9J4pURgz1XPnJPrYexEbrJcLhFkwygMdOgx_-aEsj0LqRM6Kxt2HVI6prUhDwbJqBo9U2V7iRKZ" +
+ "T6ZdJvHH02cXmD0Jwl5vrTsf0PhBcWYlH5wl2qME7xTfdolEUr4CzumCiti7ETiO-RDdHqWlasBOW5bWsZ4GSpPdU" +
+ "06YAX0TfwVBs48uU5RpCHfz1uhSzez-3elbtp9CmTOHLt5pzJodiovccO55BQKYLPtmJcs6S9YLEEogmpI4Cb1D26" +
+ "fYahDh51jEmaohPnW5pb1nQe2yPEtuIhtRzNjhFCOOMwY5DBzNsymK-Gj6eJLm7FSGHee4AHLU_XmZMe_6bcLAiOx" +
+ "6Zdl65Kdd0hLcpwVxyZMi27HnYjAdqRlV3wuCW2PkhAW14qZQLfiuHZDEwnPe2PBGSlFcCmkQvJvX-YLoA7Uyc2wf" +
+ "NX5RJm38STwfiJSkQaNDhHKTWKiLOsgY4Gze6uZoG7zOcFXFRyaA4cbMmI76uyBO7j-9uQUCtBYqYto8x_9CUJcxI" +
+ "VC5SPG_C1mk-WoDMew01f0qy-bNaCgmJ9TOQGd08FyuT1SaMpCC0gX6mHuOnEgkFw3veBIowMpp9XcM-yc42fmIOp" +
+ "FOdvQO6uE9p55Qc-uXvsDTTvT3A7EeFU8a_YoAIt9UgNYM6VTvoprLz7dBI_P6C-bdPPZCY2amm-dJNVZelT6TbJB" +
+ "H_Vxh0fzeiSUBersy_QzB0moc-vPWgnB-IkgnYLV-4L3K0L2"
+ },
+ "ResponseMetadata": {
+ "RequestId": "01234567-89ab-cdef-0123-456789abcdef"
+ }
+ }, 'someCall')
+
+ def _make_botocore_exception(self):
+ return botocore.exceptions.EndpointConnectionError(endpoint_url='junk.endpoint')
+
+ ###
+ # Test that is_boto3_error_code does what's expected when used in a try/except block
+ # (where we don't explicitly pass an exception to the function)
+ ###
+
+ def _do_try_code(self, exception, codes):
+ try:
+ raise exception
+ except is_boto3_error_code(codes) as e:
+ return e
+
+ def test_is_boto3_error_code_single__raise__client(self):
+ # 'AccessDenied' error, should be caught in our try/except in _do_try_code
+ thrown_exception = self._make_denied_exception()
+ codes_to_catch = 'AccessDenied'
+
+ caught_exception = self._do_try_code(thrown_exception, codes_to_catch)
+ assert caught_exception == thrown_exception
+
+ def test_is_boto3_error_code_single__raise__unexpected(self):
+ # 'SomeThingWentWrong' error, shouldn't be caught because the Code doesn't match
+ thrown_exception = self._make_unexpected_exception()
+ codes_to_catch = 'AccessDenied'
+
+ with pytest.raises(botocore.exceptions.ClientError) as context:
+ self._do_try_code(thrown_exception, codes_to_catch)
+ assert context.value == thrown_exception
+
+ def test_is_boto3_error_code_single__raise__botocore(self):
+ # BotoCoreExceptions don't have an error code, so shouldn't be caught (and shouldn't throw
+ # some other error due to the missing 'Code' data on the exception)
+ thrown_exception = self._make_botocore_exception()
+ codes_to_catch = 'AccessDenied'
+
+ with pytest.raises(botocore.exceptions.BotoCoreError) as context:
+ self._do_try_code(thrown_exception, codes_to_catch)
+
+ assert context.value == thrown_exception
+
+ def test_is_boto3_error_code_multiple__raise__client(self):
+ # 'AccessDenied' error, should be caught in our try/except in _do_try_code
+ # test with multiple possible codes to catch
+ thrown_exception = self._make_denied_exception()
+ codes_to_catch = ['AccessDenied', 'NotAccessDenied']
+
+ caught_exception = self._do_try_code(thrown_exception, codes_to_catch)
+ assert caught_exception == thrown_exception
+
+ thrown_exception = self._make_denied_exception()
+ codes_to_catch = ['NotAccessDenied', 'AccessDenied']
+
+ caught_exception = self._do_try_code(thrown_exception, codes_to_catch)
+ assert caught_exception == thrown_exception
+
+ def test_is_boto3_error_code_multiple__raise__unexpected(self):
+ # 'SomeThingWentWrong' error, shouldn't be caught because the Code doesn't match
+ # test with multiple possible codes to catch
+ thrown_exception = self._make_unexpected_exception()
+ codes_to_catch = ['NotAccessDenied', 'AccessDenied']
+
+ with pytest.raises(botocore.exceptions.ClientError) as context:
+ self._do_try_code(thrown_exception, codes_to_catch)
+ assert context.value == thrown_exception
+
+ def test_is_boto3_error_code_multiple__raise__botocore(self):
+ # BotoCoreErrors don't have an error code, so shouldn't be caught (and shouldn't throw
+ # some other error due to the missing 'Code' data on the exception)
+ # test with multiple possible codes to catch
+ thrown_exception = self._make_botocore_exception()
+ codes_to_catch = ['NotAccessDenied', 'AccessDenied']
+
+ with pytest.raises(botocore.exceptions.BotoCoreError) as context:
+ self._do_try_code(thrown_exception, codes_to_catch)
+ assert context.value == thrown_exception
+
+ ###
+ # Test that is_boto3_error_code returns what we expect when explicitly passed an exception
+ ###
+
+ def test_is_boto3_error_code_single__pass__client(self):
+ passed_exception = self._make_denied_exception()
+ returned_exception = is_boto3_error_code('AccessDenied', e=passed_exception)
+ assert isinstance(passed_exception, returned_exception)
+ assert issubclass(returned_exception, botocore.exceptions.ClientError)
+ assert not issubclass(returned_exception, botocore.exceptions.BotoCoreError)
+ assert issubclass(returned_exception, Exception)
+ assert returned_exception.__name__ != "NeverEverRaisedException"
+
+ def test_is_boto3_error_code_single__pass__unexpected(self):
+ passed_exception = self._make_unexpected_exception()
+ returned_exception = is_boto3_error_code('AccessDenied', e=passed_exception)
+ assert not isinstance(passed_exception, returned_exception)
+ assert not issubclass(returned_exception, botocore.exceptions.ClientError)
+ assert not issubclass(returned_exception, botocore.exceptions.BotoCoreError)
+ assert issubclass(returned_exception, Exception)
+ assert returned_exception.__name__ == "NeverEverRaisedException"
+
+ def test_is_boto3_error_code_single__pass__botocore(self):
+ passed_exception = self._make_botocore_exception()
+ returned_exception = is_boto3_error_code('AccessDenied', e=passed_exception)
+ assert not isinstance(passed_exception, returned_exception)
+ assert not issubclass(returned_exception, botocore.exceptions.ClientError)
+ assert not issubclass(returned_exception, botocore.exceptions.BotoCoreError)
+ assert issubclass(returned_exception, Exception)
+ assert returned_exception.__name__ == "NeverEverRaisedException"
+
+ def test_is_boto3_error_code_multiple__pass__client(self):
+ passed_exception = self._make_denied_exception()
+ returned_exception = is_boto3_error_code(['NotAccessDenied', 'AccessDenied'], e=passed_exception)
+ assert isinstance(passed_exception, returned_exception)
+ assert issubclass(returned_exception, botocore.exceptions.ClientError)
+ assert not issubclass(returned_exception, botocore.exceptions.BotoCoreError)
+ assert issubclass(returned_exception, Exception)
+ assert returned_exception.__name__ != "NeverEverRaisedException"
+
+ returned_exception = is_boto3_error_code(['AccessDenied', 'NotAccessDenied'], e=passed_exception)
+ assert isinstance(passed_exception, returned_exception)
+ assert issubclass(returned_exception, botocore.exceptions.ClientError)
+ assert not issubclass(returned_exception, botocore.exceptions.BotoCoreError)
+ assert issubclass(returned_exception, Exception)
+ assert returned_exception.__name__ != "NeverEverRaisedException"
+
+ def test_is_boto3_error_code_multiple__pass__unexpected(self):
+ passed_exception = self._make_unexpected_exception()
+ returned_exception = is_boto3_error_code(['NotAccessDenied', 'AccessDenied'], e=passed_exception)
+ assert not isinstance(passed_exception, returned_exception)
+ assert not issubclass(returned_exception, botocore.exceptions.ClientError)
+ assert not issubclass(returned_exception, botocore.exceptions.BotoCoreError)
+ assert issubclass(returned_exception, Exception)
+ assert returned_exception.__name__ == "NeverEverRaisedException"
+
+ def test_is_boto3_error_code_multiple__pass__botocore(self):
+ passed_exception = self._make_botocore_exception()
+ returned_exception = is_boto3_error_code(['NotAccessDenied', 'AccessDenied'], e=passed_exception)
+ assert not isinstance(passed_exception, returned_exception)
+ assert not issubclass(returned_exception, botocore.exceptions.ClientError)
+ assert not issubclass(returned_exception, botocore.exceptions.BotoCoreError)
+ assert issubclass(returned_exception, Exception)
+ assert returned_exception.__name__ == "NeverEverRaisedException"
diff --git a/ansible_collections/amazon/aws/tests/unit/module_utils/botocore/test_is_boto3_error_message.py b/ansible_collections/amazon/aws/tests/unit/module_utils/botocore/test_is_boto3_error_message.py
new file mode 100644
index 000000000..cd40a58dd
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/module_utils/botocore/test_is_boto3_error_message.py
@@ -0,0 +1,145 @@
+# -*- coding: utf-8 -*-
+# (c) 2020 Red Hat Inc.
+#
+# This file is part of Ansible
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import pytest
+
+try:
+ import botocore
+except ImportError:
+ # Handled by HAS_BOTO3
+ pass
+
+from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_message
+from ansible_collections.amazon.aws.plugins.module_utils.botocore import HAS_BOTO3
+
+if not HAS_BOTO3:
+ pytestmark = pytest.mark.skip("test_is_boto3_error_message.py requires the python modules 'boto3' and 'botocore'")
+
+
+class TestIsBoto3ErrorMessaged():
+
+ def _make_denied_exception(self):
+ return botocore.exceptions.ClientError(
+ {
+ "Error": {
+ "Code": "AccessDenied",
+ "Message": "User: arn:aws:iam::123456789012:user/ExampleUser "
+ + "is not authorized to perform: iam:GetUser on resource: user ExampleUser"
+ },
+ "ResponseMetadata": {
+ "RequestId": "01234567-89ab-cdef-0123-456789abcdef"
+ }
+ }, 'getUser')
+
+ def _make_unexpected_exception(self):
+ return botocore.exceptions.ClientError(
+ {
+ "Error": {
+ "Code": "SomeThingWentWrong",
+ "Message": "Boom!"
+ },
+ "ResponseMetadata": {
+ "RequestId": "01234567-89ab-cdef-0123-456789abcdef"
+ }
+ }, 'someCall')
+
+ def _make_encoded_exception(self):
+ return botocore.exceptions.ClientError(
+ {
+ "Error": {
+ "Code": "AccessDenied",
+ "Message": "You are not authorized to perform this operation. Encoded authorization failure message: " +
+ "fEwXX6llx3cClm9J4pURgz1XPnJPrYexEbrJcLhFkwygMdOgx_-aEsj0LqRM6Kxt2HVI6prUhDwbJqBo9U2V7iRKZ" +
+ "T6ZdJvHH02cXmD0Jwl5vrTsf0PhBcWYlH5wl2qME7xTfdolEUr4CzumCiti7ETiO-RDdHqWlasBOW5bWsZ4GSpPdU" +
+ "06YAX0TfwVBs48uU5RpCHfz1uhSzez-3elbtp9CmTOHLt5pzJodiovccO55BQKYLPtmJcs6S9YLEEogmpI4Cb1D26" +
+ "fYahDh51jEmaohPnW5pb1nQe2yPEtuIhtRzNjhFCOOMwY5DBzNsymK-Gj6eJLm7FSGHee4AHLU_XmZMe_6bcLAiOx" +
+ "6Zdl65Kdd0hLcpwVxyZMi27HnYjAdqRlV3wuCW2PkhAW14qZQLfiuHZDEwnPe2PBGSlFcCmkQvJvX-YLoA7Uyc2wf" +
+ "NX5RJm38STwfiJSkQaNDhHKTWKiLOsgY4Gze6uZoG7zOcFXFRyaA4cbMmI76uyBO7j-9uQUCtBYqYto8x_9CUJcxI" +
+ "VC5SPG_C1mk-WoDMew01f0qy-bNaCgmJ9TOQGd08FyuT1SaMpCC0gX6mHuOnEgkFw3veBIowMpp9XcM-yc42fmIOp" +
+ "FOdvQO6uE9p55Qc-uXvsDTTvT3A7EeFU8a_YoAIt9UgNYM6VTvoprLz7dBI_P6C-bdPPZCY2amm-dJNVZelT6TbJB" +
+ "H_Vxh0fzeiSUBersy_QzB0moc-vPWgnB-IkgnYLV-4L3K0L2"
+ },
+ "ResponseMetadata": {
+ "RequestId": "01234567-89ab-cdef-0123-456789abcdef"
+ }
+ }, 'someCall')
+
+ def _make_botocore_exception(self):
+ return botocore.exceptions.EndpointConnectionError(endpoint_url='junk.endpoint')
+
+ def _do_try_message(self, exception, messages):
+ try:
+ raise exception
+ except is_boto3_error_message(messages) as e:
+ return e
+
+ ###
+ # Test that is_boto3_error_message does what's expected when used in a try/except block
+ # (where we don't explicitly pass an exception to the function)
+ ###
+
+ def test_is_boto3_error_message_single__raise__client(self):
+ # error with 'is not authorized to perform' in the message, should be caught in our try/except in _do_try_code
+ thrown_exception = self._make_denied_exception()
+ messages_to_catch = 'is not authorized to perform'
+
+ caught_exception = self._do_try_message(thrown_exception, messages_to_catch)
+
+ assert caught_exception == thrown_exception
+
+ def test_is_boto3_error_message_single__raise__unexpected(self):
+ # error with 'Boom!' as the message, shouldn't match and should fall through
+ thrown_exception = self._make_unexpected_exception()
+ messages_to_catch = 'is not authorized to perform'
+
+ with pytest.raises(botocore.exceptions.ClientError) as context:
+ self._do_try_message(thrown_exception, messages_to_catch)
+
+ assert context.value == thrown_exception
+
+ def test_is_boto3_error_message_single__raise__botocore(self):
+ # Test that we don't catch BotoCoreError
+ thrown_exception = self._make_botocore_exception()
+ messages_to_catch = 'is not authorized to perform'
+
+ with pytest.raises(botocore.exceptions.BotoCoreError) as context:
+ self._do_try_message(thrown_exception, messages_to_catch)
+
+ assert context.value == thrown_exception
+
+ ###
+ # Test that is_boto3_error_message returns what we expect when explicitly passed an exception
+ ###
+
+ def test_is_boto3_error_message_single__pass__client(self):
+ passed_exception = self._make_denied_exception()
+ returned_exception = is_boto3_error_message('is not authorized to perform', e=passed_exception)
+ assert isinstance(passed_exception, returned_exception)
+ assert issubclass(returned_exception, botocore.exceptions.ClientError)
+ assert not issubclass(returned_exception, botocore.exceptions.BotoCoreError)
+ assert issubclass(returned_exception, Exception)
+ assert returned_exception.__name__ != "NeverEverRaisedException"
+
+ def test_is_boto3_error_message_single__pass__unexpected(self):
+ passed_exception = self._make_unexpected_exception()
+ returned_exception = is_boto3_error_message('is not authorized to perform', e=passed_exception)
+ assert not isinstance(passed_exception, returned_exception)
+ assert not issubclass(returned_exception, botocore.exceptions.ClientError)
+ assert not issubclass(returned_exception, botocore.exceptions.BotoCoreError)
+ assert issubclass(returned_exception, Exception)
+ assert returned_exception.__name__ == "NeverEverRaisedException"
+
+ def test_is_boto3_error_message_single__pass__botocore(self):
+ passed_exception = self._make_botocore_exception()
+ returned_exception = is_boto3_error_message('is not authorized to perform', e=passed_exception)
+ assert not isinstance(passed_exception, returned_exception)
+ assert not issubclass(returned_exception, botocore.exceptions.ClientError)
+ assert not issubclass(returned_exception, botocore.exceptions.BotoCoreError)
+ assert issubclass(returned_exception, Exception)
+ assert returned_exception.__name__ == "NeverEverRaisedException"
diff --git a/ansible_collections/amazon/aws/tests/unit/module_utils/botocore/test_normalize_boto3_result.py b/ansible_collections/amazon/aws/tests/unit/module_utils/botocore/test_normalize_boto3_result.py
new file mode 100644
index 000000000..71da9d66d
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/module_utils/botocore/test_normalize_boto3_result.py
@@ -0,0 +1,59 @@
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import pytest
+
+from ansible_collections.amazon.aws.plugins.module_utils.botocore import normalize_boto3_result
+
+example_date_txt = '2020-12-30T00:00:00.000Z'
+example_date_iso = '2020-12-30T00:00:00+00:00'
+
+try:
+ from dateutil import parser as date_parser
+ example_date = date_parser.parse(example_date_txt)
+except ImportError:
+ example_date = None
+ pytestmark = pytest.mark.skip("test_normalize_boto3_result.py requires the python module dateutil (python-dateutil)")
+
+
+normalize_boto3_result_data = [
+ (dict(),
+ dict()
+ ),
+ # Bool
+ (dict(param1=False),
+ dict(param1=False)
+ ),
+ # Simple string (shouldn't be touched
+ (dict(date_example=example_date_txt),
+ dict(date_example=example_date_txt)
+ ),
+ (dict(date_example=example_date_iso),
+ dict(date_example=example_date_iso)
+ ),
+ # Datetime -> String
+ (dict(date_example=example_date),
+ dict(date_example=example_date_iso)
+ ),
+ (list(),
+ list()
+ ),
+ (list([False]),
+ list([False])
+ ),
+ (list([example_date_txt]),
+ list([example_date_txt])
+ ),
+ (list([example_date_iso]),
+ list([example_date_iso])
+ ),
+ (list([example_date]),
+ list([example_date_iso])
+ ),
+]
+
+
+@pytest.mark.parametrize("input_params, output_params", normalize_boto3_result_data)
+def test_normalize_boto3_result(input_params, output_params):
+
+ assert normalize_boto3_result(input_params) == output_params
diff --git a/ansible_collections/amazon/aws/tests/unit/module_utils/cloud/test_backoff_iterator.py b/ansible_collections/amazon/aws/tests/unit/module_utils/cloud/test_backoff_iterator.py
new file mode 100644
index 000000000..5fee115c2
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/module_utils/cloud/test_backoff_iterator.py
@@ -0,0 +1,45 @@
+# (c) 2021 Red Hat Inc.
+#
+# This file is part of Ansible
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+from ansible_collections.amazon.aws.plugins.module_utils.cloud import BackoffIterator
+
+
+def test_backoff_value_generator():
+ max_delay = 60
+ initial = 3
+ backoff = 2
+
+ min_sleep = initial
+ counter = 0
+ for sleep in BackoffIterator(delay=initial, backoff=backoff, max_delay=max_delay):
+ if counter > 4:
+ assert sleep == max_delay
+ else:
+ assert sleep == min_sleep
+ min_sleep *= backoff
+ counter += 1
+ if counter == 10:
+ break
+
+
+def test_backoff_value_generator_with_jitter():
+ max_delay = 60
+ initial = 3
+ backoff = 2
+
+ min_sleep = initial
+ counter = 0
+ for sleep in BackoffIterator(delay=initial, backoff=backoff, max_delay=max_delay, jitter=True):
+ if counter > 4:
+ assert sleep <= max_delay
+ else:
+ assert sleep <= min_sleep
+ min_sleep *= backoff
+ counter += 1
+ if counter == 10:
+ break
diff --git a/ansible_collections/amazon/aws/tests/unit/module_utils/cloud/test_cloud_retry.py b/ansible_collections/amazon/aws/tests/unit/module_utils/cloud/test_cloud_retry.py
new file mode 100644
index 000000000..ce5f03f11
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/module_utils/cloud/test_cloud_retry.py
@@ -0,0 +1,236 @@
+# (c) 2021 Red Hat Inc.
+#
+# This file is part of Ansible
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import random
+from datetime import datetime
+import pytest
+
+from ansible_collections.amazon.aws.plugins.module_utils.cloud import CloudRetry
+
+
+class TestCloudRetry():
+
+ error_codes = [400, 500, 600]
+ custom_error_codes = [100, 200, 300]
+
+ class OurTestException(Exception):
+ """
+ custom exception class for testing
+ """
+ def __init__(self, status):
+ self.status = status
+
+ def __str__(self):
+ return "TestException with status: {0}".format(self.status)
+
+ class UnitTestsRetry(CloudRetry):
+ base_class = Exception
+
+ @staticmethod
+ def status_code_from_exception(error):
+ return getattr(error, "status") if hasattr(error, "status") else None
+
+ class CustomRetry(CloudRetry):
+ base_class = Exception
+
+ @staticmethod
+ def status_code_from_exception(error):
+ return error.status['response']['status']
+
+ @staticmethod
+ def found(response_code, catch_extra_error_codes=None):
+ if catch_extra_error_codes:
+ return response_code in catch_extra_error_codes + TestCloudRetry.custom_error_codes
+ else:
+ return response_code in TestCloudRetry.custom_error_codes
+
+ class KeyRetry(CloudRetry):
+ base_class = KeyError
+
+ @staticmethod
+ def status_code_from_exception(error):
+ return True
+
+ @staticmethod
+ def found(response_code, catch_extra_error_codes=None):
+ return True
+
+ class KeyAndIndexRetry(CloudRetry):
+ base_class = (KeyError, IndexError)
+
+ @staticmethod
+ def status_code_from_exception(error):
+ return True
+
+ @staticmethod
+ def found(response_code, catch_extra_error_codes=None):
+ return True
+
+ # ========================================================
+ # retry original backoff
+ # ========================================================
+ def test_retry_backoff(self):
+
+ @TestCloudRetry.UnitTestsRetry.backoff(tries=3, delay=1, backoff=1.1,
+ catch_extra_error_codes=TestCloudRetry.error_codes)
+ def test_retry_func():
+ if test_retry_func.counter < 2:
+ test_retry_func.counter += 1
+ raise self.OurTestException(status=random.choice(TestCloudRetry.error_codes))
+ else:
+ return True
+
+ test_retry_func.counter = 0
+ ret = test_retry_func()
+ assert ret is True
+
+ # ========================================================
+ # retry exponential backoff
+ # ========================================================
+ def test_retry_exponential_backoff(self):
+
+ @TestCloudRetry.UnitTestsRetry.exponential_backoff(retries=3, delay=1, backoff=1.1, max_delay=3,
+ catch_extra_error_codes=TestCloudRetry.error_codes)
+ def test_retry_func():
+ if test_retry_func.counter < 2:
+ test_retry_func.counter += 1
+ raise self.OurTestException(status=random.choice(TestCloudRetry.error_codes))
+ else:
+ return True
+
+ test_retry_func.counter = 0
+ ret = test_retry_func()
+ assert ret is True
+
+ def test_retry_exponential_backoff_with_unexpected_exception(self):
+ unexpected_except = self.OurTestException(status=100)
+
+ @TestCloudRetry.UnitTestsRetry.exponential_backoff(retries=3, delay=1, backoff=1.1, max_delay=3,
+ catch_extra_error_codes=TestCloudRetry.error_codes)
+ def test_retry_func():
+ if test_retry_func.counter == 0:
+ test_retry_func.counter += 1
+ raise self.OurTestException(status=random.choice(TestCloudRetry.error_codes))
+ else:
+ raise unexpected_except
+
+ test_retry_func.counter = 0
+ with pytest.raises(self.OurTestException) as context:
+ test_retry_func()
+
+ assert context.value.status == unexpected_except.status
+
+ # ========================================================
+ # retry jittered backoff
+ # ========================================================
+ def test_retry_jitter_backoff(self):
+ @TestCloudRetry.UnitTestsRetry.jittered_backoff(retries=3, delay=1, max_delay=3,
+ catch_extra_error_codes=TestCloudRetry.error_codes)
+ def test_retry_func():
+ if test_retry_func.counter < 2:
+ test_retry_func.counter += 1
+ raise self.OurTestException(status=random.choice(TestCloudRetry.error_codes))
+ else:
+ return True
+
+ test_retry_func.counter = 0
+ ret = test_retry_func()
+ assert ret is True
+
+ def test_retry_jittered_backoff_with_unexpected_exception(self):
+ unexpected_except = self.OurTestException(status=100)
+
+ @TestCloudRetry.UnitTestsRetry.jittered_backoff(retries=3, delay=1, max_delay=3,
+ catch_extra_error_codes=TestCloudRetry.error_codes)
+ def test_retry_func():
+ if test_retry_func.counter == 0:
+ test_retry_func.counter += 1
+ raise self.OurTestException(status=random.choice(TestCloudRetry.error_codes))
+ else:
+ raise unexpected_except
+
+ test_retry_func.counter = 0
+ with pytest.raises(self.OurTestException) as context:
+ test_retry_func()
+
+ assert context.value.status == unexpected_except.status
+
+ # ========================================================
+ # retry with custom class
+ # ========================================================
+ def test_retry_exponential_backoff_custom_class(self):
+ def build_response():
+ return dict(response=dict(status=random.choice(TestCloudRetry.custom_error_codes)))
+
+ @self.CustomRetry.exponential_backoff(retries=3, delay=1, backoff=1.1, max_delay=3,
+ catch_extra_error_codes=TestCloudRetry.error_codes)
+ def test_retry_func():
+ if test_retry_func.counter < 2:
+ test_retry_func.counter += 1
+ raise self.OurTestException(build_response())
+ else:
+ return True
+
+ test_retry_func.counter = 0
+
+ ret = test_retry_func()
+ assert ret is True
+
+ # =============================================================
+ # Test wrapped function multiple times will restart the sleep
+ # =============================================================
+ def test_wrapped_function_called_several_times(self):
+ @TestCloudRetry.UnitTestsRetry.exponential_backoff(retries=2, delay=2, backoff=4, max_delay=100,
+ catch_extra_error_codes=TestCloudRetry.error_codes)
+ def _fail():
+ raise self.OurTestException(status=random.choice(TestCloudRetry.error_codes))
+
+ # run the method 3 times and assert that each it is retrying after 2secs
+ # the elapsed execution time should be closed to 2sec
+ for _i in range(3):
+ start = datetime.now()
+ with pytest.raises(self.OurTestException):
+ _fail()
+ duration = (datetime.now() - start).seconds
+ assert duration == 2
+
+ def test_only_base_exception(self):
+ def _fail_index():
+ my_list = list()
+ return my_list[5]
+
+ def _fail_key():
+ my_dict = dict()
+ return my_dict['invalid_key']
+
+ def _fail_exception():
+ raise Exception('bang')
+
+ key_retry_decorator = TestCloudRetry.KeyRetry.exponential_backoff(retries=2, delay=2, backoff=4, max_delay=100)
+ key_and_index_retry_decorator = TestCloudRetry.KeyAndIndexRetry.exponential_backoff(retries=2, delay=2, backoff=4, max_delay=100)
+
+ expectations = [
+ [key_retry_decorator, _fail_exception, 0, Exception],
+ [key_retry_decorator, _fail_index, 0, IndexError],
+ [key_retry_decorator, _fail_key, 2, KeyError],
+ [key_and_index_retry_decorator, _fail_exception, 0, Exception],
+ [key_and_index_retry_decorator, _fail_index, 2, IndexError],
+ [key_and_index_retry_decorator, _fail_key, 2, KeyError],
+ ]
+
+ for expectation in expectations:
+ decorator = expectation[0]
+ function = expectation[1]
+ duration = expectation[2]
+ exception = expectation[3]
+
+ start = datetime.now()
+ with pytest.raises(exception):
+ decorator(function)()
+ _duration = (datetime.now() - start).seconds
+ assert duration == _duration
diff --git a/ansible_collections/amazon/aws/tests/unit/module_utils/cloud/test_decorator_generation.py b/ansible_collections/amazon/aws/tests/unit/module_utils/cloud/test_decorator_generation.py
new file mode 100644
index 000000000..23b446763
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/module_utils/cloud/test_decorator_generation.py
@@ -0,0 +1,156 @@
+# (c) 2022 Red Hat Inc.
+#
+# This file is part of Ansible
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import pytest
+import sys
+
+from ansible_collections.amazon.aws.plugins.module_utils.cloud import CloudRetry
+from ansible_collections.amazon.aws.plugins.module_utils.cloud import BackoffIterator
+from ansible_collections.amazon.aws.tests.unit.compat.mock import MagicMock
+from ansible_collections.amazon.aws.tests.unit.compat.mock import sentinel
+
+if sys.version_info < (3, 8):
+ pytest.skip("accessing call_args.kwargs by keyword (instead of index) was introduced in Python 3.8", allow_module_level=True)
+
+
+@pytest.fixture
+def patch_cloud_retry(monkeypatch):
+ """
+ replaces CloudRetry.base_decorator with a MagicMock so that we can exercise the generation of
+ the various "public" decorators. We can then check that base_decorator was called as expected.
+ Note: this doesn't test the operation of CloudRetry.base_decorator itself, but does make sure
+ we can fully exercise the various wrapper functions built over the top of it.
+ """
+ def perform_patch():
+ decorator_generator = MagicMock()
+ decorator_generator.return_value = sentinel.decorator
+ monkeypatch.setattr(CloudRetry, 'base_decorator', decorator_generator)
+ return CloudRetry, decorator_generator
+
+ return perform_patch
+
+
+def check_common_side_effects(decorator_generator):
+ """
+ By invoking CloudRetry.(exponential_backoff|jittered_backoff|backoff) we expect certain things
+ to have happend, specifically CloudRetry.base_decorator should have been called *once* with a
+ number of keyword arguments.
+ "found" should be CloudRetry.found
+ "status_code_from_exception" should be CloudRetry.status_code_from_exception (this is replaced when the abstract class is realised)
+ "sleep_time_generator" should be an instance of CloudRetry.BackoffIterator
+ """
+
+ assert decorator_generator.called is True
+ assert decorator_generator.call_count == 1
+
+ gen_kw_args = decorator_generator.call_args.kwargs
+ assert gen_kw_args['found'] is CloudRetry.found
+ assert gen_kw_args['status_code_from_exception'] is CloudRetry.status_code_from_exception
+
+ sleep_time_generator = gen_kw_args['sleep_time_generator']
+ assert isinstance(sleep_time_generator, BackoffIterator)
+
+ # Return the KW args used when CloudRetry.base_decorator was called and the sleep_time_generator
+ # passed, these are what should change between the different decorators
+ return gen_kw_args, sleep_time_generator
+
+
+def test_create_exponential_backoff_with_defaults(patch_cloud_retry):
+ cloud_retry, decorator_generator = patch_cloud_retry()
+
+ decorator = cloud_retry.exponential_backoff()
+
+ assert decorator is sentinel.decorator
+
+ gen_kw_args, sleep_time_generator = check_common_side_effects(decorator_generator)
+
+ assert gen_kw_args['retries'] == 10
+ assert gen_kw_args['catch_extra_error_codes'] is None
+ assert sleep_time_generator.delay == 3
+ assert sleep_time_generator.backoff == 2
+ assert sleep_time_generator.max_delay == 60
+ assert sleep_time_generator.jitter is False
+
+
+def test_create_exponential_backoff_with_args(patch_cloud_retry):
+ cloud_retry, decorator_generator = patch_cloud_retry()
+
+ decorator = cloud_retry.exponential_backoff(retries=11, delay=4, backoff=3, max_delay=61, catch_extra_error_codes=[42])
+ assert decorator is sentinel.decorator
+
+ gen_kw_args, sleep_time_generator = check_common_side_effects(decorator_generator)
+
+ assert gen_kw_args['catch_extra_error_codes'] == [42]
+ assert gen_kw_args['retries'] == 11
+ assert sleep_time_generator.delay == 4
+ assert sleep_time_generator.backoff == 3
+ assert sleep_time_generator.max_delay == 61
+ assert sleep_time_generator.jitter is False
+
+
+def test_create_jittered_backoff_with_defaults(patch_cloud_retry):
+ cloud_retry, decorator_generator = patch_cloud_retry()
+
+ decorator = cloud_retry.jittered_backoff()
+ assert decorator is sentinel.decorator
+
+ gen_kw_args, sleep_time_generator = check_common_side_effects(decorator_generator)
+
+ assert gen_kw_args['catch_extra_error_codes'] is None
+ assert gen_kw_args['retries'] == 10
+ assert sleep_time_generator.delay == 3
+ assert sleep_time_generator.backoff == 2
+ assert sleep_time_generator.max_delay == 60
+ assert sleep_time_generator.jitter is True
+
+
+def test_create_jittered_backoff_with_args(patch_cloud_retry):
+ cloud_retry, decorator_generator = patch_cloud_retry()
+
+ decorator = cloud_retry.jittered_backoff(retries=11, delay=4, backoff=3, max_delay=61, catch_extra_error_codes=[42])
+ assert decorator is sentinel.decorator
+
+ gen_kw_args, sleep_time_generator = check_common_side_effects(decorator_generator)
+
+ assert gen_kw_args['catch_extra_error_codes'] == [42]
+ assert gen_kw_args['retries'] == 11
+ assert sleep_time_generator.delay == 4
+ assert sleep_time_generator.backoff == 3
+ assert sleep_time_generator.max_delay == 61
+ assert sleep_time_generator.jitter is True
+
+
+def test_create_legacy_backoff_with_defaults(patch_cloud_retry):
+ cloud_retry, decorator_generator = patch_cloud_retry()
+
+ decorator = cloud_retry.backoff()
+
+ gen_kw_args, sleep_time_generator = check_common_side_effects(decorator_generator)
+
+ assert gen_kw_args['catch_extra_error_codes'] is None
+ assert gen_kw_args['retries'] == 10
+ assert sleep_time_generator.delay == 3
+ assert sleep_time_generator.backoff == 1.1
+ assert sleep_time_generator.max_delay is None
+ assert sleep_time_generator.jitter is False
+
+
+def test_create_legacy_backoff_with_args(patch_cloud_retry):
+ cloud_retry, decorator_generator = patch_cloud_retry()
+
+ # Note: the Keyword Args have different names here, and not all of them can be passed...
+ decorator = cloud_retry.backoff(tries=11, delay=4, backoff=3, catch_extra_error_codes=[42])
+
+ gen_kw_args, sleep_time_generator = check_common_side_effects(decorator_generator)
+
+ assert gen_kw_args['catch_extra_error_codes'] == [42]
+ assert gen_kw_args['retries'] == 11
+ assert sleep_time_generator.delay == 4
+ assert sleep_time_generator.backoff == 3
+ assert sleep_time_generator.max_delay is None
+ assert sleep_time_generator.jitter is False
diff --git a/ansible_collections/amazon/aws/tests/unit/module_utils/cloud/test_retries_found.py b/ansible_collections/amazon/aws/tests/unit/module_utils/cloud/test_retries_found.py
new file mode 100644
index 000000000..21ad74d42
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/module_utils/cloud/test_retries_found.py
@@ -0,0 +1,34 @@
+# (c) 2022 Red Hat Inc.
+#
+# This file is part of Ansible
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+from ansible_collections.amazon.aws.plugins.module_utils.cloud import CloudRetry
+
+
+def test_found_not_itterable():
+ assert CloudRetry.found('404', 5) is False
+ assert CloudRetry.found('404', None) is False
+ assert CloudRetry.found('404', 404) is False
+ # This seems counter intuitive, but the second argument is supposed to be iterable...
+ assert CloudRetry.found(404, 404) is False
+
+
+def test_found_no_match():
+ assert CloudRetry.found('404', ['403']) is False
+ assert CloudRetry.found('404', ['500', '403']) is False
+ assert CloudRetry.found('404', {'403'}) is False
+ assert CloudRetry.found('404', {'500', '403'}) is False
+
+
+def test_found_match():
+ assert CloudRetry.found('404', ['404']) is True
+ assert CloudRetry.found('404', ['403', '404']) is True
+ assert CloudRetry.found('404', ['404', '403']) is True
+ assert CloudRetry.found('404', {'404'}) is True
+ assert CloudRetry.found('404', {'403', '404'}) is True
+ # Beware, this will generally only work with strings (they're iterable)
+ assert CloudRetry.found('404', '404') is True
diff --git a/ansible_collections/amazon/aws/tests/unit/module_utils/cloud/test_retry_func.py b/ansible_collections/amazon/aws/tests/unit/module_utils/cloud/test_retry_func.py
new file mode 100644
index 000000000..609c0718b
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/module_utils/cloud/test_retry_func.py
@@ -0,0 +1,129 @@
+# (c) 2022 Red Hat Inc.
+#
+# This file is part of Ansible
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import pytest
+import sys
+
+import ansible_collections.amazon.aws.plugins.module_utils.cloud as cloud_utils
+from ansible_collections.amazon.aws.tests.unit.compat.mock import Mock
+from ansible_collections.amazon.aws.tests.unit.compat.mock import sentinel
+
+if sys.version_info < (3, 8):
+ pytest.skip("accessing call_args.kwargs by keyword (instead of index) was introduced in Python 3.8", allow_module_level=True)
+
+
+class ExceptionA(Exception):
+ def __init__(self):
+ pass
+
+
+class ExceptionB(Exception):
+ def __init__(self):
+ pass
+
+
+@pytest.fixture
+def retrier():
+ def do_retry(
+ func=None,
+ sleep_generator=None,
+ retries=4,
+ catch_extra_error_codes=None,
+ found_f=None,
+ extract_code=None,
+ base_class=None,
+ ):
+ if not func:
+ func = Mock(return_value=sentinel.successful_run)
+ if not sleep_generator:
+ sleep_generator = cloud_utils.BackoffIterator(0, 0)
+ if not found_f:
+ found_f = Mock(return_value=False)
+ if not extract_code:
+ extract_code = Mock(return_value=sentinel.extracted_code)
+ if not base_class:
+ base_class = ExceptionA
+
+ result = cloud_utils._retry_func(
+ func,
+ sleep_generator,
+ retries,
+ catch_extra_error_codes,
+ found_f,
+ extract_code,
+ base_class,
+ )
+ return func, result
+
+ return do_retry
+
+
+def test_success(retrier):
+ func, result = retrier()
+ assert result is sentinel.successful_run
+ assert func.called is True
+ assert func.call_count == 1
+
+
+def test_not_base(retrier):
+ func = Mock(side_effect=ExceptionB)
+ with pytest.raises(ExceptionB):
+ _f, _result = retrier(func=func)
+ assert func.called is True
+ assert func.call_count == 1
+
+
+def test_no_match(retrier):
+ found_f = Mock(return_value=False)
+ func = Mock(side_effect=ExceptionA)
+
+ with pytest.raises(ExceptionA):
+ _f, _result = retrier(func=func, found_f=found_f)
+ assert func.called is True
+ assert func.call_count == 1
+ assert found_f.called is True
+ assert found_f.call_count == 1
+ assert found_f.call_args.args[0] is sentinel.extracted_code
+ assert found_f.call_args.args[1] is None
+
+
+def test_no_match_with_extra_error_codes(retrier):
+ found_f = Mock(return_value=False)
+ func = Mock(side_effect=ExceptionA)
+ catch_extra_error_codes = sentinel.extra_codes
+
+ with pytest.raises(ExceptionA):
+ _f, _result = retrier(
+ func=func, found_f=found_f, catch_extra_error_codes=catch_extra_error_codes
+ )
+ assert func.called is True
+ assert func.call_count == 1
+ assert found_f.called is True
+ assert found_f.call_count == 1
+ assert found_f.call_args.args[0] is sentinel.extracted_code
+ assert found_f.call_args.args[1] is sentinel.extra_codes
+
+
+def test_simple_retries_4_times(retrier):
+ found_f = Mock(return_value=True)
+ func = Mock(side_effect=ExceptionA)
+
+ with pytest.raises(ExceptionA):
+ _f, _result = retrier(func=func, found_f=found_f)
+ assert func.called is True
+ assert func.call_count == 4
+
+
+def test_simple_retries_2_times(retrier):
+ found_f = Mock(return_value=True)
+ func = Mock(side_effect=ExceptionA)
+
+ with pytest.raises(ExceptionA):
+ _f, _result = retrier(func=func, found_f=found_f, retries=2)
+ assert func.called is True
+ assert func.call_count == 2
diff --git a/ansible_collections/amazon/aws/tests/unit/module_utils/conftest.py b/ansible_collections/amazon/aws/tests/unit/module_utils/conftest.py
new file mode 100644
index 000000000..f90055615
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/module_utils/conftest.py
@@ -0,0 +1,81 @@
+# Copyright (c) 2017 Ansible Project
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import json
+import sys
+from io import BytesIO
+import warnings
+
+import pytest
+
+import ansible.module_utils.basic
+import ansible.module_utils.common
+from ansible.module_utils.six import PY3, string_types
+from ansible.module_utils._text import to_bytes
+from ansible.module_utils.common._collections_compat import MutableMapping
+
+
+@pytest.fixture
+def stdin(mocker, request):
+ old_args = ansible.module_utils.basic._ANSIBLE_ARGS
+ ansible.module_utils.basic._ANSIBLE_ARGS = None
+ old_argv = sys.argv
+ sys.argv = ['ansible_unittest']
+
+ for var in ["_global_warnings", "_global_deprecations"]:
+ if hasattr(ansible.module_utils.common.warnings, var):
+ setattr(ansible.module_utils.common.warnings, var, [])
+ else:
+ # No need to reset the value
+ warnings.warn("deprecated")
+
+ if isinstance(request.param, string_types):
+ args = request.param
+ elif isinstance(request.param, MutableMapping):
+ if 'ANSIBLE_MODULE_ARGS' not in request.param:
+ request.param = {'ANSIBLE_MODULE_ARGS': request.param}
+ if '_ansible_remote_tmp' not in request.param['ANSIBLE_MODULE_ARGS']:
+ request.param['ANSIBLE_MODULE_ARGS']['_ansible_remote_tmp'] = '/tmp'
+ if '_ansible_keep_remote_files' not in request.param['ANSIBLE_MODULE_ARGS']:
+ request.param['ANSIBLE_MODULE_ARGS']['_ansible_keep_remote_files'] = False
+ args = json.dumps(request.param)
+ else:
+ raise Exception('Malformed data to the stdin pytest fixture')
+
+ fake_stdin = BytesIO(to_bytes(args, errors='surrogate_or_strict'))
+ if PY3:
+ mocker.patch('ansible.module_utils.basic.sys.stdin', mocker.MagicMock())
+ mocker.patch('ansible.module_utils.basic.sys.stdin.buffer', fake_stdin)
+ else:
+ mocker.patch('ansible.module_utils.basic.sys.stdin', fake_stdin)
+
+ yield fake_stdin
+
+ ansible.module_utils.basic._ANSIBLE_ARGS = old_args
+ sys.argv = old_argv
+
+
+@pytest.fixture
+def am(stdin, request):
+ old_args = ansible.module_utils.basic._ANSIBLE_ARGS
+ ansible.module_utils.basic._ANSIBLE_ARGS = None
+ old_argv = sys.argv
+ sys.argv = ['ansible_unittest']
+
+ argspec = {}
+ if hasattr(request, 'param'):
+ if isinstance(request.param, dict):
+ argspec = request.param
+
+ am = ansible.module_utils.basic.AnsibleModule(
+ argument_spec=argspec,
+ )
+ am._name = 'ansible_unittest'
+
+ yield am
+
+ ansible.module_utils.basic._ANSIBLE_ARGS = old_args
+ sys.argv = old_argv
diff --git a/ansible_collections/amazon/aws/tests/unit/module_utils/elbv2/test_prune.py b/ansible_collections/amazon/aws/tests/unit/module_utils/elbv2/test_prune.py
new file mode 100644
index 000000000..3a02b9e2e
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/module_utils/elbv2/test_prune.py
@@ -0,0 +1,188 @@
+#
+# (c) 2021 Red Hat Inc.
+#
+# This file is part of Ansible
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import pytest
+
+from ansible_collections.amazon.aws.plugins.module_utils import elbv2
+
+example_arn = 'arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/nlb-123456789abc/abcdef0123456789'
+example_arn2 = 'arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/nlb-0123456789ab/0123456789abcdef'
+
+one_action = [
+ dict(
+ ForwardConfig=dict(
+ TargetGroupStickinessConfig=dict(Enabled=False),
+ TargetGroups=[
+ dict(TargetGroupArn=example_arn, Weight=1),
+ ]
+ ),
+ TargetGroupArn=example_arn, Type='forward',
+ )
+]
+
+one_action_two_tg = [
+ dict(
+ ForwardConfig=dict(
+ TargetGroupStickinessConfig=dict(Enabled=False),
+ TargetGroups=[
+ dict(TargetGroupArn=example_arn, Weight=1),
+ dict(TargetGroupArn=example_arn2, Weight=1),
+ ]
+ ),
+ TargetGroupArn=example_arn, Type='forward',
+ )
+]
+
+simplified_action = dict(Type='forward', TargetGroupArn=example_arn)
+# Examples of various minimalistic actions which are all the same
+simple_actions = [
+ dict(Type='forward', TargetGroupArn=example_arn),
+
+ dict(Type='forward', TargetGroupArn=example_arn, ForwardConfig=dict(TargetGroups=[dict(TargetGroupArn=example_arn)])),
+ dict(Type='forward', ForwardConfig=dict(TargetGroups=[dict(TargetGroupArn=example_arn)])),
+ dict(Type='forward', TargetGroupArn=example_arn, ForwardConfig=dict(TargetGroups=[dict(TargetGroupArn=example_arn, Weight=1)])),
+ dict(Type='forward', ForwardConfig=dict(TargetGroups=[dict(TargetGroupArn=example_arn, Weight=1)])),
+ dict(Type='forward', TargetGroupArn=example_arn, ForwardConfig=dict(TargetGroups=[dict(TargetGroupArn=example_arn, Weight=42)])),
+ dict(Type='forward', ForwardConfig=dict(TargetGroups=[dict(TargetGroupArn=example_arn, Weight=42)])),
+
+ dict(Type='forward', TargetGroupArn=example_arn, ForwardConfig=dict(TargetGroupStickinessConfig=dict(Enabled=False),
+ TargetGroups=[dict(TargetGroupArn=example_arn)])),
+ dict(Type='forward', ForwardConfig=dict(TargetGroupStickinessConfig=dict(Enabled=False), TargetGroups=[dict(TargetGroupArn=example_arn)])),
+ dict(Type='forward', TargetGroupArn=example_arn, ForwardConfig=dict(TargetGroupStickinessConfig=dict(Enabled=False),
+ TargetGroups=[dict(TargetGroupArn=example_arn, Weight=1)])),
+ dict(Type='forward', ForwardConfig=dict(TargetGroupStickinessConfig=dict(Enabled=False), TargetGroups=[dict(TargetGroupArn=example_arn, Weight=1)])),
+ dict(Type='forward', TargetGroupArn=example_arn, ForwardConfig=dict(TargetGroupStickinessConfig=dict(Enabled=False),
+ TargetGroups=[dict(TargetGroupArn=example_arn, Weight=42)])),
+ dict(Type='forward', ForwardConfig=dict(TargetGroupStickinessConfig=dict(Enabled=False), TargetGroups=[dict(TargetGroupArn=example_arn, Weight=42)])),
+]
+
+# Test that _prune_ForwardConfig() doesn't mangle things we don't expect
+complex_actions = [
+ # Non-Forwarding
+ dict(
+ Type='authenticate-oidc', TargetGroupArn=example_arn,
+ AuthenticateOidcConfig=dict(
+ Issuer='https://idp.ansible.test/oidc-config',
+ AuthorizationEndpoint='https://idp.ansible.test/authz',
+ TokenEndpoint='https://idp.ansible.test/token',
+ UserInfoEndpoint='https://idp.ansible.test/user',
+ ClientId='ExampleClient',
+ UseExistingClientSecret=False,
+ ),
+ ),
+ dict(
+ Type='redirect',
+ RedirectConfig=dict(Protocol='HTTPS', Port=443, Host='redirect.ansible.test', Path='/', StatusCode='HTTP_302'),
+ ),
+ # Multiple TGs
+ dict(
+ TargetGroupArn=example_arn, Type='forward',
+ ForwardConfig=dict(
+ TargetGroupStickinessConfig=dict(Enabled=False),
+ TargetGroups=[
+ dict(TargetGroupArn=example_arn, Weight=1),
+ dict(TargetGroupArn=example_arn2, Weight=1),
+ ]
+ ),
+ ),
+ # Sticky-Sessions
+ dict(
+ Type='forward', TargetGroupArn=example_arn,
+ ForwardConfig=dict(
+ TargetGroupStickinessConfig=dict(Enabled=True, DurationSeconds=3600),
+ TargetGroups=[dict(TargetGroupArn=example_arn)]
+ )
+ ),
+]
+
+simplified_oidc_action = dict(
+ Type='authenticate-oidc', TargetGroupArn=example_arn,
+ AuthenticateOidcConfig=dict(
+ Issuer='https://idp.ansible.test/oidc-config',
+ AuthorizationEndpoint='https://idp.ansible.test/authz',
+ TokenEndpoint='https://idp.ansible.test/token',
+ UserInfoEndpoint='https://idp.ansible.test/user',
+ ClientId='ExampleClient',
+ Scope='openid',
+ SessionTimeout=604800,
+ UseExistingClientSecret=True,
+ ),
+)
+oidc_actions = [
+ dict(
+ Type='authenticate-oidc', TargetGroupArn=example_arn,
+ AuthenticateOidcConfig=dict(
+ Issuer='https://idp.ansible.test/oidc-config',
+ AuthorizationEndpoint='https://idp.ansible.test/authz',
+ TokenEndpoint='https://idp.ansible.test/token',
+ UserInfoEndpoint='https://idp.ansible.test/user',
+ ClientId='ExampleClient',
+ UseExistingClientSecret=True,
+ Scope='openid',
+ SessionTimeout=604800
+ ),
+ ),
+ dict(
+ Type='authenticate-oidc', TargetGroupArn=example_arn,
+ AuthenticateOidcConfig=dict(
+ Issuer='https://idp.ansible.test/oidc-config',
+ AuthorizationEndpoint='https://idp.ansible.test/authz',
+ TokenEndpoint='https://idp.ansible.test/token',
+ UserInfoEndpoint='https://idp.ansible.test/user',
+ ClientId='ExampleClient',
+ ClientSecret='MyVerySecretString',
+ UseExistingClientSecret=True,
+ ),
+ ),
+]
+
+
+####
+
+
+# Original tests
+def test__prune_secret():
+ assert elbv2._prune_secret(one_action[0]) == one_action[0]
+
+
+def test__prune_ForwardConfig():
+ expectation = {"TargetGroupArn": example_arn, "Type": "forward"}
+ pruned_config = elbv2._prune_ForwardConfig(one_action[0])
+ assert pruned_config == expectation
+
+ # https://github.com/ansible-collections/community.aws/issues/1089
+ pruned_config = elbv2._prune_ForwardConfig(one_action_two_tg[0])
+ assert pruned_config == one_action_two_tg[0]
+
+
+####
+
+
+@pytest.mark.parametrize("action", simple_actions)
+def test__prune_ForwardConfig_simplifiable_actions(action):
+ pruned_config = elbv2._prune_ForwardConfig(action)
+ assert pruned_config == simplified_action
+
+
+@pytest.mark.parametrize("action", complex_actions)
+def test__prune_ForwardConfig_non_simplifiable_actions(action):
+ pruned_config = elbv2._prune_ForwardConfig(action)
+ assert pruned_config == action
+
+
+@pytest.mark.parametrize("action", oidc_actions)
+def test__prune_secret_simplifiable_actions(action):
+ pruned_config = elbv2._prune_secret(action)
+ assert pruned_config == simplified_oidc_action
+
+
+@pytest.mark.parametrize("action", complex_actions)
+def test__prune_secret_non_simplifiable_actions(action):
+ pruned_config = elbv2._prune_secret(action)
+ assert pruned_config == action
diff --git a/ansible_collections/amazon/aws/tests/unit/module_utils/modules/ansible_aws_module/test_fail_json_aws.py b/ansible_collections/amazon/aws/tests/unit/module_utils/modules/ansible_aws_module/test_fail_json_aws.py
new file mode 100644
index 000000000..51e64490f
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/module_utils/modules/ansible_aws_module/test_fail_json_aws.py
@@ -0,0 +1,330 @@
+# (c) 2020 Red Hat Inc.
+#
+# This file is part of Ansible
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import json
+import pytest
+
+try:
+ import botocore
+ import boto3
+except ImportError:
+ pass
+
+from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict
+
+from ansible_collections.amazon.aws.plugins.module_utils.botocore import HAS_BOTO3
+from ansible_collections.amazon.aws.plugins.module_utils.modules import AnsibleAWSModule
+
+if not HAS_BOTO3:
+ pytestmark = pytest.mark.skip("test_fail_json_aws.py requires the python modules 'boto3' and 'botocore'")
+
+
+class TestFailJsonAwsTestSuite(object):
+ # ========================================================
+ # Prepare some data for use in our testing
+ # ========================================================
+ def setup_method(self):
+ # Basic information that ClientError needs to spawn off an error
+ self.EXAMPLE_EXCEPTION_DATA = {
+ "Error": {
+ "Code": "InvalidParameterValue",
+ "Message": "The filter 'exampleFilter' is invalid"
+ },
+ "ResponseMetadata": {
+ "RequestId": "01234567-89ab-cdef-0123-456789abcdef",
+ "HTTPStatusCode": 400,
+ "HTTPHeaders": {
+ "transfer-encoding": "chunked",
+ "date": "Fri, 13 Nov 2020 00:00:00 GMT",
+ "connection": "close",
+ "server": "AmazonEC2"
+ },
+ "RetryAttempts": 0
+ }
+ }
+ self.CAMEL_RESPONSE = camel_dict_to_snake_dict(self.EXAMPLE_EXCEPTION_DATA.get("ResponseMetadata"))
+ self.CAMEL_ERROR = camel_dict_to_snake_dict(self.EXAMPLE_EXCEPTION_DATA.get("Error"))
+ # ClientError(EXAMPLE_EXCEPTION_DATA, "testCall") will generate this
+ self.EXAMPLE_MSG = "An error occurred (InvalidParameterValue) when calling the testCall operation: The filter 'exampleFilter' is invalid"
+ self.DEFAULT_CORE_MSG = "An unspecified error occurred"
+ self.FAIL_MSG = "I Failed!"
+
+ # ========================================================
+ # Passing fail_json_aws nothing more than a ClientError
+ # ========================================================
+ @pytest.mark.parametrize("stdin", [{}], indirect=["stdin"])
+ def test_fail_client_minimal(self, monkeypatch, stdin, capfd):
+ monkeypatch.setattr(botocore, "__version__", "1.2.3")
+ monkeypatch.setattr(boto3, "__version__", "1.2.4")
+
+ # Create a minimal module that we can call
+ module = AnsibleAWSModule(argument_spec=dict())
+ try:
+ raise botocore.exceptions.ClientError(self.EXAMPLE_EXCEPTION_DATA, "testCall")
+ except botocore.exceptions.ClientError as e:
+ with pytest.raises(SystemExit) as ctx:
+ module.fail_json_aws(e)
+ assert ctx.value.code == 1
+ out, _err = capfd.readouterr()
+ return_val = json.loads(out)
+
+ assert return_val.get("msg") == self.EXAMPLE_MSG
+ assert return_val.get("boto3_version") == "1.2.4"
+ assert return_val.get("botocore_version") == "1.2.3"
+ assert return_val.get("exception") is not None
+ assert return_val.get("failed")
+ assert return_val.get("response_metadata") == self.CAMEL_RESPONSE
+ assert return_val.get("error") == self.CAMEL_ERROR
+
+ # ========================================================
+ # Passing fail_json_aws a ClientError and a message
+ # ========================================================
+ @pytest.mark.parametrize("stdin", [{}], indirect=["stdin"])
+ def test_fail_client_msg(self, monkeypatch, stdin, capfd):
+ monkeypatch.setattr(botocore, "__version__", "1.2.3")
+ monkeypatch.setattr(boto3, "__version__", "1.2.4")
+
+ # Create a minimal module that we can call
+ module = AnsibleAWSModule(argument_spec=dict())
+ try:
+ raise botocore.exceptions.ClientError(self.EXAMPLE_EXCEPTION_DATA, "testCall")
+ except botocore.exceptions.ClientError as e:
+ with pytest.raises(SystemExit) as ctx:
+ module.fail_json_aws(e, msg=self.FAIL_MSG)
+ assert ctx.value.code == 1
+ out, _err = capfd.readouterr()
+ return_val = json.loads(out)
+
+ assert return_val.get("msg") == self.FAIL_MSG + ": " + self.EXAMPLE_MSG
+ assert return_val.get("boto3_version") == "1.2.4"
+ assert return_val.get("botocore_version") == "1.2.3"
+ assert return_val.get("exception") is not None
+ assert return_val.get("failed")
+ assert return_val.get("response_metadata") == self.CAMEL_RESPONSE
+ assert return_val.get("error") == self.CAMEL_ERROR
+
+ # ========================================================
+ # Passing fail_json_aws a ClientError and a message as a positional argument
+ # ========================================================
+ @pytest.mark.parametrize("stdin", [{}], indirect=["stdin"])
+ def test_fail_client_positional_msg(self, monkeypatch, stdin, capfd):
+ monkeypatch.setattr(botocore, "__version__", "1.2.3")
+ monkeypatch.setattr(boto3, "__version__", "1.2.4")
+
+ # Create a minimal module that we can call
+ module = AnsibleAWSModule(argument_spec=dict())
+ try:
+ raise botocore.exceptions.ClientError(self.EXAMPLE_EXCEPTION_DATA, "testCall")
+ except botocore.exceptions.ClientError as e:
+ with pytest.raises(SystemExit) as ctx:
+ module.fail_json_aws(e, self.FAIL_MSG)
+ assert ctx.value.code == 1
+ out, _err = capfd.readouterr()
+ return_val = json.loads(out)
+
+ assert return_val.get("msg") == self.FAIL_MSG + ": " + self.EXAMPLE_MSG
+ assert return_val.get("boto3_version") == "1.2.4"
+ assert return_val.get("botocore_version") == "1.2.3"
+ assert return_val.get("exception") is not None
+ assert return_val.get("failed")
+ assert return_val.get("response_metadata") == self.CAMEL_RESPONSE
+ assert return_val.get("error") == self.CAMEL_ERROR
+
+ # ========================================================
+ # Passing fail_json_aws a ClientError and an arbitrary key
+ # ========================================================
+ @pytest.mark.parametrize("stdin", [{}], indirect=["stdin"])
+ def test_fail_client_key(self, monkeypatch, stdin, capfd):
+ monkeypatch.setattr(botocore, "__version__", "1.2.3")
+ monkeypatch.setattr(boto3, "__version__", "1.2.4")
+
+ # Create a minimal module that we can call
+ module = AnsibleAWSModule(argument_spec=dict())
+ try:
+ raise botocore.exceptions.ClientError(self.EXAMPLE_EXCEPTION_DATA, "testCall")
+ except botocore.exceptions.ClientError as e:
+ with pytest.raises(SystemExit) as ctx:
+ module.fail_json_aws(e, extra_key="Some Value")
+ assert ctx.value.code == 1
+ out, _err = capfd.readouterr()
+ return_val = json.loads(out)
+
+ assert return_val.get("msg") == self.EXAMPLE_MSG
+ assert return_val.get("extra_key") == "Some Value"
+ assert return_val.get("boto3_version") == "1.2.4"
+ assert return_val.get("botocore_version") == "1.2.3"
+ assert return_val.get("exception") is not None
+ assert return_val.get("failed")
+ assert return_val.get("response_metadata") == self.CAMEL_RESPONSE
+ assert return_val.get("error") == self.CAMEL_ERROR
+
+ # ========================================================
+ # Passing fail_json_aws a ClientError, and arbitraty key and a message
+ # ========================================================
+ @pytest.mark.parametrize("stdin", [{}], indirect=["stdin"])
+ def test_fail_client_msg_and_key(self, monkeypatch, stdin, capfd):
+ monkeypatch.setattr(botocore, "__version__", "1.2.3")
+ monkeypatch.setattr(boto3, "__version__", "1.2.4")
+
+ # Create a minimal module that we can call
+ module = AnsibleAWSModule(argument_spec=dict())
+ try:
+ raise botocore.exceptions.ClientError(self.EXAMPLE_EXCEPTION_DATA, "testCall")
+ except botocore.exceptions.ClientError as e:
+ with pytest.raises(SystemExit) as ctx:
+ module.fail_json_aws(e, extra_key="Some Value", msg=self.FAIL_MSG)
+ assert ctx.value.code == 1
+ out, _err = capfd.readouterr()
+ return_val = json.loads(out)
+
+ assert return_val.get("msg") == self.FAIL_MSG + ": " + self.EXAMPLE_MSG
+ assert return_val.get("extra_key") == "Some Value"
+ assert return_val.get("boto3_version") == "1.2.4"
+ assert return_val.get("botocore_version") == "1.2.3"
+ assert return_val.get("exception") is not None
+ assert return_val.get("failed")
+ assert return_val.get("response_metadata") == self.CAMEL_RESPONSE
+ assert return_val.get("error") == self.CAMEL_ERROR
+
+ # ========================================================
+ # Passing fail_json_aws nothing more than a BotoCoreError
+ # ========================================================
+ @pytest.mark.parametrize("stdin", [{}], indirect=["stdin"])
+ def test_fail_botocore_minimal(self, monkeypatch, stdin, capfd):
+ monkeypatch.setattr(botocore, "__version__", "1.2.3")
+ monkeypatch.setattr(boto3, "__version__", "1.2.4")
+
+ # Create a minimal module that we can call
+ module = AnsibleAWSModule(argument_spec=dict())
+ try:
+ raise botocore.exceptions.BotoCoreError()
+ except botocore.exceptions.BotoCoreError as e:
+ with pytest.raises(SystemExit) as ctx:
+ module.fail_json_aws(e)
+ assert ctx.value.code == 1
+ out, _err = capfd.readouterr()
+ return_val = json.loads(out)
+
+ assert return_val.get("msg") == self.DEFAULT_CORE_MSG
+ assert return_val.get("boto3_version") == "1.2.4"
+ assert return_val.get("botocore_version") == "1.2.3"
+ assert return_val.get("exception") is not None
+ assert return_val.get("failed")
+ assert "response_metadata" not in return_val
+ assert "error" not in return_val
+
+ # ========================================================
+ # Passing fail_json_aws BotoCoreError and a message
+ # ========================================================
+ @pytest.mark.parametrize("stdin", [{}], indirect=["stdin"])
+ def test_fail_botocore_msg(self, monkeypatch, stdin, capfd):
+ monkeypatch.setattr(botocore, "__version__", "1.2.3")
+ monkeypatch.setattr(boto3, "__version__", "1.2.4")
+
+ # Create a minimal module that we can call
+ module = AnsibleAWSModule(argument_spec=dict())
+ try:
+ raise botocore.exceptions.BotoCoreError()
+ except botocore.exceptions.BotoCoreError as e:
+ with pytest.raises(SystemExit) as ctx:
+ module.fail_json_aws(e, msg=self.FAIL_MSG)
+ assert ctx.value.code == 1
+ out, _err = capfd.readouterr()
+ return_val = json.loads(out)
+
+ assert return_val.get("msg") == self.FAIL_MSG + ": " + self.DEFAULT_CORE_MSG
+ assert return_val.get("boto3_version") == "1.2.4"
+ assert return_val.get("botocore_version") == "1.2.3"
+ assert return_val.get("exception") is not None
+ assert return_val.get("failed")
+ assert "response_metadata" not in return_val
+ assert "error" not in return_val
+
+ # ========================================================
+ # Passing fail_json_aws BotoCoreError and a message as a positional
+ # argument
+ # ========================================================
+ @pytest.mark.parametrize("stdin", [{}], indirect=["stdin"])
+ def test_fail_botocore_positional_msg(self, monkeypatch, stdin, capfd):
+ monkeypatch.setattr(botocore, "__version__", "1.2.3")
+ monkeypatch.setattr(boto3, "__version__", "1.2.4")
+
+ # Create a minimal module that we can call
+ module = AnsibleAWSModule(argument_spec=dict())
+ try:
+ raise botocore.exceptions.BotoCoreError()
+ except botocore.exceptions.BotoCoreError as e:
+ with pytest.raises(SystemExit) as ctx:
+ module.fail_json_aws(e, self.FAIL_MSG)
+ assert ctx.value.code == 1
+ out, _err = capfd.readouterr()
+ return_val = json.loads(out)
+
+ assert return_val.get("msg") == self.FAIL_MSG + ": " + self.DEFAULT_CORE_MSG
+ assert return_val.get("boto3_version") == "1.2.4"
+ assert return_val.get("botocore_version") == "1.2.3"
+ assert return_val.get("exception") is not None
+ assert return_val.get("failed")
+ assert "response_metadata" not in return_val
+ assert "error" not in return_val
+
+ # ========================================================
+ # Passing fail_json_aws a BotoCoreError and an arbitrary key
+ # ========================================================
+ @pytest.mark.parametrize("stdin", [{}], indirect=["stdin"])
+ def test_fail_botocore_key(self, monkeypatch, stdin, capfd):
+ monkeypatch.setattr(botocore, "__version__", "1.2.3")
+ monkeypatch.setattr(boto3, "__version__", "1.2.4")
+
+ # Create a minimal module that we can call
+ module = AnsibleAWSModule(argument_spec=dict())
+ try:
+ raise botocore.exceptions.BotoCoreError()
+ except botocore.exceptions.BotoCoreError as e:
+ with pytest.raises(SystemExit) as ctx:
+ module.fail_json_aws(e, extra_key="Some Value")
+ assert ctx.value.code == 1
+ out, _err = capfd.readouterr()
+ return_val = json.loads(out)
+
+ assert return_val.get("msg") == self.DEFAULT_CORE_MSG
+ assert return_val.get("extra_key") == "Some Value"
+ assert return_val.get("boto3_version") == "1.2.4"
+ assert return_val.get("botocore_version") == "1.2.3"
+ assert return_val.get("exception") is not None
+ assert return_val.get("failed")
+ assert "response_metadata" not in return_val
+ assert "error" not in return_val
+
+ # ========================================================
+ # Passing fail_json_aws BotoCoreError, an arbitry key and a message
+ # ========================================================
+ @pytest.mark.parametrize("stdin", [{}], indirect=["stdin"])
+ def test_fail_botocore_msg_and_key(self, monkeypatch, stdin, capfd):
+ monkeypatch.setattr(botocore, "__version__", "1.2.3")
+ monkeypatch.setattr(boto3, "__version__", "1.2.4")
+
+ # Create a minimal module that we can call
+ module = AnsibleAWSModule(argument_spec=dict())
+ try:
+ raise botocore.exceptions.BotoCoreError()
+ except botocore.exceptions.BotoCoreError as e:
+ with pytest.raises(SystemExit) as ctx:
+ module.fail_json_aws(e, extra_key="Some Value", msg=self.FAIL_MSG)
+ assert ctx.value.code == 1
+ out, _err = capfd.readouterr()
+ return_val = json.loads(out)
+
+ assert return_val.get("msg") == self.FAIL_MSG + ": " + self.DEFAULT_CORE_MSG
+ assert return_val.get("extra_key") == "Some Value"
+ assert return_val.get("boto3_version") == "1.2.4"
+ assert return_val.get("botocore_version") == "1.2.3"
+ assert return_val.get("exception") is not None
+ assert return_val.get("failed")
+ assert "response_metadata" not in return_val
+ assert "error" not in return_val
diff --git a/ansible_collections/amazon/aws/tests/unit/module_utils/modules/ansible_aws_module/test_minimal_versions.py b/ansible_collections/amazon/aws/tests/unit/module_utils/modules/ansible_aws_module/test_minimal_versions.py
new file mode 100644
index 000000000..17e69ecb5
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/module_utils/modules/ansible_aws_module/test_minimal_versions.py
@@ -0,0 +1,191 @@
+# (c) 2020 Red Hat Inc.
+#
+# This file is part of Ansible
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+from pprint import pprint
+import pytest
+import json
+import warnings
+
+try:
+ import botocore
+ import boto3
+except ImportError:
+ pass
+
+from ansible_collections.amazon.aws.plugins.module_utils.botocore import HAS_BOTO3
+from ansible_collections.amazon.aws.plugins.module_utils.modules import AnsibleAWSModule
+
+if not HAS_BOTO3:
+ pytestmark = pytest.mark.skip("test_minimal_versions.py requires the python modules 'boto3' and 'botocore'")
+
+
+class TestMinimalVersionTestSuite(object):
+ # ========================================================
+ # Prepare some data for use in our testing
+ # ========================================================
+ def setup_method(self):
+ self.MINIMAL_BOTO3 = '1.18.0'
+ self.MINIMAL_BOTOCORE = '1.21.0'
+ self.OLD_BOTO3 = '1.17.999'
+ self.OLD_BOTOCORE = '1.20.999'
+
+ # ========================================================
+ # Test we don't warn when using valid versions
+ # ========================================================
+ @pytest.mark.parametrize("stdin", [{}], indirect=["stdin"])
+ def test_no_warn(self, monkeypatch, stdin, capfd):
+ monkeypatch.setattr(botocore, "__version__", self.MINIMAL_BOTOCORE)
+ monkeypatch.setattr(boto3, "__version__", self.MINIMAL_BOTO3)
+
+ # Create a minimal module that we can call
+ module = AnsibleAWSModule(argument_spec=dict())
+
+ with pytest.raises(SystemExit):
+ module.exit_json()
+
+ out, _err = capfd.readouterr()
+ return_val = json.loads(out)
+
+ assert return_val.get("exception") is None
+ assert return_val.get("invocation") is not None
+ assert return_val.get("failed") is None
+ assert return_val.get("error") is None
+ assert return_val.get("warnings") is None
+
+ # ========================================================
+ # Test we don't warn when botocore/boto3 isn't required
+ # ========================================================
+ @pytest.mark.parametrize("stdin", [{}], indirect=["stdin"])
+ def test_no_check(self, monkeypatch, stdin, capfd):
+ monkeypatch.setattr(botocore, "__version__", self.OLD_BOTOCORE)
+ monkeypatch.setattr(boto3, "__version__", self.OLD_BOTO3)
+
+ # Create a minimal module that we can call
+ module = AnsibleAWSModule(argument_spec=dict(), check_boto3=False)
+
+ with pytest.raises(SystemExit):
+ module.exit_json()
+
+ out, _err = capfd.readouterr()
+ return_val = json.loads(out)
+
+ assert return_val.get("exception") is None
+ assert return_val.get("invocation") is not None
+ assert return_val.get("failed") is None
+ assert return_val.get("error") is None
+ assert return_val.get("warnings") is None
+
+ # ========================================================
+ # Test we warn when using an old version of boto3
+ # ========================================================
+ @pytest.mark.parametrize("stdin", [{}], indirect=["stdin"])
+ def test_warn_boto3(self, monkeypatch, stdin, capfd):
+ monkeypatch.setattr(botocore, "__version__", self.MINIMAL_BOTOCORE)
+ monkeypatch.setattr(boto3, "__version__", self.OLD_BOTO3)
+
+ # Create a minimal module that we can call
+ module = AnsibleAWSModule(argument_spec=dict())
+
+ with pytest.raises(SystemExit):
+ module.exit_json()
+
+ out, err = capfd.readouterr()
+ return_val = json.loads(out)
+
+ pprint(out)
+ pprint(err)
+ pprint(return_val)
+
+ assert return_val.get("exception") is None
+ assert return_val.get("invocation") is not None
+ assert return_val.get("failed") is None
+ assert return_val.get("error") is None
+ assert return_val.get("warnings") is not None
+ warnings = return_val.get("warnings")
+ assert len(warnings) == 1
+ # Assert that we have a warning about the version but be
+ # relaxed about the exact message
+ assert 'boto3' in warnings[0]
+ assert self.MINIMAL_BOTO3 in warnings[0]
+
+ # ========================================================
+ # Test we warn when using an old version of botocore
+ # ========================================================
+ @pytest.mark.parametrize("stdin", [{}], indirect=["stdin"])
+ def test_warn_botocore(self, monkeypatch, stdin, capfd):
+ monkeypatch.setattr(botocore, "__version__", self.OLD_BOTOCORE)
+ monkeypatch.setattr(boto3, "__version__", self.MINIMAL_BOTO3)
+
+ # Create a minimal module that we can call
+ module = AnsibleAWSModule(argument_spec=dict())
+
+ with pytest.raises(SystemExit):
+ module.exit_json()
+
+ out, err = capfd.readouterr()
+ return_val = json.loads(out)
+
+ pprint(out)
+ pprint(err)
+ pprint(return_val)
+
+ assert return_val.get("exception") is None
+ assert return_val.get("invocation") is not None
+ assert return_val.get("failed") is None
+ assert return_val.get("error") is None
+ assert return_val.get("warnings") is not None
+ warnings = return_val.get("warnings")
+ assert len(warnings) == 1
+ # Assert that we have a warning about the version but be
+ # relaxed about the exact message
+ assert 'botocore' in warnings[0]
+ assert self.MINIMAL_BOTOCORE in warnings[0]
+
+ # ========================================================
+ # Test we warn when using an old version of botocore and boto3
+ # ========================================================
+ @pytest.mark.parametrize("stdin", [{}], indirect=["stdin"])
+ def test_warn_boto3_and_botocore(self, monkeypatch, stdin, capfd):
+ monkeypatch.setattr(botocore, "__version__", self.OLD_BOTOCORE)
+ monkeypatch.setattr(boto3, "__version__", self.OLD_BOTO3)
+
+ # Create a minimal module that we can call
+ module = AnsibleAWSModule(argument_spec=dict())
+
+ with pytest.raises(SystemExit):
+ module.exit_json()
+
+ out, err = capfd.readouterr()
+ return_val = json.loads(out)
+
+ pprint(out)
+ pprint(err)
+ pprint(return_val)
+
+ assert return_val.get("exception") is None
+ assert return_val.get("invocation") is not None
+ assert return_val.get("failed") is None
+ assert return_val.get("error") is None
+ assert return_val.get("warnings") is not None
+
+ warnings = return_val.get("warnings")
+ assert len(warnings) == 2
+
+ warning_dict = dict()
+ for warning in warnings:
+ if 'boto3' in warning:
+ warning_dict['boto3'] = warning
+ if 'botocore' in warning:
+ warning_dict['botocore'] = warning
+
+ # Assert that we have a warning about the version but be
+ # relaxed about the exact message
+ assert warning_dict.get('boto3') is not None
+ assert self.MINIMAL_BOTO3 in warning_dict.get('boto3')
+ assert warning_dict.get('botocore') is not None
+ assert self.MINIMAL_BOTOCORE in warning_dict.get('botocore')
diff --git a/ansible_collections/amazon/aws/tests/unit/module_utils/modules/ansible_aws_module/test_require_at_least.py b/ansible_collections/amazon/aws/tests/unit/module_utils/modules/ansible_aws_module/test_require_at_least.py
new file mode 100644
index 000000000..adf2bf558
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/module_utils/modules/ansible_aws_module/test_require_at_least.py
@@ -0,0 +1,220 @@
+# (c) 2021 Red Hat Inc.
+#
+# This file is part of Ansible
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import json
+import pytest
+
+try:
+ import botocore
+ import boto3
+except ImportError:
+ # Handled by HAS_BOTO3
+ pass
+
+from ansible_collections.amazon.aws.plugins.module_utils.botocore import HAS_BOTO3
+from ansible_collections.amazon.aws.plugins.module_utils.modules import AnsibleAWSModule
+
+DUMMY_VERSION = '5.5.5.5'
+
+TEST_VERSIONS = [
+ ['1.1.1', '2.2.2', True],
+ ['1.1.1', '0.0.1', False],
+ ['9.9.9', '9.9.9', True],
+ ['9.9.9', '9.9.10', True],
+ ['9.9.9', '9.10.9', True],
+ ['9.9.9', '10.9.9', True],
+ ['9.9.9', '9.9.8', False],
+ ['9.9.9', '9.8.9', False],
+ ['9.9.9', '8.9.9', False],
+ ['10.10.10', '10.10.10', True],
+ ['10.10.10', '10.10.11', True],
+ ['10.10.10', '10.11.10', True],
+ ['10.10.10', '11.10.10', True],
+ ['10.10.10', '10.10.9', False],
+ ['10.10.10', '10.9.10', False],
+ ['10.10.10', '9.19.10', False],
+]
+
+if not HAS_BOTO3:
+ pytestmark = pytest.mark.skip("test_require_at_least.py requires the python modules 'boto3' and 'botocore'")
+
+
+class TestRequireAtLeastTestSuite(object):
+ # ========================================================
+ # Prepare some data for use in our testing
+ # ========================================================
+ def setup_method(self):
+ pass
+
+ # ========================================================
+ # Test botocore_at_least
+ # ========================================================
+ @pytest.mark.parametrize("stdin, desired_version, compare_version, at_least", [({}, *d) for d in TEST_VERSIONS], indirect=["stdin"])
+ def test_botocore_at_least(self, monkeypatch, stdin, desired_version, compare_version, at_least, capfd):
+ monkeypatch.setattr(botocore, "__version__", compare_version)
+ # Set boto3 version to a known value (tests are on both sides) to make
+ # sure we're comparing the right library
+ monkeypatch.setattr(boto3, "__version__", DUMMY_VERSION)
+
+ # Create a minimal module that we can call
+ module = AnsibleAWSModule(argument_spec=dict())
+
+ assert at_least == module.botocore_at_least(desired_version)
+
+ # ========================================================
+ # Test boto3_at_least
+ # ========================================================
+ @pytest.mark.parametrize("stdin, desired_version, compare_version, at_least", [({}, *d) for d in TEST_VERSIONS], indirect=["stdin"])
+ def test_boto3_at_least(self, monkeypatch, stdin, desired_version, compare_version, at_least, capfd):
+ # Set botocore version to a known value (tests are on both sides) to make
+ # sure we're comparing the right library
+ monkeypatch.setattr(botocore, "__version__", DUMMY_VERSION)
+ monkeypatch.setattr(boto3, "__version__", compare_version)
+
+ # Create a minimal module that we can call
+ module = AnsibleAWSModule(argument_spec=dict())
+
+ assert at_least == module.boto3_at_least(desired_version)
+
+ # ========================================================
+ # Test require_botocore_at_least
+ # ========================================================
+ @pytest.mark.parametrize("stdin, desired_version, compare_version, at_least", [({}, *d) for d in TEST_VERSIONS], indirect=["stdin"])
+ def test_require_botocore_at_least(self, monkeypatch, stdin, desired_version, compare_version, at_least, capfd):
+ monkeypatch.setattr(botocore, "__version__", compare_version)
+ # Set boto3 version to a known value (tests are on both sides) to make
+ # sure we're comparing the right library
+ monkeypatch.setattr(boto3, "__version__", DUMMY_VERSION)
+
+ # Create a minimal module that we can call
+ module = AnsibleAWSModule(argument_spec=dict())
+
+ with pytest.raises(SystemExit):
+ module.require_botocore_at_least(desired_version)
+ module.exit_json()
+
+ out, _err = capfd.readouterr()
+ return_val = json.loads(out)
+
+ assert return_val.get("exception") is None
+ assert return_val.get("invocation") is not None
+ if at_least:
+ assert return_val.get("failed") is None
+ else:
+ assert return_val.get("failed")
+ # The message is generated by Ansible, don't test for an exact
+ # message
+ assert desired_version in return_val.get("msg")
+ assert "botocore" in return_val.get("msg")
+ assert return_val.get("boto3_version") == DUMMY_VERSION
+ assert return_val.get("botocore_version") == compare_version
+
+ # ========================================================
+ # Test require_boto3_at_least
+ # ========================================================
+ @pytest.mark.parametrize("stdin, desired_version, compare_version, at_least", [({}, *d) for d in TEST_VERSIONS], indirect=["stdin"])
+ def test_require_boto3_at_least(self, monkeypatch, stdin, desired_version, compare_version, at_least, capfd):
+ monkeypatch.setattr(botocore, "__version__", DUMMY_VERSION)
+ # Set boto3 version to a known value (tests are on both sides) to make
+ # sure we're comparing the right library
+ monkeypatch.setattr(boto3, "__version__", compare_version)
+
+ # Create a minimal module that we can call
+ module = AnsibleAWSModule(argument_spec=dict())
+
+ with pytest.raises(SystemExit):
+ module.require_boto3_at_least(desired_version)
+ module.exit_json()
+
+ out, _err = capfd.readouterr()
+ return_val = json.loads(out)
+
+ assert return_val.get("exception") is None
+ assert return_val.get("invocation") is not None
+ if at_least:
+ assert return_val.get("failed") is None
+ else:
+ assert return_val.get("failed")
+ # The message is generated by Ansible, don't test for an exact
+ # message
+ assert desired_version in return_val.get("msg")
+ assert "boto3" in return_val.get("msg")
+ assert return_val.get("botocore_version") == DUMMY_VERSION
+ assert return_val.get("boto3_version") == compare_version
+
+ # ========================================================
+ # Test require_botocore_at_least with reason
+ # ========================================================
+ @pytest.mark.parametrize("stdin, desired_version, compare_version, at_least", [({}, *d) for d in TEST_VERSIONS], indirect=["stdin"])
+ def test_require_botocore_at_least_with_reason(self, monkeypatch, stdin, desired_version, compare_version, at_least, capfd):
+ monkeypatch.setattr(botocore, "__version__", compare_version)
+ # Set boto3 version to a known value (tests are on both sides) to make
+ # sure we're comparing the right library
+ monkeypatch.setattr(boto3, "__version__", DUMMY_VERSION)
+
+ reason = 'testing in progress'
+
+ # Create a minimal module that we can call
+ module = AnsibleAWSModule(argument_spec=dict())
+
+ with pytest.raises(SystemExit):
+ module.require_botocore_at_least(desired_version, reason=reason)
+ module.exit_json()
+
+ out, _err = capfd.readouterr()
+ return_val = json.loads(out)
+
+ assert return_val.get("exception") is None
+ assert return_val.get("invocation") is not None
+ if at_least:
+ assert return_val.get("failed") is None
+ else:
+ assert return_val.get("failed")
+ # The message is generated by Ansible, don't test for an exact
+ # message
+ assert desired_version in return_val.get("msg")
+ assert " {0}".format(reason) in return_val.get("msg")
+ assert "botocore" in return_val.get("msg")
+ assert return_val.get("boto3_version") == DUMMY_VERSION
+ assert return_val.get("botocore_version") == compare_version
+
+ # ========================================================
+ # Test require_boto3_at_least with reason
+ # ========================================================
+ @pytest.mark.parametrize("stdin, desired_version, compare_version, at_least", [({}, *d) for d in TEST_VERSIONS], indirect=["stdin"])
+ def test_require_boto3_at_least_with_reason(self, monkeypatch, stdin, desired_version, compare_version, at_least, capfd):
+ monkeypatch.setattr(botocore, "__version__", DUMMY_VERSION)
+ # Set boto3 version to a known value (tests are on both sides) to make
+ # sure we're comparing the right library
+ monkeypatch.setattr(boto3, "__version__", compare_version)
+
+ reason = 'testing in progress'
+
+ # Create a minimal module that we can call
+ module = AnsibleAWSModule(argument_spec=dict())
+
+ with pytest.raises(SystemExit):
+ module.require_boto3_at_least(desired_version, reason=reason)
+ module.exit_json()
+
+ out, _err = capfd.readouterr()
+ return_val = json.loads(out)
+
+ assert return_val.get("exception") is None
+ assert return_val.get("invocation") is not None
+ if at_least:
+ assert return_val.get("failed") is None
+ else:
+ assert return_val.get("failed")
+ # The message is generated by Ansible, don't test for an exact
+ # message
+ assert desired_version in return_val.get("msg")
+ assert " {0}".format(reason) in return_val.get("msg")
+ assert "boto3" in return_val.get("msg")
+ assert return_val.get("botocore_version") == DUMMY_VERSION
+ assert return_val.get("boto3_version") == compare_version
diff --git a/ansible_collections/amazon/aws/tests/unit/module_utils/policy/test_compare_policies.py b/ansible_collections/amazon/aws/tests/unit/module_utils/policy/test_compare_policies.py
new file mode 100644
index 000000000..eb6de22db
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/module_utils/policy/test_compare_policies.py
@@ -0,0 +1,339 @@
+# (c) 2017 Red Hat Inc.
+#
+# This file is part of Ansible
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+from ansible_collections.amazon.aws.plugins.module_utils.policy import compare_policies
+
+
+class TestComparePolicy():
+
+ # ========================================================
+ # Setup some initial data that we can use within our tests
+ # ========================================================
+ def setup_method(self):
+ # A pair of simple IAM Trust relationships using bools, the first a
+ # native bool the second a quoted string
+ self.bool_policy_bool = {
+ 'Version': '2012-10-17',
+ 'Statement': [
+ {
+ "Action": "sts:AssumeRole",
+ "Condition": {
+ "Bool": {"aws:MultiFactorAuthPresent": True}
+ },
+ "Effect": "Allow",
+ "Principal": {"AWS": "arn:aws:iam::XXXXXXXXXXXX:root"},
+ "Sid": "AssumeRoleWithBoolean"
+ }
+ ]
+ }
+
+ self.bool_policy_string = {
+ 'Version': '2012-10-17',
+ 'Statement': [
+ {
+ "Action": "sts:AssumeRole",
+ "Condition": {
+ "Bool": {"aws:MultiFactorAuthPresent": "true"}
+ },
+ "Effect": "Allow",
+ "Principal": {"AWS": "arn:aws:iam::XXXXXXXXXXXX:root"},
+ "Sid": "AssumeRoleWithBoolean"
+ }
+ ]
+ }
+
+ # A pair of simple bucket policies using numbers, the first a
+ # native int the second a quoted string
+ self.numeric_policy_number = {
+ 'Version': '2012-10-17',
+ 'Statement': [
+ {
+ "Action": "s3:ListBucket",
+ "Condition": {
+ "NumericLessThanEquals": {"s3:max-keys": 15}
+ },
+ "Effect": "Allow",
+ "Resource": "arn:aws:s3:::examplebucket",
+ "Sid": "s3ListBucketWithNumericLimit"
+ }
+ ]
+ }
+
+ self.numeric_policy_string = {
+ 'Version': '2012-10-17',
+ 'Statement': [
+ {
+ "Action": "s3:ListBucket",
+ "Condition": {
+ "NumericLessThanEquals": {"s3:max-keys": "15"}
+ },
+ "Effect": "Allow",
+ "Resource": "arn:aws:s3:::examplebucket",
+ "Sid": "s3ListBucketWithNumericLimit"
+ }
+ ]
+ }
+
+ self.small_policy_one = {
+ 'Version': '2012-10-17',
+ 'Statement': [
+ {
+ 'Action': 's3:PutObjectAcl',
+ 'Sid': 'AddCannedAcl2',
+ 'Resource': 'arn:aws:s3:::test_policy/*',
+ 'Effect': 'Allow',
+ 'Principal': {'AWS': ['arn:aws:iam::XXXXXXXXXXXX:user/username1', 'arn:aws:iam::XXXXXXXXXXXX:user/username2']}
+ }
+ ]
+ }
+
+ # The same as small_policy_one, except the single resource is in a list and the contents of Statement are jumbled
+ self.small_policy_two = {
+ 'Version': '2012-10-17',
+ 'Statement': [
+ {
+ 'Effect': 'Allow',
+ 'Action': 's3:PutObjectAcl',
+ 'Principal': {'AWS': ['arn:aws:iam::XXXXXXXXXXXX:user/username1', 'arn:aws:iam::XXXXXXXXXXXX:user/username2']},
+ 'Resource': ['arn:aws:s3:::test_policy/*'],
+ 'Sid': 'AddCannedAcl2'
+ }
+ ]
+ }
+
+ self.version_policy_missing = {
+ 'Statement': [
+ {
+ 'Action': 's3:PutObjectAcl',
+ 'Sid': 'AddCannedAcl2',
+ 'Resource': 'arn:aws:s3:::test_policy/*',
+ 'Effect': 'Allow',
+ 'Principal': {'AWS': ['arn:aws:iam::XXXXXXXXXXXX:user/username1', 'arn:aws:iam::XXXXXXXXXXXX:user/username2']}
+ }
+ ]
+ }
+
+ self.version_policy_old = {
+ 'Version': '2008-10-17',
+ 'Statement': [
+ {
+ 'Action': 's3:PutObjectAcl',
+ 'Sid': 'AddCannedAcl2',
+ 'Resource': 'arn:aws:s3:::test_policy/*',
+ 'Effect': 'Allow',
+ 'Principal': {'AWS': ['arn:aws:iam::XXXXXXXXXXXX:user/username1', 'arn:aws:iam::XXXXXXXXXXXX:user/username2']}
+ }
+ ]
+ }
+
+ self.version_policy_new = {
+ 'Version': '2012-10-17',
+ 'Statement': [
+ {
+ 'Action': 's3:PutObjectAcl',
+ 'Sid': 'AddCannedAcl2',
+ 'Resource': 'arn:aws:s3:::test_policy/*',
+ 'Effect': 'Allow',
+ 'Principal': {'AWS': ['arn:aws:iam::XXXXXXXXXXXX:user/username1', 'arn:aws:iam::XXXXXXXXXXXX:user/username2']}
+ }
+ ]
+ }
+
+ self.larger_policy_one = {
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Sid": "Test",
+ "Effect": "Allow",
+ "Principal": {
+ "AWS": [
+ "arn:aws:iam::XXXXXXXXXXXX:user/testuser1",
+ "arn:aws:iam::XXXXXXXXXXXX:user/testuser2"
+ ]
+ },
+ "Action": "s3:PutObjectAcl",
+ "Resource": "arn:aws:s3:::test_policy/*"
+ },
+ {
+ "Effect": "Allow",
+ "Principal": {
+ "AWS": "arn:aws:iam::XXXXXXXXXXXX:user/testuser2"
+ },
+ "Action": [
+ "s3:PutObject",
+ "s3:PutObjectAcl"
+ ],
+ "Resource": "arn:aws:s3:::test_policy/*"
+ }
+ ]
+ }
+
+ # The same as larger_policy_one, except having a list of length 1 and jumbled contents
+ self.larger_policy_two = {
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Principal": {
+ "AWS": ["arn:aws:iam::XXXXXXXXXXXX:user/testuser2"]
+ },
+ "Effect": "Allow",
+ "Resource": "arn:aws:s3:::test_policy/*",
+ "Action": [
+ "s3:PutObject",
+ "s3:PutObjectAcl"
+ ]
+ },
+ {
+ "Action": "s3:PutObjectAcl",
+ "Principal": {
+ "AWS": [
+ "arn:aws:iam::XXXXXXXXXXXX:user/testuser1",
+ "arn:aws:iam::XXXXXXXXXXXX:user/testuser2"
+ ]
+ },
+ "Sid": "Test",
+ "Resource": "arn:aws:s3:::test_policy/*",
+ "Effect": "Allow"
+ }
+ ]
+ }
+
+ # Different than larger_policy_two: a different principal is given
+ self.larger_policy_three = {
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Principal": {
+ "AWS": ["arn:aws:iam::XXXXXXXXXXXX:user/testuser2"]
+ },
+ "Effect": "Allow",
+ "Resource": "arn:aws:s3:::test_policy/*",
+ "Action": [
+ "s3:PutObject",
+ "s3:PutObjectAcl"]
+ },
+ {
+ "Action": "s3:PutObjectAcl",
+ "Principal": {
+ "AWS": [
+ "arn:aws:iam::XXXXXXXXXXXX:user/testuser1",
+ "arn:aws:iam::XXXXXXXXXXXX:user/testuser3"
+ ]
+ },
+ "Sid": "Test",
+ "Resource": "arn:aws:s3:::test_policy/*",
+ "Effect": "Allow"
+ }
+ ]
+ }
+
+ # Minimal policy using wildcarded Principal
+ self.wildcard_policy_one = {
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Principal": {
+ "AWS": ["*"]
+ },
+ "Effect": "Allow",
+ "Resource": "arn:aws:s3:::test_policy/*",
+ "Action": [
+ "s3:PutObject",
+ "s3:PutObjectAcl"]
+ }
+ ]
+ }
+
+ # Minimal policy using wildcarded Principal
+ self.wildcard_policy_two = {
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Principal": "*",
+ "Effect": "Allow",
+ "Resource": "arn:aws:s3:::test_policy/*",
+ "Action": [
+ "s3:PutObject",
+ "s3:PutObjectAcl"]
+ }
+ ]
+ }
+
+ # ========================================================
+ # ec2.compare_policies
+ # ========================================================
+
+ def test_compare_small_policies_without_differences(self):
+ """ Testing two small policies which are identical except for:
+ * The contents of the statement are in different orders
+ * The second policy contains a list of length one whereas in the first it is a string
+ """
+ assert compare_policies(self.small_policy_one, self.small_policy_two) is False
+
+ def test_compare_large_policies_without_differences(self):
+ """ Testing two larger policies which are identical except for:
+ * The statements are in different orders
+ * The contents of the statements are also in different orders
+ * The second contains a list of length one for the Principal whereas in the first it is a string
+ """
+ assert compare_policies(self.larger_policy_one, self.larger_policy_two) is False
+
+ def test_compare_larger_policies_with_difference(self):
+ """ Testing two larger policies which are identical except for:
+ * one different principal
+ """
+ assert compare_policies(self.larger_policy_two, self.larger_policy_three) is True
+
+ def test_compare_smaller_policy_with_larger(self):
+ """ Testing two policies of different sizes """
+ assert compare_policies(self.larger_policy_one, self.small_policy_one) is True
+
+ def test_compare_boolean_policy_bool_and_string_are_equal(self):
+ """ Testing two policies one using a quoted boolean, the other a bool """
+ assert compare_policies(self.bool_policy_string, self.bool_policy_bool) is False
+
+ def test_compare_numeric_policy_number_and_string_are_equal(self):
+ """ Testing two policies one using a quoted number, the other an int """
+ assert compare_policies(self.numeric_policy_string, self.numeric_policy_number) is False
+
+ def test_compare_version_policies_defaults_old(self):
+ """ Testing that a policy without Version is considered identical to one
+ with the 'old' Version (by default)
+ """
+ assert compare_policies(self.version_policy_old, self.version_policy_missing) is False
+ assert compare_policies(self.version_policy_new, self.version_policy_missing) is True
+
+ def test_compare_version_policies_default_disabled(self):
+ """ Testing that a policy without Version not considered identical when default_version=None
+ """
+ assert compare_policies(self.version_policy_missing, self.version_policy_missing, default_version=None) is False
+ assert compare_policies(self.version_policy_old, self.version_policy_missing, default_version=None) is True
+ assert compare_policies(self.version_policy_new, self.version_policy_missing, default_version=None) is True
+
+ def test_compare_version_policies_default_set(self):
+ """ Testing that a policy without Version is only considered identical
+ when default_version="2008-10-17"
+ """
+ assert compare_policies(self.version_policy_missing, self.version_policy_missing, default_version="2012-10-17") is False
+ assert compare_policies(self.version_policy_old, self.version_policy_missing, default_version="2012-10-17") is True
+ assert compare_policies(self.version_policy_old, self.version_policy_missing, default_version="2008-10-17") is False
+ assert compare_policies(self.version_policy_new, self.version_policy_missing, default_version="2012-10-17") is False
+ assert compare_policies(self.version_policy_new, self.version_policy_missing, default_version="2008-10-17") is True
+
+ def test_compare_version_policies_with_none(self):
+ """ Testing that comparing with no policy works
+ """
+ assert compare_policies(self.small_policy_one, None) is True
+ assert compare_policies(None, self.small_policy_one) is True
+ assert compare_policies(None, None) is False
+
+ def test_compare_wildcard_policies_without_differences(self):
+ """ Testing two small wildcard policies which are identical except for:
+ * Principal: "*" vs Principal: ["AWS": "*"]
+ """
+ assert compare_policies(self.wildcard_policy_one, self.wildcard_policy_two) is False
diff --git a/ansible_collections/amazon/aws/tests/unit/module_utils/retries/test_awsretry.py b/ansible_collections/amazon/aws/tests/unit/module_utils/retries/test_awsretry.py
new file mode 100644
index 000000000..e08700382
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/module_utils/retries/test_awsretry.py
@@ -0,0 +1,96 @@
+# -*- coding: utf-8 -*-
+# (c) 2015, Allen Sanabria <asanabria@linuxdynasty.org>
+#
+# This file is part of Ansible
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+try:
+ import botocore
+except ImportError:
+ pass
+
+import pytest
+
+from ansible_collections.amazon.aws.plugins.module_utils.retries import AWSRetry
+from ansible_collections.amazon.aws.plugins.module_utils.botocore import HAS_BOTO3
+
+if not HAS_BOTO3:
+ pytestmark = pytest.mark.skip("test_awsretry.py requires the python modules 'boto3' and 'botocore'")
+
+
+class TestAWSRetry():
+
+ def test_no_failures(self):
+ self.counter = 0
+
+ @AWSRetry.backoff(tries=2, delay=0.1)
+ def no_failures():
+ self.counter += 1
+
+ no_failures()
+ assert self.counter == 1
+
+ def test_extend_boto3_failures(self):
+ self.counter = 0
+ err_response = {'Error': {'Code': 'MalformedPolicyDocument'}}
+
+ @AWSRetry.backoff(tries=2, delay=0.1, catch_extra_error_codes=['MalformedPolicyDocument'])
+ def extend_failures():
+ self.counter += 1
+ if self.counter < 2:
+ raise botocore.exceptions.ClientError(err_response, 'You did something wrong.')
+ else:
+ return 'success'
+
+ result = extend_failures()
+ assert result == 'success'
+ assert self.counter == 2
+
+ def test_retry_once(self):
+ self.counter = 0
+ err_response = {'Error': {'Code': 'InternalFailure'}}
+
+ @AWSRetry.backoff(tries=2, delay=0.1)
+ def retry_once():
+ self.counter += 1
+ if self.counter < 2:
+ raise botocore.exceptions.ClientError(err_response, 'Something went wrong!')
+ else:
+ return 'success'
+
+ result = retry_once()
+ assert result == 'success'
+ assert self.counter == 2
+
+ def test_reached_limit(self):
+ self.counter = 0
+ err_response = {'Error': {'Code': 'RequestLimitExceeded'}}
+
+ @AWSRetry.backoff(tries=4, delay=0.1)
+ def fail():
+ self.counter += 1
+ raise botocore.exceptions.ClientError(err_response, 'toooo fast!!')
+
+ with pytest.raises(botocore.exceptions.ClientError) as context:
+ fail()
+ response = context.value.response
+ assert response['Error']['Code'] == 'RequestLimitExceeded'
+ assert self.counter == 4
+
+ def test_unexpected_exception_does_not_retry(self):
+ self.counter = 0
+ err_response = {'Error': {'Code': 'AuthFailure'}}
+
+ @AWSRetry.backoff(tries=4, delay=0.1)
+ def raise_unexpected_error():
+ self.counter += 1
+ raise botocore.exceptions.ClientError(err_response, 'unexpected error')
+
+ with pytest.raises(botocore.exceptions.ClientError) as context:
+ raise_unexpected_error()
+ response = context.value.response
+ assert response['Error']['Code'] == 'AuthFailure'
+ assert self.counter == 1
diff --git a/ansible_collections/amazon/aws/tests/unit/module_utils/test_elbv2.py b/ansible_collections/amazon/aws/tests/unit/module_utils/test_elbv2.py
new file mode 100644
index 000000000..48c32c78e
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/module_utils/test_elbv2.py
@@ -0,0 +1,214 @@
+#
+# (c) 2021 Red Hat Inc.
+#
+# This file is part of Ansible
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+from ansible_collections.amazon.aws.plugins.module_utils import elbv2
+from ansible_collections.amazon.aws.tests.unit.compat.mock import MagicMock
+
+one_action = [
+ {
+ "ForwardConfig": {
+ "TargetGroupStickinessConfig": {"Enabled": False},
+ "TargetGroups": [
+ {
+ "TargetGroupArn": "arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/my-tg-58045486/5b231e04f663ae21",
+ "Weight": 1,
+ }
+ ],
+ },
+ "TargetGroupArn": "arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/my-tg-58045486/5b231e04f663ae21",
+ "Type": "forward",
+ }
+]
+
+one_action_two_tg = [
+ {
+ "ForwardConfig": {
+ "TargetGroupStickinessConfig": {"Enabled": False},
+ "TargetGroups": [
+ {
+ "TargetGroupArn": "arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/my-tg-58045486/5b231e04f663ae21",
+ "Weight": 1,
+ },
+ {
+ "TargetGroupArn": "arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/my-tg-dadf7b62/be2f50b4041f11ed",
+ "Weight": 1,
+ }
+ ],
+ },
+ "Type": "forward",
+ }
+]
+
+
+def _sort_actions_one_entry():
+ assert elbv2._sort_actions(one_action) == one_action
+
+
+class TestElBV2Utils():
+
+ def setup_method(self):
+ self.connection = MagicMock(name="connection")
+ self.module = MagicMock(name="module")
+
+ self.module.params = dict()
+
+ self.conn_paginator = MagicMock(name="connection.paginator")
+ self.paginate = MagicMock(name="paginator.paginate")
+
+ self.connection.get_paginator.return_value = self.conn_paginator
+ self.conn_paginator.paginate.return_value = self.paginate
+
+ self.loadbalancer = {
+ "Type": "application",
+ "Scheme": "internet-facing",
+ "IpAddressType": "ipv4",
+ "VpcId": "vpc-3ac0fb5f",
+ "AvailabilityZones": [
+ {
+ "ZoneName": "us-west-2a",
+ "SubnetId": "subnet-8360a9e7"
+ },
+ {
+ "ZoneName": "us-west-2b",
+ "SubnetId": "subnet-b7d581c0"
+ }
+ ],
+ "CreatedTime": "2016-03-25T21:26:12.920Z",
+ "CanonicalHostedZoneId": "Z2P70J7EXAMPLE",
+ "DNSName": "my-load-balancer-424835706.us-west-2.elb.amazonaws.com",
+ "SecurityGroups": [
+ "sg-5943793c"
+ ],
+ "LoadBalancerName": "my-load-balancer",
+ "State": {
+ "Code": "active"
+ },
+ "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-west-2:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"
+ }
+ self.paginate.build_full_result.return_value = {
+ 'LoadBalancers': [self.loadbalancer]
+ }
+
+ self.connection.describe_load_balancer_attributes.return_value = {
+ "Attributes": [
+ {
+ "Value": "false",
+ "Key": "access_logs.s3.enabled"
+ },
+ {
+ "Value": "",
+ "Key": "access_logs.s3.bucket"
+ },
+ {
+ "Value": "",
+ "Key": "access_logs.s3.prefix"
+ },
+ {
+ "Value": "60",
+ "Key": "idle_timeout.timeout_seconds"
+ },
+ {
+ "Value": "false",
+ "Key": "deletion_protection.enabled"
+ },
+ {
+ "Value": "true",
+ "Key": "routing.http2.enabled"
+ },
+ {
+ "Value": "defensive",
+ "Key": "routing.http.desync_mitigation_mode"
+ },
+ {
+ "Value": "true",
+ "Key": "routing.http.drop_invalid_header_fields.enabled"
+ },
+ {
+ "Value": "true",
+ "Key": "routing.http.x_amzn_tls_version_and_cipher_suite.enabled"
+ },
+ {
+ "Value": "true",
+ "Key": "routing.http.xff_client_port.enabled"
+ },
+ {
+ "Value": "true",
+ "Key": "waf.fail_open.enabled"
+ },
+ ]
+ }
+ self.connection.describe_tags.return_value = {
+ "TagDescriptions": [
+ {
+ "ResourceArn": "arn:aws:elasticloadbalancing:us-west-2:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188",
+ "Tags": [
+ {
+ "Value": "ansible",
+ "Key": "project"
+ },
+ {
+ "Value": "RedHat",
+ "Key": "company"
+ }
+ ]
+ }
+ ]
+ }
+ self.elbv2obj = elbv2.ElasticLoadBalancerV2(self.connection, self.module)
+
+ # Test the simplest case - Read the ip address type
+ def test_get_elb_ip_address_type(self):
+ # Run module
+ return_value = self.elbv2obj.get_elb_ip_address_type()
+ # check that no method was called and this has been retrieved from elb attributes
+ self.connection.describe_load_balancer_attributes.assert_called_once()
+ self.connection.get_paginator.assert_called_once()
+ self.connection.describe_tags.assert_called_once()
+ self.conn_paginator.paginate.assert_called_once()
+ # assert we got the expected value
+ assert return_value == 'ipv4'
+
+ # Test modify_ip_address_type idempotency
+ def test_modify_ip_address_type_idempotency(self):
+ # Run module
+ self.elbv2obj.modify_ip_address_type("ipv4")
+ # check that no method was called and this has been retrieved from elb attributes
+ self.connection.set_ip_address_type.assert_not_called()
+ # assert we got the expected value
+ assert self.elbv2obj.changed is False
+
+ # Test modify_ip_address_type
+ def test_modify_ip_address_type_update(self):
+ # Run module
+ self.elbv2obj.modify_ip_address_type("dualstack")
+ # check that no method was called and this has been retrieved from elb attributes
+ self.connection.set_ip_address_type.assert_called_once()
+ # assert we got the expected value
+ assert self.elbv2obj.changed is True
+
+ # Test get_elb_attributes
+ def test_get_elb_attributes(self):
+ # Build expected result
+ expected_elb_attributes = {
+ "access_logs_s3_bucket": "",
+ "access_logs_s3_enabled": "false",
+ "access_logs_s3_prefix": "",
+ "deletion_protection_enabled": "false",
+ "idle_timeout_timeout_seconds": "60",
+ "routing_http2_enabled": "true",
+ "routing_http_desync_mitigation_mode": "defensive",
+ "routing_http_drop_invalid_header_fields_enabled": "true",
+ "routing_http_x_amzn_tls_version_and_cipher_suite_enabled": "true",
+ "routing_http_xff_client_port_enabled": "true",
+ "waf_fail_open_enabled": "true"
+ }
+ # Run module
+ actual_elb_attributes = self.elbv2obj.get_elb_attributes()
+ # Assert we got the expected result
+ assert actual_elb_attributes == expected_elb_attributes
diff --git a/ansible_collections/amazon/aws/tests/unit/module_utils/test_iam.py b/ansible_collections/amazon/aws/tests/unit/module_utils/test_iam.py
new file mode 100644
index 000000000..4ce430262
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/module_utils/test_iam.py
@@ -0,0 +1,300 @@
+#
+# (c) 2020 Red Hat Inc.
+#
+# This file is part of Ansible
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import pytest
+
+try:
+ import botocore
+except ImportError:
+ # Handled by HAS_BOTO3
+ pass
+
+from ansible_collections.amazon.aws.tests.unit.compat.mock import MagicMock
+
+import ansible_collections.amazon.aws.plugins.module_utils.iam as utils_iam
+from ansible_collections.amazon.aws.plugins.module_utils.ec2 import HAS_BOTO3
+
+if not HAS_BOTO3:
+ pytestmark = pytest.mark.skip("test_iam.py requires the python modules 'boto3' and 'botocore'")
+
+
+class TestIamUtils():
+
+ def _make_denied_exception(self, partition):
+ return botocore.exceptions.ClientError(
+ {
+ "Error": {
+ "Code": "AccessDenied",
+ "Message": "User: arn:" + partition + ":iam::123456789012:user/ExampleUser "
+ + "is not authorized to perform: iam:GetUser on resource: user ExampleUser"
+ },
+ "ResponseMetadata": {
+ "RequestId": "01234567-89ab-cdef-0123-456789abcdef"
+ }
+ }, 'getUser')
+
+ def _make_unexpected_exception(self):
+ return botocore.exceptions.ClientError(
+ {
+ "Error": {
+ "Code": "SomeThingWentWrong",
+ "Message": "Boom!"
+ },
+ "ResponseMetadata": {
+ "RequestId": "01234567-89ab-cdef-0123-456789abcdef"
+ }
+ }, 'someCall')
+
+ def _make_encoded_exception(self):
+ return botocore.exceptions.ClientError(
+ {
+ "Error": {
+ "Code": "AccessDenied",
+ "Message": "You are not authorized to perform this operation. Encoded authorization failure message: " +
+ "fEwXX6llx3cClm9J4pURgz1XPnJPrYexEbrJcLhFkwygMdOgx_-aEsj0LqRM6Kxt2HVI6prUhDwbJqBo9U2V7iRKZ" +
+ "T6ZdJvHH02cXmD0Jwl5vrTsf0PhBcWYlH5wl2qME7xTfdolEUr4CzumCiti7ETiO-RDdHqWlasBOW5bWsZ4GSpPdU" +
+ "06YAX0TfwVBs48uU5RpCHfz1uhSzez-3elbtp9CmTOHLt5pzJodiovccO55BQKYLPtmJcs6S9YLEEogmpI4Cb1D26" +
+ "fYahDh51jEmaohPnW5pb1nQe2yPEtuIhtRzNjhFCOOMwY5DBzNsymK-Gj6eJLm7FSGHee4AHLU_XmZMe_6bcLAiOx" +
+ "6Zdl65Kdd0hLcpwVxyZMi27HnYjAdqRlV3wuCW2PkhAW14qZQLfiuHZDEwnPe2PBGSlFcCmkQvJvX-YLoA7Uyc2wf" +
+ "NX5RJm38STwfiJSkQaNDhHKTWKiLOsgY4Gze6uZoG7zOcFXFRyaA4cbMmI76uyBO7j-9uQUCtBYqYto8x_9CUJcxI" +
+ "VC5SPG_C1mk-WoDMew01f0qy-bNaCgmJ9TOQGd08FyuT1SaMpCC0gX6mHuOnEgkFw3veBIowMpp9XcM-yc42fmIOp" +
+ "FOdvQO6uE9p55Qc-uXvsDTTvT3A7EeFU8a_YoAIt9UgNYM6VTvoprLz7dBI_P6C-bdPPZCY2amm-dJNVZelT6TbJB" +
+ "H_Vxh0fzeiSUBersy_QzB0moc-vPWgnB-IkgnYLV-4L3K0L2"
+ },
+ "ResponseMetadata": {
+ "RequestId": "01234567-89ab-cdef-0123-456789abcdef"
+ }
+ }, 'someCall')
+
+ def _make_botocore_exception(self):
+ return botocore.exceptions.EndpointConnectionError(endpoint_url='junk.endpoint')
+
+ def setup_method(self):
+ self.sts_client = MagicMock()
+ self.iam_client = MagicMock()
+ self.module = MagicMock()
+ clients = {'sts': self.sts_client, 'iam': self.iam_client}
+
+ def get_client(*args, **kwargs):
+ return clients[args[0]]
+
+ self.module.client.side_effect = get_client
+ self.module.fail_json_aws.side_effect = SystemExit(1)
+ self.module.fail_json.side_effect = SystemExit(2)
+
+ # ========== get_aws_account_id ============
+ # This is just a minimal (compatibility) wrapper around get_aws_account_info
+ # Perform some basic testing and call it a day.
+
+ # Test the simplest case - We're permitted to call GetCallerIdentity
+ def test_get_aws_account_id__caller_success(self):
+ # Prepare
+ self.sts_client.get_caller_identity.side_effect = [{'UserId': 'AIDA12345EXAMPLE54321',
+ 'Account': '123456789012',
+ 'Arn': 'arn:aws:iam::123456789012:user/ExampleUser'}]
+ # Run module
+ return_value = utils_iam.get_aws_account_id(self.module)
+ # Check we only saw the calls we mocked out
+ self.module.client.assert_called_once()
+ self.sts_client.get_caller_identity.assert_called_once()
+ # Check we got the values back we expected.
+ assert return_value == '123456789012'
+
+ # Test the simplest case - We're permitted to call GetCallerIdentity
+ # (China partition)
+ def test_get_aws_account_id__caller_success_cn(self):
+ # Prepare
+ self.sts_client.get_caller_identity.side_effect = [{'UserId': 'AIDA12345EXAMPLE54321',
+ 'Account': '123456789012',
+ 'Arn': 'arn:aws-cn:iam::123456789012:user/ExampleUser'}]
+ # Run module
+ return_value = utils_iam.get_aws_account_id(self.module)
+ # Check we only saw the calls we mocked out
+ self.module.client.assert_called_once()
+ self.sts_client.get_caller_identity.assert_called_once()
+ # Check we got the values back we expected.
+ assert return_value == '123456789012'
+
+ # ========== get_aws_account_info ============
+ # Test the simplest case - We're permitted to call GetCallerIdentity
+ def test_get_aws_account_info__caller_success(self):
+ # Prepare
+ self.sts_client.get_caller_identity.side_effect = [{'UserId': 'AIDA12345EXAMPLE54321',
+ 'Account': '123456789012',
+ 'Arn': 'arn:aws:iam::123456789012:user/ExampleUser'}]
+ # Run module
+ return_value = utils_iam.get_aws_account_info(self.module)
+ # Check we only saw the calls we mocked out
+ self.module.client.assert_called_once()
+ self.sts_client.get_caller_identity.assert_called_once()
+ # Check we got the values back we expected.
+ assert return_value == ('123456789012', 'aws',)
+
+ # (China partition)
+ def test_get_aws_account_info__caller_success_cn(self):
+ # Prepare
+ self.sts_client.get_caller_identity.side_effect = [{'UserId': 'AIDA12345EXAMPLE54321',
+ 'Account': '123456789012',
+ 'Arn': 'arn:aws-cn:iam::123456789012:user/ExampleUser'}]
+ # Run module
+ return_value = utils_iam.get_aws_account_info(self.module)
+ # Check we only saw the calls we mocked out
+ self.module.client.assert_called_once()
+ self.sts_client.get_caller_identity.assert_called_once()
+ # Check we got the values back we expected.
+ assert return_value == ('123456789012', 'aws-cn',)
+
+ # (US-Gov partition)
+ def test_get_aws_account_info__caller_success_gov(self):
+ # Prepare
+ self.sts_client.get_caller_identity.side_effect = [{'UserId': 'AIDA12345EXAMPLE54321',
+ 'Account': '123456789012',
+ 'Arn': 'arn:aws-us-gov:iam::123456789012:user/ExampleUser'}]
+ # Run module
+ return_value = utils_iam.get_aws_account_info(self.module)
+ # Check we only saw the calls we mocked out
+ self.module.client.assert_called_once()
+ self.sts_client.get_caller_identity.assert_called_once()
+ # Check we got the values back we expected.
+ assert return_value == ('123456789012', 'aws-us-gov',)
+
+ # If sts:get_caller_identity fails (most likely something wierd on the
+ # client side), then try a few extra options.
+ # Test response if STS fails and we need to fall back to GetUser
+ def test_get_aws_account_info__user_success(self):
+ # Prepare
+ self.sts_client.get_caller_identity.side_effect = [self._make_botocore_exception()]
+ self.iam_client.get_user.side_effect = [{"User": {"Path": "/", "UserName": "ExampleUser", "UserId": "AIDA12345EXAMPLE54321",
+ "Arn": "arn:aws:iam::123456789012:user/ExampleUser", "CreateDate": "2020-09-08T14:04:32Z"}}]
+ # Run module
+ return_value = utils_iam.get_aws_account_info(self.module)
+ # Check we only saw the calls we mocked out
+ assert self.module.client.call_count == 2
+ self.sts_client.get_caller_identity.assert_called_once()
+ self.iam_client.get_user.assert_called_once()
+ # Check we got the values back we expected.
+ assert return_value == ('123456789012', 'aws',)
+
+ # (China partition)
+ def test_get_aws_account_info__user_success_cn(self):
+ # Prepare
+ self.sts_client.get_caller_identity.side_effect = [self._make_botocore_exception()]
+ self.iam_client.get_user.side_effect = [{"User": {"Path": "/", "UserName": "ExampleUser", "UserId": "AIDA12345EXAMPLE54321",
+ "Arn": "arn:aws-cn:iam::123456789012:user/ExampleUser", "CreateDate": "2020-09-08T14:04:32Z"}}]
+ # Run module
+ return_value = utils_iam.get_aws_account_info(self.module)
+ # Check we only saw the calls we mocked out
+ assert self.module.client.call_count == 2
+ self.sts_client.get_caller_identity.assert_called_once()
+ self.iam_client.get_user.assert_called_once()
+ # Check we got the values back we expected.
+ assert return_value == ('123456789012', 'aws-cn',)
+
+ # (US-Gov partition)
+ def test_get_aws_account_info__user_success_gov(self):
+ # Prepare
+ self.sts_client.get_caller_identity.side_effect = [self._make_botocore_exception()]
+ self.iam_client.get_user.side_effect = [{"User": {"Path": "/", "UserName": "ExampleUser", "UserId": "AIDA12345EXAMPLE54321",
+ "Arn": "arn:aws-us-gov:iam::123456789012:user/ExampleUser", "CreateDate": "2020-09-08T14:04:32Z"}}]
+ # Run module
+ return_value = utils_iam.get_aws_account_info(self.module)
+ # Check we only saw the calls we mocked out
+ assert self.module.client.call_count == 2
+ self.sts_client.get_caller_identity.assert_called_once()
+ self.iam_client.get_user.assert_called_once()
+ # Check we got the values back we expected.
+ assert return_value == ('123456789012', 'aws-us-gov',)
+
+ # Test response if STS and IAM fails and we need to fall back to the denial message
+ def test_get_aws_account_info__user_denied(self):
+ # Prepare
+ self.sts_client.get_caller_identity.side_effect = [self._make_botocore_exception()]
+ self.iam_client.get_user.side_effect = [self._make_denied_exception('aws')]
+ # Run module
+ return_value = utils_iam.get_aws_account_info(self.module)
+ # Check we only saw the calls we mocked out
+ assert self.module.client.call_count == 2
+ self.sts_client.get_caller_identity.assert_called_once()
+ self.iam_client.get_user.assert_called_once()
+ # Check we got the values back we expected.
+ assert return_value == ('123456789012', 'aws',)
+
+ # (China partition)
+ def test_get_aws_account_info__user_denied_cn(self):
+ # Prepare
+ self.sts_client.get_caller_identity.side_effect = [self._make_botocore_exception()]
+ self.iam_client.get_user.side_effect = [self._make_denied_exception('aws-cn')]
+ # Run module
+ return_value = utils_iam.get_aws_account_info(self.module)
+ # Check we only saw the calls we mocked out
+ assert self.module.client.call_count == 2
+ self.sts_client.get_caller_identity.assert_called_once()
+ self.iam_client.get_user.assert_called_once()
+ # Check we got the values back we expected.
+ assert return_value == ('123456789012', 'aws-cn',)
+
+ # (US-Gov partition)
+ def test_get_aws_account_info__user_denied_gov(self):
+ # Prepare
+ self.sts_client.get_caller_identity.side_effect = [self._make_botocore_exception()]
+ self.iam_client.get_user.side_effect = [self._make_denied_exception('aws-us-gov')]
+ # Run module
+ return_value = utils_iam.get_aws_account_info(self.module)
+ # Check we only saw the calls we mocked out
+ assert self.module.client.call_count == 2
+ self.sts_client.get_caller_identity.assert_called_once()
+ self.iam_client.get_user.assert_called_once()
+ # Check we got the values back we expected.
+ assert return_value == ('123456789012', 'aws-us-gov',)
+
+ # Test that we fail gracefully if Boto throws exceptions at us...
+ def test_get_aws_account_info__boto_failures(self):
+ # Prepare
+ self.sts_client.get_caller_identity.side_effect = [self._make_botocore_exception()]
+ self.iam_client.get_user.side_effect = [self._make_botocore_exception()]
+ # Run module
+ with pytest.raises(SystemExit) as e:
+ utils_iam.get_aws_account_info(self.module)
+ # Check we only saw the calls we mocked out
+ assert self.module.client.call_count == 2
+ self.sts_client.get_caller_identity.assert_called_once()
+ self.iam_client.get_user.assert_called_once()
+ # Check we got the values back we expected.
+ assert e.type == SystemExit
+ assert e.value.code == 1 # 1 == fail_json_aws
+
+ def test_get_aws_account_info__client_failures(self):
+ # Prepare
+ self.sts_client.get_caller_identity.side_effect = [self._make_unexpected_exception()]
+ self.iam_client.get_user.side_effect = [self._make_unexpected_exception()]
+ # Run module
+ with pytest.raises(SystemExit) as e:
+ utils_iam.get_aws_account_info(self.module)
+ # Check we only saw the calls we mocked out
+ assert self.module.client.call_count == 2
+ self.sts_client.get_caller_identity.assert_called_once()
+ self.iam_client.get_user.assert_called_once()
+ # Check we got the values back we expected.
+ assert e.type == SystemExit
+ assert e.value.code == 1 # 1 == fail_json_aws
+
+ def test_get_aws_account_info__encoded_failures(self):
+ # Prepare
+ self.sts_client.get_caller_identity.side_effect = [self._make_encoded_exception()]
+ self.iam_client.get_user.side_effect = [self._make_encoded_exception()]
+ # Run module
+ with pytest.raises(SystemExit) as e:
+ utils_iam.get_aws_account_info(self.module)
+ # Check we only saw the calls we mocked out
+ assert self.module.client.call_count == 2
+ self.sts_client.get_caller_identity.assert_called_once()
+ self.iam_client.get_user.assert_called_once()
+ # Check we got the values back we expected.
+ assert e.type == SystemExit
+ assert e.value.code == 1 # 1 == fail_json (we couldn't parse the AccessDenied errors)
diff --git a/ansible_collections/amazon/aws/tests/unit/module_utils/test_rds.py b/ansible_collections/amazon/aws/tests/unit/module_utils/test_rds.py
new file mode 100644
index 000000000..9d96d44a8
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/module_utils/test_rds.py
@@ -0,0 +1,805 @@
+# (c) 2021 Red Hat Inc.
+#
+# This file is part of Ansible
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import absolute_import, division, print_function
+__metaclass__ = type
+
+import sys
+import pytest
+
+if sys.version_info < (3, 7):
+ pytest.skip("contextlib.nullcontext was introduced in Python 3.7", allow_module_level=True)
+
+from contextlib import nullcontext
+
+try:
+ import botocore
+except ImportError:
+ # Handled by HAS_BOTO3
+ pass
+
+from ansible_collections.amazon.aws.tests.unit.compat.mock import MagicMock
+
+from ansible_collections.amazon.aws.plugins.module_utils import rds
+from ansible_collections.amazon.aws.plugins.module_utils.botocore import HAS_BOTO3
+
+if not HAS_BOTO3:
+ pytestmark = pytest.mark.skip("test_rds.py requires the python modules 'boto3' and 'botocore'")
+
+
+def expected(x):
+ return x, nullcontext()
+
+
+def error(*args, **kwargs):
+ return MagicMock(), pytest.raises(*args, **kwargs)
+
+
+def build_exception(
+ operation_name, code=None, message=None, http_status_code=None, error=True
+):
+ # Support skipping the test is botocore isn't installed
+ # (called by parametrize before skip is evaluated)
+ if not HAS_BOTO3:
+ return Exception('MissingBotoCore')
+ response = {}
+ if error or code or message:
+ response["Error"] = {}
+ if code:
+ response["Error"]["Code"] = code
+ if message:
+ response["Error"]["Message"] = message
+ if http_status_code:
+ response["ResponseMetadata"] = {"HTTPStatusCode": http_status_code}
+
+ return botocore.exceptions.ClientError(response, operation_name)
+
+
+@pytest.mark.parametrize("waiter_name", ["", "db_snapshot_available"])
+def test__wait_for_instance_snapshot_status(waiter_name):
+ rds.wait_for_instance_snapshot_status(MagicMock(), MagicMock(), "test", waiter_name)
+
+
+@pytest.mark.parametrize("waiter_name", ["", "db_cluster_snapshot_available"])
+def test__wait_for_cluster_snapshot_status(waiter_name):
+ rds.wait_for_cluster_snapshot_status(MagicMock(), MagicMock(), "test", waiter_name)
+
+
+@pytest.mark.parametrize(
+ "input, expected",
+ [
+ (
+ "db_snapshot_available",
+ "Failed to wait for DB snapshot test to be available",
+ ),
+ (
+ "db_snapshot_deleted",
+ "Failed to wait for DB snapshot test to be deleted"),
+ ],
+)
+def test__wait_for_instance_snapshot_status_failed(input, expected):
+ spec = {"get_waiter.side_effect": [botocore.exceptions.WaiterError(None, None, None)]}
+ client = MagicMock(**spec)
+ module = MagicMock()
+
+ rds.wait_for_instance_snapshot_status(client, module, "test", input)
+ module.fail_json_aws.assert_called_once
+ module.fail_json_aws.call_args[1]["msg"] == expected
+
+
+@pytest.mark.parametrize(
+ "input, expected",
+ [
+ (
+ "db_cluster_snapshot_available",
+ "Failed to wait for DB cluster snapshot test to be available",
+ ),
+ (
+ "db_cluster_snapshot_deleted",
+ "Failed to wait for DB cluster snapshot test to be deleted",
+ ),
+ ],
+)
+def test__wait_for_cluster_snapshot_status_failed(input, expected):
+ spec = {"get_waiter.side_effect": [botocore.exceptions.WaiterError(None, None, None)]}
+ client = MagicMock(**spec)
+ module = MagicMock()
+
+ rds.wait_for_cluster_snapshot_status(client, module, "test", input)
+ module.fail_json_aws.assert_called_once
+ module.fail_json_aws.call_args[1]["msg"] == expected
+
+
+@pytest.mark.parametrize(
+ "method_name, params, expected, error",
+ [
+ (
+ "delete_db_cluster",
+ {
+ "new_db_cluster_identifier": "test",
+ },
+ *expected(
+ rds.Boto3ClientMethod(
+ name="delete_db_cluster",
+ waiter="cluster_deleted",
+ operation_description="delete DB cluster",
+ resource='cluster',
+ retry_codes=['InvalidDBClusterState']
+ )
+ ),
+ ),
+ (
+ "create_db_cluster",
+ {
+ "new_db_cluster_identifier": "test",
+ },
+ *expected(
+ rds.Boto3ClientMethod(
+ name="create_db_cluster",
+ waiter="cluster_available",
+ operation_description="create DB cluster",
+ resource='cluster',
+ retry_codes=['InvalidDBClusterState']
+ )
+ ),
+ ),
+ (
+ "restore_db_cluster_from_snapshot",
+ {
+ "new_db_cluster_identifier": "test",
+ },
+ *expected(
+ rds.Boto3ClientMethod(
+ name="restore_db_cluster_from_snapshot",
+ waiter="cluster_available",
+ operation_description="restore DB cluster from snapshot",
+ resource='cluster',
+ retry_codes=['InvalidDBClusterSnapshotState']
+ )
+ ),
+ ),
+ (
+ "modify_db_cluster",
+ {
+ "new_db_cluster_identifier": "test",
+ },
+ *expected(
+ rds.Boto3ClientMethod(
+ name="modify_db_cluster",
+ waiter="cluster_available",
+ operation_description="modify DB cluster",
+ resource='cluster',
+ retry_codes=['InvalidDBClusterState']
+ )
+ ),
+ ),
+ (
+ "list_tags_for_resource",
+ {
+ "new_db_cluster_identifier": "test",
+ },
+ *expected(
+ rds.Boto3ClientMethod(
+ name="list_tags_for_resource",
+ waiter="cluster_available",
+ operation_description="list tags for resource",
+ resource='cluster',
+ retry_codes=['InvalidDBClusterState']
+ )
+ ),
+ ),
+ (
+ "fake_method",
+ {
+ "wait": False
+ },
+ *expected(
+ rds.Boto3ClientMethod(
+ name="fake_method",
+ waiter="",
+ operation_description="fake method",
+ resource='',
+ retry_codes=[]
+ )
+ ),
+ ),
+ (
+ "fake_method",
+ {
+ "wait": True
+ },
+ *error(
+ NotImplementedError,
+ match="method fake_method hasn't been added to the list of accepted methods to use a waiter in module_utils/rds.py",
+ ),
+ ),
+ ],
+)
+def test__get_rds_method_attribute_cluster(method_name, params, expected, error):
+ module = MagicMock()
+ module.params = params
+ with error:
+ assert rds.get_rds_method_attribute(method_name, module) == expected
+
+
+@pytest.mark.parametrize(
+ "method_name, params, expected, error",
+ [
+ (
+ "delete_db_instance",
+ {
+ "new_db_instance_identifier": "test",
+ },
+ *expected(
+ rds.Boto3ClientMethod(
+ name="delete_db_instance",
+ waiter="db_instance_deleted",
+ operation_description="delete DB instance",
+ resource='instance',
+ retry_codes=['InvalidDBInstanceState', 'InvalidDBSecurityGroupState']
+ )
+ ),
+ ),
+ (
+ "create_db_instance",
+ {
+ "new_db_instance_identifier": "test",
+ },
+ *expected(
+ rds.Boto3ClientMethod(
+ name="create_db_instance",
+ waiter="db_instance_available",
+ operation_description="create DB instance",
+ resource='instance',
+ retry_codes=['InvalidDBInstanceState', 'InvalidDBSecurityGroupState']
+ )
+ ),
+ ),
+ (
+ "stop_db_instance",
+ {
+ "new_db_instance_identifier": "test",
+ },
+ *expected(
+ rds.Boto3ClientMethod(
+ name="stop_db_instance",
+ waiter="db_instance_stopped",
+ operation_description="stop DB instance",
+ resource='instance',
+ retry_codes=['InvalidDBInstanceState', 'InvalidDBSecurityGroupState']
+ )
+ ),
+ ),
+ (
+ "promote_read_replica",
+ {
+ "new_db_instance_identifier": "test",
+ },
+ *expected(
+ rds.Boto3ClientMethod(
+ name="promote_read_replica",
+ waiter="read_replica_promoted",
+ operation_description="promote read replica",
+ resource='instance',
+ retry_codes=['InvalidDBInstanceState', 'InvalidDBSecurityGroupState']
+ )
+ ),
+ ),
+ (
+ "restore_db_instance_from_db_snapshot",
+ {
+ "new_db_instance_identifier": "test",
+ },
+ *expected(
+ rds.Boto3ClientMethod(
+ name="restore_db_instance_from_db_snapshot",
+ waiter="db_instance_available",
+ operation_description="restore DB instance from DB snapshot",
+ resource='instance',
+ retry_codes=['InvalidDBSnapshotState']
+ )
+ ),
+ ),
+ (
+ "modify_db_instance",
+ {
+ "new_db_instance_identifier": "test",
+ },
+ *expected(
+ rds.Boto3ClientMethod(
+ name="modify_db_instance",
+ waiter="db_instance_available",
+ operation_description="modify DB instance",
+ resource='instance',
+ retry_codes=['InvalidDBInstanceState', 'InvalidDBSecurityGroupState']
+ )
+ ),
+ ),
+ (
+ "add_role_to_db_instance",
+ {
+ "new_db_instance_identifier": "test",
+ },
+ *expected(
+ rds.Boto3ClientMethod(
+ name="add_role_to_db_instance",
+ waiter="role_associated",
+ operation_description="add role to DB instance",
+ resource='instance',
+ retry_codes=['InvalidDBInstanceState', 'InvalidDBSecurityGroupState']
+ )
+ ),
+ ),
+ (
+ "remove_role_from_db_instance",
+ {
+ "new_db_instance_identifier": "test",
+ },
+ *expected(
+ rds.Boto3ClientMethod(
+ name="remove_role_from_db_instance",
+ waiter="role_disassociated",
+ operation_description="remove role from DB instance",
+ resource='instance',
+ retry_codes=['InvalidDBInstanceState', 'InvalidDBSecurityGroupState']
+ )
+ ),
+ ),
+ (
+ "list_tags_for_resource",
+ {
+ "new_db_instance_identifier": "test",
+ },
+ *expected(
+ rds.Boto3ClientMethod(
+ name="list_tags_for_resource",
+ waiter="db_instance_available",
+ operation_description="list tags for resource",
+ resource='instance',
+ retry_codes=['InvalidDBInstanceState', 'InvalidDBSecurityGroupState']
+ )
+ ),
+ ),
+ (
+ "fake_method",
+ {
+ "wait": False
+ },
+ *expected(
+ rds.Boto3ClientMethod(
+ name="fake_method",
+ waiter="",
+ operation_description="fake method",
+ resource='',
+ retry_codes=[]
+ )
+ ),
+ ),
+ (
+ "fake_method",
+ {
+ "wait": True
+ },
+ *error(
+ NotImplementedError,
+ match="method fake_method hasn't been added to the list of accepted methods to use a waiter in module_utils/rds.py",
+ ),
+ ),
+ ],
+)
+def test__get_rds_method_attribute_instance(method_name, params, expected, error):
+ module = MagicMock()
+ module.params = params
+ with error:
+ assert rds.get_rds_method_attribute(method_name, module) == expected
+
+
+@pytest.mark.parametrize(
+ "method_name, params, expected, error",
+ [
+ (
+ "delete_db_snapshot",
+ {
+ "db_snapshot_identifier": "test",
+ },
+ *expected(
+ rds.Boto3ClientMethod(
+ name="delete_db_snapshot",
+ waiter="db_snapshot_deleted",
+ operation_description="delete DB snapshot",
+ resource='instance_snapshot',
+ retry_codes=['InvalidDBSnapshotState']
+ )
+ ),
+ ),
+ (
+ "create_db_snapshot",
+ {
+ "db_snapshot_identifier": "test",
+ },
+ *expected(
+ rds.Boto3ClientMethod(
+ name="create_db_snapshot",
+ waiter="db_snapshot_available",
+ operation_description="create DB snapshot",
+ resource='instance_snapshot',
+ retry_codes=['InvalidDBInstanceState']
+ )
+ ),
+ ),
+ (
+ "copy_db_snapshot",
+ {
+ "source_db_snapshot_identifier": "test",
+ "db_snapshot_identifier": "test-copy"
+ },
+ *expected(
+ rds.Boto3ClientMethod(
+ name="copy_db_snapshot",
+ waiter="db_snapshot_available",
+ operation_description="copy DB snapshot",
+ resource='instance_snapshot',
+ retry_codes=['InvalidDBSnapshotState']
+ )
+ ),
+ ),
+ (
+ "list_tags_for_resource",
+ {
+ "db_snapshot_identifier": "test",
+ },
+ *expected(
+ rds.Boto3ClientMethod(
+ name="list_tags_for_resource",
+ waiter="db_snapshot_available",
+ operation_description="list tags for resource",
+ resource='instance_snapshot',
+ retry_codes=['InvalidDBSnapshotState']
+ )
+ ),
+ ),
+ (
+ "delete_db_cluster_snapshot",
+ {
+ "db_cluster_snapshot_identifier": "test",
+ },
+ *expected(
+ rds.Boto3ClientMethod(
+ name="delete_db_cluster_snapshot",
+ waiter="db_cluster_snapshot_deleted",
+ operation_description="delete DB cluster snapshot",
+ resource='cluster_snapshot',
+ retry_codes=['InvalidDBClusterSnapshotState']
+ )
+ ),
+ ),
+ (
+ "create_db_cluster_snapshot",
+ {
+ "db_cluster_snapshot_identifier": "test",
+ },
+ *expected(
+ rds.Boto3ClientMethod(
+ name="create_db_cluster_snapshot",
+ waiter="db_cluster_snapshot_available",
+ operation_description="create DB cluster snapshot",
+ resource='cluster_snapshot',
+ retry_codes=['InvalidDBClusterState']
+ )
+ ),
+ ),
+ (
+ "copy_db_cluster_snapshot",
+ {
+ "source_db_cluster_snapshot_identifier": "test",
+ "db_cluster_snapshot_identifier": "test-copy"
+ },
+ *expected(
+ rds.Boto3ClientMethod(
+ name="copy_db_cluster_snapshot",
+ waiter="db_cluster_snapshot_available",
+ operation_description="copy DB cluster snapshot",
+ resource='cluster_snapshot',
+ retry_codes=['InvalidDBClusterSnapshotState']
+ )
+ ),
+ ),
+ (
+ "list_tags_for_resource",
+ {
+ "db_cluster_snapshot_identifier": "test",
+ },
+ *expected(
+ rds.Boto3ClientMethod(
+ name="list_tags_for_resource",
+ waiter="db_cluster_snapshot_available",
+ operation_description="list tags for resource",
+ resource='cluster_snapshot',
+ retry_codes=['InvalidDBClusterSnapshotState']
+ )
+ ),
+ ),
+ (
+ "fake_method",
+ {
+ "wait": False
+ },
+ *expected(
+ rds.Boto3ClientMethod(
+ name="fake_method",
+ waiter="",
+ operation_description="fake method",
+ resource='',
+ retry_codes=[]
+ )
+ ),
+ ),
+ (
+ "fake_method",
+ {
+ "wait": True
+ },
+ *error(
+ NotImplementedError,
+ match="method fake_method hasn't been added to the list of accepted methods to use a waiter in module_utils/rds.py",
+ ),
+ ),
+ ],
+)
+def test__get_rds_method_attribute_snapshot(method_name, params, expected, error):
+ module = MagicMock()
+ module.params = params
+ with error:
+ assert rds.get_rds_method_attribute(method_name, module) == expected
+
+
+@pytest.mark.parametrize(
+ "method_name, params, expected",
+ [
+ (
+ "create_db_snapshot",
+ {
+ "db_snapshot_identifier": "test"
+ },
+ "test"
+ ),
+ (
+ "create_db_snapshot",
+ {
+ "db_snapshot_identifier": "test",
+ "apply_immediately": True
+ },
+ "test",
+ ),
+ (
+ "create_db_instance",
+ {
+ "db_instance_identifier": "test",
+ "new_db_instance_identifier": "test_updated",
+ },
+ "test",
+ ),
+ (
+ "create_db_snapshot",
+ {
+ "db_snapshot_identifier": "test",
+ "apply_immediately": True
+ },
+ "test",
+ ),
+ (
+ "create_db_instance",
+ {
+ "db_instance_identifier": "test",
+ "new_db_instance_identifier": "test_updated",
+ "apply_immediately": True,
+ },
+ "test_updated",
+ ),
+ (
+ "create_db_cluster",
+ {
+ "db_cluster_identifier": "test",
+ "new_db_cluster_identifier": "test_updated",
+ },
+ "test",
+ ),
+ (
+ "create_db_snapshot",
+ {
+ "db_snapshot_identifier": "test",
+ "apply_immediately": True
+ },
+ "test",
+ ),
+ (
+ "create_db_cluster",
+ {
+ "db_cluster_identifier": "test",
+ "new_db_cluster_identifier": "test_updated",
+ "apply_immediately": True,
+ },
+ "test_updated",
+ ),
+ ],
+)
+def test__get_final_identifier(method_name, params, expected):
+ module = MagicMock()
+ module.params = params
+ module.check_mode = False
+
+ assert rds.get_final_identifier(method_name, module) == expected
+
+
+@pytest.mark.parametrize(
+ "method_name, exception, expected",
+ [
+ (
+ "modify_db_instance",
+ build_exception(
+ "modify_db_instance",
+ code="InvalidParameterCombination",
+ message="No modifications were requested",
+ ),
+ False,
+ ),
+ (
+ "promote_read_replica",
+ build_exception(
+ "promote_read_replica",
+ code="InvalidDBInstanceState",
+ message="DB Instance is not a read replica",
+ ),
+ False,
+ ),
+ (
+ "promote_read_replica_db_cluster",
+ build_exception(
+ "promote_read_replica_db_cluster",
+ code="InvalidDBClusterStateFault",
+ message="DB Cluster that is not a read replica",
+ ),
+ False,
+ ),
+ ],
+)
+def test__handle_errors(method_name, exception, expected):
+ assert rds.handle_errors(MagicMock(), exception, method_name, {}) == expected
+
+
+@pytest.mark.parametrize(
+ "method_name, exception, expected, error",
+ [
+ (
+ "modify_db_instance",
+ build_exception(
+ "modify_db_instance",
+ code="InvalidParameterCombination",
+ message="ModifyDbCluster API",
+ ),
+ *expected(
+ "It appears you are trying to modify attributes that are managed at the cluster level. Please see rds_cluster"
+ ),
+ ),
+ (
+ "modify_db_instance",
+ build_exception("modify_db_instance", code="InvalidParameterCombination"),
+ *error(
+ NotImplementedError,
+ match="method modify_db_instance hasn't been added to the list of accepted methods to use a waiter in module_utils/rds.py",
+ ),
+ ),
+ (
+ "promote_read_replica",
+ build_exception("promote_read_replica", code="InvalidDBInstanceState"),
+ *error(
+ NotImplementedError,
+ match="method promote_read_replica hasn't been added to the list of accepted methods to use a waiter in module_utils/rds.py",
+ ),
+ ),
+ (
+ "promote_read_replica_db_cluster",
+ build_exception(
+ "promote_read_replica_db_cluster", code="InvalidDBClusterStateFault"
+ ),
+ *error(
+ NotImplementedError,
+ match="method promote_read_replica_db_cluster hasn't been added to the list of accepted methods to use a waiter in module_utils/rds.py",
+ ),
+ ),
+ (
+ "create_db_cluster",
+ build_exception("create_db_cluster", code="InvalidParameterValue"),
+ *expected(
+ "DB engine fake_engine should be one of aurora, aurora-mysql, aurora-postgresql"
+ ),
+ ),
+ ],
+)
+def test__handle_errors_failed(method_name, exception, expected, error):
+ module = MagicMock()
+
+ with error:
+ rds.handle_errors(module, exception, method_name, {"Engine": "fake_engine"})
+ module.fail_json_aws.assert_called_once
+ module.fail_json_aws.call_args[1]["msg"] == expected
+
+
+class TestRdsUtils():
+
+ # ========================================================
+ # Setup some initial data that we can use within our tests
+ # ========================================================
+ def setup_method(self):
+ self.target_role_list = [
+ {
+ 'role_arn': 'role_won',
+ 'feature_name': 's3Export'
+ },
+ {
+ 'role_arn': 'role_too',
+ 'feature_name': 'Lambda'
+ },
+ {
+ 'role_arn': 'role_thrie',
+ 'feature_name': 's3Import'
+ }
+ ]
+
+ # ========================================================
+ # rds.compare_iam_roles
+ # ========================================================
+
+ def test_compare_iam_roles_equal(self):
+ existing_list = self.target_role_list
+ roles_to_add, roles_to_delete = rds.compare_iam_roles(existing_list, self.target_role_list, purge_roles=False)
+ assert [] == roles_to_add
+ assert [] == roles_to_delete
+ roles_to_add, roles_to_delete = rds.compare_iam_roles(existing_list, self.target_role_list, purge_roles=True)
+ assert [] == roles_to_add
+ assert [] == roles_to_delete
+
+ def test_compare_iam_roles_empty_arr_existing(self):
+ roles_to_add, roles_to_delete = rds.compare_iam_roles([], self.target_role_list, purge_roles=False)
+ assert self.target_role_list == roles_to_add
+ assert [] == roles_to_delete
+ roles_to_add, roles_to_delete = rds.compare_iam_roles([], self.target_role_list, purge_roles=True)
+ assert self.target_role_list, roles_to_add
+ assert [] == roles_to_delete
+
+ def test_compare_iam_roles_empty_arr_target(self):
+ existing_list = self.target_role_list
+ roles_to_add, roles_to_delete = rds.compare_iam_roles(existing_list, [], purge_roles=False)
+ assert [] == roles_to_add
+ assert [] == roles_to_delete
+ roles_to_add, roles_to_delete = rds.compare_iam_roles(existing_list, [], purge_roles=True)
+ assert [] == roles_to_add
+ assert self.target_role_list == roles_to_delete
+
+ def test_compare_iam_roles_different(self):
+ existing_list = [
+ {
+ 'role_arn': 'role_wonn',
+ 'feature_name': 's3Export'
+ }]
+ roles_to_add, roles_to_delete = rds.compare_iam_roles(existing_list, self.target_role_list, purge_roles=False)
+ assert self.target_role_list == roles_to_add
+ assert [] == roles_to_delete
+ roles_to_add, roles_to_delete = rds.compare_iam_roles(existing_list, self.target_role_list, purge_roles=True)
+ assert self.target_role_list == roles_to_add
+ assert existing_list == roles_to_delete
+
+ existing_list = self.target_role_list.copy()
+ self.target_role_list = [
+ {
+ 'role_arn': 'role_wonn',
+ 'feature_name': 's3Export'
+ }]
+ roles_to_add, roles_to_delete = rds.compare_iam_roles(existing_list, self.target_role_list, purge_roles=False)
+ assert self.target_role_list == roles_to_add
+ assert [] == roles_to_delete
+ roles_to_add, roles_to_delete = rds.compare_iam_roles(existing_list, self.target_role_list, purge_roles=True)
+ assert self.target_role_list == roles_to_add
+ assert existing_list == roles_to_delete
diff --git a/ansible_collections/amazon/aws/tests/unit/module_utils/test_s3.py b/ansible_collections/amazon/aws/tests/unit/module_utils/test_s3.py
new file mode 100644
index 000000000..42c8ecfd0
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/module_utils/test_s3.py
@@ -0,0 +1,86 @@
+#
+# (c) 2021 Red Hat Inc.
+#
+# This file is part of Ansible
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+
+__metaclass__ = type
+
+from ansible_collections.amazon.aws.tests.unit.compat.mock import MagicMock
+from ansible_collections.amazon.aws.plugins.module_utils import s3
+from ansible.module_utils.basic import AnsibleModule
+
+import pytest
+
+
+class FakeAnsibleModule(AnsibleModule):
+ def __init__(self):
+ pass
+
+
+def test_calculate_etag_single_part(tmp_path_factory):
+ module = FakeAnsibleModule()
+ my_image = tmp_path_factory.mktemp("data") / "my.txt"
+ my_image.write_text("Hello World!")
+
+ etag = s3.calculate_etag(
+ module, str(my_image), etag="", s3=None, bucket=None, obj=None
+ )
+ assert etag == '"ed076287532e86365e841e92bfc50d8c"'
+
+
+def test_calculate_etag_multi_part(tmp_path_factory):
+ module = FakeAnsibleModule()
+ my_image = tmp_path_factory.mktemp("data") / "my.txt"
+ my_image.write_text("Hello World!" * 1000)
+
+ mocked_s3 = MagicMock()
+ mocked_s3.head_object.side_effect = [{"ContentLength": "1000"} for _i in range(12)]
+
+ etag = s3.calculate_etag(
+ module,
+ str(my_image),
+ etag='"f20e84ac3d0c33cea77b3f29e3323a09-12"',
+ s3=mocked_s3,
+ bucket="my-bucket",
+ obj="my-obj",
+ )
+ assert etag == '"f20e84ac3d0c33cea77b3f29e3323a09-12"'
+ mocked_s3.head_object.assert_called_with(
+ Bucket="my-bucket", Key="my-obj", PartNumber=12
+ )
+
+
+def test_validate_bucket_name():
+ module = MagicMock()
+
+ assert s3.validate_bucket_name(module, "docexamplebucket1") is True
+ assert not module.fail_json.called
+ assert s3.validate_bucket_name(module, "log-delivery-march-2020") is True
+ assert not module.fail_json.called
+ assert s3.validate_bucket_name(module, "my-hosted-content") is True
+ assert not module.fail_json.called
+
+ assert s3.validate_bucket_name(module, "docexamplewebsite.com") is True
+ assert not module.fail_json.called
+ assert s3.validate_bucket_name(module, "www.docexamplewebsite.com") is True
+ assert not module.fail_json.called
+ assert s3.validate_bucket_name(module, "my.example.s3.bucket") is True
+ assert not module.fail_json.called
+ assert s3.validate_bucket_name(module, "doc") is True
+ assert not module.fail_json.called
+
+ module.fail_json.reset_mock()
+ s3.validate_bucket_name(module, "doc_example_bucket")
+ assert module.fail_json.called
+
+ module.fail_json.reset_mock()
+ s3.validate_bucket_name(module, "DocExampleBucket")
+ assert module.fail_json.called
+ module.fail_json.reset_mock()
+ s3.validate_bucket_name(module, "doc-example-bucket-")
+ assert module.fail_json.called
+ s3.validate_bucket_name(module, "my")
+ assert module.fail_json.called
diff --git a/ansible_collections/amazon/aws/tests/unit/module_utils/test_tagging.py b/ansible_collections/amazon/aws/tests/unit/module_utils/test_tagging.py
new file mode 100644
index 000000000..04ec96eb0
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/module_utils/test_tagging.py
@@ -0,0 +1,203 @@
+# (c) 2017 Red Hat Inc.
+#
+# This file is part of Ansible
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+from ansible_collections.amazon.aws.plugins.module_utils.tagging import ansible_dict_to_boto3_tag_list
+from ansible_collections.amazon.aws.plugins.module_utils.tagging import boto3_tag_list_to_ansible_dict
+from ansible_collections.amazon.aws.plugins.module_utils.tagging import boto3_tag_specifications
+from ansible_collections.amazon.aws.plugins.module_utils.tagging import compare_aws_tags
+
+
+class TestTagging():
+
+ # ========================================================
+ # Setup some initial data that we can use within our tests
+ # ========================================================
+ def setup_method(self):
+
+ self.tag_example_boto3_list = [
+ {'Key': 'lowerCamel', 'Value': 'lowerCamelValue'},
+ {'Key': 'UpperCamel', 'Value': 'upperCamelValue'},
+ {'Key': 'Normal case', 'Value': 'Normal Value'},
+ {'Key': 'lower case', 'Value': 'lower case value'}
+ ]
+
+ self.tag_example_dict = {
+ 'lowerCamel': 'lowerCamelValue',
+ 'UpperCamel': 'upperCamelValue',
+ 'Normal case': 'Normal Value',
+ 'lower case': 'lower case value'
+ }
+
+ self.tag_minimal_boto3_list = [
+ {'Key': 'mykey', 'Value': 'myvalue'},
+ ]
+
+ self.tag_minimal_dict = {'mykey': 'myvalue'}
+
+ self.tag_aws_dict = {'aws:cloudformation:stack-name': 'ExampleStack'}
+ self.tag_aws_changed = {'aws:cloudformation:stack-name': 'AnotherStack'}
+
+ # ========================================================
+ # tagging.ansible_dict_to_boto3_tag_list
+ # ========================================================
+
+ def test_ansible_dict_to_boto3_tag_list(self):
+ converted_list = ansible_dict_to_boto3_tag_list(self.tag_example_dict)
+ sorted_converted_list = sorted(converted_list, key=lambda i: (i['Key']))
+ sorted_list = sorted(self.tag_example_boto3_list, key=lambda i: (i['Key']))
+ assert sorted_converted_list == sorted_list
+
+ # ========================================================
+ # tagging.boto3_tag_list_to_ansible_dict
+ # ========================================================
+
+ def test_boto3_tag_list_to_ansible_dict(self):
+ converted_dict = boto3_tag_list_to_ansible_dict(self.tag_example_boto3_list)
+ assert converted_dict == self.tag_example_dict
+
+ def test_boto3_tag_list_to_ansible_dict_empty(self):
+ # AWS returns [] when there are no tags
+ assert boto3_tag_list_to_ansible_dict([]) == {}
+ # Minio returns [{}] when there are no tags
+ assert boto3_tag_list_to_ansible_dict([{}]) == {}
+
+ # ========================================================
+ # tagging.compare_aws_tags
+ # ========================================================
+
+ def test_compare_aws_tags_equal(self):
+ new_dict = dict(self.tag_example_dict)
+ keys_to_set, keys_to_unset = compare_aws_tags(self.tag_example_dict, new_dict)
+ assert {} == keys_to_set
+ assert [] == keys_to_unset
+ keys_to_set, keys_to_unset = compare_aws_tags(self.tag_example_dict, new_dict, purge_tags=False)
+ assert {} == keys_to_set
+ assert [] == keys_to_unset
+ keys_to_set, keys_to_unset = compare_aws_tags(self.tag_example_dict, new_dict, purge_tags=True)
+ assert {} == keys_to_set
+ assert [] == keys_to_unset
+
+ def test_compare_aws_tags_removed(self):
+ new_dict = dict(self.tag_example_dict)
+ del new_dict['lowerCamel']
+ del new_dict['Normal case']
+ keys_to_set, keys_to_unset = compare_aws_tags(self.tag_example_dict, new_dict)
+ assert {} == keys_to_set
+ assert set(['lowerCamel', 'Normal case']) == set(keys_to_unset)
+ keys_to_set, keys_to_unset = compare_aws_tags(self.tag_example_dict, new_dict, purge_tags=False)
+ assert {} == keys_to_set
+ assert [] == keys_to_unset
+ keys_to_set, keys_to_unset = compare_aws_tags(self.tag_example_dict, new_dict, purge_tags=True)
+ assert {} == keys_to_set
+ assert set(['lowerCamel', 'Normal case']) == set(keys_to_unset)
+
+ def test_compare_aws_tags_added(self):
+ new_dict = dict(self.tag_example_dict)
+ new_keys = {'add_me': 'lower case', 'Me too!': 'Contributing'}
+ new_dict.update(new_keys)
+ keys_to_set, keys_to_unset = compare_aws_tags(self.tag_example_dict, new_dict)
+ assert new_keys == keys_to_set
+ assert [] == keys_to_unset
+ keys_to_set, keys_to_unset = compare_aws_tags(self.tag_example_dict, new_dict, purge_tags=False)
+ assert new_keys == keys_to_set
+ assert [] == keys_to_unset
+ keys_to_set, keys_to_unset = compare_aws_tags(self.tag_example_dict, new_dict, purge_tags=True)
+ assert new_keys == keys_to_set
+ assert [] == keys_to_unset
+
+ def test_compare_aws_tags_changed(self):
+ new_dict = dict(self.tag_example_dict)
+ new_keys = {'UpperCamel': 'anotherCamelValue', 'Normal case': 'normal value'}
+ new_dict.update(new_keys)
+ keys_to_set, keys_to_unset = compare_aws_tags(self.tag_example_dict, new_dict)
+ assert new_keys == keys_to_set
+ assert [] == keys_to_unset
+ keys_to_set, keys_to_unset = compare_aws_tags(self.tag_example_dict, new_dict, purge_tags=False)
+ assert new_keys == keys_to_set
+ assert [] == keys_to_unset
+ keys_to_set, keys_to_unset = compare_aws_tags(self.tag_example_dict, new_dict, purge_tags=True)
+ assert new_keys == keys_to_set
+ assert [] == keys_to_unset
+
+ def test_compare_aws_tags_complex_update(self):
+ # Adds 'Me too!', Changes 'UpperCamel' and removes 'Normal case'
+ new_dict = dict(self.tag_example_dict)
+ new_keys = {'UpperCamel': 'anotherCamelValue', 'Me too!': 'Contributing'}
+ new_dict.update(new_keys)
+ del new_dict['Normal case']
+ keys_to_set, keys_to_unset = compare_aws_tags(self.tag_example_dict, new_dict)
+ assert new_keys == keys_to_set
+ assert ['Normal case'] == keys_to_unset
+ keys_to_set, keys_to_unset = compare_aws_tags(self.tag_example_dict, new_dict, purge_tags=False)
+ assert new_keys == keys_to_set
+ assert [] == keys_to_unset
+ keys_to_set, keys_to_unset = compare_aws_tags(self.tag_example_dict, new_dict, purge_tags=True)
+ assert new_keys == keys_to_set
+ assert ['Normal case'] == keys_to_unset
+
+ def test_compare_aws_tags_aws(self):
+ starting_tags = dict(self.tag_aws_dict)
+ desired_tags = dict(self.tag_minimal_dict)
+ tags_to_set, tags_to_unset = compare_aws_tags(starting_tags, desired_tags, purge_tags=True)
+ assert desired_tags == tags_to_set
+ assert [] == tags_to_unset
+ # If someone explicitly passes a changed 'aws:' key the APIs will probably
+ # throw an error, but this is their responsibility.
+ desired_tags.update(self.tag_aws_changed)
+ tags_to_set, tags_to_unset = compare_aws_tags(starting_tags, desired_tags, purge_tags=True)
+ assert desired_tags == tags_to_set
+ assert [] == tags_to_unset
+
+ def test_compare_aws_tags_aws_complex(self):
+ old_dict = dict(self.tag_example_dict)
+ old_dict.update(self.tag_aws_dict)
+ # Adds 'Me too!', Changes 'UpperCamel' and removes 'Normal case'
+ new_dict = dict(self.tag_example_dict)
+ new_keys = {'UpperCamel': 'anotherCamelValue', 'Me too!': 'Contributing'}
+ new_dict.update(new_keys)
+ del new_dict['Normal case']
+ keys_to_set, keys_to_unset = compare_aws_tags(old_dict, new_dict)
+ assert new_keys == keys_to_set
+ assert ['Normal case'] == keys_to_unset
+ keys_to_set, keys_to_unset = compare_aws_tags(old_dict, new_dict, purge_tags=False)
+ assert new_keys == keys_to_set
+ assert [] == keys_to_unset
+ keys_to_set, keys_to_unset = compare_aws_tags(old_dict, new_dict, purge_tags=True)
+ assert new_keys == keys_to_set
+ assert ['Normal case'] == keys_to_unset
+
+ # ========================================================
+ # tagging.boto3_tag_specifications
+ # ========================================================
+
+ # Builds upon ansible_dict_to_boto3_tag_list, assume that if a minimal tag
+ # dictionary behaves as expected, then all will behave
+ def test_boto3_tag_specifications_no_type(self):
+ tag_specification = boto3_tag_specifications(self.tag_minimal_dict)
+ expected_specification = [{'Tags': self.tag_minimal_boto3_list}]
+ assert tag_specification == expected_specification
+
+ def test_boto3_tag_specifications_string_type(self):
+ tag_specification = boto3_tag_specifications(self.tag_minimal_dict, 'instance')
+ expected_specification = [{'ResourceType': 'instance', 'Tags': self.tag_minimal_boto3_list}]
+ assert tag_specification == expected_specification
+
+ def test_boto3_tag_specifications_single_type(self):
+ tag_specification = boto3_tag_specifications(self.tag_minimal_dict, ['instance'])
+ expected_specification = [{'ResourceType': 'instance', 'Tags': self.tag_minimal_boto3_list}]
+ assert tag_specification == expected_specification
+
+ def test_boto3_tag_specifications_multipe_types(self):
+ tag_specification = boto3_tag_specifications(self.tag_minimal_dict, ['instance', 'volume'])
+ expected_specification = [
+ {'ResourceType': 'instance', 'Tags': self.tag_minimal_boto3_list},
+ {'ResourceType': 'volume', 'Tags': self.tag_minimal_boto3_list},
+ ]
+ sorted_tag_spec = sorted(tag_specification, key=lambda i: (i['ResourceType']))
+ sorted_expected = sorted(expected_specification, key=lambda i: (i['ResourceType']))
+ assert sorted_tag_spec == sorted_expected
diff --git a/ansible_collections/amazon/aws/tests/unit/module_utils/test_tower.py b/ansible_collections/amazon/aws/tests/unit/module_utils/test_tower.py
new file mode 100644
index 000000000..9e1d90213
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/module_utils/test_tower.py
@@ -0,0 +1,40 @@
+# (c) 2022 Red Hat Inc.
+#
+# This file is part of Ansible
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+# import pytest
+
+import ansible_collections.amazon.aws.plugins.module_utils.tower as utils_tower
+
+WINDOWS_DOWNLOAD = "Invoke-Expression ((New-Object System.Net.Webclient).DownloadString(" \
+ "'https://raw.githubusercontent.com/ansible/ansible/devel/examples/scripts/ConfigureRemotingForAnsible.ps1'))"
+EXAMPLE_PASSWORD = 'MY_EXAMPLE_PASSWORD'
+WINDOWS_INVOKE = "$admin.PSBase.Invoke('SetPassword', 'MY_EXAMPLE_PASSWORD'"
+
+EXAMPLE_TOWER = "tower.example.com"
+EXAMPLE_TEMPLATE = 'My Template'
+EXAMPLE_KEY = '123EXAMPLE123'
+LINUX_TRIGGER_V1 = 'https://tower.example.com/api/v1/job_templates/My%20Template/callback/'
+LINUX_TRIGGER_V2 = 'https://tower.example.com/api/v2/job_templates/My%20Template/callback/'
+
+
+def test_windows_callback_no_password():
+ user_data = utils_tower._windows_callback_script()
+ assert WINDOWS_DOWNLOAD in user_data
+ assert 'SetPassword' not in user_data
+
+
+def test_windows_callback_password():
+ user_data = utils_tower._windows_callback_script(EXAMPLE_PASSWORD)
+ assert WINDOWS_DOWNLOAD in user_data
+ assert WINDOWS_INVOKE in user_data
+
+
+def test_linux_callback_with_name():
+ user_data = utils_tower._linux_callback_script(EXAMPLE_TOWER, EXAMPLE_TEMPLATE, EXAMPLE_KEY)
+ assert LINUX_TRIGGER_V1 in user_data
+ assert LINUX_TRIGGER_V2 in user_data
diff --git a/ansible_collections/amazon/aws/tests/unit/module_utils/transformation/test_ansible_dict_to_boto3_filter_list.py b/ansible_collections/amazon/aws/tests/unit/module_utils/transformation/test_ansible_dict_to_boto3_filter_list.py
new file mode 100644
index 000000000..23c82b173
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/module_utils/transformation/test_ansible_dict_to_boto3_filter_list.py
@@ -0,0 +1,73 @@
+# (c) 2017 Red Hat Inc.
+#
+# This file is part of Ansible
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+
+__metaclass__ = type
+
+from ansible_collections.amazon.aws.plugins.module_utils.transformation import ansible_dict_to_boto3_filter_list
+
+
+class TestAnsibleDictToBoto3FilterList():
+
+ # ========================================================
+ # ec2.ansible_dict_to_boto3_filter_list
+ # ========================================================
+
+ def test_ansible_dict_with_string_to_boto3_filter_list(self):
+ filters = {'some-aws-id': 'i-01234567'}
+ filter_list_string = [
+ {
+ 'Name': 'some-aws-id',
+ 'Values': [
+ 'i-01234567',
+ ]
+ }
+ ]
+
+ converted_filters_list = ansible_dict_to_boto3_filter_list(filters)
+ assert converted_filters_list == filter_list_string
+
+ def test_ansible_dict_with_boolean_to_boto3_filter_list(self):
+ filters = {'enabled': True}
+ filter_list_boolean = [
+ {
+ 'Name': 'enabled',
+ 'Values': [
+ 'true',
+ ]
+ }
+ ]
+
+ converted_filters_bool = ansible_dict_to_boto3_filter_list(filters)
+ assert converted_filters_bool == filter_list_boolean
+
+ def test_ansible_dict_with_integer_to_boto3_filter_list(self):
+ filters = {'version': 1}
+ filter_list_integer = [
+ {
+ 'Name': 'version',
+ 'Values': [
+ '1',
+ ]
+ }
+ ]
+
+ converted_filters_int = ansible_dict_to_boto3_filter_list(filters)
+ assert converted_filters_int == filter_list_integer
+
+ def test_ansible_dict_with_list_to_boto3_filter_list(self):
+ filters = {'version': ['1', '2', '3']}
+ filter_list_integer = [
+ {
+ 'Name': 'version',
+ 'Values': [
+ '1', '2', '3'
+ ]
+ }
+ ]
+
+ converted_filters_int = ansible_dict_to_boto3_filter_list(filters)
+ assert converted_filters_int == filter_list_integer
diff --git a/ansible_collections/amazon/aws/tests/unit/module_utils/transformation/test_map_complex_type.py b/ansible_collections/amazon/aws/tests/unit/module_utils/transformation/test_map_complex_type.py
new file mode 100644
index 000000000..2300e2351
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/module_utils/transformation/test_map_complex_type.py
@@ -0,0 +1,100 @@
+# (c) 2017 Red Hat Inc.
+#
+# This file is part of Ansible
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+from ansible_collections.amazon.aws.plugins.module_utils.transformation import map_complex_type
+
+from ansible_collections.amazon.aws.tests.unit.compat.mock import sentinel
+
+
+def test_map_complex_type_over_dict():
+ type_map = {'minimum_healthy_percent': 'int', 'maximum_percent': 'int'}
+ complex_type_dict = {'minimum_healthy_percent': "75", 'maximum_percent': "150"}
+ complex_type_expected = {'minimum_healthy_percent': 75, 'maximum_percent': 150}
+
+ complex_type_mapped = map_complex_type(complex_type_dict, type_map)
+
+ assert complex_type_mapped == complex_type_expected
+
+
+def test_map_complex_type_empty():
+ type_map = {'minimum_healthy_percent': 'int', 'maximum_percent': 'int'}
+ assert map_complex_type({}, type_map) == {}
+ assert map_complex_type([], type_map) == []
+ assert map_complex_type(None, type_map) is None
+
+
+def test_map_complex_type_no_type():
+ type_map = {'some_entry': 'int'}
+ complex_dict = {'another_entry': sentinel.UNSPECIFIED_MAPPING}
+ mapped_dict = map_complex_type(complex_dict, type_map)
+ assert mapped_dict == complex_dict
+ # we should have the original sentinel object, even if it's a new dictionary
+ assert mapped_dict['another_entry'] is sentinel.UNSPECIFIED_MAPPING
+
+
+def test_map_complex_type_list():
+ type_map = {'some_entry': 'int'}
+ complex_dict = {'some_entry': ["1", "2", "3"]}
+ expected_dict = {'some_entry': [1, 2, 3]}
+ mapped_dict = map_complex_type(complex_dict, type_map)
+ assert mapped_dict == expected_dict
+
+
+def test_map_complex_type_list_type():
+ type_map = {'some_entry': ['int']}
+ complex_dict = {'some_entry': ["1", "2", "3"]}
+ expected_dict = {'some_entry': [1, 2, 3]}
+ mapped_dict = map_complex_type(complex_dict, type_map)
+ assert mapped_dict == expected_dict
+
+ type_map = {'some_entry': ['int']}
+ complex_dict = {'some_entry': "1"}
+ expected_dict = {'some_entry': 1}
+ mapped_dict = map_complex_type(complex_dict, type_map)
+ assert mapped_dict == expected_dict
+
+
+def test_map_complex_type_complex():
+ type_map = {
+ 'my_integer': 'int',
+ 'my_bool': 'bool',
+ 'my_string': 'str',
+ 'my_typelist_of_int': ['int'],
+ 'my_maplist_of_int': 'int',
+ 'my_unused': 'bool',
+ }
+ complex_dict = {
+ 'my_integer': '-24',
+ 'my_bool': 'true',
+ 'my_string': 43,
+ 'my_typelist_of_int': '5',
+ 'my_maplist_of_int': ['-26', '47'],
+ 'my_unconverted': sentinel.UNSPECIFIED_MAPPING,
+ }
+ expected_dict = {
+ 'my_integer': -24,
+ 'my_bool': True,
+ 'my_string': '43',
+ 'my_typelist_of_int': 5,
+ 'my_maplist_of_int': [-26, 47],
+ 'my_unconverted': sentinel.UNSPECIFIED_MAPPING,
+ }
+
+ mapped_dict = map_complex_type(complex_dict, type_map)
+
+ assert mapped_dict == expected_dict
+ assert mapped_dict['my_unconverted'] is sentinel.UNSPECIFIED_MAPPING
+ assert mapped_dict['my_bool'] is True
+
+
+def test_map_complex_type_nested_list():
+ type_map = {'my_integer': 'int'}
+ complex_dict = [{'my_integer': '5'}, {'my_integer': '-24'}]
+ expected_dict = [{'my_integer': 5}, {'my_integer': -24}]
+ mapped_dict = map_complex_type(complex_dict, type_map)
+ assert mapped_dict == expected_dict
diff --git a/ansible_collections/amazon/aws/tests/unit/module_utils/transformation/test_scrub_none_parameters.py b/ansible_collections/amazon/aws/tests/unit/module_utils/transformation/test_scrub_none_parameters.py
new file mode 100644
index 000000000..82fd41ed3
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/module_utils/transformation/test_scrub_none_parameters.py
@@ -0,0 +1,88 @@
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import pytest
+
+from ansible_collections.amazon.aws.plugins.module_utils.transformation import scrub_none_parameters
+
+scrub_none_test_data = [
+ (dict(), # Input
+ dict(), # Output with descend_into_lists=False
+ dict(), # Output with descend_into_lists=True
+ ),
+ (dict(param1=None, param2=None),
+ dict(),
+ dict(),
+ ),
+ (dict(param1='something'),
+ dict(param1='something'),
+ dict(param1='something'),
+ ),
+ (dict(param1=False),
+ dict(param1=False),
+ dict(param1=False),
+ ),
+ (dict(param1=None, param2=[]),
+ dict(param2=[]),
+ dict(param2=[]),
+ ),
+ (dict(param1=None, param2=["list_value"]),
+ dict(param2=["list_value"]),
+ dict(param2=["list_value"]),
+ ),
+ (dict(param1='something', param2='something_else'),
+ dict(param1='something', param2='something_else'),
+ dict(param1='something', param2='something_else'),
+ ),
+ (dict(param1='something', param2=dict()),
+ dict(param1='something', param2=dict()),
+ dict(param1='something', param2=dict()),
+ ),
+ (dict(param1='something', param2=None),
+ dict(param1='something'),
+ dict(param1='something'),
+ ),
+ (dict(param1='something', param2=None, param3=None),
+ dict(param1='something'),
+ dict(param1='something'),
+ ),
+ (dict(param1='something', param2=None, param3=None, param4='something_else'),
+ dict(param1='something', param4='something_else'),
+ dict(param1='something', param4='something_else'),
+ ),
+ (dict(param1=dict(sub_param1='something', sub_param2=dict(sub_sub_param1='another_thing')), param2=None, param3=None, param4='something_else'),
+ dict(param1=dict(sub_param1='something', sub_param2=dict(sub_sub_param1='another_thing')), param4='something_else'),
+ dict(param1=dict(sub_param1='something', sub_param2=dict(sub_sub_param1='another_thing')), param4='something_else'),
+ ),
+ (dict(param1=dict(sub_param1='something', sub_param2=dict()), param2=None, param3=None, param4='something_else'),
+ dict(param1=dict(sub_param1='something', sub_param2=dict()), param4='something_else'),
+ dict(param1=dict(sub_param1='something', sub_param2=dict()), param4='something_else'),
+ ),
+ (dict(param1=dict(sub_param1='something', sub_param2=False), param2=None, param3=None, param4='something_else'),
+ dict(param1=dict(sub_param1='something', sub_param2=False), param4='something_else'),
+ dict(param1=dict(sub_param1='something', sub_param2=False), param4='something_else'),
+ ),
+ (dict(param1=[dict(sub_param1='my_dict_nested_in_a_list_1', sub_param2='my_dict_nested_in_a_list_2')], param2=[]),
+ dict(param1=[dict(sub_param1='my_dict_nested_in_a_list_1', sub_param2='my_dict_nested_in_a_list_2')], param2=[]),
+ dict(param1=[dict(sub_param1='my_dict_nested_in_a_list_1', sub_param2='my_dict_nested_in_a_list_2')], param2=[]),
+ ),
+ (dict(param1=[dict(sub_param1='my_dict_nested_in_a_list_1', sub_param2=None)], param2=[]),
+ dict(param1=[dict(sub_param1='my_dict_nested_in_a_list_1', sub_param2=None)], param2=[]),
+ dict(param1=[dict(sub_param1='my_dict_nested_in_a_list_1')], param2=[]),
+ ),
+ (dict(param1=[dict(sub_param1=[dict(sub_sub_param1=None)], sub_param2=None)], param2=[]),
+ dict(param1=[dict(sub_param1=[dict(sub_sub_param1=None)], sub_param2=None)], param2=[]),
+ dict(param1=[dict(sub_param1=[dict()])], param2=[]),
+ ),
+ (dict(param1=[dict(sub_param1=[dict(sub_sub_param1=None)], sub_param2=None)], param2=None),
+ dict(param1=[dict(sub_param1=[dict(sub_sub_param1=None)], sub_param2=None)]),
+ dict(param1=[dict(sub_param1=[dict()])]),
+ ),
+]
+
+
+@pytest.mark.parametrize("input_params, output_params_no_descend, output_params_descend", scrub_none_test_data)
+def test_scrub_none_parameters(input_params, output_params_no_descend, output_params_descend):
+ assert scrub_none_parameters(input_params) == output_params_descend
+ assert scrub_none_parameters(input_params, descend_into_lists=False) == output_params_no_descend
+ assert scrub_none_parameters(input_params, descend_into_lists=True) == output_params_descend
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/inventory/test_aws_ec2.py b/ansible_collections/amazon/aws/tests/unit/plugins/inventory/test_aws_ec2.py
new file mode 100644
index 000000000..5386fe6c7
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/inventory/test_aws_ec2.py
@@ -0,0 +1,514 @@
+# -*- coding: utf-8 -*-
+
+# Copyright 2017 Sloane Hertel <shertel@redhat.com>
+#
+# This file is part of Ansible
+#
+# Ansible is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Ansible is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
+
+# Make coding more python3-ish
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import pytest
+import datetime
+from unittest.mock import Mock, MagicMock
+
+from ansible.errors import AnsibleError
+from ansible.parsing.dataloader import DataLoader
+from ansible_collections.amazon.aws.plugins.inventory.aws_ec2 import InventoryModule, instance_data_filter_to_boto_attr
+
+
+instances = {
+ 'Instances': [
+ {'Monitoring': {'State': 'disabled'},
+ 'PublicDnsName': 'ec2-12-345-67-890.compute-1.amazonaws.com',
+ 'State': {'Code': 16, 'Name': 'running'},
+ 'EbsOptimized': False,
+ 'LaunchTime': datetime.datetime(2017, 10, 31, 12, 59, 25),
+ 'PublicIpAddress': '12.345.67.890',
+ 'PrivateIpAddress': '098.76.54.321',
+ 'ProductCodes': [],
+ 'VpcId': 'vpc-12345678',
+ 'StateTransitionReason': '',
+ 'InstanceId': 'i-00000000000000000',
+ 'EnaSupport': True,
+ 'ImageId': 'ami-12345678',
+ 'PrivateDnsName': 'ip-098-76-54-321.ec2.internal',
+ 'KeyName': 'testkey',
+ 'SecurityGroups': [{'GroupName': 'default', 'GroupId': 'sg-12345678'}],
+ 'ClientToken': '',
+ 'SubnetId': 'subnet-12345678',
+ 'InstanceType': 't2.micro',
+ 'NetworkInterfaces': [
+ {'Status': 'in-use',
+ 'MacAddress': '12:a0:50:42:3d:a4',
+ 'SourceDestCheck': True,
+ 'VpcId': 'vpc-12345678',
+ 'Description': '',
+ 'NetworkInterfaceId': 'eni-12345678',
+ 'PrivateIpAddresses': [
+ {'PrivateDnsName': 'ip-098-76-54-321.ec2.internal',
+ 'PrivateIpAddress': '098.76.54.321',
+ 'Primary': True,
+ 'Association':
+ {'PublicIp': '12.345.67.890',
+ 'PublicDnsName': 'ec2-12-345-67-890.compute-1.amazonaws.com',
+ 'IpOwnerId': 'amazon'}}],
+ 'PrivateDnsName': 'ip-098-76-54-321.ec2.internal',
+ 'Attachment':
+ {'Status': 'attached',
+ 'DeviceIndex': 0,
+ 'DeleteOnTermination': True,
+ 'AttachmentId': 'eni-attach-12345678',
+ 'AttachTime': datetime.datetime(2017, 10, 31, 12, 59, 25)},
+ 'Groups': [
+ {'GroupName': 'default',
+ 'GroupId': 'sg-12345678'}],
+ 'Ipv6Addresses': [],
+ 'OwnerId': '123456789012',
+ 'PrivateIpAddress': '098.76.54.321',
+ 'SubnetId': 'subnet-12345678',
+ 'Association':
+ {'PublicIp': '12.345.67.890',
+ 'PublicDnsName': 'ec2-12-345-67-890.compute-1.amazonaws.com',
+ 'IpOwnerId': 'amazon'}}],
+ 'SourceDestCheck': True,
+ 'Placement':
+ {'Tenancy': 'default',
+ 'GroupName': '',
+ 'AvailabilityZone': 'us-east-1c'},
+ 'Hypervisor': 'xen',
+ 'BlockDeviceMappings': [
+ {'DeviceName': '/dev/xvda',
+ 'Ebs':
+ {'Status': 'attached',
+ 'DeleteOnTermination': True,
+ 'VolumeId': 'vol-01234567890000000',
+ 'AttachTime': datetime.datetime(2017, 10, 31, 12, 59, 26)}}],
+ 'Architecture': 'x86_64',
+ 'RootDeviceType': 'ebs',
+ 'RootDeviceName': '/dev/xvda',
+ 'VirtualizationType': 'hvm',
+ 'Tags': [{'Value': 'test', 'Key': 'ansible'}, {'Value': 'aws_ec2', 'Key': 'Name'}],
+ 'AmiLaunchIndex': 0}],
+ 'ReservationId': 'r-01234567890000000',
+ 'Groups': [],
+ 'OwnerId': '123456789012'
+}
+
+
+@pytest.fixture()
+def inventory():
+ inventory = InventoryModule()
+ inventory._options = {
+ "aws_profile": "first_precedence",
+ "aws_access_key": "test_access_key",
+ "aws_secret_key": "test_secret_key",
+ "aws_security_token": "test_security_token",
+ "iam_role_arn": None,
+ "use_contrib_script_compatible_ec2_tag_keys": False,
+ "hostvars_prefix": "",
+ "hostvars_suffix": "",
+ "strict": True,
+ "compose": {},
+ "groups": {},
+ "keyed_groups": [],
+ "regions": ["us-east-1"],
+ "filters": [],
+ "include_filters": [],
+ "exclude_filters": [],
+ "hostnames": [],
+ "strict_permissions": False,
+ "allow_duplicated_hosts": False,
+ "cache": False,
+ "include_extra_api_calls": False,
+ "use_contrib_script_compatible_sanitization": False,
+ }
+ inventory.inventory = MagicMock()
+ return inventory
+
+
+def test_compile_values(inventory):
+ found_value = instances['Instances'][0]
+ chain_of_keys = instance_data_filter_to_boto_attr['instance.group-id']
+ for attr in chain_of_keys:
+ found_value = inventory._compile_values(found_value, attr)
+ assert found_value == "sg-12345678"
+
+
+def test_get_boto_attr_chain(inventory):
+ instance = instances['Instances'][0]
+ assert inventory._get_boto_attr_chain('network-interface.addresses.private-ip-address', instance) == "098.76.54.321"
+
+
+def test_boto3_conn(inventory):
+ inventory._options = {"aws_profile": "first_precedence",
+ "aws_access_key": "test_access_key",
+ "aws_secret_key": "test_secret_key",
+ "aws_security_token": "test_security_token",
+ "iam_role_arn": None}
+ loader = DataLoader()
+ inventory._set_credentials(loader)
+ with pytest.raises(AnsibleError) as error_message:
+ for _connection, _region in inventory._boto3_conn(regions=['us-east-1']):
+ assert "Insufficient credentials found" in error_message
+
+
+def testget_all_hostnames_default(inventory):
+ instance = instances['Instances'][0]
+ assert inventory.get_all_hostnames(instance, hostnames=None) == ["ec2-12-345-67-890.compute-1.amazonaws.com", "ip-098-76-54-321.ec2.internal"]
+
+
+def testget_all_hostnames(inventory):
+ hostnames = ['ip-address', 'dns-name']
+ instance = instances['Instances'][0]
+ assert inventory.get_all_hostnames(instance, hostnames) == ["12.345.67.890", "ec2-12-345-67-890.compute-1.amazonaws.com"]
+
+
+def testget_all_hostnames_dict(inventory):
+ hostnames = [{'name': 'private-ip-address', 'separator': '_', 'prefix': 'tag:Name'}]
+ instance = instances['Instances'][0]
+ assert inventory.get_all_hostnames(instance, hostnames) == ["aws_ec2_098.76.54.321"]
+
+
+def testget_all_hostnames_with_2_tags(inventory):
+ hostnames = ['tag:ansible', 'tag:Name']
+ instance = instances['Instances'][0]
+ assert inventory.get_all_hostnames(instance, hostnames) == ["test", "aws_ec2"]
+
+
+def test_get_preferred_hostname_default(inventory):
+ instance = instances['Instances'][0]
+ assert inventory._get_preferred_hostname(instance, hostnames=None) == "ec2-12-345-67-890.compute-1.amazonaws.com"
+
+
+def test_get_preferred_hostname(inventory):
+ hostnames = ['ip-address', 'dns-name']
+ instance = instances['Instances'][0]
+ assert inventory._get_preferred_hostname(instance, hostnames) == "12.345.67.890"
+
+
+def test_get_preferred_hostname_dict(inventory):
+ hostnames = [{'name': 'private-ip-address', 'separator': '_', 'prefix': 'tag:Name'}]
+ instance = instances['Instances'][0]
+ assert inventory._get_preferred_hostname(instance, hostnames) == "aws_ec2_098.76.54.321"
+
+
+def test_get_preferred_hostname_with_2_tags(inventory):
+ hostnames = ['tag:ansible', 'tag:Name']
+ instance = instances['Instances'][0]
+ assert inventory._get_preferred_hostname(instance, hostnames) == "test"
+
+
+def test_set_credentials(inventory):
+ inventory._options = {'aws_access_key': 'test_access_key',
+ 'aws_secret_key': 'test_secret_key',
+ 'aws_security_token': 'test_security_token',
+ 'aws_profile': 'test_profile',
+ 'iam_role_arn': 'arn:aws:iam::123456789012:role/test-role'}
+ loader = DataLoader()
+ inventory._set_credentials(loader)
+
+ assert inventory.boto_profile == "test_profile"
+ assert inventory.aws_access_key_id == "test_access_key"
+ assert inventory.aws_secret_access_key == "test_secret_key"
+ assert inventory.aws_security_token == "test_security_token"
+ assert inventory.iam_role_arn == "arn:aws:iam::123456789012:role/test-role"
+
+
+def test_insufficient_credentials(inventory):
+ inventory._options = {
+ 'aws_access_key': None,
+ 'aws_secret_key': None,
+ 'aws_security_token': None,
+ 'aws_profile': None,
+ 'iam_role_arn': None
+ }
+ with pytest.raises(AnsibleError) as error_message:
+ loader = DataLoader()
+ inventory._set_credentials(loader)
+ assert "Insufficient credentials found" in error_message
+
+
+def test_verify_file_bad_config(inventory):
+ assert inventory.verify_file('not_aws_config.yml') is False
+
+
+def test_include_filters_with_no_filter(inventory):
+ inventory._options = {
+ 'filters': {},
+ 'include_filters': [],
+ }
+ print(inventory.build_include_filters())
+ assert inventory.build_include_filters() == [{}]
+
+
+def test_include_filters_with_include_filters_only(inventory):
+ inventory._options = {
+ 'filters': {},
+ 'include_filters': [{"foo": "bar"}],
+ }
+ assert inventory.build_include_filters() == [{"foo": "bar"}]
+
+
+def test_include_filters_with_filter_and_include_filters(inventory):
+ inventory._options = {
+ 'filters': {"from_filter": 1},
+ 'include_filters': [{"from_include_filter": "bar"}],
+ }
+ print(inventory.build_include_filters())
+ assert inventory.build_include_filters() == [
+ {"from_filter": 1},
+ {"from_include_filter": "bar"}]
+
+
+def test_add_host_empty_hostnames(inventory):
+ hosts = [
+ {
+ "Placement": {
+ "AvailabilityZone": "us-east-1a",
+ },
+ "PublicDnsName": "ip-10-85-0-4.ec2.internal"
+ },
+ ]
+ inventory._add_hosts(hosts, "aws_ec2", [])
+ inventory.inventory.add_host.assert_called_with("ip-10-85-0-4.ec2.internal", group="aws_ec2")
+
+
+def test_add_host_with_hostnames_no_criteria(inventory):
+ hosts = [{}]
+
+ inventory._add_hosts(
+ hosts, "aws_ec2", hostnames=["tag:Name", "private-dns-name", "dns-name"]
+ )
+ assert inventory.inventory.add_host.call_count == 0
+
+
+def test_add_host_with_hostnames_and_one_criteria(inventory):
+ hosts = [
+ {
+ "Placement": {
+ "AvailabilityZone": "us-east-1a",
+ },
+ "PublicDnsName": "sample-host",
+ }
+ ]
+
+ inventory._add_hosts(
+ hosts, "aws_ec2", hostnames=["tag:Name", "private-dns-name", "dns-name"]
+ )
+ assert inventory.inventory.add_host.call_count == 1
+ inventory.inventory.add_host.assert_called_with("sample-host", group="aws_ec2")
+
+
+def test_add_host_with_hostnames_and_two_matching_criteria(inventory):
+ hosts = [
+ {
+ "Placement": {
+ "AvailabilityZone": "us-east-1a",
+ },
+ "PublicDnsName": "name-from-PublicDnsName",
+ "Tags": [{"Value": "name-from-tag-Name", "Key": "Name"}],
+ }
+ ]
+
+ inventory._add_hosts(
+ hosts, "aws_ec2", hostnames=["tag:Name", "private-dns-name", "dns-name"]
+ )
+ assert inventory.inventory.add_host.call_count == 1
+ inventory.inventory.add_host.assert_called_with(
+ "name-from-tag-Name", group="aws_ec2"
+ )
+
+
+def test_add_host_with_hostnames_and_two_matching_criteria_and_allow_duplicated_hosts(
+ inventory,
+):
+ hosts = [
+ {
+ "Placement": {
+ "AvailabilityZone": "us-east-1a",
+ },
+ "PublicDnsName": "name-from-PublicDnsName",
+ "Tags": [{"Value": "name-from-tag-Name", "Key": "Name"}],
+ }
+ ]
+
+ inventory._add_hosts(
+ hosts,
+ "aws_ec2",
+ hostnames=["tag:Name", "private-dns-name", "dns-name"],
+ allow_duplicated_hosts=True,
+ )
+ assert inventory.inventory.add_host.call_count == 2
+ inventory.inventory.add_host.assert_any_call(
+ "name-from-PublicDnsName", group="aws_ec2"
+ )
+ inventory.inventory.add_host.assert_any_call("name-from-tag-Name", group="aws_ec2")
+
+
+def test_sanitize_hostname(inventory):
+ assert inventory._sanitize_hostname(1) == "1"
+ assert inventory._sanitize_hostname("a:b") == "a_b"
+ assert inventory._sanitize_hostname("a:/b") == "a__b"
+ assert inventory._sanitize_hostname("example") == "example"
+
+
+def test_sanitize_hostname_legacy(inventory):
+ inventory._sanitize_group_name = (
+ inventory._legacy_script_compatible_group_sanitization
+ )
+ assert inventory._sanitize_hostname("a:/b") == "a__b"
+
+
+@pytest.mark.parametrize(
+ "hostvars_prefix,hostvars_suffix,use_contrib_script_compatible_ec2_tag_keys,expectation",
+ [
+ (
+ None,
+ None,
+ False,
+ {
+ "my_var": 1,
+ "placement": {"availability_zone": "us-east-1a", "region": "us-east-1"},
+ "tags": {"Name": "my-name"},
+ },
+ ),
+ (
+ "pre",
+ "post",
+ False,
+ {
+ "premy_varpost": 1,
+ "preplacementpost": {
+ "availability_zone": "us-east-1a",
+ "region": "us-east-1",
+ },
+ "pretagspost": {"Name": "my-name"},
+ },
+ ),
+ (
+ None,
+ None,
+ True,
+ {
+ "my_var": 1,
+ "ec2_tag_Name": "my-name",
+ "placement": {"availability_zone": "us-east-1a", "region": "us-east-1"},
+ "tags": {"Name": "my-name"},
+ },
+ ),
+ ],
+)
+def test_prepare_host_vars(
+ inventory,
+ hostvars_prefix,
+ hostvars_suffix,
+ use_contrib_script_compatible_ec2_tag_keys,
+ expectation,
+):
+ original_host_vars = {
+ "my_var": 1,
+ "placement": {"availability_zone": "us-east-1a"},
+ "Tags": [{"Key": "Name", "Value": "my-name"}],
+ }
+ assert (
+ inventory.prepare_host_vars(
+ original_host_vars,
+ hostvars_prefix,
+ hostvars_suffix,
+ use_contrib_script_compatible_ec2_tag_keys,
+ )
+ == expectation
+ )
+
+
+def test_iter_entry(inventory):
+ hosts = [
+ {
+ "Placement": {
+ "AvailabilityZone": "us-east-1a",
+ },
+ "PublicDnsName": "first-host://",
+ },
+ {
+ "Placement": {
+ "AvailabilityZone": "us-east-1a",
+ },
+ "PublicDnsName": "second-host",
+ "Tags": [{"Key": "Name", "Value": "my-name"}],
+ },
+ ]
+
+ entries = list(inventory.iter_entry(hosts, hostnames=[]))
+ assert len(entries) == 2
+ assert entries[0][0] == "first_host___"
+ assert entries[1][0] == "second-host"
+ assert entries[1][1]["tags"]["Name"] == "my-name"
+
+ entries = list(
+ inventory.iter_entry(
+ hosts,
+ hostnames=[],
+ hostvars_prefix="a_",
+ hostvars_suffix="_b",
+ use_contrib_script_compatible_ec2_tag_keys=True,
+ )
+ )
+ assert len(entries) == 2
+ assert entries[0][0] == "first_host___"
+ assert entries[1][1]["a_tags_b"]["Name"] == "my-name"
+
+
+def test_query_empty(inventory):
+ result = inventory._query("us-east-1", [], [], strict_permissions=True)
+ assert result == {"aws_ec2": []}
+
+
+instance_foobar = {"InstanceId": "foobar"}
+instance_barfoo = {"InstanceId": "barfoo"}
+
+
+def test_query_empty_include_only(inventory):
+ inventory._get_instances_by_region = Mock(side_effect=[[instance_foobar]])
+ result = inventory._query("us-east-1", [{"tag:Name": ["foobar"]}], [], strict_permissions=True)
+ assert result == {"aws_ec2": [instance_foobar]}
+
+
+def test_query_empty_include_ordered(inventory):
+ inventory._get_instances_by_region = Mock(side_effect=[[instance_foobar], [instance_barfoo]])
+ result = inventory._query("us-east-1", [{"tag:Name": ["foobar"]}, {"tag:Name": ["barfoo"]}], [], strict_permissions=True)
+ assert result == {"aws_ec2": [instance_barfoo, instance_foobar]}
+ inventory._get_instances_by_region.assert_called_with('us-east-1', [{'Name': 'tag:Name', 'Values': ['barfoo']}], True)
+
+
+def test_query_empty_include_exclude(inventory):
+ inventory._get_instances_by_region = Mock(side_effect=[[instance_foobar], [instance_foobar]])
+ result = inventory._query("us-east-1", [{"tag:Name": ["foobar"]}], [{"tag:Name": ["foobar"]}], strict_permissions=True)
+ assert result == {"aws_ec2": []}
+
+
+def test_include_extra_api_calls_deprecated(inventory):
+ inventory.display.deprecate = Mock()
+ inventory._read_config_data = Mock()
+ inventory._set_credentials = Mock()
+ inventory._query = Mock(return_value=[])
+
+ inventory.parse(inventory=[], loader=None, path=None)
+ assert inventory.display.deprecate.call_count == 0
+
+ inventory._options["include_extra_api_calls"] = True
+ inventory.parse(inventory=[], loader=None, path=None)
+ assert inventory.display.deprecate.call_count == 1
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/conftest.py b/ansible_collections/amazon/aws/tests/unit/plugins/modules/conftest.py
new file mode 100644
index 000000000..a7d1e0475
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/conftest.py
@@ -0,0 +1,31 @@
+# Copyright (c) 2017 Ansible Project
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import json
+
+import pytest
+
+from ansible.module_utils.six import string_types
+from ansible.module_utils._text import to_bytes
+from ansible.module_utils.common._collections_compat import MutableMapping
+
+
+@pytest.fixture
+def patch_ansible_module(request, mocker):
+ if isinstance(request.param, string_types):
+ args = request.param
+ elif isinstance(request.param, MutableMapping):
+ if 'ANSIBLE_MODULE_ARGS' not in request.param:
+ request.param = {'ANSIBLE_MODULE_ARGS': request.param}
+ if '_ansible_remote_tmp' not in request.param['ANSIBLE_MODULE_ARGS']:
+ request.param['ANSIBLE_MODULE_ARGS']['_ansible_remote_tmp'] = '/tmp'
+ if '_ansible_keep_remote_files' not in request.param['ANSIBLE_MODULE_ARGS']:
+ request.param['ANSIBLE_MODULE_ARGS']['_ansible_keep_remote_files'] = False
+ args = json.dumps(request.param)
+ else:
+ raise Exception('Malformed data to the patch_ansible_module pytest fixture')
+
+ mocker.patch('ansible.module_utils.basic._ANSIBLE_ARGS', to_bytes(args))
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/ec2_instance/test_build_run_instance_spec.py b/ansible_collections/amazon/aws/tests/unit/plugins/modules/ec2_instance/test_build_run_instance_spec.py
new file mode 100644
index 000000000..e889b676a
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/ec2_instance/test_build_run_instance_spec.py
@@ -0,0 +1,126 @@
+# (c) 2022 Red Hat Inc.
+#
+# This file is part of Ansible
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import pytest
+
+from ansible_collections.amazon.aws.tests.unit.compat.mock import sentinel
+import ansible_collections.amazon.aws.plugins.modules.ec2_instance as ec2_instance_module
+
+
+@pytest.fixture
+def params_object():
+ params = {
+ 'iam_instance_profile': None,
+ 'exact_count': None,
+ 'count': None,
+ 'launch_template': None,
+ 'instance_type': None,
+ }
+ return params
+
+
+@pytest.fixture
+def ec2_instance(monkeypatch):
+ # monkey patches various ec2_instance module functions, we'll separately test the operation of
+ # these functions, we just care that it's passing the results into the right place in the
+ # instance spec.
+ monkeypatch.setattr(ec2_instance_module, 'build_top_level_options', lambda params: {'TOP_LEVEL_OPTIONS': sentinel.TOP_LEVEL})
+ monkeypatch.setattr(ec2_instance_module, 'build_network_spec', lambda params: sentinel.NETWORK_SPEC)
+ monkeypatch.setattr(ec2_instance_module, 'build_volume_spec', lambda params: sentinel.VOlUME_SPEC)
+ monkeypatch.setattr(ec2_instance_module, 'build_instance_tags', lambda params: sentinel.TAG_SPEC)
+ monkeypatch.setattr(ec2_instance_module, 'determine_iam_role', lambda params: sentinel.IAM_PROFILE_ARN)
+ return ec2_instance_module
+
+
+def _assert_defaults(instance_spec, to_skip=None):
+ if not to_skip:
+ to_skip = []
+
+ assert isinstance(instance_spec, dict)
+
+ if 'TagSpecifications' not in to_skip:
+ assert 'TagSpecifications' in instance_spec
+ assert instance_spec['TagSpecifications'] is sentinel.TAG_SPEC
+
+ if 'NetworkInterfaces' not in to_skip:
+ assert 'NetworkInterfaces' in instance_spec
+ assert instance_spec['NetworkInterfaces'] is sentinel.NETWORK_SPEC
+
+ if 'BlockDeviceMappings' not in to_skip:
+ assert 'BlockDeviceMappings' in instance_spec
+ assert instance_spec['BlockDeviceMappings'] is sentinel.VOlUME_SPEC
+
+ if 'IamInstanceProfile' not in to_skip:
+ # By default, this shouldn't be returned
+ assert 'IamInstanceProfile' not in instance_spec
+
+ if 'MinCount' not in to_skip:
+ assert 'MinCount' in instance_spec
+ instance_spec['MinCount'] == 1
+
+ if 'MaxCount' not in to_skip:
+ assert 'MaxCount' in instance_spec
+ instance_spec['MaxCount'] == 1
+
+ if 'TOP_LEVEL_OPTIONS' not in to_skip:
+ assert 'TOP_LEVEL_OPTIONS' in instance_spec
+ assert instance_spec['TOP_LEVEL_OPTIONS'] is sentinel.TOP_LEVEL
+
+
+def test_build_run_instance_spec_defaults(params_object, ec2_instance):
+ instance_spec = ec2_instance.build_run_instance_spec(params_object)
+ _assert_defaults(instance_spec)
+
+
+def test_build_run_instance_spec_tagging(params_object, ec2_instance, monkeypatch):
+ # build_instance_tags can return None, RunInstance doesn't like this
+ monkeypatch.setattr(ec2_instance_module, 'build_instance_tags', lambda params: None)
+ instance_spec = ec2_instance.build_run_instance_spec(params_object)
+ _assert_defaults(instance_spec, ['TagSpecifications'])
+ assert 'TagSpecifications' not in instance_spec
+
+ # if someone *explicitly* passes {} (rather than not setting it), then [] can be returned
+ monkeypatch.setattr(ec2_instance_module, 'build_instance_tags', lambda params: [])
+ instance_spec = ec2_instance.build_run_instance_spec(params_object)
+ _assert_defaults(instance_spec, ['TagSpecifications'])
+ assert 'TagSpecifications' in instance_spec
+ assert instance_spec['TagSpecifications'] == []
+
+
+def test_build_run_instance_spec_instance_profile(params_object, ec2_instance):
+ params_object['iam_instance_profile'] = sentinel.INSTANCE_PROFILE_NAME
+ instance_spec = ec2_instance.build_run_instance_spec(params_object)
+ _assert_defaults(instance_spec, ['IamInstanceProfile'])
+ assert 'IamInstanceProfile' in instance_spec
+ assert instance_spec['IamInstanceProfile'] == {'Arn': sentinel.IAM_PROFILE_ARN}
+
+
+def test_build_run_instance_spec_count(params_object, ec2_instance):
+ # When someone passes 'count', that number of instances will be *launched*
+ params_object['count'] = sentinel.COUNT
+ instance_spec = ec2_instance.build_run_instance_spec(params_object)
+ _assert_defaults(instance_spec, ['MaxCount', 'MinCount'])
+ assert 'MaxCount' in instance_spec
+ assert 'MinCount' in instance_spec
+ assert instance_spec['MaxCount'] == sentinel.COUNT
+ assert instance_spec['MinCount'] == sentinel.COUNT
+
+
+def test_build_run_instance_spec_exact_count(params_object, ec2_instance):
+ # The "exact_count" logic relies on enforce_count doing the math to figure out how many
+ # instances to start/stop. The enforce_count call is responsible for ensuring that 'to_launch'
+ # is set and is a positive integer.
+ params_object['exact_count'] = sentinel.EXACT_COUNT
+ params_object['to_launch'] = sentinel.TO_LAUNCH
+ instance_spec = ec2_instance.build_run_instance_spec(params_object)
+
+ _assert_defaults(instance_spec, ['MaxCount', 'MinCount'])
+ assert 'MaxCount' in instance_spec
+ assert 'MinCount' in instance_spec
+ assert instance_spec['MaxCount'] == sentinel.TO_LAUNCH
+ assert instance_spec['MinCount'] == sentinel.TO_LAUNCH
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/ec2_instance/test_determine_iam_role.py b/ansible_collections/amazon/aws/tests/unit/plugins/modules/ec2_instance/test_determine_iam_role.py
new file mode 100644
index 000000000..cdde74c97
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/ec2_instance/test_determine_iam_role.py
@@ -0,0 +1,102 @@
+# (c) 2022 Red Hat Inc.
+#
+# This file is part of Ansible
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import pytest
+import sys
+
+from ansible_collections.amazon.aws.tests.unit.compat.mock import MagicMock
+from ansible_collections.amazon.aws.tests.unit.compat.mock import sentinel
+import ansible_collections.amazon.aws.plugins.modules.ec2_instance as ec2_instance_module
+import ansible_collections.amazon.aws.plugins.module_utils.arn as utils_arn
+from ansible_collections.amazon.aws.plugins.module_utils.botocore import HAS_BOTO3
+
+try:
+ import botocore
+except ImportError:
+ pass
+
+pytest.mark.skipif(not HAS_BOTO3, reason="test_determine_iam_role.py requires the python modules 'boto3' and 'botocore'")
+
+
+def _client_error(code='GenericError'):
+ return botocore.exceptions.ClientError(
+ {'Error': {'Code': code, 'Message': 'Something went wrong'},
+ 'ResponseMetadata': {'RequestId': '01234567-89ab-cdef-0123-456789abcdef'}},
+ 'some_called_method')
+
+
+@pytest.fixture
+def params_object():
+ params = {
+ 'instance_role': None,
+ 'exact_count': None,
+ 'count': None,
+ 'launch_template': None,
+ 'instance_type': None,
+ }
+ return params
+
+
+class FailJsonException(Exception):
+ def __init__(self):
+ pass
+
+
+@pytest.fixture
+def ec2_instance(monkeypatch):
+ monkeypatch.setattr(ec2_instance_module, 'parse_aws_arn', lambda arn: None)
+ monkeypatch.setattr(ec2_instance_module, 'module', MagicMock())
+ ec2_instance_module.module.fail_json.side_effect = FailJsonException()
+ ec2_instance_module.module.fail_json_aws.side_effect = FailJsonException()
+ return ec2_instance_module
+
+
+def test_determine_iam_role_arn(params_object, ec2_instance, monkeypatch):
+ # Revert the default monkey patch to make it simple to try passing a valid ARNs
+ monkeypatch.setattr(ec2_instance, 'parse_aws_arn', utils_arn.parse_aws_arn)
+
+ # Simplest example, someone passes a valid instance profile ARN
+ arn = ec2_instance.determine_iam_role('arn:aws:iam::123456789012:instance-profile/myprofile')
+ assert arn == 'arn:aws:iam::123456789012:instance-profile/myprofile'
+
+
+def test_determine_iam_role_name(params_object, ec2_instance):
+ profile_description = {'InstanceProfile': {'Arn': sentinel.IAM_PROFILE_ARN}}
+ iam_client = MagicMock(**{"get_instance_profile.return_value": profile_description})
+ ec2_instance_module.module.client.return_value = iam_client
+
+ arn = ec2_instance.determine_iam_role(sentinel.IAM_PROFILE_NAME)
+ assert arn == sentinel.IAM_PROFILE_ARN
+
+
+def test_determine_iam_role_missing(params_object, ec2_instance):
+ missing_exception = _client_error('NoSuchEntity')
+ iam_client = MagicMock(**{"get_instance_profile.side_effect": missing_exception})
+ ec2_instance_module.module.client.return_value = iam_client
+
+ with pytest.raises(FailJsonException) as exception:
+ arn = ec2_instance.determine_iam_role(sentinel.IAM_PROFILE_NAME)
+
+ assert ec2_instance_module.module.fail_json_aws.call_count == 1
+ assert ec2_instance_module.module.fail_json_aws.call_args.args[0] is missing_exception
+ assert 'Could not find' in ec2_instance_module.module.fail_json_aws.call_args.kwargs['msg']
+
+
+@pytest.mark.skipif(sys.version_info < (3, 8), reason='call_args behaviour changed in Python 3.8')
+def test_determine_iam_role_missing(params_object, ec2_instance):
+ missing_exception = _client_error()
+ iam_client = MagicMock(**{"get_instance_profile.side_effect": missing_exception})
+ ec2_instance_module.module.client.return_value = iam_client
+
+ with pytest.raises(FailJsonException) as exception:
+ arn = ec2_instance.determine_iam_role(sentinel.IAM_PROFILE_NAME)
+
+ assert ec2_instance_module.module.fail_json_aws.call_count == 1
+ assert ec2_instance_module.module.fail_json_aws.call_args.args[0] is missing_exception
+ assert 'An error occurred while searching' in ec2_instance_module.module.fail_json_aws.call_args.kwargs['msg']
+ assert 'Please try supplying the full ARN' in ec2_instance_module.module.fail_json_aws.call_args.kwargs['msg']
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/a.pem b/ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/a.pem
new file mode 100644
index 000000000..4412f3258
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/a.pem
@@ -0,0 +1,31 @@
+-----BEGIN CERTIFICATE-----
+MIIFVTCCBD2gAwIBAgISAx4pnfwvGxYrrQhr/UXiN7HCMA0GCSqGSIb3DQEBCwUA
+MEoxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MSMwIQYDVQQD
+ExpMZXQncyBFbmNyeXB0IEF1dGhvcml0eSBYMzAeFw0xOTA3MjUwMDI4NTdaFw0x
+OTEwMjMwMDI4NTdaMBoxGDAWBgNVBAMTD2NyeXB0b2dyYXBoeS5pbzCCASIwDQYJ
+KoZIhvcNAQEBBQADggEPADCCAQoCggEBAKJDpCL99DVo83587MrVp6gunmKRoUfY
+vcgk5u2v0tB9OmZkcIY37z6AunHWr18Yj55zHmm6G8Nf35hmu3ql2A26WThCbmOe
+WXbxhgarkningZI9opUWnI2dIllguVIsq99GzhpNnDdCb26s5+SRhJI4cr4hYaKC
+XGDKooKWyXUX09SJTq7nW/1+pq3y9ZMvldRKjJALeAdwnC7kmUB6pK7q8J2VlpfQ
+wqGu6q/WHVdgnhWARw3GEFJWDn9wkxBAF08CpzhVaEj+iK+Ut/1HBgNYwqI47h7S
+q+qv0G2qklRVUtEM0zYRsp+y/6vivdbFLlPw8VaerbpJN3gLtpVNcGECAwEAAaOC
+AmMwggJfMA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYB
+BQUHAwIwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUjbe0bE1aZ8HiqtwqUfCe15bF
+V8UwHwYDVR0jBBgwFoAUqEpqYwR93brm0Tm3pkVl7/Oo7KEwbwYIKwYBBQUHAQEE
+YzBhMC4GCCsGAQUFBzABhiJodHRwOi8vb2NzcC5pbnQteDMubGV0c2VuY3J5cHQu
+b3JnMC8GCCsGAQUFBzAChiNodHRwOi8vY2VydC5pbnQteDMubGV0c2VuY3J5cHQu
+b3JnLzAaBgNVHREEEzARgg9jcnlwdG9ncmFwaHkuaW8wTAYDVR0gBEUwQzAIBgZn
+gQwBAgEwNwYLKwYBBAGC3xMBAQEwKDAmBggrBgEFBQcCARYaaHR0cDovL2Nwcy5s
+ZXRzZW5jcnlwdC5vcmcwggEDBgorBgEEAdZ5AgQCBIH0BIHxAO8AdgB0ftqDMa0z
+EJEhnM4lT0Jwwr/9XkIgCMY3NXnmEHvMVgAAAWwmvtnXAAAEAwBHMEUCIFXHYX/E
+xtbYCvjjQ3dN0HOLW1d8+aduktmax4mu3KszAiEAvTpxuSVVXJnVGA4tU2GOnI60
+sqTh/IK6hvrFN1k1HBUAdQApPFGWVMg5ZbqqUPxYB9S3b79Yeily3KTDDPTlRUf0
+eAAAAWwmvtm9AAAEAwBGMEQCIDn7sgzD+7JzR+XTvjKf7VyLWwX37O8uwCfCTKo7
++tEhAiB05bHiICU5wkfRBrwcvqXf4bPF7NT5LVlRQYzJ/hbpvzANBgkqhkiG9w0B
+AQsFAAOCAQEAcMU8E6D+5WC07QSeTppRTboC++7YgQg5NiSWm7OE2FlyiRZXnu0Y
+uBoaqAkZIqj7dom9wy1c1UauxOfM9lUZKhYnDTBu9tIhBAvCS0J0avv1j1KQygQ1
+qV+urJsunUwqV/vPWo1GfWophvyXVN6MAycv34ZXZvAjtG7oDcoQVLLvK1SIo2vu
+4/dNkOQzaeZez8q6Ij9762TbBWaK5C789VMdUWZCADWoToPIK533cWbDEp4IhBU/
+K73d7lGGl7S59SjT2V/XE6eJS9Zlj0M+A8pf/8tjM/ImHAjlOHB02sM/VfZ7HAuZ
+61TPxohL+e+X1FYeqIXYGXJmCEuB8WEmBg==
+-----END CERTIFICATE-----
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/b.pem b/ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/b.pem
new file mode 100644
index 000000000..2be4bca53
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/b.pem
@@ -0,0 +1,47 @@
+-----BEGIN CERTIFICATE-----
+MIIIUjCCB/egAwIBAgIRALiJR3zQjp0MevT/Hk89sfAwCgYIKoZIzj0EAwIwgZIx
+CzAJBgNVBAYTAkdCMRswGQYDVQQIExJHcmVhdGVyIE1hbmNoZXN0ZXIxEDAOBgNV
+BAcTB1NhbGZvcmQxGjAYBgNVBAoTEUNPTU9ETyBDQSBMaW1pdGVkMTgwNgYDVQQD
+Ey9DT01PRE8gRUNDIERvbWFpbiBWYWxpZGF0aW9uIFNlY3VyZSBTZXJ2ZXIgQ0Eg
+MjAeFw0xOTA3MzEwMDAwMDBaFw0yMDAyMDYyMzU5NTlaMGwxITAfBgNVBAsTGERv
+bWFpbiBDb250cm9sIFZhbGlkYXRlZDEhMB8GA1UECxMYUG9zaXRpdmVTU0wgTXVs
+dGktRG9tYWluMSQwIgYDVQQDExtzc2wzODczMzcuY2xvdWRmbGFyZXNzbC5jb20w
+WTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARPFdjdnBIJRPnHCPsCBJ/MmPytXnZX
+KV6lD2bbG5EVNuUQln4Na8heCY+sfpV+SPuuiNzZxgDA46GvyzdRYFhxo4IGUTCC
+Bk0wHwYDVR0jBBgwFoAUQAlhZ/C8g3FP3hIILG/U1Ct2PZYwHQYDVR0OBBYEFGLh
+bHk1KAYIRfVwXA3L+yDf0CxjMA4GA1UdDwEB/wQEAwIHgDAMBgNVHRMBAf8EAjAA
+MB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjBPBgNVHSAESDBGMDoGCysG
+AQQBsjEBAgIHMCswKQYIKwYBBQUHAgEWHWh0dHBzOi8vc2VjdXJlLmNvbW9kby5j
+b20vQ1BTMAgGBmeBDAECATBWBgNVHR8ETzBNMEugSaBHhkVodHRwOi8vY3JsLmNv
+bW9kb2NhNC5jb20vQ09NT0RPRUNDRG9tYWluVmFsaWRhdGlvblNlY3VyZVNlcnZl
+ckNBMi5jcmwwgYgGCCsGAQUFBwEBBHwwejBRBggrBgEFBQcwAoZFaHR0cDovL2Ny
+dC5jb21vZG9jYTQuY29tL0NPTU9ET0VDQ0RvbWFpblZhbGlkYXRpb25TZWN1cmVT
+ZXJ2ZXJDQTIuY3J0MCUGCCsGAQUFBzABhhlodHRwOi8vb2NzcC5jb21vZG9jYTQu
+Y29tMIIDkAYDVR0RBIIDhzCCA4OCG3NzbDM4NzMzNy5jbG91ZGZsYXJlc3NsLmNv
+bYIMKi5hanJ0Y3QuY29tghMqLmFrcmVwYnVyY3UuZ2VuLnRyghUqLmFuZHJlYXNr
+YW5lbGxvcy5jb22CDSouYW5zaWJsZS5jb22CGSouYXJ0b2Z0b3VjaC1raW5nd29v
+ZC5jb22CFyouYm91bGRlcnN3YXRlcmhvbGUuY29tghcqLmJyb2Nrc3RlY2hzdXBw
+b3J0LmNvbYIQKi5idXJjbGFyLndlYi50coIcKi5ob3Blc29uZ2ZyZW5jaGJ1bGxk
+b2dzLm5ldIIMKi5odXJyZW0uY29tghAqLmh5dmVsaWNvbnMuY29tghAqLmthcm1h
+Zml0LmNvLnVrghUqLmxvd3J5c3lzdGVtc2luYy5jb22CDioubWFuaWNydW4uY29t
+ghUqLm11dHVvZmluYW5jaWVyYS5jb22CDyoucGlsZ3JpbWFnZS5waIINKi5wa2dh
+bWVzLm9yZ4IbKi5ybHBjb25zdWx0aW5nc2VydmljZXMuY29tghYqLnJ1eWF0YWJp
+cmxlcmkuZ2VuLnRyghQqLnJ5YW5hcHBoeXNpY3NjLmNvbYIVKi53ZWFyaXRiYWNr
+d2FyZHMub3Jngg8qLnlldGlzbmFjay5jb22CCmFqcnRjdC5jb22CEWFrcmVwYnVy
+Y3UuZ2VuLnRyghNhbmRyZWFza2FuZWxsb3MuY29tggthbnNpYmxlLmNvbYIXYXJ0
+b2Z0b3VjaC1raW5nd29vZC5jb22CFWJvdWxkZXJzd2F0ZXJob2xlLmNvbYIVYnJv
+Y2tzdGVjaHN1cHBvcnQuY29tgg5idXJjbGFyLndlYi50coIaaG9wZXNvbmdmcmVu
+Y2hidWxsZG9ncy5uZXSCCmh1cnJlbS5jb22CDmh5dmVsaWNvbnMuY29tgg5rYXJt
+YWZpdC5jby51a4ITbG93cnlzeXN0ZW1zaW5jLmNvbYIMbWFuaWNydW4uY29tghNt
+dXR1b2ZpbmFuY2llcmEuY29tgg1waWxncmltYWdlLnBoggtwa2dhbWVzLm9yZ4IZ
+cmxwY29uc3VsdGluZ3NlcnZpY2VzLmNvbYIUcnV5YXRhYmlybGVyaS5nZW4udHKC
+EnJ5YW5hcHBoeXNpY3NjLmNvbYITd2Vhcml0YmFja3dhcmRzLm9yZ4INeWV0aXNu
+YWNrLmNvbTCCAQQGCisGAQQB1nkCBAIEgfUEgfIA8AB2ALIeBcyLos2KIE6HZvkr
+uYolIGdr2vpw57JJUy3vi5BeAAABbEVw8SgAAAQDAEcwRQIgE2YeTfb/d4BBUwpZ
+ihWXSR+vRyNNUg8GlOak2MFMHv0CIQCLBvtU401m5/Psg9KirQZs321BSxgUKgSQ
+m9M691d3eQB2AF6nc/nfVsDntTZIfdBJ4DJ6kZoMhKESEoQYdZaBcUVYAAABbEVw
+8VgAAAQDAEcwRQIgGYsGfr3/mekjzMS9+ALAjx1ryfIfhXB/+UghTcw4Y8ICIQDS
+K2L18WX3+Oh4TjJhjh5tV1iYyZVYivcwwbr7mtmOqjAKBggqhkjOPQQDAgNJADBG
+AiEAjNt7LF78GV7snky9jwFcBsLH55ndzduvsrkJ7Ne1SgYCIQDsMJsTr9VP6kar
+4Kv8V9zNBmpGrGNuE7A1GixBvzNaHA==
+-----END CERTIFICATE-----
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/chain-1.0.cert b/ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/chain-1.0.cert
new file mode 100644
index 000000000..6997766ac
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/chain-1.0.cert
@@ -0,0 +1,121 @@
+subject=/C=AU/ST=Victoria/L=Melbourne/O=Telstra Corporation Limited/OU=Telstra Energy/CN=dev.energy.inside.telstra.com
+issuer=/C=BM/O=QuoVadis Limited/CN=QuoVadis Global SSL ICA G3
+-----BEGIN CERTIFICATE-----
+MIIIHTCCBgWgAwIBAgIUCqrrzSfjzaoyB3DOxst2kMxFp/MwDQYJKoZIhvcNAQEL
+BQAwTTELMAkGA1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxIzAh
+BgNVBAMTGlF1b1ZhZGlzIEdsb2JhbCBTU0wgSUNBIEczMB4XDTE5MDgyMTIyMjIy
+OFoXDTIxMDgyMTIyMzIwMFowgZsxCzAJBgNVBAYTAkFVMREwDwYDVQQIDAhWaWN0
+b3JpYTESMBAGA1UEBwwJTWVsYm91cm5lMSQwIgYDVQQKDBtUZWxzdHJhIENvcnBv
+cmF0aW9uIExpbWl0ZWQxFzAVBgNVBAsMDlRlbHN0cmEgRW5lcmd5MSYwJAYDVQQD
+DB1kZXYuZW5lcmd5Lmluc2lkZS50ZWxzdHJhLmNvbTCCASIwDQYJKoZIhvcNAQEB
+BQADggEPADCCAQoCggEBAMPAPH2y206qios2NMzlCNJv1mrwC1/8tH2HOqJGiYZB
+O7QOBRSvJsV++IozCB8ap99e8B64OOAQPOyykrdXd2axhftmMb1SFMF56eukHSuz
+KhKWRUgHs0UFRU51lDcBcOvphwJ+5SOgqrqKFFFBgJ0ZpcP54JpFwKIdh3ac10x2
+mBaW5ccqdv5X9oEMu1D/yivBmy34tsbLYyfttCjP76iVT7UVYHjHWynnIhsEyMsU
+gdM90NzrTlrvTSi/EcCD1W3+8b0f+G1TI5rhHbKwR0n/mv5QLFm7EABoYPhxS8bX
+B+9tE67yb0RyWbgvUiHySRynQLNMRpRx8Y9bA8uC8n8CAwEAAaOCA6QwggOgMAkG
+A1UdEwQCMAAwHwYDVR0jBBgwFoAUsxKJtalLNbwVAPCA6dh4h/ETfHYwcwYIKwYB
+BQUHAQEEZzBlMDcGCCsGAQUFBzAChitodHRwOi8vdHJ1c3QucXVvdmFkaXNnbG9i
+YWwuY29tL3F2c3NsZzMuY3J0MCoGCCsGAQUFBzABhh5odHRwOi8vb2NzcC5xdW92
+YWRpc2dsb2JhbC5jb20wgZ8GA1UdEQSBlzCBlIIdZGV2LmVuZXJneS5pbnNpZGUu
+dGVsc3RyYS5jb22CJXJlcG9ydHMuZGV2LmVuZXJneS5pbnNpZGUudGVsc3RyYS5j
+b22CJ2dyZWVuc3luYy5kZXYuZW5lcmd5Lmluc2lkZS50ZWxzdHJhLmNvbYIjbmdv
+c3MuZGV2LmVuZXJneS5pbnNpZGUudGVsc3RyYS5jb20wUQYDVR0gBEowSDBGBgwr
+BgEEAb5YAAJkAQEwNjA0BggrBgEFBQcCARYoaHR0cDovL3d3dy5xdW92YWRpc2ds
+b2JhbC5jb20vcmVwb3NpdG9yeTAdBgNVHSUEFjAUBggrBgEFBQcDAgYIKwYBBQUH
+AwEwOgYDVR0fBDMwMTAvoC2gK4YpaHR0cDovL2NybC5xdW92YWRpc2dsb2JhbC5j
+b20vcXZzc2xnMy5jcmwwHQYDVR0OBBYEFEoJQRpPC/V5ZK3mMkszZE2v6vh+MA4G
+A1UdDwEB/wQEAwIFoDCCAXwGCisGAQQB1nkCBAIEggFsBIIBaAFmAHUAVhQGmi/X
+wuzT9eG9RLI+x0Z2ubyZEVzA75SYVdaJ0N0AAAFstk9Y+gAABAMARjBEAiBFMZa6
+O9iXVjy2kqQa54vgNFdU7shgFJJhm//fSAQZUAIgBIL/yPdh+XiuQS2xPhCzNYkh
+bxf7BbN4qUISESgiZpsAdgBvU3asMfAxGdiZAKRRFf93FRwR2QLBACkGjbIImjfZ
+EwAAAWy2T1nKAAAEAwBHMEUCIG0tp63jLsDsfCTDlcvV5ItjRkbUJBnkxlPdP2PH
+88sTAiEApgaPofVdn2hdI12iDDex72ta+9wpwQ1MxoaJn2nt+qEAdQDuS723dc5g
+uuFCaR+r4Z5mow9+X7By2IMAxHuJeqj9ywAAAWy2T1iJAAAEAwBGMEQCIE/mzEFp
+CJUc71jvwJa4Px86R3ZYK4mHmUlQAUZqd0ZkAiBdEmT8xxTuleSUlYHEkKCK/FZX
+L+vsYJpPrA9TsO5IsTANBgkqhkiG9w0BAQsFAAOCAgEApE9WLz3S8tqA9Dk3r9LF
+rJy8km9cBt1O9SQZwFsduGKGdF3Fd+/Y0V7UrFDzrX+NIzqcmgBHKxaIXorMBF70
+ajMaaROP2ymkpEXnruEwoR47fbW+JRAWDRm2xnouQveQX9ZcgCLbBvAWBqpndQj2
+DGmLJhNz5GlFBjh3PQZlU1w8hU7TrDxa7M1GMtVnk8X+o3l/MX9iPeEs+PiC4dHD
+hpj84RY1VQJz8+10rql47SB5YgbwcqaizTG4ax/OAv1JHNWtfAodIMX8Y8X00zoz
+A20LQv880jCCNANVNbrXJ3h4X3xwW/C1X9vYk0shymZJbT5u17JbPD1cy39bA7kT
+F4L7scdQRxvcqazYN4/IdgvgMji9OltiYufP88Ti8KB2tcl2accpiC5St/zllGD1
+hqEeYLMzjyvUKR/1uvURQQtc0DPvBRmvkB+aI4g+sLkTTFWj5bsA1vKU8SDCyMuB
+RQV11DId5+RNNCmWnskORUZJQssvY49pnfCxCES2nt3l/XzTzVtLYmd6G9uAqVac
+e2ibnmDrFVlmlyRsCiMfZl5/OTJzt7Cj3az59m5Syfw/lnS9YP82t/r/ufuKkO5Q
+q5a9aI8DuNNmAjR4lpIJNqIpX/y+dG2aGmx4XTc31MR9szWtiTgOHe0MkMupOAL0
+qkHrBgwo1zjuTMf3QOg6Z5Q=
+-----END CERTIFICATE-----
+
+subject=/C=BM/O=QuoVadis Limited/CN=QuoVadis Global SSL ICA G3
+issuer=/C=BM/O=QuoVadis Limited/CN=QuoVadis Root CA 2 G3
+-----BEGIN CERTIFICATE-----
+MIIGFzCCA/+gAwIBAgIUftbnnMmtgcTIGT75XUQodw40ExcwDQYJKoZIhvcNAQEL
+BQAwSDELMAkGA1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxHjAc
+BgNVBAMTFVF1b1ZhZGlzIFJvb3QgQ0EgMiBHMzAeFw0xMjExMDYxNDUwMThaFw0y
+MjExMDYxNDUwMThaME0xCzAJBgNVBAYTAkJNMRkwFwYDVQQKExBRdW9WYWRpcyBM
+aW1pdGVkMSMwIQYDVQQDExpRdW9WYWRpcyBHbG9iYWwgU1NMIElDQSBHMzCCAiIw
+DQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANf8Od17be6c6lTGJDhEXpmkTs4y
+Q39Rr5VJyBeWCg06nSS71s6xF3sZvKcV0MbXlXCYM2ZX7cNTbJ81gs7uDsKFp+vK
+EymiKyEiI2SImOtECNnSg+RVR4np/xz/UlC0yFUisH75cZsJ8T1pkGMfiEouR0EM
+7O0uFgoboRfUP582TTWy0F7ynSA6YfGKnKj0OFwZJmGHVkLs1VevWjhj3R1fsPan
+H05P5moePFnpQdj1FofoSxUHZ0c7VB+sUimboHm/uHNY1LOsk77qiSuVC5/yrdg3
+2EEfP/mxJYT4r/5UiD7VahySzeZHzZ2OibQm2AfgfMN3l57lCM3/WPQBhMAPS1jz
+kE+7MjajM2f0aZctimW4Hasrj8AQnfAdHqZehbhtXaAlffNEzCdpNK584oCTVR7N
+UR9iZFx83ruTqpo+GcLP/iSYqhM4g7fy45sNhU+IS+ca03zbxTl3TTlkofXunI5B
+xxE30eGSQpDZ5+iUJcEOAuVKrlYocFbB3KF45hwcbzPWQ1DcO2jFAapOtQzeS+MZ
+yZzT2YseJ8hQHKu8YrXZWwKaNfyl8kFkHUBDICowNEoZvBwRCQp8sgqL6YRZy0uD
+JGxmnC2e0BVKSjcIvmq/CRWH7yiTk9eWm73xrsg9iIyD/kwJEnLyIk8tR5V8p/hc
+1H2AjDrZH12PsZ45AgMBAAGjgfMwgfAwEgYDVR0TAQH/BAgwBgEB/wIBATARBgNV
+HSAECjAIMAYGBFUdIAAwOgYIKwYBBQUHAQEELjAsMCoGCCsGAQUFBzABhh5odHRw
+Oi8vb2NzcC5xdW92YWRpc2dsb2JhbC5jb20wDgYDVR0PAQH/BAQDAgEGMB8GA1Ud
+IwQYMBaAFO3nb3Zav2DsSVvGpXe7chZxm8Q9MDsGA1UdHwQ0MDIwMKAuoCyGKmh0
+dHA6Ly9jcmwucXVvdmFkaXNnbG9iYWwuY29tL3F2cmNhMmczLmNybDAdBgNVHQ4E
+FgQUsxKJtalLNbwVAPCA6dh4h/ETfHYwDQYJKoZIhvcNAQELBQADggIBAFGm1Fqp
+RMiKr7a6h707M+km36PVXZnX1NZocCn36MrfRvphotbOCDm+GmRkar9ZMGhc8c/A
+Vn7JSCjwF9jNOFIOUyNLq0w4luk+Pt2YFDbgF8IDdx53xIo8Gv05e9xpTvQYaIto
+qeHbQjGXfSGc91olfX6JUwZlxxbhdJH+rxTFAg0jcbqToJoScWTfXSr1QRcNbSTs
+Y4CPG6oULsnhVvrzgldGSK+DxFi2OKcDsOKkV7W4IGg8Do2L/M588AfBnV8ERzpl
+qgMBBQxC2+0N6RdFHbmZt0HQE/NIg1s0xcjGx1XW3YTOfje31rmAXKHOehm4Bu48
+gr8gePq5cdQ2W9tA0Dnytb9wzH2SyPPIXRI7yNxaX9H8wYeDeeiKSSmQtfh1v5cV
+7RXvm8F6hLJkkco/HOW3dAUwZFcKsUH+1eUJKLN18eDGwB8yGawjHvOKqcfg5Lf/
+TvC7hgcx7pDYaCCaqHaekgUwXbB2Enzqr1fdwoU1c01W5YuQAtAx5wk1bf34Yq/J
+ph7wNXGvo88N0/EfP9AdVGmJzy7VuRXeVAOyjKAIeADMlwpjBRhcbs9m3dkqvoMb
+SXKJxv/hFmNgEOvOlaFsXX1dbKg1v+C1AzKAFdiuAIa62JzASiEhigqNSdqdTsOh
+8W8hdONuKKpe9zKedhBFAvuxhDgKmnySglYc
+-----END CERTIFICATE-----
+
+subject=/C=BM/O=QuoVadis Limited/CN=QuoVadis Root CA 2 G3
+issuer=/C=BM/O=QuoVadis Limited/CN=QuoVadis Root CA 2 G3
+-----BEGIN CERTIFICATE-----
+MIIFYDCCA0igAwIBAgIURFc0JFuBiZs18s64KztbpybwdSgwDQYJKoZIhvcNAQEL
+BQAwSDELMAkGA1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxHjAc
+BgNVBAMTFVF1b1ZhZGlzIFJvb3QgQ0EgMiBHMzAeFw0xMjAxMTIxODU5MzJaFw00
+MjAxMTIxODU5MzJaMEgxCzAJBgNVBAYTAkJNMRkwFwYDVQQKExBRdW9WYWRpcyBM
+aW1pdGVkMR4wHAYDVQQDExVRdW9WYWRpcyBSb290IENBIDIgRzMwggIiMA0GCSqG
+SIb3DQEBAQUAA4ICDwAwggIKAoICAQChriWyARjcV4g/Ruv5r+LrI3HimtFhZiFf
+qq8nUeVuGxbULX1QsFN3vXg6YOJkApt8hpvWGo6t/x8Vf9WVHhLL5hSEBMHfNrMW
+n4rjyduYNM7YMxcoRvynyfDStNVNCXJJ+fKH46nafaF9a7I6JaltUkSs+L5u+9ym
+c5GQYaYDFCDy54ejiK2toIz/pgslUiXnFgHVy7g1gQyjO/Dh4fxaXc6AcW34Sas+
+O7q414AB+6XrW7PFXmAqMaCvN+ggOp+oMiwMzAkd056OXbxMmO7FGmh77FOm6RQ1
+o9/NgJ8MSPsc9PG/Srj61YxxSscfrf5BmrODXfKEVu+lV0POKa2Mq1W/xPtbAd0j
+IaFYAI7D0GoT7RPjEiuA3GfmlbLNHiJuKvhB1PLKFAeNilUSxmn1uIZoL1NesNKq
+IcGY5jDjZ1XHm26sGahVpkUG0CM62+tlXSoREfA7T8pt9DTEceT/AFr2XK4jYIVz
+8eQQsSWu1ZK7E8EM4DnatDlXtas1qnIhO4M15zHfeiFuuDIIfR0ykRVKYnLP43eh
+vNURG3YBZwjgQQvD6xVu+KQZ2aKrr+InUlYrAoosFCT5v0ICvybIxo/gbjh9Uy3l
+7ZizlWNof/k19N+IxWA1ksB8aRxhlRbQ694Lrz4EEEVlWFA4r0jyWbYW8jwNkALG
+cC4BrTwV1wIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIB
+BjAdBgNVHQ4EFgQU7edvdlq/YOxJW8ald7tyFnGbxD0wDQYJKoZIhvcNAQELBQAD
+ggIBAJHfgD9DCX5xwvfrs4iP4VGyvD11+ShdyLyZm3tdquXK4Qr36LLTn91nMX66
+AarHakE7kNQIXLJgapDwyM4DYvmL7ftuKtwGTTwpD4kWilhMSA/ohGHqPHKmd+RC
+roijQ1h5fq7KpVMNqT1wvSAZYaRsOPxDMuHBR//47PERIjKWnML2W2mWeyAMQ0Ga
+W/ZZGYjeVYg3UQt4XAoeo0L9x52ID8DyeAIkVJOviYeIyUqAHerQbj5hLja7NQ4n
+lv1mNDthcnPxFlxHBlRJAHpYErAK74X9sbgzdWqTHBLmYF5vHX/JHyPLhGGfHoJE
++V+tYlUkmlKY7VHnoX6XOuYvHxHaU4AshZ6rNRDbIl9qxV6XU/IyAgkwo1jwDQHV
+csaxfGl7w/U2Rcxhbl5MlMVerugOXou/983g7aEOGzPuVBj+D77vfoRrQ+NwmNtd
+dbINWQeFFSM51vHfqSYP1kjHs6Yi9TM3WpVHn3u6GBVv/9YUZINJ0gpnIdsPNWNg
+KCLjsZWDzYWm3S8P52dSbrsvhXz1SnPnxT7AvSESBT/8twNJAlvIJebiVDj1eYeM
+HVOyToV7BjjHLPj4sHKNJeV3UvQDHEimUF+IIDBu8oJDqz2XhOdT+yHBTw8imoa4
+WSr2Rz0ZiC3oheGe7IUIarFsNMkd7EgrO3jtZsSOeWmD3n+M
+-----END CERTIFICATE-----
+
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/chain-1.1.cert b/ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/chain-1.1.cert
new file mode 100644
index 000000000..51f64f08d
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/chain-1.1.cert
@@ -0,0 +1,69 @@
+subject=/C=AU/ST=Victoria/L=Melbourne/O=Telstra Corporation Limited/OU=Telstra Energy/CN=dev.energy.inside.telstra.com
+issuer=/C=BM/O=QuoVadis Limited/CN=QuoVadis Global SSL ICA G3
+-----BEGIN CERTIFICATE-----
+MIIIHTCCBgWgAwIBAgIUCqrrzSfjzaoyB3DOxst2kMxFp/MwDQYJKoZIhvcNAQELBQAwTTELMAkGA1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxIzAh
+BgNVBAMTGlF1b1ZhZGlzIEdsb2JhbCBTU0wgSUNBIEczMB4XDTE5MDgyMTIyMjIyOFoXDTIxMDgyMTIyMzIwMFowgZsxCzAJBgNVBAYTAkFVMREwDwYDVQQIDAhWaWN0
+b3JpYTESMBAGA1UEBwwJTWVsYm91cm5lMSQwIgYDVQQKDBtUZWxzdHJhIENvcnBvcmF0aW9uIExpbWl0ZWQxFzAVBgNVBAsMDlRlbHN0cmEgRW5lcmd5MSYwJAYDVQQD
+DB1kZXYuZW5lcmd5Lmluc2lkZS50ZWxzdHJhLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMPAPH2y206qios2NMzlCNJv1mrwC1/8tH2HOqJGiYZB
+O7QOBRSvJsV++IozCB8ap99e8B64OOAQPOyykrdXd2axhftmMb1SFMF56eukHSuzKhKWRUgHs0UFRU51lDcBcOvphwJ+5SOgqrqKFFFBgJ0ZpcP54JpFwKIdh3ac10x2
+mBaW5ccqdv5X9oEMu1D/yivBmy34tsbLYyfttCjP76iVT7UVYHjHWynnIhsEyMsUgdM90NzrTlrvTSi/EcCD1W3+8b0f+G1TI5rhHbKwR0n/mv5QLFm7EABoYPhxS8bX
+B+9tE67yb0RyWbgvUiHySRynQLNMRpRx8Y9bA8uC8n8CAwEAAaOCA6QwggOgMAkGA1UdEwQCMAAwHwYDVR0jBBgwFoAUsxKJtalLNbwVAPCA6dh4h/ETfHYwcwYIKwYB
+BQUHAQEEZzBlMDcGCCsGAQUFBzAChitodHRwOi8vdHJ1c3QucXVvdmFkaXNnbG9iYWwuY29tL3F2c3NsZzMuY3J0MCoGCCsGAQUFBzABhh5odHRwOi8vb2NzcC5xdW92
+YWRpc2dsb2JhbC5jb20wgZ8GA1UdEQSBlzCBlIIdZGV2LmVuZXJneS5pbnNpZGUudGVsc3RyYS5jb22CJXJlcG9ydHMuZGV2LmVuZXJneS5pbnNpZGUudGVsc3RyYS5j
+b22CJ2dyZWVuc3luYy5kZXYuZW5lcmd5Lmluc2lkZS50ZWxzdHJhLmNvbYIjbmdvc3MuZGV2LmVuZXJneS5pbnNpZGUudGVsc3RyYS5jb20wUQYDVR0gBEowSDBGBgwr
+BgEEAb5YAAJkAQEwNjA0BggrBgEFBQcCARYoaHR0cDovL3d3dy5xdW92YWRpc2dsb2JhbC5jb20vcmVwb3NpdG9yeTAdBgNVHSUEFjAUBggrBgEFBQcDAgYIKwYBBQUH
+AwEwOgYDVR0fBDMwMTAvoC2gK4YpaHR0cDovL2NybC5xdW92YWRpc2dsb2JhbC5jb20vcXZzc2xnMy5jcmwwHQYDVR0OBBYEFEoJQRpPC/V5ZK3mMkszZE2v6vh+MA4G
+A1UdDwEB/wQEAwIFoDCCAXwGCisGAQQB1nkCBAIEggFsBIIBaAFmAHUAVhQGmi/XwuzT9eG9RLI+x0Z2ubyZEVzA75SYVdaJ0N0AAAFstk9Y+gAABAMARjBEAiBFMZa6
+O9iXVjy2kqQa54vgNFdU7shgFJJhm//fSAQZUAIgBIL/yPdh+XiuQS2xPhCzNYkhbxf7BbN4qUISESgiZpsAdgBvU3asMfAxGdiZAKRRFf93FRwR2QLBACkGjbIImjfZ
+EwAAAWy2T1nKAAAEAwBHMEUCIG0tp63jLsDsfCTDlcvV5ItjRkbUJBnkxlPdP2PH88sTAiEApgaPofVdn2hdI12iDDex72ta+9wpwQ1MxoaJn2nt+qEAdQDuS723dc5g
+uuFCaR+r4Z5mow9+X7By2IMAxHuJeqj9ywAAAWy2T1iJAAAEAwBGMEQCIE/mzEFpCJUc71jvwJa4Px86R3ZYK4mHmUlQAUZqd0ZkAiBdEmT8xxTuleSUlYHEkKCK/FZX
+L+vsYJpPrA9TsO5IsTANBgkqhkiG9w0BAQsFAAOCAgEApE9WLz3S8tqA9Dk3r9LFrJy8km9cBt1O9SQZwFsduGKGdF3Fd+/Y0V7UrFDzrX+NIzqcmgBHKxaIXorMBF70
+ajMaaROP2ymkpEXnruEwoR47fbW+JRAWDRm2xnouQveQX9ZcgCLbBvAWBqpndQj2DGmLJhNz5GlFBjh3PQZlU1w8hU7TrDxa7M1GMtVnk8X+o3l/MX9iPeEs+PiC4dHD
+hpj84RY1VQJz8+10rql47SB5YgbwcqaizTG4ax/OAv1JHNWtfAodIMX8Y8X00zozA20LQv880jCCNANVNbrXJ3h4X3xwW/C1X9vYk0shymZJbT5u17JbPD1cy39bA7kT
+F4L7scdQRxvcqazYN4/IdgvgMji9OltiYufP88Ti8KB2tcl2accpiC5St/zllGD1hqEeYLMzjyvUKR/1uvURQQtc0DPvBRmvkB+aI4g+sLkTTFWj5bsA1vKU8SDCyMuB
+RQV11DId5+RNNCmWnskORUZJQssvY49pnfCxCES2nt3l/XzTzVtLYmd6G9uAqVace2ibnmDrFVlmlyRsCiMfZl5/OTJzt7Cj3az59m5Syfw/lnS9YP82t/r/ufuKkO5Q
+q5a9aI8DuNNmAjR4lpIJNqIpX/y+dG2aGmx4XTc31MR9szWtiTgOHe0MkMupOAL0qkHrBgwo1zjuTMf3QOg6Z5Q=
+-----END CERTIFICATE-----
+
+subject=/C=BM/O=QuoVadis Limited/CN=QuoVadis Global SSL ICA G3
+issuer=/C=BM/O=QuoVadis Limited/CN=QuoVadis Root CA 2 G3
+-----BEGIN CERTIFICATE-----
+MIIGFzCCA/+gAwIBAgIUftbnnMmtgcTIGT75XUQodw40ExcwDQYJKoZIhvcNAQELBQAwSDELMAkGA1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxHjAc
+BgNVBAMTFVF1b1ZhZGlzIFJvb3QgQ0EgMiBHMzAeFw0xMjExMDYxNDUwMThaFw0yMjExMDYxNDUwMThaME0xCzAJBgNVBAYTAkJNMRkwFwYDVQQKExBRdW9WYWRpcyBM
+aW1pdGVkMSMwIQYDVQQDExpRdW9WYWRpcyBHbG9iYWwgU1NMIElDQSBHMzCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANf8Od17be6c6lTGJDhEXpmkTs4y
+Q39Rr5VJyBeWCg06nSS71s6xF3sZvKcV0MbXlXCYM2ZX7cNTbJ81gs7uDsKFp+vKEymiKyEiI2SImOtECNnSg+RVR4np/xz/UlC0yFUisH75cZsJ8T1pkGMfiEouR0EM
+7O0uFgoboRfUP582TTWy0F7ynSA6YfGKnKj0OFwZJmGHVkLs1VevWjhj3R1fsPanH05P5moePFnpQdj1FofoSxUHZ0c7VB+sUimboHm/uHNY1LOsk77qiSuVC5/yrdg3
+2EEfP/mxJYT4r/5UiD7VahySzeZHzZ2OibQm2AfgfMN3l57lCM3/WPQBhMAPS1jzkE+7MjajM2f0aZctimW4Hasrj8AQnfAdHqZehbhtXaAlffNEzCdpNK584oCTVR7N
+UR9iZFx83ruTqpo+GcLP/iSYqhM4g7fy45sNhU+IS+ca03zbxTl3TTlkofXunI5BxxE30eGSQpDZ5+iUJcEOAuVKrlYocFbB3KF45hwcbzPWQ1DcO2jFAapOtQzeS+MZ
+yZzT2YseJ8hQHKu8YrXZWwKaNfyl8kFkHUBDICowNEoZvBwRCQp8sgqL6YRZy0uDJGxmnC2e0BVKSjcIvmq/CRWH7yiTk9eWm73xrsg9iIyD/kwJEnLyIk8tR5V8p/hc
+1H2AjDrZH12PsZ45AgMBAAGjgfMwgfAwEgYDVR0TAQH/BAgwBgEB/wIBATARBgNVHSAECjAIMAYGBFUdIAAwOgYIKwYBBQUHAQEELjAsMCoGCCsGAQUFBzABhh5odHRw
+Oi8vb2NzcC5xdW92YWRpc2dsb2JhbC5jb20wDgYDVR0PAQH/BAQDAgEGMB8GA1UdIwQYMBaAFO3nb3Zav2DsSVvGpXe7chZxm8Q9MDsGA1UdHwQ0MDIwMKAuoCyGKmh0
+dHA6Ly9jcmwucXVvdmFkaXNnbG9iYWwuY29tL3F2cmNhMmczLmNybDAdBgNVHQ4EFgQUsxKJtalLNbwVAPCA6dh4h/ETfHYwDQYJKoZIhvcNAQELBQADggIBAFGm1Fqp
+RMiKr7a6h707M+km36PVXZnX1NZocCn36MrfRvphotbOCDm+GmRkar9ZMGhc8c/AVn7JSCjwF9jNOFIOUyNLq0w4luk+Pt2YFDbgF8IDdx53xIo8Gv05e9xpTvQYaIto
+qeHbQjGXfSGc91olfX6JUwZlxxbhdJH+rxTFAg0jcbqToJoScWTfXSr1QRcNbSTsY4CPG6oULsnhVvrzgldGSK+DxFi2OKcDsOKkV7W4IGg8Do2L/M588AfBnV8ERzpl
+qgMBBQxC2+0N6RdFHbmZt0HQE/NIg1s0xcjGx1XW3YTOfje31rmAXKHOehm4Bu48gr8gePq5cdQ2W9tA0Dnytb9wzH2SyPPIXRI7yNxaX9H8wYeDeeiKSSmQtfh1v5cV
+7RXvm8F6hLJkkco/HOW3dAUwZFcKsUH+1eUJKLN18eDGwB8yGawjHvOKqcfg5Lf/TvC7hgcx7pDYaCCaqHaekgUwXbB2Enzqr1fdwoU1c01W5YuQAtAx5wk1bf34Yq/J
+ph7wNXGvo88N0/EfP9AdVGmJzy7VuRXeVAOyjKAIeADMlwpjBRhcbs9m3dkqvoMbSXKJxv/hFmNgEOvOlaFsXX1dbKg1v+C1AzKAFdiuAIa62JzASiEhigqNSdqdTsOh
+8W8hdONuKKpe9zKedhBFAvuxhDgKmnySglYc
+-----END CERTIFICATE-----
+
+subject=/C=BM/O=QuoVadis Limited/CN=QuoVadis Root CA 2 G3
+issuer=/C=BM/O=QuoVadis Limited/CN=QuoVadis Root CA 2 G3
+-----BEGIN CERTIFICATE-----
+MIIFYDCCA0igAwIBAgIURFc0JFuBiZs18s64KztbpybwdSgwDQYJKoZIhvcNAQELBQAwSDELMAkGA1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxHjAc
+BgNVBAMTFVF1b1ZhZGlzIFJvb3QgQ0EgMiBHMzAeFw0xMjAxMTIxODU5MzJaFw00MjAxMTIxODU5MzJaMEgxCzAJBgNVBAYTAkJNMRkwFwYDVQQKExBRdW9WYWRpcyBM
+aW1pdGVkMR4wHAYDVQQDExVRdW9WYWRpcyBSb290IENBIDIgRzMwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQChriWyARjcV4g/Ruv5r+LrI3HimtFhZiFf
+qq8nUeVuGxbULX1QsFN3vXg6YOJkApt8hpvWGo6t/x8Vf9WVHhLL5hSEBMHfNrMWn4rjyduYNM7YMxcoRvynyfDStNVNCXJJ+fKH46nafaF9a7I6JaltUkSs+L5u+9ym
+c5GQYaYDFCDy54ejiK2toIz/pgslUiXnFgHVy7g1gQyjO/Dh4fxaXc6AcW34Sas+O7q414AB+6XrW7PFXmAqMaCvN+ggOp+oMiwMzAkd056OXbxMmO7FGmh77FOm6RQ1
+o9/NgJ8MSPsc9PG/Srj61YxxSscfrf5BmrODXfKEVu+lV0POKa2Mq1W/xPtbAd0jIaFYAI7D0GoT7RPjEiuA3GfmlbLNHiJuKvhB1PLKFAeNilUSxmn1uIZoL1NesNKq
+IcGY5jDjZ1XHm26sGahVpkUG0CM62+tlXSoREfA7T8pt9DTEceT/AFr2XK4jYIVz8eQQsSWu1ZK7E8EM4DnatDlXtas1qnIhO4M15zHfeiFuuDIIfR0ykRVKYnLP43eh
+vNURG3YBZwjgQQvD6xVu+KQZ2aKrr+InUlYrAoosFCT5v0ICvybIxo/gbjh9Uy3l7ZizlWNof/k19N+IxWA1ksB8aRxhlRbQ694Lrz4EEEVlWFA4r0jyWbYW8jwNkALG
+cC4BrTwV1wIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQU7edvdlq/YOxJW8ald7tyFnGbxD0wDQYJKoZIhvcNAQELBQAD
+ggIBAJHfgD9DCX5xwvfrs4iP4VGyvD11+ShdyLyZm3tdquXK4Qr36LLTn91nMX66AarHakE7kNQIXLJgapDwyM4DYvmL7ftuKtwGTTwpD4kWilhMSA/ohGHqPHKmd+RC
+roijQ1h5fq7KpVMNqT1wvSAZYaRsOPxDMuHBR//47PERIjKWnML2W2mWeyAMQ0GaW/ZZGYjeVYg3UQt4XAoeo0L9x52ID8DyeAIkVJOviYeIyUqAHerQbj5hLja7NQ4n
+lv1mNDthcnPxFlxHBlRJAHpYErAK74X9sbgzdWqTHBLmYF5vHX/JHyPLhGGfHoJE+V+tYlUkmlKY7VHnoX6XOuYvHxHaU4AshZ6rNRDbIl9qxV6XU/IyAgkwo1jwDQHV
+csaxfGl7w/U2Rcxhbl5MlMVerugOXou/983g7aEOGzPuVBj+D77vfoRrQ+NwmNtddbINWQeFFSM51vHfqSYP1kjHs6Yi9TM3WpVHn3u6GBVv/9YUZINJ0gpnIdsPNWNg
+KCLjsZWDzYWm3S8P52dSbrsvhXz1SnPnxT7AvSESBT/8twNJAlvIJebiVDj1eYeMHVOyToV7BjjHLPj4sHKNJeV3UvQDHEimUF+IIDBu8oJDqz2XhOdT+yHBTw8imoa4
+WSr2Rz0ZiC3oheGe7IUIarFsNMkd7EgrO3jtZsSOeWmD3n+M
+-----END CERTIFICATE-----
+
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/chain-1.2.cert b/ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/chain-1.2.cert
new file mode 100644
index 000000000..ce2992411
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/chain-1.2.cert
@@ -0,0 +1,113 @@
+-----BEGIN CERTIFICATE-----
+MIIIHTCCBgWgAwIBAgIUCqrrzSfjzaoyB3DOxst2kMxFp/MwDQYJKoZIhvcNAQEL
+BQAwTTELMAkGA1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxIzAh
+BgNVBAMTGlF1b1ZhZGlzIEdsb2JhbCBTU0wgSUNBIEczMB4XDTE5MDgyMTIyMjIy
+OFoXDTIxMDgyMTIyMzIwMFowgZsxCzAJBgNVBAYTAkFVMREwDwYDVQQIDAhWaWN0
+b3JpYTESMBAGA1UEBwwJTWVsYm91cm5lMSQwIgYDVQQKDBtUZWxzdHJhIENvcnBv
+cmF0aW9uIExpbWl0ZWQxFzAVBgNVBAsMDlRlbHN0cmEgRW5lcmd5MSYwJAYDVQQD
+DB1kZXYuZW5lcmd5Lmluc2lkZS50ZWxzdHJhLmNvbTCCASIwDQYJKoZIhvcNAQEB
+BQADggEPADCCAQoCggEBAMPAPH2y206qios2NMzlCNJv1mrwC1/8tH2HOqJGiYZB
+O7QOBRSvJsV++IozCB8ap99e8B64OOAQPOyykrdXd2axhftmMb1SFMF56eukHSuz
+KhKWRUgHs0UFRU51lDcBcOvphwJ+5SOgqrqKFFFBgJ0ZpcP54JpFwKIdh3ac10x2
+mBaW5ccqdv5X9oEMu1D/yivBmy34tsbLYyfttCjP76iVT7UVYHjHWynnIhsEyMsU
+gdM90NzrTlrvTSi/EcCD1W3+8b0f+G1TI5rhHbKwR0n/mv5QLFm7EABoYPhxS8bX
+B+9tE67yb0RyWbgvUiHySRynQLNMRpRx8Y9bA8uC8n8CAwEAAaOCA6QwggOgMAkG
+A1UdEwQCMAAwHwYDVR0jBBgwFoAUsxKJtalLNbwVAPCA6dh4h/ETfHYwcwYIKwYB
+BQUHAQEEZzBlMDcGCCsGAQUFBzAChitodHRwOi8vdHJ1c3QucXVvdmFkaXNnbG9i
+YWwuY29tL3F2c3NsZzMuY3J0MCoGCCsGAQUFBzABhh5odHRwOi8vb2NzcC5xdW92
+YWRpc2dsb2JhbC5jb20wgZ8GA1UdEQSBlzCBlIIdZGV2LmVuZXJneS5pbnNpZGUu
+dGVsc3RyYS5jb22CJXJlcG9ydHMuZGV2LmVuZXJneS5pbnNpZGUudGVsc3RyYS5j
+b22CJ2dyZWVuc3luYy5kZXYuZW5lcmd5Lmluc2lkZS50ZWxzdHJhLmNvbYIjbmdv
+c3MuZGV2LmVuZXJneS5pbnNpZGUudGVsc3RyYS5jb20wUQYDVR0gBEowSDBGBgwr
+BgEEAb5YAAJkAQEwNjA0BggrBgEFBQcCARYoaHR0cDovL3d3dy5xdW92YWRpc2ds
+b2JhbC5jb20vcmVwb3NpdG9yeTAdBgNVHSUEFjAUBggrBgEFBQcDAgYIKwYBBQUH
+AwEwOgYDVR0fBDMwMTAvoC2gK4YpaHR0cDovL2NybC5xdW92YWRpc2dsb2JhbC5j
+b20vcXZzc2xnMy5jcmwwHQYDVR0OBBYEFEoJQRpPC/V5ZK3mMkszZE2v6vh+MA4G
+A1UdDwEB/wQEAwIFoDCCAXwGCisGAQQB1nkCBAIEggFsBIIBaAFmAHUAVhQGmi/X
+wuzT9eG9RLI+x0Z2ubyZEVzA75SYVdaJ0N0AAAFstk9Y+gAABAMARjBEAiBFMZa6
+O9iXVjy2kqQa54vgNFdU7shgFJJhm//fSAQZUAIgBIL/yPdh+XiuQS2xPhCzNYkh
+bxf7BbN4qUISESgiZpsAdgBvU3asMfAxGdiZAKRRFf93FRwR2QLBACkGjbIImjfZ
+EwAAAWy2T1nKAAAEAwBHMEUCIG0tp63jLsDsfCTDlcvV5ItjRkbUJBnkxlPdP2PH
+88sTAiEApgaPofVdn2hdI12iDDex72ta+9wpwQ1MxoaJn2nt+qEAdQDuS723dc5g
+uuFCaR+r4Z5mow9+X7By2IMAxHuJeqj9ywAAAWy2T1iJAAAEAwBGMEQCIE/mzEFp
+CJUc71jvwJa4Px86R3ZYK4mHmUlQAUZqd0ZkAiBdEmT8xxTuleSUlYHEkKCK/FZX
+L+vsYJpPrA9TsO5IsTANBgkqhkiG9w0BAQsFAAOCAgEApE9WLz3S8tqA9Dk3r9LF
+rJy8km9cBt1O9SQZwFsduGKGdF3Fd+/Y0V7UrFDzrX+NIzqcmgBHKxaIXorMBF70
+ajMaaROP2ymkpEXnruEwoR47fbW+JRAWDRm2xnouQveQX9ZcgCLbBvAWBqpndQj2
+DGmLJhNz5GlFBjh3PQZlU1w8hU7TrDxa7M1GMtVnk8X+o3l/MX9iPeEs+PiC4dHD
+hpj84RY1VQJz8+10rql47SB5YgbwcqaizTG4ax/OAv1JHNWtfAodIMX8Y8X00zoz
+A20LQv880jCCNANVNbrXJ3h4X3xwW/C1X9vYk0shymZJbT5u17JbPD1cy39bA7kT
+F4L7scdQRxvcqazYN4/IdgvgMji9OltiYufP88Ti8KB2tcl2accpiC5St/zllGD1
+hqEeYLMzjyvUKR/1uvURQQtc0DPvBRmvkB+aI4g+sLkTTFWj5bsA1vKU8SDCyMuB
+RQV11DId5+RNNCmWnskORUZJQssvY49pnfCxCES2nt3l/XzTzVtLYmd6G9uAqVac
+e2ibnmDrFVlmlyRsCiMfZl5/OTJzt7Cj3az59m5Syfw/lnS9YP82t/r/ufuKkO5Q
+q5a9aI8DuNNmAjR4lpIJNqIpX/y+dG2aGmx4XTc31MR9szWtiTgOHe0MkMupOAL0
+qkHrBgwo1zjuTMf3QOg6Z5Q=
+-----END CERTIFICATE-----
+-----BEGIN CERTIFICATE-----
+MIIGFzCCA/+gAwIBAgIUftbnnMmtgcTIGT75XUQodw40ExcwDQYJKoZIhvcNAQEL
+BQAwSDELMAkGA1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxHjAc
+BgNVBAMTFVF1b1ZhZGlzIFJvb3QgQ0EgMiBHMzAeFw0xMjExMDYxNDUwMThaFw0y
+MjExMDYxNDUwMThaME0xCzAJBgNVBAYTAkJNMRkwFwYDVQQKExBRdW9WYWRpcyBM
+aW1pdGVkMSMwIQYDVQQDExpRdW9WYWRpcyBHbG9iYWwgU1NMIElDQSBHMzCCAiIw
+DQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANf8Od17be6c6lTGJDhEXpmkTs4y
+Q39Rr5VJyBeWCg06nSS71s6xF3sZvKcV0MbXlXCYM2ZX7cNTbJ81gs7uDsKFp+vK
+EymiKyEiI2SImOtECNnSg+RVR4np/xz/UlC0yFUisH75cZsJ8T1pkGMfiEouR0EM
+7O0uFgoboRfUP582TTWy0F7ynSA6YfGKnKj0OFwZJmGHVkLs1VevWjhj3R1fsPan
+H05P5moePFnpQdj1FofoSxUHZ0c7VB+sUimboHm/uHNY1LOsk77qiSuVC5/yrdg3
+2EEfP/mxJYT4r/5UiD7VahySzeZHzZ2OibQm2AfgfMN3l57lCM3/WPQBhMAPS1jz
+kE+7MjajM2f0aZctimW4Hasrj8AQnfAdHqZehbhtXaAlffNEzCdpNK584oCTVR7N
+UR9iZFx83ruTqpo+GcLP/iSYqhM4g7fy45sNhU+IS+ca03zbxTl3TTlkofXunI5B
+xxE30eGSQpDZ5+iUJcEOAuVKrlYocFbB3KF45hwcbzPWQ1DcO2jFAapOtQzeS+MZ
+yZzT2YseJ8hQHKu8YrXZWwKaNfyl8kFkHUBDICowNEoZvBwRCQp8sgqL6YRZy0uD
+JGxmnC2e0BVKSjcIvmq/CRWH7yiTk9eWm73xrsg9iIyD/kwJEnLyIk8tR5V8p/hc
+1H2AjDrZH12PsZ45AgMBAAGjgfMwgfAwEgYDVR0TAQH/BAgwBgEB/wIBATARBgNV
+HSAECjAIMAYGBFUdIAAwOgYIKwYBBQUHAQEELjAsMCoGCCsGAQUFBzABhh5odHRw
+Oi8vb2NzcC5xdW92YWRpc2dsb2JhbC5jb20wDgYDVR0PAQH/BAQDAgEGMB8GA1Ud
+IwQYMBaAFO3nb3Zav2DsSVvGpXe7chZxm8Q9MDsGA1UdHwQ0MDIwMKAuoCyGKmh0
+dHA6Ly9jcmwucXVvdmFkaXNnbG9iYWwuY29tL3F2cmNhMmczLmNybDAdBgNVHQ4E
+FgQUsxKJtalLNbwVAPCA6dh4h/ETfHYwDQYJKoZIhvcNAQELBQADggIBAFGm1Fqp
+RMiKr7a6h707M+km36PVXZnX1NZocCn36MrfRvphotbOCDm+GmRkar9ZMGhc8c/A
+Vn7JSCjwF9jNOFIOUyNLq0w4luk+Pt2YFDbgF8IDdx53xIo8Gv05e9xpTvQYaIto
+qeHbQjGXfSGc91olfX6JUwZlxxbhdJH+rxTFAg0jcbqToJoScWTfXSr1QRcNbSTs
+Y4CPG6oULsnhVvrzgldGSK+DxFi2OKcDsOKkV7W4IGg8Do2L/M588AfBnV8ERzpl
+qgMBBQxC2+0N6RdFHbmZt0HQE/NIg1s0xcjGx1XW3YTOfje31rmAXKHOehm4Bu48
+gr8gePq5cdQ2W9tA0Dnytb9wzH2SyPPIXRI7yNxaX9H8wYeDeeiKSSmQtfh1v5cV
+7RXvm8F6hLJkkco/HOW3dAUwZFcKsUH+1eUJKLN18eDGwB8yGawjHvOKqcfg5Lf/
+TvC7hgcx7pDYaCCaqHaekgUwXbB2Enzqr1fdwoU1c01W5YuQAtAx5wk1bf34Yq/J
+ph7wNXGvo88N0/EfP9AdVGmJzy7VuRXeVAOyjKAIeADMlwpjBRhcbs9m3dkqvoMb
+SXKJxv/hFmNgEOvOlaFsXX1dbKg1v+C1AzKAFdiuAIa62JzASiEhigqNSdqdTsOh
+8W8hdONuKKpe9zKedhBFAvuxhDgKmnySglYc
+-----END CERTIFICATE-----
+-----BEGIN CERTIFICATE-----
+MIIFYDCCA0igAwIBAgIURFc0JFuBiZs18s64KztbpybwdSgwDQYJKoZIhvcNAQEL
+BQAwSDELMAkGA1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxHjAc
+BgNVBAMTFVF1b1ZhZGlzIFJvb3QgQ0EgMiBHMzAeFw0xMjAxMTIxODU5MzJaFw00
+MjAxMTIxODU5MzJaMEgxCzAJBgNVBAYTAkJNMRkwFwYDVQQKExBRdW9WYWRpcyBM
+aW1pdGVkMR4wHAYDVQQDExVRdW9WYWRpcyBSb290IENBIDIgRzMwggIiMA0GCSqG
+SIb3DQEBAQUAA4ICDwAwggIKAoICAQChriWyARjcV4g/Ruv5r+LrI3HimtFhZiFf
+qq8nUeVuGxbULX1QsFN3vXg6YOJkApt8hpvWGo6t/x8Vf9WVHhLL5hSEBMHfNrMW
+n4rjyduYNM7YMxcoRvynyfDStNVNCXJJ+fKH46nafaF9a7I6JaltUkSs+L5u+9ym
+c5GQYaYDFCDy54ejiK2toIz/pgslUiXnFgHVy7g1gQyjO/Dh4fxaXc6AcW34Sas+
+O7q414AB+6XrW7PFXmAqMaCvN+ggOp+oMiwMzAkd056OXbxMmO7FGmh77FOm6RQ1
+o9/NgJ8MSPsc9PG/Srj61YxxSscfrf5BmrODXfKEVu+lV0POKa2Mq1W/xPtbAd0j
+IaFYAI7D0GoT7RPjEiuA3GfmlbLNHiJuKvhB1PLKFAeNilUSxmn1uIZoL1NesNKq
+IcGY5jDjZ1XHm26sGahVpkUG0CM62+tlXSoREfA7T8pt9DTEceT/AFr2XK4jYIVz
+8eQQsSWu1ZK7E8EM4DnatDlXtas1qnIhO4M15zHfeiFuuDIIfR0ykRVKYnLP43eh
+vNURG3YBZwjgQQvD6xVu+KQZ2aKrr+InUlYrAoosFCT5v0ICvybIxo/gbjh9Uy3l
+7ZizlWNof/k19N+IxWA1ksB8aRxhlRbQ694Lrz4EEEVlWFA4r0jyWbYW8jwNkALG
+cC4BrTwV1wIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIB
+BjAdBgNVHQ4EFgQU7edvdlq/YOxJW8ald7tyFnGbxD0wDQYJKoZIhvcNAQELBQAD
+ggIBAJHfgD9DCX5xwvfrs4iP4VGyvD11+ShdyLyZm3tdquXK4Qr36LLTn91nMX66
+AarHakE7kNQIXLJgapDwyM4DYvmL7ftuKtwGTTwpD4kWilhMSA/ohGHqPHKmd+RC
+roijQ1h5fq7KpVMNqT1wvSAZYaRsOPxDMuHBR//47PERIjKWnML2W2mWeyAMQ0Ga
+W/ZZGYjeVYg3UQt4XAoeo0L9x52ID8DyeAIkVJOviYeIyUqAHerQbj5hLja7NQ4n
+lv1mNDthcnPxFlxHBlRJAHpYErAK74X9sbgzdWqTHBLmYF5vHX/JHyPLhGGfHoJE
++V+tYlUkmlKY7VHnoX6XOuYvHxHaU4AshZ6rNRDbIl9qxV6XU/IyAgkwo1jwDQHV
+csaxfGl7w/U2Rcxhbl5MlMVerugOXou/983g7aEOGzPuVBj+D77vfoRrQ+NwmNtd
+dbINWQeFFSM51vHfqSYP1kjHs6Yi9TM3WpVHn3u6GBVv/9YUZINJ0gpnIdsPNWNg
+KCLjsZWDzYWm3S8P52dSbrsvhXz1SnPnxT7AvSESBT/8twNJAlvIJebiVDj1eYeM
+HVOyToV7BjjHLPj4sHKNJeV3UvQDHEimUF+IIDBu8oJDqz2XhOdT+yHBTw8imoa4
+WSr2Rz0ZiC3oheGe7IUIarFsNMkd7EgrO3jtZsSOeWmD3n+M
+-----END CERTIFICATE-----
+
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/chain-1.3.cert b/ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/chain-1.3.cert
new file mode 100644
index 000000000..0c947b17b
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/chain-1.3.cert
@@ -0,0 +1,124 @@
+subject=/C=AU/ST=Victoria/L=Melbourne/O=Telstra Corporation Limited/OU=Telstra Energy/CN=dev.energy.inside.telstra.com
+issuer=/C=BM/O=QuoVadis Limited/CN=QuoVadis Global SSL ICA G3
+-----BEGIN CERTIFICATE-----
+MIIIHTCCBgWgAwIBAgIUCqrrzSfjzaoyB3DOxst2kMxFp/MwDQYJKoZIhvcNAQEL
+BQAwTTELMAkGA1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxIzAh
+BgNVBAMTGlF1b1ZhZGlzIEdsb2JhbCBTU0wgSUNBIEczMB4XDTE5MDgyMTIyMjIy
+OFoXDTIxMDgyMTIyMzIwMFowgZsxCzAJBgNVBAYTAkFVMREwDwYDVQQIDAhWaWN0
+b3JpYTESMBAGA1UEBwwJTWVsYm91cm5lMSQwIgYDVQQKDBtUZWxzdHJhIENvcnBv
+cmF0aW9uIExpbWl0ZWQxFzAVBgNVBAsMDlRlbHN0cmEgRW5lcmd5MSYwJAYDVQQD
+DB1kZXYuZW5lcmd5Lmluc2lkZS50ZWxzdHJhLmNvbTCCASIwDQYJKoZIhvcNAQEB
+BQADggEPADCCAQoCggEBAMPAPH2y206qios2NMzlCNJv1mrwC1/8tH2HOqJGiYZB
+O7QOBRSvJsV++IozCB8ap99e8B64OOAQPOyykrdXd2axhftmMb1SFMF56eukHSuz
+KhKWRUgHs0UFRU51lDcBcOvphwJ+5SOgqrqKFFFBgJ0ZpcP54JpFwKIdh3ac10x2
+mBaW5ccqdv5X9oEMu1D/yivBmy34tsbLYyfttCjP76iVT7UVYHjHWynnIhsEyMsU
+gdM90NzrTlrvTSi/EcCD1W3+8b0f+G1TI5rhHbKwR0n/mv5QLFm7EABoYPhxS8bX
+B+9tE67yb0RyWbgvUiHySRynQLNMRpRx8Y9bA8uC8n8CAwEAAaOCA6QwggOgMAkG
+A1UdEwQCMAAwHwYDVR0jBBgwFoAUsxKJtalLNbwVAPCA6dh4h/ETfHYwcwYIKwYB
+BQUHAQEEZzBlMDcGCCsGAQUFBzAChitodHRwOi8vdHJ1c3QucXVvdmFkaXNnbG9i
+YWwuY29tL3F2c3NsZzMuY3J0MCoGCCsGAQUFBzABhh5odHRwOi8vb2NzcC5xdW92
+YWRpc2dsb2JhbC5jb20wgZ8GA1UdEQSBlzCBlIIdZGV2LmVuZXJneS5pbnNpZGUu
+dGVsc3RyYS5jb22CJXJlcG9ydHMuZGV2LmVuZXJneS5pbnNpZGUudGVsc3RyYS5j
+b22CJ2dyZWVuc3luYy5kZXYuZW5lcmd5Lmluc2lkZS50ZWxzdHJhLmNvbYIjbmdv
+c3MuZGV2LmVuZXJneS5pbnNpZGUudGVsc3RyYS5jb20wUQYDVR0gBEowSDBGBgwr
+BgEEAb5YAAJkAQEwNjA0BggrBgEFBQcCARYoaHR0cDovL3d3dy5xdW92YWRpc2ds
+b2JhbC5jb20vcmVwb3NpdG9yeTAdBgNVHSUEFjAUBggrBgEFBQcDAgYIKwYBBQUH
+AwEwOgYDVR0fBDMwMTAvoC2gK4YpaHR0cDovL2NybC5xdW92YWRpc2dsb2JhbC5j
+b20vcXZzc2xnMy5jcmwwHQYDVR0OBBYEFEoJQRpPC/V5ZK3mMkszZE2v6vh+MA4G
+A1UdDwEB/wQEAwIFoDCCAXwGCisGAQQB1nkCBAIEggFsBIIBaAFmAHUAVhQGmi/X
+wuzT9eG9RLI+x0Z2ubyZEVzA75SYVdaJ0N0AAAFstk9Y+gAABAMARjBEAiBFMZa6
+O9iXVjy2kqQa54vgNFdU7shgFJJhm//fSAQZUAIgBIL/yPdh+XiuQS2xPhCzNYkh
+bxf7BbN4qUISESgiZpsAdgBvU3asMfAxGdiZAKRRFf93FRwR2QLBACkGjbIImjfZ
+EwAAAWy2T1nKAAAEAwBHMEUCIG0tp63jLsDsfCTDlcvV5ItjRkbUJBnkxlPdP2PH
+88sTAiEApgaPofVdn2hdI12iDDex72ta+9wpwQ1MxoaJn2nt+qEAdQDuS723dc5g
+uuFCaR+r4Z5mow9+X7By2IMAxHuJeqj9ywAAAWy2T1iJAAAEAwBGMEQCIE/mzEFp
+CJUc71jvwJa4Px86R3ZYK4mHmUlQAUZqd0ZkAiBdEmT8xxTuleSUlYHEkKCK/FZX
+L+vsYJpPrA9TsO5IsTANBgkqhkiG9w0BAQsFAAOCAgEApE9WLz3S8tqA9Dk3r9LF
+rJy8km9cBt1O9SQZwFsduGKGdF3Fd+/Y0V7UrFDzrX+NIzqcmgBHKxaIXorMBF70
+ajMaaROP2ymkpEXnruEwoR47fbW+JRAWDRm2xnouQveQX9ZcgCLbBvAWBqpndQj2
+DGmLJhNz5GlFBjh3PQZlU1w8hU7TrDxa7M1GMtVnk8X+o3l/MX9iPeEs+PiC4dHD
+hpj84RY1VQJz8+10rql47SB5YgbwcqaizTG4ax/OAv1JHNWtfAodIMX8Y8X00zoz
+A20LQv880jCCNANVNbrXJ3h4X3xwW/C1X9vYk0shymZJbT5u17JbPD1cy39bA7kT
+F4L7scdQRxvcqazYN4/IdgvgMji9OltiYufP88Ti8KB2tcl2accpiC5St/zllGD1
+hqEeYLMzjyvUKR/1uvURQQtc0DPvBRmvkB+aI4g+sLkTTFWj5bsA1vKU8SDCyMuB
+RQV11DId5+RNNCmWnskORUZJQssvY49pnfCxCES2nt3l/XzTzVtLYmd6G9uAqVac
+e2ibnmDrFVlmlyRsCiMfZl5/OTJzt7Cj3az59m5Syfw/lnS9YP82t/r/ufuKkO5Q
+q5a9aI8DuNNmAjR4lpIJNqIpX/y+dG2aGmx4XTc31MR9szWtiTgOHe0MkMupOAL0
+qkHrBgwo1zjuTMf3QOg6Z5Q=
+-----END CERTIFICATE-----
+
+
+subject=/C=BM/O=QuoVadis Limited/CN=QuoVadis Root CA 2 G3
+issuer=/C=BM/O=QuoVadis Limited/CN=QuoVadis Root CA 2 G3
+-----BEGIN CERTIFICATE-----
+MIIFYDCCA0igAwIBAgIURFc0JFuBiZs18s64KztbpybwdSgwDQYJKoZIhvcNAQEL
+BQAwSDELMAkGA1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxHjAc
+BgNVBAMTFVF1b1ZhZGlzIFJvb3QgQ0EgMiBHMzAeFw0xMjAxMTIxODU5MzJaFw00
+MjAxMTIxODU5MzJaMEgxCzAJBgNVBAYTAkJNMRkwFwYDVQQKExBRdW9WYWRpcyBM
+aW1pdGVkMR4wHAYDVQQDExVRdW9WYWRpcyBSb290IENBIDIgRzMwggIiMA0GCSqG
+SIb3DQEBAQUAA4ICDwAwggIKAoICAQChriWyARjcV4g/Ruv5r+LrI3HimtFhZiFf
+qq8nUeVuGxbULX1QsFN3vXg6YOJkApt8hpvWGo6t/x8Vf9WVHhLL5hSEBMHfNrMW
+n4rjyduYNM7YMxcoRvynyfDStNVNCXJJ+fKH46nafaF9a7I6JaltUkSs+L5u+9ym
+c5GQYaYDFCDy54ejiK2toIz/pgslUiXnFgHVy7g1gQyjO/Dh4fxaXc6AcW34Sas+
+O7q414AB+6XrW7PFXmAqMaCvN+ggOp+oMiwMzAkd056OXbxMmO7FGmh77FOm6RQ1
+o9/NgJ8MSPsc9PG/Srj61YxxSscfrf5BmrODXfKEVu+lV0POKa2Mq1W/xPtbAd0j
+IaFYAI7D0GoT7RPjEiuA3GfmlbLNHiJuKvhB1PLKFAeNilUSxmn1uIZoL1NesNKq
+IcGY5jDjZ1XHm26sGahVpkUG0CM62+tlXSoREfA7T8pt9DTEceT/AFr2XK4jYIVz
+8eQQsSWu1ZK7E8EM4DnatDlXtas1qnIhO4M15zHfeiFuuDIIfR0ykRVKYnLP43eh
+vNURG3YBZwjgQQvD6xVu+KQZ2aKrr+InUlYrAoosFCT5v0ICvybIxo/gbjh9Uy3l
+7ZizlWNof/k19N+IxWA1ksB8aRxhlRbQ694Lrz4EEEVlWFA4r0jyWbYW8jwNkALG
+cC4BrTwV1wIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIB
+BjAdBgNVHQ4EFgQU7edvdlq/YOxJW8ald7tyFnGbxD0wDQYJKoZIhvcNAQELBQAD
+ggIBAJHfgD9DCX5xwvfrs4iP4VGyvD11+ShdyLyZm3tdquXK4Qr36LLTn91nMX66
+AarHakE7kNQIXLJgapDwyM4DYvmL7ftuKtwGTTwpD4kWilhMSA/ohGHqPHKmd+RC
+roijQ1h5fq7KpVMNqT1wvSAZYaRsOPxDMuHBR//47PERIjKWnML2W2mWeyAMQ0Ga
+W/ZZGYjeVYg3UQt4XAoeo0L9x52ID8DyeAIkVJOviYeIyUqAHerQbj5hLja7NQ4n
+lv1mNDthcnPxFlxHBlRJAHpYErAK74X9sbgzdWqTHBLmYF5vHX/JHyPLhGGfHoJE
++V+tYlUkmlKY7VHnoX6XOuYvHxHaU4AshZ6rNRDbIl9qxV6XU/IyAgkwo1jwDQHV
+csaxfGl7w/U2Rcxhbl5MlMVerugOXou/983g7aEOGzPuVBj+D77vfoRrQ+NwmNtd
+dbINWQeFFSM51vHfqSYP1kjHs6Yi9TM3WpVHn3u6GBVv/9YUZINJ0gpnIdsPNWNg
+KCLjsZWDzYWm3S8P52dSbrsvhXz1SnPnxT7AvSESBT/8twNJAlvIJebiVDj1eYeM
+HVOyToV7BjjHLPj4sHKNJeV3UvQDHEimUF+IIDBu8oJDqz2XhOdT+yHBTw8imoa4
+WSr2Rz0ZiC3oheGe7IUIarFsNMkd7EgrO3jtZsSOeWmD3n+M
+-----END CERTIFICATE-----
+
+
+
+
+subject=/C=BM/O=QuoVadis Limited/CN=QuoVadis Global SSL ICA G3
+issuer=/C=BM/O=QuoVadis Limited/CN=QuoVadis Root CA 2 G3
+-----BEGIN CERTIFICATE-----
+MIIGFzCCA/+gAwIBAgIUftbnnMmtgcTIGT75XUQodw40ExcwDQYJKoZIhvcNAQEL
+BQAwSDELMAkGA1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxHjAc
+BgNVBAMTFVF1b1ZhZGlzIFJvb3QgQ0EgMiBHMzAeFw0xMjExMDYxNDUwMThaFw0y
+MjExMDYxNDUwMThaME0xCzAJBgNVBAYTAkJNMRkwFwYDVQQKExBRdW9WYWRpcyBM
+aW1pdGVkMSMwIQYDVQQDExpRdW9WYWRpcyBHbG9iYWwgU1NMIElDQSBHMzCCAiIw
+DQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANf8Od17be6c6lTGJDhEXpmkTs4y
+Q39Rr5VJyBeWCg06nSS71s6xF3sZvKcV0MbXlXCYM2ZX7cNTbJ81gs7uDsKFp+vK
+EymiKyEiI2SImOtECNnSg+RVR4np/xz/UlC0yFUisH75cZsJ8T1pkGMfiEouR0EM
+7O0uFgoboRfUP582TTWy0F7ynSA6YfGKnKj0OFwZJmGHVkLs1VevWjhj3R1fsPan
+H05P5moePFnpQdj1FofoSxUHZ0c7VB+sUimboHm/uHNY1LOsk77qiSuVC5/yrdg3
+2EEfP/mxJYT4r/5UiD7VahySzeZHzZ2OibQm2AfgfMN3l57lCM3/WPQBhMAPS1jz
+kE+7MjajM2f0aZctimW4Hasrj8AQnfAdHqZehbhtXaAlffNEzCdpNK584oCTVR7N
+UR9iZFx83ruTqpo+GcLP/iSYqhM4g7fy45sNhU+IS+ca03zbxTl3TTlkofXunI5B
+xxE30eGSQpDZ5+iUJcEOAuVKrlYocFbB3KF45hwcbzPWQ1DcO2jFAapOtQzeS+MZ
+yZzT2YseJ8hQHKu8YrXZWwKaNfyl8kFkHUBDICowNEoZvBwRCQp8sgqL6YRZy0uD
+JGxmnC2e0BVKSjcIvmq/CRWH7yiTk9eWm73xrsg9iIyD/kwJEnLyIk8tR5V8p/hc
+1H2AjDrZH12PsZ45AgMBAAGjgfMwgfAwEgYDVR0TAQH/BAgwBgEB/wIBATARBgNV
+HSAECjAIMAYGBFUdIAAwOgYIKwYBBQUHAQEELjAsMCoGCCsGAQUFBzABhh5odHRw
+Oi8vb2NzcC5xdW92YWRpc2dsb2JhbC5jb20wDgYDVR0PAQH/BAQDAgEGMB8GA1Ud
+IwQYMBaAFO3nb3Zav2DsSVvGpXe7chZxm8Q9MDsGA1UdHwQ0MDIwMKAuoCyGKmh0
+dHA6Ly9jcmwucXVvdmFkaXNnbG9iYWwuY29tL3F2cmNhMmczLmNybDAdBgNVHQ4E
+FgQUsxKJtalLNbwVAPCA6dh4h/ETfHYwDQYJKoZIhvcNAQELBQADggIBAFGm1Fqp
+RMiKr7a6h707M+km36PVXZnX1NZocCn36MrfRvphotbOCDm+GmRkar9ZMGhc8c/A
+Vn7JSCjwF9jNOFIOUyNLq0w4luk+Pt2YFDbgF8IDdx53xIo8Gv05e9xpTvQYaIto
+qeHbQjGXfSGc91olfX6JUwZlxxbhdJH+rxTFAg0jcbqToJoScWTfXSr1QRcNbSTs
+Y4CPG6oULsnhVvrzgldGSK+DxFi2OKcDsOKkV7W4IGg8Do2L/M588AfBnV8ERzpl
+qgMBBQxC2+0N6RdFHbmZt0HQE/NIg1s0xcjGx1XW3YTOfje31rmAXKHOehm4Bu48
+gr8gePq5cdQ2W9tA0Dnytb9wzH2SyPPIXRI7yNxaX9H8wYeDeeiKSSmQtfh1v5cV
+7RXvm8F6hLJkkco/HOW3dAUwZFcKsUH+1eUJKLN18eDGwB8yGawjHvOKqcfg5Lf/
+TvC7hgcx7pDYaCCaqHaekgUwXbB2Enzqr1fdwoU1c01W5YuQAtAx5wk1bf34Yq/J
+ph7wNXGvo88N0/EfP9AdVGmJzy7VuRXeVAOyjKAIeADMlwpjBRhcbs9m3dkqvoMb
+SXKJxv/hFmNgEOvOlaFsXX1dbKg1v+C1AzKAFdiuAIa62JzASiEhigqNSdqdTsOh
+8W8hdONuKKpe9zKedhBFAvuxhDgKmnySglYc
+-----END CERTIFICATE-----
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/chain-1.4.cert b/ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/chain-1.4.cert
new file mode 100644
index 000000000..adbb8edca
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/chain-1.4.cert
@@ -0,0 +1,86 @@
+subject=/C=AU/ST=Victoria/L=Melbourne/O=Telstra Corporation Limited/OU=Telstra Energy/CN=dev.energy.inside.telstra.com
+issuer=/C=BM/O=QuoVadis Limited/CN=QuoVadis Global SSL ICA G3
+-----BEGIN CERTIFICATE-----
+MIIIHTCCBgWgAwIBAgIUCqrrzSfjzaoyB3DOxst2kMxFp/MwDQYJKoZIhvcNAQEL
+BQAwTTELMAkGA1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxIzAh
+BgNVBAMTGlF1b1ZhZGlzIEdsb2JhbCBTU0wgSUNBIEczMB4XDTE5MDgyMTIyMjIy
+OFoXDTIxMDgyMTIyMzIwMFowgZsxCzAJBgNVBAYTAkFVMREwDwYDVQQIDAhWaWN0
+b3JpYTESMBAGA1UEBwwJTWVsYm91cm5lMSQwIgYDVQQKDBtUZWxzdHJhIENvcnBv
+cmF0aW9uIExpbWl0ZWQxFzAVBgNVBAsMDlRlbHN0cmEgRW5lcmd5MSYwJAYDVQQD
+DB1kZXYuZW5lcmd5Lmluc2lkZS50ZWxzdHJhLmNvbTCCASIwDQYJKoZIhvcNAQEB
+BQADggEPADCCAQoCggEBAMPAPH2y206qios2NMzlCNJv1mrwC1/8tH2HOqJGiYZB
+O7QOBRSvJsV++IozCB8ap99e8B64OOAQPOyykrdXd2axhftmMb1SFMF56eukHSuz
+KhKWRUgHs0UFRU51lDcBcOvphwJ+5SOgqrqKFFFBgJ0ZpcP54JpFwKIdh3ac10x2
+mBaW5ccqdv5X9oEMu1D/yivBmy34tsbLYyfttCjP76iVT7UVYHjHWynnIhsEyMsU
+gdM90NzrTlrvTSi/EcCD1W3+8b0f+G1TI5rhHbKwR0n/mv5QLFm7EABoYPhxS8bX
+B+9tE67yb0RyWbgvUiHySRynQLNMRpRx8Y9bA8uC8n8CAwEAAaOCA6QwggOgMAkG
+A1UdEwQCMAAwHwYDVR0jBBgwFoAUsxKJtalLNbwVAPCA6dh4h/ETfHYwcwYIKwYB
+BQUHAQEEZzBlMDcGCCsGAQUFBzAChitodHRwOi8vdHJ1c3QucXVvdmFkaXNnbG9i
+YWwuY29tL3F2c3NsZzMuY3J0MCoGCCsGAQUFBzABhh5odHRwOi8vb2NzcC5xdW92
+YWRpc2dsb2JhbC5jb20wgZ8GA1UdEQSBlzCBlIIdZGV2LmVuZXJneS5pbnNpZGUu
+dGVsc3RyYS5jb22CJXJlcG9ydHMuZGV2LmVuZXJneS5pbnNpZGUudGVsc3RyYS5j
+b22CJ2dyZWVuc3luYy5kZXYuZW5lcmd5Lmluc2lkZS50ZWxzdHJhLmNvbYIjbmdv
+c3MuZGV2LmVuZXJneS5pbnNpZGUudGVsc3RyYS5jb20wUQYDVR0gBEowSDBGBgwr
+BgEEAb5YAAJkAQEwNjA0BggrBgEFBQcCARYoaHR0cDovL3d3dy5xdW92YWRpc2ds
+b2JhbC5jb20vcmVwb3NpdG9yeTAdBgNVHSUEFjAUBggrBgEFBQcDAgYIKwYBBQUH
+AwEwOgYDVR0fBDMwMTAvoC2gK4YpaHR0cDovL2NybC5xdW92YWRpc2dsb2JhbC5j
+b20vcXZzc2xnMy5jcmwwHQYDVR0OBBYEFEoJQRpPC/V5ZK3mMkszZE2v6vh+MA4G
+A1UdDwEB/wQEAwIFoDCCAXwGCisGAQQB1nkCBAIEggFsBIIBaAFmAHUAVhQGmi/X
+wuzT9eG9RLI+x0Z2ubyZEVzA75SYVdaJ0N0AAAFstk9Y+gAABAMARjBEAiBFMZa6
+O9iXVjy2kqQa54vgNFdU7shgFJJhm//fSAQZUAIgBIL/yPdh+XiuQS2xPhCzNYkh
+bxf7BbN4qUISESgiZpsAdgBvU3asMfAxGdiZAKRRFf93FRwR2QLBACkGjbIImjfZ
+EwAAAWy2T1nKAAAEAwBHMEUCIG0tp63jLsDsfCTDlcvV5ItjRkbUJBnkxlPdP2PH
+88sTAiEApgaPofVdn2hdI12iDDex72ta+9wpwQ1MxoaJn2nt+qEAdQDuS723dc5g
+uuFCaR+r4Z5mow9+X7By2IMAxHuJeqj9ywAAAWy2T1iJAAAEAwBGMEQCIE/mzEFp
+CJUc71jvwJa4Px86R3ZYK4mHmUlQAUZqd0ZkAiBdEmT8xxTuleSUlYHEkKCK/FZX
+L+vsYJpPrA9TsO5IsTANBgkqhkiG9w0BAQsFAAOCAgEApE9WLz3S8tqA9Dk3r9LF
+rJy8km9cBt1O9SQZwFsduGKGdF3Fd+/Y0V7UrFDzrX+NIzqcmgBHKxaIXorMBF70
+ajMaaROP2ymkpEXnruEwoR47fbW+JRAWDRm2xnouQveQX9ZcgCLbBvAWBqpndQj2
+DGmLJhNz5GlFBjh3PQZlU1w8hU7TrDxa7M1GMtVnk8X+o3l/MX9iPeEs+PiC4dHD
+hpj84RY1VQJz8+10rql47SB5YgbwcqaizTG4ax/OAv1JHNWtfAodIMX8Y8X00zoz
+A20LQv880jCCNANVNbrXJ3h4X3xwW/C1X9vYk0shymZJbT5u17JbPD1cy39bA7kT
+F4L7scdQRxvcqazYN4/IdgvgMji9OltiYufP88Ti8KB2tcl2accpiC5St/zllGD1
+hqEeYLMzjyvUKR/1uvURQQtc0DPvBRmvkB+aI4g+sLkTTFWj5bsA1vKU8SDCyMuB
+RQV11DId5+RNNCmWnskORUZJQssvY49pnfCxCES2nt3l/XzTzVtLYmd6G9uAqVac
+e2ibnmDrFVlmlyRsCiMfZl5/OTJzt7Cj3az59m5Syfw/lnS9YP82t/r/ufuKkO5Q
+q5a9aI8DuNNmAjR4lpIJNqIpX/y+dG2aGmx4XTc31MR9szWtiTgOHe0MkMupOAL0
+qkHrBgwo1zjuTMf3QOg6Z5Q=
+-----END CERTIFICATE-----
+
+subject=/C=BM/O=QuoVadis Limited/CN=QuoVadis Global SSL ICA G3
+issuer=/C=BM/O=QuoVadis Limited/CN=QuoVadis Root CA 2 G3
+-----BEGIN CERTIFICATE-----
+MIIGFzCCA/+gAwIBAgIUftbnnMmtgcTIGT75XUQodw40ExcwDQYJKoZIhvcNAQEL
+BQAwSDELMAkGA1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxHjAc
+BgNVBAMTFVF1b1ZhZGlzIFJvb3QgQ0EgMiBHMzAeFw0xMjExMDYxNDUwMThaFw0y
+MjExMDYxNDUwMThaME0xCzAJBgNVBAYTAkJNMRkwFwYDVQQKExBRdW9WYWRpcyBM
+aW1pdGVkMSMwIQYDVQQDExpRdW9WYWRpcyBHbG9iYWwgU1NMIElDQSBHMzCCAiIw
+DQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANf8Od17be6c6lTGJDhEXpmkTs4y
+Q39Rr5VJyBeWCg06nSS71s6xF3sZvKcV0MbXlXCYM2ZX7cNTbJ81gs7uDsKFp+vK
+EymiKyEiI2SImOtECNnSg+RVR4np/xz/UlC0yFUisH75cZsJ8T1pkGMfiEouR0EM
+7O0uFgoboRfUP582TTWy0F7ynSA6YfGKnKj0OFwZJmGHVkLs1VevWjhj3R1fsPan
+H05P5moePFnpQdj1FofoSxUHZ0c7VB+sUimboHm/uHNY1LOsk77qiSuVC5/yrdg3
+2EEfP/mxJYT4r/5UiD7VahySzeZHzZ2OibQm2AfgfMN3l57lCM3/WPQBhMAPS1jz
+kE+7MjajM2f0aZctimW4Hasrj8AQnfAdHqZehbhtXaAlffNEzCdpNK584oCTVR7N
+UR9iZFx83ruTqpo+GcLP/iSYqhM4g7fy45sNhU+IS+ca03zbxTl3TTlkofXunI5B
+xxE30eGSQpDZ5+iUJcEOAuVKrlYocFbB3KF45hwcbzPWQ1DcO2jFAapOtQzeS+MZ
+yZzT2YseJ8hQHKu8YrXZWwKaNfyl8kFkHUBDICowNEoZvBwRCQp8sgqL6YRZy0uD
+JGxmnC2e0BVKSjcIvmq/CRWH7yiTk9eWm73xrsg9iIyD/kwJEnLyIk8tR5V8p/hc
+1H2AjDrZH12PsZ45AgMBAAGjgfMwgfAwEgYDVR0TAQH/BAgwBgEB/wIBATARBgNV
+HSAECjAIMAYGBFUdIAAwOgYIKwYBBQUHAQEELjAsMCoGCCsGAQUFBzABhh5odHRw
+Oi8vb2NzcC5xdW92YWRpc2dsb2JhbC5jb20wDgYDVR0PAQH/BAQDAgEGMB8GA1Ud
+IwQYMBaAFO3nb3Zav2DsSVvGpXe7chZxm8Q9MDsGA1UdHwQ0MDIwMKAuoCyGKmh0
+dHA6Ly9jcmwucXVvdmFkaXNnbG9iYWwuY29tL3F2cmNhMmczLmNybDAdBgNVHQ4E
+FgQUsxKJtalLNbwVAPCA6dh4h/ETfHYwDQYJKoZIhvcNAQELBQADggIBAFGm1Fqp
+RMiKr7a6h707M+km36PVXZnX1NZocCn36MrfRvphotbOCDm+GmRkar9ZMGhc8c/A
+Vn7JSCjwF9jNOFIOUyNLq0w4luk+Pt2YFDbgF8IDdx53xIo8Gv05e9xpTvQYaIto
+qeHbQjGXfSGc91olfX6JUwZlxxbhdJH+rxTFAg0jcbqToJoScWTfXSr1QRcNbSTs
+Y4CPG6oULsnhVvrzgldGSK+DxFi2OKcDsOKkV7W4IGg8Do2L/M588AfBnV8ERzpl
+qgMBBQxC2+0N6RdFHbmZt0HQE/NIg1s0xcjGx1XW3YTOfje31rmAXKHOehm4Bu48
+gr8gePq5cdQ2W9tA0Dnytb9wzH2SyPPIXRI7yNxaX9H8wYeDeeiKSSmQtfh1v5cV
+7RXvm8F6hLJkkco/HOW3dAUwZFcKsUH+1eUJKLN18eDGwB8yGawjHvOKqcfg5Lf/
+TvC7hgcx7pDYaCCaqHaekgUwXbB2Enzqr1fdwoU1c01W5YuQAtAx5wk1bf34Yq/J
+ph7wNXGvo88N0/EfP9AdVGmJzy7VuRXeVAOyjKAIeADMlwpjBRhcbs9m3dkqvoMb
+SXKJxv/hFmNgEOvOlaFsXX1dbKg1v+C1AzKAFdiuAIa62JzASiEhigqNSdqdTsOh
+8W8hdONuKKpe9zKedhBFAvuxhDgKmnySglYc
+-----END CERTIFICATE-----
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/chain-4.cert b/ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/chain-4.cert
new file mode 100644
index 000000000..2b82edf6c
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/chain-4.cert
@@ -0,0 +1,121 @@
+subject=/C=AU/ST=Victoria/L=Melbourne/O=Telstra Corporation Limited/OU=Telstra Energy/CN=prod.energy.inside.telstra.com
+issuer=/C=BM/O=QuoVadis Limited/CN=QuoVadis Global SSL ICA G3
+-----BEGIN CERTIFICATE-----
+MIIIJDCCBgygAwIBAgIUP9S/56XvOFzWk1vp1+7JJT17brEwDQYJKoZIhvcNAQEL
+BQAwTTELMAkGA1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxIzAh
+BgNVBAMTGlF1b1ZhZGlzIEdsb2JhbCBTU0wgSUNBIEczMB4XDTE5MDgyNzAzMTU1
+NFoXDTIxMDgyNzAzMjUwMFowgZwxCzAJBgNVBAYTAkFVMREwDwYDVQQIDAhWaWN0
+b3JpYTESMBAGA1UEBwwJTWVsYm91cm5lMSQwIgYDVQQKDBtUZWxzdHJhIENvcnBv
+cmF0aW9uIExpbWl0ZWQxFzAVBgNVBAsMDlRlbHN0cmEgRW5lcmd5MScwJQYDVQQD
+DB5wcm9kLmVuZXJneS5pbnNpZGUudGVsc3RyYS5jb20wggEiMA0GCSqGSIb3DQEB
+AQUAA4IBDwAwggEKAoIBAQCrRouNZFOZwM1qyAU6v6ag9fzSx3y8zz36nR8HuqbA
+/wqrbMmnpofwdx/9u1bilsHfJzIODv0hm7aGk+neTK3DIapiII3m0HKW0v+GLsl7
+JkDuc2o3XlakcXlA45qDKCZXbXZtY4/kdxKG0OSUZi7oQqohhYl/c/ojrTiey+4G
+KhEVqWwOuQ1OC1DRw4qMH54d0koFxxSLPJ8JiiztLlK/e9n8BoJikj5fBqWy5R1F
+bGXCdzjcfmPV6iSOzJShpUgj4ga91mO6j3S6LLfK5ibbTlY+pmUxUT+m9nKMon3h
+mFptTYo9t9vUF/a/owjRxNLg01fJLNjYn8QV2vQvODGfAgMBAAGjggOqMIIDpjAJ
+BgNVHRMEAjAAMB8GA1UdIwQYMBaAFLMSibWpSzW8FQDwgOnYeIfxE3x2MHMGCCsG
+AQUFBwEBBGcwZTA3BggrBgEFBQcwAoYraHR0cDovL3RydXN0LnF1b3ZhZGlzZ2xv
+YmFsLmNvbS9xdnNzbGczLmNydDAqBggrBgEFBQcwAYYeaHR0cDovL29jc3AucXVv
+dmFkaXNnbG9iYWwuY29tMIGjBgNVHREEgZswgZiCHnByb2QuZW5lcmd5Lmluc2lk
+ZS50ZWxzdHJhLmNvbYImcmVwb3J0cy5wcm9kLmVuZXJneS5pbnNpZGUudGVsc3Ry
+YS5jb22CKGdyZWVuc3luYy5wcm9kLmVuZXJneS5pbnNpZGUudGVsc3RyYS5jb22C
+JG5nb3NzLnByb2QuZW5lcmd5Lmluc2lkZS50ZWxzdHJhLmNvbTBRBgNVHSAESjBI
+MEYGDCsGAQQBvlgAAmQBATA2MDQGCCsGAQUFBwIBFihodHRwOi8vd3d3LnF1b3Zh
+ZGlzZ2xvYmFsLmNvbS9yZXBvc2l0b3J5MB0GA1UdJQQWMBQGCCsGAQUFBwMCBggr
+BgEFBQcDATA6BgNVHR8EMzAxMC+gLaArhilodHRwOi8vY3JsLnF1b3ZhZGlzZ2xv
+YmFsLmNvbS9xdnNzbGczLmNybDAdBgNVHQ4EFgQUoIME5TykVAI8VF5g0zeh0xdv
+i3owDgYDVR0PAQH/BAQDAgWgMIIBfgYKKwYBBAHWeQIEAgSCAW4EggFqAWgAdgBW
+FAaaL9fC7NP14b1Esj7HRna5vJkRXMDvlJhV1onQ3QAAAWzRG8r0AAAEAwBHMEUC
+IQDShuQyYMiy7KKxWOzffolVIcPRgWD7ClNEbIcUATHKyQIgXnTZBXcpcbXBQXLs
+tFuvY36TbKIYc2ql2nmdydGQ9wcAdgCkuQmQtBhYFIe7E6LMZ3AKPDWYBPkb37jj
+d80OyA3cEAAAAWzRG8sAAAAEAwBHMEUCIGsLEoA9S7pNE3VoNZHxl2IAdeP3Dy2Q
+Mk0rM46hp6CRAiEA08rOjswSdcn7qgDEoiyvlcrOTIFJAEcMlxSY65yLVUwAdgBV
+gdTCFpA2AUrqC5tXPFPwwOQ4eHAlCBcvo6odBxPTDAAAAWzRG8q7AAAEAwBHMEUC
+IAkVCcTFG8MBDI58JKIhMlPbzkdrKnYY3Kp9KqWuTAvMAiEAipeI7RCLBk8+T/p+
+gY7+vtFZxKDthcJMUpZz7qmica0wDQYJKoZIhvcNAQELBQADggIBAESe0U1qArxL
+F2uk65q6x6HBcZuSocpceokzcUBv07Kxs6UJU9ybTbl8VYPuC+OUdpvut1kOJCJm
+1TRrr5KMh+9as42xkbKRZnh5TQt7aHmVcLHLfA4x0UrELfNX3fVTDxwDAPAhE5oM
+0w+d1foLakh7dXKKSxobEI3KRwFp19iuZeIqwI8XMWMr9ajhTC0T7D2QvKotpNBS
+sNDHiIE3IXoa9o7UiOG8IfW0wAt7CEygv0F7ctHRTcQSP/SJIGYOUZ7uotULVL5i
+elG31Y83Jx3sPNCy4IZfCip6Gw7MgsN2CZGApqi49edSqDWyRIfmCeXtMc7XI7Md
+kqqWxbqGGTdYJCucoGqahqRR+BI9anEqTD9T5Gy0TpCi2pgp1i7czza71nfz0PcN
+R0pw/1lqb9AqmJ2XELpBpo82B9XGple9thpincai7jPk3ezY5eEvDTmkHRlUFCp8
+8M66Ga19hZTgnHPWDKZYZzuZ7Lcl2WbapFOYYHJggSpBRy4GkH6eTSkUB9G9k8vU
+gbvtS7sR5ggecbCBu0M4TWYmnUojR8UXtr0oOTlXysTHVGs5Tx9ChhOLyUqhX8tM
+1zSDT8JJvbbw4RqpGzBKTNaO5nxRLgKVQOQdM8f1kjMr9/U58Lc4UiaTkJM14VfK
+8GfV8+K/vRCBtME53ILvm1l18jtakG3c
+-----END CERTIFICATE-----
+
+subject=/C=BM/O=QuoVadis Limited/CN=QuoVadis Global SSL ICA G3
+issuer=/C=BM/O=QuoVadis Limited/CN=QuoVadis Root CA 2 G3
+-----BEGIN CERTIFICATE-----
+MIIGFzCCA/+gAwIBAgIUftbnnMmtgcTIGT75XUQodw40ExcwDQYJKoZIhvcNAQEL
+BQAwSDELMAkGA1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxHjAc
+BgNVBAMTFVF1b1ZhZGlzIFJvb3QgQ0EgMiBHMzAeFw0xMjExMDYxNDUwMThaFw0y
+MjExMDYxNDUwMThaME0xCzAJBgNVBAYTAkJNMRkwFwYDVQQKExBRdW9WYWRpcyBM
+aW1pdGVkMSMwIQYDVQQDExpRdW9WYWRpcyBHbG9iYWwgU1NMIElDQSBHMzCCAiIw
+DQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANf8Od17be6c6lTGJDhEXpmkTs4y
+Q39Rr5VJyBeWCg06nSS71s6xF3sZvKcV0MbXlXCYM2ZX7cNTbJ81gs7uDsKFp+vK
+EymiKyEiI2SImOtECNnSg+RVR4np/xz/UlC0yFUisH75cZsJ8T1pkGMfiEouR0EM
+7O0uFgoboRfUP582TTWy0F7ynSA6YfGKnKj0OFwZJmGHVkLs1VevWjhj3R1fsPan
+H05P5moePFnpQdj1FofoSxUHZ0c7VB+sUimboHm/uHNY1LOsk77qiSuVC5/yrdg3
+2EEfP/mxJYT4r/5UiD7VahySzeZHzZ2OibQm2AfgfMN3l57lCM3/WPQBhMAPS1jz
+kE+7MjajM2f0aZctimW4Hasrj8AQnfAdHqZehbhtXaAlffNEzCdpNK584oCTVR7N
+UR9iZFx83ruTqpo+GcLP/iSYqhM4g7fy45sNhU+IS+ca03zbxTl3TTlkofXunI5B
+xxE30eGSQpDZ5+iUJcEOAuVKrlYocFbB3KF45hwcbzPWQ1DcO2jFAapOtQzeS+MZ
+yZzT2YseJ8hQHKu8YrXZWwKaNfyl8kFkHUBDICowNEoZvBwRCQp8sgqL6YRZy0uD
+JGxmnC2e0BVKSjcIvmq/CRWH7yiTk9eWm73xrsg9iIyD/kwJEnLyIk8tR5V8p/hc
+1H2AjDrZH12PsZ45AgMBAAGjgfMwgfAwEgYDVR0TAQH/BAgwBgEB/wIBATARBgNV
+HSAECjAIMAYGBFUdIAAwOgYIKwYBBQUHAQEELjAsMCoGCCsGAQUFBzABhh5odHRw
+Oi8vb2NzcC5xdW92YWRpc2dsb2JhbC5jb20wDgYDVR0PAQH/BAQDAgEGMB8GA1Ud
+IwQYMBaAFO3nb3Zav2DsSVvGpXe7chZxm8Q9MDsGA1UdHwQ0MDIwMKAuoCyGKmh0
+dHA6Ly9jcmwucXVvdmFkaXNnbG9iYWwuY29tL3F2cmNhMmczLmNybDAdBgNVHQ4E
+FgQUsxKJtalLNbwVAPCA6dh4h/ETfHYwDQYJKoZIhvcNAQELBQADggIBAFGm1Fqp
+RMiKr7a6h707M+km36PVXZnX1NZocCn36MrfRvphotbOCDm+GmRkar9ZMGhc8c/A
+Vn7JSCjwF9jNOFIOUyNLq0w4luk+Pt2YFDbgF8IDdx53xIo8Gv05e9xpTvQYaIto
+qeHbQjGXfSGc91olfX6JUwZlxxbhdJH+rxTFAg0jcbqToJoScWTfXSr1QRcNbSTs
+Y4CPG6oULsnhVvrzgldGSK+DxFi2OKcDsOKkV7W4IGg8Do2L/M588AfBnV8ERzpl
+qgMBBQxC2+0N6RdFHbmZt0HQE/NIg1s0xcjGx1XW3YTOfje31rmAXKHOehm4Bu48
+gr8gePq5cdQ2W9tA0Dnytb9wzH2SyPPIXRI7yNxaX9H8wYeDeeiKSSmQtfh1v5cV
+7RXvm8F6hLJkkco/HOW3dAUwZFcKsUH+1eUJKLN18eDGwB8yGawjHvOKqcfg5Lf/
+TvC7hgcx7pDYaCCaqHaekgUwXbB2Enzqr1fdwoU1c01W5YuQAtAx5wk1bf34Yq/J
+ph7wNXGvo88N0/EfP9AdVGmJzy7VuRXeVAOyjKAIeADMlwpjBRhcbs9m3dkqvoMb
+SXKJxv/hFmNgEOvOlaFsXX1dbKg1v+C1AzKAFdiuAIa62JzASiEhigqNSdqdTsOh
+8W8hdONuKKpe9zKedhBFAvuxhDgKmnySglYc
+-----END CERTIFICATE-----
+
+subject=/C=BM/O=QuoVadis Limited/CN=QuoVadis Root CA 2 G3
+issuer=/C=BM/O=QuoVadis Limited/CN=QuoVadis Root CA 2 G3
+-----BEGIN CERTIFICATE-----
+MIIFYDCCA0igAwIBAgIURFc0JFuBiZs18s64KztbpybwdSgwDQYJKoZIhvcNAQEL
+BQAwSDELMAkGA1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxHjAc
+BgNVBAMTFVF1b1ZhZGlzIFJvb3QgQ0EgMiBHMzAeFw0xMjAxMTIxODU5MzJaFw00
+MjAxMTIxODU5MzJaMEgxCzAJBgNVBAYTAkJNMRkwFwYDVQQKExBRdW9WYWRpcyBM
+aW1pdGVkMR4wHAYDVQQDExVRdW9WYWRpcyBSb290IENBIDIgRzMwggIiMA0GCSqG
+SIb3DQEBAQUAA4ICDwAwggIKAoICAQChriWyARjcV4g/Ruv5r+LrI3HimtFhZiFf
+qq8nUeVuGxbULX1QsFN3vXg6YOJkApt8hpvWGo6t/x8Vf9WVHhLL5hSEBMHfNrMW
+n4rjyduYNM7YMxcoRvynyfDStNVNCXJJ+fKH46nafaF9a7I6JaltUkSs+L5u+9ym
+c5GQYaYDFCDy54ejiK2toIz/pgslUiXnFgHVy7g1gQyjO/Dh4fxaXc6AcW34Sas+
+O7q414AB+6XrW7PFXmAqMaCvN+ggOp+oMiwMzAkd056OXbxMmO7FGmh77FOm6RQ1
+o9/NgJ8MSPsc9PG/Srj61YxxSscfrf5BmrODXfKEVu+lV0POKa2Mq1W/xPtbAd0j
+IaFYAI7D0GoT7RPjEiuA3GfmlbLNHiJuKvhB1PLKFAeNilUSxmn1uIZoL1NesNKq
+IcGY5jDjZ1XHm26sGahVpkUG0CM62+tlXSoREfA7T8pt9DTEceT/AFr2XK4jYIVz
+8eQQsSWu1ZK7E8EM4DnatDlXtas1qnIhO4M15zHfeiFuuDIIfR0ykRVKYnLP43eh
+vNURG3YBZwjgQQvD6xVu+KQZ2aKrr+InUlYrAoosFCT5v0ICvybIxo/gbjh9Uy3l
+7ZizlWNof/k19N+IxWA1ksB8aRxhlRbQ694Lrz4EEEVlWFA4r0jyWbYW8jwNkALG
+cC4BrTwV1wIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIB
+BjAdBgNVHQ4EFgQU7edvdlq/YOxJW8ald7tyFnGbxD0wDQYJKoZIhvcNAQELBQAD
+ggIBAJHfgD9DCX5xwvfrs4iP4VGyvD11+ShdyLyZm3tdquXK4Qr36LLTn91nMX66
+AarHakE7kNQIXLJgapDwyM4DYvmL7ftuKtwGTTwpD4kWilhMSA/ohGHqPHKmd+RC
+roijQ1h5fq7KpVMNqT1wvSAZYaRsOPxDMuHBR//47PERIjKWnML2W2mWeyAMQ0Ga
+W/ZZGYjeVYg3UQt4XAoeo0L9x52ID8DyeAIkVJOviYeIyUqAHerQbj5hLja7NQ4n
+lv1mNDthcnPxFlxHBlRJAHpYErAK74X9sbgzdWqTHBLmYF5vHX/JHyPLhGGfHoJE
++V+tYlUkmlKY7VHnoX6XOuYvHxHaU4AshZ6rNRDbIl9qxV6XU/IyAgkwo1jwDQHV
+csaxfGl7w/U2Rcxhbl5MlMVerugOXou/983g7aEOGzPuVBj+D77vfoRrQ+NwmNtd
+dbINWQeFFSM51vHfqSYP1kjHs6Yi9TM3WpVHn3u6GBVv/9YUZINJ0gpnIdsPNWNg
+KCLjsZWDzYWm3S8P52dSbrsvhXz1SnPnxT7AvSESBT/8twNJAlvIJebiVDj1eYeM
+HVOyToV7BjjHLPj4sHKNJeV3UvQDHEimUF+IIDBu8oJDqz2XhOdT+yHBTw8imoa4
+WSr2Rz0ZiC3oheGe7IUIarFsNMkd7EgrO3jtZsSOeWmD3n+M
+-----END CERTIFICATE-----
+
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/simple-chain-a.cert b/ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/simple-chain-a.cert
new file mode 100644
index 000000000..1d9bbe213
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/simple-chain-a.cert
@@ -0,0 +1,18 @@
+subject=/C=AU/ST=Victoria/L=Melbourne/O=Telstra Corporation Limited/OU=Telstra Energy/CN=dev.energy.inside.telstra.com
+issuer=/C=BM/O=QuoVadis Limited/CN=QuoVadis Global SSL ICA G3
+-----BEGIN CERTIFICATE-----
+aaa
+-----END CERTIFICATE-----
+
+subject=/C=BM/O=QuoVadis Limited/CN=QuoVadis Global SSL ICA G3
+issuer=/C=BM/O=QuoVadis Limited/CN=QuoVadis Root CA 2 G3
+-----BEGIN CERTIFICATE-----
+bbb
+-----END CERTIFICATE-----
+
+subject=/C=BM/O=QuoVadis Limited/CN=QuoVadis Root CA 2 G3
+issuer=/C=BM/O=QuoVadis Limited/CN=QuoVadis Root CA 2 G3
+-----BEGIN CERTIFICATE-----
+ccc
+-----END CERTIFICATE-----
+
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/simple-chain-b.cert b/ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/simple-chain-b.cert
new file mode 100644
index 000000000..1d9bbe213
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/certs/simple-chain-b.cert
@@ -0,0 +1,18 @@
+subject=/C=AU/ST=Victoria/L=Melbourne/O=Telstra Corporation Limited/OU=Telstra Energy/CN=dev.energy.inside.telstra.com
+issuer=/C=BM/O=QuoVadis Limited/CN=QuoVadis Global SSL ICA G3
+-----BEGIN CERTIFICATE-----
+aaa
+-----END CERTIFICATE-----
+
+subject=/C=BM/O=QuoVadis Limited/CN=QuoVadis Global SSL ICA G3
+issuer=/C=BM/O=QuoVadis Limited/CN=QuoVadis Root CA 2 G3
+-----BEGIN CERTIFICATE-----
+bbb
+-----END CERTIFICATE-----
+
+subject=/C=BM/O=QuoVadis Limited/CN=QuoVadis Root CA 2 G3
+issuer=/C=BM/O=QuoVadis Limited/CN=QuoVadis Root CA 2 G3
+-----BEGIN CERTIFICATE-----
+ccc
+-----END CERTIFICATE-----
+
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/thezip.zip b/ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/thezip.zip
new file mode 100644
index 000000000..6eaefdd5e
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/fixtures/thezip.zip
Binary files differ
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/.gitkeep b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/.gitkeep
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/.gitkeep
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.CreateStack_1.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.CreateStack_1.json
new file mode 100644
index 000000000..36f1489ba
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.CreateStack_1.json
@@ -0,0 +1,17 @@
+{
+ "status_code": 200,
+ "data": {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "ResponseMetadata": {
+ "RetryAttempts": 0,
+ "HTTPStatusCode": 200,
+ "RequestId": "03fbfc36-b5d0-11e7-ae09-550cfe4b2358",
+ "HTTPHeaders": {
+ "x-amzn-requestid": "03fbfc36-b5d0-11e7-ae09-550cfe4b2358",
+ "date": "Fri, 20 Oct 2017 19:51:07 GMT",
+ "content-length": "393",
+ "content-type": "text/xml"
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DeleteStack_1.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DeleteStack_1.json
new file mode 100644
index 000000000..d526155a5
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DeleteStack_1.json
@@ -0,0 +1,16 @@
+{
+ "status_code": 200,
+ "data": {
+ "ResponseMetadata": {
+ "RetryAttempts": 0,
+ "HTTPStatusCode": 200,
+ "RequestId": "170d1e02-b5d0-11e7-ae09-550cfe4b2358",
+ "HTTPHeaders": {
+ "x-amzn-requestid": "170d1e02-b5d0-11e7-ae09-550cfe4b2358",
+ "date": "Fri, 20 Oct 2017 19:51:39 GMT",
+ "content-length": "212",
+ "content-type": "text/xml"
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStackEvents_1.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStackEvents_1.json
new file mode 100644
index 000000000..3758c77b7
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStackEvents_1.json
@@ -0,0 +1,38 @@
+{
+ "status_code": 200,
+ "data": {
+ "StackEvents": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "04032730-b5d0-11e7-86b8-503ac93168c5",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::CloudFormation::Stack",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 8,
+ "microsecond": 324000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "ResourceStatusReason": "User Initiated",
+ "StackName": "ansible-test-basic-yaml",
+ "PhysicalResourceId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "LogicalResourceId": "ansible-test-basic-yaml"
+ }
+ ],
+ "ResponseMetadata": {
+ "RetryAttempts": 0,
+ "HTTPStatusCode": 200,
+ "RequestId": "043d4a05-b5d0-11e7-ae09-550cfe4b2358",
+ "HTTPHeaders": {
+ "x-amzn-requestid": "043d4a05-b5d0-11e7-ae09-550cfe4b2358",
+ "date": "Fri, 20 Oct 2017 19:51:08 GMT",
+ "content-length": "1183",
+ "content-type": "text/xml"
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStackEvents_2.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStackEvents_2.json
new file mode 100644
index 000000000..2c5a7655e
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStackEvents_2.json
@@ -0,0 +1,80 @@
+{
+ "status_code": 200,
+ "data": {
+ "StackEvents": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "MyBucket-CREATE_IN_PROGRESS-2017-10-20T19:51:12.754Z",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::S3::Bucket",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 12,
+ "microsecond": 754000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "ResourceStatusReason": "Resource creation Initiated",
+ "StackName": "ansible-test-basic-yaml",
+ "ResourceProperties": "{}\n",
+ "PhysicalResourceId": "ansible-test-basic-yaml-mybucket-13m2y4v8bptj4",
+ "LogicalResourceId": "MyBucket"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "MyBucket-CREATE_IN_PROGRESS-2017-10-20T19:51:11.159Z",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::S3::Bucket",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 11,
+ "microsecond": 159000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "StackName": "ansible-test-basic-yaml",
+ "ResourceProperties": "{}\n",
+ "PhysicalResourceId": "",
+ "LogicalResourceId": "MyBucket"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "04032730-b5d0-11e7-86b8-503ac93168c5",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::CloudFormation::Stack",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 8,
+ "microsecond": 324000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "ResourceStatusReason": "User Initiated",
+ "StackName": "ansible-test-basic-yaml",
+ "PhysicalResourceId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "LogicalResourceId": "ansible-test-basic-yaml"
+ }
+ ],
+ "ResponseMetadata": {
+ "RetryAttempts": 0,
+ "HTTPStatusCode": 200,
+ "RequestId": "075d9d71-b5d0-11e7-ae09-550cfe4b2358",
+ "HTTPHeaders": {
+ "x-amzn-requestid": "075d9d71-b5d0-11e7-ae09-550cfe4b2358",
+ "vary": "Accept-Encoding",
+ "content-length": "2730",
+ "content-type": "text/xml",
+ "date": "Fri, 20 Oct 2017 19:51:13 GMT"
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStackEvents_3.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStackEvents_3.json
new file mode 100644
index 000000000..cf2c24502
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStackEvents_3.json
@@ -0,0 +1,80 @@
+{
+ "status_code": 200,
+ "data": {
+ "StackEvents": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "MyBucket-CREATE_IN_PROGRESS-2017-10-20T19:51:12.754Z",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::S3::Bucket",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 12,
+ "microsecond": 754000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "ResourceStatusReason": "Resource creation Initiated",
+ "StackName": "ansible-test-basic-yaml",
+ "ResourceProperties": "{}\n",
+ "PhysicalResourceId": "ansible-test-basic-yaml-mybucket-13m2y4v8bptj4",
+ "LogicalResourceId": "MyBucket"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "MyBucket-CREATE_IN_PROGRESS-2017-10-20T19:51:11.159Z",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::S3::Bucket",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 11,
+ "microsecond": 159000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "StackName": "ansible-test-basic-yaml",
+ "ResourceProperties": "{}\n",
+ "PhysicalResourceId": "",
+ "LogicalResourceId": "MyBucket"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "04032730-b5d0-11e7-86b8-503ac93168c5",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::CloudFormation::Stack",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 8,
+ "microsecond": 324000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "ResourceStatusReason": "User Initiated",
+ "StackName": "ansible-test-basic-yaml",
+ "PhysicalResourceId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "LogicalResourceId": "ansible-test-basic-yaml"
+ }
+ ],
+ "ResponseMetadata": {
+ "RetryAttempts": 0,
+ "HTTPStatusCode": 200,
+ "RequestId": "0a7eb31b-b5d0-11e7-ae09-550cfe4b2358",
+ "HTTPHeaders": {
+ "x-amzn-requestid": "0a7eb31b-b5d0-11e7-ae09-550cfe4b2358",
+ "vary": "Accept-Encoding",
+ "content-length": "2730",
+ "content-type": "text/xml",
+ "date": "Fri, 20 Oct 2017 19:51:19 GMT"
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStackEvents_4.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStackEvents_4.json
new file mode 100644
index 000000000..32ee9c1c5
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStackEvents_4.json
@@ -0,0 +1,80 @@
+{
+ "status_code": 200,
+ "data": {
+ "StackEvents": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "MyBucket-CREATE_IN_PROGRESS-2017-10-20T19:51:12.754Z",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::S3::Bucket",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 12,
+ "microsecond": 754000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "ResourceStatusReason": "Resource creation Initiated",
+ "StackName": "ansible-test-basic-yaml",
+ "ResourceProperties": "{}\n",
+ "PhysicalResourceId": "ansible-test-basic-yaml-mybucket-13m2y4v8bptj4",
+ "LogicalResourceId": "MyBucket"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "MyBucket-CREATE_IN_PROGRESS-2017-10-20T19:51:11.159Z",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::S3::Bucket",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 11,
+ "microsecond": 159000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "StackName": "ansible-test-basic-yaml",
+ "ResourceProperties": "{}\n",
+ "PhysicalResourceId": "",
+ "LogicalResourceId": "MyBucket"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "04032730-b5d0-11e7-86b8-503ac93168c5",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::CloudFormation::Stack",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 8,
+ "microsecond": 324000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "ResourceStatusReason": "User Initiated",
+ "StackName": "ansible-test-basic-yaml",
+ "PhysicalResourceId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "LogicalResourceId": "ansible-test-basic-yaml"
+ }
+ ],
+ "ResponseMetadata": {
+ "RetryAttempts": 0,
+ "HTTPStatusCode": 200,
+ "RequestId": "0d9e1c06-b5d0-11e7-ae09-550cfe4b2358",
+ "HTTPHeaders": {
+ "x-amzn-requestid": "0d9e1c06-b5d0-11e7-ae09-550cfe4b2358",
+ "vary": "Accept-Encoding",
+ "content-length": "2730",
+ "content-type": "text/xml",
+ "date": "Fri, 20 Oct 2017 19:51:24 GMT"
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStackEvents_5.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStackEvents_5.json
new file mode 100644
index 000000000..b547cd4d8
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStackEvents_5.json
@@ -0,0 +1,80 @@
+{
+ "status_code": 200,
+ "data": {
+ "StackEvents": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "MyBucket-CREATE_IN_PROGRESS-2017-10-20T19:51:12.754Z",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::S3::Bucket",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 12,
+ "microsecond": 754000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "ResourceStatusReason": "Resource creation Initiated",
+ "StackName": "ansible-test-basic-yaml",
+ "ResourceProperties": "{}\n",
+ "PhysicalResourceId": "ansible-test-basic-yaml-mybucket-13m2y4v8bptj4",
+ "LogicalResourceId": "MyBucket"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "MyBucket-CREATE_IN_PROGRESS-2017-10-20T19:51:11.159Z",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::S3::Bucket",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 11,
+ "microsecond": 159000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "StackName": "ansible-test-basic-yaml",
+ "ResourceProperties": "{}\n",
+ "PhysicalResourceId": "",
+ "LogicalResourceId": "MyBucket"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "04032730-b5d0-11e7-86b8-503ac93168c5",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::CloudFormation::Stack",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 8,
+ "microsecond": 324000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "ResourceStatusReason": "User Initiated",
+ "StackName": "ansible-test-basic-yaml",
+ "PhysicalResourceId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "LogicalResourceId": "ansible-test-basic-yaml"
+ }
+ ],
+ "ResponseMetadata": {
+ "RetryAttempts": 0,
+ "HTTPStatusCode": 200,
+ "RequestId": "10bd84ca-b5d0-11e7-ae09-550cfe4b2358",
+ "HTTPHeaders": {
+ "x-amzn-requestid": "10bd84ca-b5d0-11e7-ae09-550cfe4b2358",
+ "vary": "Accept-Encoding",
+ "content-length": "2730",
+ "content-type": "text/xml",
+ "date": "Fri, 20 Oct 2017 19:51:29 GMT"
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStackEvents_6.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStackEvents_6.json
new file mode 100644
index 000000000..15bd043ab
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStackEvents_6.json
@@ -0,0 +1,100 @@
+{
+ "status_code": 200,
+ "data": {
+ "StackEvents": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "MyBucket-CREATE_COMPLETE-2017-10-20T19:51:33.200Z",
+ "ResourceStatus": "CREATE_COMPLETE",
+ "ResourceType": "AWS::S3::Bucket",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 33,
+ "microsecond": 200000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "StackName": "ansible-test-basic-yaml",
+ "ResourceProperties": "{}\n",
+ "PhysicalResourceId": "ansible-test-basic-yaml-mybucket-13m2y4v8bptj4",
+ "LogicalResourceId": "MyBucket"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "MyBucket-CREATE_IN_PROGRESS-2017-10-20T19:51:12.754Z",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::S3::Bucket",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 12,
+ "microsecond": 754000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "ResourceStatusReason": "Resource creation Initiated",
+ "StackName": "ansible-test-basic-yaml",
+ "ResourceProperties": "{}\n",
+ "PhysicalResourceId": "ansible-test-basic-yaml-mybucket-13m2y4v8bptj4",
+ "LogicalResourceId": "MyBucket"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "MyBucket-CREATE_IN_PROGRESS-2017-10-20T19:51:11.159Z",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::S3::Bucket",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 11,
+ "microsecond": 159000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "StackName": "ansible-test-basic-yaml",
+ "ResourceProperties": "{}\n",
+ "PhysicalResourceId": "",
+ "LogicalResourceId": "MyBucket"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "04032730-b5d0-11e7-86b8-503ac93168c5",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::CloudFormation::Stack",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 8,
+ "microsecond": 324000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "ResourceStatusReason": "User Initiated",
+ "StackName": "ansible-test-basic-yaml",
+ "PhysicalResourceId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "LogicalResourceId": "ansible-test-basic-yaml"
+ }
+ ],
+ "ResponseMetadata": {
+ "RetryAttempts": 0,
+ "HTTPStatusCode": 200,
+ "RequestId": "13dbb3fd-b5d0-11e7-ae09-550cfe4b2358",
+ "HTTPHeaders": {
+ "x-amzn-requestid": "13dbb3fd-b5d0-11e7-ae09-550cfe4b2358",
+ "vary": "Accept-Encoding",
+ "content-length": "3490",
+ "content-type": "text/xml",
+ "date": "Fri, 20 Oct 2017 19:51:34 GMT"
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStackEvents_7.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStackEvents_7.json
new file mode 100644
index 000000000..87db7c59e
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStackEvents_7.json
@@ -0,0 +1,119 @@
+{
+ "status_code": 200,
+ "data": {
+ "StackEvents": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "140d7220-b5d0-11e7-933f-50a686be7356",
+ "ResourceStatus": "CREATE_COMPLETE",
+ "ResourceType": "AWS::CloudFormation::Stack",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 35,
+ "microsecond": 121000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "StackName": "ansible-test-basic-yaml",
+ "PhysicalResourceId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "LogicalResourceId": "ansible-test-basic-yaml"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "MyBucket-CREATE_COMPLETE-2017-10-20T19:51:33.200Z",
+ "ResourceStatus": "CREATE_COMPLETE",
+ "ResourceType": "AWS::S3::Bucket",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 33,
+ "microsecond": 200000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "StackName": "ansible-test-basic-yaml",
+ "ResourceProperties": "{}\n",
+ "PhysicalResourceId": "ansible-test-basic-yaml-mybucket-13m2y4v8bptj4",
+ "LogicalResourceId": "MyBucket"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "MyBucket-CREATE_IN_PROGRESS-2017-10-20T19:51:12.754Z",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::S3::Bucket",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 12,
+ "microsecond": 754000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "ResourceStatusReason": "Resource creation Initiated",
+ "StackName": "ansible-test-basic-yaml",
+ "ResourceProperties": "{}\n",
+ "PhysicalResourceId": "ansible-test-basic-yaml-mybucket-13m2y4v8bptj4",
+ "LogicalResourceId": "MyBucket"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "MyBucket-CREATE_IN_PROGRESS-2017-10-20T19:51:11.159Z",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::S3::Bucket",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 11,
+ "microsecond": 159000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "StackName": "ansible-test-basic-yaml",
+ "ResourceProperties": "{}\n",
+ "PhysicalResourceId": "",
+ "LogicalResourceId": "MyBucket"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "04032730-b5d0-11e7-86b8-503ac93168c5",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::CloudFormation::Stack",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 8,
+ "microsecond": 324000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "ResourceStatusReason": "User Initiated",
+ "StackName": "ansible-test-basic-yaml",
+ "PhysicalResourceId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "LogicalResourceId": "ansible-test-basic-yaml"
+ }
+ ],
+ "ResponseMetadata": {
+ "RetryAttempts": 0,
+ "HTTPStatusCode": 200,
+ "RequestId": "16faf590-b5d0-11e7-ae09-550cfe4b2358",
+ "HTTPHeaders": {
+ "x-amzn-requestid": "16faf590-b5d0-11e7-ae09-550cfe4b2358",
+ "vary": "Accept-Encoding",
+ "content-length": "4276",
+ "content-type": "text/xml",
+ "date": "Fri, 20 Oct 2017 19:51:39 GMT"
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStacks_1.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStacks_1.json
new file mode 100644
index 000000000..7acdb3acf
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStacks_1.json
@@ -0,0 +1,40 @@
+{
+ "status_code": 200,
+ "data": {
+ "Stacks": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EnableTerminationProtection": false,
+ "Description": "Basic template that creates an S3 bucket",
+ "Tags": [],
+ "StackStatusReason": "User Initiated",
+ "CreationTime": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 8,
+ "microsecond": 324000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "StackName": "ansible-test-basic-yaml",
+ "NotificationARNs": [],
+ "StackStatus": "CREATE_IN_PROGRESS",
+ "DisableRollback": false,
+ "RollbackConfiguration": {}
+ }
+ ],
+ "ResponseMetadata": {
+ "RetryAttempts": 0,
+ "HTTPStatusCode": 200,
+ "RequestId": "042974db-b5d0-11e7-ae09-550cfe4b2358",
+ "HTTPHeaders": {
+ "x-amzn-requestid": "042974db-b5d0-11e7-ae09-550cfe4b2358",
+ "date": "Fri, 20 Oct 2017 19:51:08 GMT",
+ "content-length": "975",
+ "content-type": "text/xml"
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStacks_2.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStacks_2.json
new file mode 100644
index 000000000..0ed674b20
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStacks_2.json
@@ -0,0 +1,39 @@
+{
+ "status_code": 200,
+ "data": {
+ "Stacks": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "Description": "Basic template that creates an S3 bucket",
+ "Tags": [],
+ "EnableTerminationProtection": false,
+ "CreationTime": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 8,
+ "microsecond": 324000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "StackName": "ansible-test-basic-yaml",
+ "NotificationARNs": [],
+ "StackStatus": "CREATE_IN_PROGRESS",
+ "DisableRollback": false,
+ "RollbackConfiguration": {}
+ }
+ ],
+ "ResponseMetadata": {
+ "RetryAttempts": 0,
+ "HTTPStatusCode": 200,
+ "RequestId": "074b26dc-b5d0-11e7-ae09-550cfe4b2358",
+ "HTTPHeaders": {
+ "x-amzn-requestid": "074b26dc-b5d0-11e7-ae09-550cfe4b2358",
+ "date": "Fri, 20 Oct 2017 19:51:13 GMT",
+ "content-length": "913",
+ "content-type": "text/xml"
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStacks_3.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStacks_3.json
new file mode 100644
index 000000000..633c5e159
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStacks_3.json
@@ -0,0 +1,39 @@
+{
+ "status_code": 200,
+ "data": {
+ "Stacks": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "Description": "Basic template that creates an S3 bucket",
+ "Tags": [],
+ "EnableTerminationProtection": false,
+ "CreationTime": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 8,
+ "microsecond": 324000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "StackName": "ansible-test-basic-yaml",
+ "NotificationARNs": [],
+ "StackStatus": "CREATE_IN_PROGRESS",
+ "DisableRollback": false,
+ "RollbackConfiguration": {}
+ }
+ ],
+ "ResponseMetadata": {
+ "RetryAttempts": 0,
+ "HTTPStatusCode": 200,
+ "RequestId": "0a6cb1b3-b5d0-11e7-ae09-550cfe4b2358",
+ "HTTPHeaders": {
+ "x-amzn-requestid": "0a6cb1b3-b5d0-11e7-ae09-550cfe4b2358",
+ "date": "Fri, 20 Oct 2017 19:51:18 GMT",
+ "content-length": "913",
+ "content-type": "text/xml"
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStacks_4.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStacks_4.json
new file mode 100644
index 000000000..e5ca69dda
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStacks_4.json
@@ -0,0 +1,39 @@
+{
+ "status_code": 200,
+ "data": {
+ "Stacks": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "Description": "Basic template that creates an S3 bucket",
+ "Tags": [],
+ "EnableTerminationProtection": false,
+ "CreationTime": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 8,
+ "microsecond": 324000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "StackName": "ansible-test-basic-yaml",
+ "NotificationARNs": [],
+ "StackStatus": "CREATE_IN_PROGRESS",
+ "DisableRollback": false,
+ "RollbackConfiguration": {}
+ }
+ ],
+ "ResponseMetadata": {
+ "RetryAttempts": 0,
+ "HTTPStatusCode": 200,
+ "RequestId": "0d8cddf1-b5d0-11e7-ae09-550cfe4b2358",
+ "HTTPHeaders": {
+ "x-amzn-requestid": "0d8cddf1-b5d0-11e7-ae09-550cfe4b2358",
+ "date": "Fri, 20 Oct 2017 19:51:23 GMT",
+ "content-length": "913",
+ "content-type": "text/xml"
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStacks_5.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStacks_5.json
new file mode 100644
index 000000000..31a3057cd
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStacks_5.json
@@ -0,0 +1,39 @@
+{
+ "status_code": 200,
+ "data": {
+ "Stacks": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "Description": "Basic template that creates an S3 bucket",
+ "Tags": [],
+ "EnableTerminationProtection": false,
+ "CreationTime": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 8,
+ "microsecond": 324000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "StackName": "ansible-test-basic-yaml",
+ "NotificationARNs": [],
+ "StackStatus": "CREATE_IN_PROGRESS",
+ "DisableRollback": false,
+ "RollbackConfiguration": {}
+ }
+ ],
+ "ResponseMetadata": {
+ "RetryAttempts": 0,
+ "HTTPStatusCode": 200,
+ "RequestId": "10ac94d5-b5d0-11e7-ae09-550cfe4b2358",
+ "HTTPHeaders": {
+ "x-amzn-requestid": "10ac94d5-b5d0-11e7-ae09-550cfe4b2358",
+ "date": "Fri, 20 Oct 2017 19:51:28 GMT",
+ "content-length": "913",
+ "content-type": "text/xml"
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStacks_6.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStacks_6.json
new file mode 100644
index 000000000..90ca7467c
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStacks_6.json
@@ -0,0 +1,39 @@
+{
+ "status_code": 200,
+ "data": {
+ "Stacks": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "Description": "Basic template that creates an S3 bucket",
+ "Tags": [],
+ "EnableTerminationProtection": false,
+ "CreationTime": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 8,
+ "microsecond": 324000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "StackName": "ansible-test-basic-yaml",
+ "NotificationARNs": [],
+ "StackStatus": "CREATE_IN_PROGRESS",
+ "DisableRollback": false,
+ "RollbackConfiguration": {}
+ }
+ ],
+ "ResponseMetadata": {
+ "RetryAttempts": 0,
+ "HTTPStatusCode": 200,
+ "RequestId": "13caeb1b-b5d0-11e7-ae09-550cfe4b2358",
+ "HTTPHeaders": {
+ "x-amzn-requestid": "13caeb1b-b5d0-11e7-ae09-550cfe4b2358",
+ "date": "Fri, 20 Oct 2017 19:51:33 GMT",
+ "content-length": "913",
+ "content-type": "text/xml"
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStacks_7.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStacks_7.json
new file mode 100644
index 000000000..905c04f48
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/basic_s3_stack/cloudformation.DescribeStacks_7.json
@@ -0,0 +1,45 @@
+{
+ "status_code": 200,
+ "data": {
+ "Stacks": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-basic-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "Description": "Basic template that creates an S3 bucket",
+ "Tags": [],
+ "Outputs": [
+ {
+ "OutputKey": "TheName",
+ "OutputValue": "ansible-test-basic-yaml-mybucket-13m2y4v8bptj4"
+ }
+ ],
+ "EnableTerminationProtection": false,
+ "CreationTime": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 8,
+ "microsecond": 324000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "StackName": "ansible-test-basic-yaml",
+ "NotificationARNs": [],
+ "StackStatus": "CREATE_COMPLETE",
+ "DisableRollback": false,
+ "RollbackConfiguration": {}
+ }
+ ],
+ "ResponseMetadata": {
+ "RetryAttempts": 0,
+ "HTTPStatusCode": 200,
+ "RequestId": "16ea53bb-b5d0-11e7-ae09-550cfe4b2358",
+ "HTTPHeaders": {
+ "x-amzn-requestid": "16ea53bb-b5d0-11e7-ae09-550cfe4b2358",
+ "date": "Fri, 20 Oct 2017 19:51:39 GMT",
+ "content-length": "1115",
+ "content-type": "text/xml"
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.CreateStack_1.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.CreateStack_1.json
new file mode 100644
index 000000000..9084936a4
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.CreateStack_1.json
@@ -0,0 +1,17 @@
+{
+ "status_code": 200,
+ "data": {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "ResponseMetadata": {
+ "RetryAttempts": 0,
+ "HTTPStatusCode": 200,
+ "RequestId": "03fbfc36-b5d0-11e7-ae09-550cfe4b2358",
+ "HTTPHeaders": {
+ "x-amzn-requestid": "03fbfc36-b5d0-11e7-ae09-550cfe4b2358",
+ "date": "Fri, 20 Oct 2017 19:51:07 GMT",
+ "content-length": "393",
+ "content-type": "text/xml"
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DeleteStack_1.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DeleteStack_1.json
new file mode 100644
index 000000000..d526155a5
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DeleteStack_1.json
@@ -0,0 +1,16 @@
+{
+ "status_code": 200,
+ "data": {
+ "ResponseMetadata": {
+ "RetryAttempts": 0,
+ "HTTPStatusCode": 200,
+ "RequestId": "170d1e02-b5d0-11e7-ae09-550cfe4b2358",
+ "HTTPHeaders": {
+ "x-amzn-requestid": "170d1e02-b5d0-11e7-ae09-550cfe4b2358",
+ "date": "Fri, 20 Oct 2017 19:51:39 GMT",
+ "content-length": "212",
+ "content-type": "text/xml"
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStackEvents_1.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStackEvents_1.json
new file mode 100644
index 000000000..399eab496
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStackEvents_1.json
@@ -0,0 +1,39 @@
+{
+ "status_code": 200,
+ "data": {
+ "StackEvents": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "04032730-b5d0-11e7-86b8-503ac93168c5",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::CloudFormation::Stack",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 8,
+ "microsecond": 324000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "ResourceStatusReason": "User Initiated",
+ "StackName": "ansible-test-client-request-token-yaml",
+ "PhysicalResourceId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "ClientRequestToken": "3faf3fb5-b289-41fc-b940-44151828f6cf",
+ "LogicalResourceId": "ansible-test-client-request-token-yaml"
+ }
+ ],
+ "ResponseMetadata": {
+ "RetryAttempts": 0,
+ "HTTPStatusCode": 200,
+ "RequestId": "043d4a05-b5d0-11e7-ae09-550cfe4b2358",
+ "HTTPHeaders": {
+ "x-amzn-requestid": "043d4a05-b5d0-11e7-ae09-550cfe4b2358",
+ "date": "Fri, 20 Oct 2017 19:51:08 GMT",
+ "content-length": "1183",
+ "content-type": "text/xml"
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStackEvents_2.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStackEvents_2.json
new file mode 100644
index 000000000..f57dbf536
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStackEvents_2.json
@@ -0,0 +1,83 @@
+{
+ "status_code": 200,
+ "data": {
+ "StackEvents": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "MyBucket-CREATE_IN_PROGRESS-2017-10-20T19:51:12.754Z",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::S3::Bucket",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 12,
+ "microsecond": 754000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "ResourceStatusReason": "Resource creation Initiated",
+ "StackName": "ansible-test-client-request-token-yaml",
+ "ResourceProperties": "{}\n",
+ "PhysicalResourceId": "ansible-test-client-request-token-yaml-mybucket-13m2y4v8bptj4",
+ "ClientRequestToken": "3faf3fb5-b289-41fc-b940-44151828f6cf",
+ "LogicalResourceId": "MyBucket"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "MyBucket-CREATE_IN_PROGRESS-2017-10-20T19:51:11.159Z",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::S3::Bucket",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 11,
+ "microsecond": 159000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "StackName": "ansible-test-client-request-token-yaml",
+ "ResourceProperties": "{}\n",
+ "PhysicalResourceId": "",
+ "ClientRequestToken": "3faf3fb5-b289-41fc-b940-44151828f6cf",
+ "LogicalResourceId": "MyBucket"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "04032730-b5d0-11e7-86b8-503ac93168c5",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::CloudFormation::Stack",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 8,
+ "microsecond": 324000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "ResourceStatusReason": "User Initiated",
+ "StackName": "ansible-test-client-request-token-yaml",
+ "PhysicalResourceId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "ClientRequestToken": "3faf3fb5-b289-41fc-b940-44151828f6cf",
+ "LogicalResourceId": "ansible-test-client-request-token-yaml"
+ }
+ ],
+ "ResponseMetadata": {
+ "RetryAttempts": 0,
+ "HTTPStatusCode": 200,
+ "RequestId": "075d9d71-b5d0-11e7-ae09-550cfe4b2358",
+ "HTTPHeaders": {
+ "x-amzn-requestid": "075d9d71-b5d0-11e7-ae09-550cfe4b2358",
+ "vary": "Accept-Encoding",
+ "content-length": "2730",
+ "content-type": "text/xml",
+ "date": "Fri, 20 Oct 2017 19:51:13 GMT"
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStackEvents_3.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStackEvents_3.json
new file mode 100644
index 000000000..c8b4d694d
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStackEvents_3.json
@@ -0,0 +1,83 @@
+{
+ "status_code": 200,
+ "data": {
+ "StackEvents": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "MyBucket-CREATE_IN_PROGRESS-2017-10-20T19:51:12.754Z",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::S3::Bucket",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 12,
+ "microsecond": 754000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "ResourceStatusReason": "Resource creation Initiated",
+ "StackName": "ansible-test-client-request-token-yaml",
+ "ResourceProperties": "{}\n",
+ "PhysicalResourceId": "ansible-test-client-request-token-yaml-mybucket-13m2y4v8bptj4",
+ "ClientRequestToken": "3faf3fb5-b289-41fc-b940-44151828f6cf",
+ "LogicalResourceId": "MyBucket"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "MyBucket-CREATE_IN_PROGRESS-2017-10-20T19:51:11.159Z",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::S3::Bucket",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 11,
+ "microsecond": 159000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "StackName": "ansible-test-client-request-token-yaml",
+ "ResourceProperties": "{}\n",
+ "PhysicalResourceId": "",
+ "ClientRequestToken": "3faf3fb5-b289-41fc-b940-44151828f6cf",
+ "LogicalResourceId": "MyBucket"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "04032730-b5d0-11e7-86b8-503ac93168c5",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::CloudFormation::Stack",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 8,
+ "microsecond": 324000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "ResourceStatusReason": "User Initiated",
+ "StackName": "ansible-test-client-request-token-yaml",
+ "PhysicalResourceId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "ClientRequestToken": "3faf3fb5-b289-41fc-b940-44151828f6cf",
+ "LogicalResourceId": "ansible-test-client-request-token-yaml"
+ }
+ ],
+ "ResponseMetadata": {
+ "RetryAttempts": 0,
+ "HTTPStatusCode": 200,
+ "RequestId": "0a7eb31b-b5d0-11e7-ae09-550cfe4b2358",
+ "HTTPHeaders": {
+ "x-amzn-requestid": "0a7eb31b-b5d0-11e7-ae09-550cfe4b2358",
+ "vary": "Accept-Encoding",
+ "content-length": "2730",
+ "content-type": "text/xml",
+ "date": "Fri, 20 Oct 2017 19:51:19 GMT"
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStackEvents_4.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStackEvents_4.json
new file mode 100644
index 000000000..8bb03eded
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStackEvents_4.json
@@ -0,0 +1,83 @@
+{
+ "status_code": 200,
+ "data": {
+ "StackEvents": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "MyBucket-CREATE_IN_PROGRESS-2017-10-20T19:51:12.754Z",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::S3::Bucket",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 12,
+ "microsecond": 754000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "ResourceStatusReason": "Resource creation Initiated",
+ "StackName": "ansible-test-client-request-token-yaml",
+ "ResourceProperties": "{}\n",
+ "PhysicalResourceId": "ansible-test-client-request-token-yaml-mybucket-13m2y4v8bptj4",
+ "ClientRequestToken": "3faf3fb5-b289-41fc-b940-44151828f6cf",
+ "LogicalResourceId": "MyBucket"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "MyBucket-CREATE_IN_PROGRESS-2017-10-20T19:51:11.159Z",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::S3::Bucket",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 11,
+ "microsecond": 159000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "StackName": "ansible-test-client-request-token-yaml",
+ "ResourceProperties": "{}\n",
+ "PhysicalResourceId": "",
+ "ClientRequestToken": "3faf3fb5-b289-41fc-b940-44151828f6cf",
+ "LogicalResourceId": "MyBucket"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "04032730-b5d0-11e7-86b8-503ac93168c5",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::CloudFormation::Stack",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 8,
+ "microsecond": 324000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "ResourceStatusReason": "User Initiated",
+ "StackName": "ansible-test-client-request-token-yaml",
+ "PhysicalResourceId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "ClientRequestToken": "3faf3fb5-b289-41fc-b940-44151828f6cf",
+ "LogicalResourceId": "ansible-test-client-request-token-yaml"
+ }
+ ],
+ "ResponseMetadata": {
+ "RetryAttempts": 0,
+ "HTTPStatusCode": 200,
+ "RequestId": "0d9e1c06-b5d0-11e7-ae09-550cfe4b2358",
+ "HTTPHeaders": {
+ "x-amzn-requestid": "0d9e1c06-b5d0-11e7-ae09-550cfe4b2358",
+ "vary": "Accept-Encoding",
+ "content-length": "2730",
+ "content-type": "text/xml",
+ "date": "Fri, 20 Oct 2017 19:51:24 GMT"
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStackEvents_5.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStackEvents_5.json
new file mode 100644
index 000000000..311949d08
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStackEvents_5.json
@@ -0,0 +1,83 @@
+{
+ "status_code": 200,
+ "data": {
+ "StackEvents": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "MyBucket-CREATE_IN_PROGRESS-2017-10-20T19:51:12.754Z",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::S3::Bucket",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 12,
+ "microsecond": 754000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "ResourceStatusReason": "Resource creation Initiated",
+ "StackName": "ansible-test-client-request-token-yaml",
+ "ResourceProperties": "{}\n",
+ "PhysicalResourceId": "ansible-test-client-request-token-yaml-mybucket-13m2y4v8bptj4",
+ "ClientRequestToken": "3faf3fb5-b289-41fc-b940-44151828f6cf",
+ "LogicalResourceId": "MyBucket"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "MyBucket-CREATE_IN_PROGRESS-2017-10-20T19:51:11.159Z",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::S3::Bucket",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 11,
+ "microsecond": 159000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "StackName": "ansible-test-client-request-token-yaml",
+ "ResourceProperties": "{}\n",
+ "PhysicalResourceId": "",
+ "ClientRequestToken": "3faf3fb5-b289-41fc-b940-44151828f6cf",
+ "LogicalResourceId": "MyBucket"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "04032730-b5d0-11e7-86b8-503ac93168c5",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::CloudFormation::Stack",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 8,
+ "microsecond": 324000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "ResourceStatusReason": "User Initiated",
+ "StackName": "ansible-test-client-request-token-yaml",
+ "PhysicalResourceId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "ClientRequestToken": "3faf3fb5-b289-41fc-b940-44151828f6cf",
+ "LogicalResourceId": "ansible-test-client-request-token-yaml"
+ }
+ ],
+ "ResponseMetadata": {
+ "RetryAttempts": 0,
+ "HTTPStatusCode": 200,
+ "RequestId": "10bd84ca-b5d0-11e7-ae09-550cfe4b2358",
+ "HTTPHeaders": {
+ "x-amzn-requestid": "10bd84ca-b5d0-11e7-ae09-550cfe4b2358",
+ "vary": "Accept-Encoding",
+ "content-length": "2730",
+ "content-type": "text/xml",
+ "date": "Fri, 20 Oct 2017 19:51:29 GMT"
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStackEvents_6.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStackEvents_6.json
new file mode 100644
index 000000000..ddab94a51
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStackEvents_6.json
@@ -0,0 +1,104 @@
+{
+ "status_code": 200,
+ "data": {
+ "StackEvents": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "MyBucket-CREATE_COMPLETE-2017-10-20T19:51:33.200Z",
+ "ResourceStatus": "CREATE_COMPLETE",
+ "ResourceType": "AWS::S3::Bucket",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 33,
+ "microsecond": 200000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "StackName": "ansible-test-client-request-token-yaml",
+ "ResourceProperties": "{}\n",
+ "PhysicalResourceId": "ansible-test-client-request-token-yaml-mybucket-13m2y4v8bptj4",
+ "ClientRequestToken": "3faf3fb5-b289-41fc-b940-44151828f6cf",
+ "LogicalResourceId": "MyBucket"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "MyBucket-CREATE_IN_PROGRESS-2017-10-20T19:51:12.754Z",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::S3::Bucket",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 12,
+ "microsecond": 754000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "ResourceStatusReason": "Resource creation Initiated",
+ "StackName": "ansible-test-client-request-token-yaml",
+ "ResourceProperties": "{}\n",
+ "PhysicalResourceId": "ansible-test-client-request-token-yaml-mybucket-13m2y4v8bptj4",
+ "ClientRequestToken": "3faf3fb5-b289-41fc-b940-44151828f6cf",
+ "LogicalResourceId": "MyBucket"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "MyBucket-CREATE_IN_PROGRESS-2017-10-20T19:51:11.159Z",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::S3::Bucket",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 11,
+ "microsecond": 159000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "StackName": "ansible-test-client-request-token-yaml",
+ "ResourceProperties": "{}\n",
+ "PhysicalResourceId": "",
+ "ClientRequestToken": "3faf3fb5-b289-41fc-b940-44151828f6cf",
+ "LogicalResourceId": "MyBucket"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "04032730-b5d0-11e7-86b8-503ac93168c5",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::CloudFormation::Stack",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 8,
+ "microsecond": 324000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "ResourceStatusReason": "User Initiated",
+ "StackName": "ansible-test-client-request-token-yaml",
+ "PhysicalResourceId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "ClientRequestToken": "3faf3fb5-b289-41fc-b940-44151828f6cf",
+ "LogicalResourceId": "ansible-test-client-request-token-yaml"
+ }
+ ],
+ "ResponseMetadata": {
+ "RetryAttempts": 0,
+ "HTTPStatusCode": 200,
+ "RequestId": "13dbb3fd-b5d0-11e7-ae09-550cfe4b2358",
+ "HTTPHeaders": {
+ "x-amzn-requestid": "13dbb3fd-b5d0-11e7-ae09-550cfe4b2358",
+ "vary": "Accept-Encoding",
+ "content-length": "3490",
+ "content-type": "text/xml",
+ "date": "Fri, 20 Oct 2017 19:51:34 GMT"
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStackEvents_7.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStackEvents_7.json
new file mode 100644
index 000000000..86da5fb45
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStackEvents_7.json
@@ -0,0 +1,124 @@
+{
+ "status_code": 200,
+ "data": {
+ "StackEvents": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "140d7220-b5d0-11e7-933f-50a686be7356",
+ "ResourceStatus": "CREATE_COMPLETE",
+ "ResourceType": "AWS::CloudFormation::Stack",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 35,
+ "microsecond": 121000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "StackName": "ansible-test-client-request-token-yaml",
+ "PhysicalResourceId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "ClientRequestToken": "3faf3fb5-b289-41fc-b940-44151828f6cf",
+ "LogicalResourceId": "ansible-test-client-request-token-yaml"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "MyBucket-CREATE_COMPLETE-2017-10-20T19:51:33.200Z",
+ "ResourceStatus": "CREATE_COMPLETE",
+ "ResourceType": "AWS::S3::Bucket",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 33,
+ "microsecond": 200000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "StackName": "ansible-test-client-request-token-yaml",
+ "ResourceProperties": "{}\n",
+ "PhysicalResourceId": "ansible-test-client-request-token-yaml-mybucket-13m2y4v8bptj4",
+ "ClientRequestToken": "3faf3fb5-b289-41fc-b940-44151828f6cf",
+ "LogicalResourceId": "MyBucket"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "MyBucket-CREATE_IN_PROGRESS-2017-10-20T19:51:12.754Z",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::S3::Bucket",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 12,
+ "microsecond": 754000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "ResourceStatusReason": "Resource creation Initiated",
+ "StackName": "ansible-test-client-request-token-yaml",
+ "ResourceProperties": "{}\n",
+ "PhysicalResourceId": "ansible-test-client-request-token-yaml-mybucket-13m2y4v8bptj4",
+ "ClientRequestToken": "3faf3fb5-b289-41fc-b940-44151828f6cf",
+ "LogicalResourceId": "MyBucket"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "MyBucket-CREATE_IN_PROGRESS-2017-10-20T19:51:11.159Z",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::S3::Bucket",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 11,
+ "microsecond": 159000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "StackName": "ansible-test-client-request-token-yaml",
+ "ResourceProperties": "{}\n",
+ "PhysicalResourceId": "",
+ "ClientRequestToken": "3faf3fb5-b289-41fc-b940-44151828f6cf",
+ "LogicalResourceId": "MyBucket"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EventId": "04032730-b5d0-11e7-86b8-503ac93168c5",
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceType": "AWS::CloudFormation::Stack",
+ "Timestamp": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 8,
+ "microsecond": 324000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "ResourceStatusReason": "User Initiated",
+ "StackName": "ansible-test-client-request-token-yaml",
+ "PhysicalResourceId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "ClientRequestToken": "3faf3fb5-b289-41fc-b940-44151828f6cf",
+ "LogicalResourceId": "ansible-test-client-request-token-yaml"
+ }
+ ],
+ "ResponseMetadata": {
+ "RetryAttempts": 0,
+ "HTTPStatusCode": 200,
+ "RequestId": "16faf590-b5d0-11e7-ae09-550cfe4b2358",
+ "HTTPHeaders": {
+ "x-amzn-requestid": "16faf590-b5d0-11e7-ae09-550cfe4b2358",
+ "vary": "Accept-Encoding",
+ "content-length": "4276",
+ "content-type": "text/xml",
+ "date": "Fri, 20 Oct 2017 19:51:39 GMT"
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStacks_1.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStacks_1.json
new file mode 100644
index 000000000..7734b0ca3
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStacks_1.json
@@ -0,0 +1,40 @@
+{
+ "status_code": 200,
+ "data": {
+ "Stacks": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "EnableTerminationProtection": false,
+ "Description": "Basic template that creates an S3 bucket",
+ "Tags": [],
+ "StackStatusReason": "User Initiated",
+ "CreationTime": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 8,
+ "microsecond": 324000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "StackName": "ansible-test-client-request-token-yaml",
+ "NotificationARNs": [],
+ "StackStatus": "CREATE_IN_PROGRESS",
+ "DisableRollback": false,
+ "RollbackConfiguration": {}
+ }
+ ],
+ "ResponseMetadata": {
+ "RetryAttempts": 0,
+ "HTTPStatusCode": 200,
+ "RequestId": "042974db-b5d0-11e7-ae09-550cfe4b2358",
+ "HTTPHeaders": {
+ "x-amzn-requestid": "042974db-b5d0-11e7-ae09-550cfe4b2358",
+ "date": "Fri, 20 Oct 2017 19:51:08 GMT",
+ "content-length": "975",
+ "content-type": "text/xml"
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStacks_2.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStacks_2.json
new file mode 100644
index 000000000..0a1e74d70
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStacks_2.json
@@ -0,0 +1,39 @@
+{
+ "status_code": 200,
+ "data": {
+ "Stacks": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "Description": "Basic template that creates an S3 bucket",
+ "Tags": [],
+ "EnableTerminationProtection": false,
+ "CreationTime": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 8,
+ "microsecond": 324000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "StackName": "ansible-test-client-request-token-yaml",
+ "NotificationARNs": [],
+ "StackStatus": "CREATE_IN_PROGRESS",
+ "DisableRollback": false,
+ "RollbackConfiguration": {}
+ }
+ ],
+ "ResponseMetadata": {
+ "RetryAttempts": 0,
+ "HTTPStatusCode": 200,
+ "RequestId": "074b26dc-b5d0-11e7-ae09-550cfe4b2358",
+ "HTTPHeaders": {
+ "x-amzn-requestid": "074b26dc-b5d0-11e7-ae09-550cfe4b2358",
+ "date": "Fri, 20 Oct 2017 19:51:13 GMT",
+ "content-length": "913",
+ "content-type": "text/xml"
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStacks_3.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStacks_3.json
new file mode 100644
index 000000000..12d5839f8
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStacks_3.json
@@ -0,0 +1,39 @@
+{
+ "status_code": 200,
+ "data": {
+ "Stacks": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "Description": "Basic template that creates an S3 bucket",
+ "Tags": [],
+ "EnableTerminationProtection": false,
+ "CreationTime": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 8,
+ "microsecond": 324000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "StackName": "ansible-test-client-request-token-yaml",
+ "NotificationARNs": [],
+ "StackStatus": "CREATE_IN_PROGRESS",
+ "DisableRollback": false,
+ "RollbackConfiguration": {}
+ }
+ ],
+ "ResponseMetadata": {
+ "RetryAttempts": 0,
+ "HTTPStatusCode": 200,
+ "RequestId": "0a6cb1b3-b5d0-11e7-ae09-550cfe4b2358",
+ "HTTPHeaders": {
+ "x-amzn-requestid": "0a6cb1b3-b5d0-11e7-ae09-550cfe4b2358",
+ "date": "Fri, 20 Oct 2017 19:51:18 GMT",
+ "content-length": "913",
+ "content-type": "text/xml"
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStacks_4.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStacks_4.json
new file mode 100644
index 000000000..a3cb0a8ca
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStacks_4.json
@@ -0,0 +1,39 @@
+{
+ "status_code": 200,
+ "data": {
+ "Stacks": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "Description": "Basic template that creates an S3 bucket",
+ "Tags": [],
+ "EnableTerminationProtection": false,
+ "CreationTime": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 8,
+ "microsecond": 324000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "StackName": "ansible-test-client-request-token-yaml",
+ "NotificationARNs": [],
+ "StackStatus": "CREATE_IN_PROGRESS",
+ "DisableRollback": false,
+ "RollbackConfiguration": {}
+ }
+ ],
+ "ResponseMetadata": {
+ "RetryAttempts": 0,
+ "HTTPStatusCode": 200,
+ "RequestId": "0d8cddf1-b5d0-11e7-ae09-550cfe4b2358",
+ "HTTPHeaders": {
+ "x-amzn-requestid": "0d8cddf1-b5d0-11e7-ae09-550cfe4b2358",
+ "date": "Fri, 20 Oct 2017 19:51:23 GMT",
+ "content-length": "913",
+ "content-type": "text/xml"
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStacks_5.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStacks_5.json
new file mode 100644
index 000000000..251d71fa1
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStacks_5.json
@@ -0,0 +1,39 @@
+{
+ "status_code": 200,
+ "data": {
+ "Stacks": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "Description": "Basic template that creates an S3 bucket",
+ "Tags": [],
+ "EnableTerminationProtection": false,
+ "CreationTime": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 8,
+ "microsecond": 324000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "StackName": "ansible-test-client-request-token-yaml",
+ "NotificationARNs": [],
+ "StackStatus": "CREATE_IN_PROGRESS",
+ "DisableRollback": false,
+ "RollbackConfiguration": {}
+ }
+ ],
+ "ResponseMetadata": {
+ "RetryAttempts": 0,
+ "HTTPStatusCode": 200,
+ "RequestId": "10ac94d5-b5d0-11e7-ae09-550cfe4b2358",
+ "HTTPHeaders": {
+ "x-amzn-requestid": "10ac94d5-b5d0-11e7-ae09-550cfe4b2358",
+ "date": "Fri, 20 Oct 2017 19:51:28 GMT",
+ "content-length": "913",
+ "content-type": "text/xml"
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStacks_6.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStacks_6.json
new file mode 100644
index 000000000..2251125f6
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStacks_6.json
@@ -0,0 +1,39 @@
+{
+ "status_code": 200,
+ "data": {
+ "Stacks": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "Description": "Basic template that creates an S3 bucket",
+ "Tags": [],
+ "EnableTerminationProtection": false,
+ "CreationTime": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 8,
+ "microsecond": 324000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "StackName": "ansible-test-client-request-token-yaml",
+ "NotificationARNs": [],
+ "StackStatus": "CREATE_IN_PROGRESS",
+ "DisableRollback": false,
+ "RollbackConfiguration": {}
+ }
+ ],
+ "ResponseMetadata": {
+ "RetryAttempts": 0,
+ "HTTPStatusCode": 200,
+ "RequestId": "13caeb1b-b5d0-11e7-ae09-550cfe4b2358",
+ "HTTPHeaders": {
+ "x-amzn-requestid": "13caeb1b-b5d0-11e7-ae09-550cfe4b2358",
+ "date": "Fri, 20 Oct 2017 19:51:33 GMT",
+ "content-length": "913",
+ "content-type": "text/xml"
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStacks_7.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStacks_7.json
new file mode 100644
index 000000000..aa8c7fd09
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/client_request_token_s3_stack/cloudformation.DescribeStacks_7.json
@@ -0,0 +1,45 @@
+{
+ "status_code": 200,
+ "data": {
+ "Stacks": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-client-request-token-yaml/04023cd0-b5d0-11e7-86b8-503ac93168c5",
+ "Description": "Basic template that creates an S3 bucket",
+ "Tags": [],
+ "Outputs": [
+ {
+ "OutputKey": "TheName",
+ "OutputValue": "ansible-test-client-request-token-yaml-mybucket-13m2y4v8bptj4"
+ }
+ ],
+ "EnableTerminationProtection": false,
+ "CreationTime": {
+ "hour": 19,
+ "__class__": "datetime",
+ "month": 10,
+ "second": 8,
+ "microsecond": 324000,
+ "year": 2017,
+ "day": 20,
+ "minute": 51
+ },
+ "StackName": "ansible-test-client-request-token-yaml",
+ "NotificationARNs": [],
+ "StackStatus": "CREATE_COMPLETE",
+ "DisableRollback": false,
+ "RollbackConfiguration": {}
+ }
+ ],
+ "ResponseMetadata": {
+ "RetryAttempts": 0,
+ "HTTPStatusCode": 200,
+ "RequestId": "16ea53bb-b5d0-11e7-ae09-550cfe4b2358",
+ "HTTPHeaders": {
+ "x-amzn-requestid": "16ea53bb-b5d0-11e7-ae09-550cfe4b2358",
+ "date": "Fri, 20 Oct 2017 19:51:39 GMT",
+ "content-length": "1115",
+ "content-type": "text/xml"
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/delete_nonexistent_stack/cloudformation.DescribeStackEvents_1.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/delete_nonexistent_stack/cloudformation.DescribeStackEvents_1.json
new file mode 100644
index 000000000..109feacd9
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/delete_nonexistent_stack/cloudformation.DescribeStackEvents_1.json
@@ -0,0 +1,22 @@
+{
+ "status_code": 400,
+ "data": {
+ "ResponseMetadata": {
+ "RetryAttempts": 0,
+ "HTTPStatusCode": 400,
+ "RequestId": "179d9e46-b5d0-11e7-ae09-550cfe4b2358",
+ "HTTPHeaders": {
+ "x-amzn-requestid": "179d9e46-b5d0-11e7-ae09-550cfe4b2358",
+ "date": "Fri, 20 Oct 2017 19:51:40 GMT",
+ "content-length": "301",
+ "content-type": "text/xml",
+ "connection": "close"
+ }
+ },
+ "Error": {
+ "Message": "Stack [ansible-test-nonexist] does not exist",
+ "Code": "ValidationError",
+ "Type": "Sender"
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/delete_nonexistent_stack/cloudformation.DescribeStackEvents_2.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/delete_nonexistent_stack/cloudformation.DescribeStackEvents_2.json
new file mode 100644
index 000000000..589f92cc6
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/delete_nonexistent_stack/cloudformation.DescribeStackEvents_2.json
@@ -0,0 +1,22 @@
+{
+ "status_code": 400,
+ "data": {
+ "ResponseMetadata": {
+ "RetryAttempts": 0,
+ "HTTPStatusCode": 400,
+ "RequestId": "17d80f44-b5d0-11e7-80c4-9f499f779cdb",
+ "HTTPHeaders": {
+ "x-amzn-requestid": "17d80f44-b5d0-11e7-80c4-9f499f779cdb",
+ "date": "Fri, 20 Oct 2017 19:51:40 GMT",
+ "content-length": "301",
+ "content-type": "text/xml",
+ "connection": "close"
+ }
+ },
+ "Error": {
+ "Message": "Stack [ansible-test-nonexist] does not exist",
+ "Code": "ValidationError",
+ "Type": "Sender"
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/delete_nonexistent_stack/cloudformation.DescribeStacks_1.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/delete_nonexistent_stack/cloudformation.DescribeStacks_1.json
new file mode 100644
index 000000000..ea227415c
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/delete_nonexistent_stack/cloudformation.DescribeStacks_1.json
@@ -0,0 +1,22 @@
+{
+ "status_code": 400,
+ "data": {
+ "ResponseMetadata": {
+ "RetryAttempts": 0,
+ "HTTPStatusCode": 400,
+ "RequestId": "175fab26-b5d0-11e7-9d9b-45815c77100a",
+ "HTTPHeaders": {
+ "x-amzn-requestid": "175fab26-b5d0-11e7-9d9b-45815c77100a",
+ "date": "Fri, 20 Oct 2017 19:51:40 GMT",
+ "content-length": "307",
+ "content-type": "text/xml",
+ "connection": "close"
+ }
+ },
+ "Error": {
+ "Message": "Stack with id ansible-test-nonexist does not exist",
+ "Code": "ValidationError",
+ "Type": "Sender"
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/get_nonexistent_stack/cloudformation.DescribeStacks_1.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/get_nonexistent_stack/cloudformation.DescribeStacks_1.json
new file mode 100644
index 000000000..cf29c6c76
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/get_nonexistent_stack/cloudformation.DescribeStacks_1.json
@@ -0,0 +1,22 @@
+{
+ "status_code": 400,
+ "data": {
+ "ResponseMetadata": {
+ "RetryAttempts": 0,
+ "HTTPStatusCode": 400,
+ "RequestId": "181566c8-b5d0-11e7-9d9b-45815c77100a",
+ "HTTPHeaders": {
+ "x-amzn-requestid": "181566c8-b5d0-11e7-9d9b-45815c77100a",
+ "date": "Fri, 20 Oct 2017 19:51:41 GMT",
+ "content-length": "307",
+ "content-type": "text/xml",
+ "connection": "close"
+ }
+ },
+ "Error": {
+ "Message": "Stack with id ansible-test-nonexist does not exist",
+ "Code": "ValidationError",
+ "Type": "Sender"
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/invalid_template_json/cloudformation.CreateStack_1.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/invalid_template_json/cloudformation.CreateStack_1.json
new file mode 100644
index 000000000..7ad6cac96
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/invalid_template_json/cloudformation.CreateStack_1.json
@@ -0,0 +1,22 @@
+{
+ "status_code": 400,
+ "data": {
+ "ResponseMetadata": {
+ "RetryAttempts": 0,
+ "HTTPStatusCode": 400,
+ "RequestId": "03b1107f-b5d0-11e7-ae09-550cfe4b2358",
+ "HTTPHeaders": {
+ "x-amzn-requestid": "03b1107f-b5d0-11e7-ae09-550cfe4b2358",
+ "date": "Fri, 20 Oct 2017 19:51:07 GMT",
+ "content-length": "320",
+ "content-type": "text/xml",
+ "connection": "close"
+ }
+ },
+ "Error": {
+ "Message": "Template format error: JSON not well-formed. (line 4, column 4)",
+ "Code": "ValidationError",
+ "Type": "Sender"
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.CreateStack_1.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.CreateStack_1.json
new file mode 100644
index 000000000..64c8e1f23
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.CreateStack_1.json
@@ -0,0 +1,17 @@
+{
+ "status_code": 200,
+ "data": {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "ResponseMetadata": {
+ "RequestId": "c741ebcd-3a0e-11e9-b25f-d1217e6893bf",
+ "HTTPStatusCode": 200,
+ "HTTPHeaders": {
+ "x-amzn-requestid": "c741ebcd-3a0e-11e9-b25f-d1217e6893bf",
+ "content-type": "text/xml",
+ "content-length": "407",
+ "date": "Tue, 26 Feb 2019 21:37:55 GMT"
+ },
+ "RetryAttempts": 0
+ }
+ }
+}
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStackEvents_1.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStackEvents_1.json
new file mode 100644
index 000000000..7a6a49644
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStackEvents_1.json
@@ -0,0 +1,38 @@
+{
+ "status_code": 200,
+ "data": {
+ "StackEvents": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "EventId": "c74b1310-3a0e-11e9-9a48-067794494828",
+ "StackName": "ansible-test-on-create-failure-delete",
+ "LogicalResourceId": "ansible-test-on-create-failure-delete",
+ "PhysicalResourceId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "ResourceType": "AWS::CloudFormation::Stack",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 37,
+ "second": 55,
+ "microsecond": 909000
+ },
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceStatusReason": "User Initiated"
+ }
+ ],
+ "ResponseMetadata": {
+ "RequestId": "c7b0b337-3a0e-11e9-b25f-d1217e6893bf",
+ "HTTPStatusCode": 200,
+ "HTTPHeaders": {
+ "x-amzn-requestid": "c7b0b337-3a0e-11e9-b25f-d1217e6893bf",
+ "content-type": "text/xml",
+ "content-length": "1153",
+ "date": "Tue, 26 Feb 2019 21:37:56 GMT"
+ },
+ "RetryAttempts": 0
+ }
+ }
+}
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStackEvents_2.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStackEvents_2.json
new file mode 100644
index 000000000..6218ed8b8
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStackEvents_2.json
@@ -0,0 +1,101 @@
+{
+ "status_code": 200,
+ "data": {
+ "StackEvents": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "EventId": "ECRRepo-CREATE_FAILED-2019-02-26T21:38:01.107Z",
+ "StackName": "ansible-test-on-create-failure-delete",
+ "LogicalResourceId": "ECRRepo",
+ "PhysicalResourceId": "ansib-ecrre-8jlpw72yz5x8",
+ "ResourceType": "AWS::ECR::Repository",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 38,
+ "second": 1,
+ "microsecond": 107000
+ },
+ "ResourceStatus": "CREATE_FAILED",
+ "ResourceStatusReason": "Invalid parameter at 'PolicyText' failed to satisfy constraint: 'Invalid repository policy provided' (Service: AmazonECR; Status Code: 400; Error Code: InvalidParameterException; Request ID: ca5769ae-3a0e-11e9-a183-3f277586a4cb)",
+ "ResourceProperties": "{\"RepositoryPolicyText\":{\"Version\":\"3000-10-17\",\"Statement\":[{\"Action\":[\"ecr:*\"],\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::123456789012:root\"}}]}}"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "EventId": "ECRRepo-CREATE_IN_PROGRESS-2019-02-26T21:38:00.657Z",
+ "StackName": "ansible-test-on-create-failure-delete",
+ "LogicalResourceId": "ECRRepo",
+ "PhysicalResourceId": "ansib-ecrre-8jlpw72yz5x8",
+ "ResourceType": "AWS::ECR::Repository",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 38,
+ "second": 0,
+ "microsecond": 657000
+ },
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceStatusReason": "Resource creation Initiated",
+ "ResourceProperties": "{\"RepositoryPolicyText\":{\"Version\":\"3000-10-17\",\"Statement\":[{\"Action\":[\"ecr:*\"],\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::123456789012:root\"}}]}}"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "EventId": "ECRRepo-CREATE_IN_PROGRESS-2019-02-26T21:38:00.221Z",
+ "StackName": "ansible-test-on-create-failure-delete",
+ "LogicalResourceId": "ECRRepo",
+ "PhysicalResourceId": "",
+ "ResourceType": "AWS::ECR::Repository",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 38,
+ "second": 0,
+ "microsecond": 221000
+ },
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceProperties": "{\"RepositoryPolicyText\":{\"Version\":\"3000-10-17\",\"Statement\":[{\"Action\":[\"ecr:*\"],\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::123456789012:root\"}}]}}"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "EventId": "c74b1310-3a0e-11e9-9a48-067794494828",
+ "StackName": "ansible-test-on-create-failure-delete",
+ "LogicalResourceId": "ansible-test-on-create-failure-delete",
+ "PhysicalResourceId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "ResourceType": "AWS::CloudFormation::Stack",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 37,
+ "second": 55,
+ "microsecond": 909000
+ },
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceStatusReason": "User Initiated"
+ }
+ ],
+ "ResponseMetadata": {
+ "RequestId": "caf667e9-3a0e-11e9-b25f-d1217e6893bf",
+ "HTTPStatusCode": 200,
+ "HTTPHeaders": {
+ "x-amzn-requestid": "caf667e9-3a0e-11e9-b25f-d1217e6893bf",
+ "content-type": "text/xml",
+ "content-length": "4312",
+ "vary": "Accept-Encoding",
+ "date": "Tue, 26 Feb 2019 21:38:01 GMT"
+ },
+ "RetryAttempts": 0
+ }
+ }
+}
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStackEvents_3.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStackEvents_3.json
new file mode 100644
index 000000000..cde6beb8e
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStackEvents_3.json
@@ -0,0 +1,121 @@
+{
+ "status_code": 200,
+ "data": {
+ "StackEvents": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "EventId": "cafc8250-3a0e-11e9-86c5-02035744c0fa",
+ "StackName": "ansible-test-on-create-failure-delete",
+ "LogicalResourceId": "ansible-test-on-create-failure-delete",
+ "PhysicalResourceId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "ResourceType": "AWS::CloudFormation::Stack",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 38,
+ "second": 2,
+ "microsecond": 76000
+ },
+ "ResourceStatus": "DELETE_IN_PROGRESS",
+ "ResourceStatusReason": "The following resource(s) failed to create: [ECRRepo]. . Delete requested by user."
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "EventId": "ECRRepo-CREATE_FAILED-2019-02-26T21:38:01.107Z",
+ "StackName": "ansible-test-on-create-failure-delete",
+ "LogicalResourceId": "ECRRepo",
+ "PhysicalResourceId": "ansib-ecrre-8jlpw72yz5x8",
+ "ResourceType": "AWS::ECR::Repository",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 38,
+ "second": 1,
+ "microsecond": 107000
+ },
+ "ResourceStatus": "CREATE_FAILED",
+ "ResourceStatusReason": "Invalid parameter at 'PolicyText' failed to satisfy constraint: 'Invalid repository policy provided' (Service: AmazonECR; Status Code: 400; Error Code: InvalidParameterException; Request ID: ca5769ae-3a0e-11e9-a183-3f277586a4cb)",
+ "ResourceProperties": "{\"RepositoryPolicyText\":{\"Version\":\"3000-10-17\",\"Statement\":[{\"Action\":[\"ecr:*\"],\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::123456789012:root\"}}]}}"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "EventId": "ECRRepo-CREATE_IN_PROGRESS-2019-02-26T21:38:00.657Z",
+ "StackName": "ansible-test-on-create-failure-delete",
+ "LogicalResourceId": "ECRRepo",
+ "PhysicalResourceId": "ansib-ecrre-8jlpw72yz5x8",
+ "ResourceType": "AWS::ECR::Repository",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 38,
+ "second": 0,
+ "microsecond": 657000
+ },
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceStatusReason": "Resource creation Initiated",
+ "ResourceProperties": "{\"RepositoryPolicyText\":{\"Version\":\"3000-10-17\",\"Statement\":[{\"Action\":[\"ecr:*\"],\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::123456789012:root\"}}]}}"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "EventId": "ECRRepo-CREATE_IN_PROGRESS-2019-02-26T21:38:00.221Z",
+ "StackName": "ansible-test-on-create-failure-delete",
+ "LogicalResourceId": "ECRRepo",
+ "PhysicalResourceId": "",
+ "ResourceType": "AWS::ECR::Repository",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 38,
+ "second": 0,
+ "microsecond": 221000
+ },
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceProperties": "{\"RepositoryPolicyText\":{\"Version\":\"3000-10-17\",\"Statement\":[{\"Action\":[\"ecr:*\"],\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::123456789012:root\"}}]}}"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "EventId": "c74b1310-3a0e-11e9-9a48-067794494828",
+ "StackName": "ansible-test-on-create-failure-delete",
+ "LogicalResourceId": "ansible-test-on-create-failure-delete",
+ "PhysicalResourceId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "ResourceType": "AWS::CloudFormation::Stack",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 37,
+ "second": 55,
+ "microsecond": 909000
+ },
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceStatusReason": "User Initiated"
+ }
+ ],
+ "ResponseMetadata": {
+ "RequestId": "ce498af1-3a0e-11e9-b25f-d1217e6893bf",
+ "HTTPStatusCode": 200,
+ "HTTPHeaders": {
+ "x-amzn-requestid": "ce498af1-3a0e-11e9-b25f-d1217e6893bf",
+ "content-type": "text/xml",
+ "content-length": "5207",
+ "vary": "Accept-Encoding",
+ "date": "Tue, 26 Feb 2019 21:38:06 GMT"
+ },
+ "RetryAttempts": 0
+ }
+ }
+}
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStackEvents_4.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStackEvents_4.json
new file mode 100644
index 000000000..4f35d6ddc
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStackEvents_4.json
@@ -0,0 +1,180 @@
+{
+ "status_code": 200,
+ "data": {
+ "StackEvents": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "EventId": "d19c8600-3a0e-11e9-a4ba-0a3524ef8042",
+ "StackName": "ansible-test-on-create-failure-delete",
+ "LogicalResourceId": "ansible-test-on-create-failure-delete",
+ "PhysicalResourceId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "ResourceType": "AWS::CloudFormation::Stack",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 38,
+ "second": 13,
+ "microsecond": 177000
+ },
+ "ResourceStatus": "DELETE_COMPLETE"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "EventId": "ECRRepo-DELETE_COMPLETE-2019-02-26T21:38:12.486Z",
+ "StackName": "ansible-test-on-create-failure-delete",
+ "LogicalResourceId": "ECRRepo",
+ "PhysicalResourceId": "ansib-ecrre-8jlpw72yz5x8",
+ "ResourceType": "AWS::ECR::Repository",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 38,
+ "second": 12,
+ "microsecond": 486000
+ },
+ "ResourceStatus": "DELETE_COMPLETE",
+ "ResourceProperties": "{\"RepositoryPolicyText\":{\"Version\":\"3000-10-17\",\"Statement\":[{\"Action\":[\"ecr:*\"],\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::123456789012:root\"}}]}}"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "EventId": "ECRRepo-DELETE_IN_PROGRESS-2019-02-26T21:38:12.139Z",
+ "StackName": "ansible-test-on-create-failure-delete",
+ "LogicalResourceId": "ECRRepo",
+ "PhysicalResourceId": "ansib-ecrre-8jlpw72yz5x8",
+ "ResourceType": "AWS::ECR::Repository",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 38,
+ "second": 12,
+ "microsecond": 139000
+ },
+ "ResourceStatus": "DELETE_IN_PROGRESS",
+ "ResourceProperties": "{\"RepositoryPolicyText\":{\"Version\":\"3000-10-17\",\"Statement\":[{\"Action\":[\"ecr:*\"],\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::123456789012:root\"}}]}}"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "EventId": "cafc8250-3a0e-11e9-86c5-02035744c0fa",
+ "StackName": "ansible-test-on-create-failure-delete",
+ "LogicalResourceId": "ansible-test-on-create-failure-delete",
+ "PhysicalResourceId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "ResourceType": "AWS::CloudFormation::Stack",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 38,
+ "second": 2,
+ "microsecond": 76000
+ },
+ "ResourceStatus": "DELETE_IN_PROGRESS",
+ "ResourceStatusReason": "The following resource(s) failed to create: [ECRRepo]. . Delete requested by user."
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "EventId": "ECRRepo-CREATE_FAILED-2019-02-26T21:38:01.107Z",
+ "StackName": "ansible-test-on-create-failure-delete",
+ "LogicalResourceId": "ECRRepo",
+ "PhysicalResourceId": "ansib-ecrre-8jlpw72yz5x8",
+ "ResourceType": "AWS::ECR::Repository",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 38,
+ "second": 1,
+ "microsecond": 107000
+ },
+ "ResourceStatus": "CREATE_FAILED",
+ "ResourceStatusReason": "Invalid parameter at 'PolicyText' failed to satisfy constraint: 'Invalid repository policy provided' (Service: AmazonECR; Status Code: 400; Error Code: InvalidParameterException; Request ID: ca5769ae-3a0e-11e9-a183-3f277586a4cb)",
+ "ResourceProperties": "{\"RepositoryPolicyText\":{\"Version\":\"3000-10-17\",\"Statement\":[{\"Action\":[\"ecr:*\"],\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::123456789012:root\"}}]}}"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "EventId": "ECRRepo-CREATE_IN_PROGRESS-2019-02-26T21:38:00.657Z",
+ "StackName": "ansible-test-on-create-failure-delete",
+ "LogicalResourceId": "ECRRepo",
+ "PhysicalResourceId": "ansib-ecrre-8jlpw72yz5x8",
+ "ResourceType": "AWS::ECR::Repository",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 38,
+ "second": 0,
+ "microsecond": 657000
+ },
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceStatusReason": "Resource creation Initiated",
+ "ResourceProperties": "{\"RepositoryPolicyText\":{\"Version\":\"3000-10-17\",\"Statement\":[{\"Action\":[\"ecr:*\"],\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::123456789012:root\"}}]}}"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "EventId": "ECRRepo-CREATE_IN_PROGRESS-2019-02-26T21:38:00.221Z",
+ "StackName": "ansible-test-on-create-failure-delete",
+ "LogicalResourceId": "ECRRepo",
+ "PhysicalResourceId": "",
+ "ResourceType": "AWS::ECR::Repository",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 38,
+ "second": 0,
+ "microsecond": 221000
+ },
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceProperties": "{\"RepositoryPolicyText\":{\"Version\":\"3000-10-17\",\"Statement\":[{\"Action\":[\"ecr:*\"],\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::123456789012:root\"}}]}}"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "EventId": "c74b1310-3a0e-11e9-9a48-067794494828",
+ "StackName": "ansible-test-on-create-failure-delete",
+ "LogicalResourceId": "ansible-test-on-create-failure-delete",
+ "PhysicalResourceId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "ResourceType": "AWS::CloudFormation::Stack",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 37,
+ "second": 55,
+ "microsecond": 909000
+ },
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceStatusReason": "User Initiated"
+ }
+ ],
+ "ResponseMetadata": {
+ "RequestId": "d19fbb1b-3a0e-11e9-b25f-d1217e6893bf",
+ "HTTPStatusCode": 200,
+ "HTTPHeaders": {
+ "x-amzn-requestid": "d19fbb1b-3a0e-11e9-b25f-d1217e6893bf",
+ "content-type": "text/xml",
+ "content-length": "7857",
+ "vary": "Accept-Encoding",
+ "date": "Tue, 26 Feb 2019 21:38:12 GMT"
+ },
+ "RetryAttempts": 0
+ }
+ }
+}
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStackEvents_5.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStackEvents_5.json
new file mode 100644
index 000000000..68a743f89
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStackEvents_5.json
@@ -0,0 +1,180 @@
+{
+ "status_code": 200,
+ "data": {
+ "StackEvents": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "EventId": "d19c8600-3a0e-11e9-a4ba-0a3524ef8042",
+ "StackName": "ansible-test-on-create-failure-delete",
+ "LogicalResourceId": "ansible-test-on-create-failure-delete",
+ "PhysicalResourceId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "ResourceType": "AWS::CloudFormation::Stack",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 38,
+ "second": 13,
+ "microsecond": 177000
+ },
+ "ResourceStatus": "DELETE_COMPLETE"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "EventId": "ECRRepo-DELETE_COMPLETE-2019-02-26T21:38:12.486Z",
+ "StackName": "ansible-test-on-create-failure-delete",
+ "LogicalResourceId": "ECRRepo",
+ "PhysicalResourceId": "ansib-ecrre-8jlpw72yz5x8",
+ "ResourceType": "AWS::ECR::Repository",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 38,
+ "second": 12,
+ "microsecond": 486000
+ },
+ "ResourceStatus": "DELETE_COMPLETE",
+ "ResourceProperties": "{\"RepositoryPolicyText\":{\"Version\":\"3000-10-17\",\"Statement\":[{\"Action\":[\"ecr:*\"],\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::123456789012:root\"}}]}}"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "EventId": "ECRRepo-DELETE_IN_PROGRESS-2019-02-26T21:38:12.139Z",
+ "StackName": "ansible-test-on-create-failure-delete",
+ "LogicalResourceId": "ECRRepo",
+ "PhysicalResourceId": "ansib-ecrre-8jlpw72yz5x8",
+ "ResourceType": "AWS::ECR::Repository",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 38,
+ "second": 12,
+ "microsecond": 139000
+ },
+ "ResourceStatus": "DELETE_IN_PROGRESS",
+ "ResourceProperties": "{\"RepositoryPolicyText\":{\"Version\":\"3000-10-17\",\"Statement\":[{\"Action\":[\"ecr:*\"],\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::123456789012:root\"}}]}}"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "EventId": "cafc8250-3a0e-11e9-86c5-02035744c0fa",
+ "StackName": "ansible-test-on-create-failure-delete",
+ "LogicalResourceId": "ansible-test-on-create-failure-delete",
+ "PhysicalResourceId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "ResourceType": "AWS::CloudFormation::Stack",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 38,
+ "second": 2,
+ "microsecond": 76000
+ },
+ "ResourceStatus": "DELETE_IN_PROGRESS",
+ "ResourceStatusReason": "The following resource(s) failed to create: [ECRRepo]. . Delete requested by user."
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "EventId": "ECRRepo-CREATE_FAILED-2019-02-26T21:38:01.107Z",
+ "StackName": "ansible-test-on-create-failure-delete",
+ "LogicalResourceId": "ECRRepo",
+ "PhysicalResourceId": "ansib-ecrre-8jlpw72yz5x8",
+ "ResourceType": "AWS::ECR::Repository",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 38,
+ "second": 1,
+ "microsecond": 107000
+ },
+ "ResourceStatus": "CREATE_FAILED",
+ "ResourceStatusReason": "Invalid parameter at 'PolicyText' failed to satisfy constraint: 'Invalid repository policy provided' (Service: AmazonECR; Status Code: 400; Error Code: InvalidParameterException; Request ID: ca5769ae-3a0e-11e9-a183-3f277586a4cb)",
+ "ResourceProperties": "{\"RepositoryPolicyText\":{\"Version\":\"3000-10-17\",\"Statement\":[{\"Action\":[\"ecr:*\"],\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::123456789012:root\"}}]}}"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "EventId": "ECRRepo-CREATE_IN_PROGRESS-2019-02-26T21:38:00.657Z",
+ "StackName": "ansible-test-on-create-failure-delete",
+ "LogicalResourceId": "ECRRepo",
+ "PhysicalResourceId": "ansib-ecrre-8jlpw72yz5x8",
+ "ResourceType": "AWS::ECR::Repository",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 38,
+ "second": 0,
+ "microsecond": 657000
+ },
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceStatusReason": "Resource creation Initiated",
+ "ResourceProperties": "{\"RepositoryPolicyText\":{\"Version\":\"3000-10-17\",\"Statement\":[{\"Action\":[\"ecr:*\"],\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::123456789012:root\"}}]}}"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "EventId": "ECRRepo-CREATE_IN_PROGRESS-2019-02-26T21:38:00.221Z",
+ "StackName": "ansible-test-on-create-failure-delete",
+ "LogicalResourceId": "ECRRepo",
+ "PhysicalResourceId": "",
+ "ResourceType": "AWS::ECR::Repository",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 38,
+ "second": 0,
+ "microsecond": 221000
+ },
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceProperties": "{\"RepositoryPolicyText\":{\"Version\":\"3000-10-17\",\"Statement\":[{\"Action\":[\"ecr:*\"],\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::123456789012:root\"}}]}}"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "EventId": "c74b1310-3a0e-11e9-9a48-067794494828",
+ "StackName": "ansible-test-on-create-failure-delete",
+ "LogicalResourceId": "ansible-test-on-create-failure-delete",
+ "PhysicalResourceId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "ResourceType": "AWS::CloudFormation::Stack",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 37,
+ "second": 55,
+ "microsecond": 909000
+ },
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceStatusReason": "User Initiated"
+ }
+ ],
+ "ResponseMetadata": {
+ "RequestId": "d4fbddab-3a0e-11e9-b25f-d1217e6893bf",
+ "HTTPStatusCode": 200,
+ "HTTPHeaders": {
+ "x-amzn-requestid": "d4fbddab-3a0e-11e9-b25f-d1217e6893bf",
+ "content-type": "text/xml",
+ "content-length": "7857",
+ "vary": "Accept-Encoding",
+ "date": "Tue, 26 Feb 2019 21:38:18 GMT"
+ },
+ "RetryAttempts": 0
+ }
+ }
+}
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStacks_1.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStacks_1.json
new file mode 100644
index 000000000..cf5f86acb
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStacks_1.json
@@ -0,0 +1,42 @@
+{
+ "status_code": 200,
+ "data": {
+ "Stacks": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "StackName": "ansible-test-on-create-failure-delete",
+ "CreationTime": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 37,
+ "second": 55,
+ "microsecond": 909000
+ },
+ "RollbackConfiguration": {},
+ "StackStatus": "CREATE_IN_PROGRESS",
+ "StackStatusReason": "User Initiated",
+ "DisableRollback": false,
+ "NotificationARNs": [],
+ "Tags": [],
+ "EnableTerminationProtection": false,
+ "DriftInformation": {
+ "StackDriftStatus": "NOT_CHECKED"
+ }
+ }
+ ],
+ "ResponseMetadata": {
+ "RequestId": "c77fb823-3a0e-11e9-b25f-d1217e6893bf",
+ "HTTPStatusCode": 200,
+ "HTTPHeaders": {
+ "x-amzn-requestid": "c77fb823-3a0e-11e9-b25f-d1217e6893bf",
+ "content-type": "text/xml",
+ "content-length": "1041",
+ "date": "Tue, 26 Feb 2019 21:37:56 GMT"
+ },
+ "RetryAttempts": 0
+ }
+ }
+}
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStacks_2.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStacks_2.json
new file mode 100644
index 000000000..71a9f54b6
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStacks_2.json
@@ -0,0 +1,41 @@
+{
+ "status_code": 200,
+ "data": {
+ "Stacks": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "StackName": "ansible-test-on-create-failure-delete",
+ "CreationTime": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 37,
+ "second": 55,
+ "microsecond": 909000
+ },
+ "RollbackConfiguration": {},
+ "StackStatus": "CREATE_IN_PROGRESS",
+ "DisableRollback": false,
+ "NotificationARNs": [],
+ "Tags": [],
+ "EnableTerminationProtection": false,
+ "DriftInformation": {
+ "StackDriftStatus": "NOT_CHECKED"
+ }
+ }
+ ],
+ "ResponseMetadata": {
+ "RequestId": "cad153b2-3a0e-11e9-b25f-d1217e6893bf",
+ "HTTPStatusCode": 200,
+ "HTTPHeaders": {
+ "x-amzn-requestid": "cad153b2-3a0e-11e9-b25f-d1217e6893bf",
+ "content-type": "text/xml",
+ "content-length": "979",
+ "date": "Tue, 26 Feb 2019 21:38:01 GMT"
+ },
+ "RetryAttempts": 0
+ }
+ }
+}
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStacks_3.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStacks_3.json
new file mode 100644
index 000000000..c2028183b
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStacks_3.json
@@ -0,0 +1,52 @@
+{
+ "status_code": 200,
+ "data": {
+ "Stacks": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "StackName": "ansible-test-on-create-failure-delete",
+ "CreationTime": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 37,
+ "second": 55,
+ "microsecond": 909000
+ },
+ "DeletionTime": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 38,
+ "second": 2,
+ "microsecond": 76000
+ },
+ "RollbackConfiguration": {},
+ "StackStatus": "DELETE_IN_PROGRESS",
+ "StackStatusReason": "The following resource(s) failed to create: [ECRRepo]. . Delete requested by user.",
+ "DisableRollback": false,
+ "NotificationARNs": [],
+ "Tags": [],
+ "EnableTerminationProtection": false,
+ "DriftInformation": {
+ "StackDriftStatus": "NOT_CHECKED"
+ }
+ }
+ ],
+ "ResponseMetadata": {
+ "RequestId": "ce24289a-3a0e-11e9-b25f-d1217e6893bf",
+ "HTTPStatusCode": 200,
+ "HTTPHeaders": {
+ "x-amzn-requestid": "ce24289a-3a0e-11e9-b25f-d1217e6893bf",
+ "content-type": "text/xml",
+ "content-length": "1171",
+ "date": "Tue, 26 Feb 2019 21:38:06 GMT"
+ },
+ "RetryAttempts": 0
+ }
+ }
+}
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStacks_4.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStacks_4.json
new file mode 100644
index 000000000..89f835531
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStacks_4.json
@@ -0,0 +1,51 @@
+{
+ "status_code": 200,
+ "data": {
+ "Stacks": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "StackName": "ansible-test-on-create-failure-delete",
+ "CreationTime": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 37,
+ "second": 55,
+ "microsecond": 909000
+ },
+ "DeletionTime": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 38,
+ "second": 2,
+ "microsecond": 76000
+ },
+ "RollbackConfiguration": {},
+ "StackStatus": "DELETE_IN_PROGRESS",
+ "DisableRollback": false,
+ "NotificationARNs": [],
+ "Tags": [],
+ "EnableTerminationProtection": false,
+ "DriftInformation": {
+ "StackDriftStatus": "NOT_CHECKED"
+ }
+ }
+ ],
+ "ResponseMetadata": {
+ "RequestId": "d16c27f2-3a0e-11e9-b25f-d1217e6893bf",
+ "HTTPStatusCode": 200,
+ "HTTPHeaders": {
+ "x-amzn-requestid": "d16c27f2-3a0e-11e9-b25f-d1217e6893bf",
+ "content-type": "text/xml",
+ "content-length": "1041",
+ "date": "Tue, 26 Feb 2019 21:38:12 GMT"
+ },
+ "RetryAttempts": 0
+ }
+ }
+}
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStacks_5.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStacks_5.json
new file mode 100644
index 000000000..739c82937
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_delete/cloudformation.DescribeStacks_5.json
@@ -0,0 +1,50 @@
+{
+ "status_code": 200,
+ "data": {
+ "Stacks": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-delete/c74a4fc0-3a0e-11e9-9a48-067794494828",
+ "StackName": "ansible-test-on-create-failure-delete",
+ "CreationTime": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 37,
+ "second": 55,
+ "microsecond": 909000
+ },
+ "DeletionTime": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 38,
+ "second": 2,
+ "microsecond": 76000
+ },
+ "RollbackConfiguration": {},
+ "StackStatus": "DELETE_COMPLETE",
+ "DisableRollback": false,
+ "NotificationARNs": [],
+ "Tags": [],
+ "DriftInformation": {
+ "StackDriftStatus": "NOT_CHECKED"
+ }
+ }
+ ],
+ "ResponseMetadata": {
+ "RequestId": "d4c90dd6-3a0e-11e9-b25f-d1217e6893bf",
+ "HTTPStatusCode": 200,
+ "HTTPHeaders": {
+ "x-amzn-requestid": "d4c90dd6-3a0e-11e9-b25f-d1217e6893bf",
+ "content-type": "text/xml",
+ "content-length": "965",
+ "date": "Tue, 26 Feb 2019 21:38:18 GMT"
+ },
+ "RetryAttempts": 0
+ }
+ }
+}
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_do_nothing/cloudformation.CreateStack_1.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_do_nothing/cloudformation.CreateStack_1.json
new file mode 100644
index 000000000..86f1945fd
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_do_nothing/cloudformation.CreateStack_1.json
@@ -0,0 +1,17 @@
+{
+ "status_code": 200,
+ "data": {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-do-nothing/a39dd0a0-3a0f-11e9-96ca-02f46dd00950",
+ "ResponseMetadata": {
+ "RequestId": "a396a58a-3a0f-11e9-b7db-3fe3824c73cb",
+ "HTTPStatusCode": 200,
+ "HTTPHeaders": {
+ "x-amzn-requestid": "a396a58a-3a0f-11e9-b7db-3fe3824c73cb",
+ "content-type": "text/xml",
+ "content-length": "411",
+ "date": "Tue, 26 Feb 2019 21:44:05 GMT"
+ },
+ "RetryAttempts": 0
+ }
+ }
+}
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_do_nothing/cloudformation.DeleteStack_1.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_do_nothing/cloudformation.DeleteStack_1.json
new file mode 100644
index 000000000..1a3a67c64
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_do_nothing/cloudformation.DeleteStack_1.json
@@ -0,0 +1,16 @@
+{
+ "status_code": 200,
+ "data": {
+ "ResponseMetadata": {
+ "RequestId": "a78f0832-3a0f-11e9-b7db-3fe3824c73cb",
+ "HTTPStatusCode": 200,
+ "HTTPHeaders": {
+ "x-amzn-requestid": "a78f0832-3a0f-11e9-b7db-3fe3824c73cb",
+ "content-type": "text/xml",
+ "content-length": "212",
+ "date": "Tue, 26 Feb 2019 21:44:11 GMT"
+ },
+ "RetryAttempts": 0
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_do_nothing/cloudformation.DescribeStackEvents_1.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_do_nothing/cloudformation.DescribeStackEvents_1.json
new file mode 100644
index 000000000..58d7a89e4
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_do_nothing/cloudformation.DescribeStackEvents_1.json
@@ -0,0 +1,38 @@
+{
+ "status_code": 200,
+ "data": {
+ "StackEvents": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-do-nothing/a39dd0a0-3a0f-11e9-96ca-02f46dd00950",
+ "EventId": "a39e6ce0-3a0f-11e9-96ca-02f46dd00950",
+ "StackName": "ansible-test-on-create-failure-do-nothing",
+ "LogicalResourceId": "ansible-test-on-create-failure-do-nothing",
+ "PhysicalResourceId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-do-nothing/a39dd0a0-3a0f-11e9-96ca-02f46dd00950",
+ "ResourceType": "AWS::CloudFormation::Stack",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 44,
+ "second": 5,
+ "microsecond": 553000
+ },
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceStatusReason": "User Initiated"
+ }
+ ],
+ "ResponseMetadata": {
+ "RequestId": "a406cc84-3a0f-11e9-b7db-3fe3824c73cb",
+ "HTTPStatusCode": 200,
+ "HTTPHeaders": {
+ "x-amzn-requestid": "a406cc84-3a0f-11e9-b7db-3fe3824c73cb",
+ "content-type": "text/xml",
+ "content-length": "1169",
+ "date": "Tue, 26 Feb 2019 21:44:06 GMT"
+ },
+ "RetryAttempts": 0
+ }
+ }
+}
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_do_nothing/cloudformation.DescribeStackEvents_2.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_do_nothing/cloudformation.DescribeStackEvents_2.json
new file mode 100644
index 000000000..0a7e32e46
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_do_nothing/cloudformation.DescribeStackEvents_2.json
@@ -0,0 +1,121 @@
+{
+ "status_code": 200,
+ "data": {
+ "StackEvents": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-do-nothing/a39dd0a0-3a0f-11e9-96ca-02f46dd00950",
+ "EventId": "a6c32c80-3a0f-11e9-ac5e-06deb474fa52",
+ "StackName": "ansible-test-on-create-failure-do-nothing",
+ "LogicalResourceId": "ansible-test-on-create-failure-do-nothing",
+ "PhysicalResourceId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-do-nothing/a39dd0a0-3a0f-11e9-96ca-02f46dd00950",
+ "ResourceType": "AWS::CloudFormation::Stack",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 44,
+ "second": 10,
+ "microsecond": 804000
+ },
+ "ResourceStatus": "CREATE_FAILED",
+ "ResourceStatusReason": "The following resource(s) failed to create: [ECRRepo]. "
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-do-nothing/a39dd0a0-3a0f-11e9-96ca-02f46dd00950",
+ "EventId": "ECRRepo-CREATE_FAILED-2019-02-26T21:44:09.905Z",
+ "StackName": "ansible-test-on-create-failure-do-nothing",
+ "LogicalResourceId": "ECRRepo",
+ "PhysicalResourceId": "ansib-ecrre-a8g0mh5il4t5",
+ "ResourceType": "AWS::ECR::Repository",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 44,
+ "second": 9,
+ "microsecond": 905000
+ },
+ "ResourceStatus": "CREATE_FAILED",
+ "ResourceStatusReason": "Invalid parameter at 'PolicyText' failed to satisfy constraint: 'Invalid repository policy provided' (Service: AmazonECR; Status Code: 400; Error Code: InvalidParameterException; Request ID: a62a6f71-3a0f-11e9-9164-457e0a3a5e1b)",
+ "ResourceProperties": "{\"RepositoryPolicyText\":{\"Version\":\"3000-10-17\",\"Statement\":[{\"Action\":[\"ecr:*\"],\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::123456789012:root\"}}]}}"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-do-nothing/a39dd0a0-3a0f-11e9-96ca-02f46dd00950",
+ "EventId": "ECRRepo-CREATE_IN_PROGRESS-2019-02-26T21:44:09.497Z",
+ "StackName": "ansible-test-on-create-failure-do-nothing",
+ "LogicalResourceId": "ECRRepo",
+ "PhysicalResourceId": "ansib-ecrre-a8g0mh5il4t5",
+ "ResourceType": "AWS::ECR::Repository",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 44,
+ "second": 9,
+ "microsecond": 497000
+ },
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceStatusReason": "Resource creation Initiated",
+ "ResourceProperties": "{\"RepositoryPolicyText\":{\"Version\":\"3000-10-17\",\"Statement\":[{\"Action\":[\"ecr:*\"],\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::123456789012:root\"}}]}}"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-do-nothing/a39dd0a0-3a0f-11e9-96ca-02f46dd00950",
+ "EventId": "ECRRepo-CREATE_IN_PROGRESS-2019-02-26T21:44:09.076Z",
+ "StackName": "ansible-test-on-create-failure-do-nothing",
+ "LogicalResourceId": "ECRRepo",
+ "PhysicalResourceId": "",
+ "ResourceType": "AWS::ECR::Repository",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 44,
+ "second": 9,
+ "microsecond": 76000
+ },
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceProperties": "{\"RepositoryPolicyText\":{\"Version\":\"3000-10-17\",\"Statement\":[{\"Action\":[\"ecr:*\"],\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::123456789012:root\"}}]}}"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-do-nothing/a39dd0a0-3a0f-11e9-96ca-02f46dd00950",
+ "EventId": "a39e6ce0-3a0f-11e9-96ca-02f46dd00950",
+ "StackName": "ansible-test-on-create-failure-do-nothing",
+ "LogicalResourceId": "ansible-test-on-create-failure-do-nothing",
+ "PhysicalResourceId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-do-nothing/a39dd0a0-3a0f-11e9-96ca-02f46dd00950",
+ "ResourceType": "AWS::CloudFormation::Stack",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 44,
+ "second": 5,
+ "microsecond": 553000
+ },
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceStatusReason": "User Initiated"
+ }
+ ],
+ "ResponseMetadata": {
+ "RequestId": "a75fbad0-3a0f-11e9-b7db-3fe3824c73cb",
+ "HTTPStatusCode": 200,
+ "HTTPHeaders": {
+ "x-amzn-requestid": "a75fbad0-3a0f-11e9-b7db-3fe3824c73cb",
+ "content-type": "text/xml",
+ "content-length": "5231",
+ "vary": "Accept-Encoding",
+ "date": "Tue, 26 Feb 2019 21:44:11 GMT"
+ },
+ "RetryAttempts": 0
+ }
+ }
+}
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_do_nothing/cloudformation.DescribeStacks_1.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_do_nothing/cloudformation.DescribeStacks_1.json
new file mode 100644
index 000000000..532143313
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_do_nothing/cloudformation.DescribeStacks_1.json
@@ -0,0 +1,42 @@
+{
+ "status_code": 200,
+ "data": {
+ "Stacks": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-do-nothing/a39dd0a0-3a0f-11e9-96ca-02f46dd00950",
+ "StackName": "ansible-test-on-create-failure-do-nothing",
+ "CreationTime": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 44,
+ "second": 5,
+ "microsecond": 553000
+ },
+ "RollbackConfiguration": {},
+ "StackStatus": "CREATE_IN_PROGRESS",
+ "StackStatusReason": "User Initiated",
+ "DisableRollback": true,
+ "NotificationARNs": [],
+ "Tags": [],
+ "EnableTerminationProtection": false,
+ "DriftInformation": {
+ "StackDriftStatus": "NOT_CHECKED"
+ }
+ }
+ ],
+ "ResponseMetadata": {
+ "RequestId": "a3d44acf-3a0f-11e9-b7db-3fe3824c73cb",
+ "HTTPStatusCode": 200,
+ "HTTPHeaders": {
+ "x-amzn-requestid": "a3d44acf-3a0f-11e9-b7db-3fe3824c73cb",
+ "content-type": "text/xml",
+ "content-length": "1048",
+ "date": "Tue, 26 Feb 2019 21:44:05 GMT"
+ },
+ "RetryAttempts": 0
+ }
+ }
+}
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_do_nothing/cloudformation.DescribeStacks_2.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_do_nothing/cloudformation.DescribeStacks_2.json
new file mode 100644
index 000000000..df17f5a73
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_do_nothing/cloudformation.DescribeStacks_2.json
@@ -0,0 +1,42 @@
+{
+ "status_code": 200,
+ "data": {
+ "Stacks": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-do-nothing/a39dd0a0-3a0f-11e9-96ca-02f46dd00950",
+ "StackName": "ansible-test-on-create-failure-do-nothing",
+ "CreationTime": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 44,
+ "second": 5,
+ "microsecond": 553000
+ },
+ "RollbackConfiguration": {},
+ "StackStatus": "CREATE_FAILED",
+ "StackStatusReason": "The following resource(s) failed to create: [ECRRepo]. ",
+ "DisableRollback": true,
+ "NotificationARNs": [],
+ "Tags": [],
+ "EnableTerminationProtection": false,
+ "DriftInformation": {
+ "StackDriftStatus": "NOT_CHECKED"
+ }
+ }
+ ],
+ "ResponseMetadata": {
+ "RequestId": "a7301f4a-3a0f-11e9-b7db-3fe3824c73cb",
+ "HTTPStatusCode": 200,
+ "HTTPHeaders": {
+ "x-amzn-requestid": "a7301f4a-3a0f-11e9-b7db-3fe3824c73cb",
+ "content-type": "text/xml",
+ "content-length": "1084",
+ "date": "Tue, 26 Feb 2019 21:44:11 GMT"
+ },
+ "RetryAttempts": 0
+ }
+ }
+}
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_rollback/cloudformation.CreateStack_1.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_rollback/cloudformation.CreateStack_1.json
new file mode 100644
index 000000000..f71422b92
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_rollback/cloudformation.CreateStack_1.json
@@ -0,0 +1,17 @@
+{
+ "status_code": 200,
+ "data": {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-rollback/914046e0-3a0f-11e9-94bf-0a9edf17d014",
+ "ResponseMetadata": {
+ "RequestId": "9139de54-3a0f-11e9-b938-97983b40cabe",
+ "HTTPStatusCode": 200,
+ "HTTPHeaders": {
+ "x-amzn-requestid": "9139de54-3a0f-11e9-b938-97983b40cabe",
+ "content-type": "text/xml",
+ "content-length": "409",
+ "date": "Tue, 26 Feb 2019 21:43:34 GMT"
+ },
+ "RetryAttempts": 0
+ }
+ }
+}
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_rollback/cloudformation.DeleteStack_1.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_rollback/cloudformation.DeleteStack_1.json
new file mode 100644
index 000000000..111dc90d8
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_rollback/cloudformation.DeleteStack_1.json
@@ -0,0 +1,16 @@
+{
+ "status_code": 200,
+ "data": {
+ "ResponseMetadata": {
+ "RequestId": "988b3097-3a0f-11e9-b938-97983b40cabe",
+ "HTTPStatusCode": 200,
+ "HTTPHeaders": {
+ "x-amzn-requestid": "988b3097-3a0f-11e9-b938-97983b40cabe",
+ "content-type": "text/xml",
+ "content-length": "212",
+ "date": "Tue, 26 Feb 2019 21:43:46 GMT"
+ },
+ "RetryAttempts": 0
+ }
+ }
+} \ No newline at end of file
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_rollback/cloudformation.DescribeStackEvents_1.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_rollback/cloudformation.DescribeStackEvents_1.json
new file mode 100644
index 000000000..2bcac7f0e
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_rollback/cloudformation.DescribeStackEvents_1.json
@@ -0,0 +1,38 @@
+{
+ "status_code": 200,
+ "data": {
+ "StackEvents": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-rollback/914046e0-3a0f-11e9-94bf-0a9edf17d014",
+ "EventId": "9140bc10-3a0f-11e9-94bf-0a9edf17d014",
+ "StackName": "ansible-test-on-create-failure-rollback",
+ "LogicalResourceId": "ansible-test-on-create-failure-rollback",
+ "PhysicalResourceId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-rollback/914046e0-3a0f-11e9-94bf-0a9edf17d014",
+ "ResourceType": "AWS::CloudFormation::Stack",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 43,
+ "second": 34,
+ "microsecond": 740000
+ },
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceStatusReason": "User Initiated"
+ }
+ ],
+ "ResponseMetadata": {
+ "RequestId": "9199b1a7-3a0f-11e9-b938-97983b40cabe",
+ "HTTPStatusCode": 200,
+ "HTTPHeaders": {
+ "x-amzn-requestid": "9199b1a7-3a0f-11e9-b938-97983b40cabe",
+ "content-type": "text/xml",
+ "content-length": "1161",
+ "date": "Tue, 26 Feb 2019 21:43:35 GMT"
+ },
+ "RetryAttempts": 0
+ }
+ }
+}
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_rollback/cloudformation.DescribeStackEvents_2.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_rollback/cloudformation.DescribeStackEvents_2.json
new file mode 100644
index 000000000..3992fd397
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_rollback/cloudformation.DescribeStackEvents_2.json
@@ -0,0 +1,121 @@
+{
+ "status_code": 200,
+ "data": {
+ "StackEvents": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-rollback/914046e0-3a0f-11e9-94bf-0a9edf17d014",
+ "EventId": "945b90a0-3a0f-11e9-adaf-0211d8bec7e2",
+ "StackName": "ansible-test-on-create-failure-rollback",
+ "LogicalResourceId": "ansible-test-on-create-failure-rollback",
+ "PhysicalResourceId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-rollback/914046e0-3a0f-11e9-94bf-0a9edf17d014",
+ "ResourceType": "AWS::CloudFormation::Stack",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 43,
+ "second": 39,
+ "microsecond": 920000
+ },
+ "ResourceStatus": "ROLLBACK_IN_PROGRESS",
+ "ResourceStatusReason": "The following resource(s) failed to create: [ECRRepo]. . Rollback requested by user."
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-rollback/914046e0-3a0f-11e9-94bf-0a9edf17d014",
+ "EventId": "ECRRepo-CREATE_FAILED-2019-02-26T21:43:39.210Z",
+ "StackName": "ansible-test-on-create-failure-rollback",
+ "LogicalResourceId": "ECRRepo",
+ "PhysicalResourceId": "ansib-ecrre-1lsnxu2zpb20l",
+ "ResourceType": "AWS::ECR::Repository",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 43,
+ "second": 39,
+ "microsecond": 210000
+ },
+ "ResourceStatus": "CREATE_FAILED",
+ "ResourceStatusReason": "Invalid parameter at 'PolicyText' failed to satisfy constraint: 'Invalid repository policy provided' (Service: AmazonECR; Status Code: 400; Error Code: InvalidParameterException; Request ID: 93e0bb60-3a0f-11e9-a53c-7162bb423e4d)",
+ "ResourceProperties": "{\"RepositoryPolicyText\":{\"Version\":\"3000-10-17\",\"Statement\":[{\"Action\":[\"ecr:*\"],\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::123456789012:root\"}}]}}"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-rollback/914046e0-3a0f-11e9-94bf-0a9edf17d014",
+ "EventId": "ECRRepo-CREATE_IN_PROGRESS-2019-02-26T21:43:38.793Z",
+ "StackName": "ansible-test-on-create-failure-rollback",
+ "LogicalResourceId": "ECRRepo",
+ "PhysicalResourceId": "ansib-ecrre-1lsnxu2zpb20l",
+ "ResourceType": "AWS::ECR::Repository",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 43,
+ "second": 38,
+ "microsecond": 793000
+ },
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceStatusReason": "Resource creation Initiated",
+ "ResourceProperties": "{\"RepositoryPolicyText\":{\"Version\":\"3000-10-17\",\"Statement\":[{\"Action\":[\"ecr:*\"],\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::123456789012:root\"}}]}}"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-rollback/914046e0-3a0f-11e9-94bf-0a9edf17d014",
+ "EventId": "ECRRepo-CREATE_IN_PROGRESS-2019-02-26T21:43:38.266Z",
+ "StackName": "ansible-test-on-create-failure-rollback",
+ "LogicalResourceId": "ECRRepo",
+ "PhysicalResourceId": "",
+ "ResourceType": "AWS::ECR::Repository",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 43,
+ "second": 38,
+ "microsecond": 266000
+ },
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceProperties": "{\"RepositoryPolicyText\":{\"Version\":\"3000-10-17\",\"Statement\":[{\"Action\":[\"ecr:*\"],\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::123456789012:root\"}}]}}"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-rollback/914046e0-3a0f-11e9-94bf-0a9edf17d014",
+ "EventId": "9140bc10-3a0f-11e9-94bf-0a9edf17d014",
+ "StackName": "ansible-test-on-create-failure-rollback",
+ "LogicalResourceId": "ansible-test-on-create-failure-rollback",
+ "PhysicalResourceId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-rollback/914046e0-3a0f-11e9-94bf-0a9edf17d014",
+ "ResourceType": "AWS::CloudFormation::Stack",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 43,
+ "second": 34,
+ "microsecond": 740000
+ },
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceStatusReason": "User Initiated"
+ }
+ ],
+ "ResponseMetadata": {
+ "RequestId": "94e16307-3a0f-11e9-b938-97983b40cabe",
+ "HTTPStatusCode": 200,
+ "HTTPHeaders": {
+ "x-amzn-requestid": "94e16307-3a0f-11e9-b938-97983b40cabe",
+ "content-type": "text/xml",
+ "content-length": "5241",
+ "vary": "Accept-Encoding",
+ "date": "Tue, 26 Feb 2019 21:43:40 GMT"
+ },
+ "RetryAttempts": 0
+ }
+ }
+}
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_rollback/cloudformation.DescribeStackEvents_3.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_rollback/cloudformation.DescribeStackEvents_3.json
new file mode 100644
index 000000000..e272c734b
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_rollback/cloudformation.DescribeStackEvents_3.json
@@ -0,0 +1,180 @@
+{
+ "status_code": 200,
+ "data": {
+ "StackEvents": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-rollback/914046e0-3a0f-11e9-94bf-0a9edf17d014",
+ "EventId": "9743bc70-3a0f-11e9-b335-0ade61d04ee6",
+ "StackName": "ansible-test-on-create-failure-rollback",
+ "LogicalResourceId": "ansible-test-on-create-failure-rollback",
+ "PhysicalResourceId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-rollback/914046e0-3a0f-11e9-94bf-0a9edf17d014",
+ "ResourceType": "AWS::CloudFormation::Stack",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 43,
+ "second": 44,
+ "microsecond": 797000
+ },
+ "ResourceStatus": "ROLLBACK_COMPLETE"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-rollback/914046e0-3a0f-11e9-94bf-0a9edf17d014",
+ "EventId": "ECRRepo-DELETE_COMPLETE-2019-02-26T21:43:43.908Z",
+ "StackName": "ansible-test-on-create-failure-rollback",
+ "LogicalResourceId": "ECRRepo",
+ "PhysicalResourceId": "ansib-ecrre-1lsnxu2zpb20l",
+ "ResourceType": "AWS::ECR::Repository",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 43,
+ "second": 43,
+ "microsecond": 908000
+ },
+ "ResourceStatus": "DELETE_COMPLETE",
+ "ResourceProperties": "{\"RepositoryPolicyText\":{\"Version\":\"3000-10-17\",\"Statement\":[{\"Action\":[\"ecr:*\"],\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::123456789012:root\"}}]}}"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-rollback/914046e0-3a0f-11e9-94bf-0a9edf17d014",
+ "EventId": "ECRRepo-DELETE_IN_PROGRESS-2019-02-26T21:43:43.478Z",
+ "StackName": "ansible-test-on-create-failure-rollback",
+ "LogicalResourceId": "ECRRepo",
+ "PhysicalResourceId": "ansib-ecrre-1lsnxu2zpb20l",
+ "ResourceType": "AWS::ECR::Repository",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 43,
+ "second": 43,
+ "microsecond": 478000
+ },
+ "ResourceStatus": "DELETE_IN_PROGRESS",
+ "ResourceProperties": "{\"RepositoryPolicyText\":{\"Version\":\"3000-10-17\",\"Statement\":[{\"Action\":[\"ecr:*\"],\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::123456789012:root\"}}]}}"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-rollback/914046e0-3a0f-11e9-94bf-0a9edf17d014",
+ "EventId": "945b90a0-3a0f-11e9-adaf-0211d8bec7e2",
+ "StackName": "ansible-test-on-create-failure-rollback",
+ "LogicalResourceId": "ansible-test-on-create-failure-rollback",
+ "PhysicalResourceId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-rollback/914046e0-3a0f-11e9-94bf-0a9edf17d014",
+ "ResourceType": "AWS::CloudFormation::Stack",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 43,
+ "second": 39,
+ "microsecond": 920000
+ },
+ "ResourceStatus": "ROLLBACK_IN_PROGRESS",
+ "ResourceStatusReason": "The following resource(s) failed to create: [ECRRepo]. . Rollback requested by user."
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-rollback/914046e0-3a0f-11e9-94bf-0a9edf17d014",
+ "EventId": "ECRRepo-CREATE_FAILED-2019-02-26T21:43:39.210Z",
+ "StackName": "ansible-test-on-create-failure-rollback",
+ "LogicalResourceId": "ECRRepo",
+ "PhysicalResourceId": "ansib-ecrre-1lsnxu2zpb20l",
+ "ResourceType": "AWS::ECR::Repository",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 43,
+ "second": 39,
+ "microsecond": 210000
+ },
+ "ResourceStatus": "CREATE_FAILED",
+ "ResourceStatusReason": "Invalid parameter at 'PolicyText' failed to satisfy constraint: 'Invalid repository policy provided' (Service: AmazonECR; Status Code: 400; Error Code: InvalidParameterException; Request ID: 93e0bb60-3a0f-11e9-a53c-7162bb423e4d)",
+ "ResourceProperties": "{\"RepositoryPolicyText\":{\"Version\":\"3000-10-17\",\"Statement\":[{\"Action\":[\"ecr:*\"],\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::123456789012:root\"}}]}}"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-rollback/914046e0-3a0f-11e9-94bf-0a9edf17d014",
+ "EventId": "ECRRepo-CREATE_IN_PROGRESS-2019-02-26T21:43:38.793Z",
+ "StackName": "ansible-test-on-create-failure-rollback",
+ "LogicalResourceId": "ECRRepo",
+ "PhysicalResourceId": "ansib-ecrre-1lsnxu2zpb20l",
+ "ResourceType": "AWS::ECR::Repository",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 43,
+ "second": 38,
+ "microsecond": 793000
+ },
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceStatusReason": "Resource creation Initiated",
+ "ResourceProperties": "{\"RepositoryPolicyText\":{\"Version\":\"3000-10-17\",\"Statement\":[{\"Action\":[\"ecr:*\"],\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::123456789012:root\"}}]}}"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-rollback/914046e0-3a0f-11e9-94bf-0a9edf17d014",
+ "EventId": "ECRRepo-CREATE_IN_PROGRESS-2019-02-26T21:43:38.266Z",
+ "StackName": "ansible-test-on-create-failure-rollback",
+ "LogicalResourceId": "ECRRepo",
+ "PhysicalResourceId": "",
+ "ResourceType": "AWS::ECR::Repository",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 43,
+ "second": 38,
+ "microsecond": 266000
+ },
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceProperties": "{\"RepositoryPolicyText\":{\"Version\":\"3000-10-17\",\"Statement\":[{\"Action\":[\"ecr:*\"],\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::123456789012:root\"}}]}}"
+ },
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-rollback/914046e0-3a0f-11e9-94bf-0a9edf17d014",
+ "EventId": "9140bc10-3a0f-11e9-94bf-0a9edf17d014",
+ "StackName": "ansible-test-on-create-failure-rollback",
+ "LogicalResourceId": "ansible-test-on-create-failure-rollback",
+ "PhysicalResourceId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-rollback/914046e0-3a0f-11e9-94bf-0a9edf17d014",
+ "ResourceType": "AWS::CloudFormation::Stack",
+ "Timestamp": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 43,
+ "second": 34,
+ "microsecond": 740000
+ },
+ "ResourceStatus": "CREATE_IN_PROGRESS",
+ "ResourceStatusReason": "User Initiated"
+ }
+ ],
+ "ResponseMetadata": {
+ "RequestId": "982d0bff-3a0f-11e9-b938-97983b40cabe",
+ "HTTPStatusCode": 200,
+ "HTTPHeaders": {
+ "x-amzn-requestid": "982d0bff-3a0f-11e9-b938-97983b40cabe",
+ "content-type": "text/xml",
+ "content-length": "7911",
+ "vary": "Accept-Encoding",
+ "date": "Tue, 26 Feb 2019 21:43:45 GMT"
+ },
+ "RetryAttempts": 0
+ }
+ }
+}
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_rollback/cloudformation.DescribeStacks_1.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_rollback/cloudformation.DescribeStacks_1.json
new file mode 100644
index 000000000..25facea18
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_rollback/cloudformation.DescribeStacks_1.json
@@ -0,0 +1,42 @@
+{
+ "status_code": 200,
+ "data": {
+ "Stacks": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-rollback/914046e0-3a0f-11e9-94bf-0a9edf17d014",
+ "StackName": "ansible-test-on-create-failure-rollback",
+ "CreationTime": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 43,
+ "second": 34,
+ "microsecond": 740000
+ },
+ "RollbackConfiguration": {},
+ "StackStatus": "CREATE_IN_PROGRESS",
+ "StackStatusReason": "User Initiated",
+ "DisableRollback": false,
+ "NotificationARNs": [],
+ "Tags": [],
+ "EnableTerminationProtection": false,
+ "DriftInformation": {
+ "StackDriftStatus": "NOT_CHECKED"
+ }
+ }
+ ],
+ "ResponseMetadata": {
+ "RequestId": "91725383-3a0f-11e9-b938-97983b40cabe",
+ "HTTPStatusCode": 200,
+ "HTTPHeaders": {
+ "x-amzn-requestid": "91725383-3a0f-11e9-b938-97983b40cabe",
+ "content-type": "text/xml",
+ "content-length": "1045",
+ "date": "Tue, 26 Feb 2019 21:43:35 GMT"
+ },
+ "RetryAttempts": 0
+ }
+ }
+}
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_rollback/cloudformation.DescribeStacks_2.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_rollback/cloudformation.DescribeStacks_2.json
new file mode 100644
index 000000000..55a80d8af
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_rollback/cloudformation.DescribeStacks_2.json
@@ -0,0 +1,52 @@
+{
+ "status_code": 200,
+ "data": {
+ "Stacks": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-rollback/914046e0-3a0f-11e9-94bf-0a9edf17d014",
+ "StackName": "ansible-test-on-create-failure-rollback",
+ "CreationTime": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 43,
+ "second": 34,
+ "microsecond": 740000
+ },
+ "DeletionTime": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 43,
+ "second": 39,
+ "microsecond": 920000
+ },
+ "RollbackConfiguration": {},
+ "StackStatus": "ROLLBACK_IN_PROGRESS",
+ "StackStatusReason": "The following resource(s) failed to create: [ECRRepo]. . Rollback requested by user.",
+ "DisableRollback": false,
+ "NotificationARNs": [],
+ "Tags": [],
+ "EnableTerminationProtection": false,
+ "DriftInformation": {
+ "StackDriftStatus": "NOT_CHECKED"
+ }
+ }
+ ],
+ "ResponseMetadata": {
+ "RequestId": "94bb1651-3a0f-11e9-b938-97983b40cabe",
+ "HTTPStatusCode": 200,
+ "HTTPHeaders": {
+ "x-amzn-requestid": "94bb1651-3a0f-11e9-b938-97983b40cabe",
+ "content-type": "text/xml",
+ "content-length": "1179",
+ "date": "Tue, 26 Feb 2019 21:43:40 GMT"
+ },
+ "RetryAttempts": 0
+ }
+ }
+}
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_rollback/cloudformation.DescribeStacks_3.json b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_rollback/cloudformation.DescribeStacks_3.json
new file mode 100644
index 000000000..7c00a8364
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/placebo_recordings/cloudformation/on_create_failure_rollback/cloudformation.DescribeStacks_3.json
@@ -0,0 +1,51 @@
+{
+ "status_code": 200,
+ "data": {
+ "Stacks": [
+ {
+ "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/ansible-test-on-create-failure-rollback/914046e0-3a0f-11e9-94bf-0a9edf17d014",
+ "StackName": "ansible-test-on-create-failure-rollback",
+ "CreationTime": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 43,
+ "second": 34,
+ "microsecond": 740000
+ },
+ "DeletionTime": {
+ "__class__": "datetime",
+ "year": 2019,
+ "month": 2,
+ "day": 26,
+ "hour": 21,
+ "minute": 43,
+ "second": 39,
+ "microsecond": 920000
+ },
+ "RollbackConfiguration": {},
+ "StackStatus": "ROLLBACK_COMPLETE",
+ "DisableRollback": false,
+ "NotificationARNs": [],
+ "Tags": [],
+ "EnableTerminationProtection": false,
+ "DriftInformation": {
+ "StackDriftStatus": "NOT_CHECKED"
+ }
+ }
+ ],
+ "ResponseMetadata": {
+ "RequestId": "98016814-3a0f-11e9-b938-97983b40cabe",
+ "HTTPStatusCode": 200,
+ "HTTPHeaders": {
+ "x-amzn-requestid": "98016814-3a0f-11e9-b938-97983b40cabe",
+ "content-type": "text/xml",
+ "content-length": "1044",
+ "date": "Tue, 26 Feb 2019 21:43:45 GMT"
+ },
+ "RetryAttempts": 0
+ }
+ }
+}
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/test_cloudformation.py b/ansible_collections/amazon/aws/tests/unit/plugins/modules/test_cloudformation.py
new file mode 100644
index 000000000..f46bc1113
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/test_cloudformation.py
@@ -0,0 +1,227 @@
+# (c) 2017 Red Hat Inc.
+#
+# This file is part of Ansible
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+# Make coding more python3-ish
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import pytest
+
+# Magic...
+from ansible_collections.amazon.aws.tests.unit.utils.amazon_placebo_fixtures import maybe_sleep, placeboify # pylint: disable=unused-import
+
+from ansible_collections.amazon.aws.plugins.module_utils.botocore import boto_exception
+from ansible_collections.amazon.aws.plugins.module_utils.modules import _RetryingBotoClientWrapper
+from ansible_collections.amazon.aws.plugins.module_utils.retries import AWSRetry
+
+from ansible_collections.amazon.aws.plugins.modules import cloudformation as cfn_module
+
+basic_yaml_tpl = """
+---
+AWSTemplateFormatVersion: '2010-09-09'
+Description: 'Basic template that creates an S3 bucket'
+Resources:
+ MyBucket:
+ Type: "AWS::S3::Bucket"
+Outputs:
+ TheName:
+ Value:
+ !Ref MyBucket
+"""
+
+bad_json_tpl = """{
+ "AWSTemplateFormatVersion": "2010-09-09",
+ "Description": "Broken template, no comma here ->"
+ "Resources": {
+ "MyBucket": {
+ "Type": "AWS::S3::Bucket"
+ }
+ }
+}"""
+
+failing_yaml_tpl = """
+---
+AWSTemplateFormatVersion: 2010-09-09
+Resources:
+ ECRRepo:
+ Type: AWS::ECR::Repository
+ Properties:
+ RepositoryPolicyText:
+ Version: 3000-10-17 # <--- invalid version
+ Statement:
+ - Effect: Allow
+ Action:
+ - 'ecr:*'
+ Principal:
+ AWS: !Sub arn:${AWS::Partition}:iam::${AWS::AccountId}:root
+"""
+
+default_events_limit = 10
+
+
+class FakeModule(object):
+ def __init__(self, **kwargs):
+ self.params = kwargs
+
+ def fail_json(self, *args, **kwargs):
+ self.exit_args = args
+ self.exit_kwargs = kwargs
+ raise Exception('FAIL')
+
+ def fail_json_aws(self, *args, **kwargs):
+ self.exit_args = args
+ self.exit_kwargs = kwargs
+ raise Exception('FAIL')
+
+ def exit_json(self, *args, **kwargs):
+ self.exit_args = args
+ self.exit_kwargs = kwargs
+ raise Exception('EXIT')
+
+
+def _create_wrapped_client(placeboify):
+ connection = placeboify.client('cloudformation')
+ retry_decorator = AWSRetry.jittered_backoff()
+ wrapped_conn = _RetryingBotoClientWrapper(connection, retry_decorator)
+ return wrapped_conn
+
+
+def test_invalid_template_json(placeboify):
+ connection = _create_wrapped_client(placeboify)
+ params = {
+ 'StackName': 'ansible-test-wrong-json',
+ 'TemplateBody': bad_json_tpl,
+ }
+ m = FakeModule(disable_rollback=False)
+ with pytest.raises(Exception) as exc_info:
+ cfn_module.create_stack(m, params, connection, default_events_limit)
+ pytest.fail('Expected malformed JSON to have caused the call to fail')
+
+ assert exc_info.match('FAIL')
+ assert "ValidationError" in boto_exception(m.exit_args[0])
+
+
+def test_client_request_token_s3_stack(maybe_sleep, placeboify):
+ connection = _create_wrapped_client(placeboify)
+ params = {
+ 'StackName': 'ansible-test-client-request-token-yaml',
+ 'TemplateBody': basic_yaml_tpl,
+ 'ClientRequestToken': '3faf3fb5-b289-41fc-b940-44151828f6cf',
+ }
+ m = FakeModule(disable_rollback=False)
+ result = cfn_module.create_stack(m, params, connection, default_events_limit)
+ assert result['changed']
+ assert len(result['events']) > 1
+ # require that the final recorded stack state was CREATE_COMPLETE
+ # events are retrieved newest-first, so 0 is the latest
+ assert 'CREATE_COMPLETE' in result['events'][0]
+ connection.delete_stack(StackName='ansible-test-client-request-token-yaml')
+
+
+def test_basic_s3_stack(maybe_sleep, placeboify):
+ connection = _create_wrapped_client(placeboify)
+ params = {
+ 'StackName': 'ansible-test-basic-yaml',
+ 'TemplateBody': basic_yaml_tpl
+ }
+ m = FakeModule(disable_rollback=False)
+ result = cfn_module.create_stack(m, params, connection, default_events_limit)
+ assert result['changed']
+ assert len(result['events']) > 1
+ # require that the final recorded stack state was CREATE_COMPLETE
+ # events are retrieved newest-first, so 0 is the latest
+ assert 'CREATE_COMPLETE' in result['events'][0]
+ connection.delete_stack(StackName='ansible-test-basic-yaml')
+
+
+def test_delete_nonexistent_stack(maybe_sleep, placeboify):
+ connection = _create_wrapped_client(placeboify)
+ # module is only used if we threw an unexpected error
+ module = None
+ result = cfn_module.stack_operation(module, connection, 'ansible-test-nonexist', 'DELETE', default_events_limit)
+ assert result['changed']
+ assert 'Stack does not exist.' in result['log']
+
+
+def test_get_nonexistent_stack(placeboify):
+ connection = _create_wrapped_client(placeboify)
+ # module is only used if we threw an unexpected error
+ module = None
+ assert cfn_module.get_stack_facts(module, connection, 'ansible-test-nonexist') is None
+
+
+def test_missing_template_body():
+ m = FakeModule()
+ with pytest.raises(Exception) as exc_info:
+ cfn_module.create_stack(
+ module=m,
+ stack_params={},
+ cfn=None,
+ events_limit=default_events_limit
+ )
+ pytest.fail('Expected module to have failed with no template')
+
+ assert exc_info.match('FAIL')
+ assert not m.exit_args
+ assert "Either 'template', 'template_body' or 'template_url' is required when the stack does not exist." == m.exit_kwargs['msg']
+
+
+def test_on_create_failure_delete(maybe_sleep, placeboify):
+ m = FakeModule(
+ on_create_failure='DELETE',
+ disable_rollback=False,
+ )
+ connection = _create_wrapped_client(placeboify)
+ params = {
+ 'StackName': 'ansible-test-on-create-failure-delete',
+ 'TemplateBody': failing_yaml_tpl
+ }
+ result = cfn_module.create_stack(m, params, connection, default_events_limit)
+ assert result['changed']
+ assert result['failed']
+ assert len(result['events']) > 1
+ # require that the final recorded stack state was DELETE_COMPLETE
+ # events are retrieved newest-first, so 0 is the latest
+ assert 'DELETE_COMPLETE' in result['events'][0]
+
+
+def test_on_create_failure_rollback(maybe_sleep, placeboify):
+ m = FakeModule(
+ on_create_failure='ROLLBACK',
+ disable_rollback=False,
+ )
+ connection = _create_wrapped_client(placeboify)
+ params = {
+ 'StackName': 'ansible-test-on-create-failure-rollback',
+ 'TemplateBody': failing_yaml_tpl
+ }
+ result = cfn_module.create_stack(m, params, connection, default_events_limit)
+ assert result['changed']
+ assert result['failed']
+ assert len(result['events']) > 1
+ # require that the final recorded stack state was ROLLBACK_COMPLETE
+ # events are retrieved newest-first, so 0 is the latest
+ assert 'ROLLBACK_COMPLETE' in result['events'][0]
+ connection.delete_stack(StackName=params['StackName'])
+
+
+def test_on_create_failure_do_nothing(maybe_sleep, placeboify):
+ m = FakeModule(
+ on_create_failure='DO_NOTHING',
+ disable_rollback=False,
+ )
+ connection = _create_wrapped_client(placeboify)
+ params = {
+ 'StackName': 'ansible-test-on-create-failure-do-nothing',
+ 'TemplateBody': failing_yaml_tpl
+ }
+ result = cfn_module.create_stack(m, params, connection, default_events_limit)
+ assert result['changed']
+ assert result['failed']
+ assert len(result['events']) > 1
+ # require that the final recorded stack state was CREATE_FAILED
+ # events are retrieved newest-first, so 0 is the latest
+ assert 'CREATE_FAILED' in result['events'][0]
+ connection.delete_stack(StackName=params['StackName'])
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/test_ec2_ami.py b/ansible_collections/amazon/aws/tests/unit/plugins/modules/test_ec2_ami.py
new file mode 100644
index 000000000..5e8140d4a
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/test_ec2_ami.py
@@ -0,0 +1,44 @@
+# This file is part of Ansible
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from unittest.mock import MagicMock, Mock, patch, call
+
+import pytest
+
+from ansible_collections.amazon.aws.plugins.modules import ec2_ami
+
+module_name = "ansible_collections.amazon.aws.plugins.modules.ec2_ami"
+
+
+@patch(module_name + ".get_image_by_id")
+def test_create_image_uefi_data(m_get_image_by_id):
+ module = MagicMock()
+ connection = MagicMock()
+
+ m_get_image_by_id.return_value = {
+ "ImageId": "ami-0c7a795306730b288",
+ "BootMode": "uefi",
+ "TpmSupport": "v2.0",
+ }
+
+ module.params = {
+ "name": "my-image",
+ "boot_mode": "uefi",
+ "tpm_support": "v2.0",
+ "uefi_data": "QU1aTlVFRkk9xcN0AAAAAHj5a7fZ9+3aT2gcVRgA8Ek3NipiPST0pCiCIlTJtj20FzENCcQa",
+ }
+
+ ec2_ami.create_image(module, connection)
+ assert connection.register_image.call_count == 1
+ connection.register_image.assert_has_calls(
+ [
+ call(
+ aws_retry=True,
+ Description=None,
+ Name="my-image",
+ BootMode="uefi",
+ TpmSupport="v2.0",
+ UefiData="QU1aTlVFRkk9xcN0AAAAAHj5a7fZ9+3aT2gcVRgA8Ek3NipiPST0pCiCIlTJtj20FzENCcQa"
+ )
+ ]
+ )
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/test_ec2_key.py b/ansible_collections/amazon/aws/tests/unit/plugins/modules/test_ec2_key.py
new file mode 100644
index 000000000..2660ced63
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/test_ec2_key.py
@@ -0,0 +1,654 @@
+# This file is part of Ansible
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from unittest.mock import MagicMock
+from unittest.mock import patch
+from unittest.mock import call, ANY
+
+import pytest
+import botocore
+import datetime
+from dateutil.tz import tzutc
+from ansible.module_utils._text import to_bytes
+
+from ansible_collections.amazon.aws.plugins.module_utils.core import is_boto3_error_code
+
+from ansible_collections.amazon.aws.plugins.modules import ec2_key
+
+module_name = "ansible_collections.amazon.aws.plugins.modules.ec2_key"
+
+
+def raise_botocore_exception_clienterror(action):
+
+ params = {
+ 'Error': {
+ 'Code': 1,
+ 'Message': 'error creating key'
+ },
+ 'ResponseMetadata': {
+ 'RequestId': '01234567-89ab-cdef-0123-456789abcdef'
+ }
+ }
+
+ if action == 'create_key_pair':
+ params['Error']['Message'] = 'error creating key'
+
+ elif action == 'describe_key_pair':
+ params['Error']['Code'] = 'InvalidKeyPair.NotFound'
+ params['Error']['Message'] = 'The key pair does not exist'
+
+ elif action == 'import_key_pair':
+ params['Error']['Message'] = 'error importing key'
+
+ elif action == 'delete_key_pair':
+ params['Error']['Message'] = 'error deleting key'
+
+ return botocore.exceptions.ClientError(params, action)
+
+
+def test__import_key_pair():
+ ec2_client = MagicMock()
+ name = 'my_keypair'
+ key_material = "ssh-rsa AAAAB3NzaC1yc2EAA email@example.com"
+
+ expected_params = {
+ 'KeyName': name,
+ 'PublicKeyMaterial': to_bytes(key_material),
+ }
+
+ ec2_client.import_key_pair.return_value = {
+ 'KeyFingerprint': 'd7:ff:a6:63:18:64:9c:57:a1:ee:ca:a4:ad:c2:81:62',
+ 'KeyName': 'my_keypair',
+ 'KeyPairId': 'key-012345678905a208d'
+ }
+
+ result = ec2_key._import_key_pair(ec2_client, name, key_material)
+
+ assert result == ec2_client.import_key_pair.return_value
+ assert ec2_client.import_key_pair.call_count == 1
+ ec2_client.import_key_pair.assert_called_with(aws_retry=True, **expected_params)
+
+
+def test_api_failure__import_key_pair():
+ ec2_client = MagicMock()
+ name = 'my_keypair'
+ key_material = "ssh-rsa AAAAB3NzaC1yc2EAA email@example.com"
+
+ expected_params = {
+ 'KeyName': name,
+ 'PublicKeyMaterial': to_bytes(key_material),
+ }
+
+ ec2_client.import_key_pair.side_effect = raise_botocore_exception_clienterror('import_key_pair')
+
+ with pytest.raises(ec2_key.Ec2KeyFailure):
+ ec2_key._import_key_pair(ec2_client, name, key_material)
+
+
+def test_extract_key_data_describe_key_pairs():
+
+ key = {
+ "CreateTime": datetime.datetime(2022, 9, 15, 20, 10, 15, tzinfo=tzutc()),
+ "KeyFingerprint": "11:12:13:14:bb:26:85:b2:e8:39:27:bc:ee:aa:ff:ee:dd:cc:bb:aa",
+ "KeyName": "my_keypair",
+ "KeyPairId": "key-043046ef2a9a80b56",
+ "Tags": [],
+ }
+
+ key_type = "rsa"
+
+ expected_result = {
+ "name": "my_keypair",
+ "fingerprint": "11:12:13:14:bb:26:85:b2:e8:39:27:bc:ee:aa:ff:ee:dd:cc:bb:aa",
+ "id": "key-043046ef2a9a80b56",
+ "tags": {},
+ "type": "rsa"
+ }
+
+ result = ec2_key.extract_key_data(key, key_type)
+
+ assert result == expected_result
+
+
+def test_extract_key_data_create_key_pair():
+
+ key = {
+ 'KeyFingerprint': '11:12:13:14:bb:26:85:b2:e8:39:27:bc:ee:aa:ff:ee:dd:cc:bb:aa',
+ 'KeyName': 'my_keypair',
+ 'KeyPairId': 'key-043046ef2a9a80b56'
+ }
+
+ key_type = "rsa"
+
+ expected_result = {
+ "name": "my_keypair",
+ "fingerprint": "11:12:13:14:bb:26:85:b2:e8:39:27:bc:ee:aa:ff:ee:dd:cc:bb:aa",
+ "id": "key-043046ef2a9a80b56",
+ "tags": {},
+ "type": "rsa"
+ }
+
+ result = ec2_key.extract_key_data(key, key_type)
+
+ assert result == expected_result
+
+
+@patch(module_name + '.delete_key_pair')
+@patch(module_name + '._import_key_pair')
+@patch(module_name + '.find_key_pair')
+def test_get_key_fingerprint(m_find_key_pair, m_import_key_pair, m_delete_key_pair):
+
+ module = MagicMock()
+ ec2_client = MagicMock()
+
+ m_find_key_pair.return_value = None
+
+ m_import_key_pair.return_value = {
+ 'KeyFingerprint': 'd7:ff:a6:63:18:64:9c:57:a1:ee:ca:a4:ad:c2:81:62',
+ 'KeyName': 'my_keypair',
+ 'KeyPairId': 'key-043046ef2a9a80b56'
+ }
+
+ m_delete_key_pair.return_value = {
+ 'changed': True,
+ 'key': None,
+ 'msg': 'key deleted'
+ }
+
+ expected_result = 'd7:ff:a6:63:18:64:9c:57:a1:ee:ca:a4:ad:c2:81:62'
+
+ key_material = "ssh-rsa AAAAB3NzaC1yc2EAA email@example.com"
+
+ result = ec2_key.get_key_fingerprint(module, ec2_client, key_material)
+
+ assert result == expected_result
+ assert m_find_key_pair.call_count == 1
+ assert m_import_key_pair.call_count == 1
+ assert m_delete_key_pair.call_count == 1
+
+
+def test_find_key_pair():
+ ec2_client = MagicMock()
+ name = 'my_keypair'
+
+ ec2_client.describe_key_pairs.return_value = {
+ 'KeyPairs': [
+ {
+ 'CreateTime': datetime.datetime(2022, 9, 15, 20, 10, 15, tzinfo=tzutc()),
+ 'KeyFingerprint': '11:12:13:14:bb:26:85:b2:e8:39:27:bc:ee:aa:ff:ee:dd:cc:bb:aa',
+ 'KeyName': 'my_keypair',
+ 'KeyPairId': 'key-043046ef2a9a80b56',
+ 'KeyType': 'rsa',
+ 'Tags': []
+ }
+ ],
+ }
+
+ ec2_key.find_key_pair(ec2_client, name)
+
+ assert ec2_client.describe_key_pairs.call_count == 1
+ ec2_client.describe_key_pairs.assert_called_with(aws_retry=True, KeyNames=[name])
+
+
+def test_api_failure_find_key_pair():
+ ec2_client = MagicMock()
+ name = 'non_existing_keypair'
+
+ ec2_client.describe_key_pairs.side_effect = botocore.exceptions.BotoCoreError
+
+ with pytest.raises(ec2_key.Ec2KeyFailure):
+ ec2_key.find_key_pair(ec2_client, name)
+
+
+def test_invalid_key_pair_find_key_pair():
+ ec2_client = MagicMock()
+ name = 'non_existing_keypair'
+
+ ec2_client.describe_key_pairs.side_effect = raise_botocore_exception_clienterror('describe_key_pair')
+
+ result = ec2_key.find_key_pair(ec2_client, name)
+
+ assert result is None
+
+
+def test__create_key_pair():
+ ec2_client = MagicMock()
+ name = 'my_keypair'
+ tag_spec = None
+ key_type = None
+
+ expected_params = {'KeyName': name}
+
+ ec2_client.create_key_pair.return_value = {
+ "KeyFingerprint": "d7:ff:a6:63:18:64:9c:57:a1:ee:ca:a4:ad:c2:81:62",
+ "KeyMaterial": (
+ "-----BEGIN RSA PRIVATE KEY-----\n" # gitleaks:allow
+ "MIIEXm7/Bi9wba2m0Qtclu\nCXQw2paSIZb\n"
+ "-----END RSA PRIVATE KEY-----"
+ ),
+ "KeyName": "my_keypair",
+ "KeyPairId": "key-012345678905a208d",
+ }
+
+ result = ec2_key._create_key_pair(ec2_client, name, tag_spec, key_type)
+
+ assert result == ec2_client.create_key_pair.return_value
+ assert ec2_client.create_key_pair.call_count == 1
+ ec2_client.create_key_pair.assert_called_with(aws_retry=True, **expected_params)
+
+
+def test_api_failure__create_key_pair():
+ ec2_client = MagicMock()
+ name = 'my_keypair'
+ tag_spec = None
+ key_type = None
+
+ ec2_client.create_key_pair.side_effect = raise_botocore_exception_clienterror('create_key_pair')
+
+ with pytest.raises(ec2_key.Ec2KeyFailure):
+ ec2_key._create_key_pair(ec2_client, name, tag_spec, key_type)
+
+
+@patch(module_name + '.extract_key_data')
+@patch(module_name + '._import_key_pair')
+def test_create_new_key_pair_key_material(m_import_key_pair, m_extract_key_data):
+ module = MagicMock()
+ ec2_client = MagicMock()
+
+ name = 'my_keypair'
+ key_material = "ssh-rsa AAAAB3NzaC1yc2EAA email@example.com"
+ key_type = 'rsa'
+ tags = None
+
+ module.check_mode = False
+
+ m_import_key_pair.return_value = {
+ 'KeyFingerprint': 'd7:ff:a6:63:18:64:9c:57:a1:ee:ca:a4:ad:c2:81:62',
+ 'KeyName': 'my_keypair',
+ 'KeyPairId': 'key-012345678905a208d'
+ }
+
+ m_extract_key_data.return_value = {
+ "name": "my_keypair",
+ "fingerprint": "11:12:13:14:bb:26:85:b2:e8:39:27:bc:ee:aa:ff:ee:dd:cc:bb:aa",
+ "id": "key-043046ef2a9a80b56",
+ "tags": {},
+ "type": "rsa"
+ }
+
+ expected_result = {'changed': True, 'key': m_extract_key_data.return_value, 'msg': 'key pair created'}
+
+ result = ec2_key.create_new_key_pair(ec2_client, name, key_material, key_type, tags, module.check_mode)
+
+ assert result == expected_result
+ assert m_import_key_pair.call_count == 1
+ assert m_extract_key_data.call_count == 1
+
+
+@patch(module_name + '.extract_key_data')
+@patch(module_name + '._create_key_pair')
+def test_create_new_key_pair_no_key_material(m_create_key_pair, m_extract_key_data):
+ module = MagicMock()
+ ec2_client = MagicMock()
+
+ name = 'my_keypair'
+ key_type = 'rsa'
+ key_material = None
+ tags = None
+
+ module.check_mode = False
+
+ m_create_key_pair.return_value = {
+ 'KeyFingerprint': 'd7:ff:a6:63:18:64:9c:57:a1:ee:ca:a4:ad:c2:81:62',
+ 'KeyName': 'my_keypair',
+ 'KeyPairId': 'key-012345678905a208d'
+ }
+
+ m_extract_key_data.return_value = {
+ "name": "my_keypair",
+ "fingerprint": "11:12:13:14:bb:26:85:b2:e8:39:27:bc:ee:aa:ff:ee:dd:cc:bb:aa",
+ "id": "key-043046ef2a9a80b56",
+ "tags": {},
+ "type": "rsa"
+ }
+
+ expected_result = {'changed': True, 'key': m_extract_key_data.return_value, 'msg': 'key pair created'}
+
+ result = ec2_key.create_new_key_pair(ec2_client, name, key_material, key_type, tags, module.check_mode)
+
+ assert result == expected_result
+ assert m_create_key_pair.call_count == 1
+ assert m_extract_key_data.call_count == 1
+
+
+def test__delete_key_pair():
+ ec2_client = MagicMock()
+
+ key_name = 'my_keypair'
+ ec2_key._delete_key_pair(ec2_client, key_name)
+
+ assert ec2_client.delete_key_pair.call_count == 1
+ ec2_client.delete_key_pair.assert_called_with(aws_retry=True, KeyName=key_name)
+
+
+def test_api_failure__delete_key_pair():
+ ec2_client = MagicMock()
+ name = 'my_keypair'
+
+ ec2_client.delete_key_pair.side_effect = raise_botocore_exception_clienterror('delete_key_pair')
+
+ with pytest.raises(ec2_key.Ec2KeyFailure):
+ ec2_key._delete_key_pair(ec2_client, name)
+
+
+@patch(module_name + '.extract_key_data')
+@patch(module_name + '._import_key_pair')
+@patch(module_name + '.delete_key_pair')
+@patch(module_name + '.get_key_fingerprint')
+def test_update_key_pair_by_key_material_update_needed(m_get_key_fingerprint, m_delete_key_pair, m__import_key_pair, m_extract_key_data):
+ module = MagicMock()
+ ec2_client = MagicMock()
+
+ name = 'my_keypair'
+ key_material = "ssh-rsa AAAAB3NzaC1yc2EAA email@example.com"
+ tag_spec = None
+ key = {
+ "KeyName": "my_keypair",
+ "KeyFingerprint": "11:12:13:14:bb:26:85:b2:e8:39:27:bc:ee:aa:ff:ee:dd:cc:bb:aa",
+ "KeyPairId": "key-043046ef2a9a80b56",
+ "Tags": {},
+ }
+
+ module.check_mode = False
+
+ m_get_key_fingerprint.return_value = 'd7:ff:a6:63:18:64:9c:57:a1:ee:ca:a4:ad:c2:81:62'
+ m_delete_key_pair.return_value = None
+ m__import_key_pair.return_value = {
+ 'KeyFingerprint': '11:12:13:14:bb:26:85:b2:e8:39:27:bc:ee:aa:ff:ee:dd:cc:bb:aa',
+ 'KeyName': 'my_keypair',
+ 'KeyPairId': 'key-043046ef2a9a80b56',
+ 'Tags': {},
+ }
+ m_extract_key_data.return_value = {
+ "name": "my_keypair",
+ "fingerprint": "d7:ff:a6:63:18:64:9c:57:a1:ee:ca:a4:ad:c2:81:62",
+ "id": "key-012345678905a208d",
+ "tags": {},
+ }
+
+ expected_result = {'changed': True, 'key': m_extract_key_data.return_value, 'msg': "key pair updated"}
+
+ result = ec2_key.update_key_pair_by_key_material(module.check_mode, ec2_client, name, key, key_material, tag_spec)
+
+ assert result == expected_result
+ assert m_get_key_fingerprint.call_count == 1
+ assert m_delete_key_pair.call_count == 1
+ assert m__import_key_pair.call_count == 1
+ assert m_extract_key_data.call_count == 1
+ m_get_key_fingerprint.assert_called_with(module.check_mode, ec2_client, key_material)
+ m_delete_key_pair.assert_called_with(module.check_mode, ec2_client, name, finish_task=False)
+ m__import_key_pair.assert_called_with(ec2_client, name, key_material, tag_spec)
+ m_extract_key_data.assert_called_with(key)
+
+
+@patch(module_name + ".extract_key_data")
+@patch(module_name + ".get_key_fingerprint")
+def test_update_key_pair_by_key_material_key_exists(m_get_key_fingerprint, m_extract_key_data):
+ ec2_client = MagicMock()
+
+ key_material = MagicMock()
+ key_fingerprint = MagicMock()
+ tag_spec = MagicMock()
+ key_id = MagicMock()
+ key_name = MagicMock()
+ key = {
+ "KeyName": key_name,
+ "KeyFingerprint": key_fingerprint,
+ "KeyPairId": key_id,
+ "Tags": {},
+ }
+
+ check_mode = False
+ m_get_key_fingerprint.return_value = key_fingerprint
+ m_extract_key_data.return_value = {
+ "name": key_name,
+ "fingerprint": key_fingerprint,
+ "id": key_id,
+ "tags": {},
+ }
+
+ expected_result = {"changed": False, "key": m_extract_key_data.return_value, "msg": "key pair already exists"}
+
+ assert expected_result == ec2_key.update_key_pair_by_key_material(
+ check_mode, ec2_client, key_name, key, key_material, tag_spec
+ )
+
+ m_get_key_fingerprint.assert_called_once_with(check_mode, ec2_client, key_material)
+ m_extract_key_data.assert_called_once_with(key)
+
+
+@patch(module_name + ".extract_key_data")
+@patch(module_name + "._create_key_pair")
+@patch(module_name + ".delete_key_pair")
+def test_update_key_pair_by_key_type_update_needed(m_delete_key_pair, m__create_key_pair, m_extract_key_data):
+ module = MagicMock()
+ ec2_client = MagicMock()
+
+ name = 'my_keypair'
+ key_type = 'rsa'
+ tag_spec = None
+
+ module.check_mode = False
+
+ m_delete_key_pair.return_value = None
+ m__create_key_pair.return_value = {
+ 'KeyFingerprint': '11:12:13:14:bb:26:85:b2:e8:39:27:bc:ee:aa:ff:ee:dd:cc:bb:aa',
+ 'Name': 'my_keypair',
+ 'Id': 'key-043046ef2a9a80b56',
+ 'Tags': {},
+ 'Type': 'rsa'
+ }
+ m_extract_key_data.return_value = {
+ "name": "my_keypair",
+ "fingerprint": "11:12:13:14:bb:26:85:b2:e8:39:27:bc:ee:aa:ff:ee:dd:cc:bb:aa",
+ "id": "key-043046ef2a9a80b56",
+ "tags": {},
+ "type": "rsa"
+ }
+
+ expected_result = {"changed": True, "key": m_extract_key_data.return_value, "msg": "key pair updated"}
+
+ result = ec2_key.update_key_pair_by_key_type(module.check_mode, ec2_client, name, key_type, tag_spec)
+
+ assert result == expected_result
+ assert m_delete_key_pair.call_count == 1
+ assert m__create_key_pair.call_count == 1
+ assert m_extract_key_data.call_count == 1
+ m_delete_key_pair.assert_called_with(module.check_mode, ec2_client, name, finish_task=False)
+ m__create_key_pair.assert_called_with(ec2_client, name, tag_spec, key_type)
+ m_extract_key_data.assert_called_with(m__create_key_pair.return_value, key_type)
+
+
+@patch(module_name + '.update_key_pair_by_key_material')
+def test_handle_existing_key_pair_update_key_matrial_with_force(m_update_key_pair_by_key_material):
+ module = MagicMock()
+ ec2_client = MagicMock()
+
+ name = 'my_keypair'
+ key = {
+ "KeyName": "my_keypair",
+ "KeyFingerprint": "11:12:13:14:bb:26:85:b2:e8:39:27:bc:ee:aa:ff:ee:dd:cc:bb:aa",
+ "KeyPairId": "key-043046ef2a9a80b56",
+ "Tags": {},
+ "KeyType": "rsa"
+ }
+
+ module.params = {
+ 'key_material': "ssh-rsa AAAAB3NzaC1yc2EAA email@example.com",
+ 'force': True,
+ 'key_type': 'rsa',
+ 'tags': None,
+ 'purge_tags': True,
+ 'tag_spec': None
+ }
+
+ key_data = {
+ "name": "my_keypair",
+ "fingerprint": "d7:ff:a6:63:18:64:9c:57:a1:ee:ca:a4:ad:c2:81:62",
+ "id": "key-012345678905a208d",
+ "tags": {},
+ }
+
+ m_update_key_pair_by_key_material.return_value = {'changed': True, 'key': key_data, 'msg': "key pair updated"}
+
+ expected_result = {'changed': True, 'key': key_data, 'msg': "key pair updated"}
+
+ result = ec2_key.handle_existing_key_pair_update(module, ec2_client, name, key)
+
+ assert result == expected_result
+ assert m_update_key_pair_by_key_material.call_count == 1
+
+
+@patch(module_name + '.update_key_pair_by_key_type')
+def test_handle_existing_key_pair_update_key_type(m_update_key_pair_by_key_type):
+ module = MagicMock()
+ ec2_client = MagicMock()
+
+ name = 'my_keypair'
+ key = {
+ "KeyName": "my_keypair",
+ "KeyFingerprint": "11:12:13:14:bb:26:85:b2:e8:39:27:bc:ee:aa:ff:ee:dd:cc:bb:aa",
+ "KeyPairId": "key-043046ef2a9a80b56",
+ "Tags": {},
+ "KeyType": "ed25519"
+ }
+
+ module.params = {
+ 'key_material': "ssh-rsa AAAAB3NzaC1yc2EAA email@example.com",
+ 'force': False,
+ 'key_type': 'rsa',
+ 'tags': None,
+ 'purge_tags': True,
+ 'tag_spec': None
+ }
+
+ key_data = {
+ "name": "my_keypair",
+ "fingerprint": "d7:ff:a6:63:18:64:9c:57:a1:ee:ca:a4:ad:c2:81:62",
+ "id": "key-012345678905a208d",
+ "tags": {},
+ }
+
+ m_update_key_pair_by_key_type.return_value = {'changed': True, 'key': key_data, 'msg': "key pair updated"}
+
+ expected_result = {'changed': True, 'key': key_data, 'msg': "key pair updated"}
+
+ result = ec2_key.handle_existing_key_pair_update(module, ec2_client, name, key)
+
+ assert result == expected_result
+ assert m_update_key_pair_by_key_type.call_count == 1
+
+
+@patch(module_name + '.extract_key_data')
+def test_handle_existing_key_pair_else(m_extract_key_data):
+ module = MagicMock()
+ ec2_client = MagicMock()
+
+ name = 'my_keypair'
+ key = {
+ "KeyName": "my_keypair",
+ "KeyFingerprint": "11:12:13:14:bb:26:85:b2:e8:39:27:bc:ee:aa:ff:ee:dd:cc:bb:aa",
+ "KeyPairId": "key-043046ef2a9a80b56",
+ "Tags": {},
+ "KeyType": "rsa"
+ }
+
+ module.params = {
+ 'key_material': "ssh-rsa AAAAB3NzaC1yc2EAA email@example.com",
+ 'force': False,
+ 'key_type': 'rsa',
+ 'tags': None,
+ 'purge_tags': True,
+ 'tag_spec': None
+ }
+
+ m_extract_key_data.return_value = {
+ "name": "my_keypair",
+ "fingerprint": "11:12:13:14:bb:26:85:b2:e8:39:27:bc:ee:aa:ff:ee:dd:cc:bb:aa",
+ "id": "key-043046ef2a9a80b56",
+ "tags": {},
+ "type": "rsa"
+ }
+
+ expected_result = {"changed": False, "key": m_extract_key_data.return_value, "msg": "key pair already exists"}
+
+ result = ec2_key.handle_existing_key_pair_update(module, ec2_client, name, key)
+
+ assert result == expected_result
+ assert m_extract_key_data.call_count == 1
+
+
+@patch(module_name + '._delete_key_pair')
+@patch(module_name + '.find_key_pair')
+def test_delete_key_pair_key_exists(m_find_key_pair, m_delete_key_pair):
+ module = MagicMock()
+ ec2_client = MagicMock()
+
+ name = 'my_keypair'
+
+ module.check_mode = False
+
+ m_find_key_pair.return_value = {
+ 'KeyPairs': [
+ {
+ 'CreateTime': datetime.datetime(2022, 9, 15, 20, 10, 15, tzinfo=tzutc()),
+ 'KeyFingerprint': '11:12:13:14:bb:26:85:b2:e8:39:27:bc:ee:aa:ff:ee:dd:cc:bb:aa',
+ 'KeyName': 'my_keypair',
+ 'KeyPairId': 'key-043046ef2a9a80b56',
+ 'KeyType': 'rsa',
+ 'Tags': []
+ }
+ ],
+ }
+
+ expected_result = {'changed': True, 'key': None, 'msg': 'key deleted'}
+
+ result = ec2_key.delete_key_pair(module.check_mode, ec2_client, name)
+
+ assert m_find_key_pair.call_count == 1
+ m_find_key_pair.assert_called_with(ec2_client, name)
+ assert m_delete_key_pair.call_count == 1
+ m_delete_key_pair.assert_called_with(ec2_client, name)
+ assert result == expected_result
+
+
+@patch(module_name + '._delete_key_pair')
+@patch(module_name + '.find_key_pair')
+def test_delete_key_pair_key_not_exist(m_find_key_pair, m_delete_key_pair):
+ module = MagicMock()
+ ec2_client = MagicMock()
+
+ name = 'my_keypair'
+
+ module.check_mode = False
+
+ m_find_key_pair.return_value = None
+
+ expected_result = {'key': None, 'msg': 'key did not exist'}
+
+ result = ec2_key.delete_key_pair(module.check_mode, ec2_client, name)
+
+ assert m_find_key_pair.call_count == 1
+ m_find_key_pair.assert_called_with(ec2_client, name)
+ assert m_delete_key_pair.call_count == 0
+ assert result == expected_result
+
+
+@patch(module_name + ".AnsibleAWSModule")
+def test_main_success(m_AnsibleAWSModule):
+ m_module = MagicMock()
+ m_AnsibleAWSModule.return_value = m_module
+
+ ec2_key.main()
+
+ m_module.client.assert_called_with("ec2", retry_decorator=ANY)
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/test_ec2_security_group.py b/ansible_collections/amazon/aws/tests/unit/plugins/modules/test_ec2_security_group.py
new file mode 100644
index 000000000..1ebbe86c6
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/test_ec2_security_group.py
@@ -0,0 +1,83 @@
+# Make coding more python3-ish
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+from ansible_collections.amazon.aws.plugins.modules import ec2_security_group as group_module
+
+
+def test_from_permission():
+ internal_http = {
+ 'FromPort': 80,
+ 'IpProtocol': 'tcp',
+ 'IpRanges': [
+ {
+ 'CidrIp': '10.0.0.0/8',
+ 'Description': 'Foo Bar Baz'
+ },
+ ],
+ 'Ipv6Ranges': [
+ {'CidrIpv6': 'fe80::94cc:8aff:fef6:9cc/64'},
+ ],
+ 'PrefixListIds': [],
+ 'ToPort': 80,
+ 'UserIdGroupPairs': [],
+ }
+ perms = list(group_module.rule_from_group_permission(internal_http))
+ assert len(perms) == 2
+ assert perms[0].target == '10.0.0.0/8'
+ assert perms[0].target_type == 'ipv4'
+ assert perms[0].description == 'Foo Bar Baz'
+ assert perms[1].target == 'fe80::94cc:8aff:fef6:9cc/64'
+
+ global_egress = {
+ 'IpProtocol': '-1',
+ 'IpRanges': [{'CidrIp': '0.0.0.0/0'}],
+ 'Ipv6Ranges': [],
+ 'PrefixListIds': [],
+ 'UserIdGroupPairs': []
+ }
+ perms = list(group_module.rule_from_group_permission(global_egress))
+ assert len(perms) == 1
+ assert perms[0].target == '0.0.0.0/0'
+ assert perms[0].port_range == (None, None)
+
+ internal_prefix_http = {
+ 'FromPort': 80,
+ 'IpProtocol': 'tcp',
+ 'PrefixListIds': [
+ {'PrefixListId': 'p-1234'}
+ ],
+ 'ToPort': 80,
+ 'UserIdGroupPairs': [],
+ }
+ perms = list(group_module.rule_from_group_permission(internal_prefix_http))
+ assert len(perms) == 1
+ assert perms[0].target == 'p-1234'
+
+
+def test_rule_to_permission():
+ tests = [
+ group_module.Rule((22, 22), 'udp', 'sg-1234567890', 'group', None),
+ group_module.Rule((1, 65535), 'tcp', '0.0.0.0/0', 'ipv4', "All TCP from everywhere"),
+ group_module.Rule((443, 443), 'tcp', 'ip-123456', 'ip_prefix', "Traffic to privatelink IPs"),
+ group_module.Rule((443, 443), 'tcp', 'feed:dead:::beef/64', 'ipv6', None),
+ ]
+ for test in tests:
+ perm = group_module.to_permission(test)
+ assert perm['FromPort'], perm['ToPort'] == test.port_range
+ assert perm['IpProtocol'] == test.protocol
+
+
+def test_validate_ip():
+ class Warner(object):
+ def warn(self, msg):
+ return
+ ips = [
+ ('10.1.1.1/24', '10.1.1.0/24'),
+ ('192.168.56.101/16', '192.168.0.0/16'),
+ # Don't modify IPv6 CIDRs, AWS supports /128 and device ranges
+ ('fc00:8fe0:fe80:b897:8990:8a7c:99bf:323d/128', 'fc00:8fe0:fe80:b897:8990:8a7c:99bf:323d/128'),
+ ]
+
+ for ip, net in ips:
+ assert group_module.validate_ip(Warner(), ip) == net
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/test_ec2_vpc_dhcp_option.py b/ansible_collections/amazon/aws/tests/unit/plugins/modules/test_ec2_vpc_dhcp_option.py
new file mode 100644
index 000000000..73726590f
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/test_ec2_vpc_dhcp_option.py
@@ -0,0 +1,71 @@
+# (c) 2021 Red Hat Inc.
+#
+# This file is part of Ansible
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+# Magic... Incorrectly identified by pylint as unused
+from ansible_collections.amazon.aws.tests.unit.utils.amazon_placebo_fixtures import placeboify # pylint: disable=unused-import
+from ansible_collections.amazon.aws.tests.unit.compat.mock import patch
+
+from ansible_collections.amazon.aws.plugins.modules import ec2_vpc_dhcp_option as dhcp_module
+from ansible_collections.amazon.aws.tests.unit.plugins.modules.utils import ModuleTestCase
+
+test_module_params = {'domain_name': 'us-west-2.compute.internal',
+ 'dns_servers': ['AmazonProvidedDNS'],
+ 'ntp_servers': ['10.10.2.3', '10.10.4.5'],
+ 'netbios_name_servers': ['10.20.2.3', '10.20.4.5'],
+ 'netbios_node_type': 2}
+
+test_create_config = [{'Key': 'domain-name', 'Values': [{'Value': 'us-west-2.compute.internal'}]},
+ {'Key': 'domain-name-servers', 'Values': [{'Value': 'AmazonProvidedDNS'}]},
+ {'Key': 'ntp-servers', 'Values': [{'Value': '10.10.2.3'}, {'Value': '10.10.4.5'}]},
+ {'Key': 'netbios-name-servers', 'Values': [{'Value': '10.20.2.3'}, {'Value': '10.20.4.5'}]},
+ {'Key': 'netbios-node-type', 'Values': 2}]
+
+
+test_create_option_set = [{'Key': 'domain-name', 'Values': ['us-west-2.compute.internal']},
+ {'Key': 'domain-name-servers', 'Values': ['AmazonProvidedDNS']},
+ {'Key': 'ntp-servers', 'Values': ['10.10.2.3', '10.10.4.5']},
+ {'Key': 'netbios-name-servers', 'Values': ['10.20.2.3', '10.20.4.5']},
+ {'Key': 'netbios-node-type', 'Values': ['2']}]
+
+test_normalize_config = {'domain-name': ['us-west-2.compute.internal'],
+ 'domain-name-servers': ['AmazonProvidedDNS'],
+ 'ntp-servers': ['10.10.2.3', '10.10.4.5'],
+ 'netbios-name-servers': ['10.20.2.3', '10.20.4.5'],
+ 'netbios-node-type': '2'
+ }
+
+
+class FakeModule(object):
+ def __init__(self, **kwargs):
+ self.params = kwargs
+
+ def fail_json(self, *args, **kwargs):
+ self.exit_args = args
+ self.exit_kwargs = kwargs
+ raise Exception('FAIL')
+
+ def fail_json_aws(self, *args, **kwargs):
+ self.exit_args = args
+ self.exit_kwargs = kwargs
+ raise Exception('FAIL')
+
+ def exit_json(self, *args, **kwargs):
+ self.exit_args = args
+ self.exit_kwargs = kwargs
+ raise Exception('EXIT')
+
+
+@patch.object(dhcp_module.AnsibleAWSModule, 'client')
+class TestDhcpModule(ModuleTestCase):
+
+ def test_normalize_config(self, client_mock):
+ result = dhcp_module.normalize_ec2_vpc_dhcp_config(test_create_config)
+
+ print(result)
+ print(test_normalize_config)
+ assert result == test_normalize_config
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/test_kms_key.py b/ansible_collections/amazon/aws/tests/unit/plugins/modules/test_kms_key.py
new file mode 100644
index 000000000..5a53e2ddb
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/test_kms_key.py
@@ -0,0 +1,82 @@
+#
+# (c) 2022 Red Hat Inc.
+#
+# This file is part of Ansible
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+import pytest
+
+from unittest.mock import MagicMock, call, patch
+from ansible_collections.amazon.aws.plugins.modules import kms_key
+
+
+module_name = "ansible_collections.amazon.aws.plugins.modules.kms_key"
+key_details = {
+ "KeyMetadata": {
+ "aliases": ["mykey"],
+ "Arn": "arn:aws:kms:us-east-1:12345678:key/mrk-12345678",
+ "customer_master_key_spec": "SYMMETRIC_DEFAULT",
+ "description": "",
+ "enable_key_rotation": False,
+ "enabled": True,
+ "encryption_algorithms": ["SYMMETRIC_DEFAULT"],
+ "grants": [],
+ "key_arn": "arn:aws:kms:us-east-1:12345678:key/mrk-12345678",
+ "key_id": "mrk-12345678",
+ "key_manager": "CUSTOMER",
+ "key_policies": [
+ {
+ "Id": "key-default-1",
+ "Statement": [
+ {
+ "Action": "kms:*",
+ "Effect": "Allow",
+ "Principal": {"AWS": "arn:aws:iam::12345678:root"},
+ "Resource": "*",
+ "Sid": "Enable IAM User Permissions",
+ }
+ ],
+ "Version": "2012-10-17",
+ }
+ ],
+ "key_spec": "SYMMETRIC_DEFAULT",
+ "key_state": "Enabled",
+ "key_usage": "ENCRYPT_DECRYPT",
+ "multi_region": True,
+ "multi_region_configuration": {
+ "multi_region_key_type": "PRIM ARY",
+ "primary_key": {
+ "arn": "arn:aws:kms:us-east-1:12345678:key/mrk-12345678",
+ "region": "us-east-1",
+ },
+ "replica_keys": [],
+ },
+ "origin": "AWS_KMS",
+ "tags": {"Hello": "World2"},
+ }
+}
+
+
+@patch(module_name + ".get_kms_metadata_with_backoff")
+def test_fetch_key_metadata(m_get_kms_metadata_with_backoff):
+
+ module = MagicMock()
+ kms_client = MagicMock()
+
+ m_get_kms_metadata_with_backoff.return_value = key_details
+ kms_key.fetch_key_metadata(kms_client, module, "mrk-12345678", "mykey")
+ assert m_get_kms_metadata_with_backoff.call_count == 1
+
+
+def test_validate_params():
+
+ module = MagicMock()
+ module.params = {
+ "state": "present",
+ "multi_region": True
+ }
+
+ result = kms_key.validate_params(module, key_details["KeyMetadata"])
+ module.fail_json.assert_called_with(
+ msg="You cannot change the multi-region property on an existing key."
+ )
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/test_lambda_layer.py b/ansible_collections/amazon/aws/tests/unit/plugins/modules/test_lambda_layer.py
new file mode 100644
index 000000000..451a61766
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/test_lambda_layer.py
@@ -0,0 +1,493 @@
+#
+# (c) 2022 Red Hat Inc.
+#
+# This file is part of Ansible
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import pytest
+
+from unittest.mock import MagicMock, call, patch
+from ansible_collections.amazon.aws.plugins.modules import lambda_layer
+
+
+def raise_lambdalayer_exception(e=None, m=None):
+ e = e or "lambda layer exc"
+ m = m or "unit testing"
+ return lambda_layer.LambdaLayerFailure(exc=e, msg=m)
+
+
+mod_list_layer = 'ansible_collections.amazon.aws.plugins.modules.lambda_layer.list_layer_versions'
+mod_create_layer = 'ansible_collections.amazon.aws.plugins.modules.lambda_layer.create_layer_version'
+mod_delete_layer = 'ansible_collections.amazon.aws.plugins.modules.lambda_layer.delete_layer_version'
+
+
+@pytest.mark.parametrize(
+ "params,api_result,calls,ansible_result",
+ [
+ (
+ {
+ "name": "testlayer",
+ "version": 4
+ },
+ [],
+ [],
+ {"changed": False, "layer_versions": []}
+ ),
+ (
+ {
+ "name": "testlayer",
+ "version": 4
+ },
+ [
+ {
+ 'compatible_runtimes': ["python3.7"],
+ 'created_date': "2022-09-29T10:31:35.977+0000",
+ 'layer_version_arn': "arn:aws:lambda:eu-west-2:123456789012:layer:testlayer:2",
+ "license_info": "MIT",
+ 'version': 2,
+ 'compatible_architectures': [
+ 'arm64'
+ ]
+ },
+ {
+ "created_date": "2022-09-29T10:31:26.341+0000",
+ "description": "lambda layer first version",
+ "layer_version_arn": "arn:aws:lambda:eu-west-2:123456789012:layer:testlayer:1",
+ "version": 1
+ }
+ ],
+ [],
+ {"changed": False, "layer_versions": []}
+ ),
+ (
+ {
+ "name": "testlayer",
+ "version": 2
+ },
+ [
+ {
+ 'compatible_runtimes': ["python3.7"],
+ 'created_date': "2022-09-29T10:31:35.977+0000",
+ 'layer_version_arn': "arn:aws:lambda:eu-west-2:123456789012:layer:testlayer:2",
+ "license_info": "MIT",
+ 'version': 2,
+ 'compatible_architectures': [
+ 'arm64'
+ ]
+ },
+ {
+ "created_date": "2022-09-29T10:31:26.341+0000",
+ "description": "lambda layer first version",
+ "layer_version_arn": "arn:aws:lambda:eu-west-2:123456789012:layer:testlayer:1",
+ "version": 1
+ }
+ ],
+ [
+ call(LayerName='testlayer', VersionNumber=2)
+ ],
+ {
+ "changed": True,
+ "layer_versions": [
+ {
+ 'compatible_runtimes': ["python3.7"],
+ 'created_date': "2022-09-29T10:31:35.977+0000",
+ 'layer_version_arn': "arn:aws:lambda:eu-west-2:123456789012:layer:testlayer:2",
+ "license_info": "MIT",
+ 'version': 2,
+ 'compatible_architectures': [
+ 'arm64'
+ ]
+ }
+ ]
+ }
+ ),
+ (
+ {
+ "name": "testlayer",
+ "version": -1
+ },
+ [
+ {
+ 'compatible_runtimes': ["python3.7"],
+ 'created_date': "2022-09-29T10:31:35.977+0000",
+ 'layer_version_arn': "arn:aws:lambda:eu-west-2:123456789012:layer:testlayer:2",
+ "license_info": "MIT",
+ 'version': 2,
+ 'compatible_architectures': [
+ 'arm64'
+ ]
+ },
+ {
+ "created_date": "2022-09-29T10:31:26.341+0000",
+ "description": "lambda layer first version",
+ "layer_version_arn": "arn:aws:lambda:eu-west-2:123456789012:layer:testlayer:1",
+ "version": 1
+ }
+ ],
+ [
+ call(LayerName='testlayer', VersionNumber=2),
+ call(LayerName='testlayer', VersionNumber=1)
+ ],
+ {
+ "changed": True,
+ "layer_versions": [
+ {
+ 'compatible_runtimes': ["python3.7"],
+ 'created_date': "2022-09-29T10:31:35.977+0000",
+ 'layer_version_arn': "arn:aws:lambda:eu-west-2:123456789012:layer:testlayer:2",
+ "license_info": "MIT",
+ 'version': 2,
+ 'compatible_architectures': [
+ 'arm64'
+ ]
+ },
+ {
+ "created_date": "2022-09-29T10:31:26.341+0000",
+ "description": "lambda layer first version",
+ "layer_version_arn": "arn:aws:lambda:eu-west-2:123456789012:layer:testlayer:1",
+ "version": 1
+ }
+ ]
+ }
+ )
+ ]
+)
+@patch(mod_list_layer)
+def test_delete_layer(m_list_layer, params, api_result, calls, ansible_result):
+
+ lambda_client = MagicMock()
+ lambda_client.delete_layer_version.return_value = None
+
+ m_list_layer.return_value = api_result
+ result = lambda_layer.delete_layer_version(lambda_client, params)
+ assert result == ansible_result
+
+ m_list_layer.assert_called_once_with(
+ lambda_client, params.get("name")
+ )
+
+ if not calls:
+ lambda_client.delete_layer_version.assert_not_called()
+ else:
+ lambda_client.delete_layer_version.assert_has_calls(calls, any_order=True)
+
+
+@patch(mod_list_layer)
+def test_delete_layer_check_mode(m_list_layer):
+
+ lambda_client = MagicMock()
+ lambda_client.delete_layer_version.return_value = None
+
+ m_list_layer.return_value = [
+ {
+ 'compatible_runtimes': ["python3.7"],
+ 'created_date': "2022-09-29T10:31:35.977+0000",
+ 'layer_version_arn': "arn:aws:lambda:eu-west-2:123456789012:layer:testlayer:2",
+ "license_info": "MIT",
+ 'version': 2,
+ 'compatible_architectures': [
+ 'arm64'
+ ]
+ },
+ {
+ "created_date": "2022-09-29T10:31:26.341+0000",
+ "description": "lambda layer first version",
+ "layer_version_arn": "arn:aws:lambda:eu-west-2:123456789012:layer:testlayer:1",
+ "version": 1
+ }
+ ]
+ params = {"name": "testlayer", "version": -1}
+ result = lambda_layer.delete_layer_version(lambda_client, params, check_mode=True)
+ ansible_result = {
+ "changed": True,
+ "layer_versions": [
+ {
+ 'compatible_runtimes': ["python3.7"],
+ 'created_date': "2022-09-29T10:31:35.977+0000",
+ 'layer_version_arn': "arn:aws:lambda:eu-west-2:123456789012:layer:testlayer:2",
+ "license_info": "MIT",
+ 'version': 2,
+ 'compatible_architectures': [
+ 'arm64'
+ ]
+ },
+ {
+ "created_date": "2022-09-29T10:31:26.341+0000",
+ "description": "lambda layer first version",
+ "layer_version_arn": "arn:aws:lambda:eu-west-2:123456789012:layer:testlayer:1",
+ "version": 1
+ }
+ ]
+ }
+ assert result == ansible_result
+
+ m_list_layer.assert_called_once_with(
+ lambda_client, params.get("name")
+ )
+ lambda_client.delete_layer_version.assert_not_called()
+
+
+@patch(mod_list_layer)
+def test_delete_layer_failure(m_list_layer):
+
+ lambda_client = MagicMock()
+ lambda_client.delete_layer_version.side_effect = raise_lambdalayer_exception()
+
+ m_list_layer.return_value = [
+ {
+ "created_date": "2022-09-29T10:31:26.341+0000",
+ "description": "lambda layer first version",
+ "layer_version_arn": "arn:aws:lambda:eu-west-2:123456789012:layer:testlayer:1",
+ "version": 1
+ }
+ ]
+ params = {"name": "testlayer", "version": 1}
+ with pytest.raises(lambda_layer.LambdaLayerFailure):
+ lambda_layer.delete_layer_version(lambda_client, params)
+
+
+@pytest.mark.parametrize(
+ "b_s3content",
+ [
+ (True),
+ (False)
+ ]
+)
+@patch(mod_list_layer)
+def test_create_layer(m_list_layer, b_s3content, tmp_path):
+ params = {
+ "name": "testlayer",
+ "description": "ansible units testing sample layer",
+ "content": {},
+ "license_info": "MIT"
+ }
+
+ lambda_client = MagicMock()
+
+ lambda_client.publish_layer_version.return_value = {
+ 'CompatibleRuntimes': [
+ 'python3.6',
+ 'python3.7',
+ ],
+ 'Content': {
+ 'CodeSha256': 'tv9jJO+rPbXUUXuRKi7CwHzKtLDkDRJLB3cC3Z/ouXo=',
+ 'CodeSize': 169,
+ 'Location': 'https://awslambda-us-west-2-layers.s3.us-west-2.amazonaws.com/snapshots/123456789012/my-layer-4aaa2fbb',
+ },
+ 'CreatedDate': '2018-11-14T23:03:52.894+0000',
+ 'Description': "ansible units testing sample layer",
+ 'LayerArn': 'arn:aws:lambda:us-west-2:123456789012:layer:my-layer',
+ 'LayerVersionArn': 'arn:aws:lambda:us-west-2:123456789012:layer:testlayer:1',
+ 'LicenseInfo': 'MIT',
+ 'Version': 1,
+ 'ResponseMetadata': {
+ 'http_header': 'true',
+ },
+ }
+
+ expected = {
+ "changed": True,
+ "layer_versions": [
+ {
+ 'compatible_runtimes': ['python3.6', 'python3.7'],
+ 'content': {
+ 'code_sha256': 'tv9jJO+rPbXUUXuRKi7CwHzKtLDkDRJLB3cC3Z/ouXo=',
+ 'code_size': 169,
+ 'location': 'https://awslambda-us-west-2-layers.s3.us-west-2.amazonaws.com/snapshots/123456789012/my-layer-4aaa2fbb'
+ },
+ 'created_date': '2018-11-14T23:03:52.894+0000',
+ 'description': 'ansible units testing sample layer',
+ 'layer_arn': 'arn:aws:lambda:us-west-2:123456789012:layer:my-layer',
+ 'layer_version_arn': 'arn:aws:lambda:us-west-2:123456789012:layer:testlayer:1',
+ 'license_info': 'MIT',
+ 'version': 1
+ }
+ ]
+ }
+
+ if b_s3content:
+ params["content"] = {
+ "s3_bucket": "mybucket",
+ "s3_key": "mybucket-key",
+ "s3_object_version": "v1"
+ }
+ content_arg = {
+ "S3Bucket": "mybucket",
+ "S3Key": "mybucket-key",
+ "S3ObjectVersion": "v1"
+ }
+ else:
+ binary_data = b"simple lambda layer content"
+ test_dir = tmp_path / "lambda_layer"
+ test_dir.mkdir()
+ zipfile = test_dir / "lambda.zip"
+ zipfile.write_bytes(binary_data)
+
+ params["content"] = {"zip_file": str(zipfile)}
+ content_arg = {
+ "ZipFile": binary_data,
+ }
+
+ result = lambda_layer.create_layer_version(lambda_client, params)
+
+ assert result == expected
+
+ lambda_client.publish_layer_version.assert_called_with(
+ LayerName="testlayer",
+ Description="ansible units testing sample layer",
+ LicenseInfo="MIT",
+ Content=content_arg,
+ )
+
+ m_list_layer.assert_not_called()
+
+
+@patch(mod_list_layer)
+def test_create_layer_check_mode(m_list_layer):
+ params = {
+ "name": "testlayer",
+ "description": "ansible units testing sample layer",
+ "content": {
+ "s3_bucket": "mybucket",
+ "s3_key": "mybucket-key",
+ "s3_object_version": "v1"
+ },
+ "license_info": "MIT"
+ }
+
+ lambda_client = MagicMock()
+
+ result = lambda_layer.create_layer_version(lambda_client, params, check_mode=True)
+ assert result == {"msg": "Create operation skipped - running in check mode", "changed": True}
+
+ m_list_layer.assert_not_called()
+ lambda_client.publish_layer_version.assert_not_called()
+
+
+def test_create_layer_failure():
+ params = {
+ "name": "testlayer",
+ "description": "ansible units testing sample layer",
+ "content": {
+ "s3_bucket": "mybucket",
+ "s3_key": "mybucket-key",
+ "s3_object_version": "v1"
+ },
+ "compatible_runtimes": [
+ "nodejs",
+ "python3.9"
+ ],
+ "compatible_architectures": [
+ 'x86_64',
+ 'arm64'
+ ]
+ }
+ lambda_client = MagicMock()
+ lambda_client.publish_layer_version.side_effect = raise_lambdalayer_exception()
+
+ with pytest.raises(lambda_layer.LambdaLayerFailure):
+ lambda_layer.create_layer_version(lambda_client, params)
+
+
+def test_create_layer_using_unexisting_file():
+ params = {
+ "name": "testlayer",
+ "description": "ansible units testing sample layer",
+ "content": {
+ "zip_file": "this_file_does_not_exist",
+ },
+ "compatible_runtimes": [
+ "nodejs",
+ "python3.9"
+ ],
+ "compatible_architectures": [
+ 'x86_64',
+ 'arm64'
+ ]
+ }
+
+ lambda_client = MagicMock()
+
+ lambda_client.publish_layer_version.return_value = {}
+ with pytest.raises(FileNotFoundError):
+ lambda_layer.create_layer_version(lambda_client, params)
+
+ lambda_client.publish_layer_version.assert_not_called()
+
+
+@pytest.mark.parametrize(
+ "params,failure",
+ [
+ (
+ {"name": "test-layer"},
+ False
+ ),
+ (
+ {"name": "test-layer", "state": "absent"},
+ False
+ ),
+ (
+ {"name": "test-layer"},
+ True
+ ),
+ (
+ {"name": "test-layer", "state": "absent"},
+ True
+ ),
+ ]
+)
+@patch(mod_create_layer)
+@patch(mod_delete_layer)
+def test_execute_module(m_delete_layer, m_create_layer, params, failure):
+
+ module = MagicMock()
+ module.params = params
+ module.check_mode = False
+ module.exit_json.side_effect = SystemExit(1)
+ module.fail_json_aws.side_effect = SystemExit(2)
+
+ lambda_client = MagicMock()
+
+ state = params.get("state", "present")
+ result = {"changed": True, "layers_versions": {}}
+
+ if not failure:
+ if state == "present":
+ m_create_layer.return_value = result
+ with pytest.raises(SystemExit):
+ lambda_layer.execute_module(module, lambda_client)
+
+ module.exit_json.assert_called_with(**result)
+ module.fail_json_aws.assert_not_called()
+ m_create_layer.assert_called_with(
+ lambda_client, params, module.check_mode
+ )
+ m_delete_layer.assert_not_called()
+
+ elif state == "absent":
+ m_delete_layer.return_value = result
+ with pytest.raises(SystemExit):
+ lambda_layer.execute_module(module, lambda_client)
+
+ module.exit_json.assert_called_with(**result)
+ module.fail_json_aws.assert_not_called()
+ m_delete_layer.assert_called_with(
+ lambda_client, params, module.check_mode
+ )
+ m_create_layer.assert_not_called()
+ else:
+ exc = "lambdalayer_execute_module_exception"
+ msg = "this_exception_is_used_for_unit_testing"
+ m_create_layer.side_effect = raise_lambdalayer_exception(exc, msg)
+ m_delete_layer.side_effect = raise_lambdalayer_exception(exc, msg)
+
+ with pytest.raises(SystemExit):
+ lambda_layer.execute_module(module, lambda_client)
+
+ module.exit_json.assert_not_called()
+ module.fail_json_aws.assert_called_with(
+ exc, msg=msg
+ )
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/test_lambda_layer_info.py b/ansible_collections/amazon/aws/tests/unit/plugins/modules/test_lambda_layer_info.py
new file mode 100644
index 000000000..25a1f15ac
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/test_lambda_layer_info.py
@@ -0,0 +1,358 @@
+#
+# (c) 2022 Red Hat Inc.
+#
+# This file is part of Ansible
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import pytest
+from botocore.exceptions import BotoCoreError
+
+from unittest.mock import MagicMock, call, patch
+from ansible_collections.amazon.aws.plugins.modules import lambda_layer_info
+
+
+mod__list_layer_versions = 'ansible_collections.amazon.aws.plugins.modules.lambda_layer_info._list_layer_versions'
+mod__list_layers = 'ansible_collections.amazon.aws.plugins.modules.lambda_layer_info._list_layers'
+mod_list_layer_versions = 'ansible_collections.amazon.aws.plugins.modules.lambda_layer_info.list_layer_versions'
+mod_list_layers = 'ansible_collections.amazon.aws.plugins.modules.lambda_layer_info.list_layers'
+
+
+list_layers_paginate_result = {
+ 'NextMarker': '002',
+ 'Layers': [
+ {
+ 'LayerName': "test-layer-01",
+ 'LayerArn': "arn:aws:lambda:eu-west-2:123456789012:layer:test-layer-01",
+ 'LatestMatchingVersion': {
+ 'LayerVersionArn': "arn:aws:lambda:eu-west-2:123456789012:layer:test-layer-01:1",
+ 'Version': 1,
+ 'Description': "lambda layer created for unit tests",
+ 'CreatedDate': "2022-09-29T10:31:26.341+0000",
+ 'CompatibleRuntimes': [
+ 'nodejs',
+ 'nodejs4.3',
+ 'nodejs6.10'
+ ],
+ 'LicenseInfo': 'MIT',
+ 'CompatibleArchitectures': [
+ 'arm64'
+ ]
+ }
+ },
+ {
+ 'LayerName': "test-layer-02",
+ 'LayerArn': "arn:aws:lambda:eu-west-2:123456789012:layer:test-layer-02",
+ 'LatestMatchingVersion': {
+ 'LayerVersionArn': "arn:aws:lambda:eu-west-2:123456789012:layer:test-layer-02:1",
+ 'Version': 1,
+ 'CreatedDate': "2022-09-29T10:31:26.341+0000",
+ 'CompatibleArchitectures': [
+ 'arm64'
+ ]
+ }
+ },
+ ],
+ 'ResponseMetadata': {
+ 'http': 'true',
+ },
+}
+
+list_layers_result = [
+ {
+ 'layer_name': "test-layer-01",
+ 'layer_arn': "arn:aws:lambda:eu-west-2:123456789012:layer:test-layer-01",
+ 'layer_version_arn': "arn:aws:lambda:eu-west-2:123456789012:layer:test-layer-01:1",
+ 'version': 1,
+ 'description': "lambda layer created for unit tests",
+ 'created_date': "2022-09-29T10:31:26.341+0000",
+ 'compatible_runtimes': [
+ 'nodejs',
+ 'nodejs4.3',
+ 'nodejs6.10'
+ ],
+ 'license_info': 'MIT',
+ 'compatible_architectures': [
+ 'arm64'
+ ]
+ },
+ {
+ 'layer_name': "test-layer-02",
+ 'layer_arn': "arn:aws:lambda:eu-west-2:123456789012:layer:test-layer-02",
+ 'layer_version_arn': "arn:aws:lambda:eu-west-2:123456789012:layer:test-layer-02:1",
+ 'version': 1,
+ 'created_date': "2022-09-29T10:31:26.341+0000",
+ 'compatible_architectures': [
+ 'arm64'
+ ]
+ }
+]
+
+
+list_layers_versions_paginate_result = {
+ 'LayerVersions': [
+ {
+ 'CompatibleRuntimes': ["python3.7"],
+ 'CreatedDate': "2022-09-29T10:31:35.977+0000",
+ 'LayerVersionArn': "arn:aws:lambda:eu-west-2:123456789012:layer:layer-01:2",
+ "LicenseInfo": "MIT",
+ 'Version': 2,
+ 'CompatibleArchitectures': [
+ 'arm64'
+ ]
+ },
+ {
+ "CompatibleRuntimes": ["python3.7"],
+ "CreatedDate": "2022-09-29T10:31:26.341+0000",
+ "Description": "lambda layer first version",
+ "LayerVersionArn": "arn:aws:lambda:eu-west-2:123456789012:layer:layer-01:1",
+ "LicenseInfo": "GPL-3.0-only",
+ "Version": 1
+ }
+ ],
+ 'ResponseMetadata': {
+ 'http': 'true',
+ },
+ 'NextMarker': '001',
+}
+
+
+list_layers_versions_result = [
+ {
+ "compatible_runtimes": ["python3.7"],
+ "created_date": "2022-09-29T10:31:35.977+0000",
+ "layer_version_arn": "arn:aws:lambda:eu-west-2:123456789012:layer:layer-01:2",
+ "license_info": "MIT",
+ "version": 2,
+ 'compatible_architectures': [
+ 'arm64'
+ ]
+ },
+ {
+ "compatible_runtimes": ["python3.7"],
+ "created_date": "2022-09-29T10:31:26.341+0000",
+ "description": "lambda layer first version",
+ "layer_version_arn": "arn:aws:lambda:eu-west-2:123456789012:layer:layer-01:1",
+ "license_info": "GPL-3.0-only",
+ "version": 1
+ }
+]
+
+
+@pytest.mark.parametrize(
+ "params,call_args",
+ [
+ (
+ {
+ "compatible_runtime": "nodejs",
+ "compatible_architecture": "arm64"
+ },
+ {
+ "CompatibleRuntime": "nodejs",
+ "CompatibleArchitecture": "arm64"
+ }
+ ),
+ (
+ {
+ "compatible_runtime": "nodejs",
+ },
+ {
+ "CompatibleRuntime": "nodejs",
+ }
+ ),
+ (
+ {
+ "compatible_architecture": "arm64"
+ },
+ {
+ "CompatibleArchitecture": "arm64"
+ }
+ ),
+ (
+ {}, {}
+ )
+ ]
+)
+@patch(mod__list_layers)
+def test_list_layers_with_latest_version(m__list_layers, params, call_args):
+
+ lambda_client = MagicMock()
+
+ m__list_layers.return_value = list_layers_paginate_result
+ layers = lambda_layer_info.list_layers(lambda_client, **params)
+
+ m__list_layers.assert_has_calls(
+ [
+ call(lambda_client, **call_args)
+ ]
+ )
+ assert layers == list_layers_result
+
+
+@pytest.mark.parametrize(
+ "params,call_args",
+ [
+ (
+ {
+ "name": "layer-01",
+ "compatible_runtime": "nodejs",
+ "compatible_architecture": "arm64"
+ },
+ {
+ "LayerName": "layer-01",
+ "CompatibleRuntime": "nodejs",
+ "CompatibleArchitecture": "arm64"
+ }
+ ),
+ (
+ {
+ "name": "layer-01",
+ "compatible_runtime": "nodejs",
+ },
+ {
+ "LayerName": "layer-01",
+ "CompatibleRuntime": "nodejs",
+ }
+ ),
+ (
+ {
+ "name": "layer-01",
+ "compatible_architecture": "arm64"
+ },
+ {
+ "LayerName": "layer-01",
+ "CompatibleArchitecture": "arm64"
+ }
+ ),
+ (
+ {"name": "layer-01"}, {"LayerName": "layer-01"}
+ )
+ ]
+)
+@patch(mod__list_layer_versions)
+def test_list_layer_versions(m__list_layer_versions, params, call_args):
+
+ lambda_client = MagicMock()
+
+ m__list_layer_versions.return_value = list_layers_versions_paginate_result
+ layers = lambda_layer_info.list_layer_versions(lambda_client, **params)
+
+ m__list_layer_versions.assert_has_calls(
+ [
+ call(lambda_client, **call_args)
+ ]
+ )
+ assert layers == list_layers_versions_result
+
+
+def raise_botocore_exception():
+ return BotoCoreError(error="failed", operation="list_layers")
+
+
+@pytest.mark.parametrize(
+ "params",
+ [
+ (
+ {
+ "name": "test-layer",
+ "compatible_runtime": "nodejs",
+ "compatible_architecture": "arm64"
+ }
+ ),
+ (
+ {
+ "compatible_runtime": "nodejs",
+ "compatible_architecture": "arm64"
+ }
+ )
+ ]
+)
+@patch(mod__list_layers)
+@patch(mod__list_layer_versions)
+def test_list_layers_with_failure(m__list_layer_versions, m__list_layers, params):
+
+ lambda_client = MagicMock()
+
+ if "name" in params:
+ m__list_layer_versions.side_effect = raise_botocore_exception()
+ test_function = lambda_layer_info.list_layer_versions
+ else:
+ m__list_layers.side_effect = raise_botocore_exception()
+ test_function = lambda_layer_info.list_layers
+
+ with pytest.raises(lambda_layer_info.LambdaLayerInfoFailure):
+ test_function(lambda_client, **params)
+
+
+def raise_layer_info_exception(exc, msg):
+ return lambda_layer_info.LambdaLayerInfoFailure(exc=exc, msg=msg)
+
+
+@pytest.mark.parametrize(
+ "params,failure",
+ [
+ (
+ {
+ "name": "test-layer",
+ "compatible_runtime": "nodejs",
+ "compatible_architecture": "arm64"
+ },
+ False
+ ),
+ (
+ {
+ "compatible_runtime": "nodejs",
+ "compatible_architecture": "arm64"
+ },
+ False
+ ),
+ (
+ {
+ "name": "test-layer",
+ "compatible_runtime": "nodejs",
+ "compatible_architecture": "arm64"
+ },
+ True
+ )
+ ]
+)
+@patch(mod_list_layers)
+@patch(mod_list_layer_versions)
+def test_execute_module(m_list_layer_versions, m_list_layers, params, failure):
+
+ lambda_client = MagicMock()
+
+ module = MagicMock()
+ module.params = params
+ module.exit_json.side_effect = SystemExit(1)
+ module.fail_json_aws.side_effect = SystemExit(2)
+
+ method_called, method_not_called = m_list_layers, m_list_layer_versions
+ if "name" in params:
+ method_not_called, method_called = m_list_layers, m_list_layer_versions
+
+ if failure:
+ exc = "lambda_layer_exception"
+ msg = "this exception has been generated for unit tests"
+
+ method_called.side_effect = raise_layer_info_exception(exc, msg)
+
+ with pytest.raises(SystemExit):
+ lambda_layer_info.execute_module(module, lambda_client)
+
+ module.fail_json_aws.assert_called_with(exception=exc, msg=msg)
+
+ else:
+ result = {"A": "valueA", "B": "valueB"}
+ method_called.return_value = result
+
+ with pytest.raises(SystemExit):
+ lambda_layer_info.execute_module(module, lambda_client)
+
+ module.exit_json.assert_called_with(
+ changed=False, layers_versions=result
+ )
+ method_called.assert_called_with(lambda_client, **params)
+ method_not_called.list_layers.assert_not_called()
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/test_s3_object.py b/ansible_collections/amazon/aws/tests/unit/plugins/modules/test_s3_object.py
new file mode 100644
index 000000000..b02513072
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/test_s3_object.py
@@ -0,0 +1,29 @@
+# Make coding more python3-ish
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+from ansible.module_utils.six.moves.urllib.parse import urlparse
+
+from ansible_collections.amazon.aws.plugins.modules import s3_object
+
+
+class TestUrlparse():
+
+ def test_urlparse(self):
+ actual = urlparse("http://test.com/here")
+ assert actual.scheme == "http"
+ assert actual.netloc == "test.com"
+ assert actual.path == "/here"
+
+ def test_is_fakes3(self):
+ actual = s3_object.is_fakes3("fakes3://bla.blubb")
+ assert actual is True
+
+ def test_get_s3_connection(self):
+ aws_connect_kwargs = dict(aws_access_key_id="access_key",
+ aws_secret_access_key="secret_key")
+ location = None
+ rgw = True
+ s3_url = "http://bla.blubb"
+ actual = s3_object.get_s3_connection(None, aws_connect_kwargs, location, rgw, s3_url)
+ assert "bla.blubb" in str(actual._endpoint)
diff --git a/ansible_collections/amazon/aws/tests/unit/plugins/modules/utils.py b/ansible_collections/amazon/aws/tests/unit/plugins/modules/utils.py
new file mode 100644
index 000000000..058a5b605
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/plugins/modules/utils.py
@@ -0,0 +1,50 @@
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import json
+
+from ansible_collections.amazon.aws.tests.unit.compat import unittest
+from ansible_collections.amazon.aws.tests.unit.compat.mock import patch
+from ansible.module_utils import basic
+from ansible.module_utils._text import to_bytes
+
+
+def set_module_args(args):
+ if '_ansible_remote_tmp' not in args:
+ args['_ansible_remote_tmp'] = '/tmp'
+ if '_ansible_keep_remote_files' not in args:
+ args['_ansible_keep_remote_files'] = False
+
+ args = json.dumps({'ANSIBLE_MODULE_ARGS': args})
+ basic._ANSIBLE_ARGS = to_bytes(args)
+
+
+class AnsibleExitJson(Exception):
+ pass
+
+
+class AnsibleFailJson(Exception):
+ pass
+
+
+def exit_json(*args, **kwargs):
+ if 'changed' not in kwargs:
+ kwargs['changed'] = False
+ raise AnsibleExitJson(kwargs)
+
+
+def fail_json(*args, **kwargs):
+ kwargs['failed'] = True
+ raise AnsibleFailJson(kwargs)
+
+
+class ModuleTestCase(unittest.TestCase):
+
+ def setUp(self):
+ self.mock_module = patch.multiple(basic.AnsibleModule, exit_json=exit_json, fail_json=fail_json)
+ self.mock_module.start()
+ self.mock_sleep = patch('time.sleep')
+ self.mock_sleep.start()
+ set_module_args({})
+ self.addCleanup(self.mock_module.stop)
+ self.addCleanup(self.mock_sleep.stop)
diff --git a/ansible_collections/amazon/aws/tests/unit/requirements.txt b/ansible_collections/amazon/aws/tests/unit/requirements.txt
new file mode 100644
index 000000000..49f392832
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/requirements.txt
@@ -0,0 +1,5 @@
+# Our code is based on the AWS SDKs
+botocore
+boto3
+
+placebo
diff --git a/ansible_collections/amazon/aws/tests/unit/utils/amazon_placebo_fixtures.py b/ansible_collections/amazon/aws/tests/unit/utils/amazon_placebo_fixtures.py
new file mode 100644
index 000000000..6912c2e32
--- /dev/null
+++ b/ansible_collections/amazon/aws/tests/unit/utils/amazon_placebo_fixtures.py
@@ -0,0 +1,213 @@
+from __future__ import absolute_import, division, print_function
+__metaclass__ = type
+
+import errno
+import os
+import time
+import mock
+import pytest
+
+boto3 = pytest.importorskip("boto3")
+botocore = pytest.importorskip("botocore")
+placebo = pytest.importorskip("placebo")
+
+"""
+Using Placebo to test modules using boto3:
+
+This is an example test, using the placeboify fixture to test that a module
+will fail if resources it depends on don't exist.
+
+> from placebo_fixtures import placeboify, scratch_vpc
+>
+> def test_create_with_nonexistent_launch_config(placeboify):
+> connection = placeboify.client('autoscaling')
+> module = FakeModule('test-asg-created', None, min_size=0, max_size=0, desired_capacity=0)
+> with pytest.raises(FailJSON) as excinfo:
+> asg_module.create_autoscaling_group(connection, module)
+> .... asserts based on module state/exceptions ....
+
+In more advanced cases, use unrecorded resource fixtures to fill in ARNs/IDs of
+things modules depend on, such as:
+
+> def test_create_in_vpc(placeboify, scratch_vpc):
+> connection = placeboify.client('autoscaling')
+> module = FakeModule(name='test-asg-created',
+> min_size=0, max_size=0, desired_capacity=0,
+> availability_zones=[s['az'] for s in scratch_vpc['subnets']],
+> vpc_zone_identifier=[s['id'] for s in scratch_vpc['subnets']],
+> )
+> ..... so on and so forth ....
+"""
+
+
+@pytest.fixture
+def placeboify(request, monkeypatch):
+ """This fixture puts a recording/replaying harness around `boto3_conn`
+
+ Placeboify patches the `boto3_conn` function in ec2 module_utils to return
+ a boto3 session that in recording or replaying mode, depending on the
+ PLACEBO_RECORD environment variable. Unset PLACEBO_RECORD (the common case
+ for just running tests) will put placebo in replay mode, set PLACEBO_RECORD
+ to any value to turn off replay & operate on real AWS resources.
+
+ The recorded sessions are stored in the test file's directory, under the
+ namespace `placebo_recordings/{testfile name}/{test function name}` to
+ distinguish them.
+ """
+ session = boto3.Session(region_name='us-west-2')
+
+ recordings_path = os.path.join(
+ request.fspath.dirname,
+ 'placebo_recordings',
+ request.fspath.basename.replace('.py', ''),
+ request.function.__name__
+ # remove the test_ prefix from the function & file name
+ ).replace('test_', '')
+
+ if not os.getenv('PLACEBO_RECORD'):
+ if not os.path.isdir(recordings_path):
+ raise NotImplementedError('Missing Placebo recordings in directory: %s' % recordings_path)
+ else:
+ try:
+ # make sure the directory for placebo test recordings is available
+ os.makedirs(recordings_path)
+ except OSError as e:
+ if e.errno != errno.EEXIST:
+ raise
+
+ pill = placebo.attach(session, data_path=recordings_path)
+ if os.getenv('PLACEBO_RECORD'):
+ pill.record()
+ else:
+ pill.playback()
+
+ def boto3_middleman_connection(module, conn_type, resource, region='us-west-2', **kwargs):
+ if conn_type != 'client':
+ # TODO support resource-based connections
+ raise ValueError('Mocker only supports client, not %s' % conn_type)
+ return session.client(resource, region_name=region)
+
+ import ansible_collections.amazon.aws.plugins.module_utils.ec2
+ monkeypatch.setattr(
+ ansible_collections.amazon.aws.plugins.module_utils.ec2,
+ 'boto3_conn',
+ boto3_middleman_connection,
+ )
+ yield session
+
+ # tear down
+ pill.stop()
+
+
+@pytest.fixture(scope='module')
+def basic_launch_config():
+ """Create an EC2 launch config whose creation *is not* recorded and return its name
+
+ This fixture is module-scoped, since launch configs are immutable and this
+ can be reused for many tests.
+ """
+ if not os.getenv('PLACEBO_RECORD'):
+ yield 'pytest_basic_lc'
+ return
+
+ # use a *non recording* session to make the launch config
+ # since that's a prereq of the ec2_asg module, and isn't what
+ # we're testing.
+ asg = boto3.client('autoscaling')
+ asg.create_launch_configuration(
+ LaunchConfigurationName='pytest_basic_lc',
+ ImageId='ami-9be6f38c', # Amazon Linux 2016.09 us-east-1 AMI, can be any valid AMI
+ SecurityGroups=[],
+ UserData='#!/bin/bash\necho hello world',
+ InstanceType='t2.micro',
+ InstanceMonitoring={'Enabled': False},
+ AssociatePublicIpAddress=True
+ )
+
+ yield 'pytest_basic_lc'
+
+ try:
+ asg.delete_launch_configuration(LaunchConfigurationName='pytest_basic_lc')
+ except botocore.exceptions.ClientError as e:
+ if 'not found' in e.message:
+ return
+ raise
+
+
+@pytest.fixture(scope='module')
+def scratch_vpc():
+ if not os.getenv('PLACEBO_RECORD'):
+ yield {
+ 'vpc_id': 'vpc-123456',
+ 'cidr_range': '10.0.0.0/16',
+ 'subnets': [
+ {
+ 'id': 'subnet-123456',
+ 'az': 'us-east-1d',
+ },
+ {
+ 'id': 'subnet-654321',
+ 'az': 'us-east-1e',
+ },
+ ]
+ }
+ return
+
+ # use a *non recording* session to make the base VPC and subnets
+ ec2 = boto3.client('ec2')
+ vpc_resp = ec2.create_vpc(
+ CidrBlock='10.0.0.0/16',
+ AmazonProvidedIpv6CidrBlock=False,
+ )
+ subnets = (
+ ec2.create_subnet(
+ VpcId=vpc_resp['Vpc']['VpcId'],
+ CidrBlock='10.0.0.0/24',
+ ),
+ ec2.create_subnet(
+ VpcId=vpc_resp['Vpc']['VpcId'],
+ CidrBlock='10.0.1.0/24',
+ )
+ )
+ time.sleep(3)
+
+ yield {
+ 'vpc_id': vpc_resp['Vpc']['VpcId'],
+ 'cidr_range': '10.0.0.0/16',
+ 'subnets': [
+ {
+ 'id': s['Subnet']['SubnetId'],
+ 'az': s['Subnet']['AvailabilityZone'],
+ } for s in subnets
+ ]
+ }
+
+ try:
+ for s in subnets:
+ try:
+ ec2.delete_subnet(SubnetId=s['Subnet']['SubnetId'])
+ except botocore.exceptions.ClientError as e:
+ if 'not found' in e.message:
+ continue
+ raise
+ ec2.delete_vpc(VpcId=vpc_resp['Vpc']['VpcId'])
+ except botocore.exceptions.ClientError as e:
+ if 'not found' in e.message:
+ return
+ raise
+
+
+@pytest.fixture(scope='module')
+def maybe_sleep():
+ """If placebo is reading saved sessions, make sleep always take 0 seconds.
+
+ AWS modules often perform polling or retries, but when using recorded
+ sessions there's no reason to wait. We can still exercise retry and other
+ code paths without waiting for wall-clock time to pass."""
+ if not os.getenv('PLACEBO_RECORD'):
+ p = mock.patch('time.sleep', return_value=None)
+ p.start()
+ yield
+ p.stop()
+ else:
+ yield