diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..7d58cb8 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,7 @@ +[run] +branch = True +source = cinderclient +omit = cinderclient/openstack/* + +[report] +ignore-errors = True diff --git a/.gitignore b/.gitignore index 2c64f15..d52a0c1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,12 +6,13 @@ subunit.log *,cover cover *.pyc -.idea -*.swp -*~ AUTHORS ChangeLog build dist cinderclient/versioninfo python_cinderclient.egg-info + +# Development environment files +.project +.pydevproject diff --git a/.testr.conf b/.testr.conf index 2109af6..f5be871 100644 --- a/.testr.conf +++ b/.testr.conf @@ -1,4 +1,4 @@ [DEFAULT] -test_command=OS_STDOUT_CAPTURE=1 OS_STDERR_CAPTURE=1 ${PYTHON:-python} -m subunit.run discover -t ./ ./tests $LISTOPT $IDOPTION +test_command=OS_STDOUT_CAPTURE=1 OS_STDERR_CAPTURE=1 ${PYTHON:-python} -m subunit.run discover -t ./ ./cinderclient/tests $LISTOPT $IDOPTION test_id_option=--load-list $IDFILE test_list_option=--list diff --git a/HACKING b/HACKING deleted file mode 100644 index d9d1cb8..0000000 --- a/HACKING +++ /dev/null @@ -1,115 +0,0 @@ -Nova Style Commandments -======================= - -Step 1: Read http://www.python.org/dev/peps/pep-0008/ -Step 2: Read http://www.python.org/dev/peps/pep-0008/ again -Step 3: Read on - -Imports -------- -- thou shalt not import objects, only modules -- thou shalt not import more than one module per line -- thou shalt not make relative imports -- thou shalt organize your imports according to the following template - -:: - # vim: tabstop=4 shiftwidth=4 softtabstop=4 - {{stdlib imports in human alphabetical order}} - \n - {{cinder imports in human alphabetical order}} - \n - \n - {{begin your code}} - - -General -------- -- thou shalt put two newlines twixt toplevel code (funcs, classes, etc) -- thou shalt put one newline twixt methods in classes and anywhere else -- thou shalt not write "except:", use "except Exception:" at the very least -- thou shalt include your name with TODOs as in "TODO(termie)" -- thou shalt not name anything the same name as a builtin or reserved word -- thou shalt not violate causality in our time cone, or else - - -Human Alphabetical Order Examples ---------------------------------- -:: - import httplib - import logging - import random - import StringIO - import time - import unittest - - from cinder import flags - from cinder import test - from cinder.auth import users - from cinder.endpoint import api - from cinder.endpoint import cloud - -Docstrings ----------- - """A one line docstring looks like this and ends in a period.""" - - - """A multiline docstring has a one-line summary, less than 80 characters. - - Then a new paragraph after a newline that explains in more detail any - general information about the function, class or method. Example usages - are also great to have here if it is a complex class for function. After - you have finished your descriptions add an extra newline and close the - quotations. - - When writing the docstring for a class, an extra line should be placed - after the closing quotations. For more in-depth explanations for these - decisions see http://www.python.org/dev/peps/pep-0257/ - - If you are going to describe parameters and return values, use Sphinx, the - appropriate syntax is as follows. - - :param foo: the foo parameter - :param bar: the bar parameter - :returns: description of the return value - - """ - -Text encoding ----------- -- All text within python code should be of type 'unicode'. - - WRONG: - - >>> s = 'foo' - >>> s - 'foo' - >>> type(s) - - - RIGHT: - - >>> u = u'foo' - >>> u - u'foo' - >>> type(u) - - -- Transitions between internal unicode and external strings should always - be immediately and explicitly encoded or decoded. - -- All external text that is not explicitly encoded (database storage, - commandline arguments, etc.) should be presumed to be encoded as utf-8. - - WRONG: - - mystring = infile.readline() - myreturnstring = do_some_magic_with(mystring) - outfile.write(myreturnstring) - - RIGHT: - - mystring = infile.readline() - mytext = s.decode('utf-8') - returntext = do_some_magic_with(mytext) - returnstring = returntext.encode('utf-8') - outfile.write(returnstring) diff --git a/HACKING.rst b/HACKING.rst new file mode 100644 index 0000000..a48ac28 --- /dev/null +++ b/HACKING.rst @@ -0,0 +1,77 @@ +Cinder Client Style Commandments +================================ + +- Step 1: Read the OpenStack Style Commandments + http://docs.openstack.org/developer/hacking/ +- Step 2: Read on + +Cinder Client Specific Commandments +----------------------------------- + +General +------- +- Use 'raise' instead of 'raise e' to preserve original traceback or exception being reraised:: + + except Exception as e: + ... + raise e # BAD + + except Exception: + ... + raise # OKAY + +Text encoding +------------- +- All text within python code should be of type 'unicode'. + + WRONG: + + >>> s = 'foo' + >>> s + 'foo' + >>> type(s) + + + RIGHT: + + >>> u = u'foo' + >>> u + u'foo' + >>> type(u) + + +- Transitions between internal unicode and external strings should always + be immediately and explicitly encoded or decoded. + +- All external text that is not explicitly encoded (database storage, + commandline arguments, etc.) should be presumed to be encoded as utf-8. + + WRONG: + + mystring = infile.readline() + myreturnstring = do_some_magic_with(mystring) + outfile.write(myreturnstring) + + RIGHT: + + mystring = infile.readline() + mytext = s.decode('utf-8') + returntext = do_some_magic_with(mytext) + returnstring = returntext.encode('utf-8') + outfile.write(returnstring) + +Release Notes +------------- +- Each patch should add an entry in the doc/source/index.rst file under + "MASTER". + +- On each new release, the entries under "MASTER" will become the release notes + for that release, and "MASTER" will be cleared. + +- The format should match existing release notes. For example, a feature:: + + * Add support for function foo + + Or a bug fix:: + + .. _1241941: http://bugs.launchpad.net/python-cinderclient/+bug/1241941 diff --git a/README.rst b/README.rst index 86a8669..44ea43e 100644 --- a/README.rst +++ b/README.rst @@ -44,13 +44,13 @@ params, but it's easier to just set them as environment variables:: export OS_TENANT_NAME=myproject You will also need to define the authentication url with ``--os-auth-url`` -and the version of the API with ``--version``. Or set them as an environment -variables as well:: +and the version of the API with ``--os-volume-api-version``. Or set them as +environment variables as well:: export OS_AUTH_URL=http://example.com:8774/v1.1/ export OS_VOLUME_API_VERSION=1 -If you are using Keystone, you need to set the CINDER_URL to the keystone +If you are using Keystone, you need to set the OS_AUTH_URL to the keystone endpoint:: export OS_AUTH_URL=http://example.com:5000/v2.0/ @@ -148,3 +148,5 @@ Quick-start using keystone:: >>> nt = client.Client(USER, PASS, TENANT, AUTH_URL, service_type="volume") >>> nt.volumes.list() [...] + +See release notes and more at ``_. diff --git a/cinderclient/__init__.py b/cinderclient/__init__.py index 6f6043c..bfaa627 100644 --- a/cinderclient/__init__.py +++ b/cinderclient/__init__.py @@ -1,6 +1,6 @@ # vim: tabstop=4 shiftwidth=4 softtabstop=4 -# Copyright 2012 OpenStack LLC +# Copyright (c) 2012 OpenStack Foundation # # 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 @@ -14,9 +14,11 @@ # License for the specific language governing permissions and limitations # under the License. -from cinderclient.openstack.common import version +__all__ = ['__version__'] -version_info = version.VersionInfo('python-cinderclient') +import pbr.version + +version_info = pbr.version.VersionInfo('python-cinderclient') # We have a circular import problem when we first run python setup.py sdist # It's harmless, so deflect it. try: diff --git a/cinderclient/base.py b/cinderclient/base.py index 1ee621a..280286c 100644 --- a/cinderclient/base.py +++ b/cinderclient/base.py @@ -1,6 +1,6 @@ # Copyright 2010 Jacob Kaplan-Moss -# Copyright 2011 OpenStack LLC. +# Copyright (c) 2011 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -18,10 +18,13 @@ """ Base utilities to build API operation managers and objects on top of. """ - +import abc import contextlib import hashlib import os + +import six + from cinderclient import exceptions from cinderclient import utils @@ -99,14 +102,15 @@ class Manager(utils.HookableMixin): # pair username = utils.env('OS_USERNAME', 'CINDER_USERNAME') url = utils.env('OS_URL', 'CINDER_URL') - uniqifier = hashlib.md5(username + url).hexdigest() + uniqifier = hashlib.md5(username.encode('utf-8') + + url.encode('utf-8')).hexdigest() cache_dir = os.path.expanduser(os.path.join(base_dir, uniqifier)) try: - os.makedirs(cache_dir, 0755) + os.makedirs(cache_dir, 0o755) except OSError: - # NOTE(kiall): This is typicaly either permission denied while + # NOTE(kiall): This is typically either permission denied while # attempting to create the directory, or the directory # already exists. Either way, don't fail. pass @@ -120,7 +124,7 @@ class Manager(utils.HookableMixin): try: setattr(self, cache_attr, open(path, mode)) except IOError: - # NOTE(kiall): This is typicaly a permission denied while + # NOTE(kiall): This is typically a permission denied while # attempting to write the cache file. pass @@ -163,10 +167,15 @@ class Manager(utils.HookableMixin): return body -class ManagerWithFind(Manager): +class ManagerWithFind(six.with_metaclass(abc.ABCMeta, Manager)): """ Like a `Manager`, but with additional `find()`/`findall()` methods. """ + + @abc.abstractmethod + def list(self): + pass + def find(self, **kwargs): """ Find a single item with attributes matching ``**kwargs``. @@ -192,9 +201,12 @@ class ManagerWithFind(Manager): the Python side. """ found = [] - searches = kwargs.items() + searches = list(kwargs.items()) - for obj in self.list(): + # Want to search for all tenants here so that when attempting to delete + # that a user like admin doesn't get a failure when trying to delete + # another tenant's volume by name. + for obj in self.list(search_opts={'all_tenants': 1}): try: if all(getattr(obj, attr) == value for (attr, value) in searches): @@ -204,9 +216,6 @@ class ManagerWithFind(Manager): return found - def list(self): - raise NotImplementedError - class Resource(object): """ @@ -245,7 +254,7 @@ class Resource(object): return None def _add_details(self, info): - for (k, v) in info.iteritems(): + for (k, v) in six.iteritems(info): try: setattr(self, k, v) except AttributeError: @@ -264,8 +273,8 @@ class Resource(object): return self.__dict__[k] def __repr__(self): - reprkeys = sorted(k for k in self.__dict__.keys() if k[0] != '_' and - k != 'manager') + 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) diff --git a/cinderclient/client.py b/cinderclient/client.py index 2b58a04..c268137 100644 --- a/cinderclient/client.py +++ b/cinderclient/client.py @@ -1,16 +1,33 @@ +# Copyright (c) 2011 OpenStack Foundation # Copyright 2010 Jacob Kaplan-Moss -# Copyright 2011 OpenStack LLC. # Copyright 2011 Piston Cloud Computing, Inc. - # All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + """ OpenStack Client interface. Handles the REST calls and responses. """ +from __future__ import print_function + import logging -import os -import sys -import urlparse + +try: + import urlparse +except ImportError: + import urllib.parse as urlparse + try: from eventlet import sleep except ImportError: @@ -61,6 +78,7 @@ class HTTPClient(object): self.auth_token = None self.proxy_token = proxy_token self.proxy_tenant_id = proxy_tenant_id + self.timeout = timeout if insecure: self.verify_cert = False @@ -71,7 +89,7 @@ class HTTPClient(object): self.verify_cert = True self._logger = logging.getLogger(__name__) - if self.http_log_debug: + if self.http_log_debug and not self._logger.handlers: ch = logging.StreamHandler() self._logger.setLevel(logging.DEBUG) self._logger.addHandler(ch) @@ -115,6 +133,8 @@ class HTTPClient(object): kwargs['data'] = json.dumps(kwargs['body']) del kwargs['body'] + if self.timeout: + kwargs.setdefault('timeout', self.timeout) self.http_log_req((url, method,), kwargs) resp = requests.request( method, @@ -174,7 +194,8 @@ class HTTPClient(object): except requests.exceptions.ConnectionError as e: # Catch a connection refused from requests.request self._logger.debug("Connection refused: %s" % e) - raise + 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)) @@ -196,7 +217,8 @@ class HTTPClient(object): def _extract_service_catalog(self, url, resp, body, extract_token=True): """See what the auth service told us and process the response. We may get redirected to another site, fail or actually get - back a service catalog with a token and our endpoints.""" + back a service catalog with a token and our endpoints. + """ if resp.status_code == 200: # content must always present try: @@ -217,13 +239,13 @@ class HTTPClient(object): self.management_url = management_url.rstrip('/') return None except exceptions.AmbiguousEndpoints: - print "Found more than one valid endpoint. Use a more " \ - "restrictive filter" + print("Found more than one valid endpoint. Use a more " + "restrictive filter") raise except KeyError: raise exceptions.AuthorizationFailure() except exceptions.EndpointNotFound: - print "Could not find any suitable endpoint. Correct region?" + print("Could not find any suitable endpoint. Correct region?") raise elif resp.status_code == 305: @@ -248,7 +270,7 @@ class HTTPClient(object): % (self.proxy_token, self.proxy_tenant_id)]) self._logger.debug("Using Endpoint URL: %s" % url) resp, body = self.request(url, "GET", - headers={'X-Auth_Token': self.auth_token}) + headers={'X-Auth-Token': self.auth_token}) return self._extract_service_catalog(url, resp, body, extract_token=False) @@ -273,10 +295,7 @@ class HTTPClient(object): auth_url = self.auth_url if self.version == "v2.0": while auth_url: - if "CINDER_RAX_AUTH" in os.environ: - auth_url = self._rax_auth(auth_url) - else: - auth_url = self._v2_auth(auth_url) + auth_url = self._v2_auth(auth_url) # Are we acting on behalf of another user via an # existing token? If so, our actual endpoints may @@ -335,16 +354,6 @@ class HTTPClient(object): self._authenticate(url, body) - def _rax_auth(self, url): - """Authenticate against the Rackspace auth service.""" - body = {"auth": { - "RAX-KSKEY:apiKeyCredentials": { - "username": self.user, - "apiKey": self.password, - "tenantName": self.projectid}}} - - self._authenticate(url, body) - def _authenticate(self, url, body): """Authenticate and extract the service catalog.""" token_url = url + "/tokens" @@ -358,6 +367,18 @@ class HTTPClient(object): return self._extract_service_catalog(url, resp, body) + def get_volume_api_version_from_endpoint(self): + magic_tuple = urlparse.urlsplit(self.management_url) + scheme, netloc, path, query, frag = magic_tuple + components = path.split("/") + valid_versions = ['v1', 'v2'] + for version in valid_versions: + if version in components: + return version[1:] + msg = "Invalid client version '%s'. must be one of: %s" % ( + (version, ', '.join(valid_versions))) + raise exceptions.UnsupportedVersion(msg) + def get_client_class(version): version_map = { @@ -368,7 +389,7 @@ def get_client_class(version): client_path = version_map[str(version)] except (KeyError, ValueError): msg = "Invalid client version '%s'. must be one of: %s" % ( - (version, ', '.join(version_map.keys()))) + (version, ', '.join(version_map))) raise exceptions.UnsupportedVersion(msg) return utils.import_class(client_path) diff --git a/cinderclient/exceptions.py b/cinderclient/exceptions.py index d7be180..1e3050c 100644 --- a/cinderclient/exceptions.py +++ b/cinderclient/exceptions.py @@ -1,4 +1,18 @@ # Copyright 2010 Jacob Kaplan-Moss +# +# 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. + """ Exception definitions. """ @@ -6,7 +20,12 @@ Exception definitions. class UnsupportedVersion(Exception): """Indicates that the user is trying to use an unsupported - version of the API""" + version of the API. + """ + pass + + +class InvalidAPIVersion(Exception): pass @@ -24,7 +43,8 @@ class NoUniqueMatch(Exception): class NoTokenLookupException(Exception): """This form of authentication does not support looking up - endpoints from an existing token.""" + endpoints from an existing token. + """ pass @@ -33,6 +53,11 @@ class EndpointNotFound(Exception): pass +class ConnectionError(Exception): + """Could not open a connection to the API service.""" + pass + + class AmbiguousEndpoints(Exception): """Found more than one matching endpoint in Service Catalog.""" def __init__(self, endpoints=None): @@ -141,7 +166,7 @@ def from_response(response, body): message = "n/a" details = "n/a" if hasattr(body, 'keys'): - error = body[body.keys()[0]] + error = body[list(body)[0]] message = error.get('message', None) details = error.get('details', None) return cls(code=response.status_code, message=message, details=details, diff --git a/cinderclient/extension.py b/cinderclient/extension.py index ced67f0..84c67e9 100644 --- a/cinderclient/extension.py +++ b/cinderclient/extension.py @@ -1,4 +1,4 @@ -# Copyright 2011 OpenStack LLC. +# Copyright (c) 2011 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -29,7 +29,7 @@ class Extension(utils.HookableMixin): def _parse_extension_module(self): self.manager_class = None - for attr_name, attr_value in self.module.__dict__.items(): + for attr_name, attr_value in list(self.module.__dict__.items()): if attr_name in self.SUPPORTED_HOOKS: self.add_hook(attr_name, attr_value) elif utils.safe_issubclass(attr_value, base.Manager): diff --git a/tests/__init__.py b/cinderclient/openstack/common/apiclient/__init__.py similarity index 100% rename from tests/__init__.py rename to cinderclient/openstack/common/apiclient/__init__.py diff --git a/cinderclient/openstack/common/apiclient/auth.py b/cinderclient/openstack/common/apiclient/auth.py new file mode 100644 index 0000000..1a713b0 --- /dev/null +++ b/cinderclient/openstack/common/apiclient/auth.py @@ -0,0 +1,221 @@ +# Copyright 2013 OpenStack Foundation +# Copyright 2013 Spanish National Research Council. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +# E0202: An attribute inherited from %s hide this method +# pylint: disable=E0202 + +import abc +import argparse +import os + +import six +from stevedore import extension + +from cinderclient.openstack.common.apiclient import exceptions + + +_discovered_plugins = {} + + +def discover_auth_systems(): + """Discover the available auth-systems. + + This won't take into account the old style auth-systems. + """ + global _discovered_plugins + _discovered_plugins = {} + + def add_plugin(ext): + _discovered_plugins[ext.name] = ext.plugin + + ep_namespace = "cinderclient.openstack.common.apiclient.auth" + mgr = extension.ExtensionManager(ep_namespace) + mgr.map(add_plugin) + + +def load_auth_system_opts(parser): + """Load options needed by the available auth-systems into a parser. + + This function will try to populate the parser with options from the + available plugins. + """ + group = parser.add_argument_group("Common auth options") + BaseAuthPlugin.add_common_opts(group) + for name, auth_plugin in six.iteritems(_discovered_plugins): + group = parser.add_argument_group( + "Auth-system '%s' options" % name, + conflict_handler="resolve") + auth_plugin.add_opts(group) + + +def load_plugin(auth_system): + try: + plugin_class = _discovered_plugins[auth_system] + except KeyError: + raise exceptions.AuthSystemNotFound(auth_system) + return plugin_class(auth_system=auth_system) + + +def load_plugin_from_args(args): + """Load required plugin and populate it with options. + + Try to guess auth system if it is not specified. Systems are tried in + alphabetical order. + + :type args: argparse.Namespace + :raises: AuthorizationFailure + """ + auth_system = args.os_auth_system + if auth_system: + plugin = load_plugin(auth_system) + plugin.parse_opts(args) + plugin.sufficient_options() + return plugin + + for plugin_auth_system in sorted(six.iterkeys(_discovered_plugins)): + plugin_class = _discovered_plugins[plugin_auth_system] + plugin = plugin_class() + plugin.parse_opts(args) + try: + plugin.sufficient_options() + except exceptions.AuthPluginOptionsMissing: + continue + return plugin + raise exceptions.AuthPluginOptionsMissing(["auth_system"]) + + +@six.add_metaclass(abc.ABCMeta) +class BaseAuthPlugin(object): + """Base class for authentication plugins. + + An authentication plugin needs to override at least the authenticate + method to be a valid plugin. + """ + + auth_system = None + opt_names = [] + common_opt_names = [ + "auth_system", + "username", + "password", + "tenant_name", + "token", + "auth_url", + ] + + def __init__(self, auth_system=None, **kwargs): + self.auth_system = auth_system or self.auth_system + self.opts = dict((name, kwargs.get(name)) + for name in self.opt_names) + + @staticmethod + def _parser_add_opt(parser, opt): + """Add an option to parser in two variants. + + :param opt: option name (with underscores) + """ + dashed_opt = opt.replace("_", "-") + env_var = "OS_%s" % opt.upper() + arg_default = os.environ.get(env_var, "") + arg_help = "Defaults to env[%s]." % env_var + parser.add_argument( + "--os-%s" % dashed_opt, + metavar="<%s>" % dashed_opt, + default=arg_default, + help=arg_help) + parser.add_argument( + "--os_%s" % opt, + metavar="<%s>" % dashed_opt, + help=argparse.SUPPRESS) + + @classmethod + def add_opts(cls, parser): + """Populate the parser with the options for this plugin. + """ + for opt in cls.opt_names: + # use `BaseAuthPlugin.common_opt_names` since it is never + # changed in child classes + if opt not in BaseAuthPlugin.common_opt_names: + cls._parser_add_opt(parser, opt) + + @classmethod + def add_common_opts(cls, parser): + """Add options that are common for several plugins. + """ + for opt in cls.common_opt_names: + cls._parser_add_opt(parser, opt) + + @staticmethod + def get_opt(opt_name, args): + """Return option name and value. + + :param opt_name: name of the option, e.g., "username" + :param args: parsed arguments + """ + return (opt_name, getattr(args, "os_%s" % opt_name, None)) + + def parse_opts(self, args): + """Parse the actual auth-system options if any. + + This method is expected to populate the attribute `self.opts` with a + dict containing the options and values needed to make authentication. + """ + self.opts.update(dict(self.get_opt(opt_name, args) + for opt_name in self.opt_names)) + + def authenticate(self, http_client): + """Authenticate using plugin defined method. + + The method usually analyses `self.opts` and performs + a request to authentication server. + + :param http_client: client object that needs authentication + :type http_client: HTTPClient + :raises: AuthorizationFailure + """ + self.sufficient_options() + self._do_authenticate(http_client) + + @abc.abstractmethod + def _do_authenticate(self, http_client): + """Protected method for authentication. + """ + + def sufficient_options(self): + """Check if all required options are present. + + :raises: AuthPluginOptionsMissing + """ + missing = [opt + for opt in self.opt_names + if not self.opts.get(opt)] + if missing: + raise exceptions.AuthPluginOptionsMissing(missing) + + @abc.abstractmethod + def token_and_endpoint(self, endpoint_type, service_type): + """Return token and endpoint. + + :param service_type: Service type of the endpoint + :type service_type: string + :param endpoint_type: Type of endpoint. + Possible values: public or publicURL, + internal or internalURL, + admin or adminURL + :type endpoint_type: string + :returns: tuple of token and endpoint strings + :raises: EndpointException + """ diff --git a/cinderclient/openstack/common/apiclient/base.py b/cinderclient/openstack/common/apiclient/base.py new file mode 100644 index 0000000..e101f4c --- /dev/null +++ b/cinderclient/openstack/common/apiclient/base.py @@ -0,0 +1,491 @@ +# Copyright 2010 Jacob Kaplan-Moss +# Copyright 2011 OpenStack Foundation +# Copyright 2012 Grid Dynamics +# Copyright 2013 OpenStack Foundation +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Base utilities to build API operation managers and objects on top of. +""" + +# E1102: %s is not callable +# pylint: disable=E1102 + +import abc + +import six + +from cinderclient.openstack.common.apiclient import exceptions +from cinderclient.openstack.common.py3kcompat import urlutils +from cinderclient.openstack.common import strutils + + +def getid(obj): + """Return id if argument is a Resource. + + Abstracts the common pattern of allowing both an object or an object's ID + (UUID) as a parameter when dealing with relationships. + """ + try: + if obj.uuid: + return obj.uuid + except AttributeError: + pass + try: + return obj.id + except AttributeError: + return obj + + +# TODO(aababilov): call run_hooks() in HookableMixin's child classes +class HookableMixin(object): + """Mixin so classes can register and run hooks.""" + _hooks_map = {} + + @classmethod + def add_hook(cls, hook_type, hook_func): + """Add a new hook of specified type. + + :param cls: class that registers hooks + :param hook_type: hook type, e.g., '__pre_parse_args__' + :param hook_func: hook function + """ + if hook_type not in cls._hooks_map: + cls._hooks_map[hook_type] = [] + + cls._hooks_map[hook_type].append(hook_func) + + @classmethod + def run_hooks(cls, hook_type, *args, **kwargs): + """Run all hooks of specified type. + + :param cls: class that registers hooks + :param hook_type: hook type, e.g., '__pre_parse_args__' + :param **args: args to be passed to every hook function + :param **kwargs: kwargs to be passed to every hook function + """ + hook_funcs = cls._hooks_map.get(hook_type) or [] + for hook_func in hook_funcs: + hook_func(*args, **kwargs) + + +class BaseManager(HookableMixin): + """Basic manager type providing common operations. + + Managers interact with a particular type of API (servers, flavors, images, + etc.) and provide CRUD operations for them. + """ + resource_class = None + + def __init__(self, client): + """Initializes BaseManager with `client`. + + :param client: instance of BaseClient descendant for HTTP requests + """ + super(BaseManager, self).__init__() + self.client = client + + def _list(self, url, response_key, obj_class=None, json=None): + """List the collection. + + :param url: a partial URL, e.g., '/servers' + :param response_key: the key to be looked up in response dictionary, + e.g., 'servers' + :param obj_class: class for constructing the returned objects + (self.resource_class will be used by default) + :param json: data that will be encoded as JSON and passed in POST + request (GET will be sent by default) + """ + if json: + body = self.client.post(url, json=json).json() + else: + body = self.client.get(url).json() + + if obj_class is None: + obj_class = self.resource_class + + data = body[response_key] + # NOTE(ja): keystone returns values as list as {'values': [ ... ]} + # unlike other services which just return the list... + try: + data = data['values'] + except (KeyError, TypeError): + pass + + return [obj_class(self, res, loaded=True) for res in data if res] + + def _get(self, url, response_key): + """Get an object from collection. + + :param url: a partial URL, e.g., '/servers' + :param response_key: the key to be looked up in response dictionary, + e.g., 'server' + """ + body = self.client.get(url).json() + return self.resource_class(self, body[response_key], loaded=True) + + def _head(self, url): + """Retrieve request headers for an object. + + :param url: a partial URL, e.g., '/servers' + """ + resp = self.client.head(url) + return resp.status_code == 204 + + def _post(self, url, json, response_key, return_raw=False): + """Create an object. + + :param url: a partial URL, e.g., '/servers' + :param json: data that will be encoded as JSON and passed in POST + request (GET will be sent by default) + :param response_key: the key to be looked up in response dictionary, + e.g., 'servers' + :param return_raw: flag to force returning raw JSON instead of + Python object of self.resource_class + """ + body = self.client.post(url, json=json).json() + if return_raw: + return body[response_key] + return self.resource_class(self, body[response_key]) + + def _put(self, url, json=None, response_key=None): + """Update an object with PUT method. + + :param url: a partial URL, e.g., '/servers' + :param json: data that will be encoded as JSON and passed in POST + request (GET will be sent by default) + :param response_key: the key to be looked up in response dictionary, + e.g., 'servers' + """ + resp = self.client.put(url, json=json) + # PUT requests may not return a body + if resp.content: + body = resp.json() + if response_key is not None: + return self.resource_class(self, body[response_key]) + else: + return self.resource_class(self, body) + + def _patch(self, url, json=None, response_key=None): + """Update an object with PATCH method. + + :param url: a partial URL, e.g., '/servers' + :param json: data that will be encoded as JSON and passed in POST + request (GET will be sent by default) + :param response_key: the key to be looked up in response dictionary, + e.g., 'servers' + """ + body = self.client.patch(url, json=json).json() + if response_key is not None: + return self.resource_class(self, body[response_key]) + else: + return self.resource_class(self, body) + + def _delete(self, url): + """Delete an object. + + :param url: a partial URL, e.g., '/servers/my-server' + """ + return self.client.delete(url) + + +@six.add_metaclass(abc.ABCMeta) +class ManagerWithFind(BaseManager): + """Manager with additional `find()`/`findall()` methods.""" + + @abc.abstractmethod + def list(self): + pass + + def find(self, **kwargs): + """Find a single item with attributes matching ``**kwargs``. + + This isn't very efficient: it loads the entire list then filters on + the Python side. + """ + matches = self.findall(**kwargs) + num_matches = len(matches) + if num_matches == 0: + msg = "No %s matching %s." % (self.resource_class.__name__, kwargs) + raise exceptions.NotFound(msg) + elif num_matches > 1: + raise exceptions.NoUniqueMatch() + else: + return matches[0] + + def findall(self, **kwargs): + """Find all items with attributes matching ``**kwargs``. + + This isn't very efficient: it loads the entire list then filters on + the Python side. + """ + found = [] + searches = kwargs.items() + + for obj in self.list(): + try: + if all(getattr(obj, attr) == value + for (attr, value) in searches): + found.append(obj) + except AttributeError: + continue + + return found + + +class CrudManager(BaseManager): + """Base manager class for manipulating entities. + + Children of this class are expected to define a `collection_key` and `key`. + + - `collection_key`: Usually a plural noun by convention (e.g. `entities`); + used to refer collections in both URL's (e.g. `/v3/entities`) and JSON + objects containing a list of member resources (e.g. `{'entities': [{}, + {}, {}]}`). + - `key`: Usually a singular noun by convention (e.g. `entity`); used to + refer to an individual member of the collection. + + """ + collection_key = None + key = None + + def build_url(self, base_url=None, **kwargs): + """Builds a resource URL for the given kwargs. + + Given an example collection where `collection_key = 'entities'` and + `key = 'entity'`, the following URL's could be generated. + + By default, the URL will represent a collection of entities, e.g.:: + + /entities + + If kwargs contains an `entity_id`, then the URL will represent a + specific member, e.g.:: + + /entities/{entity_id} + + :param base_url: if provided, the generated URL will be appended to it + """ + url = base_url if base_url is not None else '' + + url += '/%s' % self.collection_key + + # do we have a specific entity? + entity_id = kwargs.get('%s_id' % self.key) + if entity_id is not None: + url += '/%s' % entity_id + + return url + + def _filter_kwargs(self, kwargs): + """Drop null values and handle ids.""" + for key, ref in six.iteritems(kwargs.copy()): + if ref is None: + kwargs.pop(key) + else: + if isinstance(ref, Resource): + kwargs.pop(key) + kwargs['%s_id' % key] = getid(ref) + return kwargs + + def create(self, **kwargs): + kwargs = self._filter_kwargs(kwargs) + return self._post( + self.build_url(**kwargs), + {self.key: kwargs}, + self.key) + + def get(self, **kwargs): + kwargs = self._filter_kwargs(kwargs) + return self._get( + self.build_url(**kwargs), + self.key) + + def head(self, **kwargs): + kwargs = self._filter_kwargs(kwargs) + return self._head(self.build_url(**kwargs)) + + def list(self, base_url=None, **kwargs): + """List the collection. + + :param base_url: if provided, the generated URL will be appended to it + """ + kwargs = self._filter_kwargs(kwargs) + + return self._list( + '%(base_url)s%(query)s' % { + 'base_url': self.build_url(base_url=base_url, **kwargs), + 'query': '?%s' % urlutils.urlencode(kwargs) if kwargs else '', + }, + self.collection_key) + + def put(self, base_url=None, **kwargs): + """Update an element. + + :param base_url: if provided, the generated URL will be appended to it + """ + kwargs = self._filter_kwargs(kwargs) + + return self._put(self.build_url(base_url=base_url, **kwargs)) + + def update(self, **kwargs): + kwargs = self._filter_kwargs(kwargs) + params = kwargs.copy() + params.pop('%s_id' % self.key) + + return self._patch( + self.build_url(**kwargs), + {self.key: params}, + self.key) + + def delete(self, **kwargs): + kwargs = self._filter_kwargs(kwargs) + + return self._delete( + self.build_url(**kwargs)) + + def find(self, base_url=None, **kwargs): + """Find a single item with attributes matching ``**kwargs``. + + :param base_url: if provided, the generated URL will be appended to it + """ + kwargs = self._filter_kwargs(kwargs) + + rl = self._list( + '%(base_url)s%(query)s' % { + 'base_url': self.build_url(base_url=base_url, **kwargs), + 'query': '?%s' % urlutils.urlencode(kwargs) if kwargs else '', + }, + self.collection_key) + num = len(rl) + + if num == 0: + msg = "No %s matching %s." % (self.resource_class.__name__, kwargs) + raise exceptions.NotFound(404, msg) + elif num > 1: + raise exceptions.NoUniqueMatch + else: + return rl[0] + + +class Extension(HookableMixin): + """Extension descriptor.""" + + SUPPORTED_HOOKS = ('__pre_parse_args__', '__post_parse_args__') + manager_class = None + + def __init__(self, name, module): + super(Extension, self).__init__() + self.name = name + self.module = module + self._parse_extension_module() + + def _parse_extension_module(self): + self.manager_class = None + for attr_name, attr_value in self.module.__dict__.items(): + if attr_name in self.SUPPORTED_HOOKS: + self.add_hook(attr_name, attr_value) + else: + try: + if issubclass(attr_value, BaseManager): + self.manager_class = attr_value + except TypeError: + pass + + def __repr__(self): + return "" % self.name + + +class Resource(object): + """Base class for OpenStack resources (tenant, user, etc.). + + This is pretty much just a bag for attributes. + """ + + HUMAN_ID = False + NAME_ATTR = 'name' + + def __init__(self, manager, info, loaded=False): + """Populate and bind to a manager. + + :param manager: BaseManager object + :param info: dictionary representing resource attributes + :param loaded: prevent lazy-loading if set to True + """ + self.manager = manager + self._info = info + self._add_details(info) + self._loaded = loaded + + def __repr__(self): + reprkeys = sorted(k + for k in self.__dict__.keys() + 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) + + @property + def human_id(self): + """Human-readable ID which can be used for bash completion. + """ + if self.NAME_ATTR in self.__dict__ and self.HUMAN_ID: + return strutils.to_slug(getattr(self, self.NAME_ATTR)) + return None + + def _add_details(self, info): + for (k, v) in six.iteritems(info): + try: + setattr(self, k, v) + self._info[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 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, Resource): + return NotImplemented + # two resources of different types are not equal + 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/openstack/common/apiclient/client.py b/cinderclient/openstack/common/apiclient/client.py new file mode 100644 index 0000000..da2e177 --- /dev/null +++ b/cinderclient/openstack/common/apiclient/client.py @@ -0,0 +1,358 @@ +# Copyright 2010 Jacob Kaplan-Moss +# Copyright 2011 OpenStack Foundation +# Copyright 2011 Piston Cloud Computing, Inc. +# Copyright 2013 Alessio Ababilov +# Copyright 2013 Grid Dynamics +# Copyright 2013 OpenStack Foundation +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +OpenStack Client interface. Handles the REST calls and responses. +""" + +# E0202: An attribute inherited from %s hide this method +# pylint: disable=E0202 + +import logging +import time + +try: + import simplejson as json +except ImportError: + import json + +import requests + +from cinderclient.openstack.common.apiclient import exceptions +from cinderclient.openstack.common import importutils + + +_logger = logging.getLogger(__name__) + + +class HTTPClient(object): + """This client handles sending HTTP requests to OpenStack servers. + + Features: + - share authentication information between several clients to different + services (e.g., for compute and image clients); + - reissue authentication request for expired tokens; + - encode/decode JSON bodies; + - raise exceptions on HTTP errors; + - pluggable authentication; + - store authentication information in a keyring; + - store time spent for requests; + - register clients for particular services, so one can use + `http_client.identity` or `http_client.compute`; + - log requests and responses in a format that is easy to copy-and-paste + into terminal and send the same request with curl. + """ + + user_agent = "cinderclient.openstack.common.apiclient" + + def __init__(self, + auth_plugin, + region_name=None, + endpoint_type="publicURL", + original_ip=None, + verify=True, + cert=None, + timeout=None, + timings=False, + keyring_saver=None, + debug=False, + user_agent=None, + http=None): + self.auth_plugin = auth_plugin + + self.endpoint_type = endpoint_type + self.region_name = region_name + + self.original_ip = original_ip + self.timeout = timeout + self.verify = verify + self.cert = cert + + self.keyring_saver = keyring_saver + self.debug = debug + self.user_agent = user_agent or self.user_agent + + self.times = [] # [("item", starttime, endtime), ...] + self.timings = timings + + # requests within the same session can reuse TCP connections from pool + self.http = http or requests.Session() + + self.cached_token = None + + def _http_log_req(self, method, url, kwargs): + if not self.debug: + return + + string_parts = [ + "curl -i", + "-X '%s'" % method, + "'%s'" % url, + ] + + for element in kwargs['headers']: + header = "-H '%s: %s'" % (element, kwargs['headers'][element]) + string_parts.append(header) + + _logger.debug("REQ: %s" % " ".join(string_parts)) + if 'data' in kwargs: + _logger.debug("REQ BODY: %s\n" % (kwargs['data'])) + + def _http_log_resp(self, resp): + if not self.debug: + return + _logger.debug( + "RESP: [%s] %s\n", + resp.status_code, + resp.headers) + if resp._content_consumed: + _logger.debug( + "RESP BODY: %s\n", + resp.text) + + def serialize(self, kwargs): + if kwargs.get('json') is not None: + kwargs['headers']['Content-Type'] = 'application/json' + kwargs['data'] = json.dumps(kwargs['json']) + try: + del kwargs['json'] + except KeyError: + pass + + def get_timings(self): + return self.times + + def reset_timings(self): + self.times = [] + + def request(self, method, url, **kwargs): + """Send an http request with the specified characteristics. + + Wrapper around `requests.Session.request` to handle tasks such as + setting headers, JSON encoding/decoding, and error handling. + + :param method: method of HTTP request + :param url: URL of HTTP request + :param kwargs: any other parameter that can be passed to +' requests.Session.request (such as `headers`) or `json` + that will be encoded as JSON and used as `data` argument + """ + kwargs.setdefault("headers", kwargs.get("headers", {})) + kwargs["headers"]["User-Agent"] = self.user_agent + if self.original_ip: + kwargs["headers"]["Forwarded"] = "for=%s;by=%s" % ( + self.original_ip, self.user_agent) + if self.timeout is not None: + kwargs.setdefault("timeout", self.timeout) + kwargs.setdefault("verify", self.verify) + if self.cert is not None: + kwargs.setdefault("cert", self.cert) + self.serialize(kwargs) + + self._http_log_req(method, url, kwargs) + if self.timings: + start_time = time.time() + resp = self.http.request(method, url, **kwargs) + if self.timings: + self.times.append(("%s %s" % (method, url), + start_time, time.time())) + self._http_log_resp(resp) + + if resp.status_code >= 400: + _logger.debug( + "Request returned failure status: %s", + resp.status_code) + raise exceptions.from_response(resp, method, url) + + return resp + + @staticmethod + def concat_url(endpoint, url): + """Concatenate endpoint and final URL. + + E.g., "http://keystone/v2.0/" and "/tokens" are concatenated to + "http://keystone/v2.0/tokens". + + :param endpoint: the base URL + :param url: the final URL + """ + return "%s/%s" % (endpoint.rstrip("/"), url.strip("/")) + + def client_request(self, client, method, url, **kwargs): + """Send an http request using `client`'s endpoint and specified `url`. + + If request was rejected as unauthorized (possibly because the token is + expired), issue one authorization attempt and send the request once + again. + + :param client: instance of BaseClient descendant + :param method: method of HTTP request + :param url: URL of HTTP request + :param kwargs: any other parameter that can be passed to +' `HTTPClient.request` + """ + + filter_args = { + "endpoint_type": client.endpoint_type or self.endpoint_type, + "service_type": client.service_type, + } + token, endpoint = (self.cached_token, client.cached_endpoint) + just_authenticated = False + if not (token and endpoint): + try: + token, endpoint = self.auth_plugin.token_and_endpoint( + **filter_args) + except exceptions.EndpointException: + pass + if not (token and endpoint): + self.authenticate() + just_authenticated = True + token, endpoint = self.auth_plugin.token_and_endpoint( + **filter_args) + if not (token and endpoint): + raise exceptions.AuthorizationFailure( + "Cannot find endpoint or token for request") + + old_token_endpoint = (token, endpoint) + kwargs.setdefault("headers", {})["X-Auth-Token"] = token + self.cached_token = token + client.cached_endpoint = endpoint + # Perform the request once. If we get Unauthorized, then it + # might be because the auth token expired, so try to + # re-authenticate and try again. If it still fails, bail. + try: + return self.request( + method, self.concat_url(endpoint, url), **kwargs) + except exceptions.Unauthorized as unauth_ex: + if just_authenticated: + raise + self.cached_token = None + client.cached_endpoint = None + self.authenticate() + try: + token, endpoint = self.auth_plugin.token_and_endpoint( + **filter_args) + except exceptions.EndpointException: + raise unauth_ex + if (not (token and endpoint) or + old_token_endpoint == (token, endpoint)): + raise unauth_ex + self.cached_token = token + client.cached_endpoint = endpoint + kwargs["headers"]["X-Auth-Token"] = token + return self.request( + method, self.concat_url(endpoint, url), **kwargs) + + def add_client(self, base_client_instance): + """Add a new instance of :class:`BaseClient` descendant. + + `self` will store a reference to `base_client_instance`. + + Example: + + >>> def test_clients(): + ... from keystoneclient.auth import keystone + ... from openstack.common.apiclient import client + ... auth = keystone.KeystoneAuthPlugin( + ... username="user", password="pass", tenant_name="tenant", + ... auth_url="http://auth:5000/v2.0") + ... openstack_client = client.HTTPClient(auth) + ... # create nova client + ... from novaclient.v1_1 import client + ... client.Client(openstack_client) + ... # create keystone client + ... from keystoneclient.v2_0 import client + ... client.Client(openstack_client) + ... # use them + ... openstack_client.identity.tenants.list() + ... openstack_client.compute.servers.list() + """ + service_type = base_client_instance.service_type + if service_type and not hasattr(self, service_type): + setattr(self, service_type, base_client_instance) + + def authenticate(self): + self.auth_plugin.authenticate(self) + # Store the authentication results in the keyring for later requests + if self.keyring_saver: + self.keyring_saver.save(self) + + +class BaseClient(object): + """Top-level object to access the OpenStack API. + + This client uses :class:`HTTPClient` to send requests. :class:`HTTPClient` + will handle a bunch of issues such as authentication. + """ + + service_type = None + endpoint_type = None # "publicURL" will be used + cached_endpoint = None + + def __init__(self, http_client, extensions=None): + self.http_client = http_client + http_client.add_client(self) + + # Add in any extensions... + if extensions: + for extension in extensions: + if extension.manager_class: + setattr(self, extension.name, + extension.manager_class(self)) + + def client_request(self, method, url, **kwargs): + return self.http_client.client_request( + self, method, url, **kwargs) + + def head(self, url, **kwargs): + return self.client_request("HEAD", url, **kwargs) + + def get(self, url, **kwargs): + return self.client_request("GET", url, **kwargs) + + def post(self, url, **kwargs): + return self.client_request("POST", url, **kwargs) + + def put(self, url, **kwargs): + return self.client_request("PUT", url, **kwargs) + + def delete(self, url, **kwargs): + return self.client_request("DELETE", url, **kwargs) + + def patch(self, url, **kwargs): + return self.client_request("PATCH", url, **kwargs) + + @staticmethod + def get_class(api_name, version, version_map): + """Returns the client class for the requested API version + + :param api_name: the name of the API, e.g. 'compute', 'image', etc + :param version: the requested API version + :param version_map: a dict of client classes keyed by version + :rtype: a client class for the requested API version + """ + try: + client_path = version_map[str(version)] + except (KeyError, ValueError): + msg = "Invalid %s client version '%s'. must be one of: %s" % ( + (api_name, version, ', '.join(version_map.keys()))) + raise exceptions.UnsupportedVersion(msg) + + return importutils.import_class(client_path) diff --git a/cinderclient/openstack/common/apiclient/exceptions.py b/cinderclient/openstack/common/apiclient/exceptions.py new file mode 100644 index 0000000..4776d58 --- /dev/null +++ b/cinderclient/openstack/common/apiclient/exceptions.py @@ -0,0 +1,444 @@ +# Copyright 2010 Jacob Kaplan-Moss +# Copyright 2011 Nebula, Inc. +# Copyright 2013 Alessio Ababilov +# Copyright 2013 OpenStack Foundation +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Exception definitions. +""" + +import inspect +import sys + +import six + + +class ClientException(Exception): + """The base exception class for all exceptions this library raises. + """ + pass + + +class MissingArgs(ClientException): + """Supplied arguments are not sufficient for calling a function.""" + def __init__(self, missing): + self.missing = missing + msg = "Missing argument(s): %s" % ", ".join(missing) + super(MissingArgs, self).__init__(msg) + + +class ValidationError(ClientException): + """Error in validation on API client side.""" + pass + + +class UnsupportedVersion(ClientException): + """User is trying to use an unsupported version of the API.""" + pass + + +class CommandError(ClientException): + """Error in CLI tool.""" + pass + + +class AuthorizationFailure(ClientException): + """Cannot authorize API client.""" + pass + + +class ConnectionRefused(ClientException): + """Cannot connect to API service.""" + pass + + +class AuthPluginOptionsMissing(AuthorizationFailure): + """Auth plugin misses some options.""" + def __init__(self, opt_names): + super(AuthPluginOptionsMissing, self).__init__( + "Authentication failed. Missing options: %s" % + ", ".join(opt_names)) + self.opt_names = opt_names + + +class AuthSystemNotFound(AuthorizationFailure): + """User has specified a AuthSystem that is not installed.""" + def __init__(self, auth_system): + super(AuthSystemNotFound, self).__init__( + "AuthSystemNotFound: %s" % repr(auth_system)) + self.auth_system = auth_system + + +class NoUniqueMatch(ClientException): + """Multiple entities found instead of one.""" + pass + + +class EndpointException(ClientException): + """Something is rotten in Service Catalog.""" + pass + + +class EndpointNotFound(EndpointException): + """Could not find requested endpoint in Service Catalog.""" + pass + + +class AmbiguousEndpoints(EndpointException): + """Found more than one matching endpoint in Service Catalog.""" + def __init__(self, endpoints=None): + super(AmbiguousEndpoints, self).__init__( + "AmbiguousEndpoints: %s" % repr(endpoints)) + self.endpoints = endpoints + + +class HttpError(ClientException): + """The base exception class for all HTTP exceptions. + """ + http_status = 0 + message = "HTTP Error" + + def __init__(self, message=None, details=None, + response=None, request_id=None, + url=None, method=None, http_status=None): + self.http_status = http_status or self.http_status + self.message = message or self.message + self.details = details + self.request_id = request_id + self.response = response + self.url = url + self.method = method + formatted_string = "%s (HTTP %s)" % (self.message, self.http_status) + if request_id: + formatted_string += " (Request-ID: %s)" % request_id + super(HttpError, self).__init__(formatted_string) + + +class HTTPClientError(HttpError): + """Client-side HTTP error. + + Exception for cases in which the client seems to have erred. + """ + message = "HTTP Client Error" + + +class HttpServerError(HttpError): + """Server-side HTTP error. + + Exception for cases in which the server is aware that it has + erred or is incapable of performing the request. + """ + message = "HTTP Server Error" + + +class BadRequest(HTTPClientError): + """HTTP 400 - Bad Request. + + The request cannot be fulfilled due to bad syntax. + """ + http_status = 400 + message = "Bad Request" + + +class Unauthorized(HTTPClientError): + """HTTP 401 - Unauthorized. + + Similar to 403 Forbidden, but specifically for use when authentication + is required and has failed or has not yet been provided. + """ + http_status = 401 + message = "Unauthorized" + + +class PaymentRequired(HTTPClientError): + """HTTP 402 - Payment Required. + + Reserved for future use. + """ + http_status = 402 + message = "Payment Required" + + +class Forbidden(HTTPClientError): + """HTTP 403 - Forbidden. + + The request was a valid request, but the server is refusing to respond + to it. + """ + http_status = 403 + message = "Forbidden" + + +class NotFound(HTTPClientError): + """HTTP 404 - Not Found. + + The requested resource could not be found but may be available again + in the future. + """ + http_status = 404 + message = "Not Found" + + +class MethodNotAllowed(HTTPClientError): + """HTTP 405 - Method Not Allowed. + + A request was made of a resource using a request method not supported + by that resource. + """ + http_status = 405 + message = "Method Not Allowed" + + +class NotAcceptable(HTTPClientError): + """HTTP 406 - Not Acceptable. + + The requested resource is only capable of generating content not + acceptable according to the Accept headers sent in the request. + """ + http_status = 406 + message = "Not Acceptable" + + +class ProxyAuthenticationRequired(HTTPClientError): + """HTTP 407 - Proxy Authentication Required. + + The client must first authenticate itself with the proxy. + """ + http_status = 407 + message = "Proxy Authentication Required" + + +class RequestTimeout(HTTPClientError): + """HTTP 408 - Request Timeout. + + The server timed out waiting for the request. + """ + http_status = 408 + message = "Request Timeout" + + +class Conflict(HTTPClientError): + """HTTP 409 - Conflict. + + Indicates that the request could not be processed because of conflict + in the request, such as an edit conflict. + """ + http_status = 409 + message = "Conflict" + + +class Gone(HTTPClientError): + """HTTP 410 - Gone. + + Indicates that the resource requested is no longer available and will + not be available again. + """ + http_status = 410 + message = "Gone" + + +class LengthRequired(HTTPClientError): + """HTTP 411 - Length Required. + + The request did not specify the length of its content, which is + required by the requested resource. + """ + http_status = 411 + message = "Length Required" + + +class PreconditionFailed(HTTPClientError): + """HTTP 412 - Precondition Failed. + + The server does not meet one of the preconditions that the requester + put on the request. + """ + http_status = 412 + message = "Precondition Failed" + + +class RequestEntityTooLarge(HTTPClientError): + """HTTP 413 - Request Entity Too Large. + + The request is larger than the server is willing or able to process. + """ + http_status = 413 + message = "Request Entity Too Large" + + def __init__(self, *args, **kwargs): + try: + self.retry_after = int(kwargs.pop('retry_after')) + except (KeyError, ValueError): + self.retry_after = 0 + + super(RequestEntityTooLarge, self).__init__(*args, **kwargs) + + +class RequestUriTooLong(HTTPClientError): + """HTTP 414 - Request-URI Too Long. + + The URI provided was too long for the server to process. + """ + http_status = 414 + message = "Request-URI Too Long" + + +class UnsupportedMediaType(HTTPClientError): + """HTTP 415 - Unsupported Media Type. + + The request entity has a media type which the server or resource does + not support. + """ + http_status = 415 + message = "Unsupported Media Type" + + +class RequestedRangeNotSatisfiable(HTTPClientError): + """HTTP 416 - Requested Range Not Satisfiable. + + The client has asked for a portion of the file, but the server cannot + supply that portion. + """ + http_status = 416 + message = "Requested Range Not Satisfiable" + + +class ExpectationFailed(HTTPClientError): + """HTTP 417 - Expectation Failed. + + The server cannot meet the requirements of the Expect request-header field. + """ + http_status = 417 + message = "Expectation Failed" + + +class UnprocessableEntity(HTTPClientError): + """HTTP 422 - Unprocessable Entity. + + The request was well-formed but was unable to be followed due to semantic + errors. + """ + http_status = 422 + message = "Unprocessable Entity" + + +class InternalServerError(HttpServerError): + """HTTP 500 - Internal Server Error. + + A generic error message, given when no more specific message is suitable. + """ + http_status = 500 + message = "Internal Server Error" + + +# NotImplemented is a python keyword. +class HttpNotImplemented(HttpServerError): + """HTTP 501 - Not Implemented. + + The server either does not recognize the request method, or it lacks + the ability to fulfill the request. + """ + http_status = 501 + message = "Not Implemented" + + +class BadGateway(HttpServerError): + """HTTP 502 - Bad Gateway. + + The server was acting as a gateway or proxy and received an invalid + response from the upstream server. + """ + http_status = 502 + message = "Bad Gateway" + + +class ServiceUnavailable(HttpServerError): + """HTTP 503 - Service Unavailable. + + The server is currently unavailable. + """ + http_status = 503 + message = "Service Unavailable" + + +class GatewayTimeout(HttpServerError): + """HTTP 504 - Gateway Timeout. + + The server was acting as a gateway or proxy and did not receive a timely + response from the upstream server. + """ + http_status = 504 + message = "Gateway Timeout" + + +class HttpVersionNotSupported(HttpServerError): + """HTTP 505 - HttpVersion Not Supported. + + The server does not support the HTTP protocol version used in the request. + """ + http_status = 505 + message = "HTTP Version Not Supported" + + +# _code_map contains all the classes that have http_status attribute. +_code_map = dict( + (getattr(obj, 'http_status', None), obj) + for name, obj in six.iteritems(vars(sys.modules[__name__])) + if inspect.isclass(obj) and getattr(obj, 'http_status', False) +) + + +def from_response(response, method, url): + """Returns an instance of :class:`HttpError` or subclass based on response. + + :param response: instance of `requests.Response` class + :param method: HTTP method used for request + :param url: URL used for request + """ + kwargs = { + "http_status": response.status_code, + "response": response, + "method": method, + "url": url, + "request_id": response.headers.get("x-compute-request-id"), + } + if "retry-after" in response.headers: + kwargs["retry_after"] = response.headers["retry-after"] + + content_type = response.headers.get("Content-Type", "") + if content_type.startswith("application/json"): + try: + body = response.json() + except ValueError: + pass + else: + if hasattr(body, "keys"): + error = body[body.keys()[0]] + kwargs["message"] = error.get("message", None) + kwargs["details"] = error.get("details", None) + elif content_type.startswith("text/"): + kwargs["details"] = response.text + + try: + cls = _code_map[response.status_code] + except KeyError: + if 500 <= response.status_code < 600: + cls = HttpServerError + elif 400 <= response.status_code < 500: + cls = HTTPClientError + else: + cls = HttpError + return cls(**kwargs) diff --git a/cinderclient/openstack/common/apiclient/fake_client.py b/cinderclient/openstack/common/apiclient/fake_client.py new file mode 100644 index 0000000..2d1c0aa --- /dev/null +++ b/cinderclient/openstack/common/apiclient/fake_client.py @@ -0,0 +1,173 @@ +# Copyright 2013 OpenStack Foundation +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +A fake server that "responds" to API methods with pre-canned responses. + +All of these responses come from the spec, so if for some reason the spec's +wrong the tests might raise AssertionError. I've indicated in comments the +places where actual behavior differs from the spec. +""" + +# W0102: Dangerous default value %s as argument +# pylint: disable=W0102 + +import json + +import requests +import six + +from cinderclient.openstack.common.apiclient import client +from cinderclient.openstack.common.py3kcompat import urlutils + + +def assert_has_keys(dct, required=[], optional=[]): + for k in required: + try: + assert k in dct + except AssertionError: + extra_keys = set(dct.keys()).difference(set(required + optional)) + raise AssertionError("found unexpected keys: %s" % + list(extra_keys)) + + +class TestResponse(requests.Response): + """Wrap requests.Response and provide a convenient initialization. + """ + + def __init__(self, data): + super(TestResponse, self).__init__() + self._content_consumed = True + if isinstance(data, dict): + self.status_code = data.get('status_code', 200) + # Fake the text attribute to streamline Response creation + text = data.get('text', "") + if isinstance(text, (dict, list)): + self._content = json.dumps(text) + default_headers = { + "Content-Type": "application/json", + } + else: + self._content = text + default_headers = {} + if six.PY3 and isinstance(self._content, six.string_types): + self._content = self._content.encode('utf-8', 'strict') + self.headers = data.get('headers') or default_headers + else: + self.status_code = data + + def __eq__(self, other): + return (self.status_code == other.status_code and + self.headers == other.headers and + self._content == other._content) + + +class FakeHTTPClient(client.HTTPClient): + + def __init__(self, *args, **kwargs): + self.callstack = [] + self.fixtures = kwargs.pop("fixtures", None) or {} + if not args and not "auth_plugin" in kwargs: + args = (None, ) + super(FakeHTTPClient, self).__init__(*args, **kwargs) + + def assert_called(self, method, url, body=None, pos=-1): + """Assert than an API method was just called. + """ + expected = (method, url) + called = self.callstack[pos][0:2] + assert self.callstack, \ + "Expected %s %s but no calls were made." % expected + + assert expected == called, 'Expected %s %s; got %s %s' % \ + (expected + called) + + if body is not None: + if self.callstack[pos][3] != body: + raise AssertionError('%r != %r' % + (self.callstack[pos][3], body)) + + def assert_called_anytime(self, method, url, body=None): + """Assert than an API method was called anytime in the test. + """ + expected = (method, url) + + assert self.callstack, \ + "Expected %s %s but no calls were made." % expected + + found = False + entry = None + for entry in self.callstack: + if expected == entry[0:2]: + found = True + break + + assert found, 'Expected %s %s; got %s' % \ + (method, url, self.callstack) + if body is not None: + assert entry[3] == body, "%s != %s" % (entry[3], body) + + self.callstack = [] + + def clear_callstack(self): + self.callstack = [] + + def authenticate(self): + pass + + def client_request(self, client, method, url, **kwargs): + # Check that certain things are called correctly + if method in ["GET", "DELETE"]: + assert "json" not in kwargs + + # Note the call + self.callstack.append( + (method, + url, + kwargs.get("headers") or {}, + kwargs.get("json") or kwargs.get("data"))) + try: + fixture = self.fixtures[url][method] + except KeyError: + pass + else: + return TestResponse({"headers": fixture[0], + "text": fixture[1]}) + + # Call the method + args = urlutils.parse_qsl(urlutils.urlparse(url)[4]) + kwargs.update(args) + munged_url = url.rsplit('?', 1)[0] + munged_url = munged_url.strip('/').replace('/', '_').replace('.', '_') + munged_url = munged_url.replace('-', '_') + + callback = "%s_%s" % (method.lower(), munged_url) + + if not hasattr(self, callback): + raise AssertionError('Called unknown API method: %s %s, ' + 'expected fakes method name: %s' % + (method, url, callback)) + + resp = getattr(self, callback)(**kwargs) + if len(resp) == 3: + status, headers, body = resp + else: + status, body = resp + headers = {} + return TestResponse({ + "status_code": status, + "text": body, + "headers": headers, + }) diff --git a/cinderclient/openstack/common/gettextutils.py b/cinderclient/openstack/common/gettextutils.py new file mode 100644 index 0000000..240ac05 --- /dev/null +++ b/cinderclient/openstack/common/gettextutils.py @@ -0,0 +1,440 @@ +# Copyright 2012 Red Hat, Inc. +# Copyright 2013 IBM Corp. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +gettext for openstack-common modules. + +Usual usage in an openstack.common module: + + from cinderclient.openstack.common.gettextutils import _ +""" + +import copy +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 = {} +USE_LAZY = False + + +def enable_lazy(): + """Convenience function for configuring _() to use lazy gettext + + Call this at the start of execution to enable the gettextutils._ + function to use lazy gettext functionality. This is useful if + your project is importing _ directly instead of using the + gettextutils.install() way of importing the _ function. + """ + global USE_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): + """Install a _() function using the given translation domain. + + Given a translation domain, install a _() function using gettext's + install() function. + + The main difference from gettext.install() is that we allow + overriding the default localedir (e.g. /usr/share/locale) using + a translation-domain-specific environment variable (e.g. + NOVA_LOCALEDIR). + + :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) + + +class Message(six.text_type): + """A Message object is a unicode object that can be translated. + + Translation of Message is done explicitly using the translate() method. + For all non-translation intents and purposes, a Message is simply unicode, + and can be treated as such. + """ + + def __new__(cls, msgid, msgtext=None, params=None, + domain='cinderclient', *args): + """Create a new Message object. + + In order for translation to work gettext requires a message ID, this + msgid will be used as the base unicode text. It is also possible + for the msgid and the base unicode text to be different by passing + the msgtext parameter. + """ + # If the base msgtext is not given, we use the default translation + # of the msgid (which is in English) just in case the system locale is + # not English, so that the base text will be in that locale by default. + if not msgtext: + msgtext = Message._translate_msgid(msgid, domain) + # We want to initialize the parent unicode with the actual object that + # would have been plain unicode if 'Message' was not enabled. + msg = super(Message, cls).__new__(cls, msgtext) + msg.msgid = msgid + msg.domain = domain + msg.params = params + return msg + + def translate(self, desired_locale=None): + """Translate this message to the desired locale. + + :param desired_locale: The desired locale to translate the message to, + if no locale is provided the message will be + translated to the system's default locale. + + :returns: the translated message in unicode + """ + + translated_message = Message._translate_msgid(self.msgid, + self.domain, + desired_locale) + if self.params is None: + # No need for more translation + return translated_message + + # This Message object may have been formatted with one or more + # Message objects as substitution arguments, given either as a single + # argument, part of a tuple, or as one or more values in a dictionary. + # When translating this Message we need to translate those Messages too + translated_params = _translate_args(self.params, desired_locale) + + translated_message = translated_message % translated_params + + return translated_message + + @staticmethod + def _translate_msgid(msgid, domain, desired_locale=None): + if not desired_locale: + system_locale = locale.getdefaultlocale() + # If the system locale is not available to the runtime use English + if not system_locale[0]: + desired_locale = 'en_US' + else: + desired_locale = system_locale[0] + + locale_dir = os.environ.get(domain.upper() + '_LOCALEDIR') + lang = gettext.translation(domain, + localedir=locale_dir, + languages=[desired_locale], + fallback=True) + if six.PY3: + translator = lang.gettext + else: + translator = lang.ugettext + + translated_message = translator(msgid) + return translated_message + + def __mod__(self, other): + # When we mod a Message we want the actual operation to be performed + # by the parent class (i.e. unicode()), the only thing we do here is + # save the original msgid and the parameters in case of a translation + params = self._sanitize_mod_params(other) + unicode_mod = super(Message, self).__mod__(params) + modded = Message(self.msgid, + msgtext=unicode_mod, + params=params, + domain=self.domain) + return modded + + def _sanitize_mod_params(self, other): + """Sanitize the object being modded with this Message. + + - Add support for modding 'None' so translation supports it + - Trim the modded object, which can be a large dictionary, to only + those keys that would actually be used in a translation + - Snapshot the object being modded, in case the message is + translated, it will be used as it was when the Message was created + """ + 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: + 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]) + + return params + + def _copy_param(self, param): + try: + return copy.deepcopy(param) + except TypeError: + # Fallback to casting to unicode this will handle the + # python code-like objects that can't be deep-copied + return six.text_type(param) + + def __add__(self, other): + msg = _('Message objects do not support addition.') + raise TypeError(msg) + + 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) + + +def get_available_languages(domain): + """Lists the available languages for the given translation domain. + + :param domain: the domain to get languages for + """ + if domain in _AVAILABLE_LANGUAGES: + return copy.copy(_AVAILABLE_LANGUAGES[domain]) + + localedir = '%s_LOCALEDIR' % domain.upper() + find = lambda x: gettext.find(domain, + localedir=os.environ.get(localedir), + languages=[x]) + + # NOTE(mrodden): en_US should always be available (and first in case + # order matters) since our in-line message strings are en_US + language_list = ['en_US'] + # NOTE(luisg): Babel <1.0 used a function called list(), which was + # renamed to locale_identifiers() in >=1.0, the requirements master list + # requires >=0.9.6, uncapped, so defensively work with both. We can remove + # this check when the master list updates to >=1.0, and update all projects + list_identifiers = (getattr(localedata, 'list', None) or + getattr(localedata, 'locale_identifiers')) + locale_identifiers = list_identifiers() + + for i in locale_identifiers: + if find(i) is not None: + language_list.append(i) + + # NOTE(luisg): Babel>=1.0,<1.3 has a bug where some OpenStack supported + # locales (e.g. 'zh_CN', and 'zh_TW') aren't supported even though they + # are perfectly legitimate locales: + # https://github.com/mitsuhiko/babel/issues/37 + # In Babel 1.3 they fixed the bug and they support these locales, but + # they are still not explicitly "listed" by locale_identifiers(). + # That is why we add the locales here explicitly if necessary so that + # they are listed as supported. + aliases = {'zh': 'zh_CN', + '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: + language_list.append(alias) + + _AVAILABLE_LANGUAGES[domain] = language_list + return copy.copy(language_list) + + +def translate(obj, desired_locale=None): + """Gets the translated unicode representation of the given object. + + If the object is not translatable it is returned as-is. + If the locale is None the object is translated to the system locale. + + :param obj: the object to translate + :param desired_locale: the locale to translate the message to, if None the + default system locale will be used + :returns: the translated object in unicode, or the original object if + it could not be translated + """ + message = obj + if not isinstance(message, Message): + # If the object to translate is not already translatable, + # let's first get its unicode representation + message = six.text_type(obj) + if isinstance(message, Message): + # Even after unicoding() we still need to check if we are + # running with translatable unicode before translating + return message.translate(desired_locale) + return obj + + +def _translate_args(args, desired_locale=None): + """Translates all the translatable elements of the given arguments object. + + This method is used for translating the translatable values in method + arguments which include values of tuples or dictionaries. + If the object is not a tuple or a dictionary the object itself is + translated if it is translatable. + + If the locale is None the object is translated to the system locale. + + :param args: the args to translate + :param desired_locale: the locale to translate the args to, if None the + default system locale will be used + :returns: a new args object with the translated contents of the original + """ + if isinstance(args, tuple): + return tuple(translate(v, desired_locale) for v in args) + if isinstance(args, dict): + translated_dict = {} + for (k, v) in six.iteritems(args): + translated_v = translate(v, desired_locale) + translated_dict[k] = translated_v + return translated_dict + return translate(args, desired_locale) + + +class TranslationHandler(handlers.MemoryHandler): + """Handler that translates records before logging them. + + The TranslationHandler takes a locale and a target logging.Handler object + to forward LogRecord objects to after translating them. This handler + depends on Message objects being logged, instead of regular strings. + + The handler can be configured declaratively in the logging.conf as follows: + + [handlers] + keys = translatedlog, translator + + [handler_translatedlog] + class = handlers.WatchedFileHandler + args = ('/var/log/api-localized.log',) + formatter = context + + [handler_translator] + class = openstack.common.log.TranslationHandler + target = translatedlog + args = ('zh_CN',) + + If the specified locale is not available in the system, the handler will + log in the default locale. + """ + + def __init__(self, locale=None, target=None): + """Initialize a TranslationHandler + + :param locale: locale to use for translating messages + :param target: logging.Handler object to forward + LogRecord objects to after translation + """ + # NOTE(luisg): In order to allow this handler to be a wrapper for + # other handlers, such as a FileHandler, and still be able to + # configure it using logging.conf, this handler has to extend + # MemoryHandler because only the MemoryHandlers' logging.conf + # parsing is implemented such that it accepts a target handler. + handlers.MemoryHandler.__init__(self, capacity=0, target=target) + self.locale = locale + + def setFormatter(self, fmt): + self.target.setFormatter(fmt) + + def emit(self, record): + # We save the message from the original record to restore it + # after translation, so other handlers are not affected by this + original_msg = record.msg + original_args = record.args + + try: + self._translate_and_log_record(record) + finally: + record.msg = original_msg + record.args = original_args + + def _translate_and_log_record(self, record): + record.msg = translate(record.msg, self.locale) + + # In addition to translating the message, we also need to translate + # arguments that were passed to the log method that were not part + # of the main message e.g., log.info(_('Some message %s'), this_one)) + record.args = _translate_args(record.args, self.locale) + + self.target.emit(record) diff --git a/cinderclient/openstack/common/importutils.py b/cinderclient/openstack/common/importutils.py new file mode 100644 index 0000000..4fd9ae2 --- /dev/null +++ b/cinderclient/openstack/common/importutils.py @@ -0,0 +1,66 @@ +# Copyright 2011 OpenStack Foundation. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Import related utilities and helper functions. +""" + +import sys +import traceback + + +def import_class(import_str): + """Returns a class from a string including module and class.""" + mod_str, _sep, class_str = import_str.rpartition('.') + try: + __import__(mod_str) + return getattr(sys.modules[mod_str], class_str) + except (ValueError, AttributeError): + raise ImportError('Class %s cannot be found (%s)' % + (class_str, + traceback.format_exception(*sys.exc_info()))) + + +def import_object(import_str, *args, **kwargs): + """Import a class and return an instance of it.""" + return import_class(import_str)(*args, **kwargs) + + +def import_object_ns(name_space, import_str, *args, **kwargs): + """Tries to import object from default namespace. + + Imports a class and return an instance of it, first by trying + to find the class in a default namespace, then failing back to + a full path if not found in the default namespace. + """ + import_value = "%s.%s" % (name_space, import_str) + try: + return import_class(import_value)(*args, **kwargs) + except ImportError: + return import_class(import_str)(*args, **kwargs) + + +def import_module(import_str): + """Import a module.""" + __import__(import_str) + return sys.modules[import_str] + + +def try_import(import_str, default=None): + """Try to import a module and if it fails return default.""" + try: + return import_module(import_str) + except ImportError: + return default diff --git a/tests/v1/__init__.py b/cinderclient/openstack/common/py3kcompat/__init__.py similarity index 100% rename from tests/v1/__init__.py rename to cinderclient/openstack/common/py3kcompat/__init__.py diff --git a/cinderclient/openstack/common/py3kcompat/urlutils.py b/cinderclient/openstack/common/py3kcompat/urlutils.py new file mode 100644 index 0000000..84e457a --- /dev/null +++ b/cinderclient/openstack/common/py3kcompat/urlutils.py @@ -0,0 +1,67 @@ +# +# Copyright 2013 Canonical Ltd. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +""" +Python2/Python3 compatibility layer for OpenStack +""" + +import six + +if six.PY3: + # python3 + import urllib.error + import urllib.parse + import urllib.request + + urlencode = urllib.parse.urlencode + urljoin = urllib.parse.urljoin + quote = urllib.parse.quote + quote_plus = urllib.parse.quote_plus + parse_qsl = urllib.parse.parse_qsl + unquote = urllib.parse.unquote + unquote_plus = urllib.parse.unquote_plus + urlparse = urllib.parse.urlparse + urlsplit = urllib.parse.urlsplit + urlunsplit = urllib.parse.urlunsplit + SplitResult = urllib.parse.SplitResult + + urlopen = urllib.request.urlopen + URLError = urllib.error.URLError + pathname2url = urllib.request.pathname2url +else: + # python2 + import urllib + import urllib2 + import urlparse + + urlencode = urllib.urlencode + quote = urllib.quote + quote_plus = urllib.quote_plus + unquote = urllib.unquote + unquote_plus = urllib.unquote_plus + + parse = urlparse + parse_qsl = parse.parse_qsl + urljoin = parse.urljoin + urlparse = parse.urlparse + urlsplit = parse.urlsplit + urlunsplit = parse.urlunsplit + SplitResult = parse.SplitResult + + urlopen = urllib2.urlopen + URLError = urllib2.URLError + pathname2url = urllib.pathname2url diff --git a/cinderclient/openstack/common/setup.py b/cinderclient/openstack/common/setup.py deleted file mode 100644 index fb187ff..0000000 --- a/cinderclient/openstack/common/setup.py +++ /dev/null @@ -1,335 +0,0 @@ -# vim: tabstop=4 shiftwidth=4 softtabstop=4 - -# Copyright 2011 OpenStack LLC. -# Copyright 2012-2013 Hewlett-Packard Development Company, L.P. -# All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. - -""" -Utilities with minimum-depends for use in setup.py -""" - -import email -import os -import re -import subprocess -import sys - -from setuptools.command import sdist - - -def parse_mailmap(mailmap='.mailmap'): - mapping = {} - if os.path.exists(mailmap): - with open(mailmap, 'r') as fp: - for l in fp: - try: - canonical_email, alias = re.match( - r'[^#]*?(<.+>).*(<.+>).*', l).groups() - except AttributeError: - continue - mapping[alias] = canonical_email - return mapping - - -def canonicalize_emails(changelog, mapping): - """Takes in a string and an email alias mapping and replaces all - instances of the aliases in the string with their real email. - """ - for alias, email_address in mapping.iteritems(): - changelog = changelog.replace(alias, email_address) - return changelog - - -# Get requirements from the first file that exists -def get_reqs_from_files(requirements_files): - for requirements_file in requirements_files: - if os.path.exists(requirements_file): - with open(requirements_file, 'r') as fil: - return fil.read().split('\n') - return [] - - -def parse_requirements(requirements_files=['requirements.txt', - 'tools/pip-requires']): - requirements = [] - for line in get_reqs_from_files(requirements_files): - # For the requirements list, we need to inject only the portion - # after egg= so that distutils knows the package it's looking for - # such as: - # -e git://github.com/openstack/nova/master#egg=nova - if re.match(r'\s*-e\s+', line): - requirements.append(re.sub(r'\s*-e\s+.*#egg=(.*)$', r'\1', - line)) - # such as: - # http://github.com/openstack/nova/zipball/master#egg=nova - elif re.match(r'\s*https?:', line): - requirements.append(re.sub(r'\s*https?:.*#egg=(.*)$', r'\1', - line)) - # -f lines are for index locations, and don't get used here - elif re.match(r'\s*-f\s+', line): - pass - # argparse is part of the standard library starting with 2.7 - # adding it to the requirements list screws distro installs - elif line == 'argparse' and sys.version_info >= (2, 7): - pass - else: - requirements.append(line) - - return requirements - - -def parse_dependency_links(requirements_files=['requirements.txt', - 'tools/pip-requires']): - dependency_links = [] - # dependency_links inject alternate locations to find packages listed - # in requirements - for line in get_reqs_from_files(requirements_files): - # skip comments and blank lines - if re.match(r'(\s*#)|(\s*$)', line): - continue - # lines with -e or -f need the whole line, minus the flag - if re.match(r'\s*-[ef]\s+', line): - dependency_links.append(re.sub(r'\s*-[ef]\s+', '', line)) - # lines that are only urls can go in unmolested - elif re.match(r'\s*https?:', line): - dependency_links.append(line) - return dependency_links - - -def _run_shell_command(cmd, throw_on_error=False): - if os.name == 'nt': - output = subprocess.Popen(["cmd.exe", "/C", cmd], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - else: - output = subprocess.Popen(["/bin/sh", "-c", cmd], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - if output.returncode and throw_on_error: - raise Exception("%s returned %d" % cmd, output.returncode) - out = output.communicate() - if len(out) == 0: - return None - if len(out[0].strip()) == 0: - return None - return out[0].strip() - - -def write_git_changelog(): - """Write a changelog based on the git changelog.""" - new_changelog = 'ChangeLog' - if not os.getenv('SKIP_WRITE_GIT_CHANGELOG'): - if os.path.isdir('.git'): - git_log_cmd = 'git log --stat' - changelog = _run_shell_command(git_log_cmd) - mailmap = parse_mailmap() - with open(new_changelog, "w") as changelog_file: - changelog_file.write(canonicalize_emails(changelog, mailmap)) - else: - open(new_changelog, 'w').close() - - -def generate_authors(): - """Create AUTHORS file using git commits.""" - jenkins_email = 'jenkins@review.(openstack|stackforge).org' - old_authors = 'AUTHORS.in' - new_authors = 'AUTHORS' - if not os.getenv('SKIP_GENERATE_AUTHORS'): - if os.path.isdir('.git'): - # don't include jenkins email address in AUTHORS file - git_log_cmd = ("git log --format='%aN <%aE>' | sort -u | " - "egrep -v '" + jenkins_email + "'") - changelog = _run_shell_command(git_log_cmd) - mailmap = parse_mailmap() - with open(new_authors, 'w') as new_authors_fh: - new_authors_fh.write(canonicalize_emails(changelog, mailmap)) - if os.path.exists(old_authors): - with open(old_authors, "r") as old_authors_fh: - new_authors_fh.write('\n' + old_authors_fh.read()) - else: - open(new_authors, 'w').close() - - -_rst_template = """%(heading)s -%(underline)s - -.. automodule:: %(module)s - :members: - :undoc-members: - :show-inheritance: -""" - - -def get_cmdclass(): - """Return dict of commands to run from setup.py.""" - - cmdclass = dict() - - def _find_modules(arg, dirname, files): - for filename in files: - if filename.endswith('.py') and filename != '__init__.py': - arg["%s.%s" % (dirname.replace('/', '.'), - filename[:-3])] = True - - class LocalSDist(sdist.sdist): - """Builds the ChangeLog and Authors files from VC first.""" - - def run(self): - write_git_changelog() - generate_authors() - # sdist.sdist is an old style class, can't use super() - sdist.sdist.run(self) - - cmdclass['sdist'] = LocalSDist - - # If Sphinx is installed on the box running setup.py, - # enable setup.py to build the documentation, otherwise, - # just ignore it - try: - from sphinx.setup_command import BuildDoc - - class LocalBuildDoc(BuildDoc): - - builders = ['html', 'man'] - - def generate_autoindex(self): - print "**Autodocumenting from %s" % os.path.abspath(os.curdir) - modules = {} - option_dict = self.distribution.get_option_dict('build_sphinx') - source_dir = os.path.join(option_dict['source_dir'][1], 'api') - if not os.path.exists(source_dir): - os.makedirs(source_dir) - for pkg in self.distribution.packages: - if '.' not in pkg: - os.path.walk(pkg, _find_modules, modules) - module_list = modules.keys() - module_list.sort() - autoindex_filename = os.path.join(source_dir, 'autoindex.rst') - with open(autoindex_filename, 'w') as autoindex: - autoindex.write(""".. toctree:: - :maxdepth: 1 - -""") - for module in module_list: - output_filename = os.path.join(source_dir, - "%s.rst" % module) - heading = "The :mod:`%s` Module" % module - underline = "=" * len(heading) - values = dict(module=module, heading=heading, - underline=underline) - - print "Generating %s" % output_filename - with open(output_filename, 'w') as output_file: - output_file.write(_rst_template % values) - autoindex.write(" %s.rst\n" % module) - - def run(self): - if not os.getenv('SPHINX_DEBUG'): - self.generate_autoindex() - - for builder in self.builders: - self.builder = builder - self.finalize_options() - self.project = self.distribution.get_name() - self.version = self.distribution.get_version() - self.release = self.distribution.get_version() - BuildDoc.run(self) - - class LocalBuildLatex(LocalBuildDoc): - builders = ['latex'] - - cmdclass['build_sphinx'] = LocalBuildDoc - cmdclass['build_sphinx_latex'] = LocalBuildLatex - except ImportError: - pass - - return cmdclass - - -def _get_revno(): - """Return the number of commits since the most recent tag. - - We use git-describe to find this out, but if there are no - tags then we fall back to counting commits since the beginning - of time. - """ - describe = _run_shell_command("git describe --always") - if "-" in describe: - return describe.rsplit("-", 2)[-2] - - # no tags found - revlist = _run_shell_command("git rev-list --abbrev-commit HEAD") - return len(revlist.splitlines()) - - -def get_version_from_git(pre_version): - """Return a version which is equal to the tag that's on the current - revision if there is one, or tag plus number of additional revisions - if the current revision has no tag.""" - - if os.path.isdir('.git'): - if pre_version: - try: - return _run_shell_command( - "git describe --exact-match", - throw_on_error=True).replace('-', '.') - except Exception: - sha = _run_shell_command("git log -n1 --pretty=format:%h") - return "%s.a%s.g%s" % (pre_version, _get_revno(), sha) - else: - return _run_shell_command( - "git describe --always").replace('-', '.') - return None - - -def get_version_from_pkg_info(package_name): - """Get the version from PKG-INFO file if we can.""" - try: - pkg_info_file = open('PKG-INFO', 'r') - except (IOError, OSError): - return None - try: - pkg_info = email.message_from_file(pkg_info_file) - except email.MessageError: - return None - # Check to make sure we're in our own dir - if pkg_info.get('Name', None) != package_name: - return None - return pkg_info.get('Version', None) - - -def get_version(package_name, pre_version=None): - """Get the version of the project. First, try getting it from PKG-INFO, if - it exists. If it does, that means we're in a distribution tarball or that - install has happened. Otherwise, if there is no PKG-INFO file, pull the - version from git. - - We do not support setup.py version sanity in git archive tarballs, nor do - we support packagers directly sucking our git repo into theirs. We expect - that a source tarball be made from our git repo - or that if someone wants - to make a source tarball from a fork of our repo with additional tags in it - that they understand and desire the results of doing that. - """ - version = os.environ.get("OSLO_PACKAGE_VERSION", None) - if version: - return version - version = get_version_from_pkg_info(package_name) - if version: - return version - version = get_version_from_git(pre_version) - if version: - return version - raise Exception("Versioning for this project requires either an sdist" - " tarball, or access to an upstream git repository.") diff --git a/cinderclient/openstack/common/strutils.py b/cinderclient/openstack/common/strutils.py index 7813b64..ee71db0 100644 --- a/cinderclient/openstack/common/strutils.py +++ b/cinderclient/openstack/common/strutils.py @@ -1,6 +1,4 @@ -# vim: tabstop=4 shiftwidth=4 softtabstop=4 - -# Copyright 2011 OpenStack LLC. +# Copyright 2011 OpenStack Foundation. # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -19,15 +17,35 @@ System-level utilities and helper functions. """ -import logging +import re import sys +import unicodedata -LOG = logging.getLogger(__name__) +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, +} +BYTE_REGEX = re.compile(r'(^-?\d+)(\D*)') + +TRUE_STRINGS = ('1', 't', 'true', 'on', 'y', 'yes') +FALSE_STRINGS = ('0', 'f', 'false', 'off', 'n', 'no') + +SLUGIFY_STRIP_RE = re.compile(r"[^\w\s-]") +SLUGIFY_HYPHENATE_RE = re.compile(r"[-\s]+") def int_from_bool_as_string(subject): - """ - Interpret a string as a boolean and return either 1 or 0. + """Interpret a string as a boolean and return either 1 or 0. Any string value in: @@ -40,42 +58,53 @@ def int_from_bool_as_string(subject): return bool_from_string(subject) and 1 or 0 -def bool_from_string(subject): +def bool_from_string(subject, strict=False, default=False): + """Interpret a string as a boolean. + + A case-insensitive match is performed such that strings matching 't', + 'true', 'on', 'y', 'yes', or '1' are considered True and, when + `strict=False`, anything else returns the value specified by 'default'. + + Useful for JSON-decoded stuff and config file parsing. + + If `strict=True`, unrecognized values, including None, will raise a + ValueError which is useful when parsing values passed in from an API call. + Strings yielding False are 'f', 'false', 'off', 'n', 'no', or '0'. """ - Interpret a string as a boolean. + if not isinstance(subject, six.string_types): + subject = str(subject) - Any string value in: + lowered = subject.strip().lower() - ('True', 'true', 'On', 'on', 'Yes', 'yes', '1') - - is interpreted as a boolean True. - - Useful for JSON-decoded stuff and config file parsing - """ - if isinstance(subject, bool): - return subject - if isinstance(subject, basestring): - if subject.strip().lower() in ('true', 'on', 'yes', '1'): - return True - return False + if lowered in TRUE_STRINGS: + return True + elif lowered in FALSE_STRINGS: + return False + elif strict: + acceptable = ', '.join( + "'%s'" % s for s in sorted(TRUE_STRINGS + FALSE_STRINGS)) + msg = _("Unrecognized value '%(val)s', acceptable values are:" + " %(acceptable)s") % {'val': subject, + 'acceptable': acceptable} + raise ValueError(msg) + else: + return default def safe_decode(text, incoming=None, errors='strict'): - """ - Decodes incoming str using `incoming` if they're - not already unicode. + """Decodes incoming str using `incoming` if they're not already unicode. :param incoming: Text's current encoding :param errors: Errors handling policy. See here for valid values http://docs.python.org/2/library/codecs.html :returns: text or a unicode `incoming` encoded representation of it. - :raises TypeError: If text is not an isntance of basestring + :raises TypeError: If text is not an instance of str """ - if not isinstance(text, basestring): + if not isinstance(text, six.string_types): raise TypeError("%s can't be decoded" % type(text)) - if isinstance(text, unicode): + if isinstance(text, six.text_type): return text if not incoming: @@ -102,11 +131,10 @@ 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`. If - incoming is not specified, text is expected to - be encoded with current python's default encoding. - (`sys.getdefaultencoding`) + """Encodes incoming str/unicode using `encoding`. + + If incoming is not specified, text is expected to be encoded with + current python's default encoding. (`sys.getdefaultencoding`) :param incoming: Text's current encoding :param encoding: Expected encoding for text (Default UTF-8) @@ -114,20 +142,81 @@ def safe_encode(text, incoming=None, values http://docs.python.org/2/library/codecs.html :returns: text or a bytestring `encoding` encoded representation of it. - :raises TypeError: If text is not an isntance of basestring + :raises TypeError: If text is not an instance of str """ - if not isinstance(text, basestring): + if not isinstance(text, six.string_types): raise TypeError("%s can't be encoded" % type(text)) if not incoming: incoming = (sys.stdin.encoding or sys.getdefaultencoding()) - if isinstance(text, unicode): - return text.encode(encoding, errors) + if isinstance(text, six.text_type): + if six.PY3: + return text.encode(encoding, errors).decode(incoming) + else: + return text.encode(encoding, errors) elif text and encoding != incoming: # Decode text before encoding it with `encoding` text = safe_decode(text, incoming, errors) - return text.encode(encoding, errors) + if six.PY3: + return text.encode(encoding, errors).decode(incoming) + else: + return text.encode(encoding, errors) return text + + +def to_bytes(text, default=0): + """Converts a string into an integer of bytes. + + 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) + + :param text: String input for bytes size conversion. + :param default: Default return value when text is blank. + + """ + match = BYTE_REGEX.search(text) + if match: + magnitude = int(match.group(1)) + mult_key_org = match.group(2) + if not mult_key_org: + return magnitude + elif text: + msg = _('Invalid string format: %s') % text + raise TypeError(msg) + 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 + + +def to_slug(value, incoming=None, errors="strict"): + """Normalize string. + + Convert to lowercase, remove non-word characters, and convert spaces + to hyphens. + + Inspired by Django's `slugify` filter. + + :param value: Text to slugify + :param incoming: Text's current encoding + :param errors: Errors handling policy. See here for valid + values http://docs.python.org/2/library/codecs.html + :returns: slugified unicode representation of `value` + :raises TypeError: If text is not an instance of str + """ + value = safe_decode(value, incoming, errors) + # NOTE(aababilov): no need to use safe_(encode|decode) here: + # encodings are always "ascii", error handling is always "ignore" + # and types are always known (first: unicode; second: str) + value = unicodedata.normalize("NFKD", value).encode( + "ascii", "ignore").decode("ascii") + value = SLUGIFY_STRIP_RE.sub("", value).strip().lower() + return SLUGIFY_HYPHENATE_RE.sub("-", value) diff --git a/cinderclient/openstack/common/version.py b/cinderclient/openstack/common/version.py deleted file mode 100644 index a3559d8..0000000 --- a/cinderclient/openstack/common/version.py +++ /dev/null @@ -1,86 +0,0 @@ - -# Copyright 2012 OpenStack LLC -# Copyright 2012-2013 Hewlett-Packard Development Company, L.P. -# -# 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. - -""" -Utilities for consuming the version from pkg_resources. -""" - -import pkg_resources - - -class VersionInfo(object): - - def __init__(self, package): - """Object that understands versioning for a package - :param package: name of the python package, such as glance, or - python-glanceclient - """ - self.package = package - self.release = None - self.version = None - self._cached_version = None - - def _get_version_from_pkg_resources(self): - """Get the version of the package from the pkg_resources record - associated with the package.""" - try: - requirement = pkg_resources.Requirement.parse(self.package) - provider = pkg_resources.get_provider(requirement) - return provider.version - except pkg_resources.DistributionNotFound: - # The most likely cause for this is running tests in a tree with - # produced from a tarball where the package itself has not been - # installed into anything. Check for a PKG-INFO file. - from cinderclient.openstack.common import setup - return setup.get_version_from_pkg_info(self.package) - - def release_string(self): - """Return the full version of the package including suffixes indicating - VCS status. - """ - if self.release is None: - self.release = self._get_version_from_pkg_resources() - - return self.release - - def version_string(self): - """Return the short version minus any alpha/beta tags.""" - if self.version is None: - parts = [] - for part in self.release_string().split('.'): - if part[0].isdigit(): - parts.append(part) - else: - break - self.version = ".".join(parts) - - return self.version - - # Compatibility functions - canonical_version_string = version_string - version_string_with_vcs = release_string - - def cached_version_string(self, prefix=""): - """Generate an object which will expand in a string context to - the results of version_string(). We do this so that don't - call into pkg_resources every time we start up a program when - passing version information into the CONF constructor, but - rather only do the calculation when and if a version is requested - """ - if not self._cached_version: - self._cached_version = "%s%s" % (prefix, - self.version_string()) - return self._cached_version diff --git a/cinderclient/service_catalog.py b/cinderclient/service_catalog.py index e1778db..b43eaed 100644 --- a/cinderclient/service_catalog.py +++ b/cinderclient/service_catalog.py @@ -1,4 +1,4 @@ -# Copyright 2011 OpenStack LLC. +# Copyright (c) 2011 OpenStack Foundation # Copyright 2011, Piston Cloud Computing, Inc. # # All Rights Reserved. @@ -33,7 +33,8 @@ class ServiceCatalog(object): service_name=None, volume_service_name=None): """Fetch the public URL from the Compute service for a particular endpoint attribute. If none given, return - the first. See tests for sample service catalog.""" + the first. See tests for sample service catalog. + """ matching_endpoints = [] if 'endpoints' in self.catalog: # We have a bastardized service catalog. Treat it special. :/ @@ -44,22 +45,29 @@ class ServiceCatalog(object): raise cinderclient.exceptions.EndpointNotFound() # We don't always get a service catalog back ... - if not 'serviceCatalog' in self.catalog['access']: + if 'serviceCatalog' not in self.catalog['access']: return None # Full catalog ... catalog = self.catalog['access']['serviceCatalog'] for service in catalog: - if service.get("type") != service_type: + + # NOTE(thingee): For backwards compatibility, if they have v2 + # enabled and the service_type is set to 'volume', go ahead and + # accept that. + skip_service_type_check = False + if service_type == 'volumev2' and service['type'] == 'volume': + version = service['endpoints'][0]['publicURL'].split('/')[3] + if version == 'v2': + skip_service_type_check = True + + if (not skip_service_type_check + and service.get("type") != service_type): continue - if (service_name and service_type == 'compute' and - service.get('name') != service_name): - continue - - if (volume_service_name and service_type == 'volume' and - service.get('name') != volume_service_name): + if (volume_service_name and service_type in ('volume', 'volumev2') + and service.get('name') != volume_service_name): continue endpoints = service['endpoints'] diff --git a/cinderclient/shell.py b/cinderclient/shell.py index c080564..385b05d 100644 --- a/cinderclient/shell.py +++ b/cinderclient/shell.py @@ -18,6 +18,8 @@ Command-line interface to the OpenStack Cinder API. """ +from __future__ import print_function + import argparse import glob import imp @@ -37,8 +39,9 @@ from cinderclient.v2 import shell as shell_v2 DEFAULT_OS_VOLUME_API_VERSION = "1" DEFAULT_CINDER_ENDPOINT_TYPE = 'publicURL' -DEFAULT_CINDER_SERVICE_TYPE = 'compute' +DEFAULT_CINDER_SERVICE_TYPE = 'volume' +logging.basicConfig() logger = logging.getLogger(__name__) @@ -115,6 +118,14 @@ class OpenStackCinderShell(object): parser.add_argument('--os_tenant_name', help=argparse.SUPPRESS) + parser.add_argument('--os-tenant-id', + metavar='', + default=utils.env('OS_TENANT_ID', + 'CINDER_TENANT_ID'), + help='Defaults to env[OS_TENANT_ID].') + parser.add_argument('--os_tenant_id', + help=argparse.SUPPRESS) + parser.add_argument('--os-auth-url', metavar='', default=utils.env('OS_AUTH_URL', @@ -133,7 +144,7 @@ class OpenStackCinderShell(object): parser.add_argument('--service-type', metavar='', - help='Defaults to compute for most actions') + help='Defaults to volume for most actions') parser.add_argument('--service_type', help=argparse.SUPPRESS) @@ -161,9 +172,9 @@ class OpenStackCinderShell(object): help=argparse.SUPPRESS) parser.add_argument('--os-volume-api-version', - metavar='', + metavar='', default=utils.env('OS_VOLUME_API_VERSION', - default=DEFAULT_OS_VOLUME_API_VERSION), + default=None), help='Accepts 1 or 2,defaults ' 'to env[OS_VOLUME_API_VERSION].') parser.add_argument('--os_volume_api_version', @@ -287,7 +298,7 @@ class OpenStackCinderShell(object): def _find_actions(self, subparsers, actions_module): for attr in (a for a in dir(actions_module) if a.startswith('do_')): - # I prefer to be hypen-separated instead of underscores. + # I prefer to be hyphen-separated instead of underscores. command = attr[3:].replace('_', '-') callback = getattr(actions_module, attr) desc = callback.__doc__ or '' @@ -317,7 +328,7 @@ class OpenStackCinderShell(object): streamhandler = logging.StreamHandler() streamformat = "%(levelname)s (%(module)s:%(lineno)d) %(message)s" streamhandler.setFormatter(logging.Formatter(streamformat)) - logger.setLevel(logging.DEBUG) + logger.setLevel(logging.WARNING) logger.addHandler(streamhandler) def main(self, argv): @@ -325,6 +336,14 @@ class OpenStackCinderShell(object): parser = self.get_base_parser() (options, args) = parser.parse_known_args(argv) self.setup_debugging(options.debug) + api_version_input = True + + if not options.os_volume_api_version: + # Environment variable OS_VOLUME_API_VERSION was + # not set and '--os-volume-api-version' option doesn't + # specify a value. Fall back to default. + options.os_volume_api_version = DEFAULT_OS_VOLUME_API_VERSION + api_version_input = False # build available subcommands based on version self.extensions = self._discover_extensions( @@ -351,13 +370,14 @@ class OpenStackCinderShell(object): return 0 (os_username, os_password, os_tenant_name, os_auth_url, - os_region_name, endpoint_type, insecure, + os_region_name, os_tenant_id, endpoint_type, insecure, service_type, service_name, volume_service_name, username, apikey, projectid, url, region_name, cacert) = ( args.os_username, args.os_password, args.os_tenant_name, args.os_auth_url, - args.os_region_name, args.endpoint_type, - args.insecure, args.service_type, args.service_name, + args.os_region_name, args.os_tenant_id, + args.endpoint_type, args.insecure, + args.service_type, args.service_name, args.volume_service_name, args.username, args.apikey, args.projectid, args.url, args.region_name, args.os_cacert) @@ -389,11 +409,11 @@ class OpenStackCinderShell(object): else: os_password = apikey - if not os_tenant_name: + if not (os_tenant_name or os_tenant_id): if not projectid: - raise exc.CommandError("You must provide a tenant name " - "via either --os-tenant-name or " - "env[OS_TENANT_NAME]") + raise exc.CommandError("You must provide a tenant_id " + "via either --os-tenant-id or " + "env[OS_TENANT_ID]") else: os_tenant_name = projectid @@ -408,10 +428,10 @@ class OpenStackCinderShell(object): if not os_region_name and region_name: os_region_name = region_name - if not os_tenant_name: + if not (os_tenant_name or os_tenant_id): raise exc.CommandError( - "You must provide a tenant name " - "via either --os-tenant-name or env[OS_TENANT_NAME]") + "You must provide a tenant_id " + "via either --os-tenant-id or env[OS_TENANT_ID]") if not os_auth_url: raise exc.CommandError( @@ -421,6 +441,7 @@ class OpenStackCinderShell(object): self.cs = client.Client(options.os_volume_api_version, os_username, os_password, os_tenant_name, os_auth_url, insecure, region_name=os_region_name, + tenant_id=os_tenant_id, endpoint_type=endpoint_type, extensions=self.extensions, service_type=service_type, @@ -438,6 +459,34 @@ class OpenStackCinderShell(object): except exc.AuthorizationFailure: raise exc.CommandError("Unable to authorize user") + endpoint_api_version = None + # Try to get the API version from the endpoint URL. If that fails fall + # back to trying to use what the user specified via + # --os-volume-api-version or with the OS_VOLUME_API_VERSION environment + # variable. Fail safe is to use the default API setting. + try: + endpoint_api_version = \ + self.cs.get_volume_api_version_from_endpoint() + if endpoint_api_version != options.os_volume_api_version: + msg = (("Volume API version is set to %s " + "but you are accessing a %s endpoint. " + "Change its value via either --os-volume-api-version " + "or env[OS_VOLUME_API_VERSION]") + % (options.os_volume_api_version, endpoint_api_version)) + raise exc.InvalidAPIVersion(msg) + except exc.UnsupportedVersion: + endpoint_api_version = options.os_volume_api_version + if api_version_input: + logger.warning("Unable to determine the API version via " + "endpoint URL. Falling back to user " + "specified version: %s" % + endpoint_api_version) + else: + logger.warning("Unable to determine the API version from " + "endpoint URL or user input. Falling back to " + "default API version: %s" % + endpoint_api_version) + args.func(self.cs, args) def _run_extension_hooks(self, hook_type, *args, **kwargs): @@ -446,20 +495,21 @@ class OpenStackCinderShell(object): extension.run_hooks(hook_type, *args, **kwargs) def do_bash_completion(self, args): - """ + """Print arguments for bash_completion. + Prints all of the commands and options to stdout so that the cinder.bash_completion script doesn't have to hard code them. """ commands = set() options = set() - for sc_str, sc in self.subcommands.items(): + for sc_str, sc in list(self.subcommands.items()): commands.add(sc_str) - for option in sc._optionals._option_string_actions.keys(): + for option in sc._optionals._option_string_actions: options.add(option) commands.remove('bash-completion') commands.remove('bash_completion') - print ' '.join(commands | options) + print(' '.join(commands | options)) @utils.arg('command', metavar='', nargs='?', help='Display help for ') @@ -487,16 +537,17 @@ class OpenStackHelpFormatter(argparse.HelpFormatter): def main(): try: - OpenStackCinderShell().main(map(strutils.safe_decode, sys.argv[1:])) + if sys.version_info >= (3, 0): + OpenStackCinderShell().main(sys.argv[1:]) + else: + OpenStackCinderShell().main(map(strutils.safe_decode, + sys.argv[1:])) except KeyboardInterrupt: - print >> sys.stderr, "... terminating cinder client" + print("... terminating cinder client", file=sys.stderr) sys.exit(130) - except Exception, e: + except Exception as e: logger.debug(e, exc_info=1) - message = e.message - if not isinstance(message, basestring): - message = str(message) - print >> sys.stderr, "ERROR: %s" % strutils.safe_encode(message) + print("ERROR: %s" % strutils.six.text_type(e), file=sys.stderr) sys.exit(1) diff --git a/tests/v1/contrib/__init__.py b/cinderclient/tests/__init__.py similarity index 100% rename from tests/v1/contrib/__init__.py rename to cinderclient/tests/__init__.py diff --git a/tests/fakes.py b/cinderclient/tests/fakes.py similarity index 69% rename from tests/fakes.py rename to cinderclient/tests/fakes.py index 04b40a4..5a3937c 100644 --- a/tests/fakes.py +++ b/cinderclient/tests/fakes.py @@ -1,3 +1,16 @@ +# 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. + """ A fake server that "responds" to API methods with pre-canned responses. @@ -6,14 +19,15 @@ wrong the tests might raise AssertionError. I've indicated in comments the places where actual behavior differs from the spec. """ +from __future__ import print_function + def assert_has_keys(dict, required=[], optional=[]): - keys = dict.keys() for k in required: try: - assert k in keys + assert k in dict except AssertionError: - extra_keys = set(keys).difference(set(required + optional)) + extra_keys = set(dict).difference(set(required + optional)) raise AssertionError("found unexpected keys: %s" % list(extra_keys)) @@ -52,19 +66,17 @@ class FakeClient(object): break assert found, 'Expected %s %s; got %s' % ( - expected, self.client.callstack) + expected + (self.client.callstack, )) if body is not None: try: assert entry[2] == body except AssertionError: - print entry[2] - print "!=" - print body + print(entry[2]) + print("!=") + print(body) raise - self.client.callstack = [] - def clear_callstack(self): self.client.callstack = [] diff --git a/tests/test_base.py b/cinderclient/tests/test_base.py similarity index 71% rename from tests/test_base.py rename to cinderclient/tests/test_base.py index 7eba986..508c03f 100644 --- a/tests/test_base.py +++ b/cinderclient/tests/test_base.py @@ -1,8 +1,21 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + from cinderclient import base from cinderclient import exceptions from cinderclient.v1 import volumes -from tests import utils -from tests.v1 import fakes +from cinderclient.tests import utils +from cinderclient.tests.v1 import fakes cs = fakes.FakeClient() diff --git a/tests/test_client.py b/cinderclient/tests/test_client.py similarity index 52% rename from tests/test_client.py rename to cinderclient/tests/test_client.py index 1b72971..47c4c69 100644 --- a/tests/test_client.py +++ b/cinderclient/tests/test_client.py @@ -1,8 +1,21 @@ +# 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 cinderclient.client import cinderclient.v1.client import cinderclient.v2.client -from tests import utils +from cinderclient.tests import utils class ClientTest(utils.TestCase): diff --git a/tests/test_http.py b/cinderclient/tests/test_http.py similarity index 91% rename from tests/test_http.py rename to cinderclient/tests/test_http.py index cfc1885..f38c6f1 100644 --- a/tests/test_http.py +++ b/cinderclient/tests/test_http.py @@ -1,10 +1,23 @@ +# 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 mock import requests from cinderclient import client from cinderclient import exceptions -from tests import utils +from cinderclient.tests import utils fake_response = utils.TestResponse({ diff --git a/cinderclient/tests/test_service_catalog.py b/cinderclient/tests/test_service_catalog.py new file mode 100644 index 0000000..d815557 --- /dev/null +++ b/cinderclient/tests/test_service_catalog.py @@ -0,0 +1,275 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from cinderclient import exceptions +from cinderclient import service_catalog +from cinderclient.tests import utils + + +# Taken directly from keystone/content/common/samples/auth.json +# Do not edit this structure. Instead, grab the latest from there. + +SERVICE_CATALOG = { + "access": { + "token": { + "id": "ab48a9efdfedb23ty3494", + "expires": "2010-11-01T03:32:15-05:00", + "tenant": { + "id": "345", + "name": "My Project" + } + }, + "user": { + "id": "123", + "name": "jqsmith", + "roles": [ + { + "id": "234", + "name": "compute:admin", + }, + { + "id": "235", + "name": "object-store:admin", + "tenantId": "1", + } + ], + "roles_links": [], + }, + "serviceCatalog": [ + { + "name": "Cloud Servers", + "type": "compute", + "endpoints": [ + { + "tenantId": "1", + "publicURL": "https://compute1.host/v1/1234", + "internalURL": "https://compute1.host/v1/1234", + "region": "North", + "versionId": "1.0", + "versionInfo": "https://compute1.host/v1/", + "versionList": "https://compute1.host/" + }, + { + "tenantId": "2", + "publicURL": "https://compute1.host/v1/3456", + "internalURL": "https://compute1.host/v1/3456", + "region": "North", + "versionId": "1.1", + "versionInfo": "https://compute1.host/v1/", + "versionList": "https://compute1.host/" + }, + ], + "endpoints_links": [], + }, + { + "name": "Cinder Volume Service", + "type": "volume", + "endpoints": [ + { + "tenantId": "1", + "publicURL": "https://volume1.host/v1/1234", + "internalURL": "https://volume1.host/v1/1234", + "region": "South", + "versionId": "1.0", + "versionInfo": "uri", + "versionList": "uri" + }, + { + "tenantId": "2", + "publicURL": "https://volume1.host/v1/3456", + "internalURL": "https://volume1.host/v1/3456", + "region": "South", + "versionId": "1.1", + "versionInfo": "https://volume1.host/v1/", + "versionList": "https://volume1.host/" + }, + ], + "endpoints_links": [ + { + "rel": "next", + "href": "https://identity1.host/v2.0/endpoints" + }, + ], + }, + { + "name": "Cinder Volume Service V2", + "type": "volumev2", + "endpoints": [ + { + "tenantId": "1", + "publicURL": "https://volume1.host/v2/1234", + "internalURL": "https://volume1.host/v2/1234", + "region": "South", + "versionId": "2.0", + "versionInfo": "uri", + "versionList": "uri" + }, + { + "tenantId": "2", + "publicURL": "https://volume1.host/v2/3456", + "internalURL": "https://volume1.host/v2/3456", + "region": "South", + "versionId": "1.1", + "versionInfo": "https://volume1.host/v2/", + "versionList": "https://volume1.host/" + }, + ], + "endpoints_links": [ + { + "rel": "next", + "href": "https://identity1.host/v2.0/endpoints" + }, + ], + }, + ], + "serviceCatalog_links": [ + { + "rel": "next", + "href": "https://identity.host/v2.0/endpoints?session=2hfh8Ar", + }, + ], + }, +} + +SERVICE_COMPATIBILITY_CATALOG = { + "access": { + "token": { + "id": "ab48a9efdfedb23ty3494", + "expires": "2010-11-01T03:32:15-05:00", + "tenant": { + "id": "345", + "name": "My Project" + } + }, + "user": { + "id": "123", + "name": "jqsmith", + "roles": [ + { + "id": "234", + "name": "compute:admin", + }, + { + "id": "235", + "name": "object-store:admin", + "tenantId": "1", + } + ], + "roles_links": [], + }, + "serviceCatalog": [ + { + "name": "Cloud Servers", + "type": "compute", + "endpoints": [ + { + "tenantId": "1", + "publicURL": "https://compute1.host/v1/1234", + "internalURL": "https://compute1.host/v1/1234", + "region": "North", + "versionId": "1.0", + "versionInfo": "https://compute1.host/v1/", + "versionList": "https://compute1.host/" + }, + { + "tenantId": "2", + "publicURL": "https://compute1.host/v1/3456", + "internalURL": "https://compute1.host/v1/3456", + "region": "North", + "versionId": "1.1", + "versionInfo": "https://compute1.host/v1/", + "versionList": "https://compute1.host/" + }, + ], + "endpoints_links": [], + }, + { + "name": "Cinder Volume Service V2", + "type": "volume", + "endpoints": [ + { + "tenantId": "1", + "publicURL": "https://volume1.host/v2/1234", + "internalURL": "https://volume1.host/v2/1234", + "region": "South", + "versionId": "2.0", + "versionInfo": "uri", + "versionList": "uri" + }, + { + "tenantId": "2", + "publicURL": "https://volume1.host/v2/3456", + "internalURL": "https://volume1.host/v2/3456", + "region": "South", + "versionId": "1.1", + "versionInfo": "https://volume1.host/v2/", + "versionList": "https://volume1.host/" + }, + ], + "endpoints_links": [ + { + "rel": "next", + "href": "https://identity1.host/v2.0/endpoints" + }, + ], + }, + ], + "serviceCatalog_links": [ + { + "rel": "next", + "href": "https://identity.host/v2.0/endpoints?session=2hfh8Ar", + }, + ], + }, +} + + +class ServiceCatalogTest(utils.TestCase): + def test_building_a_service_catalog(self): + sc = service_catalog.ServiceCatalog(SERVICE_CATALOG) + + self.assertRaises(exceptions.AmbiguousEndpoints, sc.url_for, + service_type='compute') + self.assertEqual(sc.url_for('tenantId', '1', service_type='compute'), + "https://compute1.host/v1/1234") + self.assertEqual(sc.url_for('tenantId', '2', service_type='compute'), + "https://compute1.host/v1/3456") + + self.assertRaises(exceptions.EndpointNotFound, sc.url_for, + "region", "South", service_type='compute') + + def test_alternate_service_type(self): + sc = service_catalog.ServiceCatalog(SERVICE_CATALOG) + + self.assertRaises(exceptions.AmbiguousEndpoints, sc.url_for, + service_type='volume') + self.assertEqual(sc.url_for('tenantId', '1', service_type='volume'), + "https://volume1.host/v1/1234") + self.assertEqual(sc.url_for('tenantId', '2', service_type='volume'), + "https://volume1.host/v1/3456") + + self.assertEqual(sc.url_for('tenantId', '2', service_type='volumev2'), + "https://volume1.host/v2/3456") + self.assertEqual(sc.url_for('tenantId', '2', service_type='volumev2'), + "https://volume1.host/v2/3456") + + self.assertRaises(exceptions.EndpointNotFound, sc.url_for, + "region", "North", service_type='volume') + + def test_compatibility_service_type(self): + sc = service_catalog.ServiceCatalog(SERVICE_COMPATIBILITY_CATALOG) + + self.assertEqual(sc.url_for('tenantId', '1', service_type='volume'), + "https://volume1.host/v2/1234") + self.assertEqual(sc.url_for('tenantId', '2', service_type='volume'), + "https://volume1.host/v2/3456") diff --git a/tests/test_shell.py b/cinderclient/tests/test_shell.py similarity index 75% rename from tests/test_shell.py rename to cinderclient/tests/test_shell.py index 580dd25..9692847 100644 --- a/tests/test_shell.py +++ b/cinderclient/tests/test_shell.py @@ -1,14 +1,26 @@ -import cStringIO -import os +# 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 re import sys import fixtures +from six import moves from testtools import matchers from cinderclient import exceptions import cinderclient.shell -from tests import utils +from cinderclient.tests import utils class ShellTest(utils.TestCase): @@ -30,7 +42,7 @@ class ShellTest(utils.TestCase): def shell(self, argstr): orig = sys.stdout try: - sys.stdout = cStringIO.StringIO() + sys.stdout = moves.StringIO() _shell = cinderclient.shell.OpenStackCinderShell() _shell.main(argstr.split()) except SystemExit: diff --git a/tests/test_utils.py b/cinderclient/tests/test_utils.py similarity index 53% rename from tests/test_utils.py rename to cinderclient/tests/test_utils.py index 22a167a..92aee51 100644 --- a/tests/test_utils.py +++ b/cinderclient/tests/test_utils.py @@ -1,8 +1,25 @@ +# 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 collections +import sys + +from six import moves from cinderclient import exceptions from cinderclient import utils from cinderclient import base -from tests import utils as test_utils +from cinderclient.tests import utils as test_utils UUID = '8e8ec658-c7b0-4243-bdf8-6f7f2952c0d0' @@ -38,7 +55,7 @@ class FakeManager(base.ManagerWithFind): return resource raise exceptions.NotFound(resource_id) - def list(self): + def list(self, search_opts): return self.resources @@ -73,3 +90,51 @@ class FindResourceTestCase(test_utils.TestCase): def test_find_by_str_displayname(self): output = utils.find_resource(self.manager, 'entity_three') self.assertEqual(output, self.manager.get('4242')) + + +class CaptureStdout(object): + """Context manager for capturing stdout from statments in its's block.""" + def __enter__(self): + self.real_stdout = sys.stdout + self.stringio = moves.StringIO() + sys.stdout = self.stringio + return self + + def __exit__(self, *args): + sys.stdout = self.real_stdout + self.stringio.seek(0) + self.read = self.stringio.read + + +class PrintListTestCase(test_utils.TestCase): + + def test_print_list_with_list(self): + Row = collections.namedtuple('Row', ['a', 'b']) + to_print = [Row(a=1, b=2), Row(a=3, b=4)] + with CaptureStdout() as cso: + utils.print_list(to_print, ['a', 'b']) + self.assertEqual(cso.read(), """\ ++---+---+ +| a | b | ++---+---+ +| 1 | 2 | +| 3 | 4 | ++---+---+ +""") + + def test_print_list_with_generator(self): + Row = collections.namedtuple('Row', ['a', 'b']) + + def gen_rows(): + for row in [Row(a=1, b=2), Row(a=3, b=4)]: + yield row + with CaptureStdout() as cso: + utils.print_list(gen_rows(), ['a', 'b']) + self.assertEqual(cso.read(), """\ ++---+---+ +| a | b | ++---+---+ +| 1 | 2 | +| 3 | 4 | ++---+---+ +""") diff --git a/tests/utils.py b/cinderclient/tests/utils.py similarity index 67% rename from tests/utils.py rename to cinderclient/tests/utils.py index 3a12923..0deb579 100644 --- a/tests/utils.py +++ b/cinderclient/tests/utils.py @@ -1,3 +1,16 @@ +# 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 os import fixtures @@ -23,8 +36,9 @@ class TestCase(testtools.TestCase): class TestResponse(requests.Response): - """ Class used to wrap requests.Response and provide some - convenience to initialize with a dict """ + """Class used to wrap requests.Response and provide some + convenience to initialize with a dict. + """ def __init__(self, data): self._text = None diff --git a/tests/v2/contrib/__init__.py b/cinderclient/tests/v1/__init__.py similarity index 100% rename from tests/v2/contrib/__init__.py rename to cinderclient/tests/v1/__init__.py diff --git a/cinderclient/tests/v1/contrib/__init__.py b/cinderclient/tests/v1/contrib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cinderclient/tests/v1/contrib/test_list_extensions.py b/cinderclient/tests/v1/contrib/test_list_extensions.py new file mode 100644 index 0000000..96f5e35 --- /dev/null +++ b/cinderclient/tests/v1/contrib/test_list_extensions.py @@ -0,0 +1,34 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from cinderclient import extension +from cinderclient.v1.contrib import list_extensions + +from cinderclient.tests import utils +from cinderclient.tests.v1 import fakes + + +extensions = [ + extension.Extension(list_extensions.__name__.split(".")[-1], + list_extensions), +] +cs = fakes.FakeClient(extensions=extensions) + + +class ListExtensionsTests(utils.TestCase): + def test_list_extensions(self): + all_exts = cs.list_extensions.show_all() + cs.assert_called('GET', '/extensions') + self.assertTrue(len(all_exts) > 0) + for r in all_exts: + self.assertTrue(len(r.summary) > 0) diff --git a/cinderclient/tests/v1/fakes.py b/cinderclient/tests/v1/fakes.py new file mode 100644 index 0000000..648733c --- /dev/null +++ b/cinderclient/tests/v1/fakes.py @@ -0,0 +1,779 @@ +# Copyright (c) 2011 X.commerce, a business unit of eBay Inc. +# Copyright (c) 2011 OpenStack Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from datetime import datetime + +try: + import urlparse +except ImportError: + import urllib.parse as urlparse + +from cinderclient import client as base_client +from cinderclient.tests import fakes +import cinderclient.tests.utils as utils +from cinderclient.v1 import client + + +def _stub_volume(**kwargs): + volume = { + 'id': '1234', + 'display_name': None, + 'display_description': None, + "attachments": [], + "bootable": "false", + "availability_zone": "cinder", + "created_at": "2012-08-27T00:00:00.000000", + "id": '00000000-0000-0000-0000-000000000000', + "metadata": {}, + "size": 1, + "snapshot_id": None, + "status": "available", + "volume_type": "None", + } + volume.update(kwargs) + return volume + + +def _stub_snapshot(**kwargs): + snapshot = { + "created_at": "2012-08-28T16:30:31.000000", + "display_description": None, + "display_name": None, + "id": '11111111-1111-1111-1111-111111111111', + "size": 1, + "status": "available", + "volume_id": '00000000-0000-0000-0000-000000000000', + } + snapshot.update(kwargs) + return snapshot + + +def _self_href(base_uri, tenant_id, backup_id): + return '%s/v1/%s/backups/%s' % (base_uri, tenant_id, backup_id) + + +def _bookmark_href(base_uri, tenant_id, backup_id): + return '%s/%s/backups/%s' % (base_uri, tenant_id, backup_id) + + +def _stub_backup_full(id, base_uri, tenant_id): + return { + 'id': id, + 'name': 'backup', + 'description': 'nightly backup', + 'volume_id': '712f4980-5ac1-41e5-9383-390aa7c9f58b', + 'container': 'volumebackups', + 'object_count': 220, + 'size': 10, + 'availability_zone': 'az1', + 'created_at': '2013-04-12T08:16:37.000000', + 'status': 'available', + 'links': [ + { + 'href': _self_href(base_uri, tenant_id, id), + 'rel': 'self' + }, + { + 'href': _bookmark_href(base_uri, tenant_id, id), + 'rel': 'bookmark' + } + ] + } + + +def _stub_backup(id, base_uri, tenant_id): + return { + 'id': id, + 'name': 'backup', + 'links': [ + { + 'href': _self_href(base_uri, tenant_id, id), + 'rel': 'self' + }, + { + 'href': _bookmark_href(base_uri, tenant_id, id), + 'rel': 'bookmark' + } + ] + } + + +def _stub_restore(): + return {'volume_id': '712f4980-5ac1-41e5-9383-390aa7c9f58b'} + + +def _stub_qos_full(id, base_uri, tenant_id, name=None, specs=None): + if not name: + name = 'fake-name' + if not specs: + specs = {} + + return { + 'qos_specs': { + 'id': id, + 'name': name, + 'consumer': 'back-end', + 'specs': specs, + }, + 'links': { + 'href': _bookmark_href(base_uri, tenant_id, id), + 'rel': 'bookmark' + } + } + + +def _stub_qos_associates(id, name): + return { + 'assoications_type': 'volume_type', + 'name': name, + 'id': id, + } + + +def _stub_transfer_full(id, base_uri, tenant_id): + return { + 'id': id, + 'name': 'transfer', + 'volume_id': '8c05f861-6052-4df6-b3e0-0aebfbe686cc', + 'created_at': '2013-04-12T08:16:37.000000', + 'auth_key': '123456', + 'links': [ + { + 'href': _self_href(base_uri, tenant_id, id), + 'rel': 'self' + }, + { + 'href': _bookmark_href(base_uri, tenant_id, id), + 'rel': 'bookmark' + } + ] + } + + +def _stub_transfer(id, base_uri, tenant_id): + return { + 'id': id, + 'name': 'transfer', + 'volume_id': '8c05f861-6052-4df6-b3e0-0aebfbe686cc', + 'links': [ + { + 'href': _self_href(base_uri, tenant_id, id), + 'rel': 'self' + }, + { + 'href': _bookmark_href(base_uri, tenant_id, id), + 'rel': 'bookmark' + } + ] + } + + +def _stub_extend(id, new_size): + return {'volume_id': '712f4980-5ac1-41e5-9383-390aa7c9f58b'} + + +class FakeClient(fakes.FakeClient, client.Client): + + def __init__(self, *args, **kwargs): + client.Client.__init__(self, 'username', 'password', + 'project_id', 'auth_url', + extensions=kwargs.get('extensions')) + self.client = FakeHTTPClient(**kwargs) + + def get_volume_api_version_from_endpoint(self): + return self.client.get_volume_api_version_from_endpoint() + + +class FakeHTTPClient(base_client.HTTPClient): + + def __init__(self, **kwargs): + self.username = 'username' + self.password = 'password' + self.auth_url = 'auth_url' + self.callstack = [] + self.management_url = 'http://10.0.2.15:8776/v1/fake' + + def _cs_request(self, url, method, **kwargs): + # Check that certain things are called correctly + if method in ['GET', 'DELETE']: + assert 'body' not in kwargs + elif method == 'PUT': + assert 'body' in kwargs + + # Call the method + args = urlparse.parse_qsl(urlparse.urlparse(url)[4]) + kwargs.update(args) + munged_url = url.rsplit('?', 1)[0] + munged_url = munged_url.strip('/').replace('/', '_').replace('.', '_') + munged_url = munged_url.replace('-', '_') + + callback = "%s_%s" % (method.lower(), munged_url) + + if not hasattr(self, callback): + raise AssertionError('Called unknown API method: %s %s, ' + 'expected fakes method name: %s' % + (method, url, callback)) + + # Note the call + self.callstack.append((method, url, kwargs.get('body', None))) + status, headers, body = getattr(self, callback)(**kwargs) + r = utils.TestResponse({ + "status_code": status, + "text": body, + "headers": headers, + }) + return r, body + + if hasattr(status, 'items'): + return utils.TestResponse(status), body + else: + return utils.TestResponse({"status": status}), body + + def get_volume_api_version_from_endpoint(self): + magic_tuple = urlparse.urlsplit(self.management_url) + scheme, netloc, path, query, frag = magic_tuple + return path.lstrip('/').split('/')[0][1:] + + # + # Snapshots + # + + def get_snapshots_detail(self, **kw): + return (200, {}, {'snapshots': [ + _stub_snapshot(), + ]}) + + def get_snapshots_1234(self, **kw): + return (200, {}, {'snapshot': _stub_snapshot(id='1234')}) + + def get_snapshots_5678(self, **kw): + return (200, {}, {'snapshot': _stub_snapshot(id='5678')}) + + def put_snapshots_1234(self, **kw): + snapshot = _stub_snapshot(id='1234') + snapshot.update(kw['body']['snapshot']) + return (200, {}, {'snapshot': snapshot}) + + def post_snapshots_1234_action(self, body, **kw): + _body = None + resp = 202 + assert len(list(body)) == 1 + action = list(body)[0] + if action == 'os-reset_status': + assert 'status' in body['os-reset_status'] + elif action == 'os-update_snapshot_status': + assert 'status' in body['os-update_snapshot_status'] + else: + raise AssertionError("Unexpected action: %s" % action) + return (resp, {}, _body) + + def post_snapshots_5678_action(self, body, **kw): + return self.post_snapshots_1234_action(body, **kw) + + def delete_snapshots_1234(self, **kw): + return (202, {}, {}) + + # + # Volumes + # + + def put_volumes_1234(self, **kw): + volume = _stub_volume(id='1234') + volume.update(kw['body']['volume']) + return (200, {}, {'volume': volume}) + + def get_volumes(self, **kw): + return (200, {}, {"volumes": [ + {'id': 1234, 'name': 'sample-volume'}, + {'id': 5678, 'name': 'sample-volume2'} + ]}) + + # TODO(jdg): This will need to change + # at the very least it's not complete + def get_volumes_detail(self, **kw): + return (200, {}, {"volumes": [ + {'id': kw.get('id', 1234), + 'name': 'sample-volume', + 'attachments': [{'server_id': 1234}]}, + ]}) + + def get_volumes_1234(self, **kw): + r = {'volume': self.get_volumes_detail(id=1234)[2]['volumes'][0]} + return (200, {}, r) + + def get_volumes_5678(self, **kw): + r = {'volume': self.get_volumes_detail(id=5678)[2]['volumes'][0]} + return (200, {}, r) + + def get_volumes_1234_encryption(self, **kw): + r = {'encryption_key_id': 'id'} + return (200, {}, r) + + def post_volumes_1234_action(self, body, **kw): + _body = None + resp = 202 + assert len(list(body)) == 1 + action = list(body)[0] + if action == 'os-attach': + assert sorted(list(body[action])) == ['instance_uuid', + 'mode', + 'mountpoint'] + elif action == 'os-detach': + assert body[action] is None + elif action == 'os-reserve': + assert body[action] is None + elif action == 'os-unreserve': + assert body[action] is None + elif action == 'os-initialize_connection': + assert list(body[action]) == ['connector'] + return (202, {}, {'connection_info': 'foos'}) + elif action == 'os-terminate_connection': + assert list(body[action]) == ['connector'] + elif action == 'os-begin_detaching': + assert body[action] is None + elif action == 'os-roll_detaching': + assert body[action] is None + elif action == 'os-reset_status': + assert 'status' in body[action] + elif action == 'os-extend': + assert list(body[action]) == ['new_size'] + elif action == 'os-migrate_volume': + assert 'host' in body[action] + assert 'force_host_copy' in body[action] + elif action == 'os-update_readonly_flag': + assert list(body[action]) == ['readonly'] + else: + raise AssertionError("Unexpected action: %s" % action) + return (resp, {}, _body) + + def post_volumes_5678_action(self, body, **kw): + return self.post_volumes_1234_action(body, **kw) + + def post_volumes(self, **kw): + return (202, {}, {'volume': {}}) + + def delete_volumes_1234(self, **kw): + return (202, {}, None) + + def delete_volumes_5678(self, **kw): + return (202, {}, None) + + # + # Quotas + # + + def get_os_quota_sets_test(self, **kw): + return (200, {}, {'quota_set': { + 'tenant_id': 'test', + 'metadata_items': [], + 'volumes': 1, + 'snapshots': 1, + 'gigabytes': 1}}) + + def get_os_quota_sets_test_defaults(self): + return (200, {}, {'quota_set': { + 'tenant_id': 'test', + 'metadata_items': [], + 'volumes': 1, + 'snapshots': 1, + 'gigabytes': 1}}) + + def put_os_quota_sets_test(self, body, **kw): + assert list(body) == ['quota_set'] + fakes.assert_has_keys(body['quota_set'], + required=['tenant_id']) + return (200, {}, {'quota_set': { + 'tenant_id': 'test', + 'metadata_items': [], + 'volumes': 2, + 'snapshots': 2, + 'gigabytes': 1}}) + + # + # Quota Classes + # + + def get_os_quota_class_sets_test(self, **kw): + return (200, {}, {'quota_class_set': { + 'class_name': 'test', + 'metadata_items': [], + 'volumes': 1, + 'snapshots': 1, + 'gigabytes': 1}}) + + def put_os_quota_class_sets_test(self, body, **kw): + assert list(body) == ['quota_class_set'] + fakes.assert_has_keys(body['quota_class_set'], + required=['class_name']) + return (200, {}, {'quota_class_set': { + 'class_name': 'test', + 'metadata_items': [], + 'volumes': 2, + 'snapshots': 2, + 'gigabytes': 1}}) + + # + # VolumeTypes + # + def get_types(self, **kw): + return (200, {}, { + 'volume_types': [{'id': 1, + 'name': 'test-type-1', + 'extra_specs': {}}, + {'id': 2, + 'name': 'test-type-2', + 'extra_specs': {}}]}) + + def get_types_1(self, **kw): + return (200, {}, {'volume_type': {'id': 1, + 'name': 'test-type-1', + 'extra_specs': {}}}) + + def get_types_2(self, **kw): + return (200, {}, {'volume_type': {'id': 2, + 'name': 'test-type-2', + 'extra_specs': {}}}) + + def post_types(self, body, **kw): + return (202, {}, {'volume_type': {'id': 3, + 'name': 'test-type-3', + 'extra_specs': {}}}) + + def post_types_1_extra_specs(self, body, **kw): + assert list(body) == ['extra_specs'] + return (200, {}, {'extra_specs': {'k': 'v'}}) + + def delete_types_1_extra_specs_k(self, **kw): + return(204, {}, None) + + def delete_types_1(self, **kw): + return (202, {}, None) + + # + # VolumeEncryptionTypes + # + def get_types_1_encryption(self, **kw): + return (200, {}, {'id': 1, 'volume_type_id': 1, 'provider': 'test', + 'cipher': 'test', 'key_size': 1, + 'control_location': 'front'}) + + def get_types_2_encryption(self, **kw): + return (200, {}, {}) + + def post_types_2_encryption(self, body, **kw): + return (200, {}, {'encryption': {}}) + + def put_types_1_encryption_1(self, body, **kw): + return (200, {}, {}) + + def delete_types_1_encryption_provider(self, **kw): + return (202, {}, None) + + # + # Set/Unset metadata + # + def delete_volumes_1234_metadata_test_key(self, **kw): + return (204, {}, None) + + def delete_volumes_1234_metadata_key1(self, **kw): + return (204, {}, None) + + def delete_volumes_1234_metadata_key2(self, **kw): + return (204, {}, None) + + def post_volumes_1234_metadata(self, **kw): + return (204, {}, {'metadata': {'test_key': 'test_value'}}) + + # + # List all extensions + # + def get_extensions(self, **kw): + exts = [ + { + "alias": "FAKE-1", + "description": "Fake extension number 1", + "links": [], + "name": "Fake1", + "namespace": ("http://docs.openstack.org/" + "/ext/fake1/api/v1.1"), + "updated": "2011-06-09T00:00:00+00:00" + }, + { + "alias": "FAKE-2", + "description": "Fake extension number 2", + "links": [], + "name": "Fake2", + "namespace": ("http://docs.openstack.org/" + "/ext/fake1/api/v1.1"), + "updated": "2011-06-09T00:00:00+00:00" + }, + ] + return (200, {}, {"extensions": exts, }) + + # + # VolumeBackups + # + + def get_backups_76a17945_3c6f_435c_975b_b5685db10b62(self, **kw): + base_uri = 'http://localhost:8776' + tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' + backup1 = '76a17945-3c6f-435c-975b-b5685db10b62' + return (200, {}, + {'backup': _stub_backup_full(backup1, base_uri, tenant_id)}) + + def get_backups_detail(self, **kw): + base_uri = 'http://localhost:8776' + tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' + backup1 = '76a17945-3c6f-435c-975b-b5685db10b62' + backup2 = 'd09534c6-08b8-4441-9e87-8976f3a8f699' + return (200, {}, + {'backups': [ + _stub_backup_full(backup1, base_uri, tenant_id), + _stub_backup_full(backup2, base_uri, tenant_id)]}) + + def delete_backups_76a17945_3c6f_435c_975b_b5685db10b62(self, **kw): + return (202, {}, None) + + def post_backups(self, **kw): + base_uri = 'http://localhost:8776' + tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' + backup1 = '76a17945-3c6f-435c-975b-b5685db10b62' + return (202, {}, + {'backup': _stub_backup(backup1, base_uri, tenant_id)}) + + def post_backups_76a17945_3c6f_435c_975b_b5685db10b62_restore(self, **kw): + return (200, {}, + {'restore': _stub_restore()}) + + def post_backups_1234_restore(self, **kw): + return (200, {}, + {'restore': _stub_restore()}) + + # + # QoSSpecs + # + + def get_qos_specs_1B6B6A04_A927_4AEB_810B_B7BAAD49F57C(self, **kw): + base_uri = 'http://localhost:8776' + tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' + qos_id1 = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + return (200, {}, + _stub_qos_full(qos_id1, base_uri, tenant_id)) + + def get_qos_specs(self, **kw): + base_uri = 'http://localhost:8776' + tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' + qos_id1 = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + qos_id2 = '0FD8DD14-A396-4E55-9573-1FE59042E95B' + return (200, {}, + {'qos_specs': [ + _stub_qos_full(qos_id1, base_uri, tenant_id, 'name-1'), + _stub_qos_full(qos_id2, base_uri, tenant_id)]}) + + def post_qos_specs(self, **kw): + base_uri = 'http://localhost:8776' + tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' + qos_id = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + qos_name = 'qos-name' + return (202, {}, + _stub_qos_full(qos_id, base_uri, tenant_id, qos_name)) + + def put_qos_specs_1B6B6A04_A927_4AEB_810B_B7BAAD49F57C(self, **kw): + return (202, {}, None) + + def put_qos_specs_1B6B6A04_A927_4AEB_810B_B7BAAD49F57C_delete_keys( + self, **kw): + return (202, {}, None) + + def delete_qos_specs_1B6B6A04_A927_4AEB_810B_B7BAAD49F57C(self, **kw): + return (202, {}, None) + + def get_qos_specs_1B6B6A04_A927_4AEB_810B_B7BAAD49F57C_associations( + self, **kw): + type_id1 = '4230B13A-7A37-4E84-B777-EFBA6FCEE4FF' + type_id2 = '4230B13A-AB37-4E84-B777-EFBA6FCEE4FF' + type_name1 = 'type1' + type_name2 = 'type2' + return (202, {}, + {'qos_associations': [ + _stub_qos_associates(type_id1, type_name1), + _stub_qos_associates(type_id2, type_name2)]}) + + def get_qos_specs_1B6B6A04_A927_4AEB_810B_B7BAAD49F57C_associate( + self, **kw): + return (202, {}, None) + + def get_qos_specs_1B6B6A04_A927_4AEB_810B_B7BAAD49F57C_disassociate( + self, **kw): + return (202, {}, None) + + def get_qos_specs_1B6B6A04_A927_4AEB_810B_B7BAAD49F57C_disassociate_all( + self, **kw): + return (202, {}, None) + + # + # VolumeTransfers + # + + def get_os_volume_transfer_5678(self, **kw): + base_uri = 'http://localhost:8776' + tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' + transfer1 = '5678' + return (200, {}, + {'transfer': + _stub_transfer_full(transfer1, base_uri, tenant_id)}) + + def get_os_volume_transfer_detail(self, **kw): + base_uri = 'http://localhost:8776' + tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' + transfer1 = '5678' + transfer2 = 'f625ec3e-13dd-4498-a22a-50afd534cc41' + return (200, {}, + {'transfers': [ + _stub_transfer_full(transfer1, base_uri, tenant_id), + _stub_transfer_full(transfer2, base_uri, tenant_id)]}) + + def delete_os_volume_transfer_5678(self, **kw): + return (202, {}, None) + + def post_os_volume_transfer(self, **kw): + base_uri = 'http://localhost:8776' + tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' + transfer1 = '5678' + return (202, {}, + {'transfer': _stub_transfer(transfer1, base_uri, tenant_id)}) + + def post_os_volume_transfer_5678_accept(self, **kw): + base_uri = 'http://localhost:8776' + tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' + transfer1 = '5678' + return (200, {}, + {'transfer': _stub_transfer(transfer1, base_uri, tenant_id)}) + + # + # Services + # + def get_os_services(self, **kw): + host = kw.get('host', None) + binary = kw.get('binary', None) + services = [ + { + 'binary': 'cinder-volume', + 'host': 'host1', + 'zone': 'cinder', + 'status': 'enabled', + 'state': 'up', + 'updated_at': datetime(2012, 10, 29, 13, 42, 2) + }, + { + 'binary': 'cinder-volume', + 'host': 'host2', + 'zone': 'cinder', + 'status': 'disabled', + 'state': 'down', + 'updated_at': datetime(2012, 9, 18, 8, 3, 38) + }, + { + 'binary': 'cinder-scheduler', + 'host': 'host2', + 'zone': 'cinder', + 'status': 'disabled', + 'state': 'down', + 'updated_at': datetime(2012, 9, 18, 8, 3, 38) + }, + ] + if host: + services = filter(lambda i: i['host'] == host, services) + if binary: + services = filter(lambda i: i['binary'] == binary, services) + return (200, {}, {'services': services}) + + def put_os_services_enable(self, body, **kw): + return (200, {}, {'host': body['host'], 'binary': body['binary'], + 'status': 'enabled'}) + + def put_os_services_disable(self, body, **kw): + return (200, {}, {'host': body['host'], 'binary': body['binary'], + 'status': 'disabled'}) + + def get_os_availability_zone(self, **kw): + return (200, {}, { + "availabilityZoneInfo": [ + { + "zoneName": "zone-1", + "zoneState": {"available": True}, + "hosts": None, + }, + { + "zoneName": "zone-2", + "zoneState": {"available": False}, + "hosts": None, + }, + ] + }) + + def get_os_availability_zone_detail(self, **kw): + return (200, {}, { + "availabilityZoneInfo": [ + { + "zoneName": "zone-1", + "zoneState": {"available": True}, + "hosts": { + "fake_host-1": { + "cinder-volume": { + "active": True, + "available": True, + "updated_at": + datetime(2012, 12, 26, 14, 45, 25, 0) + } + } + } + }, + { + "zoneName": "internal", + "zoneState": {"available": True}, + "hosts": { + "fake_host-1": { + "cinder-sched": { + "active": True, + "available": True, + "updated_at": + datetime(2012, 12, 26, 14, 45, 24, 0) + } + } + } + }, + { + "zoneName": "zone-2", + "zoneState": {"available": False}, + "hosts": None, + }, + ] + }) + + def post_snapshots_1234_metadata(self, **kw): + return (200, {}, {"metadata": {"key1": "val1", "key2": "val2"}}) + + def delete_snapshots_1234_metadata_key1(self, **kw): + return (200, {}, None) + + def delete_snapshots_1234_metadata_key2(self, **kw): + return (200, {}, None) + + def put_volumes_1234_metadata(self, **kw): + return (200, {}, {"metadata": {"key1": "val1", "key2": "val2"}}) + + def put_snapshots_1234_metadata(self, **kw): + return (200, {}, {"metadata": {"key1": "val1", "key2": "val2"}}) diff --git a/tests/v1/test_auth.py b/cinderclient/tests/v1/test_auth.py similarity index 88% rename from tests/v1/test_auth.py rename to cinderclient/tests/v1/test_auth.py index 4ad1231..fa9a126 100644 --- a/tests/v1/test_auth.py +++ b/cinderclient/tests/v1/test_auth.py @@ -1,3 +1,16 @@ +# 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 json import mock @@ -5,13 +18,13 @@ import requests from cinderclient.v1 import client from cinderclient import exceptions -from tests import utils +from cinderclient.tests import utils class AuthenticateAgainstKeystoneTests(utils.TestCase): def test_authenticate_success(self): cs = client.Client("username", "password", "project_id", - "auth_url/v2.0", service_type='compute') + "http://localhost:8776/v1", service_type='volume') resp = { "access": { "token": { @@ -20,13 +33,13 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): }, "serviceCatalog": [ { - "type": "compute", + "type": "volume", "endpoints": [ { "region": "RegionOne", - "adminURL": "http://localhost:8774/v1", - "internalURL": "http://localhost:8774/v1", - "publicURL": "http://localhost:8774/v1/", + "adminURL": "http://localhost:8776/v1", + "internalURL": "http://localhost:8776/v1", + "publicURL": "http://localhost:8776/v1", }, ], }, @@ -76,8 +89,9 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): test_auth_call() def test_authenticate_tenant_id(self): - cs = client.Client("username", "password", auth_url="auth_url/v2.0", - tenant_id='tenant_id', service_type='compute') + cs = client.Client("username", "password", + auth_url="http://localhost:8776/v1", + tenant_id='tenant_id', service_type='volume') resp = { "access": { "token": { @@ -92,13 +106,13 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): }, "serviceCatalog": [ { - "type": "compute", + "type": "volume", "endpoints": [ { "region": "RegionOne", - "adminURL": "http://localhost:8774/v1", - "internalURL": "http://localhost:8774/v1", - "publicURL": "http://localhost:8774/v1/", + "adminURL": "http://localhost:8776/v1", + "internalURL": "http://localhost:8776/v1", + "publicURL": "http://localhost:8776/v1", }, ], }, @@ -151,7 +165,7 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): def test_authenticate_failure(self): cs = client.Client("username", "password", "project_id", - "auth_url/v2.0") + "http://localhost:8776/v1") resp = {"unauthorized": {"message": "Unauthorized", "code": "401"}} auth_response = utils.TestResponse({ "status_code": 401, @@ -168,7 +182,7 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): def test_auth_redirect(self): cs = client.Client("username", "password", "project_id", - "auth_url/v1", service_type='compute') + "http://localhost:8776/v1", service_type='volume') dict_correct_response = { "access": { "token": { @@ -177,13 +191,13 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): }, "serviceCatalog": [ { - "type": "compute", + "type": "volume", "endpoints": [ { - "adminURL": "http://localhost:8774/v1", + "adminURL": "http://localhost:8776/v1", "region": "RegionOne", - "internalURL": "http://localhost:8774/v1", - "publicURL": "http://localhost:8774/v1/", + "internalURL": "http://localhost:8776/v1", + "publicURL": "http://localhost:8776/v1/", }, ], }, @@ -192,7 +206,7 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): } correct_response = json.dumps(dict_correct_response) dict_responses = [ - {"headers": {'location':'http://127.0.0.1:5001'}, + {"headers": {'location': 'http://127.0.0.1:5001'}, "status_code": 305, "text": "Use proxy"}, # Configured on admin port, cinder redirects to v2.0 port. @@ -252,7 +266,7 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): def test_ambiguous_endpoints(self): cs = client.Client("username", "password", "project_id", - "auth_url/v2.0", service_type='compute') + "http://localhost:8776/v1", service_type='volume') resp = { "access": { "token": { @@ -261,25 +275,25 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): }, "serviceCatalog": [ { - "adminURL": "http://localhost:8774/v1", - "type": "compute", - "name": "Compute CLoud", + "adminURL": "http://localhost:8776/v1", + "type": "volume", + "name": "Cinder Volume Service", "endpoints": [ { "region": "RegionOne", - "internalURL": "http://localhost:8774/v1", - "publicURL": "http://localhost:8774/v1/", + "internalURL": "http://localhost:8776/v1", + "publicURL": "http://localhost:8776/v1", }, ], }, { - "adminURL": "http://localhost:8774/v1", - "type": "compute", - "name": "Hyper-compute Cloud", + "adminURL": "http://localhost:8776/v1", + "type": "volume", + "name": "Cinder Volume Cloud Service", "endpoints": [ { - "internalURL": "http://localhost:8774/v1", - "publicURL": "http://localhost:8774/v1/", + "internalURL": "http://localhost:8776/v1", + "publicURL": "http://localhost:8776/v1", }, ], }, diff --git a/cinderclient/tests/v1/test_availability_zone.py b/cinderclient/tests/v1/test_availability_zone.py new file mode 100644 index 0000000..a2e1fc8 --- /dev/null +++ b/cinderclient/tests/v1/test_availability_zone.py @@ -0,0 +1,87 @@ +# Copyright 2011-2013 OpenStack Foundation +# Copyright 2013 IBM Corp. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import six + +from cinderclient.v1 import availability_zones +from cinderclient.v1 import shell +from cinderclient.tests import utils +from cinderclient.tests.v1 import fakes + + +cs = fakes.FakeClient() + + +class AvailabilityZoneTest(utils.TestCase): + + def _assertZone(self, zone, name, status): + self.assertEqual(zone.zoneName, name) + self.assertEqual(zone.zoneState, status) + + def test_list_availability_zone(self): + zones = cs.availability_zones.list(detailed=False) + cs.assert_called('GET', '/os-availability-zone') + + for zone in zones: + self.assertTrue(isinstance(zone, + availability_zones.AvailabilityZone)) + + self.assertEqual(2, len(zones)) + + l0 = [six.u('zone-1'), six.u('available')] + l1 = [six.u('zone-2'), six.u('not available')] + + z0 = shell._treeizeAvailabilityZone(zones[0]) + z1 = shell._treeizeAvailabilityZone(zones[1]) + + self.assertEqual((len(z0), len(z1)), (1, 1)) + + self._assertZone(z0[0], l0[0], l0[1]) + self._assertZone(z1[0], l1[0], l1[1]) + + def test_detail_availability_zone(self): + zones = cs.availability_zones.list(detailed=True) + cs.assert_called('GET', '/os-availability-zone/detail') + + for zone in zones: + self.assertTrue(isinstance(zone, + availability_zones.AvailabilityZone)) + + self.assertEqual(3, len(zones)) + + l0 = [six.u('zone-1'), six.u('available')] + l1 = [six.u('|- fake_host-1'), six.u('')] + l2 = [six.u('| |- cinder-volume'), + six.u('enabled :-) 2012-12-26 14:45:25')] + l3 = [six.u('internal'), six.u('available')] + l4 = [six.u('|- fake_host-1'), six.u('')] + l5 = [six.u('| |- cinder-sched'), + six.u('enabled :-) 2012-12-26 14:45:24')] + l6 = [six.u('zone-2'), six.u('not available')] + + z0 = shell._treeizeAvailabilityZone(zones[0]) + z1 = shell._treeizeAvailabilityZone(zones[1]) + z2 = shell._treeizeAvailabilityZone(zones[2]) + + self.assertEqual((len(z0), len(z1), len(z2)), (3, 3, 1)) + + self._assertZone(z0[0], l0[0], l0[1]) + self._assertZone(z0[1], l1[0], l1[1]) + self._assertZone(z0[2], l2[0], l2[1]) + self._assertZone(z1[0], l3[0], l3[1]) + self._assertZone(z1[1], l4[0], l4[1]) + self._assertZone(z1[2], l5[0], l5[1]) + self._assertZone(z2[0], l6[0], l6[1]) diff --git a/cinderclient/tests/v1/test_limits.py b/cinderclient/tests/v1/test_limits.py new file mode 100644 index 0000000..b4520e3 --- /dev/null +++ b/cinderclient/tests/v1/test_limits.py @@ -0,0 +1,164 @@ +# Copyright 2014 OpenStack Foundation +# +# 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 mock + +from cinderclient.tests import utils +from cinderclient.v1 import limits + + +def _get_default_RateLimit(verb="verb1", uri="uri1", regex="regex1", + value="value1", + remain="remain1", unit="unit1", + next_available="next1"): + return limits.RateLimit(verb, uri, regex, value, remain, unit, + next_available) + + +class TestLimits(utils.TestCase): + def test_repr(self): + l = limits.Limits(None, {"foo": "bar"}) + self.assertEqual("", repr(l)) + + def test_absolute(self): + l = limits.Limits(None, + {"absolute": {"name1": "value1", "name2": "value2"}}) + l1 = limits.AbsoluteLimit("name1", "value1") + l2 = limits.AbsoluteLimit("name2", "value2") + for item in l.absolute: + self.assertIn(item, [l1, l2]) + + def test_rate(self): + l = limits.Limits(None, + { + "rate": [ + { + "uri": "uri1", + "regex": "regex1", + "limit": [ + { + "verb": "verb1", + "value": "value1", + "remaining": "remain1", + "unit": "unit1", + "next-available": "next1", + }, + ], + }, + { + "uri": "uri2", + "regex": "regex2", + "limit": [ + { + "verb": "verb2", + "value": "value2", + "remaining": "remain2", + "unit": "unit2", + "next-available": "next2", + }, + ], + }, + ], + }) + l1 = limits.RateLimit("verb1", "uri1", "regex1", "value1", "remain1", + "unit1", "next1") + l2 = limits.RateLimit("verb2", "uri2", "regex2", "value2", "remain2", + "unit2", "next2") + for item in l.rate: + self.assertTrue(item in [l1, l2]) + + +class TestRateLimit(utils.TestCase): + def test_equal(self): + l1 = _get_default_RateLimit() + l2 = _get_default_RateLimit() + self.assertTrue(l1 == l2) + + def test_not_equal_verbs(self): + l1 = _get_default_RateLimit() + l2 = _get_default_RateLimit(verb="verb2") + self.assertFalse(l1 == l2) + + def test_not_equal_uris(self): + l1 = _get_default_RateLimit() + l2 = _get_default_RateLimit(uri="uri2") + self.assertFalse(l1 == l2) + + def test_not_equal_regexps(self): + l1 = _get_default_RateLimit() + l2 = _get_default_RateLimit(regex="regex2") + self.assertFalse(l1 == l2) + + def test_not_equal_values(self): + l1 = _get_default_RateLimit() + l2 = _get_default_RateLimit(value="value2") + self.assertFalse(l1 == l2) + + def test_not_equal_remains(self): + l1 = _get_default_RateLimit() + l2 = _get_default_RateLimit(remain="remain2") + self.assertFalse(l1 == l2) + + def test_not_equal_units(self): + l1 = _get_default_RateLimit() + l2 = _get_default_RateLimit(unit="unit2") + self.assertFalse(l1 == l2) + + def test_not_equal_next_available(self): + l1 = _get_default_RateLimit() + l2 = _get_default_RateLimit(next_available="next2") + self.assertFalse(l1 == l2) + + def test_repr(self): + l1 = _get_default_RateLimit() + self.assertEqual("", repr(l1)) + + +class TestAbsoluteLimit(utils.TestCase): + def test_equal(self): + l1 = limits.AbsoluteLimit("name1", "value1") + l2 = limits.AbsoluteLimit("name1", "value1") + self.assertTrue(l1 == l2) + + def test_not_equal_values(self): + l1 = limits.AbsoluteLimit("name1", "value1") + l2 = limits.AbsoluteLimit("name1", "value2") + self.assertFalse(l1 == l2) + + def test_not_equal_names(self): + l1 = limits.AbsoluteLimit("name1", "value1") + l2 = limits.AbsoluteLimit("name2", "value1") + self.assertFalse(l1 == l2) + + def test_repr(self): + l1 = limits.AbsoluteLimit("name1", "value1") + self.assertEqual("", repr(l1)) + + +class TestLimitsManager(utils.TestCase): + def test_get(self): + api = mock.Mock() + api.client.get.return_value = ( + None, + {"limits": {"absolute": {"name1": "value1", }}, + "no-limits": {"absolute": {"name2": "value2", }}}) + l1 = limits.AbsoluteLimit("name1", "value1") + limitsManager = limits.LimitsManager(api) + + lim = limitsManager.get() + + self.assertIsInstance(lim, limits.Limits) + for l in lim.absolute: + self.assertEqual(l, l1) diff --git a/cinderclient/tests/v1/test_qos.py b/cinderclient/tests/v1/test_qos.py new file mode 100644 index 0000000..e127a47 --- /dev/null +++ b/cinderclient/tests/v1/test_qos.py @@ -0,0 +1,79 @@ +# Copyright (C) 2013 eBay Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from cinderclient.tests import utils +from cinderclient.tests.v1 import fakes + + +cs = fakes.FakeClient() + + +class QoSSpecsTest(utils.TestCase): + + def test_create(self): + specs = dict(k1='v1', k2='v2') + cs.qos_specs.create('qos-name', specs) + cs.assert_called('POST', '/qos-specs') + + def test_get(self): + qos_id = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + cs.qos_specs.get(qos_id) + cs.assert_called('GET', '/qos-specs/%s' % qos_id) + + def test_list(self): + cs.qos_specs.list() + cs.assert_called('GET', '/qos-specs') + + def test_delete(self): + cs.qos_specs.delete('1B6B6A04-A927-4AEB-810B-B7BAAD49F57C') + cs.assert_called('DELETE', + '/qos-specs/1B6B6A04-A927-4AEB-810B-B7BAAD49F57C?' + 'force=False') + + def test_set_keys(self): + body = {'qos_specs': dict(k1='v1')} + qos_id = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + cs.qos_specs.set_keys(qos_id, body) + cs.assert_called('PUT', '/qos-specs/%s' % qos_id) + + def test_unset_keys(self): + qos_id = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + body = {'keys': ['k1']} + cs.qos_specs.unset_keys(qos_id, body) + cs.assert_called('PUT', '/qos-specs/%s/delete_keys' % qos_id) + + def test_get_associations(self): + qos_id = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + cs.qos_specs.get_associations(qos_id) + cs.assert_called('GET', '/qos-specs/%s/associations' % qos_id) + + def test_associate(self): + qos_id = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + type_id = '4230B13A-7A37-4E84-B777-EFBA6FCEE4FF' + cs.qos_specs.associate(qos_id, type_id) + cs.assert_called('GET', '/qos-specs/%s/associate?vol_type_id=%s' + % (qos_id, type_id)) + + def test_disassociate(self): + qos_id = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + type_id = '4230B13A-7A37-4E84-B777-EFBA6FCEE4FF' + cs.qos_specs.disassociate(qos_id, type_id) + cs.assert_called('GET', '/qos-specs/%s/disassociate?vol_type_id=%s' + % (qos_id, type_id)) + + def test_disassociate_all(self): + qos_id = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + cs.qos_specs.disassociate_all(qos_id) + cs.assert_called('GET', '/qos-specs/%s/disassociate_all' % qos_id) diff --git a/tests/v2/test_quota_classes.py b/cinderclient/tests/v1/test_quota_classes.py similarity index 88% rename from tests/v2/test_quota_classes.py rename to cinderclient/tests/v1/test_quota_classes.py index 6be6cc0..0cb3122 100644 --- a/tests/v2/test_quota_classes.py +++ b/cinderclient/tests/v1/test_quota_classes.py @@ -1,4 +1,4 @@ -# Copyright 2013 OpenStack LLC. +# Copyright (c) 2011 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -13,8 +13,8 @@ # License for the specific language governing permissions and limitations # under the License. -from tests import utils -from tests.v2 import fakes +from cinderclient.tests import utils +from cinderclient.tests.v1 import fakes cs = fakes.FakeClient() @@ -29,7 +29,7 @@ class QuotaClassSetsTest(utils.TestCase): def test_update_quota(self): q = cs.quota_classes.get('test') - q.update(volumes=2) + q.update(volumes=2, snapshots=2) cs.assert_called('PUT', '/os-quota-class-sets/test') def test_refresh_quota(self): diff --git a/tests/v2/test_quotas.py b/cinderclient/tests/v1/test_quotas.py similarity index 89% rename from tests/v2/test_quotas.py rename to cinderclient/tests/v1/test_quotas.py index d222e83..4751954 100644 --- a/tests/v2/test_quotas.py +++ b/cinderclient/tests/v1/test_quotas.py @@ -1,4 +1,4 @@ -# Copyright 2013 OpenStack LLC. +# Copyright (c) 2011 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -13,8 +13,8 @@ # License for the specific language governing permissions and limitations # under the License. -from tests import utils -from tests.v2 import fakes +from cinderclient.tests import utils +from cinderclient.tests.v1 import fakes cs = fakes.FakeClient() @@ -25,7 +25,7 @@ class QuotaSetsTest(utils.TestCase): def test_tenant_quotas_get(self): tenant_id = 'test' cs.quotas.get(tenant_id) - cs.assert_called('GET', '/os-quota-sets/%s' % tenant_id) + cs.assert_called('GET', '/os-quota-sets/%s?usage=False' % tenant_id) def test_tenant_quotas_defaults(self): tenant_id = 'test' diff --git a/cinderclient/tests/v1/test_services.py b/cinderclient/tests/v1/test_services.py new file mode 100644 index 0000000..de89935 --- /dev/null +++ b/cinderclient/tests/v1/test_services.py @@ -0,0 +1,66 @@ +# Copyright (c) 2013 OpenStack Foundation +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from cinderclient.tests import utils +from cinderclient.tests.v1 import fakes +from cinderclient.v1 import services + + +cs = fakes.FakeClient() + + +class ServicesTest(utils.TestCase): + + def test_list_services(self): + svs = cs.services.list() + cs.assert_called('GET', '/os-services') + self.assertEqual(len(svs), 3) + [self.assertTrue(isinstance(s, services.Service)) for s in svs] + + def test_list_services_with_hostname(self): + svs = cs.services.list(host='host2') + cs.assert_called('GET', '/os-services?host=host2') + self.assertEqual(len(svs), 2) + [self.assertTrue(isinstance(s, services.Service)) for s in svs] + [self.assertEqual(s.host, 'host2') for s in svs] + + def test_list_services_with_binary(self): + svs = cs.services.list(binary='cinder-volume') + cs.assert_called('GET', '/os-services?binary=cinder-volume') + self.assertEqual(len(svs), 2) + [self.assertTrue(isinstance(s, services.Service)) for s in svs] + [self.assertEqual(s.binary, 'cinder-volume') for s in svs] + + def test_list_services_with_host_binary(self): + svs = cs.services.list('host2', 'cinder-volume') + cs.assert_called('GET', '/os-services?host=host2&binary=cinder-volume') + self.assertEqual(len(svs), 1) + [self.assertTrue(isinstance(s, services.Service)) for s in svs] + [self.assertEqual(s.host, 'host2') for s in svs] + [self.assertEqual(s.binary, 'cinder-volume') for s in svs] + + def test_services_enable(self): + s = cs.services.enable('host1', 'cinder-volume') + values = {"host": "host1", 'binary': 'cinder-volume'} + cs.assert_called('PUT', '/os-services/enable', values) + self.assertTrue(isinstance(s, services.Service)) + self.assertEqual(s.status, 'enabled') + + def test_services_disable(self): + s = cs.services.disable('host1', 'cinder-volume') + values = {"host": "host1", 'binary': 'cinder-volume'} + cs.assert_called('PUT', '/os-services/disable', values) + self.assertTrue(isinstance(s, services.Service)) + self.assertEqual(s.status, 'disabled') diff --git a/cinderclient/tests/v1/test_shell.py b/cinderclient/tests/v1/test_shell.py new file mode 100644 index 0000000..8c2e33d --- /dev/null +++ b/cinderclient/tests/v1/test_shell.py @@ -0,0 +1,357 @@ +# Copyright 2010 Jacob Kaplan-Moss + +# Copyright (c) 2011 OpenStack Foundation +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import fixtures + +from cinderclient import client +from cinderclient import shell +from cinderclient.v1 import shell as shell_v1 +from cinderclient.tests.v1 import fakes +from cinderclient.tests import utils + + +class ShellTest(utils.TestCase): + + FAKE_ENV = { + 'CINDER_USERNAME': 'username', + 'CINDER_PASSWORD': 'password', + 'CINDER_PROJECT_ID': 'project_id', + 'OS_VOLUME_API_VERSION': '1', + 'CINDER_URL': 'http://no.where', + } + + # Patch os.environ to avoid required auth info. + def setUp(self): + """Run before each test.""" + super(ShellTest, self).setUp() + for var in self.FAKE_ENV: + self.useFixture(fixtures.EnvironmentVariable(var, + self.FAKE_ENV[var])) + + self.shell = shell.OpenStackCinderShell() + + #HACK(bcwaldon): replace this when we start using stubs + self.old_get_client_class = client.get_client_class + client.get_client_class = lambda *_: fakes.FakeClient + + def tearDown(self): + # For some method like test_image_meta_bad_action we are + # testing a SystemExit to be thrown and object self.shell has + # no time to get instantatiated which is OK in this case, so + # we make sure the method is there before launching it. + if hasattr(self.shell, 'cs'): + self.shell.cs.clear_callstack() + + #HACK(bcwaldon): replace this when we start using stubs + client.get_client_class = self.old_get_client_class + super(ShellTest, self).tearDown() + + 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_anytime(self, method, url, body=None): + return self.shell.cs.assert_called_anytime(method, url, body) + + def test_extract_metadata(self): + # mimic the result of argparse's parse_args() method + class Arguments: + def __init__(self, metadata=[]): + self.metadata = metadata + + inputs = [ + ([], {}), + (["key=value"], {"key": "value"}), + (["key"], {"key": None}), + (["k1=v1", "k2=v2"], {"k1": "v1", "k2": "v2"}), + (["k1=v1", "k2"], {"k1": "v1", "k2": None}), + (["k1", "k2=v2"], {"k1": None, "k2": "v2"}) + ] + + for input in inputs: + args = Arguments(metadata=input[0]) + self.assertEqual(shell_v1._extract_metadata(args), input[1]) + + def test_list(self): + self.run_command('list') + # NOTE(jdg): we default to detail currently + self.assert_called('GET', '/volumes/detail') + + def test_list_filter_status(self): + self.run_command('list --status=available') + self.assert_called('GET', '/volumes/detail?status=available') + + def test_list_filter_display_name(self): + self.run_command('list --display-name=1234') + self.assert_called('GET', '/volumes/detail?display_name=1234') + + def test_list_all_tenants(self): + self.run_command('list --all-tenants=1') + self.assert_called('GET', '/volumes/detail?all_tenants=1') + + def test_list_availability_zone(self): + self.run_command('availability-zone-list') + self.assert_called('GET', '/os-availability-zone') + + def test_show(self): + self.run_command('show 1234') + self.assert_called('GET', '/volumes/1234') + + def test_delete(self): + self.run_command('delete 1234') + self.assert_called('DELETE', '/volumes/1234') + + def test_delete_by_name(self): + self.run_command('delete sample-volume') + self.assert_called_anytime('GET', '/volumes/detail?all_tenants=1') + self.assert_called('DELETE', '/volumes/1234') + + def test_delete_multiple(self): + self.run_command('delete 1234 5678') + self.assert_called_anytime('DELETE', '/volumes/1234') + self.assert_called('DELETE', '/volumes/5678') + + def test_backup(self): + self.run_command('backup-create 1234') + self.assert_called('POST', '/backups') + + def test_restore(self): + self.run_command('backup-restore 1234') + self.assert_called('POST', '/backups/1234/restore') + + def test_snapshot_list_filter_volume_id(self): + self.run_command('snapshot-list --volume-id=1234') + self.assert_called('GET', '/snapshots/detail?volume_id=1234') + + def test_snapshot_list_filter_status_and_volume_id(self): + self.run_command('snapshot-list --status=available --volume-id=1234') + self.assert_called('GET', '/snapshots/detail?' + 'status=available&volume_id=1234') + + def test_rename(self): + # basic rename with positional arguments + self.run_command('rename 1234 new-name') + expected = {'volume': {'display_name': 'new-name'}} + self.assert_called('PUT', '/volumes/1234', body=expected) + # change description only + self.run_command('rename 1234 --display-description=new-description') + expected = {'volume': {'display_description': 'new-description'}} + self.assert_called('PUT', '/volumes/1234', body=expected) + # rename and change description + self.run_command('rename 1234 new-name ' + '--display-description=new-description') + expected = {'volume': { + 'display_name': 'new-name', + 'display_description': 'new-description', + }} + self.assert_called('PUT', '/volumes/1234', body=expected) + + # Call rename with no arguments + self.assertRaises(SystemExit, self.run_command, 'rename') + + def test_rename_snapshot(self): + # basic rename with positional arguments + self.run_command('snapshot-rename 1234 new-name') + expected = {'snapshot': {'display_name': 'new-name'}} + self.assert_called('PUT', '/snapshots/1234', body=expected) + # change description only + self.run_command('snapshot-rename 1234 ' + '--display-description=new-description') + expected = {'snapshot': {'display_description': 'new-description'}} + self.assert_called('PUT', '/snapshots/1234', body=expected) + # snapshot-rename and change description + self.run_command('snapshot-rename 1234 new-name ' + '--display-description=new-description') + expected = {'snapshot': { + 'display_name': 'new-name', + 'display_description': 'new-description', + }} + self.assert_called('PUT', '/snapshots/1234', body=expected) + + # Call snapshot-rename with no arguments + self.assertRaises(SystemExit, self.run_command, 'snapshot-rename') + + def test_set_metadata_set(self): + self.run_command('metadata 1234 set key1=val1 key2=val2') + self.assert_called('POST', '/volumes/1234/metadata', + {'metadata': {'key1': 'val1', 'key2': 'val2'}}) + + def test_set_metadata_delete_dict(self): + self.run_command('metadata 1234 unset key1=val1 key2=val2') + self.assert_called('DELETE', '/volumes/1234/metadata/key1') + self.assert_called('DELETE', '/volumes/1234/metadata/key2', pos=-2) + + def test_set_metadata_delete_keys(self): + self.run_command('metadata 1234 unset key1 key2') + self.assert_called('DELETE', '/volumes/1234/metadata/key1') + self.assert_called('DELETE', '/volumes/1234/metadata/key2', pos=-2) + + def test_reset_state(self): + self.run_command('reset-state 1234') + expected = {'os-reset_status': {'status': 'available'}} + self.assert_called('POST', '/volumes/1234/action', body=expected) + + def test_reset_state_with_flag(self): + self.run_command('reset-state --state error 1234') + expected = {'os-reset_status': {'status': 'error'}} + self.assert_called('POST', '/volumes/1234/action', body=expected) + + def test_reset_state_multiple(self): + self.run_command('reset-state 1234 5678 --state error') + expected = {'os-reset_status': {'status': 'error'}} + self.assert_called_anytime('POST', '/volumes/1234/action', + body=expected) + self.assert_called_anytime('POST', '/volumes/5678/action', + body=expected) + + def test_snapshot_reset_state(self): + self.run_command('snapshot-reset-state 1234') + expected = {'os-reset_status': {'status': 'available'}} + self.assert_called('POST', '/snapshots/1234/action', body=expected) + + def test_snapshot_reset_state_with_flag(self): + self.run_command('snapshot-reset-state --state error 1234') + expected = {'os-reset_status': {'status': 'error'}} + self.assert_called('POST', '/snapshots/1234/action', body=expected) + + def test_snapshot_reset_state_multiple(self): + self.run_command('snapshot-reset-state 1234 5678') + expected = {'os-reset_status': {'status': 'available'}} + self.assert_called_anytime('POST', '/snapshots/1234/action', + body=expected) + self.assert_called_anytime('POST', '/snapshots/5678/action', + body=expected) + + def test_encryption_type_list(self): + """ + Test encryption-type-list shell command. + + Verify a series of GET requests are made: + - one to get the volume type list information + - one per volume type to retrieve the encryption type information + """ + self.run_command('encryption-type-list') + self.assert_called_anytime('GET', '/types') + self.assert_called_anytime('GET', '/types/1/encryption') + self.assert_called_anytime('GET', '/types/2/encryption') + + def test_encryption_type_show(self): + """ + Test encryption-type-show shell command. + + Verify two GET requests are made per command invocation: + - one to get the volume type information + - one to get the encryption type information + """ + self.run_command('encryption-type-show 1') + self.assert_called('GET', '/types/1/encryption') + self.assert_called_anytime('GET', '/types/1') + + def test_encryption_type_create(self): + """ + Test encryption-type-create shell command. + + Verify GET and POST requests are made per command invocation: + - one GET request to retrieve the relevant volume type information + - one POST request to create the new encryption type + """ + expected = {'encryption': {'cipher': None, 'key_size': None, + 'provider': 'TestProvider', + 'control_location': None}} + self.run_command('encryption-type-create 2 TestProvider') + self.assert_called('POST', '/types/2/encryption', body=expected) + self.assert_called_anytime('GET', '/types/2') + + def test_encryption_type_update(self): + """ + Test encryption-type-update shell command. + + Verify two GETs/one PUT requests are made per command invocation: + - one GET request to retrieve the relevant volume type information + - one GET request to retrieve the relevant encryption type information + - one PUT request to update the encryption type information + """ + self.skipTest("Not implemented") + + def test_encryption_type_delete(self): + """ + Test encryption-type-delete shell command. + + Verify one GET/one DELETE requests are made per command invocation: + - one GET request to retrieve the relevant volume type information + - one DELETE request to delete the encryption type information + """ + self.run_command('encryption-type-delete 1') + self.assert_called('DELETE', '/types/1/encryption/provider') + self.assert_called_anytime('GET', '/types/1') + + def test_migrate_volume(self): + self.run_command('migrate 1234 fakehost --force-host-copy=True') + expected = {'os-migrate_volume': {'force_host_copy': 'True', + 'host': 'fakehost'}} + self.assert_called('POST', '/volumes/1234/action', body=expected) + + def test_snapshot_metadata_set(self): + self.run_command('snapshot-metadata 1234 set key1=val1 key2=val2') + self.assert_called('POST', '/snapshots/1234/metadata', + {'metadata': {'key1': 'val1', 'key2': 'val2'}}) + + def test_snapshot_metadata_unset_dict(self): + self.run_command('snapshot-metadata 1234 unset key1=val1 key2=val2') + self.assert_called_anytime('DELETE', '/snapshots/1234/metadata/key1') + self.assert_called_anytime('DELETE', '/snapshots/1234/metadata/key2') + + def test_snapshot_metadata_unset_keys(self): + self.run_command('snapshot-metadata 1234 unset key1 key2') + self.assert_called_anytime('DELETE', '/snapshots/1234/metadata/key1') + self.assert_called_anytime('DELETE', '/snapshots/1234/metadata/key2') + + def test_volume_metadata_update_all(self): + self.run_command('metadata-update-all 1234 key1=val1 key2=val2') + self.assert_called('PUT', '/volumes/1234/metadata', + {'metadata': {'key1': 'val1', 'key2': 'val2'}}) + + def test_snapshot_metadata_update_all(self): + self.run_command('snapshot-metadata-update-all\ + 1234 key1=val1 key2=val2') + self.assert_called('PUT', '/snapshots/1234/metadata', + {'metadata': {'key1': 'val1', 'key2': 'val2'}}) + + def test_readonly_mode_update(self): + self.run_command('readonly-mode-update 1234 True') + expected = {'os-update_readonly_flag': {'readonly': True}} + self.assert_called('POST', '/volumes/1234/action', body=expected) + + self.run_command('readonly-mode-update 1234 False') + expected = {'os-update_readonly_flag': {'readonly': False}} + self.assert_called('POST', '/volumes/1234/action', body=expected) + + def test_service_disable(self): + self.run_command('service-disable host cinder-volume') + self.assert_called('PUT', '/os-services/disable', + {"binary": "cinder-volume", "host": "host"}) + + def test_service_disable(self): + self.run_command('service-enable host cinder-volume') + self.assert_called('PUT', '/os-services/enable', + {"binary": "cinder-volume", "host": "host"}) + + def test_snapshot_delete(self): + self.run_command('snapshot-delete 1234') + self.assert_called('DELETE', '/snapshots/1234') diff --git a/cinderclient/tests/v1/test_snapshot_actions.py b/cinderclient/tests/v1/test_snapshot_actions.py new file mode 100644 index 0000000..70b14e1 --- /dev/null +++ b/cinderclient/tests/v1/test_snapshot_actions.py @@ -0,0 +1,35 @@ +# Copyright 2013 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from cinderclient.tests import utils +from cinderclient.tests.v1 import fakes + + +cs = fakes.FakeClient() + + +class SnapshotActionsTest(utils.TestCase): + def test_update_snapshot_status(self): + s = cs.volume_snapshots.get('1234') + cs.volume_snapshots.update_snapshot_status(s, + {'status': 'available'}) + cs.assert_called('POST', '/snapshots/1234/action') + + def test_update_snapshot_status_with_progress(self): + s = cs.volume_snapshots.get('1234') + cs.volume_snapshots.update_snapshot_status(s, + {'status': 'available', + 'progress': '73%'}) + cs.assert_called('POST', '/snapshots/1234/action') diff --git a/tests/v1/test_types.py b/cinderclient/tests/v1/test_types.py similarity index 61% rename from tests/v1/test_types.py rename to cinderclient/tests/v1/test_types.py index 92aa2c0..6212336 100644 --- a/tests/v1/test_types.py +++ b/cinderclient/tests/v1/test_types.py @@ -1,7 +1,19 @@ -from cinderclient import exceptions +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + from cinderclient.v1 import volume_types -from tests import utils -from tests.v1 import fakes +from cinderclient.tests import utils +from cinderclient.tests.v1 import fakes cs = fakes.FakeClient() diff --git a/cinderclient/tests/v1/test_volume_backups.py b/cinderclient/tests/v1/test_volume_backups.py new file mode 100644 index 0000000..dd7ec0d --- /dev/null +++ b/cinderclient/tests/v1/test_volume_backups.py @@ -0,0 +1,53 @@ +# Copyright (C) 2013 Hewlett-Packard Development Company, L.P. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from cinderclient.tests import utils +from cinderclient.tests.v1 import fakes + + +cs = fakes.FakeClient() + + +class VolumeBackupsTest(utils.TestCase): + + def test_create(self): + cs.backups.create('2b695faf-b963-40c8-8464-274008fbcef4') + cs.assert_called('POST', '/backups') + + def test_get(self): + backup_id = '76a17945-3c6f-435c-975b-b5685db10b62' + cs.backups.get(backup_id) + cs.assert_called('GET', '/backups/%s' % backup_id) + + def test_list(self): + cs.backups.list() + cs.assert_called('GET', '/backups/detail') + + def test_delete(self): + b = cs.backups.list()[0] + b.delete() + cs.assert_called('DELETE', + '/backups/76a17945-3c6f-435c-975b-b5685db10b62') + cs.backups.delete('76a17945-3c6f-435c-975b-b5685db10b62') + cs.assert_called('DELETE', + '/backups/76a17945-3c6f-435c-975b-b5685db10b62') + cs.backups.delete(b) + cs.assert_called('DELETE', + '/backups/76a17945-3c6f-435c-975b-b5685db10b62') + + def test_restore(self): + backup_id = '76a17945-3c6f-435c-975b-b5685db10b62' + cs.restores.restore(backup_id) + cs.assert_called('POST', '/backups/%s/restore' % backup_id) diff --git a/cinderclient/tests/v1/test_volume_encryption_types.py b/cinderclient/tests/v1/test_volume_encryption_types.py new file mode 100644 index 0000000..0d9868c --- /dev/null +++ b/cinderclient/tests/v1/test_volume_encryption_types.py @@ -0,0 +1,100 @@ +# Copyright (c) 2013 The Johns Hopkins University/Applied Physics Laboratory +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from cinderclient.v1.volume_encryption_types import VolumeEncryptionType +from cinderclient.tests import utils +from cinderclient.tests.v1 import fakes + +cs = fakes.FakeClient() + + +class VolumeEncryptionTypesTest(utils.TestCase): + """ + Test suite for the Volume Encryption Types Resource and Manager. + """ + + def test_list(self): + """ + Unit test for VolumeEncryptionTypesManager.list + + Verify that a series of GET requests are made: + - one GET request for the list of volume types + - one GET request per volume type for encryption type information + + Verify that all returned information is :class: VolumeEncryptionType + """ + encryption_types = cs.volume_encryption_types.list() + cs.assert_called_anytime('GET', '/types') + cs.assert_called_anytime('GET', '/types/2/encryption') + cs.assert_called_anytime('GET', '/types/1/encryption') + for encryption_type in encryption_types: + self.assertIsInstance(encryption_type, VolumeEncryptionType) + + def test_get(self): + """ + Unit test for VolumeEncryptionTypesManager.get + + Verify that one GET request is made for the volume type encryption + type information. Verify that returned information is :class: + VolumeEncryptionType + """ + encryption_type = cs.volume_encryption_types.get(1) + cs.assert_called('GET', '/types/1/encryption') + self.assertIsInstance(encryption_type, VolumeEncryptionType) + + def test_get_no_encryption(self): + """ + Unit test for VolumeEncryptionTypesManager.get + + Verify that a request on a volume type with no associated encryption + type information returns a VolumeEncryptionType with no attributes. + """ + encryption_type = cs.volume_encryption_types.get(2) + self.assertIsInstance(encryption_type, VolumeEncryptionType) + self.assertFalse(hasattr(encryption_type, 'id'), + 'encryption type has an id') + + def test_create(self): + """ + Unit test for VolumeEncryptionTypesManager.create + + Verify that one POST request is made for the encryption type creation. + Verify that encryption type creation returns a VolumeEncryptionType. + """ + result = cs.volume_encryption_types.create(2, {'encryption': + {'provider': 'Test', + 'key_size': None, + 'cipher': None, + 'control_location': + None}}) + cs.assert_called('POST', '/types/2/encryption') + self.assertIsInstance(result, VolumeEncryptionType) + + def test_update(self): + """ + Unit test for VolumeEncryptionTypesManager.update + """ + self.skipTest("Not implemented") + + def test_delete(self): + """ + Unit test for VolumeEncryptionTypesManager.delete + + Verify that one DELETE request is made for encryption type deletion + Verify that encryption type deletion returns None + """ + result = cs.volume_encryption_types.delete(1) + cs.assert_called('DELETE', '/types/1/encryption/provider') + self.assertIsNone(result, "delete result must be None") diff --git a/cinderclient/tests/v1/test_volume_transfers.py b/cinderclient/tests/v1/test_volume_transfers.py new file mode 100644 index 0000000..47656d7 --- /dev/null +++ b/cinderclient/tests/v1/test_volume_transfers.py @@ -0,0 +1,51 @@ +# Copyright (C) 2013 Hewlett-Packard Development Company, L.P. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from cinderclient.tests import utils +from cinderclient.tests.v1 import fakes + + +cs = fakes.FakeClient() + + +class VolumeTransfersTest(utils.TestCase): + + def test_create(self): + cs.transfers.create('1234') + cs.assert_called('POST', '/os-volume-transfer') + + def test_get(self): + transfer_id = '5678' + cs.transfers.get(transfer_id) + cs.assert_called('GET', '/os-volume-transfer/%s' % transfer_id) + + def test_list(self): + cs.transfers.list() + cs.assert_called('GET', '/os-volume-transfer/detail') + + def test_delete(self): + b = cs.transfers.list()[0] + b.delete() + cs.assert_called('DELETE', '/os-volume-transfer/5678') + cs.transfers.delete('5678') + cs.assert_called('DELETE', '/os-volume-transfer/5678') + cs.transfers.delete(b) + cs.assert_called('DELETE', '/os-volume-transfer/5678') + + def test_accept(self): + transfer_id = '5678' + auth_key = '12345' + cs.transfers.accept(transfer_id, auth_key) + cs.assert_called('POST', '/os-volume-transfer/%s/accept' % transfer_id) diff --git a/tests/v1/test_volumes.py b/cinderclient/tests/v1/test_volumes.py similarity index 56% rename from tests/v1/test_volumes.py rename to cinderclient/tests/v1/test_volumes.py index 913e5a9..571fa79 100644 --- a/tests/v1/test_volumes.py +++ b/cinderclient/tests/v1/test_volumes.py @@ -1,5 +1,18 @@ -from tests import utils -from tests.v1 import fakes +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from cinderclient.tests import utils +from cinderclient.tests.v1 import fakes cs = fakes.FakeClient() @@ -16,13 +29,13 @@ class VolumesTest(utils.TestCase): cs.volumes.delete(v) cs.assert_called('DELETE', '/volumes/1234') - def test_create_keypair(self): + def test_create_volume(self): cs.volumes.create(1) cs.assert_called('POST', '/volumes') def test_attach(self): v = cs.volumes.get('1234') - cs.volumes.attach(v, 1, '/dev/vdc') + cs.volumes.attach(v, 1, '/dev/vdc', mode='rw') cs.assert_called('POST', '/volumes/1234/action') def test_detach(self): @@ -69,3 +82,27 @@ class VolumesTest(utils.TestCase): keys = ['key1'] cs.volumes.delete_metadata(1234, keys) cs.assert_called('DELETE', '/volumes/1234/metadata/key1') + + def test_extend(self): + v = cs.volumes.get('1234') + cs.volumes.extend(v, 2) + cs.assert_called('POST', '/volumes/1234/action') + + def test_get_encryption_metadata(self): + cs.volumes.get_encryption_metadata('1234') + cs.assert_called('GET', '/volumes/1234/encryption') + + def test_migrate(self): + v = cs.volumes.get('1234') + cs.volumes.migrate_volume(v, 'dest', False) + cs.assert_called('POST', '/volumes/1234/action') + + def test_metadata_update_all(self): + cs.volumes.update_all_metadata(1234, {'k1': 'v1'}) + cs.assert_called('PUT', '/volumes/1234/metadata', + {'metadata': {'k1': 'v1'}}) + + def test_readonly_mode_update(self): + v = cs.volumes.get('1234') + cs.volumes.update_readonly_flag(v, True) + cs.assert_called('POST', '/volumes/1234/action') diff --git a/tests/v1/testfile.txt b/cinderclient/tests/v1/testfile.txt similarity index 100% rename from tests/v1/testfile.txt rename to cinderclient/tests/v1/testfile.txt diff --git a/cinderclient/tests/v2/__init__.py b/cinderclient/tests/v2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cinderclient/tests/v2/contrib/__init__.py b/cinderclient/tests/v2/contrib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/v2/contrib/test_list_extensions.py b/cinderclient/tests/v2/contrib/test_list_extensions.py similarity index 90% rename from tests/v2/contrib/test_list_extensions.py rename to cinderclient/tests/v2/contrib/test_list_extensions.py index 49b0e57..66126be 100644 --- a/tests/v2/contrib/test_list_extensions.py +++ b/cinderclient/tests/v2/contrib/test_list_extensions.py @@ -1,4 +1,4 @@ -# Copyright (c) 2013 OpenStack, LLC. +# Copyright (c) 2013 OpenStack Foundation # # All Rights Reserved. # @@ -16,8 +16,8 @@ from cinderclient import extension from cinderclient.v2.contrib import list_extensions -from tests import utils -from tests.v1 import fakes +from cinderclient.tests import utils +from cinderclient.tests.v1 import fakes extensions = [ diff --git a/cinderclient/tests/v2/fakes.py b/cinderclient/tests/v2/fakes.py new file mode 100644 index 0000000..890fa53 --- /dev/null +++ b/cinderclient/tests/v2/fakes.py @@ -0,0 +1,791 @@ +# Copyright (c) 2013 OpenStack Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from datetime import datetime + +try: + import urlparse +except ImportError: + import urllib.parse as urlparse + +from cinderclient import client as base_client +from cinderclient.tests import fakes +import cinderclient.tests.utils as utils +from cinderclient.v2 import client + + +def _stub_volume(**kwargs): + volume = { + 'id': '1234', + 'name': None, + 'description': None, + "attachments": [], + "bootable": "false", + "availability_zone": "cinder", + "created_at": "2012-08-27T00:00:00.000000", + "id": '00000000-0000-0000-0000-000000000000', + "metadata": {}, + "size": 1, + "snapshot_id": None, + "status": "available", + "volume_type": "None", + "links": [ + { + "href": "http://localhost/v2/fake/volumes/1234", + "rel": "self" + }, + { + "href": "http://localhost/fake/volumes/1234", + "rel": "bookmark" + } + ], + } + volume.update(kwargs) + return volume + + +def _stub_snapshot(**kwargs): + snapshot = { + "created_at": "2012-08-28T16:30:31.000000", + "display_description": None, + "display_name": None, + "id": '11111111-1111-1111-1111-111111111111', + "size": 1, + "status": "available", + "volume_id": '00000000-0000-0000-0000-000000000000', + } + snapshot.update(kwargs) + return snapshot + + +def _self_href(base_uri, tenant_id, backup_id): + return '%s/v2/%s/backups/%s' % (base_uri, tenant_id, backup_id) + + +def _bookmark_href(base_uri, tenant_id, backup_id): + return '%s/%s/backups/%s' % (base_uri, tenant_id, backup_id) + + +def _stub_backup_full(id, base_uri, tenant_id): + return { + 'id': id, + 'name': 'backup', + 'description': 'nightly backup', + 'volume_id': '712f4980-5ac1-41e5-9383-390aa7c9f58b', + 'container': 'volumebackups', + 'object_count': 220, + 'size': 10, + 'availability_zone': 'az1', + 'created_at': '2013-04-12T08:16:37.000000', + 'status': 'available', + 'links': [ + { + 'href': _self_href(base_uri, tenant_id, id), + 'rel': 'self' + }, + { + 'href': _bookmark_href(base_uri, tenant_id, id), + 'rel': 'bookmark' + } + ] + } + + +def _stub_backup(id, base_uri, tenant_id): + return { + 'id': id, + 'name': 'backup', + 'links': [ + { + 'href': _self_href(base_uri, tenant_id, id), + 'rel': 'self' + }, + { + 'href': _bookmark_href(base_uri, tenant_id, id), + 'rel': 'bookmark' + } + ] + } + + +def _stub_qos_full(id, base_uri, tenant_id, name=None, specs=None): + if not name: + name = 'fake-name' + if not specs: + specs = {} + + return { + 'qos_specs': { + 'id': id, + 'name': name, + 'consumer': 'back-end', + 'specs': specs, + }, + 'links': { + 'href': _bookmark_href(base_uri, tenant_id, id), + 'rel': 'bookmark' + } + } + + +def _stub_qos_associates(id, name): + return { + 'assoications_type': 'volume_type', + 'name': name, + 'id': id, + } + + +def _stub_restore(): + return {'volume_id': '712f4980-5ac1-41e5-9383-390aa7c9f58b'} + + +def _stub_transfer_full(id, base_uri, tenant_id): + return { + 'id': id, + 'name': 'transfer', + 'volume_id': '8c05f861-6052-4df6-b3e0-0aebfbe686cc', + 'created_at': '2013-04-12T08:16:37.000000', + 'auth_key': '123456', + 'links': [ + { + 'href': _self_href(base_uri, tenant_id, id), + 'rel': 'self' + }, + { + 'href': _bookmark_href(base_uri, tenant_id, id), + 'rel': 'bookmark' + } + ] + } + + +def _stub_transfer(id, base_uri, tenant_id): + return { + 'id': id, + 'name': 'transfer', + 'volume_id': '8c05f861-6052-4df6-b3e0-0aebfbe686cc', + 'links': [ + { + 'href': _self_href(base_uri, tenant_id, id), + 'rel': 'self' + }, + { + 'href': _bookmark_href(base_uri, tenant_id, id), + 'rel': 'bookmark' + } + ] + } + + +def _stub_extend(id, new_size): + return {'volume_id': '712f4980-5ac1-41e5-9383-390aa7c9f58b'} + + +class FakeClient(fakes.FakeClient, client.Client): + + def __init__(self, *args, **kwargs): + client.Client.__init__(self, 'username', 'password', + 'project_id', 'auth_url', + extensions=kwargs.get('extensions')) + self.client = FakeHTTPClient(**kwargs) + + def get_volume_api_version_from_endpoint(self): + return self.client.get_volume_api_version_from_endpoint() + + +class FakeHTTPClient(base_client.HTTPClient): + + def __init__(self, **kwargs): + self.username = 'username' + self.password = 'password' + self.auth_url = 'auth_url' + self.callstack = [] + self.management_url = 'http://10.0.2.15:8776/v2/fake' + + def _cs_request(self, url, method, **kwargs): + # Check that certain things are called correctly + if method in ['GET', 'DELETE']: + assert 'body' not in kwargs + elif method == 'PUT': + assert 'body' in kwargs + + # Call the method + args = urlparse.parse_qsl(urlparse.urlparse(url)[4]) + kwargs.update(args) + munged_url = url.rsplit('?', 1)[0] + munged_url = munged_url.strip('/').replace('/', '_').replace('.', '_') + munged_url = munged_url.replace('-', '_') + + callback = "%s_%s" % (method.lower(), munged_url) + + if not hasattr(self, callback): + raise AssertionError('Called unknown API method: %s %s, ' + 'expected fakes method name: %s' % + (method, url, callback)) + + # Note the call + self.callstack.append((method, url, kwargs.get('body', None))) + status, headers, body = getattr(self, callback)(**kwargs) + r = utils.TestResponse({ + "status_code": status, + "text": body, + "headers": headers, + }) + return r, body + + if hasattr(status, 'items'): + return utils.TestResponse(status), body + else: + return utils.TestResponse({"status": status}), body + + def get_volume_api_version_from_endpoint(self): + magic_tuple = urlparse.urlsplit(self.management_url) + scheme, netloc, path, query, frag = magic_tuple + return path.lstrip('/').split('/')[0][1:] + + # + # Snapshots + # + + def get_snapshots_detail(self, **kw): + return (200, {}, {'snapshots': [ + _stub_snapshot(), + ]}) + + def get_snapshots_1234(self, **kw): + return (200, {}, {'snapshot': _stub_snapshot(id='1234')}) + + def get_snapshots_5678(self, **kw): + return (200, {}, {'snapshot': _stub_snapshot(id='5678')}) + + def put_snapshots_1234(self, **kw): + snapshot = _stub_snapshot(id='1234') + snapshot.update(kw['body']['snapshot']) + return (200, {}, {'snapshot': snapshot}) + + def post_snapshots_1234_action(self, body, **kw): + _body = None + resp = 202 + assert len(list(body)) == 1 + action = list(body)[0] + if action == 'os-reset_status': + assert 'status' in body['os-reset_status'] + elif action == 'os-update_snapshot_status': + assert 'status' in body['os-update_snapshot_status'] + else: + raise AssertionError('Unexpected action: %s' % action) + return (resp, {}, _body) + + def post_snapshots_5678_action(self, body, **kw): + return self.post_snapshots_1234_action(body, **kw) + + def delete_snapshots_1234(self, **kw): + return (202, {}, {}) + + # + # Volumes + # + + def put_volumes_1234(self, **kw): + volume = _stub_volume(id='1234') + volume.update(kw['body']['volume']) + return (200, {}, {'volume': volume}) + + def get_volumes(self, **kw): + return (200, {}, {"volumes": [ + {'id': 1234, 'name': 'sample-volume'}, + {'id': 5678, 'name': 'sample-volume2'} + ]}) + + # TODO(jdg): This will need to change + # at the very least it's not complete + def get_volumes_detail(self, **kw): + return (200, {}, {"volumes": [ + {'id': kw.get('id', 1234), + 'name': 'sample-volume', + 'attachments': [{'server_id': 1234}]}, + ]}) + + def get_volumes_1234(self, **kw): + r = {'volume': self.get_volumes_detail(id=1234)[2]['volumes'][0]} + return (200, {}, r) + + def get_volumes_5678(self, **kw): + r = {'volume': self.get_volumes_detail(id=5678)[2]['volumes'][0]} + return (200, {}, r) + + def get_volumes_1234_encryption(self, **kw): + r = {'encryption_key_id': 'id'} + return (200, {}, r) + + def post_volumes_1234_action(self, body, **kw): + _body = None + resp = 202 + assert len(list(body)) == 1 + action = list(body)[0] + if action == 'os-attach': + assert sorted(list(body[action])) == ['instance_uuid', + 'mode', + 'mountpoint'] + elif action == 'os-detach': + assert body[action] is None + elif action == 'os-reserve': + assert body[action] is None + elif action == 'os-unreserve': + assert body[action] is None + elif action == 'os-initialize_connection': + assert list(body[action]) == ['connector'] + return (202, {}, {'connection_info': 'foos'}) + elif action == 'os-terminate_connection': + assert list(body[action]) == ['connector'] + elif action == 'os-begin_detaching': + assert body[action] is None + elif action == 'os-roll_detaching': + assert body[action] is None + elif action == 'os-reset_status': + assert 'status' in body[action] + elif action == 'os-extend': + assert list(body[action]) == ['new_size'] + elif action == 'os-migrate_volume': + assert 'host' in body[action] + assert 'force_host_copy' in body[action] + elif action == 'os-update_readonly_flag': + assert list(body[action]) == ['readonly'] + elif action == 'os-retype': + assert 'new_type' in body[action] + else: + raise AssertionError("Unexpected action: %s" % action) + return (resp, {}, _body) + + def post_volumes_5678_action(self, body, **kw): + return self.post_volumes_1234_action(body, **kw) + + def post_volumes(self, **kw): + return (202, {}, {'volume': {}}) + + def delete_volumes_1234(self, **kw): + return (202, {}, None) + + def delete_volumes_5678(self, **kw): + return (202, {}, None) + + # + # Quotas + # + + def get_os_quota_sets_test(self, **kw): + return (200, {}, {'quota_set': { + 'tenant_id': 'test', + 'metadata_items': [], + 'volumes': 1, + 'snapshots': 1, + 'gigabytes': 1}}) + + def get_os_quota_sets_test_defaults(self): + return (200, {}, {'quota_set': { + 'tenant_id': 'test', + 'metadata_items': [], + 'volumes': 1, + 'snapshots': 1, + 'gigabytes': 1}}) + + def put_os_quota_sets_test(self, body, **kw): + assert list(body) == ['quota_set'] + fakes.assert_has_keys(body['quota_set'], + required=['tenant_id']) + return (200, {}, {'quota_set': { + 'tenant_id': 'test', + 'metadata_items': [], + 'volumes': 2, + 'snapshots': 2, + 'gigabytes': 1}}) + + # + # Quota Classes + # + + def get_os_quota_class_sets_test(self, **kw): + return (200, {}, {'quota_class_set': { + 'class_name': 'test', + 'metadata_items': [], + 'volumes': 1, + 'snapshots': 1, + 'gigabytes': 1}}) + + def put_os_quota_class_sets_test(self, body, **kw): + assert list(body) == ['quota_class_set'] + fakes.assert_has_keys(body['quota_class_set'], + required=['class_name']) + return (200, {}, {'quota_class_set': { + 'class_name': 'test', + 'metadata_items': [], + 'volumes': 2, + 'snapshots': 2, + 'gigabytes': 1}}) + + # + # VolumeTypes + # + def get_types(self, **kw): + return (200, {}, { + 'volume_types': [{'id': 1, + 'name': 'test-type-1', + 'extra_specs': {}}, + {'id': 2, + 'name': 'test-type-2', + 'extra_specs': {}}]}) + + def get_types_1(self, **kw): + return (200, {}, {'volume_type': {'id': 1, + 'name': 'test-type-1', + 'extra_specs': {}}}) + + def get_types_2(self, **kw): + return (200, {}, {'volume_type': {'id': 2, + 'name': 'test-type-2', + 'extra_specs': {}}}) + + def post_types(self, body, **kw): + return (202, {}, {'volume_type': {'id': 3, + 'name': 'test-type-3', + 'extra_specs': {}}}) + + def post_types_1_extra_specs(self, body, **kw): + assert list(body) == ['extra_specs'] + return (200, {}, {'extra_specs': {'k': 'v'}}) + + def delete_types_1_extra_specs_k(self, **kw): + return(204, {}, None) + + def delete_types_1(self, **kw): + return (202, {}, None) + + # + # VolumeEncryptionTypes + # + def get_types_1_encryption(self, **kw): + return (200, {}, {'id': 1, 'volume_type_id': 1, 'provider': 'test', + 'cipher': 'test', 'key_size': 1, + 'control_location': 'front'}) + + def get_types_2_encryption(self, **kw): + return (200, {}, {}) + + def post_types_2_encryption(self, body, **kw): + return (200, {}, {'encryption': {}}) + + def put_types_1_encryption_1(self, body, **kw): + return (200, {}, {}) + + def delete_types_1_encryption_provider(self, **kw): + return (202, {}, None) + + # + # Set/Unset metadata + # + def delete_volumes_1234_metadata_test_key(self, **kw): + return (204, {}, None) + + def delete_volumes_1234_metadata_key1(self, **kw): + return (204, {}, None) + + def delete_volumes_1234_metadata_key2(self, **kw): + return (204, {}, None) + + def post_volumes_1234_metadata(self, **kw): + return (204, {}, {'metadata': {'test_key': 'test_value'}}) + + # + # List all extensions + # + def get_extensions(self, **kw): + exts = [ + { + "alias": "FAKE-1", + "description": "Fake extension number 1", + "links": [], + "name": "Fake1", + "namespace": ("http://docs.openstack.org/" + "/ext/fake1/api/v1.1"), + "updated": "2011-06-09T00:00:00+00:00" + }, + { + "alias": "FAKE-2", + "description": "Fake extension number 2", + "links": [], + "name": "Fake2", + "namespace": ("http://docs.openstack.org/" + "/ext/fake1/api/v1.1"), + "updated": "2011-06-09T00:00:00+00:00" + }, + ] + return (200, {}, {"extensions": exts, }) + + # + # VolumeBackups + # + + def get_backups_76a17945_3c6f_435c_975b_b5685db10b62(self, **kw): + base_uri = 'http://localhost:8776' + tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' + backup1 = '76a17945-3c6f-435c-975b-b5685db10b62' + return (200, {}, + {'backup': _stub_backup_full(backup1, base_uri, tenant_id)}) + + def get_backups_detail(self, **kw): + base_uri = 'http://localhost:8776' + tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' + backup1 = '76a17945-3c6f-435c-975b-b5685db10b62' + backup2 = 'd09534c6-08b8-4441-9e87-8976f3a8f699' + return (200, {}, + {'backups': [ + _stub_backup_full(backup1, base_uri, tenant_id), + _stub_backup_full(backup2, base_uri, tenant_id)]}) + + def delete_backups_76a17945_3c6f_435c_975b_b5685db10b62(self, **kw): + return (202, {}, None) + + def post_backups(self, **kw): + base_uri = 'http://localhost:8776' + tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' + backup1 = '76a17945-3c6f-435c-975b-b5685db10b62' + return (202, {}, + {'backup': _stub_backup(backup1, base_uri, tenant_id)}) + + def post_backups_76a17945_3c6f_435c_975b_b5685db10b62_restore(self, **kw): + return (200, {}, + {'restore': _stub_restore()}) + + def post_backups_1234_restore(self, **kw): + return (200, {}, + {'restore': _stub_restore()}) + + # + # QoSSpecs + # + + def get_qos_specs_1B6B6A04_A927_4AEB_810B_B7BAAD49F57C(self, **kw): + base_uri = 'http://localhost:8776' + tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' + qos_id1 = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + return (200, {}, + _stub_qos_full(qos_id1, base_uri, tenant_id)) + + def get_qos_specs(self, **kw): + base_uri = 'http://localhost:8776' + tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' + qos_id1 = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + qos_id2 = '0FD8DD14-A396-4E55-9573-1FE59042E95B' + return (200, {}, + {'qos_specs': [ + _stub_qos_full(qos_id1, base_uri, tenant_id, 'name-1'), + _stub_qos_full(qos_id2, base_uri, tenant_id)]}) + + def post_qos_specs(self, **kw): + base_uri = 'http://localhost:8776' + tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' + qos_id = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + qos_name = 'qos-name' + return (202, {}, + _stub_qos_full(qos_id, base_uri, tenant_id, qos_name)) + + def put_qos_specs_1B6B6A04_A927_4AEB_810B_B7BAAD49F57C(self, **kw): + return (202, {}, None) + + def put_qos_specs_1B6B6A04_A927_4AEB_810B_B7BAAD49F57C_delete_keys( + self, **kw): + return (202, {}, None) + + def delete_qos_specs_1B6B6A04_A927_4AEB_810B_B7BAAD49F57C(self, **kw): + return (202, {}, None) + + def get_qos_specs_1B6B6A04_A927_4AEB_810B_B7BAAD49F57C_associations( + self, **kw): + type_id1 = '4230B13A-7A37-4E84-B777-EFBA6FCEE4FF' + type_id2 = '4230B13A-AB37-4E84-B777-EFBA6FCEE4FF' + type_name1 = 'type1' + type_name2 = 'type2' + return (202, {}, + {'qos_associations': [ + _stub_qos_associates(type_id1, type_name1), + _stub_qos_associates(type_id2, type_name2)]}) + + def get_qos_specs_1B6B6A04_A927_4AEB_810B_B7BAAD49F57C_associate( + self, **kw): + return (202, {}, None) + + def get_qos_specs_1B6B6A04_A927_4AEB_810B_B7BAAD49F57C_disassociate( + self, **kw): + return (202, {}, None) + + def get_qos_specs_1B6B6A04_A927_4AEB_810B_B7BAAD49F57C_disassociate_all( + self, **kw): + return (202, {}, None) + + # + # + # VolumeTransfers + # + + def get_os_volume_transfer_5678(self, **kw): + base_uri = 'http://localhost:8776' + tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' + transfer1 = '5678' + return (200, {}, + {'transfer': + _stub_transfer_full(transfer1, base_uri, tenant_id)}) + + def get_os_volume_transfer_detail(self, **kw): + base_uri = 'http://localhost:8776' + tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' + transfer1 = '5678' + transfer2 = 'f625ec3e-13dd-4498-a22a-50afd534cc41' + return (200, {}, + {'transfers': [ + _stub_transfer_full(transfer1, base_uri, tenant_id), + _stub_transfer_full(transfer2, base_uri, tenant_id)]}) + + def delete_os_volume_transfer_5678(self, **kw): + return (202, {}, None) + + def post_os_volume_transfer(self, **kw): + base_uri = 'http://localhost:8776' + tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' + transfer1 = '5678' + return (202, {}, + {'transfer': _stub_transfer(transfer1, base_uri, tenant_id)}) + + def post_os_volume_transfer_5678_accept(self, **kw): + base_uri = 'http://localhost:8776' + tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' + transfer1 = '5678' + return (200, {}, + {'transfer': _stub_transfer(transfer1, base_uri, tenant_id)}) + + # + # Services + # + def get_os_services(self, **kw): + host = kw.get('host', None) + binary = kw.get('binary', None) + services = [ + { + 'binary': 'cinder-volume', + 'host': 'host1', + 'zone': 'cinder', + 'status': 'enabled', + 'state': 'up', + 'updated_at': datetime(2012, 10, 29, 13, 42, 2) + }, + { + 'binary': 'cinder-volume', + 'host': 'host2', + 'zone': 'cinder', + 'status': 'disabled', + 'state': 'down', + 'updated_at': datetime(2012, 9, 18, 8, 3, 38) + }, + { + 'binary': 'cinder-scheduler', + 'host': 'host2', + 'zone': 'cinder', + 'status': 'disabled', + 'state': 'down', + 'updated_at': datetime(2012, 9, 18, 8, 3, 38) + }, + ] + if host: + services = filter(lambda i: i['host'] == host, services) + if binary: + services = filter(lambda i: i['binary'] == binary, services) + return (200, {}, {'services': services}) + + def put_os_services_enable(self, body, **kw): + return (200, {}, {'host': body['host'], 'binary': body['binary'], + 'status': 'enabled'}) + + def put_os_services_disable(self, body, **kw): + return (200, {}, {'host': body['host'], 'binary': body['binary'], + 'status': 'disabled'}) + + def get_os_availability_zone(self, **kw): + return (200, {}, { + "availabilityZoneInfo": [ + { + "zoneName": "zone-1", + "zoneState": {"available": True}, + "hosts": None, + }, + { + "zoneName": "zone-2", + "zoneState": {"available": False}, + "hosts": None, + }, + ] + }) + + def get_os_availability_zone_detail(self, **kw): + return (200, {}, { + "availabilityZoneInfo": [ + { + "zoneName": "zone-1", + "zoneState": {"available": True}, + "hosts": { + "fake_host-1": { + "cinder-volume": { + "active": True, + "available": True, + "updated_at": + datetime(2012, 12, 26, 14, 45, 25, 0) + } + } + } + }, + { + "zoneName": "internal", + "zoneState": {"available": True}, + "hosts": { + "fake_host-1": { + "cinder-sched": { + "active": True, + "available": True, + "updated_at": + datetime(2012, 12, 26, 14, 45, 24, 0) + } + } + } + }, + { + "zoneName": "zone-2", + "zoneState": {"available": False}, + "hosts": None, + }, + ] + }) + + def post_snapshots_1234_metadata(self, **kw): + return (200, {}, {"metadata": {"key1": "val1", "key2": "val2"}}) + + def delete_snapshots_1234_metadata_key1(self, **kw): + return (200, {}, None) + + def delete_snapshots_1234_metadata_key2(self, **kw): + return (200, {}, None) + + def put_volumes_1234_metadata(self, **kw): + return (200, {}, {"metadata": {"key1": "val1", "key2": "val2"}}) + + def put_snapshots_1234_metadata(self, **kw): + return (200, {}, {"metadata": {"key1": "val1", "key2": "val2"}}) diff --git a/tests/v2/test_auth.py b/cinderclient/tests/v2/test_auth.py similarity index 92% rename from tests/v2/test_auth.py rename to cinderclient/tests/v2/test_auth.py index b18a7a3..9704840 100644 --- a/tests/v2/test_auth.py +++ b/cinderclient/tests/v2/test_auth.py @@ -1,4 +1,4 @@ -# Copyright (c) 2013 OpenStack, LLC. +# Copyright (c) 2013 OpenStack Foundation # # All Rights Reserved. # @@ -21,13 +21,13 @@ import requests from cinderclient import exceptions from cinderclient.v2 import client -from tests import utils +from cinderclient.tests import utils class AuthenticateAgainstKeystoneTests(utils.TestCase): def test_authenticate_success(self): cs = client.Client("username", "password", "project_id", - "auth_url/v2.0", service_type='compute') + "http://localhost:8776/v2", service_type='volumev2') resp = { "access": { "token": { @@ -36,13 +36,13 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): }, "serviceCatalog": [ { - "type": "compute", + "type": "volumev2", "endpoints": [ { "region": "RegionOne", - "adminURL": "http://localhost:8774/v2", - "internalURL": "http://localhost:8774/v2", - "publicURL": "http://localhost:8774/v2/", + "adminURL": "http://localhost:8776/v2", + "internalURL": "http://localhost:8776/v2", + "publicURL": "http://localhost:8776/v2", }, ], }, @@ -92,8 +92,9 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): test_auth_call() def test_authenticate_tenant_id(self): - cs = client.Client("username", "password", auth_url="auth_url/v2.0", - tenant_id='tenant_id', service_type='compute') + cs = client.Client("username", "password", + auth_url="http://localhost:8776/v2", + tenant_id='tenant_id', service_type='volumev2') resp = { "access": { "token": { @@ -108,13 +109,13 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): }, "serviceCatalog": [ { - "type": "compute", + "type": 'volumev2', "endpoints": [ { "region": "RegionOne", - "adminURL": "http://localhost:8774/v2", - "internalURL": "http://localhost:8774/v2", - "publicURL": "http://localhost:8774/v2/", + "adminURL": "http://localhost:8776/v2", + "internalURL": "http://localhost:8776/v2", + "publicURL": "http://localhost:8776/v2", }, ], }, @@ -167,7 +168,7 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): def test_authenticate_failure(self): cs = client.Client("username", "password", "project_id", - "auth_url/v2.0") + "http://localhost:8776/v2") resp = {"unauthorized": {"message": "Unauthorized", "code": "401"}} auth_response = utils.TestResponse({ "status_code": 401, @@ -184,7 +185,7 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): def test_auth_redirect(self): cs = client.Client("username", "password", "project_id", - "auth_url/v2", service_type='compute') + "http://localhost:8776/v2", service_type='volumev2') dict_correct_response = { "access": { "token": { @@ -193,13 +194,13 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): }, "serviceCatalog": [ { - "type": "compute", + "type": "volumev2", "endpoints": [ { - "adminURL": "http://localhost:8774/v2", + "adminURL": "http://localhost:8776/v2", "region": "RegionOne", - "internalURL": "http://localhost:8774/v2", - "publicURL": "http://localhost:8774/v2/", + "internalURL": "http://localhost:8776/v2", + "publicURL": "http://localhost:8776/v2/", }, ], }, @@ -208,7 +209,7 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): } correct_response = json.dumps(dict_correct_response) dict_responses = [ - {"headers": {'location':'http://127.0.0.1:5001'}, + {"headers": {'location': 'http://127.0.0.1:5001'}, "status_code": 305, "text": "Use proxy"}, # Configured on admin port, cinder redirects to v2.0 port. @@ -268,7 +269,7 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): def test_ambiguous_endpoints(self): cs = client.Client("username", "password", "project_id", - "auth_url/v2.0", service_type='compute') + "http://localhost:8776/v2", service_type='volumev2') resp = { "access": { "token": { @@ -277,25 +278,25 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): }, "serviceCatalog": [ { - "adminURL": "http://localhost:8774/v2", - "type": "compute", - "name": "Compute CLoud", + "adminURL": "http://localhost:8776/v1", + "type": "volumev2", + "name": "Cinder Volume Service", "endpoints": [ { "region": "RegionOne", - "internalURL": "http://localhost:8774/v2", - "publicURL": "http://localhost:8774/v2/", + "internalURL": "http://localhost:8776/v1", + "publicURL": "http://localhost:8776/v1", }, ], }, { - "adminURL": "http://localhost:8774/v2", - "type": "compute", - "name": "Hyper-compute Cloud", + "adminURL": "http://localhost:8776/v2", + "type": "volumev2", + "name": "Cinder Volume V2", "endpoints": [ { - "internalURL": "http://localhost:8774/v2", - "publicURL": "http://localhost:8774/v2/", + "internalURL": "http://localhost:8776/v2", + "publicURL": "http://localhost:8776/v2", }, ], }, diff --git a/cinderclient/tests/v2/test_availability_zone.py b/cinderclient/tests/v2/test_availability_zone.py new file mode 100644 index 0000000..a2e1fc8 --- /dev/null +++ b/cinderclient/tests/v2/test_availability_zone.py @@ -0,0 +1,87 @@ +# Copyright 2011-2013 OpenStack Foundation +# Copyright 2013 IBM Corp. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import six + +from cinderclient.v1 import availability_zones +from cinderclient.v1 import shell +from cinderclient.tests import utils +from cinderclient.tests.v1 import fakes + + +cs = fakes.FakeClient() + + +class AvailabilityZoneTest(utils.TestCase): + + def _assertZone(self, zone, name, status): + self.assertEqual(zone.zoneName, name) + self.assertEqual(zone.zoneState, status) + + def test_list_availability_zone(self): + zones = cs.availability_zones.list(detailed=False) + cs.assert_called('GET', '/os-availability-zone') + + for zone in zones: + self.assertTrue(isinstance(zone, + availability_zones.AvailabilityZone)) + + self.assertEqual(2, len(zones)) + + l0 = [six.u('zone-1'), six.u('available')] + l1 = [six.u('zone-2'), six.u('not available')] + + z0 = shell._treeizeAvailabilityZone(zones[0]) + z1 = shell._treeizeAvailabilityZone(zones[1]) + + self.assertEqual((len(z0), len(z1)), (1, 1)) + + self._assertZone(z0[0], l0[0], l0[1]) + self._assertZone(z1[0], l1[0], l1[1]) + + def test_detail_availability_zone(self): + zones = cs.availability_zones.list(detailed=True) + cs.assert_called('GET', '/os-availability-zone/detail') + + for zone in zones: + self.assertTrue(isinstance(zone, + availability_zones.AvailabilityZone)) + + self.assertEqual(3, len(zones)) + + l0 = [six.u('zone-1'), six.u('available')] + l1 = [six.u('|- fake_host-1'), six.u('')] + l2 = [six.u('| |- cinder-volume'), + six.u('enabled :-) 2012-12-26 14:45:25')] + l3 = [six.u('internal'), six.u('available')] + l4 = [six.u('|- fake_host-1'), six.u('')] + l5 = [six.u('| |- cinder-sched'), + six.u('enabled :-) 2012-12-26 14:45:24')] + l6 = [six.u('zone-2'), six.u('not available')] + + z0 = shell._treeizeAvailabilityZone(zones[0]) + z1 = shell._treeizeAvailabilityZone(zones[1]) + z2 = shell._treeizeAvailabilityZone(zones[2]) + + self.assertEqual((len(z0), len(z1), len(z2)), (3, 3, 1)) + + self._assertZone(z0[0], l0[0], l0[1]) + self._assertZone(z0[1], l1[0], l1[1]) + self._assertZone(z0[2], l2[0], l2[1]) + self._assertZone(z1[0], l3[0], l3[1]) + self._assertZone(z1[1], l4[0], l4[1]) + self._assertZone(z1[2], l5[0], l5[1]) + self._assertZone(z2[0], l6[0], l6[1]) diff --git a/cinderclient/tests/v2/test_limits.py b/cinderclient/tests/v2/test_limits.py new file mode 100644 index 0000000..92b50cd --- /dev/null +++ b/cinderclient/tests/v2/test_limits.py @@ -0,0 +1,164 @@ +# Copyright 2014 OpenStack Foundation +# +# 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 mock + +from cinderclient.tests import utils +from cinderclient.v2 import limits + + +def _get_default_RateLimit(verb="verb1", uri="uri1", regex="regex1", + value="value1", + remain="remain1", unit="unit1", + next_available="next1"): + return limits.RateLimit(verb, uri, regex, value, remain, unit, + next_available) + + +class TestLimits(utils.TestCase): + def test_repr(self): + l = limits.Limits(None, {"foo": "bar"}) + self.assertEqual("", repr(l)) + + def test_absolute(self): + l = limits.Limits(None, + {"absolute": {"name1": "value1", "name2": "value2"}}) + l1 = limits.AbsoluteLimit("name1", "value1") + l2 = limits.AbsoluteLimit("name2", "value2") + for item in l.absolute: + self.assertIn(item, [l1, l2]) + + def test_rate(self): + l = limits.Limits(None, + { + "rate": [ + { + "uri": "uri1", + "regex": "regex1", + "limit": [ + { + "verb": "verb1", + "value": "value1", + "remaining": "remain1", + "unit": "unit1", + "next-available": "next1", + }, + ], + }, + { + "uri": "uri2", + "regex": "regex2", + "limit": [ + { + "verb": "verb2", + "value": "value2", + "remaining": "remain2", + "unit": "unit2", + "next-available": "next2", + }, + ], + }, + ], + }) + l1 = limits.RateLimit("verb1", "uri1", "regex1", "value1", "remain1", + "unit1", "next1") + l2 = limits.RateLimit("verb2", "uri2", "regex2", "value2", "remain2", + "unit2", "next2") + for item in l.rate: + self.assertTrue(item in [l1, l2]) + + +class TestRateLimit(utils.TestCase): + def test_equal(self): + l1 = _get_default_RateLimit() + l2 = _get_default_RateLimit() + self.assertTrue(l1 == l2) + + def test_not_equal_verbs(self): + l1 = _get_default_RateLimit() + l2 = _get_default_RateLimit(verb="verb2") + self.assertFalse(l1 == l2) + + def test_not_equal_uris(self): + l1 = _get_default_RateLimit() + l2 = _get_default_RateLimit(uri="uri2") + self.assertFalse(l1 == l2) + + def test_not_equal_regexps(self): + l1 = _get_default_RateLimit() + l2 = _get_default_RateLimit(regex="regex2") + self.assertFalse(l1 == l2) + + def test_not_equal_values(self): + l1 = _get_default_RateLimit() + l2 = _get_default_RateLimit(value="value2") + self.assertFalse(l1 == l2) + + def test_not_equal_remains(self): + l1 = _get_default_RateLimit() + l2 = _get_default_RateLimit(remain="remain2") + self.assertFalse(l1 == l2) + + def test_not_equal_units(self): + l1 = _get_default_RateLimit() + l2 = _get_default_RateLimit(unit="unit2") + self.assertFalse(l1 == l2) + + def test_not_equal_next_available(self): + l1 = _get_default_RateLimit() + l2 = _get_default_RateLimit(next_available="next2") + self.assertFalse(l1 == l2) + + def test_repr(self): + l1 = _get_default_RateLimit() + self.assertEqual("", repr(l1)) + + +class TestAbsoluteLimit(utils.TestCase): + def test_equal(self): + l1 = limits.AbsoluteLimit("name1", "value1") + l2 = limits.AbsoluteLimit("name1", "value1") + self.assertTrue(l1 == l2) + + def test_not_equal_values(self): + l1 = limits.AbsoluteLimit("name1", "value1") + l2 = limits.AbsoluteLimit("name1", "value2") + self.assertFalse(l1 == l2) + + def test_not_equal_names(self): + l1 = limits.AbsoluteLimit("name1", "value1") + l2 = limits.AbsoluteLimit("name2", "value1") + self.assertFalse(l1 == l2) + + def test_repr(self): + l1 = limits.AbsoluteLimit("name1", "value1") + self.assertEqual("", repr(l1)) + + +class TestLimitsManager(utils.TestCase): + def test_get(self): + api = mock.Mock() + api.client.get.return_value = ( + None, + {"limits": {"absolute": {"name1": "value1", }}, + "no-limits": {"absolute": {"name2": "value2", }}}) + l1 = limits.AbsoluteLimit("name1", "value1") + limitsManager = limits.LimitsManager(api) + + lim = limitsManager.get() + + self.assertIsInstance(lim, limits.Limits) + for l in lim.absolute: + self.assertEqual(l, l1) diff --git a/cinderclient/tests/v2/test_qos.py b/cinderclient/tests/v2/test_qos.py new file mode 100644 index 0000000..3f3e6cf --- /dev/null +++ b/cinderclient/tests/v2/test_qos.py @@ -0,0 +1,79 @@ +# Copyright (C) 2013 eBay Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from cinderclient.tests import utils +from cinderclient.tests.v2 import fakes + + +cs = fakes.FakeClient() + + +class QoSSpecsTest(utils.TestCase): + + def test_create(self): + specs = dict(k1='v1', k2='v2') + cs.qos_specs.create('qos-name', specs) + cs.assert_called('POST', '/qos-specs') + + def test_get(self): + qos_id = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + cs.qos_specs.get(qos_id) + cs.assert_called('GET', '/qos-specs/%s' % qos_id) + + def test_list(self): + cs.qos_specs.list() + cs.assert_called('GET', '/qos-specs') + + def test_delete(self): + cs.qos_specs.delete('1B6B6A04-A927-4AEB-810B-B7BAAD49F57C') + cs.assert_called('DELETE', + '/qos-specs/1B6B6A04-A927-4AEB-810B-B7BAAD49F57C?' + 'force=False') + + def test_set_keys(self): + body = {'qos_specs': dict(k1='v1')} + qos_id = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + cs.qos_specs.set_keys(qos_id, body) + cs.assert_called('PUT', '/qos-specs/%s' % qos_id) + + def test_unset_keys(self): + qos_id = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + body = {'keys': ['k1']} + cs.qos_specs.unset_keys(qos_id, body) + cs.assert_called('PUT', '/qos-specs/%s/delete_keys' % qos_id) + + def test_get_associations(self): + qos_id = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + cs.qos_specs.get_associations(qos_id) + cs.assert_called('GET', '/qos-specs/%s/associations' % qos_id) + + def test_associate(self): + qos_id = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + type_id = '4230B13A-7A37-4E84-B777-EFBA6FCEE4FF' + cs.qos_specs.associate(qos_id, type_id) + cs.assert_called('GET', '/qos-specs/%s/associate?vol_type_id=%s' + % (qos_id, type_id)) + + def test_disassociate(self): + qos_id = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + type_id = '4230B13A-7A37-4E84-B777-EFBA6FCEE4FF' + cs.qos_specs.disassociate(qos_id, type_id) + cs.assert_called('GET', '/qos-specs/%s/disassociate?vol_type_id=%s' + % (qos_id, type_id)) + + def test_disassociate_all(self): + qos_id = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + cs.qos_specs.disassociate_all(qos_id) + cs.assert_called('GET', '/qos-specs/%s/disassociate_all' % qos_id) diff --git a/tests/v1/test_quota_classes.py b/cinderclient/tests/v2/test_quota_classes.py similarity index 88% rename from tests/v1/test_quota_classes.py rename to cinderclient/tests/v2/test_quota_classes.py index 4d4cb58..0fee1e8 100644 --- a/tests/v1/test_quota_classes.py +++ b/cinderclient/tests/v2/test_quota_classes.py @@ -1,4 +1,4 @@ -# Copyright 2011 OpenStack LLC. +# Copyright (c) 2013 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -13,8 +13,8 @@ # License for the specific language governing permissions and limitations # under the License. -from tests import utils -from tests.v1 import fakes +from cinderclient.tests import utils +from cinderclient.tests.v2 import fakes cs = fakes.FakeClient() @@ -29,7 +29,7 @@ class QuotaClassSetsTest(utils.TestCase): def test_update_quota(self): q = cs.quota_classes.get('test') - q.update(volumes=2) + q.update(volumes=2, snapshots=2) cs.assert_called('PUT', '/os-quota-class-sets/test') def test_refresh_quota(self): diff --git a/tests/v1/test_quotas.py b/cinderclient/tests/v2/test_quotas.py similarity index 89% rename from tests/v1/test_quotas.py rename to cinderclient/tests/v2/test_quotas.py index 7afc626..5a61dbb 100644 --- a/tests/v1/test_quotas.py +++ b/cinderclient/tests/v2/test_quotas.py @@ -1,4 +1,4 @@ -# Copyright 2011 OpenStack LLC. +# Copyright (c) 2013 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -13,8 +13,8 @@ # License for the specific language governing permissions and limitations # under the License. -from tests import utils -from tests.v1 import fakes +from cinderclient.tests import utils +from cinderclient.tests.v2 import fakes cs = fakes.FakeClient() @@ -25,7 +25,7 @@ class QuotaSetsTest(utils.TestCase): def test_tenant_quotas_get(self): tenant_id = 'test' cs.quotas.get(tenant_id) - cs.assert_called('GET', '/os-quota-sets/%s' % tenant_id) + cs.assert_called('GET', '/os-quota-sets/%s?usage=False' % tenant_id) def test_tenant_quotas_defaults(self): tenant_id = 'test' diff --git a/cinderclient/tests/v2/test_services.py b/cinderclient/tests/v2/test_services.py new file mode 100644 index 0000000..07a2389 --- /dev/null +++ b/cinderclient/tests/v2/test_services.py @@ -0,0 +1,66 @@ +# Copyright (c) 2013 OpenStack Foundation +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from cinderclient.tests import utils +from cinderclient.tests.v2 import fakes +from cinderclient.v2 import services + + +cs = fakes.FakeClient() + + +class ServicesTest(utils.TestCase): + + def test_list_services(self): + svs = cs.services.list() + cs.assert_called('GET', '/os-services') + self.assertEqual(len(svs), 3) + [self.assertTrue(isinstance(s, services.Service)) for s in svs] + + def test_list_services_with_hostname(self): + svs = cs.services.list(host='host2') + cs.assert_called('GET', '/os-services?host=host2') + self.assertEqual(len(svs), 2) + [self.assertTrue(isinstance(s, services.Service)) for s in svs] + [self.assertEqual(s.host, 'host2') for s in svs] + + def test_list_services_with_binary(self): + svs = cs.services.list(binary='cinder-volume') + cs.assert_called('GET', '/os-services?binary=cinder-volume') + self.assertEqual(len(svs), 2) + [self.assertTrue(isinstance(s, services.Service)) for s in svs] + [self.assertEqual(s.binary, 'cinder-volume') for s in svs] + + def test_list_services_with_host_binary(self): + svs = cs.services.list('host2', 'cinder-volume') + cs.assert_called('GET', '/os-services?host=host2&binary=cinder-volume') + self.assertEqual(len(svs), 1) + [self.assertTrue(isinstance(s, services.Service)) for s in svs] + [self.assertEqual(s.host, 'host2') for s in svs] + [self.assertEqual(s.binary, 'cinder-volume') for s in svs] + + def test_services_enable(self): + s = cs.services.enable('host1', 'cinder-volume') + values = {"host": "host1", 'binary': 'cinder-volume'} + cs.assert_called('PUT', '/os-services/enable', values) + self.assertTrue(isinstance(s, services.Service)) + self.assertEqual(s.status, 'enabled') + + def test_services_disable(self): + s = cs.services.disable('host1', 'cinder-volume') + values = {"host": "host1", 'binary': 'cinder-volume'} + cs.assert_called('PUT', '/os-services/disable', values) + self.assertTrue(isinstance(s, services.Service)) + self.assertEqual(s.status, 'disabled') diff --git a/cinderclient/tests/v2/test_shell.py b/cinderclient/tests/v2/test_shell.py new file mode 100644 index 0000000..4946ada --- /dev/null +++ b/cinderclient/tests/v2/test_shell.py @@ -0,0 +1,347 @@ +# Copyright (c) 2013 OpenStack Foundation +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import fixtures + +from cinderclient import client +from cinderclient import shell +from cinderclient.tests import utils +from cinderclient.tests.v2 import fakes + + +class ShellTest(utils.TestCase): + + FAKE_ENV = { + 'CINDER_USERNAME': 'username', + 'CINDER_PASSWORD': 'password', + 'CINDER_PROJECT_ID': 'project_id', + 'OS_VOLUME_API_VERSION': '2', + 'CINDER_URL': 'http://no.where', + } + + # Patch os.environ to avoid required auth info. + def setUp(self): + """Run before each test.""" + super(ShellTest, self).setUp() + for var in self.FAKE_ENV: + self.useFixture(fixtures.EnvironmentVariable(var, + self.FAKE_ENV[var])) + + self.shell = shell.OpenStackCinderShell() + + #HACK(bcwaldon): replace this when we start using stubs + self.old_get_client_class = client.get_client_class + client.get_client_class = lambda *_: fakes.FakeClient + + def tearDown(self): + # For some method like test_image_meta_bad_action we are + # testing a SystemExit to be thrown and object self.shell has + # no time to get instantatiated which is OK in this case, so + # we make sure the method is there before launching it. + if hasattr(self.shell, 'cs'): + self.shell.cs.clear_callstack() + + #HACK(bcwaldon): replace this when we start using stubs + client.get_client_class = self.old_get_client_class + super(ShellTest, self).tearDown() + + 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_anytime(self, method, url, body=None): + return self.shell.cs.assert_called_anytime(method, url, body) + + def test_list(self): + self.run_command('list') + # NOTE(jdg): we default to detail currently + self.assert_called('GET', '/volumes/detail') + + def test_list_filter_status(self): + self.run_command('list --status=available') + self.assert_called('GET', '/volumes/detail?status=available') + + def test_list_filter_name(self): + self.run_command('list --name=1234') + self.assert_called('GET', '/volumes/detail?name=1234') + + def test_list_all_tenants(self): + self.run_command('list --all-tenants=1') + self.assert_called('GET', '/volumes/detail?all_tenants=1') + + def test_list_availability_zone(self): + self.run_command('availability-zone-list') + self.assert_called('GET', '/os-availability-zone') + + def test_show(self): + self.run_command('show 1234') + self.assert_called('GET', '/volumes/1234') + + def test_delete(self): + self.run_command('delete 1234') + self.assert_called('DELETE', '/volumes/1234') + + def test_delete_by_name(self): + self.run_command('delete sample-volume') + self.assert_called_anytime('GET', '/volumes/detail?all_tenants=1') + self.assert_called('DELETE', '/volumes/1234') + + def test_delete_multiple(self): + self.run_command('delete 1234 5678') + self.assert_called_anytime('DELETE', '/volumes/1234') + self.assert_called('DELETE', '/volumes/5678') + + def test_backup(self): + self.run_command('backup-create 1234') + self.assert_called('POST', '/backups') + + def test_restore(self): + self.run_command('backup-restore 1234') + self.assert_called('POST', '/backups/1234/restore') + + def test_snapshot_list_filter_volume_id(self): + self.run_command('snapshot-list --volume-id=1234') + self.assert_called('GET', '/snapshots/detail?volume_id=1234') + + def test_snapshot_list_filter_status_and_volume_id(self): + self.run_command('snapshot-list --status=available --volume-id=1234') + self.assert_called('GET', '/snapshots/detail?' + 'status=available&volume_id=1234') + + def test_rename(self): + # basic rename with positional arguments + self.run_command('rename 1234 new-name') + expected = {'volume': {'name': 'new-name'}} + self.assert_called('PUT', '/volumes/1234', body=expected) + # change description only + self.run_command('rename 1234 --description=new-description') + expected = {'volume': {'description': 'new-description'}} + self.assert_called('PUT', '/volumes/1234', body=expected) + # rename and change description + self.run_command('rename 1234 new-name ' + '--description=new-description') + expected = {'volume': { + 'name': 'new-name', + 'description': 'new-description', + }} + self.assert_called('PUT', '/volumes/1234', body=expected) + + # Call rename with no arguments + self.assertRaises(SystemExit, self.run_command, 'rename') + + def test_rename_snapshot(self): + # basic rename with positional arguments + self.run_command('snapshot-rename 1234 new-name') + expected = {'snapshot': {'name': 'new-name'}} + self.assert_called('PUT', '/snapshots/1234', body=expected) + # change description only + self.run_command('snapshot-rename 1234 ' + '--description=new-description') + expected = {'snapshot': {'description': 'new-description'}} + self.assert_called('PUT', '/snapshots/1234', body=expected) + # snapshot-rename and change description + self.run_command('snapshot-rename 1234 new-name ' + '--description=new-description') + expected = {'snapshot': { + 'name': 'new-name', + 'description': 'new-description', + }} + self.assert_called('PUT', '/snapshots/1234', body=expected) + + # Call snapshot-rename with no arguments + self.assertRaises(SystemExit, self.run_command, 'snapshot-rename') + + def test_set_metadata_set(self): + self.run_command('metadata 1234 set key1=val1 key2=val2') + self.assert_called('POST', '/volumes/1234/metadata', + {'metadata': {'key1': 'val1', 'key2': 'val2'}}) + + def test_set_metadata_delete_dict(self): + self.run_command('metadata 1234 unset key1=val1 key2=val2') + self.assert_called('DELETE', '/volumes/1234/metadata/key1') + self.assert_called('DELETE', '/volumes/1234/metadata/key2', pos=-2) + + def test_set_metadata_delete_keys(self): + self.run_command('metadata 1234 unset key1 key2') + self.assert_called('DELETE', '/volumes/1234/metadata/key1') + self.assert_called('DELETE', '/volumes/1234/metadata/key2', pos=-2) + + def test_reset_state(self): + self.run_command('reset-state 1234') + expected = {'os-reset_status': {'status': 'available'}} + self.assert_called('POST', '/volumes/1234/action', body=expected) + + def test_reset_state_with_flag(self): + self.run_command('reset-state --state error 1234') + expected = {'os-reset_status': {'status': 'error'}} + self.assert_called('POST', '/volumes/1234/action', body=expected) + + def test_reset_state_multiple(self): + self.run_command('reset-state 1234 5678 --state error') + expected = {'os-reset_status': {'status': 'error'}} + self.assert_called_anytime('POST', '/volumes/1234/action', + body=expected) + self.assert_called_anytime('POST', '/volumes/5678/action', + body=expected) + + def test_snapshot_reset_state(self): + self.run_command('snapshot-reset-state 1234') + expected = {'os-reset_status': {'status': 'available'}} + self.assert_called('POST', '/snapshots/1234/action', body=expected) + + def test_snapshot_reset_state_with_flag(self): + self.run_command('snapshot-reset-state --state error 1234') + expected = {'os-reset_status': {'status': 'error'}} + self.assert_called('POST', '/snapshots/1234/action', body=expected) + + def test_snapshot_reset_state_multiple(self): + self.run_command('snapshot-reset-state 1234 5678') + expected = {'os-reset_status': {'status': 'available'}} + self.assert_called_anytime('POST', '/snapshots/1234/action', + body=expected) + self.assert_called_anytime('POST', '/snapshots/5678/action', + body=expected) + + def test_encryption_type_list(self): + """ + Test encryption-type-list shell command. + + Verify a series of GET requests are made: + - one to get the volume type list information + - one per volume type to retrieve the encryption type information + """ + self.run_command('encryption-type-list') + self.assert_called_anytime('GET', '/types') + self.assert_called_anytime('GET', '/types/1/encryption') + self.assert_called_anytime('GET', '/types/2/encryption') + + def test_encryption_type_show(self): + """ + Test encryption-type-show shell command. + + Verify two GET requests are made per command invocation: + - one to get the volume type information + - one to get the encryption type information + """ + self.run_command('encryption-type-show 1') + self.assert_called('GET', '/types/1/encryption') + self.assert_called_anytime('GET', '/types/1') + + def test_encryption_type_create(self): + """ + Test encryption-type-create shell command. + + Verify GET and POST requests are made per command invocation: + - one GET request to retrieve the relevant volume type information + - one POST request to create the new encryption type + """ + expected = {'encryption': {'cipher': None, 'key_size': None, + 'provider': 'TestProvider', + 'control_location': None}} + self.run_command('encryption-type-create 2 TestProvider') + self.assert_called('POST', '/types/2/encryption', body=expected) + self.assert_called_anytime('GET', '/types/2') + + def test_encryption_type_update(self): + """ + Test encryption-type-update shell command. + + Verify two GETs/one PUT requests are made per command invocation: + - one GET request to retrieve the relevant volume type information + - one GET request to retrieve the relevant encryption type information + - one PUT request to update the encryption type information + """ + self.skipTest("Not implemented") + + def test_encryption_type_delete(self): + """ + Test encryption-type-delete shell command. + + Verify one GET/one DELETE requests are made per command invocation: + - one GET request to retrieve the relevant volume type information + - one DELETE request to delete the encryption type information + """ + self.run_command('encryption-type-delete 1') + self.assert_called('DELETE', '/types/1/encryption/provider') + self.assert_called_anytime('GET', '/types/1') + + def test_migrate_volume(self): + self.run_command('migrate 1234 fakehost --force-host-copy=True') + expected = {'os-migrate_volume': {'force_host_copy': 'True', + 'host': 'fakehost'}} + self.assert_called('POST', '/volumes/1234/action', body=expected) + + def test_snapshot_metadata_set(self): + self.run_command('snapshot-metadata 1234 set key1=val1 key2=val2') + self.assert_called('POST', '/snapshots/1234/metadata', + {'metadata': {'key1': 'val1', 'key2': 'val2'}}) + + def test_snapshot_metadata_unset_dict(self): + self.run_command('snapshot-metadata 1234 unset key1=val1 key2=val2') + self.assert_called_anytime('DELETE', '/snapshots/1234/metadata/key1') + self.assert_called_anytime('DELETE', '/snapshots/1234/metadata/key2') + + def test_snapshot_metadata_unset_keys(self): + self.run_command('snapshot-metadata 1234 unset key1 key2') + self.assert_called_anytime('DELETE', '/snapshots/1234/metadata/key1') + self.assert_called_anytime('DELETE', '/snapshots/1234/metadata/key2') + + def test_volume_metadata_update_all(self): + self.run_command('metadata-update-all 1234 key1=val1 key2=val2') + self.assert_called('PUT', '/volumes/1234/metadata', + {'metadata': {'key1': 'val1', 'key2': 'val2'}}) + + def test_snapshot_metadata_update_all(self): + self.run_command('snapshot-metadata-update-all\ + 1234 key1=val1 key2=val2') + self.assert_called('PUT', '/snapshots/1234/metadata', + {'metadata': {'key1': 'val1', 'key2': 'val2'}}) + + def test_readonly_mode_update(self): + self.run_command('readonly-mode-update 1234 True') + expected = {'os-update_readonly_flag': {'readonly': True}} + self.assert_called('POST', '/volumes/1234/action', body=expected) + + self.run_command('readonly-mode-update 1234 False') + expected = {'os-update_readonly_flag': {'readonly': False}} + self.assert_called('POST', '/volumes/1234/action', body=expected) + + def test_service_disable(self): + self.run_command('service-disable host cinder-volume') + self.assert_called('PUT', '/os-services/disable', + {"binary": "cinder-volume", "host": "host"}) + + def test_service_enable(self): + self.run_command('service-enable host cinder-volume') + self.assert_called('PUT', '/os-services/enable', + {"binary": "cinder-volume", "host": "host"}) + + def test_retype_with_policy(self): + self.run_command('retype 1234 foo --migration-policy=on-demand') + expected = {'os-retype': {'new_type': 'foo', + 'migration_policy': 'on-demand'}} + self.assert_called('POST', '/volumes/1234/action', body=expected) + + def test_retype_default_policy(self): + self.run_command('retype 1234 foo') + expected = {'os-retype': {'new_type': 'foo', + 'migration_policy': 'never'}} + self.assert_called('POST', '/volumes/1234/action', body=expected) + + def test_snapshot_delete(self): + self.run_command('snapshot-delete 1234') + self.assert_called('DELETE', '/snapshots/1234') diff --git a/cinderclient/tests/v2/test_snapshot_actions.py b/cinderclient/tests/v2/test_snapshot_actions.py new file mode 100644 index 0000000..f70cc8f --- /dev/null +++ b/cinderclient/tests/v2/test_snapshot_actions.py @@ -0,0 +1,35 @@ +# Copyright 2013 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from cinderclient.tests import utils +from cinderclient.tests.v2 import fakes + + +cs = fakes.FakeClient() + + +class SnapshotActionsTest(utils.TestCase): + def test_update_snapshot_status(self): + s = cs.volume_snapshots.get('1234') + cs.volume_snapshots.update_snapshot_status(s, + {'status': 'available'}) + cs.assert_called('POST', '/snapshots/1234/action') + + def test_update_snapshot_status_with_progress(self): + s = cs.volume_snapshots.get('1234') + cs.volume_snapshots.update_snapshot_status(s, + {'status': 'available', + 'progress': '73%'}) + cs.assert_called('POST', '/snapshots/1234/action') diff --git a/tests/v2/test_types.py b/cinderclient/tests/v2/test_types.py similarity index 93% rename from tests/v2/test_types.py rename to cinderclient/tests/v2/test_types.py index f36b768..70fbaeb 100644 --- a/tests/v2/test_types.py +++ b/cinderclient/tests/v2/test_types.py @@ -1,4 +1,4 @@ -# Copyright (c) 2013 OpenStack, LLC. +# Copyright (c) 2013 OpenStack Foundation # # All Rights Reserved. # @@ -15,8 +15,8 @@ # under the License. from cinderclient.v2 import volume_types -from tests import utils -from tests.v2 import fakes +from cinderclient.tests import utils +from cinderclient.tests.v2 import fakes cs = fakes.FakeClient() diff --git a/cinderclient/tests/v2/test_volume_backups.py b/cinderclient/tests/v2/test_volume_backups.py new file mode 100644 index 0000000..44b1c54 --- /dev/null +++ b/cinderclient/tests/v2/test_volume_backups.py @@ -0,0 +1,53 @@ +# Copyright (C) 2013 Hewlett-Packard Development Company, L.P. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from cinderclient.tests import utils +from cinderclient.tests.v2 import fakes + + +cs = fakes.FakeClient() + + +class VolumeBackupsTest(utils.TestCase): + + def test_create(self): + cs.backups.create('2b695faf-b963-40c8-8464-274008fbcef4') + cs.assert_called('POST', '/backups') + + def test_get(self): + backup_id = '76a17945-3c6f-435c-975b-b5685db10b62' + cs.backups.get(backup_id) + cs.assert_called('GET', '/backups/%s' % backup_id) + + def test_list(self): + cs.backups.list() + cs.assert_called('GET', '/backups/detail') + + def test_delete(self): + b = cs.backups.list()[0] + b.delete() + cs.assert_called('DELETE', + '/backups/76a17945-3c6f-435c-975b-b5685db10b62') + cs.backups.delete('76a17945-3c6f-435c-975b-b5685db10b62') + cs.assert_called('DELETE', + '/backups/76a17945-3c6f-435c-975b-b5685db10b62') + cs.backups.delete(b) + cs.assert_called('DELETE', + '/backups/76a17945-3c6f-435c-975b-b5685db10b62') + + def test_restore(self): + backup_id = '76a17945-3c6f-435c-975b-b5685db10b62' + cs.restores.restore(backup_id) + cs.assert_called('POST', '/backups/%s/restore' % backup_id) diff --git a/cinderclient/tests/v2/test_volume_encryption_types.py b/cinderclient/tests/v2/test_volume_encryption_types.py new file mode 100644 index 0000000..0d609ea --- /dev/null +++ b/cinderclient/tests/v2/test_volume_encryption_types.py @@ -0,0 +1,100 @@ +# Copyright (c) 2013 The Johns Hopkins University/Applied Physics Laboratory +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from cinderclient.v2.volume_encryption_types import VolumeEncryptionType +from cinderclient.tests import utils +from cinderclient.tests.v2 import fakes + +cs = fakes.FakeClient() + + +class VolumeEncryptionTypesTest(utils.TestCase): + """ + Test suite for the Volume Encryption Types Resource and Manager. + """ + + def test_list(self): + """ + Unit test for VolumeEncryptionTypesManager.list + + Verify that a series of GET requests are made: + - one GET request for the list of volume types + - one GET request per volume type for encryption type information + + Verify that all returned information is :class: VolumeEncryptionType + """ + encryption_types = cs.volume_encryption_types.list() + cs.assert_called_anytime('GET', '/types') + cs.assert_called_anytime('GET', '/types/2/encryption') + cs.assert_called_anytime('GET', '/types/1/encryption') + for encryption_type in encryption_types: + self.assertIsInstance(encryption_type, VolumeEncryptionType) + + def test_get(self): + """ + Unit test for VolumeEncryptionTypesManager.get + + Verify that one GET request is made for the volume type encryption + type information. Verify that returned information is :class: + VolumeEncryptionType + """ + encryption_type = cs.volume_encryption_types.get(1) + cs.assert_called('GET', '/types/1/encryption') + self.assertIsInstance(encryption_type, VolumeEncryptionType) + + def test_get_no_encryption(self): + """ + Unit test for VolumeEncryptionTypesManager.get + + Verify that a request on a volume type with no associated encryption + type information returns a VolumeEncryptionType with no attributes. + """ + encryption_type = cs.volume_encryption_types.get(2) + self.assertIsInstance(encryption_type, VolumeEncryptionType) + self.assertFalse(hasattr(encryption_type, 'id'), + 'encryption type has an id') + + def test_create(self): + """ + Unit test for VolumeEncryptionTypesManager.create + + Verify that one POST request is made for the encryption type creation. + Verify that encryption type creation returns a VolumeEncryptionType. + """ + result = cs.volume_encryption_types.create(2, {'encryption': + {'provider': 'Test', + 'key_size': None, + 'cipher': None, + 'control_location': + None}}) + cs.assert_called('POST', '/types/2/encryption') + self.assertIsInstance(result, VolumeEncryptionType) + + def test_update(self): + """ + Unit test for VolumeEncryptionTypesManager.update + """ + self.skipTest("Not implemented") + + def test_delete(self): + """ + Unit test for VolumeEncryptionTypesManager.delete + + Verify that one DELETE request is made for encryption type deletion + Verify that encryption type deletion returns None + """ + result = cs.volume_encryption_types.delete(1) + cs.assert_called('DELETE', '/types/1/encryption/provider') + self.assertIsNone(result, "delete result must be None") diff --git a/cinderclient/tests/v2/test_volume_transfers.py b/cinderclient/tests/v2/test_volume_transfers.py new file mode 100644 index 0000000..47656d7 --- /dev/null +++ b/cinderclient/tests/v2/test_volume_transfers.py @@ -0,0 +1,51 @@ +# Copyright (C) 2013 Hewlett-Packard Development Company, L.P. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from cinderclient.tests import utils +from cinderclient.tests.v1 import fakes + + +cs = fakes.FakeClient() + + +class VolumeTransfersTest(utils.TestCase): + + def test_create(self): + cs.transfers.create('1234') + cs.assert_called('POST', '/os-volume-transfer') + + def test_get(self): + transfer_id = '5678' + cs.transfers.get(transfer_id) + cs.assert_called('GET', '/os-volume-transfer/%s' % transfer_id) + + def test_list(self): + cs.transfers.list() + cs.assert_called('GET', '/os-volume-transfer/detail') + + def test_delete(self): + b = cs.transfers.list()[0] + b.delete() + cs.assert_called('DELETE', '/os-volume-transfer/5678') + cs.transfers.delete('5678') + cs.assert_called('DELETE', '/os-volume-transfer/5678') + cs.transfers.delete(b) + cs.assert_called('DELETE', '/os-volume-transfer/5678') + + def test_accept(self): + transfer_id = '5678' + auth_key = '12345' + cs.transfers.accept(transfer_id, auth_key) + cs.assert_called('POST', '/os-volume-transfer/%s/accept' % transfer_id) diff --git a/tests/v2/test_volumes.py b/cinderclient/tests/v2/test_volumes.py similarity index 65% rename from tests/v2/test_volumes.py rename to cinderclient/tests/v2/test_volumes.py index 85401af..5e12812 100644 --- a/tests/v2/test_volumes.py +++ b/cinderclient/tests/v2/test_volumes.py @@ -1,4 +1,4 @@ -# Copyright (c) 2013 OpenStack, LLC. +# Copyright (c) 2013 OpenStack Foundation # # All Rights Reserved. # @@ -14,8 +14,8 @@ # License for the specific language governing permissions and limitations # under the License. -from tests import utils -from tests.v2 import fakes +from cinderclient.tests import utils +from cinderclient.tests.v2 import fakes cs = fakes.FakeClient() @@ -32,13 +32,13 @@ class VolumesTest(utils.TestCase): cs.volumes.delete(v) cs.assert_called('DELETE', '/volumes/1234') - def test_create_keypair(self): + def test_create_volume(self): cs.volumes.create(1) cs.assert_called('POST', '/volumes') def test_attach(self): v = cs.volumes.get('1234') - cs.volumes.attach(v, 1, '/dev/vdc') + cs.volumes.attach(v, 1, '/dev/vdc', mode='ro') cs.assert_called('POST', '/volumes/1234/action') def test_detach(self): @@ -85,3 +85,34 @@ class VolumesTest(utils.TestCase): keys = ['key1'] cs.volumes.delete_metadata(1234, keys) cs.assert_called('DELETE', '/volumes/1234/metadata/key1') + + def test_extend(self): + v = cs.volumes.get('1234') + cs.volumes.extend(v, 2) + cs.assert_called('POST', '/volumes/1234/action') + + def test_get_encryption_metadata(self): + cs.volumes.get_encryption_metadata('1234') + cs.assert_called('GET', '/volumes/1234/encryption') + + def test_migrate(self): + v = cs.volumes.get('1234') + cs.volumes.migrate_volume(v, 'dest', False) + cs.assert_called('POST', '/volumes/1234/action') + + def test_metadata_update_all(self): + cs.volumes.update_all_metadata(1234, {'k1': 'v1'}) + cs.assert_called('PUT', '/volumes/1234/metadata', + {'metadata': {'k1': 'v1'}}) + + def test_readonly_mode_update(self): + v = cs.volumes.get('1234') + cs.volumes.update_readonly_flag(v, True) + cs.assert_called('POST', '/volumes/1234/action') + + def test_retype(self): + v = cs.volumes.get('1234') + cs.volumes.retype(v, 'foo', 'on-demand') + cs.assert_called('POST', '/volumes/1234/action', + {'os-retype': {'new_type': 'foo', + 'migration_policy': 'on-demand'}}) diff --git a/cinderclient/utils.py b/cinderclient/utils.py index f9c6566..a941873 100644 --- a/cinderclient/utils.py +++ b/cinderclient/utils.py @@ -1,8 +1,26 @@ +# Copyright (c) 2013 OpenStack Foundation +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from __future__ import print_function + import os import re import sys import uuid +import six import prettytable from cinderclient import exceptions @@ -38,7 +56,7 @@ def add_arg(f, *args, **kwargs): # NOTE(sirp): avoid dups that can occur when the module is shared across # tests. if (args, kwargs) not in f.arguments: - # Because of the sematics of decorator composition if we just append + # Because of the semantics of decorator composition if we just append # to the options list positional options will appear to be backwards. f.arguments.insert(0, (args, kwargs)) @@ -70,8 +88,12 @@ def get_resource_manager_extra_kwargs(f, args, allow_conflicts=False): conflicting_keys = set(hook_kwargs.keys()) & set(extra_kwargs.keys()) if conflicting_keys and not allow_conflicts: - raise Exception("Hook '%(hook_name)s' is attempting to redefine" - " attributes '%(conflicting_keys)s'" % locals()) + msg = ("Hook '%(hook_name)s' is attempting to redefine attributes " + "'%(conflicting_keys)s'" % { + 'hook_name': hook_name, + 'conflicting_keys': conflicting_keys + }) + raise Exception(msg) extra_kwargs.update(hook_kwargs) @@ -124,7 +146,14 @@ def pretty_choice_list(l): return ', '.join("'%s'" % i for i in l) -def print_list(objs, fields, formatters={}): +def _print(pt, order): + if sys.version_info >= (3, 0): + print(pt.get_string(sortby=order)) + else: + print(strutils.safe_encode(pt.get_string(sortby=order))) + + +def print_list(objs, fields, formatters={}, order_by=None): mixed_case_fields = ['serverId'] pt = prettytable.PrettyTable([f for f in fields], caching=False) pt.aligns = ['l' for f in fields] @@ -139,18 +168,23 @@ def print_list(objs, fields, formatters={}): field_name = field.replace(' ', '_') else: field_name = field.lower().replace(' ', '_') - data = getattr(o, field_name, '') + if type(o) == dict and field in o: + data = o[field] + else: + data = getattr(o, field_name, '') row.append(data) pt.add_row(row) - print strutils.safe_encode(pt.get_string(sortby=fields[0])) + if order_by is None: + order_by = fields[0] + _print(pt, order_by) def print_dict(d, property="Property"): pt = prettytable.PrettyTable([property, 'Value'], caching=False) pt.aligns = ['l', 'l'] - [pt.add_row(list(r)) for r in d.iteritems()] - print strutils.safe_encode(pt.get_string(sortby=property)) + [pt.add_row(list(r)) for r in six.iteritems(d)] + _print(pt, property) def find_resource(manager, name_or_id): @@ -162,9 +196,12 @@ def find_resource(manager, name_or_id): except exceptions.NotFound: pass + if sys.version_info <= (3, 0): + name_or_id = strutils.safe_decode(name_or_id) + # now try to get entity as uuid try: - uuid.UUID(strutils.safe_decode(name_or_id)) + uuid.UUID(name_or_id) return manager.get(name_or_id) except (ValueError, exceptions.NotFound): pass @@ -196,9 +233,14 @@ def find_resource(manager, name_or_id): raise exceptions.CommandError(msg) +def find_volume(cs, volume): + """Get a volume by name or ID.""" + return find_resource(cs.volumes, volume) + + def _format_servers_list_networks(server): output = [] - for (network, addresses) in server.networks.items(): + for (network, addresses) in list(server.networks.items()): if len(addresses) == 0: continue addresses_csv = ', '.join(addresses) @@ -258,8 +300,8 @@ def slugify(value): From Django's "django/template/defaultfilters.py". """ import unicodedata - if not isinstance(value, unicode): - value = unicode(value) + if not isinstance(value, six.text_type): + value = six.text_type(value) value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore') - value = unicode(_slugify_strip_re.sub('', value).strip().lower()) + value = six.text_type(_slugify_strip_re.sub('', value).strip().lower()) return _slugify_hyphenate_re.sub('-', value) diff --git a/cinderclient/v1/__init__.py b/cinderclient/v1/__init__.py index cecfacd..3637ffd 100644 --- a/cinderclient/v1/__init__.py +++ b/cinderclient/v1/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2012 OpenStack, LLC. +# Copyright (c) 2012 OpenStack Foundation # # All Rights Reserved. # @@ -14,4 +14,4 @@ # License for the specific language governing permissions and limitations # under the License. -from cinderclient.v1.client import Client +from cinderclient.v1.client import Client # noqa diff --git a/cinderclient/v1/availability_zones.py b/cinderclient/v1/availability_zones.py new file mode 100644 index 0000000..85ea9d7 --- /dev/null +++ b/cinderclient/v1/availability_zones.py @@ -0,0 +1,42 @@ +# Copyright 2011-2013 OpenStack Foundation +# Copyright 2013 IBM Corp. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Availability Zone interface (v1 extension)""" + +from cinderclient import base + + +class AvailabilityZone(base.Resource): + NAME_ATTR = 'display_name' + + def __repr__(self): + return "" % self.zoneName + + +class AvailabilityZoneManager(base.ManagerWithFind): + """Manage :class:`AvailabilityZone` resources.""" + resource_class = AvailabilityZone + + def list(self, detailed=False): + """Get a list of all availability zones + + :rtype: list of :class:`AvailabilityZone` + """ + if detailed is True: + return self._list("/os-availability-zone/detail", + "availabilityZoneInfo") + else: + return self._list("/os-availability-zone", "availabilityZoneInfo") diff --git a/cinderclient/v1/client.py b/cinderclient/v1/client.py index efdf3e2..82d1ee0 100644 --- a/cinderclient/v1/client.py +++ b/cinderclient/v1/client.py @@ -1,10 +1,32 @@ +# Copyright (c) 2013 OpenStack Foundation +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + from cinderclient import client +from cinderclient.v1 import availability_zones from cinderclient.v1 import limits +from cinderclient.v1 import qos_specs from cinderclient.v1 import quota_classes from cinderclient.v1 import quotas +from cinderclient.v1 import services from cinderclient.v1 import volumes from cinderclient.v1 import volume_snapshots from cinderclient.v1 import volume_types +from cinderclient.v1 import volume_encryption_types +from cinderclient.v1 import volume_backups +from cinderclient.v1 import volume_backups_restore +from cinderclient.v1 import volume_transfers class Client(object): @@ -39,8 +61,17 @@ class Client(object): self.volumes = volumes.VolumeManager(self) self.volume_snapshots = volume_snapshots.SnapshotManager(self) self.volume_types = volume_types.VolumeTypeManager(self) + self.volume_encryption_types = \ + volume_encryption_types.VolumeEncryptionTypeManager(self) + self.qos_specs = qos_specs.QoSSpecsManager(self) self.quota_classes = quota_classes.QuotaClassSetManager(self) self.quotas = quotas.QuotaSetManager(self) + self.backups = volume_backups.VolumeBackupManager(self) + self.restores = volume_backups_restore.VolumeBackupRestoreManager(self) + self.transfers = volume_transfers.VolumeTransferManager(self) + self.services = services.ServiceManager(self) + self.availability_zones = \ + availability_zones.AvailabilityZoneManager(self) # Add in any extensions... if extensions: @@ -79,3 +110,6 @@ class Client(object): credentials are wrong. """ self.client.authenticate() + + def get_volume_api_version_from_endpoint(self): + return self.client.get_volume_api_version_from_endpoint() diff --git a/cinderclient/v1/contrib/list_extensions.py b/cinderclient/v1/contrib/list_extensions.py index 91fa040..5aab82f 100644 --- a/cinderclient/v1/contrib/list_extensions.py +++ b/cinderclient/v1/contrib/list_extensions.py @@ -1,4 +1,4 @@ -# Copyright 2011 OpenStack LLC. +# Copyright (c) 2011 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may diff --git a/cinderclient/v1/limits.py b/cinderclient/v1/limits.py index 2008a69..1ae2815 100644 --- a/cinderclient/v1/limits.py +++ b/cinderclient/v1/limits.py @@ -1,17 +1,30 @@ -# Copyright 2011 OpenStack LLC. +# Copyright 2011 OpenStack Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. from cinderclient import base class Limits(base.Resource): - """A collection of RateLimit and AbsoluteLimit objects""" + """A collection of RateLimit and AbsoluteLimit objects.""" def __repr__(self): return "" @property def absolute(self): - for (name, value) in self._info['absolute'].items(): + for (name, value) in list(self._info['absolute'].items()): yield AbsoluteLimit(name, value) @property @@ -26,7 +39,7 @@ class Limits(base.Resource): class RateLimit(object): - """Data model that represents a flattened view of a single rate limit""" + """Data model that represents a flattened view of a single rate limit.""" def __init__(self, verb, uri, regex, value, remain, unit, next_available): @@ -48,11 +61,11 @@ class RateLimit(object): and self.next_available == other.next_available def __repr__(self): - return "" % (self.method, self.uri) + return "" % (self.verb, self.uri) class AbsoluteLimit(object): - """Data model that represents a single absolute limit""" + """Data model that represents a single absolute limit.""" def __init__(self, name, value): self.name = name @@ -66,7 +79,7 @@ class AbsoluteLimit(object): class LimitsManager(base.Manager): - """Manager object used to interact with limits resource""" + """Manager object used to interact with limits resource.""" resource_class = Limits diff --git a/cinderclient/v1/qos_specs.py b/cinderclient/v1/qos_specs.py new file mode 100644 index 0000000..b4e4272 --- /dev/null +++ b/cinderclient/v1/qos_specs.py @@ -0,0 +1,149 @@ +# Copyright (c) 2013 eBay Inc. +# Copyright (c) OpenStack LLC. +# +# 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. + + +""" +QoS Specs interface. +""" + +from cinderclient import base + + +class QoSSpecs(base.Resource): + """QoS specs entity represents quality-of-service parameters/requirements. + + A QoS specs is a set of parameters or requirements for quality-of-service + purpose, which can be associated with volume types (for now). In future, + QoS specs may be extended to be associated other entities, such as single + volume. + """ + def __repr__(self): + return "" % self.name + + def delete(self): + return self.manager.delete(self) + + +class QoSSpecsManager(base.ManagerWithFind): + """ + Manage :class:`QoSSpecs` resources. + """ + resource_class = QoSSpecs + + def list(self): + """Get a list of all qos specs. + + :rtype: list of :class:`QoSSpecs`. + """ + return self._list("/qos-specs", "qos_specs") + + def get(self, qos_specs): + """Get a specific qos specs. + + :param qos_specs: The ID of the :class:`QoSSpecs` to get. + :rtype: :class:`QoSSpecs` + """ + return self._get("/qos-specs/%s" % base.getid(qos_specs), "qos_specs") + + def delete(self, qos_specs, force=False): + """Delete a specific qos specs. + + :param qos_specs: The ID of the :class:`QoSSpecs` to be removed. + :param force: Flag that indicates whether to delete target qos specs + if it was in-use. + """ + self._delete("/qos-specs/%s?force=%s" % + (base.getid(qos_specs), force)) + + def create(self, name, specs): + """Create a qos specs. + + :param name: Descriptive name of the qos specs, must be unique + :param specs: A dict of key/value pairs to be set + :rtype: :class:`QoSSpecs` + """ + + body = { + "qos_specs": { + "name": name, + } + } + + body["qos_specs"].update(specs) + return self._create("/qos-specs", body, "qos_specs") + + def set_keys(self, qos_specs, specs): + """Update a qos specs with new specifications. + + :param qos_specs: The ID of qos specs + :param specs: A dict of key/value pairs to be set + :rtype: :class:`QoSSpecs` + """ + + body = { + "qos_specs": {} + } + + body["qos_specs"].update(specs) + return self._update("/qos-specs/%s" % qos_specs, body) + + def unset_keys(self, qos_specs, specs): + """Update a qos specs with new specifications. + + :param qos_specs: The ID of qos specs + :param specs: A list of key to be unset + :rtype: :class:`QoSSpecs` + """ + + body = {'keys': specs} + + return self._update("/qos-specs/%s/delete_keys" % qos_specs, + body) + + def get_associations(self, qos_specs): + """Get associated entities of a qos specs. + + :param qos_specs: The id of the :class: `QoSSpecs` + :return: a list of entities that associated with specific qos specs. + """ + return self._list("/qos-specs/%s/associations" % base.getid(qos_specs), + "qos_associations") + + def associate(self, qos_specs, vol_type_id): + """Associate a volume type with specific qos specs. + + :param qos_specs: The qos specs to be associated with + :param vol_type_id: The volume type id to be associated with + """ + self.api.client.get("/qos-specs/%s/associate?vol_type_id=%s" % + (base.getid(qos_specs), vol_type_id)) + + def disassociate(self, qos_specs, vol_type_id): + """Disassociate qos specs from volume type. + + :param qos_specs: The qos specs to be associated with + :param vol_type_id: The volume type id to be associated with + """ + self.api.client.get("/qos-specs/%s/disassociate?vol_type_id=%s" % + (base.getid(qos_specs), vol_type_id)) + + def disassociate_all(self, qos_specs): + """Disassociate all entities from specific qos specs. + + :param qos_specs: The qos specs to be associated with + """ + self.api.client.get("/qos-specs/%s/disassociate_all" % + base.getid(qos_specs)) diff --git a/cinderclient/v1/quota_classes.py b/cinderclient/v1/quota_classes.py index 6aa4fdc..9e81e2c 100644 --- a/cinderclient/v1/quota_classes.py +++ b/cinderclient/v1/quota_classes.py @@ -1,4 +1,4 @@ -# Copyright 2012 OpenStack LLC. +# Copyright (c) 2012 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -21,32 +21,25 @@ class QuotaClassSet(base.Resource): @property def id(self): """QuotaClassSet does not have a 'id' attribute but base.Resource - needs it to self-refresh and QuotaSet is indexed by class_name""" + needs it to self-refresh and QuotaSet is indexed by class_name. + """ return self.class_name def update(self, *args, **kwargs): self.manager.update(self.class_name, *args, **kwargs) -class QuotaClassSetManager(base.ManagerWithFind): +class QuotaClassSetManager(base.Manager): resource_class = QuotaClassSet def get(self, class_name): return self._get("/os-quota-class-sets/%s" % (class_name), "quota_class_set") - def update(self, - class_name, - volumes=None, - gigabytes=None): + def update(self, class_name, **updates): + body = {'quota_class_set': {'class_name': class_name}} - body = {'quota_class_set': { - 'class_name': class_name, - 'volumes': volumes, - 'gigabytes': gigabytes}} - - for key in body['quota_class_set'].keys(): - if body['quota_class_set'][key] is None: - body['quota_class_set'].pop(key) + for update in updates: + body['quota_class_set'][update] = updates[update] self._update('/os-quota-class-sets/%s' % (class_name), body) diff --git a/cinderclient/v1/quotas.py b/cinderclient/v1/quotas.py index 2ac22df..dd00186 100644 --- a/cinderclient/v1/quotas.py +++ b/cinderclient/v1/quotas.py @@ -1,4 +1,4 @@ -# Copyright 2011 OpenStack LLC. +# Copyright (c) 2011 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -20,33 +20,29 @@ class QuotaSet(base.Resource): @property def id(self): - """QuotaSet does not have a 'id' attribute but base.Resource needs it - to self-refresh and QuotaSet is indexed by tenant_id""" + """QuotaSet does not have a 'id' attribute but base. Resource needs it + to self-refresh and QuotaSet is indexed by tenant_id. + """ return self.tenant_id def update(self, *args, **kwargs): self.manager.update(self.tenant_id, *args, **kwargs) -class QuotaSetManager(base.ManagerWithFind): +class QuotaSetManager(base.Manager): resource_class = QuotaSet - def get(self, tenant_id): + def get(self, tenant_id, usage=False): if hasattr(tenant_id, 'tenant_id'): tenant_id = tenant_id.tenant_id - return self._get("/os-quota-sets/%s" % (tenant_id), "quota_set") + return self._get("/os-quota-sets/%s?usage=%s" % (tenant_id, usage), + "quota_set") - def update(self, tenant_id, volumes=None, snapshots=None, gigabytes=None): + def update(self, tenant_id, **updates): + body = {'quota_set': {'tenant_id': tenant_id}} - body = {'quota_set': { - 'tenant_id': tenant_id, - 'volumes': volumes, - 'snapshots': snapshots, - 'gigabytes': gigabytes}} - - for key in body['quota_set'].keys(): - if body['quota_set'][key] is None: - body['quota_set'].pop(key) + for update in updates: + body['quota_set'][update] = updates[update] self._update('/os-quota-sets/%s' % (tenant_id), body) diff --git a/cinderclient/v1/services.py b/cinderclient/v1/services.py new file mode 100644 index 0000000..2669a5e --- /dev/null +++ b/cinderclient/v1/services.py @@ -0,0 +1,58 @@ +# Copyright (c) 2013 OpenStack Foundation +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +service interface +""" +from cinderclient import base + + +class Service(base.Resource): + + def __repr__(self): + return "" % self.service + + +class ServiceManager(base.ManagerWithFind): + resource_class = Service + + def list(self, host=None, binary=None): + """ + Describes service list for host. + + :param host: destination host name. + :param binary: service binary. + """ + url = "/os-services" + filters = [] + if host: + filters.append("host=%s" % host) + if binary: + filters.append("binary=%s" % binary) + if filters: + url = "%s?%s" % (url, "&".join(filters)) + return self._list(url, "services") + + def enable(self, host, binary): + """Enable the service specified by hostname and binary.""" + body = {"host": host, "binary": binary} + result = self._update("/os-services/enable", body) + return self.resource_class(self, result) + + def disable(self, host, binary): + """Enable the service specified by hostname and binary.""" + body = {"host": host, "binary": binary} + result = self._update("/os-services/disable", body) + return self.resource_class(self, result) diff --git a/cinderclient/v1/shell.py b/cinderclient/v1/shell.py index 8119654..02ea769 100644 --- a/cinderclient/v1/shell.py +++ b/cinderclient/v1/shell.py @@ -1,6 +1,6 @@ # Copyright 2010 Jacob Kaplan-Moss -# Copyright 2011 OpenStack LLC. +# Copyright (c) 2011 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -15,13 +15,18 @@ # License for the specific language governing permissions and limitations # under the License. +from __future__ import print_function + import argparse +import copy import os import sys import time from cinderclient import exceptions +from cinderclient.openstack.common import strutils from cinderclient import utils +from cinderclient.v1 import availability_zones def _poll_for_status(poll_fn, obj_id, action, final_ok_states, @@ -39,33 +44,43 @@ def _poll_for_status(poll_fn, obj_id, action, final_ok_states, sys.stdout.write(msg) sys.stdout.flush() - print + print() while True: obj = poll_fn(obj_id) status = obj.status.lower() progress = getattr(obj, 'progress', None) or 0 if status in final_ok_states: print_progress(100) - print "\nFinished" + print("\nFinished") break elif status == "error": - print "\nError %(action)s instance" % locals() + print("\nError %(action)s instance" % {'action': action}) break else: print_progress(progress) time.sleep(poll_period) -def _find_volume(cs, volume): - """Get a volume by ID.""" - return utils.find_resource(cs.volumes, volume) - - def _find_volume_snapshot(cs, snapshot): - """Get a volume snapshot by ID.""" + """Get a volume snapshot by name or ID.""" return utils.find_resource(cs.volume_snapshots, snapshot) +def _find_backup(cs, backup): + """Get a backup by name or ID.""" + return utils.find_resource(cs.backups, backup) + + +def _find_transfer(cs, transfer): + """Get a transfer by name or ID.""" + return utils.find_resource(cs.transfers, transfer) + + +def _find_qos_specs(cs, qos_specs): + """Get a qos specs by ID.""" + return utils.find_resource(cs.qos_specs, qos_specs) + + def _print_volume(volume): utils.print_dict(volume._info) @@ -74,22 +89,31 @@ def _print_volume_snapshot(snapshot): utils.print_dict(snapshot._info) -def _translate_volume_keys(collection): - convert = [('displayName', 'display_name'), ('volumeType', 'volume_type')] +def _print_volume_image(image): + utils.print_dict(image[1]['os-volume_upload_image']) + + +def _translate_keys(collection, convert): for item in collection: - keys = item.__dict__.keys() + keys = item.__dict__ for from_key, to_key in convert: if from_key in keys and to_key not in keys: setattr(item, to_key, item._info[from_key]) +def _translate_volume_keys(collection): + convert = [('displayName', 'display_name'), ('volumeType', 'volume_type')] + _translate_keys(collection, convert) + + def _translate_volume_snapshot_keys(collection): convert = [('displayName', 'display_name'), ('volumeId', 'volume_id')] - for item in collection: - keys = item.__dict__.keys() - for from_key, to_key in convert: - if from_key in keys and to_key not in keys: - setattr(item, to_key, item._info[from_key]) + _translate_keys(collection, convert) + + +def _translate_availability_zone_keys(collection): + convert = [('zoneName', 'name'), ('zoneState', 'status')] + _translate_keys(collection, convert) def _extract_metadata(args): @@ -131,6 +155,13 @@ def _extract_metadata(args): metavar='', default=None, help='Filter results by status') +@utils.arg( + '--metadata', + type=str, + nargs='*', + metavar='', + help='Filter results by metadata', + default=None) @utils.service_type('volume') def do_list(cs, args): """List all the volumes.""" @@ -139,6 +170,7 @@ def do_list(cs, args): 'all_tenants': all_tenants, 'display_name': args.display_name, 'status': args.status, + 'metadata': _extract_metadata(args) if args.metadata else None, } volumes = cs.volumes.list(search_opts=search_opts) _translate_volume_keys(volumes) @@ -151,11 +183,11 @@ def do_list(cs, args): 'Size', 'Volume Type', 'Bootable', 'Attached to']) -@utils.arg('volume', metavar='', help='ID of the volume.') +@utils.arg('volume', metavar='', help='Name or ID of the volume.') @utils.service_type('volume') def do_show(cs, args): """Show details about a volume.""" - volume = _find_volume(cs, args.volume) + volume = utils.find_volume(cs, args.volume) _print_volume(volume) @@ -245,23 +277,70 @@ def do_create(cs, args): _print_volume(volume) -@utils.arg('volume', metavar='', help='ID of the volume to delete.') +@utils.arg('volume', metavar='', nargs='+', + help='Name or ID of the volume(s) to delete.') @utils.service_type('volume') def do_delete(cs, args): - """Remove a volume.""" - volume = _find_volume(cs, args.volume) - volume.delete() + """Remove volume(s).""" + failure_count = 0 + for volume in args.volume: + try: + utils.find_volume(cs, volume).delete() + except Exception as e: + failure_count += 1 + print("Delete for volume %s failed: %s" % (volume, e)) + if failure_count == len(args.volume): + raise exceptions.CommandError("Unable to delete any of the specified " + "volumes.") -@utils.arg('volume', metavar='', help='ID of the volume to delete.') +@utils.arg('volume', metavar='', nargs='+', + help='Name or ID of the volume(s) to delete.') @utils.service_type('volume') def do_force_delete(cs, args): - """Attempt forced removal of a volume, regardless of it's state.""" - volume = _find_volume(cs, args.volume) - volume.force_delete() + """Attempt forced removal of volume(s), regardless of the state(s).""" + failure_count = 0 + for volume in args.volume: + try: + utils.find_volume(cs, volume).force_delete() + except Exception as e: + failure_count += 1 + print("Delete for volume %s failed: %s" % (volume, e)) + if failure_count == len(args.volume): + raise exceptions.CommandError("Unable to force delete any of the " + "specified volumes.") -@utils.arg('volume', metavar='', help='ID of the volume to rename.') +@utils.arg('volume', metavar='', nargs='+', + help='Name or ID of the volume to modify.') +@utils.arg('--state', metavar='', default='available', + help=('Indicate which state to assign the volume. Options include ' + 'available, error, creating, deleting, error_deleting. If no ' + 'state is provided, available will be used.')) +@utils.service_type('volume') +def do_reset_state(cs, args): + """Explicitly update the state of a volume.""" + failure_count = 0 + + single = (len(args.volume) == 1) + + for volume in args.volume: + try: + utils.find_volume(cs, volume).reset_state(args.state) + except Exception as e: + failure_count += 1 + msg = "Reset state for volume %s failed: %s" % (volume, e) + if not single: + print(msg) + + if failure_count == len(args.volume): + if not single: + msg = "Unable to reset the state for any of the specified volumes." + raise exceptions.CommandError(msg) + + +@utils.arg('volume', metavar='', + help='Name or ID of the volume to rename.') @utils.arg('display_name', nargs='?', metavar='', help='New display-name for the volume.') @utils.arg('--display-description', metavar='', @@ -275,12 +354,17 @@ def do_rename(cs, args): kwargs['display_name'] = args.display_name if args.display_description is not None: kwargs['display_description'] = args.display_description - _find_volume(cs, args.volume).update(**kwargs) + + if not any(kwargs): + msg = 'Must supply either display-name or display-description.' + raise exceptions.ClientException(code=1, message=msg) + + utils.find_volume(cs, args.volume).update(**kwargs) @utils.arg('volume', metavar='', - help='ID of the volume to update metadata on.') + help='Name or ID of the volume to update metadata on.') @utils.arg('action', metavar='', choices=['set', 'unset'], @@ -293,13 +377,15 @@ def do_rename(cs, args): @utils.service_type('volume') def do_metadata(cs, args): """Set or Delete metadata on a volume.""" - volume = _find_volume(cs, args.volume) + volume = utils.find_volume(cs, args.volume) metadata = _extract_metadata(args) if args.action == 'set': cs.volumes.set_metadata(volume, metadata) elif args.action == 'unset': - cs.volumes.delete_metadata(volume, metadata.keys()) + # NOTE(zul): Make sure py2/py3 sorting is the same + cs.volumes.delete_metadata(volume, sorted(metadata.keys(), + reverse=True)) @utils.arg( @@ -349,7 +435,8 @@ def do_snapshot_list(cs, args): ['ID', 'Volume ID', 'Status', 'Display Name', 'Size']) -@utils.arg('snapshot', metavar='', help='ID of the snapshot.') +@utils.arg('snapshot', metavar='', + help='Name or ID of the snapshot.') @utils.service_type('volume') def do_snapshot_show(cs, args): """Show details about a snapshot.""" @@ -357,9 +444,9 @@ def do_snapshot_show(cs, args): _print_volume_snapshot(snapshot) -@utils.arg('volume_id', - metavar='', - help='ID of the volume to snapshot') +@utils.arg('volume', + metavar='', + help='Name or ID of the volume to snapshot') @utils.arg('--force', metavar='', help='Optional flag to indicate whether ' @@ -385,24 +472,26 @@ def do_snapshot_show(cs, args): @utils.service_type('volume') def do_snapshot_create(cs, args): """Add a new snapshot.""" - snapshot = cs.volume_snapshots.create(args.volume_id, + volume = utils.find_volume(cs, args.volume) + snapshot = cs.volume_snapshots.create(volume.id, args.force, args.display_name, args.display_description) _print_volume_snapshot(snapshot) -@utils.arg('snapshot_id', - metavar='', - help='ID of the snapshot to delete.') +@utils.arg('snapshot', + metavar='', + help='Name or ID of the snapshot to delete.') @utils.service_type('volume') def do_snapshot_delete(cs, args): """Remove a snapshot.""" - snapshot = _find_volume_snapshot(cs, args.snapshot_id) + snapshot = _find_volume_snapshot(cs, args.snapshot) snapshot.delete() -@utils.arg('snapshot', metavar='', help='ID of the snapshot.') +@utils.arg('snapshot', metavar='', + help='Name or ID of the snapshot.') @utils.arg('display_name', nargs='?', metavar='', help='New display-name for the snapshot.') @utils.arg('--display-description', metavar='', @@ -416,9 +505,45 @@ def do_snapshot_rename(cs, args): kwargs['display_name'] = args.display_name if args.display_description is not None: kwargs['display_description'] = args.display_description + + if not any(kwargs): + msg = 'Must supply either display-name or display-description.' + raise exceptions.ClientException(code=1, message=msg) + _find_volume_snapshot(cs, args.snapshot).update(**kwargs) +@utils.arg('snapshot', metavar='', nargs='+', + help='Name or ID of the snapshot to modify.') +@utils.arg('--state', metavar='', + default='available', + help=('Indicate which state to assign the snapshot. ' + 'Options include available, error, creating, deleting, ' + 'error_deleting. If no state is provided, ' + 'available will be used.')) +@utils.service_type('volume') +def do_snapshot_reset_state(cs, args): + """Explicitly update the state of a snapshot.""" + failure_count = 0 + + single = (len(args.snapshot) == 1) + + for snapshot in args.snapshot: + try: + _find_volume_snapshot(cs, snapshot).reset_state(args.state) + except Exception as e: + failure_count += 1 + msg = "Reset state for snapshot %s failed: %s" % (snapshot, e) + if not single: + print(msg) + + if failure_count == len(args.snapshot): + if not single: + msg = ("Unable to reset the state for any of the the specified " + "snapshots.") + raise exceptions.CommandError(msg) + + def _print_volume_type_list(vtypes): utils.print_list(vtypes, ['ID', 'Name']) @@ -457,7 +582,7 @@ def do_type_create(cs, args): help="Unique ID of the volume type to delete") @utils.service_type('volume') def do_type_delete(cs, args): - """Delete a specific volume type""" + """Delete a specific volume type.""" cs.volume_types.delete(args.id) @@ -475,7 +600,7 @@ def do_type_delete(cs, args): help='Extra_specs to set/unset (only key is necessary on unset)') @utils.service_type('volume') def do_type_key(cs, args): - "Set or unset extra_spec for a volume type.""" + """Set or unset extra_spec for a volume type.""" vtype = _find_volume_type(cs, args.vtype) if args.metadata is not None: @@ -484,37 +609,63 @@ def do_type_key(cs, args): if args.action == 'set': vtype.set_keys(keypair) elif args.action == 'unset': - vtype.unset_keys(keypair.keys()) + vtype.unset_keys(list(keypair)) def do_endpoints(cs, args): - """Discover endpoints that get returned from the authenticate services""" + """Discover endpoints that get returned from the authenticate services.""" catalog = cs.client.service_catalog.catalog for e in catalog['access']['serviceCatalog']: utils.print_dict(e['endpoints'][0], e['name']) def do_credentials(cs, args): - """Show user credentials returned from auth""" + """Show user credentials returned from auth.""" catalog = cs.client.service_catalog.catalog utils.print_dict(catalog['access']['user'], "User Credentials") utils.print_dict(catalog['access']['token'], "Token") + _quota_resources = ['volumes', 'snapshots', 'gigabytes'] +_quota_infos = ['Type', 'In_use', 'Reserved', 'Limit'] def _quota_show(quotas): quota_dict = {} - for resource in _quota_resources: + for resource in quotas._info: + good_name = False + for name in _quota_resources: + if resource.startswith(name): + good_name = True + if not good_name: + continue quota_dict[resource] = getattr(quotas, resource, None) utils.print_dict(quota_dict) +def _quota_usage_show(quotas): + quota_list = [] + for resource in quotas._info.keys(): + good_name = False + for name in _quota_resources: + if resource.startswith(name): + good_name = True + if not good_name: + continue + quota_info = getattr(quotas, resource, None) + quota_info['Type'] = resource + quota_info = dict((k.capitalize(), v) for k, v in quota_info.items()) + quota_list.append(quota_info) + utils.print_list(quota_list, _quota_infos) + + def _quota_update(manager, identifier, args): updates = {} for resource in _quota_resources: val = getattr(args, resource, None) if val is not None: + if args.volume_type: + resource = resource + '_%s' % args.volume_type updates[resource] = val if updates: @@ -530,6 +681,15 @@ def do_quota_show(cs, args): _quota_show(cs.quotas.get(args.tenant)) +@utils.arg('tenant', metavar='', + help='UUID of tenant to list the quota usage for.') +@utils.service_type('volume') +def do_quota_usage(cs, args): + """List the quota usage for a tenant.""" + + _quota_usage_show(cs.quotas.get(args.tenant, usage=True)) + + @utils.arg('tenant', metavar='', help='UUID of tenant to list the default quotas for.') @utils.service_type('volume') @@ -553,6 +713,10 @@ def do_quota_defaults(cs, args): metavar='', type=int, default=None, help='New value for the "gigabytes" quota.') +@utils.arg('--volume-type', + metavar='', + default=None, + help='Volume type (Optional, Default=None)') @utils.service_type('volume') def do_quota_update(cs, args): """Update the quotas for a tenant.""" @@ -583,6 +747,10 @@ def do_quota_class_show(cs, args): metavar='', type=int, default=None, help='New value for the "gigabytes" quota.') +@utils.arg('--volume-type', + metavar='', + default=None, + help='Volume type (Optional, Default=None)') @utils.service_type('volume') def do_quota_class_update(cs, args): """Update the quotas for a quota class.""" @@ -618,9 +786,9 @@ def _find_volume_type(cs, vtype): return utils.find_resource(cs.volume_types, vtype) -@utils.arg('volume_id', - metavar='', - help='ID of the volume to upload to an image') +@utils.arg('volume', + metavar='', + help='Name or ID of the volume to upload to an image') @utils.arg('--force', metavar='', help='Optional flag to indicate whether ' @@ -643,8 +811,581 @@ def _find_volume_type(cs, vtype): @utils.service_type('volume') def do_upload_to_image(cs, args): """Upload volume to image service as image.""" - volume = _find_volume(cs, args.volume_id) - volume.upload_to_image(args.force, - args.image_name, - args.container_format, - args.disk_format) + volume = utils.find_volume(cs, args.volume) + _print_volume_image(volume.upload_to_image(args.force, + args.image_name, + args.container_format, + args.disk_format)) + + +@utils.arg('volume', metavar='', + help='Name or ID of the volume to backup.') +@utils.arg('--container', metavar='', + help='Optional Backup container name. (Default=None)', + default=None) +@utils.arg('--display-name', metavar='', + help='Optional backup name. (Default=None)', + default=None) +@utils.arg('--display-description', metavar='', + help='Optional backup description. (Default=None)', + default=None) +@utils.service_type('volume') +def do_backup_create(cs, args): + """Creates a backup.""" + volume = utils.find_volume(cs, args.volume) + backup = cs.backups.create(volume.id, + args.container, + args.display_name, + args.display_description) + + info = {"volume_id": volume.id} + info.update(backup._info) + + if 'links' in info: + info.pop('links') + + utils.print_dict(info) + + +@utils.arg('backup', metavar='', help='Name or ID of the backup.') +@utils.service_type('volume') +def do_backup_show(cs, args): + """Show details about a backup.""" + backup = _find_backup(cs, args.backup) + info = dict() + info.update(backup._info) + + if 'links' in info: + info.pop('links') + + utils.print_dict(info) + + +@utils.service_type('volume') +def do_backup_list(cs, args): + """List all the backups.""" + backups = cs.backups.list() + columns = ['ID', 'Volume ID', 'Status', 'Name', 'Size', 'Object Count', + 'Container'] + utils.print_list(backups, columns) + + +@utils.arg('backup', metavar='', + help='Name or ID of the backup to delete.') +@utils.service_type('volume') +def do_backup_delete(cs, args): + """Remove a backup.""" + backup = _find_backup(cs, args.backup) + backup.delete() + + +@utils.arg('backup', metavar='', + help='ID of the backup to restore.') +@utils.arg('--volume-id', metavar='', + help='Optional ID(or name) of the volume to restore to.', + default=None) +@utils.service_type('volume') +def do_backup_restore(cs, args): + """Restore a backup.""" + if args.volume_id: + volume_id = utils.find_volume(cs, args.volume_id).id + else: + volume_id = None + cs.restores.restore(args.backup, volume_id) + + +@utils.arg('volume', metavar='', + help='Name or ID of the volume to transfer.') +@utils.arg('--display-name', metavar='', + help='Optional transfer name. (Default=None)', + default=None) +@utils.service_type('volume') +def do_transfer_create(cs, args): + """Creates a volume transfer.""" + volume = utils.find_volume(cs, args.volume) + transfer = cs.transfers.create(volume.id, + args.display_name) + info = dict() + info.update(transfer._info) + + if 'links' in info: + info.pop('links') + + utils.print_dict(info) + + +@utils.arg('transfer', metavar='', + help='Name or ID of the transfer to delete.') +@utils.service_type('volume') +def do_transfer_delete(cs, args): + """Undo a transfer.""" + transfer = _find_transfer(cs, args.transfer) + transfer.delete() + + +@utils.arg('transfer', metavar='', + help='ID of the transfer to accept.') +@utils.arg('auth_key', metavar='', + help='Auth key of the transfer to accept.') +@utils.service_type('volume') +def do_transfer_accept(cs, args): + """Accepts a volume transfer.""" + transfer = cs.transfers.accept(args.transfer, args.auth_key) + info = dict() + info.update(transfer._info) + + if 'links' in info: + info.pop('links') + + utils.print_dict(info) + + +@utils.service_type('volume') +def do_transfer_list(cs, args): + """List all the transfers.""" + transfers = cs.transfers.list() + columns = ['ID', 'Volume ID', 'Name'] + utils.print_list(transfers, columns) + + +@utils.arg('transfer', metavar='', + help='Name or ID of the transfer to accept.') +@utils.service_type('volume') +def do_transfer_show(cs, args): + """Show details about a transfer.""" + transfer = _find_transfer(cs, args.transfer) + info = dict() + info.update(transfer._info) + + if 'links' in info: + info.pop('links') + + utils.print_dict(info) + + +@utils.arg('volume', metavar='', + help='Name or ID of the volume to extend.') +@utils.arg('new_size', + metavar='', + type=int, + help='New size of volume in GB') +@utils.service_type('volume') +def do_extend(cs, args): + """Attempt to extend the size of an existing volume.""" + volume = utils.find_volume(cs, args.volume) + cs.volumes.extend(volume, args.new_size) + + +@utils.arg('--host', metavar='', default=None, + help='Name of host.') +@utils.arg('--binary', metavar='', default=None, + help='Service binary.') +@utils.service_type('volume') +def do_service_list(cs, args): + """List all the services. Filter by host & service binary.""" + result = cs.services.list(host=args.host, binary=args.binary) + columns = ["Binary", "Host", "Zone", "Status", "State", "Updated_at"] + utils.print_list(result, columns) + + +@utils.arg('host', metavar='', help='Name of host.') +@utils.arg('binary', metavar='', help='Service binary.') +@utils.service_type('volume') +def do_service_enable(cs, args): + """Enable the service.""" + result = cs.services.enable(args.host, args.binary) + columns = ["Host", "Binary", "Status"] + utils.print_list([result], columns) + + +@utils.arg('host', metavar='', help='Name of host.') +@utils.arg('binary', metavar='', help='Service binary.') +@utils.service_type('volume') +def do_service_disable(cs, args): + """Disable the service.""" + result = cs.services.disable(args.host, args.binary) + columns = ["Host", "Binary", "Status"] + utils.print_list([result], columns) + + +def _treeizeAvailabilityZone(zone): + """Build a tree view for availability zones.""" + AvailabilityZone = availability_zones.AvailabilityZone + + az = AvailabilityZone(zone.manager, + copy.deepcopy(zone._info), zone._loaded) + result = [] + + # Zone tree view item + az.zoneName = zone.zoneName + az.zoneState = ('available' + if zone.zoneState['available'] else 'not available') + az._info['zoneName'] = az.zoneName + az._info['zoneState'] = az.zoneState + result.append(az) + + if getattr(zone, "hosts", None) and zone.hosts is not None: + for (host, services) in zone.hosts.items(): + # Host tree view item + az = AvailabilityZone(zone.manager, + copy.deepcopy(zone._info), zone._loaded) + az.zoneName = '|- %s' % host + az.zoneState = '' + az._info['zoneName'] = az.zoneName + az._info['zoneState'] = az.zoneState + result.append(az) + + for (svc, state) in services.items(): + # Service tree view item + az = AvailabilityZone(zone.manager, + copy.deepcopy(zone._info), zone._loaded) + az.zoneName = '| |- %s' % svc + az.zoneState = '%s %s %s' % ( + 'enabled' if state['active'] else 'disabled', + ':-)' if state['available'] else 'XXX', + state['updated_at']) + az._info['zoneName'] = az.zoneName + az._info['zoneState'] = az.zoneState + result.append(az) + return result + + +@utils.service_type('volume') +def do_availability_zone_list(cs, _args): + """List all the availability zones.""" + try: + availability_zones = cs.availability_zones.list() + except exceptions.Forbidden as e: # policy doesn't allow probably + try: + availability_zones = cs.availability_zones.list(detailed=False) + except Exception: + raise e + + result = [] + for zone in availability_zones: + result += _treeizeAvailabilityZone(zone) + _translate_availability_zone_keys(result) + utils.print_list(result, ['Name', 'Status']) + + +def _print_volume_encryption_type_list(encryption_types): + """ + Display a tabularized list of volume encryption types. + + :param encryption_types: a list of :class: VolumeEncryptionType instances + """ + utils.print_list(encryption_types, ['Volume Type ID', 'Provider', + 'Cipher', 'Key Size', + 'Control Location']) + + +@utils.service_type('volume') +def do_encryption_type_list(cs, args): + """List encryption type information for all volume types (Admin Only).""" + result = cs.volume_encryption_types.list() + utils.print_list(result, ['Volume Type ID', 'Provider', 'Cipher', + 'Key Size', 'Control Location']) + + +@utils.arg('volume_type', + metavar='', + type=str, + help="Name or ID of the volume type") +@utils.service_type('volume') +def do_encryption_type_show(cs, args): + """Show the encryption type information for a volume type (Admin Only).""" + volume_type = _find_volume_type(cs, args.volume_type) + + result = cs.volume_encryption_types.get(volume_type) + + # Display result or an empty table if no result + if hasattr(result, 'volume_type_id'): + _print_volume_encryption_type_list([result]) + else: + _print_volume_encryption_type_list([]) + + +@utils.arg('volume_type', + metavar='', + type=str, + help="Name or ID of the volume type") +@utils.arg('provider', + metavar='', + type=str, + help="Class providing encryption support (e.g. LuksEncryptor)") +@utils.arg('--cipher', + metavar='', + type=str, + required=False, + default=None, + help="Encryption algorithm/mode to use (e.g., aes-xts-plain64) " + "(Optional, Default=None)") +@utils.arg('--key_size', + metavar='', + type=int, + required=False, + default=None, + help="Size of the encryption key, in bits (e.g., 128, 256) " + "(Optional, Default=None)") +@utils.arg('--control_location', + metavar='', + choices=['front-end', 'back-end'], + type=str, + required=False, + default=None, + help="Notional service where encryption is performed (e.g., " + "front-end=Nova). Values: 'front-end', 'back-end' " + "(Optional, Default=None)") +@utils.service_type('volume') +def do_encryption_type_create(cs, args): + """Create a new encryption type for a volume type (Admin Only).""" + volume_type = _find_volume_type(cs, args.volume_type) + + body = {} + body['provider'] = args.provider + body['cipher'] = args.cipher + body['key_size'] = args.key_size + body['control_location'] = args.control_location + + result = cs.volume_encryption_types.create(volume_type, body) + _print_volume_encryption_type_list([result]) + + +@utils.arg('volume_type', + metavar='', + type=str, + help="Name or ID of the volume type") +@utils.service_type('volume') +def do_encryption_type_delete(cs, args): + """Delete the encryption type for a volume type (Admin Only).""" + volume_type = _find_volume_type(cs, args.volume_type) + cs.volume_encryption_types.delete(volume_type) + + +@utils.arg('volume', metavar='', help='ID of the volume to migrate') +@utils.arg('host', metavar='', help='Destination host') +@utils.arg('--force-host-copy', metavar='', + choices=['True', 'False'], required=False, + help='Optional flag to force the use of the generic ' + 'host-based migration mechanism, bypassing driver ' + 'optimizations (Default=False).', + default=False) +@utils.service_type('volume') +def do_migrate(cs, args): + """Migrate the volume to the new host.""" + volume = utils.find_volume(cs, args.volume) + + volume.migrate_volume(args.host, args.force_host_copy) + + +def _print_qos_specs(qos_specs): + utils.print_dict(qos_specs._info) + + +def _print_qos_specs_list(q_specs): + utils.print_list(q_specs, ['ID', 'Name', 'Consumer', 'specs']) + + +def _print_qos_specs_and_associations_list(q_specs): + utils.print_list(q_specs, ['ID', 'Name', 'Consumer', 'specs']) + + +def _print_associations_list(associations): + utils.print_list(associations, ['Association_Type', 'Name', 'ID']) + + +@utils.arg('name', + metavar='', + help="Name of the new QoS specs") +@utils.arg('metadata', + metavar='', + nargs='+', + default=[], + help='Specifications for QoS') +@utils.service_type('volume') +def do_qos_create(cs, args): + """Create a new qos specs.""" + keypair = None + if args.metadata is not None: + keypair = _extract_metadata(args) + qos_specs = cs.qos_specs.create(args.name, keypair) + _print_qos_specs(qos_specs) + + +@utils.service_type('volume') +def do_qos_list(cs, args): + """Get full list of qos specs.""" + qos_specs = cs.qos_specs.list() + _print_qos_specs_list(qos_specs) + + +@utils.arg('qos_specs', metavar='', + help='ID of the qos_specs to show.') +@utils.service_type('volume') +def do_qos_show(cs, args): + """Get a specific qos specs.""" + qos_specs = _find_qos_specs(cs, args.qos_specs) + _print_qos_specs(qos_specs) + + +@utils.arg('qos_specs', metavar='', + help='ID of the qos_specs to delete.') +@utils.arg('--force', + metavar='', + default=False, + help='Optional flag that indicates whether to delete ' + 'specified qos specs even if it is in-use.') +@utils.service_type('volume') +def do_qos_delete(cs, args): + """Delete a specific qos specs.""" + force = strutils.bool_from_string(args.force) + qos_specs = _find_qos_specs(cs, args.qos_specs) + cs.qos_specs.delete(qos_specs, force) + + +@utils.arg('qos_specs', metavar='', + help='ID of qos_specs.') +@utils.arg('vol_type_id', metavar='', + help='ID of volume type to be associated with.') +@utils.service_type('volume') +def do_qos_associate(cs, args): + """Associate qos specs with specific volume type.""" + cs.qos_specs.associate(args.qos_specs, args.vol_type_id) + + +@utils.arg('qos_specs', metavar='', + help='ID of qos_specs.') +@utils.arg('vol_type_id', metavar='', + help='ID of volume type to be associated with.') +@utils.service_type('volume') +def do_qos_disassociate(cs, args): + """Disassociate qos specs from specific volume type.""" + cs.qos_specs.disassociate(args.qos_specs, args.vol_type_id) + + +@utils.arg('qos_specs', metavar='', + help='ID of qos_specs to be operate on.') +@utils.service_type('volume') +def do_qos_disassociate_all(cs, args): + """Disassociate qos specs from all of its associations.""" + cs.qos_specs.disassociate_all(args.qos_specs) + + +@utils.arg('qos_specs', metavar='', + help='ID of qos specs') +@utils.arg('action', + metavar='', + choices=['set', 'unset'], + help="Actions: 'set' or 'unset'") +@utils.arg('metadata', metavar='key=value', + nargs='+', + default=[], + help='QoS specs to set/unset (only key is necessary on unset)') +def do_qos_key(cs, args): + """Set or unset specifications for a qos spec.""" + keypair = _extract_metadata(args) + + if args.action == 'set': + cs.qos_specs.set_keys(args.qos_specs, keypair) + elif args.action == 'unset': + cs.qos_specs.unset_keys(args.qos_specs, list(keypair)) + + +@utils.arg('qos_specs', metavar='', + help='ID of the qos_specs.') +@utils.service_type('volume') +def do_qos_get_association(cs, args): + """Get all associations of specific qos specs.""" + associations = cs.qos_specs.get_associations(args.qos_specs) + _print_associations_list(associations) + + +@utils.arg('snapshot', + metavar='', + help='ID of the snapshot to update metadata on.') +@utils.arg('action', + metavar='', + choices=['set', 'unset'], + help="Actions: 'set' or 'unset'") +@utils.arg('metadata', + metavar='', + nargs='+', + default=[], + help='Metadata to set/unset (only key is necessary on unset)') +@utils.service_type('volume') +def do_snapshot_metadata(cs, args): + """Set or Delete metadata of a snapshot.""" + snapshot = _find_volume_snapshot(cs, args.snapshot) + metadata = _extract_metadata(args) + + if args.action == 'set': + metadata = snapshot.set_metadata(metadata) + utils.print_dict(metadata._info) + elif args.action == 'unset': + snapshot.delete_metadata(list(metadata.keys())) + + +@utils.arg('snapshot', metavar='', + help='ID of snapshot') +@utils.service_type('volume') +def do_snapshot_metadata_show(cs, args): + """Show metadata of given snapshot.""" + snapshot = _find_volume_snapshot(cs, args.snapshot) + utils.print_dict(snapshot._info['metadata'], 'Metadata-property') + + +@utils.arg('volume', metavar='', + help='ID of volume') +@utils.service_type('volume') +def do_metadata_show(cs, args): + """Show metadata of given volume.""" + volume = utils.find_volume(cs, args.volume) + utils.print_dict(volume._info['metadata'], 'Metadata-property') + + +@utils.arg('volume', + metavar='', + help='ID of the volume to update metadata on.') +@utils.arg('metadata', + metavar='', + nargs='+', + default=[], + help='Metadata entry/entries to update.') +@utils.service_type('volume') +def do_metadata_update_all(cs, args): + """Update all metadata of a volume.""" + volume = utils.find_volume(cs, args.volume) + metadata = _extract_metadata(args) + metadata = volume.update_all_metadata(metadata) + utils.print_dict(metadata) + + +@utils.arg('snapshot', + metavar='', + help='ID of the snapshot to update metadata on.') +@utils.arg('metadata', + metavar='', + nargs='+', + default=[], + help='Metadata entry/entries to update.') +@utils.service_type('volume') +def do_snapshot_metadata_update_all(cs, args): + """Update all metadata of a snapshot.""" + snapshot = _find_volume_snapshot(cs, args.snapshot) + metadata = _extract_metadata(args) + metadata = snapshot.update_all_metadata(metadata) + utils.print_dict(metadata) + + +@utils.arg('volume', metavar='', help='ID of the volume to update.') +@utils.arg('read_only', + metavar='', + choices=['True', 'true', 'False', 'false'], + help='Flag to indicate whether to update volume to ' + 'read-only access mode.') +@utils.service_type('volume') +def do_readonly_mode_update(cs, args): + """Update volume read-only access mode read_only.""" + volume = utils.find_volume(cs, args.volume) + cs.volumes.update_readonly_flag(volume, + strutils.bool_from_string(args.read_only)) diff --git a/cinderclient/v1/volume_backups.py b/cinderclient/v1/volume_backups.py new file mode 100644 index 0000000..89056af --- /dev/null +++ b/cinderclient/v1/volume_backups.py @@ -0,0 +1,76 @@ +# Copyright (C) 2013 Hewlett-Packard Development Company, L.P. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Volume Backups interface (1.1 extension). +""" + +from cinderclient import base + + +class VolumeBackup(base.Resource): + """A volume backup is a block level backup of a volume.""" + def __repr__(self): + return "" % self.id + + def delete(self): + """Delete this volume backup.""" + return self.manager.delete(self) + + +class VolumeBackupManager(base.ManagerWithFind): + """Manage :class:`VolumeBackup` resources.""" + resource_class = VolumeBackup + + def create(self, volume_id, container=None, + name=None, description=None): + """Create a volume backup. + + :param volume_id: The ID of the volume to backup. + :param container: The name of the backup service container. + :param name: The name of the backup. + :param description: The description of the backup. + :rtype: :class:`VolumeBackup` + """ + body = {'backup': {'volume_id': volume_id, + 'container': container, + 'name': name, + 'description': description}} + return self._create('/backups', body, 'backup') + + def get(self, backup_id): + """Show details of a volume backup. + + :param backup_id: The ID of the backup to display. + :rtype: :class:`VolumeBackup` + """ + return self._get("/backups/%s" % backup_id, "backup") + + def list(self, detailed=True): + """Get a list of all volume backups. + + :rtype: list of :class:`VolumeBackup` + """ + if detailed is True: + return self._list("/backups/detail", "backups") + else: + return self._list("/backups", "backups") + + def delete(self, backup): + """Delete a volume backup. + + :param backup: The :class:`VolumeBackup` to delete. + """ + self._delete("/backups/%s" % base.getid(backup)) diff --git a/cinderclient/v1/volume_backups_restore.py b/cinderclient/v1/volume_backups_restore.py new file mode 100644 index 0000000..0eafa82 --- /dev/null +++ b/cinderclient/v1/volume_backups_restore.py @@ -0,0 +1,43 @@ +# Copyright (C) 2013 Hewlett-Packard Development Company, L.P. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Volume Backups Restore interface (1.1 extension). + +This is part of the Volume Backups interface. +""" + +from cinderclient import base + + +class VolumeBackupsRestore(base.Resource): + """A Volume Backups Restore represents a restore operation.""" + def __repr__(self): + return "" % self.volume_id + + +class VolumeBackupRestoreManager(base.Manager): + """Manage :class:`VolumeBackupsRestore` resources.""" + resource_class = VolumeBackupsRestore + + def restore(self, backup_id, volume_id=None): + """Restore a backup to a volume. + + :param backup_id: The ID of the backup to restore. + :param volume_id: The ID of the volume to restore the backup to. + :rtype: :class:`Restore` + """ + body = {'restore': {'volume_id': volume_id}} + return self._create("/backups/%s/restore" % backup_id, + body, "restore") diff --git a/cinderclient/v1/volume_encryption_types.py b/cinderclient/v1/volume_encryption_types.py new file mode 100644 index 0000000..8385add --- /dev/null +++ b/cinderclient/v1/volume_encryption_types.py @@ -0,0 +1,97 @@ +# Copyright (c) 2013 The Johns Hopkins University/Applied Physics Laboratory +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +""" +Volume Encryption Type interface +""" + +from cinderclient import base + + +class VolumeEncryptionType(base.Resource): + """ + A Volume Encryption Type is a collection of settings used to conduct + encryption for a specific volume type. + """ + def __repr__(self): + return "" % self.name + + +class VolumeEncryptionTypeManager(base.ManagerWithFind): + """ + Manage :class: `VolumeEncryptionType` resources. + """ + resource_class = VolumeEncryptionType + + def list(self, search_opts=None): + """ + List all volume encryption types. + + :param volume_types: a list of volume types + :return: a list of :class: VolumeEncryptionType instances + """ + # Since the encryption type is a volume type extension, we cannot get + # all encryption types without going through all volume types. + volume_types = self.api.volume_types.list() + encryption_types = [] + for volume_type in volume_types: + encryption_type = self._get("/types/%s/encryption" + % base.getid(volume_type)) + if hasattr(encryption_type, 'volume_type_id'): + encryption_types.append(encryption_type) + return encryption_types + + def get(self, volume_type): + """ + Get the volume encryption type for the specified volume type. + + :param volume_type: the volume type to query + :return: an instance of :class: VolumeEncryptionType + """ + return self._get("/types/%s/encryption" % base.getid(volume_type)) + + def create(self, volume_type, specs): + """ + Create a new encryption type for the specified volume type. + + :param volume_type: the volume type on which to add an encryption type + :param specs: the encryption type specifications to add + :return: an instance of :class: VolumeEncryptionType + """ + body = {'encryption': specs} + return self._create("/types/%s/encryption" % base.getid(volume_type), + body, "encryption") + + def update(self, volume_type, specs): + """ + Update the encryption type information for the specified volume type. + + :param volume_type: the volume type whose encryption type information + must be updated + :param specs: the encryption type specifications to update + :return: an instance of :class: VolumeEncryptionType + """ + raise NotImplementedError() + + def delete(self, volume_type): + """ + Delete the encryption type information for the specified volume type. + + :param volume_type: the volume type whose encryption type information + must be deleted + """ + return self._delete("/types/%s/encryption/provider" % + base.getid(volume_type)) diff --git a/cinderclient/v1/volume_snapshots.py b/cinderclient/v1/volume_snapshots.py index 50fa566..461d663 100644 --- a/cinderclient/v1/volume_snapshots.py +++ b/cinderclient/v1/volume_snapshots.py @@ -17,8 +17,13 @@ Volume snapshot interface (1.1 extension). """ -import urllib +try: + from urllib import urlencode +except ImportError: + from urllib.parse import urlencode + from cinderclient import base +import six class Snapshot(base.Resource): @@ -48,6 +53,22 @@ class Snapshot(base.Resource): def project_id(self): return self._info.get('os-extended-snapshot-attributes:project_id') + def reset_state(self, state): + """Update the snapshot with the privided state.""" + self.manager.reset_state(self, state) + + def set_metadata(self, metadata): + """Set metadata of this snapshot.""" + return self.manager.set_metadata(self, metadata) + + def delete_metadata(self, keys): + """Delete metadata of this snapshot.""" + return self.manager.delete_metadata(self, keys) + + def update_all_metadata(self, metadata): + """Update_all metadata of this snapshot.""" + return self.manager.update_all_metadata(self, metadata) + class SnapshotManager(base.ManagerWithFind): """ @@ -95,11 +116,17 @@ class SnapshotManager(base.ManagerWithFind): qparams = {} - for opt, val in search_opts.iteritems(): + for opt, val in six.iteritems(search_opts): if val: qparams[opt] = val - query_string = "?%s" % urllib.urlencode(qparams) if qparams else "" + # 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: @@ -128,3 +155,48 @@ class SnapshotManager(base.ManagerWithFind): body = {"snapshot": kwargs} self._update("/snapshots/%s" % base.getid(snapshot), body) + + def reset_state(self, snapshot, state): + """Update the specified volume with the provided state.""" + return self._action('os-reset_status', snapshot, {'status': state}) + + def _action(self, action, snapshot, info=None, **kwargs): + """Perform a snapshot action.""" + body = {action: info} + self.run_hooks('modify_body_for_action', body, **kwargs) + url = '/snapshots/%s/action' % base.getid(snapshot) + return self.api.client.post(url, body=body) + + def update_snapshot_status(self, snapshot, update_dict): + return self._action('os-update_snapshot_status', + base.getid(snapshot), update_dict) + + def set_metadata(self, snapshot, metadata): + """Update/Set a snapshots metadata. + + :param snapshot: The :class:`Snapshot`. + :param metadata: A list of keys to be set. + """ + body = {'metadata': metadata} + return self._create("/snapshots/%s/metadata" % base.getid(snapshot), + body, "metadata") + + def delete_metadata(self, snapshot, keys): + """Delete specified keys from snapshot metadata. + + :param snapshot: The :class:`Snapshot`. + :param keys: A list of keys to be removed. + """ + snapshot_id = base.getid(snapshot) + for k in keys: + self._delete("/snapshots/%s/metadata/%s" % (snapshot_id, k)) + + def update_all_metadata(self, snapshot, metadata): + """Update_all snapshot metadata. + + :param snapshot: The :class:`Snapshot`. + :param metadata: A list of keys to be updated. + """ + body = {'metadata': metadata} + return self._update("/snapshots/%s/metadata" % base.getid(snapshot), + body) diff --git a/cinderclient/v1/volume_transfers.py b/cinderclient/v1/volume_transfers.py new file mode 100644 index 0000000..e6b0e30 --- /dev/null +++ b/cinderclient/v1/volume_transfers.py @@ -0,0 +1,82 @@ +# Copyright (C) 2013 Hewlett-Packard Development Company, L.P. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Volume transfer interface (1.1 extension). +""" + +from cinderclient import base + + +class VolumeTransfer(base.Resource): + """Transfer a volume from one tenant to another""" + def __repr__(self): + return "" % self.id + + def delete(self): + """Delete this volume transfer.""" + return self.manager.delete(self) + + +class VolumeTransferManager(base.ManagerWithFind): + """Manage :class:`VolumeTransfer` resources.""" + resource_class = VolumeTransfer + + def create(self, volume_id, name=None): + """Create a volume transfer. + + :param volume_id: The ID of the volume to transfer. + :param name: The name of the transfer. + :rtype: :class:`VolumeTransfer` + """ + body = {'transfer': {'volume_id': volume_id, + 'name': name}} + return self._create('/os-volume-transfer', body, 'transfer') + + def accept(self, transfer_id, auth_key): + """Accept a volume transfer. + + :param transfer_id: The ID of the trasnfer to accept. + :param auth_key: The auth_key of the transfer. + :rtype: :class:`VolumeTransfer` + """ + body = {'accept': {'auth_key': auth_key}} + return self._create('/os-volume-transfer/%s/accept' % transfer_id, + body, 'transfer') + + def get(self, transfer_id): + """Show details of a volume transfer. + + :param transfer_id: The ID of the volume transfer to display. + :rtype: :class:`VolumeTransfer` + """ + return self._get("/os-volume-transfer/%s" % transfer_id, "transfer") + + def list(self, detailed=True, search_opts=None): + """Get a list of all volume transfer. + + :rtype: list of :class:`VolumeTransfer` + """ + if detailed is True: + return self._list("/os-volume-transfer/detail", "transfers") + else: + return self._list("/os-volume-transfer", "transfers") + + def delete(self, transfer_id): + """Delete a volume transfer. + + :param transfer_id: The :class:`VolumeTransfer` to delete. + """ + self._delete("/os-volume-transfer/%s" % base.getid(transfer_id)) diff --git a/cinderclient/v1/volume_types.py b/cinderclient/v1/volume_types.py index f230786..e8b2c39 100644 --- a/cinderclient/v1/volume_types.py +++ b/cinderclient/v1/volume_types.py @@ -55,7 +55,7 @@ class VolumeType(base.Resource): def unset_keys(self, keys): """ - Unset extra specs on a volue type. + Unset extra specs on a volume type. :param type_id: The :class:`VolumeType` to unset extra spec on :param keys: A list of keys to be unset @@ -65,7 +65,7 @@ class VolumeType(base.Resource): # the return in the loop resulted in ony ONE key being unset. # since on success the return was NONE, we'll only interrupt the loop # and return if there's an error - result = None + resp = None for k in keys: resp = self.manager._delete( "/types/%s/extra_specs/%s" % ( @@ -80,7 +80,7 @@ class VolumeTypeManager(base.ManagerWithFind): """ resource_class = VolumeType - def list(self): + def list(self, search_opts=None): """ Get a list of all volume types. diff --git a/cinderclient/v1/volumes.py b/cinderclient/v1/volumes.py index 078cd6f..238e29f 100644 --- a/cinderclient/v1/volumes.py +++ b/cinderclient/v1/volumes.py @@ -17,109 +17,125 @@ Volume interface (1.1 extension). """ -import urllib +try: + from urllib import urlencode +except ImportError: + from urllib.parse import urlencode +import six from cinderclient import base class Volume(base.Resource): - """ - A volume is an extra block level storage to the OpenStack instances. - """ + """A volume is an extra block level storage to the OpenStack instances.""" def __repr__(self): return "" % self.id def delete(self): - """ - Delete this volume. - """ + """Delete this volume.""" self.manager.delete(self) def update(self, **kwargs): - """ - Update the display_name or display_description for this volume. - """ + """Update the display_name or display_description for this volume.""" self.manager.update(self, **kwargs) - def attach(self, instance_uuid, mountpoint): - """ - Set attachment metadata. + def attach(self, instance_uuid, mountpoint, mode='rw'): + """Set attachment metadata. :param instance_uuid: uuid of the attaching instance. :param mountpoint: mountpoint on the attaching instance. + :param mode: the access mode """ - return self.manager.attach(self, instance_uuid, mountpoint) + return self.manager.attach(self, instance_uuid, mountpoint, mode) def detach(self): - """ - Clear attachment metadata. - """ + """Clear attachment metadata.""" return self.manager.detach(self) def reserve(self, volume): - """ - Reserve this volume. - """ + """Reserve this volume.""" return self.manager.reserve(self) def unreserve(self, volume): - """ - Unreserve this volume. - """ + """Unreserve this volume.""" return self.manager.unreserve(self) def begin_detaching(self, volume): - """ - Begin detaching volume. - """ + """Begin detaching volume.""" return self.manager.begin_detaching(self) def roll_detaching(self, volume): - """ - Roll detaching volume. - """ + """Roll detaching volume.""" return self.manager.roll_detaching(self) def initialize_connection(self, volume, connector): - """ - Initialize a volume connection. + """Initialize a volume connection. :param connector: connector dict from nova. """ return self.manager.initialize_connection(self, connector) def terminate_connection(self, volume, connector): - """ - Terminate a volume connection. + """Terminate a volume connection. :param connector: connector dict from nova. """ return self.manager.terminate_connection(self, connector) def set_metadata(self, volume, metadata): - """ - Set or Append metadata to a volume. + """Set or Append metadata to a volume. - :param type : The :class: `Volume` to set metadata on + :param volume : The :class: `Volume` to set metadata on :param metadata: A dict of key/value pairs to set """ return self.manager.set_metadata(self, metadata) def upload_to_image(self, force, image_name, container_format, disk_format): - """ - Upload a volume to image service as an image. - """ - self.manager.upload_to_image(self, force, image_name, container_format, - disk_format) + """Upload a volume to image service as an image.""" + return self.manager.upload_to_image(self, force, image_name, + container_format, disk_format) def force_delete(self): - """ - Delete the specififed volume ignoring it's current state. + """Delete the specified volume ignoring its current state. :param volume: The UUID of the volume to force-delete. """ self.manager.force_delete(self) + def reset_state(self, state): + """Update the volume with the provided state.""" + self.manager.reset_state(self, state) + + def extend(self, volume, new_size): + """Extend the size of the specified volume. + + :param volume: The UUID of the volume to extend. + :param new_size: The desired size to extend volume to. + """ + self.manager.extend(self, volume, new_size) + + def migrate_volume(self, host, force_host_copy): + """Migrate the volume to a new host.""" + self.manager.migrate_volume(self, host, force_host_copy) + +# def migrate_volume_completion(self, old_volume, new_volume, error): +# """Complete the migration of the volume.""" +# self.manager.migrate_volume_completion(self, old_volume, +# new_volume, error) + + def update_all_metadata(self, metadata): + """Update all metadata of this volume.""" + return self.manager.update_all_metadata(self, metadata) + + def update_readonly_flag(self, volume, read_only): + """Update the read-only access mode flag of the specified volume. + + :param volume: The UUID of the volume to update. + :param read_only: The value to indicate whether to update volume to + read-only access mode. + """ + self.manager.update_readonly_flag(self, volume, read_only) + class VolumeManager(base.ManagerWithFind): """ @@ -140,13 +156,13 @@ class VolumeManager(base.ManagerWithFind): :param display_name: Name of the volume :param display_description: Description of the volume :param volume_type: Type of volume - :rtype: :class:`Volume` :param user_id: User id derived from context :param project_id: Project id derived from context :param availability_zone: Availability Zone to use :param metadata: Optional metadata to set on volume creation :param imageRef: reference to an image stored in glance :param source_volid: ID of source volume to clone from + :rtype: :class:`Volume` """ if metadata is None: @@ -190,11 +206,11 @@ class VolumeManager(base.ManagerWithFind): qparams = {} - for opt, val in search_opts.iteritems(): + for opt, val in six.iteritems(search_opts): if val: qparams[opt] = val - query_string = "?%s" % urllib.urlencode(qparams) if qparams else "" + query_string = "?%s" % urlencode(qparams) if qparams else "" detail = "" if detailed: @@ -215,7 +231,7 @@ class VolumeManager(base.ManagerWithFind): """ Update the display_name or display_description for a volume. - :param volume: The :class:`Volume` to delete. + :param volume: The :class:`Volume` to update. """ if not kwargs: return @@ -233,7 +249,7 @@ class VolumeManager(base.ManagerWithFind): url = '/volumes/%s/action' % base.getid(volume) return self.api.client.post(url, body=body) - def attach(self, volume, instance_uuid, mountpoint): + def attach(self, volume, instance_uuid, mountpoint, mode='rw'): """ Set attachment metadata. @@ -241,11 +257,13 @@ class VolumeManager(base.ManagerWithFind): you would like to attach. :param instance_uuid: uuid of the attaching instance. :param mountpoint: mountpoint on the attaching instance. + :param mode: the access mode. """ return self._action('os-attach', volume, {'instance_uuid': instance_uuid, - 'mountpoint': mountpoint}) + 'mountpoint': mountpoint, + 'mode': mode}) def detach(self, volume): """ @@ -328,7 +346,7 @@ class VolumeManager(base.ManagerWithFind): Delete specified keys from volumes metadata. :param volume: The :class:`Volume`. - :param metadata: A list of keys to be removed. + :param keys: A list of keys to be removed. """ for k in keys: self._delete("/volumes/%s/metadata/%s" % (base.getid(volume), k)) @@ -349,3 +367,61 @@ class VolumeManager(base.ManagerWithFind): def force_delete(self, volume): return self._action('os-force_delete', base.getid(volume)) + + def reset_state(self, volume, state): + """Update the provided volume with the provided state.""" + return self._action('os-reset_status', volume, {'status': state}) + + def extend(self, volume, new_size): + return self._action('os-extend', + base.getid(volume), + {'new_size': new_size}) + + def get_encryption_metadata(self, volume_id): + """ + Retrieve the encryption metadata from the desired volume. + + :param volume_id: the id of the volume to query + :return: a dictionary of volume encryption metadata + """ + return self._get("/volumes/%s/encryption" % volume_id)._info + + def migrate_volume(self, volume, host, force_host_copy): + """Migrate volume to new host. + + :param volume: The :class:`Volume` to migrate + :param host: The destination host + :param force_host_copy: Skip driver optimizations + """ + + return self._action('os-migrate_volume', + volume, + {'host': host, 'force_host_copy': force_host_copy}) + + def migrate_volume_completion(self, old_volume, new_volume, error): + """Complete the migration from the old volume to the temp new one. + + :param old_volume: The original :class:`Volume` in the migration + :param new_volume: The new temporary :class:`Volume` in the migration + :param error: Inform of an error to cause migration cleanup + """ + + new_volume_id = base.getid(new_volume) + return self._action('os-migrate_volume_completion', + old_volume, + {'new_volume': new_volume_id, 'error': error})[1] + + def update_all_metadata(self, volume, metadata): + """Update all metadata of a volume. + + :param volume: The :class:`Volume`. + :param metadata: A list of keys to be updated. + """ + body = {'metadata': metadata} + return self._update("/volumes/%s/metadata" % base.getid(volume), + body) + + def update_readonly_flag(self, volume, flag): + return self._action('os-update_readonly_flag', + base.getid(volume), + {'readonly': flag}) diff --git a/cinderclient/v2/__init__.py b/cinderclient/v2/__init__.py index 5408cd3..75afdec 100644 --- a/cinderclient/v2/__init__.py +++ b/cinderclient/v2/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2013 OpenStack, LLC. +# Copyright (c) 2013 OpenStack Foundation # # All Rights Reserved. # @@ -14,4 +14,4 @@ # License for the specific language governing permissions and limitations # under the License. -from cinderclient.v2.client import Client +from cinderclient.v2.client import Client # noqa diff --git a/cinderclient/v2/availability_zones.py b/cinderclient/v2/availability_zones.py new file mode 100644 index 0000000..c8aef24 --- /dev/null +++ b/cinderclient/v2/availability_zones.py @@ -0,0 +1,42 @@ +# Copyright 2011-2013 OpenStack Foundation +# Copyright 2013 IBM Corp. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Availability Zone interface (v2 extension)""" + +from cinderclient import base + + +class AvailabilityZone(base.Resource): + NAME_ATTR = 'display_name' + + def __repr__(self): + return "" % self.zoneName + + +class AvailabilityZoneManager(base.ManagerWithFind): + """Manage :class:`AvailabilityZone` resources.""" + resource_class = AvailabilityZone + + def list(self, detailed=False): + """Get a list of all availability zones + + :rtype: list of :class:`AvailabilityZone` + """ + if detailed is True: + return self._list("/os-availability-zone/detail", + "availabilityZoneInfo") + else: + return self._list("/os-availability-zone", "availabilityZoneInfo") diff --git a/cinderclient/v2/client.py b/cinderclient/v2/client.py index 92bbd56..7b91c23 100644 --- a/cinderclient/v2/client.py +++ b/cinderclient/v2/client.py @@ -1,10 +1,32 @@ +# Copyright (c) 2013 OpenStack Foundation +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + from cinderclient import client +from cinderclient.v1 import availability_zones from cinderclient.v2 import limits +from cinderclient.v2 import qos_specs from cinderclient.v2 import quota_classes from cinderclient.v2 import quotas +from cinderclient.v2 import services from cinderclient.v2 import volumes from cinderclient.v2 import volume_snapshots from cinderclient.v2 import volume_types +from cinderclient.v2 import volume_encryption_types +from cinderclient.v2 import volume_backups +from cinderclient.v2 import volume_backups_restore +from cinderclient.v1 import volume_transfers class Client(object): @@ -24,7 +46,7 @@ class Client(object): insecure=False, timeout=None, tenant_id=None, proxy_tenant_id=None, proxy_token=None, region_name=None, endpoint_type='publicURL', extensions=None, - service_type='volume', service_name=None, + service_type='volumev2', service_name=None, volume_service_name=None, retries=None, http_log_debug=False, cacert=None): @@ -37,8 +59,17 @@ class Client(object): self.volumes = volumes.VolumeManager(self) self.volume_snapshots = volume_snapshots.SnapshotManager(self) self.volume_types = volume_types.VolumeTypeManager(self) + self.volume_encryption_types = \ + volume_encryption_types.VolumeEncryptionTypeManager(self) + self.qos_specs = qos_specs.QoSSpecsManager(self) self.quota_classes = quota_classes.QuotaClassSetManager(self) self.quotas = quotas.QuotaSetManager(self) + self.backups = volume_backups.VolumeBackupManager(self) + self.restores = volume_backups_restore.VolumeBackupRestoreManager(self) + self.transfers = volume_transfers.VolumeTransferManager(self) + self.services = services.ServiceManager(self) + self.availability_zones = \ + availability_zones.AvailabilityZoneManager(self) # Add in any extensions... if extensions: @@ -76,3 +107,6 @@ class Client(object): credentials are wrong. """ self.client.authenticate() + + def get_volume_api_version_from_endpoint(self): + return self.client.get_volume_api_version_from_endpoint() diff --git a/cinderclient/v2/contrib/__init__.py b/cinderclient/v2/contrib/__init__.py index 0cd9c14..e69de29 100644 --- a/cinderclient/v2/contrib/__init__.py +++ b/cinderclient/v2/contrib/__init__.py @@ -1,15 +0,0 @@ -# Copyright (c) 2013 OpenStack, LLC. -# -# All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. diff --git a/cinderclient/v2/contrib/list_extensions.py b/cinderclient/v2/contrib/list_extensions.py index 9031a51..eab9435 100644 --- a/cinderclient/v2/contrib/list_extensions.py +++ b/cinderclient/v2/contrib/list_extensions.py @@ -1,4 +1,4 @@ -# Copyright 2013 OpenStack LLC. +# Copyright (c) 2013 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may diff --git a/cinderclient/v2/limits.py b/cinderclient/v2/limits.py index d076db8..512a58d 100644 --- a/cinderclient/v2/limits.py +++ b/cinderclient/v2/limits.py @@ -1,17 +1,30 @@ -# Copyright 2013 OpenStack LLC. +# Copyright 2013 OpenStack Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. from cinderclient import base class Limits(base.Resource): - """A collection of RateLimit and AbsoluteLimit objects""" + """A collection of RateLimit and AbsoluteLimit objects.""" def __repr__(self): return "" @property def absolute(self): - for (name, value) in self._info['absolute'].items(): + for (name, value) in list(self._info['absolute'].items()): yield AbsoluteLimit(name, value) @property @@ -26,7 +39,7 @@ class Limits(base.Resource): class RateLimit(object): - """Data model that represents a flattened view of a single rate limit""" + """Data model that represents a flattened view of a single rate limit.""" def __init__(self, verb, uri, regex, value, remain, unit, next_available): @@ -48,11 +61,11 @@ class RateLimit(object): and self.next_available == other.next_available def __repr__(self): - return "" % (self.method, self.uri) + return "" % (self.verb, self.uri) class AbsoluteLimit(object): - """Data model that represents a single absolute limit""" + """Data model that represents a single absolute limit.""" def __init__(self, name, value): self.name = name @@ -66,7 +79,7 @@ class AbsoluteLimit(object): class LimitsManager(base.Manager): - """Manager object used to interact with limits resource""" + """Manager object used to interact with limits resource.""" resource_class = Limits diff --git a/cinderclient/v2/qos_specs.py b/cinderclient/v2/qos_specs.py new file mode 100644 index 0000000..b4e4272 --- /dev/null +++ b/cinderclient/v2/qos_specs.py @@ -0,0 +1,149 @@ +# Copyright (c) 2013 eBay Inc. +# Copyright (c) OpenStack LLC. +# +# 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. + + +""" +QoS Specs interface. +""" + +from cinderclient import base + + +class QoSSpecs(base.Resource): + """QoS specs entity represents quality-of-service parameters/requirements. + + A QoS specs is a set of parameters or requirements for quality-of-service + purpose, which can be associated with volume types (for now). In future, + QoS specs may be extended to be associated other entities, such as single + volume. + """ + def __repr__(self): + return "" % self.name + + def delete(self): + return self.manager.delete(self) + + +class QoSSpecsManager(base.ManagerWithFind): + """ + Manage :class:`QoSSpecs` resources. + """ + resource_class = QoSSpecs + + def list(self): + """Get a list of all qos specs. + + :rtype: list of :class:`QoSSpecs`. + """ + return self._list("/qos-specs", "qos_specs") + + def get(self, qos_specs): + """Get a specific qos specs. + + :param qos_specs: The ID of the :class:`QoSSpecs` to get. + :rtype: :class:`QoSSpecs` + """ + return self._get("/qos-specs/%s" % base.getid(qos_specs), "qos_specs") + + def delete(self, qos_specs, force=False): + """Delete a specific qos specs. + + :param qos_specs: The ID of the :class:`QoSSpecs` to be removed. + :param force: Flag that indicates whether to delete target qos specs + if it was in-use. + """ + self._delete("/qos-specs/%s?force=%s" % + (base.getid(qos_specs), force)) + + def create(self, name, specs): + """Create a qos specs. + + :param name: Descriptive name of the qos specs, must be unique + :param specs: A dict of key/value pairs to be set + :rtype: :class:`QoSSpecs` + """ + + body = { + "qos_specs": { + "name": name, + } + } + + body["qos_specs"].update(specs) + return self._create("/qos-specs", body, "qos_specs") + + def set_keys(self, qos_specs, specs): + """Update a qos specs with new specifications. + + :param qos_specs: The ID of qos specs + :param specs: A dict of key/value pairs to be set + :rtype: :class:`QoSSpecs` + """ + + body = { + "qos_specs": {} + } + + body["qos_specs"].update(specs) + return self._update("/qos-specs/%s" % qos_specs, body) + + def unset_keys(self, qos_specs, specs): + """Update a qos specs with new specifications. + + :param qos_specs: The ID of qos specs + :param specs: A list of key to be unset + :rtype: :class:`QoSSpecs` + """ + + body = {'keys': specs} + + return self._update("/qos-specs/%s/delete_keys" % qos_specs, + body) + + def get_associations(self, qos_specs): + """Get associated entities of a qos specs. + + :param qos_specs: The id of the :class: `QoSSpecs` + :return: a list of entities that associated with specific qos specs. + """ + return self._list("/qos-specs/%s/associations" % base.getid(qos_specs), + "qos_associations") + + def associate(self, qos_specs, vol_type_id): + """Associate a volume type with specific qos specs. + + :param qos_specs: The qos specs to be associated with + :param vol_type_id: The volume type id to be associated with + """ + self.api.client.get("/qos-specs/%s/associate?vol_type_id=%s" % + (base.getid(qos_specs), vol_type_id)) + + def disassociate(self, qos_specs, vol_type_id): + """Disassociate qos specs from volume type. + + :param qos_specs: The qos specs to be associated with + :param vol_type_id: The volume type id to be associated with + """ + self.api.client.get("/qos-specs/%s/disassociate?vol_type_id=%s" % + (base.getid(qos_specs), vol_type_id)) + + def disassociate_all(self, qos_specs): + """Disassociate all entities from specific qos specs. + + :param qos_specs: The qos specs to be associated with + """ + self.api.client.get("/qos-specs/%s/disassociate_all" % + base.getid(qos_specs)) diff --git a/cinderclient/v2/quota_classes.py b/cinderclient/v2/quota_classes.py index d8b3e2f..bf80db0 100644 --- a/cinderclient/v2/quota_classes.py +++ b/cinderclient/v2/quota_classes.py @@ -1,4 +1,4 @@ -# Copyright 2013 OpenStack LLC. +# Copyright (c) 2013 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -20,32 +20,24 @@ class QuotaClassSet(base.Resource): @property def id(self): - """Needed by base.Resource to self-refresh and be indexed""" + """Needed by base.Resource to self-refresh and be indexed.""" return self.class_name def update(self, *args, **kwargs): self.manager.update(self.class_name, *args, **kwargs) -class QuotaClassSetManager(base.ManagerWithFind): +class QuotaClassSetManager(base.Manager): resource_class = QuotaClassSet def get(self, class_name): return self._get("/os-quota-class-sets/%s" % (class_name), "quota_class_set") - def update(self, - class_name, - volumes=None, - gigabytes=None): + def update(self, class_name, **updates): + body = {'quota_class_set': {'class_name': class_name}} - body = {'quota_class_set': { - 'class_name': class_name, - 'volumes': volumes, - 'gigabytes': gigabytes}} - - for key in body['quota_class_set'].keys(): - if body['quota_class_set'][key] is None: - body['quota_class_set'].pop(key) + for update in updates: + body['quota_class_set'][update] = updates[update] self._update('/os-quota-class-sets/%s' % (class_name), body) diff --git a/cinderclient/v2/quotas.py b/cinderclient/v2/quotas.py index 803d72c..3a094ff 100644 --- a/cinderclient/v2/quotas.py +++ b/cinderclient/v2/quotas.py @@ -1,4 +1,4 @@ -# Copyright 2013 OpenStack LLC. +# Copyright (c) 2013 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -20,32 +20,27 @@ class QuotaSet(base.Resource): @property def id(self): - """Needed by base.Resource to self-refresh and be indexed""" + """Needed by base.Resource to self-refresh and be indexed.""" return self.tenant_id def update(self, *args, **kwargs): self.manager.update(self.tenant_id, *args, **kwargs) -class QuotaSetManager(base.ManagerWithFind): +class QuotaSetManager(base.Manager): resource_class = QuotaSet - def get(self, tenant_id): + def get(self, tenant_id, usage=False): if hasattr(tenant_id, 'tenant_id'): tenant_id = tenant_id.tenant_id - return self._get("/os-quota-sets/%s" % (tenant_id), "quota_set") + return self._get("/os-quota-sets/%s?usage=%s" % (tenant_id, usage), + "quota_set") - def update(self, tenant_id, volumes=None, snapshots=None, gigabytes=None): + def update(self, tenant_id, **updates): + body = {'quota_set': {'tenant_id': tenant_id}} - body = {'quota_set': { - 'tenant_id': tenant_id, - 'volumes': volumes, - 'snapshots': snapshots, - 'gigabytes': gigabytes}} - - for key in body['quota_set'].keys(): - if body['quota_set'][key] is None: - body['quota_set'].pop(key) + for update in updates: + body['quota_set'][update] = updates[update] self._update('/os-quota-sets/%s' % (tenant_id), body) diff --git a/cinderclient/v2/services.py b/cinderclient/v2/services.py new file mode 100644 index 0000000..2669a5e --- /dev/null +++ b/cinderclient/v2/services.py @@ -0,0 +1,58 @@ +# Copyright (c) 2013 OpenStack Foundation +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +service interface +""" +from cinderclient import base + + +class Service(base.Resource): + + def __repr__(self): + return "" % self.service + + +class ServiceManager(base.ManagerWithFind): + resource_class = Service + + def list(self, host=None, binary=None): + """ + Describes service list for host. + + :param host: destination host name. + :param binary: service binary. + """ + url = "/os-services" + filters = [] + if host: + filters.append("host=%s" % host) + if binary: + filters.append("binary=%s" % binary) + if filters: + url = "%s?%s" % (url, "&".join(filters)) + return self._list(url, "services") + + def enable(self, host, binary): + """Enable the service specified by hostname and binary.""" + body = {"host": host, "binary": binary} + result = self._update("/os-services/enable", body) + return self.resource_class(self, result) + + def disable(self, host, binary): + """Enable the service specified by hostname and binary.""" + body = {"host": host, "binary": binary} + result = self._update("/os-services/disable", body) + return self.resource_class(self, result) diff --git a/cinderclient/v2/shell.py b/cinderclient/v2/shell.py index 2cf7c76..636c812 100644 --- a/cinderclient/v2/shell.py +++ b/cinderclient/v2/shell.py @@ -1,4 +1,4 @@ -# Copyright 2013 OpenStack LLC. +# Copyright (c) 2013 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -13,13 +13,20 @@ # License for the specific language governing permissions and limitations # under the License. +from __future__ import print_function + import argparse +import copy import os import sys import time +import six + from cinderclient import exceptions from cinderclient import utils +from cinderclient.openstack.common import strutils +from cinderclient.v2 import availability_zones def _poll_for_status(poll_fn, obj_id, action, final_ok_states, @@ -35,58 +42,77 @@ def _poll_for_status(poll_fn, obj_id, action, final_ok_states, sys.stdout.write(msg) sys.stdout.flush() - print + print() while True: obj = poll_fn(obj_id) status = obj.status.lower() progress = getattr(obj, 'progress', None) or 0 if status in final_ok_states: print_progress(100) - print "\nFinished" + print("\nFinished") break elif status == "error": - print "\nError %(action)s instance" % locals() + print("\nError %(action)s instance" % {'action': action}) break else: print_progress(progress) time.sleep(poll_period) -def _find_volume(cs, volume): - """Get a volume by ID.""" - return utils.find_resource(cs.volumes, volume) - - def _find_volume_snapshot(cs, snapshot): - """Get a volume snapshot by ID.""" + """Get a volume snapshot by name or ID.""" return utils.find_resource(cs.volume_snapshots, snapshot) +def _find_backup(cs, backup): + """Get a backup by name or ID.""" + return utils.find_resource(cs.backups, backup) + + +def _find_transfer(cs, transfer): + """Get a transfer by name or ID.""" + return utils.find_resource(cs.transfers, transfer) + + +def _find_qos_specs(cs, qos_specs): + """Get a qos specs by ID.""" + return utils.find_resource(cs.qos_specs, qos_specs) + + def _print_volume_snapshot(snapshot): utils.print_dict(snapshot._info) -def _translate_volume_keys(collection): - convert = [('volumeType', 'volume_type')] +def _print_volume_image(image): + utils.print_dict(image[1]['os-volume_upload_image']) + + +def _translate_keys(collection, convert): for item in collection: - keys = item.__dict__.keys() + keys = item.__dict__ for from_key, to_key in convert: if from_key in keys and to_key not in keys: setattr(item, to_key, item._info[from_key]) +def _translate_volume_keys(collection): + convert = [('volumeType', 'volume_type')] + _translate_keys(collection, convert) + + def _translate_volume_snapshot_keys(collection): convert = [('volumeId', 'volume_id')] - for item in collection: - keys = item.__dict__.keys() - for from_key, to_key in convert: - if from_key in keys and to_key not in keys: - setattr(item, to_key, item._info[from_key]) + _translate_keys(collection, convert) + + +def _translate_availability_zone_keys(collection): + convert = [('zoneName', 'name'), ('zoneState', 'status')] + _translate_keys(collection, convert) def _extract_metadata(args): metadata = {} - for metadatum in args.metadata[0]: + for metadatum in args.metadata: # unset doesn't require a val, so we have the if/else if '=' in metadatum: (key, value) = metadatum.split('=', 1) @@ -121,7 +147,13 @@ def _extract_metadata(args): metavar='', default=None, help='Filter results by status') -@utils.service_type('volume') +@utils.arg('--metadata', + type=str, + nargs='*', + metavar='', + help='Filter results by metadata', + default=None) +@utils.service_type('volumev2') def do_list(cs, args): """List all the volumes.""" # NOTE(thingee): Backwards-compatibility with v1 args @@ -133,6 +165,7 @@ def do_list(cs, args): 'all_tenants': all_tenants, 'name': args.name, 'status': args.status, + 'metadata': _extract_metadata(args) if args.metadata else None, } volumes = cs.volumes.list(search_opts=search_opts) _translate_volume_keys(volumes) @@ -148,17 +181,15 @@ def do_list(cs, args): @utils.arg('volume', metavar='', - help='ID of the volume.') -@utils.service_type('volume') + help='Name or ID of the volume.') +@utils.service_type('volumev2') def do_show(cs, args): """Show details about a volume.""" info = dict() - volume = _find_volume(cs, args.volume) + volume = utils.find_volume(cs, args.volume) info.update(volume._info) - if 'links' in info: - info.pop('links') - + info.pop('links', None) utils.print_dict(info) @@ -218,7 +249,13 @@ def do_show(cs, args): metavar='', help='Metadata key=value pairs (Optional, Default=None)', default=None) -@utils.service_type('volume') +@utils.arg('--hint', + metavar='', + dest='scheduler_hints', + action='append', + default=[], + help='Scheduler hint like in nova') +@utils.service_type('volumev2') def do_create(cs, args): """Add a new volume.""" # NOTE(thingee): Backwards-compatibility with v1 args @@ -232,6 +269,21 @@ def do_create(cs, args): if args.metadata is not None: volume_metadata = _extract_metadata(args) + #NOTE(N.S.): take this piece from novaclient + hints = {} + if args.scheduler_hints: + for hint in args.scheduler_hints: + key, _sep, value = hint.partition('=') + # NOTE(vish): multiple copies of the same hint will + # result in a list of values + if key in hints: + if isinstance(hints[key], six.string_types): + hints[key] = [hints[key]] + hints[key] += [value] + else: + hints[key] = value + #NOTE(N.S.): end of the taken piece + volume = cs.volumes.create(args.size, args.snapshot_id, args.source_volid, @@ -240,40 +292,84 @@ def do_create(cs, args): args.volume_type, availability_zone=args.availability_zone, imageRef=args.image_id, - metadata=volume_metadata) + metadata=volume_metadata, + scheduler_hints=hints) info = dict() - volume = cs.volumes.get(info['id']) + volume = cs.volumes.get(volume.id) info.update(volume._info) - info.pop('links') - + info.pop('links', None) utils.print_dict(info) @utils.arg('volume', - metavar='', - help='ID of the volume to delete.') -@utils.service_type('volume') + metavar='', nargs='+', + help='Name or ID of the volume(s) to delete.') +@utils.service_type('volumev2') def do_delete(cs, args): - """Remove a volume.""" - volume = _find_volume(cs, args.volume) - volume.delete() + """Remove a volume(s).""" + failure_count = 0 + for volume in args.volume: + try: + utils.find_volume(cs, volume).delete() + except Exception as e: + failure_count += 1 + print("Delete for volume %s failed: %s" % (volume, e)) + if failure_count == len(args.volume): + raise exceptions.CommandError("Unable to delete any of the specified " + "volumes.") @utils.arg('volume', - metavar='', - help='ID of the volume to delete.') -@utils.service_type('volume') + metavar='', nargs='+', + help='Name or ID of the volume(s) to delete.') +@utils.service_type('volumev2') def do_force_delete(cs, args): - """Attempt forced removal of a volume, regardless of it's state.""" - volume = _find_volume(cs, args.volume) - volume.force_delete() + """Attempt forced removal of volume(s), regardless of the state(s).""" + failure_count = 0 + for volume in args.volume: + try: + utils.find_volume(cs, volume).force_delete() + except Exception as e: + failure_count += 1 + print("Delete for volume %s failed: %s" % (volume, e)) + if failure_count == len(args.volume): + raise exceptions.CommandError("Unable to force delete any of the " + "specified volumes.") + + +@utils.arg('volume', metavar='', nargs='+', + help='Name or ID of the volume to modify.') +@utils.arg('--state', metavar='', default='available', + help=('Indicate which state to assign the volume. Options include ' + 'available, error, creating, deleting, error_deleting. If no ' + 'state is provided, available will be used.')) +@utils.service_type('volumev2') +def do_reset_state(cs, args): + """Explicitly update the state of a volume.""" + failure_count = 0 + + single = (len(args.volume) == 1) + + for volume in args.volume: + try: + utils.find_volume(cs, volume).reset_state(args.state) + except Exception as e: + failure_count += 1 + msg = "Reset state for volume %s failed: %s" % (volume, e) + if not single: + print(msg) + + if failure_count == len(args.volume): + if not single: + msg = "Unable to reset the state for any of the specified volumes." + raise exceptions.CommandError(msg) @utils.arg('volume', metavar='', - help='ID of the volume to rename.') + help='Name or ID of the volume to rename.') @utils.arg('name', nargs='?', metavar='', @@ -285,7 +381,7 @@ def do_force_delete(cs, args): help=argparse.SUPPRESS) @utils.arg('--display_description', help=argparse.SUPPRESS) -@utils.service_type('volume') +@utils.service_type('volumev2') def do_rename(cs, args): """Rename a volume.""" kwargs = {} @@ -297,12 +393,16 @@ def do_rename(cs, args): elif args.description is not None: kwargs['description'] = args.description - _find_volume(cs, args.volume).update(**kwargs) + if not any(kwargs): + msg = 'Must supply either name or description.' + raise exceptions.ClientException(code=1, message=msg) + + utils.find_volume(cs, args.volume).update(**kwargs) @utils.arg('volume', metavar='', - help='ID of the volume to update metadata on.') + help='Name or ID of the volume to update metadata on.') @utils.arg('action', metavar='', choices=['set', 'unset'], @@ -310,19 +410,20 @@ def do_rename(cs, args): @utils.arg('metadata', metavar='', nargs='+', - action='append', default=[], help='Metadata to set/unset (only key is necessary on unset)') -@utils.service_type('volume') +@utils.service_type('volumev2') def do_metadata(cs, args): """Set or Delete metadata on a volume.""" - volume = _find_volume(cs, args.volume) + volume = utils.find_volume(cs, args.volume) metadata = _extract_metadata(args) if args.action == 'set': cs.volumes.set_metadata(volume, metadata) elif args.action == 'unset': - cs.volumes.delete_metadata(volume, metadata.keys()) + # NOTE(zul): Make sure py2/py3 sorting is the same + cs.volumes.delete_metadata(volume, sorted(metadata.keys(), + reverse=True)) @utils.arg('--all-tenants', @@ -356,7 +457,7 @@ def do_metadata(cs, args): help='Filter results by volume-id') @utils.arg('--volume_id', help=argparse.SUPPRESS) -@utils.service_type('volume') +@utils.service_type('volumev2') def do_snapshot_list(cs, args): """List all the snapshots.""" all_tenants = int(os.environ.get("ALL_TENANTS", args.all_tenants)) @@ -379,17 +480,17 @@ def do_snapshot_list(cs, args): @utils.arg('snapshot', metavar='', - help='ID of the snapshot.') -@utils.service_type('volume') + help='Name or ID of the snapshot.') +@utils.service_type('volumev2') def do_snapshot_show(cs, args): """Show details about a snapshot.""" snapshot = _find_volume_snapshot(cs, args.snapshot) _print_volume_snapshot(snapshot) -@utils.arg('volume-id', - metavar='', - help='ID of the volume to snapshot') +@utils.arg('volume', + metavar='', + help='Name or ID of the volume to snapshot') @utils.arg('--force', metavar='', help='Optional flag to indicate whether ' @@ -412,7 +513,7 @@ def do_snapshot_show(cs, args): help=argparse.SUPPRESS) @utils.arg('--display_description', help=argparse.SUPPRESS) -@utils.service_type('volume') +@utils.service_type('volumev2') def do_snapshot_create(cs, args): """Add a new snapshot.""" if args.display_name is not None: @@ -421,24 +522,26 @@ def do_snapshot_create(cs, args): if args.display_description is not None: args.description = args.display_description - snapshot = cs.volume_snapshots.create(args.volume_id, + volume = utils.find_volume(cs, args.volume) + snapshot = cs.volume_snapshots.create(volume.id, args.force, args.name, args.description) _print_volume_snapshot(snapshot) -@utils.arg('snapshot-id', - metavar='', - help='ID of the snapshot to delete.') -@utils.service_type('volume') +@utils.arg('snapshot', + metavar='', + help='Name or ID of the snapshot to delete.') +@utils.service_type('volumev2') def do_snapshot_delete(cs, args): """Remove a snapshot.""" - snapshot = _find_volume_snapshot(cs, args.snapshot_id) + snapshot = _find_volume_snapshot(cs, args.snapshot) snapshot.delete() -@utils.arg('snapshot', metavar='', help='ID of the snapshot.') +@utils.arg('snapshot', metavar='', + help='Name or ID of the snapshot.') @utils.arg('name', nargs='?', metavar='', help='New name for the snapshot.') @utils.arg('--description', metavar='', @@ -448,7 +551,7 @@ def do_snapshot_delete(cs, args): help=argparse.SUPPRESS) @utils.arg('--display_description', help=argparse.SUPPRESS) -@utils.service_type('volume') +@utils.service_type('volumev2') def do_snapshot_rename(cs, args): """Rename a snapshot.""" kwargs = {} @@ -461,9 +564,44 @@ def do_snapshot_rename(cs, args): elif args.display_description is not None: kwargs['description'] = args.display_description + if not any(kwargs): + msg = 'Must supply either name or description.' + raise exceptions.ClientException(code=1, message=msg) + _find_volume_snapshot(cs, args.snapshot).update(**kwargs) +@utils.arg('snapshot', metavar='', nargs='+', + help='Name or ID of the snapshot to modify.') +@utils.arg('--state', metavar='', + default='available', + help=('Indicate which state to assign the snapshot. ' + 'Options include available, error, creating, ' + 'deleting, error_deleting. If no state is provided, ' + 'available will be used.')) +@utils.service_type('volumev2') +def do_snapshot_reset_state(cs, args): + """Explicitly update the state of a snapshot.""" + failure_count = 0 + + single = (len(args.snapshot) == 1) + + for snapshot in args.snapshot: + try: + _find_volume_snapshot(cs, snapshot).reset_state(args.state) + except Exception as e: + failure_count += 1 + msg = "Reset state for snapshot %s failed: %s" % (snapshot, e) + if not single: + print(msg) + + if failure_count == len(args.snapshot): + if not single: + msg = ("Unable to reset the state for any of the the specified " + "snapshots.") + raise exceptions.CommandError(msg) + + def _print_volume_type_list(vtypes): utils.print_list(vtypes, ['ID', 'Name']) @@ -473,14 +611,14 @@ def _print_type_and_extra_specs_list(vtypes): utils.print_list(vtypes, ['ID', 'Name', 'extra_specs'], formatters) -@utils.service_type('volume') +@utils.service_type('volumev2') def do_type_list(cs, args): """Print a list of available 'volume types'.""" vtypes = cs.volume_types.list() _print_volume_type_list(vtypes) -@utils.service_type('volume') +@utils.service_type('volumev2') def do_extra_specs_list(cs, args): """Print a list of current 'volume types and extra specs' (Admin Only).""" vtypes = cs.volume_types.list() @@ -490,7 +628,7 @@ def do_extra_specs_list(cs, args): @utils.arg('name', metavar='', help="Name of the new volume type") -@utils.service_type('volume') +@utils.service_type('volumev2') def do_type_create(cs, args): """Create a new volume type.""" vtype = cs.volume_types.create(args.name) @@ -500,9 +638,9 @@ def do_type_create(cs, args): @utils.arg('id', metavar='', help="Unique ID of the volume type to delete") -@utils.service_type('volume') +@utils.service_type('volumev2') def do_type_delete(cs, args): - """Delete a specific volume type""" + """Delete a specific volume type.""" cs.volume_types.delete(args.id) @@ -516,49 +654,74 @@ def do_type_delete(cs, args): @utils.arg('metadata', metavar='', nargs='+', - action='append', default=[], help='Extra_specs to set/unset (only key is necessary on unset)') -@utils.service_type('volume') +@utils.service_type('volumev2') def do_type_key(cs, args): - "Set or unset extra_spec for a volume type.""" + """Set or unset extra_spec for a volume type.""" vtype = _find_volume_type(cs, args.vtype) keypair = _extract_metadata(args) if args.action == 'set': vtype.set_keys(keypair) elif args.action == 'unset': - vtype.unset_keys(keypair.keys()) + vtype.unset_keys(list(keypair)) def do_endpoints(cs, args): - """Discover endpoints that get returned from the authenticate services""" + """Discover endpoints that get returned from the authenticate services.""" catalog = cs.client.service_catalog.catalog for e in catalog['access']['serviceCatalog']: utils.print_dict(e['endpoints'][0], e['name']) def do_credentials(cs, args): - """Show user credentials returned from auth""" + """Show user credentials returned from auth.""" catalog = cs.client.service_catalog.catalog utils.print_dict(catalog['access']['user'], "User Credentials") utils.print_dict(catalog['access']['token'], "Token") + _quota_resources = ['volumes', 'snapshots', 'gigabytes'] +_quota_infos = ['Type', 'In_use', 'Reserved', 'Limit'] def _quota_show(quotas): quota_dict = {} - for resource in _quota_resources: + for resource in quotas._info: + good_name = False + for name in _quota_resources: + if resource.startswith(name): + good_name = True + if not good_name: + continue quota_dict[resource] = getattr(quotas, resource, None) utils.print_dict(quota_dict) +def _quota_usage_show(quotas): + quota_list = [] + for resource in quotas._info.keys(): + good_name = False + for name in _quota_resources: + if resource.startswith(name): + good_name = True + if not good_name: + continue + quota_info = getattr(quotas, resource, None) + quota_info['Type'] = resource + quota_info = dict((k.capitalize(), v) for k, v in quota_info.items()) + quota_list.append(quota_info) + utils.print_list(quota_list, _quota_infos) + + def _quota_update(manager, identifier, args): updates = {} for resource in _quota_resources: val = getattr(args, resource, None) if val is not None: + if args.volume_type: + resource = resource + '_%s' % args.volume_type updates[resource] = val if updates: @@ -568,17 +731,26 @@ def _quota_update(manager, identifier, args): @utils.arg('tenant', metavar='', help='UUID of tenant to list the quotas for.') -@utils.service_type('volume') +@utils.service_type('volumev2') def do_quota_show(cs, args): """List the quotas for a tenant.""" _quota_show(cs.quotas.get(args.tenant)) +@utils.arg('tenant', metavar='', + help='UUID of tenant to list the quota usage for.') +@utils.service_type('volumev2') +def do_quota_usage(cs, args): + """List the quota usage for a tenant.""" + + _quota_usage_show(cs.quotas.get(args.tenant, usage=True)) + + @utils.arg('tenant', metavar='', help='UUID of tenant to list the default quotas for.') -@utils.service_type('volume') +@utils.service_type('volumev2') def do_quota_defaults(cs, args): """List the default quotas for a tenant.""" @@ -600,7 +772,11 @@ def do_quota_defaults(cs, args): metavar='', type=int, default=None, help='New value for the "gigabytes" quota.') -@utils.service_type('volume') +@utils.arg('--volume-type', + metavar='', + default=None, + help='Volume type (Optional, Default=None)') +@utils.service_type('volumev2') def do_quota_update(cs, args): """Update the quotas for a tenant.""" @@ -610,7 +786,7 @@ def do_quota_update(cs, args): @utils.arg('class_name', metavar='', help='Name of quota class to list the quotas for.') -@utils.service_type('volume') +@utils.service_type('volumev2') def do_quota_class_show(cs, args): """List the quotas for a quota class.""" @@ -632,14 +808,18 @@ def do_quota_class_show(cs, args): metavar='', type=int, default=None, help='New value for the "gigabytes" quota.') -@utils.service_type('volume') +@utils.arg('--volume-type', + metavar='', + default=None, + help='Volume type (Optional, Default=None)') +@utils.service_type('volumev2') def do_quota_class_update(cs, args): """Update the quotas for a quota class.""" _quota_update(cs.quota_classes, args.class_name, args) -@utils.service_type('volume') +@utils.service_type('volumev2') def do_absolute_limits(cs, args): """Print a list of absolute limits for a user""" limits = cs.limits.get().absolute @@ -647,7 +827,7 @@ def do_absolute_limits(cs, args): utils.print_list(limits, columns) -@utils.service_type('volume') +@utils.service_type('volumev2') def do_rate_limits(cs, args): """Print a list of rate limits for a user""" limits = cs.limits.get().rate @@ -667,9 +847,9 @@ def _find_volume_type(cs, vtype): return utils.find_resource(cs.volume_types, vtype) -@utils.arg('volume-id', - metavar='', - help='ID of the volume to snapshot') +@utils.arg('volume', + metavar='', + help='Name or ID of the volume to snapshot') @utils.arg('--force', metavar='', help='Optional flag to indicate whether ' @@ -690,16 +870,614 @@ def _find_volume_type(cs, vtype): default='raw') @utils.arg('--disk_format', help=argparse.SUPPRESS) -@utils.arg('image-name', +@utils.arg('image_name', metavar='', help='Name for created image') @utils.arg('--image_name', help=argparse.SUPPRESS) -@utils.service_type('volume') +@utils.service_type('volumev2') def do_upload_to_image(cs, args): """Upload volume to image service as image.""" - volume = _find_volume(cs, args.volume_id) - volume.upload_to_image(args.force, - args.image_name, - args.container_format, - args.disk_format) + volume = utils.find_volume(cs, args.volume) + _print_volume_image(volume.upload_to_image(args.force, + args.image_name, + args.container_format, + args.disk_format)) + + +@utils.arg('volume', metavar='', help='ID of the volume to migrate') +@utils.arg('host', metavar='', help='Destination host') +@utils.arg('--force-host-copy', metavar='', + choices=['True', 'False'], required=False, + help='Optional flag to force the use of the generic ' + 'host-based migration mechanism, bypassing driver ' + 'optimizations (Default=False).', + default=False) +@utils.service_type('volumev2') +def do_migrate(cs, args): + """Migrate the volume to the new host.""" + volume = utils.find_volume(cs, args.volume) + volume.migrate_volume(args.host, args.force_host_copy) + + +@utils.arg('volume', metavar='', + help='Name or ID of the volume to retype') +@utils.arg('new_type', metavar='', help='New volume type') +@utils.arg('--migration-policy', metavar='', required=False, + choices=['never', 'on-demand'], default='never', + help='Policy on migrating the volume during the retype.') +@utils.service_type('volumev2') +def do_retype(cs, args): + """Change the volume's type.""" + volume = utils.find_volume(cs, args.volume) + volume.retype(args.new_type, args.migration_policy) + + +@utils.arg('volume', metavar='', + help='Name or ID of the volume to backup.') +@utils.arg('--container', metavar='', + help='Optional backup container name. (Default=None)', + default=None) +@utils.arg('--display-name', + help=argparse.SUPPRESS) +@utils.arg('--name', metavar='', + help='Optional backup name. (Default=None)', + default=None) +@utils.arg('--display-description', + help=argparse.SUPPRESS) +@utils.arg('--description', + metavar='', + default=None, + help='Options backup description (Default=None)') +@utils.service_type('volumev2') +def do_backup_create(cs, args): + """Creates a backup.""" + if args.display_name is not None: + args.name = args.display_name + + if args.display_description is not None: + args.description = args.display_description + + volume = utils.find_volume(cs, args.volume) + backup = cs.backups.create(volume.id, + args.container, + args.name, + args.description) + + info = {"volume_id": volume.id} + info.update(backup._info) + + if 'links' in info: + info.pop('links') + + utils.print_dict(info) + + +@utils.arg('backup', metavar='', help='Name or ID of the backup.') +@utils.service_type('volumev2') +def do_backup_show(cs, args): + """Show details about a backup.""" + backup = _find_backup(cs, args.backup) + info = dict() + info.update(backup._info) + + info.pop('links', None) + utils.print_dict(info) + + +@utils.service_type('volumev2') +def do_backup_list(cs, args): + """List all the backups.""" + backups = cs.backups.list() + columns = ['ID', 'Volume ID', 'Status', 'Name', 'Size', 'Object Count', + 'Container'] + utils.print_list(backups, columns) + + +@utils.arg('backup', metavar='', + help='Name or ID of the backup to delete.') +@utils.service_type('volumev2') +def do_backup_delete(cs, args): + """Remove a backup.""" + backup = _find_backup(cs, args.backup) + backup.delete() + + +@utils.arg('backup', metavar='', + help='ID of the backup to restore.') +@utils.arg('--volume-id', metavar='', + help=argparse.SUPPRESS, + default=None) +@utils.arg('--volume', metavar='', + help='Optional ID(or name) of the volume to restore to.', + default=None) +@utils.service_type('volumev2') +def do_backup_restore(cs, args): + """Restore a backup.""" + vol = args.volume or args.volume_id + if vol: + volume_id = utils.find_volume(cs, vol).id + else: + volume_id = None + cs.restores.restore(args.backup, volume_id) + + +@utils.arg('volume', metavar='', + help='Name or ID of the volume to transfer.') +@utils.arg('--name', + metavar='', + default=None, + help='Optional transfer name. (Default=None)') +@utils.arg('--display-name', + help=argparse.SUPPRESS) +@utils.service_type('volumev2') +def do_transfer_create(cs, args): + """Creates a volume transfer.""" + if args.display_name is not None: + args.name = args.display_name + + volume = utils.find_volume(cs, args.volume) + transfer = cs.transfers.create(volume.id, + args.name) + info = dict() + info.update(transfer._info) + + info.pop('links', None) + utils.print_dict(info) + + +@utils.arg('transfer', metavar='', + help='Name or ID of the transfer to delete.') +@utils.service_type('volumev2') +def do_transfer_delete(cs, args): + """Undo a transfer.""" + transfer = _find_transfer(cs, args.transfer) + transfer.delete() + + +@utils.arg('transfer', metavar='', + help='ID of the transfer to accept.') +@utils.arg('auth_key', metavar='', + help='Auth key of the transfer to accept.') +@utils.service_type('volumev2') +def do_transfer_accept(cs, args): + """Accepts a volume transfer.""" + transfer = cs.transfers.accept(args.transfer, args.auth_key) + info = dict() + info.update(transfer._info) + + info.pop('links', None) + utils.print_dict(info) + + +@utils.service_type('volumev2') +def do_transfer_list(cs, args): + """List all the transfers.""" + transfers = cs.transfers.list() + columns = ['ID', 'Volume ID', 'Name'] + utils.print_list(transfers, columns) + + +@utils.arg('transfer', metavar='', + help='Name or ID of the transfer to accept.') +@utils.service_type('volumev2') +def do_transfer_show(cs, args): + """Show details about a transfer.""" + transfer = _find_transfer(cs, args.transfer) + info = dict() + info.update(transfer._info) + + info.pop('links', None) + utils.print_dict(info) + + +@utils.arg('volume', metavar='', + help='Name or ID of the volume to extend.') +@utils.arg('new_size', + metavar='', + type=int, + help='New size of volume in GB') +@utils.service_type('volumev2') +def do_extend(cs, args): + """Attempt to extend the size of an existing volume.""" + volume = utils.find_volume(cs, args.volume) + cs.volumes.extend(volume, args.new_size) + + +@utils.arg('--host', metavar='', default=None, + help='Name of host.') +@utils.arg('--binary', metavar='', default=None, + help='Service binary.') +@utils.service_type('volumev2') +def do_service_list(cs, args): + """List all the services. Filter by host & service binary.""" + result = cs.services.list(host=args.host, binary=args.binary) + columns = ["Binary", "Host", "Zone", "Status", "State", "Updated_at"] + utils.print_list(result, columns) + + +@utils.arg('host', metavar='', help='Name of host.') +@utils.arg('binary', metavar='', help='Service binary.') +@utils.service_type('volumev2') +def do_service_enable(cs, args): + """Enable the service.""" + result = cs.services.enable(args.host, args.binary) + columns = ["Host", "Binary", "Status"] + utils.print_list([result], columns) + + +@utils.arg('host', metavar='', help='Name of host.') +@utils.arg('binary', metavar='', help='Service binary.') +@utils.service_type('volumev2') +def do_service_disable(cs, args): + """Disable the service.""" + result = cs.services.disable(args.host, args.binary) + columns = ["Host", "Binary", "Status"] + utils.print_list([result], columns) + + +def _treeizeAvailabilityZone(zone): + """Build a tree view for availability zones.""" + AvailabilityZone = availability_zones.AvailabilityZone + + az = AvailabilityZone(zone.manager, + copy.deepcopy(zone._info), zone._loaded) + result = [] + + # Zone tree view item + az.zoneName = zone.zoneName + az.zoneState = ('available' + if zone.zoneState['available'] else 'not available') + az._info['zoneName'] = az.zoneName + az._info['zoneState'] = az.zoneState + result.append(az) + + if getattr(zone, "hosts", None) and zone.hosts is not None: + for (host, services) in zone.hosts.items(): + # Host tree view item + az = AvailabilityZone(zone.manager, + copy.deepcopy(zone._info), zone._loaded) + az.zoneName = '|- %s' % host + az.zoneState = '' + az._info['zoneName'] = az.zoneName + az._info['zoneState'] = az.zoneState + result.append(az) + + for (svc, state) in services.items(): + # Service tree view item + az = AvailabilityZone(zone.manager, + copy.deepcopy(zone._info), zone._loaded) + az.zoneName = '| |- %s' % svc + az.zoneState = '%s %s %s' % ( + 'enabled' if state['active'] else 'disabled', + ':-)' if state['available'] else 'XXX', + state['updated_at']) + az._info['zoneName'] = az.zoneName + az._info['zoneState'] = az.zoneState + result.append(az) + return result + + +@utils.service_type('volumev2') +def do_availability_zone_list(cs, _args): + """List all the availability zones.""" + try: + availability_zones = cs.availability_zones.list() + except exceptions.Forbidden as e: # policy doesn't allow probably + try: + availability_zones = cs.availability_zones.list(detailed=False) + except Exception: + raise e + + result = [] + for zone in availability_zones: + result += _treeizeAvailabilityZone(zone) + _translate_availability_zone_keys(result) + utils.print_list(result, ['Name', 'Status']) + + +def _print_volume_encryption_type_list(encryption_types): + """ + Display a tabularized list of volume encryption types. + + :param encryption_types: a list of :class: VolumeEncryptionType instances + """ + utils.print_list(encryption_types, ['Volume Type ID', 'Provider', + 'Cipher', 'Key Size', + 'Control Location']) + + +@utils.service_type('volumev2') +def do_encryption_type_list(cs, args): + """List encryption type information for all volume types (Admin Only).""" + result = cs.volume_encryption_types.list() + utils.print_list(result, ['Volume Type ID', 'Provider', 'Cipher', + 'Key Size', 'Control Location']) + + +@utils.arg('volume_type', + metavar='', + type=str, + help="Name or ID of the volume type") +@utils.service_type('volumev2') +def do_encryption_type_show(cs, args): + """Show the encryption type information for a volume type (Admin Only).""" + volume_type = _find_volume_type(cs, args.volume_type) + + result = cs.volume_encryption_types.get(volume_type) + + # Display result or an empty table if no result + if hasattr(result, 'volume_type_id'): + _print_volume_encryption_type_list([result]) + else: + _print_volume_encryption_type_list([]) + + +@utils.arg('volume_type', + metavar='', + type=str, + help="Name or ID of the volume type") +@utils.arg('provider', + metavar='', + type=str, + help="Class providing encryption support (e.g. LuksEncryptor)") +@utils.arg('--cipher', + metavar='', + type=str, + required=False, + default=None, + help="Encryption algorithm/mode to use (e.g., aes-xts-plain64) " + "(Optional, Default=None)") +@utils.arg('--key_size', + metavar='', + type=int, + required=False, + default=None, + help="Size of the encryption key, in bits (e.g., 128, 256) " + "(Optional, Default=None)") +@utils.arg('--control_location', + metavar='', + choices=['front-end', 'back-end'], + type=str, + required=False, + default=None, + help="Notional service where encryption is performed (e.g., " + "front-end=Nova). Values: 'front-end', 'back-end' " + "(Optional, Default=None)") +@utils.service_type('volumev2') +def do_encryption_type_create(cs, args): + """Create a new encryption type for a volume type (Admin Only).""" + volume_type = _find_volume_type(cs, args.volume_type) + + body = {} + body['provider'] = args.provider + body['cipher'] = args.cipher + body['key_size'] = args.key_size + body['control_location'] = args.control_location + + result = cs.volume_encryption_types.create(volume_type, body) + _print_volume_encryption_type_list([result]) + + +@utils.arg('volume_type', + metavar='', + type=str, + help="Name or ID of the volume type") +@utils.service_type('volumev2') +def do_encryption_type_delete(cs, args): + """Delete the encryption type for a volume type (Admin Only).""" + volume_type = _find_volume_type(cs, args.volume_type) + cs.volume_encryption_types.delete(volume_type) + + +def _print_qos_specs(qos_specs): + utils.print_dict(qos_specs._info) + + +def _print_qos_specs_list(q_specs): + utils.print_list(q_specs, ['ID', 'Name', 'Consumer', 'specs']) + + +def _print_qos_specs_and_associations_list(q_specs): + utils.print_list(q_specs, ['ID', 'Name', 'Consumer', 'specs']) + + +def _print_associations_list(associations): + utils.print_list(associations, ['Association_Type', 'Name', 'ID']) + + +@utils.arg('name', + metavar='', + help="Name of the new QoS specs") +@utils.arg('metadata', + metavar='', + nargs='+', + default=[], + help='Specifications for QoS') +@utils.service_type('volumev2') +def do_qos_create(cs, args): + """Create a new qos specs.""" + keypair = None + if args.metadata is not None: + keypair = _extract_metadata(args) + qos_specs = cs.qos_specs.create(args.name, keypair) + _print_qos_specs(qos_specs) + + +@utils.service_type('volumev2') +def do_qos_list(cs, args): + """Get full list of qos specs.""" + qos_specs = cs.qos_specs.list() + _print_qos_specs_list(qos_specs) + + +@utils.arg('qos_specs', metavar='', + help='ID of the qos_specs to show.') +@utils.service_type('volumev2') +def do_qos_show(cs, args): + """Get a specific qos specs.""" + qos_specs = _find_qos_specs(cs, args.qos_specs) + _print_qos_specs(qos_specs) + + +@utils.arg('qos_specs', metavar='', + help='ID of the qos_specs to delete.') +@utils.arg('--force', + metavar='', + default=False, + help='Optional flag that indicates whether to delete ' + 'specified qos specs even if it is in-use.') +@utils.service_type('volumev2') +def do_qos_delete(cs, args): + """Delete a specific qos specs.""" + force = strutils.bool_from_string(args.force) + qos_specs = _find_qos_specs(cs, args.qos_specs) + cs.qos_specs.delete(qos_specs, force) + + +@utils.arg('qos_specs', metavar='', + help='ID of qos_specs.') +@utils.arg('vol_type_id', metavar='', + help='ID of volume type to be associated with.') +@utils.service_type('volumev2') +def do_qos_associate(cs, args): + """Associate qos specs with specific volume type.""" + cs.qos_specs.associate(args.qos_specs, args.vol_type_id) + + +@utils.arg('qos_specs', metavar='', + help='ID of qos_specs.') +@utils.arg('vol_type_id', metavar='', + help='ID of volume type to be associated with.') +@utils.service_type('volumev2') +def do_qos_disassociate(cs, args): + """Disassociate qos specs from specific volume type.""" + cs.qos_specs.disassociate(args.qos_specs, args.vol_type_id) + + +@utils.arg('qos_specs', metavar='', + help='ID of qos_specs to be operate on.') +@utils.service_type('volumev2') +def do_qos_disassociate_all(cs, args): + """Disassociate qos specs from all of its associations.""" + cs.qos_specs.disassociate_all(args.qos_specs) + + +@utils.arg('qos_specs', metavar='', + help='ID of qos specs') +@utils.arg('action', + metavar='', + choices=['set', 'unset'], + help="Actions: 'set' or 'unset'") +@utils.arg('metadata', metavar='key=value', + nargs='+', + default=[], + help='QoS specs to set/unset (only key is necessary on unset)') +def do_qos_key(cs, args): + """Set or unset specifications for a qos spec.""" + keypair = _extract_metadata(args) + + if args.action == 'set': + cs.qos_specs.set_keys(args.qos_specs, keypair) + elif args.action == 'unset': + cs.qos_specs.unset_keys(args.qos_specs, list(keypair)) + + +@utils.arg('qos_specs', metavar='', + help='ID of the qos_specs.') +@utils.service_type('volumev2') +def do_qos_get_association(cs, args): + """Get all associations of specific qos specs.""" + associations = cs.qos_specs.get_associations(args.qos_specs) + _print_associations_list(associations) + + +@utils.arg('snapshot', + metavar='', + help='ID of the snapshot to update metadata on.') +@utils.arg('action', + metavar='', + choices=['set', 'unset'], + help="Actions: 'set' or 'unset'") +@utils.arg('metadata', + metavar='', + nargs='+', + default=[], + help='Metadata to set/unset (only key is necessary on unset)') +@utils.service_type('volumev2') +def do_snapshot_metadata(cs, args): + """Set or Delete metadata of a snapshot.""" + snapshot = _find_volume_snapshot(cs, args.snapshot) + metadata = _extract_metadata(args) + + if args.action == 'set': + metadata = snapshot.set_metadata(metadata) + utils.print_dict(metadata._info) + elif args.action == 'unset': + snapshot.delete_metadata(list(metadata.keys())) + + +@utils.arg('snapshot', metavar='', + help='ID of snapshot') +@utils.service_type('volumev2') +def do_snapshot_metadata_show(cs, args): + """Show metadata of given snapshot.""" + snapshot = _find_volume_snapshot(cs, args.snapshot) + utils.print_dict(snapshot._info['metadata'], 'Metadata-property') + + +@utils.arg('volume', metavar='', + help='ID of volume') +@utils.service_type('volumev2') +def do_metadata_show(cs, args): + """Show metadata of given volume.""" + volume = utils.find_volume(cs, args.volume) + utils.print_dict(volume._info['metadata'], 'Metadata-property') + + +@utils.arg('volume', + metavar='', + help='ID of the volume to update metadata on.') +@utils.arg('metadata', + metavar='', + nargs='+', + default=[], + help='Metadata entry/entries to update.') +@utils.service_type('volumev2') +def do_metadata_update_all(cs, args): + """Update all metadata of a volume.""" + volume = utils.find_volume(cs, args.volume) + metadata = _extract_metadata(args) + metadata = volume.update_all_metadata(metadata) + utils.print_dict(metadata) + + +@utils.arg('snapshot', + metavar='', + help='ID of the snapshot to update metadata on.') +@utils.arg('metadata', + metavar='', + nargs='+', + default=[], + help='Metadata entry/entries to update') +@utils.service_type('volumev2') +def do_snapshot_metadata_update_all(cs, args): + """Update all metadata of a snapshot.""" + snapshot = _find_volume_snapshot(cs, args.snapshot) + metadata = _extract_metadata(args) + metadata = snapshot.update_all_metadata(metadata) + utils.print_dict(metadata) + + +@utils.arg('volume', metavar='', help='ID of the volume to update.') +@utils.arg('read_only', + metavar='', + choices=['True', 'true', 'False', 'false'], + help='Flag to indicate whether to update volume to ' + 'read-only access mode.') +@utils.service_type('volumev2') +def do_readonly_mode_update(cs, args): + """Update volume read-only access mode flag.""" + volume = utils.find_volume(cs, args.volume) + cs.volumes.update_readonly_flag(volume, + strutils.bool_from_string(args.read_only)) diff --git a/cinderclient/v2/volume_backups.py b/cinderclient/v2/volume_backups.py new file mode 100644 index 0000000..89056af --- /dev/null +++ b/cinderclient/v2/volume_backups.py @@ -0,0 +1,76 @@ +# Copyright (C) 2013 Hewlett-Packard Development Company, L.P. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Volume Backups interface (1.1 extension). +""" + +from cinderclient import base + + +class VolumeBackup(base.Resource): + """A volume backup is a block level backup of a volume.""" + def __repr__(self): + return "" % self.id + + def delete(self): + """Delete this volume backup.""" + return self.manager.delete(self) + + +class VolumeBackupManager(base.ManagerWithFind): + """Manage :class:`VolumeBackup` resources.""" + resource_class = VolumeBackup + + def create(self, volume_id, container=None, + name=None, description=None): + """Create a volume backup. + + :param volume_id: The ID of the volume to backup. + :param container: The name of the backup service container. + :param name: The name of the backup. + :param description: The description of the backup. + :rtype: :class:`VolumeBackup` + """ + body = {'backup': {'volume_id': volume_id, + 'container': container, + 'name': name, + 'description': description}} + return self._create('/backups', body, 'backup') + + def get(self, backup_id): + """Show details of a volume backup. + + :param backup_id: The ID of the backup to display. + :rtype: :class:`VolumeBackup` + """ + return self._get("/backups/%s" % backup_id, "backup") + + def list(self, detailed=True): + """Get a list of all volume backups. + + :rtype: list of :class:`VolumeBackup` + """ + if detailed is True: + return self._list("/backups/detail", "backups") + else: + return self._list("/backups", "backups") + + def delete(self, backup): + """Delete a volume backup. + + :param backup: The :class:`VolumeBackup` to delete. + """ + self._delete("/backups/%s" % base.getid(backup)) diff --git a/cinderclient/v2/volume_backups_restore.py b/cinderclient/v2/volume_backups_restore.py new file mode 100644 index 0000000..0eafa82 --- /dev/null +++ b/cinderclient/v2/volume_backups_restore.py @@ -0,0 +1,43 @@ +# Copyright (C) 2013 Hewlett-Packard Development Company, L.P. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Volume Backups Restore interface (1.1 extension). + +This is part of the Volume Backups interface. +""" + +from cinderclient import base + + +class VolumeBackupsRestore(base.Resource): + """A Volume Backups Restore represents a restore operation.""" + def __repr__(self): + return "" % self.volume_id + + +class VolumeBackupRestoreManager(base.Manager): + """Manage :class:`VolumeBackupsRestore` resources.""" + resource_class = VolumeBackupsRestore + + def restore(self, backup_id, volume_id=None): + """Restore a backup to a volume. + + :param backup_id: The ID of the backup to restore. + :param volume_id: The ID of the volume to restore the backup to. + :rtype: :class:`Restore` + """ + body = {'restore': {'volume_id': volume_id}} + return self._create("/backups/%s/restore" % backup_id, + body, "restore") diff --git a/cinderclient/v2/volume_encryption_types.py b/cinderclient/v2/volume_encryption_types.py new file mode 100644 index 0000000..8385add --- /dev/null +++ b/cinderclient/v2/volume_encryption_types.py @@ -0,0 +1,97 @@ +# Copyright (c) 2013 The Johns Hopkins University/Applied Physics Laboratory +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +""" +Volume Encryption Type interface +""" + +from cinderclient import base + + +class VolumeEncryptionType(base.Resource): + """ + A Volume Encryption Type is a collection of settings used to conduct + encryption for a specific volume type. + """ + def __repr__(self): + return "" % self.name + + +class VolumeEncryptionTypeManager(base.ManagerWithFind): + """ + Manage :class: `VolumeEncryptionType` resources. + """ + resource_class = VolumeEncryptionType + + def list(self, search_opts=None): + """ + List all volume encryption types. + + :param volume_types: a list of volume types + :return: a list of :class: VolumeEncryptionType instances + """ + # Since the encryption type is a volume type extension, we cannot get + # all encryption types without going through all volume types. + volume_types = self.api.volume_types.list() + encryption_types = [] + for volume_type in volume_types: + encryption_type = self._get("/types/%s/encryption" + % base.getid(volume_type)) + if hasattr(encryption_type, 'volume_type_id'): + encryption_types.append(encryption_type) + return encryption_types + + def get(self, volume_type): + """ + Get the volume encryption type for the specified volume type. + + :param volume_type: the volume type to query + :return: an instance of :class: VolumeEncryptionType + """ + return self._get("/types/%s/encryption" % base.getid(volume_type)) + + def create(self, volume_type, specs): + """ + Create a new encryption type for the specified volume type. + + :param volume_type: the volume type on which to add an encryption type + :param specs: the encryption type specifications to add + :return: an instance of :class: VolumeEncryptionType + """ + body = {'encryption': specs} + return self._create("/types/%s/encryption" % base.getid(volume_type), + body, "encryption") + + def update(self, volume_type, specs): + """ + Update the encryption type information for the specified volume type. + + :param volume_type: the volume type whose encryption type information + must be updated + :param specs: the encryption type specifications to update + :return: an instance of :class: VolumeEncryptionType + """ + raise NotImplementedError() + + def delete(self, volume_type): + """ + Delete the encryption type information for the specified volume type. + + :param volume_type: the volume type whose encryption type information + must be deleted + """ + return self._delete("/types/%s/encryption/provider" % + base.getid(volume_type)) diff --git a/cinderclient/v2/volume_snapshots.py b/cinderclient/v2/volume_snapshots.py index d3ae632..7e561d9 100644 --- a/cinderclient/v2/volume_snapshots.py +++ b/cinderclient/v2/volume_snapshots.py @@ -1,4 +1,4 @@ -# Copyright 2013 OpenStack LLC. +# Copyright (c) 2013 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -15,7 +15,11 @@ """Volume snapshot interface (1.1 extension).""" -import urllib +import six +try: + from urllib import urlencode +except ImportError: + from urllib.parse import urlencode from cinderclient import base @@ -41,6 +45,22 @@ class Snapshot(base.Resource): def project_id(self): return self._info.get('os-extended-snapshot-attributes:project_id') + def reset_state(self, state): + """Update the snapshot with the provided state.""" + self.manager.reset_state(self, state) + + def set_metadata(self, metadata): + """Set metadata of this snapshot.""" + return self.manager.set_metadata(self, metadata) + + def delete_metadata(self, keys): + """Delete metadata of this snapshot.""" + return self.manager.delete_metadata(self, keys) + + def update_all_metadata(self, metadata): + """Update_all metadata of this snapshot.""" + return self.manager.update_all_metadata(self, metadata) + class SnapshotManager(base.ManagerWithFind): """Manage :class:`Snapshot` resources.""" @@ -83,11 +103,17 @@ class SnapshotManager(base.ManagerWithFind): qparams = {} - for opt, val in search_opts.iteritems(): + for opt, val in six.iteritems(search_opts): if val: qparams[opt] = val - query_string = "?%s" % urllib.urlencode(qparams) if qparams else "" + # 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: @@ -114,3 +140,48 @@ class SnapshotManager(base.ManagerWithFind): body = {"snapshot": kwargs} self._update("/snapshots/%s" % base.getid(snapshot), body) + + def reset_state(self, snapshot, state): + """Update the specified snapshot with the provided state.""" + return self._action('os-reset_status', snapshot, {'status': state}) + + def _action(self, action, snapshot, info=None, **kwargs): + """Perform a snapshot action.""" + body = {action: info} + self.run_hooks('modify_body_for_action', body, **kwargs) + url = '/snapshots/%s/action' % base.getid(snapshot) + return self.api.client.post(url, body=body) + + def update_snapshot_status(self, snapshot, update_dict): + return self._action('os-update_snapshot_status', + base.getid(snapshot), update_dict) + + def set_metadata(self, snapshot, metadata): + """Update/Set a snapshots metadata. + + :param snapshot: The :class:`Snapshot`. + :param metadata: A list of keys to be set. + """ + body = {'metadata': metadata} + return self._create("/snapshots/%s/metadata" % base.getid(snapshot), + body, "metadata") + + def delete_metadata(self, snapshot, keys): + """Delete specified keys from snapshot metadata. + + :param snapshot: The :class:`Snapshot`. + :param keys: A list of keys to be removed. + """ + snapshot_id = base.getid(snapshot) + for k in keys: + self._delete("/snapshots/%s/metadata/%s" % (snapshot_id, k)) + + def update_all_metadata(self, snapshot, metadata): + """Update_all snapshot metadata. + + :param snapshot: The :class:`Snapshot`. + :param metadata: A list of keys to be updated. + """ + body = {'metadata': metadata} + return self._update("/snapshots/%s/metadata" % base.getid(snapshot), + body) diff --git a/cinderclient/v2/volume_transfers.py b/cinderclient/v2/volume_transfers.py new file mode 100644 index 0000000..e6b0e30 --- /dev/null +++ b/cinderclient/v2/volume_transfers.py @@ -0,0 +1,82 @@ +# Copyright (C) 2013 Hewlett-Packard Development Company, L.P. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Volume transfer interface (1.1 extension). +""" + +from cinderclient import base + + +class VolumeTransfer(base.Resource): + """Transfer a volume from one tenant to another""" + def __repr__(self): + return "" % self.id + + def delete(self): + """Delete this volume transfer.""" + return self.manager.delete(self) + + +class VolumeTransferManager(base.ManagerWithFind): + """Manage :class:`VolumeTransfer` resources.""" + resource_class = VolumeTransfer + + def create(self, volume_id, name=None): + """Create a volume transfer. + + :param volume_id: The ID of the volume to transfer. + :param name: The name of the transfer. + :rtype: :class:`VolumeTransfer` + """ + body = {'transfer': {'volume_id': volume_id, + 'name': name}} + return self._create('/os-volume-transfer', body, 'transfer') + + def accept(self, transfer_id, auth_key): + """Accept a volume transfer. + + :param transfer_id: The ID of the trasnfer to accept. + :param auth_key: The auth_key of the transfer. + :rtype: :class:`VolumeTransfer` + """ + body = {'accept': {'auth_key': auth_key}} + return self._create('/os-volume-transfer/%s/accept' % transfer_id, + body, 'transfer') + + def get(self, transfer_id): + """Show details of a volume transfer. + + :param transfer_id: The ID of the volume transfer to display. + :rtype: :class:`VolumeTransfer` + """ + return self._get("/os-volume-transfer/%s" % transfer_id, "transfer") + + def list(self, detailed=True, search_opts=None): + """Get a list of all volume transfer. + + :rtype: list of :class:`VolumeTransfer` + """ + if detailed is True: + return self._list("/os-volume-transfer/detail", "transfers") + else: + return self._list("/os-volume-transfer", "transfers") + + def delete(self, transfer_id): + """Delete a volume transfer. + + :param transfer_id: The :class:`VolumeTransfer` to delete. + """ + self._delete("/os-volume-transfer/%s" % base.getid(transfer_id)) diff --git a/cinderclient/v2/volume_types.py b/cinderclient/v2/volume_types.py index 9d4c2ff..3d76302 100644 --- a/cinderclient/v2/volume_types.py +++ b/cinderclient/v2/volume_types.py @@ -1,4 +1,4 @@ -# Copyright 2013 OpenStack LLC. +# Copyright (c) 2013 OpenStack Foundation # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -70,7 +70,7 @@ class VolumeTypeManager(base.ManagerWithFind): """Manage :class:`VolumeType` resources.""" resource_class = VolumeType - def list(self): + def list(self, search_opts=None): """Get a list of all volume types. :rtype: list of :class:`VolumeType`. diff --git a/cinderclient/v2/volumes.py b/cinderclient/v2/volumes.py index 4e9b86b..524456d 100644 --- a/cinderclient/v2/volumes.py +++ b/cinderclient/v2/volumes.py @@ -1,4 +1,4 @@ -# Copyright 2013 OpenStack LLC. +# Copyright (c) 2013 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -15,7 +15,11 @@ """Volume interface (v2 extension).""" -import urllib +import six +try: + from urllib import urlencode +except ImportError: + from urllib.parse import urlencode from cinderclient import base @@ -33,13 +37,14 @@ class Volume(base.Resource): """Update the name or description for this volume.""" self.manager.update(self, **kwargs) - def attach(self, instance_uuid, mountpoint): + def attach(self, instance_uuid, mountpoint, mode='rw'): """Set attachment metadata. :param instance_uuid: uuid of the attaching instance. :param mountpoint: mountpoint on the attaching instance. + :param mode: the access mode. """ - return self.manager.attach(self, instance_uuid, mountpoint) + return self.manager.attach(self, instance_uuid, mountpoint, mode) def detach(self): """Clear attachment metadata.""" @@ -78,7 +83,7 @@ class Volume(base.Resource): def set_metadata(self, volume, metadata): """Set or Append metadata to a volume. - :param type : The :class: `Volume` to set metadata on + :param volume : The :class: `Volume` to set metadata on :param metadata: A dict of key/value pairs to set """ return self.manager.set_metadata(self, metadata) @@ -86,16 +91,49 @@ class Volume(base.Resource): def upload_to_image(self, force, image_name, container_format, disk_format): """Upload a volume to image service as an image.""" - self.manager.upload_to_image(self, force, image_name, container_format, - disk_format) + return self.manager.upload_to_image(self, force, image_name, + container_format, disk_format) def force_delete(self): - """Delete the specififed volume ignoring it's current state. + """Delete the specified volume ignoring its current state. :param volume: The UUID of the volume to force-delete. """ self.manager.force_delete(self) + def reset_state(self, state): + """Update the volume with the provided state.""" + self.manager.reset_state(self, state) + + def extend(self, volume, new_size): + """Extend the size of the specified volume. + :param volume: The UUID of the volume to extend + :param new_size: The desired size to extend volume to. + """ + + self.manager.extend(self, volume, new_size) + + def migrate_volume(self, host, force_host_copy): + """Migrate the volume to a new host.""" + self.manager.migrate_volume(self, host, force_host_copy) + + def retype(self, volume_type, policy): + """Change a volume's type.""" + self.manager.retype(self, volume_type, policy) + + def update_all_metadata(self, metadata): + """Update all metadata of this volume.""" + return self.manager.update_all_metadata(self, metadata) + + def update_readonly_flag(self, volume, read_only): + """Update the read-only access mode flag of the specified volume. + + :param volume: The UUID of the volume to update. + :param read_only: The value to indicate whether to update volume to + read-only access mode. + """ + self.manager.update_readonly_flag(self, volume, read_only) + class VolumeManager(base.ManagerWithFind): """Manage :class:`Volume` resources.""" @@ -105,7 +143,7 @@ class VolumeManager(base.ManagerWithFind): name=None, description=None, volume_type=None, user_id=None, project_id=None, availability_zone=None, - metadata=None, imageRef=None): + metadata=None, imageRef=None, scheduler_hints=None): """Create a volume. :param size: Size of volume in GB @@ -113,14 +151,16 @@ class VolumeManager(base.ManagerWithFind): :param name: Name of the volume :param description: Description of the volume :param volume_type: Type of volume - :rtype: :class:`Volume` :param user_id: User id derived from context :param project_id: Project id derived from context :param availability_zone: Availability Zone to use :param metadata: Optional metadata to set on volume creation :param imageRef: reference to an image stored in glance :param source_volid: ID of source volume to clone from - """ + :param scheduler_hints: (optional extension) arbitrary key-value pairs + specified by the client to help boot an instance + :rtype: :class:`Volume` + """ if metadata is None: volume_metadata = {} @@ -140,6 +180,7 @@ class VolumeManager(base.ManagerWithFind): 'metadata': volume_metadata, 'imageRef': imageRef, 'source_volid': source_volid, + 'scheduler_hints': scheduler_hints, }} return self._create('/volumes', body, 'volume') @@ -161,11 +202,11 @@ class VolumeManager(base.ManagerWithFind): qparams = {} - for opt, val in search_opts.iteritems(): + for opt, val in six.iteritems(search_opts): if val: qparams[opt] = val - query_string = "?%s" % urllib.urlencode(qparams) if qparams else "" + query_string = "?%s" % urlencode(qparams) if qparams else "" detail = "" if detailed: @@ -201,18 +242,20 @@ class VolumeManager(base.ManagerWithFind): url = '/volumes/%s/action' % base.getid(volume) return self.api.client.post(url, body=body) - def attach(self, volume, instance_uuid, mountpoint): + def attach(self, volume, instance_uuid, mountpoint, mode='rw'): """Set attachment metadata. :param volume: The :class:`Volume` (or its ID) you would like to attach. :param instance_uuid: uuid of the attaching instance. :param mountpoint: mountpoint on the attaching instance. + :param mode: the access mode. """ return self._action('os-attach', volume, {'instance_uuid': instance_uuid, - 'mountpoint': mountpoint}) + 'mountpoint': mountpoint, + 'mode': mode}) def detach(self, volume): """Clear attachment metadata. @@ -286,7 +329,7 @@ class VolumeManager(base.ManagerWithFind): """Delete specified keys from volumes metadata. :param volume: The :class:`Volume`. - :param metadata: A list of keys to be removed. + :param keys: A list of keys to be removed. """ for k in keys: self._delete("/volumes/%s/metadata/%s" % (base.getid(volume), k)) @@ -306,3 +349,73 @@ class VolumeManager(base.ManagerWithFind): def force_delete(self, volume): return self._action('os-force_delete', base.getid(volume)) + + def reset_state(self, volume, state): + """Update the provided volume with the provided state.""" + return self._action('os-reset_status', volume, {'status': state}) + + def extend(self, volume, new_size): + return self._action('os-extend', + base.getid(volume), + {'new_size': new_size}) + + def get_encryption_metadata(self, volume_id): + """ + Retrieve the encryption metadata from the desired volume. + + :param volume_id: the id of the volume to query + :return: a dictionary of volume encryption metadata + """ + return self._get("/volumes/%s/encryption" % volume_id)._info + + def migrate_volume(self, volume, host, force_host_copy): + """Migrate volume to new host. + + :param volume: The :class:`Volume` to migrate + :param host: The destination host + :param force_host_copy: Skip driver optimizations + """ + + return self._action('os-migrate_volume', + volume, + {'host': host, 'force_host_copy': force_host_copy}) + + def migrate_volume_completion(self, old_volume, new_volume, error): + """Complete the migration from the old volume to the temp new one. + + :param old_volume: The original :class:`Volume` in the migration + :param new_volume: The new temporary :class:`Volume` in the migration + :param error: Inform of an error to cause migration cleanup + """ + + new_volume_id = base.getid(new_volume) + return self._action('os-migrate_volume_completion', + old_volume, + {'new_volume': new_volume_id, 'error': error})[1] + + def update_all_metadata(self, volume, metadata): + """Update all metadata of a volume. + + :param volume: The :class:`Volume`. + :param metadata: A list of keys to be updated. + """ + body = {'metadata': metadata} + return self._update("/volumes/%s/metadata" % base.getid(volume), + body) + + def update_readonly_flag(self, volume, flag): + return self._action('os-update_readonly_flag', + base.getid(volume), + {'readonly': flag}) + + def retype(self, volume, volume_type, policy): + """Change a volume's type. + + :param volume: The :class:`Volume` to retype + :param volume_type: New volume type + :param policy: Policy for migration during the retype + """ + return self._action('os-retype', + volume, + {'new_type': volume_type, + 'migration_policy': policy}) diff --git a/debian/changelog b/debian/changelog index f006b67..555b631 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,4 +1,64 @@ -python-cinderclient (1:1.0.3-1) experimental; urgency=low +python-cinderclient (1:1.0.8-2) unstable; urgency=medium + + * Uploading to unstable. + + -- Thomas Goirand Fri, 18 Apr 2014 06:36:30 +0000 + +python-cinderclient (1:1.0.8-1) experimental; urgency=low + + * New upstream release. + * Standards-Version: is now 3.9.5. + * New (build-)depends for this release. + * Disabled useless dh targets. + + -- Thomas Goirand Wed, 09 Apr 2014 00:46:18 +0800 + +python-cinderclient (1:1.0.6-2) unstable; urgency=low + + * Added missing build-depends: python-babel. + + -- Thomas Goirand Mon, 07 Oct 2013 07:20:30 +0000 + +python-cinderclient (1:1.0.6-1) unstable; urgency=low + + * New upstream release. + * Fixed new (build-)depends for this release. + * Bumped Standard-Version: to 3.9.4. + + -- Thomas Goirand Tue, 01 Oct 2013 15:19:25 +0800 + +python-cinderclient (1:1.0.5-2) unstable; urgency=low + + * Added missing python-requests (build-)depends. + + -- Thomas Goirand Wed, 11 Sep 2013 16:54:23 +0800 + +python-cinderclient (1:1.0.5-1) unstable; urgency=low + + * New upstream release. + * Ran wrap-and-sort. + * Now build-depends python-pbr (>= 0.5.20), testrepository. + * Unit tests are now using testrepository. + * Added (build-)depends: python-six. + + -- Thomas Goirand Thu, 30 May 2013 13:42:32 +0800 + +python-cinderclient (1:1.0.4-2) unstable; urgency=low + + * Added missing build-dependencies: python-setputools-git and python-subunit. + + -- Thomas Goirand Fri, 17 May 2013 17:39:51 +0000 + +python-cinderclient (1:1.0.4-1) unstable; urgency=low + + * Uploading to unstable. + * New upstream release. + * Added export OSLO_PACKAGE_VERSION=$(VERSION) in debian/rules. + * Fixed the launch of unit tests. + + -- Thomas Goirand Mon, 13 May 2013 09:53:36 +0000 + +python-cinderclient (1:1.0.2-1) experimental; urgency=low * New upstream release. * Removes all old patches which aren't needed anymore. diff --git a/debian/control b/debian/control index 9b9d4d9..4eee077 100644 --- a/debian/control +++ b/debian/control @@ -7,24 +7,27 @@ Uploaders: Julien Danjou , Ghe Rivero , Mehdi Abaakouk Build-Depends: debhelper (>= 9), - python-all (>= 2.6.6-3~), openstack-pkg-tools -Build-Depends-Indep: python-coverage, - python-discover, - python-fixtures, - python-httplib2, - python-mock, - python-nose, - python-nosexcover, - python-prettytable, - python-requests, - python-setuptools, - python-simplejson, - python-sphinx, - python-testrepository, - python-testtools, - python-unittest2, - testrepository -Standards-Version: 3.9.4 + openstack-pkg-tools, + python-all (>= 2.6.6-3~), + python-pbr (>= 0.5.21), + python-setuptools +Build-Depends-Indep: python-babel (>= 1.3), + python-fixtures (>= 0.3.14), + python-hacking (>= 0.8.0), + python-httplib2, + python-mock (>= 1.0), + python-nose, + python-prettytable (>= 0.7), + python-requests (>= 1.1), + python-simplejson (>= 2.0.9), + python-six (>= 1.4.1), + python-sphinx, + python-subunit, + python-testtools (>= 0.9.32), + python-unittest2, + subunit, + testrepository (>= 0.0.17) +Standards-Version: 3.9.5 Vcs-Browser: http://anonscm.debian.org/gitweb/?p=openstack/python-cinderclient.git;a=summary Vcs-Git: git://anonscm.debian.org/openstack/python-cinderclient.git Homepage: https://github.com/openstack/python-cinderclient @@ -32,12 +35,15 @@ Homepage: https://github.com/openstack/python-cinderclient Package: python-cinderclient Architecture: all Pre-Depends: dpkg (>= 1.15.6~) -Depends: ${python:Depends}, ${misc:Depends}, - python-argparse, - python-httplib2, - python-prettytable, - python-requests, - python-simplejson +Depends: python-babel (>= 1.3), + python-httplib2, + python-pbr (>= 0.5.21), + python-prettytable (>= 0.7), + python-requests (>= 1.1), + python-simplejson (>= 2.0.9), + python-six (>= 1.4.1), + ${misc:Depends}, + ${python:Depends} Provides: ${python:Provides} Description: python bindings to the OpenStack Volume API Cinder is a block storage as service system for the Openstack cloud computing diff --git a/debian/copyright b/debian/copyright index 6d4b2f8..bcf5a3b 100644 --- a/debian/copyright +++ b/debian/copyright @@ -42,4 +42,3 @@ License: GPL-2+ . On Debian systems, the complete text of the GNU General Public License version 2 can be found in "/usr/share/common-licenses/GPL-2". - diff --git a/debian/gbp.conf b/debian/gbp.conf index fe85503..08509a2 100644 --- a/debian/gbp.conf +++ b/debian/gbp.conf @@ -1,8 +1,9 @@ [DEFAULT] upstream-branch = master -debian-branch = debian/experimental +debian-branch = debian/unstable upstream-tag = %(version)s compression = xz [git-buildpackage] export-dir = ../build-area/ +cleaner = true diff --git a/debian/rules b/debian/rules index 5439ed4..ac37a7a 100755 --- a/debian/rules +++ b/debian/rules @@ -10,7 +10,8 @@ export OSLO_PACKAGE_VERSION=$(VERSION) override_dh_auto_clean: dh_auto_clean rm -f cinderclient/versioninfo - rm -rf setuptools_git-*-py*.egg/ + rm -rf setuptools_git-*.egg + rm -rf build override_dh_auto_configure: echo $(VERSION) > cinderclient/versioninfo @@ -25,5 +26,14 @@ override_dh_install: ifeq (,$(findstring nocheck, $(DEB_BUILD_OPTIONS))) override_dh_auto_test: - ./run_tests.sh -N -P || true + ./run_tests.sh -P -N || true + #python setup.py testr --slowest --testr-args='--subunit ' endif + +# Commands not to run +override_dh_installcatalogs: +override_dh_installemacsen override_dh_installifupdown: +override_dh_installinfo override_dh_installmenu override_dh_installmime: +override_dh_installmodules override_dh_installlogcheck: +override_dh_installpam override_dh_installppp override_dh_installudev override_dh_installwm: +override_dh_installxfonts override_dh_gconf override_dh_icons override_dh_perl override_dh_usrlocal: diff --git a/doc/source/conf.py b/doc/source/conf.py index 7abb3d0..d4ae7ca 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -13,6 +13,7 @@ import os import sys +import pbr.version # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the @@ -42,17 +43,17 @@ source_suffix = '.rst' master_doc = 'index' # General information about the project. -project = u'python-cinderclient' -copyright = u'Rackspace, based on work by Jacob Kaplan-Moss' +project = 'python-cinderclient' +copyright = 'OpenStack Contributors' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. -# +version_info = pbr.version.VersionInfo('python-cinderclient') # The short X.Y version. -version = '2.6' +version = version_info.version_string() # The full version, including alpha/beta/rc tags. -release = '2.6.10' +release = version_info.release_string() # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -93,6 +94,10 @@ pygments_style = 'sphinx' #modindex_common_prefix = [] +man_pages = [ + ('man/cinder', 'cinder', u'Client for OpenStack Block Storage API', + [u'OpenStack Contributors'], 1), +] # -- Options for HTML output -------------------------------------------------- # The theme to use for HTML and HTML Help pages. Major themes that come with @@ -179,8 +184,8 @@ htmlhelp_basename = 'python-cinderclientdoc' # (source start file, target name, title, author, documentclass [howto/manual]) # . latex_documents = [ - ('index', 'python-cinderclient.tex', u'python-cinderclient Documentation', - u'Rackspace - based on work by Jacob Kaplan-Moss', 'manual'), + ('index', 'python-cinderclient.tex', 'python-cinderclient Documentation', + 'Rackspace - based on work by Jacob Kaplan-Moss', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of diff --git a/doc/source/index.rst b/doc/source/index.rst index 1ebe9a9..8341aa7 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -24,11 +24,102 @@ In order to use the CLI, you must provide your OpenStack username, password, ten Once you've configured your authentication parameters, you can run ``cinder help`` to see a complete listing of available commands. +See also :doc:`/man/cinder`. + Release Notes ============= -1.1.0 +1.0.8 +----- +* Add support for reset-state on multiple volumes or snapshots at once +* Add volume retype command + +.. _966329: https://bugs.launchpad.net/python-cinderclient/+bug/966329 +.. _1256043: https://bugs.launchpad.net/python-cinderclient/+bug/1256043 +.. _1254951: http://bugs.launchpad.net/python-cinderclient/+bug/1254951 +.. _1254587: http://bugs.launchpad.net/python-cinderclient/+bug/1254587 +.. _1253142: http://bugs.launchpad.net/python-cinderclient/+bug/1253142 +.. _1252665: http://bugs.launchpad.net/python-cinderclient/+bug/1252665 +.. _1255876: http://bugs.launchpad.net/python-cinderclient/+bug/1255876 +.. _1251385: http://bugs.launchpad.net/python-cinderclient/+bug/1251385 +.. _1264415: http://bugs.launchpad.net/python-cinderclient/+bug/1264415 +.. _1258489: http://bugs.launchpad.net/python-cinderclient/+bug/1258489 +.. _1248519: http://bugs.launchpad.net/python-cinderclient/+bug/1248519 +.. _1257747: http://bugs.launchpad.net/python-cinderclient/+bug/1257747 + +1.0.7 +----- +* Add support for read-only volumes +* Add support for setting snapshot metadata +* Deprecate volume-id arg to backup restore in favor of --volume +* Add quota-usage command +* Fix exception deprecation warning message +* Report error when no args supplied to rename cmd + +.. _1241941: http://bugs.launchpad.net/python-cinderclient/+bug/1241941 +.. _1242816: http://bugs.launchpad.net/python-cinderclient/+bug/1242816 +.. _1233311: http://bugs.launchpad.net/python-cinderclient/+bug/1233311 +.. _1227307: http://bugs.launchpad.net/python-cinderclient/+bug/1227307 +.. _1240151: http://bugs.launchpad.net/python-cinderclient/+bug/1240151 +.. _1241682: http://bugs.launchpad.net/python-cinderclient/+bug/1241682 + + +1.0.6 +----- +* Add support for multiple endpoints +* Add response info for backup command +* Add metadata option to cinder list command +* Add timeout parameter for requests +* Add update action for snapshot metadata +* Add encryption metadata support +* Add volume migrate support +* Add support for QoS specs + +.. _1221104: http://bugs.launchpad.net/python-cinderclient/+bug/1221104 +.. _1220590: http://bugs.launchpad.net/python-cinderclient/+bug/1220590 +.. _1220147: http://bugs.launchpad.net/python-cinderclient/+bug/1220147 +.. _1214176: http://bugs.launchpad.net/python-cinderclient/+bug/1214176 +.. _1210874: http://bugs.launchpad.net/python-cinderclient/+bug/1210874 +.. _1210296: http://bugs.launchpad.net/python-cinderclient/+bug/1210296 +.. _1210292: http://bugs.launchpad.net/python-cinderclient/+bug/1210292 +.. _1207635: http://bugs.launchpad.net/python-cinderclient/+bug/1207635 +.. _1207609: http://bugs.launchpad.net/python-cinderclient/+bug/1207609 +.. _1207260: http://bugs.launchpad.net/python-cinderclient/+bug/1207260 +.. _1206968: http://bugs.launchpad.net/python-cinderclient/+bug/1206968 +.. _1203471: http://bugs.launchpad.net/python-cinderclient/+bug/1203471 +.. _1200214: http://bugs.launchpad.net/python-cinderclient/+bug/1200214 +.. _1195014: http://bugs.launchpad.net/python-cinderclient/+bug/1195014 + +1.0.5 +----- +* Add CLI man page +* Add Availability Zone list command +* Add support for scheduler-hints +* Add support to extend volumes +* Add support to reset state on volumes and snapshots +* Add snapshot support for quota class + +.. _1190853: http://bugs.launchpad.net/python-cinderclient/+bug/1190853 +.. _1190731: http://bugs.launchpad.net/python-cinderclient/+bug/1190731 +.. _1169455: http://bugs.launchpad.net/python-cinderclient/+bug/1169455 +.. _1188452: http://bugs.launchpad.net/python-cinderclient/+bug/1188452 +.. _1180393: http://bugs.launchpad.net/python-cinderclient/+bug/1180393 +.. _1182678: http://bugs.launchpad.net/python-cinderclient/+bug/1182678 +.. _1179008: http://bugs.launchpad.net/python-cinderclient/+bug/1179008 +.. _1180059: http://bugs.launchpad.net/python-cinderclient/+bug/1180059 +.. _1170565: http://bugs.launchpad.net/python-cinderclient/+bug/1170565 + +1.0.4 +----- +* Added suport for backup-service commands +.. _1163546: http://bugs.launchpad.net/python-cinderclient/+bug/1163546 +.. _1161857: http://bugs.launchpad.net/python-cinderclient/+bug/1161857 +.. _1160898: http://bugs.launchpad.net/python-cinderclient/+bug/1160898 +.. _1161857: http://bugs.launchpad.net/python-cinderclient/+bug/1161857 +.. _1156994: http://bugs.launchpad.net/python-cinderclient/+bug/1156994 + +1.0.3 ----- * Added support for V2 Cinder API diff --git a/doc/source/man/cinder.rst b/doc/source/man/cinder.rst new file mode 100644 index 0000000..50fb644 --- /dev/null +++ b/doc/source/man/cinder.rst @@ -0,0 +1,58 @@ +============================== +:program:`cinder` CLI man page +============================== + +.. program:: cinder +.. highlight:: bash + + +SYNOPSIS +======== + +:program:`cinder` [options] [command-options] + +:program:`cinder help` + +:program:`cinder help` + + +DESCRIPTION +=========== + +The :program:`cinder` command line utility interacts with OpenStack Block +Storage Service (Cinder). + +In order to use the CLI, you must provide your OpenStack username, password, +project (historically called tenant), and auth endpoint. You can use +configuration options :option:`--os-username`, :option:`--os-password`, +:option:`--os-tenant-name` or :option:`--os-tenant-id`, and +:option:`--os-auth-url` or set corresponding environment variables:: + + export OS_USERNAME=user + export OS_PASSWORD=pass + export OS_TENANT_NAME=myproject + export OS_AUTH_URL=http://auth.example.com:5000/v2.0 + +You can select an API version to use by :option:`--os-volume-api-version` +option or by setting corresponding environment variable:: + + export OS_VOLUME_API_VERSION=2 + + +OPTIONS +======= + +To get a list of available commands and options run:: + + cinder help + +To get usage and options of a command:: + + cinder help + + +BUGS +==== + +Cinder client is hosted in Launchpad so you can view current bugs at +https://bugs.launchpad.net/python-cinderclient/. diff --git a/openstack-common.conf b/openstack-common.conf index 39d114c..4ee0ff3 100644 --- a/openstack-common.conf +++ b/openstack-common.conf @@ -1,7 +1,9 @@ [DEFAULT] # The list of modules to copy from openstack-common -modules=setup,version,strutils +module=apiclient +module=strutils +module=install_venv_common # The base module to hold the copy of openstack.common base=cinderclient diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..361bda1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +pbr>=0.5.21,<1.0 +argparse +PrettyTable>=0.7,<0.8 +requests>=1.1 +simplejson>=2.0.9 +Babel>=1.3 +six>=1.4.1 diff --git a/run_tests.sh b/run_tests.sh index 9b3684f..017f7e2 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -4,17 +4,27 @@ set -eu function usage { echo "Usage: $0 [OPTION]..." - echo "Run python-cinderclient test suite" + echo "Run cinderclient's test suite(s)" echo "" - echo " -V, --virtual-env Always use virtualenv. Install automatically if not present" - echo " -N, --no-virtual-env Don't use virtualenv. Run tests in local environment" - echo " -s, --no-site-packages Isolate the virtualenv from the global Python environment" - echo " -f, --force Force a clean re-build of the virtual environment. Useful when dependencies have been added." - echo " -p, --pep8 Just run pep8" - echo " -P, --no-pep8 Don't run pep8" - echo " -c, --coverage Generate coverage report" - echo " -h, --help Print this usage message" - echo " --hide-elapsed Don't print the elapsed time for each test along with slow test list" + echo " -V, --virtual-env Always use virtualenv. Install automatically if not present" + echo " -N, --no-virtual-env Don't use virtualenv. Run tests in local environment" + echo " -s, --no-site-packages Isolate the virtualenv from the global Python environment" + echo " -r, --recreate-db Recreate the test database (deprecated, as this is now the default)." + echo " -n, --no-recreate-db Don't recreate the test database." + echo " -f, --force Force a clean re-build of the virtual environment. Useful when dependencies have been added." + echo " -u, --update Update the virtual environment with any newer package versions" + echo " -p, --pep8 Just run PEP8 and HACKING compliance check" + echo " -P, --no-pep8 Don't run static code checks" + echo " -c, --coverage Generate coverage report" + echo " -d, --debug Run tests with testtools instead of testr. This allows you to use the debugger." + echo " -h, --help Print this usage message" + echo " --hide-elapsed Don't print the elapsed time for each test along with slow test list" + echo " --virtual-env-path Location of the virtualenv directory" + echo " Default: \$(pwd)" + echo " --virtual-env-name Name of the virtualenv directory" + echo " Default: .venv" + echo " --tools-path Location of the tools directory" + echo " Default: \$(pwd)" echo "" echo "Note: with no options specified, the script will try to run the tests in a virtual environment," echo " If no virtualenv is found, the script will ask if you would like to create one. If you " @@ -22,23 +32,44 @@ function usage { exit } -function process_option { - case "$1" in - -h|--help) usage;; - -V|--virtual-env) always_venv=1; never_venv=0;; - -N|--no-virtual-env) always_venv=0; never_venv=1;; - -s|--no-site-packages) no_site_packages=1;; - -f|--force) force=1;; - -p|--pep8) just_pep8=1;; - -P|--no-pep8) no_pep8=1;; - -c|--coverage) coverage=1;; - -d|--debug) debug=1;; - -*) testropts="$testropts $1";; - *) testrargs="$testrargs $1" - esac +function process_options { + i=1 + while [ $i -le $# ]; do + case "${!i}" in + -h|--help) usage;; + -V|--virtual-env) always_venv=1; never_venv=0;; + -N|--no-virtual-env) always_venv=0; never_venv=1;; + -s|--no-site-packages) no_site_packages=1;; + -r|--recreate-db) recreate_db=1;; + -n|--no-recreate-db) recreate_db=0;; + -f|--force) force=1;; + -u|--update) update=1;; + -p|--pep8) just_pep8=1;; + -P|--no-pep8) no_pep8=1;; + -c|--coverage) coverage=1;; + -d|--debug) debug=1;; + --virtual-env-path) + (( i++ )) + venv_path=${!i} + ;; + --virtual-env-name) + (( i++ )) + venv_dir=${!i} + ;; + --tools-path) + (( i++ )) + tools_path=${!i} + ;; + -*) testropts="$testropts ${!i}";; + *) testrargs="$testrargs ${!i}" + esac + (( i++ )) + done } -venv=.venv +tool_path=${tools_path:-$(pwd)} +venv_path=${venv_path:-$(pwd)} +venv_dir=${venv_name:-.venv} with_venv=tools/with_venv.sh always_venv=0 never_venv=0 @@ -52,24 +83,25 @@ just_pep8=0 no_pep8=0 coverage=0 debug=0 +recreate_db=1 +update=0 LANG=en_US.UTF-8 LANGUAGE=en_US:en LC_ALL=C -for arg in "$@"; do - process_option $arg -done +process_options $@ +# Make our paths available to other scripts we call +export venv_path +export venv_dir +export venv_name +export tools_dir +export venv=${venv_path}/${venv_dir} if [ $no_site_packages -eq 1 ]; then installvenvopts="--no-site-packages" fi -function init_testr { - if [ ! -d .testrepository ]; then - ${wrapper} testr init - fi -} function run_tests { # Cleanup *pyc @@ -79,7 +111,7 @@ function run_tests { if [ "$testropts" = "" ] && [ "$testrargs" = "" ]; then # Default to running all tests if specific test is not # provided. - testrargs="discover ./tests" + testrargs="discover ./cinderclient/tests" fi ${wrapper} python -m testtools.run $testropts $testrargs @@ -90,22 +122,40 @@ function run_tests { fi if [ $coverage -eq 1 ]; then - # Do not test test_coverage_ext when gathering coverage. - if [ "x$testrargs" = "x" ]; then - testrargs="^(?!.*test_coverage_ext).*$" - fi - export PYTHON="${wrapper} coverage run --source cinderclient --parallel-mode" + TESTRTESTS="$TESTRTESTS --coverage" + else + TESTRTESTS="$TESTRTESTS" fi + # Just run the test suites in current environment set +e - TESTRTESTS="$TESTRTESTS $testrargs" + testrargs=`echo "$testrargs" | sed -e's/^\s*\(.*\)\s*$/\1/'` + TESTRTESTS="$TESTRTESTS --testr-args='--subunit $testropts $testrargs'" + if [ setup.cfg -nt cinderclient.egg-info/entry_points.txt ] + then + ${wrapper} python setup.py egg_info + fi echo "Running \`${wrapper} $TESTRTESTS\`" - ${wrapper} $TESTRTESTS + if ${wrapper} which subunit-2to1 2>&1 > /dev/null + then + # subunit-2to1 is present, testr subunit stream should be in version 2 + # format. Convert to version one before colorizing. + bash -c "${wrapper} $TESTRTESTS | ${wrapper} subunit-2to1 | ${wrapper} tools/colorizer.py" + else + bash -c "${wrapper} $TESTRTESTS | ${wrapper} tools/colorizer.py" + fi RESULT=$? set -e copy_subunit_log + if [ $coverage -eq 1 ]; then + echo "Generating coverage report in covhtml/" + # Don't compute coverage for common code, which is tested elsewhere + ${wrapper} coverage combine + ${wrapper} coverage html --include='cinderclient/*' --omit='cinderclient/openstack/common/*' -d covhtml -i + fi + return $RESULT } @@ -117,29 +167,12 @@ function copy_subunit_log { } function run_pep8 { - echo "Running pep8 ..." - srcfiles="cinderclient tests" - # Just run PEP8 in current environment - # - # NOTE(sirp): W602 (deprecated 3-arg raise) is being ignored for the - # following reasons: - # - # 1. It's needed to preserve traceback information when re-raising - # exceptions; this is needed b/c Eventlet will clear exceptions when - # switching contexts. - # - # 2. There doesn't appear to be an alternative, "pep8-tool" compatible way of doing this - # in Python 2 (in Python 3 `with_traceback` could be used). - # - # 3. Can find no corroborating evidence that this is deprecated in Python 2 - # other than what the PEP8 tool claims. It is deprecated in Python 3, so, - # perhaps the mistake was thinking that the deprecation applied to Python 2 - # as well. - pep8_opts="--ignore=E202,W602 --repeat" - ${wrapper} pep8 ${pep8_opts} ${srcfiles} + echo "Running flake8 ..." + bash -c "${wrapper} flake8" } -TESTRTESTS="testr run --parallel $testropts" + +TESTRTESTS="python setup.py testr" if [ $never_venv -eq 0 ] then @@ -148,6 +181,10 @@ then echo "Cleaning virtualenv..." rm -rf ${venv} fi + if [ $update -eq 1 ]; then + echo "Updating virtualenv..." + python tools/install_venv.py $installvenvopts + fi if [ -e ${venv} ]; then wrapper="${with_venv}" else @@ -177,19 +214,18 @@ if [ $just_pep8 -eq 1 ]; then exit fi -init_testr +if [ $recreate_db -eq 1 ]; then + rm -f tests.sqlite +fi + run_tests # NOTE(sirp): we only want to run pep8 when we're running the full-test suite, -# not when we're running tests individually. +# not when we're running tests individually. To handle this, we need to +# distinguish between options (testropts), which begin with a '-', and +# arguments (testrargs). if [ -z "$testrargs" ]; then if [ $no_pep8 -eq 0 ]; then run_pep8 fi fi - -if [ $coverage -eq 1 ]; then - echo "Generating coverage report in covhtml/" - ${wrapper} coverage combine - ${wrapper} coverage html --include='cinderclient/*' --omit='cinderclient/openstack/common/*' -d covhtml -i -fi diff --git a/setup.cfg b/setup.cfg index 6d6fe64..f332186 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,3 +1,36 @@ +[metadata] +name = python-cinderclient +summary = OpenStack Block Storage API Client Library +description-file = + README.rst +author = OpenStack +author-email = openstack-dev@lists.openstack.org +home-page = http://www.openstack.org/ +classifier = + Development Status :: 5 - Production/Stable + Environment :: Console + Environment :: OpenStack + Intended Audience :: Information Technology + Intended Audience :: System Administrators + License :: OSI Approved :: Apache Software License + Operating System :: POSIX :: Linux + Programming Language :: Python + Programming Language :: Python :: 2 + Programming Language :: Python :: 2.7 + Programming Language :: Python :: 2.6 + +[global] +setup-hooks = + pbr.hooks.setup_hook + +[files] +packages = + cinderclient + +[entry_points] +console_scripts = + cinder = cinderclient.shell:main + [build_sphinx] all_files = 1 source-dir = doc/source diff --git a/setup.py b/setup.py index a2b0693..70c2b3f 100644 --- a/setup.py +++ b/setup.py @@ -1,60 +1,22 @@ -# Copyright 2011 OpenStack, LLC +#!/usr/bin/env python +# Copyright (c) 2013 Hewlett-Packard Development Company, L.P. # # 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 +# 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. +# 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 os +# THIS FILE IS MANAGED BY THE GLOBAL REQUIREMENTS REPO - DO NOT EDIT import setuptools -import sys - - -from cinderclient.openstack.common import setup - -requires = setup.parse_requirements() -depend_links = setup.parse_dependency_links() -tests_require = setup.parse_requirements(['tools/test-requires']) -project = 'python-cinderclient' - - -def read_file(file_name): - return open(os.path.join(os.path.dirname(__file__), file_name)).read() - setuptools.setup( - name=project, - version=setup.get_version(project), - author="Rackspace, based on work by Jacob Kaplan-Moss", - author_email="github@racklabs.com", - description="Client library for OpenStack Cinder API.", - long_description=read_file("README.rst"), - license="Apache License, Version 2.0", - url="https://github.com/openstack/python-cinderclient", - packages=setuptools.find_packages(exclude=['tests', 'tests.*']), - cmdclass=setup.get_cmdclass(), - install_requires=requires, - tests_require=tests_require, - setup_requires=['setuptools-git>=0.4'], - dependency_links=depend_links, - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Environment :: Console", - "Environment :: OpenStack", - "Intended Audience :: Developers", - "Intended Audience :: Information Technology", - "License :: OSI Approved :: Apache Software License", - "Operating System :: OS Independent", - "Programming Language :: Python" - ], - entry_points={ - "console_scripts": ["cinder = cinderclient.shell:main"] - } -) + setup_requires=['pbr'], + pbr=True) diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..0c60d32 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,10 @@ +# Hacking already pins down pep8, pyflakes and flake8 +hacking>=0.8.0,<0.9 +coverage>=3.6 +discover +fixtures>=0.3.14 +mock>=1.0 +python-subunit +sphinx>=1.1.2,<1.2 +testtools>=0.9.32 +testrepository>=0.0.17 diff --git a/tests/test_service_catalog.py b/tests/test_service_catalog.py deleted file mode 100644 index 8dabd99..0000000 --- a/tests/test_service_catalog.py +++ /dev/null @@ -1,127 +0,0 @@ -from cinderclient import exceptions -from cinderclient import service_catalog -from tests import utils - - -# Taken directly from keystone/content/common/samples/auth.json -# Do not edit this structure. Instead, grab the latest from there. - -SERVICE_CATALOG = { - "access": { - "token": { - "id": "ab48a9efdfedb23ty3494", - "expires": "2010-11-01T03:32:15-05:00", - "tenant": { - "id": "345", - "name": "My Project" - } - }, - "user": { - "id": "123", - "name": "jqsmith", - "roles": [ - { - "id": "234", - "name": "compute:admin", - }, - { - "id": "235", - "name": "object-store:admin", - "tenantId": "1", - } - ], - "roles_links": [], - }, - "serviceCatalog": [ - { - "name": "Cloud Servers", - "type": "compute", - "endpoints": [ - { - "tenantId": "1", - "publicURL": "https://compute1.host/v1/1234", - "internalURL": "https://compute1.host/v1/1234", - "region": "North", - "versionId": "1.0", - "versionInfo": "https://compute1.host/v1/", - "versionList": "https://compute1.host/" - }, - { - "tenantId": "2", - "publicURL": "https://compute1.host/v1/3456", - "internalURL": "https://compute1.host/v1/3456", - "region": "North", - "versionId": "1.1", - "versionInfo": "https://compute1.host/v1/", - "versionList": "https://compute1.host/" - }, - ], - "endpoints_links": [], - }, - { - "name": "Nova Volumes", - "type": "volume", - "endpoints": [ - { - "tenantId": "1", - "publicURL": "https://volume1.host/v1/1234", - "internalURL": "https://volume1.host/v1/1234", - "region": "South", - "versionId": "1.0", - "versionInfo": "uri", - "versionList": "uri" - }, - { - "tenantId": "2", - "publicURL": "https://volume1.host/v1/3456", - "internalURL": "https://volume1.host/v1/3456", - "region": "South", - "versionId": "1.1", - "versionInfo": "https://volume1.host/v1/", - "versionList": "https://volume1.host/" - }, - ], - "endpoints_links": [ - { - "rel": "next", - "href": "https://identity1.host/v2.0/endpoints" - }, - ], - }, - ], - "serviceCatalog_links": [ - { - "rel": "next", - "href": "https://identity.host/v2.0/endpoints?session=2hfh8Ar", - }, - ], - }, -} - - -class ServiceCatalogTest(utils.TestCase): - def test_building_a_service_catalog(self): - sc = service_catalog.ServiceCatalog(SERVICE_CATALOG) - - self.assertRaises(exceptions.AmbiguousEndpoints, sc.url_for, - service_type='compute') - self.assertEquals(sc.url_for('tenantId', '1', service_type='compute'), - "https://compute1.host/v1/1234") - self.assertEquals(sc.url_for('tenantId', '2', service_type='compute'), - "https://compute1.host/v1/3456") - - self.assertRaises(exceptions.EndpointNotFound, sc.url_for, - "region", "South", service_type='compute') - - def test_alternate_service_type(self): - sc = service_catalog.ServiceCatalog(SERVICE_CATALOG) - - self.assertRaises(exceptions.AmbiguousEndpoints, sc.url_for, - service_type='volume') - self.assertEquals(sc.url_for('tenantId', '1', service_type='volume'), - "https://volume1.host/v1/1234") - self.assertEquals(sc.url_for('tenantId', '2', service_type='volume'), - "https://volume1.host/v1/3456") - - self.assertRaises(exceptions.EndpointNotFound, sc.url_for, - "region", "North", service_type='volume') diff --git a/tests/v1/contrib/test_list_extensions.py b/tests/v1/contrib/test_list_extensions.py deleted file mode 100644 index faf10bb..0000000 --- a/tests/v1/contrib/test_list_extensions.py +++ /dev/null @@ -1,21 +0,0 @@ -from cinderclient import extension -from cinderclient.v1.contrib import list_extensions - -from tests import utils -from tests.v1 import fakes - - -extensions = [ - extension.Extension(list_extensions.__name__.split(".")[-1], - list_extensions), -] -cs = fakes.FakeClient(extensions=extensions) - - -class ListExtensionsTests(utils.TestCase): - def test_list_extensions(self): - all_exts = cs.list_extensions.show_all() - cs.assert_called('GET', '/extensions') - self.assertTrue(len(all_exts) > 0) - for r in all_exts: - self.assertTrue(len(r.summary) > 0) diff --git a/tests/v1/fakes.py b/tests/v1/fakes.py deleted file mode 100644 index ed0a640..0000000 --- a/tests/v1/fakes.py +++ /dev/null @@ -1,315 +0,0 @@ -# Copyright (c) 2011 X.commerce, a business unit of eBay Inc. -# Copyright 2011 OpenStack, LLC -# -# 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 urlparse - -from cinderclient import client as base_client -from cinderclient.v1 import client -from tests import fakes -import tests.utils as utils - - -def _stub_volume(**kwargs): - volume = { - 'id': '1234', - 'display_name': None, - 'display_description': None, - "attachments": [], - "bootable": "false", - "availability_zone": "cinder", - "created_at": "2012-08-27T00:00:00.000000", - "display_description": None, - "display_name": None, - "id": '00000000-0000-0000-0000-000000000000', - "metadata": {}, - "size": 1, - "snapshot_id": None, - "status": "available", - "volume_type": "None", - } - volume.update(kwargs) - return volume - - -def _stub_snapshot(**kwargs): - snapshot = { - "created_at": "2012-08-28T16:30:31.000000", - "display_description": None, - "display_name": None, - "id": '11111111-1111-1111-1111-111111111111', - "size": 1, - "status": "available", - "volume_id": '00000000-0000-0000-0000-000000000000', - } - snapshot.update(kwargs) - return snapshot - - -class FakeClient(fakes.FakeClient, client.Client): - - def __init__(self, *args, **kwargs): - client.Client.__init__(self, 'username', 'password', - 'project_id', 'auth_url', - extensions=kwargs.get('extensions')) - self.client = FakeHTTPClient(**kwargs) - - -class FakeHTTPClient(base_client.HTTPClient): - - def __init__(self, **kwargs): - self.username = 'username' - self.password = 'password' - self.auth_url = 'auth_url' - self.callstack = [] - - def _cs_request(self, url, method, **kwargs): - # Check that certain things are called correctly - if method in ['GET', 'DELETE']: - assert 'body' not in kwargs - elif method == 'PUT': - assert 'body' in kwargs - - # Call the method - args = urlparse.parse_qsl(urlparse.urlparse(url)[4]) - kwargs.update(args) - munged_url = url.rsplit('?', 1)[0] - munged_url = munged_url.strip('/').replace('/', '_').replace('.', '_') - munged_url = munged_url.replace('-', '_') - - callback = "%s_%s" % (method.lower(), munged_url) - - if not hasattr(self, callback): - raise AssertionError('Called unknown API method: %s %s, ' - 'expected fakes method name: %s' % - (method, url, callback)) - - # Note the call - self.callstack.append((method, url, kwargs.get('body', None))) - status, headers, body = getattr(self, callback)(**kwargs) - r = utils.TestResponse({ - "status_code": status, - "text": body, - "headers": headers, - }) - return r, body - - if hasattr(status, 'items'): - return utils.TestResponse(status), body - else: - return utils.TestResponse({"status": status}), body - - # - # Snapshots - # - - def get_snapshots_detail(self, **kw): - return (200, {}, {'snapshots': [ - _stub_snapshot(), - ]}) - - def get_snapshots_1234(self, **kw): - return (200, {}, {'snapshot': _stub_snapshot(id='1234')}) - - def put_snapshots_1234(self, **kw): - snapshot = _stub_snapshot(id='1234') - snapshot.update(kw['body']['snapshot']) - return (200, {}, {'snapshot': snapshot}) - - # - # Volumes - # - - def put_volumes_1234(self, **kw): - volume = _stub_volume(id='1234') - volume.update(kw['body']['volume']) - return (200, {}, {'volume': volume}) - - def get_volumes(self, **kw): - return (200, {}, {"volumes": [ - {'id': 1234, 'name': 'sample-volume'}, - {'id': 5678, 'name': 'sample-volume2'} - ]}) - - # TODO(jdg): This will need to change - # at the very least it's not complete - def get_volumes_detail(self, **kw): - return (200, {}, {"volumes": [ - {'id': 1234, - 'name': 'sample-volume', - 'attachments': [{'server_id': 1234}]}, - ]}) - - def get_volumes_1234(self, **kw): - r = {'volume': self.get_volumes_detail()[2]['volumes'][0]} - return (200, {}, r) - - def post_volumes_1234_action(self, body, **kw): - _body = None - resp = 202 - assert len(body.keys()) == 1 - action = body.keys()[0] - if action == 'os-attach': - assert body[action].keys() == ['instance_uuid', 'mountpoint'] - elif action == 'os-detach': - assert body[action] is None - elif action == 'os-reserve': - assert body[action] is None - elif action == 'os-unreserve': - assert body[action] is None - elif action == 'os-initialize_connection': - assert body[action].keys() == ['connector'] - return (202, {}, {'connection_info': 'foos'}) - elif action == 'os-terminate_connection': - assert body[action].keys() == ['connector'] - elif action == 'os-begin_detaching': - assert body[action] is None - elif action == 'os-roll_detaching': - assert body[action] is None - else: - raise AssertionError("Unexpected server action: %s" % action) - return (resp, {}, _body) - - def post_volumes(self, **kw): - return (202, {}, {'volume': {}}) - - def delete_volumes_1234(self, **kw): - return (202, {}, None) - - # - # Quotas - # - - def get_os_quota_sets_test(self, **kw): - return (200, {}, {'quota_set': { - 'tenant_id': 'test', - 'metadata_items': [], - 'volumes': 1, - 'snapshots': 1, - 'gigabytes': 1}}) - - def get_os_quota_sets_test_defaults(self): - return (200, {}, {'quota_set': { - 'tenant_id': 'test', - 'metadata_items': [], - 'volumes': 1, - 'snapshots': 1, - 'gigabytes': 1}}) - - def put_os_quota_sets_test(self, body, **kw): - assert body.keys() == ['quota_set'] - fakes.assert_has_keys(body['quota_set'], - required=['tenant_id']) - return (200, {}, {'quota_set': { - 'tenant_id': 'test', - 'metadata_items': [], - 'volumes': 2, - 'snapshots': 2, - 'gigabytes': 1}}) - - # - # Quota Classes - # - - def get_os_quota_class_sets_test(self, **kw): - return (200, {}, {'quota_class_set': { - 'class_name': 'test', - 'metadata_items': [], - 'volumes': 1, - 'snapshots': 1, - 'gigabytes': 1}}) - - def put_os_quota_class_sets_test(self, body, **kw): - assert body.keys() == ['quota_class_set'] - fakes.assert_has_keys(body['quota_class_set'], - required=['class_name']) - return (200, {}, {'quota_class_set': { - 'class_name': 'test', - 'metadata_items': [], - 'volumes': 2, - 'snapshots': 2, - 'gigabytes': 1}}) - - # - # VolumeTypes - # - def get_types(self, **kw): - return (200, {}, { - 'volume_types': [{'id': 1, - 'name': 'test-type-1', - 'extra_specs':{}}, - {'id': 2, - 'name': 'test-type-2', - 'extra_specs':{}}]}) - - def get_types_1(self, **kw): - return (200, {}, {'volume_type': {'id': 1, - 'name': 'test-type-1', - 'extra_specs': {}}}) - - def post_types(self, body, **kw): - return (202, {}, {'volume_type': {'id': 3, - 'name': 'test-type-3', - 'extra_specs': {}}}) - - def post_types_1_extra_specs(self, body, **kw): - assert body.keys() == ['extra_specs'] - return (200, {}, {'extra_specs': {'k': 'v'}}) - - def delete_types_1_extra_specs_k(self, **kw): - return(204, {}, None) - - def delete_types_1(self, **kw): - return (202, {}, None) - - # - # Set/Unset metadata - # - def delete_volumes_1234_metadata_test_key(self, **kw): - return (204, {}, None) - - def delete_volumes_1234_metadata_key1(self, **kw): - return (204, {}, None) - - def delete_volumes_1234_metadata_key2(self, **kw): - return (204, {}, None) - - def post_volumes_1234_metadata(self, **kw): - return (204, {}, {'metadata': {'test_key': 'test_value'}}) - - # - # List all extensions - # - def get_extensions(self, **kw): - exts = [ - { - "alias": "FAKE-1", - "description": "Fake extension number 1", - "links": [], - "name": "Fake1", - "namespace": ("http://docs.openstack.org/" - "/ext/fake1/api/v1.1"), - "updated": "2011-06-09T00:00:00+00:00" - }, - { - "alias": "FAKE-2", - "description": "Fake extension number 2", - "links": [], - "name": "Fake2", - "namespace": ("http://docs.openstack.org/" - "/ext/fake1/api/v1.1"), - "updated": "2011-06-09T00:00:00+00:00" - }, - ] - return (200, {}, {"extensions": exts, }) diff --git a/tests/v1/test_shell.py b/tests/v1/test_shell.py deleted file mode 100644 index 8d28309..0000000 --- a/tests/v1/test_shell.py +++ /dev/null @@ -1,183 +0,0 @@ -# Copyright 2010 Jacob Kaplan-Moss - -# Copyright 2011 OpenStack LLC. -# All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. - -import os - -import fixtures - -from cinderclient import client -from cinderclient import shell -from cinderclient.v1 import shell as shell_v1 -from tests.v1 import fakes -from tests import utils - - -class ShellTest(utils.TestCase): - - FAKE_ENV = { - 'CINDER_USERNAME': 'username', - 'CINDER_PASSWORD': 'password', - 'CINDER_PROJECT_ID': 'project_id', - 'OS_VOLUME_API_VERSION': '1.1', - 'CINDER_URL': 'http://no.where', - } - - # Patch os.environ to avoid required auth info. - def setUp(self): - """Run before each test.""" - super(ShellTest, self).setUp() - for var in self.FAKE_ENV: - self.useFixture(fixtures.EnvironmentVariable(var, - self.FAKE_ENV[var])) - - self.shell = shell.OpenStackCinderShell() - - #HACK(bcwaldon): replace this when we start using stubs - self.old_get_client_class = client.get_client_class - client.get_client_class = lambda *_: fakes.FakeClient - - def tearDown(self): - # For some method like test_image_meta_bad_action we are - # testing a SystemExit to be thrown and object self.shell has - # no time to get instantatiated which is OK in this case, so - # we make sure the method is there before launching it. - if hasattr(self.shell, 'cs'): - self.shell.cs.clear_callstack() - - #HACK(bcwaldon): replace this when we start using stubs - client.get_client_class = self.old_get_client_class - super(ShellTest, self).tearDown() - - 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_anytime(self, method, url, body=None): - return self.shell.cs.assert_called_anytime(method, url, body) - - def test_extract_metadata(self): - # mimic the result of argparse's parse_args() method - class Arguments: - def __init__(self, metadata=[]): - self.metadata = metadata - - inputs = [ - ([], {}), - (["key=value"], {"key": "value"}), - (["key"], {"key": None}), - (["k1=v1", "k2=v2"], {"k1": "v1", "k2": "v2"}), - (["k1=v1", "k2"], {"k1": "v1", "k2": None}), - (["k1", "k2=v2"], {"k1": None, "k2": "v2"}) - ] - - for input in inputs: - args = Arguments(metadata=input[0]) - self.assertEquals(shell_v1._extract_metadata(args), input[1]) - - def test_list(self): - self.run_command('list') - # NOTE(jdg): we default to detail currently - self.assert_called('GET', '/volumes/detail') - - def test_list_filter_status(self): - self.run_command('list --status=available') - self.assert_called('GET', '/volumes/detail?status=available') - - def test_list_filter_display_name(self): - self.run_command('list --display-name=1234') - self.assert_called('GET', '/volumes/detail?display_name=1234') - - def test_list_all_tenants(self): - self.run_command('list --all-tenants=1') - self.assert_called('GET', '/volumes/detail?all_tenants=1') - - def test_show(self): - self.run_command('show 1234') - self.assert_called('GET', '/volumes/1234') - - def test_delete(self): - self.run_command('delete 1234') - self.assert_called('DELETE', '/volumes/1234') - - def test_snapshot_list_filter_volume_id(self): - self.run_command('snapshot-list --volume-id=1234') - self.assert_called('GET', '/snapshots/detail?volume_id=1234') - - def test_snapshot_list_filter_status_and_volume_id(self): - self.run_command('snapshot-list --status=available --volume-id=1234') - self.assert_called('GET', '/snapshots/detail?' - 'status=available&volume_id=1234') - - def test_rename(self): - # basic rename with positional agruments - self.run_command('rename 1234 new-name') - expected = {'volume': {'display_name': 'new-name'}} - self.assert_called('PUT', '/volumes/1234', body=expected) - # change description only - self.run_command('rename 1234 --display-description=new-description') - expected = {'volume': {'display_description': 'new-description'}} - self.assert_called('PUT', '/volumes/1234', body=expected) - # rename and change description - self.run_command('rename 1234 new-name ' - '--display-description=new-description') - expected = {'volume': { - 'display_name': 'new-name', - 'display_description': 'new-description', - }} - self.assert_called('PUT', '/volumes/1234', body=expected) - # noop, the only all will be the lookup - self.run_command('rename 1234') - self.assert_called('GET', '/volumes/1234') - - def test_rename_snapshot(self): - # basic rename with positional agruments - self.run_command('snapshot-rename 1234 new-name') - expected = {'snapshot': {'display_name': 'new-name'}} - self.assert_called('PUT', '/snapshots/1234', body=expected) - # change description only - self.run_command('snapshot-rename 1234 ' - '--display-description=new-description') - expected = {'snapshot': {'display_description': 'new-description'}} - self.assert_called('PUT', '/snapshots/1234', body=expected) - # snapshot-rename and change description - self.run_command('snapshot-rename 1234 new-name ' - '--display-description=new-description') - expected = {'snapshot': { - 'display_name': 'new-name', - 'display_description': 'new-description', - }} - self.assert_called('PUT', '/snapshots/1234', body=expected) - # noop, the only all will be the lookup - self.run_command('snapshot-rename 1234') - self.assert_called('GET', '/snapshots/1234') - - def test_set_metadata_set(self): - self.run_command('metadata 1234 set key1=val1 key2=val2') - self.assert_called('POST', '/volumes/1234/metadata', - {'metadata': {'key1': 'val1', 'key2': 'val2'}}) - - def test_set_metadata_delete_dict(self): - self.run_command('metadata 1234 unset key1=val1 key2=val2') - self.assert_called('DELETE', '/volumes/1234/metadata/key1') - self.assert_called('DELETE', '/volumes/1234/metadata/key2', pos=-2) - - def test_set_metadata_delete_keys(self): - self.run_command('metadata 1234 unset key1 key2') - self.assert_called('DELETE', '/volumes/1234/metadata/key1') - self.assert_called('DELETE', '/volumes/1234/metadata/key2', pos=-2) diff --git a/tests/v2/__init__.py b/tests/v2/__init__.py deleted file mode 100644 index 0cd9c14..0000000 --- a/tests/v2/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -# Copyright (c) 2013 OpenStack, LLC. -# -# All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. diff --git a/tests/v2/fakes.py b/tests/v2/fakes.py deleted file mode 100644 index af11af7..0000000 --- a/tests/v2/fakes.py +++ /dev/null @@ -1,322 +0,0 @@ -# Copyright 2013 OpenStack, LLC -# -# 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 urlparse - -from cinderclient import client as base_client -from cinderclient.v2 import client -from tests import fakes -import tests.utils as utils - - -def _stub_volume(**kwargs): - volume = { - 'id': '1234', - 'name': None, - 'description': None, - "attachments": [], - "bootable": "false", - "availability_zone": "cinder", - "created_at": "2012-08-27T00:00:00.000000", - "id": '00000000-0000-0000-0000-000000000000', - "metadata": {}, - "size": 1, - "snapshot_id": None, - "status": "available", - "volume_type": "None", - "links": [ - { - "href": "http://localhost/v2/fake/volumes/1234", - "rel": "self" - }, - { - "href": "http://localhost/fake/volumes/1234", - "rel": "bookmark" - } - ], - } - volume.update(kwargs) - return volume - - -def _stub_snapshot(**kwargs): - snapshot = { - "created_at": "2012-08-28T16:30:31.000000", - "display_description": None, - "display_name": None, - "id": '11111111-1111-1111-1111-111111111111', - "size": 1, - "status": "available", - "volume_id": '00000000-0000-0000-0000-000000000000', - } - snapshot.update(kwargs) - return snapshot - - -class FakeClient(fakes.FakeClient, client.Client): - - def __init__(self, *args, **kwargs): - client.Client.__init__(self, 'username', 'password', - 'project_id', 'auth_url', - extensions=kwargs.get('extensions')) - self.client = FakeHTTPClient(**kwargs) - - -class FakeHTTPClient(base_client.HTTPClient): - - def __init__(self, **kwargs): - self.username = 'username' - self.password = 'password' - self.auth_url = 'auth_url' - self.callstack = [] - - def _cs_request(self, url, method, **kwargs): - # Check that certain things are called correctly - if method in ['GET', 'DELETE']: - assert 'body' not in kwargs - elif method == 'PUT': - assert 'body' in kwargs - - # Call the method - args = urlparse.parse_qsl(urlparse.urlparse(url)[4]) - kwargs.update(args) - munged_url = url.rsplit('?', 1)[0] - munged_url = munged_url.strip('/').replace('/', '_').replace('.', '_') - munged_url = munged_url.replace('-', '_') - - callback = "%s_%s" % (method.lower(), munged_url) - - if not hasattr(self, callback): - raise AssertionError('Called unknown API method: %s %s, ' - 'expected fakes method name: %s' % - (method, url, callback)) - - # Note the call - self.callstack.append((method, url, kwargs.get('body', None))) - status, headers, body = getattr(self, callback)(**kwargs) - r = utils.TestResponse({ - "status_code": status, - "text": body, - "headers": headers, - }) - return r, body - - if hasattr(status, 'items'): - return utils.TestResponse(status), body - else: - return utils.TestResponse({"status": status}), body - - # - # Snapshots - # - - def get_snapshots_detail(self, **kw): - return (200, {}, {'snapshots': [ - _stub_snapshot(), - ]}) - - def get_snapshots_1234(self, **kw): - return (200, {}, {'snapshot': _stub_snapshot(id='1234')}) - - def put_snapshots_1234(self, **kw): - snapshot = _stub_snapshot(id='1234') - snapshot.update(kw['body']['snapshot']) - return (200, {}, {'snapshot': snapshot}) - - # - # Volumes - # - - def put_volumes_1234(self, **kw): - volume = _stub_volume(id='1234') - volume.update(kw['body']['volume']) - return (200, {}, {'volume': volume}) - - def get_volumes(self, **kw): - return (200, {}, {"volumes": [ - {'id': 1234, 'name': 'sample-volume'}, - {'id': 5678, 'name': 'sample-volume2'} - ]}) - - # TODO(jdg): This will need to change - # at the very least it's not complete - def get_volumes_detail(self, **kw): - return (200, {}, {"volumes": [ - {'id': 1234, - 'name': 'sample-volume', - 'attachments': [{'server_id': 1234}]}, - ]}) - - def get_volumes_1234(self, **kw): - r = {'volume': self.get_volumes_detail()[2]['volumes'][0]} - return (200, {}, r) - - def post_volumes_1234_action(self, body, **kw): - _body = None - resp = 202 - assert len(body.keys()) == 1 - action = body.keys()[0] - if action == 'os-attach': - assert body[action].keys() == ['instance_uuid', 'mountpoint'] - elif action == 'os-detach': - assert body[action] is None - elif action == 'os-reserve': - assert body[action] is None - elif action == 'os-unreserve': - assert body[action] is None - elif action == 'os-initialize_connection': - assert body[action].keys() == ['connector'] - return (202, {}, {'connection_info': 'foos'}) - elif action == 'os-terminate_connection': - assert body[action].keys() == ['connector'] - elif action == 'os-begin_detaching': - assert body[action] is None - elif action == 'os-roll_detaching': - assert body[action] is None - else: - raise AssertionError("Unexpected server action: %s" % action) - return (resp, {}, _body) - - def post_volumes(self, **kw): - return (202, {}, {'volume': {}}) - - def delete_volumes_1234(self, **kw): - return (202, {}, None) - - # - # Quotas - # - - def get_os_quota_sets_test(self, **kw): - return (200, {}, {'quota_set': { - 'tenant_id': 'test', - 'metadata_items': [], - 'volumes': 1, - 'snapshots': 1, - 'gigabytes': 1}}) - - def get_os_quota_sets_test_defaults(self): - return (200, {}, {'quota_set': { - 'tenant_id': 'test', - 'metadata_items': [], - 'volumes': 1, - 'snapshots': 1, - 'gigabytes': 1}}) - - def put_os_quota_sets_test(self, body, **kw): - assert body.keys() == ['quota_set'] - fakes.assert_has_keys(body['quota_set'], - required=['tenant_id']) - return (200, {}, {'quota_set': { - 'tenant_id': 'test', - 'metadata_items': [], - 'volumes': 2, - 'snapshots': 2, - 'gigabytes': 1}}) - - # - # Quota Classes - # - - def get_os_quota_class_sets_test(self, **kw): - return (200, {}, {'quota_class_set': { - 'class_name': 'test', - 'metadata_items': [], - 'volumes': 1, - 'snapshots': 1, - 'gigabytes': 1}}) - - def put_os_quota_class_sets_test(self, body, **kw): - assert body.keys() == ['quota_class_set'] - fakes.assert_has_keys(body['quota_class_set'], - required=['class_name']) - return (200, {}, {'quota_class_set': { - 'class_name': 'test', - 'metadata_items': [], - 'volumes': 2, - 'snapshots': 2, - 'gigabytes': 1}}) - - # - # VolumeTypes - # - def get_types(self, **kw): - return (200, {}, { - 'volume_types': [{'id': 1, - 'name': 'test-type-1', - 'extra_specs':{}}, - {'id': 2, - 'name': 'test-type-2', - 'extra_specs':{}}]}) - - def get_types_1(self, **kw): - return (200, {}, {'volume_type': {'id': 1, - 'name': 'test-type-1', - 'extra_specs': {}}}) - - def post_types(self, body, **kw): - return (202, {}, {'volume_type': {'id': 3, - 'name': 'test-type-3', - 'extra_specs': {}}}) - - def post_types_1_extra_specs(self, body, **kw): - assert body.keys() == ['extra_specs'] - return (200, {}, {'extra_specs': {'k': 'v'}}) - - def delete_types_1_extra_specs_k(self, **kw): - return(204, {}, None) - - def delete_types_1(self, **kw): - return (202, {}, None) - - # - # Set/Unset metadata - # - def delete_volumes_1234_metadata_test_key(self, **kw): - return (204, {}, None) - - def delete_volumes_1234_metadata_key1(self, **kw): - return (204, {}, None) - - def delete_volumes_1234_metadata_key2(self, **kw): - return (204, {}, None) - - def post_volumes_1234_metadata(self, **kw): - return (204, {}, {'metadata': {'test_key': 'test_value'}}) - - # - # List all extensions - # - def get_extensions(self, **kw): - exts = [ - { - "alias": "FAKE-1", - "description": "Fake extension number 1", - "links": [], - "name": "Fake1", - "namespace": ("http://docs.openstack.org/" - "/ext/fake1/api/v1.1"), - "updated": "2011-06-09T00:00:00+00:00" - }, - { - "alias": "FAKE-2", - "description": "Fake extension number 2", - "links": [], - "name": "Fake2", - "namespace": ("http://docs.openstack.org/" - "/ext/fake1/api/v1.1"), - "updated": "2011-06-09T00:00:00+00:00" - }, - ] - return (200, {}, {"extensions": exts, }) diff --git a/tests/v2/test_shell.py b/tests/v2/test_shell.py deleted file mode 100644 index ce6646f..0000000 --- a/tests/v2/test_shell.py +++ /dev/null @@ -1,159 +0,0 @@ -# Copyright 2013 OpenStack LLC. -# All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. - -import fixtures - -from cinderclient import client -from cinderclient import shell -from tests import utils -from tests.v2 import fakes - - -class ShellTest(utils.TestCase): - - FAKE_ENV = { - 'CINDER_USERNAME': 'username', - 'CINDER_PASSWORD': 'password', - 'CINDER_PROJECT_ID': 'project_id', - 'OS_VOLUME_API_VERSION': '2', - 'CINDER_URL': 'http://no.where', - } - - # Patch os.environ to avoid required auth info. - def setUp(self): - """Run before each test.""" - super(ShellTest, self).setUp() - for var in self.FAKE_ENV: - self.useFixture(fixtures.EnvironmentVariable(var, - self.FAKE_ENV[var])) - - self.shell = shell.OpenStackCinderShell() - - #HACK(bcwaldon): replace this when we start using stubs - self.old_get_client_class = client.get_client_class - client.get_client_class = lambda *_: fakes.FakeClient - - def tearDown(self): - # For some method like test_image_meta_bad_action we are - # testing a SystemExit to be thrown and object self.shell has - # no time to get instantatiated which is OK in this case, so - # we make sure the method is there before launching it. - if hasattr(self.shell, 'cs'): - self.shell.cs.clear_callstack() - - #HACK(bcwaldon): replace this when we start using stubs - client.get_client_class = self.old_get_client_class - super(ShellTest, self).tearDown() - - 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_anytime(self, method, url, body=None): - return self.shell.cs.assert_called_anytime(method, url, body) - - def test_list(self): - self.run_command('list') - # NOTE(jdg): we default to detail currently - self.assert_called('GET', '/volumes/detail') - - def test_list_filter_status(self): - self.run_command('list --status=available') - self.assert_called('GET', '/volumes/detail?status=available') - - def test_list_filter_name(self): - self.run_command('list --name=1234') - self.assert_called('GET', '/volumes/detail?name=1234') - - def test_list_all_tenants(self): - self.run_command('list --all-tenants=1') - self.assert_called('GET', '/volumes/detail?all_tenants=1') - - def test_show(self): - self.run_command('show 1234') - self.assert_called('GET', '/volumes/1234') - - def test_delete(self): - self.run_command('delete 1234') - self.assert_called('DELETE', '/volumes/1234') - - def test_snapshot_list_filter_volume_id(self): - self.run_command('snapshot-list --volume-id=1234') - self.assert_called('GET', '/snapshots/detail?volume_id=1234') - - def test_snapshot_list_filter_status_and_volume_id(self): - self.run_command('snapshot-list --status=available --volume-id=1234') - self.assert_called('GET', '/snapshots/detail?' - 'status=available&volume_id=1234') - - def test_rename(self): - # basic rename with positional agruments - self.run_command('rename 1234 new-name') - expected = {'volume': {'name': 'new-name'}} - self.assert_called('PUT', '/volumes/1234', body=expected) - # change description only - self.run_command('rename 1234 --description=new-description') - expected = {'volume': {'description': 'new-description'}} - self.assert_called('PUT', '/volumes/1234', body=expected) - # rename and change description - self.run_command('rename 1234 new-name ' - '--description=new-description') - expected = {'volume': { - 'name': 'new-name', - 'description': 'new-description', - }} - self.assert_called('PUT', '/volumes/1234', body=expected) - # noop, the only all will be the lookup - self.run_command('rename 1234') - self.assert_called('GET', '/volumes/1234') - - def test_rename_snapshot(self): - # basic rename with positional agruments - self.run_command('snapshot-rename 1234 new-name') - expected = {'snapshot': {'name': 'new-name'}} - self.assert_called('PUT', '/snapshots/1234', body=expected) - # change description only - self.run_command('snapshot-rename 1234 ' - '--description=new-description') - expected = {'snapshot': {'description': 'new-description'}} - self.assert_called('PUT', '/snapshots/1234', body=expected) - # snapshot-rename and change description - self.run_command('snapshot-rename 1234 new-name ' - '--description=new-description') - expected = {'snapshot': { - 'name': 'new-name', - 'description': 'new-description', - }} - self.assert_called('PUT', '/snapshots/1234', body=expected) - # noop, the only all will be the lookup - self.run_command('snapshot-rename 1234') - self.assert_called('GET', '/snapshots/1234') - - def test_set_metadata_set(self): - self.run_command('metadata 1234 set key1=val1 key2=val2') - self.assert_called('POST', '/volumes/1234/metadata', - {'metadata': {'key1': 'val1', 'key2': 'val2'}}) - - def test_set_metadata_delete_dict(self): - self.run_command('metadata 1234 unset key1=val1 key2=val2') - self.assert_called('DELETE', '/volumes/1234/metadata/key1') - self.assert_called('DELETE', '/volumes/1234/metadata/key2', pos=-2) - - def test_set_metadata_delete_keys(self): - self.run_command('metadata 1234 unset key1 key2') - self.assert_called('DELETE', '/volumes/1234/metadata/key1') - self.assert_called('DELETE', '/volumes/1234/metadata/key2', pos=-2) diff --git a/tools/colorizer.py b/tools/colorizer.py new file mode 100755 index 0000000..2a079fc --- /dev/null +++ b/tools/colorizer.py @@ -0,0 +1,336 @@ +#!/usr/bin/env python +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2013, Nebula, Inc. +# Copyright 2010 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# Colorizer Code is borrowed from Twisted: +# Copyright (c) 2001-2010 Twisted Matrix Laboratories. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""Display a subunit stream through a colorized unittest test runner.""" + +import heapq +import subunit +import sys +import unittest + +import six +import testtools + + +class _AnsiColorizer(object): + """ + A colorizer is an object that loosely wraps around a stream, allowing + callers to write text to the stream in a particular color. + + Colorizer classes must implement C{supported()} and C{write(text, color)}. + """ + _colors = dict(black=30, red=31, green=32, yellow=33, + blue=34, magenta=35, cyan=36, white=37) + + def __init__(self, stream): + self.stream = stream + + def supported(cls, stream=sys.stdout): + """ + A class method that returns True if the current platform supports + coloring terminal output using this method. Returns False otherwise. + """ + if not stream.isatty(): + return False # auto color only on TTYs + try: + import curses + except ImportError: + return False + else: + try: + try: + return curses.tigetnum("colors") > 2 + except curses.error: + curses.setupterm() + return curses.tigetnum("colors") > 2 + except Exception: + # guess false in case of error + return False + supported = classmethod(supported) + + def write(self, text, color): + """ + Write the given text to the stream in the given color. + + @param text: Text to be written to the stream. + + @param color: A string label for a color. e.g. 'red', 'white'. + """ + color = self._colors[color] + self.stream.write('\x1b[%s;1m%s\x1b[0m' % (color, text)) + + +class _Win32Colorizer(object): + """ + See _AnsiColorizer docstring. + """ + def __init__(self, stream): + import win32console + red, green, blue, bold = (win32console.FOREGROUND_RED, + win32console.FOREGROUND_GREEN, + win32console.FOREGROUND_BLUE, + win32console.FOREGROUND_INTENSITY) + self.stream = stream + self.screenBuffer = win32console.GetStdHandle( + win32console.STD_OUT_HANDLE) + self._colors = { + 'normal': red | green | blue, + 'red': red | bold, + 'green': green | bold, + 'blue': blue | bold, + 'yellow': red | green | bold, + 'magenta': red | blue | bold, + 'cyan': green | blue | bold, + 'white': red | green | blue | bold + } + + def supported(cls, stream=sys.stdout): + try: + import win32console + screenBuffer = win32console.GetStdHandle( + win32console.STD_OUT_HANDLE) + except ImportError: + return False + import pywintypes + try: + screenBuffer.SetConsoleTextAttribute( + win32console.FOREGROUND_RED | + win32console.FOREGROUND_GREEN | + win32console.FOREGROUND_BLUE) + except pywintypes.error: + return False + else: + return True + supported = classmethod(supported) + + def write(self, text, color): + color = self._colors[color] + self.screenBuffer.SetConsoleTextAttribute(color) + self.stream.write(text) + self.screenBuffer.SetConsoleTextAttribute(self._colors['normal']) + + +class _NullColorizer(object): + """ + See _AnsiColorizer docstring. + """ + def __init__(self, stream): + self.stream = stream + + def supported(cls, stream=sys.stdout): + return True + supported = classmethod(supported) + + def write(self, text, color): + self.stream.write(text) + + +def get_elapsed_time_color(elapsed_time): + if elapsed_time > 1.0: + return 'red' + elif elapsed_time > 0.25: + return 'yellow' + else: + return 'green' + + +class NovaTestResult(testtools.TestResult): + def __init__(self, stream, descriptions, verbosity): + super(NovaTestResult, self).__init__() + self.stream = stream + self.showAll = verbosity > 1 + self.num_slow_tests = 10 + self.slow_tests = [] # this is a fixed-sized heap + self.colorizer = None + # NOTE(vish): reset stdout for the terminal check + stdout = sys.stdout + sys.stdout = sys.__stdout__ + for colorizer in [_Win32Colorizer, _AnsiColorizer, _NullColorizer]: + if colorizer.supported(): + self.colorizer = colorizer(self.stream) + break + sys.stdout = stdout + self.start_time = None + self.last_time = {} + self.results = {} + self.last_written = None + + def _writeElapsedTime(self, elapsed): + color = get_elapsed_time_color(elapsed) + self.colorizer.write(" %.2f" % elapsed, color) + + def _addResult(self, test, *args): + try: + name = test.id() + except AttributeError: + name = 'Unknown.unknown' + test_class, test_name = name.rsplit('.', 1) + + elapsed = (self._now() - self.start_time).total_seconds() + item = (elapsed, test_class, test_name) + if len(self.slow_tests) >= self.num_slow_tests: + heapq.heappushpop(self.slow_tests, item) + else: + heapq.heappush(self.slow_tests, item) + + self.results.setdefault(test_class, []) + self.results[test_class].append((test_name, elapsed) + args) + self.last_time[test_class] = self._now() + self.writeTests() + + def _writeResult(self, test_name, elapsed, long_result, color, + short_result, success): + if self.showAll: + self.stream.write(' %s' % str(test_name).ljust(66)) + self.colorizer.write(long_result, color) + if success: + self._writeElapsedTime(elapsed) + self.stream.writeln() + else: + self.colorizer.write(short_result, color) + + def addSuccess(self, test): + super(NovaTestResult, self).addSuccess(test) + self._addResult(test, 'OK', 'green', '.', True) + + def addFailure(self, test, err): + if test.id() == 'process-returncode': + return + super(NovaTestResult, self).addFailure(test, err) + self._addResult(test, 'FAIL', 'red', 'F', False) + + def addError(self, test, err): + super(NovaTestResult, self).addFailure(test, err) + self._addResult(test, 'ERROR', 'red', 'E', False) + + def addSkip(self, test, reason=None, details=None): + super(NovaTestResult, self).addSkip(test, reason, details) + self._addResult(test, 'SKIP', 'blue', 'S', True) + + def startTest(self, test): + self.start_time = self._now() + super(NovaTestResult, self).startTest(test) + + def writeTestCase(self, cls): + if not self.results.get(cls): + return + if cls != self.last_written: + self.colorizer.write(cls, 'white') + self.stream.writeln() + for result in self.results[cls]: + self._writeResult(*result) + del self.results[cls] + self.stream.flush() + self.last_written = cls + + def writeTests(self): + time = self.last_time.get(self.last_written, self._now()) + if not self.last_written or (self._now() - time).total_seconds() > 2.0: + diff = 3.0 + while diff > 2.0: + classes =list(self.results) + oldest = min(classes, key=lambda x: self.last_time[x]) + diff = (self._now() - self.last_time[oldest]).total_seconds() + self.writeTestCase(oldest) + else: + self.writeTestCase(self.last_written) + + def done(self): + self.stopTestRun() + + def stopTestRun(self): + for cls in list(six.iterkeys(self.results)): + self.writeTestCase(cls) + self.stream.writeln() + self.writeSlowTests() + + def writeSlowTests(self): + # Pare out 'fast' tests + slow_tests = [item for item in self.slow_tests + if get_elapsed_time_color(item[0]) != 'green'] + if slow_tests: + slow_total_time = sum(item[0] for item in slow_tests) + slow = ("Slowest %i tests took %.2f secs:" + % (len(slow_tests), slow_total_time)) + self.colorizer.write(slow, 'yellow') + self.stream.writeln() + last_cls = None + # sort by name + for elapsed, cls, name in sorted(slow_tests, + key=lambda x: x[1] + x[2]): + if cls != last_cls: + self.colorizer.write(cls, 'white') + self.stream.writeln() + last_cls = cls + self.stream.write(' %s' % str(name).ljust(68)) + self._writeElapsedTime(elapsed) + self.stream.writeln() + + def printErrors(self): + if self.showAll: + self.stream.writeln() + self.printErrorList('ERROR', self.errors) + self.printErrorList('FAIL', self.failures) + + def printErrorList(self, flavor, errors): + for test, err in errors: + self.colorizer.write("=" * 70, 'red') + self.stream.writeln() + self.colorizer.write(flavor, 'red') + self.stream.writeln(": %s" % test.id()) + self.colorizer.write("-" * 70, 'red') + self.stream.writeln() + self.stream.writeln("%s" % err) + + +test = subunit.ProtocolTestCase(sys.stdin, passthrough=None) + +if sys.version_info[0:2] <= (2, 6): + runner = unittest.TextTestRunner(verbosity=2) +else: + runner = unittest.TextTestRunner(verbosity=2, resultclass=NovaTestResult) + +if runner.run(test).wasSuccessful(): + exit_code = 0 +else: + exit_code = 1 +sys.exit(exit_code) diff --git a/tools/install_venv.py b/tools/install_venv.py index db0e32b..0011a8b 100644 --- a/tools/install_venv.py +++ b/tools/install_venv.py @@ -4,242 +4,74 @@ # Administrator of the National Aeronautics and Space Administration. # All Rights Reserved. # -# Copyright 2010 OpenStack, LLC +# Copyright 2010 OpenStack Foundation +# Copyright 2013 IBM Corp. +# Copyright (c) 2013 Hewlett-Packard Development Company, L.P. # -# 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 +# 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 +# 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. +# 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. -""" -Installation script for Nova's development virtualenv -""" - -import optparse +import ConfigParser import os -import subprocess import sys -import platform + +import install_venv_common as install_venv # flake8: noqa -ROOT = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) -VENV = os.path.join(ROOT, '.venv') -PIP_REQUIRES = os.path.join(ROOT, 'tools', 'pip-requires') -TEST_REQUIRES = os.path.join(ROOT, 'tools', 'test-requires') -PY_VERSION = "python%s.%s" % (sys.version_info[0], sys.version_info[1]) - - -def die(message, *args): - print >> sys.stderr, message % args - sys.exit(1) - - -def check_python_version(): - if sys.version_info < (2, 6): - die("Need Python Version >= 2.6") - - -def run_command_with_code(cmd, redirect_output=True, check_exit_code=True): - """ - Runs a command in an out-of-process shell, returning the - output of that command. Working directory is ROOT. - """ - if redirect_output: - stdout = subprocess.PIPE - else: - stdout = None - - proc = subprocess.Popen(cmd, cwd=ROOT, stdout=stdout) - output = proc.communicate()[0] - if check_exit_code and proc.returncode != 0: - die('Command "%s" failed.\n%s', ' '.join(cmd), output) - return (output, proc.returncode) - - -def run_command(cmd, redirect_output=True, check_exit_code=True): - return run_command_with_code(cmd, redirect_output, check_exit_code)[0] - - -class Distro(object): - - def check_cmd(self, cmd): - return bool(run_command(['which', cmd], check_exit_code=False).strip()) - - def install_virtualenv(self): - if self.check_cmd('virtualenv'): - return - - if self.check_cmd('easy_install'): - print 'Installing virtualenv via easy_install...', - if run_command(['easy_install', 'virtualenv']): - print 'Succeeded' - return - else: - print 'Failed' - - die('ERROR: virtualenv not found.\n\nDevelopment' - ' requires virtualenv, please install it using your' - ' favorite package management tool') - - def post_process(self): - """Any distribution-specific post-processing gets done here. - - In particular, this is useful for applying patches to code inside - the venv.""" - pass - - -class Debian(Distro): - """This covers all Debian-based distributions.""" - - def check_pkg(self, pkg): - return run_command_with_code(['dpkg', '-l', pkg], - check_exit_code=False)[1] == 0 - - def apt_install(self, pkg, **kwargs): - run_command(['sudo', 'apt-get', 'install', '-y', pkg], **kwargs) - - def apply_patch(self, originalfile, patchfile): - run_command(['patch', originalfile, patchfile]) - - def install_virtualenv(self): - if self.check_cmd('virtualenv'): - return - - if not self.check_pkg('python-virtualenv'): - self.apt_install('python-virtualenv', check_exit_code=False) - - super(Debian, self).install_virtualenv() - - -class Fedora(Distro): - """This covers all Fedora-based distributions. - - Includes: Fedora, RHEL, CentOS, Scientific Linux""" - - def check_pkg(self, pkg): - return run_command_with_code(['rpm', '-q', pkg], - check_exit_code=False)[1] == 0 - - def yum_install(self, pkg, **kwargs): - run_command(['sudo', 'yum', 'install', '-y', pkg], **kwargs) - - def apply_patch(self, originalfile, patchfile): - run_command(['patch', originalfile, patchfile]) - - def install_virtualenv(self): - if self.check_cmd('virtualenv'): - return - - if not self.check_pkg('python-virtualenv'): - self.yum_install('python-virtualenv', check_exit_code=False) - - super(Fedora, self).install_virtualenv() - - -def get_distro(): - if os.path.exists('/etc/fedora-release') or \ - os.path.exists('/etc/redhat-release'): - return Fedora() - elif os.path.exists('/etc/debian_version'): - return Debian() - else: - return Distro() - - -def check_dependencies(): - get_distro().install_virtualenv() - - -def create_virtualenv(venv=VENV, no_site_packages=True): - """Creates the virtual environment and installs PIP only into the - virtual environment - """ - print 'Creating venv...', - if no_site_packages: - run_command(['virtualenv', '-q', '--no-site-packages', VENV]) - else: - run_command(['virtualenv', '-q', VENV]) - print 'done.' - print 'Installing pip in virtualenv...', - if not run_command(['tools/with_venv.sh', 'easy_install', - 'pip>1.0']).strip(): - die("Failed to install pip.") - print 'done.' - - -def pip_install(*args): - run_command(['tools/with_venv.sh', - 'pip', 'install', '--upgrade'] + list(args), - redirect_output=False) - - -def install_dependencies(venv=VENV): - print 'Installing dependencies with pip (this can take a while)...' - - # First things first, make sure our venv has the latest pip and distribute. - pip_install('pip') - pip_install('distribute') - - pip_install('-r', PIP_REQUIRES) - pip_install('-r', TEST_REQUIRES) - # Tell the virtual env how to "import cinder" - pthfile = os.path.join(venv, "lib", PY_VERSION, "site-packages", - "cinderclient.pth") - f = open(pthfile, 'w') - f.write("%s\n" % ROOT) - - -def post_process(): - get_distro().post_process() - - -def print_help(): +def print_help(project, venv, root): help = """ - python-cinderclient development environment setup is complete. + %(project)s development environment setup is complete. - python-cinderclient development uses virtualenv to track and manage Python + %(project)s development uses virtualenv to track and manage Python dependencies while in development and testing. - To activate the python-cinderclient virtualenv for the extent of your - current shell session you can run: + To activate the %(project)s virtualenv for the extent of your current + shell session you can run: - $ source .venv/bin/activate + $ source %(venv)s/bin/activate - Or, if you prefer, you can run commands in the virtualenv on a case by case - basis by running: + Or, if you prefer, you can run commands in the virtualenv on a case by + case basis by running: - $ tools/with_venv.sh - - Also, make test will automatically use the virtualenv. + $ %(root)s/tools/with_venv.sh """ - print help - - -def parse_args(): - """Parse command-line arguments""" - parser = optparse.OptionParser() - parser.add_option("-n", "--no-site-packages", dest="no_site_packages", - default=False, action="store_true", - help="Do not inherit packages from global Python install") - return parser.parse_args() + print help % dict(project=project, venv=venv, root=root) def main(argv): - (options, args) = parse_args() - check_python_version() - check_dependencies() - create_virtualenv(no_site_packages=options.no_site_packages) - install_dependencies() - post_process() - print_help() + root = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) + + if os.environ.get('tools_path'): + root = os.environ['tools_path'] + venv = os.path.join(root, '.venv') + if os.environ.get('venv'): + venv = os.environ['venv'] + + pip_requires = os.path.join(root, 'requirements.txt') + test_requires = os.path.join(root, 'test-requirements.txt') + py_version = "python%s.%s" % (sys.version_info[0], sys.version_info[1]) + setup_cfg = ConfigParser.ConfigParser() + setup_cfg.read('setup.cfg') + project = setup_cfg.get('metadata', 'name') + + install = install_venv.InstallVenv( + root, venv, pip_requires, test_requires, py_version, project) + options = install.parse_args(argv) + install.check_python_version() + install.check_dependencies() + install.create_virtualenv(no_site_packages=options.no_site_packages) + install.install_dependencies() + install.post_process() + print_help(project, venv, root) if __name__ == '__main__': main(sys.argv) diff --git a/tools/install_venv_common.py b/tools/install_venv_common.py new file mode 100644 index 0000000..46822e3 --- /dev/null +++ b/tools/install_venv_common.py @@ -0,0 +1,172 @@ +# Copyright 2013 OpenStack Foundation +# Copyright 2013 IBM Corp. +# +# 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. + +"""Provides methods needed by installation script for OpenStack development +virtual environments. + +Since this script is used to bootstrap a virtualenv from the system's Python +environment, it should be kept strictly compatible with Python 2.6. + +Synced in from openstack-common +""" + +from __future__ import print_function + +import optparse +import os +import subprocess +import sys + + +class InstallVenv(object): + + def __init__(self, root, venv, requirements, + test_requirements, py_version, + project): + self.root = root + self.venv = venv + self.requirements = requirements + self.test_requirements = test_requirements + self.py_version = py_version + self.project = project + + def die(self, message, *args): + print(message % args, file=sys.stderr) + sys.exit(1) + + def check_python_version(self): + if sys.version_info < (2, 6): + self.die("Need Python Version >= 2.6") + + def run_command_with_code(self, cmd, redirect_output=True, + check_exit_code=True): + """Runs a command in an out-of-process shell. + + Returns the output of that command. Working directory is self.root. + """ + if redirect_output: + stdout = subprocess.PIPE + else: + stdout = None + + proc = subprocess.Popen(cmd, cwd=self.root, stdout=stdout) + output = proc.communicate()[0] + if check_exit_code and proc.returncode != 0: + self.die('Command "%s" failed.\n%s', ' '.join(cmd), output) + return (output, proc.returncode) + + def run_command(self, cmd, redirect_output=True, check_exit_code=True): + return self.run_command_with_code(cmd, redirect_output, + check_exit_code)[0] + + def get_distro(self): + if (os.path.exists('/etc/fedora-release') or + os.path.exists('/etc/redhat-release')): + return Fedora( + self.root, self.venv, self.requirements, + self.test_requirements, self.py_version, self.project) + else: + return Distro( + self.root, self.venv, self.requirements, + self.test_requirements, self.py_version, self.project) + + def check_dependencies(self): + self.get_distro().install_virtualenv() + + def create_virtualenv(self, no_site_packages=True): + """Creates the virtual environment and installs PIP. + + Creates the virtual environment and installs PIP only into the + virtual environment. + """ + if not os.path.isdir(self.venv): + print('Creating venv...', end=' ') + if no_site_packages: + self.run_command(['virtualenv', '-q', '--no-site-packages', + self.venv]) + else: + self.run_command(['virtualenv', '-q', self.venv]) + print('done.') + else: + print("venv already exists...") + pass + + def pip_install(self, *args): + self.run_command(['tools/with_venv.sh', + 'pip', 'install', '--upgrade'] + list(args), + redirect_output=False) + + def install_dependencies(self): + print('Installing dependencies with pip (this can take a while)...') + + # First things first, make sure our venv has the latest pip and + # setuptools and pbr + self.pip_install('pip>=1.4') + self.pip_install('setuptools') + self.pip_install('pbr') + + self.pip_install('-r', self.requirements, '-r', self.test_requirements) + + def parse_args(self, argv): + """Parses command-line arguments.""" + parser = optparse.OptionParser() + parser.add_option('-n', '--no-site-packages', + action='store_true', + help="Do not inherit packages from global Python " + "install") + return parser.parse_args(argv[1:])[0] + + +class Distro(InstallVenv): + + def check_cmd(self, cmd): + return bool(self.run_command(['which', cmd], + check_exit_code=False).strip()) + + def install_virtualenv(self): + if self.check_cmd('virtualenv'): + return + + if self.check_cmd('easy_install'): + print('Installing virtualenv via easy_install...', end=' ') + if self.run_command(['easy_install', 'virtualenv']): + print('Succeeded') + return + else: + print('Failed') + + self.die('ERROR: virtualenv not found.\n\n%s development' + ' requires virtualenv, please install it using your' + ' favorite package management tool' % self.project) + + +class Fedora(Distro): + """This covers all Fedora-based distributions. + + Includes: Fedora, RHEL, CentOS, Scientific Linux + """ + + def check_pkg(self, pkg): + return self.run_command_with_code(['rpm', '-q', pkg], + check_exit_code=False)[1] == 0 + + def install_virtualenv(self): + if self.check_cmd('virtualenv'): + return + + if not self.check_pkg('python-virtualenv'): + self.die("Please install 'python-virtualenv'.") + + super(Fedora, self).install_virtualenv() diff --git a/tools/pip-requires b/tools/pip-requires deleted file mode 100644 index e708630..0000000 --- a/tools/pip-requires +++ /dev/null @@ -1,4 +0,0 @@ -argparse -prettytable -requests>=0.8 -simplejson diff --git a/tools/test-requires b/tools/test-requires deleted file mode 100644 index 4dd5249..0000000 --- a/tools/test-requires +++ /dev/null @@ -1,10 +0,0 @@ -distribute>=0.6.24 - -coverage -discover -fixtures -mock -pep8==1.3.3 -sphinx>=1.1.2 -testrepository>=0.0.13 -testtools>=0.9.22 diff --git a/tox.ini b/tox.ini index 34ca825..fcd7d91 100644 --- a/tox.ini +++ b/tox.ini @@ -1,19 +1,23 @@ [tox] -envlist = py26,py27,pep8 +distribute = False +envlist = py26,py27,py33,pypy,pep8 +minversion = 1.6 +skipsdist = True [testenv] +usedevelop = True +install_command = pip install -U {opts} {packages} setenv = VIRTUAL_ENV={envdir} LANG=en_US.UTF-8 LANGUAGE=en_US:en LC_ALL=C -deps = -r{toxinidir}/tools/pip-requires - -r{toxinidir}/tools/test-requires +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt commands = python setup.py testr --testr-args='{posargs}' [testenv:pep8] -deps = pep8 -commands = pep8 --repeat --show-source cinderclient setup.py +commands = flake8 [testenv:venv] commands = {posargs} @@ -23,3 +27,8 @@ commands = python setup.py testr --coverage --testr-args='{posargs}' [tox:jenkins] downloadcache = ~/cache/pip + +[flake8] +show-source = True +ignore = F811,F821,H302,H306,H404 +exclude=.venv,.git,.tox,dist,doc,*openstack/common*,*lib/python*,*egg,build,tools