diff --git a/doc/api_samples/all_extensions/extensions-get-resp.json b/doc/api_samples/all_extensions/extensions-get-resp.json index a52c8fd9099e..748f30f8dd6a 100644 --- a/doc/api_samples/all_extensions/extensions-get-resp.json +++ b/doc/api_samples/all_extensions/extensions-get-resp.json @@ -96,14 +96,6 @@ "namespace": "http://docs.openstack.org/compute/ext/flavor_extra_data/api/v1.1", "updated": "2011-09-14T00:00:00+00:00" }, - { - "alias": "OS-SRV-USG", - "description": "Adds launched_at and terminated_at on Instances.", - "links": [], - "name": "ServerUsage", - "namespace": "http://docs.openstack.org/compute/ext/server_usage/api/v1.1", - "updated": "2013-04-29T00:00:00+00:00" - }, { "alias": "OS-SCH-HNT", "description": "Pass arbitrary key/value pairs to the scheduler.", @@ -112,6 +104,14 @@ "namespace": "http://docs.openstack.org/compute/ext/scheduler-hints/api/v2", "updated": "2011-07-19T00:00:00+00:00" }, + { + "alias": "OS-SRV-USG", + "description": "Adds launched_at and terminated_at on Servers.", + "links": [], + "name": "ServerUsage", + "namespace": "http://docs.openstack.org/compute/ext/server_usage/api/v1.1", + "updated": "2013-04-29T00:00:00+00:00" + }, { "alias": "os-admin-actions", "description": "Enable admin-only server actions\n\n Actions include: pause, unpause, suspend, resume, migrate,\n resetNetwork, injectNetworkInfo, lock, unlock, createBackup\n ", @@ -160,14 +160,6 @@ "namespace": "http://docs.openstack.org/compute/ext/baremetal_nodes/api/v2", "updated": "2013-01-04T00:00:00+00:00" }, - { - "alias": "os-cells", - "description": "Enables cells-related functionality such as adding neighbor cells,\n listing neighbor cells, and getting the capabilities of the local cell.\n ", - "links": [], - "name": "Cells", - "namespace": "http://docs.openstack.org/compute/ext/cells/api/v1.1", - "updated": "2011-09-21T00:00:00+00:00" - }, { "alias": "os-cell-capacities", "description": "Adding functionality to get cell capacities.", @@ -176,6 +168,14 @@ "namespace": "http://docs.openstack.org/compute/ext/cell_capacities/api/v1.1", "updated": "2013-05-27T00:00:00+00:00" }, + { + "alias": "os-cells", + "description": "Enables cells-related functionality such as adding neighbor cells,\n listing neighbor cells, and getting the capabilities of the local cell.\n ", + "links": [], + "name": "Cells", + "namespace": "http://docs.openstack.org/compute/ext/cells/api/v1.1", + "updated": "2013-05-14T00:00:00+00:00" + }, { "alias": "os-certificates", "description": "Certificates support.", @@ -264,6 +264,22 @@ "namespace": "http://docs.openstack.org/compute/ext/extended_floating_ips/api/v2", "updated": "2013-04-19T00:00:00+00:00" }, + { + "alias": "os-extended-quotas", + "description": "Adds ability for admins to delete quota\n and optionally force the update Quota command.\n ", + "links": [], + "name": "ExtendedQuotas", + "namespace": "http://docs.openstack.org/compute/ext/extended_quotas/api/v1.1", + "updated": "2013-06-09T00:00:00+00:00" + }, + { + "alias": "os-extended-services", + "description": "Extended services support.", + "links": [], + "name": "ExtendedServices", + "namespace": "http://docs.openstack.org/compute/ext/extended_services/api/v2", + "updated": "2013-05-17T00:00:00-00:00" + }, { "alias": "os-fixed-ips", "description": "Fixed IPs support.", @@ -432,14 +448,6 @@ "namespace": "http://docs.openstack.org/compute/ext/quota-classes-sets/api/v1.1", "updated": "2012-03-12T00:00:00+00:00" }, - { - "alias": "os-extended-quotas", - "description": "Adds ability for admins to delete quota and optionally force the update Quota command.", - "links": [], - "name": "ExtendedQuotas", - "namespace": "http://docs.openstack.org/compute/ext/extended_quotas/api/v1.1", - "updated": "2013-06-09T00:00:00+00:00" - }, { "alias": "os-quota-sets", "description": "Quotas management support.", @@ -470,7 +478,7 @@ "links": [], "name": "SecurityGroups", "namespace": "http://docs.openstack.org/compute/ext/securitygroups/api/v1.1", - "updated": "2011-07-21T00:00:00+00:00" + "updated": "2013-05-28T00:00:00+00:00" }, { "alias": "os-server-diagnostics", @@ -504,14 +512,6 @@ "namespace": "http://docs.openstack.org/compute/ext/services/api/v2", "updated": "2012-10-28T00:00:00-00:00" }, - { - "alias": "os-extended-services", - "description": "Extended services support.", - "links": [], - "name": "ExtendedServices", - "namespace": "http://docs.openstack.org/compute/ext/extended_services/api/v2", - "updated": "2013-05-17T00:00:00-00:00" - }, { "alias": "os-shelve", "description": "Instance shelve mode.", @@ -568,6 +568,14 @@ "namespace": "http://docs.openstack.org/compute/ext/virtual_interfaces/api/v1.1", "updated": "2011-08-17T00:00:00+00:00" }, + { + "alias": "os-volume-attachment-update", + "description": "Support for updating a volume attachment.", + "links": [], + "name": "VolumeAttachmentUpdate", + "namespace": "http://docs.openstack.org/compute/ext/os-volume-attachment-update/api/v2", + "updated": "2013-06-20T00:00:00-00:00" + }, { "alias": "os-volumes", "description": "Volumes support.", diff --git a/doc/api_samples/all_extensions/extensions-get-resp.xml b/doc/api_samples/all_extensions/extensions-get-resp.xml index 5cac81097d49..22a6c3a162a1 100644 --- a/doc/api_samples/all_extensions/extensions-get-resp.xml +++ b/doc/api_samples/all_extensions/extensions-get-resp.xml @@ -36,12 +36,12 @@ Provide additional data for flavors. - - Adds launched_at and terminated_at on Servers. - Pass arbitrary key/value pairs to the scheduler. + + Adds launched_at and terminated_at on Servers. + Enable admin-only server actions @@ -66,14 +66,14 @@ Admin-only bare-metal node administration. - + + Adding functionality to get cell capacities. + + Enables cells-related functionality such as adding neighbor cells, listing neighbor cells, and getting the capabilities of the local cell. - - Adds functionality to get cell capacities. - Certificates support. @@ -115,6 +115,14 @@ Adds optional fixed_address to the add floating IP command. + + Adds ability for admins to delete quota + and optionally force the update Quota command. + + + + Extended services support. + Fixed IPs support. @@ -180,9 +188,6 @@ Quota classes management support. - - Adds ability for admins to delete quota and optionally force the update Quota command. - Quotas management support. @@ -192,7 +197,7 @@ Default rules for security group support. - + Security group support. @@ -207,9 +212,6 @@ Services support. - - Extended services support. - Instance shelve mode. @@ -231,6 +233,9 @@ Virtual interface support. + + Support for updating a volume attachment. + Volumes support. diff --git a/doc/api_samples/os-volume-attachment-update/server-post-req.json b/doc/api_samples/os-volume-attachment-update/server-post-req.json new file mode 100644 index 000000000000..d88eb4122223 --- /dev/null +++ b/doc/api_samples/os-volume-attachment-update/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==" + } + ] + } +} \ No newline at end of file diff --git a/doc/api_samples/os-volume-attachment-update/server-post-req.xml b/doc/api_samples/os-volume-attachment-update/server-post-req.xml new file mode 100644 index 000000000000..0a3c8bb5303d --- /dev/null +++ b/doc/api_samples/os-volume-attachment-update/server-post-req.xml @@ -0,0 +1,19 @@ + + + + Apache1 + + + + ICAgICAgDQoiQSBjbG91ZCBkb2VzIG5vdCBrbm93IHdoeSBp + dCBtb3ZlcyBpbiBqdXN0IHN1Y2ggYSBkaXJlY3Rpb24gYW5k + IGF0IHN1Y2ggYSBzcGVlZC4uLkl0IGZlZWxzIGFuIGltcHVs + c2lvbi4uLnRoaXMgaXMgdGhlIHBsYWNlIHRvIGdvIG5vdy4g + QnV0IHRoZSBza3kga25vd3MgdGhlIHJlYXNvbnMgYW5kIHRo + ZSBwYXR0ZXJucyBiZWhpbmQgYWxsIGNsb3VkcywgYW5kIHlv + dSB3aWxsIGtub3csIHRvbywgd2hlbiB5b3UgbGlmdCB5b3Vy + c2VsZiBoaWdoIGVub3VnaCB0byBzZWUgYmV5b25kIGhvcml6 + b25zLiINCg0KLVJpY2hhcmQgQmFjaA== + + + \ No newline at end of file diff --git a/doc/api_samples/os-volume-attachment-update/server-post-resp.json b/doc/api_samples/os-volume-attachment-update/server-post-resp.json new file mode 100644 index 000000000000..59adf043a528 --- /dev/null +++ b/doc/api_samples/os-volume-attachment-update/server-post-resp.json @@ -0,0 +1,16 @@ +{ + "server": { + "adminPass": "JpSxM7jTxqC2", + "id": "3307a815-3f98-4d01-bf59-9e7c4fb2a6a9", + "links": [ + { + "href": "http://openstack.example.com/v2/openstack/servers/3307a815-3f98-4d01-bf59-9e7c4fb2a6a9", + "rel": "self" + }, + { + "href": "http://openstack.example.com/openstack/servers/3307a815-3f98-4d01-bf59-9e7c4fb2a6a9", + "rel": "bookmark" + } + ] + } +} \ No newline at end of file diff --git a/doc/api_samples/os-volume-attachment-update/server-post-resp.xml b/doc/api_samples/os-volume-attachment-update/server-post-resp.xml new file mode 100644 index 000000000000..558e1abbfc3b --- /dev/null +++ b/doc/api_samples/os-volume-attachment-update/server-post-resp.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/doc/api_samples/os-volume-attachment-update/update-volume-req.json b/doc/api_samples/os-volume-attachment-update/update-volume-req.json new file mode 100644 index 000000000000..09b42cf99691 --- /dev/null +++ b/doc/api_samples/os-volume-attachment-update/update-volume-req.json @@ -0,0 +1,6 @@ +{ + "volumeAttachment": { + "volumeId": "a26887c6-c47b-4654-abb5-dfadf7d3f805", + "device": "/dev/sdd" + } +} \ No newline at end of file diff --git a/doc/api_samples/os-volume-attachment-update/update-volume-req.xml b/doc/api_samples/os-volume-attachment-update/update-volume-req.xml new file mode 100644 index 000000000000..37d7f84630b1 --- /dev/null +++ b/doc/api_samples/os-volume-attachment-update/update-volume-req.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/etc/nova/policy.json b/etc/nova/policy.json index 2820d8e89a56..ad17f31e3854 100644 --- a/etc/nova/policy.json +++ b/etc/nova/policy.json @@ -179,6 +179,7 @@ "compute_extension:volume_attachments:index": "", "compute_extension:volume_attachments:show": "", "compute_extension:volume_attachments:create": "", + "compute_extension:volume_attachments:update": "", "compute_extension:volume_attachments:delete": "", "compute_extension:volumetypes": "", "compute_extension:availability_zone:list": "", diff --git a/nova/api/openstack/compute/contrib/volume_attachment_update.py b/nova/api/openstack/compute/contrib/volume_attachment_update.py new file mode 100644 index 000000000000..bc00e2a94a07 --- /dev/null +++ b/nova/api/openstack/compute/contrib/volume_attachment_update.py @@ -0,0 +1,26 @@ +# Copyright 2013 Nebula, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from nova.api.openstack import extensions + + +class Volume_attachment_update(extensions.ExtensionDescriptor): + """Support for updating a volume attachment.""" + + name = "VolumeAttachmentUpdate" + alias = "os-volume-attachment-update" + namespace = ("http://docs.openstack.org/compute/ext/" + "os-volume-attachment-update/api/v2") + updated = "2013-06-20T00:00:00-00:00" diff --git a/nova/api/openstack/compute/contrib/volumes.py b/nova/api/openstack/compute/contrib/volumes.py index 55806bc832ce..0ce695a1733a 100644 --- a/nova/api/openstack/compute/contrib/volumes.py +++ b/nova/api/openstack/compute/contrib/volumes.py @@ -326,9 +326,10 @@ class VolumeAttachmentController(wsgi.Controller): """ - def __init__(self): + def __init__(self, ext_mgr=None): self.compute_api = compute.API() self.volume_api = volume.API() + self.ext_mgr = ext_mgr super(VolumeAttachmentController, self).__init__() @wsgi.serializers(xml=VolumeAttachmentsTemplate) @@ -431,8 +432,52 @@ class VolumeAttachmentController(wsgi.Controller): return {'volumeAttachment': attachment} def update(self, req, server_id, id, body): - """Update a volume attachment. We don't currently support this.""" - raise exc.HTTPBadRequest() + if (not self.ext_mgr or + not self.ext_mgr.is_loaded('os-volume-attachment-update')): + raise exc.HTTPBadRequest() + context = req.environ['nova.context'] + authorize(context) + authorize_attach(context, action='update') + + if not self.is_valid_body(body, 'volumeAttachment'): + raise exc.HTTPUnprocessableEntity() + + old_volume_id = id + old_volume = self.volume_api.get(context, old_volume_id) + + new_volume_id = body['volumeAttachment']['volumeId'] + self._validate_volume_id(new_volume_id) + new_volume = self.volume_api.get(context, new_volume_id) + + try: + instance = self.compute_api.get(context, server_id, + want_objects=True) + except exception.NotFound: + raise exc.HTTPNotFound() + + bdms = self.compute_api.get_instance_bdms(context, instance) + 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.InstanceInvalidState as state_error: + common.raise_http_conflict_for_instance_invalid_state(state_error, + 'swap_volume') + + if not found: + raise exc.HTTPNotFound() + else: + return webob.Response(status_int=202) def delete(self, req, server_id, id): """Detach a volume from an instance.""" @@ -656,8 +701,9 @@ class Volumes(extensions.ExtensionDescriptor): collection_actions={'detail': 'GET'}) resources.append(res) + attachment_controller = VolumeAttachmentController(self.ext_mgr) res = extensions.ResourceExtension('os-volume_attachments', - VolumeAttachmentController(), + attachment_controller, parent=dict( member_name='server', collection_name='servers')) diff --git a/nova/compute/api.py b/nova/compute/api.py index 862d948cb137..d49699b08192 100644 --- a/nova/compute/api.py +++ b/nova/compute/api.py @@ -2551,6 +2551,36 @@ class API(base.Base): raise exception.VolumeUnattached(volume_id=volume['id']) self._detach_volume(context, instance, volume) + @wrap_check_policy + @check_instance_lock + @check_instance_state(vm_state=[vm_states.ACTIVE, vm_states.PAUSED, + vm_states.SUSPENDED, vm_states.STOPPED, + vm_states.RESIZED, vm_states.SOFT_DELETED], + task_state=None) + def swap_volume(self, context, instance, old_volume, new_volume): + """Swap volume attached to an instance.""" + if old_volume['attach_status'] == 'detached': + msg = _("Old volume must be attached in order to swap.") + raise exception.InvalidVolume(reason=msg) + # The caller likely got the instance from volume['instance_uuid'] + # in the first place, but let's sanity check. + if old_volume['instance_uuid'] != instance['uuid']: + raise exception.VolumeUnattached(volume_id=volume['id']) + if new_volume['attach_status'] == 'attached': + msg = _("New volume must be detached in order to swap.") + raise exception.InvalidVolume(reason=msg) + if int(new_volume['size']) < int(old_volume['size']): + msg = _("New volume must be the same size or larger.") + raise exception.InvalidVolume(reason=msg) + self.volume_api.check_detach(context, old_volume) + self.volume_api.begin_detaching(context, old_volume) + self.volume_api.check_attach(context, new_volume, instance=instance) + self.volume_api.reserve_volume(context, new_volume) + self.compute_rpcapi.swap_volume( + context, instance=instance, + old_volume_id=old_volume['id'], + new_volume_id=new_volume['id']) + @wrap_check_policy def attach_interface(self, context, instance, network_id, port_id, requested_ip): diff --git a/nova/compute/manager.py b/nova/compute/manager.py index df82b06ec772..b1d4a44807b1 100755 --- a/nova/compute/manager.py +++ b/nova/compute/manager.py @@ -364,7 +364,7 @@ class ComputeVirtAPI(virtapi.VirtAPI): class ComputeManager(manager.SchedulerDependentManager): """Manages the running instances from creation to destruction.""" - RPC_API_VERSION = '2.33' + RPC_API_VERSION = '2.34' def __init__(self, compute_driver=None, *args, **kwargs): """Load configuration options and connect to the hypervisor.""" @@ -3524,6 +3524,70 @@ class ComputeManager(manager.SchedulerDependentManager): self._notify_about_instance_usage( context, instance, "volume.detach", extra_usage_info=info) + @exception.wrap_exception(notifier=notifier, publisher_id=publisher_id()) + @reverts_task_state + @wrap_instance_fault + def swap_volume(self, context, old_volume_id, new_volume_id, instance): + """Swap volume for an instance.""" + context = context.elevated() + bdm = self._get_instance_volume_bdm(context, instance, old_volume_id) + mountpoint = bdm['device_name'] + connector = self.driver.get_volume_connector(instance) + volume = self.volume_api.get(context, new_volume_id) + try: + new_cinfo = self.volume_api.initialize_connection(context, + volume, + connector) + except Exception: # pylint: disable=W0702 + with excutils.save_and_reraise_exception(): + msg = _("Failed to connect to volume %(volume_id)s " + "with volume at %(mountpoint)s") + LOG.exception(msg % {'volume_id': new_volume_id, + 'mountpoint': mountpoint}, + context=context, + instance=instance) + self.volume_api.unreserve_volume(context, volume) + + old_cinfo = jsonutils.loads(bdm['connection_info']) + if old_cinfo and 'serial' not in old_cinfo: + old_cinfo['serial'] = old_volume_id + new_cinfo['serial'] = old_cinfo['serial'] + + try: + self.driver.swap_volume(old_cinfo, new_cinfo, instance, mountpoint) + except Exception: # pylint: disable=W0702 + with excutils.save_and_reraise_exception(): + msg = _("Failed to swap volume %(old_volume_id)s " + "for %(new_volume_id)s") + LOG.exception(msg % {'old_volume_id': old_volume_id, + 'new_volume_id': new_volume_id}, + context=context, + instance=instance) + self.volume_api.terminate_connection(context, + volume, + connector) + self.volume_api.attach(context, + volume, + instance['uuid'], + mountpoint) + # Remove old connection + volume = self.volume_api.get(context, old_volume_id) + self.volume_api.terminate_connection(context, volume, connector) + self.volume_api.detach(context.elevated(), volume) + # Update bdm + values = { + 'instance_uuid': instance['uuid'], + 'connection_info': jsonutils.dumps(new_cinfo), + 'device_name': mountpoint, + 'delete_on_termination': False, + 'virtual_name': None, + 'snapshot_id': None, + 'volume_id': new_volume_id, + 'volume_size': None, + 'no_device': None} + self.conductor_api.block_device_mapping_update_or_create(context, + values) + @exception.wrap_exception(notifier=notifier, publisher_id=publisher_id()) def remove_volume_connection(self, context, volume_id, instance): """Remove a volume connection using the volume api.""" diff --git a/nova/compute/rpcapi.py b/nova/compute/rpcapi.py index d3c5b9027827..4f1aac83b985 100644 --- a/nova/compute/rpcapi.py +++ b/nova/compute/rpcapi.py @@ -186,6 +186,7 @@ class ComputeAPI(nova.openstack.common.rpc.proxy.RpcProxy): 2.32 - Make reboot_instance take a new world instance object 2.33 - Made suspend_instance() and resume_instance() take new-world instance objects + 2.34 - Added swap_volume() ''' # @@ -600,6 +601,13 @@ class ComputeAPI(nova.openstack.common.rpc.proxy.RpcProxy): return self.call(ctxt, self.make_msg('set_host_enabled', enabled=enabled), topic) + def swap_volume(self, ctxt, instance, old_volume_id, new_volume_id): + self.cast(ctxt, self.make_msg('swap_volume', + instance=instance, old_volume_id=old_volume_id, + new_volume_id=new_volume_id), + topic=_compute_topic(self.topic, ctxt, None, instance), + version='2.34') + def get_host_uptime(self, ctxt, host): topic = _compute_topic(self.topic, ctxt, host, None) return self.call(ctxt, self.make_msg('get_host_uptime'), topic) diff --git a/nova/tests/api/openstack/compute/contrib/test_volumes.py b/nova/tests/api/openstack/compute/contrib/test_volumes.py index bdca7fdeff16..24aa83ec85df 100644 --- a/nova/tests/api/openstack/compute/contrib/test_volumes.py +++ b/nova/tests/api/openstack/compute/contrib/test_volumes.py @@ -21,6 +21,7 @@ import webob from webob import exc from nova.api.openstack.compute.contrib import volumes +from nova.api.openstack import extensions from nova.compute import api as compute_api from nova.compute import flavors from nova import context @@ -65,7 +66,7 @@ def fake_compute_api_create(cls, context, instance_type, image_href, **kwargs): }], resv_id) -def fake_get_instance(self, context, instance_id): +def fake_get_instance(self, context, instance_id, want_objects=False): return {'uuid': instance_id} @@ -81,6 +82,11 @@ def fake_detach_volume(self, context, instance, volume): pass +def fake_swap_volume(self, context, instance, + old_volume_id, new_volume_id): + pass + + def fake_create_snapshot(self, context, volume, name, description): return {'id': 123, 'volume_id': 'fakeVolId', @@ -263,45 +269,43 @@ 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) def test_show(self): - attachments = volumes.VolumeAttachmentController() - req = webob.Request.blank('/v2/fake/os-volumes/show') + req = webob.Request.blank('/v2/servers/id/os-volume_attachments/uuid') req.method = 'POST' req.body = jsonutils.dumps({}) req.headers['content-type'] = 'application/json' req.environ['nova.context'] = self.context - result = attachments.show(req, FAKE_UUID, FAKE_UUID_A) + result = self.attachments.show(req, FAKE_UUID, FAKE_UUID_A) self.assertEqual(self.expected_show, result) - def test_delete(self): + def test_detach(self): self.stubs.Set(compute_api.API, 'detach_volume', fake_detach_volume) - attachments = volumes.VolumeAttachmentController() - req = webob.Request.blank('/v2/fake/os-volumes/delete') - req.method = 'POST' - req.body = jsonutils.dumps({}) + req = webob.Request.blank('/v2/servers/id/os-volume_attachments/uuid') + req.method = 'DELETE' req.headers['content-type'] = 'application/json' req.environ['nova.context'] = self.context - result = attachments.delete(req, FAKE_UUID, FAKE_UUID_A) + result = self.attachments.delete(req, FAKE_UUID, FAKE_UUID_A) self.assertEqual('202 Accepted', result.status) - def test_delete_vol_not_found(self): + def test_detach_vol_not_found(self): self.stubs.Set(compute_api.API, 'detach_volume', fake_detach_volume) - attachments = volumes.VolumeAttachmentController() - req = webob.Request.blank('/v2/fake/os-volumes/delete') - req.method = 'POST' - req.body = jsonutils.dumps({}) + req = webob.Request.blank('/v2/servers/id/os-volume_attachments/uuid') + req.method = 'DELETE' req.headers['content-type'] = 'application/json' req.environ['nova.context'] = self.context self.assertRaises(exc.HTTPNotFound, - attachments.delete, + self.attachments.delete, req, FAKE_UUID, FAKE_UUID_C) @@ -310,15 +314,14 @@ class VolumeAttachTests(test.TestCase): self.stubs.Set(compute_api.API, 'attach_volume', fake_attach_volume) - attachments = volumes.VolumeAttachmentController() body = {'volumeAttachment': {'volumeId': FAKE_UUID_A, 'device': '/dev/fake'}} - req = webob.Request.blank('/v2/fake/os-volumes/attach') + req = webob.Request.blank('/v2/servers/id/os-volume_attachments') req.method = 'POST' req.body = jsonutils.dumps({}) req.headers['content-type'] = 'application/json' req.environ['nova.context'] = self.context - result = attachments.create(req, FAKE_UUID, body) + result = self.attachments.create(req, FAKE_UUID, body) self.assertEqual(result['volumeAttachment']['id'], '00000000-aaaa-aaaa-aaaa-000000000000') @@ -326,7 +329,6 @@ class VolumeAttachTests(test.TestCase): self.stubs.Set(compute_api.API, 'attach_volume', fake_attach_volume) - attachments = volumes.VolumeAttachmentController() body = { 'volumeAttachment': { @@ -335,14 +337,41 @@ class VolumeAttachTests(test.TestCase): } } - req = fakes.HTTPRequest.blank('/v2/fake/os-volumes/attach') + req = webob.Request.blank('/v2/servers/id/os-volume_attachments') req.method = 'POST' - req.content_type = 'application/json' - req.body = jsonutils.dumps(body) + req.body = jsonutils.dumps({}) + req.headers['content-type'] = 'application/json' + req.environ['nova.context'] = self.context - self.assertRaises(webob.exc.HTTPBadRequest, attachments.create, + self.assertRaises(webob.exc.HTTPBadRequest, self.attachments.create, req, FAKE_UUID, body) + def _test_swap(self, uuid=FAKE_UUID_A): + self.stubs.Set(compute_api.API, + 'swap_volume', + fake_swap_volume) + body = {'volumeAttachment': {'volumeId': FAKE_UUID_B, + 'device': '/dev/fake'}} + req = webob.Request.blank('/v2/servers/id/os-volume_attachments/uuid') + req.method = 'PUT' + 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) + + 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) + + 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) + class VolumeSerializerTest(test.TestCase): def _verify_volume_attachment(self, attach, tree): diff --git a/nova/tests/api/openstack/compute/test_extensions.py b/nova/tests/api/openstack/compute/test_extensions.py index f5ecf3255494..2bedd69272da 100644 --- a/nova/tests/api/openstack/compute/test_extensions.py +++ b/nova/tests/api/openstack/compute/test_extensions.py @@ -229,6 +229,7 @@ class ExtensionControllerTest(ExtensionTestCase): "UsedLimits", "UserData", "VirtualInterfaces", + "VolumeAttachmentUpdate", "Volumes", ] self.ext_list.sort() diff --git a/nova/tests/compute/test_rpcapi.py b/nova/tests/compute/test_rpcapi.py index 2fb6068db252..48c78cc6b62f 100644 --- a/nova/tests/compute/test_rpcapi.py +++ b/nova/tests/compute/test_rpcapi.py @@ -229,6 +229,12 @@ class ComputeRpcAPITestCase(test.TestCase): reservations=['uuid1', 'uuid2'], version='2.27') + def test_swap_volume(self): + self._test_compute_api('swap_volume', 'cast', + instance=self.fake_instance, old_volume_id='oldid', + new_volume_id='newid', + version='2.34') + def test_restore_instance(self): self._test_compute_api('restore_instance', 'cast', instance=self.fake_instance) diff --git a/nova/tests/fake_policy.py b/nova/tests/fake_policy.py index 4818769c1824..942fd0e4c8da 100644 --- a/nova/tests/fake_policy.py +++ b/nova/tests/fake_policy.py @@ -255,6 +255,7 @@ policy_data = """ "compute_extension:volume_attachments:index": "", "compute_extension:volume_attachments:show": "", "compute_extension:volume_attachments:create": "", + "compute_extension:volume_attachments:update": "", "compute_extension:volume_attachments:delete": "", "compute_extension:volumetypes": "", "compute_extension:zones": "", diff --git a/nova/tests/integrated/api_samples/all_extensions/extensions-get-resp.json.tpl b/nova/tests/integrated/api_samples/all_extensions/extensions-get-resp.json.tpl index eca786e27470..c7019842b844 100644 --- a/nova/tests/integrated/api_samples/all_extensions/extensions-get-resp.json.tpl +++ b/nova/tests/integrated/api_samples/all_extensions/extensions-get-resp.json.tpl @@ -560,6 +560,14 @@ "namespace": "http://docs.openstack.org/compute/ext/virtual_interfaces/api/v1.1", "updated": "%(timestamp)s" }, + { + "alias": "os-volume-attachment-update", + "description": "%(text)s", + "links": [], + "name": "VolumeAttachmentUpdate", + "namespace": "http://docs.openstack.org/compute/ext/os-volume-attachment-update/api/v2", + "updated": "%(timestamp)s" + }, { "alias": "os-volumes", "description": "%(text)s", diff --git a/nova/tests/integrated/api_samples/all_extensions/extensions-get-resp.xml.tpl b/nova/tests/integrated/api_samples/all_extensions/extensions-get-resp.xml.tpl index 8edf7f7e3d80..99896a82b527 100644 --- a/nova/tests/integrated/api_samples/all_extensions/extensions-get-resp.xml.tpl +++ b/nova/tests/integrated/api_samples/all_extensions/extensions-get-resp.xml.tpl @@ -210,6 +210,9 @@ %(text)s + + %(text)s + %(text)s diff --git a/nova/tests/integrated/api_samples/os-volume-attachment-update/server-post-req.json.tpl b/nova/tests/integrated/api_samples/os-volume-attachment-update/server-post-req.json.tpl new file mode 100644 index 000000000000..d3916d1aa68a --- /dev/null +++ b/nova/tests/integrated/api_samples/os-volume-attachment-update/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/api_samples/os-volume-attachment-update/server-post-req.xml.tpl b/nova/tests/integrated/api_samples/os-volume-attachment-update/server-post-req.xml.tpl new file mode 100644 index 000000000000..f92614984242 --- /dev/null +++ b/nova/tests/integrated/api_samples/os-volume-attachment-update/server-post-req.xml.tpl @@ -0,0 +1,19 @@ + + + + Apache1 + + + + ICAgICAgDQoiQSBjbG91ZCBkb2VzIG5vdCBrbm93IHdoeSBp + dCBtb3ZlcyBpbiBqdXN0IHN1Y2ggYSBkaXJlY3Rpb24gYW5k + IGF0IHN1Y2ggYSBzcGVlZC4uLkl0IGZlZWxzIGFuIGltcHVs + c2lvbi4uLnRoaXMgaXMgdGhlIHBsYWNlIHRvIGdvIG5vdy4g + QnV0IHRoZSBza3kga25vd3MgdGhlIHJlYXNvbnMgYW5kIHRo + ZSBwYXR0ZXJucyBiZWhpbmQgYWxsIGNsb3VkcywgYW5kIHlv + dSB3aWxsIGtub3csIHRvbywgd2hlbiB5b3UgbGlmdCB5b3Vy + c2VsZiBoaWdoIGVub3VnaCB0byBzZWUgYmV5b25kIGhvcml6 + b25zLiINCg0KLVJpY2hhcmQgQmFjaA== + + + diff --git a/nova/tests/integrated/api_samples/os-volume-attachment-update/server-post-resp.json.tpl b/nova/tests/integrated/api_samples/os-volume-attachment-update/server-post-resp.json.tpl new file mode 100644 index 000000000000..d5f030c8730b --- /dev/null +++ b/nova/tests/integrated/api_samples/os-volume-attachment-update/server-post-resp.json.tpl @@ -0,0 +1,16 @@ +{ + "server": { + "adminPass": "%(password)s", + "id": "%(id)s", + "links": [ + { + "href": "%(host)s/v2/openstack/servers/%(uuid)s", + "rel": "self" + }, + { + "href": "%(host)s/openstack/servers/%(uuid)s", + "rel": "bookmark" + } + ] + } +} diff --git a/nova/tests/integrated/api_samples/os-volume-attachment-update/server-post-resp.xml.tpl b/nova/tests/integrated/api_samples/os-volume-attachment-update/server-post-resp.xml.tpl new file mode 100644 index 000000000000..3bb13e69bd6d --- /dev/null +++ b/nova/tests/integrated/api_samples/os-volume-attachment-update/server-post-resp.xml.tpl @@ -0,0 +1,6 @@ + + + + + + diff --git a/nova/tests/integrated/api_samples/os-volume-attachment-update/update-volume-req.json.tpl b/nova/tests/integrated/api_samples/os-volume-attachment-update/update-volume-req.json.tpl new file mode 100644 index 000000000000..3d360a57bced --- /dev/null +++ b/nova/tests/integrated/api_samples/os-volume-attachment-update/update-volume-req.json.tpl @@ -0,0 +1,6 @@ +{ + "volumeAttachment": { + "volumeId": "%(volume_id)s", + "device": "%(device)s" + } +} diff --git a/nova/tests/integrated/api_samples/os-volume-attachment-update/update-volume-req.xml.tpl b/nova/tests/integrated/api_samples/os-volume-attachment-update/update-volume-req.xml.tpl new file mode 100644 index 000000000000..ffb20ad1eaf7 --- /dev/null +++ b/nova/tests/integrated/api_samples/os-volume-attachment-update/update-volume-req.xml.tpl @@ -0,0 +1,2 @@ + + diff --git a/nova/tests/integrated/test_api_samples.py b/nova/tests/integrated/test_api_samples.py index c68630134510..45854737b413 100644 --- a/nova/tests/integrated/test_api_samples.py +++ b/nova/tests/integrated/test_api_samples.py @@ -3861,7 +3861,33 @@ class SnapshotsSampleXmlTests(SnapshotsSampleJsonTests): ctype = "xml" -class VolumeAttachmentsSampleJsonTest(ServersSampleBase): +class VolumeAttachmentsSampleBase(ServersSampleBase): + def _stub_compute_api_get_instance_bdms(self, server_id): + + def fake_compute_api_get_instance_bdms(self, context, instance): + bdms = [ + {'volume_id': 'a26887c6-c47b-4654-abb5-dfadf7d3f803', + 'instance_uuid': server_id, + 'device_name': '/dev/sdd'}, + {'volume_id': 'a26887c6-c47b-4654-abb5-dfadf7d3f804', + 'instance_uuid': server_id, + 'device_name': '/dev/sdc'} + ] + return bdms + + self.stubs.Set(compute_api.API, "get_instance_bdms", + fake_compute_api_get_instance_bdms) + + def _stub_compute_api_get(self): + + def fake_compute_api_get(self, context, instance_id, + want_objects=False): + return {'uuid': instance_id} + + self.stubs.Set(compute_api.API, 'get', fake_compute_api_get) + + +class VolumeAttachmentsSampleJsonTest(VolumeAttachmentsSampleBase): extension_name = ("nova.api.openstack.compute.contrib.volumes.Volumes") def test_attach_volume_to_server(self): @@ -3888,29 +3914,6 @@ class VolumeAttachmentsSampleJsonTest(ServersSampleBase): self._verify_response('attach-volume-to-server-resp', subs, response, 200) - def _stub_compute_api_get_instance_bdms(self, server_id): - - def fake_compute_api_get_instance_bdms(self, context, instance): - bdms = [ - {'volume_id': 'a26887c6-c47b-4654-abb5-dfadf7d3f803', - 'instance_uuid': server_id, - 'device_name': '/dev/sdd'}, - {'volume_id': 'a26887c6-c47b-4654-abb5-dfadf7d3f804', - 'instance_uuid': server_id, - 'device_name': '/dev/sdc'} - ] - return bdms - - self.stubs.Set(compute_api.API, "get_instance_bdms", - fake_compute_api_get_instance_bdms) - - def _stub_compute_api_get(self): - - def fake_compute_api_get(self, context, instance_id): - return {'uuid': instance_id} - - self.stubs.Set(compute_api.API, 'get', fake_compute_api_get) - def test_list_volume_attachments(self): server_id = self._post_server() @@ -3950,6 +3953,35 @@ class VolumeAttachmentsSampleXmlTest(VolumeAttachmentsSampleJsonTest): ctype = 'xml' +class VolumeAttachUpdateSampleJsonTest(VolumeAttachmentsSampleBase): + extends_name = ("nova.api.openstack.compute.contrib.volumes.Volumes") + extension_name = ("nova.api.openstack.compute.contrib." + "volume_attachment_update.Volume_attachment_update") + + 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_compute_api_get_instance_bdms(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, 202) + self.assertEqual(response.read(), '') + + +class VolumeAttachUpdateSampleXmlTest(VolumeAttachUpdateSampleJsonTest): + ctype = 'xml' + + class VolumesSampleJsonTest(ServersSampleBase): extension_name = ("nova.api.openstack.compute.contrib.volumes.Volumes") diff --git a/nova/tests/virt/test_virt_drivers.py b/nova/tests/virt/test_virt_drivers.py index b7a9b90aba70..5f390e34a556 100644 --- a/nova/tests/virt/test_virt_drivers.py +++ b/nova/tests/virt/test_virt_drivers.py @@ -418,6 +418,17 @@ class _VirtDriverTestCase(_FakeDriverBackendTestCase): instance_ref, '/dev/sda') + @catch_notimplementederror + def test_swap_volume(self): + instance_ref, network_info = self._get_running_instance() + self.connection.attach_volume({'driver_volume_type': 'fake'}, + instance_ref, + '/dev/sda') + self.connection.swap_volume({'driver_volume_type': 'fake'}, + {'driver_volume_type': 'fake'}, + instance_ref, + '/dev/sda') + @catch_notimplementederror def test_attach_detach_different_power_states(self): instance_ref, network_info = self._get_running_instance() diff --git a/nova/virt/driver.py b/nova/virt/driver.py index 400428e65ce3..3f83dc1dd68f 100755 --- a/nova/virt/driver.py +++ b/nova/virt/driver.py @@ -328,6 +328,11 @@ class ComputeDriver(object): """Detach the disk attached to the instance.""" raise NotImplementedError() + def swap_volume(self, old_connection_info, new_connection_info, + instance, mountpoint): + """Replace the disk attached to the instance.""" + raise NotImplementedError() + def attach_interface(self, instance, image_meta, network_info): """Attach an interface to the instance.""" raise NotImplementedError() diff --git a/nova/virt/fake.py b/nova/virt/fake.py index 8ef83dc8c3bf..95f08efa874b 100755 --- a/nova/virt/fake.py +++ b/nova/virt/fake.py @@ -234,6 +234,15 @@ class FakeDriver(driver.ComputeDriver): pass return True + def swap_volume(self, old_connection_info, new_connection_info, + instance, mountpoint): + """Replace the disk attached to the instance.""" + instance_name = instance['name'] + if instance_name not in self._mounts: + self._mounts[instance_name] = {} + self._mounts[instance_name][mountpoint] = new_connection_info + return True + def attach_interface(self, instance, image_meta, network_info): for (network, mapping) in network_info: if mapping['vif_uuid'] in self._interfaces: diff --git a/nova/virt/libvirt/driver.py b/nova/virt/libvirt/driver.py index 3517802a9885..53e5510da51b 100755 --- a/nova/virt/libvirt/driver.py +++ b/nova/virt/libvirt/driver.py @@ -1044,6 +1044,65 @@ class LibvirtDriver(driver.ComputeDriver): connection_info, disk_dev) + def _swap_volume(self, domain, disk_path, new_path): + """Swap existing disk with a new block device.""" + # Save a copy of the domain's running XML file + xml = domain.XMLDesc(0) + + # Abort is an idempotent operation, so make sure any block + # jobs which may have failed are ended. + try: + domain.blockJobAbort(disk_path, 0) + except Exception: + pass + + try: + # NOTE (rmk): blockRebase cannot be executed on persistent + # domains, so we need to temporarily undefine it. + # If any part of this block fails, the domain is + # re-defined regardless. + if domain.isPersistent(): + domain.undefine() + + domain.blockRebase(disk_path, new_path, 0, + libvirt.VIR_DOMAIN_BLOCK_REBASE_COPY) + + while self._wait_for_block_job(domain, disk_path): + time.sleep(0.5) + + domain.blockJobAbort(disk_path, + libvirt.VIR_DOMAIN_BLOCK_JOB_ABORT_PIVOT) + finally: + self._conn.defineXML(xml) + + def swap_volume(self, old_connection_info, + new_connection_info, instance, mountpoint): + instance_name = instance['name'] + virt_dom = self._lookup_by_name(instance_name) + disk_dev = mountpoint.rpartition("/")[2] + xml = self._get_disk_xml(virt_dom.XMLDesc(0), disk_dev) + if not xml: + raise exception.DiskNotFound(location=disk_dev) + disk_info = { + 'dev': disk_dev, + 'bus': blockinfo.get_disk_bus_for_disk_dev(CONF.libvirt_type, + disk_dev), + 'type': 'disk', + } + conf = self.volume_driver_method('connect_volume', + new_connection_info, + disk_info) + if not conf.source_path: + self.volume_driver_method('disconnect_volume', + new_connection_info, + disk_dev) + raise NotImplementedError(_("Swap only supports host devices")) + + self._swap_volume(virt_dom, disk_dev, conf.source_path) + self.volume_driver_method('disconnect_volume', + old_connection_info, + disk_dev) + @staticmethod def _get_disk_xml(xml, device): """Returns the xml for the disk mounted at device.""" @@ -1288,23 +1347,31 @@ class LibvirtDriver(driver.ComputeDriver): LOG.info(_("Snapshot image upload complete"), instance=instance) + @staticmethod + def _wait_for_block_job(domain, disk_path): + status = domain.blockJobInfo(disk_path, 0) + try: + cur = status.get('cur', 0) + end = status.get('end', 0) + except Exception: + return False + + if cur == end and cur != 0 and end != 0: + return False + else: + return True + def _live_snapshot(self, domain, disk_path, out_path, image_format): """Snapshot an instance without downtime.""" # Save a copy of the domain's running XML file xml = domain.XMLDesc(0) - def _wait_for_block_job(domain, disk_path): - status = domain.blockJobInfo(disk_path, 0) - try: - cur = status.get('cur', 0) - end = status.get('end', 0) - except Exception: - return False - - if cur == end and cur != 0 and end != 0: - return False - else: - return True + # Abort is an idempotent operation, so make sure any block + # jobs which may have failed are ended. + try: + domain.blockJobAbort(disk_path, 0) + except Exception: + pass # NOTE (rmk): We are using shallow rebases as a workaround to a bug # in QEMU 1.3. In order to do this, we need to create @@ -1332,7 +1399,7 @@ class LibvirtDriver(driver.ComputeDriver): libvirt.VIR_DOMAIN_BLOCK_REBASE_REUSE_EXT | libvirt.VIR_DOMAIN_BLOCK_REBASE_SHALLOW) - while _wait_for_block_job(domain, disk_path): + while self._wait_for_block_job(domain, disk_path): time.sleep(0.5) domain.blockJobAbort(disk_path, 0)