diff --git a/nova/compute/api.py b/nova/compute/api.py index 800fe5143a3..aa13e966ab0 100644 --- a/nova/compute/api.py +++ b/nova/compute/api.py @@ -46,6 +46,7 @@ from nova.i18n import _ from nova.i18n import _LE from nova import image +from nova import keymgr from nova import network from nova.network import model as network_model from nova.network.security_group import openstack_driver @@ -111,9 +112,32 @@ 'boot from volume. A negative number means unlimited.'), ] +ephemeral_storage_encryption_group = cfg.OptGroup( + name='ephemeral_storage_encryption', + title='Ephemeral storage encryption options') + +ephemeral_storage_encryption_opts = [ + cfg.BoolOpt('enabled', + default=False, + help='Whether to encrypt ephemeral storage'), + cfg.StrOpt('cipher', + default='aes-xts-plain64', + help='The cipher and mode to be used to encrypt ephemeral ' + 'storage. Which ciphers are available ciphers depends ' + 'on kernel support. See /proc/crypto for the list of ' + 'available options.'), + cfg.IntOpt('key_size', + default=512, + help='The bit length of the encryption key to be used to ' + 'encrypt ephemeral storage (in XTS mode only half of ' + 'the bits are used for encryption key)') +] CONF = cfg.CONF CONF.register_opts(compute_opts) +CONF.register_group(ephemeral_storage_encryption_group) +CONF.register_opts(ephemeral_storage_encryption_opts, + group='ephemeral_storage_encryption') CONF.import_opt('compute_topic', 'nova.compute.rpcapi') CONF.import_opt('enable', 'nova.cells.opts', group='cells') CONF.import_opt('default_ephemeral_format', 'nova.virt.driver') @@ -244,6 +268,8 @@ def __init__(self, image_api=None, network_api=None, volume_api=None, self._compute_task_api = None self.servicegroup_api = servicegroup.API() self.notifier = rpc.get_notifier('compute', CONF.host) + if CONF.ephemeral_storage_encryption.enabled: + self.key_manager = keymgr.API() super(API, self).__init__(**kwargs) @@ -1185,7 +1211,7 @@ def _populate_instance_names(self, instance, num_instances): def _default_display_name(self, instance_uuid): return "Server %s" % instance_uuid - def _populate_instance_for_create(self, instance, image, + def _populate_instance_for_create(self, context, instance, image, index, security_groups, instance_type): """Build the beginning of a new instance.""" @@ -1201,6 +1227,12 @@ def _populate_instance_for_create(self, instance, image, info_cache.instance_uuid = instance.uuid info_cache.network_info = network_model.NetworkInfo() instance.info_cache = info_cache + if CONF.ephemeral_storage_encryption.enabled: + instance.ephemeral_key_uuid = self.key_manager.create_key( + context, + length=CONF.ephemeral_storage_encryption.key_size) + else: + instance.ephemeral_key_uuid = None # Store image properties so we can use them later # (for notifications, etc). Only store what we can. @@ -1233,7 +1265,7 @@ def create_db_entry_for_new_instance(self, context, instance_type, image, This is called by the scheduler after a location for the instance has been determined. """ - self._populate_instance_for_create(instance, image, index, + self._populate_instance_for_create(context, instance, image, index, security_group, instance_type) self._populate_instance_names(instance, num_instances) diff --git a/nova/tests/compute/test_compute.py b/nova/tests/compute/test_compute.py index 3ec0d8f1071..8a01b09636b 100644 --- a/nova/tests/compute/test_compute.py +++ b/nova/tests/compute/test_compute.py @@ -7313,6 +7313,7 @@ def test_populate_instance_for_create(self): instance.update(base_options) inst_type = flavors.get_flavor_by_name("m1.tiny") instance = self.compute_api._populate_instance_for_create( + self.context, instance, self.fake_image, 1, @@ -7331,9 +7332,9 @@ def test_default_hostname_generator(self): orig_populate = self.compute_api._populate_instance_for_create - def _fake_populate(base_options, *args, **kwargs): + def _fake_populate(context, base_options, *args, **kwargs): base_options['uuid'] = fake_uuids.pop(0) - return orig_populate(base_options, *args, **kwargs) + return orig_populate(context, base_options, *args, **kwargs) self.stubs.Set(self.compute_api, '_populate_instance_for_create', diff --git a/nova/tests/virt/libvirt/fake_imagebackend.py b/nova/tests/virt/libvirt/fake_imagebackend.py index 66822c18ed4..297fc6071e0 100644 --- a/nova/tests/virt/libvirt/fake_imagebackend.py +++ b/nova/tests/virt/libvirt/fake_imagebackend.py @@ -52,10 +52,12 @@ def libvirt_info(self, disk_bus, disk_dev, device_type, return FakeImage(instance, name) - def snapshot(self, path, image_type=''): + def snapshot(self, instance, disk_path, image_type=''): # NOTE(bfilippov): this is done in favor for # snapshot tests in test_libvirt.LibvirtConnTestCase - return imagebackend.Backend(True).snapshot(path, image_type) + return imagebackend.Backend(True).snapshot(instance, + disk_path, + image_type=image_type) class Raw(imagebackend.Image): diff --git a/nova/tests/virt/libvirt/fake_libvirt_utils.py b/nova/tests/virt/libvirt/fake_libvirt_utils.py index f2f0781eb00..e25487e78ff 100644 --- a/nova/tests/virt/libvirt/fake_libvirt_utils.py +++ b/nova/tests/virt/libvirt/fake_libvirt_utils.py @@ -156,7 +156,12 @@ def file_open(path, mode=None): def find_disk(virt_dom): - return "filename" + if disk_type == 'lvm': + return "/dev/nova-vg/lv" + elif disk_type in ['raw', 'qcow2']: + return "filename" + else: + return "unknown_type_disk" def load_file(path): diff --git a/nova/tests/virt/libvirt/test_driver.py b/nova/tests/virt/libvirt/test_driver.py index 78c6ebae1c1..1af7358db2a 100644 --- a/nova/tests/virt/libvirt/test_driver.py +++ b/nova/tests/virt/libvirt/test_driver.py @@ -3634,6 +3634,83 @@ def convert_image(source, dest, out_format): self.assertEqual(snapshot['disk_format'], 'raw') self.assertEqual(snapshot['name'], snapshot_name) + def test_lvm_snapshot_in_raw_format(self): + # Tests Lvm backend snapshot functionality with raw format + # snapshots. + xml = """ + + + + + + + + """ + update_task_state_calls = [ + mock.call(task_state=task_states.IMAGE_PENDING_UPLOAD), + mock.call(task_state=task_states.IMAGE_UPLOADING, + expected_state=task_states.IMAGE_PENDING_UPLOAD)] + mock_update_task_state = mock.Mock() + mock_lookupByName = mock.Mock(return_value=FakeVirtDomain(xml), + autospec=True) + volume_info = {'VG': 'nova-vg', 'LV': 'disk'} + mock_volume_info = mock.Mock(return_value=volume_info, + autospec=True) + mock_volume_info_calls = [mock.call('/dev/nova-vg/lv')] + mock_convert_image = mock.Mock() + + def convert_image_side_effect(source, dest, out_format, + run_as_root=True): + libvirt_driver.libvirt_utils.files[dest] = '' + mock_convert_image.side_effect = convert_image_side_effect + + self.flags(snapshots_directory='./', + snapshot_image_format='raw', + images_type='lvm', + images_volume_group='nova-vg', group='libvirt') + libvirt_driver.libvirt_utils.disk_type = "lvm" + + # Start test + image_service = nova.tests.image.fake.FakeImageService() + instance_ref = db.instance_create(self.context, self.test_instance) + properties = {'instance_id': instance_ref['id'], + 'user_id': str(self.context.user_id)} + snapshot_name = 'test-snap' + sent_meta = {'name': snapshot_name, 'is_public': False, + 'status': 'creating', 'properties': properties} + recv_meta = image_service.create(context, sent_meta) + + conn = libvirt_driver.LibvirtDriver(fake.FakeVirtAPI(), False) + with contextlib.nested( + mock.patch.object(libvirt_driver.LibvirtDriver, + '_conn', + autospec=True), + mock.patch.object(libvirt_driver.imagebackend.lvm, + 'volume_info', + mock_volume_info), + mock.patch.object(libvirt_driver.imagebackend.images, + 'convert_image', + mock_convert_image), + mock.patch.object(libvirt_driver.LibvirtDriver, + '_lookup_by_name', + mock_lookupByName)): + conn.snapshot(self.context, instance_ref, recv_meta['id'], + mock_update_task_state) + + mock_lookupByName.assert_called_once_with("instance-00000001") + mock_volume_info.assert_has_calls(mock_volume_info_calls) + mock_convert_image.assert_called_once() + snapshot = image_service.show(context, recv_meta['id']) + mock_update_task_state.assert_has_calls(update_task_state_calls) + self.assertEqual('available', snapshot['properties']['image_state']) + self.assertEqual('active', snapshot['status']) + self.assertEqual('raw', snapshot['disk_format']) + self.assertEqual(snapshot_name, snapshot['name']) + # This is for all the subsequent tests that do not set the value of + # images type + self.flags(images_type='default', group='libvirt') + libvirt_driver.libvirt_utils.disk_type = "qcow2" + def test_lxc_snapshot_in_raw_format(self): expected_calls = [ {'args': (), @@ -3665,6 +3742,7 @@ def test_lxc_snapshot_in_raw_format(self): self.mox.StubOutWithMock(libvirt_driver.utils, 'execute') libvirt_driver.utils.execute = self.fake_execute self.stubs.Set(libvirt_driver.libvirt_utils, 'disk_type', 'raw') + libvirt_driver.libvirt_utils.disk_type = "raw" def convert_image(source, dest, out_format): libvirt_driver.libvirt_utils.files[dest] = '' @@ -3775,6 +3853,80 @@ def test_lxc_snapshot_in_qcow2_format(self): self.assertEqual(snapshot['disk_format'], 'qcow2') self.assertEqual(snapshot['name'], snapshot_name) + def test_lvm_snapshot_in_qcow2_format(self): + # Tests Lvm backend snapshot functionality with raw format + # snapshots. + xml = """ + + + + + + + + """ + update_task_state_calls = [ + mock.call(task_state=task_states.IMAGE_PENDING_UPLOAD), + mock.call(task_state=task_states.IMAGE_UPLOADING, + expected_state=task_states.IMAGE_PENDING_UPLOAD)] + mock_update_task_state = mock.Mock() + mock_lookupByName = mock.Mock(return_value=FakeVirtDomain(xml), + autospec=True) + volume_info = {'VG': 'nova-vg', 'LV': 'disk'} + mock_volume_info = mock.Mock(return_value=volume_info, autospec=True) + mock_volume_info_calls = [mock.call('/dev/nova-vg/lv')] + mock_convert_image = mock.Mock() + + def convert_image_side_effect(source, dest, out_format, + run_as_root=True): + libvirt_driver.libvirt_utils.files[dest] = '' + mock_convert_image.side_effect = convert_image_side_effect + + self.flags(snapshots_directory='./', + snapshot_image_format='qcow2', + images_type='lvm', + images_volume_group='nova-vg', group='libvirt') + libvirt_driver.libvirt_utils.disk_type = "lvm" + + # Start test + image_service = nova.tests.image.fake.FakeImageService() + instance_ref = db.instance_create(self.context, self.test_instance) + properties = {'instance_id': instance_ref['id'], + 'user_id': str(self.context.user_id)} + snapshot_name = 'test-snap' + sent_meta = {'name': snapshot_name, 'is_public': False, + 'status': 'creating', 'properties': properties} + recv_meta = image_service.create(context, sent_meta) + + conn = libvirt_driver.LibvirtDriver(fake.FakeVirtAPI(), False) + with contextlib.nested( + mock.patch.object(libvirt_driver.LibvirtDriver, + '_conn', + autospec=True), + mock.patch.object(libvirt_driver.imagebackend.lvm, + 'volume_info', + mock_volume_info), + mock.patch.object(libvirt_driver.imagebackend.images, + 'convert_image', + mock_convert_image), + mock.patch.object(libvirt_driver.LibvirtDriver, + '_lookup_by_name', + mock_lookupByName)): + conn.snapshot(self.context, instance_ref, recv_meta['id'], + mock_update_task_state) + + mock_lookupByName.assert_called_once_with("instance-00000001") + mock_volume_info.assert_has_calls(mock_volume_info_calls) + mock_convert_image.assert_called_once() + snapshot = image_service.show(context, recv_meta['id']) + mock_update_task_state.assert_has_calls(update_task_state_calls) + self.assertEqual('available', snapshot['properties']['image_state']) + self.assertEqual('active', snapshot['status']) + self.assertEqual('qcow2', snapshot['disk_format']) + self.assertEqual(snapshot_name, snapshot['name']) + self.flags(images_type='default', group='libvirt') + libvirt_driver.libvirt_utils.disk_type = "qcow2" + def test_snapshot_no_image_architecture(self): expected_calls = [ {'args': (), @@ -3857,6 +4009,7 @@ def test_lxc_snapshot_no_image_architecture(self): libvirt_driver.LibvirtDriver._conn.lookupByName = self.fake_lookup self.mox.StubOutWithMock(libvirt_driver.utils, 'execute') libvirt_driver.utils.execute = self.fake_execute + libvirt_driver.libvirt_utils.disk_type = "qcow2" self.mox.ReplayAll() @@ -3927,6 +4080,7 @@ def test_lxc_snapshot_no_original_image(self): self.flags(snapshots_directory='./', virt_type='lxc', group='libvirt') + libvirt_driver.libvirt_utils.disk_type = "qcow2" # Assign a non-existent image test_instance = copy.deepcopy(self.test_instance) @@ -6281,7 +6435,6 @@ def _test_destroy_removes_disk(self, volume_fail=False): self.mox.StubOutWithMock(libvirt_driver.LibvirtDriver, '_undefine_domain') - libvirt_driver.LibvirtDriver._undefine_domain(instance) self.mox.StubOutWithMock(db, 'instance_get_by_uuid') db.instance_get_by_uuid(mox.IgnoreArg(), mox.IgnoreArg(), columns_to_join=['info_cache', @@ -6304,8 +6457,7 @@ def _test_destroy_removes_disk(self, volume_fail=False): 'delete_instance_files') (libvirt_driver.LibvirtDriver.delete_instance_files(mox.IgnoreArg()). AndReturn(True)) - self.mox.StubOutWithMock(libvirt_driver.LibvirtDriver, '_cleanup_lvm') - libvirt_driver.LibvirtDriver._cleanup_lvm(instance) + libvirt_driver.LibvirtDriver._undefine_domain(instance) # Start test self.mox.ReplayAll() diff --git a/nova/tests/virt/libvirt/test_imagebackend.py b/nova/tests/virt/libvirt/test_imagebackend.py index 3a87c2e3979..2b4b9dcf2b0 100644 --- a/nova/tests/virt/libvirt/test_imagebackend.py +++ b/nova/tests/virt/libvirt/test_imagebackend.py @@ -13,16 +13,19 @@ # License for the specific language governing permissions and limitations # under the License. +import contextlib +import inspect import os import shutil import tempfile import fixtures +import mock from oslo.config import cfg -import inspect - +from nova import context from nova import exception +from nova import keymgr from nova.openstack.common import units from nova.openstack.common import uuidutils from nova import test @@ -52,6 +55,7 @@ def setUp(self): self.INSTANCE['uuid'], 'disk.info') self.NAME = 'fake.vm' self.TEMPLATE = 'template' + self.CONTEXT = context.get_admin_context() self.OLD_STYLE_INSTANCE_PATH = \ fake_libvirt_utils.get_instance_path(self.INSTANCE, forceold=True) @@ -462,10 +466,11 @@ def setUp(self): self.image_class = imagebackend.Lvm super(LvmTestCase, self).setUp() self.flags(images_volume_group=self.VG, group='libvirt') + self.flags(enabled=False, group='ephemeral_storage_encryption') + self.INSTANCE['ephemeral_key_uuid'] = None self.LV = '%s_%s' % (self.INSTANCE['uuid'], self.NAME) self.OLD_STYLE_INSTANCE_PATH = None self.PATH = os.path.join('/dev', self.VG, self.LV) - self.disk = imagebackend.disk self.utils = imagebackend.utils self.lvm = imagebackend.lvm @@ -656,6 +661,378 @@ def fake_fetch(target, *args, **kwargs): self.assertEqual(fake_processutils.fake_execute_get_log(), []) +class EncryptedLvmTestCase(_ImageTestCase, test.TestCase): + VG = 'FakeVG' + TEMPLATE_SIZE = 512 + SIZE = 1024 + + def setUp(self): + super(EncryptedLvmTestCase, self).setUp() + self.image_class = imagebackend.Lvm + self.flags(enabled=True, group='ephemeral_storage_encryption') + self.flags(cipher='aes-xts-plain64', + group='ephemeral_storage_encryption') + self.flags(key_size=512, group='ephemeral_storage_encryption') + self.flags(fixed_key='00000000000000000000000000000000' + '00000000000000000000000000000000', + group='keymgr') + self.flags(images_volume_group=self.VG, group='libvirt') + self.LV = '%s_%s' % (self.INSTANCE['uuid'], self.NAME) + self.OLD_STYLE_INSTANCE_PATH = None + self.LV_PATH = os.path.join('/dev', self.VG, self.LV) + self.PATH = os.path.join('/dev/mapper', + imagebackend.dmcrypt.volume_name(self.LV)) + self.key_manager = keymgr.API() + self.INSTANCE['ephemeral_key_uuid'] =\ + self.key_manager.create_key(self.CONTEXT) + self.KEY = self.key_manager.get_key(self.CONTEXT, + self.INSTANCE['ephemeral_key_uuid']).get_encoded() + + self.lvm = imagebackend.lvm + self.disk = imagebackend.disk + self.utils = imagebackend.utils + self.libvirt_utils = imagebackend.libvirt_utils + self.dmcrypt = imagebackend.dmcrypt + + def _create_image(self, sparse): + with contextlib.nested( + mock.patch.object(self.lvm, 'create_volume', mock.Mock()), + mock.patch.object(self.lvm, 'remove_volumes', mock.Mock()), + mock.patch.object(self.disk, 'resize2fs', mock.Mock()), + mock.patch.object(self.disk, 'get_disk_size', + mock.Mock(return_value=self.TEMPLATE_SIZE)), + mock.patch.object(self.dmcrypt, 'create_volume', mock.Mock()), + mock.patch.object(self.dmcrypt, 'delete_volume', mock.Mock()), + mock.patch.object(self.dmcrypt, 'list_volumes', mock.Mock()), + mock.patch.object(self.libvirt_utils, 'create_lvm_image', + mock.Mock()), + mock.patch.object(self.libvirt_utils, 'remove_logical_volumes', + mock.Mock()), + mock.patch.object(self.utils, 'execute', mock.Mock())): + fn = mock.Mock() + + image = self.image_class(self.INSTANCE, self.NAME) + image.create_image(fn, self.TEMPLATE_PATH, self.TEMPLATE_SIZE, + context=self.CONTEXT) + + fn.assert_called_with(context=self.CONTEXT, + max_size=self.TEMPLATE_SIZE, + target=self.TEMPLATE_PATH) + self.lvm.create_volume.assert_called_with(self.VG, + self.LV, + self.TEMPLATE_SIZE, + sparse=sparse) + self.dmcrypt.create_volume.assert_called_with( + self.PATH.rpartition('/')[2], + self.LV_PATH, + CONF.ephemeral_storage_encryption.cipher, + CONF.ephemeral_storage_encryption.key_size, + self.KEY) + cmd = ('qemu-img', + 'convert', + '-O', + 'raw', + self.TEMPLATE_PATH, + self.PATH) + self.utils.execute.assert_called_with(*cmd, run_as_root=True) + + def _create_image_generated(self, sparse): + with contextlib.nested( + mock.patch.object(self.lvm, 'create_volume', mock.Mock()), + mock.patch.object(self.lvm, 'remove_volumes', mock.Mock()), + mock.patch.object(self.disk, 'resize2fs', mock.Mock()), + mock.patch.object(self.disk, 'get_disk_size', + mock.Mock(return_value=self.TEMPLATE_SIZE)), + mock.patch.object(self.dmcrypt, 'create_volume', mock.Mock()), + mock.patch.object(self.dmcrypt, 'delete_volume', mock.Mock()), + mock.patch.object(self.dmcrypt, 'list_volumes', mock.Mock()), + mock.patch.object(self.libvirt_utils, 'create_lvm_image', + mock.Mock()), + mock.patch.object(self.libvirt_utils, 'remove_logical_volumes', + mock.Mock()), + mock.patch.object(self.utils, 'execute', mock.Mock())): + fn = mock.Mock() + + image = self.image_class(self.INSTANCE, self.NAME) + image.create_image(fn, self.TEMPLATE_PATH, + self.SIZE, + ephemeral_size=None, + context=self.CONTEXT) + + self.lvm.create_volume.assert_called_with( + self.VG, + self.LV, + self.SIZE, + sparse=sparse) + self.dmcrypt.create_volume.assert_called_with( + self.PATH.rpartition('/')[2], + self.LV_PATH, + CONF.ephemeral_storage_encryption.cipher, + CONF.ephemeral_storage_encryption.key_size, + self.KEY) + fn.assert_called_with(target=self.PATH, + ephemeral_size=None, context=self.CONTEXT) + + def _create_image_resize(self, sparse): + with contextlib.nested( + mock.patch.object(self.lvm, 'create_volume', mock.Mock()), + mock.patch.object(self.lvm, 'remove_volumes', mock.Mock()), + mock.patch.object(self.disk, 'resize2fs', mock.Mock()), + mock.patch.object(self.disk, 'get_disk_size', + mock.Mock(return_value=self.TEMPLATE_SIZE)), + mock.patch.object(self.dmcrypt, 'create_volume', mock.Mock()), + mock.patch.object(self.dmcrypt, 'delete_volume', mock.Mock()), + mock.patch.object(self.dmcrypt, 'list_volumes', mock.Mock()), + mock.patch.object(self.libvirt_utils, 'create_lvm_image', + mock.Mock()), + mock.patch.object(self.libvirt_utils, 'remove_logical_volumes', + mock.Mock()), + mock.patch.object(self.utils, 'execute', mock.Mock())): + fn = mock.Mock() + + image = self.image_class(self.INSTANCE, self.NAME) + image.create_image(fn, self.TEMPLATE_PATH, self.SIZE, + context=self.CONTEXT) + + fn.assert_called_with(context=self.CONTEXT, max_size=self.SIZE, + target=self.TEMPLATE_PATH) + self.disk.get_disk_size.assert_called_with(self.TEMPLATE_PATH) + self.lvm.create_volume.assert_called_with( + self.VG, + self.LV, + self.SIZE, + sparse=sparse) + self.dmcrypt.create_volume.assert_called_with( + self.PATH.rpartition('/')[2], + self.LV_PATH, + CONF.ephemeral_storage_encryption.cipher, + CONF.ephemeral_storage_encryption.key_size, + self.KEY) + cmd = ('qemu-img', + 'convert', + '-O', + 'raw', + self.TEMPLATE_PATH, + self.PATH) + self.utils.execute.assert_called_with(*cmd, run_as_root=True) + self.disk.resize2fs.assert_called_with(self.PATH, run_as_root=True) + + def test_create_image(self): + self._create_image(False) + + def test_create_image_sparsed(self): + self.flags(sparse_logical_volumes=True, group='libvirt') + self._create_image(True) + + def test_create_image_generated(self): + self._create_image_generated(False) + + def test_create_image_generated_sparsed(self): + self.flags(sparse_logical_volumes=True, group='libvirt') + self._create_image_generated(True) + + def test_create_image_resize(self): + self._create_image_resize(False) + + def test_create_image_resize_sparsed(self): + self.flags(sparse_logical_volumes=True, group='libvirt') + self._create_image_resize(True) + + def test_create_image_negative(self): + with contextlib.nested( + mock.patch.object(self.lvm, 'create_volume', mock.Mock()), + mock.patch.object(self.lvm, 'remove_volumes', mock.Mock()), + mock.patch.object(self.disk, 'resize2fs', mock.Mock()), + mock.patch.object(self.disk, 'get_disk_size', + mock.Mock(return_value=self.TEMPLATE_SIZE)), + mock.patch.object(self.dmcrypt, 'create_volume', mock.Mock()), + mock.patch.object(self.dmcrypt, 'delete_volume', mock.Mock()), + mock.patch.object(self.dmcrypt, 'list_volumes', mock.Mock()), + mock.patch.object(self.libvirt_utils, 'create_lvm_image', + mock.Mock()), + mock.patch.object(self.libvirt_utils, 'remove_logical_volumes', + mock.Mock()), + mock.patch.object(self.utils, 'execute', mock.Mock())): + fn = mock.Mock() + self.lvm.create_volume.side_effect = RuntimeError() + + image = self.image_class(self.INSTANCE, self.NAME) + self.assertRaises( + RuntimeError, + image.create_image, + fn, + self.TEMPLATE_PATH, + self.SIZE, + context=self.CONTEXT) + + fn.assert_called_with( + context=self.CONTEXT, + max_size=self.SIZE, + target=self.TEMPLATE_PATH) + self.disk.get_disk_size.assert_called_with( + self.TEMPLATE_PATH) + self.lvm.create_volume.assert_called_with( + self.VG, + self.LV, + self.SIZE, + sparse=False) + self.dmcrypt.delete_volume.assert_called_with( + self.PATH.rpartition('/')[2]) + self.lvm.remove_volumes.assert_called_with(self.LV_PATH) + + def test_create_image_encrypt_negative(self): + with contextlib.nested( + mock.patch.object(self.lvm, 'create_volume', mock.Mock()), + mock.patch.object(self.lvm, 'remove_volumes', mock.Mock()), + mock.patch.object(self.disk, 'resize2fs', mock.Mock()), + mock.patch.object(self.disk, 'get_disk_size', + mock.Mock(return_value=self.TEMPLATE_SIZE)), + mock.patch.object(self.dmcrypt, 'create_volume', mock.Mock()), + mock.patch.object(self.dmcrypt, 'delete_volume', mock.Mock()), + mock.patch.object(self.dmcrypt, 'list_volumes', mock.Mock()), + mock.patch.object(self.libvirt_utils, 'create_lvm_image', + mock.Mock()), + mock.patch.object(self.libvirt_utils, 'remove_logical_volumes', + mock.Mock()), + mock.patch.object(self.utils, 'execute', mock.Mock())): + fn = mock.Mock() + self.dmcrypt.create_volume.side_effect = RuntimeError() + + image = self.image_class(self.INSTANCE, self.NAME) + self.assertRaises( + RuntimeError, + image.create_image, + fn, + self.TEMPLATE_PATH, + self.SIZE, + context=self.CONTEXT) + + fn.assert_called_with( + context=self.CONTEXT, + max_size=self.SIZE, + target=self.TEMPLATE_PATH) + self.disk.get_disk_size.assert_called_with(self.TEMPLATE_PATH) + self.lvm.create_volume.assert_called_with( + self.VG, + self.LV, + self.SIZE, + sparse=False) + self.dmcrypt.create_volume.assert_called_with( + self.dmcrypt.volume_name(self.LV), + self.LV_PATH, + CONF.ephemeral_storage_encryption.cipher, + CONF.ephemeral_storage_encryption.key_size, + self.KEY) + self.dmcrypt.delete_volume.assert_called_with( + self.PATH.rpartition('/')[2]) + self.lvm.remove_volumes.assert_called_with(self.LV_PATH) + + def test_create_image_generated_negative(self): + with contextlib.nested( + mock.patch.object(self.lvm, 'create_volume', mock.Mock()), + mock.patch.object(self.lvm, 'remove_volumes', mock.Mock()), + mock.patch.object(self.disk, 'resize2fs', mock.Mock()), + mock.patch.object(self.disk, 'get_disk_size', + mock.Mock(return_value=self.TEMPLATE_SIZE)), + mock.patch.object(self.dmcrypt, 'create_volume', mock.Mock()), + mock.patch.object(self.dmcrypt, 'delete_volume', mock.Mock()), + mock.patch.object(self.dmcrypt, 'list_volumes', mock.Mock()), + mock.patch.object(self.libvirt_utils, 'create_lvm_image', + mock.Mock()), + mock.patch.object(self.libvirt_utils, 'remove_logical_volumes', + mock.Mock()), + mock.patch.object(self.utils, 'execute', mock.Mock())): + fn = mock.Mock() + fn.side_effect = RuntimeError() + + image = self.image_class(self.INSTANCE, self.NAME) + self.assertRaises(RuntimeError, + image.create_image, + fn, + self.TEMPLATE_PATH, + self.SIZE, + ephemeral_size=None, + context=self.CONTEXT) + + self.lvm.create_volume.assert_called_with( + self.VG, + self.LV, + self.SIZE, + sparse=False) + self.dmcrypt.create_volume.assert_called_with( + self.PATH.rpartition('/')[2], + self.LV_PATH, + CONF.ephemeral_storage_encryption.cipher, + CONF.ephemeral_storage_encryption.key_size, + self.KEY) + fn.assert_called_with( + target=self.PATH, + ephemeral_size=None, + context=self.CONTEXT) + self.dmcrypt.delete_volume.assert_called_with( + self.PATH.rpartition('/')[2]) + self.lvm.remove_volumes.assert_called_with(self.LV_PATH) + + def test_create_image_generated_encrypt_negative(self): + with contextlib.nested( + mock.patch.object(self.lvm, 'create_volume', mock.Mock()), + mock.patch.object(self.lvm, 'remove_volumes', mock.Mock()), + mock.patch.object(self.disk, 'resize2fs', mock.Mock()), + mock.patch.object(self.disk, 'get_disk_size', + mock.Mock(return_value=self.TEMPLATE_SIZE)), + mock.patch.object(self.dmcrypt, 'create_volume', mock.Mock()), + mock.patch.object(self.dmcrypt, 'delete_volume', mock.Mock()), + mock.patch.object(self.dmcrypt, 'list_volumes', mock.Mock()), + mock.patch.object(self.libvirt_utils, 'create_lvm_image', + mock.Mock()), + mock.patch.object(self.libvirt_utils, 'remove_logical_volumes', + mock.Mock()), + mock.patch.object(self.utils, 'execute', mock.Mock())): + fn = mock.Mock() + fn.side_effect = RuntimeError() + + image = self.image_class(self.INSTANCE, self.NAME) + self.assertRaises( + RuntimeError, + image.create_image, + fn, + self.TEMPLATE_PATH, + self.SIZE, + ephemeral_size=None, + context=self.CONTEXT) + + self.lvm.create_volume.assert_called_with( + self.VG, + self.LV, + self.SIZE, + sparse=False) + self.dmcrypt.create_volume.assert_called_with( + self.PATH.rpartition('/')[2], + self.LV_PATH, + CONF.ephemeral_storage_encryption.cipher, + CONF.ephemeral_storage_encryption.key_size, + self.KEY) + self.dmcrypt.delete_volume.assert_called_with( + self.PATH.rpartition('/')[2]) + self.lvm.remove_volumes.assert_called_with(self.LV_PATH) + + def test_prealloc_image(self): + self.flags(preallocate_images='space') + fake_processutils.fake_execute_clear_log() + fake_processutils.stub_out_processutils_execute(self.stubs) + image = self.image_class(self.INSTANCE, self.NAME) + + def fake_fetch(target, *args, **kwargs): + return + + self.stubs.Set(os.path, 'exists', lambda _: True) + self.stubs.Set(image, 'check_image_exists', lambda: True) + + image.cache(fake_fetch, self.TEMPLATE_PATH, self.SIZE) + + self.assertEqual(fake_processutils.fake_execute_get_log(), []) + + class RbdTestCase(_ImageTestCase, test.NoDBTestCase): POOL = "FakePool" USER = "FakeUser" @@ -862,6 +1239,8 @@ class BackendTestCase(test.NoDBTestCase): def setUp(self): super(BackendTestCase, self).setUp() + self.flags(enabled=False, group='ephemeral_storage_encryption') + self.INSTANCE['ephemeral_key_uuid'] = None def get_image(self, use_cow, image_type): return imagebackend.Backend(use_cow).image(self.INSTANCE, diff --git a/nova/virt/libvirt/dmcrypt.py b/nova/virt/libvirt/dmcrypt.py index fb973725220..0864e7286a4 100644 --- a/nova/virt/libvirt/dmcrypt.py +++ b/nova/virt/libvirt/dmcrypt.py @@ -29,6 +29,14 @@ def volume_name(base): return base + _dmcrypt_suffix +def is_encrypted(path): + """Returns true if the path corresponds to an encrypted disk.""" + if path.startswith('/dev/mapper'): + return path.rpartition('/')[2].endswith(_dmcrypt_suffix) + else: + return False + + def create_volume(target, device, cipher, key_size, key): """Sets up a dmcrypt mapping diff --git a/nova/virt/libvirt/driver.py b/nova/virt/libvirt/driver.py index 425d68e1d2e..1bd5c076cba 100644 --- a/nova/virt/libvirt/driver.py +++ b/nova/virt/libvirt/driver.py @@ -92,6 +92,7 @@ from nova.virt import hardware from nova.virt.libvirt import blockinfo from nova.virt.libvirt import config as vconfig +from nova.virt.libvirt import dmcrypt from nova.virt.libvirt import firewall as libvirt_firewall from nova.virt.libvirt import imagebackend from nova.virt.libvirt import imagecache @@ -243,6 +244,12 @@ CONF.import_opt('my_ip', 'nova.netconf') CONF.import_opt('default_ephemeral_format', 'nova.virt.driver') CONF.import_opt('use_cow_images', 'nova.virt.driver') +CONF.import_opt('enabled', 'nova.compute.api', + group='ephemeral_storage_encryption') +CONF.import_opt('cipher', 'nova.compute.api', + group='ephemeral_storage_encryption') +CONF.import_opt('key_size', 'nova.compute.api', + group='ephemeral_storage_encryption') CONF.import_opt('live_migration_retry_count', 'nova.compute.manager') CONF.import_opt('vncserver_proxyclient_address', 'nova.vnc') CONF.import_opt('server_proxyclient_address', 'nova.spice', group='spice') @@ -1045,7 +1052,6 @@ def _undefine_domain(self, instance): def cleanup(self, context, instance, network_info, block_device_info=None, destroy_disks=True, migrate_data=None, destroy_vifs=True): - self._undefine_domain(instance) if destroy_vifs: self._unplug_vifs(instance, network_info, True) @@ -1119,16 +1125,27 @@ def cleanup(self, context, instance, network_info, block_device_info=None, {'vol_id': vol.get('volume_id'), 'exc': exc}, instance=instance) + if destroy_disks: + # NOTE(haomai): destroy volumes if needed + if CONF.libvirt.images_type == 'lvm': + self._cleanup_lvm(instance) + if CONF.libvirt.images_type == 'rbd': + self._cleanup_rbd(instance) + if destroy_disks or ( migrate_data and migrate_data.get('is_shared_block_storage', False)): self._delete_instance_files(instance) - if destroy_disks: - self._cleanup_lvm(instance) - # NOTE(haomai): destroy volumes if needed - if CONF.libvirt.images_type == 'rbd': - self._cleanup_rbd(instance) + self._undefine_domain(instance) + + def _detach_encrypted_volumes(self, instance): + """Detaches encrypted volumes attached to instance.""" + disks = jsonutils.loads(self.get_instance_disk_info(instance['name'])) + encrypted_volumes = filter(dmcrypt.is_encrypted, + [disk['path'] for disk in disks]) + for path in encrypted_volumes: + dmcrypt.delete_volume(path) if CONF.serial_console.enabled: for host, port in self._get_serial_ports_from_instance(instance): @@ -1162,6 +1179,9 @@ def _cleanup_rbd(self, instance): def _cleanup_lvm(self, instance): """Delete all LVM disks for given instance object.""" + if instance.get('ephemeral_key_uuid') is not None: + self._detach_encrypted_volumes(instance) + disks = self._lvm_disks(instance) if disks: lvm.remove_volumes(disks) @@ -1593,10 +1613,16 @@ def snapshot(self, context, instance, image_id, update_task_state): # NOTE(rmk): Live snapshots require QEMU 1.3 and Libvirt 1.0.0. # These restrictions can be relaxed as other configurations # can be validated. - if self._has_min_version(MIN_LIBVIRT_LIVESNAPSHOT_VERSION, - MIN_QEMU_LIVESNAPSHOT_VERSION, - REQ_HYPERVISOR_LIVESNAPSHOT) \ - and not source_format == "lvm" and not source_format == 'rbd': + # NOTE(dgenin): Instances with LVM encrypted ephemeral storage require + # cold snapshots. Currently, checking for encryption is + # redundant because LVM supports only cold snapshots. + # It is necessary in case this situation changes in the + # future. + if (self._has_min_version(MIN_LIBVIRT_LIVESNAPSHOT_VERSION, + MIN_QEMU_LIVESNAPSHOT_VERSION, + REQ_HYPERVISOR_LIVESNAPSHOT) + and source_format not in ('lvm', 'rbd') + and not CONF.ephemeral_storage_encryption.enabled): live_snapshot = True # Abort is an idempotent operation, so make sure any block # jobs which may have failed are ended. This operation also @@ -1626,7 +1652,8 @@ def snapshot(self, context, instance, image_id, update_task_state): pci_manager.get_instance_pci_devs(instance)) virt_dom.managedSave(0) - snapshot_backend = self.image_backend.snapshot(disk_path, + snapshot_backend = self.image_backend.snapshot(instance, + disk_path, image_type=source_format) if live_snapshot: @@ -2732,7 +2759,7 @@ def _create_local(target, local_size, unit='G', def _create_ephemeral(self, target, ephemeral_size, fs_label, os_type, is_block_dev=False, - max_size=None, specified_fs=None): + max_size=None, context=None, specified_fs=None): if not is_block_dev: self._create_local(target, ephemeral_size) @@ -2741,7 +2768,7 @@ def _create_ephemeral(self, target, ephemeral_size, specified_fs=specified_fs) @staticmethod - def _create_swap(target, swap_mb, max_size=None): + def _create_swap(target, swap_mb, max_size=None, context=None): """Create a swap file of specified size.""" libvirt_utils.create_image('raw', target, '%dM' % swap_mb) utils.mkfs('swap', target) @@ -2961,6 +2988,7 @@ def clone_fallback_to_fetch(*args, **kwargs): fname = "ephemeral_%s_%s" % (ephemeral_gb, os_type_with_default) size = ephemeral_gb * units.Gi disk_image.cache(fetch_func=fn, + context=context, filename=fname, size=size, ephemeral_size=ephemeral_gb) @@ -2980,8 +3008,8 @@ def clone_fallback_to_fetch(*args, **kwargs): is_block_dev=disk_image.is_block_dev) size = eph['size'] * units.Gi fname = "ephemeral_%s_%s" % (eph['size'], os_type_with_default) - disk_image.cache( - fetch_func=fn, + disk_image.cache(fetch_func=fn, + context=context, filename=fname, size=size, ephemeral_size=eph['size'], @@ -3002,6 +3030,7 @@ def clone_fallback_to_fetch(*args, **kwargs): if swap_mb > 0: size = swap_mb * units.Mi image('disk.swap').cache(fetch_func=self._create_swap, + context=context, filename="swap_%s" % swap_mb, size=size, swap_mb=swap_mb) @@ -5281,7 +5310,7 @@ def _get_instance_disk_info(self, instance_name, xml, for cnt, path_node in enumerate(path_nodes): disk_type = disk_nodes[cnt].get('type') - path = path_node.get('file') + path = path_node.get('file') or path_node.get('dev') target = target_nodes[cnt].attrib['dev'] if not path: @@ -5289,8 +5318,8 @@ def _get_instance_disk_info(self, instance_name, xml, instance_name) continue - if disk_type != 'file': - LOG.debug('skipping %s since it looks like volume', path) + if disk_type not in ['file', 'block']: + LOG.debug('skipping disk because it looks like a volume', path) continue if target in volume_devices: @@ -5300,7 +5329,10 @@ def _get_instance_disk_info(self, instance_name, xml, # get the real disk size or # raise a localized error if image is unavailable - dk_size = int(os.path.getsize(path)) + if disk_type == 'file': + dk_size = int(os.path.getsize(path)) + elif disk_type == 'block': + dk_size = lvm.get_volume_size(path) disk_type = driver_nodes[cnt].get('type') if disk_type == "qcow2": diff --git a/nova/virt/libvirt/imagebackend.py b/nova/virt/libvirt/imagebackend.py index 54c586bc3b3..f5d699506cd 100644 --- a/nova/virt/libvirt/imagebackend.py +++ b/nova/virt/libvirt/imagebackend.py @@ -24,6 +24,7 @@ from nova.i18n import _ from nova.i18n import _LE from nova import image +from nova import keymgr from nova.openstack.common import excutils from nova.openstack.common import fileutils from nova.openstack.common import jsonutils @@ -33,6 +34,7 @@ from nova.virt.disk import api as disk from nova.virt import images from nova.virt.libvirt import config as vconfig +from nova.virt.libvirt import dmcrypt from nova.virt.libvirt import lvm from nova.virt.libvirt import rbd_utils from nova.virt.libvirt import utils as libvirt_utils @@ -69,6 +71,12 @@ CONF.register_opts(__imagebackend_opts, 'libvirt') CONF.import_opt('image_cache_subdirectory_name', 'nova.virt.imagecache') CONF.import_opt('preallocate_images', 'nova.virt.driver') +CONF.import_opt('enabled', 'nova.compute.api', + group='ephemeral_storage_encryption') +CONF.import_opt('cipher', 'nova.compute.api', + group='ephemeral_storage_encryption') +CONF.import_opt('key_size', 'nova.compute.api', + group='ephemeral_storage_encryption') CONF.import_opt('rbd_user', 'nova.virt.libvirt.volume', group='libvirt') CONF.import_opt('rbd_secret_uuid', 'nova.virt.libvirt.volume', group='libvirt') @@ -88,6 +96,12 @@ def __init__(self, source_type, driver_format, is_block_dev=False): :driver_format: raw or qcow2 :is_block_dev: """ + if (CONF.ephemeral_storage_encryption.enabled and + not self._supports_encryption()): + raise exception.NovaException(_('Incompatible settings: ' + 'ephemeral storage encryption is supported ' + 'only for LVM images.')) + self.source_type = source_type self.driver_format = driver_format self.is_block_dev = is_block_dev @@ -103,6 +117,12 @@ def __init__(self, source_type, driver_format, is_block_dev=False): # are trying to create a base file at the same time self.lock_path = os.path.join(CONF.instances_path, 'locks') + def _supports_encryption(self): + """Used to test that the backend supports encryption. + Override in the subclass if backend supports encryption. + """ + return False + @abc.abstractmethod def create_image(self, prepare_template, base, size, *args, **kwargs): """Create image from template. @@ -317,6 +337,7 @@ def clone(self, context, image_id_or_uri): class Raw(Image): def __init__(self, instance=None, disk_name=None, path=None): + self.disk_name = disk_name super(Raw, self).__init__("file", "raw", is_block_dev=False) self.path = (path or @@ -331,6 +352,18 @@ def _get_driver_format(self): data = images.qemu_img_info(self.path) return data.file_format or 'raw' + def _supports_encryption(self): + # NOTE(dgenin): Kernel, ramdisk and disk.config are fetched using + # the Raw backend regardless of which backend is configured for + # ephemeral storage. Encryption for the Raw backend is not yet + # implemented so this loophole is necessary to allow other + # backends already supporting encryption to function. This can + # be removed once encryption for Raw is implemented. + if self.disk_name not in ['kernel', 'ramdisk', 'disk.config']: + return False + else: + return True + def correct_format(self): if os.path.exists(self.path): self.driver_format = self.resolve_driver_format() @@ -436,11 +469,21 @@ def escape(filename): def __init__(self, instance=None, disk_name=None, path=None): super(Lvm, self).__init__("block", "raw", is_block_dev=True) + self.ephemeral_key_uuid = instance.get('ephemeral_key_uuid') + + if self.ephemeral_key_uuid is not None: + self.key_manager = keymgr.API() + else: + self.key_manager = None + if path: - info = lvm.volume_info(path) - self.vg = info['VG'] - self.lv = info['LV'] self.path = path + if self.ephemeral_key_uuid is None: + info = lvm.volume_info(path) + self.vg = info['VG'] + self.lv = info['LV'] + else: + self.vg = CONF.libvirt.images_volume_group else: if not CONF.libvirt.images_volume_group: raise RuntimeError(_('You should specify' @@ -449,20 +492,32 @@ def __init__(self, instance=None, disk_name=None, path=None): self.vg = CONF.libvirt.images_volume_group self.lv = '%s_%s' % (instance['uuid'], self.escape(disk_name)) - self.path = os.path.join('/dev', self.vg, self.lv) + if self.ephemeral_key_uuid is None: + self.path = os.path.join('/dev', self.vg, self.lv) + else: + self.lv_path = os.path.join('/dev', self.vg, self.lv) + self.path = '/dev/mapper/' + dmcrypt.volume_name(self.lv) # TODO(pbrady): possibly deprecate libvirt.sparse_logical_volumes # for the more general preallocate_images self.sparse = CONF.libvirt.sparse_logical_volumes self.preallocate = not self.sparse + def _supports_encryption(self): + return True + def _can_fallocate(self): return False def create_image(self, prepare_template, base, size, *args, **kwargs): - filename = os.path.split(base)[-1] - - @utils.synchronized(filename, external=True, lock_path=self.lock_path) + def encrypt_lvm_image(): + dmcrypt.create_volume(self.path.rpartition('/')[2], + self.lv_path, + CONF.ephemeral_storage_encryption.cipher, + CONF.ephemeral_storage_encryption.key_size, + key) + + @utils.synchronized(base, external=True, lock_path=self.lock_path) def create_lvm_image(base, size): base_size = disk.get_disk_size(base) self.verify_base_size(base, size, base_size=base_size) @@ -470,17 +525,35 @@ def create_lvm_image(base, size): size = size if resize else base_size lvm.create_volume(self.vg, self.lv, size, sparse=self.sparse) + if self.ephemeral_key_uuid is not None: + encrypt_lvm_image() images.convert_image(base, self.path, 'raw', run_as_root=True) if resize: disk.resize2fs(self.path, run_as_root=True) generated = 'ephemeral_size' in kwargs - + if self.ephemeral_key_uuid is not None: + if 'context' in kwargs: + try: + # NOTE(dgenin): Key manager corresponding to the + # specific backend catches and reraises an + # an exception if key retrieval fails. + key = self.key_manager.get_key(kwargs['context'], + self.ephemeral_key_uuid).get_encoded() + except Exception: + with excutils.save_and_reraise_exception(): + LOG.error(_LE("Failed to retrieve ephemeral encryption" + " key")) + else: + raise exception.NovaException( + _("Instance disk to be encrypted but no context provided")) # Generate images with specified size right on volume if generated and size: lvm.create_volume(self.vg, self.lv, size, sparse=self.sparse) with self.remove_volume_on_error(self.path): + if self.ephemeral_key_uuid is not None: + encrypt_lvm_image() prepare_template(target=self.path, *args, **kwargs) else: if not os.path.exists(base): @@ -494,7 +567,11 @@ def remove_volume_on_error(self, path): yield except Exception: with excutils.save_and_reraise_exception(): - lvm.remove_volumes(path) + if self.ephemeral_key_uuid is None: + lvm.remove_volumes(path) + else: + dmcrypt.delete_volume(path.rpartition('/')[2]) + lvm.remove_volumes(self.lv_path) def snapshot_extract(self, target, out_format): images.convert_image(self.path, target, out_format, @@ -655,16 +732,15 @@ def image(self, instance, disk_name, image_type=None): :name: Image name. :image_type: Image type. Optional, is CONF.libvirt.images_type by default. - """ backend = self.backend(image_type) return backend(instance=instance, disk_name=disk_name) - def snapshot(self, disk_path, image_type=None): + def snapshot(self, instance, disk_path, image_type=None): """Returns snapshot for given image :path: path to image :image_type: type of image """ backend = self.backend(image_type) - return backend(path=disk_path) + return backend(instance=instance, path=disk_path)