diff --git a/charm-helpers.yaml b/charm-helpers.yaml new file mode 100644 index 00000000..7d9aa62e --- /dev/null +++ b/charm-helpers.yaml @@ -0,0 +1,10 @@ +branch: lp:charm-helpers +destination: hooks/charmhelpers +include: + - core + - contrib.openstack|inc=* + - contrib.storage + - contrib.hahelpers: + - apache + - ceph + - cluster diff --git a/hooks/install b/hooks/install new file mode 120000 index 00000000..6eb6593e --- /dev/null +++ b/hooks/install @@ -0,0 +1 @@ +nova_compute_relations.py \ No newline at end of file diff --git a/hooks/nova_compute_relations.py b/hooks/nova_compute_relations.py index 7f352559..67ed6f62 100755 --- a/hooks/nova_compute_relations.py +++ b/hooks/nova_compute_relations.py @@ -24,8 +24,7 @@ from charmhelpers.contrib.openstack.utils import ( ) from nova_compute_utils import ( - PACKAGES, - RESTART_MAP, + determine_packages, import_authorized_keys, import_keystone_ca_cert, migration_enabled, @@ -33,9 +32,11 @@ from nova_compute_utils import ( configure_network_service, configure_volume_service, do_openstack_upgrade, + quantum_attribute, quantum_enabled, - quantum_plugin_config, + quantum_plugin, public_ssh_key, + restart_map, register_configs, ) @@ -51,11 +52,11 @@ CONFIGS = register_configs() def install(): configure_installation_source(config('openstack-origin')) apt_update() - apt_install(PACKAGES, fatal=True) + apt_install(determine_packages(), fatal=True) @hooks.hook('config-changed') -@restart_on_change(RESTART_MAP) +@restart_on_change(restart_map()) def config_changed(): if openstack_upgrade_available('nova-common'): do_openstack_upgrade() @@ -68,13 +69,13 @@ def config_changed(): @hooks.hook('amqp-relation-joined') -@restart_on_change(RESTART_MAP) +@restart_on_change(restart_map()) def amqp_joined(): relation_set(username=config('rabbit-user'), vhost=config('rabbit-vhost')) @hooks.hook('amqp-relation-changed') -@restart_on_change(RESTART_MAP) +@restart_on_change(restart_map()) def amqp_changed(): if 'amqp' not in CONFIGS.complete_contexts(): log('amqp relation incomplete. Peer not ready?') @@ -91,18 +92,19 @@ def db_joined(): @hooks.hook('shared-db-relation-changed') -@restart_on_change(RESTART_MAP) +@restart_on_change(restart_map()) def db_changed(): if 'shared-db' not in CONFIGS.complete_contexts(): log('shared-db relation incomplete. Peer not ready?') return CONFIGS.write('/etc/nova/nova.conf') if quantum_enabled(): - CONFIGS.write(quantum_plugin_config()) + plugin = quantum_plugin() + CONFIGS.write(quantum_attribute(plugin, 'config')) @hooks.hook('image-service-relation-changed') -@restart_on_change(RESTART_MAP) +@restart_on_change(restart_map()) def image_service_changed(): if 'image-service' not in CONFIGS.complete_contexts(): log('image-service relation incomplete. Peer not ready?') @@ -124,7 +126,7 @@ def compute_joined(rid=None): @hooks.hook('cloud-compute-relation-changed') -@restart_on_change(RESTART_MAP) +@restart_on_change(restart_map()) def compute_changed(): configure_network_service() configure_volume_service() @@ -133,7 +135,7 @@ def compute_changed(): @hooks.hook('ceph-relation-joined') -@restart_on_change(RESTART_MAP) +@restart_on_change(restart_map()) def ceph_joined(): if not os.path.isdir('/etc/ceph'): os.mkdir('/etc/ceph') @@ -141,7 +143,7 @@ def ceph_joined(): @hooks.hook('ceph-relation-changed') -@restart_on_change(RESTART_MAP) +@restart_on_change(restart_map()) def ceph_changed(): if 'ceph' not in CONFIGS.complete_contexts(): log('ceph relation incomplete. Peer not ready?') diff --git a/hooks/nova_compute_utils.py b/hooks/nova_compute_utils.py index 86d567e9..d08b625b 100644 --- a/hooks/nova_compute_utils.py +++ b/hooks/nova_compute_utils.py @@ -1,12 +1,47 @@ +import copy from charmhelpers.core.hookenv import ( config, + log, + related_units, + relation_ids, + relation_get, + ERROR, ) -PACKAGES = [] +BASE_PACKAGES = [ + 'nova-compute', + 'genisoimage', # was missing as a package dependency until raring. +] -RESTART_MAP = { +BASE_RESTART_MAP = { '/etc/libvirt/qemu.conf': ['libvirt-bin'], - '/etc/default/libvirt-bin': ['libvirt-bin'] + '/etc/default/libvirt-bin': ['libvirt-bin'], + '/etc/nova/nova.conf': ['nova-compute'], + '/etc/nova/nova-compute.conf': ['nova-compute'], +} + + +QUANTUM_PLUGINS = { + 'ovs': { + 'config': '/etc/quantum/plugins/openvswitch/ovs_quantum_plugin.ini', + 'services': ['quantum-plugin-openvswitch-agent'], + 'packages': ['quantum-plugin-openvswitch-agent', + 'openvswitch-datapath-dkms'], + }, + 'nvp': { + 'config': '/etc/quantum/plugins/nicira/nvp.ini', + 'services': [], + 'packages': ['quantum-plugin-nicira'], + } +} + +# Maps virt-type config to a compute package(s). +VIRT_TYPES = { + 'kvm': ['nova-compute-kvm'], + 'qemu': ['nova-compute-qemu'], + 'xen': ['nova-compute-xen'], + 'uml': ['nova-compute-uml'], + 'lxc': ['nova-compute-lxc'], } # This is just a label and it must be consistent across @@ -14,6 +49,58 @@ RESTART_MAP = { CEPH_SECRET_UUID = '514c9fca-8cbe-11e2-9c52-3bc8c7819472' +def restart_map(): + ''' + Constructs a restart map based on charm config settings and relation + state. + ''' + _restart_map = copy.copy(BASE_RESTART_MAP) + + net_manager = network_manager() + + if (net_manager in ['FlatManager', 'FlatDHCPManager'] and + config('multi-host').lower() == 'yes'): + _restart_map['/etc/nova/nova.conf'].extend( + ['nova-api', 'nova-network'] + ) + elif net_manager == 'Quantum': + plugin = quantum_plugin() + if plugin: + conf = quantum_attribute(plugin, 'config') + svcs = quantum_attribute(plugin, 'services') + _restart_map[conf] = svcs + _restart_map['/etc/quantum/quantum.conf'] = svcs + return _restart_map + + +def determine_packages(): + packages = [] + BASE_PACKAGES + + net_manager = network_manager() + if (net_manager in ['FlatManager', 'FlatDHCPManager'] and + config('multi-host').lower() == 'yes'): + packages.extend(['nova-api', 'nova-network']) + elif net_manager == 'Quantum': + plugin = quantum_plugin() + packages.extend(quantum_attribute(plugin, 'packages')) + + if relation_ids('ceph'): + packages.append('ceph-common') + + virt_type = config('virt-type') + try: + packages.extend(VIRT_TYPES[virt_type]) + except KeyError: + log('Unsupported virt-type configured: %s' % virt_type) + + raise + return packages + + +def register_configs(): + pass + + def migration_enabled(): return config('enable-live-migration').lower() == 'true' @@ -22,8 +109,37 @@ def quantum_enabled(): return config('network-manager').lower() == 'quantum' -def quantum_plugin_config(): - pass +def _network_config(): + ''' + Obtain all relevant network configuration settings from nova-c-c via + cloud-compute interface. + ''' + settings = ['network_manager', 'quantum_plugin'] + net_config = {} + for rid in relation_ids('cloud-compute'): + for unit in related_units(rid): + for setting in settings: + value = relation_get(setting, rid=rid, unit=unit) + if value: + net_config[setting] = value + return net_config + + +def quantum_plugin(): + return _network_config().get('quantum_plugin') + + +def network_manager(): + return _network_config().get('network_manager') + + +def quantum_attribute(plugin, attr): + try: + _plugin = QUANTUM_PLUGINS[plugin] + except KeyError: + log('Unrecognised plugin for quantum: %s' % plugin, level=ERROR) + raise + return _plugin[attr] def public_ssh_key(user='root'): @@ -59,10 +175,6 @@ def do_openstack_upgrade(): pass -def register_configs(): - pass - - def import_keystone_ca_cert(): pass diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_nova_compute_relations.py b/tests/test_nova_compute_relations.py new file mode 100644 index 00000000..67dcc1c8 --- /dev/null +++ b/tests/test_nova_compute_relations.py @@ -0,0 +1,257 @@ +from mock import call, patch, MagicMock + +from tests.test_utils import CharmTestCase + +import hooks.nova_compute_utils as utils + +_reg = utils.register_configs +_map = utils.restart_map + +utils.register_configs = MagicMock() +utils.restart_map = MagicMock() + +import hooks.nova_compute_relations as relations + +utils.register_configs = _reg +utils.restart_map = _map + +TO_PATCH = [ + # charmhelpers.core.hookenv + 'Hooks', + 'config', + 'log', + 'relation_ids', + 'relation_set', + 'service_name', + 'unit_get', + # charmhelpers.core.host + 'apt_install', + 'apt_update', + 'restart_on_change', + #charmhelpers.contrib.openstack.utils + 'configure_installation_source', + 'openstack_upgrade_available', + # nova_compute_utils + #'PACKAGES', + 'restart_map', + 'determine_packages', + 'import_authorized_keys', + 'import_keystone_ca_cert', + 'migration_enabled', + 'configure_live_migration', + 'configure_network_service', + 'configure_volume_service', + 'do_openstack_upgrade', + 'quantum_attribute', + 'quantum_enabled', + 'quantum_plugin', + 'public_ssh_key', + 'register_configs', + # misc_utils + 'ensure_ceph_keyring', +] + + +class NovaComputeRelationsTests(CharmTestCase): + def setUp(self): + super(NovaComputeRelationsTests, self).setUp(relations, + TO_PATCH) + self.config.side_effect = self.test_config.get + + def test_install_hook(self): + repo = 'cloud:precise-grizzly' + self.test_config.set('openstack-origin', repo) + self.determine_packages.return_value = ['foo', 'bar'] + relations.install() + self.configure_installation_source.assert_called_with(repo) + self.assertTrue(self.apt_update.called) + self.apt_install.assert_called_with(['foo', 'bar'], fatal=True) + + def test_config_changed_with_upgrade(self): + self.openstack_upgrade_available.return_value = True + relations.config_changed() + self.assertTrue(self.do_openstack_upgrade.called) + + @patch.object(relations, 'compute_joined') + def test_config_changed_with_migration(self, compute_joined): + self.migration_enabled.return_value = True + self.test_config.set('migration-auth-type', 'ssh') + self.relation_ids.return_value = [ + 'cloud-compute:0', + 'cloud-compute:1' + ] + relations.config_changed() + ex = [ + call('cloud-compute:0'), + call('cloud-compute:1'), + ] + self.assertEquals(ex, compute_joined.call_args_list) + + @patch.object(relations, 'compute_joined') + def test_config_changed_no_upgrade_no_migration(self, compute_joined): + self.openstack_upgrade_available.return_value = False + self.migration_enabled.return_value = False + relations.config_changed() + self.assertFalse(self.do_openstack_upgrade.called) + self.assertTrue(self.configure_live_migration) + self.assertFalse(compute_joined.called) + + def test_amqp_joined(self): + relations.amqp_joined() + self.relation_set.assert_called_with(username='nova', vhost='nova') + + @patch.object(relations, 'CONFIGS') + def test_amqp_changed_missing_relation_data(self, configs): + configs.complete_contexts = MagicMock() + configs.complete_contexts.return_value = [] + relations.amqp_changed() + self.log.assert_called_with( + 'amqp relation incomplete. Peer not ready?' + ) + + def _amqp_test(self, configs, quantum=False): + configs.complete_contexts = MagicMock() + configs.complete_contexts.return_value = ['amqp'] + configs.write = MagicMock() + self.quantum_enabled.return_value = quantum + relations.amqp_changed() + + @patch.object(relations, 'CONFIGS') + def test_amqp_changed_with_data_no_quantum(self, configs): + self._amqp_test(configs, quantum=False) + self.assertEquals([call('/etc/nova/nova.conf')], + configs.write.call_args_list) + + @patch.object(relations, 'CONFIGS') + def test_amqp_changed_with_data_and_quantum(self, configs): + self._amqp_test(configs, quantum=True) + self.assertEquals([call('/etc/nova/nova.conf'), + call('/etc/quantum/quantum.conf')], + configs.write.call_args_list) + + def test_db_joined(self): + self.unit_get.return_value = 'nova.foohost.com' + relations.db_joined() + self.relation_set.assert_called_with(database='nova', username='nova', + hostname='nova.foohost.com') + self.unit_get.assert_called_with('private-address') + + @patch.object(relations, 'CONFIGS') + def test_db_changed_missing_relation_data(self, configs): + configs.complete_contexts = MagicMock() + configs.complete_contexts.return_value = [] + relations.db_changed() + self.log.assert_called_with( + 'shared-db relation incomplete. Peer not ready?' + ) + + def _shared_db_test(self, configs, quantum=False): + configs.complete_contexts = MagicMock() + configs.complete_contexts.return_value = ['shared-db'] + configs.write = MagicMock() + self.quantum_enabled.return_value = quantum + relations.db_changed() + + @patch.object(relations, 'CONFIGS') + def test_db_changed_with_data_no_quantum(self, configs): + self._shared_db_test(configs, quantum=False) + self.assertEquals([call('/etc/nova/nova.conf')], + configs.write.call_args_list) + + @patch.object(relations, 'CONFIGS') + def test_db_changed_with_data_and_quantum(self, configs): + self.quantum_attribute.return_value = '/etc/quantum/plugin.conf' + self._shared_db_test(configs, quantum=True) + ex = [call('/etc/nova/nova.conf'), call('/etc/quantum/plugin.conf')] + self.assertEquals(ex, configs.write.call_args_list) + + @patch.object(relations, 'CONFIGS') + def test_image_service_missing_relation_data(self, configs): + configs.complete_contexts = MagicMock() + configs.complete_contexts.return_value = [] + relations.image_service_changed() + self.log.assert_called_with( + 'image-service relation incomplete. Peer not ready?' + ) + + @patch.object(relations, 'CONFIGS') + def test_image_service_with_relation_data(self, configs): + configs.complete_contexts = MagicMock() + configs.write = MagicMock() + configs.complete_contexts.return_value = ['image-service'] + relations.image_service_changed() + configs.write.assert_called_with('/etc/nova/nova.conf') + + def test_compute_joined_no_migration(self): + self.migration_enabled.return_value = False + relations.compute_joined() + self.assertFalse(self.relation_set.called) + + def test_compute_joined_with_ssh_migration(self): + self.migration_enabled.return_value = True + self.test_config.set('migration-auth-type', 'ssh') + self.public_ssh_key.return_value = 'foo' + relations.compute_joined() + self.relation_set.assert_called_with( + relation_id=None, + ssh_public_key='foo', + migration_auth_type='ssh' + ) + relations.compute_joined(rid='cloud-compute:2') + self.relation_set.assert_called_with( + relation_id='cloud-compute:2', + ssh_public_key='foo', + migration_auth_type='ssh' + ) + + def test_compute_changed(self): + relations.compute_changed() + expected_funcs = [ + self.configure_network_service, + self.configure_volume_service, + self.import_authorized_keys, + self.import_keystone_ca_cert, + ] + for func in expected_funcs: + self.assertTrue(func.called) + + @patch('os.mkdir') + @patch('os.path.isdir') + def test_ceph_joined(self, isdir, mkdir): + isdir.return_value = False + relations.ceph_joined() + mkdir.assert_called_with('/etc/ceph') + self.apt_install.assert_called_with('ceph-common') + + @patch.object(relations, 'CONFIGS') + def test_ceph_changed_missing_relation_data(self, configs): + configs.complete_contexts = MagicMock() + configs.complete_contexts.return_value = [] + relations.ceph_changed() + self.log.assert_called_with( + 'ceph relation incomplete. Peer not ready?' + ) + + @patch.object(relations, 'CONFIGS') + def test_ceph_changed_no_keyring(self, configs): + configs.complete_contexts = MagicMock() + configs.complete_contexts.return_value = ['ceph'] + self.ensure_ceph_keyring.return_value = False + relations.ceph_changed() + self.log.assert_called_with( + 'Could not create ceph keyring: peer not ready?' + ) + + @patch.object(relations, 'CONFIGS') + def test_ceph_changed_with_key_and_relation_data(self, configs): + configs.complete_contexts = MagicMock() + configs.complete_contexts.return_value = ['ceph'] + configs.write = MagicMock() + self.ensure_ceph_keyring.return_value = True + relations.ceph_changed() + ex = [ + call('/etc/ceph/ceph.conf'), + call('/etc/ceph/secret.xml'), + call('/etc/nova/nova.conf'), + ] + self.assertEquals(ex, configs.write.call_args_list) diff --git a/tests/test_nova_compute_utils.py b/tests/test_nova_compute_utils.py new file mode 100644 index 00000000..3bae58ac --- /dev/null +++ b/tests/test_nova_compute_utils.py @@ -0,0 +1,105 @@ +from mock import patch + +from tests.test_utils import CharmTestCase + + +import hooks.nova_compute_utils as utils + +TO_PATCH = [ + 'config', + 'log', + 'related_units', + 'relation_ids', + 'relation_get', +] + + +class NovaComputeUtilsTests(CharmTestCase): + def setUp(self): + super(NovaComputeUtilsTests, self).setUp(utils, TO_PATCH) + self.config.side_effect = self.test_config.get + + @patch.object(utils, 'network_manager') + def test_determine_packages_nova_network(self, net_man): + net_man.return_value = 'FlatDHCPManager' + self.relation_ids.return_value = [] + result = utils.determine_packages() + ex = utils.BASE_PACKAGES + [ + 'nova-api', + 'nova-network', + 'nova-compute-kvm' + ] + self.assertEquals(ex, result) + + @patch.object(utils, 'quantum_plugin') + @patch.object(utils, 'network_manager') + def test_determine_packages_quantum(self, net_man, q_plugin): + net_man.return_value = 'Quantum' + q_plugin.return_value = 'ovs' + self.relation_ids.return_value = [] + result = utils.determine_packages() + ex = utils.BASE_PACKAGES + [ + 'quantum-plugin-openvswitch-agent', + 'openvswitch-datapath-dkms', + 'nova-compute-kvm' + ] + self.assertEquals(ex, result) + + @patch.object(utils, 'quantum_plugin') + @patch.object(utils, 'network_manager') + def test_determine_packages_quantum_ceph(self, net_man, q_plugin): + net_man.return_value = 'Quantum' + q_plugin.return_value = 'ovs' + self.relation_ids.return_value = ['ceph:0'] + result = utils.determine_packages() + ex = utils.BASE_PACKAGES + [ + 'quantum-plugin-openvswitch-agent', + 'openvswitch-datapath-dkms', + 'ceph-common', + 'nova-compute-kvm' + ] + self.assertEquals(ex, result) + + # NOTE: These tests faill if run together, something is holding + # a reference to BASE_RESOURCE_MAP ? + @patch.object(utils, 'network_manager') + def test_resource_map_nova_network_no_multihost(self, net_man): + self.test_config.set('multi-host', 'no') + net_man.return_value = 'FlatDHCPManager' + result = utils.restart_map() + ex = { + '/etc/default/libvirt-bin': ['libvirt-bin'], + '/etc/libvirt/qemu.conf': ['libvirt-bin'], + '/etc/nova/nova-compute.conf': ['nova-compute'], + '/etc/nova/nova.conf': ['nova-compute'] + } + self.assertEquals(ex, result) + + @patch.object(utils, 'network_manager') + def test_resource_map_nova_network(self, net_man): + net_man.return_value = 'FlatDHCPManager' + result = utils.restart_map() + ex = { + '/etc/default/libvirt-bin': ['libvirt-bin'], + '/etc/libvirt/qemu.conf': ['libvirt-bin'], + '/etc/nova/nova-compute.conf': ['nova-compute'], + '/etc/nova/nova.conf': ['nova-compute', 'nova-api', 'nova-network'] + } + self.assertEquals(ex, result) + + @patch.object(utils, 'quantum_plugin') + @patch.object(utils, 'network_manager') + def test_resource_map_quantum_ovs(self, net_man, _plugin): + net_man.return_value = 'Quantum' + _plugin.return_value = 'ovs' + result = utils.restart_map() + ex = { + '/etc/default/libvirt-bin': ['libvirt-bin'], + '/etc/libvirt/qemu.conf': ['libvirt-bin'], + '/etc/nova/nova-compute.conf': ['nova-compute'], + '/etc/nova/nova.conf': ['nova-compute'], + '/etc/quantum/plugins/openvswitch/ovs_quantum_plugin.ini': + ['quantum-plugin-openvswitch-agent'], + '/etc/quantum/quantum.conf': ['quantum-plugin-openvswitch-agent'] + } + self.assertEquals(ex, result) diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..c75c739a --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,101 @@ +import logging +import unittest +import os +import yaml + +from mock import patch + + +def load_config(): + ''' + Walk backwords from __file__ looking for config.yaml, load and return the + 'options' section' + ''' + config = None + f = __file__ + while config is None: + d = os.path.dirname(f) + if os.path.isfile(os.path.join(d, 'config.yaml')): + config = os.path.join(d, 'config.yaml') + break + f = d + + if not config: + logging.error('Could not find config.yaml in any parent directory ' + 'of %s. ' % file) + raise Exception + + return yaml.safe_load(open(config).read())['options'] + + +def get_default_config(): + ''' + Load default charm config from config.yaml return as a dict. + If no default is set in config.yaml, its value is None. + ''' + default_config = {} + config = load_config() + for k, v in config.iteritems(): + if 'default' in v: + default_config[k] = v['default'] + else: + default_config[k] = None + return default_config + + +class CharmTestCase(unittest.TestCase): + def setUp(self, obj, patches): + super(CharmTestCase, self).setUp() + self.patches = patches + self.obj = obj + self.test_config = TestConfig() + self.test_relation = TestRelation() + self.patch_all() + + def patch(self, method): + _m = patch.object(self.obj, method) + mock = _m.start() + self.addCleanup(_m.stop) + return mock + + def patch_all(self): + for method in self.patches: + setattr(self, method, self.patch(method)) + + +class TestConfig(object): + def __init__(self): + self.config = get_default_config() + + def get(self, attr=None): + if not attr: + return self.get_all() + try: + return self.config[attr] + except KeyError: + return None + + def get_all(self): + return self.config + + def set(self, attr, value): + if attr not in self.config: + raise KeyError + self.config[attr] = value + + +class TestRelation(object): + def __init__(self, relation_data={}): + self.relation_data = relation_data + + def set(self, relation_data): + self.relation_data = relation_data + + def get(self, attr=None, unit=None, rid=None): + if attr == None: + return self.relation_data + elif attr in self.relation_data: + return self.relation_data[attr] + return None + +