Merge "Add support for instance storage encryption"
This commit is contained in:
commit
6a0ed48a3a
24
config.yaml
24
config.yaml
|
@ -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).
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
storage.bootstrap
|
|
@ -0,0 +1 @@
|
|||
storage.bootstrap
|
|
@ -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():
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
nova_compute_hooks.py
|
|
@ -0,0 +1 @@
|
|||
nova_compute_hooks.py
|
|
@ -0,0 +1 @@
|
|||
nova_compute_hooks.py
|
|
@ -0,0 +1 @@
|
|||
nova_compute_hooks.py
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
nova_compute_hooks.py
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
# vaultlocker configuration from nova-compute charm
|
||||
[vault]
|
||||
url = {{ vault_url }}
|
||||
approle = {{ role_id }}
|
||||
backend = {{ secret_backend }}
|
||||
secret_id = {{ secret_id }}
|
|
@ -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'
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue