summaryrefslogtreecommitdiffstats
path: root/ansible_collections/google/cloud/plugins/module_utils
diff options
context:
space:
mode:
Diffstat (limited to 'ansible_collections/google/cloud/plugins/module_utils')
-rw-r--r--ansible_collections/google/cloud/plugins/module_utils/gcp_utils.py456
1 files changed, 456 insertions, 0 deletions
diff --git a/ansible_collections/google/cloud/plugins/module_utils/gcp_utils.py b/ansible_collections/google/cloud/plugins/module_utils/gcp_utils.py
new file mode 100644
index 00000000..fd430274
--- /dev/null
+++ b/ansible_collections/google/cloud/plugins/module_utils/gcp_utils.py
@@ -0,0 +1,456 @@
+# Copyright (c), Google Inc, 2017
+# Simplified BSD License (see licenses/simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause)
+
+from __future__ import (absolute_import, division, print_function)
+
+__metaclass__ = type
+
+import ast
+import os
+import json
+
+try:
+ import requests
+ HAS_REQUESTS = True
+except ImportError:
+ HAS_REQUESTS = False
+
+try:
+ import google.auth
+ import google.auth.compute_engine
+ from google.oauth2 import service_account
+ from google.auth.transport.requests import AuthorizedSession
+ HAS_GOOGLE_LIBRARIES = True
+except ImportError:
+ HAS_GOOGLE_LIBRARIES = False
+
+from ansible.module_utils.basic import AnsibleModule, env_fallback
+from ansible.module_utils.six import string_types
+from ansible.module_utils._text import to_text, to_native
+
+
+def navigate_hash(source, path, default=None):
+ if not source:
+ return None
+
+ key = path[0]
+ path = path[1:]
+ if key not in source:
+ return default
+ result = source[key]
+ if path:
+ return navigate_hash(result, path, default)
+ return result
+
+
+class GcpRequestException(Exception):
+ pass
+
+
+def remove_nones_from_dict(obj):
+ new_obj = {}
+ for key in obj:
+ value = obj[key]
+ if value is not None and value != {} and value != []:
+ new_obj[key] = value
+
+ # Blank dictionaries should return None or GCP API may complain.
+ if not new_obj:
+ return None
+ return new_obj
+
+
+# Handles the replacement of dicts with values -> the needed value for GCP API
+def replace_resource_dict(item, value):
+ if isinstance(item, list):
+ items = []
+ for i in item:
+ items.append(replace_resource_dict(i, value))
+ return items
+ if not item:
+ return item
+ return item.get(value)
+
+
+# Handles all authentication and HTTP sessions for GCP API calls.
+class GcpSession(object):
+ def __init__(self, module, product):
+ self.module = module
+ self.product = product
+ self._validate()
+
+ def get(self, url, body=None, **kwargs):
+ """
+ This method should be avoided in favor of full_get
+ """
+ kwargs.update({'json': body})
+ return self.full_get(url, **kwargs)
+
+ def post(self, url, body=None, headers=None, **kwargs):
+ """
+ This method should be avoided in favor of full_post
+ """
+ kwargs.update({'json': body, 'headers': headers})
+ return self.full_post(url, **kwargs)
+
+ def post_contents(self, url, file_contents=None, headers=None, **kwargs):
+ """
+ This method should be avoided in favor of full_post
+ """
+ kwargs.update({'data': file_contents, 'headers': headers})
+ return self.full_post(url, **kwargs)
+
+ def delete(self, url, body=None):
+ """
+ This method should be avoided in favor of full_delete
+ """
+ kwargs = {'json': body}
+ return self.full_delete(url, **kwargs)
+
+ def put(self, url, body=None, params=None):
+ """
+ This method should be avoided in favor of full_put
+ """
+ kwargs = {'json': body}
+ return self.full_put(url, params=params, **kwargs)
+
+ def patch(self, url, body=None, **kwargs):
+ """
+ This method should be avoided in favor of full_patch
+ """
+ kwargs.update({'json': body})
+ return self.full_patch(url, **kwargs)
+
+ def list(self, url, callback, params=None, array_name='items',
+ pageToken='nextPageToken', **kwargs):
+ """
+ This should be used for calling the GCP list APIs. It will return
+ an array of items
+
+ This takes a callback to a `return_if_object(module, response)`
+ function that will decode the response + return a dictionary. Some
+ modules handle the decode + error processing differently, so we should
+ defer to the module to handle this.
+ """
+ resp = callback(self.module, self.full_get(url, params, **kwargs))
+ items = resp.get(array_name) if resp.get(array_name) else []
+ while resp.get(pageToken):
+ if params:
+ params['pageToken'] = resp.get(pageToken)
+ else:
+ params = {'pageToken': resp[pageToken]}
+
+ resp = callback(self.module, self.full_get(url, params, **kwargs))
+ if resp.get(array_name):
+ items = items + resp.get(array_name)
+ return items
+
+ # The following methods fully mimic the requests API and should be used.
+ def full_get(self, url, params=None, **kwargs):
+ kwargs['headers'] = self._set_headers(kwargs.get('headers'))
+ try:
+ return self.session().get(url, params=params, **kwargs)
+ except getattr(requests.exceptions, 'RequestException') as inst:
+ # Only log the message to avoid logging any sensitive info.
+ self.module.fail_json(msg=inst.message)
+
+ def full_post(self, url, data=None, json=None, **kwargs):
+ kwargs['headers'] = self._set_headers(kwargs.get('headers'))
+
+ try:
+ return self.session().post(url, data=data, json=json, **kwargs)
+ except getattr(requests.exceptions, 'RequestException') as inst:
+ self.module.fail_json(msg=inst.message)
+
+ def full_put(self, url, data=None, **kwargs):
+ kwargs['headers'] = self._set_headers(kwargs.get('headers'))
+
+ try:
+ return self.session().put(url, data=data, **kwargs)
+ except getattr(requests.exceptions, 'RequestException') as inst:
+ self.module.fail_json(msg=inst.message)
+
+ def full_patch(self, url, data=None, **kwargs):
+ kwargs['headers'] = self._set_headers(kwargs.get('headers'))
+
+ try:
+ return self.session().patch(url, data=data, **kwargs)
+ except getattr(requests.exceptions, 'RequestException') as inst:
+ self.module.fail_json(msg=inst.message)
+
+ def full_delete(self, url, **kwargs):
+ kwargs['headers'] = self._set_headers(kwargs.get('headers'))
+
+ try:
+ return self.session().delete(url, **kwargs)
+ except getattr(requests.exceptions, 'RequestException') as inst:
+ self.module.fail_json(msg=inst.message)
+
+ def _set_headers(self, headers):
+ if headers:
+ return self._merge_dictionaries(headers, self._headers())
+ return self._headers()
+
+ def session(self):
+ return AuthorizedSession(
+ self._credentials())
+
+ def _validate(self):
+ if not HAS_REQUESTS:
+ self.module.fail_json(msg="Please install the requests library")
+
+ if not HAS_GOOGLE_LIBRARIES:
+ self.module.fail_json(msg="Please install the google-auth library")
+
+ if self.module.params.get('service_account_email') is not None and self.module.params['auth_kind'] != 'machineaccount':
+ self.module.fail_json(
+ msg="Service Account Email only works with Machine Account-based authentication"
+ )
+
+ if (self.module.params.get('service_account_file') is not None or
+ self.module.params.get('service_account_contents') is not None) and self.module.params['auth_kind'] != 'serviceaccount':
+ self.module.fail_json(
+ msg="Service Account File only works with Service Account-based authentication"
+ )
+
+ def _credentials(self):
+ cred_type = self.module.params['auth_kind']
+ if cred_type == 'application':
+ credentials, project_id = google.auth.default(scopes=self.module.params['scopes'])
+ return credentials
+ if cred_type == 'serviceaccount' and self.module.params.get('service_account_file'):
+ path = os.path.realpath(os.path.expanduser(self.module.params['service_account_file']))
+ if not os.path.exists(path):
+ self.module.fail_json(
+ msg="Unable to find service_account_file at '%s'" % path
+ )
+ return service_account.Credentials.from_service_account_file(path).with_scopes(self.module.params['scopes'])
+ if cred_type == 'serviceaccount' and self.module.params.get('service_account_contents'):
+ try:
+ cred = json.loads(self.module.params.get('service_account_contents'))
+ except json.decoder.JSONDecodeError as e:
+ self.module.fail_json(
+ msg="Unable to decode service_account_contents as JSON"
+ )
+ return service_account.Credentials.from_service_account_info(cred).with_scopes(self.module.params['scopes'])
+ if cred_type == 'machineaccount':
+ return google.auth.compute_engine.Credentials(
+ self.module.params['service_account_email'])
+ self.module.fail_json(msg="Credential type '%s' not implemented" % cred_type)
+
+ def _headers(self):
+ user_agent = "Google-Ansible-MM-{0}".format(self.product)
+ if self.module.params.get('env_type'):
+ user_agent = "{0}-{1}".format(user_agent, self.module.params.get('env_type'))
+ return {
+ 'User-Agent': user_agent
+ }
+
+ def _merge_dictionaries(self, a, b):
+ new = a.copy()
+ new.update(b)
+ return new
+
+
+class GcpModule(AnsibleModule):
+ def __init__(self, *args, **kwargs):
+ arg_spec = kwargs.get('argument_spec', {})
+
+ kwargs['argument_spec'] = self._merge_dictionaries(
+ arg_spec,
+ dict(
+ project=dict(
+ required=False,
+ type='str',
+ fallback=(env_fallback, ['GCP_PROJECT'])),
+ auth_kind=dict(
+ required=True,
+ fallback=(env_fallback, ['GCP_AUTH_KIND']),
+ choices=['machineaccount', 'serviceaccount', 'application'],
+ type='str'),
+ service_account_email=dict(
+ required=False,
+ fallback=(env_fallback, ['GCP_SERVICE_ACCOUNT_EMAIL']),
+ type='str'),
+ service_account_file=dict(
+ required=False,
+ fallback=(env_fallback, ['GCP_SERVICE_ACCOUNT_FILE']),
+ type='path'),
+ service_account_contents=dict(
+ required=False,
+ fallback=(env_fallback, ['GCP_SERVICE_ACCOUNT_CONTENTS']),
+ no_log=True,
+ type='jsonarg'),
+ scopes=dict(
+ required=False,
+ fallback=(env_fallback, ['GCP_SCOPES']),
+ type='list',
+ elements='str'),
+ env_type=dict(
+ required=False,
+ fallback=(env_fallback, ['GCP_ENV_TYPE']),
+ type='str')
+ )
+ )
+
+ mutual = kwargs.get('mutually_exclusive', [])
+
+ kwargs['mutually_exclusive'] = mutual.append(
+ ['service_account_email', 'service_account_file', 'service_account_contents']
+ )
+
+ AnsibleModule.__init__(self, *args, **kwargs)
+
+ def raise_for_status(self, response):
+ try:
+ response.raise_for_status()
+ except getattr(requests.exceptions, 'RequestException') as inst:
+ self.fail_json(
+ msg="GCP returned error: %s" % response.json(),
+ request={
+ "url": response.request.url,
+ "body": response.request.body,
+ "method": response.request.method,
+ }
+ )
+
+ def _merge_dictionaries(self, a, b):
+ new = a.copy()
+ new.update(b)
+ return new
+
+
+# This class does difference checking according to a set of GCP-specific rules.
+# This will be primarily used for checking dictionaries.
+# In an equivalence check, the left-hand dictionary will be the request and
+# the right-hand side will be the response.
+
+# Rules:
+# Extra keys in response will be ignored.
+# Ordering of lists does not matter.
+# - exception: lists of dictionaries are
+# assumed to be in sorted order.
+class GcpRequest(object):
+ def __init__(self, request):
+ self.request = request
+
+ def __eq__(self, other):
+ return not self.difference(other)
+
+ def __ne__(self, other):
+ return not self.__eq__(other)
+
+ # Returns the difference between a request + response.
+ # While this is used under the hood for __eq__ and __ne__,
+ # it is useful for debugging.
+ def difference(self, response):
+ return self._compare_value(self.request, response.request)
+
+ def _compare_dicts(self, req_dict, resp_dict):
+ difference = {}
+ for key in req_dict:
+ if resp_dict.get(key):
+ difference[key] = self._compare_value(req_dict.get(key), resp_dict.get(key))
+
+ # Remove all empty values from difference.
+ sanitized_difference = {}
+ for key in difference:
+ if difference[key]:
+ sanitized_difference[key] = difference[key]
+
+ return sanitized_difference
+
+ # Takes in two lists and compares them.
+ # All things in the list should be identical (even if a dictionary)
+ def _compare_lists(self, req_list, resp_list):
+ # Have to convert each thing over to unicode.
+ # Python doesn't handle equality checks between unicode + non-unicode well.
+ difference = []
+ new_req_list = self._convert_value(req_list)
+ new_resp_list = self._convert_value(resp_list)
+
+ # We have to compare each thing in the request to every other thing
+ # in the response.
+ # This is because the request value will be a subset of the response value.
+ # The assumption is that these lists will be small enough that it won't
+ # be a performance burden.
+ for req_item in new_req_list:
+ found_item = False
+ for resp_item in new_resp_list:
+ # Looking for a None value here.
+ if not self._compare_value(req_item, resp_item):
+ found_item = True
+ if not found_item:
+ difference.append(req_item)
+
+ difference2 = []
+ for value in difference:
+ if value:
+ difference2.append(value)
+
+ return difference2
+
+ # Compare two values of arbitrary types.
+ def _compare_value(self, req_value, resp_value):
+ diff = None
+ # If a None is found, a difference does not exist.
+ # Only differing values matter.
+ if not resp_value:
+ return None
+
+ # Can assume non-None types at this point.
+ try:
+ if isinstance(req_value, list):
+ diff = self._compare_lists(req_value, resp_value)
+ elif isinstance(req_value, dict):
+ diff = self._compare_dicts(req_value, resp_value)
+ elif isinstance(req_value, bool):
+ diff = self._compare_boolean(req_value, resp_value)
+ # Always use to_text values to avoid unicode issues.
+ elif to_text(req_value) != to_text(resp_value):
+ diff = req_value
+ # to_text may throw UnicodeErrors.
+ # These errors shouldn't crash Ansible and should be hidden.
+ except UnicodeError:
+ pass
+
+ return diff
+
+ # Compare two boolean values.
+ def _compare_boolean(self, req_value, resp_value):
+ try:
+ # Both True
+ if req_value and isinstance(resp_value, bool) and resp_value:
+ return None
+ # Value1 True, resp_value 'true'
+ if req_value and to_text(resp_value) == 'true':
+ return None
+ # Both False
+ if not req_value and isinstance(resp_value, bool) and not resp_value:
+ return None
+ # Value1 False, resp_value 'false'
+ if not req_value and to_text(resp_value) == 'false':
+ return None
+ return resp_value
+
+ # to_text may throw UnicodeErrors.
+ # These errors shouldn't crash Ansible and should be hidden.
+ except UnicodeError:
+ return None
+
+ # Python (2 esp.) doesn't do comparisons between unicode + non-unicode well.
+ # This leads to a lot of false positives when diffing values.
+ # The Ansible to_text() function is meant to get all strings
+ # into a standard format.
+ def _convert_value(self, value):
+ if isinstance(value, list):
+ new_list = []
+ for item in value:
+ new_list.append(self._convert_value(item))
+ return new_list
+ if isinstance(value, dict):
+ new_dict = {}
+ for key in value:
+ new_dict[key] = self._convert_value(value[key])
+ return new_dict
+ return to_text(value)