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:
@@ -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
|
||||
|
0
keystoneclient/generic/__init__.py
Normal file
0
keystoneclient/generic/__init__.py
Normal file
205
keystoneclient/generic/client.py
Normal file
205
keystoneclient/generic/client.py
Normal 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)
|
59
keystoneclient/generic/shell.py
Normal file
59
keystoneclient/generic/shell.py
Normal 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"
|
@@ -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 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 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 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:
|
||||
|
@@ -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)
|
||||
|
@@ -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()
|
||||
|
105
tests/v2_0/test_discovery.py
Normal file
105
tests/v2_0/test_discovery.py
Normal 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'])
|
Reference in New Issue
Block a user