Port volume_attachments extension to v2.1 API

This patch ports v2 volume_attachments & volume_attachment_update
extension to v2.1.
Unittest code modified to share testing with both v2/v2.1.
API sample file and their tests have been added.

Partially implements blueprint v2-on-v3-api

Change-Id: Icf63529f317bc61debc665641d1c9d9f2e45bcec
This commit is contained in:
ghanshyam 2014-09-19 14:28:48 +09:00
parent 90dee8d431
commit d2367ea1d2
15 changed files with 532 additions and 20 deletions

View File

@ -0,0 +1,6 @@
{
"volumeAttachment": {
"volumeId": "a26887c6-c47b-4654-abb5-dfadf7d3f803",
"device": "/dev/vdd"
}
}

View File

@ -0,0 +1,8 @@
{
"volumeAttachment": {
"device": "/dev/vdd",
"id": "a26887c6-c47b-4654-abb5-dfadf7d3f803",
"serverId": "0c92f3f6-c253-4c9b-bd43-e880a8d2eb0a",
"volumeId": "a26887c6-c47b-4654-abb5-dfadf7d3f803"
}
}

View File

@ -0,0 +1,16 @@
{
"volumeAttachments": [
{
"device": "/dev/sdd",
"id": "a26887c6-c47b-4654-abb5-dfadf7d3f803",
"serverId": "4d8c3732-a248-40ed-bebc-539a6ffd25c0",
"volumeId": "a26887c6-c47b-4654-abb5-dfadf7d3f803"
},
{
"device": "/dev/sdc",
"id": "a26887c6-c47b-4654-abb5-dfadf7d3f804",
"serverId": "4d8c3732-a248-40ed-bebc-539a6ffd25c0",
"volumeId": "a26887c6-c47b-4654-abb5-dfadf7d3f804"
}
]
}

View File

@ -0,0 +1,6 @@
{
"volumeAttachment": {
"volumeId": "a26887c6-c47b-4654-abb5-dfadf7d3f805",
"device": "/dev/sdd"
}
}

View File

@ -0,0 +1,8 @@
{
"volumeAttachment": {
"device": "/dev/sdd",
"id": "a26887c6-c47b-4654-abb5-dfadf7d3f803",
"serverId": "2390fb4d-1693-45d7-b309-e29c4af16538",
"volumeId": "a26887c6-c47b-4654-abb5-dfadf7d3f803"
}
}

View File

@ -295,6 +295,12 @@
"compute_extension:volume_attachments:delete": "",
"compute_extension:v3:os-volumes": "",
"compute_extension:v3:os-volumes:discoverable": "",
"compute_extension:v3:os-volumes-attachments:index": "",
"compute_extension:v3:os-volumes-attachments:show": "",
"compute_extension:v3:os-volumes-attachments:create": "",
"compute_extension:v3:os-volumes-attachments:update": "",
"compute_extension:v3:os-volumes-attachments:delete": "",
"compute_extension:v3:os-volumes-attachments:discoverable": "",
"compute_extension:volumetypes": "",
"compute_extension:availability_zone:list": "",
"compute_extension:v3:os-availability-zone:list": "",

View File

