diff --git a/lib/ansible/module_utils/gcp.py b/lib/ansible/module_utils/gcp.py index c3b0d2bdd3..441462bab7 100644 --- a/lib/ansible/module_utils/gcp.py +++ b/lib/ansible/module_utils/gcp.py @@ -29,6 +29,7 @@ import json import os +import time import traceback from distutils.version import LooseVersion @@ -44,7 +45,7 @@ try: import google.auth from google.oauth2 import service_account HAS_GOOGLE_AUTH = True -except ImportError as e: +except ImportError: HAS_GOOGLE_AUTH = False # google-python-api @@ -52,6 +53,8 @@ try: import google_auth_httplib2 from httplib2 import Http from googleapiclient.http import set_user_agent + from googleapiclient.errors import HttpError + from apiclient.discovery import build HAS_GOOGLE_API_LIB = True except ImportError: HAS_GOOGLE_API_LIB = False @@ -64,6 +67,11 @@ except ImportError: from ansible.utils.display import Display display = Display() +import ansible.module_utils.six.moves.urllib.parse as urlparse + +GCP_DEFAULT_SCOPES = ['https://www.googleapis.com/auth/cloud-platform'] + + def _get_gcp_ansible_credentials(module): """Helper to fetch creds from AnsibleModule object.""" service_account_email = module.params.get('service_account_email', None) @@ -74,11 +82,13 @@ def _get_gcp_ansible_credentials(module): return (service_account_email, credentials_file, project_id) + def _get_gcp_environ_var(var_name, default_value): """Wrapper around os.environ.get call.""" return os.environ.get( var_name, default_value) + def _get_gcp_environment_credentials(service_account_email, credentials_file, project_id): """Helper to look in environment variables for credentials.""" # If any of the values are not given as parameters, check the appropriate @@ -95,6 +105,7 @@ def _get_gcp_environment_credentials(service_account_email, credentials_file, pr 'GOOGLE_CLOUD_PROJECT', None) return (service_account_email, credentials_file, project_id) + def _get_gcp_libcloud_credentials(service_account_email=None, credentials_file=None, project_id=None): """ Helper to look for libcloud secrets.py file. @@ -134,6 +145,7 @@ def _get_gcp_libcloud_credentials(service_account_email=None, credentials_file=N project_id = keyword_params.get('project', None) return (service_account_email, credentials_file, project_id) + def _get_gcp_credentials(module, require_valid_json=True, check_libcloud=False): """ Obtain GCP credentials by trying various methods. @@ -193,13 +205,14 @@ def _get_gcp_credentials(module, require_valid_json=True, check_libcloud=False): if credentials_file is None or project_id is None or service_account_email is None: if check_libcloud is True: if project_id is None: - # TODO(supertom): this message is legacy and integration tests depend on it. + # TODO(supertom): this message is legacy and integration tests + # depend on it. module.fail_json(msg='Missing GCE connection parameters in libcloud ' 'secrets file.') else: if project_id is None: module.fail_json(msg=('GCP connection error: unable to determine project (%s) or ' - 'credentials file (%s)' % (project_id, credentials_file))) + 'credentials file (%s)' % (project_id, credentials_file))) # Set these fields to empty strings if they are None # consumers of this will make the distinction between an empty string # and None. @@ -218,6 +231,7 @@ def _get_gcp_credentials(module, require_valid_json=True, check_libcloud=False): 'credentials_file': credentials_file, 'project_id': project_id} + def _validate_credentials_file(module, credentials_file, require_valid_json=True, check_libcloud=False): """ Check for valid credentials file. @@ -245,17 +259,20 @@ def _validate_credentials_file(module, credentials_file, require_valid_json=True with open(credentials_file) as credentials: json.loads(credentials.read()) # If the credentials are proper JSON and we do not have the minimum - # required libcloud version, bail out and return a descriptive error + # required libcloud version, bail out and return a descriptive + # error if check_libcloud and LooseVersion(libcloud.__version__) < '0.17.0': module.fail_json(msg='Using JSON credentials but libcloud minimum version not met. ' 'Upgrade to libcloud>=0.17.0.') return True except IOError as e: - module.fail_json(msg='GCP Credentials File %s not found.' % credentials_file, changed=False) + module.fail_json(msg='GCP Credentials File %s not found.' % + credentials_file, changed=False) return False except ValueError as e: if require_valid_json: - module.fail_json(msg='GCP Credentials File %s invalid. Must be valid JSON.' % credentials_file, changed=False) + module.fail_json( + msg='GCP Credentials File %s invalid. Must be valid JSON.' % credentials_file, changed=False) else: display.deprecated(msg=("Non-JSON credentials file provided. This format is deprecated. " " Please generate a new JSON key from the Google Cloud console"), @@ -273,7 +290,7 @@ def gcp_connect(module, provider, get_driver, user_agent_product, user_agent_ver check_libcloud=True) try: gcp = get_driver(provider)(creds['service_account_email'], creds['credentials_file'], - datacenter=module.params.get('zone', None), + datacenter=module.params.get('zone', None), project=creds['project_id']) gcp.connection.user_agent_append("%s/%s" % ( user_agent_product, user_agent_version)) @@ -318,8 +335,8 @@ def get_google_cloud_credentials(module, scopes=[]): module.fail_json(msg='Please install google-auth.') conn_params = _get_gcp_credentials(module, - require_valid_json=True, - check_libcloud=False) + require_valid_json=True, + check_libcloud=False) try: if conn_params['credentials_file']: credentials = service_account.Credentials.from_service_account_file( @@ -337,6 +354,7 @@ def get_google_cloud_credentials(module, scopes=[]): module.fail_json(msg=unexpected_error_msg(e), changed=False) return (None, None) + def get_google_api_auth(module, scopes=[], user_agent_product='ansible-python-api', user_agent_version='NA'): """ Authentication for use with google-python-api-client. @@ -375,12 +393,12 @@ def get_google_api_auth(module, scopes=[], user_agent_product='ansible-python-ap """ if not HAS_GOOGLE_API_LIB: module.fail_json(msg="Please install google-api-python-client library") - # TODO(supertom): verify scopes if not scopes: - scopes = ['https://www.googleapis.com/auth/cloud-platform'] + scopes = GCP_DEFAULT_SCOPES try: (credentials, conn_params) = get_google_cloud_credentials(module, scopes) - http = set_user_agent(Http(), '%s-%s' % (user_agent_product, user_agent_version)) + http = set_user_agent(Http(), '%s-%s' % + (user_agent_product, user_agent_version)) http_auth = google_auth_httplib2.AuthorizedHttp(credentials, http=http) return (http_auth, conn_params) @@ -388,6 +406,30 @@ def get_google_api_auth(module, scopes=[], user_agent_product='ansible-python-ap module.fail_json(msg=unexpected_error_msg(e), changed=False) return (None, None) + +def get_google_api_client(module, service, user_agent_product, user_agent_version, + scopes=None, api_version='v1'): + """ + Get the discovery-based python client. Use when a cloud client is not available. + + client = get_google_api_client(module, 'compute', user_agent_product=USER_AGENT_PRODUCT, + user_agent_version=USER_AGENT_VERSION) + + :returns: A tuple containing the authorized client to the specified service and a + params dict {'service_account_email': '...', 'credentials_file': '...', 'project_id': ...} + :rtype: ``tuple`` + """ + if not scopes: + scopes = GCP_DEFAULT_SCOPES + + http_auth, conn_params = get_google_api_auth(module, scopes=scopes, + user_agent_product=user_agent_product, + user_agent_version=user_agent_version) + client = build(service, api_version, http=http_auth) + + return (client, conn_params) + + def check_min_pkg_version(pkg_name, minimum_version): """Minimum required version is >= installed version.""" from pkg_resources import get_distribution @@ -397,10 +439,12 @@ def check_min_pkg_version(pkg_name, minimum_version): except Exception as e: return False + def unexpected_error_msg(error): """Create an error string based on passed in error.""" return 'Unexpected response: (%s). Detail: %s' % (str(error), traceback.format_exc()) + def get_valid_location(module, driver, location, location_type='zone'): if location_type == 'zone': l = driver.ex_get_zone(location) @@ -414,6 +458,7 @@ def get_valid_location(module, driver, location, location_type='zone'): changed=False) return l + def check_params(params, field_list): """ Helper to validate params. @@ -435,11 +480,12 @@ def check_params(params, field_list): if not d['name'] in params: if 'required' in d and d['required'] is True: raise ValueError(("%s is required and must be of type: %s" % - (d['name'], str(d['type'])))) + (d['name'], str(d['type'])))) else: if not isinstance(params[d['name']], d['type']): - raise ValueError(("%s must be of type: %s" % ( - d['name'], str(d['type'])))) + raise ValueError(("%s must be of type: %s. %s (%s) provided." % ( + d['name'], str(d['type']), params[d['name']], + type(params[d['name']])))) if 'values' in d: if params[d['name']] not in d['values']: raise ValueError(("%s must be one of: %s" % ( @@ -454,3 +500,372 @@ def check_params(params, field_list): raise ValueError("%s must be less than or equal to: %s" % ( d['name'], d['max'])) return True + + +class GCPUtils(object): + """ + Helper utilities for GCP. + """ + + @staticmethod + def underscore_to_camel(txt): + return txt.split('_')[0] + ''.join(x.capitalize() + or '_' for x in txt.split('_')[1:]) + + @staticmethod + def remove_non_gcp_params(params): + """ + Remove params if found. + """ + params_to_remove = ['state'] + for p in params_to_remove: + if p in params: + del params[p] + + return params + + @staticmethod + def params_to_gcp_dict(params, resource_name=None): + """ + Recursively convert ansible params to GCP Params. + + Keys are converted from snake to camelCase + ex: default_service to defaultService + + Handles lists, dicts and strings + + special provision for the resource name + """ + if not isinstance(params, dict): + return params + gcp_dict = {} + params = GCPUtils.remove_non_gcp_params(params) + for k, v in params.items(): + gcp_key = GCPUtils.underscore_to_camel(k) + if isinstance(v, dict): + retval = GCPUtils.params_to_gcp_dict(v) + gcp_dict[gcp_key] = retval + elif isinstance(v, list): + gcp_dict[gcp_key] = [GCPUtils.params_to_gcp_dict(x) for x in v] + else: + if resource_name and k == resource_name: + gcp_dict['name'] = v + else: + gcp_dict[gcp_key] = v + return gcp_dict + + @staticmethod + def execute_api_client_req(req, client=None, raw=True, + operation_timeout=180, poll_interval=5, + raise_404=True): + """ + General python api client interaction function. + + For use with google-api-python-client, or clients created + with get_google_api_client function + Not for use with Google Cloud client libraries + + For long-running operations, we make an immediate query and then + sleep poll_interval before re-querying. After the request is done + we rebuild the request with a get method and return the result. + + """ + try: + resp = req.execute() + + if not resp: + return None + + if raw: + return resp + + if resp['kind'] == 'compute#operation': + resp = GCPUtils.execute_api_client_operation_req(req, resp, + client, + operation_timeout, + poll_interval) + + if 'items' in resp: + return resp['items'] + + return resp + except HttpError as h: + # Note: 404s can be generated (incorrectly) for dependent + # resources not existing. We let the caller determine if + # they want 404s raised for their invocation. + if h.resp.status == 404 and not raise_404: + return None + else: + raise + except Exception: + raise + + @staticmethod + def execute_api_client_operation_req(orig_req, op_resp, client, + operation_timeout=180, poll_interval=5): + """ + Poll an operation for a result. + """ + parsed_url = GCPUtils.parse_gcp_url(orig_req.uri) + project_id = parsed_url['project'] + resource_name = GCPUtils.get_gcp_resource_from_methodId( + orig_req.methodId) + resource = GCPUtils.build_resource_from_name(client, resource_name) + + start_time = time.time() + + complete = False + attempts = 1 + while not complete: + if start_time + operation_timeout >= time.time(): + op_req = client.globalOperations().get( + project=project_id, operation=op_resp['name']) + op_resp = op_req.execute() + if op_resp['status'] != 'DONE': + time.sleep(poll_interval) + attempts += 1 + else: + complete = True + if op_resp['operationType'] == 'delete': + # don't wait for the delete + return True + elif op_resp['operationType'] in ['insert', 'update', 'patch']: + # TODO(supertom): Isolate 'build-new-request' stuff. + resource_name_singular = GCPUtils.get_entity_name_from_resource_name( + resource_name) + if op_resp['operationType'] == 'insert' or not 'entity_name' in parsed_url: + parsed_url['entity_name'] = GCPUtils.parse_gcp_url(op_resp['targetLink'])[ + 'entity_name'] + args = {'project': project_id, + resource_name_singular: parsed_url['entity_name']} + new_req = resource.get(**args) + resp = new_req.execute() + return resp + else: + # assuming multiple entities, do a list call. + new_req = resource.list(project=project_id) + resp = new_req.execute() + return resp + else: + # operation didn't complete on time. + raise GCPOperationTimeoutError("Operation timed out: %s" % ( + op_resp['targetLink'])) + + @staticmethod + def build_resource_from_name(client, resource_name): + try: + method = getattr(client, resource_name) + return method() + except AttributeError: + raise NotImplementedError('%s is not an attribute of %s' % (resource_name, + client)) + + @staticmethod + def get_gcp_resource_from_methodId(methodId): + try: + parts = methodId.split('.') + if len(parts) != 3: + return None + else: + return parts[1] + except AttributeError: + return None + + @staticmethod + def get_entity_name_from_resource_name(resource_name): + if not resource_name: + return None + + try: + # Chop off global or region prefixes + if resource_name.startswith('global'): + resource_name = resource_name.replace('global', '') + elif resource_name.startswith('regional'): + resource_name = resource_name.replace('region', '') + + # ensure we have a lower case first letter + resource_name = resource_name[0].lower() + resource_name[1:] + + if resource_name[-3:] == 'ies': + return resource_name.replace( + resource_name[-3:], 'y') + if resource_name[-1] == 's': + return resource_name[:-1] + + return resource_name + + except AttributeError: + return None + + @staticmethod + def parse_gcp_url(url): + """ + Parse GCP urls and return dict of parts. + + Supported URL structures: + /SERVICE/VERSION/'projects'/PROJECT_ID/RESOURCE + /SERVICE/VERSION/'projects'/PROJECT_ID/RESOURCE/ENTITY_NAME + /SERVICE/VERSION/'projects'/PROJECT_ID/RESOURCE/ENTITY_NAME/METHOD_NAME + /SERVICE/VERSION/'projects'/PROJECT_ID/'global'/RESOURCE + /SERVICE/VERSION/'projects'/PROJECT_ID/'global'/RESOURCE/ENTITY_NAME + /SERVICE/VERSION/'projects'/PROJECT_ID/'global'/RESOURCE/ENTITY_NAME/METHOD_NAME + /SERVICE/VERSION/'projects'/PROJECT_ID/LOCATION_TYPE/LOCATION/RESOURCE + /SERVICE/VERSION/'projects'/PROJECT_ID/LOCATION_TYPE/LOCATION/RESOURCE/ENTITY_NAME + /SERVICE/VERSION/'projects'/PROJECT_ID/LOCATION_TYPE/LOCATION/RESOURCE/ENTITY_NAME/METHOD_NAME + + :param url: GCP-generated URL, such as a selflink or resource location. + :type url: ``str`` + + :return: dictionary of parts. Includes stanard components of urlparse, plus + GCP-specific 'service', 'api_version', 'project' and + 'resource_name' keys. Optionally, 'zone', 'region', 'entity_name' + and 'method_name', if applicable. + :rtype: ``dict`` + """ + + p = urlparse.urlparse(url) + if not p: + return None + else: + # we add extra items such as + # zone, region and resource_name + url_parts = {} + url_parts['scheme'] = p.scheme + url_parts['host'] = p.netloc + url_parts['path'] = p.path + if p.path.find('/') == 0: + url_parts['path'] = p.path[1:] + url_parts['params'] = p.params + url_parts['fragment'] = p.fragment + url_parts['query'] = p.query + url_parts['project'] = None + url_parts['service'] = None + url_parts['api_version'] = None + + path_parts = url_parts['path'].split('/') + url_parts['service'] = path_parts[0] + url_parts['api_version'] = path_parts[1] + if path_parts[2] == 'projects': + url_parts['project'] = path_parts[3] + else: + # invalid URL + raise GCPInvalidURLError('unable to parse: %s' % url) + + if 'global' in path_parts: + url_parts['global'] = True + idx = path_parts.index('global') + if len(path_parts) - idx == 4: + # we have a resource, entity and method_name + url_parts['resource_name'] = path_parts[idx + 1] + url_parts['entity_name'] = path_parts[idx + 2] + url_parts['method_name'] = path_parts[idx + 3] + + if len(path_parts) - idx == 3: + # we have a resource and entity + url_parts['resource_name'] = path_parts[idx + 1] + url_parts['entity_name'] = path_parts[idx + 2] + + if len(path_parts) - idx == 2: + url_parts['resource_name'] = path_parts[idx + 1] + + if len(path_parts) - idx < 2: + # invalid URL + raise GCPInvalidURLError('unable to parse: %s' % url) + + elif 'regions' in path_parts or 'zones' in path_parts: + idx = -1 + if 'regions' in path_parts: + idx = path_parts.index('regions') + url_parts['region'] = path_parts[idx + 1] + else: + idx = path_parts.index('zones') + url_parts['zone'] = path_parts[idx + 1] + + if len(path_parts) - idx == 5: + # we have a resource, entity and method_name + url_parts['resource_name'] = path_parts[idx + 2] + url_parts['entity_name'] = path_parts[idx + 3] + url_parts['method_name'] = path_parts[idx + 4] + + if len(path_parts) - idx == 4: + # we have a resource and entity + url_parts['resource_name'] = path_parts[idx + 2] + url_parts['entity_name'] = path_parts[idx + 3] + + if len(path_parts) - idx == 3: + url_parts['resource_name'] = path_parts[idx + 2] + + if len(path_parts) - idx < 3: + # invalid URL + raise GCPInvalidURLError('unable to parse: %s' % url) + + else: + # no location in URL. + idx = path_parts.index('projects') + if len(path_parts) - idx == 5: + # we have a resource, entity and method_name + url_parts['resource_name'] = path_parts[idx + 2] + url_parts['entity_name'] = path_parts[idx + 3] + url_parts['method_name'] = path_parts[idx + 4] + + if len(path_parts) - idx == 4: + # we have a resource and entity + url_parts['resource_name'] = path_parts[idx + 2] + url_parts['entity_name'] = path_parts[idx + 3] + + if len(path_parts) - idx == 3: + url_parts['resource_name'] = path_parts[idx + 2] + + if len(path_parts) - idx < 3: + # invalid URL + raise GCPInvalidURLError('unable to parse: %s' % url) + + return url_parts + + @staticmethod + def build_googleapi_url(project, api_version='v1', service='compute'): + return 'https://www.googleapis.com/%s/%s/projects/%s' % (service, api_version, project) + + @staticmethod + def filter_gcp_fields(params, excluded_fields=None): + new_params = {} + if not excluded_fields: + excluded_fields = ['creationTimestamp', 'id', 'kind', + 'selfLink', 'fingerprint', 'description'] + + if isinstance(params, list): + new_params = [GCPUtils.filter_gcp_fields( + x, excluded_fields) for x in params] + elif isinstance(params, dict): + for k in params.keys(): + if k not in excluded_fields: + new_params[k] = GCPUtils.filter_gcp_fields( + params[k], excluded_fields) + else: + new_params = params + + return new_params + + @staticmethod + def are_params_equal(p1, p2): + """ + Check if two params dicts are equal. + TODO(supertom): need a way to filter out URLs, or they need to be built + """ + filtered_p1 = GCPUtils.filter_gcp_fields(p1) + filtered_p2 = GCPUtils.filter_gcp_fields(p2) + if filtered_p1 != filtered_p2: + return False + return True + + +class GCPError(Exception): + pass + + +class GCPOperationTimeoutError(GCPError): + pass + + +class GCPInvalidURLError(GCPError): + pass diff --git a/lib/ansible/modules/cloud/google/gcp_url_map.py b/lib/ansible/modules/cloud/google/gcp_url_map.py new file mode 100644 index 0000000000..e865327f64 --- /dev/null +++ b/lib/ansible/modules/cloud/google/gcp_url_map.py @@ -0,0 +1,519 @@ +#!/usr/bin/python +# Copyright 2017 Google Inc. +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +ANSIBLE_METADATA = {'metadata_version': '1.0', + 'status': ['preview'], + 'supported_by': 'community'} + + +DOCUMENTATION = ''' +--- +module: gcp_url_map +version_added: "2.4" +short_description: Create, Update or Destory a Url_Map. +description: + - Create, Update or Destory a Url_Map. See + U(https://cloud.google.com/compute/docs/load-balancing/http/url-map) for an overview. + More details on the Url_Map API can be found at + U(https://cloud.google.com/compute/docs/reference/latest/urlMaps#resource). +requirements: + - "python >= 2.6" + - "google-api-python-client >= 1.6.2" + - "google-auth >= 0.9.0" + - "google-auth-httplib2 >= 0.0.2" +notes: + - Only supports global Backend Services. + - Url_Map tests are not currently supported. +author: + - "Tom Melendez (@supertom) " +options: + url_map_name: + description: + - Name of the Url_Map. + required: true + default_service: + description: + - Default Backend Service if no host rules match. + required: true + host_rules: + description: + - The list of HostRules to use against the URL. Contains + a list of hosts and an associated path_matcher. + - The 'hosts' parameter is a list of host patterns to match. They + must be valid hostnames, except * will match any string of + ([a-z0-9-.]*). In that case, * must be the first character + and must be followed in the pattern by either - or .. + - The 'path_matcher' parameter is name of the PathMatcher to use + to match the path portion of the URL if the hostRule matches the URL's + host portion. + required: false + path_matchers: + description: + - The list of named PathMatchers to use against the URL. Contains + path_rules, which is a list of paths and an associated service. A + default_service can also be specified for each path_matcher. + - The 'name' parameter to which this path_matcher is referred by the + host_rule. + - The 'default_service' parameter is the name of the + BackendService resource. This will be used if none of the path_rules + defined by this path_matcher is matched by the URL's path portion. + - The 'path_rules' parameter is a list of dictionaries containing a + list of paths and a service to direct traffic to. Each path item must + start with / and the only place a * is allowed is at the end following + a /. The string fed to the path matcher does not include any text after + the first ? or #, and those chars are not allowed here. + required: false +''' + +EXAMPLES = ''' +- name: Create Minimal Url_Map + gcp_url_map: + service_account_email: "{{ service_account_email }}" + credentials_file: "{{ credentials_file }}" + project_id: "{{ project_id }}" + url_map_name: my-url_map + default_service: my-backend-service + state: present +- name: Create UrlMap with pathmatcher + gcp_url_map: + service_account_email: "{{ service_account_email }}" + credentials_file: "{{ credentials_file }}" + project_id: "{{ project_id }}" + url_map_name: my-url-map-pm + default_service: default-backend-service + path_matchers: + - name: 'path-matcher-one' + description: 'path matcher one' + default_service: 'bes-pathmatcher-one-default' + path_rules: + - service: 'my-one-bes' + paths: + - '/data' + - '/aboutus' + host_rules: + - hosts: + - '*.' + path_matcher: 'path-matcher-one' + state: "present" +''' + +RETURN = ''' +host_rules: + description: List of HostRules. + returned: If specified. + type: dict + sample: [ { hosts: ["*."], "path_matcher": "my-pm" } ] +path_matchers: + description: The list of named PathMatchers to use against the URL. + returned: If specified. + type: dict + sample: [ { "name": "my-pm", "path_rules": [ { "paths": [ "/data" ] } ], "service": "my-service" } ] +state: + description: state of the Url_Map + returned: Always. + type: str + sample: present +updated_url_map: + description: True if the url_map has been updated. Will not appear on + initial url_map creation. + returned: if the url_map has been updated. + type: bool + sample: true +url_map_name: + description: Name of the Url_Map + returned: Always + type: str + sample: my-url-map +url_map: + description: GCP Url_Map dictionary + returned: Always. Refer to GCP documentation for detailed field descriptions. + type: dict + sample: { "name": "my-url-map", "hostRules": [...], "pathMatchers": [...] } +''' + + +try: + from ast import literal_eval + HAS_PYTHON26 = True +except ImportError: + HAS_PYTHON26 = False + +# import module snippets +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.gcp import check_params, get_google_api_client, GCPUtils + +USER_AGENT_PRODUCT = 'ansible-url_map' +USER_AGENT_VERSION = '0.0.1' + + +def _validate_params(params): + """ + Validate url_map params. + + This function calls _validate_host_rules_params to verify + the host_rules-specific parameters. + + This function calls _validate_path_matchers_params to verify + the path_matchers-specific parameters. + + :param params: Ansible dictionary containing configuration. + :type params: ``dict`` + + :return: True or raises ValueError + :rtype: ``bool`` or `class:ValueError` + """ + fields = [ + {'name': 'default_service', 'type': str, 'required': True}, + {'name': 'host_rules', 'type': list}, + {'name': 'path_matchers', 'type': list}, + ] + try: + check_params(params, fields) + if 'path_matchers' in params and params['path_matchers'] is not None: + _validate_path_matcher_params(params['path_matchers']) + if 'host_rules' in params and params['host_rules'] is not None: + _validate_host_rules_params(params['host_rules']) + except: + raise + + return (True, '') + + +def _validate_path_matcher_params(path_matchers): + """ + Validate configuration for path_matchers. + + :param path_matchers: Ansible dictionary containing path_matchers + configuration (only). + :type path_matchers: ``dict`` + + :return: True or raises ValueError + :rtype: ``bool`` or `class:ValueError` + """ + fields = [ + {'name': 'name', 'type': str, 'required': True}, + {'name': 'default_service', 'type': str, 'required': True}, + {'name': 'path_rules', 'type': list, 'required': True}, + {'name': 'max_rate', 'type': int}, + {'name': 'max_rate_per_instance', 'type': float}, + ] + pr_fields = [ + {'name': 'service', 'type': str, 'required': True}, + {'name': 'paths', 'type': list, 'required': True}, + ] + + if not path_matchers: + raise ValueError(('path_matchers should be a list. %s (%s) provided' + % (path_matchers, type(path_matchers)))) + + for pm in path_matchers: + try: + check_params(pm, fields) + for pr in pm['path_rules']: + check_params(pr, pr_fields) + for path in pr['paths']: + if not path.startswith('/'): + raise ValueError("path for %s must start with /" % ( + pm['name'])) + except: + raise + + return (True, '') + + +def _validate_host_rules_params(host_rules): + """ + Validate configuration for host_rules. + + :param host_rules: Ansible dictionary containing host_rules + configuration (only). + :type host_rules ``dict`` + + :return: True or raises ValueError + :rtype: ``bool`` or `class:ValueError` + """ + fields = [ + {'name': 'path_matcher', 'type': str, 'required': True}, + ] + + if not host_rules: + raise ValueError('host_rules should be a list.') + + for hr in host_rules: + try: + check_params(hr, fields) + for host in hr['hosts']: + if not isinstance(host, basestring): + raise ValueError("host in hostrules must be a string") + elif '*' in host: + if host.index('*') != 0: + raise ValueError("wildcard must be first char in host, %s" % ( + host)) + else: + if host[1] not in ['.', '-', ]: + raise ValueError("wildcard be followed by a '.' or '-', %s" % ( + host)) + + except: + raise + + return (True, '') + + +def _build_path_matchers(path_matcher_list, project_id): + """ + Reformat services in path matchers list. + + Specifically, builds out URLs. + + :param path_matcher_list: The GCP project ID. + :type path_matcher_list: ``list`` of ``dict`` + + :param project_id: The GCP project ID. + :type project_id: ``str`` + + :return: list suitable for submission to GCP + UrlMap API Path Matchers list. + :rtype ``list`` of ``dict`` + """ + url = '' + if project_id: + url = GCPUtils.build_googleapi_url(project_id) + for pm in path_matcher_list: + if 'defaultService' in pm: + pm['defaultService'] = '%s/global/backendServices/%s' % (url, + pm['defaultService']) + if 'pathRules' in pm: + for rule in pm['pathRules']: + if 'service' in rule: + rule['service'] = '%s/global/backendServices/%s' % (url, + rule['service']) + return path_matcher_list + + +def _build_url_map_dict(params, project_id=None): + """ + Reformat services in Ansible Params. + + :param params: Params from AnsibleModule object + :type params: ``dict`` + + :param project_id: The GCP project ID. + :type project_id: ``str`` + + :return: dictionary suitable for submission to GCP UrlMap API. + :rtype ``dict`` + """ + url = '' + if project_id: + url = GCPUtils.build_googleapi_url(project_id) + gcp_dict = GCPUtils.params_to_gcp_dict(params, 'url_map_name') + if 'defaultService' in gcp_dict: + gcp_dict['defaultService'] = '%s/global/backendServices/%s' % (url, + gcp_dict['defaultService']) + if 'pathMatchers' in gcp_dict: + gcp_dict['pathMatchers'] = _build_path_matchers(gcp_dict['pathMatchers'], project_id) + + return gcp_dict + + +def get_url_map(client, name, project_id=None): + """ + Get a Url_Map from GCP. + + :param client: An initialized GCE Compute Disovery resource. + :type client: :class: `googleapiclient.discovery.Resource` + + :param name: Name of the Url Map. + :type name: ``str`` + + :param project_id: The GCP project ID. + :type project_id: ``str`` + + :return: A dict resp from the respective GCP 'get' request. + :rtype: ``dict`` + """ + try: + req = client.urlMaps().get(project=project_id, urlMap=name) + return GCPUtils.execute_api_client_req(req, raise_404=False) + except: + raise + + +def create_url_map(client, params, project_id): + """ + Create a new Url_Map. + + :param client: An initialized GCE Compute Disovery resource. + :type client: :class: `googleapiclient.discovery.Resource` + + :param params: Dictionary of arguments from AnsibleModule. + :type params: ``dict`` + + :return: Tuple with changed status and response dict + :rtype: ``tuple`` in the format of (bool, dict) + """ + gcp_dict = _build_url_map_dict(params, project_id) + try: + req = client.urlMaps().insert(project=project_id, body=gcp_dict) + return_data = GCPUtils.execute_api_client_req(req, client, raw=False) + if not return_data: + return_data = get_url_map(client, + name=params['url_map_name'], + project_id=project_id) + return (True, return_data) + except: + raise + + +def delete_url_map(client, name, project_id): + """ + Delete a Url_Map. + + :param client: An initialized GCE Compute Disover resource. + :type client: :class: `googleapiclient.discovery.Resource` + + :param name: Name of the Url Map. + :type name: ``str`` + + :param project_id: The GCP project ID. + :type project_id: ``str`` + + :return: Tuple with changed status and response dict + :rtype: ``tuple`` in the format of (bool, dict) + """ + try: + req = client.urlMaps().delete(project=project_id, urlMap=name) + return_data = GCPUtils.execute_api_client_req(req, client) + return (True, return_data) + except: + raise + + +def update_url_map(client, url_map, params, name, project_id): + """ + Update a Url_Map. + + If the url_map has not changed, the update will not occur. + + :param client: An initialized GCE Compute Disovery resource. + :type client: :class: `googleapiclient.discovery.Resource` + + :param url_map: Name of the Url Map. + :type url_map: ``dict`` + + :param params: Dictionary of arguments from AnsibleModule. + :type params: ``dict`` + + :param name: Name of the Url Map. + :type name: ``str`` + + :param project_id: The GCP project ID. + :type project_id: ``str`` + + :return: Tuple with changed status and response dict + :rtype: ``tuple`` in the format of (bool, dict) + """ + gcp_dict = _build_url_map_dict(params, project_id) + + ans = GCPUtils.are_params_equal(url_map, gcp_dict) + if ans: + return (False, 'no update necessary') + + gcp_dict['fingerprint'] = url_map['fingerprint'] + try: + req = client.urlMaps().update(project=project_id, + urlMap=name, body=gcp_dict) + return_data = GCPUtils.execute_api_client_req(req, client=client, raw=False) + return (True, return_data) + except: + raise + + +def main(): + module = AnsibleModule(argument_spec=dict( + url_map_name=dict(required=True), + state=dict(choices=['absent', 'present'], default='present'), + default_service=dict(required=True), + path_matchers=dict(type='list', required=False), + host_rules=dict(type='list', required=False), + service_account_email=dict(), + service_account_permissions=dict(type='list'), + pem_file=dict(), + credentials_file=dict(), + project_id=dict(), ), required_together=[ + ['path_matchers', 'host_rules'], ]) + + if not HAS_PYTHON26: + module.fail_json( + msg="GCE module requires python's 'ast' module, python v2.6+") + + client, conn_params = get_google_api_client(module, 'compute', user_agent_product=USER_AGENT_PRODUCT, + user_agent_version=USER_AGENT_VERSION) + + params = {} + params['state'] = module.params.get('state') + params['url_map_name'] = module.params.get('url_map_name') + params['default_service'] = module.params.get('default_service') + if module.params.get('path_matchers'): + params['path_matchers'] = module.params.get('path_matchers') + if module.params.get('host_rules'): + params['host_rules'] = module.params.get('host_rules') + + try: + _validate_params(params) + except Exception as e: + module.fail_json(msg=e.message, changed=False) + + changed = False + json_output = {'state': params['state']} + url_map = get_url_map(client, + name=params['url_map_name'], + project_id=conn_params['project_id']) + + if not url_map: + if params['state'] == 'absent': + # Doesn't exist in GCE, and state==absent. + changed = False + module.fail_json( + msg="Cannot delete unknown url_map: %s" % + (params['url_map_name'])) + else: + # Create + changed, json_output['url_map'] = create_url_map(client, + params=params, + project_id=conn_params['project_id']) + elif params['state'] == 'absent': + # Delete + changed, json_output['url_map'] = delete_url_map(client, + name=params['url_map_name'], + project_id=conn_params['project_id']) + else: + changed, json_output['url_map'] = update_url_map(client, + url_map=url_map, + params=params, + name=params['url_map_name'], + project_id=conn_params['project_id']) + json_output['updated_url_map'] = changed + + json_output['changed'] = changed + json_output.update(params) + module.exit_json(**json_output) + +if __name__ == '__main__': + main() diff --git a/test/integration/gce.yml b/test/integration/gce.yml index 1c7dafb6a1..268b025770 100644 --- a/test/integration/gce.yml +++ b/test/integration/gce.yml @@ -7,4 +7,5 @@ - { role: test_gcdns, tags: test_gcdns } - { role: test_gce_tag, tags: test_gce_tag } - { role: test_gce_net, tags: test_gce_net } + - { role: test_gcp_url_map, tags: test_gcp_url_map } # TODO: tests for gce_lb, gc_storage diff --git a/test/integration/roles/test_gcp_url_map/defaults/main.yml b/test/integration/roles/test_gcp_url_map/defaults/main.yml new file mode 100644 index 0000000000..ce48baab5d --- /dev/null +++ b/test/integration/roles/test_gcp_url_map/defaults/main.yml @@ -0,0 +1,6 @@ +--- +# defaults file for test_gcp_url_map +service_account_email: "{{ gce_service_account_email }}" +credentials_file: "{{ gce_pem_file }}" +project_id: "{{ gce_project_id }}" +urlmap: "ans-int-urlmap-{{ resource_prefix|lower }}" \ No newline at end of file diff --git a/test/integration/roles/test_gcp_url_map/tasks/main.yml b/test/integration/roles/test_gcp_url_map/tasks/main.yml new file mode 100644 index 0000000000..53c31dc39d --- /dev/null +++ b/test/integration/roles/test_gcp_url_map/tasks/main.yml @@ -0,0 +1,178 @@ +# GCP UrlMap Integration Tests. +# Only parameter tests are currently done in this file as this module requires +# a significant amount of infrastructure. +###### +# ============================================================ +- name: "Create UrlMap with no default service (changed == False)" +# ============================================================ + gcp_url_map: + service_account_email: "{{ service_account_email }}" + credentials_file: "{{ credentials_file }}" + project_id: "{{ project_id }}" + url_map_name: "{{ urlmap }}" + host_rules: + - hosts: + - '*.' + path_matcher: 'path-matcher-one' + state: "present" + register: result + ignore_errors: True + tags: + - param-check +- name: "assert urlmap no default service (msg error ignored, changed==False)" + assert: + that: + - 'not result.changed' + - 'result.msg == "missing required arguments: default_service"' + +# ============================================================ +- name: "Create UrlMap with no pathmatcher (changed == False)" +# ============================================================ + gcp_url_map: + service_account_email: "{{ service_account_email }}" + credentials_file: "{{ credentials_file }}" + project_id: "{{ project_id }}" + url_map_name: "{{ urlmap }}" + default_service: "gfr2-bes" + host_rules: + - hosts: + - '*.' + path_matcher: 'path-matcher-one' + state: "present" + register: result + ignore_errors: True + tags: + - param-check +- name: "assert urlmap no path_matcher (msg error ignored, changed==False)" + assert: + that: + - 'not result.changed' + - 'result.msg == "parameters are required together: [''path_matchers'', ''host_rules'']"' + +# ============================================================ +- name: "Create UrlMap with no hostrules (changed == False)" +# ============================================================ + gcp_url_map: + service_account_email: "{{ service_account_email }}" + credentials_file: "{{ credentials_file }}" + project_id: "{{ project_id }}" + url_map_name: "{{ urlmap }}" + default_service: "gfr2-bes" + path_matchers: + - name: 'path-matcher-one' + description: 'path matcher one' + default_service: 'gfr-bes' + path_rules: + - service: 'gfr2-bes' + paths: + - '/data' + - '/aboutus' + state: "present" + tags: + - param-check + register: result + ignore_errors: True +- name: "assert no host_rules (msg error ignored, changed==False)" + assert: + that: + - 'not result.changed' + - 'result.msg == "parameters are required together: [''path_matchers'', ''host_rules'']"' + +# ============================================================ +- name: "Update UrlMap with non-absolute paths (changed==False)" +# ============================================================ + gcp_url_map: + service_account_email: "{{ service_account_email }}" + credentials_file: "{{ credentials_file }}" + project_id: "{{ project_id }}" + url_map_name: "{{ urlmap }}" + default_service: "gfr2-bes" + path_matchers: + - name: 'path-matcher-one' + description: 'path matcher one' + default_service: 'gfr-bes' + path_rules: + - service: 'gfr2-bes' + paths: + - 'data' + - 'aboutus' + host_rules: + - hosts: + - '*.' + path_matcher: 'path-matcher-one' + state: "present" + tags: + - param-check + ignore_errors: True + register: result +- name: "assert path error updated (changed==False)" + assert: + that: + - 'not result.changed' + - 'result.msg == "path for path-matcher-one must start with /"' + +# ============================================================ +- name: "Update UrlMap with invalid wildcard host (changed==False)" +# ============================================================ + gcp_url_map: + service_account_email: "{{ service_account_email }}" + credentials_file: "{{ credentials_file }}" + project_id: "{{ project_id }}" + url_map_name: "{{ urlmap }}" + default_service: "gfr2-bes" + path_matchers: + - name: 'path-matcher-one' + description: 'path matcher one' + default_service: 'gfr-bes' + path_rules: + - service: 'gfr2-bes' + paths: + - '/data' + - '/aboutus' + host_rules: + - hosts: + - 'foobar*' + path_matcher: 'path-matcher-one' + state: "present" + tags: + - param-check + ignore_errors: True + register: result +- name: "assert host wildcard error (error msg ignored, changed==False)" + assert: + that: + - 'not result.changed' + - 'result.msg == "wildcard must be first char in host, foobar*"' + +# ============================================================ +- name: "Update UrlMap with invalid wildcard host second char (changed==False)" +# ============================================================ + gcp_url_map: + service_account_email: "{{ service_account_email }}" + credentials_file: "{{ credentials_file }}" + project_id: "{{ project_id }}" + url_map_name: "{{ urlmap }}" + default_service: "gfr2-bes" + path_matchers: + - name: 'path-matcher-one' + description: 'path matcher one' + default_service: 'gfr-bes' + path_rules: + - service: 'gfr2-bes' + paths: + - '/data' + - '/aboutus' + host_rules: + - hosts: + - '*=' + path_matcher: 'path-matcher-one' + state: "present" + tags: + - param-check + ignore_errors: True + register: result +- name: "assert wildcard error second char (error msg ignored, changed==False)" + assert: + that: + - 'not result.changed' + - 'result.msg == "wildcard be followed by a ''.'' or ''-'', *="' diff --git a/test/sanity/pep8/legacy-files.txt b/test/sanity/pep8/legacy-files.txt index bd250c4178..897c0f7d59 100644 --- a/test/sanity/pep8/legacy-files.txt +++ b/test/sanity/pep8/legacy-files.txt @@ -913,7 +913,6 @@ test/units/module_utils/basic/test_safe_eval.py test/units/module_utils/basic/test_set_mode_if_different.py test/units/module_utils/ec2/test_aws.py test/units/module_utils/gcp/test_auth.py -test/units/module_utils/gcp/test_utils.py test/units/module_utils/json_utils/test_filter_non_json_lines.py test/units/module_utils/test_basic.py test/units/module_utils/test_distribution_version.py diff --git a/test/units/module_utils/gcp/test_utils.py b/test/units/module_utils/gcp/test_utils.py index 75795c9e1c..9842521949 100644 --- a/test/units/module_utils/gcp/test_utils.py +++ b/test/units/module_utils/gcp/test_utils.py @@ -19,7 +19,8 @@ import os import sys from ansible.compat.tests import mock, unittest -from ansible.module_utils.gcp import (check_min_pkg_version) +from ansible.module_utils.gcp import check_min_pkg_version, GCPUtils, GCPInvalidURLError + def build_distribution(version): obj = mock.MagicMock() @@ -28,9 +29,316 @@ def build_distribution(version): class GCPUtilsTestCase(unittest.TestCase): + params_dict = { + 'url_map_name': 'foo_url_map_name', + 'description': 'foo_url_map description', + 'host_rules': [ + { + 'description': 'host rules description', + 'hosts': [ + 'www.example.com', + 'www2.example.com' + ], + 'path_matcher': 'host_rules_path_matcher' + } + ], + 'path_matchers': [ + { + 'name': 'path_matcher_one', + 'description': 'path matcher one', + 'defaultService': 'bes-pathmatcher-one-default', + 'pathRules': [ + { + 'service': 'my-one-bes', + 'paths': [ + '/', + '/aboutus' + ] + } + ] + }, + { + 'name': 'path_matcher_two', + 'description': 'path matcher two', + 'defaultService': 'bes-pathmatcher-two-default', + 'pathRules': [ + { + 'service': 'my-two-bes', + 'paths': [ + '/webapp', + '/graphs' + ] + } + ] + } + ] + } @mock.patch("pkg_resources.get_distribution", side_effect=build_distribution) def test_check_minimum_pkg_version(self, mockobj): self.assertTrue(check_min_pkg_version('foobar', '0.4.0')) self.assertTrue(check_min_pkg_version('foobar', '0.5.0')) self.assertFalse(check_min_pkg_version('foobar', '0.6.0')) + + def test_parse_gcp_url(self): + # region, resource, entity, method + input_url = 'https://www.googleapis.com/compute/v1/projects/myproject/regions/us-east1/instanceGroupManagers/my-mig/recreateInstances' + actual = GCPUtils.parse_gcp_url(input_url) + self.assertEquals('compute', actual['service']) + self.assertEquals('v1', actual['api_version']) + self.assertEquals('myproject', actual['project']) + self.assertEquals('us-east1', actual['region']) + self.assertEquals('instanceGroupManagers', actual['resource_name']) + self.assertEquals('my-mig', actual['entity_name']) + self.assertEquals('recreateInstances', actual['method_name']) + + # zone, resource, entity, method + input_url = 'https://www.googleapis.com/compute/v1/projects/myproject/zones/us-east1-c/instanceGroupManagers/my-mig/recreateInstances' + actual = GCPUtils.parse_gcp_url(input_url) + self.assertEquals('compute', actual['service']) + self.assertEquals('v1', actual['api_version']) + self.assertEquals('myproject', actual['project']) + self.assertEquals('us-east1-c', actual['zone']) + self.assertEquals('instanceGroupManagers', actual['resource_name']) + self.assertEquals('my-mig', actual['entity_name']) + self.assertEquals('recreateInstances', actual['method_name']) + + # global, resource + input_url = 'https://www.googleapis.com/compute/v1/projects/myproject/global/urlMaps' + actual = GCPUtils.parse_gcp_url(input_url) + self.assertEquals('compute', actual['service']) + self.assertEquals('v1', actual['api_version']) + self.assertEquals('myproject', actual['project']) + self.assertTrue('global' in actual) + self.assertTrue(actual['global']) + self.assertEquals('urlMaps', actual['resource_name']) + + # global, resource, entity + input_url = 'https://www.googleapis.com/compute/v1/projects/myproject/global/urlMaps/my-url-map' + actual = GCPUtils.parse_gcp_url(input_url) + self.assertEquals('myproject', actual['project']) + self.assertTrue('global' in actual) + self.assertTrue(actual['global']) + self.assertEquals('v1', actual['api_version']) + self.assertEquals('compute', actual['service']) + + # global URL, resource, entity, method_name + input_url = 'https://www.googleapis.com/compute/v1/projects/myproject/global/backendServices/mybackendservice/getHealth' + actual = GCPUtils.parse_gcp_url(input_url) + self.assertEquals('compute', actual['service']) + self.assertEquals('v1', actual['api_version']) + self.assertEquals('myproject', actual['project']) + self.assertTrue('global' in actual) + self.assertTrue(actual['global']) + self.assertEquals('backendServices', actual['resource_name']) + self.assertEquals('mybackendservice', actual['entity_name']) + self.assertEquals('getHealth', actual['method_name']) + + # no location in URL + input_url = 'https://www.googleapis.com/compute/v1/projects/myproject/targetHttpProxies/mytargetproxy/setUrlMap' + actual = GCPUtils.parse_gcp_url(input_url) + self.assertEquals('compute', actual['service']) + self.assertEquals('v1', actual['api_version']) + self.assertEquals('myproject', actual['project']) + self.assertFalse('global' in actual) + self.assertEquals('targetHttpProxies', actual['resource_name']) + self.assertEquals('mytargetproxy', actual['entity_name']) + self.assertEquals('setUrlMap', actual['method_name']) + + input_url = 'https://www.googleapis.com/compute/v1/projects/myproject/targetHttpProxies/mytargetproxy' + actual = GCPUtils.parse_gcp_url(input_url) + self.assertEquals('compute', actual['service']) + self.assertEquals('v1', actual['api_version']) + self.assertEquals('myproject', actual['project']) + self.assertFalse('global' in actual) + self.assertEquals('targetHttpProxies', actual['resource_name']) + self.assertEquals('mytargetproxy', actual['entity_name']) + + input_url = 'https://www.googleapis.com/compute/v1/projects/myproject/targetHttpProxies' + actual = GCPUtils.parse_gcp_url(input_url) + self.assertEquals('compute', actual['service']) + self.assertEquals('v1', actual['api_version']) + self.assertEquals('myproject', actual['project']) + self.assertFalse('global' in actual) + self.assertEquals('targetHttpProxies', actual['resource_name']) + + # test exceptions + no_projects_input_url = 'https://www.googleapis.com/compute/v1/not-projects/myproject/global/backendServices/mybackendservice/getHealth' + no_resource_input_url = 'https://www.googleapis.com/compute/v1/not-projects/myproject/global' + + no_resource_no_loc_input_url = 'https://www.googleapis.com/compute/v1/not-projects/myproject' + + with self.assertRaises(GCPInvalidURLError) as cm: + GCPUtils.parse_gcp_url(no_projects_input_url) + self.assertTrue(cm.exception, GCPInvalidURLError) + + with self.assertRaises(GCPInvalidURLError) as cm: + GCPUtils.parse_gcp_url(no_resource_input_url) + self.assertTrue(cm.exception, GCPInvalidURLError) + + with self.assertRaises(GCPInvalidURLError) as cm: + GCPUtils.parse_gcp_url(no_resource_no_loc_input_url) + self.assertTrue(cm.exception, GCPInvalidURLError) + + def test_params_to_gcp_dict(self): + + expected = { + 'description': 'foo_url_map description', + 'hostRules': [ + { + 'description': 'host rules description', + 'hosts': [ + 'www.example.com', + 'www2.example.com' + ], + 'pathMatcher': 'host_rules_path_matcher' + } + ], + 'name': 'foo_url_map_name', + 'pathMatchers': [ + { + 'defaultService': 'bes-pathmatcher-one-default', + 'description': 'path matcher one', + 'name': 'path_matcher_one', + 'pathRules': [ + { + 'paths': [ + '/', + '/aboutus' + ], + 'service': 'my-one-bes' + } + ] + }, + { + 'defaultService': 'bes-pathmatcher-two-default', + 'description': 'path matcher two', + 'name': 'path_matcher_two', + 'pathRules': [ + { + 'paths': [ + '/webapp', + '/graphs' + ], + 'service': 'my-two-bes' + } + ] + } + ] + } + + actual = GCPUtils.params_to_gcp_dict(self.params_dict, 'url_map_name') + self.assertEqual(expected, actual) + + def test_get_gcp_resource_from_methodId(self): + input_data = 'compute.urlMaps.list' + actual = GCPUtils.get_gcp_resource_from_methodId(input_data) + self.assertEqual('urlMaps', actual) + input_data = None + actual = GCPUtils.get_gcp_resource_from_methodId(input_data) + self.assertFalse(actual) + input_data = 666 + actual = GCPUtils.get_gcp_resource_from_methodId(input_data) + self.assertFalse(actual) + + def test_get_entity_name_from_resource_name(self): + input_data = 'urlMaps' + actual = GCPUtils.get_entity_name_from_resource_name(input_data) + self.assertEqual('urlMap', actual) + input_data = 'targetHttpProxies' + actual = GCPUtils.get_entity_name_from_resource_name(input_data) + self.assertEqual('targetHttpProxy', actual) + input_data = 'globalForwardingRules' + actual = GCPUtils.get_entity_name_from_resource_name(input_data) + self.assertEqual('forwardingRule', actual) + input_data = '' + actual = GCPUtils.get_entity_name_from_resource_name(input_data) + self.assertEqual(None, actual) + input_data = 666 + actual = GCPUtils.get_entity_name_from_resource_name(input_data) + self.assertEqual(None, actual) + + def test_are_params_equal(self): + params1 = {'one': 1} + params2 = {'one': 1} + actual = GCPUtils.are_params_equal(params1, params2) + self.assertTrue(actual) + + params1 = {'one': 1} + params2 = {'two': 2} + actual = GCPUtils.are_params_equal(params1, params2) + self.assertFalse(actual) + + params1 = {'three': 3, 'two': 2, 'one': 1} + params2 = {'one': 1, 'two': 2, 'three': 3} + actual = GCPUtils.are_params_equal(params1, params2) + self.assertTrue(actual) + + params1 = { + "creationTimestamp": "2017-04-21T11:19:20.718-07:00", + "defaultService": "https://www.googleapis.com/compute/v1/projects/myproject/global/backendServices/default-backend-service", + "description": "", + "fingerprint": "ickr_pwlZPU=", + "hostRules": [ + { + "description": "", + "hosts": [ + "*." + ], + "pathMatcher": "path-matcher-one" + } + ], + "id": "8566395781175047111", + "kind": "compute#urlMap", + "name": "newtesturlmap-foo", + "pathMatchers": [ + { + "defaultService": "https://www.googleapis.com/compute/v1/projects/myproject/global/backendServices/bes-pathmatcher-one-default", + "description": "path matcher one", + "name": "path-matcher-one", + "pathRules": [ + { + "paths": [ + "/data", + "/aboutus" + ], + "service": "https://www.googleapis.com/compute/v1/projects/myproject/global/backendServices/my-one-bes" + } + ] + } + ], + "selfLink": "https://www.googleapis.com/compute/v1/projects/myproject/global/urlMaps/newtesturlmap-foo" + } + params2 = { + "defaultService": "https://www.googleapis.com/compute/v1/projects/myproject/global/backendServices/default-backend-service", + "hostRules": [ + { + "description": "", + "hosts": [ + "*." + ], + "pathMatcher": "path-matcher-one" + } + ], + "name": "newtesturlmap-foo", + "pathMatchers": [ + { + "defaultService": "https://www.googleapis.com/compute/v1/projects/myproject/global/backendServices/bes-pathmatcher-one-default", + "description": "path matcher one", + "name": "path-matcher-one", + "pathRules": [ + { + "paths": [ + "/data", + "/aboutus" + ], + "service": "https://www.googleapis.com/compute/v1/projects/myproject/global/backendServices/my-one-bes" + } + ] + } + ], + } + + # params1 has exclude fields, params2 doesn't. Should be equal + actual = GCPUtils.are_params_equal(params1, params2) + self.assertTrue(actual) diff --git a/test/units/modules/cloud/google/test_gcp_url_map.py b/test/units/modules/cloud/google/test_gcp_url_map.py new file mode 100644 index 0000000000..6178c4cecb --- /dev/null +++ b/test/units/modules/cloud/google/test_gcp_url_map.py @@ -0,0 +1,164 @@ +import unittest + +from ansible.modules.cloud.google.gcp_url_map import _build_path_matchers, _build_url_map_dict + + +class TestGCPUrlMap(unittest.TestCase): + """Unit tests for gcp_url_map module.""" + params_dict = { + 'url_map_name': 'foo_url_map_name', + 'description': 'foo_url_map description', + 'host_rules': [ + { + 'description': 'host rules description', + 'hosts': [ + 'www.example.com', + 'www2.example.com' + ], + 'path_matcher': 'host_rules_path_matcher' + } + ], + 'path_matchers': [ + { + 'name': 'path_matcher_one', + 'description': 'path matcher one', + 'defaultService': 'bes-pathmatcher-one-default', + 'pathRules': [ + { + 'service': 'my-one-bes', + 'paths': [ + '/', + '/aboutus' + ] + } + ] + }, + { + 'name': 'path_matcher_two', + 'description': 'path matcher two', + 'defaultService': 'bes-pathmatcher-two-default', + 'pathRules': [ + { + 'service': 'my-two-bes', + 'paths': [ + '/webapp', + '/graphs' + ] + } + ] + } + ] + } + + def test__build_path_matchers(self): + input_list = [ + { + 'defaultService': 'bes-pathmatcher-one-default', + 'description': 'path matcher one', + 'name': 'path_matcher_one', + 'pathRules': [ + { + 'paths': [ + '/', + '/aboutus' + ], + 'service': 'my-one-bes' + } + ] + }, + { + 'defaultService': 'bes-pathmatcher-two-default', + 'description': 'path matcher two', + 'name': 'path_matcher_two', + 'pathRules': [ + { + 'paths': [ + '/webapp', + '/graphs' + ], + 'service': 'my-two-bes' + } + ] + } + ] + expected = [ + { + 'defaultService': 'https://www.googleapis.com/compute/v1/projects/my-project/global/backendServices/bes-pathmatcher-one-default', + 'description': 'path matcher one', + 'name': 'path_matcher_one', + 'pathRules': [ + { + 'paths': [ + '/', + '/aboutus' + ], + 'service': 'https://www.googleapis.com/compute/v1/projects/my-project/global/backendServices/my-one-bes' + } + ] + }, + { + 'defaultService': 'https://www.googleapis.com/compute/v1/projects/my-project/global/backendServices/bes-pathmatcher-two-default', + 'description': 'path matcher two', + 'name': 'path_matcher_two', + 'pathRules': [ + { + 'paths': [ + '/webapp', + '/graphs' + ], + 'service': 'https://www.googleapis.com/compute/v1/projects/my-project/global/backendServices/my-two-bes' + } + ] + } + ] + actual = _build_path_matchers(input_list, 'my-project') + self.assertEqual(expected, actual) + + def test__build_url_map_dict(self): + + expected = { + 'description': 'foo_url_map description', + 'hostRules': [ + { + 'description': 'host rules description', + 'hosts': [ + 'www.example.com', + 'www2.example.com' + ], + 'pathMatcher': 'host_rules_path_matcher' + } + ], + 'name': 'foo_url_map_name', + 'pathMatchers': [ + { + 'defaultService': 'https://www.googleapis.com/compute/v1/projects/my-project/global/backendServices/bes-pathmatcher-one-default', + 'description': 'path matcher one', + 'name': 'path_matcher_one', + 'pathRules': [ + { + 'paths': [ + '/', + '/aboutus' + ], + 'service': 'https://www.googleapis.com/compute/v1/projects/my-project/global/backendServices/my-one-bes' + } + ] + }, + { + 'defaultService': 'https://www.googleapis.com/compute/v1/projects/my-project/global/backendServices/bes-pathmatcher-two-default', + 'description': 'path matcher two', + 'name': 'path_matcher_two', + 'pathRules': [ + { + 'paths': [ + '/webapp', + '/graphs' + ], + 'service': 'https://www.googleapis.com/compute/v1/projects/my-project/global/backendServices/my-two-bes' + } + ] + } + ] + } + actual = _build_url_map_dict(self.params_dict, 'my-project') + self.assertEqual(expected, actual)