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
This commit is contained in:
Ziad Sawalha
2011-12-28 00:23:31 -06:00
parent cbe1f82931
commit 8db366c448
8 changed files with 446 additions and 20 deletions

View File

@@ -4,6 +4,12 @@ The :mod:`keystoneclient` Python API
.. module:: keystoneclient .. module:: keystoneclient
:synopsis: A client for the OpenStack Keystone API. :synopsis: A client for the OpenStack Keystone API.
.. currentmodule:: keystoneclient.generic.client
.. autoclass:: Client
.. automethod:: discover
.. currentmodule:: keystoneclient.v2_0.client .. currentmodule:: keystoneclient.v2_0.client
.. autoclass:: Client .. autoclass:: Client

View File

View File

@@ -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)

View File

@@ -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"

View File

@@ -26,6 +26,7 @@ import sys
from keystoneclient import exceptions as exc from keystoneclient import exceptions as exc
from keystoneclient import utils from keystoneclient import utils
from keystoneclient.v2_0 import shell as shell_v2_0 from keystoneclient.v2_0 import shell as shell_v2_0
from keystoneclient.generic import shell as shell_generic
def env(e): def env(e):
@@ -99,6 +100,7 @@ class OpenStackIdentityShell(object):
actions_module = shell_v2_0 actions_module = shell_v2_0
self._find_actions(subparsers, actions_module) self._find_actions(subparsers, actions_module)
self._find_actions(subparsers, shell_generic)
self._find_actions(subparsers, self) self._find_actions(subparsers, self)
return parser return parser
@@ -151,28 +153,33 @@ class OpenStackIdentityShell(object):
#FIXME(usrleon): Here should be restrict for project id same as #FIXME(usrleon): Here should be restrict for project id same as
# for username or apikey but for compatibility it is not. # for username or apikey but for compatibility it is not.
if not args.os_username: if not utils.isunauthenticated(args.func):
raise exc.CommandError("You must provide a username:" if not args.os_username:
"via --username or env[OS_USERNAME]") raise exc.CommandError("You must provide a username:"
if not args.os_password: "via --username or env[OS_USERNAME]")
raise exc.CommandError("You must provide a password, either" if not args.os_password:
"via --password or env[OS_PASSWORD]") raise exc.CommandError("You must provide a password, either"
"via --password or env[OS_PASSWORD]")
if not args.os_auth_url: if not args.os_auth_url:
raise exc.CommandError("You must provide a auth url, either" raise exc.CommandError("You must provide a auth url, either"
"via --os-auth_url or via" "via --os-auth_url or via"
"env[OS_AUTH_URL]") "env[OS_AUTH_URL]")
self.cs = self.get_api_class(options.os_version)( if utils.isunauthenticated(args.func):
username=args.os_username, self.cs = shell_generic.CLIENT_CLASS(endpoint=args.os_auth_url)
tenant_name=args.os_tenant_name, else:
tenant_id=args.os_tenant_id, self.cs = self.get_api_class(options.version)(
password=args.os_password, username=args.os_username,
auth_url=args.os_auth_url, tenant_name=args.os_tenant_name,
region_name=args.os_region_name) tenant_id=args.os_tenant_id,
password=args.os_password,
auth_url=args.os_auth_url,
region_name=args.os_region_name)
try: try:
self.cs.authenticate() if not utils.isunauthenticated(args.func):
self.cs.authenticate()
except exc.Unauthorized: except exc.Unauthorized:
raise exc.CommandError("Invalid OpenStack Keystone credentials.") raise exc.CommandError("Invalid OpenStack Keystone credentials.")
except exc.AuthorizationFailure: except exc.AuthorizationFailure:

View File

@@ -67,3 +67,24 @@ def find_resource(manager, name_or_id):
msg = "No %s with a name or ID of '%s' exists." % \ msg = "No %s with a name or ID of '%s' exists." % \
(manager.resource_class.__name__.lower(), name_or_id) (manager.resource_class.__name__.lower(), name_or_id)
raise exceptions.CommandError(msg) 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)

View File

@@ -12,8 +12,10 @@ class TestCase(unittest.TestCase):
TEST_TENANT_NAME = 'aTenant' TEST_TENANT_NAME = 'aTenant'
TEST_TOKEN = 'aToken' TEST_TOKEN = 'aToken'
TEST_USER = 'test' TEST_USER = 'test'
TEST_URL = 'http://127.0.0.1:5000/v2.0' TEST_ROOT_URL = 'http://127.0.0.1:5000/'
TEST_ADMIN_URL = 'http://127.0.0.1:35357/v2.0' 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 = [{ TEST_SERVICE_CATALOG = [{
"endpoints": [{ "endpoints": [{
@@ -79,3 +81,24 @@ class TestCase(unittest.TestCase):
super(TestCase, self).tearDown() super(TestCase, self).tearDown()
self.mox.UnsetStubs() self.mox.UnsetStubs()
self.mox.VerifyAll() 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()

View File

@@ -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'])