@ -23,11 +23,17 @@ from nova.api.openstack.compute.schemas.v3 import volumes as volumes_schema
from nova.api.openstack import extensions
from nova.api.openstack import wsgi
from nova.api import validation
from nova import compute
from nova import exception
from nova.i18n import _
from nova import objects
from nova.openstack.common import uuidutils
from nova import volume
ALIAS = "os-volumes"
authorize = extensions.extension_authorizer('compute', 'v3:' + ALIAS)
authorize_attach = extensions.extension_authorizer('compute',
'v3:os-volumes-attachments')
def _translate_volume_detail_view(context, vol):
@ -203,6 +209,252 @@ def _translate_attachment_summary_view(volume_id, instance_uuid, mountpoint):
return d
class VolumeAttachmentController(wsgi.Controller):
"""The volume attachment API controller for the OpenStack API.
A child resource of the server. Note that we use the volume id
as the ID of the attachment (though this is not guaranteed externally)
"""
def __init__(self):
self.compute_api = compute.API()
self.volume_api = volume.API()
super(VolumeAttachmentController, self).__init__()
@extensions.expected_errors(404)
def index(self, req, server_id):
"""Returns the list of volume attachments for a given instance."""
context = req.environ['nova.context']
authorize_attach(context, action='index')
return self._items(req, server_id,
entity_maker=_translate_attachment_summary_view)
@extensions.expected_errors(404)
def show(self, req, server_id, id):
"""Return data about the given volume attachment."""
context = req.environ['nova.context']
authorize(context)
authorize_attach(context, action='show')
volume_id = id
instance = common.get_instance(self.compute_api, context, server_id,
want_objects=True)
bdms = objects.BlockDeviceMappingList.get_by_instance_uuid(
context, instance['uuid'])
if not bdms:
msg = _("Instance %s is not attached.") % server_id
raise exc.HTTPNotFound(explanation=msg)
assigned_mountpoint = None
for bdm in bdms:
if bdm.volume_id == volume_id:
assigned_mountpoint = bdm.device_name
break
if assigned_mountpoint is None:
msg = _("volume_id not found: %s") % volume_id
raise exc.HTTPNotFound(explanation=msg)
return {'volumeAttachment': _translate_attachment_detail_view(
volume_id,
instance['uuid'],
assigned_mountpoint)}
def _validate_volume_id(self, volume_id):
if not uuidutils.is_uuid_like(volume_id):
msg = _("Bad volumeId format: volumeId is "
"not in proper format (%s)") % volume_id
raise exc.HTTPBadRequest(explanation=msg)
@extensions.expected_errors((400, 404, 409))
def create(self, req, server_id, body):
"""Attach a volume to an instance."""
context = req.environ['nova.context']
authorize(context)
authorize_attach(context, action='create')
if not self.is_valid_body(body, 'volumeAttachment'):
msg = _("volumeAttachment not specified")
raise exc.HTTPBadRequest(explanation=msg)
try:
volume_id = body['volumeAttachment']['volumeId']
except KeyError:
msg = _("volumeId must be specified.")
raise exc.HTTPBadRequest(explanation=msg)
device = body['volumeAttachment'].get('device')
self._validate_volume_id(volume_id)
instance = common.get_instance(self.compute_api, context, server_id,
want_objects=True)
try:
device = self.compute_api.attach_volume(context, instance,
volume_id, device)
except exception.VolumeNotFound as e:
raise exc.HTTPNotFound(explanation=e.format_message())
except exception.InstanceIsLocked as e:
raise exc.HTTPConflict(explanation=e.format_message())
except exception.InstanceInvalidState as state_error:
common.raise_http_conflict_for_instance_invalid_state(state_error,
'attach_volume', server_id)
except (exception.InvalidVolume,
exception.InvalidDevicePath) as e:
raise exc.HTTPBadRequest(explanation=e.format_message())
# The attach is async
attachment = {}
attachment['id'] = volume_id
attachment['serverId'] = server_id
attachment['volumeId'] = volume_id
attachment['device'] = device
# NOTE(justinsb): And now, we have a problem...
# The attach is async, so there's a window in which we don't see
# the attachment (until the attachment completes). We could also
# get problems with concurrent requests. I think we need an
# attachment state, and to write to the DB here, but that's a bigger
# change.
# For now, we'll probably have to rely on libraries being smart
# TODO(justinsb): How do I return "accepted" here?
return {'volumeAttachment': attachment}
@wsgi.response(202)
@extensions.expected_errors((400, 404, 409))
def update(self, req, server_id, id, body):
context = req.environ['nova.context']
authorize(context)
authorize_attach(context, action='update')
if not self.is_valid_body(body, 'volumeAttachment'):
msg = _("volumeAttachment not specified")
raise exc.HTTPBadRequest(explanation=msg)
old_volume_id = id
try:
old_volume = self.volume_api.get(context, old_volume_id)
try:
new_volume_id = body['volumeAttachment']['volumeId']
except KeyError:
msg = _("volumeId must be specified.")
raise exc.HTTPBadRequest(explanation=msg)
self._validate_volume_id(new_volume_id)
new_volume = self.volume_api.get(context, new_volume_id)
except exception.VolumeNotFound as e:
raise exc.HTTPNotFound(explanation=e.format_message())
instance = common.get_instance(self.compute_api, context, server_id,
want_objects=True)
bdms = objects.BlockDeviceMappingList.get_by_instance_uuid(
context, instance.uuid)
found = False
try:
for bdm in bdms:
if bdm.volume_id != old_volume_id:
continue
try:
self.compute_api.swap_volume(context, instance, old_volume,
new_volume)
found = True
break
except exception.VolumeUnattached:
# The volume is not attached. Treat it as NotFound
# by falling through.
pass
except exception.InvalidVolume as e:
raise exc.HTTPBadRequest(explanation=e.format_message())
except exception.InstanceIsLocked as e:
raise exc.HTTPConflict(explanation=e.format_message())
except exception.InstanceInvalidState as state_error:
common.raise_http_conflict_for_instance_invalid_state(state_error,
'swap_volume', server_id)
if not found:
msg = _("The volume was either invalid or not attached to the "
"instance.")
raise exc.HTTPNotFound(explanation=msg)
@wsgi.response(202)
@extensions.expected_errors((400, 403, 404, 409))
def delete(self, req, server_id, id):
"""Detach a volume from an instance."""
context = req.environ['nova.context']
authorize(context)
authorize_attach(context, action='delete')
volume_id = id
instance = common.get_instance(self.compute_api, context, server_id,
want_objects=True)
try:
volume = self.volume_api.get(context, volume_id)
except exception.VolumeNotFound as e:
raise exc.HTTPNotFound(explanation=e.format_message())
bdms = objects.BlockDeviceMappingList.get_by_instance_uuid(
context, instance['uuid'])
if not bdms:
msg = _("Instance %s is not attached.") % server_id
raise exc.HTTPNotFound(explanation=msg)
found = False
try:
for bdm in bdms:
if bdm.volume_id != volume_id:
continue
if bdm.is_root:
msg = _("Can't detach root device volume")
raise exc.HTTPForbidden(explanation=msg)
try:
self.compute_api.detach_volume(context, instance, volume)
found = True
break
except exception.VolumeUnattached:
# The volume is not attached. Treat it as NotFound
# by falling through.
pass
except exception.InvalidVolume as e:
raise exc.HTTPBadRequest(explanation=e.format_message())
except exception.InstanceIsLocked as e:
raise exc.HTTPConflict(explanation=e.format_message())
except exception.InstanceInvalidState as state_error:
common.raise_http_conflict_for_instance_invalid_state(state_error,
'detach_volume', server_id)
if not found:
msg = _("volume_id not found: %s") % volume_id
raise exc.HTTPNotFound(explanation=msg)
def _items(self, req, server_id, entity_maker):
"""Returns a list of attachments, transformed through entity_maker."""
context = req.environ['nova.context']
authorize(context)
instance = common.get_instance(self.compute_api, context, server_id,
want_objects=True)
bdms = objects.BlockDeviceMappingList.get_by_instance_uuid(
context, instance['uuid'])
limited_list = common.limited(bdms, req)
results = []
for bdm in limited_list:
if bdm.volume_id:
results.append(entity_maker(bdm.volume_id,
bdm.instance_uuid,
bdm.device_name))
return {'volumeAttachments': results}
def _translate_snapshot_detail_view(context, vol):
"""Maps keys for snapshots details view."""
@ -322,6 +574,13 @@ class Volumes(extensions.V3APIExtensionBase):
inherits='servers')
resources.append(res)
res = extensions.ResourceExtension('os-volume_attachments',
VolumeAttachmentController(),
parent=dict(
member_name='server',
collection_name='servers'))
resources.append(res)
res = extensions.ResourceExtension(
'os-snapshots', SnapshotController(),
collection_actions={'detail': 'GET'})

