summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cinderclient/client.py1
-rw-r--r--cinderclient/shell.py5
-rw-r--r--cinderclient/v2/__init__.py17
-rw-r--r--cinderclient/v2/client.py78
-rw-r--r--cinderclient/v2/contrib/__init__.py15
-rw-r--r--cinderclient/v2/contrib/list_extensions.py47
-rw-r--r--cinderclient/v2/limits.py78
-rw-r--r--cinderclient/v2/quota_classes.py51
-rw-r--r--cinderclient/v2/quotas.py53
-rw-r--r--cinderclient/v2/shell.py697
-rw-r--r--cinderclient/v2/volume_snapshots.py116
-rw-r--r--cinderclient/v2/volume_types.py108
-rw-r--r--cinderclient/v2/volumes.py308
-rw-r--r--tests/test_client.py5
-rw-r--r--tests/v1/test_shell.py2
-rw-r--r--tests/v2/__init__.py15
-rw-r--r--tests/v2/contrib/__init__.py0
-rw-r--r--tests/v2/contrib/test_list_extensions.py36
-rw-r--r--tests/v2/fakes.py317
-rw-r--r--tests/v2/test_auth.py390
-rw-r--r--tests/v2/test_quota_classes.py42
-rw-r--r--tests/v2/test_quotas.py47
-rw-r--r--tests/v2/test_shell.py159
-rw-r--r--tests/v2/test_types.py50
-rw-r--r--tests/v2/test_volumes.py87
25 files changed, 2721 insertions, 3 deletions
diff --git a/cinderclient/client.py b/cinderclient/client.py
index d0f75d7..54b756a 100644
--- a/cinderclient/client.py
+++ b/cinderclient/client.py
@@ -362,6 +362,7 @@ class HTTPClient(object):
def get_client_class(version):
version_map = {
'1': 'cinderclient.v1.client.Client',
+ '2': 'cinderclient.v2.client.Client',
}
try:
client_path = version_map[str(version)]
diff --git a/cinderclient/shell.py b/cinderclient/shell.py
index 423db58..d6e5317 100644
--- a/cinderclient/shell.py
+++ b/cinderclient/shell.py
@@ -32,6 +32,7 @@ from cinderclient import exceptions as exc
import cinderclient.extension
from cinderclient import utils
from cinderclient.v1 import shell as shell_v1
+from cinderclient.v2 import shell as shell_v2
DEFAULT_OS_VOLUME_API_VERSION = "1"
DEFAULT_CINDER_ENDPOINT_TYPE = 'publicURL'
@@ -162,7 +163,7 @@ class OpenStackCinderShell(object):
metavar='<compute-api-ver>',
default=utils.env('OS_VOLUME_API_VERSION',
default=DEFAULT_OS_VOLUME_API_VERSION),
- help='Accepts 1,defaults '
+ help='Accepts 1 or 2,defaults '
'to env[OS_VOLUME_API_VERSION].')
parser.add_argument('--os_volume_api_version',
help=argparse.SUPPRESS)
@@ -223,7 +224,7 @@ class OpenStackCinderShell(object):
try:
actions_module = {
'1.1': shell_v1,
- '2': shell_v1,
+ '2': shell_v2,
}[version]
except KeyError:
actions_module = shell_v1
diff --git a/cinderclient/v2/__init__.py b/cinderclient/v2/__init__.py
new file mode 100644
index 0000000..5408cd3
--- /dev/null
+++ b/cinderclient/v2/__init__.py
@@ -0,0 +1,17 @@
+# 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.
+
+from cinderclient.v2.client import Client
diff --git a/cinderclient/v2/client.py b/cinderclient/v2/client.py
new file mode 100644
index 0000000..92bbd56
--- /dev/null
+++ b/cinderclient/v2/client.py
@@ -0,0 +1,78 @@
+from cinderclient import client
+from cinderclient.v2 import limits
+from cinderclient.v2 import quota_classes
+from cinderclient.v2 import quotas
+from cinderclient.v2 import volumes
+from cinderclient.v2 import volume_snapshots
+from cinderclient.v2 import volume_types
+
+
+class Client(object):
+ """Top-level object to access the OpenStack Volume API.
+
+ Create an instance with your creds::
+
+ >>> client = Client(USERNAME, PASSWORD, PROJECT_ID, AUTH_URL)
+
+ Then call methods on its managers::
+
+ >>> client.volumes.list()
+ ...
+ """
+
+ def __init__(self, username, api_key, project_id=None, auth_url='',
+ 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,
+ volume_service_name=None, retries=None,
+ http_log_debug=False,
+ cacert=None):
+ # FIXME(comstud): Rename the api_key argument above when we
+ # know it's not being used as keyword argument
+ password = api_key
+ self.limits = limits.LimitsManager(self)
+
+ # extensions
+ self.volumes = volumes.VolumeManager(self)
+ self.volume_snapshots = volume_snapshots.SnapshotManager(self)
+ self.volume_types = volume_types.VolumeTypeManager(self)
+ self.quota_classes = quota_classes.QuotaClassSetManager(self)
+ self.quotas = quotas.QuotaSetManager(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,
+ tenant_id=tenant_id,
+ 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,
+ retries=retries,
+ http_log_debug=http_log_debug,
+ cacert=cacert)
+
+ 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/v2/contrib/__init__.py b/cinderclient/v2/contrib/__init__.py
new file mode 100644
index 0000000..0cd9c14
--- /dev/null
+++ b/cinderclient/v2/contrib/__init__.py
@@ -0,0 +1,15 @@
+# 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
new file mode 100644
index 0000000..9031a51
--- /dev/null
+++ b/cinderclient/v2/contrib/list_extensions.py
@@ -0,0 +1,47 @@
+# 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.
+
+from cinderclient import base
+from cinderclient import utils
+
+
+class ListExtResource(base.Resource):
+ @property
+ def summary(self):
+ descr = self.description.strip()
+ if not descr:
+ return '??'
+ lines = descr.split("\n")
+ if len(lines) == 1:
+ return lines[0]
+ else:
+ return lines[0] + "..."
+
+
+class ListExtManager(base.Manager):
+ resource_class = ListExtResource
+
+ def show_all(self):
+ return self._list("/extensions", 'extensions')
+
+
+@utils.service_type('volume')
+def do_list_extensions(client, _args):
+ """
+ List all the os-api extensions that are available.
+ """
+ extensions = client.list_extensions.show_all()
+ fields = ["Name", "Summary", "Alias", "Updated"]
+ utils.print_list(extensions, fields)
diff --git a/cinderclient/v2/limits.py b/cinderclient/v2/limits.py
new file mode 100644
index 0000000..d076db8
--- /dev/null
+++ b/cinderclient/v2/limits.py
@@ -0,0 +1,78 @@
+# Copyright 2013 OpenStack LLC.
+
+from cinderclient import base
+
+
+class Limits(base.Resource):
+ """A collection of RateLimit and AbsoluteLimit objects"""
+
+ def __repr__(self):
+ return "<Limits>"
+
+ @property
+ def absolute(self):
+ for (name, value) in self._info['absolute'].items():
+ yield AbsoluteLimit(name, value)
+
+ @property
+ def rate(self):
+ for group in self._info['rate']:
+ uri = group['uri']
+ regex = group['regex']
+ for rate in group['limit']:
+ yield RateLimit(rate['verb'], uri, regex, rate['value'],
+ rate['remaining'], rate['unit'],
+ rate['next-available'])
+
+
+class RateLimit(object):
+ """Data model that represents a flattened view of a single rate limit"""
+
+ def __init__(self, verb, uri, regex, value, remain,
+ unit, next_available):
+ self.verb = verb
+ self.uri = uri
+ self.regex = regex
+ self.value = value
+ self.remain = remain
+ self.unit = unit
+ self.next_available = next_available
+
+ def __eq__(self, other):
+ return self.uri == other.uri \
+ and self.regex == other.regex \
+ and self.value == other.value \
+ and self.verb == other.verb \
+ and self.remain == other.remain \
+ and self.unit == other.unit \
+ and self.next_available == other.next_available
+
+ def __repr__(self):
+ return "<RateLimit: method=%s uri=%s>" % (self.method, self.uri)
+
+
+class AbsoluteLimit(object):
+ """Data model that represents a single absolute limit"""
+
+ def __init__(self, name, value):
+ self.name = name
+ self.value = value
+
+ def __eq__(self, other):
+ return self.value == other.value and self.name == other.name
+
+ def __repr__(self):
+ return "<AbsoluteLimit: name=%s>" % (self.name)
+
+
+class LimitsManager(base.Manager):
+ """Manager object used to interact with limits resource"""
+
+ resource_class = Limits
+
+ def get(self):
+ """Get a specific extension.
+
+ :rtype: :class:`Limits`
+ """
+ return self._get("/limits", "limits")
diff --git a/cinderclient/v2/quota_classes.py b/cinderclient/v2/quota_classes.py
new file mode 100644
index 0000000..d8b3e2f
--- /dev/null
+++ b/cinderclient/v2/quota_classes.py
@@ -0,0 +1,51 @@
+# 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.
+
+from cinderclient import base
+
+
+class QuotaClassSet(base.Resource):
+
+ @property
+ def id(self):
+ """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):
+ 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):
+
+ 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)
+
+ self._update('/os-quota-class-sets/%s' % (class_name), body)
diff --git a/cinderclient/v2/quotas.py b/cinderclient/v2/quotas.py
new file mode 100644
index 0000000..7a264d8
--- /dev/null
+++ b/cinderclient/v2/quotas.py
@@ -0,0 +1,53 @@
+# 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.
+
+from cinderclient import base
+
+
+class QuotaSet(base.Resource):
+
+ @property
+ def id(self):
+ """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):
+ resource_class = QuotaSet
+
+ def get(self, tenant_id):
+ if hasattr(tenant_id, 'tenant_id'):
+ tenant_id = tenant_id.tenant_id
+ return self._get("/os-quota-sets/%s" % (tenant_id), "quota_set")
+
+ def update(self, tenant_id, volumes=None, gigabytes=None):
+
+ body = {'quota_set': {
+ 'tenant_id': tenant_id,
+ 'volumes': volumes,
+ 'gigabytes': gigabytes}}
+
+ for key in body['quota_set'].keys():
+ if body['quota_set'][key] is None:
+ body['quota_set'].pop(key)
+
+ self._update('/os-quota-sets/%s' % (tenant_id), body)
+
+ def defaults(self, tenant_id):
+ return self._get('/os-quota-sets/%s/defaults' % tenant_id,
+ 'quota_set')
diff --git a/cinderclient/v2/shell.py b/cinderclient/v2/shell.py
new file mode 100644
index 0000000..4759a59
--- /dev/null
+++ b/cinderclient/v2/shell.py
@@ -0,0 +1,697 @@
+# 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 argparse
+import os
+import sys
+import time
+
+from cinderclient import exceptions
+from cinderclient import utils
+
+
+def _poll_for_status(poll_fn, obj_id, action, final_ok_states,
+ poll_period=5, show_progress=True):
+ """Block while action is 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_snapshot(snapshot):
+ utils.print_dict(snapshot._info)
+
+
+def _translate_volume_keys(collection):
+ convert = [('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 = [('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])
+
+
+def _extract_metadata(args):
+ metadata = {}
+ for metadatum in args.metadata[0]:
+ # unset doesn't require a val, so we have the if/else
+ if '=' in metadatum:
+ (key, value) = metadatum.split('=', 1)
+ else:
+ key = metadatum
+ value = None
+
+ metadata[key] = value
+ return metadata
+
+
+@utils.arg('--all-tenants',
+ dest='all_tenants',
+ metavar='<0|1>',
+ nargs='?',
+ type=int,
+ const=1,
+ default=0,
+ help='Display information from all tenants (Admin only).')
+@utils.arg('--all_tenants',
+ nargs='?',
+ type=int,
+ const=1,
+ help=argparse.SUPPRESS)
+@utils.arg('--name',
+ metavar='<name>',
+ default=None,
+ help='Filter results by name')
+@utils.arg('--display-name',
+ help=argparse.SUPPRESS)
+@utils.arg('--status',
+ metavar='<status>',
+ default=None,
+ help='Filter results by status')
+@utils.service_type('volume')
+def do_list(cs, args):
+ """List all the volumes."""
+ # NOTE(thingee): Backwards-compatibility with v1 args
+ if args.display_name is not None:
+ args.name = args.display_name
+
+ all_tenants = int(os.environ.get("ALL_TENANTS", args.all_tenants))
+ search_opts = {
+ 'all_tenants': all_tenants,
+ 'name': args.name,
+ 'status': args.status,
+ }
+ volumes = cs.volumes.list(search_opts=search_opts)
+ _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', 'Name',
+ 'Size', 'Volume Type', 'Bootable', '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."""
+ info = dict()
+ volume = _find_volume(cs, args.volume)
+ info.update(volume._info)
+
+ if 'links' in info:
+ info.pop('links')
+
+ utils.print_dict(info)
+
+
+@utils.arg('size',
+ metavar='<size>',
+ type=int,
+ help='Size of volume in GB')
+@utils.arg('--snapshot-id',
+ metavar='<snapshot-id>',
+ default=None,
+ help='Create volume from snapshot id (Optional, Default=None)')
+@utils.arg('--snapshot_id',
+ help=argparse.SUPPRESS)
+@utils.arg('--source-volid',
+ metavar='<source-volid>',
+ default=None,
+ help='Create volume from volume id (Optional, Default=None)')
+@utils.arg('--source_volid',
+ help=argparse.SUPPRESS)
+@utils.arg('--image-id',
+ metavar='<image-id>',
+ default=None,
+ help='Create volume from image id (Optional, Default=None)')
+@utils.arg('--image_id',
+ help=argparse.SUPPRESS)
+@utils.arg('--name',
+ metavar='<name>',
+ default=None,
+ help='Volume name (Optional, Default=None)')
+@utils.arg('--display-name',
+ help=argparse.SUPPRESS)
+@utils.arg('--display_name',
+ help=argparse.SUPPRESS)
+@utils.arg('--description',
+ metavar='<description>',
+ default=None,
+ help='Volume description (Optional, Default=None)')
+@utils.arg('--display-description',
+ help=argparse.SUPPRESS)
+@utils.arg('--display_description',
+ help=argparse.SUPPRESS)
+@utils.arg('--volume-type',
+ metavar='<volume-type>',
+ default=None,
+ help='Volume type (Optional, Default=None)')
+@utils.arg('--volume_type',
+ help=argparse.SUPPRESS)
+@utils.arg('--availability-zone',
+ metavar='<availability-zone>',
+ default=None,
+ help='Availability zone for volume (Optional, Default=None)')
+@utils.arg('--availability_zone',
+ help=argparse.SUPPRESS)
+@utils.arg('--metadata',
+ type=str,
+ nargs='*',
+ metavar='<key=value>',
+ help='Metadata key=value pairs (Optional, Default=None)',
+ default=None)
+@utils.service_type('volume')
+def do_create(cs, args):
+ """Add a new volume."""
+ # NOTE(thingee): Backwards-compatibility with v1 args
+ 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_metadata = None
+ if args.metadata is not None:
+ volume_metadata = _extract_metadata(args)
+
+ volume = cs.volumes.create(args.size,
+ args.snapshot_id,
+ args.source_volid,
+ args.name,
+ args.description,
+ args.volume_type,
+ availability_zone=args.availability_zone,
+ imageRef=args.image_id,
+ metadata=volume_metadata)
+
+ info = dict()
+ volume = cs.volumes.get(info['id'])
+ info.update(volume._info)
+
+ info.pop('links')
+
+ utils.print_dict(info)
+
+
+@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.arg('volume',
+ metavar='<volume>',
+ help='ID of the volume 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()
+
+
+@utils.arg('volume',
+ metavar='<volume>',
+ help='ID of the volume to rename.')
+@utils.arg('name',
+ nargs='?',
+ metavar='<name>',
+ help='New name for the volume.')
+@utils.arg('--description', metavar='<description>',
+ help='Optional volume description. (Default=None)',
+ default=None)
+@utils.arg('--display-description',
+ help=argparse.SUPPRESS)
+@utils.arg('--display_description',
+ help=argparse.SUPPRESS)
+@utils.service_type('volume')
+def do_rename(cs, args):
+ """Rename a volume."""
+ kwargs = {}
+
+ if args.name is not None:
+ kwargs['name'] = args.name
+ if args.display_description is not None:
+ kwargs['description'] = args.display_description
+ elif args.description is not None:
+ kwargs['description'] = args.description
+
+ _find_volume(cs, args.volume).update(**kwargs)
+
+
+@utils.arg('volume',
+ metavar='<volume>',
+ help='ID of the volume to update metadata on.')
+@utils.arg('action',
+ metavar='<action>',
+ choices=['set', 'unset'],
+ help="Actions: 'set' or 'unset'")
+@utils.arg('metadata',
+ metavar='<key=value>',
+ nargs='+',
+ action='append',
+ default=[],
+ help='Metadata to set/unset (only key is necessary on unset)')
+@utils.service_type('volume')
+def do_metadata(cs, args):
+ """Set or Delete metadata on a volume."""
+ volume = _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())
+
+
+@utils.arg('--all-tenants',
+ dest='all_tenants',
+ metavar='<0|1>',
+ nargs='?',
+ type=int,
+ const=1,
+ default=0,
+ help='Display information from all tenants (Admin only).')
+@utils.arg('--all_tenants',
+ nargs='?',
+ type=int,
+ const=1,
+ help=argparse.SUPPRESS)
+@utils.arg('--name',
+ metavar='<name>',
+ default=None,
+ help='Filter results by name')
+@utils.arg('--display-name',
+ help=argparse.SUPPRESS)
+@utils.arg('--display_name',
+ help=argparse.SUPPRESS)
+@utils.arg('--status',
+ metavar='<status>',
+ default=None,
+ help='Filter results by status')
+@utils.arg('--volume-id',
+ metavar='<volume-id>',
+ default=None,
+ help='Filter results by volume-id')
+@utils.arg('--volume_id',
+ help=argparse.SUPPRESS)
+@utils.service_type('volume')
+def do_snapshot_list(cs, args):
+ """List all the snapshots."""
+ all_tenants = int(os.environ.get("ALL_TENANTS", args.all_tenants))
+
+ if args.display_name is not None:
+ args.name = args.display_name
+
+ search_opts = {
+ 'all_tenants': all_tenants,
+ 'display_name': args.name,
+ 'status': args.status,
+ 'volume_id': args.volume_id,
+ }
+
+ snapshots = cs.volume_snapshots.list(search_opts=search_opts)
+ _translate_volume_snapshot_keys(snapshots)
+ utils.print_list(snapshots,
+ ['ID', 'Volume ID', 'Status', '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(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 it\'s '
+ 'attached to an instance. (Default=False)',
+ default=False)
+@utils.arg('--name',
+ metavar='<name>',
+ default=None,
+ help='Optional snapshot name. (Default=None)')
+@utils.arg('--display-name',
+ help=argparse.SUPPRESS)
+@utils.arg('--display_name',
+ help=argparse.SUPPRESS)
+@utils.arg('--description',
+ metavar='<description>',
+ default=None,
+ help='Optional snapshot description. (Default=None)')
+@utils.arg('--display-description',
+ help=argparse.SUPPRESS)
+@utils.arg('--display_description',
+ help=argparse.SUPPRESS)
+@utils.service_type('volume')
+def do_snapshot_create(cs, args):
+ """Add a new snapshot."""
+ if args.display_name is not None:
+ args.name = args.display_name
+
+ if args.display_description is not None:
+ args.description = args.display_description
+
+ snapshot = cs.volume_snapshots.create(args.volume_id,
+ args.force,
+ args.name,
+ args.description)
+ _print_volume_snapshot(snapshot)
+
+
+@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()
+
+
+@utils.arg('snapshot', metavar='<snapshot>', help='ID of the snapshot.')
+@utils.arg('name', nargs='?', metavar='<name>',
+ help='New name for the snapshot.')
+@utils.arg('--description', metavar='<description>',
+ help='Optional snapshot description. (Default=None)',
+ default=None)
+@utils.arg('--display-description',
+ help=argparse.SUPPRESS)
+@utils.arg('--display_description',
+ help=argparse.SUPPRESS)
+@utils.service_type('volume')
+def do_snapshot_rename(cs, args):
+ """Rename a snapshot."""
+ kwargs = {}
+
+ if args.name is not None:
+ kwargs['name'] = args.name
+
+ if args.description is not None:
+ kwargs['description'] = args.description
+ elif args.display_description is not None:
+ kwargs['description'] = args.display_description
+
+ _find_volume_snapshot(cs, args.snapshot).update(**kwargs)
+
+
+def _print_volume_type_list(vtypes):
+ utils.print_list(vtypes, ['ID', 'Name'])
+
+
+def _print_type_and_extra_specs_list(vtypes):
+ formatters = {'extra_specs': _print_type_extra_specs}
+ utils.print_list(vtypes, ['ID', 'Name', 'extra_specs'], formatters)
+
+
+@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.service_type('volume')
+def do_extra_specs_list(cs, args):
+ """Print a list of current 'volume types and extra specs' (Admin Only)."""
+ vtypes = cs.volume_types.list()
+ _print_type_and_extra_specs_list(vtypes)
+
+
+@utils.arg('name',
+ metavar='<name>',
+ help="Name of the new volume type")
+@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 volume type"""
+ cs.volume_types.delete(args.id)
+
+
+@utils.arg('vtype',
+ metavar='<vtype>',
+ help="Name or ID of the volume type")
+@utils.arg('action',
+ metavar='<action>',
+ choices=['set', 'unset'],
+ help="Actions: 'set' or 'unset'")
+@utils.arg('metadata',
+ metavar='<key=value>',
+ nargs='+',
+ action='append',
+ default=[],
+ 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."""
+ 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())
+
+
+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")
+
+_quota_resources = ['volumes', 'gigabytes']
+
+
+def _quota_show(quotas):
+ quota_dict = {}
+ for resource in _quota_resources:
+ quota_dict[resource] = getattr(quotas, resource, None)
+ utils.print_dict(quota_dict)
+
+
+def _quota_update(manager, identifier, args):
+ updates = {}
+ for resource in _quota_resources:
+ val = getattr(args, resource, None)
+ if val is not None:
+ updates[resource] = val
+
+ if updates:
+ manager.update(identifier, **updates)
+
+
+@utils.arg('tenant',
+ metavar='<tenant_id>',
+ help='UUID of tenant to list the quotas for.')
+@utils.service_type('volume')
+def do_quota_show(cs, args):
+ """List the quotas for a tenant."""
+
+ _quota_show(cs.quotas.get(args.tenant))
+
+
+@utils.arg('tenant',
+ metavar='<tenant_id>',
+ help='UUID of tenant to list the default quotas for.')
+@utils.service_type('volume')
+def do_quota_defaults(cs, args):
+ """List the default quotas for a tenant."""
+
+ _quota_show(cs.quotas.defaults(args.tenant))
+
+
+@utils.arg('tenant',
+ metavar='<tenant_id>',
+ help='UUID of tenant to set the quotas for.')
+@utils.arg('--volumes',
+ metavar='<volumes>',
+ type=int, default=None,
+ help='New value for the "volumes" quota.')
+@utils.arg('--gigabytes',
+ metavar='<gigabytes>',
+ type=int, default=None,
+ help='New value for the "gigabytes" quota.')
+@utils.service_type('volume')
+def do_quota_update(cs, args):
+ """Update the quotas for a tenant."""
+
+ _quota_update(cs.quotas, args.tenant, args)
+
+
+@utils.arg('class_name',
+ metavar='<class>',
+ help='Name of quota class to list the quotas for.')
+@utils.service_type('volume')
+def do_quota_class_show(cs, args):
+ """List the quotas for a quota class."""
+
+ _quota_show(cs.quota_classes.get(args.class_name))
+
+
+@utils.arg('class-name',
+ metavar='<class-name>',
+ help='Name of quota class to set the quotas for.')
+@utils.arg('--volumes',
+ metavar='<volumes>',
+ type=int, default=None,
+ help='New value for the "volumes" quota.')
+@utils.arg('--gigabytes',
+ metavar='<gigabytes>',
+ type=int, default=None,
+ help='New value for the "gigabytes" quota.')
+@utils.service_type('volume')
+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')
+def do_absolute_limits(cs, args):
+ """Print a list of absolute limits for a user"""
+ limits = cs.limits.get().absolute
+ columns = ['Name', 'Value']
+ utils.print_list(limits, columns)
+
+
+@utils.service_type('volume')
+def do_rate_limits(cs, args):
+ """Print a list of rate limits for a user"""
+ limits = cs.limits.get().rate
+ columns = ['Verb', 'URI', 'Value', 'Remain', 'Unit', 'Next_Available']
+ utils.print_list(limits, columns)
+
+
+def _print_type_extra_specs(vol_type):
+ try:
+ return vol_type.get_keys()
+ except exceptions.NotFound:
+ return "N/A"
+
+
+def _find_volume_type(cs, vtype):
+ """Get a volume type by name or ID."""
+ return utils.find_resource(cs.volume_types, vtype)
+
+
+@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 upload a volume even if it\'s '
+ 'attached to an instance. (Default=False)',
+ default=False)
+@utils.arg('--container-format',
+ metavar='<container-format>',
+ help='Optional type for container format '
+ '(Default=bare)',
+ default='bare')
+@utils.arg('--container_format',
+ help=argparse.SUPPRESS)
+@utils.arg('--disk-format',
+ metavar='<disk-format>',
+ help='Optional type for disk format '
+ '(Default=raw)',
+ default='raw')
+@utils.arg('--disk_format',
+ help=argparse.SUPPRESS)
+@utils.arg('image-name',
+ metavar='<image-name>',
+ help='Name for created image')
+@utils.arg('--image_name',
+ help=argparse.SUPPRESS)
+@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)
diff --git a/cinderclient/v2/volume_snapshots.py b/cinderclient/v2/volume_snapshots.py
new file mode 100644
index 0000000..d3ae632
--- /dev/null
+++ b/cinderclient/v2/volume_snapshots.py
@@ -0,0 +1,116 @@
+# 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.
+
+"""Volume snapshot interface (1.1 extension)."""
+
+import urllib
+
+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)
+
+ def update(self, **kwargs):
+ """Update the name or description for this snapshot."""
+ self.manager.update(self, **kwargs)
+
+ @property
+ def progress(self):
+ return self._info.get('os-extended-snapshot-attributes:progress')
+
+ @property
+ def project_id(self):
+ return self._info.get('os-extended-snapshot-attributes:project_id')
+
+
+class SnapshotManager(base.ManagerWithFind):
+ """Manage :class:`Snapshot` resources."""
+ resource_class = Snapshot
+
+ def create(self, volume_id, force=False,
+ name=None, 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 name: Name of the snapshot
+ :param description: Description of the snapshot
+ :rtype: :class:`Snapshot`
+ """
+ body = {'snapshot': {'volume_id': volume_id,
+ 'force': force,
+ 'name': name,
+ 'description': 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, search_opts=None):
+ """Get a list of all snapshots.
+
+ :rtype: list of :class:`Snapshot`
+ """
+
+ if search_opts is None:
+ search_opts = {}
+
+ qparams = {}
+
+ for opt, val in search_opts.iteritems():
+ if val:
+ qparams[opt] = val
+
+ query_string = "?%s" % urllib.urlencode(qparams) if qparams else ""
+
+ detail = ""
+ if detailed:
+ detail = "/detail"
+
+ return self._list("/snapshots%s%s" % (detail, query_string),
+ "snapshots")
+
+ def delete(self, snapshot):
+ """Delete a snapshot.
+
+ :param snapshot: The :class:`Snapshot` to delete.
+ """
+ self._delete("/snapshots/%s" % base.getid(snapshot))
+
+ def update(self, snapshot, **kwargs):
+ """Update the name or description for a snapshot.
+
+ :param snapshot: The :class:`Snapshot` to delete.
+ """
+ if not kwargs:
+ return
+
+ body = {"snapshot": kwargs}
+
+ self._update("/snapshots/%s" % base.getid(snapshot), body)
diff --git a/cinderclient/v2/volume_types.py b/cinderclient/v2/volume_types.py
new file mode 100644
index 0000000..9d4c2ff
--- /dev/null
+++ b/cinderclient/v2/volume_types.py
@@ -0,0 +1,108 @@
+# 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.
+
+
+"""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 "<VolumeType: %s>" % self.name
+
+ def get_keys(self):
+ """Get extra specs from a volume type.
+
+ :param vol_type: The :class:`VolumeType` to get extra specs from
+ """
+ _resp, body = self.manager.api.client.get(
+ "/types/%s/extra_specs" %
+ base.getid(self))
+ return body["extra_specs"]
+
+ def set_keys(self, metadata):
+ """Set extra specs on a volume type.
+
+ :param type : The :class:`VolumeType` to set extra spec on
+ :param metadata: A dict of key/value pairs to be set
+ """
+ body = {'extra_specs': metadata}
+ return self.manager._create(
+ "/types/%s/extra_specs" % base.getid(self),
+ body,
+ "extra_specs",
+ return_raw=True)
+
+ def unset_keys(self, keys):
+ """Unset extra specs on a volue type.
+
+ :param type_id: The :class:`VolumeType` to unset extra spec on
+ :param keys: A list of keys to be unset
+ """
+
+ # NOTE(jdg): This wasn't actually doing all of the keys before
+ # 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
+ for k in keys:
+ resp = self.manager._delete(
+ "/types/%s/extra_specs/%s" % (
+ base.getid(self), k))
+ if resp is not None:
+ return resp
+
+
+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/v2/volumes.py b/cinderclient/v2/volumes.py
new file mode 100644
index 0000000..4e9b86b
--- /dev/null
+++ b/cinderclient/v2/volumes.py
@@ -0,0 +1,308 @@
+# 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.
+
+"""Volume interface (v2 extension)."""
+
+import urllib
+
+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)
+
+ def update(self, **kwargs):
+ """Update the name or description for this volume."""
+ self.manager.update(self, **kwargs)
+
+ def attach(self, instance_uuid, mountpoint):
+ """Set attachment metadata.
+
+ :param instance_uuid: uuid of the attaching instance.
+ :param mountpoint: mountpoint on the attaching instance.
+ """
+ return self.manager.attach(self, instance_uuid, mountpoint)
+
+ def detach(self):
+ """Clear attachment metadata."""
+ return self.manager.detach(self)
+
+ def reserve(self, volume):
+ """Reserve this volume."""
+ return self.manager.reserve(self)
+
+ def unreserve(self, volume):
+ """Unreserve this volume."""
+ return self.manager.unreserve(self)
+
+ def begin_detaching(self, volume):
+ """Begin detaching volume."""
+ return self.manager.begin_detaching(self)
+
+ def roll_detaching(self, volume):
+ """Roll detaching volume."""
+ return self.manager.roll_detaching(self)
+
+ def initialize_connection(self, volume, connector):
+ """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.
+
+ :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.
+
+ :param type : 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)
+
+ def force_delete(self):
+ """Delete the specififed volume ignoring it's current state.
+
+ :param volume: The UUID of the volume to force-delete.
+ """
+ self.manager.force_delete(self)
+
+
+class VolumeManager(base.ManagerWithFind):
+ """Manage :class:`Volume` resources."""
+ resource_class = Volume
+
+ def create(self, size, snapshot_id=None, source_volid=None,
+ name=None, description=None,
+ volume_type=None, user_id=None,
+ project_id=None, availability_zone=None,
+ metadata=None, imageRef=None):
+ """Create a volume.
+
+ :param size: Size of volume in GB
+ :param snapshot_id: ID of the snapshot
+ :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
+ """
+
+ if metadata is None:
+ volume_metadata = {}
+ else:
+ volume_metadata = metadata
+
+ body = {'volume': {'size': size,
+ 'snapshot_id': snapshot_id,
+ 'name': name,
+ 'description': description,
+ 'volume_type': volume_type,
+ 'user_id': user_id,
+ 'project_id': project_id,
+ 'availability_zone': availability_zone,
+ 'status': "creating",
+ 'attach_status': "detached",
+ 'metadata': volume_metadata,
+ 'imageRef': imageRef,
+ 'source_volid': source_volid,
+ }}
+ 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, search_opts=None):
+ """Get a list of all volumes.
+
+ :rtype: list of :class:`Volume`
+ """
+ if search_opts is None:
+ search_opts = {}
+
+ qparams = {}
+
+ for opt, val in search_opts.iteritems():
+ if val:
+ qparams[opt] = val
+
+ query_string = "?%s" % urllib.urlencode(qparams) if qparams else ""
+
+ detail = ""
+ if detailed:
+ detail = "/detail"
+
+ return self._list("/volumes%s%s" % (detail, query_string),
+ "volumes")
+
+ def delete(self, volume):
+ """Delete a volume.
+
+ :param volume: The :class:`Volume` to delete.
+ """
+ self._delete("/volumes/%s" % base.getid(volume))
+
+ def update(self, volume, **kwargs):
+ """Update the name or description for a volume.
+
+ :param volume: The :class:`Volume` to delete.
+ """
+ if not kwargs:
+ return
+
+ body = {"volume": kwargs}
+
+ self._update("/volumes/%s" % base.getid(volume), body)
+
+ def _action(self, action, volume, info=None, **kwargs):
+ """Perform a volume "action."
+ """
+ body = {action: info}
+ self.run_hooks('modify_body_for_action', body, **kwargs)
+ url = '/volumes/%s/action' % base.getid(volume)
+ return self.api.client.post(url, body=body)
+
+ def attach(self, volume, instance_uuid, mountpoint):
+ """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.
+ """
+ return self._action('os-attach',
+ volume,
+ {'instance_uuid': instance_uuid,
+ 'mountpoint': mountpoint})
+
+ def detach(self, volume):
+ """Clear attachment metadata.
+
+ :param volume: The :class:`Volume` (or its ID)
+ you would like to detach.
+ """
+ return self._action('os-detach', volume)
+
+ def reserve(self, volume):
+ """Reserve this volume.
+
+ :param volume: The :class:`Volume` (or its ID)
+ you would like to reserve.
+ """
+ return self._action('os-reserve', volume)
+
+ def unreserve(self, volume):
+ """Unreserve this volume.
+
+ :param volume: The :class:`Volume` (or its ID)
+ you would like to unreserve.
+ """
+ return self._action('os-unreserve', volume)
+
+ def begin_detaching(self, volume):
+ """Begin detaching this volume.
+
+ :param volume: The :class:`Volume` (or its ID)
+ you would like to detach.
+ """
+ return self._action('os-begin_detaching', volume)
+
+ def roll_detaching(self, volume):
+ """Roll detaching this volume.
+
+ :param volume: The :class:`Volume` (or its ID)
+ you would like to roll detaching.
+ """
+ return self._action('os-roll_detaching', volume)
+
+ def initialize_connection(self, volume, connector):
+ """Initialize a volume connection.
+
+ :param volume: The :class:`Volume` (or its ID).
+ :param connector: connector dict from nova.
+ """
+ return self._action('os-initialize_connection', volume,
+ {'connector': connector})[1]['connection_info']
+
+ def terminate_connection(self, volume, connector):
+ """Terminate a volume connection.
+
+ :param volume: The :class:`Volume` (or its ID).
+ :param connector: connector dict from nova.
+ """
+ self._action('os-terminate_connection', volume,
+ {'connector': connector})
+
+ def set_metadata(self, volume, metadata):
+ """Update/Set a volumes metadata.
+
+ :param volume: The :class:`Volume`.
+ :param metadata: A list of keys to be set.
+ """
+ body = {'metadata': metadata}
+ return self._create("/volumes/%s/metadata" % base.getid(volume),
+ body, "metadata")
+
+ def delete_metadata(self, volume, keys):
+ """Delete specified keys from volumes metadata.
+
+ :param volume: The :class:`Volume`.
+ :param metadata: A list of keys to be removed.
+ """
+ for k in keys:
+ self._delete("/volumes/%s/metadata/%s" % (base.getid(volume), k))
+
+ def upload_to_image(self, volume, force, image_name, container_format,
+ disk_format):
+ """Upload volume to image service as image.
+
+ :param volume: The :class:`Volume` to upload.
+ """
+ return self._action('os-volume_upload_image',
+ volume,
+ {'force': force,
+ 'image_name': image_name,
+ 'container_format': container_format,
+ 'disk_format': disk_format})
+
+ def force_delete(self, volume):
+ return self._action('os-force_delete', base.getid(volume))
diff --git a/tests/test_client.py b/tests/test_client.py
index 77c7297..1b72971 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -1,6 +1,7 @@
import cinderclient.client
import cinderclient.v1.client
+import cinderclient.v2.client
from tests import utils
@@ -10,6 +11,10 @@ class ClientTest(utils.TestCase):
output = cinderclient.client.get_client_class('1')
self.assertEqual(output, cinderclient.v1.client.Client)
+ def test_get_client_class_v2(self):
+ output = cinderclient.client.get_client_class('2')
+ self.assertEqual(output, cinderclient.v2.client.Client)
+
def test_get_client_class_unknown(self):
self.assertRaises(cinderclient.exceptions.UnsupportedVersion,
cinderclient.client.get_client_class, '0')
diff --git a/tests/v1/test_shell.py b/tests/v1/test_shell.py
index ad88918..8d28309 100644
--- a/tests/v1/test_shell.py
+++ b/tests/v1/test_shell.py
@@ -32,7 +32,7 @@ class ShellTest(utils.TestCase):
'CINDER_USERNAME': 'username',
'CINDER_PASSWORD': 'password',
'CINDER_PROJECT_ID': 'project_id',
- 'OS_COMPUTE_API_VERSION': '1.1',
+ 'OS_VOLUME_API_VERSION': '1.1',
'CINDER_URL': 'http://no.where',
}
diff --git a/tests/v2/__init__.py b/tests/v2/__init__.py
new file mode 100644
index 0000000..0cd9c14
--- /dev/null
+++ b/tests/v2/__init__.py
@@ -0,0 +1,15 @@
+# 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/contrib/__init__.py b/tests/v2/contrib/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/v2/contrib/__init__.py
diff --git a/tests/v2/contrib/test_list_extensions.py b/tests/v2/contrib/test_list_extensions.py
new file mode 100644
index 0000000..49b0e57
--- /dev/null
+++ b/tests/v2/contrib/test_list_extensions.py
@@ -0,0 +1,36 @@
+# 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.
+
+from cinderclient import extension
+from cinderclient.v2.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/v2/fakes.py b/tests/v2/fakes.py
new file mode 100644
index 0000000..e04caf4
--- /dev/null
+++ b/tests/v2/fakes.py
@@ -0,0 +1,317 @@
+# 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,
+ 'gigabytes': 1}})
+
+ def get_os_quota_sets_test_defaults(self):
+ return (200, {}, {'quota_set': {
+ 'tenant_id': 'test',
+ 'metadata_items': [],
+ 'volumes': 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,
+ '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,
+ '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,
+ '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_auth.py b/tests/v2/test_auth.py
new file mode 100644
index 0000000..b18a7a3
--- /dev/null
+++ b/tests/v2/test_auth.py
@@ -0,0 +1,390 @@
+# 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.
+
+import json
+
+import mock
+import requests
+
+from cinderclient import exceptions
+from cinderclient.v2 import client
+from 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')
+ resp = {
+ "access": {
+ "token": {
+ "expires": "12345",
+ "id": "FAKE_ID",
+ },
+ "serviceCatalog": [
+ {
+ "type": "compute",
+ "endpoints": [
+ {
+ "region": "RegionOne",
+ "adminURL": "http://localhost:8774/v2",
+ "internalURL": "http://localhost:8774/v2",
+ "publicURL": "http://localhost:8774/v2/",
+ },
+ ],
+ },
+ ],
+ },
+ }
+ auth_response = utils.TestResponse({
+ "status_code": 200,
+ "text": json.dumps(resp),
+ })
+
+ mock_request = mock.Mock(return_value=(auth_response))
+
+ @mock.patch.object(requests, "request", mock_request)
+ def test_auth_call():
+ cs.client.authenticate()
+ headers = {
+ 'User-Agent': cs.client.USER_AGENT,
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json',
+ }
+ body = {
+ 'auth': {
+ 'passwordCredentials': {
+ 'username': cs.client.user,
+ 'password': cs.client.password,
+ },
+ 'tenantName': cs.client.projectid,
+ },
+ }
+
+ token_url = cs.client.auth_url + "/tokens"
+ mock_request.assert_called_with(
+ "POST",
+ token_url,
+ headers=headers,
+ data=json.dumps(body),
+ allow_redirects=True,
+ **self.TEST_REQUEST_BASE)
+
+ endpoints = resp["access"]["serviceCatalog"][0]['endpoints']
+ public_url = endpoints[0]["publicURL"].rstrip('/')
+ self.assertEqual(cs.client.management_url, public_url)
+ token_id = resp["access"]["token"]["id"]
+ self.assertEqual(cs.client.auth_token, token_id)
+
+ 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')
+ resp = {
+ "access": {
+ "token": {
+ "expires": "12345",
+ "id": "FAKE_ID",
+ "tenant": {
+ "description": None,
+ "enabled": True,
+ "id": "tenant_id",
+ "name": "demo"
+ } # tenant associated with token
+ },
+ "serviceCatalog": [
+ {
+ "type": "compute",
+ "endpoints": [
+ {
+ "region": "RegionOne",
+ "adminURL": "http://localhost:8774/v2",
+ "internalURL": "http://localhost:8774/v2",
+ "publicURL": "http://localhost:8774/v2/",
+ },
+ ],
+ },
+ ],
+ },
+ }
+ auth_response = utils.TestResponse({
+ "status_code": 200,
+ "text": json.dumps(resp),
+ })
+
+ mock_request = mock.Mock(return_value=(auth_response))
+
+ @mock.patch.object(requests, "request", mock_request)
+ def test_auth_call():
+ cs.client.authenticate()
+ headers = {
+ 'User-Agent': cs.client.USER_AGENT,
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json',
+ }
+ body = {
+ 'auth': {
+ 'passwordCredentials': {
+ 'username': cs.client.user,
+ 'password': cs.client.password,
+ },
+ 'tenantId': cs.client.tenant_id,
+ },
+ }
+
+ token_url = cs.client.auth_url + "/tokens"
+ mock_request.assert_called_with(
+ "POST",
+ token_url,
+ headers=headers,
+ data=json.dumps(body),
+ allow_redirects=True,
+ **self.TEST_REQUEST_BASE)
+
+ endpoints = resp["access"]["serviceCatalog"][0]['endpoints']
+ public_url = endpoints[0]["publicURL"].rstrip('/')
+ self.assertEqual(cs.client.management_url, public_url)
+ token_id = resp["access"]["token"]["id"]
+ self.assertEqual(cs.client.auth_token, token_id)
+ tenant_id = resp["access"]["token"]["tenant"]["id"]
+ self.assertEqual(cs.client.tenant_id, tenant_id)
+
+ test_auth_call()
+
+ def test_authenticate_failure(self):
+ cs = client.Client("username", "password", "project_id",
+ "auth_url/v2.0")
+ resp = {"unauthorized": {"message": "Unauthorized", "code": "401"}}
+ auth_response = utils.TestResponse({
+ "status_code": 401,
+ "text": json.dumps(resp),
+ })
+
+ mock_request = mock.Mock(return_value=(auth_response))
+
+ @mock.patch.object(requests, "request", mock_request)
+ def test_auth_call():
+ self.assertRaises(exceptions.Unauthorized, cs.client.authenticate)
+
+ test_auth_call()
+
+ def test_auth_redirect(self):
+ cs = client.Client("username", "password", "project_id",
+ "auth_url/v2", service_type='compute')
+ dict_correct_response = {
+ "access": {
+ "token": {
+ "expires": "12345",
+ "id": "FAKE_ID",
+ },
+ "serviceCatalog": [
+ {
+ "type": "compute",
+ "endpoints": [
+ {
+ "adminURL": "http://localhost:8774/v2",
+ "region": "RegionOne",
+ "internalURL": "http://localhost:8774/v2",
+ "publicURL": "http://localhost:8774/v2/",
+ },
+ ],
+ },
+ ],
+ },
+ }
+ correct_response = json.dumps(dict_correct_response)
+ dict_responses = [
+ {"headers": {'location':'http://127.0.0.1:5001'},
+ "status_code": 305,
+ "text": "Use proxy"},
+ # Configured on admin port, cinder redirects to v2.0 port.
+ # When trying to connect on it, keystone auth succeed by v1.0
+ # protocol (through headers) but tokens are being returned in
+ # body (looks like keystone bug). Leaved for compatibility.
+ {"headers": {},
+ "status_code": 200,
+ "text": correct_response},
+ {"headers": {},
+ "status_code": 200,
+ "text": correct_response}
+ ]
+
+ responses = [(utils.TestResponse(resp)) for resp in dict_responses]
+
+ def side_effect(*args, **kwargs):
+ return responses.pop(0)
+
+ mock_request = mock.Mock(side_effect=side_effect)
+
+ @mock.patch.object(requests, "request", mock_request)
+ def test_auth_call():
+ cs.client.authenticate()
+ headers = {
+ 'User-Agent': cs.client.USER_AGENT,
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json',
+ }
+ body = {
+ 'auth': {
+ 'passwordCredentials': {
+ 'username': cs.client.user,
+ 'password': cs.client.password,
+ },
+ 'tenantName': cs.client.projectid,
+ },
+ }
+
+ token_url = cs.client.auth_url + "/tokens"
+ mock_request.assert_called_with(
+ "POST",
+ token_url,
+ headers=headers,
+ data=json.dumps(body),
+ allow_redirects=True,
+ **self.TEST_REQUEST_BASE)
+
+ resp = dict_correct_response
+ endpoints = resp["access"]["serviceCatalog"][0]['endpoints']
+ public_url = endpoints[0]["publicURL"].rstrip('/')
+ self.assertEqual(cs.client.management_url, public_url)
+ token_id = resp["access"]["token"]["id"]
+ self.assertEqual(cs.client.auth_token, token_id)
+
+ test_auth_call()
+
+ def test_ambiguous_endpoints(self):
+ cs = client.Client("username", "password", "project_id",
+ "auth_url/v2.0", service_type='compute')
+ resp = {
+ "access": {
+ "token": {
+ "expires": "12345",
+ "id": "FAKE_ID",
+ },
+ "serviceCatalog": [
+ {
+ "adminURL": "http://localhost:8774/v2",
+ "type": "compute",
+ "name": "Compute CLoud",
+ "endpoints": [
+ {
+ "region": "RegionOne",
+ "internalURL": "http://localhost:8774/v2",
+ "publicURL": "http://localhost:8774/v2/",
+ },
+ ],
+ },
+ {
+ "adminURL": "http://localhost:8774/v2",
+ "type": "compute",
+ "name": "Hyper-compute Cloud",
+ "endpoints": [
+ {
+ "internalURL": "http://localhost:8774/v2",
+ "publicURL": "http://localhost:8774/v2/",
+ },
+ ],
+ },
+ ],
+ },
+ }
+ auth_response = utils.TestResponse({
+ "status_code": 200,
+ "text": json.dumps(resp),
+ })
+
+ mock_request = mock.Mock(return_value=(auth_response))
+
+ @mock.patch.object(requests, "request", mock_request)
+ def test_auth_call():
+ self.assertRaises(exceptions.AmbiguousEndpoints,
+ cs.client.authenticate)
+
+ test_auth_call()
+
+
+class AuthenticationTests(utils.TestCase):
+ def test_authenticate_success(self):
+ cs = client.Client("username", "password", "project_id", "auth_url")
+ management_url = 'https://localhost/v2.1/443470'
+ auth_response = utils.TestResponse({
+ 'status_code': 204,
+ 'headers': {
+ 'x-server-management-url': management_url,
+ 'x-auth-token': '1b751d74-de0c-46ae-84f0-915744b582d1',
+ },
+ })
+ mock_request = mock.Mock(return_value=(auth_response))
+
+ @mock.patch.object(requests, "request", mock_request)
+ def test_auth_call():
+ cs.client.authenticate()
+ headers = {
+ 'Accept': 'application/json',
+ 'X-Auth-User': 'username',
+ 'X-Auth-Key': 'password',
+ 'X-Auth-Project-Id': 'project_id',
+ 'User-Agent': cs.client.USER_AGENT
+ }
+ mock_request.assert_called_with(
+ "GET",
+ cs.client.auth_url,
+ headers=headers,
+ **self.TEST_REQUEST_BASE)
+
+ self.assertEqual(cs.client.management_url,
+ auth_response.headers['x-server-management-url'])
+ self.assertEqual(cs.client.auth_token,
+ auth_response.headers['x-auth-token'])
+
+ test_auth_call()
+
+ def test_authenticate_failure(self):
+ cs = client.Client("username", "password", "project_id", "auth_url")
+ auth_response = utils.TestResponse({"status_code": 401})
+ mock_request = mock.Mock(return_value=(auth_response))
+
+ @mock.patch.object(requests, "request", mock_request)
+ def test_auth_call():
+ self.assertRaises(exceptions.Unauthorized, cs.client.authenticate)
+
+ test_auth_call()
+
+ def test_auth_automatic(self):
+ cs = client.Client("username", "password", "project_id", "auth_url")
+ http_client = cs.client
+ http_client.management_url = ''
+ mock_request = mock.Mock(return_value=(None, None))
+
+ @mock.patch.object(http_client, 'request', mock_request)
+ @mock.patch.object(http_client, 'authenticate')
+ def test_auth_call(m):
+ http_client.get('/')
+ m.assert_called()
+ mock_request.assert_called()
+
+ test_auth_call()
+
+ def test_auth_manual(self):
+ cs = client.Client("username", "password", "project_id", "auth_url")
+
+ @mock.patch.object(cs.client, 'authenticate')
+ def test_auth_call(m):
+ cs.authenticate()
+ m.assert_called()
+
+ test_auth_call()
diff --git a/tests/v2/test_quota_classes.py b/tests/v2/test_quota_classes.py
new file mode 100644
index 0000000..6be6cc0
--- /dev/null
+++ b/tests/v2/test_quota_classes.py
@@ -0,0 +1,42 @@
+# 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.
+
+from tests import utils
+from tests.v2 import fakes
+
+
+cs = fakes.FakeClient()
+
+
+class QuotaClassSetsTest(utils.TestCase):
+
+ def test_class_quotas_get(self):
+ class_name = 'test'
+ cs.quota_classes.get(class_name)
+ cs.assert_called('GET', '/os-quota-class-sets/%s' % class_name)
+
+ def test_update_quota(self):
+ q = cs.quota_classes.get('test')
+ q.update(volumes=2)
+ cs.assert_called('PUT', '/os-quota-class-sets/test')
+
+ def test_refresh_quota(self):
+ q = cs.quota_classes.get('test')
+ q2 = cs.quota_classes.get('test')
+ self.assertEqual(q.volumes, q2.volumes)
+ q2.volumes = 0
+ self.assertNotEqual(q.volumes, q2.volumes)
+ q2.get()
+ self.assertEqual(q.volumes, q2.volumes)
diff --git a/tests/v2/test_quotas.py b/tests/v2/test_quotas.py
new file mode 100644
index 0000000..71837a2
--- /dev/null
+++ b/tests/v2/test_quotas.py
@@ -0,0 +1,47 @@
+# 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.
+
+from tests import utils
+from tests.v2 import fakes
+
+
+cs = fakes.FakeClient()
+
+
+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)
+
+ def test_tenant_quotas_defaults(self):
+ tenant_id = 'test'
+ cs.quotas.defaults(tenant_id)
+ cs.assert_called('GET', '/os-quota-sets/%s/defaults' % tenant_id)
+
+ def test_update_quota(self):
+ q = cs.quotas.get('test')
+ q.update(volumes=2)
+ cs.assert_called('PUT', '/os-quota-sets/test')
+
+ def test_refresh_quota(self):
+ q = cs.quotas.get('test')
+ q2 = cs.quotas.get('test')
+ self.assertEqual(q.volumes, q2.volumes)
+ q2.volumes = 0
+ self.assertNotEqual(q.volumes, q2.volumes)
+ q2.get()
+ self.assertEqual(q.volumes, q2.volumes)
diff --git a/tests/v2/test_shell.py b/tests/v2/test_shell.py
new file mode 100644
index 0000000..ce6646f
--- /dev/null
+++ b/tests/v2/test_shell.py
@@ -0,0 +1,159 @@
+# 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/tests/v2/test_types.py b/tests/v2/test_types.py
new file mode 100644
index 0000000..f36b768
--- /dev/null
+++ b/tests/v2/test_types.py
@@ -0,0 +1,50 @@
+# 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.
+
+from cinderclient.v2 import volume_types
+from tests import utils
+from tests.v2 import fakes
+
+cs = fakes.FakeClient()
+
+
+class TypesTest(utils.TestCase):
+ def test_list_types(self):
+ tl = cs.volume_types.list()
+ cs.assert_called('GET', '/types')
+ for t in tl:
+ self.assertTrue(isinstance(t, volume_types.VolumeType))
+
+ def test_create(self):
+ t = cs.volume_types.create('test-type-3')
+ cs.assert_called('POST', '/types')
+ self.assertTrue(isinstance(t, volume_types.VolumeType))
+
+ def test_set_key(self):
+ t = cs.volume_types.get(1)
+ t.set_keys({'k': 'v'})
+ cs.assert_called('POST',
+ '/types/1/extra_specs',
+ {'extra_specs': {'k': 'v'}})
+
+ def test_unsset_keys(self):
+ t = cs.volume_types.get(1)
+ t.unset_keys(['k'])
+ cs.assert_called('DELETE', '/types/1/extra_specs/k')
+
+ def test_delete(self):
+ cs.volume_types.delete(1)
+ cs.assert_called('DELETE', '/types/1')
diff --git a/tests/v2/test_volumes.py b/tests/v2/test_volumes.py
new file mode 100644
index 0000000..85401af
--- /dev/null
+++ b/tests/v2/test_volumes.py
@@ -0,0 +1,87 @@
+# 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.
+
+from tests import utils
+from tests.v2 import fakes
+
+
+cs = fakes.FakeClient()
+
+
+class VolumesTest(utils.TestCase):
+
+ def test_delete_volume(self):
+ v = cs.volumes.list()[0]
+ v.delete()
+ cs.assert_called('DELETE', '/volumes/1234')
+ cs.volumes.delete('1234')
+ cs.assert_called('DELETE', '/volumes/1234')
+ cs.volumes.delete(v)
+ cs.assert_called('DELETE', '/volumes/1234')
+
+ def test_create_keypair(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.assert_called('POST', '/volumes/1234/action')
+
+ def test_detach(self):
+ v = cs.volumes.get('1234')
+ cs.volumes.detach(v)
+ cs.assert_called('POST', '/volumes/1234/action')
+
+ def test_reserve(self):
+ v = cs.volumes.get('1234')
+ cs.volumes.reserve(v)
+ cs.assert_called('POST', '/volumes/1234/action')
+
+ def test_unreserve(self):
+ v = cs.volumes.get('1234')
+ cs.volumes.unreserve(v)
+ cs.assert_called('POST', '/volumes/1234/action')
+
+ def test_begin_detaching(self):
+ v = cs.volumes.get('1234')
+ cs.volumes.begin_detaching(v)
+ cs.assert_called('POST', '/volumes/1234/action')
+
+ def test_roll_detaching(self):
+ v = cs.volumes.get('1234')
+ cs.volumes.roll_detaching(v)
+ cs.assert_called('POST', '/volumes/1234/action')
+
+ def test_initialize_connection(self):
+ v = cs.volumes.get('1234')
+ cs.volumes.initialize_connection(v, {})
+ cs.assert_called('POST', '/volumes/1234/action')
+
+ def test_terminate_connection(self):
+ v = cs.volumes.get('1234')
+ cs.volumes.terminate_connection(v, {})
+ cs.assert_called('POST', '/volumes/1234/action')
+
+ def test_set_metadata(self):
+ cs.volumes.set_metadata(1234, {'k1': 'v2'})
+ cs.assert_called('POST', '/volumes/1234/metadata',
+ {'metadata': {'k1': 'v2'}})
+
+ def test_delete_metadata(self):
+ keys = ['key1']
+ cs.volumes.delete_metadata(1234, keys)
+ cs.assert_called('DELETE', '/volumes/1234/metadata/key1')