From 8db366c448d4074c044f596a8e4271befdb797cb Mon Sep 17 00:00:00 2001 From: Ziad Sawalha Date: Jan 20 2012 18:14:02 +0000 Subject: Support for version and extension discovery - Supports unauthenticated call to Keystone to discover supported API versions - Added command-line support (usage: keystone discover) - Added client support (keystoneclient.genenric client). Client returns dicts, whereas shell command prints formated output. - Added tests for genenric client - Replicates 'nove discover' in python-novaclient - Starts to address blueprint keystone-client - keystone discover output looks like this: $ keystone discover Keystone found at http://localhost:35357 - supports version v1.0 (DEPRECATED) here http://localhost:35357/v1.0 - supports version v1.1 (CURRENT) here http://localhost:35357/v1.1 - supports version v2.0 (BETA) here http://localhost:35357/v2.0 - and HP-IDM: HP Token Validation Extension - and OS-KSADM: Openstack Keystone Admin - and OS-KSCATALOG: Openstack Keystone Catalog Change-Id: Id16d34dac094c780d36afb3e31c98c318b6071ac --- diff --git a/docs/api.rst b/docs/api.rst index 5e38c8b..c223d28 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -4,6 +4,12 @@ The :mod:`keystoneclient` Python API .. module:: keystoneclient :synopsis: A client for the OpenStack Keystone API. +.. currentmodule:: keystoneclient.generic.client + +.. autoclass:: Client + + .. automethod:: discover + .. currentmodule:: keystoneclient.v2_0.client .. autoclass:: Client diff --git a/keystoneclient/generic/__init__.py b/keystoneclient/generic/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/keystoneclient/generic/__init__.py diff --git a/keystoneclient/generic/client.py b/keystoneclient/generic/client.py new file mode 100644 index 0000000..724c05b --- /dev/null +++ b/keystoneclient/generic/client.py @@ -0,0 +1,205 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2010 OpenStack LLC. +# All Rights Reserved. +# +# 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 urlparse + +from keystoneclient import client +from keystoneclient import exceptions + +_logger = logging.getLogger(__name__) + + +class Client(client.HTTPClient): + """Client for the OpenStack Keystone pre-version calls API. + + :param string endpoint: A user-supplied endpoint URL for the keystone + service. + :param integer timeout: Allows customization of the timeout for client + http requests. (optional) + + Example:: + + >>> from keystoneclient.generic import client + >>> root = client.Client(auth_url=KEYSTONE_URL) + >>> versions = root.discover() + ... + >>> from keystoneclient.v2_0 import client as v2client + >>> keystone = v2client.Client(auth_url=versions['v2.0']['url']) + ... + >>> user = keystone.users.get(USER_ID) + >>> user.delete() + + """ + + def __init__(self, endpoint=None, **kwargs): + """ Initialize a new client for the Keystone v2.0 API. """ + super(Client, self).__init__(endpoint=endpoint, **kwargs) + self.endpoint = endpoint + + def discover(self, url=None): + """ Discover Keystone servers and return API versions supported. + + :param url: optional url to test (without version) + + Returns:: + + { + 'message': 'Keystone found at http://127.0.0.1:5000/', + 'v2.0': { + 'status': 'beta', + 'url': 'http://127.0.0.1:5000/v2.0/', + 'id': 'v2.0' + }, + } + + """ + if url: + return self._check_keystone_versions(url) + else: + return self._local_keystone_exists() + + def _local_keystone_exists(self): + """ Checks if Keystone is available on default local port 35357 """ + return self._check_keystone_versions("http://localhost:35357") + + def _check_keystone_versions(self, url): + """ Calls Keystone URL and detects the available API versions """ + try: + httpclient = client.HTTPClient() + resp, body = httpclient.request(url, "GET", + headers={'Accept': 'application/json'}) + if resp.status in (200, 204): # in some cases we get No Content + try: + results = {} + if 'version' in body: + results['message'] = "Keystone found at %s" % url + version = body['version'] + # Stable/diablo incorrect format + id, status, version_url = self._get_version_info( + version, url) + results[str(id)] = {"id": id, + "status": status, + "url": version_url} + return results + elif 'versions' in body: + # Correct format + results['message'] = "Keystone found at %s" % url + for version in body['versions']['values']: + id, status, version_url = self._get_version_info( + version, url) + results[str(id)] = {"id": id, + "status": status, + "url": version_url} + return results + else: + results['message'] = "Unrecognized response from %s" \ + % url + return results + except KeyError: + raise exceptions.AuthorizationFailure() + elif resp.status == 305: + return self._check_keystone_versions(resp['location']) + else: + raise exceptions.from_response(resp, body) + except Exception as e: + _logger.exception(e) + + def discover_extensions(self, url=None): + """ Discover Keystone extensions supported. + + :param url: optional url to test (should have a version in it) + + Returns:: + + { + 'message': 'Keystone extensions at http://127.0.0.1:35357/v2', + 'OS-KSEC2': 'OpenStack EC2 Credentials Extension', + } + + """ + if url: + return self._check_keystone_extensions(url) + + def _check_keystone_extensions(self, url): + """ Calls Keystone URL and detects the available extensions """ + try: + httpclient = client.HTTPClient() + if not url.endswith("/"): + url += '/' + resp, body = httpclient.request("%sextensions" % url, "GET", + headers={'Accept': 'application/json'}) + if resp.status in (200, 204): # in some cases we get No Content + try: + results = {} + if 'extensions' in body: + if 'values' in body['extensions']: + # Parse correct format (per contract) + for extension in body['extensions']['values']: + alias, name = self._get_extension_info( + extension['extension']) + results[alias] = name + return results + else: + # Support incorrect, but prevalent format + for extension in body['extensions']: + alias, name = self._get_extension_info( + extension) + results[alias] = name + return results + else: + results['message'] = "Unrecognized extensions" \ + " response from %s" % url + return results + except KeyError: + raise exceptions.AuthorizationFailure() + elif resp.status == 305: + return self._check_keystone_extensions(resp['location']) + else: + raise exceptions.from_response(resp, body) + except Exception as e: + _logger.exception(e) + + @staticmethod + def _get_version_info(version, root_url): + """ Parses version information + + :param version: a dict of a Keystone version response + :param root_url: string url used to construct + the version if no URL is provided. + :returns: tuple - (verionId, versionStatus, versionUrl) + """ + id = version['id'] + status = version['status'] + ref = urlparse.urljoin(root_url, id) + if 'links' in version: + for link in version['links']: + if link['rel'] == 'self': + ref = link['href'] + break + return (id, status, ref) + + @staticmethod + def _get_extension_info(extension): + """ Parses extension information + + :param extension: a dict of a Keystone extension response + :returns: tuple - (alias, name) + """ + alias = extension['alias'] + name = extension['name'] + return (alias, name) diff --git a/keystoneclient/generic/shell.py b/keystoneclient/generic/shell.py new file mode 100644 index 0000000..52d7d1f --- /dev/null +++ b/keystoneclient/generic/shell.py @@ -0,0 +1,59 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2010 OpenStack LLC. +# All Rights Reserved. +# +# 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. + +from keystoneclient import utils +from keystoneclient.generic import client + +CLIENT_CLASS = client.Client + + +@utils.unauthenticated +def do_discover(cs, args): + """ + Discover Keystone servers and show authentication protocols and + extensions supported. + + Usage:: + $ keystone discover + Keystone found at http://localhost:35357 + - supports version v1.0 (DEPRECATED) here http://localhost:35357/v1.0 + - supports version v1.1 (CURRENT) here http://localhost:35357/v1.1 + - supports version v2.0 (BETA) here http://localhost:35357/v2.0 + - and RAX-KSKEY: Rackspace API Key Authentication Admin Extension + - and RAX-KSGRP: Rackspace Keystone Group Extensions + """ + if cs.endpoint: + versions = cs.discover(cs.endpoint) + elif cs.auth_url: + versions = cs.discover(cs.auth_url) + else: + versions = cs.discover() + if versions: + if 'message' in versions: + print versions['message'] + for key, version in versions.iteritems(): + if key != 'message': + print " - supports version %s (%s) here %s" % \ + (version['id'], version['status'], version['url']) + extensions = cs.discover_extensions(version['url']) + if extensions: + for key, extension in extensions.iteritems(): + if key != 'message': + print " - and %s: %s" % \ + (key, extension) + else: + print "No Keystone-compatible endpoint found" diff --git a/keystoneclient/shell.py b/keystoneclient/shell.py index 01f7fc5..ba30054 100644 --- a/keystoneclient/shell.py +++ b/keystoneclient/shell.py @@ -26,6 +26,7 @@ import sys from keystoneclient import exceptions as exc from keystoneclient import utils from keystoneclient.v2_0 import shell as shell_v2_0 +from keystoneclient.generic import shell as shell_generic def env(e): @@ -99,6 +100,7 @@ class OpenStackIdentityShell(object): actions_module = shell_v2_0 self._find_actions(subparsers, actions_module) + self._find_actions(subparsers, shell_generic) self._find_actions(subparsers, self) return parser @@ -151,28 +153,33 @@ class OpenStackIdentityShell(object): #FIXME(usrleon): Here should be restrict for project id same as # for username or apikey but for compatibility it is not. - if not args.os_username: - raise exc.CommandError("You must provide a username:" - "via --username or env[OS_USERNAME]") - if not args.os_password: - raise exc.CommandError("You must provide a password, either" - "via --password or env[OS_PASSWORD]") - - if not args.os_auth_url: - raise exc.CommandError("You must provide a auth url, either" - "via --os-auth_url or via" - "env[OS_AUTH_URL]") - - self.cs = self.get_api_class(options.os_version)( - username=args.os_username, - tenant_name=args.os_tenant_name, - tenant_id=args.os_tenant_id, - password=args.os_password, - auth_url=args.os_auth_url, - region_name=args.os_region_name) + if not utils.isunauthenticated(args.func): + if not args.os_username: + raise exc.CommandError("You must provide a username:" + "via --username or env[OS_USERNAME]") + if not args.os_password: + raise exc.CommandError("You must provide a password, either" + "via --password or env[OS_PASSWORD]") + + if not args.os_auth_url: + raise exc.CommandError("You must provide a auth url, either" + "via --os-auth_url or via" + "env[OS_AUTH_URL]") + + if utils.isunauthenticated(args.func): + self.cs = shell_generic.CLIENT_CLASS(endpoint=args.os_auth_url) + else: + self.cs = self.get_api_class(options.version)( + username=args.os_username, + tenant_name=args.os_tenant_name, + tenant_id=args.os_tenant_id, + password=args.os_password, + auth_url=args.os_auth_url, + region_name=args.os_region_name) try: - self.cs.authenticate() + if not utils.isunauthenticated(args.func): + self.cs.authenticate() except exc.Unauthorized: raise exc.CommandError("Invalid OpenStack Keystone credentials.") except exc.AuthorizationFailure: diff --git a/keystoneclient/utils.py b/keystoneclient/utils.py index 75a9e08..2cb385a 100644 --- a/keystoneclient/utils.py +++ b/keystoneclient/utils.py @@ -67,3 +67,24 @@ def find_resource(manager, name_or_id): msg = "No %s with a name or ID of '%s' exists." % \ (manager.resource_class.__name__.lower(), name_or_id) raise exceptions.CommandError(msg) + + +def unauthenticated(f): + """ Adds 'unauthenticated' attribute to decorated function. + + Usage: + @unauthenticated + def mymethod(f): + ... + """ + f.unauthenticated = True + return f + + +def isunauthenticated(f): + """ + Checks to see if the function is marked as not requiring authentication + with the @unauthenticated decorator. Returns True if decorator is + set to True, False otherwise. + """ + return getattr(f, 'unauthenticated', False) diff --git a/tests/utils.py b/tests/utils.py index c4530e8..04c5890 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -12,8 +12,10 @@ class TestCase(unittest.TestCase): TEST_TENANT_NAME = 'aTenant' TEST_TOKEN = 'aToken' TEST_USER = 'test' - TEST_URL = 'http://127.0.0.1:5000/v2.0' - TEST_ADMIN_URL = 'http://127.0.0.1:35357/v2.0' + TEST_ROOT_URL = 'http://127.0.0.1:5000/' + TEST_URL = '%s%s' % (TEST_ROOT_URL, 'v2.0') + TEST_ROOT_ADMIN_URL = 'http://127.0.0.1:35357/' + TEST_ADMIN_URL = '%s%s' % (TEST_ROOT_ADMIN_URL, 'v2.0') TEST_SERVICE_CATALOG = [{ "endpoints": [{ @@ -79,3 +81,24 @@ class TestCase(unittest.TestCase): super(TestCase, self).tearDown() self.mox.UnsetStubs() self.mox.VerifyAll() + + +class UnauthenticatedTestCase(unittest.TestCase): + """ Class used as base for unauthenticated calls """ + TEST_ROOT_URL = 'http://127.0.0.1:5000/' + TEST_URL = '%s%s' % (TEST_ROOT_URL, 'v2.0') + TEST_ROOT_ADMIN_URL = 'http://127.0.0.1:35357/' + TEST_ADMIN_URL = '%s%s' % (TEST_ROOT_ADMIN_URL, 'v2.0') + + def setUp(self): + super(UnauthenticatedTestCase, self).setUp() + self.mox = mox.Mox() + self._original_time = time.time + time.time = lambda: 1234 + httplib2.Http.request = self.mox.CreateMockAnything() + + def tearDown(self): + time.time = self._original_time + super(UnauthenticatedTestCase, self).tearDown() + self.mox.UnsetStubs() + self.mox.VerifyAll() diff --git a/tests/v2_0/test_discovery.py b/tests/v2_0/test_discovery.py new file mode 100644 index 0000000..0e089ac --- /dev/null +++ b/tests/v2_0/test_discovery.py @@ -0,0 +1,105 @@ +import httplib2 +import json + +from keystoneclient.generic import client +from tests import utils + + +def to_http_response(resp_dict): + """ + Utility function to convert a python dictionary + (e.g. {'status':status, 'body': body, 'headers':headers} + to an httplib2 response. + """ + resp = httplib2.Response(resp_dict) + for k, v in resp_dict['headers'].items(): + resp[k] = v + return resp + + +class DiscoverKeystoneTests(utils.UnauthenticatedTestCase): + def setUp(self): + super(DiscoverKeystoneTests, self).setUp() + self.TEST_RESPONSE_DICT = { + "versions": { + "values": [{ + "id": "v2.0", + "status": "beta", + "updated": "2011-11-19T00:00:00Z", + "links": [{ + "rel": "self", + "href": "http://127.0.0.1:5000/v2.0/" + }, { + "rel": "describedby", + "type": "text/html", + "href": + "http://docs.openstack.org/api/openstack-identity-service/2.0/content/" + }, { + "rel": "describedby", + "type": "application/pdf", + "href": + "http://docs.openstack.org/api/openstack-identity-service/2.0/\ +identity-dev-guide-2.0.pdf" + }, { + "rel": "describedby", + "type": "application/vnd.sun.wadl+xml", + "href": "http://127.0.0.1:5000/v2.0/identity.wadl" + }], + "media-types": [{ + "base": "application/xml", + "type": + "application/vnd.openstack.identity-v2.0+xml" + }, { + "base": "application/json", + "type": + "application/vnd.openstack.identity-v2.0+json" + }] + }] + } + } + self.TEST_REQUEST_HEADERS = { + 'User-Agent': 'python-keystoneclient', + 'Accept': 'application/json' + } + + def test_get_versions(self): + resp = httplib2.Response({ + "status": 200, + "body": json.dumps(self.TEST_RESPONSE_DICT), + }) + + httplib2.Http.request(self.TEST_ROOT_URL, + 'GET', + headers=self.TEST_REQUEST_HEADERS) \ + .AndReturn((resp, resp['body'])) + self.mox.ReplayAll() + + cs = client.Client() + versions = cs.discover(self.TEST_ROOT_URL) + self.assertIsInstance(versions, dict) + self.assertIn('message', versions) + self.assertIn('v2.0', versions) + self.assertEquals(versions['v2.0']['url'], + self.TEST_RESPONSE_DICT['versions']['values'][0]['links'][0] + ['href']) + + def test_get_version_local(self): + resp = httplib2.Response({ + "status": 200, + "body": json.dumps(self.TEST_RESPONSE_DICT), + }) + + httplib2.Http.request("http://localhost:35357", + 'GET', + headers=self.TEST_REQUEST_HEADERS) \ + .AndReturn((resp, resp['body'])) + self.mox.ReplayAll() + + cs = client.Client() + versions = cs.discover() + self.assertIsInstance(versions, dict) + self.assertIn('message', versions) + self.assertIn('v2.0', versions) + self.assertEquals(versions['v2.0']['url'], + self.TEST_RESPONSE_DICT['versions']['values'][0]['links'][0] + ['href'])