diff options
Diffstat (limited to 'ansible_collections/amazon/aws/plugins/plugin_utils')
5 files changed, 377 insertions, 0 deletions
diff --git a/ansible_collections/amazon/aws/plugins/plugin_utils/base.py b/ansible_collections/amazon/aws/plugins/plugin_utils/base.py new file mode 100644 index 000000000..3c9066209 --- /dev/null +++ b/ansible_collections/amazon/aws/plugins/plugin_utils/base.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- + +# (c) 2022 Red Hat Inc. +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +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.module_utils.retries import RetryingBotoClientWrapper +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 + +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(f"{message}: {to_native(exception)}") + + def client(self, service, retry_decorator=None, **extra_params): + region, endpoint_url, aws_connect_kwargs = get_aws_connection_info(self) + kw_args = dict(region=region, endpoint=endpoint_url, **aws_connect_kwargs) + kw_args.update(extra_params) + conn = boto3_conn(self, conn_type="client", resource=service, **kw_args) + return conn if retry_decorator is None else RetryingBotoClientWrapper(conn, retry_decorator) + + def resource(self, service, **extra_params): + region, endpoint_url, aws_connect_kwargs = get_aws_connection_info(self) + kw_args = dict(region=region, endpoint=endpoint_url, **aws_connect_kwargs) + kw_args.update(extra_params) + return boto3_conn(self, conn_type="resource", resource=service, **kw_args) + + @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/ansible_collections/amazon/aws/plugins/plugin_utils/botocore.py b/ansible_collections/amazon/aws/plugins/plugin_utils/botocore.py new file mode 100644 index 000000000..2fe2ca0eb --- /dev/null +++ b/ansible_collections/amazon/aws/plugins/plugin_utils/botocore.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- + +# (c) 2022 Red Hat Inc. +# 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(f"Couldn't connect to AWS: {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( + f"The {plugin.ansible_name} plugin requires a region and none was found in configuration, " + "environment variables or module parameters" + ) + 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/ansible_collections/amazon/aws/plugins/plugin_utils/connection.py b/ansible_collections/amazon/aws/plugins/plugin_utils/connection.py new file mode 100644 index 000000000..1e3a16678 --- /dev/null +++ b/ansible_collections/amazon/aws/plugins/plugin_utils/connection.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- + +# (c) 2023 Red Hat Inc. +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from ansible.errors import AnsibleConnectionFailure +from ansible.plugins.connection import ConnectionBase + +from ansible_collections.amazon.aws.plugins.plugin_utils.base import AWSPluginBase + + +class AWSConnectionBase(AWSPluginBase, ConnectionBase): + def _do_fail(self, message): + raise AnsibleConnectionFailure(message) + + def __init__(self, *args, boto3_version=None, botocore_version=None, **kwargs): + super().__init__(*args, **kwargs) + self.require_aws_sdk(botocore_version=botocore_version, boto3_version=boto3_version) diff --git a/ansible_collections/amazon/aws/plugins/plugin_utils/inventory.py b/ansible_collections/amazon/aws/plugins/plugin_utils/inventory.py new file mode 100644 index 000000000..144f77a7a --- /dev/null +++ b/ansible_collections/amazon/aws/plugins/plugin_utils/inventory.py @@ -0,0 +1,221 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2022, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +try: + import boto3 + import botocore +except ImportError: + pass # will be captured by imported HAS_BOTO3 + +from ansible.plugins.inventory import BaseInventoryPlugin +from ansible.plugins.inventory import Cacheable +from ansible.plugins.inventory import Constructable + +from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code +from ansible_collections.amazon.aws.plugins.plugin_utils.base import AWSPluginBase +from ansible_collections.amazon.aws.plugins.plugin_utils.botocore import AnsibleBotocoreError + + +def _boto3_session(profile_name=None): + if profile_name is None: + return boto3.Session() + return boto3.session.Session(profile_name=profile_name) + + +class AWSInventoryBase(BaseInventoryPlugin, Constructable, Cacheable, AWSPluginBase): + class TemplatedOptions: + # When someone looks up the TEMPLATABLE_OPTIONS using get() any templates + # will be templated using the loader passed to parse. + TEMPLATABLE_OPTIONS = ( + "access_key", + "secret_key", + "session_token", + "profile", + "iam_role_name", + ) + + def __init__(self, templar, options): + self.original_options = options + self.templar = templar + + def __getitem__(self, *args): + return self.original_options.__getitem__(self, *args) + + def __setitem__(self, *args): + return self.original_options.__setitem__(self, *args) + + def get(self, *args): + value = self.original_options.get(*args) + if not value: + return value + if args[0] not in self.TEMPLATABLE_OPTIONS: + return value + if not self.templar.is_template(value): + return value + + return self.templar.template(variable=value, disable_lookups=False) + + def get_options(self, *args): + original_options = super().get_options(*args) + if not self.templar: + return original_options + return self.TemplatedOptions(self.templar, original_options) + + def __init__(self): + super().__init__() + self._frozen_credentials = {} + + # pylint: disable=too-many-arguments + def parse(self, inventory, loader, path, cache=True, botocore_version=None, boto3_version=None): + super().parse(inventory, loader, path) + self.require_aws_sdk(botocore_version=botocore_version, boto3_version=boto3_version) + self._read_config_data(path) + self._set_frozen_credentials() + + def client(self, *args, **kwargs): + kw_args = dict(self._frozen_credentials) + kw_args.update(kwargs) + return super().client(*args, **kw_args) + + def resource(self, *args, **kwargs): + kw_args = dict(self._frozen_credentials) + kw_args.update(kwargs) + return super().resource(*args, **kw_args) + + def _freeze_iam_role(self, iam_role_arn): + if hasattr(self, "ansible_name"): + role_session_name = f"ansible_aws_{self.ansible_name}_dynamic_inventory" + else: + role_session_name = "ansible_aws_dynamic_inventory" + assume_params = {"RoleArn": iam_role_arn, "RoleSessionName": role_session_name} + + try: + sts = self.client("sts") + assumed_role = sts.assume_role(**assume_params) + except AnsibleBotocoreError as e: + self.fail_aws(f"Unable to assume role {iam_role_arn}", exception=e) + + credentials = assumed_role.get("Credentials") + if not credentials: + self.fail_aws(f"Unable to assume role {iam_role_arn}") + + self._frozen_credentials = { + "profile_name": None, + "aws_access_key_id": credentials.get("AccessKeyId"), + "aws_secret_access_key": credentials.get("SecretAccessKey"), + "aws_session_token": credentials.get("SessionToken"), + } + + def _set_frozen_credentials(self): + options = self.get_options() + iam_role_arn = options.get("assume_role_arn") + if iam_role_arn: + self._freeze_iam_role(iam_role_arn) + + def _describe_regions(self, service): + # Try pulling a list of regions from the service + try: + initial_region = self.region or "us-east-1" + client = self.client(service, region=initial_region) + resp = client.describe_regions() + except AttributeError: + # Not all clients support describe + pass + except is_boto3_error_code("UnauthorizedOperation"): + self.warn(f"UnauthorizedOperation when trying to list {service} regions") + except botocore.exceptions.NoRegionError: + self.warn(f"NoRegionError when trying to list {service} regions") + except (botocore.exceptions.BotoCoreError, botocore.exceptions.ClientError) as e: + self.warn(f"Unexpected error while trying to list {service} regions: {e}") + else: + regions = [x["RegionName"] for x in resp.get("Regions", [])] + if regions: + return regions + return None + + def _boto3_regions(self, service): + options = self.get_options() + + if options.get("regions"): + return options.get("regions") + + # boto3 has hard coded lists of available regions for resources, however this does bit-rot + # As such we try to query the service, and fall back to ec2 for a list of regions + for resource_type in list({service, "ec2"}): + regions = self._describe_regions(resource_type) + if regions: + return regions + + # fallback to local list hardcoded in boto3 if still no regions + session = _boto3_session(options.get("profile")) + regions = session.get_available_regions(service) + + if not regions: + # I give up, now you MUST give me regions + self.fail_aws( + "Unable to get regions list from available methods, you must specify the 'regions' option to continue." + ) + + return regions + + def all_clients(self, service): + """ + Generator that yields a boto3 client and the region + + :param service: The boto3 service to connect to. + + Note: For services which don't support 'DescribeRegions' this may include bad + endpoints, and as such EndpointConnectionError should be cleanly handled as a non-fatal + error. + """ + regions = self._boto3_regions(service=service) + + for region in regions: + connection = self.client(service, region=region) + yield connection, region + + def get_cached_result(self, path, cache): + # false when refresh_cache or --flush-cache is used + if not cache: + return False, None + # get the user-specified directive + if not self.get_option("cache"): + return False, None + + cache_key = self.get_cache_key(path) + try: + cached_value = self._cache[cache_key] + except KeyError: + # if cache expires or cache file doesn"t exist + return False, None + + return True, cached_value + + def update_cached_result(self, path, cache, result): + if not self.get_option("cache"): + return + + cache_key = self.get_cache_key(path) + # We weren't explicitly told to flush the cache, and there's already a cache entry, + # this means that the result we're being passed came from the cache. As such we don't + # want to "update" the cache as that could reset a TTL on the cache entry. + if cache and cache_key in self._cache: + return + + self._cache[cache_key] = result + + def verify_file(self, path): + """ + :param path: the path to the inventory config file + :return the contents of the config file + """ + if not super().verify_file(path): + return False + + if hasattr(self, "INVENTORY_FILE_SUFFIXES"): + if not path.endswith(self.INVENTORY_FILE_SUFFIXES): + return False + + return True diff --git a/ansible_collections/amazon/aws/plugins/plugin_utils/lookup.py b/ansible_collections/amazon/aws/plugins/plugin_utils/lookup.py new file mode 100644 index 000000000..635d161d1 --- /dev/null +++ b/ansible_collections/amazon/aws/plugins/plugin_utils/lookup.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- + +# (c) 2022 Red Hat Inc. +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +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) |