Update python-ceilometerclient to support Keystone V3 API

Keystone is now deprecating the V2 API in favor of the V3 API.
The ceilometer client is now using the keystone session to
delegate the discovery of the version that is going to be used,
this eliminates the need of updating the client every time there
is a new API version.

DocImpact

blueprint support-keystone-v3-api

Change-Id: I6ebacce7adf70f24bdede9b920853ab9851071cf
This commit is contained in:
Fabio Giannetti
2014-05-28 18:18:55 -07:00
parent e5cd3b26b7
commit 6082ae31ed
4 changed files with 378 additions and 156 deletions

View File

@@ -10,38 +10,133 @@
# License for the specific language governing permissions and limitations
# under the License.
from keystoneclient.v2_0 import client as ksclient
from keystoneclient.auth.identity import v2 as v2_auth
from keystoneclient.auth.identity import v3 as v3_auth
from keystoneclient import discover
from keystoneclient import session
import six
from ceilometerclient.common import utils
from ceilometerclient import exc
def _get_ksclient(**kwargs):
"""Get an endpoint and auth token from Keystone.
def _get_keystone_session(**kwargs):
# TODO(fabgia): the heavy lifting here should be really done by Keystone.
# Unfortunately Keystone does not support a richer method to perform
# discovery and return a single viable URL. A bug against Keystone has
# been filed: https://bugs.launchpad.net/pyhton-keystoneclient/+bug/1330677
:param kwargs: keyword args containing credentials:
* username: name of user
* password: user's password
* auth_url: endpoint to authenticate against
* cacert: path of CA TLS certificate
* insecure: allow insecure SSL (no cert verification)
* tenant_{name|id}: name or ID of tenant
"""
return ksclient.Client(username=kwargs.get('username'),
password=kwargs.get('password'),
tenant_id=kwargs.get('tenant_id'),
tenant_name=kwargs.get('tenant_name'),
auth_url=kwargs.get('auth_url'),
region_name=kwargs.get('region_name'),
cacert=kwargs.get('cacert'),
insecure=kwargs.get('insecure'))
# first create a Keystone session
cacert = kwargs.pop('cacert', None)
cert = kwargs.pop('cert', None)
key = kwargs.pop('key', None)
insecure = kwargs.pop('insecure', False)
auth_url = kwargs.pop('auth_url', None)
project_id = kwargs.pop('project_id', None)
project_name = kwargs.pop('project_name', None)
if insecure:
verify = False
else:
verify = cacert or True
if cert and key:
# passing cert and key together is deprecated in favour of the
# requests lib form of having the cert and key as a tuple
cert = (cert, key)
# create the keystone client session
ks_session = session.Session(verify=verify, cert=cert)
try:
# discover the supported keystone versions using the auth endpoint url
ks_discover = discover.Discover(session=ks_session, auth_url=auth_url)
# Determine which authentication plugin to use.
v2_auth_url = ks_discover.url_for('2.0')
v3_auth_url = ks_discover.url_for('3.0')
except Exception:
raise exc.CommandError('Unable to determine the Keystone version '
'to authenticate with using the given '
'auth_url: %s' % auth_url)
username = kwargs.pop('username', None)
user_id = kwargs.pop('user_id', None)
user_domain_name = kwargs.pop('user_domain_name', None)
user_domain_id = kwargs.pop('user_domain_id', None)
project_domain_name = kwargs.pop('project_domain_name', None)
project_domain_id = kwargs.pop('project_domain_id', None)
auth = None
if v3_auth_url and v2_auth_url:
# the auth_url does not have the versions specified
# e.g. http://no.where:5000
# Keystone will return both v2 and v3 as viable options
# but we need to decide based on the arguments passed
# what version is callable
if (user_domain_name or user_domain_id or project_domain_name or
project_domain_id):
# domain is supported only in v3
auth = v3_auth.Password(
v3_auth_url,
username=username,
user_id=user_id,
user_domain_name=user_domain_name,
user_domain_id=user_domain_id,
project_domain_name=project_domain_name,
project_domain_id=project_domain_id,
**kwargs)
else:
# no domain, then use v2
auth = v2_auth.Password(
v2_auth_url,
username,
kwargs.pop('password', None),
tenant_id=project_id,
tenant_name=project_name)
elif v3_auth_url:
# the auth_url as v3 specified
# e.g. http://no.where:5000/v3
# Keystone will return only v3 as viable option
auth = v3_auth.Password(
v3_auth_url,
username=username,
user_id=user_id,
user_domain_name=user_domain_name,
user_domain_id=user_domain_id,
project_domain_name=project_domain_name,
project_domain_id=project_domain_id,
**kwargs)
elif v2_auth_url:
# the auth_url as v2 specified
# e.g. http://no.where:5000/v2.0
# Keystone will return only v2 as viable option
auth = v2_auth.Password(
v2_auth_url,
username,
kwargs.pop('password', None),
tenant_id=project_id,
tenant_name=project_name)
else:
raise exc.CommandError('Unable to determine the Keystone version '
'to authenticate with using the given '
'auth_url.')
ks_session.auth = auth
return ks_session
def _get_endpoint(client, **kwargs):
"""Get an endpoint using the provided keystone client."""
return client.service_catalog.url_for(
service_type=kwargs.get('service_type') or 'metering',
endpoint_type=kwargs.get('endpoint_type') or 'publicURL')
def _get_endpoint(ks_session, **kwargs):
"""Get an endpoint using the provided keystone session."""
# set service specific endpoint types
endpoint_type = kwargs.get('endpoint_type') or 'publicURL'
service_type = kwargs.get('service_type') or 'metering'
endpoint = ks_session.get_endpoint(service_type=service_type,
endpoint_type=endpoint_type,
region_name=kwargs.get('region_name'))
return endpoint
def get_client(api_version, **kwargs):
@@ -55,10 +150,19 @@ def get_client(api_version, **kwargs):
or:
* os_username: name of user
* os_password: user's password
* os_user_id: user's id
* os_user_domain_id: the domain id of the user
* os_user_domain_name: the domain name of the user
* os_project_id: the user project id
* os_tenant_id: V2 alternative to os_project_id
* os_project_name: the user project name
* os_tenant_name: V2 alternative to os_project_name
* os_project_domain_name: domain name for the user project
* os_project_domain_id: domain id for the user project
* os_auth_url: endpoint to authenticate against
* os_cacert: path of CA TLS certificate
* os_cert|os_cacert: path of CA TLS certificate
* os_key: SSL private key
* insecure: allow insecure SSL (no cert verification)
* os_tenant_{name|id}: name or ID of tenant
"""
token = kwargs.get('os_auth_token')
if token and not six.callable(token):
@@ -66,36 +170,41 @@ def get_client(api_version, **kwargs):
if token and kwargs.get('ceilometer_url'):
endpoint = kwargs.get('ceilometer_url')
elif (kwargs.get('os_username') and
kwargs.get('os_password') and
kwargs.get('os_auth_url') and
(kwargs.get('os_tenant_id') or kwargs.get('os_tenant_name'))):
else:
project_id = kwargs.get('os_project_id') or kwargs.get('os_tenant_id')
project_name = (kwargs.get('os_project_name') or
kwargs.get('os_tenant_name'))
ks_kwargs = {
'username': kwargs.get('os_username'),
'password': kwargs.get('os_password'),
'tenant_id': kwargs.get('os_tenant_id'),
'tenant_name': kwargs.get('os_tenant_name'),
'user_id': kwargs.get('os_user_id'),
'user_domain_id': kwargs.get('os_user_domain_id'),
'user_domain_name': kwargs.get('os_user_domain_name'),
'project_id': project_id,
'project_name': project_name,
'project_domain_name': kwargs.get('os_project_domain_name'),
'project_domain_id': kwargs.get('os_project_domain_id'),
'auth_url': kwargs.get('os_auth_url'),
'region_name': kwargs.get('os_region_name'),
'service_type': kwargs.get('os_service_type'),
'endpoint_type': kwargs.get('os_endpoint_type'),
'cacert': kwargs.get('os_cacert'),
'insecure': kwargs.get('insecure'),
'cert': kwargs.get('os_cert'),
'key': kwargs.get('os_key'),
'insecure': kwargs.get('insecure')
}
_ksclient = _get_ksclient(**ks_kwargs)
token = token or (lambda: _ksclient.auth_token)
# retrieve session
ks_session = _get_keystone_session(**ks_kwargs)
token = token or (lambda: ks_session.get_token())
endpoint = kwargs.get('ceilometer_url') or \
_get_endpoint(_ksclient, **ks_kwargs)
_get_endpoint(ks_session, **ks_kwargs)
cli_kwargs = {
'token': token,
'insecure': kwargs.get('insecure'),
'timeout': kwargs.get('timeout'),
'cacert': kwargs.get('os_cacert'),
'cert_file': kwargs.get('cert_file'),
'key_file': kwargs.get('key_file'),
'cert_file': kwargs.get('os_cert'),
'key_file': kwargs.get('os_key')
}
return Client(api_version, endpoint, **cli_kwargs)

View File

@@ -32,6 +32,157 @@ from ceilometerclient.openstack.common import strutils
class CeilometerShell(object):
def _append_identity_args(self, parser):
# FIXME(fabgia): identity related parameters should be passed by the
# Keystone client itself to avoid constant update in all the services
# clients. When this fix is merged this method can be made obsolete.
# Bug: https://bugs.launchpad.net/python-keystoneclient/+bug/1332337
parser.add_argument('-k', '--insecure',
default=False,
action='store_true',
help="Explicitly allow ceilometerclient to "
"perform \"insecure\" SSL (https) requests. "
"The server's certificate will "
"not be verified against any certificate "
"authorities. This option should be used with "
"caution.")
# User related options
parser.add_argument('--os-username',
default=cliutils.env('OS_USERNAME'),
help='Defaults to env[OS_USERNAME].')
parser.add_argument('--os_username',
help=argparse.SUPPRESS)
parser.add_argument('--os-user-id',
default=cliutils.env('OS_USER_ID'),
help='Defaults to env[OS_USER_ID].')
parser.add_argument('--os-password',
default=cliutils.env('OS_PASSWORD'),
help='Defaults to env[OS_PASSWORD].')
parser.add_argument('--os_password',
help=argparse.SUPPRESS)
# Domain related options
parser.add_argument('--os-user-domain-id',
default=cliutils.env('OS_USER_DOMAIN_ID'),
help='Defaults to env[OS_USER_DOMAIN_ID].')
parser.add_argument('--os-user-domain-name',
default=cliutils.env('OS_USER_DOMAIN_NAME'),
help='Defaults to env[OS_USER_DOMAIN_NAME].')
parser.add_argument('--os-project-domain-id',
default=cliutils.env('OS_PROJECT_DOMAIN_ID'),
help='Defaults to env[OS_PROJECT_DOMAIN_ID].')
parser.add_argument('--os-project-domain-name',
default=cliutils.env('OS_PROJECT_DOMAIN_NAME'),
help='Defaults to env[OS_PROJECT_DOMAIN_NAME].')
# Project V3 or Tenant V2 related options
parser.add_argument('--os-project-id',
default=cliutils.env('OS_PROJECT_ID'),
help='Another way to specify tenant ID. '
'This option is mutually exclusive with '
' --os-tenant-id. '
'Defaults to env[OS_PROJECT_ID].')
parser.add_argument('--os-project-name',
default=cliutils.env('OS_PROJECT_NAME'),
help='Another way to specify tenant name. '
'This option is mutually exclusive with '
' --os-tenant-name. '
'Defaults to env[OS_PROJECT_NAME].')
parser.add_argument('--os-tenant-id',
default=cliutils.env('OS_TENANT_ID'),
help='This option is mutually exclusive with '
' --os-project-id. '
'Defaults to env[OS_PROJECT_ID].')
parser.add_argument('--os_tenant_id',
help=argparse.SUPPRESS)
parser.add_argument('--os-tenant-name',
default=cliutils.env('OS_TENANT_NAME'),
help='Defaults to env[OS_TENANT_NAME].')
parser.add_argument('--os_tenant_name',
help=argparse.SUPPRESS)
# Auth related options
parser.add_argument('--os-auth-url',
default=cliutils.env('OS_AUTH_URL'),
help='Defaults to env[OS_AUTH_URL].')
parser.add_argument('--os_auth_url',
help=argparse.SUPPRESS)
parser.add_argument('--os-auth-token',
default=cliutils.env('OS_AUTH_TOKEN'),
help='Defaults to env[OS_AUTH_TOKEN].')
parser.add_argument('--os_auth_token',
help=argparse.SUPPRESS)
parser.add_argument('--os-cacert',
metavar='<ca-certificate-file>',
dest='os_cacert',
default=cliutils.env('OS_CACERT'),
help='Path of CA TLS certificate(s) used to verify'
'the remote server\'s certificate. Without this '
'option ceilometer looks for the default system '
'CA certificates.')
parser.add_argument('--os-cert',
help='Path of certificate file to use in SSL '
'connection. This file can optionally be '
'prepended with the private key.')
parser.add_argument('--os-key',
help='Path of client key to use in SSL '
'connection. This option is not necessary '
'if your key is prepended to your cert file.')
# Service Catalog related options
parser.add_argument('--os-service-type',
default=cliutils.env('OS_SERVICE_TYPE'),
help='Defaults to env[OS_SERVICE_TYPE].')
parser.add_argument('--os_service_type',
help=argparse.SUPPRESS)
parser.add_argument('--os-endpoint-type',
default=cliutils.env('OS_ENDPOINT_TYPE'),
help='Defaults to env[OS_ENDPOINT_TYPE].')
parser.add_argument('--os_endpoint_type',
help=argparse.SUPPRESS)
parser.add_argument('--os-region-name',
default=cliutils.env('OS_REGION_NAME'),
help='Defaults to env[OS_REGION_NAME].')
parser.add_argument('--os_region_name',
help=argparse.SUPPRESS)
# Deprecated options
parser.add_argument('--ca-file',
dest='os_cacert',
help='DEPRECATED! Use --os-cacert.')
parser.add_argument('--cert-file',
dest='os_cert',
help='DEPRECATED! Use --os-cert.')
parser.add_argument('--key-file',
dest='os_key',
help='DEPRECATED! Use --os-key.')
def get_base_parser(self):
parser = argparse.ArgumentParser(
prog='ceilometer',
@@ -62,91 +213,10 @@ class CeilometerShell(object):
default=False, action="store_true",
help="Print more verbose output.")
parser.add_argument('-k', '--insecure',
default=False,
action='store_true',
help="Explicitly allow ceilometerclient to "
"perform \"insecure\" SSL (https) requests. "
"The server's certificate will "
"not be verified against any certificate "
"authorities. This option should be used with "
"caution.")
parser.add_argument('--cert-file',
help='Path of certificate file to use in SSL '
'connection. This file can optionally be prepended'
' with the private key.')
parser.add_argument('--key-file',
help='Path of client key to use in SSL connection.'
' This option is not necessary if your key is '
'prepended to your cert file.')
parser.add_argument('--os-cacert',
metavar='<ca-certificate-file>',
dest='os_cacert',
default=cliutils.env('OS_CACERT'),
help='Path of CA TLS certificate(s) used to verify'
'the remote server\'s certificate. Without this '
'option ceilometer looks for the default system '
'CA certificates.')
parser.add_argument('--ca-file',
dest='os_cacert',
help='DEPRECATED! Use --os-cacert.')
parser.add_argument('--timeout',
default=600,
help='Number of seconds to wait for a response.')
parser.add_argument('--os-username',
default=cliutils.env('OS_USERNAME'),
help='Defaults to env[OS_USERNAME].')
parser.add_argument('--os_username',
help=argparse.SUPPRESS)
parser.add_argument('--os-password',
default=cliutils.env('OS_PASSWORD'),
help='Defaults to env[OS_PASSWORD].')
parser.add_argument('--os_password',
help=argparse.SUPPRESS)
parser.add_argument('--os-tenant-id',
default=cliutils.env('OS_TENANT_ID'),
help='Defaults to env[OS_TENANT_ID].')
parser.add_argument('--os_tenant_id',
help=argparse.SUPPRESS)
parser.add_argument('--os-tenant-name',
default=cliutils.env('OS_TENANT_NAME'),
help='Defaults to env[OS_TENANT_NAME].')
parser.add_argument('--os_tenant_name',
help=argparse.SUPPRESS)
parser.add_argument('--os-auth-url',
default=cliutils.env('OS_AUTH_URL'),
help='Defaults to env[OS_AUTH_URL].')
parser.add_argument('--os_auth_url',
help=argparse.SUPPRESS)
parser.add_argument('--os-region-name',
default=cliutils.env('OS_REGION_NAME'),
help='Defaults to env[OS_REGION_NAME].')
parser.add_argument('--os_region_name',
help=argparse.SUPPRESS)
parser.add_argument('--os-auth-token',
default=cliutils.env('OS_AUTH_TOKEN'),
help='Defaults to env[OS_AUTH_TOKEN].')
parser.add_argument('--os_auth_token',
help=argparse.SUPPRESS)
parser.add_argument('--ceilometer-url',
default=cliutils.env('CEILOMETER_URL'),
help='Defaults to env[CEILOMETER_URL].')
@@ -163,19 +233,9 @@ class CeilometerShell(object):
parser.add_argument('--ceilometer_api_version',
help=argparse.SUPPRESS)
parser.add_argument('--os-service-type',
default=cliutils.env('OS_SERVICE_TYPE'),
help='Defaults to env[OS_SERVICE_TYPE].')
parser.add_argument('--os_service_type',
help=argparse.SUPPRESS)
parser.add_argument('--os-endpoint-type',
default=cliutils.env('OS_ENDPOINT_TYPE'),
help='Defaults to env[OS_ENDPOINT_TYPE].')
parser.add_argument('--os_endpoint_type',
help=argparse.SUPPRESS)
# FIXME(fabgia): identity related parameters should be passed by the
# Keystone client itself.
self._append_identity_args(parser)
return parser
@@ -247,6 +307,14 @@ class CeilometerShell(object):
# Return parsed args
return api_version, subcommand_parser.parse_args(argv)
def no_project_and_domain_set(self, args):
if not (args.os_project_id or (args.os_project_name and
(args.os_user_domain_name or args.os_user_domain_id)) or
(args.os_tenant_id or args.os_tenant_name)):
return True
else:
return False
def main(self, argv):
parsed = self.parse_args(argv)
if parsed == 0:
@@ -272,10 +340,17 @@ class CeilometerShell(object):
"either --os-password or via "
"env[OS_PASSWORD]")
if not (args.os_tenant_id or args.os_tenant_name):
raise exc.CommandError("You must provide a tenant_id via "
"either --os-tenant-id or via "
"env[OS_TENANT_ID]")
if self.no_project_and_domain_set(args):
# steer users towards Keystone V3 API
raise exc.CommandError("You must provide a project_id via "
"either --os-project-id or via "
"env[OS_PROJECT_ID] and "
"a domain_name via either "
"--os-user-domain-name or via "
"env[OS_USER_DOMAIN_NAME] or "
"a domain_id via either "
"--os-user-domain-id or via "
"env[OS_USER_DOMAIN_ID]")
if not args.os_auth_url:
raise exc.CommandError("You must provide an auth url via "

View File

@@ -20,7 +20,7 @@ from ceilometerclient.v2 import client as v2client
FAKE_ENV = {'os_username': 'username',
'os_password': 'password',
'os_tenant_name': 'tenant_name',
'os_auth_url': 'http://no.where',
'os_auth_url': 'http://no.where:5000/',
'os_auth_token': '1234',
'ceilometer_url': 'http://no.where'}

View File

@@ -14,7 +14,7 @@ import re
import sys
import fixtures
from keystoneclient.v2_0 import client as ksclient
from keystoneclient import session as ks_session
import mock
import six
from testtools import matchers
@@ -24,25 +24,31 @@ from ceilometerclient import shell as ceilometer_shell
from ceilometerclient.tests import utils
from ceilometerclient.v1 import client as v1client
FAKE_ENV = {'OS_USERNAME': 'username',
'OS_PASSWORD': 'password',
'OS_TENANT_NAME': 'tenant_name',
'OS_AUTH_URL': 'http://no.where'}
FAKE_V2_ENV = {'OS_USERNAME': 'username',
'OS_PASSWORD': 'password',
'OS_TENANT_NAME': 'tenant_name',
'OS_AUTH_URL': 'http://localhost:5000/v2.0'}
FAKE_V3_ENV = {'OS_USERNAME': 'username',
'OS_PASSWORD': 'password',
'OS_USER_DOMAIN_NAME': 'domain_name',
'OS_PROJECT_ID': '1234567890',
'OS_AUTH_URL': 'http://localhost:5000/v3'}
class ShellTest(utils.BaseTestCase):
re_options = re.DOTALL | re.MULTILINE
# Patch os.environ to avoid required auth info.
def make_env(self, exclude=None):
env = dict((k, v) for k, v in FAKE_ENV.items() if k != exclude)
def make_env(self, env_version, exclude=None):
env = dict((k, v) for k, v in env_version.items() if k != exclude)
self.useFixture(fixtures.MonkeyPatch('os.environ', env))
def setUp(self):
super(ShellTest, self).setUp()
@mock.patch('sys.stdout', new=six.StringIO())
@mock.patch.object(ksclient, 'Client')
@mock.patch.object(ks_session, 'Session')
@mock.patch.object(v1client.http.HTTPClient, 'json_request')
@mock.patch.object(v1client.http.HTTPClient, 'raw_request')
def shell(self, argstr, mock_ksclient, mock_json, mock_raw):
@@ -85,28 +91,60 @@ class ShellTest(utils.BaseTestCase):
self.assertThat(help_text,
matchers.MatchesRegex(r, self.re_options))
class ShellKeystoneV2Test(ShellTest):
def test_auth_param(self):
self.make_env(exclude='OS_USERNAME')
self.make_env(FAKE_V2_ENV, exclude='OS_USERNAME')
self.test_help()
@mock.patch.object(ksclient, 'Client')
@mock.patch.object(ks_session, 'Session')
def test_debug_switch_raises_error(self, mock_ksclient):
mock_ksclient.side_effect = exc.HTTPUnauthorized
self.make_env()
self.make_env(FAKE_V2_ENV)
args = ['--debug', 'event-list']
self.assertRaises(exc.HTTPUnauthorized, ceilometer_shell.main, args)
@mock.patch.object(ksclient, 'Client')
@mock.patch.object(ks_session, 'Session')
def test_dash_d_switch_raises_error(self, mock_ksclient):
mock_ksclient.side_effect = exc.CommandError("FAIL")
self.make_env()
self.make_env(FAKE_V2_ENV)
args = ['-d', 'event-list']
self.assertRaises(exc.CommandError, ceilometer_shell.main, args)
@mock.patch('sys.stderr')
@mock.patch.object(ksclient, 'Client')
@mock.patch.object(ks_session, 'Session')
def test_no_debug_switch_no_raises_errors(self, mock_ksclient, __):
mock_ksclient.side_effect = exc.HTTPUnauthorized("FAIL")
self.make_env()
self.make_env(FAKE_V2_ENV)
args = ['event-list']
self.assertRaises(SystemExit, ceilometer_shell.main, args)
class ShellKeystoneV3Test(ShellTest):
def test_auth_param(self):
self.make_env(FAKE_V3_ENV, exclude='OS_USER_DOMAIN_NAME')
self.test_help()
@mock.patch.object(ks_session, 'Session')
def test_debug_switch_raises_error(self, mock_ksclient):
mock_ksclient.side_effect = exc.HTTPUnauthorized
self.make_env(FAKE_V3_ENV)
args = ['--debug', 'event-list']
self.assertRaises(exc.HTTPUnauthorized, ceilometer_shell.main, args)
@mock.patch.object(ks_session, 'Session')
def test_dash_d_switch_raises_error(self, mock_ksclient):
mock_ksclient.side_effect = exc.CommandError("FAIL")
self.make_env(FAKE_V3_ENV)
args = ['-d', 'event-list']
self.assertRaises(exc.CommandError, ceilometer_shell.main, args)
@mock.patch('sys.stderr')
@mock.patch.object(ks_session, 'Session')
def test_no_debug_switch_no_raises_errors(self, mock_ksclient, __):
mock_ksclient.side_effect = exc.HTTPUnauthorized("FAIL")
self.make_env(FAKE_V3_ENV)
args = ['event-list']
self.assertRaises(SystemExit, ceilometer_shell.main, args)