diff options
24 files changed, 879 insertions, 296 deletions
@@ -8,6 +8,7 @@ cover *.pyc AUTHORS ChangeLog +doc/build build dist cinderclient/versioninfo diff --git a/cinderclient/base.py b/cinderclient/base.py index 280286c..5274f24 100644 --- a/cinderclient/base.py +++ b/cinderclient/base.py @@ -26,9 +26,13 @@ import os import six from cinderclient import exceptions +from cinderclient.openstack.common.apiclient import base as common_base from cinderclient import utils +Resource = common_base.Resource + + # Python 2.4 compat try: all @@ -215,88 +219,3 @@ class ManagerWithFind(six.with_metaclass(abc.ABCMeta, Manager)): continue return found - - -class Resource(object): - """ - A resource represents a particular instance of an object (server, flavor, - etc). This is pretty much just a bag for attributes. - - :param manager: Manager object - :param info: dictionary representing resource attributes - :param loaded: prevent lazy-loading if set to True - """ - HUMAN_ID = False - - def __init__(self, manager, info, loaded=False): - self.manager = manager - self._info = info - self._add_details(info) - self._loaded = loaded - - # NOTE(sirp): ensure `id` is already present because if it isn't we'll - # enter an infinite loop of __getattr__ -> get -> __init__ -> - # __getattr__ -> ... - if 'id' in self.__dict__ and len(str(self.id)) == 36: - self.manager.write_to_completion_cache('uuid', self.id) - - human_id = self.human_id - if human_id: - self.manager.write_to_completion_cache('human_id', human_id) - - @property - def human_id(self): - """Subclasses may override this provide a pretty ID which can be used - for bash completion. - """ - if 'name' in self.__dict__ and self.HUMAN_ID: - return utils.slugify(self.name) - return None - - def _add_details(self, info): - for (k, v) in six.iteritems(info): - try: - setattr(self, k, v) - except AttributeError: - # In this case we already defined the attribute on the class - pass - - def __getattr__(self, k): - if k not in self.__dict__: - #NOTE(bcwaldon): disallow lazy-loading if already loaded once - if not self.is_loaded(): - self.get() - return self.__getattr__(k) - - raise AttributeError(k) - else: - return self.__dict__[k] - - def __repr__(self): - reprkeys = sorted(k for k in self.__dict__ if k[0] != '_' - and k != 'manager') - info = ", ".join("%s=%s" % (k, getattr(self, k)) for k in reprkeys) - return "<%s %s>" % (self.__class__.__name__, info) - - def get(self): - # set_loaded() first ... so if we have to bail, we know we tried. - self.set_loaded(True) - if not hasattr(self.manager, 'get'): - return - - new = self.manager.get(self.id) - if new: - self._add_details(new._info) - - def __eq__(self, other): - if not isinstance(other, self.__class__): - return False - if hasattr(self, 'id') and hasattr(other, 'id'): - return self.id == other.id - return self._info == other._info - - def is_loaded(self): - return self._loaded - - def set_loaded(self, val): - self._loaded = val diff --git a/cinderclient/client.py b/cinderclient/client.py index 71d2a42..d1dbe18 100644 --- a/cinderclient/client.py +++ b/cinderclient/client.py @@ -23,6 +23,7 @@ from __future__ import print_function import logging from cinderclient import exceptions +from cinderclient.openstack.common import strutils from cinderclient import utils from keystoneclient import access @@ -212,12 +213,6 @@ class HTTPClient(CinderClientMixin): self.auth_plugin = auth_plugin self._logger = logging.getLogger(__name__) - if self.http_log_debug and not self._logger.handlers: - ch = logging.StreamHandler() - self._logger.setLevel(logging.DEBUG) - self._logger.addHandler(ch) - if hasattr(requests, 'logging'): - requests.logging.getLogger(requests.__name__).addHandler(ch) def http_log_req(self, args, kwargs): if not self.http_log_debug: @@ -235,7 +230,11 @@ class HTTPClient(CinderClientMixin): string_parts.append(header) if 'data' in kwargs: - string_parts.append(" -d '%s'" % (kwargs['data'])) + if "password" in kwargs['data']: + data = strutils.mask_password(kwargs['data']) + else: + data = kwargs['data'] + string_parts.append(" -d '%s'" % (data)) self._logger.debug("\nREQ: %s\n" % "".join(string_parts)) def http_log_resp(self, resp): @@ -315,10 +314,10 @@ class HTTPClient(CinderClientMixin): else: raise except requests.exceptions.ConnectionError as e: - # Catch a connection refused from requests.request - self._logger.debug("Connection refused: %s" % e) - msg = 'Unable to establish connection: %s' % e - raise exceptions.ConnectionError(msg) + self._logger.debug("Connection error: %s" % e) + if attempts > self.retries: + msg = 'Unable to establish connection: %s' % e + raise exceptions.ConnectionError(msg) self._logger.debug( "Failed attempt(%s of %s), retrying in %s seconds" % (attempts, self.retries, backoff)) diff --git a/cinderclient/openstack/common/__init__.py b/cinderclient/openstack/common/__init__.py index e69de29..d1223ea 100644 --- a/cinderclient/openstack/common/__init__.py +++ b/cinderclient/openstack/common/__init__.py @@ -0,0 +1,17 @@ +# +# 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 six + + +six.add_move(six.MovedModule('mox', 'mox', 'mox3.mox')) diff --git a/cinderclient/openstack/common/gettextutils.py b/cinderclient/openstack/common/gettextutils.py index 240ac05..1516be1 100644 --- a/cinderclient/openstack/common/gettextutils.py +++ b/cinderclient/openstack/common/gettextutils.py @@ -19,7 +19,7 @@ gettext for openstack-common modules. Usual usage in an openstack.common module: - from cinderclient.openstack.common.gettextutils import _ + from openstack.common.gettextutils import _ """ import copy @@ -27,18 +27,119 @@ import gettext import locale from logging import handlers import os -import re from babel import localedata import six -_localedir = os.environ.get('cinderclient'.upper() + '_LOCALEDIR') -_t = gettext.translation('cinderclient', localedir=_localedir, fallback=True) - _AVAILABLE_LANGUAGES = {} + +# FIXME(dhellmann): Remove this when moving to oslo.i18n. USE_LAZY = False +class TranslatorFactory(object): + """Create translator functions + """ + + def __init__(self, domain, localedir=None): + """Establish a set of translation functions for the domain. + + :param domain: Name of translation domain, + specifying a message catalog. + :type domain: str + :param lazy: Delays translation until a message is emitted. + Defaults to False. + :type lazy: Boolean + :param localedir: Directory with translation catalogs. + :type localedir: str + """ + self.domain = domain + if localedir is None: + localedir = os.environ.get(domain.upper() + '_LOCALEDIR') + self.localedir = localedir + + def _make_translation_func(self, domain=None): + """Return a new translation function ready for use. + + Takes into account whether or not lazy translation is being + done. + + The domain can be specified to override the default from the + factory, but the localedir from the factory is always used + because we assume the log-level translation catalogs are + installed in the same directory as the main application + catalog. + + """ + if domain is None: + domain = self.domain + t = gettext.translation(domain, + localedir=self.localedir, + fallback=True) + # Use the appropriate method of the translation object based + # on the python version. + m = t.gettext if six.PY3 else t.ugettext + + def f(msg): + """oslo.i18n.gettextutils translation function.""" + if USE_LAZY: + return Message(msg, domain=domain) + return m(msg) + return f + + @property + def primary(self): + "The default translation function." + return self._make_translation_func() + + def _make_log_translation_func(self, level): + return self._make_translation_func(self.domain + '-log-' + level) + + @property + def log_info(self): + "Translate info-level log messages." + return self._make_log_translation_func('info') + + @property + def log_warning(self): + "Translate warning-level log messages." + return self._make_log_translation_func('warning') + + @property + def log_error(self): + "Translate error-level log messages." + return self._make_log_translation_func('error') + + @property + def log_critical(self): + "Translate critical-level log messages." + return self._make_log_translation_func('critical') + + +# NOTE(dhellmann): When this module moves out of the incubator into +# oslo.i18n, these global variables can be moved to an integration +# module within each application. + +# Create the global translation functions. +_translators = TranslatorFactory('cinderclient') + +# 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 + +# NOTE(dhellmann): End of globals that will move to the application's +# integration module. + + def enable_lazy(): """Convenience function for configuring _() to use lazy gettext @@ -51,16 +152,7 @@ def enable_lazy(): USE_LAZY = True -def _(msg): - if USE_LAZY: - return Message(msg, domain='cinderclient') - else: - if six.PY3: - return _t.gettext(msg) - return _t.ugettext(msg) - - -def install(domain, lazy=False): +def install(domain): """Install a _() function using the given translation domain. Given a translation domain, install a _() function using gettext's @@ -71,43 +163,14 @@ def install(domain, lazy=False): a translation-domain-specific environment variable (e.g. NOVA_LOCALEDIR). + Note that to enable lazy translation, enable_lazy must be + called. + :param domain: the translation domain - :param lazy: indicates whether or not to install the lazy _() function. - The lazy _() introduces a way to do deferred translation - of messages by installing a _ that builds Message objects, - instead of strings, which can then be lazily translated into - any available locale. """ - if lazy: - # NOTE(mrodden): Lazy gettext functionality. - # - # The following introduces a deferred way to do translations on - # messages in OpenStack. We override the standard _() function - # and % (format string) operation to build Message objects that can - # later be translated when we have more information. - def _lazy_gettext(msg): - """Create and return a Message object. - - Lazy gettext function for a given domain, it is a factory method - for a project/module to get a lazy gettext function for its own - translation domain (i.e. nova, glance, cinder, etc.) - - Message encapsulates a string so that we can translate - it later when needed. - """ - return Message(msg, domain=domain) - - from six import moves - moves.builtins.__dict__['_'] = _lazy_gettext - else: - localedir = '%s_LOCALEDIR' % domain.upper() - if six.PY3: - gettext.install(domain, - localedir=os.environ.get(localedir)) - else: - gettext.install(domain, - localedir=os.environ.get(localedir), - unicode=True) + from six import moves + tf = TranslatorFactory(domain) + moves.builtins.__dict__['_'] = tf.primary class Message(six.text_type): @@ -214,47 +277,22 @@ class Message(six.text_type): if other is None: params = (other,) elif isinstance(other, dict): - params = self._trim_dictionary_parameters(other) - else: - params = self._copy_param(other) - return params - - def _trim_dictionary_parameters(self, dict_param): - """Return a dict that only has matching entries in the msgid.""" - # NOTE(luisg): Here we trim down the dictionary passed as parameters - # to avoid carrying a lot of unnecessary weight around in the message - # object, for example if someone passes in Message() % locals() but - # only some params are used, and additionally we prevent errors for - # non-deepcopyable objects by unicoding() them. - - # Look for %(param) keys in msgid; - # Skip %% and deal with the case where % is first character on the line - keys = re.findall('(?:[^%]|^)?%\((\w*)\)[a-z]', self.msgid) - - # If we don't find any %(param) keys but have a %s - if not keys and re.findall('(?:[^%]|^)%[a-z]', self.msgid): - # Apparently the full dictionary is the parameter - params = self._copy_param(dict_param) - else: + # Merge the dictionaries + # Copy each item in case one does not support deep copy. params = {} - # Save our existing parameters as defaults to protect - # ourselves from losing values if we are called through an - # (erroneous) chain that builds a valid Message with - # arguments, and then does something like "msg % kwds" - # where kwds is an empty dictionary. - src = {} if isinstance(self.params, dict): - src.update(self.params) - src.update(dict_param) - for key in keys: - params[key] = self._copy_param(src[key]) - + for key, val in self.params.items(): + params[key] = self._copy_param(val) + for key, val in other.items(): + params[key] = self._copy_param(val) + else: + params = self._copy_param(other) return params def _copy_param(self, param): try: return copy.deepcopy(param) - except TypeError: + except Exception: # Fallback to casting to unicode this will handle the # python code-like objects that can't be deep-copied return six.text_type(param) @@ -266,13 +304,14 @@ class Message(six.text_type): def __radd__(self, other): return self.__add__(other) - def __str__(self): - # NOTE(luisg): Logging in python 2.6 tries to str() log records, - # and it expects specifically a UnicodeError in order to proceed. - msg = _('Message objects do not support str() because they may ' - 'contain non-ascii characters. ' - 'Please use unicode() or translate() instead.') - raise UnicodeError(msg) + if six.PY2: + def __str__(self): + # NOTE(luisg): Logging in python 2.6 tries to str() log records, + # and it expects specifically a UnicodeError in order to proceed. + msg = _('Message objects do not support str() because they may ' + 'contain non-ascii characters. ' + 'Please use unicode() or translate() instead.') + raise UnicodeError(msg) def get_available_languages(domain): @@ -315,8 +354,8 @@ def get_available_languages(domain): 'zh_Hant_HK': 'zh_HK', 'zh_Hant': 'zh_TW', 'fil': 'tl_PH'} - for (locale, alias) in six.iteritems(aliases): - if locale in language_list and alias not in language_list: + for (locale_, alias) in six.iteritems(aliases): + if locale_ in language_list and alias not in language_list: language_list.append(alias) _AVAILABLE_LANGUAGES[domain] = language_list diff --git a/cinderclient/openstack/common/strutils.py b/cinderclient/openstack/common/strutils.py index ee71db0..dcccf61 100644 --- a/cinderclient/openstack/common/strutils.py +++ b/cinderclient/openstack/common/strutils.py @@ -17,6 +17,7 @@ System-level utilities and helper functions. """ +import math import re import sys import unicodedata @@ -26,16 +27,21 @@ import six from cinderclient.openstack.common.gettextutils import _ -# Used for looking up extensions of text -# to their 'multiplied' byte amount -BYTE_MULTIPLIERS = { - '': 1, - 't': 1024 ** 4, - 'g': 1024 ** 3, - 'm': 1024 ** 2, - 'k': 1024, +UNIT_PREFIX_EXPONENT = { + 'k': 1, + 'K': 1, + 'Ki': 1, + 'M': 2, + 'Mi': 2, + 'G': 3, + 'Gi': 3, + 'T': 4, + 'Ti': 4, +} +UNIT_SYSTEM_INFO = { + 'IEC': (1024, re.compile(r'(^[-+]?\d*\.?\d+)([KMGT]i?)?(b|bit|B)$')), + 'SI': (1000, re.compile(r'(^[-+]?\d*\.?\d+)([kMGT])?(b|bit|B)$')), } -BYTE_REGEX = re.compile(r'(^-?\d+)(\D*)') TRUE_STRINGS = ('1', 't', 'true', 'on', 'y', 'yes') FALSE_STRINGS = ('0', 'f', 'false', 'off', 'n', 'no') @@ -44,6 +50,28 @@ SLUGIFY_STRIP_RE = re.compile(r"[^\w\s-]") SLUGIFY_HYPHENATE_RE = re.compile(r"[-\s]+") +# NOTE(flaper87): The following 3 globals are used by `mask_password` +_SANITIZE_KEYS = ['adminPass', 'admin_pass', 'password', 'admin_password'] + +# NOTE(ldbragst): Let's build a list of regex objects using the list of +# _SANITIZE_KEYS we already have. This way, we only have to add the new key +# to the list of _SANITIZE_KEYS and we can generate regular expressions +# for XML and JSON automatically. +_SANITIZE_PATTERNS = [] +_FORMAT_PATTERNS = [r'(%(key)s\s*[=]\s*[\"\']).*?([\"\'])', + r'(<%(key)s>).*?(</%(key)s>)', + r'([\"\']%(key)s[\"\']\s*:\s*[\"\']).*?([\"\'])', + r'([\'"].*?%(key)s[\'"]\s*:\s*u?[\'"]).*?([\'"])', + r'([\'"].*?%(key)s[\'"]\s*,\s*\'--?[A-z]+\'\s*,\s*u?[\'"])' + '.*?([\'"])', + r'(%(key)s\s*--?[A-z]+\s*)\S+(\s*)'] + +for key in _SANITIZE_KEYS: + for pattern in _FORMAT_PATTERNS: + reg_ex = re.compile(pattern % {'key': key}, re.DOTALL) + _SANITIZE_PATTERNS.append(reg_ex) + + def int_from_bool_as_string(subject): """Interpret a string as a boolean and return either 1 or 0. @@ -72,7 +100,7 @@ def bool_from_string(subject, strict=False, default=False): Strings yielding False are 'f', 'false', 'off', 'n', 'no', or '0'. """ if not isinstance(subject, six.string_types): - subject = str(subject) + subject = six.text_type(subject) lowered = subject.strip().lower() @@ -92,7 +120,8 @@ def bool_from_string(subject, strict=False, default=False): def safe_decode(text, incoming=None, errors='strict'): - """Decodes incoming str using `incoming` if they're not already unicode. + """Decodes incoming text/bytes string using `incoming` if they're not + already unicode. :param incoming: Text's current encoding :param errors: Errors handling policy. See here for valid @@ -101,7 +130,7 @@ def safe_decode(text, incoming=None, errors='strict'): representation of it. :raises TypeError: If text is not an instance of str """ - if not isinstance(text, six.string_types): + if not isinstance(text, (six.string_types, six.binary_type)): raise TypeError("%s can't be decoded" % type(text)) if isinstance(text, six.text_type): @@ -131,7 +160,7 @@ def safe_decode(text, incoming=None, errors='strict'): def safe_encode(text, incoming=None, encoding='utf-8', errors='strict'): - """Encodes incoming str/unicode using `encoding`. + """Encodes incoming text/bytes string using `encoding`. If incoming is not specified, text is expected to be encoded with current python's default encoding. (`sys.getdefaultencoding`) @@ -144,7 +173,7 @@ def safe_encode(text, incoming=None, representation of it. :raises TypeError: If text is not an instance of str """ - if not isinstance(text, six.string_types): + if not isinstance(text, (six.string_types, six.binary_type)): raise TypeError("%s can't be encoded" % type(text)) if not incoming: @@ -152,49 +181,59 @@ def safe_encode(text, incoming=None, sys.getdefaultencoding()) if isinstance(text, six.text_type): - if six.PY3: - return text.encode(encoding, errors).decode(incoming) - else: - return text.encode(encoding, errors) + return text.encode(encoding, errors) elif text and encoding != incoming: # Decode text before encoding it with `encoding` text = safe_decode(text, incoming, errors) - if six.PY3: - return text.encode(encoding, errors).decode(incoming) - else: - return text.encode(encoding, errors) + return text.encode(encoding, errors) + else: + return text + + +def string_to_bytes(text, unit_system='IEC', return_int=False): + """Converts a string into an float representation of bytes. - return text + The units supported for IEC :: + Kb(it), Kib(it), Mb(it), Mib(it), Gb(it), Gib(it), Tb(it), Tib(it) + KB, KiB, MB, MiB, GB, GiB, TB, TiB -def to_bytes(text, default=0): - """Converts a string into an integer of bytes. + The units supported for SI :: - Looks at the last characters of the text to determine - what conversion is needed to turn the input text into a byte number. - Supports "B, K(B), M(B), G(B), and T(B)". (case insensitive) + kb(it), Mb(it), Gb(it), Tb(it) + kB, MB, GB, TB + + Note that the SI unit system does not support capital letter 'K' :param text: String input for bytes size conversion. - :param default: Default return value when text is blank. + :param unit_system: Unit system for byte size conversion. + :param return_int: If True, returns integer representation of text + in bytes. (default: decimal) + :returns: Numerical representation of text in bytes. + :raises ValueError: If text has an invalid value. """ - match = BYTE_REGEX.search(text) + try: + base, reg_ex = UNIT_SYSTEM_INFO[unit_system] + except KeyError: + msg = _('Invalid unit system: "%s"') % unit_system + raise ValueError(msg) + match = reg_ex.match(text) if match: - magnitude = int(match.group(1)) - mult_key_org = match.group(2) - if not mult_key_org: - return magnitude - elif text: + magnitude = float(match.group(1)) + unit_prefix = match.group(2) + if match.group(3) in ['b', 'bit']: + magnitude /= 8 + else: msg = _('Invalid string format: %s') % text - raise TypeError(msg) + raise ValueError(msg) + if not unit_prefix: + res = magnitude else: - return default - mult_key = mult_key_org.lower().replace('b', '', 1) - multiplier = BYTE_MULTIPLIERS.get(mult_key) - if multiplier is None: - msg = _('Unknown byte multiplier: %s') % mult_key_org - raise TypeError(msg) - return magnitude * multiplier + res = magnitude * pow(base, UNIT_PREFIX_EXPONENT[unit_prefix]) + if return_int: + return int(math.ceil(res)) + return res def to_slug(value, incoming=None, errors="strict"): @@ -220,3 +259,37 @@ def to_slug(value, incoming=None, errors="strict"): "ascii", "ignore").decode("ascii") value = SLUGIFY_STRIP_RE.sub("", value).strip().lower() return SLUGIFY_HYPHENATE_RE.sub("-", value) + + +def mask_password(message, secret="***"): + """Replace password with 'secret' in message. + + :param message: The string which includes security information. + :param secret: value with which to replace passwords. + :returns: The unicode value of message with the password fields masked. + + For example: + + >>> mask_password("'adminPass' : 'aaaaa'") + "'adminPass' : '***'" + >>> mask_password("'admin_pass' : 'aaaaa'") + "'admin_pass' : '***'" + >>> mask_password('"password" : "aaaaa"') + '"password" : "***"' + >>> mask_password("'original_password' : 'aaaaa'") + "'original_password' : '***'" + >>> mask_password("u'original_password' : u'aaaaa'") + "u'original_password' : u'***'" + """ + message = six.text_type(message) + + # NOTE(ldbragst): Check to see if anything in message contains any key + # specified in _SANITIZE_KEYS, if not then just return the message since + # we don't have to mask any passwords. + if not any(key in message for key in _SANITIZE_KEYS): + return message + + secret = r'\g<1>' + secret + r'\g<2>' + for pattern in _SANITIZE_PATTERNS: + message = re.sub(pattern, secret, message) + return message diff --git a/cinderclient/shell.py b/cinderclient/shell.py index af166a0..83242f3 100644 --- a/cinderclient/shell.py +++ b/cinderclient/shell.py @@ -29,6 +29,8 @@ import os import pkgutil import sys +import requests + from cinderclient import client from cinderclient import exceptions as exc from cinderclient import utils @@ -478,6 +480,13 @@ class OpenStackCinderShell(object): logger.setLevel(logging.WARNING) logger.addHandler(streamhandler) + client_logger = logging.getLogger(client.__name__) + ch = logging.StreamHandler() + client_logger.setLevel(logging.DEBUG) + client_logger.addHandler(ch) + if hasattr(requests, 'logging'): + requests.logging.getLogger(requests.__name__).addHandler(ch) + def main(self, argv): # Parse args once to find version and debug settings diff --git a/cinderclient/tests/fakes.py b/cinderclient/tests/fakes.py index 5a3937c..eede694 100644 --- a/cinderclient/tests/fakes.py +++ b/cinderclient/tests/fakes.py @@ -34,7 +34,22 @@ def assert_has_keys(dict, required=[], optional=[]): class FakeClient(object): - def assert_called(self, method, url, body=None, pos=-1, **kwargs): + def _dict_match(self, partial, real): + + result = True + try: + for key, value in partial.items(): + if type(value) is dict: + result = self._dict_match(value, real[key]) + else: + assert real[key] == value + result = True + except (AssertionError, KeyError): + result = False + return result + + def assert_called(self, method, url, body=None, + partial_body=None, pos=-1, **kwargs): """ Assert than an API method was just called. """ @@ -50,7 +65,17 @@ class FakeClient(object): if body is not None: assert self.client.callstack[pos][2] == body - def assert_called_anytime(self, method, url, body=None): + if partial_body is not None: + try: + assert self._dict_match(partial_body, + self.client.callstack[pos][2]) + except AssertionError: + print(self.client.callstack[pos][2]) + print("does not contain") + print(partial_body) + raise + + def assert_called_anytime(self, method, url, body=None, partial_body=None): """ Assert than an API method was called anytime in the test. """ @@ -77,6 +102,15 @@ class FakeClient(object): print(body) raise + if partial_body is not None: + try: + assert self._dict_match(partial_body, entry[2]) + except AssertionError: + print(entry[2]) + print("does not contain") + print(partial_body) + raise + def clear_callstack(self): self.client.callstack = [] diff --git a/cinderclient/tests/test_client.py b/cinderclient/tests/test_client.py index 47c4c69..f81cf3d 100644 --- a/cinderclient/tests/test_client.py +++ b/cinderclient/tests/test_client.py @@ -11,6 +11,9 @@ # See the License for the specific language governing permissions and # limitations under the License. +import logging + +import fixtures import cinderclient.client import cinderclient.v1.client @@ -31,3 +34,30 @@ class ClientTest(utils.TestCase): def test_get_client_class_unknown(self): self.assertRaises(cinderclient.exceptions.UnsupportedVersion, cinderclient.client.get_client_class, '0') + + def test_log_req(self): + self.logger = self.useFixture( + fixtures.FakeLogger( + format="%(message)s", + level=logging.DEBUG, + nuke_handlers=True + ) + ) + + kwargs = {} + kwargs['headers'] = {"X-Foo": "bar"} + kwargs['data'] = ('{"auth": {"tenantName": "fakeService",' + ' "passwordCredentials": {"username": "fakeUser",' + ' "password": "fakePassword"}}}') + + cs = cinderclient.client.HTTPClient("user", None, None, + "http://127.0.0.1:5000") + cs.http_log_debug = True + cs.http_log_req('PUT', kwargs) + + output = self.logger.output.split('\n') + + print("JSBRYANT: output is", output) + + self.assertNotIn("fakePassword", output[1]) + self.assertIn("fakeUser", output[1]) diff --git a/cinderclient/tests/test_http.py b/cinderclient/tests/test_http.py index 18eafeb..8d162be 100644 --- a/cinderclient/tests/test_http.py +++ b/cinderclient/tests/test_http.py @@ -51,6 +51,9 @@ bad_500_response = utils.TestResponse({ }) bad_500_request = mock.Mock(return_value=(bad_500_response)) +connection_error_request = mock.Mock( + side_effect=requests.exceptions.ConnectionError) + def get_client(retries=0): cl = client.HTTPClient("username", "password", @@ -127,6 +130,23 @@ class ClientTest(utils.TestCase): test_get_call() self.assertEqual(self.requests, []) + def test_get_retry_connection_error(self): + cl = get_authed_client(retries=1) + + self.requests = [connection_error_request, mock_request] + + def request(*args, **kwargs): + next_request = self.requests.pop(0) + return next_request(*args, **kwargs) + + @mock.patch.object(requests, "request", request) + @mock.patch('time.time', mock.Mock(return_value=1234)) + def test_get_call(): + resp, body = cl.get("/hi") + + test_get_call() + self.assertEqual(self.requests, []) + def test_retry_limit(self): cl = get_authed_client(retries=1) diff --git a/cinderclient/tests/v1/test_limits.py b/cinderclient/tests/v1/test_limits.py index b4520e3..06fd178 100644 --- a/cinderclient/tests/v1/test_limits.py +++ b/cinderclient/tests/v1/test_limits.py @@ -77,49 +77,49 @@ class TestLimits(utils.TestCase): l2 = limits.RateLimit("verb2", "uri2", "regex2", "value2", "remain2", "unit2", "next2") for item in l.rate: - self.assertTrue(item in [l1, l2]) + self.assertIn(item, [l1, l2]) class TestRateLimit(utils.TestCase): def test_equal(self): l1 = _get_default_RateLimit() l2 = _get_default_RateLimit() - self.assertTrue(l1 == l2) + self.assertEqual(l1, l2) def test_not_equal_verbs(self): l1 = _get_default_RateLimit() l2 = _get_default_RateLimit(verb="verb2") - self.assertFalse(l1 == l2) + self.assertNotEqual(l1, l2) def test_not_equal_uris(self): l1 = _get_default_RateLimit() l2 = _get_default_RateLimit(uri="uri2") - self.assertFalse(l1 == l2) + self.assertNotEqual(l1, l2) def test_not_equal_regexps(self): l1 = _get_default_RateLimit() l2 = _get_default_RateLimit(regex="regex2") - self.assertFalse(l1 == l2) + self.assertNotEqual(l1, l2) def test_not_equal_values(self): l1 = _get_default_RateLimit() l2 = _get_default_RateLimit(value="value2") - self.assertFalse(l1 == l2) + self.assertNotEqual(l1, l2) def test_not_equal_remains(self): l1 = _get_default_RateLimit() l2 = _get_default_RateLimit(remain="remain2") - self.assertFalse(l1 == l2) + self.assertNotEqual(l1, l2) def test_not_equal_units(self): l1 = _get_default_RateLimit() l2 = _get_default_RateLimit(unit="unit2") - self.assertFalse(l1 == l2) + self.assertNotEqual(l1, l2) def test_not_equal_next_available(self): l1 = _get_default_RateLimit() l2 = _get_default_RateLimit(next_available="next2") - self.assertFalse(l1 == l2) + self.assertNotEqual(l1, l2) def test_repr(self): l1 = _get_default_RateLimit() @@ -130,17 +130,17 @@ class TestAbsoluteLimit(utils.TestCase): def test_equal(self): l1 = limits.AbsoluteLimit("name1", "value1") l2 = limits.AbsoluteLimit("name1", "value1") - self.assertTrue(l1 == l2) + self.assertEqual(l1, l2) def test_not_equal_values(self): l1 = limits.AbsoluteLimit("name1", "value1") l2 = limits.AbsoluteLimit("name1", "value2") - self.assertFalse(l1 == l2) + self.assertNotEqual(l1, l2) def test_not_equal_names(self): l1 = limits.AbsoluteLimit("name1", "value1") l2 = limits.AbsoluteLimit("name2", "value1") - self.assertFalse(l1 == l2) + self.assertNotEqual(l1, l2) def test_repr(self): l1 = limits.AbsoluteLimit("name1", "value1") diff --git a/cinderclient/tests/v1/test_services.py b/cinderclient/tests/v1/test_services.py index 6d855d3..1e44a53 100644 --- a/cinderclient/tests/v1/test_services.py +++ b/cinderclient/tests/v1/test_services.py @@ -71,5 +71,5 @@ class ServicesTest(utils.TestCase): values = {"host": "host1", 'binary': 'cinder-volume', "disabled_reason": "disable bad host"} cs.assert_called('PUT', '/os-services/disable-log-reason', values) - self.assertTrue(isinstance(s, services.Service)) + self.assertIsInstance(s, services.Service) self.assertEqual(s.status, 'disabled') diff --git a/cinderclient/tests/v1/test_shell.py b/cinderclient/tests/v1/test_shell.py index 0b9cbe7..96c600c 100644 --- a/cinderclient/tests/v1/test_shell.py +++ b/cinderclient/tests/v1/test_shell.py @@ -18,6 +18,7 @@ import fixtures from cinderclient import client +from cinderclient import exceptions from cinderclient import shell from cinderclient.v1 import shell as shell_v1 from cinderclient.tests.v1 import fakes @@ -95,6 +96,16 @@ class ShellTest(utils.TestCase): args = Arguments(metadata=input[0]) self.assertEqual(shell_v1._extract_metadata(args), input[1]) + def test_translate_volume_keys(self): + cs = fakes.FakeClient() + v = cs.volumes.list()[0] + setattr(v, 'os-vol-tenant-attr:tenant_id', 'fake_tenant') + setattr(v, '_info', {'attachments': [{'server_id': 1234}], + 'id': 1234, 'name': 'sample-volume', + 'os-vol-tenant-attr:tenant_id': 'fake_tenant'}) + shell_v1._translate_volume_keys([v]) + self.assertEqual(v.tenant_id, 'fake_tenant') + @httpretty.activate def test_list(self): self.register_keystone_auth_fixture() @@ -270,6 +281,21 @@ class ShellTest(utils.TestCase): body=expected) @httpretty.activate + def test_reset_state_two_with_one_nonexistent(self): + self.register_keystone_auth_fixture() + cmd = 'reset-state 1234 123456789' + self.assertRaises(exceptions.CommandError, self.run_command, cmd) + expected = {'os-reset_status': {'status': 'available'}} + self.assert_called_anytime('POST', '/volumes/1234/action', + body=expected) + + @httpretty.activate + def test_reset_state_one_with_one_nonexistent(self): + self.register_keystone_auth_fixture() + cmd = 'reset-state 123456789' + self.assertRaises(exceptions.CommandError, self.run_command, cmd) + + @httpretty.activate def test_snapshot_reset_state(self): self.register_keystone_auth_fixture() diff --git a/cinderclient/tests/v2/fakes.py b/cinderclient/tests/v2/fakes.py index 98f3500..f271653 100644 --- a/cinderclient/tests/v2/fakes.py +++ b/cinderclient/tests/v2/fakes.py @@ -370,6 +370,8 @@ class FakeHTTPClient(base_client.HTTPClient): assert 'new_type' in body[action] elif action == 'os-set_bootable': assert list(body[action]) == ['bootable'] + elif action == 'os-unmanage': + assert body[action] is None else: raise AssertionError("Unexpected action: %s" % action) return (resp, {}, _body) @@ -378,7 +380,9 @@ class FakeHTTPClient(base_client.HTTPClient): return self.post_volumes_1234_action(body, **kw) def post_volumes(self, **kw): - return (202, {}, {'volume': {}}) + size = kw['body']['volume'].get('size', 1) + volume = _stub_volume(id='1234', size=size) + return (202, {}, {'volume': volume}) def delete_volumes_1234(self, **kw): return (202, {}, None) @@ -826,3 +830,8 @@ class FakeHTTPClient(base_client.HTTPClient): def put_snapshots_1234_metadata(self, **kw): return (200, {}, {"metadata": {"key1": "val1", "key2": "val2"}}) + + def post_os_volume_manage(self, **kw): + volume = _stub_volume(id='1234') + volume.update(kw['body']['volume']) + return (202, {}, {'volume': volume}) diff --git a/cinderclient/tests/v2/test_limits.py b/cinderclient/tests/v2/test_limits.py index 92b50cd..c41d22c 100644 --- a/cinderclient/tests/v2/test_limits.py +++ b/cinderclient/tests/v2/test_limits.py @@ -77,49 +77,49 @@ class TestLimits(utils.TestCase): l2 = limits.RateLimit("verb2", "uri2", "regex2", "value2", "remain2", "unit2", "next2") for item in l.rate: - self.assertTrue(item in [l1, l2]) + self.assertIn(item, [l1, l2]) class TestRateLimit(utils.TestCase): def test_equal(self): l1 = _get_default_RateLimit() l2 = _get_default_RateLimit() - self.assertTrue(l1 == l2) + self.assertEqual(l1, l2) def test_not_equal_verbs(self): l1 = _get_default_RateLimit() l2 = _get_default_RateLimit(verb="verb2") - self.assertFalse(l1 == l2) + self.assertNotEqual(l1, l2) def test_not_equal_uris(self): l1 = _get_default_RateLimit() l2 = _get_default_RateLimit(uri="uri2") - self.assertFalse(l1 == l2) + self.assertNotEqual(l1, l2) def test_not_equal_regexps(self): l1 = _get_default_RateLimit() l2 = _get_default_RateLimit(regex="regex2") - self.assertFalse(l1 == l2) + self.assertNotEqual(l1, l2) def test_not_equal_values(self): l1 = _get_default_RateLimit() l2 = _get_default_RateLimit(value="value2") - self.assertFalse(l1 == l2) + self.assertNotEqual(l1, l2) def test_not_equal_remains(self): l1 = _get_default_RateLimit() l2 = _get_default_RateLimit(remain="remain2") - self.assertFalse(l1 == l2) + self.assertNotEqual(l1, l2) def test_not_equal_units(self): l1 = _get_default_RateLimit() l2 = _get_default_RateLimit(unit="unit2") - self.assertFalse(l1 == l2) + self.assertNotEqual(l1, l2) def test_not_equal_next_available(self): l1 = _get_default_RateLimit() l2 = _get_default_RateLimit(next_available="next2") - self.assertFalse(l1 == l2) + self.assertNotEqual(l1, l2) def test_repr(self): l1 = _get_default_RateLimit() @@ -130,17 +130,17 @@ class TestAbsoluteLimit(utils.TestCase): def test_equal(self): l1 = limits.AbsoluteLimit("name1", "value1") l2 = limits.AbsoluteLimit("name1", "value1") - self.assertTrue(l1 == l2) + self.assertEqual(l1, l2) def test_not_equal_values(self): l1 = limits.AbsoluteLimit("name1", "value1") l2 = limits.AbsoluteLimit("name1", "value2") - self.assertFalse(l1 == l2) + self.assertNotEqual(l1, l2) def test_not_equal_names(self): l1 = limits.AbsoluteLimit("name1", "value1") l2 = limits.AbsoluteLimit("name2", "value1") - self.assertFalse(l1 == l2) + self.assertNotEqual(l1, l2) def test_repr(self): l1 = limits.AbsoluteLimit("name1", "value1") diff --git a/cinderclient/tests/v2/test_services.py b/cinderclient/tests/v2/test_services.py index 5d624dd..d0fe3d9 100644 --- a/cinderclient/tests/v2/test_services.py +++ b/cinderclient/tests/v2/test_services.py @@ -71,5 +71,5 @@ class ServicesTest(utils.TestCase): values = {"host": "host1", 'binary': 'cinder-volume', "disabled_reason": "disable bad host"} cs.assert_called('PUT', '/os-services/disable-log-reason', values) - self.assertTrue(isinstance(s, services.Service)) + self.assertIsInstance(s, services.Service) self.assertEqual(s.status, 'disabled') diff --git a/cinderclient/tests/v2/test_shell.py b/cinderclient/tests/v2/test_shell.py index a52bd10..a41d0b4 100644 --- a/cinderclient/tests/v2/test_shell.py +++ b/cinderclient/tests/v2/test_shell.py @@ -14,13 +14,14 @@ # under the License. import fixtures +import httpretty from cinderclient import client +from cinderclient import exceptions from cinderclient import shell from cinderclient.tests import utils from cinderclient.tests.v2 import fakes from cinderclient.tests.fixture_data import keystone_client -import httpretty class ShellTest(utils.TestCase): @@ -66,11 +67,15 @@ class ShellTest(utils.TestCase): def run_command(self, cmd): self.shell.main(cmd.split()) - def assert_called(self, method, url, body=None, **kwargs): - return self.shell.cs.assert_called(method, url, body, **kwargs) + def assert_called(self, method, url, body=None, + partial_body=None, **kwargs): + return self.shell.cs.assert_called(method, url, body, + partial_body, **kwargs) - def assert_called_anytime(self, method, url, body=None): - return self.shell.cs.assert_called_anytime(method, url, body) + def assert_called_anytime(self, method, url, body=None, + partial_body=None): + return self.shell.cs.assert_called_anytime(method, url, body, + partial_body) @httpretty.activate def test_list(self): @@ -98,12 +103,65 @@ class ShellTest(utils.TestCase): self.assert_called('GET', '/volumes/detail?all_tenants=1') @httpretty.activate + def test_list_marker(self): + self.register_keystone_auth_fixture() + self.run_command('list --marker=1234') + self.assert_called('GET', '/volumes/detail?marker=1234') + + @httpretty.activate + def test_list_limit(self): + self.register_keystone_auth_fixture() + self.run_command('list --limit=10') + self.assert_called('GET', '/volumes/detail?limit=10') + + @httpretty.activate + def test_list_sort(self): + self.register_keystone_auth_fixture() + self.run_command('list --sort_key=name --sort_dir=asc') + self.assert_called('GET', '/volumes/detail?sort_dir=asc&sort_key=name') + + @httpretty.activate def test_list_availability_zone(self): self.register_keystone_auth_fixture() self.run_command('availability-zone-list') self.assert_called('GET', '/os-availability-zone') @httpretty.activate + def test_create_volume_from_snapshot(self): + self.register_keystone_auth_fixture() + expected = {'volume': {'size': None}} + + expected['volume']['snapshot_id'] = '1234' + self.run_command('create --snapshot-id=1234') + self.assert_called_anytime('POST', '/volumes', partial_body=expected) + self.assert_called('GET', '/volumes/1234') + + expected['volume']['size'] = 2 + self.run_command('create --snapshot-id=1234 2') + self.assert_called_anytime('POST', '/volumes', partial_body=expected) + self.assert_called('GET', '/volumes/1234') + + @httpretty.activate + def test_create_volume_from_volume(self): + self.register_keystone_auth_fixture() + expected = {'volume': {'size': None}} + + expected['volume']['source_volid'] = '1234' + self.run_command('create --source-volid=1234') + self.assert_called_anytime('POST', '/volumes', partial_body=expected) + self.assert_called('GET', '/volumes/1234') + + expected['volume']['size'] = 2 + self.run_command('create --source-volid=1234 2') + self.assert_called_anytime('POST', '/volumes', partial_body=expected) + self.assert_called('GET', '/volumes/1234') + + @httpretty.activate + def test_create_size_required_if_not_snapshot_or_clone(self): + self.register_keystone_auth_fixture() + self.assertRaises(SystemExit, self.run_command, 'create') + + @httpretty.activate def test_show(self): self.register_keystone_auth_fixture() self.run_command('show 1234') @@ -261,6 +319,21 @@ class ShellTest(utils.TestCase): body=expected) @httpretty.activate + def test_reset_state_two_with_one_nonexistent(self): + self.register_keystone_auth_fixture() + cmd = 'reset-state 1234 123456789' + self.assertRaises(exceptions.CommandError, self.run_command, cmd) + expected = {'os-reset_status': {'status': 'available'}} + self.assert_called_anytime('POST', '/volumes/1234/action', + body=expected) + + @httpretty.activate + def test_reset_state_one_with_one_nonexistent(self): + self.register_keystone_auth_fixture() + cmd = 'reset-state 123456789' + self.assertRaises(exceptions.CommandError, self.run_command, cmd) + + @httpretty.activate def test_snapshot_reset_state(self): self.register_keystone_auth_fixture() self.run_command('snapshot-reset-state 1234') @@ -482,3 +555,100 @@ class ShellTest(utils.TestCase): self.run_command('snapshot-delete 5678') self.assert_called('DELETE', '/snapshots/5678') + + @httpretty.activate + def test_volume_manage(self): + self.register_keystone_auth_fixture() + self.run_command('manage host1 key1=val1 key2=val2 ' + '--name foo --description bar ' + '--volume-type baz --availability-zone az ' + '--metadata k1=v1 k2=v2') + expected = {'volume': {'host': 'host1', + 'ref': {'key1': 'val1', 'key2': 'val2'}, + 'name': 'foo', + 'description': 'bar', + 'volume_type': 'baz', + 'availability_zone': 'az', + 'metadata': {'k1': 'v1', 'k2': 'v2'}, + 'bootable': False}} + self.assert_called_anytime('POST', '/os-volume-manage', body=expected) + + @httpretty.activate + def test_volume_manage_bootable(self): + """ + Tests the --bootable option + + If this flag is specified, then the resulting POST should contain + bootable: True. + """ + self.register_keystone_auth_fixture() + self.run_command('manage host1 key1=val1 key2=val2 ' + '--name foo --description bar --bootable ' + '--volume-type baz --availability-zone az ' + '--metadata k1=v1 k2=v2') + expected = {'volume': {'host': 'host1', + 'ref': {'key1': 'val1', 'key2': 'val2'}, + 'name': 'foo', + 'description': 'bar', + 'volume_type': 'baz', + 'availability_zone': 'az', + 'metadata': {'k1': 'v1', 'k2': 'v2'}, + 'bootable': True}} + self.assert_called_anytime('POST', '/os-volume-manage', body=expected) + + @httpretty.activate + def test_volume_manage_source_name(self): + """ + Tests the --source-name option. + + Checks that the --source-name option correctly updates the + ref structure that is passed in the HTTP POST + """ + self.register_keystone_auth_fixture() + self.run_command('manage host1 key1=val1 key2=val2 ' + '--source-name VolName ' + '--name foo --description bar ' + '--volume-type baz --availability-zone az ' + '--metadata k1=v1 k2=v2') + expected = {'volume': {'host': 'host1', + 'ref': {'source-name': 'VolName', + 'key1': 'val1', 'key2': 'val2'}, + 'name': 'foo', + 'description': 'bar', + 'volume_type': 'baz', + 'availability_zone': 'az', + 'metadata': {'k1': 'v1', 'k2': 'v2'}, + 'bootable': False}} + self.assert_called_anytime('POST', '/os-volume-manage', body=expected) + + @httpretty.activate + def test_volume_manage_source_id(self): + """ + Tests the --source-id option. + + Checks that the --source-id option correctly updates the + ref structure that is passed in the HTTP POST + """ + self.register_keystone_auth_fixture() + self.run_command('manage host1 key1=val1 key2=val2 ' + '--source-id 1234 ' + '--name foo --description bar ' + '--volume-type baz --availability-zone az ' + '--metadata k1=v1 k2=v2') + expected = {'volume': {'host': 'host1', + 'ref': {'source-id': '1234', + 'key1': 'val1', 'key2': 'val2'}, + 'name': 'foo', + 'description': 'bar', + 'volume_type': 'baz', + 'availability_zone': 'az', + 'metadata': {'k1': 'v1', 'k2': 'v2'}, + 'bootable': False}} + self.assert_called_anytime('POST', '/os-volume-manage', body=expected) + + @httpretty.activate + def test_volume_unmanage(self): + self.register_keystone_auth_fixture() + self.run_command('unmanage 1234') + self.assert_called('POST', '/volumes/1234/action', + body={'os-unmanage': None}) diff --git a/cinderclient/tests/v2/test_volumes.py b/cinderclient/tests/v2/test_volumes.py index a4c0e90..fa42b87 100644 --- a/cinderclient/tests/v2/test_volumes.py +++ b/cinderclient/tests/v2/test_volumes.py @@ -23,6 +23,22 @@ cs = fakes.FakeClient() class VolumesTest(utils.TestCase): + def test_list_volumes_with_marker_limit(self): + cs.volumes.list(marker=1234, limit=2) + cs.assert_called('GET', '/volumes/detail?limit=2&marker=1234') + + def test_list_volumes_with_sort_key_dir(self): + cs.volumes.list(sort_key='id', sort_dir='asc') + cs.assert_called('GET', '/volumes/detail?sort_dir=asc&sort_key=id') + + def test_list_volumes_with_invalid_sort_key(self): + self.assertRaises(ValueError, + cs.volumes.list, sort_key='invalid', sort_dir='asc') + + def test_list_volumes_with_invalid_sort_dir(self): + self.assertRaises(ValueError, + cs.volumes.list, sort_key='id', sort_dir='invalid') + def test_delete_volume(self): v = cs.volumes.list()[0] v.delete() @@ -139,3 +155,22 @@ class VolumesTest(utils.TestCase): v = cs.volumes.get('1234') cs.volumes.set_bootable(v, True) cs.assert_called('POST', '/volumes/1234/action') + + def test_volume_manage(self): + cs.volumes.manage('host1', {'k': 'v'}) + expected = {'host': 'host1', 'name': None, 'availability_zone': None, + 'description': None, 'metadata': None, 'ref': {'k': 'v'}, + 'volume_type': None, 'bootable': False} + cs.assert_called('POST', '/os-volume-manage', {'volume': expected}) + + def test_volume_manage_bootable(self): + cs.volumes.manage('host1', {'k': 'v'}, bootable=True) + expected = {'host': 'host1', 'name': None, 'availability_zone': None, + 'description': None, 'metadata': None, 'ref': {'k': 'v'}, + 'volume_type': None, 'bootable': True} + cs.assert_called('POST', '/os-volume-manage', {'volume': expected}) + + def test_volume_unmanage(self): + v = cs.volumes.get('1234') + cs.volumes.unmanage(v) + cs.assert_called('POST', '/volumes/1234/action', {'os-unmanage': None}) diff --git a/cinderclient/v1/shell.py b/cinderclient/v1/shell.py index c806327..4526a4e 100644 --- a/cinderclient/v1/shell.py +++ b/cinderclient/v1/shell.py @@ -101,7 +101,8 @@ def _translate_keys(collection, convert): def _translate_volume_keys(collection): - convert = [('displayName', 'display_name'), ('volumeType', 'volume_type')] + convert = [('displayName', 'display_name'), ('volumeType', 'volume_type'), + ('os-vol-tenant-attr:tenant_id', 'tenant_id')] _translate_keys(collection, convert) @@ -179,8 +180,13 @@ def do_list(cs, args): for vol in volumes: servers = [s.get('server_id') for s in vol.attachments] setattr(vol, 'attached_to', ','.join(map(str, servers))) - utils.print_list(volumes, ['ID', 'Status', 'Display Name', - 'Size', 'Volume Type', 'Bootable', 'Attached to']) + if all_tenants: + key_list = ['ID', 'Tenant ID', 'Status', 'Display Name', + 'Size', 'Volume Type', 'Bootable', 'Attached to'] + else: + key_list = ['ID', 'Status', 'Display Name', + 'Size', 'Volume Type', 'Bootable', 'Attached to'] + utils.print_list(volumes, key_list) @utils.arg('volume', metavar='<volume>', help='Volume name or ID.') @@ -332,22 +338,18 @@ def do_force_delete(cs, args): @utils.service_type('volume') def do_reset_state(cs, args): """Explicitly updates the volume state.""" - failure_count = 0 - - single = (len(args.volume) == 1) + failure_flag = False for volume in args.volume: try: utils.find_volume(cs, volume).reset_state(args.state) except Exception as e: - failure_count += 1 + failure_flag = True msg = "Reset state for volume %s failed: %s" % (volume, e) - if not single: - print(msg) + print(msg) - if failure_count == len(args.volume): - if not single: - msg = "Unable to reset the state for any of the specified volumes." + if failure_flag: + msg = "Unable to reset the state for the specified volume(s)." raise exceptions.CommandError(msg) diff --git a/cinderclient/v2/shell.py b/cinderclient/v2/shell.py index 6af0829..6fed443 100644 --- a/cinderclient/v2/shell.py +++ b/cinderclient/v2/shell.py @@ -97,7 +97,8 @@ def _translate_keys(collection, convert): def _translate_volume_keys(collection): - convert = [('volumeType', 'volume_type')] + convert = [('volumeType', 'volume_type'), + ('os-vol-tenant-attr:tenant_id', 'tenant_id')] _translate_keys(collection, convert) @@ -155,6 +156,27 @@ def _extract_metadata(args): help='Filters results by a metadata key and value pair. ' 'OPTIONAL: Default=None.', default=None) +@utils.arg('--marker', + metavar='<marker>', + default=None, + help='Begin returning volumes that appear later in the volume ' + 'list than that represented by this volume id. ' + 'OPTIONAL: Default=None.') +@utils.arg('--limit', + metavar='<limit>', + default=None, + help='Maximum number of volumes to return. OPTIONAL: Default=None.') +@utils.arg('--sort_key', + metavar='<sort_key>', + default=None, + help='Key to be sorted, should be (`id`, `status`, `size`, ' + '`availability_zone`, `name`, `bootable`, `created_at`). ' + 'OPTIONAL: Default=None.') +@utils.arg('--sort_dir', + metavar='<sort_dir>', + default=None, + help='Sort direction, should be `desc` or `asc`. ' + 'OPTIONAL: Default=None.') @utils.service_type('volumev2') def do_list(cs, args): """Lists all volumes.""" @@ -169,7 +191,9 @@ def do_list(cs, args): 'status': args.status, 'metadata': _extract_metadata(args) if args.metadata else None, } - volumes = cs.volumes.list(search_opts=search_opts) + volumes = cs.volumes.list(search_opts=search_opts, marker=args.marker, + limit=args.limit, sort_key=args.sort_key, + sort_dir=args.sort_dir) _translate_volume_keys(volumes) # Create a list of servers to which the volume is attached @@ -177,8 +201,13 @@ def do_list(cs, args): servers = [s.get('server_id') for s in vol.attachments] setattr(vol, 'attached_to', ','.join(map(str, servers))) - utils.print_list(volumes, ['ID', 'Status', 'Name', - 'Size', 'Volume Type', 'Bootable', 'Attached to']) + if all_tenants: + key_list = ['ID', 'Tenant ID', 'Status', 'Name', + 'Size', 'Volume Type', 'Bootable', 'Attached to'] + else: + key_list = ['ID', 'Status', 'Name', + 'Size', 'Volume Type', 'Bootable', 'Attached to'] + utils.print_list(volumes, key_list) @utils.arg('volume', @@ -195,10 +224,21 @@ def do_show(cs, args): utils.print_dict(info) +class CheckSizeArgForCreate(argparse.Action): + def __call__(self, parser, args, values, option_string=None): + if (values or args.snapshot_id or args.source_volid) is None: + parser.error('Size is a required parameter if snapshot ' + 'or source volume is not specified.') + setattr(args, self.dest, values) + + @utils.arg('size', metavar='<size>', + nargs='?', type=int, - help='Size of volume, in GBs.') + action=CheckSizeArgForCreate, + help='Size of volume, in GBs. (Required unless ' + 'snapshot-id/source-volid is specified).') @utils.arg('--snapshot-id', metavar='<snapshot-id>', default=None, @@ -351,22 +391,18 @@ def do_force_delete(cs, args): @utils.service_type('volumev2') def do_reset_state(cs, args): """Explicitly updates the volume state.""" - failure_count = 0 - - single = (len(args.volume) == 1) + failure_flag = False for volume in args.volume: try: utils.find_volume(cs, volume).reset_state(args.state) except Exception as e: - failure_count += 1 + failure_flag = True msg = "Reset state for volume %s failed: %s" % (volume, e) - if not single: - print(msg) + print(msg) - if failure_count == len(args.volume): - if not single: - msg = "Unable to reset the state for any of specified volumes." + if failure_flag: + msg = "Unable to reset the state for the specified volume(s)." raise exceptions.CommandError(msg) @@ -1548,3 +1584,91 @@ def do_set_bootable(cs, args): volume = utils.find_volume(cs, args.volume) cs.volumes.set_bootable(volume, strutils.bool_from_string(args.bootable)) + + +@utils.arg('host', + metavar='<host>', + help='Cinder host on which the existing volume resides') +@utils.arg('ref', + type=str, + nargs='*', + metavar='<key=value>', + help='Driver-specific reference to the existing volume as ' + 'key=value pairs') +@utils.arg('--source-name', + metavar='<source-name>', + help='Name of the volume to manage (Optional)') +@utils.arg('--source-id', + metavar='<source-id>', + help='ID of the volume to manage (Optional)') +@utils.arg('--name', + metavar='<name>', + help='Volume name (Optional, Default=None)') +@utils.arg('--description', + metavar='<description>', + help='Volume description (Optional, Default=None)') +@utils.arg('--volume-type', + metavar='<volume-type>', + help='Volume type (Optional, Default=None)') +@utils.arg('--availability-zone', + metavar='<availability-zone>', + help='Availability zone for volume (Optional, Default=None)') +@utils.arg('--metadata', + type=str, + nargs='*', + metavar='<key=value>', + help='Metadata key=value pairs (Optional, Default=None)') +@utils.arg('--bootable', + action='store_true', + help='Specifies that the newly created volume should be' + ' marked as bootable') +@utils.service_type('volumev2') +def do_manage(cs, args): + """Manage an existing volume.""" + volume_metadata = None + if args.metadata is not None: + volume_metadata = _extract_metadata(args) + + # Build a dictionary of key/value pairs to pass to the API. + ref_dict = {} + for pair in args.ref: + (k, v) = pair.split('=', 1) + ref_dict[k] = v + + # The recommended way to specify an existing volume is by ID or name, and + # have the Cinder driver look for 'source-name' or 'source-id' elements in + # the ref structure. To make things easier for the user, we have special + # --source-name and --source-id CLI options that add the appropriate + # element to the ref structure. + # + # Note how argparse converts hyphens to underscores. We use hyphens in the + # dictionary so that it is consistent with what the user specified on the + # CLI. + if hasattr(args, 'source_name') and \ + args.source_name is not None: + ref_dict['source-name'] = args.source_name + if hasattr(args, 'source_id') and \ + args.source_id is not None: + ref_dict['source-id'] = args.source_id + + volume = cs.volumes.manage(host=args.host, + ref=ref_dict, + name=args.name, + description=args.description, + volume_type=args.volume_type, + availability_zone=args.availability_zone, + metadata=volume_metadata, + bootable=args.bootable) + + info = {} + volume = cs.volumes.get(volume.id) + info.update(volume._info) + info.pop('links', None) + utils.print_dict(info) + + +@utils.arg('volume', metavar='<volume>', + help='Name or ID of the volume to unmanage.') +@utils.service_type('volumev2') +def do_unmanage(cs, args): + utils.find_volume(cs, args.volume).unmanage(args.volume) diff --git a/cinderclient/v2/volumes.py b/cinderclient/v2/volumes.py index ce2b06f..9a687d0 100644 --- a/cinderclient/v2/volumes.py +++ b/cinderclient/v2/volumes.py @@ -24,6 +24,11 @@ except ImportError: from cinderclient import base +SORT_DIR_VALUES = ('asc', 'desc') +SORT_KEY_VALUES = ('id', 'status', 'size', 'availability_zone', 'name', + 'bootable', 'created_at') + + class Volume(base.Resource): """A volume is an extra block level storage to the OpenStack instances.""" def __repr__(self): @@ -134,6 +139,19 @@ class Volume(base.Resource): """ self.manager.update_readonly_flag(self, read_only) + def manage(self, host, ref, name=None, description=None, + volume_type=None, availability_zone=None, metadata=None, + bootable=False): + """Manage an existing volume.""" + self.manager.manage(host=host, ref=ref, name=name, + description=description, volume_type=volume_type, + availability_zone=availability_zone, + metadata=metadata, bootable=bootable) + + def unmanage(self, volume): + """Unmanage a volume.""" + self.manager.unmanage(volume) + class VolumeManager(base.ManagerWithFind): """Manage :class:`Volume` resources.""" @@ -195,9 +213,17 @@ class VolumeManager(base.ManagerWithFind): """ return self._get("/volumes/%s" % volume_id, "volume") - def list(self, detailed=True, search_opts=None): + def list(self, detailed=True, search_opts=None, marker=None, limit=None, + sort_key=None, sort_dir=None): """Lists all volumes. + :param detailed: Whether to return detailed volume info. + :param search_opts: Search options to filter out volumes. + :param marker: Begin returning volumes that appear later in the volume + list than that represented by this volume id. + :param limit: Maximum number of volumes to return. + :param sort_key: Key to be sorted. + :param sort_dir: Sort direction, should be 'desc' or 'asc'. :rtype: list of :class:`Volume` """ if search_opts is None: @@ -209,7 +235,33 @@ class VolumeManager(base.ManagerWithFind): if val: qparams[opt] = val - query_string = "?%s" % urlencode(qparams) if qparams else "" + if marker: + qparams['marker'] = marker + + if limit: + qparams['limit'] = limit + + if sort_key is not None: + if sort_key in SORT_KEY_VALUES: + qparams['sort_key'] = sort_key + else: + raise ValueError('sort_key must be one of the following: %s.' + % ', '.join(SORT_KEY_VALUES)) + + if sort_dir is not None: + if sort_dir in SORT_DIR_VALUES: + qparams['sort_dir'] = sort_dir + else: + raise ValueError('sort_dir must be one of the following: %s.' + % ', '.join(SORT_DIR_VALUES)) + + # Transform the dict to a sequence of two-element tuples in fixed + # order, then the encoded string will be consistent in Python 2&3. + if qparams: + new_qparams = sorted(qparams.items(), key=lambda x: x[0]) + query_string = "?%s" % urlencode(new_qparams) + else: + query_string = "" detail = "" if detailed: @@ -427,3 +479,22 @@ class VolumeManager(base.ManagerWithFind): return self._action('os-set_bootable', base.getid(volume), {'bootable': flag}) + + def manage(self, host, ref, name=None, description=None, + volume_type=None, availability_zone=None, metadata=None, + bootable=False): + """Manage an existing volume.""" + body = {'volume': {'host': host, + 'ref': ref, + 'name': name, + 'description': description, + 'volume_type': volume_type, + 'availability_zone': availability_zone, + 'metadata': metadata, + 'bootable': bootable + }} + return self._create('/os-volume-manage', body, 'volume') + + def unmanage(self, volume): + """Unmanage a volume.""" + return self._action('os-unmanage', volume, None) diff --git a/doc/source/conf.py b/doc/source/conf.py index d4ae7ca..ff74d86 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -28,7 +28,7 @@ sys.path.insert(0, ROOT) # Add any Sphinx extension module names here, as strings. They can be # extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx'] +extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'oslosphinx'] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -102,7 +102,7 @@ man_pages = [ # The theme to use for HTML and HTML Help pages. Major themes that come with # Sphinx are currently 'default' and 'sphinxdoc'. -html_theme = 'nature' +#html_theme = 'nature' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the diff --git a/test-requirements.txt b/test-requirements.txt index afd5af7..0c2aa1c 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -5,6 +5,7 @@ discover fixtures>=0.3.14 mock>=1.0 httpretty>=0.8.0,!=0.8.1,!=0.8.2 +oslosphinx python-subunit>=0.0.18 sphinx>=1.1.2,!=1.2.0,<1.3 testtools>=0.9.34 @@ -22,6 +22,10 @@ commands = {posargs} [testenv:cover] commands = python setup.py testr --coverage --testr-args='{posargs}' +[testenv:docs] +commands= + python setup.py build_sphinx + [tox:jenkins] downloadcache = ~/cache/pip |