From e843dbd1b67f8e21895d5bf6583d5a728ec8ce21 Mon Sep 17 00:00:00 2001 From: Isaku Yamahata Date: Wed, 8 Apr 2015 18:18:25 -0700 Subject: [PATCH] catch up neutronclient change Change-Id: I1354fe5378566dec66e7cac311a394cb5498c734 --- tackerclient/client.py | 180 ++++++++-- tackerclient/common/__init__.py | 26 -- tackerclient/common/clientmanager.py | 21 +- tackerclient/common/command.py | 7 +- tackerclient/common/constants.py | 3 +- tackerclient/common/exceptions.py | 19 +- tackerclient/common/extension.py | 86 +++++ tackerclient/common/serializer.py | 54 ++- tackerclient/common/utils.py | 111 +++--- tackerclient/common/validators.py | 4 +- tackerclient/i18n.py | 28 ++ tackerclient/shell.py | 485 +++++++++++++++++++++------ tackerclient/tacker/client.py | 7 +- tackerclient/v1_0/client.py | 385 ++++++++++----------- 14 files changed, 947 insertions(+), 469 deletions(-) create mode 100644 tackerclient/common/extension.py create mode 100644 tackerclient/i18n.py diff --git a/tackerclient/client.py b/tackerclient/client.py index 507656b4..0f615e16 100644 --- a/tackerclient/client.py +++ b/tackerclient/client.py @@ -22,11 +22,12 @@ import logging import os from keystoneclient import access +from keystoneclient import adapter import requests from tackerclient.common import exceptions from tackerclient.common import utils -from tackerclient.openstack.common.gettextutils import _ +from tackerclient.i18n import _ _logger = logging.getLogger(__name__) @@ -39,12 +40,14 @@ else: _requests_log_level = logging.WARNING logging.getLogger("requests").setLevel(_requests_log_level) +MAX_URI_LEN = 8192 class HTTPClient(object): """Handles the REST calls and responses, include authn.""" USER_AGENT = 'python-tackerclient' + CONTENT_TYPE = 'application/json' def __init__(self, username=None, user_id=None, tenant_name=None, tenant_id=None, @@ -69,7 +72,6 @@ class HTTPClient(object): self.auth_token = token self.auth_tenant_id = None self.auth_user_id = None - self.content_type = 'application/json' self.endpoint_url = endpoint_url self.auth_strategy = auth_strategy self.log_credentials = log_credentials @@ -83,17 +85,8 @@ class HTTPClient(object): kargs.setdefault('headers', kwargs.get('headers', {})) kargs['headers']['User-Agent'] = self.USER_AGENT - if 'content_type' in kwargs: - kargs['headers']['Content-Type'] = kwargs['content_type'] - kargs['headers']['Accept'] = kwargs['content_type'] - else: - kargs['headers']['Content-Type'] = self.content_type - kargs['headers']['Accept'] = self.content_type - if 'body' in kwargs: kargs['body'] = kwargs['body'] - args = utils.safe_encode_list(args) - kargs = utils.safe_encode_dict(kargs) if self.log_credentials: log_kargs = kargs @@ -112,8 +105,7 @@ class HTTPClient(object): _logger.debug("throwing ConnectionFailed : %s", e) raise exceptions.ConnectionFailed(reason=e) utils.http_log_resp(_logger, resp, body) - status_code = self.get_status_code(resp) - if status_code == 401: + if resp.status_code == 401: raise exceptions.Unauthorized(message=body) return resp, body @@ -132,24 +124,40 @@ class HTTPClient(object): elif not self.endpoint_url: self.endpoint_url = self._get_endpoint_url() - def request(self, url, method, **kwargs): - kwargs.setdefault('headers', kwargs.get('headers', {})) - kwargs['headers']['User-Agent'] = self.USER_AGENT - kwargs['headers']['Accept'] = 'application/json' - if 'body' in kwargs: - kwargs['headers']['Content-Type'] = 'application/json' - kwargs['data'] = kwargs['body'] - del kwargs['body'] + def request(self, url, method, body=None, headers=None, **kwargs): + """Request without authentication.""" + + content_type = kwargs.pop('content_type', None) or 'application/json' + headers = headers or {} + headers.setdefault('Accept', content_type) + + if body: + headers.setdefault('Content-Type', content_type) + + headers['User-Agent'] = self.USER_AGENT + resp = requests.request( method, url, + data=body, + headers=headers, verify=self.verify_cert, + timeout=self.timeout, **kwargs) return resp, resp.text + def _check_uri_length(self, action): + uri_len = len(self.endpoint_url) + len(action) + if uri_len > MAX_URI_LEN: + raise exceptions.RequestURITooLong( + excess=uri_len - MAX_URI_LEN) + def do_request(self, url, method, **kwargs): + # Ensure client always has correct uri - do not guesstimate anything self.authenticate_and_fetch_endpoint_url() + self._check_uri_length(url) + # Perform the request once. If we get a 401 back then it # might be because the auth token expired, so try to # re-authenticate and try again. If it still fails, bail. @@ -206,8 +214,7 @@ class HTTPClient(object): body=json.dumps(body), content_type="application/json", allow_redirects=True) - status_code = self.get_status_code(resp) - if status_code != 200: + if resp.status_code != 200: raise exceptions.Unauthorized(message=resp_body) if resp_body: try: @@ -250,7 +257,7 @@ class HTTPClient(object): body = json.loads(body) for endpoint in body.get('endpoints', []): if (endpoint['type'] == 'servicevm' and - endpoint.get('region') == self.region_name): + endpoint.get('region') == self.region_name): if self.endpoint_type not in endpoint: raise exceptions.EndpointTypeNotFound( type_=self.endpoint_type) @@ -264,13 +271,122 @@ class HTTPClient(object): 'auth_user_id': self.auth_user_id, 'endpoint_url': self.endpoint_url} - def get_status_code(self, response): - """Returns the integer status code from the response. - Either a Webob.Response (used in testing) or requests.Response - is returned. - """ - if hasattr(response, 'status_int'): - return response.status_int +class SessionClient(adapter.Adapter): + + def request(self, *args, **kwargs): + kwargs.setdefault('authenticated', False) + kwargs.setdefault('raise_exc', False) + + content_type = kwargs.pop('content_type', None) or 'application/json' + + headers = kwargs.setdefault('headers', {}) + headers.setdefault('Accept', content_type) + + try: + kwargs.setdefault('data', kwargs.pop('body')) + except KeyError: + pass + + if kwargs.get('data'): + headers.setdefault('Content-Type', content_type) + + resp = super(SessionClient, self).request(*args, **kwargs) + return resp, resp.text + + def _check_uri_length(self, url): + uri_len = len(self.endpoint_url) + len(url) + if uri_len > MAX_URI_LEN: + raise exceptions.RequestURITooLong( + excess=uri_len - MAX_URI_LEN) + + def do_request(self, url, method, **kwargs): + kwargs.setdefault('authenticated', True) + self._check_uri_length(url) + return self.request(url, method, **kwargs) + + @property + def endpoint_url(self): + # NOTE(jamielennox): This is used purely by the CLI and should be + # removed when the CLI gets smarter. + return self.get_endpoint() + + @property + def auth_token(self): + # NOTE(jamielennox): This is used purely by the CLI and should be + # removed when the CLI gets smarter. + return self.get_token() + + def authenticate(self): + # NOTE(jamielennox): This is used purely by the CLI and should be + # removed when the CLI gets smarter. + self.get_token() + + def get_auth_info(self): + auth_info = {'auth_token': self.auth_token, + 'endpoint_url': self.endpoint_url} + + # NOTE(jamielennox): This is the best we can do here. It will work + # with identity plugins which is the primary case but we should + # deprecate it's usage as much as possible. + try: + get_access = (self.auth or self.session.auth).get_access + except AttributeError: + pass else: - return response.status_code + auth_ref = get_access(self.session) + + auth_info['auth_tenant_id'] = auth_ref.project_id + auth_info['auth_user_id'] = auth_ref.user_id + + return auth_info + + +# FIXME(bklei): Should refactor this to use kwargs and only +# explicitly list arguments that are not None. +def construct_http_client(username=None, + user_id=None, + tenant_name=None, + tenant_id=None, + password=None, + auth_url=None, + token=None, + region_name=None, + timeout=None, + endpoint_url=None, + insecure=False, + endpoint_type='publicURL', + log_credentials=None, + auth_strategy='keystone', + ca_cert=None, + service_type='servicevm', + session=None, + **kwargs): + + if session: + kwargs.setdefault('user_agent', 'python-tackerclient') + kwargs.setdefault('interface', endpoint_type) + return SessionClient(session=session, + service_type=service_type, + region_name=region_name, + **kwargs) + else: + # FIXME(bklei): username and password are now optional. Need + # to test that they were provided in this mode. Should also + # refactor to use kwargs. + return HTTPClient(username=username, + password=password, + tenant_id=tenant_id, + tenant_name=tenant_name, + user_id=user_id, + auth_url=auth_url, + token=token, + endpoint_url=endpoint_url, + insecure=insecure, + timeout=timeout, + region_name=region_name, + endpoint_type=endpoint_type, + service_type=service_type, + ca_cert=ca_cert, + log_credentials=log_credentials, + auth_strategy=auth_strategy) diff --git a/tackerclient/common/__init__.py b/tackerclient/common/__init__.py index 2799717d..e69de29b 100644 --- a/tackerclient/common/__init__.py +++ b/tackerclient/common/__init__.py @@ -1,26 +0,0 @@ -# Copyright 2011 VMware, Inc. -# 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 gettext - -t = gettext.translation('tackerclient', fallback=True) -try: - ugettext = t.ugettext # Python 2 -except AttributeError: - ugettext = t.gettext # Python 3 - - -def _(msg): - return ugettext(msg) diff --git a/tackerclient/common/clientmanager.py b/tackerclient/common/clientmanager.py index 084146a0..ed94b0b9 100644 --- a/tackerclient/common/clientmanager.py +++ b/tackerclient/common/clientmanager.py @@ -27,8 +27,7 @@ LOG = logging.getLogger(__name__) class ClientCache(object): - """Descriptor class for caching created client handles. - """ + """Descriptor class for caching created client handles.""" def __init__(self, factory): self.factory = factory @@ -42,8 +41,7 @@ class ClientCache(object): class ClientManager(object): - """Manages access to API clients, including authentication. - """ + """Manages access to API clients, including authentication.""" tacker = ClientCache(tacker_client.make_client) def __init__(self, token=None, url=None, @@ -61,6 +59,11 @@ class ClientManager(object): ca_cert=None, log_credentials=False, service_type=None, + timeout=None, + retries=0, + raise_errors=True, + session=None, + auth=None, ): self._token = token self._url = url @@ -79,11 +82,16 @@ class ClientManager(object): self._insecure = insecure self._ca_cert = ca_cert self._log_credentials = log_credentials + self._timeout = timeout + self._retries = retries + self._raise_errors = raise_errors + self._session = session + self._auth = auth return def initialize(self): if not self._url: - httpclient = client.HTTPClient( + httpclient = client.construct_http_client( username=self._username, user_id=self._user_id, tenant_name=self._tenant_name, @@ -95,6 +103,9 @@ class ClientManager(object): endpoint_type=self._endpoint_type, insecure=self._insecure, ca_cert=self._ca_cert, + timeout=self._timeout, + session=self._session, + auth=self._auth, log_credentials=self._log_credentials) httpclient.authenticate() # Populate other password flow attributes diff --git a/tackerclient/common/command.py b/tackerclient/common/command.py index 2cd32935..3d054074 100644 --- a/tackerclient/common/command.py +++ b/tackerclient/common/command.py @@ -14,16 +14,11 @@ # under the License. # -""" -OpenStack base command -""" - from cliff import command class OpenStackCommand(command.Command): - """Base class for OpenStack commands - """ + """Base class for OpenStack commands.""" api = None diff --git a/tackerclient/common/constants.py b/tackerclient/common/constants.py index 35ba4df8..7292cb21 100644 --- a/tackerclient/common/constants.py +++ b/tackerclient/common/constants.py @@ -15,7 +15,7 @@ EXT_NS = '_extension_ns' -XML_NS_V10 = 'http://openstack.org/tacker/api/v1.0' +XML_NS_V20 = 'http://openstack.org/tacker/api/v1.0' XSI_NAMESPACE = "http://www.w3.org/2001/XMLSchema-instance" XSI_ATTR = "xsi:nil" XSI_NIL_ATTR = "xmlns:xsi" @@ -33,6 +33,7 @@ TYPE_FLOAT = "float" TYPE_LIST = "list" TYPE_DICT = "dict" + PLURALS = {'templates': 'template', 'devices': 'device', 'services': 'service'} diff --git a/tackerclient/common/exceptions.py b/tackerclient/common/exceptions.py index 828d7e5b..000b69ca 100644 --- a/tackerclient/common/exceptions.py +++ b/tackerclient/common/exceptions.py @@ -13,7 +13,7 @@ # License for the specific language governing permissions and limitations # under the License. -from tackerclient.common import _ +from tackerclient.i18n import _ """ Tacker base exception handling. @@ -30,12 +30,11 @@ Exceptions are classified into three categories: class TackerException(Exception): - """Base Tacker Exception + """Base Tacker Exception. To correctly use this class, inherit from it and define a 'message' property. That message will get printf'd with the keyword arguments provided to the constructor. - """ message = _("An unknown exception occurred.") @@ -60,6 +59,8 @@ class TackerClientException(TackerException): blocks. The actual error message is the one generated on the server side. """ + status_code = 0 + def __init__(self, message=None, **kwargs): if 'status_code' in kwargs: self.status_code = kwargs['status_code'] @@ -139,6 +140,10 @@ class IpAddressInUseClient(Conflict): pass +class InvalidIpForNetworkClient(BadRequest): + pass + + class OverQuotaClient(Conflict): pass @@ -153,6 +158,10 @@ class IpAddressGenerationFailureClient(Conflict): pass +class MacAddressInUseClient(Conflict): + pass + + class ExternalIpAddressExhaustedClient(BadRequest): pass @@ -212,8 +221,8 @@ class CommandError(TackerCLIError): class UnsupportedVersion(TackerCLIError): - """Indicates that the user is trying to use an unsupported - version of the API + """Indicates that the user is trying to use an unsupported version of + the API. """ pass diff --git a/tackerclient/common/extension.py b/tackerclient/common/extension.py new file mode 100644 index 00000000..df602d2e --- /dev/null +++ b/tackerclient/common/extension.py @@ -0,0 +1,86 @@ +# Copyright 2015 Rackspace Hosting Inc. +# 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 stevedore import extension + +from tackerclient.tacker import v1_0 as tackerV10 + + +def _discover_via_entry_points(): + emgr = extension.ExtensionManager('tackerclient.extension', + invoke_on_load=False) + return ((ext.name, ext.plugin) for ext in emgr) + + +class TackerClientExtension(tackerV10.TackerCommand): + pagination_support = False + _formatters = {} + sorting_support = False + + +class ClientExtensionShow(TackerClientExtension, tackerV10.ShowCommand): + def get_data(self, parsed_args): + # NOTE(mdietz): Calls 'execute' to provide a consistent pattern + # for any implementers adding extensions with + # regard to any other extension verb. + return self.execute(parsed_args) + + def execute(self, parsed_args): + return super(ClientExtensionShow, self).get_data(parsed_args) + + +class ClientExtensionList(TackerClientExtension, tackerV10.ListCommand): + + def get_data(self, parsed_args): + # NOTE(mdietz): Calls 'execute' to provide a consistent pattern + # for any implementers adding extensions with + # regard to any other extension verb. + return self.execute(parsed_args) + + def execute(self, parsed_args): + return super(ClientExtensionList, self).get_data(parsed_args) + + +class ClientExtensionDelete(TackerClientExtension, tackerV10.DeleteCommand): + def run(self, parsed_args): + # NOTE(mdietz): Calls 'execute' to provide a consistent pattern + # for any implementers adding extensions with + # regard to any other extension verb. + return self.execute(parsed_args) + + def execute(self, parsed_args): + return super(ClientExtensionDelete, self).run(parsed_args) + + +class ClientExtensionCreate(TackerClientExtension, tackerV10.CreateCommand): + def get_data(self, parsed_args): + # NOTE(mdietz): Calls 'execute' to provide a consistent pattern + # for any implementers adding extensions with + # regard to any other extension verb. + return self.execute(parsed_args) + + def execute(self, parsed_args): + return super(ClientExtensionCreate, self).get_data(parsed_args) + + +class ClientExtensionUpdate(TackerClientExtension, tackerV10.UpdateCommand): + def run(self, parsed_args): + # NOTE(mdietz): Calls 'execute' to provide a consistent pattern + # for any implementers adding extensions with + # regard to any other extension verb. + return self.execute(parsed_args) + + def execute(self, parsed_args): + return super(ClientExtensionUpdate, self).run(parsed_args) diff --git a/tackerclient/common/serializer.py b/tackerclient/common/serializer.py index a8f8e309..798882a5 100644 --- a/tackerclient/common/serializer.py +++ b/tackerclient/common/serializer.py @@ -12,23 +12,23 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. -# -### -### Codes from tacker wsgi -### import logging - from xml.etree import ElementTree as etree from xml.parsers import expat +from oslo.serialization import jsonutils +import six + from tackerclient.common import constants from tackerclient.common import exceptions as exception -from tackerclient.openstack.common.gettextutils import _ -from tackerclient.openstack.common import jsonutils +from tackerclient.i18n import _ LOG = logging.getLogger(__name__) +if six.PY3: + long = int + class ActionDispatcher(object): """Maps method name to local methods through action name.""" @@ -58,7 +58,7 @@ class JSONDictSerializer(DictSerializer): def default(self, data): def sanitizer(obj): - return unicode(obj) + return six.text_type(obj) return jsonutils.dumps(data, default=sanitizer) @@ -67,16 +67,16 @@ class XMLDictSerializer(DictSerializer): def __init__(self, metadata=None, xmlns=None): """XMLDictSerializer constructor. - :param metadata: information needed to deserialize xml into + :param metadata: information needed to deserialize XML into a dictionary. - :param xmlns: XML namespace to include with serialized xml + :param xmlns: XML namespace to include with serialized XML """ super(XMLDictSerializer, self).__init__() self.metadata = metadata or {} if not xmlns: xmlns = self.metadata.get('xmlns') if not xmlns: - xmlns = constants.XML_NS_V10 + xmlns = constants.XML_NS_V20 self.xmlns = xmlns def default(self, data): @@ -93,13 +93,13 @@ class XMLDictSerializer(DictSerializer): root_key = constants.VIRTUAL_ROOT_KEY root_value = None else: - link_keys = [k for k in data.iterkeys() or [] + link_keys = [k for k in six.iterkeys(data) or [] if k.endswith('_links')] if link_keys: links = data.pop(link_keys[0], None) has_atom = True root_key = (len(data) == 1 and - data.keys()[0] or constants.VIRTUAL_ROOT_KEY) + list(data.keys())[0] or constants.VIRTUAL_ROOT_KEY) root_value = data.get(root_key, data) doc = etree.Element("_temp_root") used_prefixes = [] @@ -122,8 +122,8 @@ class XMLDictSerializer(DictSerializer): self._add_xmlns(node, used_prefixes, has_atom) return etree.tostring(node, encoding='UTF-8') - #NOTE (ameade): the has_atom should be removed after all of the - # xml serializers and view builders have been updated to the current + # NOTE(ameade): the has_atom should be removed after all of the + # XML serializers and view builders have been updated to the current # spec that required all responses include the xmlns:atom, the has_atom # flag is to prevent current tests from breaking def _add_xmlns(self, node, used_prefixes, has_atom=False): @@ -142,7 +142,7 @@ class XMLDictSerializer(DictSerializer): result = etree.SubElement(parent, nodename) if ":" in nodename: used_prefixes.append(nodename.split(":", 1)[0]) - #TODO(bcwaldon): accomplish this without a type-check + # TODO(bcwaldon): accomplish this without a type-check if isinstance(data, list): if not data: result.set( @@ -158,7 +158,7 @@ class XMLDictSerializer(DictSerializer): for item in data: self._to_xml_node(result, metadata, singular, item, used_prefixes) - #TODO(bcwaldon): accomplish this without a type-check + # TODO(bcwaldon): accomplish this without a type-check elif isinstance(data, dict): if not data: result.set( @@ -191,13 +191,10 @@ class XMLDictSerializer(DictSerializer): result.set( constants.TYPE_ATTR, constants.TYPE_FLOAT) - LOG.debug(_("Data %(data)s type is %(type)s"), + LOG.debug("Data %(data)s type is %(type)s", {'data': data, 'type': type(data)}) - if isinstance(data, str): - result.text = unicode(data, 'utf-8') - else: - result.text = unicode(data) + result.text = six.text_type(data) return result def _create_link_nodes(self, xml_doc, links): @@ -235,14 +232,14 @@ class XMLDeserializer(TextDeserializer): def __init__(self, metadata=None): """XMLDeserializer constructor. - :param metadata: information needed to deserialize xml into + :param metadata: information needed to deserialize XML into a dictionary. """ super(XMLDeserializer, self).__init__() self.metadata = metadata or {} xmlns = self.metadata.get('xmlns') if not xmlns: - xmlns = constants.XML_NS_V10 + xmlns = constants.XML_NS_V20 self.xmlns = xmlns def _get_key(self, tag): @@ -290,7 +287,7 @@ class XMLDeserializer(TextDeserializer): parseError = False # Python2.7 if (hasattr(etree, 'ParseError') and - isinstance(e, getattr(etree, 'ParseError'))): + isinstance(e, getattr(etree, 'ParseError'))): parseError = True # Python2.6 elif isinstance(e, expat.ExpatError): @@ -340,9 +337,9 @@ class XMLDeserializer(TextDeserializer): result = dict() for attr in node.keys(): if (attr == 'xmlns' or - attr.startswith('xmlns:') or - attr == constants.XSI_ATTR or - attr == constants.TYPE_ATTR): + attr.startswith('xmlns:') or + attr == constants.XSI_ATTR or + attr == constants.TYPE_ATTR): continue result[self._get_key(attr)] = node.get(attr) children = list(node) @@ -392,7 +389,6 @@ class Serializer(object): """Deserialize a string to a dictionary. The string must be in the format of a supported MIME type. - """ return self.get_deserialize_handler(content_type).deserialize( datastring) diff --git a/tackerclient/common/utils.py b/tackerclient/common/utils.py index cf3def2f..3a0105b5 100644 --- a/tackerclient/common/utils.py +++ b/tackerclient/common/utils.py @@ -17,21 +17,22 @@ """Utilities and helper functions.""" -import datetime -import json +import argparse import logging import os -import sys -from tackerclient.common import _ +from oslo.utils import encodeutils +from oslo.utils import importutils +import six + from tackerclient.common import exceptions -from tackerclient.openstack.common import strutils +from tackerclient.i18n import _ def env(*vars, **kwargs): """Returns the first environment variable set. - if none are non-empty, defaults to '' or keyword arg default. + If none are non-empty, defaults to '' or keyword arg default. """ for v in vars: value = os.environ.get(v) @@ -40,52 +41,8 @@ def env(*vars, **kwargs): return kwargs.get('default', '') -def to_primitive(value): - if isinstance(value, list) or isinstance(value, tuple): - o = [] - for v in value: - o.append(to_primitive(v)) - return o - elif isinstance(value, dict): - o = {} - for k, v in value.iteritems(): - o[k] = to_primitive(v) - return o - elif isinstance(value, datetime.datetime): - return str(value) - elif hasattr(value, 'iteritems'): - return to_primitive(dict(value.iteritems())) - elif hasattr(value, '__iter__'): - return to_primitive(list(value)) - else: - return value - - -def dumps(value, indent=None): - try: - return json.dumps(value, indent=indent) - except TypeError: - pass - return json.dumps(to_primitive(value)) - - -def loads(s): - return json.loads(s) - - -def import_class(import_str): - """Returns a class from a string including module and class. - - :param import_str: a string representation of the class name - :rtype: the requested class - """ - mod_str, _sep, class_str = import_str.rpartition('.') - __import__(mod_str) - return getattr(sys.modules[mod_str], class_str) - - def get_client_class(api_name, version, version_map): - """Returns the client class for the requested API version + """Returns the client class for the requested API version. :param api_name: the name of the API, e.g. 'compute', 'image', etc :param version: the requested API version @@ -101,10 +58,10 @@ def get_client_class(api_name, version, version_map): 'map_keys': ', '.join(version_map.keys())} raise exceptions.UnsupportedVersion(msg) - return import_class(client_path) + return importutils.import_class(client_path) -def get_item_properties(item, fields, mixed_case_fields=[], formatters={}): +def get_item_properties(item, fields, mixed_case_fields=(), formatters=None): """Return a tuple containing the item properties. :param item: a single item resource (e.g. Server, Tenant, etc) @@ -113,6 +70,9 @@ def get_item_properties(item, fields, mixed_case_fields=[], formatters={}): :param formatters: dictionary mapping field names to callables to format the values """ + if formatters is None: + formatters = {} + row = [] for field in fields: @@ -136,22 +96,17 @@ def get_item_properties(item, fields, mixed_case_fields=[], formatters={}): def str2bool(strbool): if strbool is None: return None - else: - return strbool.lower() == 'true' + return strbool.lower() == 'true' def str2dict(strdict): - '''Convert key1=value1,key2=value2,... string into dictionary. + """Convert key1=value1,key2=value2,... string into dictionary. - :param strdict: key1=value1,key2=value2 - ''' - _info = {} - if not strdict: - return _info - for kv_str in strdict.split(","): - k, v = kv_str.split("=", 1) - _info.update({k: v}) - return _info + :param strdict: key1=value1,key2=value2 + """ + if not strdict: + return {} + return dict([kv.split('=', 1) for kv in strdict.split(',')]) def http_log_req(_logger, args, kwargs): @@ -171,27 +126,27 @@ def http_log_req(_logger, args, kwargs): if 'body' in kwargs and kwargs['body']: string_parts.append(" -d '%s'" % (kwargs['body'])) - string_parts = safe_encode_list(string_parts) - _logger.debug(_("\nREQ: %s\n"), "".join(string_parts)) + req = encodeutils.safe_encode("".join(string_parts)) + _logger.debug("\nREQ: %s\n", req) def http_log_resp(_logger, resp, body): if not _logger.isEnabledFor(logging.DEBUG): return - _logger.debug(_("RESP:%(code)s %(headers)s %(body)s\n"), + _logger.debug("RESP:%(code)s %(headers)s %(body)s\n", {'code': resp.status_code, 'headers': resp.headers, 'body': body}) def _safe_encode_without_obj(data): - if isinstance(data, basestring): - return strutils.safe_encode(data) + if isinstance(data, six.string_types): + return encodeutils.safe_encode(data) return data def safe_encode_list(data): - return map(_safe_encode_without_obj, data) + return list(map(_safe_encode_without_obj, data)) def safe_encode_dict(data): @@ -203,4 +158,16 @@ def safe_encode_dict(data): return (k, safe_encode_dict(v)) return (k, _safe_encode_without_obj(v)) - return dict(map(_encode_item, data.items())) + return dict(list(map(_encode_item, data.items()))) + + +def add_boolean_argument(parser, name, **kwargs): + for keyword in ('metavar', 'choices'): + kwargs.pop(keyword, None) + default = kwargs.pop('default', argparse.SUPPRESS) + parser.add_argument( + name, + metavar='{True,False}', + choices=['True', 'true', 'False', 'false'], + default=default, + **kwargs) diff --git a/tackerclient/common/validators.py b/tackerclient/common/validators.py index 8fc270f6..39964dbb 100644 --- a/tackerclient/common/validators.py +++ b/tackerclient/common/validators.py @@ -16,7 +16,7 @@ import netaddr from tackerclient.common import exceptions -from tackerclient.openstack.common.gettextutils import _ +from tackerclient.i18n import _ def validate_int_range(parsed_args, attr_name, min_value=None, max_value=None): @@ -29,7 +29,7 @@ def validate_int_range(parsed_args, attr_name, min_value=None, max_value=None): else: int_val = val if ((min_value is None or min_value <= int_val) and - (max_value is None or int_val <= max_value)): + (max_value is None or int_val <= max_value)): return except (ValueError, TypeError): pass diff --git a/tackerclient/i18n.py b/tackerclient/i18n.py new file mode 100644 index 00000000..eb699a7d --- /dev/null +++ b/tackerclient/i18n.py @@ -0,0 +1,28 @@ +# 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 oslo import i18n + +_translators = i18n.TranslatorFactory(domain='tackerclient') + +# The primary translation function using the well-known name "_" +_ = _translators.primary + +# Translators for log levels. +# +# The abbreviated names are meant to reflect the usual use of a short +# name like '_'. The "L" is for "log" and the other letter comes from +# the level. +_LI = _translators.log_info +_LW = _translators.log_warning +_LE = _translators.log_error +_LC = _translators.log_critical diff --git a/tackerclient/shell.py b/tackerclient/shell.py index b21b1b11..bfc29ca7 100644 --- a/tackerclient/shell.py +++ b/tackerclient/shell.py @@ -21,18 +21,30 @@ Command-line interface to the Tacker APIs from __future__ import print_function import argparse +import getpass +import inspect +import itertools import logging import os import sys +from keystoneclient.auth.identity import v2 as v2_auth +from keystoneclient.auth.identity import v3 as v3_auth +from keystoneclient import discover +from keystoneclient.openstack.common.apiclient import exceptions as ks_exc +from keystoneclient import session +from oslo.utils import encodeutils +import six.moves.urllib.parse as urlparse + from cliff import app from cliff import commandmanager from tackerclient.common import clientmanager +from tackerclient.common import command as openstack_command from tackerclient.common import exceptions as exc +from tackerclient.common import extension as client_extension from tackerclient.common import utils -from tackerclient.openstack.common.gettextutils import _ -from tackerclient.openstack.common import strutils +from tackerclient.i18n import _ from tackerclient.tacker.v1_0 import extension from tackerclient.tacker.v1_0.vm import device from tackerclient.tacker.v1_0.vm import device_template @@ -70,7 +82,23 @@ def env(*_vars, **kwargs): return kwargs.get('default', '') +def check_non_negative_int(value): + try: + value = int(value) + except ValueError: + raise argparse.ArgumentTypeError(_("invalid int value: %r") % value) + if value < 0: + raise argparse.ArgumentTypeError(_("input value %d is negative") % + value) + return value + + +class BashCompletionCommand(openstack_command.OpenStackCommand): + """Prints all of the commands and options for bash-completion.""" + resource = "bash_completion" + COMMAND_V1 = { + 'bash-completion': BashCompletionCommand, 'ext-list': extension.ListExt, 'ext-show': extension.ShowExt, 'device-template-create': device_template.CreateDeviceTemplate, @@ -134,6 +162,11 @@ class TackerShell(app.App): for k, v in self.commands[apiversion].items(): self.command_manager.add_command(k, v) + self._register_extensions(VERSION) + + # Pop the 'complete' to correct the outputs of 'tacker help'. + self.command_manager.commands.pop('complete') + # This is instantiated in initialize_app() only when using # password flow auth self.auth_client = None @@ -169,20 +202,64 @@ class TackerShell(app.App): action='store_const', dest='verbose_level', const=0, - help=_('Suppress output except warnings and errors')) + help=_('Suppress output except warnings and errors.')) parser.add_argument( '-h', '--help', action=HelpAction, nargs=0, default=self, # tricky - help=_("Show this help message and exit")) - # Global arguments + help=_("Show this help message and exit.")) + parser.add_argument( + '-r', '--retries', + metavar="NUM", + type=check_non_negative_int, + default=0, + help=_("How many times the request to the Tacker server should " + "be retried if it fails.")) + # FIXME(bklei): this method should come from python-keystoneclient + self._append_global_identity_args(parser) + + return parser + + def _append_global_identity_args(self, parser): + # FIXME(bklei): these are global identity (Keystone) arguments which + # should be consistent and shared by all service clients. Therefore, + # they should be provided by python-keystoneclient. We will need to + # refactor this code once this functionality is available in + # python-keystoneclient. + # + # Note: At that time we'll need to decide if we can just abandon + # the deprecated args (--service-type and --endpoint-type). + + parser.add_argument( + '--os-service-type', metavar='', + default=env('OS_SERVICEVM_SERVICE_TYPE', default='servicevm'), + help=_('Defaults to env[OS_SERVICEVM_SERVICE_TYPE] or servicevm.')) + + parser.add_argument( + '--os-endpoint-type', metavar='', + default=env('OS_ENDPOINT_TYPE', default='publicURL'), + help=_('Defaults to env[OS_ENDPOINT_TYPE] or publicURL.')) + + # FIXME(bklei): --service-type is deprecated but kept in for + # backward compatibility. + parser.add_argument( + '--service-type', metavar='', + default=env('OS_SERVICEVM_SERVICE_TYPE', default='servicevm'), + help=_('DEPRECATED! Use --os-service-type.')) + + # FIXME(bklei): --endpoint-type is deprecated but kept in for + # backward compatibility. + parser.add_argument( + '--endpoint-type', metavar='', + default=env('OS_ENDPOINT_TYPE', default='publicURL'), + help=_('DEPRECATED! Use --os-endpoint-type.')) + parser.add_argument( '--os-auth-strategy', metavar='', default=env('OS_AUTH_STRATEGY', default='keystone'), - help=_('Authentication strategy (Env: OS_AUTH_STRATEGY' - ', default keystone). For now, any other value will' - ' disable the authentication')) + help=_('DEPRECATED! Only keystone is supported.')) + parser.add_argument( '--os_auth_strategy', help=argparse.SUPPRESS) @@ -190,28 +267,49 @@ class TackerShell(app.App): parser.add_argument( '--os-auth-url', metavar='', default=env('OS_AUTH_URL'), - help=_('Authentication URL (Env: OS_AUTH_URL)')) + help=_('Authentication URL, defaults to env[OS_AUTH_URL].')) parser.add_argument( '--os_auth_url', help=argparse.SUPPRESS) - parser.add_argument( + project_name_group = parser.add_mutually_exclusive_group() + project_name_group.add_argument( '--os-tenant-name', metavar='', default=env('OS_TENANT_NAME'), - help=_('Authentication tenant name (Env: OS_TENANT_NAME)')) + help=_('Authentication tenant name, defaults to ' + 'env[OS_TENANT_NAME].')) + project_name_group.add_argument( + '--os-project-name', + metavar='', + default=utils.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_name', help=argparse.SUPPRESS) - parser.add_argument( + project_id_group = parser.add_mutually_exclusive_group() + project_id_group.add_argument( '--os-tenant-id', metavar='', default=env('OS_TENANT_ID'), - help=_('Authentication tenant ID (Env: OS_TENANT_ID)')) + help=_('Authentication tenant ID, defaults to ' + 'env[OS_TENANT_ID].')) + project_id_group.add_argument( + '--os-project-id', + metavar='', + default=utils.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-username', metavar='', default=utils.env('OS_USERNAME'), - help=_('Authentication username (Env: OS_USERNAME)')) + help=_('Authentication username, defaults to env[OS_USERNAME].')) parser.add_argument( '--os_username', help=argparse.SUPPRESS) @@ -222,54 +320,115 @@ class TackerShell(app.App): help=_('Authentication user ID (Env: OS_USER_ID)')) parser.add_argument( - '--os-password', metavar='', - default=utils.env('OS_PASSWORD'), - help=_('Authentication password (Env: OS_PASSWORD)')) - parser.add_argument( - '--os_password', + '--os_user_id', help=argparse.SUPPRESS) parser.add_argument( - '--os-region-name', metavar='', - default=env('OS_REGION_NAME'), - help=_('Authentication region name (Env: OS_REGION_NAME)')) + '--os-user-domain-id', + metavar='', + default=utils.env('OS_USER_DOMAIN_ID'), + help='OpenStack user domain ID. ' + 'Defaults to env[OS_USER_DOMAIN_ID].') + parser.add_argument( - '--os_region_name', + '--os_user_domain_id', help=argparse.SUPPRESS) parser.add_argument( - '--os-token', metavar='', - default=env('OS_TOKEN'), - help=_('Defaults to env[OS_TOKEN]')) + '--os-user-domain-name', + metavar='', + default=utils.env('OS_USER_DOMAIN_NAME'), + help='OpenStack user domain name. ' + 'Defaults to env[OS_USER_DOMAIN_NAME].') + parser.add_argument( - '--os_token', + '--os_user_domain_name', help=argparse.SUPPRESS) parser.add_argument( - '--service-type', metavar='', - default=env('OS_SERVICEVM_SERVICE_TYPE', default='servicevm'), - help=_('Defaults to env[OS_SERVICEVM_SERVICE_TYPE] or servicevm.')) - - parser.add_argument( - '--endpoint-type', metavar='', - default=env('OS_ENDPOINT_TYPE', default='publicURL'), - help=_('Defaults to env[OS_ENDPOINT_TYPE] or publicURL.')) - - parser.add_argument( - '--os-url', metavar='', - default=env('OS_URL'), - help=_('Defaults to env[OS_URL]')) - parser.add_argument( - '--os_url', + '--os_project_id', help=argparse.SUPPRESS) + parser.add_argument( + '--os_project_name', + help=argparse.SUPPRESS) + + parser.add_argument( + '--os-project-domain-id', + metavar='', + default=utils.env('OS_PROJECT_DOMAIN_ID'), + help='Defaults to env[OS_PROJECT_DOMAIN_ID].') + + parser.add_argument( + '--os-project-domain-name', + metavar='', + default=utils.env('OS_PROJECT_DOMAIN_NAME'), + help='Defaults to env[OS_PROJECT_DOMAIN_NAME].') + + parser.add_argument( + '--os-cert', + metavar='', + default=utils.env('OS_CERT'), + help=_("Path of certificate file to use in SSL " + "connection. This file can optionally be " + "prepended with the private key. Defaults " + "to env[OS_CERT].")) + parser.add_argument( '--os-cacert', metavar='', default=env('OS_CACERT', default=None), help=_("Specify a CA bundle file to use in " "verifying a TLS (https) server certificate. " - "Defaults to env[OS_CACERT]")) + "Defaults to env[OS_CACERT].")) + + parser.add_argument( + '--os-key', + metavar='', + default=utils.env('OS_KEY'), + help=_("Path of client key to use in SSL " + "connection. This option is not necessary " + "if your key is prepended to your certificate " + "file. Defaults to env[OS_KEY].")) + + parser.add_argument( + '--os-password', metavar='', + default=utils.env('OS_PASSWORD'), + help=_('Authentication password, defaults to env[OS_PASSWORD].')) + parser.add_argument( + '--os_password', + help=argparse.SUPPRESS) + + parser.add_argument( + '--os-region-name', metavar='', + default=env('OS_REGION_NAME'), + help=_('Authentication region name, defaults to ' + 'env[OS_REGION_NAME].')) + parser.add_argument( + '--os_region_name', + help=argparse.SUPPRESS) + + parser.add_argument( + '--os-token', metavar='', + default=env('OS_TOKEN'), + help=_('Authentication token, defaults to env[OS_TOKEN].')) + parser.add_argument( + '--os_token', + help=argparse.SUPPRESS) + + parser.add_argument( + '--http-timeout', metavar='', + default=env('OS_NETWORK_TIMEOUT', default=None), type=float, + help=_('Timeout in seconds to wait for an HTTP response. Defaults ' + 'to env[OS_NETWORK_TIMEOUT] or None if not specified.')) + + parser.add_argument( + '--os-url', metavar='', + default=env('OS_URL'), + help=_('Defaults to env[OS_URL].')) + parser.add_argument( + '--os_url', + help=argparse.SUPPRESS) parser.add_argument( '--insecure', @@ -280,8 +439,6 @@ class TackerShell(app.App): "not be verified against any certificate authorities. " "This option should be used with caution.")) - return parser - def _bash_completion(self): """Prints all of the commands and options for bash-completion.""" commands = set() @@ -297,6 +454,26 @@ class TackerShell(app.App): options.add(option) print(' '.join(commands | options)) + def _register_extensions(self, version): + for name, module in itertools.chain( + client_extension._discover_via_entry_points()): + self._extend_shell_commands(module, version) + + def _extend_shell_commands(self, module, version): + classes = inspect.getmembers(module, inspect.isclass) + for cls_name, cls in classes: + if (issubclass(cls, client_extension.TackerClientExtension) and + hasattr(cls, 'shell_command')): + cmd = cls.shell_command + if hasattr(cls, 'versions'): + if version not in cls.versions: + continue + try: + self.command_manager.add_command(cmd, cls) + self.commands[version][cmd] = cls + except TypeError: + pass + def run(self, argv): """Equivalent to the main program for the application. @@ -309,7 +486,7 @@ class TackerShell(app.App): help_pos = -1 help_command_pos = -1 for arg in argv: - if arg == 'bash-completion': + if arg == 'bash-completion' and help_command_pos == -1: self._bash_completion() return 0 if arg in self.commands[self.api_version]: @@ -331,27 +508,22 @@ class TackerShell(app.App): self.interactive_mode = not remainder self.initialize_app(remainder) except Exception as err: - if self.options.verbose_level == self.DEBUG_LEVEL: - self.log.exception(unicode(err)) + if self.options.verbose_level >= self.DEBUG_LEVEL: + self.log.exception(err) raise else: - self.log.error(unicode(err)) + self.log.error(err) return 1 - result = 1 if self.interactive_mode: _argv = [sys.argv[0]] sys.argv = _argv - result = self.interact() - else: - result = self.run_subcommand(remainder) - return result + return self.interact() + return self.run_subcommand(remainder) def run_subcommand(self, argv): subcommand = self.command_manager.find_command(argv) cmd_factory, cmd_name, sub_argv = subcommand cmd = cmd_factory(self, self.options) - err = None - result = 1 try: self.prepare_to_run_command(cmd) full_name = (cmd_name @@ -360,29 +532,12 @@ class TackerShell(app.App): ) cmd_parser = cmd.get_parser(full_name) return run_command(cmd, cmd_parser, sub_argv) - except Exception as err: - if self.options.verbose_level == self.DEBUG_LEVEL: - self.log.exception(unicode(err)) - else: - self.log.error(unicode(err)) - try: - self.clean_up(cmd, result, err) - except Exception as err2: - if self.options.verbose_level == self.DEBUG_LEVEL: - self.log.exception(unicode(err2)) - else: - self.log.error(_('Could not clean up: %s'), unicode(err2)) - if self.options.verbose_level == self.DEBUG_LEVEL: + except Exception as e: + if self.options.verbose_level >= self.DEBUG_LEVEL: + self.log.exception("%s", e) raise - else: - try: - self.clean_up(cmd, result, None) - except Exception as err3: - if self.options.verbose_level == self.DEBUG_LEVEL: - self.log.exception(unicode(err3)) - else: - self.log.error(_('Could not clean up: %s'), unicode(err3)) - return result + self.log.error("%s", e) + return 1 def authenticate_user(self): """Make sure the user has provided all of the authentication @@ -394,43 +549,74 @@ class TackerShell(app.App): if not self.options.os_token: raise exc.CommandError( _("You must provide a token via" - " either --os-token or env[OS_TOKEN]")) + " either --os-token or env[OS_TOKEN]" + " when providing a service URL")) if not self.options.os_url: raise exc.CommandError( _("You must provide a service URL via" - " either --os-url or env[OS_URL]")) + " either --os-url or env[OS_URL]" + " when providing a token")) else: # Validate password flow auth + project_info = (self.options.os_tenant_name or + self.options.os_tenant_id or + (self.options.os_project_name and + (self.options.os_project_domain_name or + self.options.os_project_domain_id)) or + self.options.os_project_id) + if (not self.options.os_username - and not self.options.os_user_id): + and not self.options.os_user_id): raise exc.CommandError( _("You must provide a username or user ID via" " --os-username, env[OS_USERNAME] or" - " --os-user_id, env[OS_USER_ID]")) + " --os-user-id, env[OS_USER_ID]")) if not self.options.os_password: - raise exc.CommandError( - _("You must provide a password via" - " either --os-password or env[OS_PASSWORD]")) + # No password, If we've got a tty, try prompting for it + if hasattr(sys.stdin, 'isatty') and sys.stdin.isatty(): + # Check for Ctl-D + try: + self.options.os_password = getpass.getpass( + 'OS Password: ') + except EOFError: + pass + # No password because we didn't have a tty or the + # user Ctl-D when prompted. + if not self.options.os_password: + raise exc.CommandError( + _("You must provide a password via" + " either --os-password or env[OS_PASSWORD]")) - if (not self.options.os_tenant_name - and not self.options.os_tenant_id): + if (not project_info): + # tenent is deprecated in Keystone v3. Use the latest + # terminology instead. raise exc.CommandError( - _("You must provide a tenant_name or tenant_id via" - " --os-tenant-name, env[OS_TENANT_NAME]" - " --os-tenant-id, or via env[OS_TENANT_ID]")) + _("You must provide a project_id or project_name (" + "with project_domain_name or project_domain_id) " + "via " + " --os-project-id (env[OS_PROJECT_ID])" + " --os-project-name (env[OS_PROJECT_NAME])," + " --os-project-domain-id " + "(env[OS_PROJECT_DOMAIN_ID])" + " --os-project-domain-name " + "(env[OS_PROJECT_DOMAIN_NAME])")) if not self.options.os_auth_url: raise exc.CommandError( _("You must provide an auth url via" " either --os-auth-url or via env[OS_AUTH_URL]")) + auth_session = self._get_keystone_session() + auth = auth_session.auth else: # not keystone if not self.options.os_url: raise exc.CommandError( _("You must provide a service URL via" " either --os-url or env[OS_URL]")) + auth_session = None + auth = None self.client_manager = clientmanager.ClientManager( token=self.options.os_token, @@ -444,10 +630,18 @@ class TackerShell(app.App): region_name=self.options.os_region_name, api_version=self.api_version, auth_strategy=self.options.os_auth_strategy, - service_type=self.options.service_type, - endpoint_type=self.options.endpoint_type, + # FIXME (bklei) honor deprecated service_type and + # endpoint type until they are removed + service_type=self.options.os_service_type or + self.options.service_type, + endpoint_type=self.options.os_endpoint_type or self.endpoint_type, insecure=self.options.insecure, ca_cert=self.options.os_cacert, + timeout=self.options.http_timeout, + retries=self.options.retries, + raise_errors=False, + session=auth_session, + auth=auth, log_credentials=True) return @@ -471,11 +665,6 @@ class TackerShell(app.App): if self.interactive_mode or cmd_name != 'help': self.authenticate_user() - def clean_up(self, cmd, result, err): - self.log.debug('clean_up %s', cmd.__class__.__name__) - if err: - self.log.debug(_('Got an error: %s'), unicode(err)) - def configure_logging(self): """Create logging handlers for any log output.""" root_logger = logging.getLogger('') @@ -489,24 +678,118 @@ class TackerShell(app.App): self.INFO_LEVEL: logging.INFO, self.DEBUG_LEVEL: logging.DEBUG, }.get(self.options.verbose_level, logging.DEBUG) - console.setLevel(console_level) + # The default log level is INFO, in this situation, set the + # log level of the console to WARNING, to avoid displaying + # useless messages. This equals using "--quiet" + if console_level == logging.INFO: + console.setLevel(logging.WARNING) + else: + console.setLevel(console_level) if logging.DEBUG == console_level: formatter = logging.Formatter(self.DEBUG_MESSAGE_FORMAT) else: formatter = logging.Formatter(self.CONSOLE_MESSAGE_FORMAT) + logging.getLogger('iso8601.iso8601').setLevel(logging.WARNING) + logging.getLogger('urllib3.connectionpool').setLevel(logging.WARNING) console.setFormatter(formatter) root_logger.addHandler(console) return + def get_v2_auth(self, v2_auth_url): + return v2_auth.Password( + v2_auth_url, + username=self.options.os_username, + password=self.options.os_password, + tenant_id=self.options.os_tenant_id, + tenant_name=self.options.os_tenant_name) + + def get_v3_auth(self, v3_auth_url): + project_id = self.options.os_project_id or self.options.os_tenant_id + project_name = (self.options.os_project_name or + self.options.os_tenant_name) + + return v3_auth.Password( + v3_auth_url, + username=self.options.os_username, + password=self.options.os_password, + user_id=self.options.os_user_id, + user_domain_name=self.options.os_user_domain_name, + user_domain_id=self.options.os_user_domain_id, + project_id=project_id, + project_name=project_name, + project_domain_name=self.options.os_project_domain_name, + project_domain_id=self.options.os_project_domain_id + ) + + def _discover_auth_versions(self, session, auth_url): + # discover the API versions the server is supporting base on the + # given URL + try: + ks_discover = discover.Discover(session=session, auth_url=auth_url) + return (ks_discover.url_for('2.0'), ks_discover.url_for('3.0')) + except ks_exc.ClientException: + # Identity service may not support discover API version. + # Lets try to figure out the API version from the original URL. + url_parts = urlparse.urlparse(auth_url) + (scheme, netloc, path, params, query, fragment) = url_parts + path = path.lower() + if path.startswith('/v3'): + return (None, auth_url) + elif path.startswith('/v2'): + return (auth_url, None) + else: + # not enough information to determine the auth version + msg = _('Unable to determine the Keystone version ' + 'to authenticate with using the given ' + 'auth_url. Identity service may not support API ' + 'version discovery. Please provide a versioned ' + 'auth_url instead.') + raise exc.CommandError(msg) + + def _get_keystone_session(self): + # first create a Keystone session + cacert = self.options.os_cacert or None + cert = self.options.os_cert or None + key = self.options.os_key or None + insecure = self.options.insecure or False + ks_session = session.Session.construct(dict(cacert=cacert, + cert=cert, + key=key, + insecure=insecure)) + # discover the supported keystone versions using the given url + (v2_auth_url, v3_auth_url) = self._discover_auth_versions( + session=ks_session, + auth_url=self.options.os_auth_url) + + # Determine which authentication plugin to use. First inspect the + # auth_url to see the supported version. If both v3 and v2 are + # supported, then use the highest version if possible. + user_domain_name = self.options.os_user_domain_name or None + user_domain_id = self.options.os_user_domain_id or None + project_domain_name = self.options.os_project_domain_name or None + project_domain_id = self.options.os_project_domain_id or None + domain_info = (user_domain_name or user_domain_id or + project_domain_name or project_domain_id) + + if (v2_auth_url and not domain_info) or not v3_auth_url: + ks_session.auth = self.get_v2_auth(v2_auth_url) + else: + ks_session.auth = self.get_v3_auth(v3_auth_url) + + return ks_session + def main(argv=sys.argv[1:]): try: - return TackerShell(TACKER_API_VERSION).run(map(strutils.safe_decode, - argv)) + return TackerShell(TACKER_API_VERSION).run( + list(map(encodeutils.safe_decode, argv))) + except KeyboardInterrupt: + print("... terminating tacker client", file=sys.stderr) + return 130 except exc.TackerClientException: return 1 except Exception as e: - print(unicode(e)) + print(e) return 1 diff --git a/tackerclient/tacker/client.py b/tackerclient/tacker/client.py index ffc7d0b5..9d48ef37 100644 --- a/tackerclient/tacker/client.py +++ b/tackerclient/tacker/client.py @@ -43,10 +43,15 @@ def make_client(instance): region_name=instance._region_name, auth_url=instance._auth_url, endpoint_url=url, + endpoint_type=instance._endpoint_type, token=instance._token, auth_strategy=instance._auth_strategy, insecure=instance._insecure, - ca_cert=instance._ca_cert) + ca_cert=instance._ca_cert, + retries=instance._retries, + raise_errors=instance._raise_errors, + session=instance._session, + auth=instance._auth) return client else: raise exceptions.UnsupportedVersion(_("API version %s is not " diff --git a/tackerclient/v1_0/client.py b/tackerclient/v1_0/client.py index 59871567..6d8e637d 100644 --- a/tackerclient/v1_0/client.py +++ b/tackerclient/v1_0/client.py @@ -1,4 +1,5 @@ # Copyright 2012 OpenStack Foundation. +# Copyright 2015 Hewlett-Packard Development Company, L.P. # All Rights Reserved # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -16,31 +17,29 @@ import logging import time -import urllib import requests import six.moves.urllib.parse as urlparse from tackerclient import client -from tackerclient.common import _ from tackerclient.common import constants from tackerclient.common import exceptions from tackerclient.common import serializer from tackerclient.common import utils +from tackerclient.i18n import _ _logger = logging.getLogger(__name__) def exception_handler_v10(status_code, error_content): - """Exception handler for API v1.0 client + """Exception handler for API v1.0 client. - This routine generates the appropriate - Tacker exception according to the contents of the - response body + This routine generates the appropriate Tacker exception according to + the contents of the response body. - :param status_code: HTTP error status code - :param error_content: deserialized body of error response + :param status_code: HTTP error status code + :param error_content: deserialized body of error response """ error_dict = None if isinstance(error_content, dict): @@ -87,8 +86,7 @@ def exception_handler_v10(status_code, error_content): class APIParamsCall(object): - """A Decorator to add support for format and tenant overriding - and filters + """A Decorator to add support for format and tenant overriding and filters. """ def __init__(self, function): self.function = function @@ -104,7 +102,7 @@ class APIParamsCall(object): return with_params -class Client(object): +class ClientBase(object): """Client for the OpenStack Tacker v1.0 API. :param string username: Username for authentication. (optional) @@ -113,6 +111,8 @@ class Client(object): :param string token: Token for authentication. (optional) :param string tenant_name: Tenant name. (optional) :param string tenant_id: Tenant id. (optional) + :param string auth_strategy: 'keystone' by default, 'noauth' for no + authentication against keystone. (optional) :param string auth_url: Keystone service endpoint for authorization. :param string service_type: Network service type to pull from the keystone catalog (e.g. 'network') (optional) @@ -128,7 +128,17 @@ class Client(object): :param integer timeout: Allows customization of the timeout for client http requests. (optional) :param bool insecure: SSL certificate validation. (optional) + :param bool log_credentials: Allow for logging of passwords or not. + Defaults to False. (optional) :param string ca_cert: SSL CA bundle file to use. (optional) + :param integer retries: How many times idempotent (GET, PUT, DELETE) + requests to Tacker server should be retried if + they fail (default: 0). + :param bool raise_errors: If True then exceptions caused by connection + failure are propagated to the caller. + (default: True) + :param session: Keystone client auth session to use. (optional) + :param auth: Keystone auth plugin to use. (optional) Example:: @@ -143,20 +153,84 @@ class Client(object): """ - extensions_path = "/extensions" - extension_path = "/extensions/%s" - - device_templates_path = '/device-templates' - device_template_path = '/device-templates/%s' - devices_path = '/devices' - device_path = '/devices/%s' - interface_attach_path = '/devices/%s/attach_interface' - interface_detach_path = '/devices/%s/detach_interface' - # API has no way to report plurals, so we have to hard code them + # This variable should be overridden by a child class. EXTED_PLURALS = {} - # 8192 Is the default max URI len for eventlet.wsgi.server - MAX_URI_LEN = 8192 + + def __init__(self, **kwargs): + """Initialize a new client for the Tacker v1.0 API.""" + super(ClientBase, self).__init__() + self.retries = kwargs.pop('retries', 0) + self.raise_errors = kwargs.pop('raise_errors', True) + self.httpclient = client.construct_http_client(**kwargs) + self.version = '1.0' + self.format = 'json' + self.action_prefix = "/v%s" % (self.version) + self.retry_interval = 1 + + def _handle_fault_response(self, status_code, response_body): + # Create exception with HTTP status code and message + _logger.debug("Error message: %s", response_body) + # Add deserialized error message to exception arguments + try: + des_error_body = self.deserialize(response_body, status_code) + except Exception: + # If unable to deserialized body it is probably not a + # Tacker error + des_error_body = {'message': response_body} + # Raise the appropriate exception + exception_handler_v10(status_code, des_error_body) + + def do_request(self, method, action, body=None, headers=None, params=None): + # Add format and tenant_id + action += ".%s" % self.format + action = self.action_prefix + action + if type(params) is dict and params: + params = utils.safe_encode_dict(params) + action += '?' + urlparse.urlencode(params, doseq=1) + + if body: + body = self.serialize(body) + + resp, replybody = self.httpclient.do_request( + action, method, body=body, + content_type=self.content_type()) + + status_code = resp.status_code + if status_code in (requests.codes.ok, + requests.codes.created, + requests.codes.accepted, + requests.codes.no_content): + return self.deserialize(replybody, status_code) + else: + if not replybody: + replybody = resp.reason + self._handle_fault_response(status_code, replybody) + + def get_auth_info(self): + return self.httpclient.get_auth_info() + + def serialize(self, data): + """Serializes a dictionary into either XML or JSON. + + A dictionary with a single key can be passed and it can contain any + structure. + """ + if data is None: + return None + elif type(data) is dict: + return serializer.Serializer( + self.get_attr_metadata()).serialize(data, self.content_type()) + else: + raise Exception(_("Unable to serialize object of type = '%s'") % + type(data)) + + def deserialize(self, data, status_code): + """Deserializes an XML or JSON string into a dictionary.""" + if status_code == 204: + return data + return serializer.Serializer(self.get_attr_metadata()).deserialize( + data, self.content_type())['body'] def get_attr_metadata(self): if self.format == 'json': @@ -168,9 +242,107 @@ class Client(object): ns = dict([(ext['alias'], ext['namespace']) for ext in exts]) self.EXTED_PLURALS.update(constants.PLURALS) return {'plurals': self.EXTED_PLURALS, - 'xmlns': constants.XML_NS_V10, + 'xmlns': constants.XML_NS_V20, constants.EXT_NS: ns} + def content_type(self, _format=None): + """Returns the mime-type for either 'xml' or 'json'. + + Defaults to the currently set format. + """ + _format = _format or self.format + return "application/%s" % (_format) + + def retry_request(self, method, action, body=None, + headers=None, params=None): + """Call do_request with the default retry configuration. + + Only idempotent requests should retry failed connection attempts. + :raises: ConnectionFailed if the maximum # of retries is exceeded + """ + max_attempts = self.retries + 1 + for i in range(max_attempts): + try: + return self.do_request(method, action, body=body, + headers=headers, params=params) + except exceptions.ConnectionFailed: + # Exception has already been logged by do_request() + if i < self.retries: + _logger.debug('Retrying connection to Tacker service') + time.sleep(self.retry_interval) + elif self.raise_errors: + raise + + if self.retries: + msg = (_("Failed to connect to Tacker server after %d attempts") + % max_attempts) + else: + msg = _("Failed to connect Tacker server") + + raise exceptions.ConnectionFailed(reason=msg) + + def delete(self, action, body=None, headers=None, params=None): + return self.retry_request("DELETE", action, body=body, + headers=headers, params=params) + + def get(self, action, body=None, headers=None, params=None): + return self.retry_request("GET", action, body=body, + headers=headers, params=params) + + def post(self, action, body=None, headers=None, params=None): + # Do not retry POST requests to avoid the orphan objects problem. + return self.do_request("POST", action, body=body, + headers=headers, params=params) + + def put(self, action, body=None, headers=None, params=None): + return self.retry_request("PUT", action, body=body, + headers=headers, params=params) + + def list(self, collection, path, retrieve_all=True, **params): + if retrieve_all: + res = [] + for r in self._pagination(collection, path, **params): + res.extend(r[collection]) + return {collection: res} + else: + return self._pagination(collection, path, **params) + + def _pagination(self, collection, path, **params): + if params.get('page_reverse', False): + linkrel = 'previous' + else: + linkrel = 'next' + next = True + while next: + res = self.get(path, params=params) + yield res + next = False + try: + for link in res['%s_links' % collection]: + if link['rel'] == linkrel: + query_str = urlparse.urlparse(link['href']).query + params = urlparse.parse_qs(query_str) + next = True + break + except KeyError: + break + + +class Client(ClientBase): + + extensions_path = "/extensions" + extension_path = "/extensions/%s" + + device_templates_path = '/device-templates' + device_template_path = '/device-templates/%s' + devices_path = '/devices' + device_path = '/devices/%s' + interface_attach_path = '/devices/%s/attach_interface' + interface_detach_path = '/devices/%s/detach_interface' + + # API has no way to report plurals, so we have to hard code them + # EXTED_PLURALS = {} + @APIParamsCall def list_extensions(self, **_params): """Fetch a list of all exts on server side.""" @@ -229,168 +401,3 @@ class Client(object): @APIParamsCall def detach_interface(self, device, body=None): return self.put(self.detach_interface_path % device, body) - - def __init__(self, **kwargs): - """Initialize a new client for the Tacker v1.0 API.""" - super(Client, self).__init__() - self.httpclient = client.HTTPClient(**kwargs) - self.version = '1.0' - self.format = 'json' - self.action_prefix = "/v%s" % (self.version) - self.retries = 0 - self.retry_interval = 1 - - def _handle_fault_response(self, status_code, response_body): - # Create exception with HTTP status code and message - _logger.debug(_("Error message: %s"), response_body) - # Add deserialized error message to exception arguments - try: - des_error_body = self.deserialize(response_body, status_code) - except Exception: - # If unable to deserialized body it is probably not a - # Tacker error - des_error_body = {'message': response_body} - # Raise the appropriate exception - exception_handler_v10(status_code, des_error_body) - - def _check_uri_length(self, action): - uri_len = len(self.httpclient.endpoint_url) + len(action) - if uri_len > self.MAX_URI_LEN: - raise exceptions.RequestURITooLong( - excess=uri_len - self.MAX_URI_LEN) - - def do_request(self, method, action, body=None, headers=None, params=None): - # Add format and tenant_id - action += ".%s" % self.format - action = self.action_prefix + action - if type(params) is dict and params: - params = utils.safe_encode_dict(params) - action += '?' + urllib.urlencode(params, doseq=1) - # Ensure client always has correct uri - do not guesstimate anything - self.httpclient.authenticate_and_fetch_endpoint_url() - self._check_uri_length(action) - - if body: - body = self.serialize(body) - self.httpclient.content_type = self.content_type() - resp, replybody = self.httpclient.do_request(action, method, body=body) - status_code = self.get_status_code(resp) - if status_code in (requests.codes.ok, - requests.codes.created, - requests.codes.accepted, - requests.codes.no_content): - return self.deserialize(replybody, status_code) - else: - if not replybody: - replybody = resp.reason - self._handle_fault_response(status_code, replybody) - - def get_auth_info(self): - return self.httpclient.get_auth_info() - - def get_status_code(self, response): - """Returns the integer status code from the response. - - Either a Webob.Response (used in testing) or requests.Response - is returned. - """ - if hasattr(response, 'status_int'): - return response.status_int - else: - return response.status_code - - def serialize(self, data): - """Serializes a dictionary into either xml or json. - - A dictionary with a single key can be passed and - it can contain any structure. - """ - if data is None: - return None - elif type(data) is dict: - return serializer.Serializer( - self.get_attr_metadata()).serialize(data, self.content_type()) - else: - raise Exception(_("Unable to serialize object of type = '%s'") % - type(data)) - - def deserialize(self, data, status_code): - """Deserializes an xml or json string into a dictionary.""" - if status_code == 204: - return data - return serializer.Serializer(self.get_attr_metadata()).deserialize( - data, self.content_type())['body'] - - def content_type(self, _format=None): - """Returns the mime-type for either 'xml' or 'json'. - - Defaults to the currently set format. - """ - _format = _format or self.format - return "application/%s" % (_format) - - def retry_request(self, method, action, body=None, - headers=None, params=None): - """Call do_request with the default retry configuration. - - Only idempotent requests should retry failed connection attempts. - :raises: ConnectionFailed if the maximum # of retries is exceeded - """ - max_attempts = self.retries + 1 - for i in range(max_attempts): - try: - return self.do_request(method, action, body=body, - headers=headers, params=params) - except exceptions.ConnectionFailed: - # Exception has already been logged by do_request() - if i < self.retries: - _logger.debug(_('Retrying connection to Tacker service')) - time.sleep(self.retry_interval) - - raise exceptions.ConnectionFailed(reason=_("Maximum attempts reached")) - - def delete(self, action, body=None, headers=None, params=None): - return self.retry_request("DELETE", action, body=body, - headers=headers, params=params) - - def get(self, action, body=None, headers=None, params=None): - return self.retry_request("GET", action, body=body, - headers=headers, params=params) - - def post(self, action, body=None, headers=None, params=None): - # Do not retry POST requests to avoid the orphan objects problem. - return self.do_request("POST", action, body=body, - headers=headers, params=params) - - def put(self, action, body=None, headers=None, params=None): - return self.retry_request("PUT", action, body=body, - headers=headers, params=params) - - def list(self, collection, path, retrieve_all=True, **params): - if retrieve_all: - res = [] - for r in self._pagination(collection, path, **params): - res.extend(r[collection]) - return {collection: res} - else: - return self._pagination(collection, path, **params) - - def _pagination(self, collection, path, **params): - if params.get('page_reverse', False): - linkrel = 'previous' - else: - linkrel = 'next' - next = True - while next: - res = self.get(path, params=params) - yield res - next = False - try: - for link in res['%s_links' % collection]: - if link['rel'] == linkrel: - query_str = urlparse.urlparse(link['href']).query - params = urlparse.parse_qs(query_str) - next = True - break - except KeyError: - break