summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorZiad Sawalha <github@highbridgellc.com>2011-12-28 00:23:31 -0600
committerZiad Sawalha <github@highbridgellc.com>2012-01-20 12:14:02 -0600
commit8db366c448d4074c044f596a8e4271befdb797cb (patch)
treeff128968469d602e00ee41cfe09e12ba31a6d6b0
parentcbe1f82931002e3562ab41582ff86470681b889b (diff)
downloadpython-keystoneclient-8db366c448d4074c044f596a8e4271befdb797cb.tar.gz
Support for version and extension discoveryessex-3
- 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
-rw-r--r--docs/api.rst6
-rw-r--r--keystoneclient/generic/__init__.py0
-rw-r--r--keystoneclient/generic/client.py205
-rw-r--r--keystoneclient/generic/shell.py59
-rw-r--r--keystoneclient/shell.py47
-rw-r--r--keystoneclient/utils.py21
-rw-r--r--tests/utils.py27
-rw-r--r--tests/v2_0/test_discovery.py105
8 files changed, 448 insertions, 22 deletions
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'])