From 8c0673d3102478ef34ef1b11c1db156db4b68f62 Mon Sep 17 00:00:00 2001 From: Joel Coffman Date: Wed, 11 Sep 2013 12:15:43 -0400 Subject: [PATCH] Add encryption support for volumes to libvirt Add support to encrypt Cinder volumes. Hooks within the libvirt driver encrypt volumes during the attach call. Created a VolumeEncryptor interface as well as several concrete subclasses (e.g., CryptsetupEncryptor and LuksEncryptor) to handle encryption. This feature depends upon related changes that have been accepted into Cinder (e.g., storing encryption key UUIDs for encrypted volumes). Implements: blueprint encrypt-cinder-volumes Change-Id: I358813b3ecde4f88de7202c1c07d9b1168c2c332 SecurityImpact --- etc/nova/rootwrap.d/compute.filters | 2 + nova/compute/manager.py | 20 +++- nova/tests/compute/test_compute.py | 28 ++++- nova/tests/keymgr/test_single_key_mgr.py | 3 - nova/tests/virt/hyperv/test_hypervapi.py | 3 +- nova/tests/virt/libvirt/test_libvirt.py | 6 +- nova/tests/virt/test_block_device.py | 8 +- nova/tests/virt/test_virt_drivers.py | 21 ++-- nova/tests/virt/vmwareapi/test_vmwareapi.py | 12 +- nova/tests/virt/xenapi/test_xenapi.py | 7 +- nova/tests/volume/encryptors/__init__.py | 16 +++ nova/tests/volume/encryptors/test_base.py | 40 +++++++ .../volume/encryptors/test_cryptsetup.py | 85 ++++++++++++++ nova/tests/volume/encryptors/test_luks.py | 76 ++++++++++++ nova/tests/volume/encryptors/test_nop.py | 33 ++++++ nova/tests/volume/test_cinder.py | 12 ++ nova/virt/baremetal/driver.py | 11 +- nova/virt/driver.py | 6 +- nova/virt/fake.py | 8 +- nova/virt/hyperv/driver.py | 8 +- nova/virt/libvirt/driver.py | 85 ++++++++++++-- nova/virt/powervm/driver.py | 2 +- nova/virt/vmwareapi/driver.py | 11 +- nova/virt/xenapi/driver.py | 8 +- nova/volume/cinder.py | 3 + nova/volume/encryptors/__init__.py | 68 +++++++++++ nova/volume/encryptors/base.py | 60 ++++++++++ nova/volume/encryptors/cryptsetup.py | 103 +++++++++++++++++ nova/volume/encryptors/luks.py | 108 ++++++++++++++++++ nova/volume/encryptors/nop.py | 41 +++++++ 30 files changed, 824 insertions(+), 70 deletions(-) create mode 100644 nova/tests/volume/encryptors/__init__.py create mode 100644 nova/tests/volume/encryptors/test_base.py create mode 100644 nova/tests/volume/encryptors/test_cryptsetup.py create mode 100644 nova/tests/volume/encryptors/test_luks.py create mode 100644 nova/tests/volume/encryptors/test_nop.py create mode 100644 nova/volume/encryptors/__init__.py create mode 100644 nova/volume/encryptors/base.py create mode 100644 nova/volume/encryptors/cryptsetup.py create mode 100644 nova/volume/encryptors/luks.py create mode 100644 nova/volume/encryptors/nop.py diff --git a/etc/nova/rootwrap.d/compute.filters b/etc/nova/rootwrap.d/compute.filters index fec68ff70d..e98c3f265d 100644 --- a/etc/nova/rootwrap.d/compute.filters +++ b/etc/nova/rootwrap.d/compute.filters @@ -198,6 +198,8 @@ systool: CommandFilter, systool, root # nova/virt/libvirt/volume.py: sginfo: CommandFilter, sginfo, root sg_scan: CommandFilter, sg_scan, root +cryptsetup: CommandFilter, cryptsetup, root +ln: RegExpFilter, ln, root, ln, --symbolic, --force, /dev/mapper/ip-.*-iscsi-iqn.2010-10.org.openstack:volume-.*, /dev/disk/by-path/ip-.*-iscsi-iqn.2010-10.org.openstack:volume-.* # nova/virt/xenapi/vm_utils.py: xenstore-read: CommandFilter, xenstore-read, root diff --git a/nova/compute/manager.py b/nova/compute/manager.py index 22f5ebe7ad..0eb4fe2a10 100755 --- a/nova/compute/manager.py +++ b/nova/compute/manager.py @@ -83,6 +83,7 @@ from nova.virt import event as virtevent from nova.virt import storage_users from nova.virt import virtapi from nova import volume +from nova.volume import encryptors compute_opts = [ @@ -1414,7 +1415,6 @@ class ComputeManager(manager.SchedulerDependentManager): injected_files, admin_password, network_info, block_device_info) - except Exception: with excutils.save_and_reraise_exception(): LOG.exception(_('Instance failed to spawn'), instance=instance) @@ -1654,8 +1654,8 @@ class ComputeManager(manager.SchedulerDependentManager): # NOTE(melwitt): attempt driver destroy before releasing ip, may # want to keep ip allocated for certain failures try: - self.driver.destroy(instance, network_info, - block_device_info) + self.driver.destroy(instance, network_info, block_device_info, + context=context) except exception.InstancePowerOffFailure: # if the instance can't power off, don't release the ip with excutils.save_and_reraise_exception(): @@ -3598,10 +3598,14 @@ class ComputeManager(manager.SchedulerDependentManager): if 'serial' not in connection_info: connection_info['serial'] = volume_id + encryption = encryptors.get_encryption_metadata(context, volume_id, + connection_info) try: - self.driver.attach_volume(connection_info, + self.driver.attach_volume(context, + connection_info, instance, - mountpoint) + mountpoint, + encryption=encryption) except Exception: # pylint: disable=W0702 with excutils.save_and_reraise_exception(): LOG.exception(_("Failed to attach volume %(volume_id)s " @@ -3651,9 +3655,13 @@ class ComputeManager(manager.SchedulerDependentManager): if not self.driver.instance_exists(instance['name']): LOG.warn(_('Detaching volume from unknown instance'), context=context, instance=instance) + + encryption = encryptors.get_encryption_metadata(context, volume_id, + connection_info) self.driver.detach_volume(connection_info, instance, - mp) + mp, + encryption=encryption) except Exception: # pylint: disable=W0702 with excutils.save_and_reraise_exception(): LOG.exception(_('Failed to detach volume %(volume_id)s ' diff --git a/nova/tests/compute/test_compute.py b/nova/tests/compute/test_compute.py index 15b26d53dc..5108a7bb1e 100644 --- a/nova/tests/compute/test_compute.py +++ b/nova/tests/compute/test_compute.py @@ -368,6 +368,10 @@ class ComputeVolumeTestCase(BaseTestCase): store_cinfo) def test_attach_volume_serial(self): + def fake_get_volume_encryption_metadata(self, context, volume_id): + return {} + self.stubs.Set(cinder.API, 'get_volume_encryption_metadata', + fake_get_volume_encryption_metadata) instance = self._create_fake_instance() self.compute.attach_volume(self.context, self.volume_id, @@ -574,6 +578,11 @@ class ComputeVolumeTestCase(BaseTestCase): self.mox.ReplayAll() + def fake_get_volume_encryption_metadata(self, context, volume_id): + return {} + self.stubs.Set(cinder.API, 'get_volume_encryption_metadata', + fake_get_volume_encryption_metadata) + self.compute.attach_volume(self.context, 1, '/dev/vdb', instance) # Poll volume usage & then detach the volume. This will update the @@ -2860,8 +2869,10 @@ class ComputeTestCase(BaseTestCase): self.mox.StubOutWithMock(self.compute.driver, 'destroy') self.mox.StubOutWithMock(self.compute, '_deallocate_network') exp = exception.InstancePowerOffFailure(reason='') - self.compute.driver.destroy(mox.IgnoreArg(), mox.IgnoreArg(), - mox.IgnoreArg()).AndRaise(exp) + self.compute.driver.destroy(mox.IgnoreArg(), + mox.IgnoreArg(), + mox.IgnoreArg(), + context=mox.IgnoreArg()).AndRaise(exp) # mox will detect if _deallocate_network gets called unexpectedly self.mox.ReplayAll() instance = self._create_fake_instance() @@ -2875,8 +2886,10 @@ class ComputeTestCase(BaseTestCase): self.mox.StubOutWithMock(self.compute.driver, 'destroy') self.mox.StubOutWithMock(self.compute, '_deallocate_network') exp = test.TestingException() - self.compute.driver.destroy(mox.IgnoreArg(), mox.IgnoreArg(), - mox.IgnoreArg()).AndRaise(exp) + self.compute.driver.destroy(mox.IgnoreArg(), + mox.IgnoreArg(), + mox.IgnoreArg(), + context=mox.IgnoreArg()).AndRaise(exp) self.compute._deallocate_network(mox.IgnoreArg(), mox.IgnoreArg(), mox.IgnoreArg()) @@ -3388,6 +3401,11 @@ class ComputeTestCase(BaseTestCase): return volume self.stubs.Set(cinder.API, "get", fake_volume_get) + def fake_get_volume_encryption_metadata(self, context, volume_id): + return {} + self.stubs.Set(cinder.API, 'get_volume_encryption_metadata', + fake_get_volume_encryption_metadata) + orig_connection_data = { 'target_discovered': True, 'target_iqn': 'iqn.2010-10.org.openstack:%s.1' % volume_id, @@ -9115,7 +9133,7 @@ class ComputeInjectedFilesTestCase(BaseTestCase): self.stubs.Set(self.compute.driver, 'spawn', self._spawn) def _spawn(self, context, instance, image_meta, injected_files, - admin_password, nw_info, block_device_info): + admin_password, nw_info, block_device_info, db_api=None): self.assertEqual(self.expected, injected_files) def _test(self, injected_files, decoded_files): diff --git a/nova/tests/keymgr/test_single_key_mgr.py b/nova/tests/keymgr/test_single_key_mgr.py index a44a06d039..85eb74e503 100644 --- a/nova/tests/keymgr/test_single_key_mgr.py +++ b/nova/tests/keymgr/test_single_key_mgr.py @@ -20,7 +20,6 @@ Test cases for the single key manager. import array -from nova import context from nova import exception from nova.keymgr import key from nova.tests.keymgr import single_key_mgr @@ -35,8 +34,6 @@ class SingleKeyManagerTestCase(test_mock_key_mgr.MockKeyManagerTestCase): def setUp(self): super(SingleKeyManagerTestCase, self).setUp() - self.ctxt = context.RequestContext('fake', 'fake') - self.key_id = '00000000-0000-0000-0000-000000000000' encoded = array.array('B', ('0' * 64).decode('hex')).tolist() self.key = key.SymmetricKey('AES', encoded) diff --git a/nova/tests/virt/hyperv/test_hypervapi.py b/nova/tests/virt/hyperv/test_hypervapi.py index ab38af0fbf..13816ac48c 100644 --- a/nova/tests/virt/hyperv/test_hypervapi.py +++ b/nova/tests/virt/hyperv/test_hypervapi.py @@ -1214,7 +1214,8 @@ class HyperVAPITestCase(test.TestCase): target_portal) self._mox.ReplayAll() - self._conn.attach_volume(connection_info, instance_data, mount_point) + self._conn.attach_volume(None, connection_info, instance_data, + mount_point) self._mox.VerifyAll() self.assertEquals(len(self._instance_volume_disks), 1) diff --git a/nova/tests/virt/libvirt/test_libvirt.py b/nova/tests/virt/libvirt/test_libvirt.py index 78aeb8235b..1ab70454cd 100644 --- a/nova/tests/virt/libvirt/test_libvirt.py +++ b/nova/tests/virt/libvirt/test_libvirt.py @@ -2293,7 +2293,7 @@ class LibvirtConnTestCase(test.TestCase): self.mox.ReplayAll() conn = libvirt_driver.LibvirtDriver(fake.FakeVirtAPI(), False) self.assertRaises(exception.VolumeDriverNotFound, - conn.attach_volume, + conn.attach_volume, None, {"driver_volume_type": "badtype"}, {"name": "fake-instance"}, "/dev/sda") @@ -2305,7 +2305,7 @@ class LibvirtConnTestCase(test.TestCase): self.mox.ReplayAll() conn = libvirt_driver.LibvirtDriver(fake.FakeVirtAPI(), False) self.assertRaises(exception.InvalidHypervisorType, - conn.attach_volume, + conn.attach_volume, None, {"driver_volume_type": "fake", "data": {"logical_block_size": "4096", "physical_block_size": "4096"} @@ -2323,7 +2323,7 @@ class LibvirtConnTestCase(test.TestCase): conn = libvirt_driver.LibvirtDriver(fake.FakeVirtAPI(), False) self.stubs.Set(self.conn, "getLibVersion", get_lib_version_stub) self.assertRaises(exception.Invalid, - conn.attach_volume, + conn.attach_volume, None, {"driver_volume_type": "fake", "data": {"logical_block_size": "4096", "physical_block_size": "4096"} diff --git a/nova/tests/virt/test_block_device.py b/nova/tests/virt/test_block_device.py index 5928c1f601..591efe8ed4 100644 --- a/nova/tests/virt/test_block_device.py +++ b/nova/tests/virt/test_block_device.py @@ -278,8 +278,8 @@ class TestDriverBlockDevice(test.TestCase): instance = {'id': 'fake_id', 'uuid': 'fake_uuid'} volume = {'id': 'fake-volume-id-1'} connector = {'ip': 'fake_ip', 'host': 'fake_host'} - connection_info = {'data': 'fake_data'} - expected_conn_info = {'data': 'fake_data', + connection_info = {'data': {}} + expected_conn_info = {'data': {}, 'serial': 'fake-volume-id-1'} self.volume_api.get(self.context, @@ -308,8 +308,8 @@ class TestDriverBlockDevice(test.TestCase): instance = {'id': 'fake_id', 'uuid': 'fake_uuid'} connector = {'ip': 'fake_ip', 'host': 'fake_host'} - connection_info = {'data': 'fake_data'} - expected_conn_info = {'data': 'fake_data', + connection_info = {'data': {}} + expected_conn_info = {'data': {}, 'serial': 'fake-volume-id-2'} self.virt_driver.get_volume_connector(instance).AndReturn(connector) diff --git a/nova/tests/virt/test_virt_drivers.py b/nova/tests/virt/test_virt_drivers.py index 5c28cd701f..88d04c4f7c 100644 --- a/nova/tests/virt/test_virt_drivers.py +++ b/nova/tests/virt/test_virt_drivers.py @@ -420,17 +420,19 @@ class _VirtDriverTestCase(_FakeDriverBackendTestCase): @catch_notimplementederror def test_attach_detach_volume(self): instance_ref, network_info = self._get_running_instance() - self.connection.attach_volume({'driver_volume_type': 'fake'}, - instance_ref, + connection_info = { + "driver_volume_type": "fake", + "serial": "fake_serial", + } + self.connection.attach_volume(None, connection_info, instance_ref, '/dev/sda') - self.connection.detach_volume({'driver_volume_type': 'fake'}, - instance_ref, + self.connection.detach_volume(connection_info, 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'}, + self.connection.attach_volume(None, {'driver_volume_type': 'fake'}, instance_ref, '/dev/sda') self.connection.swap_volume({'driver_volume_type': 'fake'}, @@ -441,9 +443,12 @@ class _VirtDriverTestCase(_FakeDriverBackendTestCase): @catch_notimplementederror def test_attach_detach_different_power_states(self): instance_ref, network_info = self._get_running_instance() + connection_info = { + "driver_volume_type": "fake", + "serial": "fake_serial", + } self.connection.power_off(instance_ref) - self.connection.attach_volume({'driver_volume_type': 'fake'}, - instance_ref, + self.connection.attach_volume(None, connection_info, instance_ref, '/dev/sda') bdm = { @@ -463,7 +468,7 @@ class _VirtDriverTestCase(_FakeDriverBackendTestCase): }] } self.connection.power_on(self.ctxt, instance_ref, network_info, bdm) - self.connection.detach_volume({'driver_volume_type': 'fake'}, + self.connection.detach_volume(connection_info, instance_ref, '/dev/sda') diff --git a/nova/tests/virt/vmwareapi/test_vmwareapi.py b/nova/tests/virt/vmwareapi/test_vmwareapi.py index 4c7acc424c..85a0d7e8b3 100644 --- a/nova/tests/virt/vmwareapi/test_vmwareapi.py +++ b/nova/tests/virt/vmwareapi/test_vmwareapi.py @@ -687,7 +687,8 @@ class VMwareAPIVMTestCase(test.TestCase): volumeops.VMwareVolumeOps._attach_volume_vmdk(connection_info, self.instance, mount_point) self.mox.ReplayAll() - self.conn.attach_volume(connection_info, self.instance, mount_point) + self.conn.attach_volume(None, connection_info, self.instance, + mount_point) def test_volume_detach_vmdk(self): self._create_vm() @@ -723,7 +724,8 @@ class VMwareAPIVMTestCase(test.TestCase): controller_key=mox.IgnoreArg(), unit_number=mox.IgnoreArg()) self.mox.ReplayAll() - self.conn.attach_volume(connection_info, self.instance, mount_point) + self.conn.attach_volume(None, connection_info, self.instance, + mount_point) def test_detach_vmdk_disk_from_vm(self): self._create_vm() @@ -756,7 +758,8 @@ class VMwareAPIVMTestCase(test.TestCase): volumeops.VMwareVolumeOps._attach_volume_iscsi(connection_info, self.instance, mount_point) self.mox.ReplayAll() - self.conn.attach_volume(connection_info, self.instance, mount_point) + self.conn.attach_volume(None, connection_info, self.instance, + mount_point) def test_volume_detach_iscsi(self): self._create_vm() @@ -788,7 +791,8 @@ class VMwareAPIVMTestCase(test.TestCase): unit_number=mox.IgnoreArg(), device_name=mox.IgnoreArg()) self.mox.ReplayAll() - self.conn.attach_volume(connection_info, self.instance, mount_point) + self.conn.attach_volume(None, connection_info, self.instance, + mount_point) def test_detach_iscsi_disk_from_vm(self): self._create_vm() diff --git a/nova/tests/virt/xenapi/test_xenapi.py b/nova/tests/virt/xenapi/test_xenapi.py index 358257f030..d4d8d0fc5d 100644 --- a/nova/tests/virt/xenapi/test_xenapi.py +++ b/nova/tests/virt/xenapi/test_xenapi.py @@ -286,7 +286,7 @@ class XenAPIVolumeTestCase(stubs.XenAPITestBase): conn = xenapi_conn.XenAPIDriver(fake.FakeVirtAPI(), False) instance = db.instance_create(self.context, self.instance_values) vm = xenapi_fake.create_vm(instance['name'], 'Running') - result = conn.attach_volume(self._make_connection_info(), + result = conn.attach_volume(None, self._make_connection_info(), instance, '/dev/sdc') # check that the VM has a VBD attached to it @@ -305,9 +305,8 @@ class XenAPIVolumeTestCase(stubs.XenAPITestBase): xenapi_fake.create_vm(instance['name'], 'Running') self.assertRaises(exception.VolumeDriverNotFound, conn.attach_volume, - {'driver_volume_type': 'nonexist'}, - instance, - '/dev/sdc') + None, {'driver_volume_type': 'nonexist'}, + instance, '/dev/sdc') class XenAPIVMTestCase(stubs.XenAPITestBase): diff --git a/nova/tests/volume/encryptors/__init__.py b/nova/tests/volume/encryptors/__init__.py new file mode 100644 index 0000000000..0ee3757019 --- /dev/null +++ b/nova/tests/volume/encryptors/__init__.py @@ -0,0 +1,16 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2013 The Johns Hopkins University/Applied Physics Laboratory +# 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. diff --git a/nova/tests/volume/encryptors/test_base.py b/nova/tests/volume/encryptors/test_base.py new file mode 100644 index 0000000000..aad1f11381 --- /dev/null +++ b/nova/tests/volume/encryptors/test_base.py @@ -0,0 +1,40 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2013 The Johns Hopkins University/Applied Physics Laboratory +# 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 import keymgr +from nova import test +from nova.tests.keymgr import fake + + +class VolumeEncryptorTestCase(test.TestCase): + def _create(self, device_path): + pass + + def setUp(self): + super(VolumeEncryptorTestCase, self).setUp() + + self.stubs.Set(keymgr, 'API', fake.fake_api) + + self.connection_info = { + "data": { + "device_path": "/dev/disk/by-path/" + "ip-192.0.2.0:3260-iscsi-iqn.2010-10.org.openstack" + ":volume-fake_uuid-lun-1", + }, + } + self.encryptor = self._create(self.connection_info) diff --git a/nova/tests/volume/encryptors/test_cryptsetup.py b/nova/tests/volume/encryptors/test_cryptsetup.py new file mode 100644 index 0000000000..eaca299813 --- /dev/null +++ b/nova/tests/volume/encryptors/test_cryptsetup.py @@ -0,0 +1,85 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2013 The Johns Hopkins University/Applied Physics Laboratory +# 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 array +import os + +from nova.keymgr import key +from nova.tests.volume.encryptors import test_base +from nova import utils +from nova.volume.encryptors import cryptsetup + + +def fake__get_key(context): + raw = array.array('B', ('0' * 64).decode('hex')).tolist() + + symmetric_key = key.SymmetricKey('AES', raw) + return symmetric_key + + +class CryptsetupEncryptorTestCase(test_base.VolumeEncryptorTestCase): + def _create(self, connection_info): + return cryptsetup.CryptsetupEncryptor(connection_info) + + def setUp(self): + super(CryptsetupEncryptorTestCase, self).setUp() + + self.executes = [] + + def fake_execute(*cmd, **kwargs): + self.executes.append(cmd) + return None, None + + self.stubs.Set(utils, 'execute', fake_execute) + self.stubs.Set(os.path, "realpath", lambda x: x) + + self.dev_path = self.connection_info['data']['device_path'] + self.dev_name = self.dev_path.split('/')[-1] + + self.symlink_path = self.dev_path + + def test__open_volume(self): + self.encryptor._open_volume("passphrase") + + expected_commands = [('cryptsetup', 'create', '--key-file=-', + self.dev_name, self.dev_path)] + self.assertEqual(expected_commands, self.executes) + + def test_attach_volume(self): + self.stubs.Set(self.encryptor, '_get_key', fake__get_key) + + self.encryptor.attach_volume(None) + + expected_commands = [('cryptsetup', 'create', '--key-file=-', + self.dev_name, self.dev_path), + ('ln', '--symbolic', '--force', + '/dev/mapper/%s' % self.dev_name, + self.symlink_path)] + self.assertEqual(expected_commands, self.executes) + + def test__close_volume(self): + self.encryptor.detach_volume() + + expected_commands = [('cryptsetup', 'remove', self.dev_name)] + self.assertEqual(expected_commands, self.executes) + + def test_detach_volume(self): + self.encryptor.detach_volume() + + expected_commands = [('cryptsetup', 'remove', self.dev_name)] + self.assertEqual(expected_commands, self.executes) diff --git a/nova/tests/volume/encryptors/test_luks.py b/nova/tests/volume/encryptors/test_luks.py new file mode 100644 index 0000000000..2db7e77637 --- /dev/null +++ b/nova/tests/volume/encryptors/test_luks.py @@ -0,0 +1,76 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2013 The Johns Hopkins University/Applied Physics Laboratory +# 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.tests.volume.encryptors import test_cryptsetup +from nova.volume.encryptors import luks + + +""" +The utility of these test cases is limited given the simplicity of the +LuksEncryptor class. The attach_volume method has the only significant logic +to handle cases where the volume has not previously been formatted, but +exercising this logic requires "real" devices and actually executing the +various cryptsetup commands rather than simply logging them. +""" + + +class LuksEncryptorTestCase(test_cryptsetup.CryptsetupEncryptorTestCase): + def _create(self, connection_info): + return luks.LuksEncryptor(connection_info) + + def setUp(self): + super(LuksEncryptorTestCase, self).setUp() + + def test__format_volume(self): + self.encryptor._format_volume("passphrase") + + expected_commands = [('cryptsetup', '--batch-mode', 'luksFormat', + '--key-file=-', self.dev_path)] + self.assertEqual(expected_commands, self.executes) + + def test__open_volume(self): + self.encryptor._open_volume("passphrase") + + expected_commands = [('cryptsetup', 'luksOpen', '--key-file=-', + self.dev_path, self.dev_name)] + self.assertEqual(expected_commands, self.executes) + + def test_attach_volume(self): + self.stubs.Set(self.encryptor, '_get_key', + test_cryptsetup.fake__get_key) + + self.encryptor.attach_volume(None) + + expected_commands = [('cryptsetup', 'luksOpen', '--key-file=-', + self.dev_path, self.dev_name), + ('ln', '--symbolic', '--force', + '/dev/mapper/%s' % self.dev_name, + self.symlink_path)] + self.assertEqual(expected_commands, self.executes) + + def test__close_volume(self): + self.encryptor.detach_volume() + + expected_commands = [('cryptsetup', 'luksClose', self.dev_name)] + self.assertEqual(expected_commands, self.executes) + + def test_detach_volume(self): + self.encryptor.detach_volume() + + expected_commands = [('cryptsetup', 'luksClose', self.dev_name)] + self.assertEqual(expected_commands, self.executes) diff --git a/nova/tests/volume/encryptors/test_nop.py b/nova/tests/volume/encryptors/test_nop.py new file mode 100644 index 0000000000..ff813d473e --- /dev/null +++ b/nova/tests/volume/encryptors/test_nop.py @@ -0,0 +1,33 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2013 The Johns Hopkins University/Applied Physics Laboratory +# 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.tests.volume.encryptors import test_base +from nova.volume.encryptors import nop + + +class NoOpEncryptorTestCase(test_base.VolumeEncryptorTestCase): + def _create(self, connection_info): + return nop.NoOpEncryptor(connection_info) + + def setUp(self): + super(NoOpEncryptorTestCase, self).setUp() + + def test_attach_volume(self): + self.encryptor.attach_volume(None) + + def test_detach_volume(self): + self.encryptor.detach_volume() diff --git a/nova/tests/volume/test_cinder.py b/nova/tests/volume/test_cinder.py index fe0e7c7dde..93fc775787 100644 --- a/nova/tests/volume/test_cinder.py +++ b/nova/tests/volume/test_cinder.py @@ -296,3 +296,15 @@ class CinderApiTestCase(test.TestCase): 'id1', {'status': 'error', 'progress': '90%'}) self.mox.ReplayAll() self.api.update_snapshot_status(self.ctx, 'id1', 'error') + + def test_get_volume_encryption_metadata(self): + cinder.cinderclient(self.ctx).AndReturn(self.cinderclient) + self.mox.StubOutWithMock(self.cinderclient.volumes, + 'get_encryption_metadata') + self.cinderclient.volumes.\ + get_encryption_metadata({'encryption_key_id': 'fake_key'}) + self.mox.ReplayAll() + + self.api.get_volume_encryption_metadata(self.ctx, + {'encryption_key_id': + 'fake_key'}) diff --git a/nova/virt/baremetal/driver.py b/nova/virt/baremetal/driver.py index 2cb1d6c760..e3b25f73d9 100755 --- a/nova/virt/baremetal/driver.py +++ b/nova/virt/baremetal/driver.py @@ -196,7 +196,7 @@ class BareMetalDriver(driver.ComputeDriver): for vol in block_device_mapping: connection_info = vol['connection_info'] mountpoint = vol['mount_device'] - self.attach_volume( + self.attach_volume(None, connection_info, instance['name'], mountpoint) def _detach_block_devices(self, instance, block_device_info): @@ -294,7 +294,8 @@ class BareMetalDriver(driver.ComputeDriver): "for instance %r") % instance['uuid']) _update_state(ctx, node, instance, state) - def destroy(self, instance, network_info, block_device_info=None): + def destroy(self, instance, network_info, block_device_info=None, + context=None): context = nova_context.get_admin_context() try: @@ -354,11 +355,13 @@ class BareMetalDriver(driver.ComputeDriver): def get_volume_connector(self, instance): return self.volume_driver.get_volume_connector(instance) - def attach_volume(self, connection_info, instance, mountpoint): + def attach_volume(self, context, connection_info, instance, mountpoint, + encryption=None): return self.volume_driver.attach_volume(connection_info, instance, mountpoint) - def detach_volume(self, connection_info, instance_name, mountpoint): + def detach_volume(self, connection_info, instance_name, mountpoint, + encryption=None): return self.volume_driver.detach_volume(connection_info, instance_name, mountpoint) diff --git a/nova/virt/driver.py b/nova/virt/driver.py index 5c356065d3..d18f04a7fb 100755 --- a/nova/virt/driver.py +++ b/nova/virt/driver.py @@ -320,11 +320,13 @@ class ComputeDriver(object): # TODO(Vek): Need to pass context in for access to auth_token raise NotImplementedError() - def attach_volume(self, connection_info, instance, mountpoint): + def attach_volume(self, context, connection_info, instance, mountpoint, + encryption=None): """Attach the disk to the instance at mountpoint using info.""" raise NotImplementedError() - def detach_volume(self, connection_info, instance, mountpoint): + def detach_volume(self, connection_info, instance, mountpoint, + encryption=None): """Detach the disk attached to the instance.""" raise NotImplementedError() diff --git a/nova/virt/fake.py b/nova/virt/fake.py index ae977762e8..ed3f12e72d 100755 --- a/nova/virt/fake.py +++ b/nova/virt/fake.py @@ -207,7 +207,7 @@ class FakeDriver(driver.ComputeDriver): pass def destroy(self, instance, network_info, block_device_info=None, - destroy_disks=True): + destroy_disks=True, context=None): key = instance['name'] if key in self.instances: del self.instances[key] @@ -216,7 +216,8 @@ class FakeDriver(driver.ComputeDriver): {'key': key, 'inst': self.instances}, instance=instance) - def attach_volume(self, connection_info, instance, mountpoint): + def attach_volume(self, context, connection_info, instance, mountpoint, + encryption=None): """Attach the disk to the instance at mountpoint using info.""" instance_name = instance['name'] if instance_name not in self._mounts: @@ -224,7 +225,8 @@ class FakeDriver(driver.ComputeDriver): self._mounts[instance_name][mountpoint] = connection_info return True - def detach_volume(self, connection_info, instance, mountpoint): + def detach_volume(self, connection_info, instance, mountpoint, + encryption=None): """Detach the disk attached to the instance.""" try: del self._mounts[instance['name']][mountpoint] diff --git a/nova/virt/hyperv/driver.py b/nova/virt/hyperv/driver.py index e36c1ce5be..5966913adc 100755 --- a/nova/virt/hyperv/driver.py +++ b/nova/virt/hyperv/driver.py @@ -59,18 +59,20 @@ class HyperVDriver(driver.ComputeDriver): self._vmops.reboot(instance, network_info, reboot_type) def destroy(self, instance, network_info, block_device_info=None, - destroy_disks=True): + destroy_disks=True, context=None): self._vmops.destroy(instance, network_info, block_device_info, destroy_disks) def get_info(self, instance): return self._vmops.get_info(instance) - def attach_volume(self, connection_info, instance, mountpoint): + def attach_volume(self, context, connection_info, instance, mountpoint, + encryption=None): return self._volumeops.attach_volume(connection_info, instance['name']) - def detach_volume(self, connection_info, instance, mountpoint): + def detach_volume(self, connection_info, instance, mountpoint, + encryption=None): return self._volumeops.detach_volume(connection_info, instance['name']) diff --git a/nova/virt/libvirt/driver.py b/nova/virt/libvirt/driver.py index 65c2432db9..a8e7d4f7dd 100755 --- a/nova/virt/libvirt/driver.py +++ b/nova/virt/libvirt/driver.py @@ -100,6 +100,7 @@ from nova.virt.libvirt import imagecache from nova.virt.libvirt import utils as libvirt_utils from nova.virt import netutils from nova import volume +from nova.volume import encryptors native_threading = patcher.original("threading") native_Queue = patcher.original("Queue") @@ -828,9 +829,10 @@ class LibvirtDriver(driver.ComputeDriver): self._destroy(instance) def destroy(self, instance, network_info, block_device_info=None, - destroy_disks=True): + destroy_disks=True, context=None): self._destroy(instance) - self._cleanup(instance, network_info, block_device_info, destroy_disks) + self._cleanup(instance, network_info, block_device_info, + destroy_disks, context=context) def _undefine_domain(self, instance): try: @@ -864,7 +866,7 @@ class LibvirtDriver(driver.ComputeDriver): {'errcode': errcode, 'e': e}, instance=instance) def _cleanup(self, instance, network_info, block_device_info, - destroy_disks): + destroy_disks, context=None): self._undefine_domain(instance) self.unplug_vifs(instance, network_info) retry = True @@ -908,6 +910,21 @@ class LibvirtDriver(driver.ComputeDriver): for vol in block_device_mapping: connection_info = vol['connection_info'] disk_dev = vol['mount_device'].rpartition("/")[2] + + if ('data' in connection_info and + 'volume_id' in connection_info['data']): + volume_id = connection_info['data']['volume_id'] + encryption = \ + encryptors.get_encryption_metadata(context, volume_id, + connection_info) + if encryption: + # The volume must be detached from the VM before + # disconnecting it from its encryptor. Otherwise, the + # encryptor may report that the volume is still in use. + encryptor = self._get_volume_encryptor(connection_info, + encryption) + encryptor.detach_volume(**encryption) + self.volume_driver_method('disconnect_volume', connection_info, disk_dev) @@ -1012,7 +1029,13 @@ class LibvirtDriver(driver.ComputeDriver): method = getattr(driver, method_name) return method(connection_info, *args, **kwargs) - def attach_volume(self, connection_info, instance, mountpoint): + def _get_volume_encryptor(self, connection_info, encryption): + encryptor = encryptors.get_volume_encryptor(connection_info, + **encryption) + return encryptor + + def attach_volume(self, context, connection_info, instance, mountpoint, + encryption=None): instance_name = instance['name'] virt_dom = self._lookup_by_name(instance_name) disk_dev = mountpoint.rpartition("/")[2] @@ -1056,6 +1079,16 @@ class LibvirtDriver(driver.ComputeDriver): state = LIBVIRT_POWER_STATE[virt_dom.info()[0]] if state == power_state.RUNNING: flags |= libvirt.VIR_DOMAIN_AFFECT_LIVE + + # cache device_path in connection_info -- required by encryptors + if 'data' in connection_info: + connection_info['data']['device_path'] = conf.source_path + + if encryption: + encryptor = self._get_volume_encryptor(connection_info, + encryption) + encryptor.attach_volume(context, **encryption) + virt_dom.attachDeviceFlags(conf.to_xml(), flags) except Exception as ex: if isinstance(ex, libvirt.libvirtError): @@ -1157,7 +1190,8 @@ class LibvirtDriver(driver.ComputeDriver): block_device_info=block_device_info) return xml - def detach_volume(self, connection_info, instance, mountpoint): + def detach_volume(self, connection_info, instance, mountpoint, + encryption=None): instance_name = instance['name'] disk_dev = mountpoint.rpartition("/")[2] try: @@ -1174,6 +1208,14 @@ class LibvirtDriver(driver.ComputeDriver): if state == power_state.RUNNING: flags |= libvirt.VIR_DOMAIN_AFFECT_LIVE virt_dom.detachDeviceFlags(xml, flags) + + if encryption: + # The volume must be detached from the VM before + # disconnecting it from its encryptor. Otherwise, the + # encryptor may report that the volume is still in use. + encryptor = self._get_volume_encryptor(connection_info, + encryption) + encryptor.detach_volume(**encryption) except libvirt.libvirtError as ex: # NOTE(vish): This is called to cleanup volumes after live # migration, so we should still disconnect even if @@ -1864,7 +1906,8 @@ class LibvirtDriver(driver.ComputeDriver): # Initialize all the necessary networking, block devices and # start the instance. self._create_domain_and_network(xml, instance, network_info, - block_device_info) + block_device_info, context=context, + reboot=True) self._prepare_pci_devices_for_use( pci_manager.get_instance_pci_devs(instance)) @@ -2022,7 +2065,7 @@ class LibvirtDriver(driver.ComputeDriver): write_to_disk=True) self._create_domain_and_network(xml, instance, network_info, - block_device_info) + block_device_info, context=context) LOG.debug(_("Instance is running"), instance=instance) def _wait_for_boot(): @@ -3108,7 +3151,8 @@ class LibvirtDriver(driver.ComputeDriver): return domain def _create_domain_and_network(self, xml, instance, network_info, - block_device_info=None, power_on=True): + block_device_info=None, power_on=True, + context=None, reboot=False): """Do required network setup and create domain.""" block_device_mapping = driver.block_device_info_get_mapping( @@ -3118,9 +3162,25 @@ class LibvirtDriver(driver.ComputeDriver): connection_info = vol['connection_info'] disk_dev = vol['mount_device'].rpartition("/")[2] disk_info = blockinfo.get_info_from_bdm(CONF.libvirt_type, vol) - self.volume_driver_method('connect_volume', - connection_info, - disk_info) + conf = self.volume_driver_method('connect_volume', + connection_info, + disk_info) + + # cache device_path in connection_info -- required by encryptors + if (not reboot and 'data' in connection_info and + 'volume_id' in connection_info['data']): + connection_info['data']['device_path'] = conf.source_path + self.virtapi.block_device_mapping_update(context, vol.id, + {'connection_info': jsonutils.dumps(connection_info)}) + + volume_id = connection_info['data']['volume_id'] + encryption = \ + encryptors.get_encryption_metadata(context, volume_id, + connection_info) + if encryption: + encryptor = self._get_volume_encryptor(connection_info, + encryption) + encryptor.attach_volume(context, **encryption) self.plug_vifs(instance, network_info) self.firewall_driver.setup_basic_filtering(instance, network_info) @@ -4454,7 +4514,8 @@ class LibvirtDriver(driver.ComputeDriver): block_device_info=block_device_info, write_to_disk=True) self._create_domain_and_network(xml, instance, network_info, - block_device_info, power_on) + block_device_info, power_on, + context=context) if power_on: timer = loopingcall.FixedIntervalLoopingCall( self._wait_for_running, diff --git a/nova/virt/powervm/driver.py b/nova/virt/powervm/driver.py index 2556f378c0..f746cc1474 100755 --- a/nova/virt/powervm/driver.py +++ b/nova/virt/powervm/driver.py @@ -105,7 +105,7 @@ class PowerVMDriver(driver.ComputeDriver): self._powervm.spawn(context, instance, image_meta['id'], network_info) def destroy(self, instance, network_info, block_device_info=None, - destroy_disks=True): + destroy_disks=True, context=None): """Destroy (shutdown and delete) the specified instance.""" self._powervm.destroy(instance['name'], destroy_disks) diff --git a/nova/virt/vmwareapi/driver.py b/nova/virt/vmwareapi/driver.py index 459322ee7e..44b9b9ec20 100644 --- a/nova/virt/vmwareapi/driver.py +++ b/nova/virt/vmwareapi/driver.py @@ -191,7 +191,7 @@ class VMwareESXDriver(driver.ComputeDriver): self._vmops.reboot(instance, network_info) def destroy(self, instance, network_info, block_device_info=None, - destroy_disks=True): + destroy_disks=True, context=None): """Destroy VM instance.""" self._vmops.destroy(instance, network_info, destroy_disks) @@ -283,13 +283,15 @@ class VMwareESXDriver(driver.ComputeDriver): """Retrieves the IP address of the ESX host.""" return self._host_ip - def attach_volume(self, connection_info, instance, mountpoint): + def attach_volume(self, context, connection_info, instance, mountpoint, + encryption=None): """Attach volume storage to VM instance.""" return self._volumeops.attach_volume(connection_info, instance, mountpoint) - def detach_volume(self, connection_info, instance, mountpoint): + def detach_volume(self, connection_info, instance, mountpoint, + encryption=None): """Detach volume storage to VM instance.""" return self._volumeops.detach_volume(connection_info, instance, @@ -603,7 +605,8 @@ class VMwareVCDriver(VMwareESXDriver): _vmops.spawn(context, instance, image_meta, injected_files, admin_password, network_info, block_device_info) - def attach_volume(self, connection_info, instance, mountpoint): + def attach_volume(self, context, connection_info, instance, mountpoint, + encryption=None): """Attach volume storage to VM instance.""" _volumeops = self._get_volumeops_for_compute_node(instance['node']) return _volumeops.attach_volume(connection_info, diff --git a/nova/virt/xenapi/driver.py b/nova/virt/xenapi/driver.py index 45454a6e22..883bdd9da9 100755 --- a/nova/virt/xenapi/driver.py +++ b/nova/virt/xenapi/driver.py @@ -237,7 +237,7 @@ class XenAPIDriver(driver.ComputeDriver): self._vmops.change_instance_metadata(instance, diff) def destroy(self, instance, network_info, block_device_info=None, - destroy_disks=True): + destroy_disks=True, context=None): """Destroy VM instance.""" self._vmops.destroy(instance, network_info, block_device_info, destroy_disks) @@ -376,13 +376,15 @@ class XenAPIDriver(driver.ComputeDriver): xs_url = urlparse.urlparse(CONF.xenapi_connection_url) return xs_url.netloc - def attach_volume(self, connection_info, instance, mountpoint): + def attach_volume(self, context, connection_info, instance, mountpoint, + encryption=None): """Attach volume storage to VM instance.""" return self._volumeops.attach_volume(connection_info, instance['name'], mountpoint) - def detach_volume(self, connection_info, instance, mountpoint): + def detach_volume(self, connection_info, instance, mountpoint, + encryption=None): """Detach volume storage from VM instance.""" return self._volumeops.detach_volume(connection_info, instance['name'], diff --git a/nova/volume/cinder.py b/nova/volume/cinder.py index 8704f9e176..8ff5687136 100644 --- a/nova/volume/cinder.py +++ b/nova/volume/cinder.py @@ -351,6 +351,9 @@ class API(base.Base): def delete_snapshot(self, context, snapshot_id): cinderclient(context).volume_snapshots.delete(snapshot_id) + def get_volume_encryption_metadata(self, context, volume_id): + return cinderclient(context).volumes.get_encryption_metadata(volume_id) + @translate_volume_exception def get_volume_metadata(self, context, volume_id): raise NotImplementedError() diff --git a/nova/volume/encryptors/__init__.py b/nova/volume/encryptors/__init__.py new file mode 100644 index 0000000000..b492eab928 --- /dev/null +++ b/nova/volume/encryptors/__init__.py @@ -0,0 +1,68 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2013 The Johns Hopkins University/Applied Physics Laboratory +# 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.openstack.common.gettextutils import _ +from nova.openstack.common import importutils +from nova.openstack.common import log as logging +from nova import volume +from nova.volume.encryptors import nop + + +LOG = logging.getLogger(__name__) + + +def get_volume_encryptor(connection_info, **kwargs): + """Creates a VolumeEncryptor used to encrypt the specified volume. + + :param: the connection information used to attach the volume + :returns VolumeEncryptor: the VolumeEncryptor for the volume + """ + encryptor = nop.NoOpEncryptor(connection_info, **kwargs) + + location = kwargs.get('control_location', None) + if location and location.lower() == 'front-end': # case insensitive + provider = kwargs.get('provider') + + try: + encryptor = importutils.import_object(provider, connection_info, + **kwargs) + except Exception as e: + LOG.error(_("Error instantiating %(provider)s: %(exception)s"), + provider=provider, exception=e) + raise + + return encryptor + + +_volume_api = volume.API() + + +def get_encryption_metadata(context, volume_id, connection_info): + metadata = {} + if ('data' in connection_info and + connection_info['data'].get('encrypted', False)): + try: + metadata = _volume_api.get_volume_encryption_metadata(context, + volume_id) + except Exception as e: + LOG.error(_("Failed to retrieve encryption metadata for " + "volume %(volume_id)s: %(exception)s"), + {'volume_id': volume_id, 'exception': e}) + raise + + return metadata diff --git a/nova/volume/encryptors/base.py b/nova/volume/encryptors/base.py new file mode 100644 index 0000000000..9d5a9365cf --- /dev/null +++ b/nova/volume/encryptors/base.py @@ -0,0 +1,60 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2013 The Johns Hopkins University/Applied Physics Laboratory +# 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 abc + +from nova import keymgr +from nova.openstack.common import log as logging + +LOG = logging.getLogger(__name__) + + +class VolumeEncryptor(object): + """Base class to support encrypted volumes. + + A VolumeEncryptor provides hooks for attaching and detaching volumes, which + are called immediately prior to attaching the volume to an instance and + immediately following detaching the volume from an instance. This class + performs no actions for either hook. + """ + + __metaclass__ = abc.ABCMeta + + def __init__(self, connection_info, **kwargs): + self._key_manager = keymgr.API() + + self.encryption_key_id = kwargs.get('encryption_key_id') + + def _get_key(self, context): + """Retrieves the encryption key for the specified volume. + + :param: the connection information used to attach the volume + """ + return self._key_manager.get_key(context, self.encryption_key_id) + + @abc.abstractmethod + def attach_volume(self, context, **kwargs): + """Hook called immediately prior to attaching a volume to an instance. + """ + pass + + @abc.abstractmethod + def detach_volume(self, **kwargs): + """Hook called immediately after detaching a volume from an instance. + """ + pass diff --git a/nova/volume/encryptors/cryptsetup.py b/nova/volume/encryptors/cryptsetup.py new file mode 100644 index 0000000000..5d9096fb18 --- /dev/null +++ b/nova/volume/encryptors/cryptsetup.py @@ -0,0 +1,103 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2013 The Johns Hopkins University/Applied Physics Laboratory +# 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 os + +from nova.openstack.common.gettextutils import _ +from nova.openstack.common import log as logging +from nova import utils +from nova.volume.encryptors import base + + +LOG = logging.getLogger(__name__) + + +class CryptsetupEncryptor(base.VolumeEncryptor): + """A VolumeEncryptor based on dm-crypt. + + This VolumeEncryptor uses dm-crypt to encrypt the specified volume. + """ + + def __init__(self, connection_info, **kwargs): + super(CryptsetupEncryptor, self).__init__(connection_info, **kwargs) + + # the device's path as given to libvirt -- e.g., /dev/disk/by-path/... + self.symlink_path = connection_info['data']['device_path'] + + # a unique name for the volume -- e.g., the iSCSI participant name + self.dev_name = self.symlink_path.split('/')[-1] + # the device's actual path on the compute host -- e.g., /dev/sd_ + self.dev_path = os.path.realpath(self.symlink_path) + + def _get_passphrase(self, key): + return ''.join(hex(x).replace('0x', '') for x in key) + + def _open_volume(self, passphrase, **kwargs): + """Opens the LUKS partition on the volume using the specified + passphrase. + + :param passphrase: the passphrase used to access the volume + """ + LOG.debug(_("opening encrypted volume %s"), self.dev_path) + + # NOTE(joel-coffman): cryptsetup will strip trailing newlines from + # input specified on stdin unless --key-file=- is specified. + cmd = ["cryptsetup", "create", "--key-file=-"] + + cipher = kwargs.get("cipher", None) + if cipher is not None: + cmd.extend(["--cipher", cipher]) + + key_size = kwargs.get("key_size", None) + if key_size is not None: + cmd.extend(["--key-size", key_size]) + + cmd.extend([self.dev_name, self.dev_path]) + + utils.execute(*cmd, process_input=passphrase, + check_exit_code=True, run_as_root=True) + + def attach_volume(self, context, **kwargs): + """Shadows the device and passes an unencrypted version to the + instance. + + Transparent disk encryption is achieved by mounting the volume via + dm-crypt and passing the resulting device to the instance. The + instance is unaware of the underlying encryption due to modifying the + original symbolic link to refer to the device mounted by dm-crypt. + """ + + key = self._get_key(context).get_encoded() + passphrase = self._get_passphrase(key) + + self._open_volume(passphrase, **kwargs) + + # modify the original symbolic link to refer to the decrypted device + utils.execute('ln', '--symbolic', '--force', + '/dev/mapper/%s' % self.dev_name, self.symlink_path, + run_as_root=True, check_exit_code=True) + + def _close_volume(self, **kwargs): + """Closes the device (effectively removes the dm-crypt mapping).""" + LOG.debug(_("closing encrypted volume %s"), self.dev_path) + utils.execute('cryptsetup', 'remove', self.dev_name, + run_as_root=True, check_exit_code=True) + + def detach_volume(self, **kwargs): + """Removes the dm-crypt mapping for the device.""" + self._close_volume(**kwargs) diff --git a/nova/volume/encryptors/luks.py b/nova/volume/encryptors/luks.py new file mode 100644 index 0000000000..1ffbb1f1e1 --- /dev/null +++ b/nova/volume/encryptors/luks.py @@ -0,0 +1,108 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2013 The Johns Hopkins University/Applied Physics Laboratory +# 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 re + +from nova.openstack.common.gettextutils import _ +from nova.openstack.common import log as logging +from nova.openstack.common import processutils +from nova import utils +from nova.volume.encryptors import cryptsetup + + +LOG = logging.getLogger(__name__) + + +class LuksEncryptor(cryptsetup.CryptsetupEncryptor): + """A VolumeEncryptor based on LUKS. + + This VolumeEncryptor uses dm-crypt to encrypt the specified volume. + """ + def __init__(self, connection_info, **kwargs): + super(LuksEncryptor, self).__init__(connection_info, **kwargs) + + def _format_volume(self, passphrase, **kwargs): + """Creates a LUKS header on the volume. + + :param passphrase: the passphrase used to access the volume + """ + LOG.debug(_("formatting encrypted volume %s"), self.dev_path) + + # NOTE(joel-coffman): cryptsetup will strip trailing newlines from + # input specified on stdin unless --key-file=- is specified. + cmd = ["cryptsetup", "--batch-mode", "luksFormat", "--key-file=-"] + + cipher = kwargs.get("cipher", None) + if cipher is not None: + cmd.extend(["--cipher", cipher]) + + key_size = kwargs.get("key_size", None) + if key_size is not None: + cmd.extend(["--key-size", key_size]) + + cmd.extend([self.dev_path]) + + utils.execute(*cmd, process_input=passphrase, + check_exit_code=True, run_as_root=True) + + def _open_volume(self, passphrase, **kwargs): + """Opens the LUKS partition on the volume using the specified + passphrase. + + :param passphrase: the passphrase used to access the volume + """ + LOG.debug(_("opening encrypted volume %s"), self.dev_path) + utils.execute('cryptsetup', 'luksOpen', '--key-file=-', + self.dev_path, self.dev_name, process_input=passphrase, + run_as_root=True, check_exit_code=True) + + def attach_volume(self, context, **kwargs): + """Shadows the device and passes an unencrypted version to the + instance. + + Transparent disk encryption is achieved by mounting the volume via + dm-crypt and passing the resulting device to the instance. The + instance is unaware of the underlying encryption due to modifying the + original symbolic link to refer to the device mounted by dm-crypt. + """ + + key = self._get_key(context).get_encoded() + # LUKS uses a passphrase rather than a raw key -- convert to string + passphrase = ''.join(hex(x).replace('0x', '') for x in key) + + try: + self._open_volume(passphrase, **kwargs) + except processutils.ProcessExecutionError as e: + pattern = re.compile('Device \S+ is not a valid LUKS device.') + if e.exit_code == 1 and pattern.search(e.stderr): + # the device has never been formatted; format it and try again + self._format_volume(passphrase, **kwargs) + self._open_volume(passphrase, **kwargs) + else: + raise + + # modify the original symbolic link to refer to the decrypted device + utils.execute('ln', '--symbolic', '--force', + '/dev/mapper/%s' % self.dev_name, self.symlink_path, + run_as_root=True, check_exit_code=True) + + def _close_volume(self, **kwargs): + """Closes the device (effectively removes the dm-crypt mapping).""" + LOG.debug(_("closing encrypted volume %s"), self.dev_path) + utils.execute('cryptsetup', 'luksClose', self.dev_name, + run_as_root=True, check_exit_code=True) diff --git a/nova/volume/encryptors/nop.py b/nova/volume/encryptors/nop.py new file mode 100644 index 0000000000..6772585947 --- /dev/null +++ b/nova/volume/encryptors/nop.py @@ -0,0 +1,41 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2013 The Johns Hopkins University/Applied Physics Laboratory +# 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.openstack.common import log as logging +from nova.volume.encryptors import base + + +LOG = logging.getLogger(__name__) + + +class NoOpEncryptor(base.VolumeEncryptor): + """A VolumeEncryptor that does nothing. + + This class exists solely to wrap regular (i.e., unencrypted) volumes so + that they do not require special handling with respect to an encrypted + volume. This implementation performs no action when a volume is attached + or detached. + """ + def __init__(self, connection_info, **kwargs): + super(NoOpEncryptor, self).__init__(connection_info, **kwargs) + + def attach_volume(self, context): + pass + + def detach_volume(self): + pass