diff --git a/doc/v3/api_samples/os-volumes/os-volumes-detail-resp.json b/doc/v3/api_samples/os-volumes/os-volumes-detail-resp.json new file mode 100644 index 0000000000..6a92f5e0d5 --- /dev/null +++ b/doc/v3/api_samples/os-volumes/os-volumes-detail-resp.json @@ -0,0 +1,24 @@ +{ + "volumes": [ + { + "attachments": [ + { + "device": "/", + "id": "a26887c6-c47b-4654-abb5-dfadf7d3f803", + "serverId": "3912f2b4-c5ba-4aec-9165-872876fe202e", + "volumeId": "a26887c6-c47b-4654-abb5-dfadf7d3f803" + } + ], + "availabilityZone": "zone1:host1", + "createdAt": "1999-01-01T01:01:01.000000", + "displayDescription": "Volume Description", + "displayName": "Volume Name", + "id": "a26887c6-c47b-4654-abb5-dfadf7d3f803", + "metadata": {}, + "size": 100, + "snapshotId": null, + "status": "in-use", + "volumeType": "Backup" + } + ] +} diff --git a/doc/v3/api_samples/os-volumes/os-volumes-get-resp.json b/doc/v3/api_samples/os-volumes/os-volumes-get-resp.json new file mode 100644 index 0000000000..5c8429cb1b --- /dev/null +++ b/doc/v3/api_samples/os-volumes/os-volumes-get-resp.json @@ -0,0 +1,22 @@ +{ + "volume": { + "attachments": [ + { + "device": "/", + "id": "a26887c6-c47b-4654-abb5-dfadf7d3f803", + "serverId": "3912f2b4-c5ba-4aec-9165-872876fe202e", + "volumeId": "a26887c6-c47b-4654-abb5-dfadf7d3f803" + } + ], + "availabilityZone": "zone1:host1", + "createdAt": "2013-02-18T14:51:18.528085", + "displayDescription": "Volume Description", + "displayName": "Volume Name", + "id": "a26887c6-c47b-4654-abb5-dfadf7d3f803", + "metadata": {}, + "size": 100, + "snapshotId": null, + "status": "in-use", + "volumeType": "Backup" + } +} \ No newline at end of file diff --git a/doc/v3/api_samples/os-volumes/os-volumes-index-resp.json b/doc/v3/api_samples/os-volumes/os-volumes-index-resp.json new file mode 100644 index 0000000000..c0fa4b8917 --- /dev/null +++ b/doc/v3/api_samples/os-volumes/os-volumes-index-resp.json @@ -0,0 +1,24 @@ +{ + "volumes": [ + { + "attachments": [ + { + "device": "/", + "id": "a26887c6-c47b-4654-abb5-dfadf7d3f803", + "serverId": "3912f2b4-c5ba-4aec-9165-872876fe202e", + "volumeId": "a26887c6-c47b-4654-abb5-dfadf7d3f803" + } + ], + "availabilityZone": "zone1:host1", + "createdAt": "2013-02-19T20:01:40.274897", + "displayDescription": "Volume Description", + "displayName": "Volume Name", + "id": "a26887c6-c47b-4654-abb5-dfadf7d3f803", + "metadata": {}, + "size": 100, + "snapshotId": null, + "status": "in-use", + "volumeType": "Backup" + } + ] +} \ No newline at end of file diff --git a/doc/v3/api_samples/os-volumes/os-volumes-post-req.json b/doc/v3/api_samples/os-volumes/os-volumes-post-req.json new file mode 100644 index 0000000000..bc8d75a9a7 --- /dev/null +++ b/doc/v3/api_samples/os-volumes/os-volumes-post-req.json @@ -0,0 +1,8 @@ +{ + "volume": { + "availability_zone": "zone1:host1", + "display_name": "Volume Name", + "display_description": "Volume Description", + "size": 100 + } +} diff --git a/doc/v3/api_samples/os-volumes/os-volumes-post-resp.json b/doc/v3/api_samples/os-volumes/os-volumes-post-resp.json new file mode 100644 index 0000000000..ba3795a0bb --- /dev/null +++ b/doc/v3/api_samples/os-volumes/os-volumes-post-resp.json @@ -0,0 +1,22 @@ +{ + "volume": { + "attachments": [ + { + "device": "/", + "id": "a26887c6-c47b-4654-abb5-dfadf7d3f803", + "serverId": "3912f2b4-c5ba-4aec-9165-872876fe202e", + "volumeId": "a26887c6-c47b-4654-abb5-dfadf7d3f803" + } + ], + "availabilityZone": "zone1:host1", + "createdAt": "2013-02-18T14:51:17.970024", + "displayDescription": "Volume Description", + "displayName": "Volume Name", + "id": "a26887c6-c47b-4654-abb5-dfadf7d3f803", + "metadata": {}, + "size": 100, + "snapshotId": null, + "status": "in-use", + "volumeType": "Backup" + } +} \ No newline at end of file diff --git a/doc/v3/api_samples/os-volumes/server-post-req.json b/doc/v3/api_samples/os-volumes/server-post-req.json new file mode 100644 index 0000000000..f0bd486482 --- /dev/null +++ b/doc/v3/api_samples/os-volumes/server-post-req.json @@ -0,0 +1,16 @@ +{ + "server": { + "name": "new-server-test", + "imageRef": "http://openstack.example.com/openstack/images/70a599e0-31e7-49b7-b260-868f441e862b", + "flavorRef": "http://openstack.example.com/openstack/flavors/1", + "metadata": { + "My Server Name": "Apache1" + }, + "personality": [ + { + "path": "/etc/banner.txt", + "contents": "ICAgICAgDQoiQSBjbG91ZCBkb2VzIG5vdCBrbm93IHdoeSBpdCBtb3ZlcyBpbiBqdXN0IHN1Y2ggYSBkaXJlY3Rpb24gYW5kIGF0IHN1Y2ggYSBzcGVlZC4uLkl0IGZlZWxzIGFuIGltcHVsc2lvbi4uLnRoaXMgaXMgdGhlIHBsYWNlIHRvIGdvIG5vdy4gQnV0IHRoZSBza3kga25vd3MgdGhlIHJlYXNvbnMgYW5kIHRoZSBwYXR0ZXJucyBiZWhpbmQgYWxsIGNsb3VkcywgYW5kIHlvdSB3aWxsIGtub3csIHRvbywgd2hlbiB5b3UgbGlmdCB5b3Vyc2VsZiBoaWdoIGVub3VnaCB0byBzZWUgYmV5b25kIGhvcml6b25zLiINCg0KLVJpY2hhcmQgQmFjaA==" + } + ] + } +} diff --git a/doc/v3/api_samples/os-volumes/server-post-resp.json b/doc/v3/api_samples/os-volumes/server-post-resp.json new file mode 100644 index 0000000000..2059f423d2 --- /dev/null +++ b/doc/v3/api_samples/os-volumes/server-post-resp.json @@ -0,0 +1,16 @@ +{ + "server": { + "adminPass": "8VqALQcVB9MT", + "id": "a80b9477-84c1-4242-9731-14a3c2a04241", + "links": [ + { + "href": "http://openstack.example.com/v3/servers/a80b9477-84c1-4242-9731-14a3c2a04241", + "rel": "self" + }, + { + "href": "http://openstack.example.com/servers/a80b9477-84c1-4242-9731-14a3c2a04241", + "rel": "bookmark" + } + ] + } +} diff --git a/doc/v3/api_samples/os-volumes/snapshot-create-req.json b/doc/v3/api_samples/os-volumes/snapshot-create-req.json new file mode 100644 index 0000000000..8ad5b3cb04 --- /dev/null +++ b/doc/v3/api_samples/os-volumes/snapshot-create-req.json @@ -0,0 +1,8 @@ +{ + "snapshot": { + "display_name": "snap-001", + "display_description": "Daily backup", + "volume_id": "521752a6-acf6-4b2d-bc7a-119f9148cd8c", + "force": false + } +} \ No newline at end of file diff --git a/doc/v3/api_samples/os-volumes/snapshot-create-resp.json b/doc/v3/api_samples/os-volumes/snapshot-create-resp.json new file mode 100644 index 0000000000..a8dd57d841 --- /dev/null +++ b/doc/v3/api_samples/os-volumes/snapshot-create-resp.json @@ -0,0 +1,11 @@ +{ + "snapshot": { + "createdAt": "2013-02-25T16:27:54.680544", + "displayDescription": "Daily backup", + "displayName": "snap-001", + "id": 100, + "size": 100, + "status": "available", + "volumeId": "521752a6-acf6-4b2d-bc7a-119f9148cd8c" + } +} \ No newline at end of file diff --git a/doc/v3/api_samples/os-volumes/snapshots-detail-resp.json b/doc/v3/api_samples/os-volumes/snapshots-detail-resp.json new file mode 100644 index 0000000000..796e522c43 --- /dev/null +++ b/doc/v3/api_samples/os-volumes/snapshots-detail-resp.json @@ -0,0 +1,31 @@ +{ + "snapshots": [ + { + "createdAt": "2013-02-25T16:27:54.671372", + "displayDescription": "Default description", + "displayName": "Default name", + "id": 100, + "size": 100, + "status": "available", + "volumeId": 12 + }, + { + "createdAt": "2013-02-25T16:27:54.671378", + "displayDescription": "Default description", + "displayName": "Default name", + "id": 101, + "size": 100, + "status": "available", + "volumeId": 12 + }, + { + "createdAt": "2013-02-25T16:27:54.671381", + "displayDescription": "Default description", + "displayName": "Default name", + "id": 102, + "size": 100, + "status": "available", + "volumeId": 12 + } + ] +} \ No newline at end of file diff --git a/doc/v3/api_samples/os-volumes/snapshots-list-resp.json b/doc/v3/api_samples/os-volumes/snapshots-list-resp.json new file mode 100644 index 0000000000..9b7cb412a8 --- /dev/null +++ b/doc/v3/api_samples/os-volumes/snapshots-list-resp.json @@ -0,0 +1,31 @@ +{ + "snapshots": [ + { + "createdAt": "2013-02-25T16:27:54.684999", + "displayDescription": "Default description", + "displayName": "Default name", + "id": 100, + "size": 100, + "status": "available", + "volumeId": 12 + }, + { + "createdAt": "2013-02-25T16:27:54.685005", + "displayDescription": "Default description", + "displayName": "Default name", + "id": 101, + "size": 100, + "status": "available", + "volumeId": 12 + }, + { + "createdAt": "2013-02-25T16:27:54.685008", + "displayDescription": "Default description", + "displayName": "Default name", + "id": 102, + "size": 100, + "status": "available", + "volumeId": 12 + } + ] +} \ No newline at end of file diff --git a/doc/v3/api_samples/os-volumes/snapshots-show-resp.json b/doc/v3/api_samples/os-volumes/snapshots-show-resp.json new file mode 100644 index 0000000000..d7b3f26e41 --- /dev/null +++ b/doc/v3/api_samples/os-volumes/snapshots-show-resp.json @@ -0,0 +1,11 @@ +{ + "snapshot": { + "createdAt": "2013-02-25T16:27:54.724209", + "displayDescription": "Default description", + "displayName": "Default name", + "id": "100", + "size": 100, + "status": "available", + "volumeId": 12 + } +} \ No newline at end of file diff --git a/etc/nova/policy.json b/etc/nova/policy.json index a910eead68..3cdcb8591a 100644 --- a/etc/nova/policy.json +++ b/etc/nova/policy.json @@ -261,6 +261,8 @@ "compute_extension:volume_attachments:create": "", "compute_extension:volume_attachments:update": "", "compute_extension:volume_attachments:delete": "", + "compute_extension:v3:os-volumes": "", + "compute_extension:v3:os-volumes:discoverable": "", "compute_extension:volumetypes": "", "compute_extension:availability_zone:list": "", "compute_extension:v3:os-availability-zone:list": "", diff --git a/nova/api/openstack/compute/plugins/v3/volumes.py b/nova/api/openstack/compute/plugins/v3/volumes.py new file mode 100644 index 0000000000..c0951f99a5 --- /dev/null +++ b/nova/api/openstack/compute/plugins/v3/volumes.py @@ -0,0 +1,356 @@ +# Copyright 2011 Justin Santa Barbara +# 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. + +"""The volumes extension.""" + +import webob +from webob import exc + +from nova.api.openstack import common +from nova.api.openstack import extensions +from nova.api.openstack import wsgi +from nova import exception +from nova.i18n import _ +from nova.openstack.common import log as logging +from nova.openstack.common import strutils +from nova import volume + +ALIAS = "os-volumes" +LOG = logging.getLogger(__name__) +authorize = extensions.extension_authorizer('compute', 'v3:' + ALIAS) + + +def _translate_volume_detail_view(context, vol): + """Maps keys for volumes details view.""" + + d = _translate_volume_summary_view(context, vol) + + # No additional data / lookups at the moment + + return d + + +def _translate_volume_summary_view(context, vol): + """Maps keys for volumes summary view.""" + d = {} + + d['id'] = vol['id'] + d['status'] = vol['status'] + d['size'] = vol['size'] + d['availabilityZone'] = vol['availability_zone'] + d['createdAt'] = vol['created_at'] + + if vol['attach_status'] == 'attached': + d['attachments'] = [_translate_attachment_detail_view(vol['id'], + vol['instance_uuid'], + vol['mountpoint'])] + else: + d['attachments'] = [{}] + + d['displayName'] = vol['display_name'] + d['displayDescription'] = vol['display_description'] + + if vol['volume_type_id'] and vol.get('volume_type'): + d['volumeType'] = vol['volume_type']['name'] + else: + d['volumeType'] = vol['volume_type_id'] + + d['snapshotId'] = vol['snapshot_id'] + LOG.audit(_("vol=%s"), vol, context=context) + + if vol.get('volume_metadata'): + d['metadata'] = vol.get('volume_metadata') + else: + d['metadata'] = {} + + return d + + +class VolumeController(wsgi.Controller): + """The Volumes API controller for the OpenStack API.""" + + def __init__(self): + self.volume_api = volume.API() + super(VolumeController, self).__init__() + + @extensions.expected_errors(404) + def show(self, req, id): + """Return data about the given volume.""" + context = req.environ['nova.context'] + authorize(context) + + try: + vol = self.volume_api.get(context, id) + except exception.NotFound as e: + raise exc.HTTPNotFound(explanation=e.format_message()) + + return {'volume': _translate_volume_detail_view(context, vol)} + + @extensions.expected_errors(404) + def delete(self, req, id): + """Delete a volume.""" + context = req.environ['nova.context'] + authorize(context) + + LOG.audit(_("Delete volume with id: %s"), id, context=context) + + try: + self.volume_api.delete(context, id) + except exception.NotFound as e: + raise exc.HTTPNotFound(explanation=e.format_message()) + return webob.Response(status_int=202) + + @extensions.expected_errors(()) + def index(self, req): + """Returns a summary list of volumes.""" + return self._items(req, entity_maker=_translate_volume_summary_view) + + @extensions.expected_errors(()) + def detail(self, req): + """Returns a detailed list of volumes.""" + return self._items(req, entity_maker=_translate_volume_detail_view) + + def _items(self, req, entity_maker): + """Returns a list of volumes, transformed through entity_maker.""" + context = req.environ['nova.context'] + authorize(context) + + volumes = self.volume_api.get_all(context) + limited_list = common.limited(volumes, req) + res = [entity_maker(context, vol) for vol in limited_list] + return {'volumes': res} + + @extensions.expected_errors(400) + def create(self, req, body): + """Creates a new volume.""" + context = req.environ['nova.context'] + authorize(context) + + if not self.is_valid_body(body, 'volume'): + msg = _("volume not specified") + raise exc.HTTPBadRequest(explanation=msg) + + vol = body['volume'] + + vol_type = vol.get('volume_type') + metadata = vol.get('metadata') + snapshot_id = vol.get('snapshot_id') + + if snapshot_id is not None: + snapshot = self.volume_api.get_snapshot(context, snapshot_id) + else: + snapshot = None + + size = vol.get('size', None) + if size is None and snapshot is not None: + size = snapshot['volume_size'] + + LOG.audit(_("Create volume of %s GB"), size, context=context) + + availability_zone = vol.get('availability_zone') + + try: + new_volume = self.volume_api.create( + context, + size, + vol.get('display_name'), + vol.get('display_description'), + snapshot=snapshot, + volume_type=vol_type, + metadata=metadata, + availability_zone=availability_zone + ) + except exception.InvalidInput as err: + raise exc.HTTPBadRequest(explanation=err.format_message()) + + # TODO(vish): Instance should be None at db layer instead of + # trying to lazy load, but for now we turn it into + # a dict to avoid an error. + retval = _translate_volume_detail_view(context, dict(new_volume)) + result = {'volume': retval} + + location = '%s/%s' % (req.url, new_volume['id']) + + return wsgi.ResponseObject(result, headers=dict(location=location)) + + +def _translate_attachment_detail_view(volume_id, instance_uuid, mountpoint): + """Maps keys for attachment details view.""" + + d = _translate_attachment_summary_view(volume_id, + instance_uuid, + mountpoint) + + # No additional data / lookups at the moment + return d + + +def _translate_attachment_summary_view(volume_id, instance_uuid, mountpoint): + """Maps keys for attachment summary view.""" + d = {} + + # NOTE(justinsb): We use the volume id as the id of the attachment object + d['id'] = volume_id + + d['volumeId'] = volume_id + + d['serverId'] = instance_uuid + if mountpoint: + d['device'] = mountpoint + + return d + + +def _translate_snapshot_detail_view(context, vol): + """Maps keys for snapshots details view.""" + + d = _translate_snapshot_summary_view(context, vol) + + # NOTE(gagupta): No additional data / lookups at the moment + return d + + +def _translate_snapshot_summary_view(context, vol): + """Maps keys for snapshots summary view.""" + d = {} + + d['id'] = vol['id'] + d['volumeId'] = vol['volume_id'] + d['status'] = vol['status'] + # NOTE(gagupta): We map volume_size as the snapshot size + d['size'] = vol['volume_size'] + d['createdAt'] = vol['created_at'] + d['displayName'] = vol['display_name'] + d['displayDescription'] = vol['display_description'] + return d + + +class SnapshotController(wsgi.Controller): + """The Snapshots API controller for the OpenStack API.""" + + def __init__(self): + self.volume_api = volume.API() + super(SnapshotController, self).__init__() + + @extensions.expected_errors(404) + def show(self, req, id): + """Return data about the given snapshot.""" + context = req.environ['nova.context'] + authorize(context) + + try: + vol = self.volume_api.get_snapshot(context, id) + except exception.NotFound as e: + raise exc.HTTPNotFound(explanation=e.format_message()) + + return {'snapshot': _translate_snapshot_detail_view(context, vol)} + + @extensions.expected_errors(404) + def delete(self, req, id): + """Delete a snapshot.""" + context = req.environ['nova.context'] + authorize(context) + + LOG.audit(_("Delete snapshot with id: %s"), id, context=context) + + try: + self.volume_api.delete_snapshot(context, id) + except exception.NotFound as e: + raise exc.HTTPNotFound(explanation=e.format_message()) + return webob.Response(status_int=202) + + @extensions.expected_errors(()) + def index(self, req): + """Returns a summary list of snapshots.""" + return self._items(req, entity_maker=_translate_snapshot_summary_view) + + @extensions.expected_errors(()) + def detail(self, req): + """Returns a detailed list of snapshots.""" + return self._items(req, entity_maker=_translate_snapshot_detail_view) + + def _items(self, req, entity_maker): + """Returns a list of snapshots, transformed through entity_maker.""" + context = req.environ['nova.context'] + authorize(context) + + snapshots = self.volume_api.get_all_snapshots(context) + limited_list = common.limited(snapshots, req) + res = [entity_maker(context, snapshot) for snapshot in limited_list] + return {'snapshots': res} + + @extensions.expected_errors(400) + def create(self, req, body): + """Creates a new snapshot.""" + context = req.environ['nova.context'] + authorize(context) + + if not self.is_valid_body(body, 'snapshot'): + msg = _("snapshot not specified") + raise exc.HTTPBadRequest(explanation=msg) + + snapshot = body['snapshot'] + volume_id = snapshot['volume_id'] + + LOG.audit(_("Create snapshot from volume %s"), volume_id, + context=context) + + force = snapshot.get('force', False) + try: + force = strutils.bool_from_string(force, strict=True) + except ValueError: + msg = _("Invalid value '%s' for force.") % force + raise exc.HTTPBadRequest(explanation=msg) + + if force: + create_func = self.volume_api.create_snapshot_force + else: + create_func = self.volume_api.create_snapshot + + new_snapshot = create_func(context, volume_id, + snapshot.get('display_name'), + snapshot.get('display_description')) + + retval = _translate_snapshot_detail_view(context, new_snapshot) + return {'snapshot': retval} + + +class Volumes(extensions.V3APIExtensionBase): + """Volumes support.""" + + name = "Volumes" + alias = ALIAS + version = 1 + + def get_resources(self): + resources = [] + + res = extensions.ResourceExtension( + ALIAS, VolumeController(), collection_actions={'detail': 'GET'}) + resources.append(res) + + res = extensions.ResourceExtension('os-volumes_boot', + inherits='servers') + resources.append(res) + + res = extensions.ResourceExtension( + 'os-snapshots', SnapshotController(), + collection_actions={'detail': 'GET'}) + resources.append(res) + + return resources + + def get_controller_extensions(self): + return [] diff --git a/nova/tests/api/openstack/compute/contrib/test_volumes.py b/nova/tests/api/openstack/compute/contrib/test_volumes.py index d981479458..32b4ce09c2 100644 --- a/nova/tests/api/openstack/compute/contrib/test_volumes.py +++ b/nova/tests/api/openstack/compute/contrib/test_volumes.py @@ -25,6 +25,7 @@ from webob import exc from nova.api.openstack.compute.contrib import assisted_volume_snapshots as \ assisted_snaps from nova.api.openstack.compute.contrib import volumes +from nova.api.openstack.compute.plugins.v3 import volumes as volumes_v3 from nova.api.openstack import extensions from nova.compute import api as compute_api from nova.compute import flavors @@ -217,9 +218,11 @@ class BootFromVolumeTest(test.TestCase): '/dev/vda') -class VolumeApiTest(test.TestCase): +class VolumeApiTestV21(test.TestCase): + url_prefix = '/v3' + def setUp(self): - super(VolumeApiTest, self).setUp() + super(VolumeApiTestV21, self).setUp() fakes.stub_out_networking(self.stubs) fakes.stub_out_rate_limiting(self.stubs) @@ -232,7 +235,10 @@ class VolumeApiTest(test.TestCase): osapi_compute_ext_list=['Volumes']) self.context = context.get_admin_context() - self.app = fakes.wsgi_app(init_only=('os-volumes',)) + self.app = self._get_app() + + def _get_app(self): + return fakes.wsgi_app_v3() def test_volume_create(self): self.stubs.Set(cinder.API, "create", fakes.stub_volume_create) @@ -242,7 +248,7 @@ class VolumeApiTest(test.TestCase): "display_description": "Volume Test Desc", "availability_zone": "zone1:host1"} body = {"volume": vol} - req = webob.Request.blank('/v2/fake/os-volumes') + req = webob.Request.blank(self.url_prefix + '/os-volumes') req.method = 'POST' req.body = jsonutils.dumps(body) req.headers['content-type'] = 'application/json' @@ -274,35 +280,35 @@ class VolumeApiTest(test.TestCase): "availability_zone": "zone1:host1"} body = {"volume": vol} - req = fakes.HTTPRequest.blank('/v2/fake/os-volumes') + req = fakes.HTTPRequest.blank(self.url_prefix + '/os-volumes') self.assertRaises(webob.exc.HTTPBadRequest, volumes.VolumeController().create, req, body) def test_volume_index(self): - req = webob.Request.blank('/v2/fake/os-volumes') + req = webob.Request.blank(self.url_prefix + '/os-volumes') resp = req.get_response(self.app) self.assertEqual(resp.status_int, 200) def test_volume_detail(self): - req = webob.Request.blank('/v2/fake/os-volumes/detail') + req = webob.Request.blank(self.url_prefix + '/os-volumes/detail') resp = req.get_response(self.app) self.assertEqual(resp.status_int, 200) def test_volume_show(self): - req = webob.Request.blank('/v2/fake/os-volumes/123') + req = webob.Request.blank(self.url_prefix + '/os-volumes/123') resp = req.get_response(self.app) self.assertEqual(resp.status_int, 200) def test_volume_show_no_volume(self): self.stubs.Set(cinder.API, "get", fakes.stub_volume_notfound) - req = webob.Request.blank('/v2/fake/os-volumes/456') + req = webob.Request.blank(self.url_prefix + '/os-volumes/456') resp = req.get_response(self.app) self.assertEqual(resp.status_int, 404) self.assertIn('Volume 456 could not be found.', resp.body) def test_volume_delete(self): - req = webob.Request.blank('/v2/fake/os-volumes/123') + req = webob.Request.blank(self.url_prefix + '/os-volumes/123') req.method = 'DELETE' resp = req.get_response(self.app) self.assertEqual(resp.status_int, 202) @@ -310,13 +316,30 @@ class VolumeApiTest(test.TestCase): def test_volume_delete_no_volume(self): self.stubs.Set(cinder.API, "delete", fakes.stub_volume_notfound) - req = webob.Request.blank('/v2/fake/os-volumes/456') + req = webob.Request.blank(self.url_prefix + '/os-volumes/456') req.method = 'DELETE' resp = req.get_response(self.app) self.assertEqual(resp.status_int, 404) self.assertIn('Volume 456 could not be found.', resp.body) +class VolumeApiTestV2(VolumeApiTestV21): + url_prefix = '/v2/fake' + + def setUp(self): + super(VolumeApiTestV2, self).setUp() + self.flags( + osapi_compute_extension=[ + 'nova.api.openstack.compute.contrib.select_extensions'], + osapi_compute_ext_list=['Volumes']) + + self.context = context.get_admin_context() + self.app = self._get_app() + + def _get_app(self): + return fakes.wsgi_app() + + class VolumeAttachTests(test.TestCase): def setUp(self): super(VolumeAttachTests, self).setUp() @@ -879,24 +902,27 @@ class CommonBadRequestTestCase(object): self._bad_request_create(body=body) -class BadRequestVolumeTestCase(CommonBadRequestTestCase, - test.TestCase): +class BadRequestVolumeTestCaseV21(CommonBadRequestTestCase, + test.TestCase): resource = 'os-volumes' entity_name = 'volume' + controller_cls = volumes_v3.VolumeController + + +class BadRequestVolumeTestCaseV2(BadRequestVolumeTestCaseV21): controller_cls = volumes.VolumeController class BadRequestAttachmentTestCase(CommonBadRequestTestCase, test.TestCase): - resource = 'servers/' + FAKE_UUID + '/os-volume_attachments' entity_name = 'volumeAttachment' controller_cls = volumes.VolumeAttachmentController kwargs = {'server_id': FAKE_UUID} -class BadRequestSnapshotTestCase(CommonBadRequestTestCase, +class BadRequestSnapshotTestCaseV21(CommonBadRequestTestCase, test.TestCase): resource = 'os-snapshots' @@ -904,10 +930,16 @@ class BadRequestSnapshotTestCase(CommonBadRequestTestCase, controller_cls = volumes.SnapshotController -class ShowSnapshotTestCase(test.TestCase): +class BadRequestSnapshotTestCaseV2(BadRequestSnapshotTestCaseV21): + controller_cls = volumes_v3.SnapshotController + + +class ShowSnapshotTestCaseV21(test.TestCase): + snapshot_cls = volumes_v3.SnapshotController + def setUp(self): - super(ShowSnapshotTestCase, self).setUp() - self.controller = volumes.SnapshotController() + super(ShowSnapshotTestCaseV21, self).setUp() + self.controller = self.snapshot_cls() self.req = fakes.HTTPRequest.blank('/v2/fake/os-snapshots') self.req.method = 'GET' @@ -919,10 +951,16 @@ class ShowSnapshotTestCase(test.TestCase): self.controller.show, self.req, FAKE_UUID_A) -class CreateSnapshotTestCase(test.TestCase): +class ShowSnapshotTestCaseV2(ShowSnapshotTestCaseV21): + snapshot_cls = volumes.SnapshotController + + +class CreateSnapshotTestCaseV21(test.TestCase): + snapshot_cls = volumes_v3.SnapshotController + def setUp(self): - super(CreateSnapshotTestCase, self).setUp() - self.controller = volumes.SnapshotController() + super(CreateSnapshotTestCaseV21, self).setUp() + self.controller = self.snapshot_cls() self.stubs.Set(cinder.API, 'get', fake_get_volume) self.stubs.Set(cinder.API, 'create_snapshot_force', fake_create_snapshot) @@ -945,10 +983,16 @@ class CreateSnapshotTestCase(test.TestCase): self.controller.create, self.req, body=self.body) -class DeleteSnapshotTestCase(test.TestCase): +class CreateSnapshotTestCaseV2(CreateSnapshotTestCaseV21): + snapshot_cls = volumes.SnapshotController + + +class DeleteSnapshotTestCaseV21(test.TestCase): + snapshot_cls = volumes_v3.SnapshotController + def setUp(self): - super(DeleteSnapshotTestCase, self).setUp() - self.controller = volumes.SnapshotController() + super(DeleteSnapshotTestCaseV21, self).setUp() + self.controller = self.snapshot_cls() self.stubs.Set(cinder.API, 'get', fake_get_volume) self.stubs.Set(cinder.API, 'create_snapshot_force', fake_create_snapshot) @@ -980,6 +1024,10 @@ class DeleteSnapshotTestCase(test.TestCase): self.req, result['snapshot']['id']) +class DeleteSnapshotTestCaseV2(DeleteSnapshotTestCaseV21): + snapshot_cls = volumes.SnapshotController + + class AssistedSnapshotCreateTestCase(test.TestCase): def setUp(self): super(AssistedSnapshotCreateTestCase, self).setUp() diff --git a/nova/tests/fake_policy.py b/nova/tests/fake_policy.py index 684fcd2b76..8643cd4c1e 100644 --- a/nova/tests/fake_policy.py +++ b/nova/tests/fake_policy.py @@ -297,6 +297,7 @@ policy_data = """ "compute_extension:volume_attachments:create": "", "compute_extension:volume_attachments:update": "", "compute_extension:volume_attachments:delete": "", + "compute_extension:v3:os-volumes": "", "compute_extension:volumetypes": "", "compute_extension:zones": "", "compute_extension:availability_zone:list": "", diff --git a/nova/tests/integrated/v3/api_samples/os-volumes/os-volumes-detail-resp.json.tpl b/nova/tests/integrated/v3/api_samples/os-volumes/os-volumes-detail-resp.json.tpl new file mode 100644 index 0000000000..82a63eda5f --- /dev/null +++ b/nova/tests/integrated/v3/api_samples/os-volumes/os-volumes-detail-resp.json.tpl @@ -0,0 +1,24 @@ +{ + "volumes": [ + { + "attachments": [ + { + "device": "/", + "id": "%(uuid)s", + "serverId": "%(uuid)s", + "volumeId": "%(uuid)s" + } + ], + "availabilityZone": "zone1:host1", + "createdAt": "%(strtime)s", + "displayDescription": "%(volume_desc)s", + "displayName": "%(volume_name)s", + "id": "%(uuid)s", + "metadata": {}, + "size": 100, + "snapshotId": null, + "status": "in-use", + "volumeType": "Backup" + } + ] +} diff --git a/nova/tests/integrated/v3/api_samples/os-volumes/os-volumes-get-resp.json.tpl b/nova/tests/integrated/v3/api_samples/os-volumes/os-volumes-get-resp.json.tpl new file mode 100644 index 0000000000..84bfdd2a5b --- /dev/null +++ b/nova/tests/integrated/v3/api_samples/os-volumes/os-volumes-get-resp.json.tpl @@ -0,0 +1,22 @@ +{ + "volume": { + "attachments": [ + { + "device": "/", + "id": "%(uuid)s", + "serverId": "%(uuid)s", + "volumeId": "%(uuid)s" + } + ], + "availabilityZone": "zone1:host1", + "createdAt": "%(strtime)s", + "displayDescription": "%(volume_desc)s", + "displayName": "%(volume_name)s", + "id": "%(uuid)s", + "metadata": {}, + "size": 100, + "snapshotId": null, + "status": "in-use", + "volumeType": "Backup" + } +} diff --git a/nova/tests/integrated/v3/api_samples/os-volumes/os-volumes-index-resp.json.tpl b/nova/tests/integrated/v3/api_samples/os-volumes/os-volumes-index-resp.json.tpl new file mode 100644 index 0000000000..82a63eda5f --- /dev/null +++ b/nova/tests/integrated/v3/api_samples/os-volumes/os-volumes-index-resp.json.tpl @@ -0,0 +1,24 @@ +{ + "volumes": [ + { + "attachments": [ + { + "device": "/", + "id": "%(uuid)s", + "serverId": "%(uuid)s", + "volumeId": "%(uuid)s" + } + ], + "availabilityZone": "zone1:host1", + "createdAt": "%(strtime)s", + "displayDescription": "%(volume_desc)s", + "displayName": "%(volume_name)s", + "id": "%(uuid)s", + "metadata": {}, + "size": 100, + "snapshotId": null, + "status": "in-use", + "volumeType": "Backup" + } + ] +} diff --git a/nova/tests/integrated/v3/api_samples/os-volumes/os-volumes-post-req.json.tpl b/nova/tests/integrated/v3/api_samples/os-volumes/os-volumes-post-req.json.tpl new file mode 100644 index 0000000000..33e9a68944 --- /dev/null +++ b/nova/tests/integrated/v3/api_samples/os-volumes/os-volumes-post-req.json.tpl @@ -0,0 +1,8 @@ +{ + "volume": { + "availability_zone": "zone1:host1", + "display_name": "%(volume_name)s", + "display_description": "%(volume_desc)s", + "size": 100 + } +} diff --git a/nova/tests/integrated/v3/api_samples/os-volumes/os-volumes-post-resp.json.tpl b/nova/tests/integrated/v3/api_samples/os-volumes/os-volumes-post-resp.json.tpl new file mode 100644 index 0000000000..d13ce20cc3 --- /dev/null +++ b/nova/tests/integrated/v3/api_samples/os-volumes/os-volumes-post-resp.json.tpl @@ -0,0 +1,21 @@ +{ + "volume": { + "status": "in-use", + "displayDescription": "%(volume_desc)s", + "availabilityZone": "zone1:host1", + "displayName": "%(volume_name)s", + "attachments": [ + { "device": "/", + "serverId": "%(uuid)s", + "id": "%(uuid)s", + "volumeId": "%(uuid)s" + } + ], + "volumeType": "Backup", + "snapshotId": null, + "metadata": {}, + "id": "%(uuid)s", + "createdAt": "%(strtime)s", + "size": 100 + } +} diff --git a/nova/tests/integrated/v3/api_samples/os-volumes/server-post-req.json.tpl b/nova/tests/integrated/v3/api_samples/os-volumes/server-post-req.json.tpl new file mode 100644 index 0000000000..3271a58a7d --- /dev/null +++ b/nova/tests/integrated/v3/api_samples/os-volumes/server-post-req.json.tpl @@ -0,0 +1,16 @@ +{ + "server": { + "name": "new-server-test", + "imageRef": "%(host)s/openstack/images/%(image_id)s", + "flavorRef": "%(host)s/openstack/flavors/1", + "metadata": { + "My Server Name": "Apache1" + }, + "personality": [ + { + "path": "/etc/banner.txt", + "contents": "ICAgICAgDQoiQSBjbG91ZCBkb2VzIG5vdCBrbm93IHdoeSBpdCBtb3ZlcyBpbiBqdXN0IHN1Y2ggYSBkaXJlY3Rpb24gYW5kIGF0IHN1Y2ggYSBzcGVlZC4uLkl0IGZlZWxzIGFuIGltcHVsc2lvbi4uLnRoaXMgaXMgdGhlIHBsYWNlIHRvIGdvIG5vdy4gQnV0IHRoZSBza3kga25vd3MgdGhlIHJlYXNvbnMgYW5kIHRoZSBwYXR0ZXJucyBiZWhpbmQgYWxsIGNsb3VkcywgYW5kIHlvdSB3aWxsIGtub3csIHRvbywgd2hlbiB5b3UgbGlmdCB5b3Vyc2VsZiBoaWdoIGVub3VnaCB0byBzZWUgYmV5b25kIGhvcml6b25zLiINCg0KLVJpY2hhcmQgQmFjaA==" + } + ] + } +} diff --git a/nova/tests/integrated/v3/api_samples/os-volumes/server-post-resp.json.tpl b/nova/tests/integrated/v3/api_samples/os-volumes/server-post-resp.json.tpl new file mode 100644 index 0000000000..adfaaa381e --- /dev/null +++ b/nova/tests/integrated/v3/api_samples/os-volumes/server-post-resp.json.tpl @@ -0,0 +1,16 @@ +{ + "server": { + "adminPass": "%(password)s", + "id": "%(id)s", + "links": [ + { + "href": "%(host)s/v3/servers/%(uuid)s", + "rel": "self" + }, + { + "href": "%(host)s/servers/%(uuid)s", + "rel": "bookmark" + } + ] + } +} diff --git a/nova/tests/integrated/v3/api_samples/os-volumes/snapshot-create-req.json.tpl b/nova/tests/integrated/v3/api_samples/os-volumes/snapshot-create-req.json.tpl new file mode 100644 index 0000000000..a8d47ea031 --- /dev/null +++ b/nova/tests/integrated/v3/api_samples/os-volumes/snapshot-create-req.json.tpl @@ -0,0 +1,8 @@ +{ + "snapshot": { + "display_name": "%(snapshot_name)s", + "display_description": "%(description)s", + "volume_id": "%(volume_id)s", + "force": false + } +} diff --git a/nova/tests/integrated/v3/api_samples/os-volumes/snapshot-create-resp.json.tpl b/nova/tests/integrated/v3/api_samples/os-volumes/snapshot-create-resp.json.tpl new file mode 100644 index 0000000000..6153e8140e --- /dev/null +++ b/nova/tests/integrated/v3/api_samples/os-volumes/snapshot-create-resp.json.tpl @@ -0,0 +1,11 @@ +{ + "snapshot": { + "createdAt": "%(strtime)s", + "displayDescription": "%(description)s", + "displayName": "%(snapshot_name)s", + "id": 100, + "size": 100, + "status": "available", + "volumeId": "%(uuid)s" + } +} diff --git a/nova/tests/integrated/v3/api_samples/os-volumes/snapshots-detail-resp.json.tpl b/nova/tests/integrated/v3/api_samples/os-volumes/snapshots-detail-resp.json.tpl new file mode 100644 index 0000000000..1b509d54f8 --- /dev/null +++ b/nova/tests/integrated/v3/api_samples/os-volumes/snapshots-detail-resp.json.tpl @@ -0,0 +1,31 @@ +{ + "snapshots": [ + { + "createdAt": "%(strtime)s", + "displayDescription": "Default description", + "displayName": "Default name", + "id": 100, + "size": 100, + "status": "available", + "volumeId": 12 + }, + { + "createdAt": "%(strtime)s", + "displayDescription": "Default description", + "displayName": "Default name", + "id": 101, + "size": 100, + "status": "available", + "volumeId": 12 + }, + { + "createdAt": "%(strtime)s", + "displayDescription": "Default description", + "displayName": "Default name", + "id": 102, + "size": 100, + "status": "available", + "volumeId": 12 + } + ] +} diff --git a/nova/tests/integrated/v3/api_samples/os-volumes/snapshots-list-resp.json.tpl b/nova/tests/integrated/v3/api_samples/os-volumes/snapshots-list-resp.json.tpl new file mode 100644 index 0000000000..c65d073ad7 --- /dev/null +++ b/nova/tests/integrated/v3/api_samples/os-volumes/snapshots-list-resp.json.tpl @@ -0,0 +1,31 @@ +{ + "snapshots": [ + { + "createdAt": "%(strtime)s", + "displayDescription": "%(text)s", + "displayName": "%(text)s", + "id": 100, + "size": 100, + "status": "available", + "volumeId": 12 + }, + { + "createdAt": "%(strtime)s", + "displayDescription": "%(text)s", + "displayName": "%(text)s", + "id": 101, + "size": 100, + "status": "available", + "volumeId": 12 + }, + { + "createdAt": "%(strtime)s", + "displayDescription": "%(text)s", + "displayName": "%(text)s", + "id": 102, + "size": 100, + "status": "available", + "volumeId": 12 + } + ] +} diff --git a/nova/tests/integrated/v3/api_samples/os-volumes/snapshots-show-resp.json.tpl b/nova/tests/integrated/v3/api_samples/os-volumes/snapshots-show-resp.json.tpl new file mode 100644 index 0000000000..a9ab6240d6 --- /dev/null +++ b/nova/tests/integrated/v3/api_samples/os-volumes/snapshots-show-resp.json.tpl @@ -0,0 +1,11 @@ +{ + "snapshot": { + "createdAt": "%(strtime)s", + "displayDescription": "%(description)s", + "displayName": "%(snapshot_name)s", + "id": "100", + "size": 100, + "status": "available", + "volumeId": 12 + } +} diff --git a/nova/tests/integrated/v3/test_volumes.py b/nova/tests/integrated/v3/test_volumes.py new file mode 100644 index 0000000000..a7f1cae927 --- /dev/null +++ b/nova/tests/integrated/v3/test_volumes.py @@ -0,0 +1,184 @@ +# Copyright 2012 Nebula, Inc. +# Copyright 2014 IBM Corp. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import datetime + +from nova.tests.api.openstack import fakes +from nova.tests.integrated.v3 import api_sample_base +from nova.tests.integrated.v3 import test_servers +from nova.volume import cinder + + +class SnapshotsSampleJsonTests(api_sample_base.ApiSampleTestBaseV3): + extension_name = "os-volumes" + + create_subs = { + 'snapshot_name': 'snap-001', + 'description': 'Daily backup', + 'volume_id': '521752a6-acf6-4b2d-bc7a-119f9148cd8c' + } + + def setUp(self): + super(SnapshotsSampleJsonTests, self).setUp() + self.stubs.Set(cinder.API, "get_all_snapshots", + fakes.stub_snapshot_get_all) + self.stubs.Set(cinder.API, "get_snapshot", fakes.stub_snapshot_get) + + def _create_snapshot(self): + self.stubs.Set(cinder.API, "create_snapshot", + fakes.stub_snapshot_create) + + response = self._do_post("os-snapshots", + "snapshot-create-req", + self.create_subs) + return response + + def test_snapshots_create(self): + response = self._create_snapshot() + self.create_subs.update(self._get_regexes()) + self._verify_response("snapshot-create-resp", + self.create_subs, response, 200) + + def test_snapshots_delete(self): + self.stubs.Set(cinder.API, "delete_snapshot", + fakes.stub_snapshot_delete) + self._create_snapshot() + response = self._do_delete('os-snapshots/100') + self.assertEqual(response.status, 202) + self.assertEqual(response.read(), '') + + def test_snapshots_detail(self): + response = self._do_get('os-snapshots/detail') + subs = self._get_regexes() + self._verify_response('snapshots-detail-resp', subs, response, 200) + + def test_snapshots_list(self): + response = self._do_get('os-snapshots') + subs = self._get_regexes() + self._verify_response('snapshots-list-resp', subs, response, 200) + + def test_snapshots_show(self): + response = self._do_get('os-snapshots/100') + subs = { + 'snapshot_name': 'Default name', + 'description': 'Default description' + } + subs.update(self._get_regexes()) + self._verify_response('snapshots-show-resp', subs, response, 200) + + +class VolumesSampleJsonTest(test_servers.ServersSampleBase): + extension_name = "os-volumes" + + def _get_volume_id(self): + return 'a26887c6-c47b-4654-abb5-dfadf7d3f803' + + def _stub_volume(self, id, displayname="Volume Name", + displaydesc="Volume Description", size=100): + volume = { + 'id': id, + 'size': size, + 'availability_zone': 'zone1:host1', + 'instance_uuid': '3912f2b4-c5ba-4aec-9165-872876fe202e', + 'mountpoint': '/', + 'status': 'in-use', + 'attach_status': 'attached', + 'name': 'vol name', + 'display_name': displayname, + 'display_description': displaydesc, + 'created_at': datetime.datetime(2008, 12, 1, 11, 1, 55), + 'snapshot_id': None, + 'volume_type_id': 'fakevoltype', + 'volume_metadata': [], + 'volume_type': {'name': 'Backup'} + } + return volume + + def _stub_volume_get(self, context, volume_id): + return self._stub_volume(volume_id) + + def _stub_volume_delete(self, context, *args, **param): + pass + + def _stub_volume_get_all(self, context, search_opts=None): + id = self._get_volume_id() + return [self._stub_volume(id)] + + def _stub_volume_create(self, context, size, name, description, snapshot, + **param): + id = self._get_volume_id() + return self._stub_volume(id) + + def setUp(self): + super(VolumesSampleJsonTest, self).setUp() + fakes.stub_out_networking(self.stubs) + fakes.stub_out_rate_limiting(self.stubs) + + self.stubs.Set(cinder.API, "delete", self._stub_volume_delete) + self.stubs.Set(cinder.API, "get", self._stub_volume_get) + self.stubs.Set(cinder.API, "get_all", self._stub_volume_get_all) + + def _post_volume(self): + subs_req = { + 'volume_name': "Volume Name", + 'volume_desc': "Volume Description", + } + + self.stubs.Set(cinder.API, "create", self._stub_volume_create) + response = self._do_post('os-volumes', 'os-volumes-post-req', + subs_req) + subs = self._get_regexes() + subs.update(subs_req) + self._verify_response('os-volumes-post-resp', subs, response, 200) + + def test_volumes_show(self): + subs = { + 'volume_name': "Volume Name", + 'volume_desc': "Volume Description", + } + vol_id = self._get_volume_id() + response = self._do_get('os-volumes/%s' % vol_id) + subs.update(self._get_regexes()) + self._verify_response('os-volumes-get-resp', subs, response, 200) + + def test_volumes_index(self): + subs = { + 'volume_name': "Volume Name", + 'volume_desc': "Volume Description", + } + response = self._do_get('os-volumes') + subs.update(self._get_regexes()) + self._verify_response('os-volumes-index-resp', subs, response, 200) + + def test_volumes_detail(self): + # For now, index and detail are the same. + # See the volumes api + subs = { + 'volume_name': "Volume Name", + 'volume_desc': "Volume Description", + } + response = self._do_get('os-volumes/detail') + subs.update(self._get_regexes()) + self._verify_response('os-volumes-detail-resp', subs, response, 200) + + def test_volumes_create(self): + self._post_volume() + + def test_volumes_delete(self): + self._post_volume() + vol_id = self._get_volume_id() + response = self._do_delete('os-volumes/%s' % vol_id) + self.assertEqual(response.status, 202) + self.assertEqual(response.read(), '') diff --git a/setup.cfg b/setup.cfg index b2780ceb3f..4fea329f65 100644 --- a/setup.cfg +++ b/setup.cfg @@ -114,6 +114,7 @@ nova.api.v3.extensions = simple_tenant_usage = nova.api.openstack.compute.plugins.v3.simple_tenant_usage:SimpleTenantUsage suspend_server = nova.api.openstack.compute.plugins.v3.suspend_server:SuspendServer versions = nova.api.openstack.compute.plugins.v3.versions:Versions + volumes = nova.api.openstack.compute.plugins.v3.volumes:Volumes nova.api.v3.extensions.server.create = access_ips = nova.api.openstack.compute.plugins.v3.access_ips:AccessIPs