Merge "Add support for instance storage encryption"

This commit is contained in:
Zuul 2018-05-15 09:59:03 +00:00 committed by Gerrit Code Review
commit 6a0ed48a3a
18 changed files with 415 additions and 2 deletions

View File

@ -388,4 +388,28 @@ options:
presenting the disk via device mapper (/dev/mapper/XX) to the VM instead
of a single path (/dev/disk/by-path/XX). If changed after deployment,
each VM will require a full stop/start for changes to take affect.
ephemeral-device:
type: string
default:
description: |
Block devices to use for storage of ephermeral disks to support nova
instances; generally used in-conjunction with 'encrypt' to support
data-at-rest encryption of instance direct attached storage volumes.
encrypt:
default: False
type: boolean
description: |
Encrypt block devices used for nova instances using dm-crypt, making use
of vault for encryption key management; requires a relation to vault.
ephemeral-unmount:
type: string
default:
description: |
Cloud instances provide ephermeral storage which is normally mounted
on /mnt.
.
Setting this option to the path of the ephemeral mountpoint will force
an unmount of the corresponding device so that it can be used for as the
backing store for local instances. This is useful for testing purposes
(cloud deployment is not a typical use case).

View File

@ -6,6 +6,7 @@ global
group haproxy
spread-checks 0
stats socket /var/run/haproxy/admin.sock mode 600 level admin
stats socket /var/run/haproxy/operator.sock mode 600 level operator
stats timeout 2m
defaults

View File

@ -0,0 +1 @@
storage.bootstrap

View File

@ -0,0 +1 @@
storage.bootstrap

View File

