From 4a5cf0b5c19afe05945da6f68181864058c1ea10 Mon Sep 17 00:00:00 2001 From: Tom Melendez Date: Thu, 11 May 2017 10:02:32 -0700 Subject: [PATCH] [GCE] [GCP] UrlMap module (#24422) * [GCP] UrlMap module This module provides support for UrlMaps on Google Cloud Platform. UrlMaps allow users to segment requests by hostname and path and direct those requests to Backend Services. UrlMaps are a powerful and necessary part of HTTP(S) Global Load Balancing on Google Cloud Platform. UrlMap takes advantage of the python-api so the appropriate infrastructure has been added to module_utils. More about UrlMaps can be found at: https://cloud.google.com/compute/docs/load-balancing/http/url-map UrlMap API: https://cloud.google.com/compute/docs/reference/latest/ Google Cloud Platform HTTP(S) Cross-Region Load Balancer: https://cloud.google.com/compute/docs/load-balancing/http/ * updated documentation, remmoved parens * fixed tabs --- lib/ansible/module_utils/gcp.py | 445 ++++++++++++++- .../modules/cloud/google/gcp_url_map.py | 519 ++++++++++++++++++ test/integration/gce.yml | 1 + .../roles/test_gcp_url_map/defaults/main.yml | 6 + .../roles/test_gcp_url_map/tasks/main.yml | 178 ++++++ test/sanity/pep8/legacy-files.txt | 1 - test/units/module_utils/gcp/test_utils.py | 310 ++++++++++- .../modules/cloud/google/test_gcp_url_map.py | 164 ++++++ 8 files changed, 1607 insertions(+), 17 deletions(-) create mode 100644 lib/ansible/modules/cloud/google/gcp_url_map.py create mode 100644 test/integration/roles/test_gcp_url_map/defaults/main.yml create mode 100644 test/integration/roles/test_gcp_url_map/tasks/main.yml create mode 100644 test/units/modules/cloud/google/test_gcp_url_map.py diff --git a/lib/ansible/module_utils/gcp.py b/lib/ansible/module_utils/gcp.py index c3b0d2bdd3b..441462bab73 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 00000000000..e865327f641 --- /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 1c7dafb6a17..268b0257705 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 00000000000..ce48baab5d3 --- /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 00000000000..53c31dc39d7 --- /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 bd250c41785..897c0f7d59e 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 75795c9e1cb..98425219492 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 00000000000..6178c4cecbb --- /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)