diff --git a/nova/api/openstack/contrib/diskconfig.py b/nova/api/openstack/contrib/diskconfig.py new file mode 100644 index 000000000000..5173050fd29e --- /dev/null +++ b/nova/api/openstack/contrib/diskconfig.py @@ -0,0 +1,150 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 OpenStack LLC. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License + +import json + +from webob import exc +import webob + +from nova import compute +from nova import exception +import nova.image +from nova import log as logging +from nova import network +from nova import rpc +from nova.api.openstack import faults +from nova.api.openstack import extensions +from nova.api.openstack import wsgi + +LOG = logging.getLogger('nova.api.openstack.contrib.disk_config') + + +class DiskConfigController(object): + def __init__(self): + self.compute_api = compute.API() + + def _return_dict(self, server_id, managed_disk): + return {'server': {'id': server_id, + 'managed_disk': managed_disk}} + + def index(self, req, server_id): + context = req.environ['nova.context'] + try: + server = self.compute_api.routing_get(context, server_id) + except exception.NotFound: + explanation = _("Server not found.") + return faults.Fault(exc.HTTPNotFound(explanation=explanation)) + managed_disk = server['managed_disk'] or False + return self._return_dict(server_id, managed_disk) + + def update(self, req, server_id, body=None): + if not body: + return faults.Fault(exc.HTTPUnprocessableEntity()) + context = req.environ['nova.context'] + try: + server = self.compute_api.routing_get(context, server_id) + except exception.NotFound: + explanation = _("Server not found.") + return faults.Fault(exc.HTTPNotFound(explanation=explanation)) + + managed_disk = str(body['server'].get('managed_disk', False)).lower() + managed_disk = managed_disk == 'true' or False + self.compute_api.update(context, server_id, managed_disk=managed_disk) + + return self._return_dict(server_id, managed_disk) + + +class ImageDiskConfigController(object): + def __init__(self, image_service=None): + self.compute_api = compute.API() + self._image_service = image_service or \ + nova.image.get_default_image_service() + + def _return_dict(self, image_id, managed_disk): + return {'image': {'id': image_id, + 'managed_disk': managed_disk}} + + def index(self, req, image_id): + context = req.environ['nova.context'] + try: + image = self._image_service.show(context, image_id) + except (exception.NotFound, exception.InvalidImageRef): + explanation = _("Image not found.") + raise webob.exc.HTTPNotFound(explanation=explanation) + image_properties = image.get('properties', None) + if image_properties: + managed_disk = image_properties.get('managed_disk', False) + + return self._return_dict(image_id, managed_disk) + + +class Diskconfig(extensions.ExtensionDescriptor): + def __init__(self): + super(Diskconfig, self).__init__() + + def get_name(self): + return "DiskConfig" + + def get_alias(self): + return "OS-DCFG" + + def get_description(self): + return "Disk Configuration support" + + def get_namespace(self): + return "http://docs.openstack.org/ext/disk_config/api/v1.1" + + def get_updated(self): + return "2011-08-31T00:00:00+00:00" + + def _server_extension_controller(self): + metadata = { + "attributes": { + 'managed_disk': ["server_id", "enabled"]}} + + body_serializers = { + 'application/xml': wsgi.XMLDictSerializer(metadata=metadata, + xmlns=wsgi.XMLNS_V11)} + serializer = wsgi.ResponseSerializer(body_serializers, None) + res = extensions.ResourceExtension( + 'os-disk-config', + controller=DiskConfigController(), + collection_actions={'update': 'PUT'}, + parent=dict(member_name='server', collection_name='servers'), + serializer=serializer) + return res + + def _image_extension_controller(self): + resources = [] + metadata = { + "attributes": { + 'managed_disk': ["image_id", "enabled"]}} + + body_serializers = { + 'application/xml': wsgi.XMLDictSerializer(metadata=metadata, + xmlns=wsgi.XMLNS_V11)} + serializer = wsgi.ResponseSerializer(body_serializers, None) + res = extensions.ResourceExtension( + 'os-disk-config', + controller=ImageDiskConfigController(), + collection_actions={'update': 'PUT'}, + parent=dict(member_name='image', collection_name='images'), + serializer=serializer) + return res + + def get_resources(self): + return [self._server_extension_controller(), + self._image_extension_controller()] diff --git a/nova/db/sqlalchemy/migrate_repo/versions/050_add_disk_config_to_instances.py b/nova/db/sqlalchemy/migrate_repo/versions/050_add_disk_config_to_instances.py new file mode 100644 index 000000000000..72e49f2ad3be --- /dev/null +++ b/nova/db/sqlalchemy/migrate_repo/versions/050_add_disk_config_to_instances.py @@ -0,0 +1,39 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2011 OpenStack LLC. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from sqlalchemy import Column, Integer, MetaData, Table, Boolean + +meta = MetaData() + +# temporary table for creating the new columns + +instances = Table("instances", meta, + Column("id", Integer(), primary_key=True, nullable=False)) + +# The new column + +managed_disk = Column("managed_disk", Boolean(create_constraint=False, + name=None)) + + +def upgrade(migrate_engine): + meta.bind = migrate_engine + instances.create_column(managed_disk) + + +def downgrade(migrate_engine): + meta.bind = migrate_engine + instances.drop_column(managed_disk) diff --git a/nova/db/sqlalchemy/models.py b/nova/db/sqlalchemy/models.py index 2261a1d09544..2d9340777d01 100644 --- a/nova/db/sqlalchemy/models.py +++ b/nova/db/sqlalchemy/models.py @@ -241,6 +241,7 @@ class Instance(BASE, NovaBase): access_ip_v4 = Column(String(255)) access_ip_v6 = Column(String(255)) + managed_disk = Column(Boolean()) progress = Column(Integer) diff --git a/nova/tests/api/openstack/contrib/test_diskconfig.py b/nova/tests/api/openstack/contrib/test_diskconfig.py new file mode 100644 index 000000000000..b6843b49c59b --- /dev/null +++ b/nova/tests/api/openstack/contrib/test_diskconfig.py @@ -0,0 +1,156 @@ +# Copyright 2011 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import json +import webob + +from nova import compute +from nova import exception +from nova import image +from nova import test +from nova.api.openstack.contrib.diskconfig import DiskConfigController +from nova.api.openstack.contrib.diskconfig import ImageDiskConfigController +from nova.tests.api.openstack import fakes + + +class DiskConfigTest(test.TestCase): + + def test_retrieve_disk_config(self): + def fake_compute_get(*args, **kwargs): + return {'managed_disk': True} + + self.stubs.Set(compute.api.API, 'routing_get', fake_compute_get) + req = webob.Request.blank('/v1.1/openstack/servers/50/os-disk-config') + req.headers['Accept'] = 'application/json' + res = req.get_response(fakes.wsgi_app()) + self.assertEqual(res.status_int, 200) + body = json.loads(res.body) + self.assertEqual(body['server']['managed_disk'], True) + self.assertEqual(int(body['server']['id']), 50) + + def test_set_disk_config(self): + def fake_compute_get(*args, **kwargs): + return {'managed_disk': 'True'} + + def fake_compute_update(*args, **kwargs): + return {'managed_disk': 'False'} + + self.stubs.Set(compute.api.API, 'update', fake_compute_update) + self.stubs.Set(compute.api.API, 'routing_get', fake_compute_get) + + req = webob.Request.blank('/v1.1/openstack/servers/50/os-disk-config') + req.method = 'PUT' + req.headers['Accept'] = 'application/json' + req.headers['Content-Type'] = 'application/json' + req.body = json.dumps({'server': {'managed_disk': False}}) + + res = req.get_response(fakes.wsgi_app()) + self.assertEqual(res.status_int, 200) + body = json.loads(res.body) + self.assertEqual(body['server']['managed_disk'], False) + self.assertEqual(int(body['server']['id']), 50) + + def test_retrieve_disk_config_bad_server_fails(self): + def fake_compute_get(*args, **kwargs): + raise exception.NotFound() + + self.stubs.Set(compute.api.API, 'routing_get', fake_compute_get) + req = webob.Request.blank('/v1.1/openstack/servers/50/os-disk-config') + req.headers['Accept'] = 'application/json' + res = req.get_response(fakes.wsgi_app()) + self.assertEqual(res.status_int, 404) + + def test_set_disk_config_bad_server_fails(self): + self.called = False + + def fake_compute_get(*args, **kwargs): + raise exception.NotFound() + + def fake_compute_update(*args, **kwargs): + self.called = True + + self.stubs.Set(compute.api.API, 'update', fake_compute_update) + self.stubs.Set(compute.api.API, 'routing_get', fake_compute_get) + + req = webob.Request.blank('/v1.1/openstack/servers/50/os-disk-config') + req.method = 'PUT' + req.headers['Accept'] = 'application/json' + req.headers['Content-Type'] = 'application/json' + req.body = json.dumps({'server': {'managed_disk': False}}) + + res = req.get_response(fakes.wsgi_app()) + self.assertEqual(res.status_int, 404) + self.assertEqual(self.called, False) + + +class ImageDiskConfigTest(test.TestCase): + + NOW_GLANCE_FORMAT = "2010-10-11T10:30:22" + NOW_API_FORMAT = "2010-10-11T10:30:22Z" + + def test_image_get_disk_config(self): + self.flags(image_service='nova.image.glance.GlanceImageService') + fakes.stub_out_glance(self.stubs) + + def fake_image_service_show(*args, **kwargs): + return {'properties': {'managed_disk': True}} + + self.stubs.Set(image.glance.GlanceImageService, 'show', + fake_image_service_show) + + req = webob.Request.blank('/v1.1/openstack/images/10/os-disk-config') + req.headers['Accept'] = 'application/json' + res = req.get_response(fakes.wsgi_app()) + self.assertEqual(res.status_int, 200) + + body = json.loads(res.body) + + self.assertEqual(body['image']['managed_disk'], True) + self.assertEqual(int(body['image']['id']), 10) + + def test_image_get_disk_config_no_image_fails(self): + self.flags(image_service='nova.image.glance.GlanceImageService') + fakes.stub_out_glance(self.stubs) + + def fake_image_service_show(*args, **kwargs): + raise exception.NotFound() + + self.stubs.Set(image.glance.GlanceImageService, 'show', + fake_image_service_show) + + req = webob.Request.blank('/v1.1/openstack/images/10/os-disk-config') + req.headers['Accept'] = 'application/json' + res = req.get_response(fakes.wsgi_app()) + self.assertEqual(res.status_int, 404) + + @classmethod + def _make_image_fixtures(cls): + image_id = 123 + base_attrs = {'created_at': cls.NOW_GLANCE_FORMAT, + 'updated_at': cls.NOW_GLANCE_FORMAT, + 'deleted_at': None, + 'deleted': False} + + fixtures = [] + + def add_fixture(**kwargs): + kwargs.update(base_attrs) + fixtures.append(kwargs) + + # Public image + add_fixture(id=1, name='snapshot', is_public=False, + status='active', properties={}) + + return fixtures diff --git a/nova/tests/api/openstack/test_extensions.py b/nova/tests/api/openstack/test_extensions.py index ca36523e4576..a5c6fe65a453 100644 --- a/nova/tests/api/openstack/test_extensions.py +++ b/nova/tests/api/openstack/test_extensions.py @@ -102,6 +102,7 @@ class ExtensionControllerTest(test.TestCase): "VirtualInterfaces", "Volumes", "VolumeTypes", + "DiskConfig", ] self.ext_list.sort() diff --git a/nova/tests/xenapi/stubs.py b/nova/tests/xenapi/stubs.py index aee279920182..3b3d494bac68 100644 --- a/nova/tests/xenapi/stubs.py +++ b/nova/tests/xenapi/stubs.py @@ -298,6 +298,9 @@ class FakeSessionForMigrationTests(fake.SessionBase): def VM_set_name_label(self, *args): pass + def VDI_set_name_label(self, session_ref, vdi_ref, name_label): + pass + def stub_out_migration_methods(stubs): def fake_create_snapshot(self, instance): diff --git a/nova/virt/xenapi/vm_utils.py b/nova/virt/xenapi/vm_utils.py index 5778ca1c2e7f..51f102689206 100644 --- a/nova/virt/xenapi/vm_utils.py +++ b/nova/virt/xenapi/vm_utils.py @@ -313,6 +313,11 @@ class VMHelper(HelperBase): % locals()) return vdi_ref + @classmethod + def set_vdi_name_label(cls, session, vdi_uuid, name_label): + vdi_ref = session.get_xenapi().VDI.get_by_uuid(vdi_uuid) + session.get_xenapi().VDI.set_name_label(vdi_ref, name_label) + @classmethod def get_vdi_for_vm_safely(cls, session, vm_ref): """Retrieves the primary VDI for a VM""" @@ -370,7 +375,8 @@ class VMHelper(HelperBase): return os.path.join(FLAGS.xenapi_sr_base_path, sr_uuid) @classmethod - def upload_image(cls, context, session, instance, vdi_uuids, image_id): + def upload_image(cls, context, session, instance, vdi_uuids, image_id, + options=None): """ Requests that the Glance plugin bundle the specified VDIs and push them into Glance using the specified human-friendly name. """ @@ -388,7 +394,8 @@ class VMHelper(HelperBase): 'glance_port': glance_port, 'sr_path': cls.get_sr_path(session), 'os_type': os_type, - 'auth_token': getattr(context, 'auth_token', None)} + 'auth_token': getattr(context, 'auth_token', None), + 'options': options} kwargs = {'params': pickle.dumps(params)} task = session.async_call_plugin('glance', 'upload_vhd', kwargs) @@ -471,7 +478,7 @@ class VMHelper(HelperBase): # Set the name-label to ease debugging vdi_ref = session.get_xenapi().VDI.get_by_uuid(os_vdi_uuid) - primary_name_label = get_name_label_for_image(image) + primary_name_label = instance.name session.get_xenapi().VDI.set_name_label(vdi_ref, primary_name_label) cls._check_vdi_size(context, session, instance, os_vdi_uuid) @@ -559,7 +566,7 @@ class VMHelper(HelperBase): _("Kernel/Ramdisk image is too large: %(vdi_size)d bytes, " "max %(max_size)d bytes") % locals()) - name_label = get_name_label_for_image(image) + name_label = instance.name vdi_ref = cls.create_vdi(session, sr_ref, name_label, vdi_size, False) # From this point we have a VDI on Xen host; # If anything goes wrong, we need to remember its uuid. @@ -1156,11 +1163,6 @@ def _write_partition(virtual_size, dev): LOG.debug(_('Writing partition table %s done.'), dest) -def get_name_label_for_image(image): - # TODO(sirp): This should eventually be the URI for the Glance image - return _('Glance image %s') % image - - def _mount_filesystem(dev_path, dir): """mounts the device specified by dev_path in dir""" try: diff --git a/nova/virt/xenapi/vmops.py b/nova/virt/xenapi/vmops.py index 1dfa5abd1e71..bf4481d694ec 100644 --- a/nova/virt/xenapi/vmops.py +++ b/nova/virt/xenapi/vmops.py @@ -549,12 +549,16 @@ class VMOps(object): """ template_vm_ref = None + options = None + if instance['managed_disk']: + options = {'managed_disk': instance['managed_disk']} try: template_vm_ref, template_vdi_uuids =\ self._create_snapshot(instance) # call plugin to ship snapshot off to glance VMHelper.upload_image(context, - self._session, instance, template_vdi_uuids, image_id) + self._session, instance, template_vdi_uuids, image_id, + options) finally: if template_vm_ref: self._destroy(instance, template_vm_ref, @@ -697,6 +701,11 @@ class VMOps(object): # Now we rescan the SR so we find the VHDs VMHelper.scan_default_sr(self._session) + # Set name-label so we can find if we need to clean up a failed + # migration + VMHelper.set_vdi_name_label(self._session, new_cow_uuid, + instance.name) + return new_cow_uuid def resize_instance(self, instance, vdi_uuid): diff --git a/plugins/xenserver/xenapi/etc/xapi.d/plugins/glance b/plugins/xenserver/xenapi/etc/xapi.d/plugins/glance index 950b787079f0..e7bf7e5a8335 100755 --- a/plugins/xenserver/xenapi/etc/xapi.d/plugins/glance +++ b/plugins/xenserver/xenapi/etc/xapi.d/plugins/glance @@ -246,7 +246,7 @@ def _prepare_staging_area_for_upload(sr_path, staging_path, vdi_uuids): def _upload_tarball(staging_path, image_id, glance_host, glance_port, os_type, - auth_token): + auth_token, options): """ Create a tarball of the image and then stream that into Glance using chunked-transfer-encoded HTTP. @@ -293,6 +293,9 @@ def _upload_tarball(staging_path, image_id, glance_host, glance_port, os_type, 'x-image-meta-container-format': 'ovf', 'x-image-meta-property-os-type': os_type} + if options.get('managed_disk'): + headers['x-image-meta-property-managed-disk'] = options['managed_disk'] + # If we have an auth_token, set an x-auth-token header if auth_token: ovf_headers['x-auth-token'] = auth_token @@ -424,12 +427,13 @@ def upload_vhd(session, args): sr_path = params["sr_path"] os_type = params["os_type"] auth_token = params["auth_token"] + options = params["options"] staging_path = _make_staging_area(sr_path) try: _prepare_staging_area_for_upload(sr_path, staging_path, vdi_uuids) _upload_tarball(staging_path, image_id, glance_host, glance_port, - os_type, auth_token) + os_type, auth_token, options) finally: _cleanup_staging_area(staging_path)