@ -14,11 +14,14 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import base64
import json
import platform
import sys
import uuid
import yaml
import os
import subprocess
import charmhelpers.core.unitdata as unitdata
@ -42,12 +45,15 @@ from charmhelpers.core.templating import (
)
from charmhelpers.core.host import (
service_restart,
write_file,
umount,
)
from charmhelpers.fetch import (
apt_install,
apt_purge,
apt_update,
filter_installed_packages,
add_source,
)
from charmhelpers.contrib.openstack.utils import (
@ -94,6 +100,7 @@ from nova_compute_utils import (
network_manager,
libvirt_daemon,
LIBVIRT_TYPES,
configure_local_ephemeral_storage,
)
from charmhelpers.contrib.network.ip import (
@ -116,6 +123,8 @@ from charmhelpers.contrib.hardening.harden import harden
from socket import gethostname
import charmhelpers.contrib.openstack.vaultlocker as vaultlocker
hooks = Hooks()
CONFIGS = register_configs()
MIGRATION_AUTH_TYPES = ["ssh"]
@ -137,6 +146,9 @@ def install():
@restart_on_change(restart_map())
@harden()
def config_changed():
if config('ephemeral-unmount'):
umount(config('ephemeral-unmount'), persist=True)
if config('prefer-ipv6'):
status_set('maintenance', 'configuring ipv6')
assert_charm_supports_ipv6()
@ -217,6 +229,20 @@ def config_changed():
NovaAPIAppArmorContext().setup_aa_profile()
NovaNetworkAppArmorContext().setup_aa_profile()
install_vaultlocker()
configure_local_ephemeral_storage()
def install_vaultlocker():
"""Determine whether vaultlocker is required and install"""
if config('encrypt'):
installed = len(filter_installed_packages(['vaultlocker'])) == 0
if not installed:
add_source('ppa:openstack-charmers/vaultlocker')
apt_update(fatal=True)
apt_install('vaultlocker', fatal=True)
@hooks.hook('amqp-relation-joined')
def amqp_joined(relation_id=None):
@ -510,6 +536,31 @@ def ceph_access(rid=None, unit=None):
key=key)
@hooks.hook('secrets-storage-relation-joined')
def secrets_storage_joined(relation_id=None):
relation_set(relation_id=relation_id,
secret_backend=vaultlocker.VAULTLOCKER_BACKEND,
isolated=True,
access_address=get_relation_ip('secrets-storage'),
hostname=gethostname())
@hooks.hook('secrets-storage-relation-changed')
def secrets_storage_changed():
vault_ca = relation_get('vault_ca')
if vault_ca:
vault_ca = base64.decodestring(json.loads(vault_ca).encode())
write_file('/usr/local/share/ca-certificates/vault-ca.crt',
vault_ca, perms=0o644)
subprocess.check_call(['update-ca-certificates', '--fresh'])
configure_local_ephemeral_storage()
@hooks.hook('storage.real')
def storage_changed():
configure_local_ephemeral_storage()
@hooks.hook('update-status')
@harden()
def update_status():

View File

@ -17,6 +17,7 @@ import re
import pwd
import subprocess
import platform
import uuid
from itertools import chain
from base64 import b64decode
@ -40,6 +41,8 @@ from charmhelpers.core.host import (
lsb_release,
rsync,
CompareHostReleases,
mount,
fstab_add,
)
from charmhelpers.core.hookenv import (
@ -53,6 +56,8 @@ from charmhelpers.core.hookenv import (
DEBUG,
INFO,
WARNING,
storage_list,
storage_get,
)
from charmhelpers.core.decorators import retry_on_exception
@ -98,6 +103,16 @@ from nova_compute_context import (
NovaComputeAvailabilityZoneContext,
)
import charmhelpers.contrib.openstack.vaultlocker as vaultlocker
from charmhelpers.core.unitdata import kv
from charmhelpers.contrib.storage.linux.utils import (
is_block_device,
is_device_mounted,
mkfs_xfs,
)
CA_CERT_PATH = '/usr/local/share/ca-certificates/keystone_juju_ca_cert.crt'
TEMPLATES = 'templates/'
@ -108,6 +123,7 @@ BASE_PACKAGES = [
'librbd1', # bug 1440953
'python-six',
'python-psutil',
'xfsprogs',
]
VERSION_PACKAGE = 'nova-common'
@ -157,7 +173,9 @@ BASE_RESOURCE_MAP = {
context.VolumeAPIContext('nova-common'),
SerialConsoleContext(),
NovaComputeAvailabilityZoneContext(),
context.WorkerConfigContext()],
context.WorkerConfigContext(),
vaultlocker.VaultKVContext(
vaultlocker.VAULTLOCKER_BACKEND)],
},
NOVA_API_AA_PROFILE_PATH: {
'services': ['nova-api'],
@ -717,6 +735,8 @@ def get_optional_relations():
optional_interfaces['neutron-plugin'] = ['neutron-plugin']
if relation_ids('shared-db'):
optional_interfaces['database'] = ['shared-db']
if config('encrypt'):
optional_interfaces['vault'] = ['secrets-storage']
return optional_interfaces
@ -787,3 +807,101 @@ def _pause_resume_helper(f, configs):
f(assess_status_func(configs),
services=services(),
ports=None)
def determine_block_device():
"""Determine the block device to use for ephemeral storage
:returns: Block device to use for storage
:rtype: str or None if not configured"""
config_dev = config('ephemeral-device')
if config_dev and os.path.exists(config_dev):
return config_dev
storage_ids = storage_list('ephemeral-device')
storage_devs = [storage_get('location', s) for s in storage_ids]
if storage_devs:
return storage_devs[0]
return None
def configure_local_ephemeral_storage():
"""Configure local block device for use as ephemeral instance storage"""
# Preflight check vault relation if encryption is enabled
vault_kv = vaultlocker.VaultKVContext(
secret_backend=vaultlocker.VAULTLOCKER_BACKEND
)
context = vault_kv()
encrypt = config('encrypt')
if encrypt and not vault_kv.complete:
log("Encryption requested but vault relation not complete",
level=DEBUG)
return
elif encrypt and vault_kv.complete:
# NOTE: only write vaultlocker configuration once relation is complete
# otherwise we run the chance of an empty configuration file
# being installed on a machine with other vaultlocker based
# services
vaultlocker.write_vaultlocker_conf(context, priority=80)
db = kv()
storage_configured = db.get('storage-configured', False)
if storage_configured:
log("Ephemeral storage already configured, skipping",
level=DEBUG)
return
dev = determine_block_device()
if not dev:
log('No block device configuration found, skipping',
level=DEBUG)
return
if not is_block_device(dev):
log("Device '{}' is not a block device, "
"unable to configure storage".format(dev),
level=DEBUG)
return
# NOTE: this deals with a dm-crypt'ed block device already in
# use
if is_device_mounted(dev):
log("Device '{}' is already mounted, "
"unable to configure storage".format(dev),
level=DEBUG)
return
options = None
if encrypt:
dev_uuid = str(uuid.uuid4())
check_call(['vaultlocker', 'encrypt',
'--uuid', dev_uuid,
dev])
dev = '/dev/mapper/crypt-{}'.format(dev_uuid)
options = ','.join([
"defaults",
"nofail",
("x-systemd.requires="
"vaultlocker-decrypt@{uuid}.service".format(uuid=dev_uuid)),
"comment=vaultlocker",
])
# If not cleaned and in use, mkfs should fail.
mkfs_xfs(dev, force=True)
mountpoint = '/var/lib/nova/instances'
filesystem = "xfs"
mount(dev, mountpoint, filesystem=filesystem)
fstab_add(dev, mountpoint, filesystem, options=options)
check_call(['chown', '-R', 'nova:nova', mountpoint])
check_call(['chmod', '-R', '0755', mountpoint])
# NOTE: record preparation of device - this ensures that ephemeral
# storage is never reconfigured by mistake, losing instance disks
db.set('storage-configured', True)
db.flush()

View File

@ -0,0 +1 @@
nova_compute_hooks.py

View File

@ -0,0 +1 @@
nova_compute_hooks.py

View File

@ -0,0 +1 @@
nova_compute_hooks.py

View File

@ -0,0 +1 @@
nova_compute_hooks.py

8
hooks/storage.bootstrap Executable file
View File

@ -0,0 +1,8 @@
#!/bin/sh
if ! dpkg -s nova-compute > /dev/null 2>&1; then
juju-log "Nova not yet installed."
exit 0
fi
./hooks/storage.real

1
hooks/storage.real Symbolic link
View File

@ -0,0 +1 @@
nova_compute_hooks.py

View File

@ -46,6 +46,14 @@ requires:
scope: container
ceph-access:
interface: cinder-ceph-key
secrets-storage:
interface: vault-kv
peers:
compute-peer:
interface: nova
storage:
ephemeral-device:
type: block
multiple:
range: 0-1
minimum-size: 10G

View File

@ -0,0 +1,6 @@
# vaultlocker configuration from nova-compute charm
[vault]
url = {{ vault_url }}
approle = {{ role_id }}
backend = {{ secret_backend }}
secret_id = {{ secret_id }}

View File

@ -137,6 +137,9 @@ class NovaBasicDeployment(OpenStackAmuletDeployment):
nova_config = {'config-flags': 'auto_assign_floating_ip=False',
'enable-live-migration': 'False',
'aa-profile-mode': 'enforce'}
if self._get_openstack_release() > self.trusty_mitaka:
nova_config.update({'ephemeral-device': '/dev/vdb',
'ephemeral-unmount': '/mnt'})
nova_cc_config = {}
if self._get_openstack_release() >= self.xenial_ocata:
nova_cc_config['network-manager'] = 'Neutron'

View File

@ -77,6 +77,7 @@ TO_PATCH = [
'update_nrpe_config',
'network_manager',
'libvirt_daemon',
'configure_local_ephemeral_storage',
# misc_utils
'ensure_ceph_keyring',
'execd_preinstall',
@ -133,6 +134,7 @@ class NovaComputeRelationsTests(CharmTestCase):
self.assertTrue(self.do_openstack_upgrade.called)
neutron_plugin_joined.assert_called_with('rid1', remote_restart=True)
ceph_changed.assert_called_with(rid='ceph:0', unit='ceph/0')
self.configure_local_ephemeral_storage.assert_called_once_with()
def test_config_changed_with_openstack_upgrade_action(self):
self.openstack_upgrade_available.return_value = True
@ -708,3 +710,22 @@ class NovaComputeRelationsTests(CharmTestCase):
group='nova',
key='mykey'
)
def test_secrets_storage_relation_joined(self):
self.get_relation_ip.return_value = '10.23.1.2'
self.gethostname.return_value = 'testhost'
hooks.secrets_storage_joined()
self.get_relation_ip.assert_called_with('secrets-storage')
self.relation_set.assert_called_with(
relation_id=None,
secret_backend='charm-vaultlocker',
isolated=True,
access_address='10.23.1.2',
hostname='testhost'
)
self.gethostname.assert_called_once_with()
def test_secrets_storage_relation_changed(self,):
self.relation_get.return_value = None
hooks.secrets_storage_changed()
self.configure_local_ephemeral_storage.assert_called_once_with()

View File

@ -24,7 +24,8 @@ from mock import (
)
from test_utils import (
CharmTestCase,
patch_open
patch_open,
TestKV,
)
@ -55,6 +56,16 @@ TO_PATCH = [
'Fstab',
'os_application_version_set',
'lsb_release',
'storage_list',
'storage_get',
'vaultlocker',
'kv',
'check_call',
'mkfs_xfs',
'is_block_device',
'is_device_mounted',
'fstab_add',
'mount',
]
@ -65,6 +76,8 @@ class NovaComputeUtilsTests(CharmTestCase):
self.config.side_effect = self.test_config.get
self.charm_dir.return_value = 'mycharm'
self.lsb_release.return_value = {'DISTRIB_CODENAME': 'precise'}
self.test_kv = TestKV()
self.kv.return_value = self.test_kv
@patch.object(utils, 'nova_metadata_requirement')
@patch.object(utils, 'network_manager')
@ -803,3 +816,139 @@ class NovaComputeUtilsTests(CharmTestCase):
'DISTRIB_CODENAME': 'xenial'
}
self.assertEqual(utils.libvirt_daemon(), utils.LIBVIRT_BIN_DAEMON)
@patch.object(utils, 'os')
def test_determine_block_device(self, mock_os):
self.test_config.set('ephemeral-device', '/dev/sdd')
mock_os.path.exists.return_value = True
self.assertEqual(utils.determine_block_device(), '/dev/sdd')
self.config.assert_called_with('ephemeral-device')
def test_determine_block_device_storage(self):
_test_devices = {
'a': '/dev/bcache0'
}
self.storage_list.side_effect = _test_devices.keys()
self.storage_get.side_effect = lambda _, key: _test_devices.get(key)
self.assertEqual(utils.determine_block_device(), '/dev/bcache0')
self.config.assert_called_with('ephemeral-device')
self.storage_get.assert_called_with('location', 'a')
self.storage_list.assert_called_with('ephemeral-device')
def test_determine_block_device_none(self):
self.storage_list.return_value = []
self.assertEqual(utils.determine_block_device(), None)
self.config.assert_called_with('ephemeral-device')
self.storage_list.assert_called_with('ephemeral-device')
@patch.object(utils, 'uuid')
@patch.object(utils, 'determine_block_device')
def test_configure_local_ephemeral_storage_encrypted(
self,
determine_block_device,
uuid):
determine_block_device.return_value = '/dev/sdb'
uuid.uuid4.return_value = 'test'
mock_context = MagicMock()
mock_context.complete = True
mock_context.return_value = 'test_context'
self.test_config.set('encrypt', True)
self.vaultlocker.VaultKVContext.return_value = mock_context
self.is_block_device.return_value = True
self.is_device_mounted.return_value = False
utils.configure_local_ephemeral_storage()
self.mkfs_xfs.assert_called_with(
'/dev/mapper/crypt-test',
force=True
)
self.check_call.assert_has_calls([
call(['vaultlocker', 'encrypt',
'--uuid', 'test', '/dev/sdb']),
call(['chown', '-R', 'nova:nova',
'/var/lib/nova/instances']),
call(['chmod', '-R', '0755',
'/var/lib/nova/instances'])
])
self.mount.assert_called_with(
'/dev/mapper/crypt-test',
'/var/lib/nova/instances',
filesystem='xfs')
self.fstab_add.assert_called_with(
'/dev/mapper/crypt-test',
'/var/lib/nova/instances',
'xfs',
options='defaults,nofail,'
'x-systemd.requires=vaultlocker-decrypt@test.service,'
'comment=vaultlocker'
)
self.assertTrue(self.test_kv.get('storage-configured'))
self.vaultlocker.write_vaultlocker_conf.assert_called_with(
'test_context',
priority=80
)
@patch.object(utils, 'uuid')
@patch.object(utils, 'determine_block_device')
def test_configure_local_ephemeral_storage(self,
determine_block_device,
uuid):
determine_block_device.return_value = '/dev/sdb'
uuid.uuid4.return_value = 'test'
mock_context = MagicMock()
mock_context.complete = False
mock_context.return_value = {}
self.test_config.set('encrypt', False)
self.vaultlocker.VaultKVContext.return_value = mock_context
self.is_block_device.return_value = True
self.is_device_mounted.return_value = False
utils.configure_local_ephemeral_storage()
self.mkfs_xfs.assert_called_with(
'/dev/sdb',
force=True
)
self.check_call.assert_has_calls([
call(['chown', '-R', 'nova:nova',
'/var/lib/nova/instances']),
call(['chmod', '-R', '0755',
'/var/lib/nova/instances'])
])
self.mount.assert_called_with(
'/dev/sdb',
'/var/lib/nova/instances',
filesystem='xfs')
self.fstab_add.assert_called_with(
'/dev/sdb',
'/var/lib/nova/instances',
'xfs',
options=None
)
self.assertTrue(self.test_kv.get('storage-configured'))
self.vaultlocker.write_vaultlocker_conf.assert_not_called()
def test_configure_local_ephemeral_storage_done(self):
self.test_kv.set('storage-configured', True)
mock_context = MagicMock()
mock_context.complete = True
mock_context.return_value = 'test_context'
self.test_config.set('encrypt', True)
self.vaultlocker.VaultKVContext.return_value = mock_context
utils.configure_local_ephemeral_storage()
# NOTE: vaultlocker conf should always be re-written to
# pickup any changes to secret_id over time.
self.vaultlocker.write_vaultlocker_conf.assert_called_with(
'test_context',
priority=80
)
self.is_block_device.assert_not_called()

View File

@ -122,6 +122,23 @@ class TestRelation(object):
return None
class TestKV(dict):
def __init__(self):
super(TestKV, self).__init__()
self.flushed = False
self.data = {}
def get(self, attribute, default=None):
return self.data.get(attribute, default)
def set(self, attribute, value):
self.data[attribute] = value
def flush(self):
self.flushed = True
@contextmanager
def patch_open():
'''Patch open() to allow mocking both open() itself and the file that is