summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--cinderclient/base.py89
-rw-r--r--cinderclient/client.py21
-rw-r--r--cinderclient/openstack/common/__init__.py17
-rw-r--r--cinderclient/openstack/common/gettextutils.py225
-rw-r--r--cinderclient/openstack/common/strutils.py159
-rw-r--r--cinderclient/shell.py9
-rw-r--r--cinderclient/tests/fakes.py38
-rw-r--r--cinderclient/tests/test_client.py30
-rw-r--r--cinderclient/tests/test_http.py20
-rw-r--r--cinderclient/tests/v1/test_limits.py24
-rw-r--r--cinderclient/tests/v1/test_services.py2
-rw-r--r--cinderclient/tests/v1/test_shell.py26
-rw-r--r--cinderclient/tests/v2/fakes.py11
-rw-r--r--cinderclient/tests/v2/test_limits.py24
-rw-r--r--cinderclient/tests/v2/test_services.py2
-rw-r--r--cinderclient/tests/v2/test_shell.py180
-rw-r--r--cinderclient/tests/v2/test_volumes.py35
-rw-r--r--cinderclient/v1/shell.py26
-rw-r--r--cinderclient/v2/shell.py152
-rw-r--r--cinderclient/v2/volumes.py75
-rw-r--r--doc/source/conf.py4
-rw-r--r--test-requirements.txt1
-rw-r--r--tox.ini4
24 files changed, 879 insertions, 296 deletions
diff --git a/.gitignore b/.gitignore
index d52a0c1..db243da 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/tox.ini b/tox.ini
index a8cc00b..510cfb5 100644
--- a/tox.ini
+++ b/tox.ini
@@ -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