diff options
author | Jenkins <jenkins@review.openstack.org> | 2012-05-21 16:32:35 -0400 |
---|---|---|
committer | Monty Taylor <mordred@inaugust.com> | 2012-05-21 16:32:35 -0400 |
commit | 471704df644eced17026c280b0aab9e549718e14 (patch) | |
tree | c2d8d0ec74fa45e0b61ca4b2153fb5b0e7bf490d /cinderclient | |
download | python-cinderclient-0.0.tar.gz |
Initial split from python-novaclient.0.0
Diffstat (limited to 'cinderclient')
-rw-r--r-- | cinderclient/__init__.py | 0 | ||||
-rw-r--r-- | cinderclient/base.py | 293 | ||||
-rw-r--r-- | cinderclient/client.py | 330 | ||||
-rw-r--r-- | cinderclient/exceptions.py | 146 | ||||
-rw-r--r-- | cinderclient/extension.py | 39 | ||||
-rw-r--r-- | cinderclient/service_catalog.py | 77 | ||||
-rw-r--r-- | cinderclient/shell.py | 435 | ||||
-rw-r--r-- | cinderclient/utils.py | 261 | ||||
-rw-r--r-- | cinderclient/v1/__init__.py | 17 | ||||
-rw-r--r-- | cinderclient/v1/client.py | 71 | ||||
-rw-r--r-- | cinderclient/v1/contrib/__init__.py | 0 | ||||
-rw-r--r-- | cinderclient/v1/shell.py | 241 | ||||
-rw-r--r-- | cinderclient/v1/volume_snapshots.py | 88 | ||||
-rw-r--r-- | cinderclient/v1/volume_types.py | 77 | ||||
-rw-r--r-- | cinderclient/v1/volumes.py | 135 |
15 files changed, 2210 insertions, 0 deletions
diff --git a/cinderclient/__init__.py b/cinderclient/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/cinderclient/__init__.py diff --git a/cinderclient/base.py b/cinderclient/base.py new file mode 100644 index 0000000..02d3549 --- /dev/null +++ b/cinderclient/base.py @@ -0,0 +1,293 @@ +# 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. + +""" +Base utilities to build API operation managers and objects on top of. +""" + +import contextlib +import hashlib +import os +from cinderclient import exceptions +from cinderclient import utils + + +# Python 2.4 compat +try: + all +except NameError: + def all(iterable): + return True not in (not x for x in iterable) + + +def getid(obj): + """ + Abstracts the common pattern of allowing both an object or an object's ID + as a parameter when dealing with relationships. + """ + try: + return obj.id + except AttributeError: + return obj + + +class Manager(utils.HookableMixin): + """ + Managers interact with a particular type of API (servers, flavors, images, + etc.) and provide CRUD operations for them. + """ + resource_class = None + + def __init__(self, api): + self.api = api + + def _list(self, url, response_key, obj_class=None, body=None): + resp = None + if body: + resp, body = self.api.client.post(url, body=body) + else: + resp, body = self.api.client.get(url) + + 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... + if isinstance(data, dict): + try: + data = data['values'] + except KeyError: + pass + + with self.completion_cache('human_id', obj_class, mode="w"): + with self.completion_cache('uuid', obj_class, mode="w"): + return [obj_class(self, res, loaded=True) + for res in data if res] + + @contextlib.contextmanager + def completion_cache(self, cache_type, obj_class, mode): + """ + The completion cache store items that can be used for bash + autocompletion, like UUIDs or human-friendly IDs. + + A resource listing will clear and repopulate the cache. + + A resource create will append to the cache. + + Delete is not handled because listings are assumed to be performed + often enough to keep the cache reasonably up-to-date. + """ + base_dir = utils.env('CINDERCLIENT_UUID_CACHE_DIR', + default="~/.cinderclient") + + # NOTE(sirp): Keep separate UUID caches for each username + endpoint + # pair + username = utils.env('OS_USERNAME', 'CINDER_USERNAME') + url = utils.env('OS_URL', 'CINDER_URL') + uniqifier = hashlib.md5(username + url).hexdigest() + + cache_dir = os.path.expanduser(os.path.join(base_dir, uniqifier)) + + try: + os.makedirs(cache_dir, 0755) + except OSError: + # NOTE(kiall): This is typicaly either permission denied while + # attempting to create the directory, or the directory + # already exists. Either way, don't fail. + pass + + resource = obj_class.__name__.lower() + filename = "%s-%s-cache" % (resource, cache_type.replace('_', '-')) + path = os.path.join(cache_dir, filename) + + cache_attr = "_%s_cache" % cache_type + + try: + setattr(self, cache_attr, open(path, mode)) + except IOError: + # NOTE(kiall): This is typicaly a permission denied while + # attempting to write the cache file. + pass + + try: + yield + finally: + cache = getattr(self, cache_attr, None) + if cache: + cache.close() + delattr(self, cache_attr) + + def write_to_completion_cache(self, cache_type, val): + cache = getattr(self, "_%s_cache" % cache_type, None) + if cache: + cache.write("%s\n" % val) + + def _get(self, url, response_key=None): + resp, body = self.api.client.get(url) + if response_key: + return self.resource_class(self, body[response_key], loaded=True) + else: + return self.resource_class(self, body, loaded=True) + + def _create(self, url, body, response_key, return_raw=False, **kwargs): + self.run_hooks('modify_body_for_create', body, **kwargs) + resp, body = self.api.client.post(url, body=body) + if return_raw: + return body[response_key] + + with self.completion_cache('human_id', self.resource_class, mode="a"): + with self.completion_cache('uuid', self.resource_class, mode="a"): + return self.resource_class(self, body[response_key]) + + def _delete(self, url): + resp, body = self.api.client.delete(url) + + def _update(self, url, body, **kwargs): + self.run_hooks('modify_body_for_update', body, **kwargs) + resp, body = self.api.client.put(url, body=body) + return body + + +class ManagerWithFind(Manager): + """ + Like a `Manager`, but with additional `find()`/`findall()` methods. + """ + 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(404, 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 + + def list(self): + raise NotImplementedError + + +class Resource(object): + """ + A resource represents a particular instance of an object (server, flavor, + etc). This is pretty much just a bag for attributes. + + :param manager: Manager object + :param info: dictionary representing resource attributes + :param loaded: prevent lazy-loading if set to True + """ + HUMAN_ID = False + + def __init__(self, manager, info, loaded=False): + self.manager = manager + self._info = info + self._add_details(info) + self._loaded = loaded + + # NOTE(sirp): ensure `id` is already present because if it isn't we'll + # enter an infinite loop of __getattr__ -> get -> __init__ -> + # __getattr__ -> ... + if 'id' in self.__dict__ and len(str(self.id)) == 36: + self.manager.write_to_completion_cache('uuid', self.id) + + human_id = self.human_id + if human_id: + self.manager.write_to_completion_cache('human_id', human_id) + + @property + def human_id(self): + """Subclasses may override this provide a pretty ID which can be used + for bash completion. + """ + if 'name' in self.__dict__ and self.HUMAN_ID: + return utils.slugify(self.name) + return None + + def _add_details(self, info): + for (k, v) in info.iteritems(): + try: + setattr(self, k, v) + except AttributeError: + # In this case we already defined the attribute on the class + pass + + def __getattr__(self, k): + if k not in self.__dict__: + #NOTE(bcwaldon): disallow lazy-loading if already loaded once + if not self.is_loaded(): + self.get() + return self.__getattr__(k) + + raise AttributeError(k) + else: + return self.__dict__[k] + + def __repr__(self): + reprkeys = sorted(k for k in self.__dict__.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) + + def get(self): + # set_loaded() first ... so if we have to bail, we know we tried. + self.set_loaded(True) + if not hasattr(self.manager, 'get'): + return + + new = self.manager.get(self.id) + if new: + self._add_details(new._info) + + def __eq__(self, other): + if not isinstance(other, self.__class__): + return False + if hasattr(self, 'id') and hasattr(other, 'id'): + return self.id == other.id + return self._info == other._info + + def is_loaded(self): + return self._loaded + + def set_loaded(self, val): + self._loaded = val diff --git a/cinderclient/client.py b/cinderclient/client.py new file mode 100644 index 0000000..278e922 --- /dev/null +++ b/cinderclient/client.py @@ -0,0 +1,330 @@ +# Copyright 2010 Jacob Kaplan-Moss +# Copyright 2011 OpenStack LLC. +# Copyright 2011 Piston Cloud Computing, Inc. + +# All Rights Reserved. +""" +OpenStack Client interface. Handles the REST calls and responses. +""" + +import httplib2 +import logging +import os +import urlparse + +try: + import json +except ImportError: + import simplejson as json + +# Python 2.5 compat fix +if not hasattr(urlparse, 'parse_qsl'): + import cgi + urlparse.parse_qsl = cgi.parse_qsl + +from cinderclient import exceptions +from cinderclient import service_catalog +from cinderclient import utils + + +_logger = logging.getLogger(__name__) +if 'CINDERCLIENT_DEBUG' in os.environ and os.environ['CINDERCLIENT_DEBUG']: + ch = logging.StreamHandler() + _logger.setLevel(logging.DEBUG) + _logger.addHandler(ch) + + +class HTTPClient(httplib2.Http): + + USER_AGENT = 'python-cinderclient' + + def __init__(self, user, password, projectid, auth_url, insecure=False, + timeout=None, proxy_tenant_id=None, + proxy_token=None, region_name=None, + endpoint_type='publicURL', service_type=None, + service_name=None, volume_service_name=None): + super(HTTPClient, self).__init__(timeout=timeout) + self.user = user + self.password = password + self.projectid = projectid + self.auth_url = auth_url.rstrip('/') + self.version = 'v1' + self.region_name = region_name + self.endpoint_type = endpoint_type + self.service_type = service_type + self.service_name = service_name + self.volume_service_name = volume_service_name + + self.management_url = None + self.auth_token = None + self.proxy_token = proxy_token + self.proxy_tenant_id = proxy_tenant_id + + # httplib2 overrides + self.force_exception_to_status_code = True + self.disable_ssl_certificate_validation = insecure + + def http_log(self, args, kwargs, resp, body): + if not _logger.isEnabledFor(logging.DEBUG): + return + + string_parts = ['curl -i'] + for element in args: + if element in ('GET', 'POST'): + string_parts.append(' -X %s' % element) + else: + string_parts.append(' %s' % element) + + for element in kwargs['headers']: + header = ' -H "%s: %s"' % (element, kwargs['headers'][element]) + string_parts.append(header) + + _logger.debug("REQ: %s\n" % "".join(string_parts)) + if 'body' in kwargs: + _logger.debug("REQ BODY: %s\n" % (kwargs['body'])) + _logger.debug("RESP:%s %s\n", resp, body) + + def request(self, *args, **kwargs): + kwargs.setdefault('headers', kwargs.get('headers', {})) + kwargs['headers']['User-Agent'] = self.USER_AGENT + kwargs['headers']['Accept'] = 'application/json' + if 'body' in kwargs: + kwargs['headers']['Content-Type'] = 'application/json' + kwargs['body'] = json.dumps(kwargs['body']) + + resp, body = super(HTTPClient, self).request(*args, **kwargs) + + self.http_log(args, kwargs, resp, body) + + if body: + try: + body = json.loads(body) + except ValueError: + pass + else: + body = None + + if resp.status >= 400: + raise exceptions.from_response(resp, body) + + return resp, body + + def _cs_request(self, url, method, **kwargs): + if not self.management_url: + self.authenticate() + + # Perform the request once. If we get a 401 back then it + # might be because the auth token expired, so try to + # re-authenticate and try again. If it still fails, bail. + try: + kwargs.setdefault('headers', {})['X-Auth-Token'] = self.auth_token + if self.projectid: + kwargs['headers']['X-Auth-Project-Id'] = self.projectid + + resp, body = self.request(self.management_url + url, method, + **kwargs) + return resp, body + except exceptions.Unauthorized, ex: + try: + self.authenticate() + resp, body = self.request(self.management_url + url, method, + **kwargs) + return resp, body + except exceptions.Unauthorized: + raise ex + + def get(self, url, **kwargs): + return self._cs_request(url, 'GET', **kwargs) + + def post(self, url, **kwargs): + return self._cs_request(url, 'POST', **kwargs) + + def put(self, url, **kwargs): + return self._cs_request(url, 'PUT', **kwargs) + + def delete(self, url, **kwargs): + return self._cs_request(url, 'DELETE', **kwargs) + + 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.""" + + if resp.status == 200: # content must always present + try: + self.auth_url = url + self.service_catalog = \ + service_catalog.ServiceCatalog(body) + + if extract_token: + self.auth_token = self.service_catalog.get_token() + + management_url = self.service_catalog.url_for( + attr='region', + filter_value=self.region_name, + endpoint_type=self.endpoint_type, + service_type=self.service_type, + service_name=self.service_name, + volume_service_name=self.volume_service_name,) + self.management_url = management_url.rstrip('/') + return None + except exceptions.AmbiguousEndpoints: + 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?" + raise + + elif resp.status == 305: + return resp['location'] + else: + raise exceptions.from_response(resp, body) + + def _fetch_endpoints_from_auth(self, url): + """We have a token, but don't know the final endpoint for + the region. We have to go back to the auth service and + ask again. This request requires an admin-level token + to work. The proxy token supplied could be from a low-level enduser. + + We can't get this from the keystone service endpoint, we have to use + the admin endpoint. + + This will overwrite our admin token with the user token. + """ + + # GET ...:5001/v2.0/tokens/#####/endpoints + url = '/'.join([url, 'tokens', '%s?belongsTo=%s' + % (self.proxy_token, self.proxy_tenant_id)]) + _logger.debug("Using Endpoint URL: %s" % url) + resp, body = self.request(url, "GET", + headers={'X-Auth_Token': self.auth_token}) + return self._extract_service_catalog(url, resp, body, + extract_token=False) + + def authenticate(self): + magic_tuple = urlparse.urlsplit(self.auth_url) + scheme, netloc, path, query, frag = magic_tuple + port = magic_tuple.port + if port is None: + port = 80 + path_parts = path.split('/') + for part in path_parts: + if len(part) > 0 and part[0] == 'v': + self.version = part + break + + # TODO(sandy): Assume admin endpoint is 35357 for now. + # Ideally this is going to have to be provided by the service catalog. + new_netloc = netloc.replace(':%d' % port, ':%d' % (35357,)) + admin_url = urlparse.urlunsplit( + (scheme, new_netloc, path, query, frag)) + + 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) + + # Are we acting on behalf of another user via an + # existing token? If so, our actual endpoints may + # be different than that of the admin token. + if self.proxy_token: + self._fetch_endpoints_from_auth(admin_url) + # Since keystone no longer returns the user token + # with the endpoints any more, we need to replace + # our service account token with the user token. + self.auth_token = self.proxy_token + else: + try: + while auth_url: + auth_url = self._v1_auth(auth_url) + # In some configurations cinder makes redirection to + # v2.0 keystone endpoint. Also, new location does not contain + # real endpoint, only hostname and port. + except exceptions.AuthorizationFailure: + if auth_url.find('v2.0') < 0: + auth_url = auth_url + '/v2.0' + self._v2_auth(auth_url) + + def _v1_auth(self, url): + if self.proxy_token: + raise exceptions.NoTokenLookupException() + + headers = {'X-Auth-User': self.user, + 'X-Auth-Key': self.password} + if self.projectid: + headers['X-Auth-Project-Id'] = self.projectid + + resp, body = self.request(url, 'GET', headers=headers) + if resp.status in (200, 204): # in some cases we get No Content + try: + mgmt_header = 'x-server-management-url' + self.management_url = resp[mgmt_header].rstrip('/') + self.auth_token = resp['x-auth-token'] + self.auth_url = url + except KeyError: + raise exceptions.AuthorizationFailure() + elif resp.status == 305: + return resp['location'] + else: + raise exceptions.from_response(resp, body) + + def _v2_auth(self, url): + """Authenticate against a v2.0 auth service.""" + body = {"auth": { + "passwordCredentials": {"username": self.user, + "password": self.password}}} + + if self.projectid: + body['auth']['tenantName'] = self.projectid + + 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" + + # Make sure we follow redirects when trying to reach Keystone + tmp_follow_all_redirects = self.follow_all_redirects + self.follow_all_redirects = True + + try: + resp, body = self.request(token_url, "POST", body=body) + finally: + self.follow_all_redirects = tmp_follow_all_redirects + + return self._extract_service_catalog(url, resp, body) + + +def get_client_class(version): + version_map = { + '1': 'cinderclient.v1.client.Client', + } + try: + client_path = version_map[str(version)] + except (KeyError, ValueError): + msg = "Invalid client version '%s'. must be one of: %s" % ( + (version, ', '.join(version_map.keys()))) + raise exceptions.UnsupportedVersion(msg) + + return utils.import_class(client_path) + + +def Client(version, *args, **kwargs): + client_class = get_client_class(version) + return client_class(*args, **kwargs) diff --git a/cinderclient/exceptions.py b/cinderclient/exceptions.py new file mode 100644 index 0000000..91bf30e --- /dev/null +++ b/cinderclient/exceptions.py @@ -0,0 +1,146 @@ +# Copyright 2010 Jacob Kaplan-Moss +""" +Exception definitions. +""" + + +class UnsupportedVersion(Exception): + """Indicates that the user is trying to use an unsupported + version of the API""" + pass + + +class CommandError(Exception): + pass + + +class AuthorizationFailure(Exception): + pass + + +class NoUniqueMatch(Exception): + pass + + +class NoTokenLookupException(Exception): + """This form of authentication does not support looking up + endpoints from an existing token.""" + pass + + +class EndpointNotFound(Exception): + """Could not find Service or Region in Service Catalog.""" + pass + + +class AmbiguousEndpoints(Exception): + """Found more than one matching endpoint in Service Catalog.""" + def __init__(self, endpoints=None): + self.endpoints = endpoints + + def __str__(self): + return "AmbiguousEndpoints: %s" % repr(self.endpoints) + + +class ClientException(Exception): + """ + The base exception class for all exceptions this library raises. + """ + def __init__(self, code, message=None, details=None, request_id=None): + self.code = code + self.message = message or self.__class__.message + self.details = details + self.request_id = request_id + + def __str__(self): + formatted_string = "%s (HTTP %s)" % (self.message, self.code) + if self.request_id: + formatted_string += " (Request-ID: %s)" % self.request_id + + return formatted_string + + +class BadRequest(ClientException): + """ + HTTP 400 - Bad request: you sent some malformed data. + """ + http_status = 400 + message = "Bad request" + + +class Unauthorized(ClientException): + """ + HTTP 401 - Unauthorized: bad credentials. + """ + http_status = 401 + message = "Unauthorized" + + +class Forbidden(ClientException): + """ + HTTP 403 - Forbidden: your credentials don't give you access to this + resource. + """ + http_status = 403 + message = "Forbidden" + + +class NotFound(ClientException): + """ + HTTP 404 - Not found + """ + http_status = 404 + message = "Not found" + + +class OverLimit(ClientException): + """ + HTTP 413 - Over limit: you're over the API limits for this time period. + """ + http_status = 413 + message = "Over limit" + + +# NotImplemented is a python keyword. +class HTTPNotImplemented(ClientException): + """ + HTTP 501 - Not Implemented: the server does not support this operation. + """ + http_status = 501 + message = "Not Implemented" + + +# In Python 2.4 Exception is old-style and thus doesn't have a __subclasses__() +# so we can do this: +# _code_map = dict((c.http_status, c) +# for c in ClientException.__subclasses__()) +# +# Instead, we have to hardcode it: +_code_map = dict((c.http_status, c) for c in [BadRequest, Unauthorized, + Forbidden, NotFound, OverLimit, HTTPNotImplemented]) + + +def from_response(response, body): + """ + Return an instance of an ClientException or subclass + based on an httplib2 response. + + Usage:: + + resp, body = http.request(...) + if resp.status != 200: + raise exception_from_response(resp, body) + """ + cls = _code_map.get(response.status, ClientException) + request_id = response.get('x-compute-request-id') + if body: + message = "n/a" + details = "n/a" + if hasattr(body, 'keys'): + error = body[body.keys()[0]] + message = error.get('message', None) + details = error.get('details', None) + return cls(code=response.status, message=message, details=details, + request_id=request_id) + else: + return cls(code=response.status, request_id=request_id) diff --git a/cinderclient/extension.py b/cinderclient/extension.py new file mode 100644 index 0000000..ced67f0 --- /dev/null +++ b/cinderclient/extension.py @@ -0,0 +1,39 @@ +# 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. + +from cinderclient import base +from cinderclient import utils + + +class Extension(utils.HookableMixin): + """Extension descriptor.""" + + SUPPORTED_HOOKS = ('__pre_parse_args__', '__post_parse_args__') + + def __init__(self, name, module): + 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) + elif utils.safe_issubclass(attr_value, base.Manager): + self.manager_class = attr_value + + def __repr__(self): + return "<Extension '%s'>" % self.name diff --git a/cinderclient/service_catalog.py b/cinderclient/service_catalog.py new file mode 100644 index 0000000..a2c8b37 --- /dev/null +++ b/cinderclient/service_catalog.py @@ -0,0 +1,77 @@ +# 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. + + +import cinderclient.exceptions + + +class ServiceCatalog(object): + """Helper methods for dealing with a Keystone Service Catalog.""" + + def __init__(self, resource_dict): + self.catalog = resource_dict + + def get_token(self): + return self.catalog['access']['token']['id'] + + def url_for(self, attr=None, filter_value=None, + service_type=None, endpoint_type='publicURL', + 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.""" + matching_endpoints = [] + if 'endpoints' in self.catalog: + # We have a bastardized service catalog. Treat it special. :/ + for endpoint in self.catalog['endpoints']: + if not filter_value or endpoint[attr] == filter_value: + matching_endpoints.append(endpoint) + if not matching_endpoints: + raise cinderclient.exceptions.EndpointNotFound() + + # We don't always get a service catalog back ... + if not 'serviceCatalog' in self.catalog['access']: + return None + + # Full catalog ... + catalog = self.catalog['access']['serviceCatalog'] + + for service in catalog: + if 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): + continue + + endpoints = service['endpoints'] + for endpoint in endpoints: + if not filter_value or endpoint.get(attr) == filter_value: + endpoint["serviceName"] = service.get("name") + matching_endpoints.append(endpoint) + + if not matching_endpoints: + raise cinderclient.exceptions.EndpointNotFound() + elif len(matching_endpoints) > 1: + raise cinderclient.exceptions.AmbiguousEndpoints( + endpoints=matching_endpoints) + else: + return matching_endpoints[0][endpoint_type] diff --git a/cinderclient/shell.py b/cinderclient/shell.py new file mode 100644 index 0000000..25d5536 --- /dev/null +++ b/cinderclient/shell.py @@ -0,0 +1,435 @@ + +# 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. + +""" +Command-line interface to the OpenStack Volume API. +""" + +import argparse +import glob +import httplib2 +import imp +import itertools +import os +import pkgutil +import sys +import logging + +from cinderclient import client +from cinderclient import exceptions as exc +import cinderclient.extension +from cinderclient import utils +from cinderclient.v1 import shell as shell_v1 + +DEFAULT_OS_VOLUME_API_VERSION = "1" +DEFAULT_CINDER_ENDPOINT_TYPE = 'publicURL' +DEFAULT_CINDER_SERVICE_TYPE = 'compute' + +logger = logging.getLogger(__name__) + + +class CinderClientArgumentParser(argparse.ArgumentParser): + + def __init__(self, *args, **kwargs): + super(CinderClientArgumentParser, self).__init__(*args, **kwargs) + + def error(self, message): + """error(message: string) + + Prints a usage message incorporating the message to stderr and + exits. + """ + self.print_usage(sys.stderr) + #FIXME(lzyeval): if changes occur in argparse.ArgParser._check_value + choose_from = ' (choose from' + progparts = self.prog.partition(' ') + self.exit(2, "error: %(errmsg)s\nTry '%(mainp)s help %(subp)s'" + " for more information.\n" % + {'errmsg': message.split(choose_from)[0], + 'mainp': progparts[0], + 'subp': progparts[2]}) + + +class OpenStackCinderShell(object): + + def get_base_parser(self): + parser = CinderClientArgumentParser( + prog='cinder', + description=__doc__.strip(), + epilog='See "cinder help COMMAND" '\ + 'for help on a specific command.', + add_help=False, + formatter_class=OpenStackHelpFormatter, + ) + + # Global arguments + parser.add_argument('-h', '--help', + action='store_true', + help=argparse.SUPPRESS, + ) + + parser.add_argument('--debug', + default=False, + action='store_true', + help="Print debugging output") + + parser.add_argument('--os_username', + default=utils.env('OS_USERNAME', 'CINDER_USERNAME'), + help='Defaults to env[OS_USERNAME].') + + parser.add_argument('--os_password', + default=utils.env('OS_PASSWORD', 'CINDER_PASSWORD'), + help='Defaults to env[OS_PASSWORD].') + + parser.add_argument('--os_tenant_name', + default=utils.env('OS_TENANT_NAME', 'CINDER_PROJECT_ID'), + help='Defaults to env[OS_TENANT_NAME].') + + parser.add_argument('--os_auth_url', + default=utils.env('OS_AUTH_URL', 'CINDER_URL'), + help='Defaults to env[OS_AUTH_URL].') + + parser.add_argument('--os_region_name', + default=utils.env('OS_REGION_NAME', 'CINDER_REGION_NAME'), + help='Defaults to env[OS_REGION_NAME].') + + parser.add_argument('--service_type', + help='Defaults to compute for most actions') + + parser.add_argument('--service_name', + default=utils.env('CINDER_SERVICE_NAME'), + help='Defaults to env[CINDER_SERVICE_NAME]') + + parser.add_argument('--volume_service_name', + default=utils.env('CINDER_VOLUME_SERVICE_NAME'), + help='Defaults to env[CINDER_VOLUME_SERVICE_NAME]') + + parser.add_argument('--endpoint_type', + default=utils.env('CINDER_ENDPOINT_TYPE', + default=DEFAULT_CINDER_ENDPOINT_TYPE), + help='Defaults to env[CINDER_ENDPOINT_TYPE] or ' + + DEFAULT_CINDER_ENDPOINT_TYPE + '.') + + parser.add_argument('--os_volume_api_version', + default=utils.env('OS_VOLUME_API_VERSION', + default=DEFAULT_OS_VOLUME_API_VERSION), + help='Accepts 1, defaults to env[OS_VOLUME_API_VERSION].') + + parser.add_argument('--insecure', + default=utils.env('CINDERCLIENT_INSECURE', default=False), + action='store_true', + help=argparse.SUPPRESS) + + # FIXME(dtroyer): The args below are here for diablo compatibility, + # remove them in folsum cycle + + # alias for --os_username, left in for backwards compatibility + parser.add_argument('--username', + help='Deprecated') + + # alias for --os_region_name, left in for backwards compatibility + parser.add_argument('--region_name', + help='Deprecated') + + # alias for --os_password, left in for backwards compatibility + parser.add_argument('--apikey', '--password', dest='apikey', + default=utils.env('CINDER_API_KEY'), + help='Deprecated') + + # alias for --os_tenant_name, left in for backward compatibility + parser.add_argument('--projectid', '--tenant_name', dest='projectid', + default=utils.env('CINDER_PROJECT_ID'), + help='Deprecated') + + # alias for --os_auth_url, left in for backward compatibility + parser.add_argument('--url', '--auth_url', dest='url', + default=utils.env('CINDER_URL'), + help='Deprecated') + + return parser + + def get_subcommand_parser(self, version): + parser = self.get_base_parser() + + self.subcommands = {} + subparsers = parser.add_subparsers(metavar='<subcommand>') + + try: + actions_module = { + '1.1': shell_v1, + '2': shell_v1, + }[version] + except KeyError: + actions_module = shell_v1 + + self._find_actions(subparsers, actions_module) + self._find_actions(subparsers, self) + + for extension in self.extensions: + self._find_actions(subparsers, extension.module) + + self._add_bash_completion_subparser(subparsers) + + return parser + + def _discover_extensions(self, version): + extensions = [] + for name, module in itertools.chain( + self._discover_via_python_path(version), + self._discover_via_contrib_path(version)): + + extension = cinderclient.extension.Extension(name, module) + extensions.append(extension) + + return extensions + + def _discover_via_python_path(self, version): + for (module_loader, name, ispkg) in pkgutil.iter_modules(): + if name.endswith('python_cinderclient_ext'): + if not hasattr(module_loader, 'load_module'): + # Python 2.6 compat: actually get an ImpImporter obj + module_loader = module_loader.find_module(name) + + module = module_loader.load_module(name) + yield name, module + + def _discover_via_contrib_path(self, version): + module_path = os.path.dirname(os.path.abspath(__file__)) + version_str = "v%s" % version.replace('.', '_') + ext_path = os.path.join(module_path, version_str, 'contrib') + ext_glob = os.path.join(ext_path, "*.py") + + for ext_path in glob.iglob(ext_glob): + name = os.path.basename(ext_path)[:-3] + + if name == "__init__": + continue + + module = imp.load_source(name, ext_path) + yield name, module + + def _add_bash_completion_subparser(self, subparsers): + subparser = subparsers.add_parser('bash_completion', + add_help=False, + formatter_class=OpenStackHelpFormatter + ) + self.subcommands['bash_completion'] = subparser + subparser.set_defaults(func=self.do_bash_completion) + + 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. + command = attr[3:].replace('_', '-') + callback = getattr(actions_module, attr) + desc = callback.__doc__ or '' + help = desc.strip().split('\n')[0] + arguments = getattr(callback, 'arguments', []) + + subparser = subparsers.add_parser(command, + help=help, + description=desc, + add_help=False, + formatter_class=OpenStackHelpFormatter + ) + subparser.add_argument('-h', '--help', + action='help', + help=argparse.SUPPRESS, + ) + self.subcommands[command] = subparser + for (args, kwargs) in arguments: + subparser.add_argument(*args, **kwargs) + subparser.set_defaults(func=callback) + + def setup_debugging(self, debug): + if not debug: + return + + streamhandler = logging.StreamHandler() + streamformat = "%(levelname)s (%(module)s:%(lineno)d) %(message)s" + streamhandler.setFormatter(logging.Formatter(streamformat)) + logger.setLevel(logging.DEBUG) + logger.addHandler(streamhandler) + + httplib2.debuglevel = 1 + + def main(self, argv): + # Parse args once to find version + parser = self.get_base_parser() + (options, args) = parser.parse_known_args(argv) + self.setup_debugging(options.debug) + + # build available subcommands based on version + self.extensions = self._discover_extensions( + options.os_volume_api_version) + self._run_extension_hooks('__pre_parse_args__') + + subcommand_parser = self.get_subcommand_parser( + options.os_volume_api_version) + self.parser = subcommand_parser + + if options.help and len(args) == 0: + subcommand_parser.print_help() + return 0 + + args = subcommand_parser.parse_args(argv) + self._run_extension_hooks('__post_parse_args__', args) + + # Short-circuit and deal with help right away. + if args.func == self.do_help: + self.do_help(args) + return 0 + elif args.func == self.do_bash_completion: + self.do_bash_completion(args) + return 0 + + (os_username, os_password, os_tenant_name, os_auth_url, + os_region_name, endpoint_type, insecure, + service_type, service_name, volume_service_name, + username, apikey, projectid, url, region_name) = ( + 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.volume_service_name, args.username, + args.apikey, args.projectid, + args.url, args.region_name) + + if not endpoint_type: + endpoint_type = DEFAULT_CINDER_ENDPOINT_TYPE + + if not service_type: + service_type = DEFAULT_CINDER_SERVICE_TYPE + service_type = utils.get_service_type(args.func) or service_type + + #FIXME(usrleon): Here should be restrict for project id same as + # for os_username or os_password but for compatibility it is not. + + if not utils.isunauthenticated(args.func): + if not os_username: + if not username: + raise exc.CommandError("You must provide a username " + "via either --os_username or env[OS_USERNAME]") + else: + os_username = username + + if not os_password: + if not apikey: + raise exc.CommandError("You must provide a password " + "via either --os_password or via " + "env[OS_PASSWORD]") + else: + os_password = apikey + + if not os_tenant_name: + if not projectid: + raise exc.CommandError("You must provide a tenant name " + "via either --os_tenant_name or " + "env[OS_TENANT_NAME]") + else: + os_tenant_name = projectid + + if not os_auth_url: + if not url: + raise exc.CommandError("You must provide an auth url " + "via either --os_auth_url or env[OS_AUTH_URL]") + else: + os_auth_url = url + + if not os_region_name and region_name: + os_region_name = region_name + + if not os_tenant_name: + raise exc.CommandError("You must provide a tenant name " + "via either --os_tenant_name or env[OS_TENANT_NAME]") + + if not os_auth_url: + raise exc.CommandError("You must provide an auth url " + "via either --os_auth_url or env[OS_AUTH_URL]") + + 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, endpoint_type=endpoint_type, + extensions=self.extensions, service_type=service_type, + service_name=service_name, + volume_service_name=volume_service_name) + + try: + if not utils.isunauthenticated(args.func): + self.cs.authenticate() + except exc.Unauthorized: + raise exc.CommandError("Invalid OpenStack Nova credentials.") + except exc.AuthorizationFailure: + raise exc.CommandError("Unable to authorize user") + + args.func(self.cs, args) + + def _run_extension_hooks(self, hook_type, *args, **kwargs): + """Run hooks for all registered extensions.""" + for extension in self.extensions: + extension.run_hooks(hook_type, *args, **kwargs) + + def do_bash_completion(self, args): + """ + 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(): + commands.add(sc_str) + for option in sc._optionals._option_string_actions.keys(): + options.add(option) + + commands.remove('bash-completion') + commands.remove('bash_completion') + print ' '.join(commands | options) + + @utils.arg('command', metavar='<subcommand>', nargs='?', + help='Display help for <subcommand>') + def do_help(self, args): + """ + Display help about this program or one of its subcommands. + """ + if args.command: + if args.command in self.subcommands: + self.subcommands[args.command].print_help() + else: + raise exc.CommandError("'%s' is not a valid subcommand" % + args.command) + else: + self.parser.print_help() + + +# I'm picky about my shell help. +class OpenStackHelpFormatter(argparse.HelpFormatter): + def start_section(self, heading): + # Title-case the headings + heading = '%s%s' % (heading[0].upper(), heading[1:]) + super(OpenStackHelpFormatter, self).start_section(heading) + + +def main(): + try: + OpenStackCinderShell().main(sys.argv[1:]) + + except Exception, e: + logger.debug(e, exc_info=1) + print >> sys.stderr, "ERROR: %s" % str(e) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/cinderclient/utils.py b/cinderclient/utils.py new file mode 100644 index 0000000..52f4da9 --- /dev/null +++ b/cinderclient/utils.py @@ -0,0 +1,261 @@ +import os +import re +import sys +import uuid + +import prettytable + +from cinderclient import exceptions + + +def arg(*args, **kwargs): + """Decorator for CLI args.""" + def _decorator(func): + add_arg(func, *args, **kwargs) + return func + return _decorator + + +def env(*vars, **kwargs): + """ + returns the first environment variable set + if none are non-empty, defaults to '' or keyword arg default + """ + for v in vars: + value = os.environ.get(v, None) + if value: + return value + return kwargs.get('default', '') + + +def add_arg(f, *args, **kwargs): + """Bind CLI arguments to a shell.py `do_foo` function.""" + + if not hasattr(f, 'arguments'): + f.arguments = [] + + # 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 + # to the options list positional options will appear to be backwards. + f.arguments.insert(0, (args, kwargs)) + + +def add_resource_manager_extra_kwargs_hook(f, hook): + """Adds hook to bind CLI arguments to ResourceManager calls. + + The `do_foo` calls in shell.py will receive CLI args and then in turn pass + them through to the ResourceManager. Before passing through the args, the + hooks registered here will be called, giving us a chance to add extra + kwargs (taken from the command-line) to what's passed to the + ResourceManager. + """ + if not hasattr(f, 'resource_manager_kwargs_hooks'): + f.resource_manager_kwargs_hooks = [] + + names = [h.__name__ for h in f.resource_manager_kwargs_hooks] + if hook.__name__ not in names: + f.resource_manager_kwargs_hooks.append(hook) + + +def get_resource_manager_extra_kwargs(f, args, allow_conflicts=False): + """Return extra_kwargs by calling resource manager kwargs hooks.""" + hooks = getattr(f, "resource_manager_kwargs_hooks", []) + extra_kwargs = {} + for hook in hooks: + hook_name = hook.__name__ + hook_kwargs = hook(args) + + 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()) + + extra_kwargs.update(hook_kwargs) + + return extra_kwargs + + +def unauthenticated(f): + """ + Adds 'unauthenticated' attribute to decorated function. + Usage: + @unauthenticated + def mymethod(f): + ... + """ + f.unauthenticated = True + return f + + +def isunauthenticated(f): + """ + Checks to see if the function is marked as not requiring authentication + with the @unauthenticated decorator. Returns True if decorator is + set to True, False otherwise. + """ + return getattr(f, 'unauthenticated', False) + + +def service_type(stype): + """ + Adds 'service_type' attribute to decorated function. + Usage: + @service_type('volume') + def mymethod(f): + ... + """ + def inner(f): + f.service_type = stype + return f + return inner + + +def get_service_type(f): + """ + Retrieves service type from function + """ + return getattr(f, 'service_type', None) + + +def pretty_choice_list(l): + return ', '.join("'%s'" % i for i in l) + + +def print_list(objs, fields, formatters={}): + mixed_case_fields = ['serverId'] + pt = prettytable.PrettyTable([f for f in fields], caching=False) + pt.aligns = ['l' for f in fields] + + for o in objs: + row = [] + for field in fields: + if field in formatters: + row.append(formatters[field](o)) + else: + if field in mixed_case_fields: + field_name = field.replace(' ', '_') + else: + field_name = field.lower().replace(' ', '_') + data = getattr(o, field_name, '') + row.append(data) + pt.add_row(row) + + print pt.get_string(sortby=fields[0]) + + +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 pt.get_string(sortby=property) + + +def find_resource(manager, name_or_id): + """Helper for the _find_* methods.""" + # first try to get entity as integer id + try: + if isinstance(name_or_id, int) or name_or_id.isdigit(): + return manager.get(int(name_or_id)) + except exceptions.NotFound: + pass + + # now try to get entity as uuid + try: + uuid.UUID(str(name_or_id)) + return manager.get(name_or_id) + except (ValueError, exceptions.NotFound): + pass + + try: + try: + return manager.find(human_id=name_or_id) + except exceptions.NotFound: + pass + + # finally try to find entity by name + try: + return manager.find(name=name_or_id) + except exceptions.NotFound: + try: + # Volumes does not have name, but display_name + return manager.find(display_name=name_or_id) + except exceptions.NotFound: + msg = "No %s with a name or ID of '%s' exists." % \ + (manager.resource_class.__name__.lower(), name_or_id) + raise exceptions.CommandError(msg) + except exceptions.NoUniqueMatch: + msg = ("Multiple %s matches found for '%s', use an ID to be more" + " specific." % (manager.resource_class.__name__.lower(), + name_or_id)) + raise exceptions.CommandError(msg) + + +def _format_servers_list_networks(server): + output = [] + for (network, addresses) in server.networks.items(): + if len(addresses) == 0: + continue + addresses_csv = ', '.join(addresses) + group = "%s=%s" % (network, addresses_csv) + output.append(group) + + return '; '.join(output) + + +class HookableMixin(object): + """Mixin so classes can register and run hooks.""" + _hooks_map = {} + + @classmethod + def add_hook(cls, hook_type, hook_func): + 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): + hook_funcs = cls._hooks_map.get(hook_type) or [] + for hook_func in hook_funcs: + hook_func(*args, **kwargs) + + +def safe_issubclass(*args): + """Like issubclass, but will just return False if not a class.""" + + try: + if issubclass(*args): + return True + except TypeError: + pass + + return False + + +def import_class(import_str): + """Returns a class from a string including module and class.""" + mod_str, _sep, class_str = import_str.rpartition('.') + __import__(mod_str) + return getattr(sys.modules[mod_str], class_str) + +_slugify_strip_re = re.compile(r'[^\w\s-]') +_slugify_hyphenate_re = re.compile(r'[-\s]+') + + +# http://code.activestate.com/recipes/ +# 577257-slugify-make-a-string-usable-in-a-url-or-filename/ +def slugify(value): + """ + Normalizes string, converts to lowercase, removes non-alpha characters, + and converts spaces to hyphens. + + From Django's "django/template/defaultfilters.py". + """ + import unicodedata + if not isinstance(value, unicode): + value = unicode(value) + value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore') + value = unicode(_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 new file mode 100644 index 0000000..cecfacd --- /dev/null +++ b/cinderclient/v1/__init__.py @@ -0,0 +1,17 @@ +# Copyright (c) 2012 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. + +from cinderclient.v1.client import Client diff --git a/cinderclient/v1/client.py b/cinderclient/v1/client.py new file mode 100644 index 0000000..cbee8ba --- /dev/null +++ b/cinderclient/v1/client.py @@ -0,0 +1,71 @@ +from cinderclient import client +from cinderclient.v1 import volumes +from cinderclient.v1 import volume_snapshots +from cinderclient.v1 import volume_types + + +class Client(object): + """ + Top-level object to access the OpenStack Compute API. + + Create an instance with your creds:: + + >>> client = Client(USERNAME, PASSWORD, PROJECT_ID, AUTH_URL) + + Then call methods on its managers:: + + >>> client.servers.list() + ... + >>> client.flavors.list() + ... + + """ + + # FIXME(jesse): project_id isn't required to authenticate + def __init__(self, username, api_key, project_id, auth_url, + insecure=False, timeout=None, proxy_tenant_id=None, + proxy_token=None, region_name=None, + endpoint_type='publicURL', extensions=None, + service_type='compute', service_name=None, + volume_service_name=None): + # FIXME(comstud): Rename the api_key argument above when we + # know it's not being used as keyword argument + password = api_key + + # extensions + self.volumes = volumes.VolumeManager(self) + self.volume_snapshots = volume_snapshots.SnapshotManager(self) + self.volume_types = volume_types.VolumeTypeManager(self) + + # Add in any extensions... + if extensions: + for extension in extensions: + if extension.manager_class: + setattr(self, extension.name, + extension.manager_class(self)) + + self.client = client.HTTPClient(username, + password, + project_id, + auth_url, + insecure=insecure, + timeout=timeout, + proxy_token=proxy_token, + proxy_tenant_id=proxy_tenant_id, + region_name=region_name, + endpoint_type=endpoint_type, + service_type=service_type, + service_name=service_name, + volume_service_name=volume_service_name) + + def authenticate(self): + """ + Authenticate against the server. + + Normally this is called automatically when you first access the API, + but you can call this method to force authentication right now. + + Returns on success; raises :exc:`exceptions.Unauthorized` if the + credentials are wrong. + """ + self.client.authenticate() diff --git a/cinderclient/v1/contrib/__init__.py b/cinderclient/v1/contrib/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/cinderclient/v1/contrib/__init__.py diff --git a/cinderclient/v1/shell.py b/cinderclient/v1/shell.py new file mode 100644 index 0000000..6b8b7bb --- /dev/null +++ b/cinderclient/v1/shell.py @@ -0,0 +1,241 @@ +# 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 sys +import time + +from cinderclient import utils + + +def _poll_for_status(poll_fn, obj_id, action, final_ok_states, + poll_period=5, show_progress=True): + """Block while an action is being performed, periodically printing + progress. + """ + def print_progress(progress): + if show_progress: + msg = ('\rInstance %(action)s... %(progress)s%% complete' + % dict(action=action, progress=progress)) + else: + msg = '\rInstance %(action)s...' % dict(action=action) + + sys.stdout.write(msg) + sys.stdout.flush() + + 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" + break + elif status == "error": + print "\nError %(action)s instance" % locals() + 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.""" + return utils.find_resource(cs.volume_snapshots, snapshot) + + +def _print_volume(cs, volume): + utils.print_dict(volume._info) + + +def _print_volume_snapshot(cs, snapshot): + utils.print_dict(snapshot._info) + + +def _translate_volume_keys(collection): + convert = [('displayName', 'display_name'), ('volumeType', 'volume_type')] + 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]) + + +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]) + + +@utils.service_type('volume') +def do_list(cs, args): + """List all the volumes.""" + volumes = cs.volumes.list() + _translate_volume_keys(volumes) + + # Create a list of servers to which the volume is attached + for vol in volumes: + servers = [s.get('server_id') for s in vol.attachments] + setattr(vol, 'attached_to', ','.join(map(str, servers))) + utils.print_list(volumes, ['ID', 'Status', 'Display Name', + 'Size', 'Volume Type', 'Attached to']) + + +@utils.arg('volume', metavar='<volume>', help='ID of the volume.') +@utils.service_type('volume') +def do_show(cs, args): + """Show details about a volume.""" + volume = _find_volume(cs, args.volume) + _print_volume(cs, volume) + + +@utils.arg('size', + metavar='<size>', + type=int, + help='Size of volume in GB') +@utils.arg('--snapshot_id', + metavar='<snapshot_id>', + help='Optional snapshot id to create the volume from. (Default=None)', + default=None) +@utils.arg('--display_name', metavar='<display_name>', + help='Optional volume name. (Default=None)', + default=None) +@utils.arg('--display_description', metavar='<display_description>', + help='Optional volume description. (Default=None)', + default=None) +@utils.arg('--volume_type', + metavar='<volume_type>', + help='Optional volume type. (Default=None)', + default=None) +@utils.service_type('volume') +def do_create(cs, args): + """Add a new volume.""" + cs.volumes.create(args.size, + args.snapshot_id, + args.display_name, + args.display_description, + args.volume_type) + + +@utils.arg('volume', metavar='<volume>', help='ID of the volume to delete.') +@utils.service_type('volume') +def do_delete(cs, args): + """Remove a volume.""" + volume = _find_volume(cs, args.volume) + volume.delete() + + +@utils.service_type('volume') +def do_snapshot_list(cs, args): + """List all the snapshots.""" + snapshots = cs.volume_snapshots.list() + _translate_volume_snapshot_keys(snapshots) + utils.print_list(snapshots, ['ID', 'Volume ID', 'Status', 'Display Name', + 'Size']) + + +@utils.arg('snapshot', metavar='<snapshot>', help='ID of the snapshot.') +@utils.service_type('volume') +def do_snapshot_show(cs, args): + """Show details about a snapshot.""" + snapshot = _find_volume_snapshot(cs, args.snapshot) + _print_volume_snapshot(cs, snapshot) + + +@utils.arg('volume_id', + metavar='<volume_id>', + help='ID of the volume to snapshot') +@utils.arg('--force', + metavar='<True|False>', + help='Optional flag to indicate whether to snapshot a volume even if its ' + 'attached to an instance. (Default=False)', + default=False) +@utils.arg('--display_name', metavar='<display_name>', + help='Optional snapshot name. (Default=None)', + default=None) +@utils.arg('--display_description', metavar='<display_description>', + help='Optional snapshot description. (Default=None)', + default=None) +@utils.service_type('volume') +def do_snapshot_create(cs, args): + """Add a new snapshot.""" + cs.volume_snapshots.create(args.volume_id, + args.force, + args.display_name, + args.display_description) + + +@utils.arg('snapshot_id', + metavar='<snapshot_id>', + help='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.delete() + + +def _print_volume_type_list(vtypes): + utils.print_list(vtypes, ['ID', 'Name']) + + +@utils.service_type('volume') +def do_type_list(cs, args): + """Print a list of available 'volume types'.""" + vtypes = cs.volume_types.list() + _print_volume_type_list(vtypes) + + +@utils.arg('name', + metavar='<name>', + help="Name of the new flavor") +@utils.service_type('volume') +def do_type_create(cs, args): + """Create a new volume type.""" + vtype = cs.volume_types.create(args.name) + _print_volume_type_list([vtype]) + + +@utils.arg('id', + metavar='<id>', + help="Unique ID of the volume type to delete") +@utils.service_type('volume') +def do_type_delete(cs, args): + """Delete a specific flavor""" + cs.volume_types.delete(args.id) + + +def do_endpoints(cs, args): + """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""" + catalog = cs.client.service_catalog.catalog + utils.print_dict(catalog['access']['user'], "User Credentials") + utils.print_dict(catalog['access']['token'], "Token") diff --git a/cinderclient/v1/volume_snapshots.py b/cinderclient/v1/volume_snapshots.py new file mode 100644 index 0000000..fa6c4b4 --- /dev/null +++ b/cinderclient/v1/volume_snapshots.py @@ -0,0 +1,88 @@ +# Copyright 2011 Denali Systems, 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. + +""" +Volume snapshot interface (1.1 extension). +""" + +from cinderclient import base + + +class Snapshot(base.Resource): + """ + A Snapshot is a point-in-time snapshot of an openstack volume. + """ + def __repr__(self): + return "<Snapshot: %s>" % self.id + + def delete(self): + """ + Delete this snapshot. + """ + self.manager.delete(self) + + +class SnapshotManager(base.ManagerWithFind): + """ + Manage :class:`Snapshot` resources. + """ + resource_class = Snapshot + + def create(self, volume_id, force=False, + display_name=None, display_description=None): + + """ + Create a snapshot of the given volume. + + :param volume_id: The ID of the volume to snapshot. + :param force: If force is True, create a snapshot even if the volume is + attached to an instance. Default is False. + :param display_name: Name of the snapshot + :param display_description: Description of the snapshot + :rtype: :class:`Snapshot` + """ + body = {'snapshot': {'volume_id': volume_id, + 'force': force, + 'display_name': display_name, + 'display_description': display_description}} + return self._create('/snapshots', body, 'snapshot') + + def get(self, snapshot_id): + """ + Get a snapshot. + + :param snapshot_id: The ID of the snapshot to get. + :rtype: :class:`Snapshot` + """ + return self._get("/snapshots/%s" % snapshot_id, "snapshot") + + def list(self, detailed=True): + """ + Get a list of all snapshots. + + :rtype: list of :class:`Snapshot` + """ + if detailed is True: + return self._list("/snapshots/detail", "snapshots") + else: + return self._list("/snapshots", "snapshots") + + def delete(self, snapshot): + """ + Delete a snapshot. + + :param snapshot: The :class:`Snapshot` to delete. + """ + self._delete("/snapshots/%s" % base.getid(snapshot)) diff --git a/cinderclient/v1/volume_types.py b/cinderclient/v1/volume_types.py new file mode 100644 index 0000000..e6d644d --- /dev/null +++ b/cinderclient/v1/volume_types.py @@ -0,0 +1,77 @@ +# Copyright (c) 2011 Rackspace US, Inc. +# +# 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 Type interface. +""" + +from cinderclient import base + + +class VolumeType(base.Resource): + """ + A Volume Type is the type of volume to be created + """ + def __repr__(self): + return "<Volume Type: %s>" % self.name + + +class VolumeTypeManager(base.ManagerWithFind): + """ + Manage :class:`VolumeType` resources. + """ + resource_class = VolumeType + + def list(self): + """ + Get a list of all volume types. + + :rtype: list of :class:`VolumeType`. + """ + return self._list("/types", "volume_types") + + def get(self, volume_type): + """ + Get a specific volume type. + + :param volume_type: The ID of the :class:`VolumeType` to get. + :rtype: :class:`VolumeType` + """ + return self._get("/types/%s" % base.getid(volume_type), "volume_type") + + def delete(self, volume_type): + """ + Delete a specific volume_type. + + :param volume_type: The ID of the :class:`VolumeType` to get. + """ + self._delete("/types/%s" % base.getid(volume_type)) + + def create(self, name): + """ + Create a volume type. + + :param name: Descriptive name of the volume type + :rtype: :class:`VolumeType` + """ + + body = { + "volume_type": { + "name": name, + } + } + + return self._create("/types", body, "volume_type") diff --git a/cinderclient/v1/volumes.py b/cinderclient/v1/volumes.py new file mode 100644 index 0000000..d465724 --- /dev/null +++ b/cinderclient/v1/volumes.py @@ -0,0 +1,135 @@ +# Copyright 2011 Denali Systems, 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. + +""" +Volume interface (1.1 extension). +""" + +from cinderclient import base + + +class Volume(base.Resource): + """ + A volume is an extra block level storage to the OpenStack instances. + """ + def __repr__(self): + return "<Volume: %s>" % self.id + + def delete(self): + """ + Delete this volume. + """ + self.manager.delete(self) + + +class VolumeManager(base.ManagerWithFind): + """ + Manage :class:`Volume` resources. + """ + resource_class = Volume + + def create(self, size, snapshot_id=None, + display_name=None, display_description=None, + volume_type=None): + """ + Create a volume. + + :param size: Size of volume in GB + :param snapshot_id: ID of the snapshot + :param display_name: Name of the volume + :param display_description: Description of the volume + :param volume_type: Type of volume + :rtype: :class:`Volume` + """ + body = {'volume': {'size': size, + 'snapshot_id': snapshot_id, + 'display_name': display_name, + 'display_description': display_description, + 'volume_type': volume_type}} + return self._create('/volumes', body, 'volume') + + def get(self, volume_id): + """ + Get a volume. + + :param volume_id: The ID of the volume to delete. + :rtype: :class:`Volume` + """ + return self._get("/volumes/%s" % volume_id, "volume") + + def list(self, detailed=True): + """ + Get a list of all volumes. + + :rtype: list of :class:`Volume` + """ + if detailed is True: + return self._list("/volumes/detail", "volumes") + else: + return self._list("/volumes", "volumes") + + def delete(self, volume): + """ + Delete a volume. + + :param volume: The :class:`Volume` to delete. + """ + self._delete("/volumes/%s" % base.getid(volume)) + + def create_server_volume(self, server_id, volume_id, device): + """ + Attach a volume identified by the volume ID to the given server ID + + :param server_id: The ID of the server + :param volume_id: The ID of the volume to attach. + :param device: The device name + :rtype: :class:`Volume` + """ + body = {'volumeAttachment': {'volumeId': volume_id, + 'device': device}} + return self._create("/servers/%s/os-volume_attachments" % server_id, + body, "volumeAttachment") + + def get_server_volume(self, server_id, attachment_id): + """ + Get the volume identified by the attachment ID, that is attached to + the given server ID + + :param server_id: The ID of the server + :param attachment_id: The ID of the attachment + :rtype: :class:`Volume` + """ + return self._get("/servers/%s/os-volume_attachments/%s" % (server_id, + attachment_id,), "volumeAttachment") + + def get_server_volumes(self, server_id): + """ + Get a list of all the attached volumes for the given server ID + + :param server_id: The ID of the server + :rtype: list of :class:`Volume` + """ + return self._list("/servers/%s/os-volume_attachments" % server_id, + "volumeAttachments") + + def delete_server_volume(self, server_id, attachment_id): + """ + Detach a volume identified by the attachment ID from the given server + + :param server_id: The ID of the server + :param attachment_id: The ID of the attachment + """ + self._delete("/servers/%s/os-volume_attachments/%s" % + (server_id, attachment_id,)) |