diff --git a/keystoneclient/client.py b/keystoneclient/client.py index bfd69ab29..72620eb54 100644 --- a/keystoneclient/client.py +++ b/keystoneclient/client.py @@ -12,7 +12,30 @@ # License for the specific language governing permissions and limitations # under the License. +from keystoneclient import discover from keystoneclient import httpclient # Using client.HTTPClient is deprecated. Use httpclient.HTTPClient instead. HTTPClient = httpclient.HTTPClient + + +def Client(version=None, unstable=False, **kwargs): + """Factory function to create a new identity service client. + + :param tuple version: The required version of the identity API. If + specified the client will be selected such that the + major version is equivalent and an endpoint provides + at least the specified minor version. For example to + specify the 3.1 API use (3, 1). + :param bool unstable: Accept endpoints not marked as 'stable'. (optional) + :param kwargs: Additional arguments are passed through to the client + that is being created. + :returns: New keystone client object + (keystoneclient.v2_0.Client or keystoneclient.v3.Client). + + :raises: DiscoveryFailure if the server's response is invalid + :raises: VersionNotAvailable if a suitable client cannot be found. + """ + + return discover.Discover(**kwargs).create_client(version=version, + unstable=unstable) diff --git a/keystoneclient/discover.py b/keystoneclient/discover.py new file mode 100644 index 000000000..426b0b965 --- /dev/null +++ b/keystoneclient/discover.py @@ -0,0 +1,421 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import logging +import six + +from keystoneclient import exceptions +from keystoneclient import httpclient +from keystoneclient.v2_0 import client as v2_client +from keystoneclient.v3 import client as v3_client + +_logger = logging.getLogger(__name__) + + +class _KeystoneVersion(object): + """A factory object that holds all the information to create a client. + + Instances of this class are callable objects that hold all the kwargs that + were passed to discovery so that a user may simply call it to create a new + client object. Additional arguments passed to the call will override or + add to those provided to the object. + """ + + _CLIENT_VERSIONS = {2: v2_client.Client, + 3: v3_client.Client} + + def __init__(self, version, status, client_class=None, **kwargs): + """Create a new discovered version object. + + :param tuple version: the version of the available API. + :param string status: the stability of the API. + :param Class client_class: the client class that should be used to + instantiate against this version of the API. + (optional, will be matched against known) + :param dict **kwargs: Additional arguments that should be passed on to + the client when it is constructed. + """ + self.version = version + self.status = status + self.client_class = client_class + self.client_kwargs = kwargs + + if not self.client_class: + try: + self.client_class = self._CLIENT_VERSIONS[self.version[0]] + except KeyError: + raise exceptions.DiscoveryFailure("No client available " + "for version: %s" % + self.version) + + def __lt__(self, other): + """Version Ordering. + + Versions are ordered by major, then minor version number, then + 'stable' is deemed the highest possible status, then they are just + treated alphabetically (alpha < beta etc) + """ + if self.version == other.version: + if self.status == 'stable': + return False + elif other.status == 'stable': + return True + else: + return self.status < other.status + + return self.version < other.version + + def __eq__(self, other): + return self.version == other.version and self.status == other.status + + def __call__(self, **kwargs): + if kwargs: + client_kwargs = self.client_kwargs.copy() + client_kwargs.update(kwargs) + else: + client_kwargs = self.client_kwargs + return self.client_class(**client_kwargs) + + @property + def _str_ver(self): + ver = ".".join([str(v) for v in self.version]) + + if self.status != 'stable': + ver = "%s-%s" % (ver, self.status) + + return ver + + +def _normalize_version_number(version): + """Turn a version representation into a tuple.""" + + # trim the v from a 'v2.0' or similar + try: + version = version.lstrip('v') + except AttributeError: + pass + + # if it's an integer or a numeric as a string then normalize it + # to a string, this ensures 1 decimal point + try: + num = float(version) + except Exception: + pass + else: + version = str(num) + + # if it's a string (or an integer) from above break it on . + try: + return tuple(map(int, version.split("."))) + except Exception: + pass + + # last attempt, maybe it's a list or iterable. + try: + return tuple(map(int, version)) + except Exception: + pass + + raise TypeError("Invalid version specified: %s" % version) + + +def available_versions(url, **kwargs): + headers = {'Accept': 'application/json'} + + client = httpclient.HTTPClient(**kwargs) + resp, body_resp = client.request(url, 'GET', headers=headers) + + # In the event of querying a root URL we will get back a list of + # available versions. + try: + return body_resp['versions']['values'] + except (KeyError, TypeError): + pass + + # Otherwise if we query an endpoint like /v2.0 then we will get back + # just the one available version. + try: + return [body_resp['version']] + except (KeyError, TypeError): + pass + + raise exceptions.DiscoveryFailure("Invalid Response - Bad version" + " data returned: %s" % body_resp) + + +class Discover(object): + """A means to discover and create clients depending on the supported API + versions on the server. + + Querying the server is done on object creation and every subsequent method + operates upon the data that was retrieved. + """ + + def __init__(self, **kwargs): + """Construct a new discovery object. + + The connection parameters associated with this method are the same + format and name as those used by a client (see + keystoneclient.v2_0.client.Client and keystoneclient.v3.client.Client). + If not overridden in subsequent methods they will also be what is + passed to the constructed client. + + In the event that auth_url and endpoint is provided then auth_url will + be used in accordance with how the client operates. + + The initialization process also queries the server. + + :param string auth_url: Identity service endpoint for authorization. + (optional) + :param string endpoint: A user-supplied endpoint URL for the identity + service. (optional) + :param string original_ip: The original IP of the requesting user + which will be sent to identity service in a + 'Forwarded' header. (optional) + :param boolean debug: Enables debug logging of all request and + responses to the identity service. + default False (optional) + :param string cacert: Path to the Privacy Enhanced Mail (PEM) file + which contains the trusted authority X.509 + certificates needed to established SSL connection + with the identity service. (optional) + :param string key: Path to the Privacy Enhanced Mail (PEM) file which + contains the unencrypted client private key needed + to established two-way SSL connection with the + identity service. (optional) + :param string cert: Path to the Privacy Enhanced Mail (PEM) file which + contains the corresponding X.509 client certificate + needed to established two-way SSL connection with + the identity service. (optional) + :param boolean insecure: Does not perform X.509 certificate validation + when establishing SSL connection with identity + service. default: False (optional) + """ + + url = kwargs.get('endpoint') or kwargs.get('auth_url') + if not url: + raise exceptions.DiscoveryFailure('Not enough information to ' + 'determine URL. Provide either ' + 'auth_url or endpoint') + + self._client_kwargs = kwargs + self._available_versions = available_versions(url, **kwargs) + + def _get_client_constructor_kwargs(self, kwargs_dict={}, **kwargs): + client_kwargs = self._client_kwargs.copy() + + client_kwargs.update(kwargs_dict) + client_kwargs.update(**kwargs) + + return client_kwargs + + def available_versions(self, unstable=False): + """Return a list of identity APIs available on the server and the data + associated with them. + + :param bool unstable: Accept endpoints not marked 'stable'. (optional) + + :returns: A List of dictionaries as presented by the server. Each dict + will contain the version and the URL to use for the version. + It is a direct representation of the layout presented by the + identity API. + + Example:: + + >>> from keystoneclient import discover + >>> disc = discover.Discovery(auth_url='http://localhost:5000') + >>> disc.available_versions() + [{'id': 'v3.0', + 'links': [{'href': u'http://127.0.0.1:5000/v3/', + 'rel': u'self'}], + 'media-types': [ + {'base': 'application/json', + 'type': 'application/vnd.openstack.identity-v3+json'}, + {'base': 'application/xml', + 'type': 'application/vnd.openstack.identity-v3+xml'}], + 'status': 'stable', + 'updated': '2013-03-06T00:00:00Z'}, + {'id': 'v2.0', + 'links': [{'href': u'http://127.0.0.1:5000/v2.0/', + 'rel': u'self'}, + {'href': u'...', + 'rel': u'describedby', + 'type': u'application/pdf'}], + 'media-types': [ + {'base': 'application/json', + 'type': 'application/vnd.openstack.identity-v2.0+json'}, + {'base': 'application/xml', + 'type': 'application/vnd.openstack.identity-v2.0+xml'}], + 'status': 'stable', + 'updated': '2013-03-06T00:00:00Z'}] + """ + if unstable: + # no need to determine the stable endpoints, just return everything + return self._available_versions + + versions = [] + + for v in self._available_versions: + try: + status = v['status'] + except KeyError: + _logger.warning("Skipping over invalid version data. " + "No stability status in version.") + else: + if status == 'stable': + versions.append(v) + + return versions + + def _get_factory_from_response_entry(self, version_data, **kwargs): + """Create a _KeystoneVersion factory object from a version response + entry returned from a server. + """ + try: + version_str = version_data['id'] + status = version_data['status'] + + if not version_str.startswith('v'): + raise exceptions.DiscoveryFailure('Skipping over invalid ' + 'version string: %s. It ' + 'should start with a v.' % + version_str) + + for link in version_data['links']: + # NOTE(jamielennox): there are plenty of links like with + # documentation and such, we only care about the self + # which is a link to the URL we should use. + if link['rel'].lower() == 'self': + version_number = _normalize_version_number(version_str) + version_url = link['href'] + break + else: + raise exceptions.DiscoveryFailure("Didn't find any links " + "in version data.") + + except (KeyError, TypeError, ValueError): + raise exceptions.DiscoveryFailure('Skipping over invalid ' + 'version data.') + + # NOTE(jamielennox): the url might be the auth_url or the endpoint + # depending on what was passed initially. Order is important, endpoint + # needs to override auth_url. + for url_type in ('auth_url', 'endpoint'): + if self._client_kwargs.get(url_type, False): + kwargs[url_type] = version_url + else: + kwargs[url_type] = None + + return _KeystoneVersion(status=status, + version=version_number, + **kwargs) + + def _available_clients(self, unstable=False, **kwargs): + """Return a dictionary of factory functions for available API versions. + + :returns: A dictionary of available API endpoints with the version + number as a tuple as the key, and a factory object that can + be used to create an appropriate client as the value. + + To use the returned factory you simply call it with the parameters to + pass to keystoneclient. These parameters will override those saved in + the factory. + + Example:: + + >>> from keystoneclient import client + >>> available_clients = client._available_clients(auth_url=url, + ... **kwargs) + >>> try: + ... v2_factory = available_clients[(2, 0)] + ... except KeyError: + ... print "Version 2.0 unavailable" + ... else: + ... v2_client = v2_factory(token='abcdef') + ... v2_client.tenants.list() + + :raises: DiscoveryFailure if the response is invalid + :raises: VersionNotAvailable if a suitable client cannot be found. + """ + + versions = dict() + response_values = self.available_versions(unstable=unstable) + client_kwargs = self._get_client_constructor_kwargs(kwargs_dict=kwargs) + + for version_data in response_values: + try: + v = self._get_factory_from_response_entry(version_data, + **client_kwargs) + except exceptions.DiscoveryFailure, e: + _logger.warning("Invalid entry: %s", e, exc_info=True) + else: + versions[v.version] = v + + return versions + + def create_client(self, version=None, **kwargs): + """Factory function to create a new identity service client. + + :param tuple version: The required version of the identity API. If + specified the client will be selected such that + the major version is equivalent and an endpoint + provides at least the specified minor version. + For example to specify the 3.1 API use (3, 1). + (optional) + :param bool unstable: Accept endpoints not marked 'stable'. (optional) + :param kwargs: Additional arguments will override those provided to + this object's constructor. + + :returns: An instantiated identity client object. + + :raises: DiscoveryFailure if the server response is invalid + :raises: VersionNotAvailable if a suitable client cannot be found. + """ + versions = self._available_clients(**kwargs) + chosen = None + + if version: + version = _normalize_version_number(version) + + for keystone_version in six.itervalues(versions): + # major versions must be the same (eg even though v2 is a lower + # version than v3 we can't use it if v2 was requested) + if version[0] != keystone_version.version[0]: + continue + + # prevent selecting a minor version less than what is required + if version <= keystone_version.version: + chosen = keystone_version + break + + elif versions: + # if no version specified pick the latest one + chosen = max(six.iteritems(versions))[1] + + if not chosen: + msg = "Could not find a suitable endpoint" + + if version: + msg = "%s for client version: %s" % (msg, version) + + if versions: + available = ", ".join([v._str_ver + for v in six.itervalues(versions)]) + msg = "%s. Available_versions are: %s" % (msg, available) + else: + msg = "%s. No versions reported available" % msg + + raise exceptions.VersionNotAvailable(msg) + + return chosen() diff --git a/keystoneclient/exceptions.py b/keystoneclient/exceptions.py index df20cebf3..5254c22cf 100644 --- a/keystoneclient/exceptions.py +++ b/keystoneclient/exceptions.py @@ -41,3 +41,11 @@ class SSLError(ConnectionError): class Timeout(ClientException): """The request timed out.""" + + +class DiscoveryFailure(ClientException): + """Discovery of client versions failed.""" + + +class VersionNotAvailable(DiscoveryFailure): + """Discovery failed as the version you requested is not available.""" diff --git a/keystoneclient/tests/test_discovery.py b/keystoneclient/tests/test_discovery.py new file mode 100644 index 000000000..2a3e7e8ed --- /dev/null +++ b/keystoneclient/tests/test_discovery.py @@ -0,0 +1,459 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import httpretty +from testtools import matchers + +from keystoneclient import client +from keystoneclient import discover +from keystoneclient import exceptions +from keystoneclient.openstack.common import jsonutils +from keystoneclient.tests import utils +from keystoneclient.v2_0 import client as v2_client +from keystoneclient.v3 import client as v3_client + +BASE_HOST = 'http://keystone.example.com' +BASE_URL = "%s:5000/" % BASE_HOST +UPDATED = '2013-03-06T00:00:00Z' + +TEST_SERVICE_CATALOG = [{ + "endpoints": [{ + "adminURL": "%s:8774/v1.0" % BASE_HOST, + "region": "RegionOne", + "internalURL": "%s://127.0.0.1:8774/v1.0" % BASE_HOST, + "publicURL": "%s:8774/v1.0/" % BASE_HOST + }], + "type": "nova_compat", + "name": "nova_compat" +}, { + "endpoints": [{ + "adminURL": "http://nova/novapi/admin", + "region": "RegionOne", + "internalURL": "http://nova/novapi/internal", + "publicURL": "http://nova/novapi/public" + }], + "type": "compute", + "name": "nova" +}, { + "endpoints": [{ + "adminURL": "http://glance/glanceapi/admin", + "region": "RegionOne", + "internalURL": "http://glance/glanceapi/internal", + "publicURL": "http://glance/glanceapi/public" + }], + "type": "image", + "name": "glance" +}, { + "endpoints": [{ + "adminURL": "%s:35357/v2.0" % BASE_HOST, + "region": "RegionOne", + "internalURL": "%s:5000/v2.0" % BASE_HOST, + "publicURL": "%s:5000/v2.0" % BASE_HOST + }], + "type": "identity", + "name": "keystone" +}, { + "endpoints": [{ + "adminURL": "http://swift/swiftapi/admin", + "region": "RegionOne", + "internalURL": "http://swift/swiftapi/internal", + "publicURL": "http://swift/swiftapi/public" + }], + "type": "object-store", + "name": "swift" +}] + +V2_URL = "%sv2.0" % BASE_URL +V2_DESCRIBED_BY_HTML = {'href': 'http://docs.openstack.org/api/' + 'openstack-identity-service/2.0/content/', + 'rel': 'describedby', + 'type': 'text/html'} +V2_DESCRIBED_BY_PDF = {'href': 'http://docs.openstack.org/api/openstack-ident' + 'ity-service/2.0/identity-dev-guide-2.0.pdf', + 'rel': 'describedby', + 'type': 'application/pdf'} + +V2_VERSION = {'id': 'v2.0', + 'links': [{'href': V2_URL, 'rel': 'self'}, + V2_DESCRIBED_BY_HTML, V2_DESCRIBED_BY_PDF], + 'status': 'stable', + 'updated': UPDATED} + +V2_AUTH_RESPONSE = jsonutils.dumps({ + "access": { + "token": { + "expires": "2020-01-01T00:00:10.000123Z", + "id": 'fakeToken', + "tenant": { + "id": '1' + }, + }, + "user": { + "id": 'test' + }, + "serviceCatalog": TEST_SERVICE_CATALOG, + }, +}) + +V3_URL = "%sv3" % BASE_URL +V3_MEDIA_TYPES = [{'base': 'application/json', + 'type': 'application/vnd.openstack.identity-v3+json'}, + {'base': 'application/xml', + 'type': 'application/vnd.openstack.identity-v3+xml'}] + +V3_VERSION = {'id': 'v3.0', + 'links': [{'href': V3_URL, 'rel': 'self'}], + 'media-types': V3_MEDIA_TYPES, + 'status': 'stable', + 'updated': UPDATED} + +V3_TOKEN = u'3e2813b7ba0b4006840c3825860b86ed', +V3_AUTH_RESPONSE = jsonutils.dumps({ + "token": { + "methods": [ + "token", + "password" + ], + + "expires_at": "2020-01-01T00:00:10.000123Z", + "project": { + "domain": { + "id": '1', + "name": 'test-domain' + }, + "id": '1', + "name": 'test-project' + }, + "user": { + "domain": { + "id": '1', + "name": 'test-domain' + }, + "id": '1', + "name": 'test-user' + }, + "issued_at": "2013-05-29T16:55:21.468960Z", + }, +}) + + +def _create_version_list(versions): + return jsonutils.dumps({'versions': {'values': versions}}) + + +def _create_single_version(version): + return jsonutils.dumps({'version': version}) + + +V3_VERSION_LIST = _create_version_list([V3_VERSION, V2_VERSION]) +V2_VERSION_LIST = _create_version_list([V2_VERSION]) + +V3_VERSION_ENTRY = _create_single_version(V3_VERSION) +V2_VERSION_ENTRY = _create_single_version(V2_VERSION) + + +@httpretty.activate +class AvailableVersionsTests(utils.TestCase): + + def test_available_versions(self): + httpretty.register_uri(httpretty.GET, BASE_URL, status=300, + body=V3_VERSION_LIST) + + versions = discover.available_versions(BASE_URL) + + for v in versions: + self.assertIn('id', v) + self.assertIn('status', v) + self.assertIn('links', v) + + def test_available_versions_individual(self): + httpretty.register_uri(httpretty.GET, V3_URL, status=200, + body=V3_VERSION_ENTRY) + + versions = discover.available_versions(V3_URL) + + for v in versions: + self.assertEqual(v['id'], 'v3.0') + self.assertEqual(v['status'], 'stable') + self.assertIn('media-types', v) + self.assertIn('links', v) + + +@httpretty.activate +class ClientDiscoveryTests(utils.TestCase): + + def assertCreatesV3(self, **kwargs): + httpretty.register_uri(httpretty.POST, "%s/auth/tokens" % V3_URL, + body=V3_AUTH_RESPONSE, X_Subject_Token=V3_TOKEN) + + kwargs.setdefault('username', 'foo') + kwargs.setdefault('password', 'bar') + keystone = client.Client(**kwargs) + self.assertIsInstance(keystone, v3_client.Client) + return keystone + + def assertCreatesV2(self, **kwargs): + httpretty.register_uri(httpretty.POST, "%s/tokens" % V2_URL, + body=V2_AUTH_RESPONSE) + + kwargs.setdefault('username', 'foo') + kwargs.setdefault('password', 'bar') + keystone = client.Client(**kwargs) + self.assertIsInstance(keystone, v2_client.Client) + return keystone + + def assertVersionNotAvailable(self, **kwargs): + kwargs.setdefault('username', 'foo') + kwargs.setdefault('password', 'bar') + + self.assertRaises(exceptions.VersionNotAvailable, + client.Client, **kwargs) + + def assertDiscoveryFailure(self, **kwargs): + kwargs.setdefault('username', 'foo') + kwargs.setdefault('password', 'bar') + + self.assertRaises(exceptions.DiscoveryFailure, + client.Client, **kwargs) + + def test_discover_v3(self): + httpretty.register_uri(httpretty.GET, BASE_URL, status=300, + body=V3_VERSION_LIST) + + self.assertCreatesV3(auth_url=BASE_URL) + + def test_discover_v2(self): + httpretty.register_uri(httpretty.GET, BASE_URL, status=300, + body=V2_VERSION_LIST) + httpretty.register_uri(httpretty.POST, "%s/tokens" % V2_URL, + body=V2_AUTH_RESPONSE) + + self.assertCreatesV2(auth_url=BASE_URL) + + def test_discover_endpoint_v2(self): + httpretty.register_uri(httpretty.GET, BASE_URL, status=300, + body=V2_VERSION_LIST) + self.assertCreatesV2(endpoint=BASE_URL, token='fake-token') + + def test_discover_endpoint_v3(self): + httpretty.register_uri(httpretty.GET, BASE_URL, status=300, + body=V3_VERSION_LIST) + self.assertCreatesV3(endpoint=BASE_URL, token='fake-token') + + def test_discover_invalid_major_version(self): + httpretty.register_uri(httpretty.GET, BASE_URL, status=300, + body=V3_VERSION_LIST) + + self.assertVersionNotAvailable(auth_url=BASE_URL, version=5) + + def test_discover_200_response_fails(self): + httpretty.register_uri(httpretty.GET, BASE_URL, status=200, body='ok') + self.assertDiscoveryFailure(auth_url=BASE_URL) + + def test_discover_minor_greater_than_available_fails(self): + httpretty.register_uri(httpretty.GET, BASE_URL, status=300, + body=V3_VERSION_LIST) + + self.assertVersionNotAvailable(endpoint=BASE_URL, version=3.4) + + def test_discover_individual_version_v2(self): + httpretty.register_uri(httpretty.GET, V2_URL, status=200, + body=V2_VERSION_ENTRY) + + self.assertCreatesV2(auth_url=V2_URL) + + def test_discover_individual_version_v3(self): + httpretty.register_uri(httpretty.GET, V3_URL, status=200, + body=V3_VERSION_ENTRY) + + self.assertCreatesV3(auth_url=V3_URL) + + def test_discover_individual_endpoint_v2(self): + httpretty.register_uri(httpretty.GET, V2_URL, status=200, + body=V2_VERSION_ENTRY) + self.assertCreatesV2(endpoint=V2_URL, token='fake-token') + + def test_discover_individual_endpoint_v3(self): + httpretty.register_uri(httpretty.GET, V3_URL, status=200, + body=V3_VERSION_ENTRY) + self.assertCreatesV3(endpoint=V3_URL, token='fake-token') + + def test_discover_fail_to_create_bad_individual_version(self): + httpretty.register_uri(httpretty.GET, V2_URL, status=200, + body=V2_VERSION_ENTRY) + httpretty.register_uri(httpretty.GET, V3_URL, status=200, + body=V3_VERSION_ENTRY) + + self.assertVersionNotAvailable(auth_url=V2_URL, version=3) + self.assertVersionNotAvailable(auth_url=V3_URL, version=2) + + def test_discover_unstable_versions(self): + v3_unstable_version = V3_VERSION.copy() + v3_unstable_version['status'] = 'beta' + version_list = _create_version_list([v3_unstable_version, V2_VERSION]) + + httpretty.register_uri(httpretty.GET, BASE_URL, status=300, + body=version_list) + + self.assertCreatesV2(auth_url=BASE_URL) + self.assertVersionNotAvailable(auth_url=BASE_URL, version=3) + self.assertCreatesV3(auth_url=BASE_URL, unstable=True) + + def test_discover_forwards_original_ip(self): + httpretty.register_uri(httpretty.GET, BASE_URL, status=300, + body=V3_VERSION_LIST) + + ip = '192.168.1.1' + self.assertCreatesV3(auth_url=BASE_URL, original_ip=ip) + + self.assertThat(httpretty.httpretty.last_request.headers['forwarded'], + matchers.Contains(ip)) + + def test_discover_bad_args(self): + self.assertRaises(exceptions.DiscoveryFailure, + client.Client) + + def test_discover_bad_response(self): + httpretty.register_uri(httpretty.GET, BASE_URL, status=300, + body=jsonutils.dumps({'FOO': 'BAR'})) + self.assertDiscoveryFailure(auth_url=BASE_URL) + + def test_discovery_ignore_invalid(self): + resp = [{'id': '3.99', # without a leading v + 'links': [{'href': V3_URL, 'rel': 'self'}], + 'media-types': V3_MEDIA_TYPES, + 'status': 'stable', + 'updated': UPDATED}, + {'id': 'v3.0', + 'links': [1, 2, 3, 4], # invalid links + 'media-types': V3_MEDIA_TYPES, + 'status': 'stable', + 'updated': UPDATED}] + httpretty.register_uri(httpretty.GET, BASE_URL, status=300, + body=_create_version_list(resp)) + self.assertDiscoveryFailure(auth_url=BASE_URL) + + def test_ignore_entry_without_links(self): + v3 = V3_VERSION.copy() + v3['links'] = [] + httpretty.register_uri(httpretty.GET, BASE_URL, status=300, + body=_create_version_list([v3, V2_VERSION])) + self.assertCreatesV2(auth_url=BASE_URL) + + def test_ignore_entry_without_status(self): + v3 = V3_VERSION.copy() + del v3['status'] + httpretty.register_uri(httpretty.GET, BASE_URL, status=300, + body=_create_version_list([v3, V2_VERSION])) + self.assertCreatesV2(auth_url=BASE_URL) + + def test_greater_version_than_required(self): + resp = [{'id': 'v3.6', + 'links': [{'href': V3_URL, 'rel': 'self'}], + 'media-types': V3_MEDIA_TYPES, + 'status': 'stable', + 'updated': UPDATED}] + httpretty.register_uri(httpretty.GET, BASE_URL, status=200, + body=_create_version_list(resp)) + self.assertCreatesV3(auth_url=BASE_URL, version=(3, 4)) + + def test_lesser_version_than_required(self): + resp = [{'id': 'v3.4', + 'links': [{'href': V3_URL, 'rel': 'self'}], + 'media-types': V3_MEDIA_TYPES, + 'status': 'stable', + 'updated': UPDATED}] + httpretty.register_uri(httpretty.GET, BASE_URL, status=200, + body=_create_version_list(resp)) + self.assertVersionNotAvailable(auth_url=BASE_URL, version=(3, 6)) + + def test_bad_response(self): + httpretty.register_uri(httpretty.GET, BASE_URL, status=300, + body="Ugly Duckling") + self.assertDiscoveryFailure(auth_url=BASE_URL) + + def test_pass_client_arguments(self): + httpretty.register_uri(httpretty.GET, BASE_URL, status=300, + body=V2_VERSION_LIST) + kwargs = {'original_ip': '100', 'use_keyring': False, + 'stale_duration': 15} + + cl = self.assertCreatesV2(auth_url=BASE_URL, **kwargs) + + self.assertEqual(cl.original_ip, '100') + self.assertEqual(cl.stale_duration, 15) + self.assertFalse(cl.use_keyring) + + def test_overriding_stored_kwargs(self): + httpretty.register_uri(httpretty.GET, BASE_URL, status=300, + body=V3_VERSION_LIST) + + httpretty.register_uri(httpretty.POST, "%s/auth/tokens" % V3_URL, + body=V3_AUTH_RESPONSE, X_Subject_Token=V3_TOKEN) + + disc = discover.Discover(auth_url=BASE_URL, debug=False, + username='foo') + client = disc.create_client(debug=True, password='bar') + + self.assertIsInstance(client, v3_client.Client) + self.assertTrue(client.debug_log) + self.assertFalse(disc._client_kwargs['debug']) + self.assertEqual(client.username, 'foo') + self.assertEqual(client.password, 'bar') + + +class DiscoverUtils(utils.TestCase): + + def test_version_number(self): + def assertVersion(inp, out): + self.assertEqual(discover._normalize_version_number(inp), out) + + def versionRaises(inp): + self.assertRaises(TypeError, + discover._normalize_version_number, + inp) + + assertVersion('v1.2', (1, 2)) + assertVersion('v11', (11, 0)) + assertVersion('1.2', (1, 2)) + assertVersion('1.5.1', (1, 5, 1)) + assertVersion('1', (1, 0)) + assertVersion(1, (1, 0)) + assertVersion(5.2, (5, 2)) + assertVersion((6, 1), (6, 1)) + assertVersion([1, 4], (1, 4)) + + versionRaises('hello') + versionRaises('1.a') + versionRaises('vaccuum') + + def test_keystone_version_objects(self): + v31s = discover._KeystoneVersion((3, 1), 'stable') + v20s = discover._KeystoneVersion((2, 0), 'stable') + v30s = discover._KeystoneVersion((3, 0), 'stable') + + v31a = discover._KeystoneVersion((3, 1), 'alpha') + v31b = discover._KeystoneVersion((3, 1), 'beta') + + self.assertTrue(v31s > v30s) + self.assertTrue(v30s > v20s) + + self.assertTrue(v31s > v31a) + self.assertFalse(v31s < v31a) + self.assertTrue(v31b > v31a) + self.assertTrue(v31a < v31b) + self.assertTrue(v31b > v30s) + + self.assertNotEqual(v31s, v31b) + self.assertEqual(v31s, discover._KeystoneVersion((3, 1), 'stable')) diff --git a/keystoneclient/v2_0/client.py b/keystoneclient/v2_0/client.py index 217c098bd..78d282413 100644 --- a/keystoneclient/v2_0/client.py +++ b/keystoneclient/v2_0/client.py @@ -121,10 +121,11 @@ class Client(httpclient.HTTPClient): """ + version = 'v2.0' + def __init__(self, **kwargs): """Initialize a new client for the Keystone v2.0 API.""" super(Client, self).__init__(**kwargs) - self.version = 'v2.0' self.endpoints = endpoints.EndpointManager(self) self.roles = roles.RoleManager(self) self.services = services.ServiceManager(self) diff --git a/keystoneclient/v3/client.py b/keystoneclient/v3/client.py index 5de064a43..00f3c9cb9 100644 --- a/keystoneclient/v3/client.py +++ b/keystoneclient/v3/client.py @@ -84,11 +84,12 @@ class Client(httpclient.HTTPClient): """ + version = 'v3' + def __init__(self, **kwargs): """Initialize a new client for the Keystone v3 API.""" super(Client, self).__init__(**kwargs) - self.version = 'v3' self.credentials = credentials.CredentialManager(self) self.endpoints = endpoints.EndpointManager(self) self.domains = domains.DomainManager(self)