RBD: Support encrypted volumes
When creating an encrypted RBD volume, initialize LUKS on the volume using the volume's encryption key. This is required because os-brick only handles this step for volumes that attach via block devices. This requires qemu-img 2.10. Co-Authored-By: Lee Yarwood <lyarwood@redhat.com> Related-Bug: #1463525 Implements: blueprint libvirt-qemu-native-luks Change-Id: Id02130e9af8bdf90a712968916017d05c3213c32
This commit is contained in:
parent
3b86eb7a82
commit
fcb45b439b
@ -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_MIN_FORCE_SHARE_VERSION = [2, 10, 0]
|
||||
QEMU_IMG_MIN_CONVERT_LUKS_VERSION = '2.10'
|
||||
|
||||
|
||||
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,
|
||||
out_subformat=None, cache_mode=None,
|
||||
prefix=None):
|
||||
prefix=None, cipher_spec=None, passphrase_file=None):
|
||||
|
||||
if out_format == 'vhd':
|
||||
# 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'):
|
||||
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]
|
||||
|
||||
return cmd
|
||||
@ -193,7 +206,7 @@ def check_qemu_img_version(minimum_version):
|
||||
|
||||
def _convert_image(prefix, source, dest, out_format,
|
||||
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."""
|
||||
|
||||
# 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,
|
||||
out_subformat=out_subformat,
|
||||
cache_mode=cache_mode,
|
||||
prefix=prefix)
|
||||
prefix=prefix,
|
||||
cipher_spec=cipher_spec,
|
||||
passphrase_file=passphrase_file)
|
||||
|
||||
start_time = timeutils.utcnow()
|
||||
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,
|
||||
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:
|
||||
throttle = throttling.Throttle.get_default()
|
||||
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_subformat=out_subformat,
|
||||
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):
|
||||
@ -699,6 +717,21 @@ def replace_xenserver_image_with_coalesced_vhd(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):
|
||||
"""Manage temporarily downloaded images to avoid downloading it twice.
|
||||
|
||||
|
@ -1703,3 +1703,10 @@ class TestImageUtils(test.TestCase):
|
||||
virtual_size,
|
||||
volume_size,
|
||||
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
|
||||
# under the License.
|
||||
|
||||
import ddt
|
||||
import math
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import castellan
|
||||
import ddt
|
||||
import mock
|
||||
from mock import call
|
||||
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_snapshot
|
||||
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.volume import test_driver
|
||||
from cinder.volume import configuration as conf
|
||||
@ -65,6 +67,11 @@ class MockImageExistsException(MockException):
|
||||
"""Used as mock for rbd.ImageExists."""
|
||||
|
||||
|
||||
class KeyObject(object):
|
||||
def get_encoded(arg):
|
||||
return "asdf".encode('utf-8')
|
||||
|
||||
|
||||
def common_mocks(f):
|
||||
"""Decorator to set mocks common to all tests.
|
||||
|
||||
@ -185,6 +192,13 @@ class RBDTestCase(test.TestCase):
|
||||
'id': '0c7d1f44-5a06-403f-bb82-ae7ad0d693a6',
|
||||
'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.context, name='snapshot-0000000a')
|
||||
|
||||
@ -459,14 +473,6 @@ class RBDTestCase(test.TestCase):
|
||||
client.__enter__.assert_called_once_with()
|
||||
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
|
||||
def test_manage_existing_get_size(self):
|
||||
with mock.patch.object(self.driver.rbd.Image(), 'size') as \
|
||||
@ -2023,6 +2029,50 @@ class RBDTestCase(test.TestCase):
|
||||
mock_delete.assert_called_once_with(self.volume_a)
|
||||
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):
|
||||
driver_name = "cinder.volume.drivers.rbd.RBDDriver"
|
||||
|
@ -14,12 +14,15 @@
|
||||
"""RADOS Block Device Driver"""
|
||||
|
||||
from __future__ import absolute_import
|
||||
import binascii
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from castellan import key_manager
|
||||
from eventlet import tpool
|
||||
from os_brick import encryptors
|
||||
from os_brick.initiator import linuxrbd
|
||||
from oslo_config import cfg
|
||||
from oslo_log import log as logging
|
||||
@ -681,12 +684,81 @@ class RBDDriver(driver.CloneableImageVD,
|
||||
return {'replication_status': fields.ReplicationStatus.DISABLED}
|
||||
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):
|
||||
"""Creates a logical volume."""
|
||||
|
||||
if volume.encryption_key_id:
|
||||
message = _("Encryption is not yet supported.")
|
||||
raise exception.VolumeDriverException(message=message)
|
||||
return self._create_encrypted_volume(volume, volume.obj_context)
|
||||
|
||||
size = int(volume.size) * units.Gi
|
||||
|
||||
@ -1262,7 +1334,45 @@ class RBDDriver(driver.CloneableImageVD,
|
||||
|
||||
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):
|
||||
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()
|
||||
|
||||
@ -1272,6 +1382,9 @@ class RBDDriver(driver.CloneableImageVD,
|
||||
self.configuration.volume_dd_blocksize,
|
||||
size=volume.size)
|
||||
|
||||
if encrypted:
|
||||
self._encrypt_image(context, volume, tmp_dir, tmp.name)
|
||||
|
||||
self.delete_volume(volume)
|
||||
|
||||
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