From 78ea25304571f5d2e778f1458999d5553c361956 Mon Sep 17 00:00:00 2001 From: Mark Chappell Date: Mon, 7 Nov 2022 14:27:17 +0100 Subject: [PATCH] Refacter lookup plugins (#1225) Refacter lookup plugins Depends-On: #1248 SUMMARY Refacters the lookup plugins to use common code for common boto3/botocore operations ISSUE TYPE Feature Pull Request COMPONENT NAME plugins/lookup/aws_account_attribute.py plugins/lookup/aws_secret.py plugins/lookup/aws_ssm.py plugins/module_utils/botocore.py plugins/module_utils/core.py plugins/module_utils/exceptions.py plugins/module_utils/modules.py ADDITIONAL INFORMATION Reviewed-by: Alina Buzachis Reviewed-by: Mark Chappell --- changelogs/fragments/1225-refacter-lookup.yml | 36 ++++ meta/runtime.yml | 7 + plugins/lookup/aws_account_attribute.py | 71 ++----- plugins/lookup/aws_service_ip_ranges.py | 12 +- ...aws_secret.py => secretsmanager_secret.py} | 156 ++++++--------- .../lookup/{aws_ssm.py => ssm_parameter.py} | 136 +++++-------- plugins/plugin_utils/base.py | 62 ++++++ plugins/plugin_utils/botocore.py | 60 ++++++ plugins/plugin_utils/lookup.py | 22 +++ .../aliases | 0 .../meta/main.yml | 0 .../tasks/main.yaml | 0 .../aliases | 0 .../defaults/main.yml | 0 .../meta/main.yml | 0 .../tasks/main.yml | 0 .../module_utils/botocore/test_boto3_conn.py | 100 ++++++++++ .../ansible_aws_module/test_passthrough.py | 178 ++++++++++++++++++ tests/unit/plugin_utils/base/test_plugin.py | 142 ++++++++++++++ .../botocore/test_boto3_conn_plugin.py | 136 +++++++++++++ .../botocore/test_get_aws_region.py | 86 +++++++++ .../botocore/test_get_connection_info.py | 85 +++++++++ .../plugin_utils/lookup/test_lookup_base.py | 44 +++++ tox.ini | 2 +- 24 files changed, 1083 insertions(+), 252 deletions(-) create mode 100644 changelogs/fragments/1225-refacter-lookup.yml rename plugins/lookup/{aws_secret.py => secretsmanager_secret.py} (56%) rename plugins/lookup/{aws_ssm.py => ssm_parameter.py} (63%) create mode 100644 plugins/plugin_utils/base.py create mode 100644 plugins/plugin_utils/botocore.py create mode 100644 plugins/plugin_utils/lookup.py rename tests/integration/targets/{lookup_aws_secret => lookup_secretsmanager_secret}/aliases (100%) rename tests/integration/targets/{lookup_aws_secret => lookup_secretsmanager_secret}/meta/main.yml (100%) rename tests/integration/targets/{lookup_aws_secret => lookup_secretsmanager_secret}/tasks/main.yaml (100%) rename tests/integration/targets/{lookup_aws_ssm => lookup_ssm_parameter}/aliases (100%) rename tests/integration/targets/{lookup_aws_ssm => lookup_ssm_parameter}/defaults/main.yml (100%) rename tests/integration/targets/{lookup_aws_ssm => lookup_ssm_parameter}/meta/main.yml (100%) rename tests/integration/targets/{lookup_aws_ssm => lookup_ssm_parameter}/tasks/main.yml (100%) create mode 100644 tests/unit/module_utils/botocore/test_boto3_conn.py create mode 100644 tests/unit/module_utils/modules/ansible_aws_module/test_passthrough.py create mode 100644 tests/unit/plugin_utils/base/test_plugin.py create mode 100644 tests/unit/plugin_utils/botocore/test_boto3_conn_plugin.py create mode 100644 tests/unit/plugin_utils/botocore/test_get_aws_region.py create mode 100644 tests/unit/plugin_utils/botocore/test_get_connection_info.py create mode 100644 tests/unit/plugin_utils/lookup/test_lookup_base.py diff --git a/changelogs/fragments/1225-refacter-lookup.yml b/changelogs/fragments/1225-refacter-lookup.yml new file mode 100644 index 00000000000..6c1a554db50 --- /dev/null +++ b/changelogs/fragments/1225-refacter-lookup.yml @@ -0,0 +1,36 @@ +minor_changes: +- aws_secret - the ``aws_secret`` lookup plugin has been renamed ``secretsmanager_secret``, ``aws_secret`` remains as an alias + (https://github.com/ansible-collections/amazon.aws/pull/1225). +- aws_ssm - the ``aws_ssm`` lookup plugin has been renamed ``ssm_parameter``, ``aws_ssm`` remains as an alias + (https://github.com/ansible-collections/amazon.aws/pull/1225). + +- aws_account_attribute - the ``aws_account_attribute`` lookup plugin has been refactored to use + ``AWSLookupBase`` as its base class + (https://github.com/ansible-collections/amazon.aws/pull/1225). +- aws_secret - the ``aws_secret`` lookup plugin has been refactored to use + ``AWSLookupBase`` as its base class + (https://github.com/ansible-collections/amazon.aws/pull/1225). +- aws_ssm - the ``aws_ssm`` lookup plugin has been refactored to use + ``AWSLookupBase`` as its base class + (https://github.com/ansible-collections/amazon.aws/pull/1225). + +- amazon.aws lookup plugins - ``aws_profile`` has been renamed to ``profile`` for consistency + between modules and plugins, ``aws_profile`` remains as an alias. + This change should have no observable effect for users outside the module/plugin documentation + (https://github.com/ansible-collections/amazon.aws/pull/1225). +- amazon.aws lookup plugins - ``aws_access_key`` has been renamed to ``access_key`` for consistency + between modules and plugins, ``aws_access_key`` remains as an alias. + This change should have no observable effect for users outside the module/plugin documentation + (https://github.com/ansible-collections/amazon.aws/pull/1225). +- amazon.aws lookup plugins - ``aws_secret_key`` has been renamed to ``secret_key`` for consistency + between modules and plugins, ``aws_secret_key`` remains as an alias. + This change should have no observable effect for users outside the module/plugin documentation + (https://github.com/ansible-collections/amazon.aws/pull/1225). +- amazon.aws lookup plugins - ``aws_security_token`` has been renamed to ``session_token`` for consistency + between modules and plugins, ``aws_security_token`` remains as an alias. + This change should have no observable effect for users outside the module/plugin documentation + (https://github.com/ansible-collections/amazon.aws/pull/1225). + +deprecated_features: +- amazon.aws lookup plugins - the ``boto3_profile`` alias for the ``profile`` option has been deprecated, please use ``profile`` instead + (https://github.com/ansible-collections/amazon.aws/pull/1225). diff --git a/meta/runtime.yml b/meta/runtime.yml index ea227181b8f..d06b23ede10 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -126,3 +126,10 @@ plugin_routing: execute_lambda: # Deprecation for this alias should not *start* prior to 2024-09-01 redirect: amazon.aws.lambda_execute + lookup: + aws_ssm: + # Deprecation for this alias should not *start* prior to 2024-09-01 + redirect: amazon.aws.ssm_parameter + aws_secret: + # Deprecation for this alias should not *start* prior to 2024-09-01 + redirect: amazon.aws.secretsmanager_secret diff --git a/plugins/lookup/aws_account_attribute.py b/plugins/lookup/aws_account_attribute.py index cded4fab3db..499a7f589d0 100644 --- a/plugins/lookup/aws_account_attribute.py +++ b/plugins/lookup/aws_account_attribute.py @@ -3,14 +3,10 @@ from __future__ import (absolute_import, division, print_function) __metaclass__ = type -DOCUMENTATION = ''' +DOCUMENTATION = r""" name: aws_account_attribute author: - Sloane Hertel (@s-hertel) -extends_documentation_fragment: - - amazon.aws.boto3 - - amazon.aws.aws_credentials - - amazon.aws.region.plugins short_description: Look up AWS account attributes description: - Describes attributes of your AWS account. You can specify one of the listed @@ -26,9 +22,13 @@ - max-elastic-ips - vpc-max-elastic-ips - has-ec2-classic -''' +extends_documentation_fragment: + - amazon.aws.boto3 + - amazon.aws.common.plugins + - amazon.aws.region.plugins +""" -EXAMPLES = """ +EXAMPLES = r""" vars: has_ec2_classic: "{{ lookup('aws_account_attribute', attribute='has-ec2-classic') }}" # true | false @@ -42,7 +42,7 @@ """ -RETURN = """ +RETURN = r""" _raw: description: Returns a boolean when I(attribute) is check_ec2_classic. Otherwise returns the value(s) of the attribute @@ -50,63 +50,26 @@ """ try: - import boto3 import botocore except ImportError: - pass # will be captured by imported HAS_BOTO3 + pass # Handled by AWSLookupBase -from ansible.errors import AnsibleError +from ansible.errors import AnsibleLookupError from ansible.module_utils._text import to_native -from ansible.module_utils.basic import missing_required_lib -from ansible.plugins.lookup import LookupBase - -from ansible_collections.amazon.aws.plugins.module_utils.ec2 import AWSRetry -from ansible_collections.amazon.aws.plugins.module_utils.ec2 import HAS_BOTO3 +from ansible_collections.amazon.aws.plugins.module_utils.retries import AWSRetry +from ansible_collections.amazon.aws.plugins.plugin_utils.lookup import AWSLookupBase -def _boto3_conn(region, credentials): - boto_profile = credentials.pop('aws_profile', None) - try: - connection = boto3.session.Session(profile_name=boto_profile).client('ec2', region, **credentials) - except (botocore.exceptions.ProfileNotFound, botocore.exceptions.PartialCredentialsError): - if boto_profile: - try: - connection = boto3.session.Session(profile_name=boto_profile).client('ec2', region) - except (botocore.exceptions.ProfileNotFound, botocore.exceptions.PartialCredentialsError): - raise AnsibleError("Insufficient credentials found.") - else: - raise AnsibleError("Insufficient credentials found.") - return connection - - -def _get_credentials(options): - credentials = {} - credentials['aws_profile'] = options['aws_profile'] - credentials['aws_secret_access_key'] = options['aws_secret_key'] - credentials['aws_access_key_id'] = options['aws_access_key'] - if options['aws_security_token']: - credentials['aws_session_token'] = options['aws_security_token'] - - return credentials - - -@AWSRetry.jittered_backoff(retries=10) def _describe_account_attributes(client, **params): - return client.describe_account_attributes(**params) + return client.describe_account_attributes(aws_retry=True, **params) -class LookupModule(LookupBase): +class LookupModule(AWSLookupBase): def run(self, terms, variables, **kwargs): + super(LookupModule, self).run(terms, variables, **kwargs) - if not HAS_BOTO3: - raise AnsibleError(missing_required_lib('botocore and boto3')) - - self.set_options(var_options=variables, direct=kwargs) - boto_credentials = _get_credentials(self._options) - - region = self._options['region'] - client = _boto3_conn(region, boto_credentials) + client = self.client('ec2', AWSRetry.jittered_backoff()) attribute = kwargs.get('attribute') params = {'AttributeNames': []} @@ -120,7 +83,7 @@ def run(self, terms, variables, **kwargs): try: response = _describe_account_attributes(client, **params)['AccountAttributes'] except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: - raise AnsibleError("Failed to describe account attributes: %s" % to_native(e)) + raise AnsibleLookupError("Failed to describe account attributes: {0}".format(to_native(e))) if check_ec2_classic: attr = response[0] diff --git a/plugins/lookup/aws_service_ip_ranges.py b/plugins/lookup/aws_service_ip_ranges.py index bd34d1bd131..938fcd20cf3 100644 --- a/plugins/lookup/aws_service_ip_ranges.py +++ b/plugins/lookup/aws_service_ip_ranges.py @@ -45,7 +45,7 @@ import json -from ansible.errors import AnsibleError +from ansible.errors import AnsibleLookupError from ansible.module_utils.six.moves.urllib.error import HTTPError from ansible.module_utils.six.moves.urllib.error import URLError from ansible.module_utils._text import to_native @@ -70,15 +70,15 @@ def run(self, terms, variables, **kwargs): except getattr(json.decoder, 'JSONDecodeError', ValueError) as e: # on Python 3+, json.decoder.JSONDecodeError is raised for bad # JSON. On 2.x it's a ValueError - raise AnsibleError("Could not decode AWS IP ranges: %s" % to_native(e)) + raise AnsibleLookupError("Could not decode AWS IP ranges: {0}".format(to_native(e))) except HTTPError as e: - raise AnsibleError("Received HTTP error while pulling IP ranges: %s" % to_native(e)) + raise AnsibleLookupError("Received HTTP error while pulling IP ranges: {0}".format(to_native(e))) except SSLValidationError as e: - raise AnsibleError("Error validating the server's certificate for: %s" % to_native(e)) + raise AnsibleLookupError("Error validating the server's certificate for: {0}".format(to_native(e))) except URLError as e: - raise AnsibleError("Failed look up IP range service: %s" % to_native(e)) + raise AnsibleLookupError("Failed look up IP range service: {0}".format(to_native(e))) except ConnectionError as e: - raise AnsibleError("Error connecting to IP range service: %s" % to_native(e)) + raise AnsibleLookupError("Error connecting to IP range service: {0}".format(to_native(e))) if 'region' in kwargs: region = kwargs['region'] diff --git a/plugins/lookup/aws_secret.py b/plugins/lookup/secretsmanager_secret.py similarity index 56% rename from plugins/lookup/aws_secret.py rename to plugins/lookup/secretsmanager_secret.py index e482f0a34f2..f7266e8994f 100644 --- a/plugins/lookup/aws_secret.py +++ b/plugins/lookup/secretsmanager_secret.py @@ -4,14 +4,10 @@ from __future__ import (absolute_import, division, print_function) __metaclass__ = type -DOCUMENTATION = r''' -name: aws_secret +DOCUMENTATION = r""" +name: secretsmanager_secret author: - Aaron Smith (!UNKNOWN) -extends_documentation_fragment: - - amazon.aws.boto3 - - amazon.aws.aws_credentials - - amazon.aws.region.plugins short_description: Look up secrets stored in AWS Secrets Manager description: @@ -19,6 +15,8 @@ has the appropriate permissions to read the secret. - Lookup is based on the secret's I(Name) value. - Optional parameters can be passed into this lookup; I(version_id) and I(version_stage) + - Prior to release 6.0.0 this module was known as C(aws_ssm), the usage remains the same. + options: _terms: description: Name of the secret to look up in AWS Secrets Manager. @@ -74,7 +72,11 @@ default: error type: string choices: ['error', 'skip', 'warn'] -''' +extends_documentation_fragment: + - amazon.aws.boto3 + - amazon.aws.common.plugins + - amazon.aws.region.plugins +""" EXAMPLES = r""" - name: lookup secretsmanager secret in the current region @@ -121,98 +123,47 @@ import json try: - import boto3 import botocore except ImportError: - pass # will be captured by imported HAS_BOTO3 + pass # Handled by AWSLookupBase -from ansible.errors import AnsibleError +from ansible.errors import AnsibleLookupError from ansible.module_utils.six import string_types from ansible.module_utils._text import to_native -from ansible.module_utils.basic import missing_required_lib -from ansible.plugins.lookup import LookupBase -from ansible_collections.amazon.aws.plugins.module_utils.core import is_boto3_error_code -from ansible_collections.amazon.aws.plugins.module_utils.core import is_boto3_error_message -from ansible_collections.amazon.aws.plugins.module_utils.ec2 import HAS_BOTO3 +from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code +from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_message +from ansible_collections.amazon.aws.plugins.module_utils.retries import AWSRetry +from ansible_collections.amazon.aws.plugins.plugin_utils.lookup import AWSLookupBase -def _boto3_conn(region, credentials): - boto_profile = credentials.pop('aws_profile', None) - try: - connection = boto3.session.Session(profile_name=boto_profile).client('secretsmanager', region, **credentials) - except (botocore.exceptions.ProfileNotFound, botocore.exceptions.PartialCredentialsError): - if boto_profile: - try: - connection = boto3.session.Session(profile_name=boto_profile).client('secretsmanager', region) - except (botocore.exceptions.ProfileNotFound, botocore.exceptions.PartialCredentialsError): - raise AnsibleError("Insufficient credentials found.") - else: - raise AnsibleError("Insufficient credentials found.") - return connection - - -class LookupModule(LookupBase): - def run(self, terms, variables=None, boto_profile=None, aws_profile=None, - aws_secret_key=None, aws_access_key=None, aws_security_token=None, region=None, - bypath=False, nested=False, join=False, version_stage=None, version_id=None, on_missing='error', - on_denied='error', on_deleted='error'): +class LookupModule(AWSLookupBase): + def run(self, terms, variables, **kwargs): + ''' + :arg terms: a list of lookups to run. + e.g. ['example_secret_name', 'example_secret_too' ] + :variables: ansible variables active at the time of the lookup + :returns: A list of parameter values or a list of dictionaries if bypath=True. ''' - :arg terms: a list of lookups to run. - e.g. ['parameter_name', 'parameter_name_too' ] - :kwarg variables: ansible variables active at the time of the lookup - :kwarg aws_secret_key: identity of the AWS key to use - :kwarg aws_access_key: AWS secret key (matching identity) - :kwarg aws_security_token: AWS session key if using STS - :kwarg decrypt: Set to True to get decrypted parameters - :kwarg region: AWS region in which to do the lookup - :kwarg bypath: Set to True to do a lookup of variables under a path - :kwarg nested: Set to True to do a lookup of nested secrets - :kwarg join: Join two or more entries to form an extended secret - :kwarg version_stage: Stage of the secret version - :kwarg version_id: Version of the secret(s) - :kwarg on_missing: Action to take if the secret is missing - :kwarg on_deleted: Action to take if the secret is marked for deletion - :kwarg on_denied: Action to take if access to the secret is denied - :returns: A list of parameter values or a list of dictionaries if bypath=True. - ''' - if not HAS_BOTO3: - raise AnsibleError(missing_required_lib('botocore and boto3')) - - deleted = on_deleted.lower() - if not isinstance(deleted, string_types) or deleted not in ['error', 'warn', 'skip']: - raise AnsibleError('"on_deleted" must be a string and one of "error", "warn" or "skip", not %s' % deleted) - - missing = on_missing.lower() - if not isinstance(missing, string_types) or missing not in ['error', 'warn', 'skip']: - raise AnsibleError('"on_missing" must be a string and one of "error", "warn" or "skip", not %s' % missing) - denied = on_denied.lower() - if not isinstance(denied, string_types) or denied not in ['error', 'warn', 'skip']: - raise AnsibleError('"on_denied" must be a string and one of "error", "warn" or "skip", not %s' % denied) + super(LookupModule, self).run(terms, variables, **kwargs) - credentials = {} - if aws_profile: - credentials['aws_profile'] = aws_profile - else: - credentials['aws_profile'] = boto_profile - credentials['aws_secret_access_key'] = aws_secret_key - credentials['aws_access_key_id'] = aws_access_key - credentials['aws_session_token'] = aws_security_token + on_missing = self.get_option('on_missing') + on_denied = self.get_option('on_denied') + on_deleted = self.get_option('on_deleted') - # fallback to IAM role credentials - if not credentials['aws_profile'] and not ( - credentials['aws_access_key_id'] and credentials['aws_secret_access_key']): - session = botocore.session.get_session() - if session.get_credentials() is not None: - credentials['aws_access_key_id'] = session.get_credentials().access_key - credentials['aws_secret_access_key'] = session.get_credentials().secret_key - credentials['aws_session_token'] = session.get_credentials().token + # validate arguments 'on_missing' and 'on_denied' + if on_missing is not None and (not isinstance(on_missing, string_types) or on_missing.lower() not in ['error', 'warn', 'skip']): + raise AnsibleLookupError('"on_missing" must be a string and one of "error", "warn" or "skip", not {0}'.format(on_missing)) + if on_denied is not None and (not isinstance(on_denied, string_types) or on_denied.lower() not in ['error', 'warn', 'skip']): + raise AnsibleLookupError('"on_denied" must be a string and one of "error", "warn" or "skip", not {0}'.format(on_denied)) + if on_deleted is not None and (not isinstance(on_deleted, string_types) or on_deleted.lower() not in ['error', 'warn', 'skip']): + raise AnsibleLookupError('"on_deleted" must be a string and one of "error", "warn" or "skip", not {0}'.format(on_deleted)) - client = _boto3_conn(region, credentials) + client = self.client('secretsmanager', AWSRetry.jittered_backoff()) - if bypath: + if self.get_option('bypath'): secrets = {} for term in terms: try: @@ -223,21 +174,24 @@ def run(self, terms, variables=None, boto_profile=None, aws_profile=None, if 'SecretList' in object: for secret_obj in object['SecretList']: secrets.update({secret_obj['Name']: self.get_secret_value( - secret_obj['Name'], client, on_missing=missing, on_denied=denied)}) + secret_obj['Name'], client, on_missing=on_missing, on_denied=on_denied)}) secrets = [secrets] except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: - raise AnsibleError("Failed to retrieve secret: %s" % to_native(e)) + raise AnsibleLookupError("Failed to retrieve secret: {0}".format(to_native(e))) else: secrets = [] for term in terms: - value = self.get_secret_value(term, client, - version_stage=version_stage, version_id=version_id, - on_missing=missing, on_denied=denied, on_deleted=deleted, - nested=nested) + value = self.get_secret_value( + term, client, + version_stage=self.get_option('version_stage'), + version_id=self.get_option('version_id'), + on_missing=on_missing, on_denied=on_denied, on_deleted=on_deleted, + nested=self.get_option('nested') + ) if value: secrets.append(value) - if join: + if self.get_option('join'): joined_secret = [] joined_secret.append(''.join(secrets)) return joined_secret @@ -253,12 +207,12 @@ def get_secret_value(self, term, client, version_stage=None, version_id=None, on params['VersionStage'] = version_stage if nested: if len(term.split('.')) < 2: - raise AnsibleError("Nested query must use the following syntax: `aws_secret_name..") + raise AnsibleLookupError("Nested query must use the following syntax: `aws_secret_name..") secret_name = term.split('.')[0] params['SecretId'] = secret_name try: - response = client.get_secret_value(**params) + response = client.get_secret_value(aws_retry=True, **params) if 'SecretBinary' in response: return response['SecretBinary'] if 'SecretString' in response: @@ -270,26 +224,26 @@ def get_secret_value(self, term, client, version_stage=None, version_id=None, on if key in ret_val: ret_val = ret_val[key] else: - raise AnsibleError("Successfully retrieved secret but there exists no key {0} in the secret".format(key)) + raise AnsibleLookupError("Successfully retrieved secret but there exists no key {0} in the secret".format(key)) return str(ret_val) else: return response['SecretString'] except is_boto3_error_message('marked for deletion'): if on_deleted == 'error': - raise AnsibleError("Failed to find secret %s (marked for deletion)" % term) + raise AnsibleLookupError("Failed to find secret {0} (marked for deletion)".format(term)) elif on_deleted == 'warn': - self._display.warning('Skipping, did not find secret (marked for deletion) %s' % term) + self._display.warning('Skipping, did not find secret (marked for deletion) {0}'.format(term)) except is_boto3_error_code('ResourceNotFoundException'): # pylint: disable=duplicate-except if on_missing == 'error': - raise AnsibleError("Failed to find secret %s (ResourceNotFound)" % term) + raise AnsibleLookupError("Failed to find secret {0} (ResourceNotFound)".format(term)) elif on_missing == 'warn': - self._display.warning('Skipping, did not find secret %s' % term) + self._display.warning('Skipping, did not find secret {0}'.format(term)) except is_boto3_error_code('AccessDeniedException'): # pylint: disable=duplicate-except if on_denied == 'error': - raise AnsibleError("Failed to access secret %s (AccessDenied)" % term) + raise AnsibleLookupError("Failed to access secret {0} (AccessDenied)".format(term)) elif on_denied == 'warn': - self._display.warning('Skipping, access denied for secret %s' % term) + self._display.warning('Skipping, access denied for secret {0}'.format(term)) except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: # pylint: disable=duplicate-except - raise AnsibleError("Failed to retrieve secret: %s" % to_native(e)) + raise AnsibleLookupError("Failed to retrieve secret: {0}".format(to_native(e))) return None diff --git a/plugins/lookup/aws_ssm.py b/plugins/lookup/ssm_parameter.py similarity index 63% rename from plugins/lookup/aws_ssm.py rename to plugins/lookup/ssm_parameter.py index 93de478576f..f04c126f060 100644 --- a/plugins/lookup/aws_ssm.py +++ b/plugins/lookup/ssm_parameter.py @@ -7,28 +7,29 @@ from __future__ import (absolute_import, division, print_function) __metaclass__ = type -DOCUMENTATION = ''' -name: aws_ssm +DOCUMENTATION = r""" +name: ssm_parameter author: - Bill Wang (!UNKNOWN) - Marat Bakeev (!UNKNOWN) - Michael De La Rue (!UNKNOWN) -short_description: Get the value for a SSM parameter or all parameters under a path +short_description: gets the value for a SSM parameter or all parameters under a path description: - Get the value for an Amazon Simple Systems Manager parameter or a hierarchy of parameters. The first argument you pass the lookup can either be a parameter name or a hierarchy of parameters. Hierarchies start with a forward slash and end with the parameter name. Up to 5 layers may be specified. - If looking up an explicitly listed parameter by name which does not exist then the lookup - will generate an error. You can use the ```default``` filter to give a default value in - this case but must set the ```on_missing``` parameter to ```skip``` or ```warn```. You must - also set the second parameter of the ```default``` filter to ```true``` (see examples below). + will generate an error. You can use the C(default) filter to give a default value in + this case but must set the I(on_missing) parameter to C(skip) or C(warn). You must + also set the second parameter of the C(default) filter to C(true) (see examples below). - When looking up a path for parameters under it a dictionary will be returned for each path. If there is no parameter under that path then the lookup will generate an error. - If the lookup fails due to lack of permissions or due to an AWS client error then the aws_ssm will generate an error. If you want to continue in this case then you will have to set up two ansible tasks, one which sets a variable and ignores failures and one which uses the value of that variable with a default. See the examples below. + - Prior to release 6.0.0 this module was known as C(aws_ssm), the usage remains the same. options: decrypt: @@ -67,16 +68,13 @@ type: string choices: ['error', 'skip', 'warn'] version_added: 2.0.0 - endpoint: - description: Use a custom endpoint when connecting to SSM service. - type: string - version_added: 3.3.0 extends_documentation_fragment: - amazon.aws.boto3 + - amazon.aws.common.plugins - amazon.aws.region.plugins -''' +""" -EXAMPLES = ''' +EXAMPLES = r""" # lookup sample: - name: lookup ssm parameter store in the current region debug: msg="{{ lookup('aws_ssm', 'Hello' ) }}" @@ -126,110 +124,68 @@ - name: lookup ssm parameter warn if access is denied debug: msg="{{ lookup('aws_ssm', 'missing-parameter', on_denied="warn" ) }}" -''' +""" try: import botocore except ImportError: - pass # will be captured by imported HAS_BOTO3 + pass # Handled by AWSLookupBase -from ansible.errors import AnsibleError +from ansible.errors import AnsibleLookupError from ansible.module_utils._text import to_native -from ansible.plugins.lookup import LookupBase from ansible.utils.display import Display from ansible.module_utils.six import string_types -from ansible.module_utils.basic import missing_required_lib -from ansible_collections.amazon.aws.plugins.module_utils.ec2 import boto3_conn -from ansible_collections.amazon.aws.plugins.module_utils.ec2 import get_aws_connection_info -from ansible_collections.amazon.aws.plugins.module_utils.ec2 import HAS_BOTO3 -from ansible_collections.amazon.aws.plugins.module_utils.ec2 import boto3_tag_list_to_ansible_dict -from ansible_collections.amazon.aws.plugins.module_utils.core import is_boto3_error_code +from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code +from ansible_collections.amazon.aws.plugins.module_utils.tagging import boto3_tag_list_to_ansible_dict +from ansible_collections.amazon.aws.plugins.module_utils.retries import AWSRetry + +from ansible_collections.amazon.aws.plugins.plugin_utils.lookup import AWSLookupBase display = Display() -class LookupModule(LookupBase): - def run(self, terms, variables=None, boto_profile=None, aws_profile=None, - aws_secret_key=None, aws_access_key=None, aws_security_token=None, region=None, - bypath=False, shortnames=False, recursive=False, decrypt=True, on_missing="error", - on_denied="error", endpoint=None): +class LookupModule(AWSLookupBase): + def run(self, terms, variables, **kwargs): ''' :arg terms: a list of lookups to run. e.g. ['parameter_name', 'parameter_name_too' ] :kwarg variables: ansible variables active at the time of the lookup - :kwarg aws_secret_key: identity of the AWS key to use - :kwarg aws_access_key: AWS secret key (matching identity) - :kwarg aws_security_token: AWS session key if using STS - :kwarg decrypt: Set to True to get decrypted parameters - :kwarg region: AWS region in which to do the lookup - :kwarg bypath: Set to True to do a lookup of variables under a path - :kwarg recursive: Set to True to recurse below the path (requires bypath=True) - :kwarg on_missing: Action to take if the SSM parameter is missing - :kwarg on_denied: Action to take if access to the SSM parameter is denied - :kwarg endpoint: Endpoint for SSM client :returns: A list of parameter values or a list of dictionaries if bypath=True. ''' - if not HAS_BOTO3: - raise AnsibleError(missing_required_lib('botocore and boto3')) + super(LookupModule, self).run(terms, variables, **kwargs) + + on_missing = self.get_option('on_missing') + on_denied = self.get_option('on_denied') # validate arguments 'on_missing' and 'on_denied' if on_missing is not None and (not isinstance(on_missing, string_types) or on_missing.lower() not in ['error', 'warn', 'skip']): - raise AnsibleError('"on_missing" must be a string and one of "error", "warn" or "skip", not %s' % on_missing) + raise AnsibleLookupError('"on_missing" must be a string and one of "error", "warn" or "skip", not {0}'.format(on_missing)) if on_denied is not None and (not isinstance(on_denied, string_types) or on_denied.lower() not in ['error', 'warn', 'skip']): - raise AnsibleError('"on_denied" must be a string and one of "error", "warn" or "skip", not %s' % on_denied) + raise AnsibleLookupError('"on_denied" must be a string and one of "error", "warn" or "skip", not {0}'.format(on_denied)) ret = [] ssm_dict = {} - self.params = variables - - cli_region, cli_endpoint, cli_boto_params = get_aws_connection_info(self, boto3=True) - - if region: - cli_region = region - - if endpoint: - cli_endpoint = endpoint - - # For backward compatibility - if aws_access_key: - cli_boto_params.update({'aws_access_key_id': aws_access_key}) - if aws_secret_key: - cli_boto_params.update({'aws_secret_access_key': aws_secret_key}) - if aws_security_token: - cli_boto_params.update({'aws_session_token': aws_security_token}) - if boto_profile: - cli_boto_params.update({'profile_name': boto_profile}) - if aws_profile: - cli_boto_params.update({'profile_name': aws_profile}) - - cli_boto_params.update(dict( - conn_type='client', - resource='ssm', - region=cli_region, - endpoint=cli_endpoint, - )) - - client = boto3_conn(module=self, **cli_boto_params) + client = self.client('ssm', AWSRetry.jittered_backoff()) - ssm_dict['WithDecryption'] = decrypt + ssm_dict['WithDecryption'] = self.get_option('decrypt') # Lookup by path - if bypath: - ssm_dict['Recursive'] = recursive + if self.get_option('bypath'): + ssm_dict['Recursive'] = self.get_option('recursive') for term in terms: - display.vvv("AWS_ssm path lookup term: %s in region: %s" % (term, region)) + display.vvv("AWS_ssm path lookup term: {0} in region: {1}".format(term, self.region)) paramlist = self.get_path_parameters(client, ssm_dict, term, on_missing.lower(), on_denied.lower()) # Shorten parameter names. Yes, this will return # duplicate names with different values. - if shortnames: + if self.get_option('shortnames'): for x in paramlist: x['Name'] = x['Name'][x['Name'].rfind('/') + 1:] - display.vvvv("AWS_ssm path lookup returned: %s" % str(paramlist)) + display.vvvv("AWS_ssm path lookup returned: {0}".format(to_native(paramlist))) ret.append(boto3_tag_list_to_ansible_dict(paramlist, tag_name_key_name="Name", @@ -237,10 +193,10 @@ def run(self, terms, variables=None, boto_profile=None, aws_profile=None, # Lookup by parameter name - always returns a list with one or # no entry. else: - display.vvv("AWS_ssm name lookup term: %s" % terms) + display.vvv("AWS_ssm name lookup term: {0}".format(terms)) for term in terms: ret.append(self.get_parameter_value(client, ssm_dict, term, on_missing.lower(), on_denied.lower())) - display.vvvv("AWS_ssm path lookup returning: %s " % str(ret)) + display.vvvv("AWS_ssm path lookup returning: {0} ".format(to_native(ret))) return ret def get_path_parameters(self, client, ssm_dict, term, on_missing, on_denied): @@ -250,38 +206,38 @@ def get_path_parameters(self, client, ssm_dict, term, on_missing, on_denied): paramlist = paginator.paginate(**ssm_dict).build_full_result()['Parameters'] except is_boto3_error_code('AccessDeniedException'): if on_denied == 'error': - raise AnsibleError("Failed to access SSM parameter path %s (AccessDenied)" % term) + raise AnsibleLookupError("Failed to access SSM parameter path {0} (AccessDenied)".format(term)) elif on_denied == 'warn': - self._display.warning('Skipping, access denied for SSM parameter path %s' % term) + self.warn('Skipping, access denied for SSM parameter path {0}'.format(term)) paramlist = [{}] elif on_denied == 'skip': paramlist = [{}] except botocore.exceptions.ClientError as e: # pylint: disable=duplicate-except - raise AnsibleError("SSM lookup exception: {0}".format(to_native(e))) + raise AnsibleLookupError("SSM lookup exception: {0}".format(to_native(e))) if not len(paramlist): if on_missing == "error": - raise AnsibleError("Failed to find SSM parameter path %s (ResourceNotFound)" % term) + raise AnsibleLookupError("Failed to find SSM parameter path {0} (ResourceNotFound)".format(term)) elif on_missing == "warn": - self._display.warning('Skipping, did not find SSM parameter path %s' % term) + self.warn('Skipping, did not find SSM parameter path {0}'.format(term)) return paramlist def get_parameter_value(self, client, ssm_dict, term, on_missing, on_denied): ssm_dict["Name"] = term try: - response = client.get_parameter(**ssm_dict) + response = client.get_parameter(aws_retry=True, **ssm_dict) return response['Parameter']['Value'] except is_boto3_error_code('ParameterNotFound'): if on_missing == 'error': - raise AnsibleError("Failed to find SSM parameter %s (ResourceNotFound)" % term) + raise AnsibleLookupError("Failed to find SSM parameter {0} (ResourceNotFound)".format(term)) elif on_missing == 'warn': - self._display.warning('Skipping, did not find SSM parameter %s' % term) + self.warn('Skipping, did not find SSM parameter {0}'.format(term)) except is_boto3_error_code('AccessDeniedException'): # pylint: disable=duplicate-except if on_denied == 'error': - raise AnsibleError("Failed to access SSM parameter %s (AccessDenied)" % term) + raise AnsibleLookupError("Failed to access SSM parameter {0} (AccessDenied)".format(term)) elif on_denied == 'warn': - self._display.warning('Skipping, access denied for SSM parameter %s' % term) + self.warn('Skipping, access denied for SSM parameter {0}'.format(term)) except botocore.exceptions.ClientError as e: # pylint: disable=duplicate-except - raise AnsibleError("SSM lookup exception: {0}".format(to_native(e))) + raise AnsibleLookupError("SSM lookup exception: {0}".format(to_native(e))) return None diff --git a/plugins/plugin_utils/base.py b/plugins/plugin_utils/base.py new file mode 100644 index 00000000000..351faed2011 --- /dev/null +++ b/plugins/plugin_utils/base.py @@ -0,0 +1,62 @@ +# (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.errors import AnsibleError +from ansible.module_utils.basic import to_native +from ansible.utils.display import Display + +from ansible_collections.amazon.aws.plugins.module_utils.botocore import check_sdk_version_supported +from ansible_collections.amazon.aws.plugins.plugin_utils.botocore import boto3_conn +from ansible_collections.amazon.aws.plugins.plugin_utils.botocore import get_aws_connection_info +from ansible_collections.amazon.aws.plugins.plugin_utils.botocore import get_aws_region + +from ansible_collections.amazon.aws.plugins.module_utils.retries import RetryingBotoClientWrapper + +display = Display() + + +class AWSPluginBase(): + + def warn(self, message): + display.warning(message) + + def debug(self, message): + display.debug(message) + + # Should be overridden with the plugin-type specific exception + def _do_fail(self, message): + raise AnsibleError(message) + + # We don't know what the correct exception is to raise, so the actual "raise" is handled by + # _do_fail() + def fail_aws(self, message, exception=None): + if not exception: + self._do_fail(to_native(message)) + self._do_fail("{0}: {1}".format(message, to_native(exception))) + + def client(self, service, retry_decorator=None): + region, endpoint_url, aws_connect_kwargs = get_aws_connection_info(self) + conn = boto3_conn(self, conn_type='client', resource=service, + region=region, endpoint=endpoint_url, **aws_connect_kwargs) + return conn if retry_decorator is None else RetryingBotoClientWrapper(conn, retry_decorator) + + def resource(self, service): + region, endpoint_url, aws_connect_kwargs = get_aws_connection_info(self) + return boto3_conn(self, conn_type='resource', resource=service, + region=region, endpoint=endpoint_url, **aws_connect_kwargs) + + @property + def region(self): + return get_aws_region(self) + + def require_aws_sdk(self, botocore_version=None, boto3_version=None): + return check_sdk_version_supported( + botocore_version=botocore_version, + boto3_version=boto3_version, + warn=self.warn + ) diff --git a/plugins/plugin_utils/botocore.py b/plugins/plugin_utils/botocore.py new file mode 100644 index 00000000000..203f1fd1af5 --- /dev/null +++ b/plugins/plugin_utils/botocore.py @@ -0,0 +1,60 @@ +# (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) + +try: + import botocore +except ImportError: + pass # will be captured by imported HAS_BOTO3 + +from ansible.module_utils.basic import to_native + +from ansible_collections.amazon.aws.plugins.module_utils.botocore import _aws_connection_info +from ansible_collections.amazon.aws.plugins.module_utils.botocore import _aws_region +from ansible_collections.amazon.aws.plugins.module_utils.botocore import _boto3_conn +from ansible_collections.amazon.aws.plugins.module_utils.exceptions import AnsibleBotocoreError + + +def boto3_conn(plugin, conn_type=None, resource=None, region=None, endpoint=None, **params): + """ + Builds a boto3 resource/client connection cleanly wrapping the most common failures. + Handles: + ValueError, + botocore.exceptions.ProfileNotFound, botocore.exceptions.PartialCredentialsError, + botocore.exceptions.NoCredentialsError, botocore.exceptions.ConfigParseError, + botocore.exceptions.NoRegionError + """ + + try: + return _boto3_conn(conn_type=conn_type, resource=resource, region=region, endpoint=endpoint, **params) + except ValueError as e: + plugin.fail_aws("Couldn't connect to AWS: {0}".format(to_native(e))) + except (botocore.exceptions.ProfileNotFound, botocore.exceptions.PartialCredentialsError, + botocore.exceptions.NoCredentialsError, botocore.exceptions.ConfigParseError) as e: + plugin.fail_aws(to_native(e)) + except botocore.exceptions.NoRegionError: + # ansible_name is added in 2.14 + if hasattr(plugin, 'ansible_name'): + plugin.fail_aws( + "The {0} plugin requires a region and none was found in configuration, " + "environment variables or module parameters".format(plugin.ansible_name) + ) + plugin.fail_aws( + "A region is required and none was found in configuration, " + "environment variables or module parameters" + ) + + +def get_aws_connection_info(plugin): + try: + return _aws_connection_info(plugin.get_options()) + except AnsibleBotocoreError as e: + plugin.fail_aws(to_native(e)) + + +def get_aws_region(plugin): + try: + return _aws_region(plugin.get_options()) + except AnsibleBotocoreError as e: + plugin.fail_aws(to_native(e)) diff --git a/plugins/plugin_utils/lookup.py b/plugins/plugin_utils/lookup.py new file mode 100644 index 00000000000..7e07d0caf77 --- /dev/null +++ b/plugins/plugin_utils/lookup.py @@ -0,0 +1,22 @@ +# (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.errors import AnsibleLookupError +from ansible.plugins.lookup import LookupBase + +from ansible_collections.amazon.aws.plugins.plugin_utils.base import AWSPluginBase + + +class AWSLookupBase(AWSPluginBase, LookupBase): + + def _do_fail(self, message): + raise AnsibleLookupError(message) + + def run(self, terms, variables, botocore_version=None, boto3_version=None, **kwargs): + self.require_aws_sdk(botocore_version=botocore_version, boto3_version=boto3_version) + self.set_options(var_options=variables, direct=kwargs) diff --git a/tests/integration/targets/lookup_aws_secret/aliases b/tests/integration/targets/lookup_secretsmanager_secret/aliases similarity index 100% rename from tests/integration/targets/lookup_aws_secret/aliases rename to tests/integration/targets/lookup_secretsmanager_secret/aliases diff --git a/tests/integration/targets/lookup_aws_secret/meta/main.yml b/tests/integration/targets/lookup_secretsmanager_secret/meta/main.yml similarity index 100% rename from tests/integration/targets/lookup_aws_secret/meta/main.yml rename to tests/integration/targets/lookup_secretsmanager_secret/meta/main.yml diff --git a/tests/integration/targets/lookup_aws_secret/tasks/main.yaml b/tests/integration/targets/lookup_secretsmanager_secret/tasks/main.yaml similarity index 100% rename from tests/integration/targets/lookup_aws_secret/tasks/main.yaml rename to tests/integration/targets/lookup_secretsmanager_secret/tasks/main.yaml diff --git a/tests/integration/targets/lookup_aws_ssm/aliases b/tests/integration/targets/lookup_ssm_parameter/aliases similarity index 100% rename from tests/integration/targets/lookup_aws_ssm/aliases rename to tests/integration/targets/lookup_ssm_parameter/aliases diff --git a/tests/integration/targets/lookup_aws_ssm/defaults/main.yml b/tests/integration/targets/lookup_ssm_parameter/defaults/main.yml similarity index 100% rename from tests/integration/targets/lookup_aws_ssm/defaults/main.yml rename to tests/integration/targets/lookup_ssm_parameter/defaults/main.yml diff --git a/tests/integration/targets/lookup_aws_ssm/meta/main.yml b/tests/integration/targets/lookup_ssm_parameter/meta/main.yml similarity index 100% rename from tests/integration/targets/lookup_aws_ssm/meta/main.yml rename to tests/integration/targets/lookup_ssm_parameter/meta/main.yml diff --git a/tests/integration/targets/lookup_aws_ssm/tasks/main.yml b/tests/integration/targets/lookup_ssm_parameter/tasks/main.yml similarity index 100% rename from tests/integration/targets/lookup_aws_ssm/tasks/main.yml rename to tests/integration/targets/lookup_ssm_parameter/tasks/main.yml diff --git a/tests/unit/module_utils/botocore/test_boto3_conn.py b/tests/unit/module_utils/botocore/test_boto3_conn.py new file mode 100644 index 00000000000..52cc235ed29 --- /dev/null +++ b/tests/unit/module_utils/botocore/test_boto3_conn.py @@ -0,0 +1,100 @@ +# (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 + +try: + import botocore +except ImportError: + pass + +import pytest +from unittest.mock import MagicMock +from unittest.mock import sentinel +from unittest.mock import call + +import ansible_collections.amazon.aws.plugins.module_utils.botocore as utils_botocore + + +class FailException(Exception): + pass + + +@pytest.fixture +def aws_module(monkeypatch): + aws_module = MagicMock() + aws_module.fail_json.side_effect = FailException() + monkeypatch.setattr(aws_module, '_name', sentinel.MODULE_NAME) + return aws_module + + +@pytest.fixture +def botocore_utils(monkeypatch): + return utils_botocore + + +############################################################### +# module_utils.botocore.boto3_conn +############################################################### +def test_boto3_conn_success(monkeypatch, aws_module, botocore_utils): + connection_method = MagicMock(name='_boto3_conn') + monkeypatch.setattr(botocore_utils, '_boto3_conn', connection_method) + connection_method.return_value = sentinel.RETURNED_CONNECTION + + assert botocore_utils.boto3_conn(aws_module) is sentinel.RETURNED_CONNECTION + passed_args = connection_method.call_args + assert passed_args == call(conn_type=None, resource=None, region=None, endpoint=None) + + result = botocore_utils.boto3_conn( + aws_module, + conn_type=sentinel.PARAM_CONNTYPE, + resource=sentinel.PARAM_RESOURCE, + region=sentinel.PARAM_REGION, + endpoint=sentinel.PARAM_ENDPOINT, + extra_arg=sentinel.PARAM_EXTRA, + ) + assert result is sentinel.RETURNED_CONNECTION + passed_args = connection_method.call_args + assert passed_args == call( + conn_type=sentinel.PARAM_CONNTYPE, + resource=sentinel.PARAM_RESOURCE, + region=sentinel.PARAM_REGION, + endpoint=sentinel.PARAM_ENDPOINT, + extra_arg=sentinel.PARAM_EXTRA, + ) + + +@pytest.mark.parametrize( + "failure, custom_error", + [ + (ValueError(sentinel.VALUE_ERROR), + "Couldn't connect to AWS: sentinel.VALUE_ERROR"), + (botocore.exceptions.ProfileNotFound(profile=sentinel.PROFILE_ERROR), + None), + (botocore.exceptions.PartialCredentialsError(provider=sentinel.CRED_ERROR_PROV, + cred_var=sentinel.CRED_ERROR_VAR), + None), + (botocore.exceptions.NoCredentialsError(), + None), + (botocore.exceptions.ConfigParseError(path=sentinel.PARSE_ERROR), + None), + (botocore.exceptions.NoRegionError(), + "The sentinel.MODULE_NAME module requires a region and none was found"), + ], +) +def test_boto3_conn_exception(monkeypatch, aws_module, botocore_utils, failure, custom_error): + connection_method = MagicMock(name='_boto3_conn') + monkeypatch.setattr(botocore_utils, '_boto3_conn', connection_method) + connection_method.side_effect = failure + + if custom_error is None: + custom_error = str(failure) + + with pytest.raises(FailException): + botocore_utils.boto3_conn(aws_module) + + fail_args = aws_module.fail_json.call_args + assert custom_error in fail_args[1]['msg'] diff --git a/tests/unit/module_utils/modules/ansible_aws_module/test_passthrough.py b/tests/unit/module_utils/modules/ansible_aws_module/test_passthrough.py new file mode 100644 index 00000000000..87997a3220a --- /dev/null +++ b/tests/unit/module_utils/modules/ansible_aws_module/test_passthrough.py @@ -0,0 +1,178 @@ +# (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 call +from unittest.mock import MagicMock +from unittest.mock import sentinel +import warnings + +import ansible_collections.amazon.aws.plugins.module_utils.modules as utils_module + + +@pytest.mark.parametrize("stdin", [{}], indirect=["stdin"]) +def test_params(monkeypatch, stdin): + aws_module = utils_module.AnsibleAWSModule(argument_spec=dict()) + monkeypatch.setattr(aws_module._module, 'params', sentinel.RETURNED_PARAMS) + + assert aws_module.params is sentinel.RETURNED_PARAMS + + +@pytest.mark.parametrize("stdin", [{}], indirect=["stdin"]) +def test_debug(monkeypatch, stdin): + aws_module = utils_module.AnsibleAWSModule(argument_spec=dict()) + monkeypatch.setattr(aws_module._module, 'debug', warnings.warn) + + with pytest.warns(UserWarning, match="My debug message"): + aws_module.debug("My debug message") + + +@pytest.mark.parametrize("stdin", [{}], indirect=["stdin"]) +def test_warn(monkeypatch, stdin): + aws_module = utils_module.AnsibleAWSModule(argument_spec=dict()) + monkeypatch.setattr(aws_module._module, 'warn', warnings.warn) + + with pytest.warns(UserWarning, match="My warning message"): + aws_module.warn("My warning message") + + +@pytest.mark.parametrize("stdin", [{}], indirect=["stdin"]) +def test_deprecate(monkeypatch, stdin): + kwargs = {'example': sentinel.KWARG} + deprecate = MagicMock(name='deprecate') + deprecate.return_value = sentinel.RET_DEPRECATE + + aws_module = utils_module.AnsibleAWSModule(argument_spec=dict()) + monkeypatch.setattr(aws_module._module, 'deprecate', deprecate) + assert aws_module.deprecate(sentinel.PARAM_DEPRECATE, **kwargs) is sentinel.RET_DEPRECATE + assert deprecate.call_args == call(sentinel.PARAM_DEPRECATE, **kwargs) + + +@pytest.mark.parametrize("stdin", [{}], indirect=["stdin"]) +def test_gather_versions(monkeypatch, stdin): + gather_sdk_versions = MagicMock(name='gather_sdk_versions') + gather_sdk_versions.return_value = sentinel.RETURNED_SDK_VERSIONS + monkeypatch.setattr(utils_module, 'gather_sdk_versions', gather_sdk_versions) + aws_module = utils_module.AnsibleAWSModule(argument_spec=dict()) + + assert aws_module._gather_versions() is sentinel.RETURNED_SDK_VERSIONS + assert gather_sdk_versions.call_args == call() + + +@pytest.mark.parametrize("stdin", [{}], indirect=["stdin"]) +def test_region(monkeypatch, stdin): + get_aws_region = MagicMock(name='get_aws_region') + get_aws_region.return_value = sentinel.RETURNED_REGION + monkeypatch.setattr(utils_module, 'get_aws_region', get_aws_region) + aws_module = utils_module.AnsibleAWSModule(argument_spec=dict()) + + assert aws_module.region is sentinel.RETURNED_REGION + assert get_aws_region.call_args == call(aws_module, True) + + +@pytest.mark.parametrize("stdin", [{}], indirect=["stdin"]) +def test_boto3_at_least(monkeypatch, stdin): + boto3_at_least = MagicMock(name='boto3_at_least') + boto3_at_least.return_value = sentinel.RET_BOTO3_AT_LEAST + monkeypatch.setattr(utils_module, 'boto3_at_least', boto3_at_least) + + aws_module = utils_module.AnsibleAWSModule(argument_spec=dict()) + assert aws_module.boto3_at_least(sentinel.PARAM_BOTO3) is sentinel.RET_BOTO3_AT_LEAST + assert boto3_at_least.call_args == call(sentinel.PARAM_BOTO3) + + +@pytest.mark.parametrize("stdin", [{}], indirect=["stdin"]) +def test_botocore_at_least(monkeypatch, stdin): + botocore_at_least = MagicMock(name='botocore_at_least') + botocore_at_least.return_value = sentinel.RET_BOTOCORE_AT_LEAST + monkeypatch.setattr(utils_module, 'botocore_at_least', botocore_at_least) + + aws_module = utils_module.AnsibleAWSModule(argument_spec=dict()) + assert aws_module.botocore_at_least(sentinel.PARAM_BOTOCORE) is sentinel.RET_BOTOCORE_AT_LEAST + assert botocore_at_least.call_args == call(sentinel.PARAM_BOTOCORE) + + +@pytest.mark.parametrize("stdin", [{}], indirect=["stdin"]) +def test_boolean(monkeypatch, stdin): + boolean = MagicMock(name='boolean') + boolean.return_value = sentinel.RET_BOOLEAN + + aws_module = utils_module.AnsibleAWSModule(argument_spec=dict()) + monkeypatch.setattr(aws_module._module, 'boolean', boolean) + assert aws_module.boolean(sentinel.PARAM_BOOLEAN) is sentinel.RET_BOOLEAN + assert boolean.call_args == call(sentinel.PARAM_BOOLEAN) + + +@pytest.mark.parametrize("stdin", [{}], indirect=["stdin"]) +def test_md5(monkeypatch, stdin): + md5 = MagicMock(name='md5') + md5.return_value = sentinel.RET_MD5 + + aws_module = utils_module.AnsibleAWSModule(argument_spec=dict()) + monkeypatch.setattr(aws_module._module, 'md5', md5) + assert aws_module.md5(sentinel.PARAM_MD5) is sentinel.RET_MD5 + assert md5.call_args == call(sentinel.PARAM_MD5) + + +@pytest.mark.parametrize("stdin", [{}], indirect=["stdin"]) +def test_client_no_wrapper(monkeypatch, stdin): + get_aws_connection_info = MagicMock(name='get_aws_connection_info') + sentinel.CONN_ARGS = dict() + get_aws_connection_info.return_value = (sentinel.CONN_REGION, sentinel.CONN_URL, sentinel.CONN_ARGS) + monkeypatch.setattr(utils_module, 'get_aws_connection_info', get_aws_connection_info) + boto3_conn = MagicMock(name='boto3_conn') + boto3_conn.return_value = sentinel.BOTO3_CONN + monkeypatch.setattr(utils_module, 'boto3_conn', boto3_conn) + + aws_module = utils_module.AnsibleAWSModule(argument_spec=dict()) + assert aws_module.client(sentinel.PARAM_SERVICE) is sentinel.BOTO3_CONN + assert get_aws_connection_info.call_args == call(aws_module, boto3=True) + assert boto3_conn.call_args == call(aws_module, conn_type='client', + resource=sentinel.PARAM_SERVICE, + region=sentinel.CONN_REGION, + endpoint=sentinel.CONN_URL,) + + +@pytest.mark.parametrize("stdin", [{}], indirect=["stdin"]) +def test_client_wrapper(monkeypatch, stdin): + get_aws_connection_info = MagicMock(name='get_aws_connection_info') + sentinel.CONN_ARGS = dict() + get_aws_connection_info.return_value = (sentinel.CONN_REGION, sentinel.CONN_URL, sentinel.CONN_ARGS) + monkeypatch.setattr(utils_module, 'get_aws_connection_info', get_aws_connection_info) + boto3_conn = MagicMock(name='boto3_conn') + boto3_conn.return_value = sentinel.BOTO3_CONN + monkeypatch.setattr(utils_module, 'boto3_conn', boto3_conn) + + aws_module = utils_module.AnsibleAWSModule(argument_spec=dict()) + wrapped_conn = aws_module.client(sentinel.PARAM_SERVICE, sentinel.PARAM_WRAPPER) + assert wrapped_conn.client is sentinel.BOTO3_CONN + assert wrapped_conn.retry is sentinel.PARAM_WRAPPER + assert get_aws_connection_info.call_args == call(aws_module, boto3=True) + assert boto3_conn.call_args == call(aws_module, conn_type='client', + resource=sentinel.PARAM_SERVICE, + region=sentinel.CONN_REGION, + endpoint=sentinel.CONN_URL,) + + +@pytest.mark.parametrize("stdin", [{}], indirect=["stdin"]) +def test_resource(monkeypatch, stdin): + get_aws_connection_info = MagicMock(name='get_aws_connection_info') + sentinel.CONN_ARGS = dict() + get_aws_connection_info.return_value = (sentinel.CONN_REGION, sentinel.CONN_URL, sentinel.CONN_ARGS) + monkeypatch.setattr(utils_module, 'get_aws_connection_info', get_aws_connection_info) + boto3_conn = MagicMock(name='boto3_conn') + boto3_conn.return_value = sentinel.BOTO3_CONN + monkeypatch.setattr(utils_module, 'boto3_conn', boto3_conn) + + aws_module = utils_module.AnsibleAWSModule(argument_spec=dict()) + assert aws_module.resource(sentinel.PARAM_SERVICE) is sentinel.BOTO3_CONN + assert get_aws_connection_info.call_args == call(aws_module, boto3=True) + assert boto3_conn.call_args == call(aws_module, conn_type='resource', + resource=sentinel.PARAM_SERVICE, + region=sentinel.CONN_REGION, + endpoint=sentinel.CONN_URL,) diff --git a/tests/unit/plugin_utils/base/test_plugin.py b/tests/unit/plugin_utils/base/test_plugin.py new file mode 100644 index 00000000000..594d5c495f1 --- /dev/null +++ b/tests/unit/plugin_utils/base/test_plugin.py @@ -0,0 +1,142 @@ +# (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 call +from unittest.mock import MagicMock +from unittest.mock import sentinel +import warnings + +from ansible.errors import AnsibleError + +import ansible_collections.amazon.aws.plugins.plugin_utils.base as utils_base + + +def test_debug(monkeypatch): + monkeypatch.setattr(utils_base.display, 'debug', warnings.warn) + base_plugin = utils_base.AWSPluginBase() + + with pytest.warns(UserWarning, match="My debug message"): + base_plugin.debug("My debug message") + + +def test_warn(monkeypatch): + monkeypatch.setattr(utils_base.display, 'warning', warnings.warn) + base_plugin = utils_base.AWSPluginBase() + + with pytest.warns(UserWarning, match="My warning message"): + base_plugin.warn("My warning message") + + +def test_do_fail(): + base_plugin = utils_base.AWSPluginBase() + + with pytest.raises(AnsibleError, match='My exception message'): + base_plugin._do_fail('My exception message') + + +def test_fail_aws(): + base_plugin = utils_base.AWSPluginBase() + example_exception = Exception('My example exception') + example_message = 'My example failure message' + + with pytest.raises(AnsibleError, match='My example failure message'): + base_plugin.fail_aws(example_message) + + with pytest.raises(AnsibleError, match='My example failure message'): + base_plugin.fail_aws(message=example_message) + + # As long as example_example_exception is supported by to_native, we're good. + with pytest.raises(AnsibleError, match='My example exception'): + base_plugin.fail_aws(example_exception) + + with pytest.raises(AnsibleError, match='My example failure message: My example exception'): + base_plugin.fail_aws(example_message, example_exception) + + with pytest.raises(AnsibleError, match='My example failure message: My example exception'): + base_plugin.fail_aws(message=example_message, exception=example_exception) + + +def test_region(monkeypatch): + get_aws_region = MagicMock(name='get_aws_region') + get_aws_region.return_value = sentinel.RETURNED_REGION + monkeypatch.setattr(utils_base, 'get_aws_region', get_aws_region) + base_plugin = utils_base.AWSPluginBase() + + assert base_plugin.region is sentinel.RETURNED_REGION + assert get_aws_region.call_args == call(base_plugin) + + +def test_require_aws_sdk(monkeypatch): + require_sdk = MagicMock(name='check_sdk_version_supported') + require_sdk.return_value = sentinel.RETURNED_SDK + monkeypatch.setattr(utils_base, 'check_sdk_version_supported', require_sdk) + + base_plugin = utils_base.AWSPluginBase() + assert base_plugin.require_aws_sdk() is sentinel.RETURNED_SDK + assert require_sdk.call_args == call(botocore_version=None, boto3_version=None, warn=base_plugin.warn) + + base_plugin = utils_base.AWSPluginBase() + assert base_plugin.require_aws_sdk(botocore_version=sentinel.PARAM_BOTOCORE, boto3_version=sentinel.PARAM_BOTO3) is sentinel.RETURNED_SDK + assert require_sdk.call_args == call(botocore_version=sentinel.PARAM_BOTOCORE, boto3_version=sentinel.PARAM_BOTO3, warn=base_plugin.warn) + + +def test_client_no_wrapper(monkeypatch): + get_aws_connection_info = MagicMock(name='get_aws_connection_info') + sentinel.CONN_ARGS = dict() + get_aws_connection_info.return_value = (sentinel.CONN_REGION, sentinel.CONN_URL, sentinel.CONN_ARGS) + monkeypatch.setattr(utils_base, 'get_aws_connection_info', get_aws_connection_info) + boto3_conn = MagicMock(name='boto3_conn') + boto3_conn.return_value = sentinel.BOTO3_CONN + monkeypatch.setattr(utils_base, 'boto3_conn', boto3_conn) + + base_plugin = utils_base.AWSPluginBase() + assert base_plugin.client(sentinel.PARAM_SERVICE) is sentinel.BOTO3_CONN + assert get_aws_connection_info.call_args == call(base_plugin) + assert boto3_conn.call_args == call(base_plugin, conn_type='client', + resource=sentinel.PARAM_SERVICE, + region=sentinel.CONN_REGION, + endpoint=sentinel.CONN_URL,) + + +def test_client_wrapper(monkeypatch): + get_aws_connection_info = MagicMock(name='get_aws_connection_info') + sentinel.CONN_ARGS = dict() + get_aws_connection_info.return_value = (sentinel.CONN_REGION, sentinel.CONN_URL, sentinel.CONN_ARGS) + monkeypatch.setattr(utils_base, 'get_aws_connection_info', get_aws_connection_info) + boto3_conn = MagicMock(name='boto3_conn') + boto3_conn.return_value = sentinel.BOTO3_CONN + monkeypatch.setattr(utils_base, 'boto3_conn', boto3_conn) + + base_plugin = utils_base.AWSPluginBase() + wrapped_conn = base_plugin.client(sentinel.PARAM_SERVICE, sentinel.PARAM_WRAPPER) + assert wrapped_conn.client is sentinel.BOTO3_CONN + assert wrapped_conn.retry is sentinel.PARAM_WRAPPER + assert get_aws_connection_info.call_args == call(base_plugin) + assert boto3_conn.call_args == call(base_plugin, conn_type='client', + resource=sentinel.PARAM_SERVICE, + region=sentinel.CONN_REGION, + endpoint=sentinel.CONN_URL,) + + +def test_resource(monkeypatch): + get_aws_connection_info = MagicMock(name='get_aws_connection_info') + sentinel.CONN_ARGS = dict() + get_aws_connection_info.return_value = (sentinel.CONN_REGION, sentinel.CONN_URL, sentinel.CONN_ARGS) + monkeypatch.setattr(utils_base, 'get_aws_connection_info', get_aws_connection_info) + boto3_conn = MagicMock(name='boto3_conn') + boto3_conn.return_value = sentinel.BOTO3_CONN + monkeypatch.setattr(utils_base, 'boto3_conn', boto3_conn) + + base_plugin = utils_base.AWSPluginBase() + assert base_plugin.resource(sentinel.PARAM_SERVICE) is sentinel.BOTO3_CONN + assert get_aws_connection_info.call_args == call(base_plugin) + assert boto3_conn.call_args == call(base_plugin, conn_type='resource', + resource=sentinel.PARAM_SERVICE, + region=sentinel.CONN_REGION, + endpoint=sentinel.CONN_URL,) diff --git a/tests/unit/plugin_utils/botocore/test_boto3_conn_plugin.py b/tests/unit/plugin_utils/botocore/test_boto3_conn_plugin.py new file mode 100644 index 00000000000..62558318f5b --- /dev/null +++ b/tests/unit/plugin_utils/botocore/test_boto3_conn_plugin.py @@ -0,0 +1,136 @@ +# (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 + +try: + import botocore +except ImportError: + pass + +import pytest +from unittest.mock import MagicMock +from unittest.mock import sentinel +from unittest.mock import call + +import ansible_collections.amazon.aws.plugins.plugin_utils.botocore as utils_botocore + + +class FailException(Exception): + pass + + +@pytest.fixture +def aws_plugin(monkeypatch): + aws_plugin = MagicMock() + aws_plugin.fail_aws.side_effect = FailException() + monkeypatch.setattr(aws_plugin, 'ansible_name', sentinel.PLUGIN_NAME) + return aws_plugin + + +@pytest.fixture +def botocore_utils(monkeypatch): + return utils_botocore + + +############################################################### +# module_utils.botocore.boto3_conn +############################################################### +def test_boto3_conn_success_plugin(monkeypatch, aws_plugin, botocore_utils): + connection_method = MagicMock(name='_boto3_conn') + monkeypatch.setattr(botocore_utils, '_boto3_conn', connection_method) + connection_method.return_value = sentinel.RETURNED_CONNECTION + + assert botocore_utils.boto3_conn(aws_plugin) is sentinel.RETURNED_CONNECTION + passed_args = connection_method.call_args + assert passed_args == call(conn_type=None, resource=None, region=None, endpoint=None) + + result = botocore_utils.boto3_conn( + aws_plugin, + conn_type=sentinel.PARAM_CONNTYPE, + resource=sentinel.PARAM_RESOURCE, + region=sentinel.PARAM_REGION, + endpoint=sentinel.PARAM_ENDPOINT, + extra_arg=sentinel.PARAM_EXTRA, + ) + assert result is sentinel.RETURNED_CONNECTION + passed_args = connection_method.call_args + assert passed_args == call( + conn_type=sentinel.PARAM_CONNTYPE, + resource=sentinel.PARAM_RESOURCE, + region=sentinel.PARAM_REGION, + endpoint=sentinel.PARAM_ENDPOINT, + extra_arg=sentinel.PARAM_EXTRA, + ) + + +@pytest.mark.parametrize( + "failure, custom_error", + [ + (ValueError(sentinel.VALUE_ERROR), + "Couldn't connect to AWS: sentinel.VALUE_ERROR"), + (botocore.exceptions.ProfileNotFound(profile=sentinel.PROFILE_ERROR), + None), + (botocore.exceptions.PartialCredentialsError(provider=sentinel.CRED_ERROR_PROV, + cred_var=sentinel.CRED_ERROR_VAR), + None), + (botocore.exceptions.NoCredentialsError(), + None), + (botocore.exceptions.ConfigParseError(path=sentinel.PARSE_ERROR), + None), + (botocore.exceptions.NoRegionError(), + "The sentinel.PLUGIN_NAME plugin requires a region" + ), + ], +) +def test_boto3_conn_exception_plugin(monkeypatch, aws_plugin, botocore_utils, failure, custom_error): + connection_method = MagicMock(name='_boto3_conn') + monkeypatch.setattr(botocore_utils, '_boto3_conn', connection_method) + connection_method.side_effect = failure + + if custom_error is None: + custom_error = str(failure) + + with pytest.raises(FailException): + botocore_utils.boto3_conn(aws_plugin) + + fail_args = aws_plugin.fail_aws.call_args + assert custom_error in fail_args[0][0] + + +@pytest.mark.parametrize( + "failure, custom_error", + [ + (ValueError(sentinel.VALUE_ERROR), + "Couldn't connect to AWS: sentinel.VALUE_ERROR"), + (botocore.exceptions.ProfileNotFound(profile=sentinel.PROFILE_ERROR), + None), + (botocore.exceptions.PartialCredentialsError(provider=sentinel.CRED_ERROR_PROV, + cred_var=sentinel.CRED_ERROR_VAR), + None), + (botocore.exceptions.NoCredentialsError(), + None), + (botocore.exceptions.ConfigParseError(path=sentinel.PARSE_ERROR), + None), + (botocore.exceptions.NoRegionError(), + "A region is required and none was found", + ), + ], +) +def test_boto3_conn_exception_no_plugin_name(monkeypatch, aws_plugin, botocore_utils, failure, custom_error): + connection_method = MagicMock(name='_boto3_conn') + monkeypatch.setattr(botocore_utils, '_boto3_conn', connection_method) + connection_method.side_effect = failure + del aws_plugin.ansible_name + + if custom_error is None: + custom_error = str(failure) + + with pytest.raises(FailException): + botocore_utils.boto3_conn(aws_plugin) + + fail_args = aws_plugin.fail_aws.call_args + assert custom_error in fail_args[0][0] diff --git a/tests/unit/plugin_utils/botocore/test_get_aws_region.py b/tests/unit/plugin_utils/botocore/test_get_aws_region.py new file mode 100644 index 00000000000..b6f518a78e0 --- /dev/null +++ b/tests/unit/plugin_utils/botocore/test_get_aws_region.py @@ -0,0 +1,86 @@ +# (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 +from unittest.mock import sentinel +from unittest.mock import call + +import ansible_collections.amazon.aws.plugins.plugin_utils.botocore as utils_botocore +from ansible_collections.amazon.aws.plugins.module_utils.exceptions import AnsibleBotocoreError + + +class FailException(Exception): + pass + + +@pytest.fixture +def aws_plugin(monkeypatch): + aws_plugin = MagicMock() + aws_plugin.fail_aws.side_effect = FailException() + aws_plugin.get_options.return_value = sentinel.PLUGIN_OPTIONS + + return aws_plugin + + +@pytest.fixture +def botocore_utils(monkeypatch): + return utils_botocore + + +############################################################### +# module_utils.botocore.get_aws_region +############################################################### +def test_get_aws_region_simple_plugin(monkeypatch, aws_plugin, botocore_utils): + region_method = MagicMock(name='_aws_region') + monkeypatch.setattr(botocore_utils, '_aws_region', region_method) + region_method.return_value = sentinel.RETURNED_REGION + + assert botocore_utils.get_aws_region(aws_plugin) is sentinel.RETURNED_REGION + passed_args = region_method.call_args + assert passed_args == call(sentinel.PLUGIN_OPTIONS) + # args[0] + assert passed_args[0][0] is sentinel.PLUGIN_OPTIONS + + +def test_get_aws_region_exception_nested_plugin(monkeypatch, aws_plugin, botocore_utils): + region_method = MagicMock(name='_aws_region') + monkeypatch.setattr(botocore_utils, '_aws_region', region_method) + + exception_nested = AnsibleBotocoreError(message=sentinel.ERROR_MSG, exception=sentinel.ERROR_EX) + region_method.side_effect = exception_nested + + with pytest.raises(FailException): + assert botocore_utils.get_aws_region(aws_plugin) + + passed_args = region_method.call_args + assert passed_args == call(sentinel.PLUGIN_OPTIONS) + # call_args[0] == positional args + assert passed_args[0][0] is sentinel.PLUGIN_OPTIONS + + fail_args = aws_plugin.fail_aws.call_args + assert fail_args == call('sentinel.ERROR_MSG: sentinel.ERROR_EX') + + +def test_get_aws_region_exception_msg_plugin(monkeypatch, aws_plugin, botocore_utils): + region_method = MagicMock(name='_aws_region') + monkeypatch.setattr(botocore_utils, '_aws_region', region_method) + + exception_nested = AnsibleBotocoreError(message=sentinel.ERROR_MSG) + region_method.side_effect = exception_nested + + with pytest.raises(FailException): + assert botocore_utils.get_aws_region(aws_plugin) + + passed_args = region_method.call_args + assert passed_args == call(sentinel.PLUGIN_OPTIONS) + # call_args[0] == positional args + assert passed_args[0][0] is sentinel.PLUGIN_OPTIONS + + fail_args = aws_plugin.fail_aws.call_args + assert fail_args == call('sentinel.ERROR_MSG') diff --git a/tests/unit/plugin_utils/botocore/test_get_connection_info.py b/tests/unit/plugin_utils/botocore/test_get_connection_info.py new file mode 100644 index 00000000000..fb552eaeba6 --- /dev/null +++ b/tests/unit/plugin_utils/botocore/test_get_connection_info.py @@ -0,0 +1,85 @@ +# (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 +from unittest.mock import sentinel +from unittest.mock import call + +import ansible_collections.amazon.aws.plugins.plugin_utils.botocore as utils_botocore +from ansible_collections.amazon.aws.plugins.module_utils.exceptions import AnsibleBotocoreError + + +class FailException(Exception): + pass + + +@pytest.fixture +def aws_plugin(monkeypatch): + aws_plugin = MagicMock() + aws_plugin.fail_aws.side_effect = FailException() + aws_plugin.get_options.return_value = sentinel.PLUGIN_OPTIONS + return aws_plugin + + +@pytest.fixture +def botocore_utils(monkeypatch): + return utils_botocore + + +############################################################### +# module_utils.botocore.get_aws_connection_info +############################################################### +def test_get_aws_connection_info_simple_plugin(monkeypatch, aws_plugin, botocore_utils): + connection_info_method = MagicMock(name='_aws_connection_info') + monkeypatch.setattr(botocore_utils, '_aws_connection_info', connection_info_method) + connection_info_method.return_value = sentinel.RETURNED_INFO + + assert botocore_utils.get_aws_connection_info(aws_plugin) is sentinel.RETURNED_INFO + passed_args = connection_info_method.call_args + assert passed_args == call(sentinel.PLUGIN_OPTIONS) + # args[0] + assert passed_args[0][0] is sentinel.PLUGIN_OPTIONS + + +def test_get_aws_connection_info_exception_nested_plugin(monkeypatch, aws_plugin, botocore_utils): + connection_info_method = MagicMock(name='_aws_connection_info') + monkeypatch.setattr(botocore_utils, '_aws_connection_info', connection_info_method) + + exception_nested = AnsibleBotocoreError(message=sentinel.ERROR_MSG, exception=sentinel.ERROR_EX) + connection_info_method.side_effect = exception_nested + + with pytest.raises(FailException): + botocore_utils.get_aws_connection_info(aws_plugin) + + passed_args = connection_info_method.call_args + assert passed_args == call(sentinel.PLUGIN_OPTIONS) + # call_args[0] == positional args + assert passed_args[0][0] is sentinel.PLUGIN_OPTIONS + + fail_args = aws_plugin.fail_aws.call_args + assert fail_args == call('sentinel.ERROR_MSG: sentinel.ERROR_EX') + + +def test_get_aws_connection_info_exception_msg_plugin(monkeypatch, aws_plugin, botocore_utils): + connection_info_method = MagicMock(name='_aws_connection_info') + monkeypatch.setattr(botocore_utils, '_aws_connection_info', connection_info_method) + + exception_nested = AnsibleBotocoreError(message=sentinel.ERROR_MSG) + connection_info_method.side_effect = exception_nested + + with pytest.raises(FailException): + botocore_utils.get_aws_connection_info(aws_plugin) + + passed_args = connection_info_method.call_args + assert passed_args == call(sentinel.PLUGIN_OPTIONS) + # call_args[0] == positional args + assert passed_args[0][0] is sentinel.PLUGIN_OPTIONS + + fail_args = aws_plugin.fail_aws.call_args + assert fail_args == call('sentinel.ERROR_MSG') diff --git a/tests/unit/plugin_utils/lookup/test_lookup_base.py b/tests/unit/plugin_utils/lookup/test_lookup_base.py new file mode 100644 index 00000000000..3e59943fbc5 --- /dev/null +++ b/tests/unit/plugin_utils/lookup/test_lookup_base.py @@ -0,0 +1,44 @@ +# (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 call +from unittest.mock import MagicMock +from unittest.mock import sentinel + +from ansible.errors import AnsibleLookupError + +import ansible_collections.amazon.aws.plugins.plugin_utils.lookup as utils_lookup + + +def test_fail_aws(): + lookup_plugin = utils_lookup.AWSLookupBase() + with pytest.raises(AnsibleLookupError, match=str(sentinel.ERROR_MSG)): + lookup_plugin._do_fail(sentinel.ERROR_MSG) + + +def test_run(monkeypatch): + kwargs = {'example': sentinel.KWARG} + require_aws_sdk = MagicMock(name='require_aws_sdk') + require_aws_sdk.return_value = sentinel.RETURNED_SDK + set_options = MagicMock(name='set_options') + set_options.return_value = sentinel.RETURNED_OPTIONS + + lookup_plugin = utils_lookup.AWSLookupBase() + monkeypatch.setattr(lookup_plugin, 'require_aws_sdk', require_aws_sdk) + monkeypatch.setattr(lookup_plugin, 'set_options', set_options) + + lookup_plugin.run(sentinel.PARAM_TERMS, sentinel.PARAM_VARS, **kwargs) + assert require_aws_sdk.call_args == call(botocore_version=None, boto3_version=None) + assert set_options.call_args == call(var_options=sentinel.PARAM_VARS, direct=kwargs) + + lookup_plugin.run(sentinel.PARAM_TERMS, sentinel.PARAM_VARS, + boto3_version=sentinel.PARAM_BOTO3, botocore_version=sentinel.PARAM_BOTOCORE, + **kwargs) + assert require_aws_sdk.call_args == call(botocore_version=sentinel.PARAM_BOTOCORE, boto3_version=sentinel.PARAM_BOTO3) + assert set_options.call_args == call(var_options=sentinel.PARAM_VARS, direct=kwargs) diff --git a/tox.ini b/tox.ini index bf50962181d..deba4740bd9 100644 --- a/tox.ini +++ b/tox.ini @@ -13,7 +13,7 @@ deps = pytest-ansible-units -rtest-requirements.txt with_constraints: -rtests/unit/constraints.txt -commands = pytest --cov-report html --cov plugins/callback --cov plugins/inventory --cov plugins/lookup --cov plugins/module_utils --cov plugins/modules plugins {posargs:tests/} +commands = pytest --cov-report html --cov plugins/callback --cov plugins/inventory --cov plugins/lookup --cov plugins/module_utils --cov plugins/modules --cov plugins/plugin_utils plugins {posargs:tests/} [testenv:clean] deps = coverage