diff --git a/keystoneauth1/discover.py b/keystoneauth1/discover.py index 77752dac..a7189e90 100644 --- a/keystoneauth1/discover.py +++ b/keystoneauth1/discover.py @@ -34,6 +34,28 @@ from keystoneauth1 import exceptions _LOGGER = utils.get_logger(__name__) +LATEST = float('inf') + + +def _str_or_latest(val): + """Convert val to a string, handling LATEST => 'latest'. + + :param val: An int or the special value LATEST. + :return: A string representation of val. If val was LATEST, the return is + 'latest'. + """ + return 'latest' if val == LATEST else str(val) + + +def _int_or_latest(val): + """Convert val to an int or the special value LATEST. + + :param val: An int()-able, or the string 'latest', or the special value + LATEST. + :return: An int, or the special value LATEST + """ + return LATEST if val == 'latest' or val == LATEST else int(val) + @positional() def get_version_data(session, url, authenticated=None): @@ -122,17 +144,26 @@ def normalize_version_number(version): 'v1.20.3', '1.20.3', (1, 20, 3), ['1', '20', '3'] + The following all produce a return value of (LATEST, LATEST):: + + 'latest', 'vlatest', ('latest', 'latest'), (LATEST, LATEST) + + The following all produce a return value of (2, LATEST):: + + '2.latest', 'v2.latest', (2, LATEST), ('2', 'latest') + :param version: A version specifier in any of the following forms: String, possibly prefixed with 'v', containing one or more numbers - separated by periods. Examples: 'v1', 'v1.2', '1.2.3', '123' + *or* the string 'latest', separated by periods. Examples: 'v1', + 'v1.2', '1.2.3', '123', 'latest', '1.latest', 'v1.latest'. Integer. This will be assumed to be the major version, with a minor version of 0. Float. The integer part is assumed to be the major version; the decimal part the minor version. - Non-string iterable comprising integers or integer strings. - Examples: (1,), [1, 2], ('12', '34', '56') - :return: A tuple of integers of len >= 2. - :rtype: tuple(int) + Non-string iterable comprising integers, integer strings, the string + 'latest', or the special value LATEST. + Examples: (1,), [1, 2], ('12', '34', '56'), (LATEST,), (2, 'latest') + :return: A tuple of len >= 2 comprising integers and/or LATEST. :raises TypeError: If the input version cannot be interpreted. """ # Copy the input var so the error presents the original value @@ -142,7 +173,7 @@ def normalize_version_number(version): # processing. This ensures at least 1 decimal point if e.g. [1] is given. if not isinstance(ver, six.string_types): try: - ver = '.'.join(map(str, ver)) + ver = '.'.join(map(_str_or_latest, ver)) except TypeError: # Not an iterable pass @@ -164,7 +195,7 @@ def normalize_version_number(version): # If it's an int or float, turn it into a float string elif isinstance(ver, (int, float)): - ver = str(float(ver)) + ver = _str_or_latest(float(ver)) # At this point, we should either have a string that contains numbers with # at least one decimal point, or something decidedly else. @@ -176,9 +207,13 @@ def normalize_version_number(version): # Not a string pass + # Handle special case variants of just 'latest' + if ver == 'latest' or tuple(ver) == ('latest',): + return LATEST, LATEST + # It's either an interable, or something else that makes us sad. try: - return tuple(map(int, ver)) + return tuple(map(_int_or_latest, ver)) except (TypeError, ValueError): pass @@ -190,58 +225,97 @@ def _normalize_version_args(version, min_version, max_version): raise ValueError( "version is mutually exclusive with min_version and max_version") - if min_version == 'latest' and max_version not in (None, 'latest'): - raise ValueError( - "min_version is 'latest' and max_version is {max_version}" - " but is only allowed to be 'latest' or None".format( - max_version=max_version)) + if version: + # Explode this into min_version and max_version + min_version = normalize_version_number(version) + max_version = (min_version[0], LATEST) + return min_version, max_version - if version and version != 'latest': - version = normalize_version_number(version) + if min_version == 'latest': + if max_version not in (None, 'latest'): + raise ValueError( + "min_version is 'latest' and max_version is {max_version}" + " but is only allowed to be 'latest' or None".format( + max_version=max_version)) + max_version = 'latest' + + # Normalize e.g. empty string to None + min_version = min_version or None + max_version = max_version or None if min_version: - if min_version == 'latest': - min_version = None - max_version = 'latest' - else: - min_version = normalize_version_number(min_version) + min_version = normalize_version_number(min_version) + # If min_version was specified but max_version was not, max is latest. + max_version = normalize_version_number(max_version or 'latest') - if max_version and max_version != 'latest': + # NOTE(efried): We should be doing this instead: + # max_version = normalize_version_number(max_version or 'latest') + # However, see first NOTE(jamielennox) in EndpointData._set_version_info. + if max_version: max_version = normalize_version_number(max_version) - return version, min_version, max_version + if None not in (min_version, max_version) and max_version < min_version: + raise ValueError("min_version cannot be greater than max_version") + + return min_version, max_version def version_to_string(version): """Turn a version tuple into a string. - :param tuple(int) version: A version represented as a tuple of ints. + :param tuple version: A version represented as a tuple of ints. As a + special case, a tuple member may be LATEST, which + translates to 'latest'. :return: A version represented as a period-delimited string. """ - return ".".join(map(str, version)) + # Special case + if all(ver == LATEST for ver in version): + return 'latest' + + return ".".join(map(_str_or_latest, version)) def version_between(min_version, max_version, candidate): + """Determine whether a candidate version is within a specified range. + + :param min_version: Normalized lower bound. May be None. May be + (LATEST, LATEST). + :param max_version: Normalized upper bound. May be None. May be + (LATEST, LATEST). + :param candidate: Normalized candidate version to test. May not be None. + :return: True if candidate is between min_version and max_version; False + otherwise. + :raises ValueError: If candidate is None or the input is not properly + normalized. + """ + def is_normalized(ver): + return normalize_version_number(ver) == ver + # A version can't be between a range that doesn't exist if not min_version and not max_version: return False + if candidate is None: + raise ValueError("candidate cannot be None.") + + if min_version is not None and not is_normalized(min_version): + raise ValueError("min_version is not normalized.") + if max_version is not None and not is_normalized(max_version): + raise ValueError("max_version is not normalized.") + if not is_normalized(candidate): + raise ValueError("candidate is not normalized.") + # This is only possible if args weren't run through _normalize_version_args + if max_version is None and min_version is not None: + raise ValueError("Can't use None as an upper bound.") + # If the candidate is less than the min_version, it's - # not a match. - if min_version: - min_version = normalize_version_number(min_version) - if candidate < min_version: - return False - - # Lack of max_version implies latest. - if max_version == 'latest' or not max_version: - return True - - max_version = normalize_version_number(max_version) - if version_match(max_version, candidate): - return True - if max_version < candidate: + # not a match. None works here. + if min_version is not None and candidate < min_version: return False + + if max_version is not None and candidate > max_version: + return False + return True @@ -271,6 +345,24 @@ def version_match(required, candidate): return True +def _latest_soft_match(required, candidate): + if not required: + return False + + if LATEST not in required: + return False + + if all(part == LATEST for part in required): + return True + + if required[0] == candidate[0] and required[1] == LATEST: + return True + + # TODO(efried): Do we need to handle >2-part version numbers here? + + return False + + def _combine_relative_url(discovery_url, version_url): # NOTE(jamielennox): urllib.parse.urljoin allows the url to be relative # or even protocol-less. The additional trailing '/' makes urljoin respect @@ -446,6 +538,10 @@ class Discover(object): version = normalize_version_number(version) for data in self.version_data(reverse=True, **kwargs): + # Since the data is reversed, the latest version is first. If + # latest was requested, return it. + if _latest_soft_match(version, data['version']): + return data if version_match(version, data['version']): return data @@ -468,43 +564,39 @@ class Discover(object): data = self.data_for(version, **kwargs) return data['url'] if data else None - def versioned_data_for(self, version=None, url=None, + def versioned_data_for(self, url=None, min_version=None, max_version=None, **kwargs): """Return endpoint data for the service at a url. - version, min_version and max_version can all be given either as a - string or a tuple. + min_version and max_version can be given either as strings or tuples. - :param version: The version is the minimum version in the - same major release as there should be no compatibility issues with - using a version newer than the one asked for. If version is not - given, the highest available version will be matched. :param string url: If url is given, the data will be returned for the endpoint data that has a self link matching the url. - :param min_version: The minimum version that is acceptable. Mutually - exclusive with version. If min_version is given with no max_version - it is as if max version is 'latest'. If min_version is 'latest', - max_version may only be 'latest' or None. - :param max_version: The maximum version that is acceptable. Mutually - exclusive with version. If min_version is given with no max_version - it is as if max version is 'latest'. If min_version is 'latest', - max_version may only be 'latest' or None. + :param min_version: The minimum endpoint version that is acceptable. If + min_version is given with no max_version it is as if max version is + 'latest'. If min_version is 'latest', max_version may only be + 'latest' or None. + :param max_version: The maximum endpoint version that is acceptable. If + min_version is given with no max_version it is as if max version is + 'latest'. If min_version is 'latest', max_version may only be + 'latest' or None. :returns: the endpoint data for a URL that matches the required version (the format is described in version_data) or None if no match. :rtype: dict """ - version, min_version, max_version = _normalize_version_args( - version, min_version, max_version) - no_version = not version and not max_version and not min_version + min_version, max_version = _normalize_version_args( + None, min_version, max_version) + no_version = not max_version and not min_version version_data = self.version_data(reverse=True, **kwargs) # If we don't have to check a min_version, we can short # circuit anything else - if 'latest' in (version, max_version) and not min_version: + if (max_version == (LATEST, LATEST) and + (not min_version or min_version == (LATEST, LATEST))): # because we reverse we can just take the first entry return version_data[0] @@ -520,7 +612,7 @@ class Discover(object): for data in version_data: if url and data['url'] and data['url'].rstrip('/') + '/' == url: return data - if version and version_match(version, data['version']): + if _latest_soft_match(min_version, data['version']): return data if version_between(min_version, max_version, data['version']): return data @@ -537,27 +629,22 @@ class Discover(object): # We couldn't find a match. return None - def versioned_url_for(self, version=None, - min_version=None, max_version=None, **kwargs): + def versioned_url_for(self, min_version=None, max_version=None, **kwargs): """Get the endpoint url for a version. - version, min_version and max_version can all be given either as a - string or a tuple. + min_version and max_version can be given either as strings or tuples. - :param version: The version is always a minimum version in the - same major release as there should be no compatibility issues with - using a version newer than the one asked for. - :param min_version: The minimum version that is acceptable. Mutually - exclusive with version. If min_version is given with no max_version - it is as if max version is 'latest'. - :param max_version: The maximum version that is acceptable. Mutually - exclusive with version. If min_version is given with no max_version - it is as if max version is 'latest'. + :param min_version: The minimum version that is acceptable. If + min_version is given with no max_version it is as if max version + is 'latest'. + :param max_version: The maximum version that is acceptable. If + min_version is given with no max_version it is as if max version is + 'latest'. :returns: The url for the specified version or None if no match. :rtype: str """ - data = self.versioned_data_for(version, min_version=min_version, + data = self.versioned_data_for(min_version=min_version, max_version=max_version, **kwargs) return data['url'] if data else None @@ -639,22 +726,19 @@ class EndpointData(object): return self.service_url or self.catalog_url @positional(3) - def get_versioned_data(self, session, version=None, - allow=None, cache=None, allow_version_hack=True, - project_id=None, discover_versions=True, + def get_versioned_data(self, session, allow=None, cache=None, + allow_version_hack=True, project_id=None, + discover_versions=True, min_version=None, max_version=None): """Run version discovery for the service described. Performs Version Discovery and returns a new EndpointData object with information found. - version, min_version and max_version can all be given either as a - string or a tuple. + min_version and max_version can be given either as strings or tuples. :param session: A session object that can be used for communication. :type session: keystoneauth1.session.Session - :param version: The minimum major version required for this endpoint. - Mutually exclusive with min_version and max_version. :param dict allow: Extra filters to pass when discovering API versions. (optional) :param dict cache: A dict to be used for caching results in @@ -671,14 +755,12 @@ class EndpointData(object): if it's not neccessary to fulfill the major version request. (optional, defaults to True) - :param min_version: The minimum version that is acceptable. Mutually - exclusive with version. If min_version is given - with no max_version it is as if max version is - 'latest'. - :param max_version: The maximum version that is acceptable. Mutually - exclusive with version. If min_version is given - with no max_version it is as if max version is - 'latest'. + :param min_version: The minimum version that is acceptable. If + min_version is given with no max_version it is as + if max version is 'latest'. + :param max_version: The maximum version that is acceptable. If + min_version is given with no max_version it is as + if max version is 'latest'. :returns: A new EndpointData with the requested versioned data. :rtype: :py:class:`keystoneauth1.discover.EndpointData` @@ -686,8 +768,8 @@ class EndpointData(object): appropriate versioned data could not be discovered. """ - version, min_version, max_version = _normalize_version_args( - version, min_version, max_version) + min_version, max_version = _normalize_version_args( + None, min_version, max_version) if not allow: allow = {} @@ -696,19 +778,19 @@ class EndpointData(object): new_data = copy.copy(self) new_data._set_version_info( - session=session, version=version, allow=allow, cache=cache, + session=session, allow=allow, cache=cache, allow_version_hack=allow_version_hack, project_id=project_id, discover_versions=discover_versions, min_version=min_version, max_version=max_version) return new_data - def _set_version_info(self, session, version, allow=None, cache=None, + def _set_version_info(self, session, allow=None, cache=None, allow_version_hack=True, project_id=None, discover_versions=False, min_version=None, max_version=None): match_url = None - no_version = not version and not max_version and not min_version + no_version = not max_version and not min_version if no_version and not discover_versions: # NOTE(jamielennox): This may not be the best thing to default to # but is here for backwards compatibility. It may be worth @@ -727,27 +809,22 @@ class EndpointData(object): # satisfy the request without further work if self._disc: discovered_data = self._disc.versioned_data_for( - version, min_version=min_version, max_version=max_version, + min_version=min_version, max_version=max_version, url=match_url, **allow) if not discovered_data: self._run_discovery( session=session, cache=cache, - version=version, min_version=min_version, - max_version=max_version, project_id=project_id, - allow_version_hack=allow_version_hack, + min_version=min_version, max_version=max_version, + project_id=project_id, allow_version_hack=allow_version_hack, discover_versions=discover_versions) if not self._disc: return discovered_data = self._disc.versioned_data_for( - version, min_version=min_version, max_version=max_version, + min_version=min_version, max_version=max_version, url=match_url, **allow) if not discovered_data: - if version: - raise exceptions.DiscoveryFailure( - "Version {version} requested, but was not found".format( - version=version_to_string(version))) - elif min_version and not max_version: + if min_version and not max_version: raise exceptions.DiscoveryFailure( "Minimum version {min_version} was not found".format( min_version=version_to_string(min_version))) @@ -787,13 +864,12 @@ class EndpointData(object): self.service_url = url @positional(1) - def _run_discovery(self, session, cache, version, min_version, - max_version, project_id, - allow_version_hack, discover_versions): + def _run_discovery(self, session, cache, min_version, max_version, + project_id, allow_version_hack, discover_versions): tried = set() for vers_url in self._get_discovery_url_choices( - version=version, project_id=project_id, + project_id=project_id, allow_version_hack=allow_version_hack, min_version=min_version, max_version=max_version): @@ -850,12 +926,12 @@ class EndpointData(object): " found and allow_version_hack was False") def _get_discovery_url_choices( - self, version=None, project_id=None, allow_version_hack=True, + self, project_id=None, allow_version_hack=True, min_version=None, max_version=None): """Find potential locations for version discovery URLs. - version, min_version and max_version are already normalized, so will - either be None, 'latest' or a tuple. + min_version and max_version are already normalized, so will either be + None or a tuple. """ url = urllib.parse.urlparse(self.url) url_parts = url.path.split('/') @@ -891,14 +967,12 @@ class EndpointData(object): except TypeError: pass else: - is_between = version_between( - min_version, max_version, url_version) - exact_match = (version and version != 'latest' and - version_match(version, url_version)) + is_between = version_between(min_version, max_version, url_version) + exact_match = (is_between and max_version and + max_version[0] == url_version[0]) high_match = (is_between and max_version and - max_version != 'latest' and + max_version[1] != LATEST and version_match(max_version, url_version)) - if exact_match or is_between: self._catalog_matches_version = True self._catalog_matches_exactly = exact_match diff --git a/keystoneauth1/identity/base.py b/keystoneauth1/identity/base.py index 52a7290c..50539f7b 100644 --- a/keystoneauth1/identity/base.py +++ b/keystoneauth1/identity/base.py @@ -157,12 +157,10 @@ class BaseIdentityPlugin(plugin.BaseAuthPlugin): return False def get_endpoint_data(self, session, service_type=None, interface=None, - region_name=None, service_name=None, version=None, - allow={}, allow_version_hack=True, - discover_versions=True, skip_discovery=False, - min_version=None, max_version=None, - endpoint_override=None, - **kwargs): + region_name=None, service_name=None, allow={}, + allow_version_hack=True, discover_versions=True, + skip_discovery=False, min_version=None, + max_version=None, endpoint_override=None, **kwargs): """Return a valid endpoint data for a service. If a valid token is not present then a new one will be fetched using @@ -190,8 +188,6 @@ class BaseIdentityPlugin(plugin.BaseAuthPlugin): (optional) :param string service_name: The name of the service in the catalog. (optional) - :param version: The minimum version number required for this - endpoint. (optional) :param dict allow: Extra filters to pass when discovering API versions. (optional) :param bool allow_version_hack: Allow keystoneauth to hack up catalog @@ -220,6 +216,7 @@ class BaseIdentityPlugin(plugin.BaseAuthPlugin): but version discovery will be run. Sets allow_version_hack to False (optional) + :param kwargs: Ignored. :raises keystoneauth1.exceptions.http.HttpError: An error from an invalid HTTP response. @@ -227,6 +224,9 @@ class BaseIdentityPlugin(plugin.BaseAuthPlugin): :return: Valid EndpointData or None if not available. :rtype: `keystoneauth1.discover.EndpointData` or None """ + min_version, max_version = discover._normalize_version_args( + None, min_version, max_version) + # NOTE(jamielennox): if you specifically ask for requests to be sent to # the auth url then we can ignore many of the checks. Typically if you # are asking for the auth endpoint it means that there is no catalog to @@ -283,7 +283,7 @@ class BaseIdentityPlugin(plugin.BaseAuthPlugin): try: return endpoint_data.get_versioned_data( - session, version, + session, project_id=project_id, min_version=min_version, max_version=max_version, @@ -295,7 +295,7 @@ class BaseIdentityPlugin(plugin.BaseAuthPlugin): exceptions.ConnectionError): # If a version was requested, we didn't find it, return # None. - if version or max_version or min_version: + if max_version or min_version: return None # If one wasn't, then the endpoint_data we already have # should be fine @@ -361,6 +361,10 @@ class BaseIdentityPlugin(plugin.BaseAuthPlugin): :return: A valid endpoint URL or None if not available. :rtype: string or None """ + # Explode `version` into min_version and max_version - everything below + # here uses the latter rather than the former. + min_version, max_version = discover._normalize_version_args( + version, min_version, max_version) # Set discover_versions to False since we're only going to return # a URL. Fetching the microversion data would be needlessly # expensive in the common case. However, discover_versions=False @@ -369,11 +373,8 @@ class BaseIdentityPlugin(plugin.BaseAuthPlugin): endpoint_data = self.get_endpoint_data( session, service_type=service_type, interface=interface, region_name=region_name, service_name=service_name, - version=version, allow=allow, - min_version=min_version, - max_version=max_version, - discover_versions=False, - skip_discovery=skip_discovery, + allow=allow, min_version=min_version, max_version=max_version, + discover_versions=False, skip_discovery=skip_discovery, allow_version_hack=allow_version_hack, **kwargs) return endpoint_data.url if endpoint_data else None diff --git a/keystoneauth1/session.py b/keystoneauth1/session.py index bef5d566..5621c5f3 100644 --- a/keystoneauth1/session.py +++ b/keystoneauth1/session.py @@ -434,6 +434,11 @@ class Session(object): # TODO(mordred) Validate that the requested microversion works # with the microversion range we found in discovery. microversion = discover.normalize_version_number(microversion) + # Can't specify a M.latest microversion + if (microversion[0] != discover.LATEST and + discover.LATEST in microversion[1:]): + raise TypeError( + "Specifying a '{major}.latest' microversion is not allowed.") microversion = discover.version_to_string(microversion) if not service_type: if endpoint_filter and 'service_type' in endpoint_filter: @@ -942,6 +947,7 @@ class Session(object): return kwargs['endpoint_override'] auth = self._auth_required(auth, 'determine endpoint URL') + return auth.get_endpoint(self, **kwargs) def get_endpoint_data(self, auth=None, **kwargs): diff --git a/keystoneauth1/tests/unit/identity/test_identity_common.py b/keystoneauth1/tests/unit/identity/test_identity_common.py index fd08e53e..f886385c 100644 --- a/keystoneauth1/tests/unit/identity/test_identity_common.py +++ b/keystoneauth1/tests/unit/identity/test_identity_common.py @@ -535,11 +535,13 @@ class CommonIdentityTests(object): data_v2 = a.get_endpoint_data(session=s, service_type='compute', interface='admin', - version=(2, 0)) + min_version=(2, 0), + max_version=(2, discover.LATEST)) data_v3 = a.get_endpoint_data(session=s, service_type='compute', interface='admin', - version=(3, 0)) + min_version=(3, 0), + max_version=(3, discover.LATEST)) self.assertEqual(self.TEST_COMPUTE_ADMIN + '/v2.0', data_v2.url) self.assertEqual(self.TEST_COMPUTE_ADMIN + '/v3', data_v3.url) @@ -566,15 +568,26 @@ class CommonIdentityTests(object): interface='admin') self.assertEqual(v3_compute, data.url) - v2_data = data.get_versioned_data(s, version='2.0') + v2_data = data.get_versioned_data(s, min_version='2.0', + max_version='2.latest') self.assertEqual(v2_compute, v2_data.url) self.assertEqual(v2_compute, v2_data.service_url) self.assertEqual(self.TEST_COMPUTE_ADMIN, v2_data.catalog_url) - v3_data = data.get_versioned_data(s, version='3.0') - self.assertEqual(v3_compute, v3_data.url) - self.assertEqual(v3_compute, v3_data.service_url) - self.assertEqual(self.TEST_COMPUTE_ADMIN, v3_data.catalog_url) + # Variants that all return v3 data + for vkwargs in (dict(min_version='3.0', max_version='3.latest'), + # min/max spans major versions + dict(min_version='2.0', max_version='3.latest'), + # latest major max + dict(min_version='2.0', max_version='latest'), + # implicit max + dict(min_version='2.0'), + # implicit min/max + dict()): + v3_data = data.get_versioned_data(s, **vkwargs) + self.assertEqual(v3_compute, v3_data.url) + self.assertEqual(v3_compute, v3_data.service_url) + self.assertEqual(self.TEST_COMPUTE_ADMIN, v3_data.catalog_url) def test_interface_list(self): @@ -637,7 +650,8 @@ class CommonIdentityTests(object): data.url) v3_data = data.get_versioned_data( - s, version='3.0', project_id=self.project_id) + s, min_version='3.0', max_version='3.latest', + project_id=self.project_id) self.assertEqual(self.TEST_VOLUME.versions['v3'].service.public, v3_data.url) @@ -651,7 +665,8 @@ class CommonIdentityTests(object): # find the unversioned endpoint self.requests_mock.get(self.TEST_VOLUME.unversioned.public, resps) v2_data = data.get_versioned_data( - s, version='2.0', project_id=self.project_id) + s, min_version='2.0', max_version='2.latest', + project_id=self.project_id) # Even though we never requested volumev2 from the catalog, we should # wind up re-constructing it via version discovery and re-appending @@ -700,7 +715,8 @@ class CommonIdentityTests(object): # it should fetch the unversioned endpoint v2_data = s.get_endpoint_data(service_type='volumev3', interface='public', - version='2.0', + min_version='2.0', + max_version='2.latest', project_id=self.project_id) # Even though we never requested volumev2 from the catalog, we should @@ -718,7 +734,8 @@ class CommonIdentityTests(object): # request for v2, we should have all the relevant data cached in the # discovery object - and should not fetch anything new. v3_data = v2_data.get_versioned_data( - s, version='3.0', project_id=self.project_id) + s, min_version='3.0', max_version='3.latest', + project_id=self.project_id) self.assertEqual(self.TEST_VOLUME.versions['v3'].service.public, v3_data.url) @@ -1104,7 +1121,8 @@ class CatalogHackTests(utils.TestCase): data = sess.get_endpoint_data(endpoint_override=self.OTHER_URL, service_type=self.IDENTITY, interface='public', - version=(2, 0)) + min_version=(2, 0), + max_version=(2, discover.LATEST)) self.assertTrue(common_m.called) self.assertEqual(self.OTHER_URL, data.url) @@ -1308,9 +1326,8 @@ class CatalogHackTests(utils.TestCase): endpoint = sess.get_endpoint(service_type=self.IDENTITY, min_version='1', max_version='2') - # We should make one more calls - # TODO(mordred) optimize this - we can peek in the cache - self.assertTrue(v2_m.called) + # We should make no calls - we peek in the cache + self.assertFalse(v2_m.called) self.assertFalse(common_m.called) # And get the v2 url diff --git a/keystoneauth1/tests/unit/test_discovery.py b/keystoneauth1/tests/unit/test_discovery.py index f5482e44..d71b0303 100644 --- a/keystoneauth1/tests/unit/test_discovery.py +++ b/keystoneauth1/tests/unit/test_discovery.py @@ -303,13 +303,114 @@ class DiscoverUtils(utils.TestCase): assertVersion([1, 40], (1, 40)) assertVersion((1,), (1, 0)) assertVersion(['1'], (1, 0)) + assertVersion('latest', (discover.LATEST, discover.LATEST)) + assertVersion(['latest'], (discover.LATEST, discover.LATEST)) + assertVersion(discover.LATEST, (discover.LATEST, discover.LATEST)) + assertVersion((discover.LATEST,), (discover.LATEST, discover.LATEST)) + assertVersion('10.latest', (10, discover.LATEST)) + assertVersion((10, 'latest'), (10, discover.LATEST)) + assertVersion((10, discover.LATEST), (10, discover.LATEST)) + versionRaises(None) versionRaises('hello') versionRaises('1.a') versionRaises('vacuum') versionRaises('') versionRaises(('1', 'a')) + def test_version_args(self): + """Validate _normalize_version_args.""" + def assert_min_max(in_ver, in_min, in_max, out_min, out_max): + self.assertEqual( + (out_min, out_max), + discover._normalize_version_args(in_ver, in_min, in_max)) + + def normalize_raises(ver, min, max): + self.assertRaises(ValueError, + discover._normalize_version_args, ver, min, max) + + assert_min_max(None, None, None, + None, None) + assert_min_max(None, None, 'v1.2', + None, (1, 2)) + assert_min_max(None, 'v1.2', 'latest', + (1, 2), (discover.LATEST, discover.LATEST)) + assert_min_max(None, 'v1.2', '1.6', + (1, 2), (1, 6)) + assert_min_max(None, 'v1.2', '1.latest', + (1, 2), (1, discover.LATEST)) + assert_min_max(None, 'latest', 'latest', + (discover.LATEST, discover.LATEST), + (discover.LATEST, discover.LATEST)) + assert_min_max(None, 'latest', None, + (discover.LATEST, discover.LATEST), + (discover.LATEST, discover.LATEST)) + assert_min_max(None, (1, 2), None, + (1, 2), (discover.LATEST, discover.LATEST)) + assert_min_max('', ('1', '2'), (1, 6), + (1, 2), (1, 6)) + assert_min_max(None, ('1', '2'), (1, discover.LATEST), + (1, 2), (1, discover.LATEST)) + assert_min_max('v1.2', '', None, + (1, 2), (1, discover.LATEST)) + assert_min_max('1.latest', None, '', + (1, discover.LATEST), (1, discover.LATEST)) + assert_min_max('v1', None, None, + (1, 0), (1, discover.LATEST)) + assert_min_max('latest', None, None, + (discover.LATEST, discover.LATEST), + (discover.LATEST, discover.LATEST)) + + normalize_raises('v1', 'v2', None) + normalize_raises('v1', None, 'v2') + normalize_raises(None, 'latest', 'v1') + normalize_raises(None, 'v1.2', 'v1.1') + normalize_raises(None, 'v1.2', 1) + + def test_version_to_string(self): + def assert_string(inp, out): + self.assertEqual(out, discover.version_to_string(inp)) + + assert_string((discover.LATEST,), 'latest') + assert_string((discover.LATEST, discover.LATEST), 'latest') + assert_string((discover.LATEST, discover.LATEST, discover.LATEST), + 'latest') + assert_string((1,), '1') + assert_string((1, 2), '1.2') + assert_string((1, discover.LATEST), '1.latest') + + def test_version_between(self): + def good(minver, maxver, cand): + self.assertTrue(discover.version_between(minver, maxver, cand)) + + def bad(minver, maxver, cand): + self.assertFalse(discover.version_between(minver, maxver, cand)) + + def exc(minver, maxver, cand): + self.assertRaises(ValueError, + discover.version_between, minver, maxver, cand) + + good((1, 0), (1, 0), (1, 0)) + good((1, 0), (1, 10), (1, 2)) + good(None, (1, 10), (1, 2)) + good((1, 20), (2, 0), (1, 21)) + good((1, 0), (2, discover.LATEST), (1, 21)) + good((1, 0), (2, discover.LATEST), (1, discover.LATEST)) + good((1, 50), (2, discover.LATEST), (2, discover.LATEST)) + + bad((discover.LATEST, discover.LATEST), + (discover.LATEST, discover.LATEST), (1, 0)) + bad(None, None, (1, 0)) + bad((1, 50), (2, discover.LATEST), (3, 0)) + bad((1, 50), (2, discover.LATEST), (3, discover.LATEST)) + bad((1, 50), (2, 5), (2, discover.LATEST)) + + exc((1, 0), (1, 0), None) + exc('v1.0', (1, 0), (1, 0)) + exc((1, 0), 'v1.0', (1, 0)) + exc((1, 0), (1, 0), 'v1.0') + exc((1, 0), None, (1, 0)) + class VersionDataTests(utils.TestCase): @@ -476,7 +577,7 @@ class VersionDataTests(utils.TestCase): disc = discover.Discover(self.session, V3_URL) - data = disc.versioned_data_for(version=None) + data = disc.versioned_data_for() self.assertEqual(data['version'], (3, 0)) self.assertEqual(data['raw_status'], 'stable') self.assertEqual(data['url'], V3_URL) @@ -508,21 +609,43 @@ class VersionDataTests(utils.TestCase): self.assertIn(v['version'], ((2, 0), (3, 0))) self.assertEqual(v['raw_status'], 'stable') - for meth in (disc.data_for, disc.versioned_data_for): - version = meth('v3.0') + for version in (disc.data_for('v3.0'), + disc.data_for('3.latest'), + disc.data_for('latest'), + disc.versioned_data_for( + min_version='v3.0', max_version='v3.latest'), + disc.versioned_data_for(min_version='3'), + disc.versioned_data_for(min_version='3.latest'), + disc.versioned_data_for(min_version='latest'), + disc.versioned_data_for(min_version='3.latest', + max_version='latest'), + disc.versioned_data_for(min_version='latest', + max_version='latest'), + disc.versioned_data_for(min_version=2), + disc.versioned_data_for(min_version='2.latest')): self.assertEqual((3, 0), version['version']) self.assertEqual('stable', version['raw_status']) self.assertEqual(V3_URL, version['url']) - version = meth(2) + for version in (disc.data_for(2), + disc.data_for('2.latest'), + disc.versioned_data_for( + min_version=2, max_version=(2, discover.LATEST)), + disc.versioned_data_for( + min_version='2.latest', max_version='2.latest')): self.assertEqual((2, 0), version['version']) self.assertEqual('stable', version['raw_status']) self.assertEqual(V2_URL, version['url']) - for meth in (disc.url_for, disc.versioned_url_for): - self.assertIsNone(meth('v4')) - self.assertEqual(V3_URL, meth('v3')) - self.assertEqual(V2_URL, meth('v2')) + self.assertIsNone(disc.url_for('v4')) + self.assertIsNone(disc.versioned_url_for( + min_version='v4', max_version='v4.latest')) + self.assertEqual(V3_URL, disc.url_for('v3')) + self.assertEqual(V3_URL, disc.versioned_url_for( + min_version='v3', max_version='v3.latest')) + self.assertEqual(V2_URL, disc.url_for('v2')) + self.assertEqual(V2_URL, disc.versioned_url_for( + min_version='v2', max_version='v2.latest')) self.assertTrue(mock.called_once) @@ -585,22 +708,33 @@ class VersionDataTests(utils.TestCase): }, ]) - for meth in (disc.data_for, disc.versioned_data_for): - version = meth('v2.0') + for version in (disc.data_for('v2.0'), + disc.versioned_data_for(min_version='v2.0', + max_version='v2.latest')): self.assertEqual((2, 0), version['version']) self.assertEqual('CURRENT', version['raw_status']) self.assertEqual(v2_url, version['url']) - version = meth(1) + for version in (disc.data_for(1), + disc.versioned_data_for( + min_version=(1,), + max_version=(1, discover.LATEST))): self.assertEqual((1, 0), version['version']) self.assertEqual('CURRENT', version['raw_status']) self.assertEqual(v1_url, version['url']) - for meth in (disc.url_for, disc.versioned_url_for): - self.assertIsNone(meth('v4')) - self.assertEqual(v3_url, meth('v3')) - self.assertEqual(v2_url, meth('v2')) - self.assertEqual(v1_url, meth('v1')) + self.assertIsNone(disc.url_for('v4')) + self.assertIsNone(disc.versioned_url_for(min_version='v4', + max_version='v4.latest')) + self.assertEqual(v3_url, disc.url_for('v3')) + self.assertEqual(v3_url, disc.versioned_url_for( + min_version='v3', max_version='v3.latest')) + self.assertEqual(v2_url, disc.url_for('v2')) + self.assertEqual(v2_url, disc.versioned_url_for( + min_version='v2', max_version='v2.latest')) + self.assertEqual(v1_url, disc.url_for('v1')) + self.assertEqual(v1_url, disc.versioned_url_for( + min_version='v1', max_version='v1.latest')) self.assertTrue(mock.called_once) @@ -679,24 +813,32 @@ class VersionDataTests(utils.TestCase): }, ]) - for meth in (disc.data_for, disc.versioned_data_for): - for ver in (2, 2.1, 2.2): - version = meth(ver) + for ver in (2, 2.1, 2.2): + for version in (disc.data_for(ver), + disc.versioned_data_for( + min_version=ver, + max_version=(2, discover.LATEST))): self.assertEqual((2, 2), version['version']) self.assertEqual('CURRENT', version['raw_status']) self.assertEqual(v2_url, version['url']) self.assertEqual(v2_url, disc.url_for(ver)) - for ver in (1, 1.1): - version = meth(ver) + for ver in (1, 1.1): + for version in (disc.data_for(ver), + disc.versioned_data_for( + min_version=ver, + max_version=(1, discover.LATEST))): self.assertEqual((1, 1), version['version']) self.assertEqual('CURRENT', version['raw_status']) self.assertEqual(v1_url, version['url']) self.assertEqual(v1_url, disc.url_for(ver)) - for meth in (disc.url_for, disc.versioned_url_for): - self.assertIsNone(meth('v3')) - self.assertIsNone(meth('v2.3')) + self.assertIsNone(disc.url_for('v3')) + self.assertIsNone(disc.versioned_url_for(min_version='v3', + max_version='v3.latest')) + self.assertIsNone(disc.url_for('v2.3')) + self.assertIsNone(disc.versioned_url_for(min_version='v2.3', + max_version='v2.latest')) self.assertTrue(mock.called_once) @@ -799,7 +941,7 @@ class EndpointDataTests(utils.TestCase): mock_url_choices.return_value = ('url1', 'url2', 'url1', 'url3') epd = discover.EndpointData() epd._run_discovery( - session='sess', cache='cache', version='vers', min_version='min', + session='sess', cache='cache', min_version='min', max_version='max', project_id='projid', allow_version_hack='allow_hack', discover_versions='disc_vers') # Only one call with 'url1' diff --git a/keystoneauth1/tests/unit/test_session.py b/keystoneauth1/tests/unit/test_session.py index 1a6a9577..c08c4a2f 100644 --- a/keystoneauth1/tests/unit/test_session.py +++ b/keystoneauth1/tests/unit/test_session.py @@ -23,6 +23,7 @@ import six from testtools import matchers from keystoneauth1 import adapter +from keystoneauth1 import discover from keystoneauth1 import exceptions from keystoneauth1 import plugin from keystoneauth1 import session as client_session @@ -123,6 +124,22 @@ class SessionTests(utils.TestCase): self.assertEqual(headers['X-OpenStack-Nova-API-Version'], '2.30') self.assertEqual(len(headers.keys()), 2) + # 'latest' (string) microversion + headers = {} + client_session.Session._set_microversion_headers( + headers, 'latest', 'compute', None) + self.assertEqual(headers['OpenStack-API-Version'], 'compute latest') + self.assertEqual(headers['X-OpenStack-Nova-API-Version'], 'latest') + self.assertEqual(len(headers.keys()), 2) + + # LATEST (tuple) microversion + headers = {} + client_session.Session._set_microversion_headers( + headers, (discover.LATEST, discover.LATEST), 'compute', None) + self.assertEqual(headers['OpenStack-API-Version'], 'compute latest') + self.assertEqual(headers['X-OpenStack-Nova-API-Version'], 'latest') + self.assertEqual(len(headers.keys()), 2) + # ironic microversion, specified service type headers = {} client_session.Session._set_microversion_headers( @@ -154,10 +171,19 @@ class SessionTests(utils.TestCase): self.assertEqual(headers['OpenStack-API-Version'], 'compute 2.30') self.assertEqual(headers['X-OpenStack-Nova-API-Version'], '2.30') + # Can't specify a 'M.latest' microversion + self.assertRaises(TypeError, + client_session.Session._set_microversion_headers, + {}, '2.latest', 'service_type', None) + self.assertRaises(TypeError, + client_session.Session._set_microversion_headers, + {}, (2, discover.LATEST), 'service_type', None) + # Normalization error self.assertRaises(TypeError, client_session.Session._set_microversion_headers, {}, 'bogus', 'service_type', None) + # No service type in param or endpoint filter self.assertRaises(TypeError, client_session.Session._set_microversion_headers,