Merge "RBD: Support encrypted volumes"
This commit is contained in:
commit
996817b2a7
@ -73,6 +73,7 @@ QEMU_IMG_FORMAT_MAP_INV = {v: k for k, v in QEMU_IMG_FORMAT_MAP.items()}
|
|||||||
|
|
||||||
QEMU_IMG_VERSION = None
|
QEMU_IMG_VERSION = None
|
||||||
QEMU_IMG_MIN_FORCE_SHARE_VERSION = [2, 10, 0]
|
QEMU_IMG_MIN_FORCE_SHARE_VERSION = [2, 10, 0]
|
||||||
|
QEMU_IMG_MIN_CONVERT_LUKS_VERSION = '2.10'
|
||||||
|
|
||||||
|
|
||||||
def validate_disk_format(disk_format):
|
def validate_disk_format(disk_format):
|
||||||
@ -140,7 +141,7 @@ def qemu_img_supports_force_share():
|
|||||||
|
|
||||||
def _get_qemu_convert_cmd(src, dest, out_format, src_format=None,
|
def _get_qemu_convert_cmd(src, dest, out_format, src_format=None,
|
||||||
out_subformat=None, cache_mode=None,
|
out_subformat=None, cache_mode=None,
|
||||||
prefix=None):
|
prefix=None, cipher_spec=None, passphrase_file=None):
|
||||||
|
|
||||||
if out_format == 'vhd':
|
if out_format == 'vhd':
|
||||||
# qemu-img still uses the legacy vpc name
|
# qemu-img still uses the legacy vpc name
|
||||||
@ -165,6 +166,18 @@ def _get_qemu_convert_cmd(src, dest, out_format, src_format=None,
|
|||||||
if (src_format or '').lower() not in ('', 'ami'):
|
if (src_format or '').lower() not in ('', 'ami'):
|
||||||
cmd += ('-f', src_format) # prevent detection of format
|
cmd += ('-f', src_format) # prevent detection of format
|
||||||
|
|
||||||
|
# NOTE(lyarwood): When converting to LUKS add the cipher spec if present
|
||||||
|
# and create a secret for the passphrase, written to a temp file
|
||||||
|
if out_format == 'luks':
|
||||||
|
check_qemu_img_version(QEMU_IMG_MIN_CONVERT_LUKS_VERSION)
|
||||||
|
if cipher_spec:
|
||||||
|
cmd += ('-o', 'cipher-alg=%s,cipher-mode=%s,ivgen-alg=%s' %
|
||||||
|
(cipher_spec['cipher_alg'], cipher_spec['cipher_mode'],
|
||||||
|
cipher_spec['ivgen_alg']))
|
||||||
|
cmd += ('--object',
|
||||||
|
'secret,id=luks_sec,format=raw,file=%s' % passphrase_file,
|
||||||
|
'-o', 'key-secret=luks_sec')
|
||||||
|
|
||||||
cmd += [src, dest]
|
cmd += [src, dest]
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
@ -193,7 +206,7 @@ def check_qemu_img_version(minimum_version):
|
|||||||
|
|
||||||
def _convert_image(prefix, source, dest, out_format,
|
def _convert_image(prefix, source, dest, out_format,
|
||||||
out_subformat=None, src_format=None,
|
out_subformat=None, src_format=None,
|
||||||
run_as_root=True):
|
run_as_root=True, cipher_spec=None, passphrase_file=None):
|
||||||
"""Convert image to other format."""
|
"""Convert image to other format."""
|
||||||
|
|
||||||
# Check whether O_DIRECT is supported and set '-t none' if it is
|
# Check whether O_DIRECT is supported and set '-t none' if it is
|
||||||
@ -219,7 +232,9 @@ def _convert_image(prefix, source, dest, out_format,
|
|||||||
src_format=src_format,
|
src_format=src_format,
|
||||||
out_subformat=out_subformat,
|
out_subformat=out_subformat,
|
||||||
cache_mode=cache_mode,
|
cache_mode=cache_mode,
|
||||||
prefix=prefix)
|
prefix=prefix,
|
||||||
|
cipher_spec=cipher_spec,
|
||||||
|
passphrase_file=passphrase_file)
|
||||||
|
|
||||||
start_time = timeutils.utcnow()
|
start_time = timeutils.utcnow()
|
||||||
utils.execute(*cmd, run_as_root=run_as_root)
|
utils.execute(*cmd, run_as_root=run_as_root)
|
||||||
@ -254,7 +269,8 @@ def _convert_image(prefix, source, dest, out_format,
|
|||||||
|
|
||||||
|
|
||||||
def convert_image(source, dest, out_format, out_subformat=None,
|
def convert_image(source, dest, out_format, out_subformat=None,
|
||||||
src_format=None, run_as_root=True, throttle=None):
|
src_format=None, run_as_root=True, throttle=None,
|
||||||
|
cipher_spec=None, passphrase_file=None):
|
||||||
if not throttle:
|
if not throttle:
|
||||||
throttle = throttling.Throttle.get_default()
|
throttle = throttling.Throttle.get_default()
|
||||||
with throttle.subcommand(source, dest) as throttle_cmd:
|
with throttle.subcommand(source, dest) as throttle_cmd:
|
||||||
@ -263,7 +279,9 @@ def convert_image(source, dest, out_format, out_subformat=None,
|
|||||||
out_format,
|
out_format,
|
||||||
out_subformat=out_subformat,
|
out_subformat=out_subformat,
|
||||||
src_format=src_format,
|
src_format=src_format,
|
||||||
run_as_root=run_as_root)
|
run_as_root=run_as_root,
|
||||||
|
cipher_spec=cipher_spec,
|
||||||
|
passphrase_file=passphrase_file)
|
||||||
|
|
||||||
|
|
||||||
def resize_image(source, size, run_as_root=False):
|
def resize_image(source, size, run_as_root=False):
|
||||||
@ -699,6 +717,21 @@ def replace_xenserver_image_with_coalesced_vhd(image_file):
|
|||||||
os.rename(coalesced, image_file)
|
os.rename(coalesced, image_file)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_cipher(cipher_spec, key_size):
|
||||||
|
"""Decode a dm-crypt style cipher specification string
|
||||||
|
|
||||||
|
The assumed format being cipher[:keycount]-chainmode-ivmode[:ivopts] as
|
||||||
|
documented under linux/Documentation/device-mapper/dm-crypt.txt in the
|
||||||
|
kernel source tree.
|
||||||
|
"""
|
||||||
|
cipher_alg, cipher_mode, ivgen_alg = cipher_spec.split('-')
|
||||||
|
cipher_alg = cipher_alg + '-' + str(key_size)
|
||||||
|
|
||||||
|
return {'cipher_alg': cipher_alg,
|
||||||
|
'cipher_mode': cipher_mode,
|
||||||
|
'ivgen_alg': ivgen_alg}
|
||||||
|
|
||||||
|
|
||||||
class TemporaryImages(object):
|
class TemporaryImages(object):
|
||||||
"""Manage temporarily downloaded images to avoid downloading it twice.
|
"""Manage temporarily downloaded images to avoid downloading it twice.
|
||||||
|
|
||||||
|
@ -1703,3 +1703,10 @@ class TestImageUtils(test.TestCase):
|
|||||||
virtual_size,
|
virtual_size,
|
||||||
volume_size,
|
volume_size,
|
||||||
image_id)
|
image_id)
|
||||||
|
|
||||||
|
def test_decode_cipher(self):
|
||||||
|
expected = {'cipher_alg': 'aes-256',
|
||||||
|
'cipher_mode': 'xts',
|
||||||
|
'ivgen_alg': 'essiv'}
|
||||||
|
result = image_utils.decode_cipher('aes-xts-essiv', 256)
|
||||||
|
self.assertEqual(expected, result)
|
||||||
|
@ -14,11 +14,12 @@
|
|||||||
# License for the specific language governing permissions and limitations
|
# License for the specific language governing permissions and limitations
|
||||||
# under the License.
|
# under the License.
|
||||||
|
|
||||||
import ddt
|
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
import castellan
|
||||||
|
import ddt
|
||||||
import mock
|
import mock
|
||||||
from mock import call
|
from mock import call
|
||||||
from oslo_utils import imageutils
|
from oslo_utils import imageutils
|
||||||
@ -34,6 +35,7 @@ from cinder import test
|
|||||||
from cinder.tests.unit import fake_constants as fake
|
from cinder.tests.unit import fake_constants as fake
|
||||||
from cinder.tests.unit import fake_snapshot
|
from cinder.tests.unit import fake_snapshot
|
||||||
from cinder.tests.unit import fake_volume
|
from cinder.tests.unit import fake_volume
|
||||||
|
from cinder.tests.unit.keymgr import fake as fake_keymgr
|
||||||
from cinder.tests.unit import utils
|
from cinder.tests.unit import utils
|
||||||
from cinder.tests.unit.volume import test_driver
|
from cinder.tests.unit.volume import test_driver
|
||||||
from cinder.volume import configuration as conf
|
from cinder.volume import configuration as conf
|
||||||
@ -65,6 +67,11 @@ class MockImageExistsException(MockException):
|
|||||||
"""Used as mock for rbd.ImageExists."""
|
"""Used as mock for rbd.ImageExists."""
|
||||||
|
|
||||||
|
|
||||||
|
class KeyObject(object):
|
||||||
|
def get_encoded(arg):
|
||||||
|
return "asdf".encode('utf-8')
|
||||||
|
|
||||||
|
|
||||||
def common_mocks(f):
|
def common_mocks(f):
|
||||||
"""Decorator to set mocks common to all tests.
|
"""Decorator to set mocks common to all tests.
|
||||||
|
|
||||||
@ -185,6 +192,13 @@ class RBDTestCase(test.TestCase):
|
|||||||
'id': '0c7d1f44-5a06-403f-bb82-ae7ad0d693a6',
|
'id': '0c7d1f44-5a06-403f-bb82-ae7ad0d693a6',
|
||||||
'size': 10})
|
'size': 10})
|
||||||
|
|
||||||
|
self.volume_c = fake_volume.fake_volume_obj(
|
||||||
|
self.context,
|
||||||
|
**{'name': u'volume-0000000a',
|
||||||
|
'id': '55555555-222f-4b32-b585-9991b3bf0a99',
|
||||||
|
'size': 12,
|
||||||
|
'encryption_key_id': 'set_in_test'})
|
||||||
|
|
||||||
self.snapshot = fake_snapshot.fake_snapshot_obj(
|
self.snapshot = fake_snapshot.fake_snapshot_obj(
|
||||||
self.context, name='snapshot-0000000a')
|
self.context, name='snapshot-0000000a')
|
||||||
|
|
||||||
@ -459,14 +473,6 @@ class RBDTestCase(test.TestCase):
|
|||||||
client.__enter__.assert_called_once_with()
|
client.__enter__.assert_called_once_with()
|
||||||
client.__exit__.assert_called_once_with(None, None, None)
|
client.__exit__.assert_called_once_with(None, None, None)
|
||||||
|
|
||||||
@common_mocks
|
|
||||||
def test_create_encrypted_volume(self):
|
|
||||||
self.volume_a.encryption_key_id = \
|
|
||||||
'00000000-0000-0000-0000-000000000000'
|
|
||||||
self.assertRaises(exception.VolumeDriverException,
|
|
||||||
self.driver.create_volume,
|
|
||||||
self.volume_a)
|
|
||||||
|
|
||||||
@common_mocks
|
@common_mocks
|
||||||
def test_manage_existing_get_size(self):
|
def test_manage_existing_get_size(self):
|
||||||
with mock.patch.object(self.driver.rbd.Image(), 'size') as \
|
with mock.patch.object(self.driver.rbd.Image(), 'size') as \
|
||||||
@ -2034,6 +2040,50 @@ class RBDTestCase(test.TestCase):
|
|||||||
mock_delete.assert_called_once_with(self.volume_a)
|
mock_delete.assert_called_once_with(self.volume_a)
|
||||||
self.assertEqual((True, None), ret)
|
self.assertEqual((True, None), ret)
|
||||||
|
|
||||||
|
@mock.patch('tempfile.NamedTemporaryFile')
|
||||||
|
@mock.patch('cinder.volume.drivers.rbd.RBDDriver.'
|
||||||
|
'_check_encryption_provider',
|
||||||
|
return_value={'encryption_key_id': fake.ENCRYPTION_KEY_ID})
|
||||||
|
def test_create_encrypted_volume(self,
|
||||||
|
mock_check_enc_prov,
|
||||||
|
mock_temp_file):
|
||||||
|
class DictObj(object):
|
||||||
|
# convert a dict to object w/ attributes
|
||||||
|
def __init__(self, d):
|
||||||
|
self.__dict__ = d
|
||||||
|
|
||||||
|
mock_temp_file.return_value.__enter__.side_effect = [
|
||||||
|
DictObj({'name': '/imgfile'}),
|
||||||
|
DictObj({'name': '/passfile'})]
|
||||||
|
|
||||||
|
key_mgr = fake_keymgr.fake_api()
|
||||||
|
|
||||||
|
self.mock_object(castellan.key_manager, 'API', return_value=key_mgr)
|
||||||
|
key_id = key_mgr.store(self.context, KeyObject())
|
||||||
|
self.volume_c.encryption_key_id = key_id
|
||||||
|
|
||||||
|
enc_info = {'encryption_key_id': key_id,
|
||||||
|
'cipher': 'aes-xts-essiv',
|
||||||
|
'key_size': 256}
|
||||||
|
|
||||||
|
with mock.patch('cinder.volume.drivers.rbd.RBDDriver.'
|
||||||
|
'_check_encryption_provider', return_value=enc_info), \
|
||||||
|
mock.patch('cinder.volume.drivers.rbd.open') as mock_open, \
|
||||||
|
mock.patch.object(self.driver, '_execute') as mock_exec:
|
||||||
|
self.driver._create_encrypted_volume(self.volume_c,
|
||||||
|
self.context)
|
||||||
|
mock_open.assert_called_with('/passfile', 'w')
|
||||||
|
|
||||||
|
mock_exec.assert_any_call(
|
||||||
|
'qemu-img', 'create', '-f', 'luks', '-o',
|
||||||
|
'cipher-alg=aes-256,cipher-mode=xts,ivgen-alg=essiv',
|
||||||
|
'--object',
|
||||||
|
'secret,id=luks_sec,format=raw,file=/passfile',
|
||||||
|
'-o', 'key-secret=luks_sec', '/imgfile', '12288M')
|
||||||
|
mock_exec.assert_any_call(
|
||||||
|
'rbd', 'import', '--pool', 'rbd', '--order', 22,
|
||||||
|
'/imgfile', self.volume_c.name)
|
||||||
|
|
||||||
|
|
||||||
class ManagedRBDTestCase(test_driver.BaseDriverTestCase):
|
class ManagedRBDTestCase(test_driver.BaseDriverTestCase):
|
||||||
driver_name = "cinder.volume.drivers.rbd.RBDDriver"
|
driver_name = "cinder.volume.drivers.rbd.RBDDriver"
|
||||||
|
@ -14,12 +14,15 @@
|
|||||||
"""RADOS Block Device Driver"""
|
"""RADOS Block Device Driver"""
|
||||||
|
|
||||||
from __future__ import absolute_import
|
from __future__ import absolute_import
|
||||||
|
import binascii
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
from castellan import key_manager
|
||||||
from eventlet import tpool
|
from eventlet import tpool
|
||||||
|
from os_brick import encryptors
|
||||||
from os_brick.initiator import linuxrbd
|
from os_brick.initiator import linuxrbd
|
||||||
from oslo_config import cfg
|
from oslo_config import cfg
|
||||||
from oslo_log import log as logging
|
from oslo_log import log as logging
|
||||||
@ -681,12 +684,81 @@ class RBDDriver(driver.CloneableImageVD,
|
|||||||
return {'replication_status': fields.ReplicationStatus.DISABLED}
|
return {'replication_status': fields.ReplicationStatus.DISABLED}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _check_encryption_provider(self, volume, context):
|
||||||
|
"""Check that this is a LUKS encryption provider.
|
||||||
|
|
||||||
|
:returns: encryption dict
|
||||||
|
"""
|
||||||
|
|
||||||
|
encryption = self.db.volume_encryption_metadata_get(context, volume.id)
|
||||||
|
provider = encryption['provider']
|
||||||
|
if provider in encryptors.LEGACY_PROVIDER_CLASS_TO_FORMAT_MAP:
|
||||||
|
provider = encryptors.LEGACY_PROVIDER_CLASS_TO_FORMAT_MAP[provider]
|
||||||
|
if provider != encryptors.LUKS:
|
||||||
|
message = _("Provider %s not supported.") % provider
|
||||||
|
raise exception.VolumeDriverException(message=message)
|
||||||
|
|
||||||
|
if 'cipher' not in encryption or 'key_size' not in encryption:
|
||||||
|
msg = _('encryption spec must contain "cipher" and'
|
||||||
|
'"key_size"')
|
||||||
|
raise exception.VolumeDriverException(message=msg)
|
||||||
|
|
||||||
|
return encryption
|
||||||
|
|
||||||
|
def _create_encrypted_volume(self, volume, context):
|
||||||
|
"""Create an encrypted volume.
|
||||||
|
|
||||||
|
This works by creating an encrypted image locally,
|
||||||
|
and then uploading it to the volume.
|
||||||
|
"""
|
||||||
|
|
||||||
|
encryption = self._check_encryption_provider(volume, context)
|
||||||
|
|
||||||
|
# Fetch the key associated with the volume and decode the passphrase
|
||||||
|
keymgr = key_manager.API(CONF)
|
||||||
|
key = keymgr.get(context, encryption['encryption_key_id'])
|
||||||
|
passphrase = binascii.hexlify(key.get_encoded()).decode('utf-8')
|
||||||
|
|
||||||
|
# create a file
|
||||||
|
tmp_dir = self._image_conversion_dir()
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(dir=tmp_dir) as tmp_image:
|
||||||
|
with tempfile.NamedTemporaryFile(dir=tmp_dir) as tmp_key:
|
||||||
|
with open(tmp_key.name, 'w') as f:
|
||||||
|
f.write(passphrase)
|
||||||
|
|
||||||
|
cipher_spec = image_utils.decode_cipher(encryption['cipher'],
|
||||||
|
encryption['key_size'])
|
||||||
|
|
||||||
|
create_cmd = (
|
||||||
|
'qemu-img', 'create', '-f', 'luks',
|
||||||
|
'-o', 'cipher-alg=%(cipher_alg)s,'
|
||||||
|
'cipher-mode=%(cipher_mode)s,'
|
||||||
|
'ivgen-alg=%(ivgen_alg)s' % cipher_spec,
|
||||||
|
'--object', 'secret,id=luks_sec,'
|
||||||
|
'format=raw,file=%(passfile)s' % {'passfile':
|
||||||
|
tmp_key.name},
|
||||||
|
'-o', 'key-secret=luks_sec',
|
||||||
|
tmp_image.name,
|
||||||
|
'%sM' % (volume.size * 1024))
|
||||||
|
self._execute(*create_cmd)
|
||||||
|
|
||||||
|
# Copy image into RBD
|
||||||
|
chunk_size = self.configuration.rbd_store_chunk_size * units.Mi
|
||||||
|
order = int(math.log(chunk_size, 2))
|
||||||
|
|
||||||
|
cmd = ['rbd', 'import',
|
||||||
|
'--pool', self.configuration.rbd_pool,
|
||||||
|
'--order', order,
|
||||||
|
tmp_image.name, volume.name]
|
||||||
|
cmd.extend(self._ceph_args())
|
||||||
|
self._execute(*cmd)
|
||||||
|
|
||||||
def create_volume(self, volume):
|
def create_volume(self, volume):
|
||||||
"""Creates a logical volume."""
|
"""Creates a logical volume."""
|
||||||
|
|
||||||
if volume.encryption_key_id:
|
if volume.encryption_key_id:
|
||||||
message = _("Encryption is not yet supported.")
|
return self._create_encrypted_volume(volume, volume.obj_context)
|
||||||
raise exception.VolumeDriverException(message=message)
|
|
||||||
|
|
||||||
size = int(volume.size) * units.Gi
|
size = int(volume.size) * units.Gi
|
||||||
|
|
||||||
@ -1254,7 +1326,45 @@ class RBDDriver(driver.CloneableImageVD,
|
|||||||
|
|
||||||
return tmpdir
|
return tmpdir
|
||||||
|
|
||||||
|
def copy_image_to_encrypted_volume(self, context, volume, image_service,
|
||||||
|
image_id):
|
||||||
|
self._copy_image_to_volume(context, volume, image_service, image_id,
|
||||||
|
encrypted=True)
|
||||||
|
|
||||||
def copy_image_to_volume(self, context, volume, image_service, image_id):
|
def copy_image_to_volume(self, context, volume, image_service, image_id):
|
||||||
|
self._copy_image_to_volume(context, volume, image_service, image_id)
|
||||||
|
|
||||||
|
def _encrypt_image(self, context, volume, tmp_dir, src_image_path):
|
||||||
|
encryption = self._check_encryption_provider(volume, context)
|
||||||
|
|
||||||
|
# Fetch the key associated with the volume and decode the passphrase
|
||||||
|
keymgr = key_manager.API(CONF)
|
||||||
|
key = keymgr.get(context, encryption['encryption_key_id'])
|
||||||
|
passphrase = binascii.hexlify(key.get_encoded()).decode('utf-8')
|
||||||
|
|
||||||
|
# Decode the dm-crypt style cipher spec into something qemu-img can use
|
||||||
|
cipher_spec = image_utils.decode_cipher(encryption['cipher'],
|
||||||
|
encryption['key_size'])
|
||||||
|
|
||||||
|
tmp_dir = self._image_conversion_dir()
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(prefix='luks_',
|
||||||
|
dir=tmp_dir) as pass_file:
|
||||||
|
with open(pass_file.name, 'w') as f:
|
||||||
|
f.write(passphrase)
|
||||||
|
|
||||||
|
# Convert the raw image to luks
|
||||||
|
dest_image_path = src_image_path + '.luks'
|
||||||
|
image_utils.convert_image(src_image_path, dest_image_path,
|
||||||
|
'luks', src_format='raw',
|
||||||
|
cipher_spec=cipher_spec,
|
||||||
|
passphrase_file=pass_file.name)
|
||||||
|
|
||||||
|
# Replace the original image with the now encrypted image
|
||||||
|
os.rename(dest_image_path, src_image_path)
|
||||||
|
|
||||||
|
def _copy_image_to_volume(self, context, volume, image_service, image_id,
|
||||||
|
encrypted=False):
|
||||||
|
|
||||||
tmp_dir = self._image_conversion_dir()
|
tmp_dir = self._image_conversion_dir()
|
||||||
|
|
||||||
@ -1264,6 +1374,9 @@ class RBDDriver(driver.CloneableImageVD,
|
|||||||
self.configuration.volume_dd_blocksize,
|
self.configuration.volume_dd_blocksize,
|
||||||
size=volume.size)
|
size=volume.size)
|
||||||
|
|
||||||
|
if encrypted:
|
||||||
|
self._encrypt_image(context, volume, tmp_dir, tmp.name)
|
||||||
|
|
||||||
self.delete_volume(volume)
|
self.delete_volume(volume)
|
||||||
|
|
||||||
chunk_size = self.configuration.rbd_store_chunk_size * units.Mi
|
chunk_size = self.configuration.rbd_store_chunk_size * units.Mi
|
||||||
|
@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
features:
|
||||||
|
- |
|
||||||
|
LUKS Encrypted RBD volumes can now be created by cinder-volume. This
|
||||||
|
capability was previously blocked by the rbd volume driver due to the lack
|
||||||
|
of any encryptors capable of attaching to an encrypted RBD volume. These
|
||||||
|
volumes can also be seeded with RAW image data from Glance through the use
|
||||||
|
of QEMU 2.10 and the qemu-img convert command.
|
Loading…
Reference in New Issue
Block a user