View File

@ -340,9 +340,9 @@ class VolumeApiTestV2(VolumeApiTestV21):
return fakes.wsgi_app()
class VolumeAttachTests(test.TestCase):
class VolumeAttachTestsV21(test.TestCase):
def setUp(self):
super(VolumeAttachTests, self).setUp()
super(VolumeAttachTestsV21, self).setUp()
self.stubs.Set(db, 'block_device_mapping_get_all_by_instance',
fake_bdms_get_all_by_instance)
self.stubs.Set(compute_api.API, 'get', fake_get_instance)
@ -354,9 +354,10 @@ class VolumeAttachTests(test.TestCase):
'id': FAKE_UUID_A,
'volumeId': FAKE_UUID_A
}}
self.ext_mgr = extensions.ExtensionManager()
self.ext_mgr.extensions = {}
self.attachments = volumes.VolumeAttachmentController(self.ext_mgr)
self._set_up_controller()
def _set_up_controller(self):
self.attachments = volumes_v3.VolumeAttachmentController()
def test_show(self):
req = webob.Request.blank('/v2/servers/id/os-volume_attachments/uuid')
@ -423,7 +424,14 @@ class VolumeAttachTests(test.TestCase):
req.environ['nova.context'] = self.context
result = self.attachments.delete(req, FAKE_UUID, FAKE_UUID_A)
self.assertEqual('202 Accepted', result.status)
# NOTE: on v2.1, http status code is set as wsgi_code of API
# method instead of status_int in a response object.
if isinstance(self.attachments,
volumes_v3.VolumeAttachmentController):
status_int = self.attachments.delete.wsgi_code
else:
status_int = result.status_int
self.assertEqual(202, status_int)
def test_detach_vol_not_found(self):
self.stubs.Set(compute_api.API,
@ -545,7 +553,8 @@ class VolumeAttachTests(test.TestCase):
self.assertRaises(webob.exc.HTTPBadRequest, self.attachments.create,
req, FAKE_UUID, body=body)
def _test_swap(self, uuid=FAKE_UUID_A, fake_func=None, body=None):
def _test_swap(self, attachments, uuid=FAKE_UUID_A,
fake_func=None, body=None):
fake_func = fake_func or fake_swap_volume
self.stubs.Set(compute_api.API,
'swap_volume',
@ -558,40 +567,57 @@ class VolumeAttachTests(test.TestCase):
req.body = jsonutils.dumps({})
req.headers['content-type'] = 'application/json'
req.environ['nova.context'] = self.context
return self.attachments.update(req, FAKE_UUID, uuid, body=body)
return attachments.update(req, FAKE_UUID, uuid, body=body)
def test_swap_volume_for_locked_server(self):
self.ext_mgr.extensions['os-volume-attachment-update'] = True
def fake_swap_volume_for_locked_server(self, context, instance,
old_volume, new_volume):
raise exception.InstanceIsLocked(instance_uuid=instance['uuid'])
self.ext_mgr.extensions['os-volume-attachment-update'] = True
self.assertRaises(webob.exc.HTTPConflict, self._test_swap,
self.attachments,
fake_func=fake_swap_volume_for_locked_server)
def test_swap_volume_no_extension(self):
self.assertRaises(webob.exc.HTTPBadRequest, self._test_swap)
def test_swap_volume(self):
self.ext_mgr.extensions['os-volume-attachment-update'] = True
result = self._test_swap()
self.assertEqual('202 Accepted', result.status)
result = self._test_swap(self.attachments)
# NOTE: on v2.1, http status code is set as wsgi_code of API
# method instead of status_int in a response object.
if isinstance(self.attachments,
volumes_v3.VolumeAttachmentController):
status_int = self.attachments.update.wsgi_code
else:
status_int = result.status_int
self.assertEqual(202, status_int)
def test_swap_volume_no_attachment(self):
self.ext_mgr.extensions['os-volume-attachment-update'] = True
self.assertRaises(exc.HTTPNotFound, self._test_swap, FAKE_UUID_C)
self.assertRaises(exc.HTTPNotFound, self._test_swap,
self.attachments, FAKE_UUID_C)
def test_swap_volume_without_volumeId(self):
self.ext_mgr.extensions['os-volume-attachment-update'] = True
body = {'volumeAttachment': {'device': '/dev/fake'}}
self.assertRaises(webob.exc.HTTPBadRequest,
self._test_swap,
self.attachments,
body=body)
class VolumeAttachTestsV2(VolumeAttachTestsV21):
def _set_up_controller(self):
ext_mgr = extensions.ExtensionManager()
ext_mgr.extensions = {'os-volume-attachment-update'}
self.attachments = volumes.VolumeAttachmentController(ext_mgr)
ext_mgr_no_update = extensions.ExtensionManager()
ext_mgr_no_update.extensions = {}
self.attachments_no_update = volumes.VolumeAttachmentController(
ext_mgr_no_update)
def test_swap_volume_no_extension(self):
self.assertRaises(webob.exc.HTTPBadRequest, self._test_swap,
self.attachments_no_update)
class VolumeSerializerTest(test.TestCase):
def _verify_volume_attachment(self, attach, tree):
for attr in ('id', 'volumeId', 'serverId', 'device'):

View File

@ -315,6 +315,11 @@ policy_data = """
"compute_extension:volume_attachments:update": "",
"compute_extension:volume_attachments:delete": "",
"compute_extension:v3:os-volumes": "",
"compute_extension:v3:os-volumes-attachments:index": "",
"compute_extension:v3:os-volumes-attachments:show": "",
"compute_extension:v3:os-volumes-attachments:create": "",
"compute_extension:v3:os-volumes-attachments:update": "",
"compute_extension:v3:os-volumes-attachments:delete": "",
"compute_extension:volumetypes": "",
"compute_extension:zones": "",
"compute_extension:availability_zone:list": "",

View File

@ -0,0 +1,6 @@
{
"volumeAttachment": {
"volumeId": "%(volume_id)s",
"device": "%(device)s"
}
}

View File

@ -0,0 +1,8 @@
{
"volumeAttachment": {
"device": "%(device)s",
"id": "%(volume_id)s",
"serverId": "%(uuid)s",
"volumeId": "%(volume_id)s"
}
}

View File

@ -0,0 +1,16 @@
{
"volumeAttachments": [
{
"device": "/dev/sdd",
"id": "a26887c6-c47b-4654-abb5-dfadf7d3f803",
"serverId": "%(uuid)s",
"volumeId": "a26887c6-c47b-4654-abb5-dfadf7d3f803"
},
{
"device": "/dev/sdc",
"id": "a26887c6-c47b-4654-abb5-dfadf7d3f804",
"serverId": "%(uuid)s",
"volumeId": "a26887c6-c47b-4654-abb5-dfadf7d3f804"
}
]
}

View File

@ -0,0 +1,6 @@
{
"volumeAttachment": {
"volumeId": "%(volume_id)s",
"device": "%(device)s"
}
}

View File

@ -0,0 +1,8 @@
{
"volumeAttachment": {
"device": "/dev/sdd",
"id": "a26887c6-c47b-4654-abb5-dfadf7d3f803",
"serverId": "%(uuid)s",
"volumeId": "a26887c6-c47b-4654-abb5-dfadf7d3f803"
}
}

View File

@ -15,7 +15,14 @@
import datetime
from nova.compute import api as compute_api
from nova.compute import manager as compute_manager
from nova import context
from nova import db
from nova import objects
from nova.tests.unit.api.openstack import fakes
from nova.tests.unit import fake_block_device
from nova.tests.unit import fake_instance
from nova.tests.unit.integrated.v3 import api_sample_base
from nova.tests.unit.integrated.v3 import test_servers
from nova.volume import cinder
@ -182,3 +189,124 @@ class VolumesSampleJsonTest(test_servers.ServersSampleBase):
response = self._do_delete('os-volumes/%s' % vol_id)
self.assertEqual(response.status_code, 202)
self.assertEqual(response.content, '')
class VolumeAttachmentsSampleBase(test_servers.ServersSampleBase):
def _stub_db_bdms_get_all_by_instance(self, server_id):
def fake_bdms_get_all_by_instance(context, instance_uuid,
use_slave=False):
bdms = [
fake_block_device.FakeDbBlockDeviceDict(
{'id': 1, 'volume_id': 'a26887c6-c47b-4654-abb5-dfadf7d3f803',
'instance_uuid': server_id, 'source_type': 'volume',
'destination_type': 'volume', 'device_name': '/dev/sdd'}),
fake_block_device.FakeDbBlockDeviceDict(
{'id': 2, 'volume_id': 'a26887c6-c47b-4654-abb5-dfadf7d3f804',
'instance_uuid': server_id, 'source_type': 'volume',
'destination_type': 'volume', 'device_name': '/dev/sdc'})
]
return bdms
self.stubs.Set(db, 'block_device_mapping_get_all_by_instance',
fake_bdms_get_all_by_instance)
def _stub_compute_api_get(self):
def fake_compute_api_get(self, context, instance_id,
want_objects=False, expected_attrs=None):
if want_objects:
return fake_instance.fake_instance_obj(
context, **{'uuid': instance_id})
else:
return {'uuid': instance_id}
self.stubs.Set(compute_api.API, 'get', fake_compute_api_get)
class VolumeAttachmentsSampleJsonTest(VolumeAttachmentsSampleBase):
extension_name = "os-volumes"
def test_attach_volume_to_server(self):
self.stubs.Set(cinder.API, 'get', fakes.stub_volume_get)
self.stubs.Set(cinder.API, 'check_attach', lambda *a, **k: None)
self.stubs.Set(cinder.API, 'reserve_volume', lambda *a, **k: None)
device_name = '/dev/vdd'
bdm = objects.BlockDeviceMapping()
bdm['device_name'] = device_name
self.stubs.Set(compute_manager.ComputeManager,
"reserve_block_device_name",
lambda *a, **k: bdm)
self.stubs.Set(compute_manager.ComputeManager,
'attach_volume',
lambda *a, **k: None)
self.stubs.Set(objects.BlockDeviceMapping, 'get_by_volume_id',
classmethod(lambda *a, **k: None))
volume = fakes.stub_volume_get(None, context.get_admin_context(),
'a26887c6-c47b-4654-abb5-dfadf7d3f803')
subs = {
'volume_id': volume['id'],
'device': device_name
}
server_id = self._post_server()
response = self._do_post('servers/%s/os-volume_attachments'
% server_id,
'attach-volume-to-server-req', subs)
subs.update(self._get_regexes())
self._verify_response('attach-volume-to-server-resp', subs,
response, 200)
def test_list_volume_attachments(self):
server_id = self._post_server()
self._stub_db_bdms_get_all_by_instance(server_id)
response = self._do_get('servers/%s/os-volume_attachments'
% server_id)
subs = self._get_regexes()
self._verify_response('list-volume-attachments-resp', subs,
response, 200)
def test_volume_attachment_detail(self):
server_id = self._post_server()
attach_id = "a26887c6-c47b-4654-abb5-dfadf7d3f803"
self._stub_db_bdms_get_all_by_instance(server_id)
self._stub_compute_api_get()
response = self._do_get('servers/%s/os-volume_attachments/%s'
% (server_id, attach_id))
subs = self._get_regexes()
self._verify_response('volume-attachment-detail-resp', subs,
response, 200)
def test_volume_attachment_delete(self):
server_id = self._post_server()
attach_id = "a26887c6-c47b-4654-abb5-dfadf7d3f803"
self._stub_db_bdms_get_all_by_instance(server_id)
self._stub_compute_api_get()
self.stubs.Set(cinder.API, 'get', fakes.stub_volume_get)
self.stubs.Set(compute_api.API, 'detach_volume', lambda *a, **k: None)
response = self._do_delete('servers/%s/os-volume_attachments/%s'
% (server_id, attach_id))
self.assertEqual(response.status_code, 202)
self.assertEqual(response.content, '')
def test_volume_attachment_update(self):
self.stubs.Set(cinder.API, 'get', fakes.stub_volume_get)
subs = {
'volume_id': 'a26887c6-c47b-4654-abb5-dfadf7d3f805',
'device': '/dev/sdd'
}
server_id = self._post_server()
attach_id = 'a26887c6-c47b-4654-abb5-dfadf7d3f803'
self._stub_db_bdms_get_all_by_instance(server_id)
self._stub_compute_api_get()
self.stubs.Set(cinder.API, 'get', fakes.stub_volume_get)
self.stubs.Set(compute_api.API, 'swap_volume', lambda *a, **k: None)
response = self._do_put('servers/%s/os-volume_attachments/%s'
% (server_id, attach_id),
'update-volume-req',
subs)
self.assertEqual(response.status_code, 202)
self.assertEqual(response.content, '')