diff --git a/keystoneauth1/discover.py b/keystoneauth1/discover.py index b6233881..fc690716 100644 --- a/keystoneauth1/discover.py +++ b/keystoneauth1/discover.py @@ -286,6 +286,10 @@ class Discover(object): def data_for(self, version, **kwargs): """Return endpoint data for a version. + NOTE: This method raises a TypeError if version is None. It is + kept for backwards compatability. New code should use + versioned_data_for instead. + :param tuple 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. @@ -306,6 +310,10 @@ class Discover(object): def url_for(self, version, **kwargs): """Get the endpoint url for a version. + NOTE: This method raises a TypeError if version is None. It is + kept for backwards compatability. New code should use + versioned_url_for instead. + :param tuple 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. @@ -316,6 +324,47 @@ 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, **kwargs): + """Return endpoint data that matches the version or url. + + :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. + + :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 + """ + if version: + version = normalize_version_number(version) + if url: + url = url.rstrip('/') + '/' + + for data in self.version_data(reverse=True, **kwargs): + if url and data['url'] and data['url'].rstrip('/') + '/' == url: + return data + if version and version_match(version, data['version']): + return data + + return None + + def versioned_url_for(self, version, **kwargs): + """Get the endpoint url for a version. + + :param tuple 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. + + :returns: The url for the specified version or None if no match. + :rtype: str + """ + data = self.versioned_data_for(version, **kwargs) + return data['url'] if data else None + class EndpointData(object): """Normalized information about a discovered endpoint. @@ -383,7 +432,8 @@ class EndpointData(object): @positional(3) def get_versioned_data(self, session, version, authenticated=False, allow=None, cache=None, - allow_version_hack=True, project_id=None): + allow_version_hack=True, project_id=None, + discover_versions=False): """Run version discovery for the service described. Performs Version Discovery and returns a new EndpointData object with @@ -406,6 +456,10 @@ class EndpointData(object): (optional) :param bool authenticated: Include a token in the discovery call. (optional) Defaults to False. + :param bool discover_versions: Whether to perform version discovery + even if a version string wasn't + requested. This is useful for getting + microversion information. :raises keystoneauth1.exceptions.http.HttpError: An error from an invalid HTTP response. @@ -416,7 +470,7 @@ class EndpointData(object): # This method should always return a new EndpointData new_data = copy.copy(self) - if not version: + if not 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 # defaulting to the most recent version. @@ -425,12 +479,25 @@ class EndpointData(object): new_data._set_version_info( session=session, version=version, authenticated=authenticated, allow=allow, cache=cache, allow_version_hack=allow_version_hack, - project_id=project_id) + project_id=project_id, discover_versions=discover_versions) return new_data def _set_version_info(self, session, version, authenticated=False, allow=None, cache=None, - allow_version_hack=True, project_id=None): + allow_version_hack=True, project_id=None, + discover_versions=False): + match_url = None + if not 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 + # defaulting to the most recent version. + return + elif not version and discover_versions: + # We want to run discovery, but we don't want to find different + # endpoints than what's in the catalog + allow_version_hack = False + match_url = self.catalog_url + if project_id: self.project_id = project_id @@ -490,7 +557,8 @@ class EndpointData(object): # for example a "v2" path from http://host/admin should resolve as # http://host/admin/v2 where it would otherwise be host/v2. # This has no effect on absolute urls returned from url_for. - discovered_data = disc.data_for(version, **allow) + discovered_data = disc.versioned_data_for( + version, url=match_url, **allow) if not discovered_data: raise exceptions.DiscoveryFailure( "Version {version} requested, but was not found".format( diff --git a/keystoneauth1/identity/base.py b/keystoneauth1/identity/base.py index eac5eb6b..b54dacec 100644 --- a/keystoneauth1/identity/base.py +++ b/keystoneauth1/identity/base.py @@ -158,7 +158,9 @@ class BaseIdentityPlugin(plugin.BaseAuthPlugin): def get_endpoint_data(self, session, service_type=None, interface=None, region_name=None, service_name=None, version=None, - allow={}, allow_version_hack=True, **kwargs): + allow={}, allow_version_hack=True, + discover_versions=False, skip_discovery=False, + **kwargs): """Return a valid endpoint data for a service. If a valid token is not present then a new one will be fetched using @@ -185,6 +187,15 @@ class BaseIdentityPlugin(plugin.BaseAuthPlugin): :param bool allow_version_hack: Allow keystoneauth to hack up catalog URLS to support older schemes. (optional, default True) + :param bool discover_versions: Whether to perform version discovery + even if a version string wasn't + requested. This is useful for getting + microversion information. + :param bool skip_discovery: Whether to skip version discovery even + if a version has been given. This is useful + if endpoint_override or similar has been + given and grabbing additional information + about the endpoint is not useful. :raises keystoneauth1.exceptions.http.HttpError: An error from an invalid HTTP response. @@ -226,21 +237,32 @@ class BaseIdentityPlugin(plugin.BaseAuthPlugin): if not endpoint_data: return None + if skip_discovery: + return endpoint_data + try: return endpoint_data.get_versioned_data( session, version, project_id=project_id, authenticated=False, cache=self._discovery_cache, + discover_versions=discover_versions, allow_version_hack=allow_version_hack, allow=allow) except (exceptions.DiscoveryFailure, exceptions.HttpError, exceptions.ConnectionError): - return None + # If a version was requested, we didn't find it, return + # None. + if version: + return None + # If one wasn't, then the endpoint_data we already have + # should be fine + return endpoint_data def get_endpoint(self, session, service_type=None, interface=None, region_name=None, service_name=None, version=None, - allow={}, allow_version_hack=True, **kwargs): + allow={}, allow_version_hack=True, + discover_versions=False, skip_discovery=False, **kwargs): """Return a valid endpoint for a service. If a valid token is not present then a new one will be fetched using @@ -267,6 +289,15 @@ class BaseIdentityPlugin(plugin.BaseAuthPlugin): :param bool allow_version_hack: Allow keystoneauth to hack up catalog URLS to support older schemes. (optional, default True) + :param bool discover_versions: Whether to perform version discovery + even if a version string wasn't + requested. This is useful for getting + microversion information. + :param bool skip_discovery: Whether to skip version discovery even + if a version has been given. This is useful + if endpoint_override or similar has been + given and grabbing additional information + about the endpoint is not useful. :raises keystoneauth1.exceptions.http.HttpError: An error from an invalid HTTP response. @@ -278,6 +309,8 @@ class BaseIdentityPlugin(plugin.BaseAuthPlugin): session, service_type=service_type, interface=interface, region_name=region_name, service_name=service_name, version=version, allow=allow, + discover_versions=discover_versions, + skip_discovery=skip_discovery, allow_version_hack=allow_version_hack, **kwargs) return endpoint_data.url if endpoint_data else None diff --git a/keystoneauth1/tests/unit/identity/test_identity_common.py b/keystoneauth1/tests/unit/identity/test_identity_common.py index ffef4b7b..01c7014b 100644 --- a/keystoneauth1/tests/unit/identity/test_identity_common.py +++ b/keystoneauth1/tests/unit/identity/test_identity_common.py @@ -924,6 +924,122 @@ class CatalogHackTests(utils.TestCase): self.assertEqual(self.V2_URL, endpoint) + def test_returns_original_skipping_discovery(self): + token = fixture.V2Token() + service = token.add_service(self.IDENTITY) + service.add_endpoint(public=self.V2_URL, + admin=self.V2_URL, + internal=self.V2_URL) + + self.stub_url('POST', + ['tokens'], + base_url=self.V2_URL, + json=token) + + v2_auth = identity.V2Password(self.V2_URL, + username=uuid.uuid4().hex, + password=uuid.uuid4().hex) + + sess = session.Session(auth=v2_auth) + + endpoint = sess.get_endpoint(service_type=self.IDENTITY, + interface='public', + skip_discovery=True, + version=(3, 0)) + + self.assertEqual(self.V2_URL, endpoint) + + def test_forcing_discovery(self): + v2_disc = fixture.V2Discovery(self.V2_URL) + common_disc = fixture.DiscoveryList(href=self.BASE_URL) + + v2_m = self.stub_url('GET', + ['v2.0'], + base_url=self.BASE_URL, + status_code=200, + json={'version': v2_disc}) + + common_m = self.stub_url('GET', + [], + base_url=self.BASE_URL, + status_code=300, + json=common_disc) + + token = fixture.V2Token() + service = token.add_service(self.IDENTITY) + service.add_endpoint(public=self.V2_URL, + admin=self.V2_URL, + internal=self.V2_URL) + + self.stub_url('POST', + ['tokens'], + base_url=self.V2_URL, + json=token) + + v2_auth = identity.V2Password(self.V2_URL, + username=uuid.uuid4().hex, + password=uuid.uuid4().hex) + + sess = session.Session(auth=v2_auth) + + # v2 auth with v2 url doesn't make any discovery calls. + self.assertFalse(v2_m.called) + self.assertFalse(common_m.called) + + endpoint = sess.get_endpoint(service_type=self.IDENTITY, + discover_versions=True) + + # We should get the v2 document, but not the unversioned + self.assertTrue(v2_m.called) + self.assertFalse(common_m.called) + + # got v2 url + self.assertEqual(self.V2_URL, endpoint) + + def test_forcing_discovery_list_returns_url(self): + common_disc = fixture.DiscoveryList(href=self.BASE_URL) + + # 2.0 doesn't usually return a list. This is testing that if + # the catalog url returns an endpoint that has a discovery document + # with more than one URL and that a different url would be returned + # by "return the latest" rules, that we get the info of the url from + # the catalog if we don't provide a version but do provide + # discover_versions + v2_m = self.stub_url('GET', + ['v2.0'], + base_url=self.BASE_URL, + status_code=200, + json=common_disc) + + token = fixture.V2Token() + service = token.add_service(self.IDENTITY) + service.add_endpoint(public=self.V2_URL, + admin=self.V2_URL, + internal=self.V2_URL) + + self.stub_url('POST', + ['tokens'], + base_url=self.V2_URL, + json=token) + + v2_auth = identity.V2Password(self.V2_URL, + username=uuid.uuid4().hex, + password=uuid.uuid4().hex) + + sess = session.Session(auth=v2_auth) + + # v2 auth with v2 url doesn't make any discovery calls. + self.assertFalse(v2_m.called) + + endpoint = sess.get_endpoint(service_type=self.IDENTITY, + discover_versions=True) + + # We should make the one call + self.assertTrue(v2_m.called) + + # got v2 url + self.assertEqual(self.V2_URL, endpoint) + def test_getting_endpoints_on_auth_interface(self): disc = fixture.DiscoveryList(href=self.BASE_URL) self.stub_url('GET', diff --git a/keystoneauth1/tests/unit/test_discovery.py b/keystoneauth1/tests/unit/test_discovery.py index 249d2e88..c6c40d9c 100644 --- a/keystoneauth1/tests/unit/test_discovery.py +++ b/keystoneauth1/tests/unit/test_discovery.py @@ -359,6 +359,32 @@ class VersionDataTests(utils.TestCase): self.assertTrue(mock.called_once) + def test_data_for_url(self): + mock = self.requests_mock.get(V3_URL, + status_code=200, + json=V3_VERSION_ENTRY) + + disc = discover.Discover(self.session, V3_URL) + for url in (V3_URL, V3_URL + '/'): + data = disc.versioned_data_for(url=url) + self.assertEqual(data['version'], (3, 0)) + self.assertEqual(data['raw_status'], 'stable') + self.assertEqual(data['url'], V3_URL) + + self.assertTrue(mock.called_once) + + def test_data_for_no_version(self): + mock = self.requests_mock.get(V3_URL, + status_code=200, + json=V3_VERSION_ENTRY) + + disc = discover.Discover(self.session, V3_URL) + + self.assertIsNone(disc.versioned_data_for(version=None)) + self.assertRaises(TypeError, disc.data_for, version=None) + + self.assertTrue(mock.called_once) + def test_keystone_version_data(self): mock = self.requests_mock.get(BASE_URL, status_code=300, @@ -383,19 +409,21 @@ class VersionDataTests(utils.TestCase): self.assertIn(v['version'], ((2, 0), (3, 0))) self.assertEqual(v['raw_status'], 'stable') - version = disc.data_for('v3.0') - self.assertEqual((3, 0), version['version']) - self.assertEqual('stable', version['raw_status']) - self.assertEqual(V3_URL, version['url']) + for meth in (disc.data_for, disc.versioned_data_for): + version = meth('v3.0') + self.assertEqual((3, 0), version['version']) + self.assertEqual('stable', version['raw_status']) + self.assertEqual(V3_URL, version['url']) - version = disc.data_for(2) - self.assertEqual((2, 0), version['version']) - self.assertEqual('stable', version['raw_status']) - self.assertEqual(V2_URL, version['url']) + version = meth(2) + self.assertEqual((2, 0), version['version']) + self.assertEqual('stable', version['raw_status']) + self.assertEqual(V2_URL, version['url']) - self.assertIsNone(disc.url_for('v4')) - self.assertEqual(V3_URL, disc.url_for('v3')) - self.assertEqual(V2_URL, disc.url_for('v2')) + 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.assertTrue(mock.called_once) @@ -452,20 +480,22 @@ class VersionDataTests(utils.TestCase): }, ]) - version = disc.data_for('v2.0') - self.assertEqual((2, 0), version['version']) - self.assertEqual('CURRENT', version['raw_status']) - self.assertEqual(v2_url, version['url']) + for meth in (disc.data_for, disc.versioned_data_for): + version = meth('v2.0') + self.assertEqual((2, 0), version['version']) + self.assertEqual('CURRENT', version['raw_status']) + self.assertEqual(v2_url, version['url']) - version = disc.data_for(1) - self.assertEqual((1, 0), version['version']) - self.assertEqual('CURRENT', version['raw_status']) - self.assertEqual(v1_url, version['url']) + version = meth(1) + self.assertEqual((1, 0), version['version']) + self.assertEqual('CURRENT', version['raw_status']) + self.assertEqual(v1_url, version['url']) - self.assertIsNone(disc.url_for('v4')) - self.assertEqual(v3_url, disc.url_for('v3')) - self.assertEqual(v2_url, disc.url_for('v2')) - self.assertEqual(v1_url, disc.url_for('v1')) + 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.assertTrue(mock.called_once) @@ -534,22 +564,24 @@ class VersionDataTests(utils.TestCase): }, ]) - for ver in (2, 2.1, 2.2): - version = disc.data_for(ver) - 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 meth in (disc.data_for, disc.versioned_data_for): + for ver in (2, 2.1, 2.2): + version = meth(ver) + 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 = disc.data_for(ver) - 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 ver in (1, 1.1): + version = meth(ver) + 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)) - self.assertIsNone(disc.url_for('v3')) - self.assertIsNone(disc.url_for('v2.3')) + for meth in (disc.url_for, disc.versioned_url_for): + self.assertIsNone(meth('v3')) + self.assertIsNone(meth('v2.3')) self.assertTrue(mock.called_once)