diff --git a/src/config.yaml b/src/config.yaml index f314b7d8..581af6bd 100644 --- a/src/config.yaml +++ b/src/config.yaml @@ -66,3 +66,11 @@ options: . Note that these certificates are not used for any load balancer payload data. + custom-amp-flavor-id: + type: string + default: + description: | + ID of Nova flavor Octavia should use when launching ``Amphorae`` + instances. + . + The default behaviour is to let the charm create and maintain the flavor. diff --git a/src/layer.yaml b/src/layer.yaml index eac684ca..66bdacc7 100644 --- a/src/layer.yaml +++ b/src/layer.yaml @@ -9,6 +9,7 @@ options: basic: use_venv: True include_system_packages: True + packages: [ 'libffi-dev', 'libssl-dev' ] repo: https://github.com/openstack/charm-octavia config: deletes: diff --git a/src/lib/charm/openstack/api_crud.py b/src/lib/charm/openstack/api_crud.py new file mode 100644 index 00000000..761d6909 --- /dev/null +++ b/src/lib/charm/openstack/api_crud.py @@ -0,0 +1,91 @@ +# Copyright 2018 Canonical Ltd +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# NOTE(fnordahl) imported dependencies are included in the reactive charm +# ``wheelhouse.txt`` and are isolated from any system installed payload managed +# by the charm. +# +# An alternative could be to execute the openstack CLI to manage the resources, +# but at the time of this writing we can not due to it producing invalid JSON +# and YAML for the ``fixed_ips`` field when providing details for a Neutron +# port. + +from keystoneauth1 import identity as keystone_identity +from keystoneauth1 import session as keystone_session +from keystoneauth1 import exceptions as keystone_exceptions +from novaclient import client as nova_client + + +class APIUnavailable(Exception): + + def __init__(self, service_type, resource_type, upstream_exception): + self.service_type = service_type + self.resource_type = resource_type + self.upstream_exception = upstream_exception + + +def session_from_identity_service(identity_service): + """Get Keystone Session from `identity-service` relation. + + :param identity_service: reactive Endpoint + :type identity_service: RelationBase + :returns: Keystone session + :rtype: keystone_session.Session + """ + auth = keystone_identity.Password( + auth_url='{}://{}:{}/' + .format(identity_service.auth_protocol(), + identity_service.auth_host(), + identity_service.auth_port()), + user_domain_name=identity_service.service_domain(), + username=identity_service.service_username(), + password=identity_service.service_password(), + project_domain_name=identity_service.service_domain(), + project_name=identity_service.service_tenant(), + ) + return keystone_session.Session(auth=auth) + + +def get_nova_flavor(identity_service): + """Get or create private Nova flavor for use with Octavia. + + A side effect of calling this function is that Nova flavors are + created if they do not already exist. + + Handle exceptions ourself without Tenacity so we can detect Nova API + readiness. At present we do not have a relation or interface to inform us + about Nova API readiness. This function also executes just one or two API + calls. + + :param identity_service: reactive Endpoint of type ``identity-service`` + :type identity_service: RelationBase class + :returns: Nova Flavor Resource object + :rtype: novaclient.v2.flavors.Flavor + """ + try: + session = session_from_identity_service(identity_service) + nova = nova_client.Client('2', session=session) + flavors = nova.flavors.list(is_public=False) + for flavor in flavors: + if flavor.name == 'charm-octavia': + return flavor + + # create flavor + return nova.flavors.create('charm-octavia', 1024, 1, 8, + is_public=False) + except (keystone_exceptions.catalog.EndpointNotFound, + keystone_exceptions.connection.ConnectFailure, + nova_client.exceptions.ConnectionRefused, + nova_client.exceptions.ClientException) as e: + raise APIUnavailable('nova', 'flavors', e) diff --git a/src/lib/charm/openstack/octavia.py b/src/lib/charm/openstack/octavia.py index 50bcec3c..4f38d7a0 100644 --- a/src/lib/charm/openstack/octavia.py +++ b/src/lib/charm/openstack/octavia.py @@ -45,6 +45,11 @@ class OctaviaAdapters(charms_openstack.adapters.OpenStackAPIRelationAdapters): charm_intance=charm_instance) +@charms_openstack.adapters.config_property +def heartbeat_key(cls): + return leadership.leader_get('heartbeat-key') + + @charms_openstack.adapters.config_property def issuing_cacert(cls): """Get path to certificate provided in ``lb-mgmt-issuing-cacert`` option. @@ -130,6 +135,24 @@ def controller_cert(cls): config) +@charms_openstack.adapters.config_property +def amp_flavor_id(cls): + """Flavor to use when creating Amphorae instances. + + ID from charm managed flavor shared among all units through leader + storage. + + :param cls: charms_openstack.adapters.ConfigurationAdapter derived class + instance. Charm class instance is at cls.charm_instance. + :type: cls: charms_openstack.adapters.ConfiguartionAdapter + :returns: Nova flavor UUID. + :rtype: str + """ + return ( + ch_core.hookenv.config('custom-amp-flavor-id') or + leadership.leader_get('amp-flavor-id')) + + class OctaviaCharm(charms_openstack.charm.HAOpenStackCharm): """Charm class for the Octavia charm.""" # layer-openstack-api uses service_type as service name in endpoint catalog @@ -203,7 +226,3 @@ class OctaviaCharm(charms_openstack.charm.HAOpenStackCharm): ch_core.host.write_file(filename, base64.b64decode(encoded_data), group=self.group, perms=0o440) return filename - - @charms_openstack.adapters.config_property - def heartbeat_key(self): - return leadership.leader_get('heartbeat-key') diff --git a/src/reactive/octavia_handlers.py b/src/reactive/octavia_handlers.py index 13fb3807..57a86a86 100644 --- a/src/reactive/octavia_handlers.py +++ b/src/reactive/octavia_handlers.py @@ -20,7 +20,10 @@ import charms.leadership as leadership import charms_openstack.charm as charm import charms_openstack.ip as os_ip +import charmhelpers.core as ch_core + import charm.openstack.octavia as octavia # noqa +import charm.openstack.api_crud as api_crud charm.use_defaults( 'charm.installed', @@ -49,6 +52,24 @@ def setup_neutron_lbaas_proxy(): neutron.publish_load_balancer_info('octavia', octavia_url) +@reactive.when('leadership.is_leader') +@reactive.when('identity-service.available') +@reactive.when('config.default.custom-amp-flavor-id') +def get_nova_flavor(): + """Get or create private Nova flavor for use with Octavia.""" + identity_service = reactive.endpoint_from_flag( + 'identity-service.available') + try: + flavor = api_crud.get_nova_flavor(identity_service) + except api_crud.APIUnavailable as e: + ch_core.hookenv.log('Nova API not available yet, deferring ' + 'flavor discovery/creation. ("{}")' + .format(e), + level=ch_core.hookenv.DEBUG) + else: + leadership.leader_set({'amp-flavor-id': flavor.id}) + + @reactive.when('shared-db.available') @reactive.when('identity-service.available') @reactive.when('amqp.available') diff --git a/src/wheelhouse.txt b/src/wheelhouse.txt new file mode 100644 index 00000000..7198248d --- /dev/null +++ b/src/wheelhouse.txt @@ -0,0 +1,4 @@ +tenacity +keystoneauth1 +pbr +python-novaclient diff --git a/unit_tests/__init__.py b/unit_tests/__init__.py index 3616592d..84639073 100644 --- a/unit_tests/__init__.py +++ b/unit_tests/__init__.py @@ -25,3 +25,8 @@ import mock import charms charms.leadership = mock.MagicMock() sys.modules['charms.leadership'] = charms.leadership +keystoneauth1 = mock.MagicMock() +novaclient = mock.MagicMock() +sys.modules['charms.leadership'] = charms.leadership +sys.modules['keystoneauth1'] = keystoneauth1 +sys.modules['novaclient'] = novaclient diff --git a/unit_tests/test_lib_charm_openstack_api_crud.py b/unit_tests/test_lib_charm_openstack_api_crud.py new file mode 100644 index 00000000..cdbb88ca --- /dev/null +++ b/unit_tests/test_lib_charm_openstack_api_crud.py @@ -0,0 +1,76 @@ +# Copyright 2018 Canonical Ltd +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import mock + +import charms_openstack.test_utils as test_utils + +import charm.openstack.api_crud as api_crud + + +class TestAPICrud(test_utils.PatchHelper): + + def test_session_from_identity_service(self): + self.patch_object(api_crud, 'keystone_identity') + self.patch_object(api_crud, 'keystone_session') + identity_service = mock.MagicMock() + result = api_crud.session_from_identity_service(identity_service) + self.keystone_identity.Password.assert_called_once_with( + auth_url='{}://{}:{}/' + .format(identity_service.auth_protocol(), + identity_service.auth_host(), + identity_service.auth_port()), + user_domain_name=identity_service.service_domain(), + username=identity_service.service_username(), + password=identity_service.service_password(), + project_domain_name=identity_service.service_domain(), + project_name=identity_service.service_tenant(), + ) + self.keystone_session.Session.assert_called_once_with( + auth=self.keystone_identity.Password()) + self.assertEqual(result, self.keystone_session.Session()) + + def test_get_nova_flavor(self): + self.patch_object(api_crud, 'nova_client') + self.patch_object(api_crud, 'keystone_session') + self.patch_object(api_crud, 'keystone_identity') + self.patch_object(api_crud, 'keystone_exceptions') + nova = mock.MagicMock() + flavor = mock.MagicMock() + flavor.id = 'fake-id' + flavor.name = 'charm-octavia' + nova.flavors.list.return_value = [flavor] + self.nova_client.Client.return_value = nova + + self.keystone_exceptions.catalog.EndpointNotFound = Exception + self.keystone_exceptions.connection.ConnectFailure = Exception + self.nova_client.exceptions.ConnectionRefused = Exception + self.nova_client.exceptions.ClientException = Exception + nova.flavors.list.side_effect = Exception + identity_service = mock.MagicMock() + with self.assertRaises(api_crud.APIUnavailable): + api_crud.get_nova_flavor(identity_service) + + nova.flavors.list.side_effect = None + api_crud.get_nova_flavor(identity_service) + self.nova_client.Client.assert_called_with( + '2', + session=self.keystone_session.Session(auth=self.keystone_identity)) + nova.flavors.list.assert_called_with(is_public=False) + self.assertFalse(nova.flavors.create.called) + nova.flavors.list.return_value = [] + nova.flavors.create.return_value = flavor + api_crud.get_nova_flavor(identity_service) + nova.flavors.create.assert_called_with('charm-octavia', 1024, 1, 8, + is_public=False) diff --git a/unit_tests/test_lib_charm_openstack_octavia.py b/unit_tests/test_lib_charm_openstack_octavia.py index dcd6e84c..afa99380 100644 --- a/unit_tests/test_lib_charm_openstack_octavia.py +++ b/unit_tests/test_lib_charm_openstack_octavia.py @@ -15,6 +15,8 @@ from __future__ import absolute_import from __future__ import print_function +import mock + import charms_openstack.test_utils as test_utils import charm.openstack.octavia as octavia @@ -27,6 +29,30 @@ class Helper(test_utils.PatchHelper): self.patch_release(octavia.OctaviaCharm.release) +class TestOctaviaCharmConfigProperties(Helper): + + def test_heartbeat_key(self): + cls = mock.MagicMock() + self.patch('charms.leadership.leader_get', 'leader_get') + self.leader_get.return_value = None + self.assertEqual(octavia.heartbeat_key(cls), None) + self.leader_get.return_value = 'FAKE-STORED-UUID-STRING' + self.assertEqual(octavia.heartbeat_key(cls), 'FAKE-STORED-UUID-STRING') + self.leader_get.assert_called_with('heartbeat-key') + + def test_amp_flavor_id(self): + cls = mock.MagicMock() + self.patch('charmhelpers.core.hookenv.config', 'config') + self.patch('charms.leadership.leader_get', 'leader_get') + self.config.return_value = 'something' + octavia.amp_flavor_id(cls) + self.config.assert_called_with('custom-amp-flavor-id') + self.assertFalse(self.leader_get.called) + self.config.return_value = None + octavia.amp_flavor_id(cls) + self.leader_get.assert_called_with('amp-flavor-id') + + class TestOctaviaCharm(Helper): def test_get_amqp_credentials(self): @@ -55,12 +81,3 @@ class TestOctaviaCharm(Helper): self.sp_check_call.assert_called_with(['a2ensite', 'octavia-api']) self.service_reload.assert_called_with( 'apache2', restart_on_failure=True) - - def test_heartbeat_key(self): - self.patch('charms.leadership.leader_get', 'leader_get') - self.leader_get.return_value = None - c = octavia.OctaviaCharm() - self.assertEqual(c.heartbeat_key(), None) - self.leader_get.return_value = 'FAKE-STORED-UUID-STRING' - self.assertEqual(c.heartbeat_key(), 'FAKE-STORED-UUID-STRING') - self.leader_get.assert_called_with('heartbeat-key') diff --git a/unit_tests/test_octavia_handlers.py b/unit_tests/test_octavia_handlers.py index a67d298c..62b4022f 100644 --- a/unit_tests/test_octavia_handlers.py +++ b/unit_tests/test_octavia_handlers.py @@ -44,6 +44,10 @@ class TestRegisteredHooks(test_utils.TestRegisteredHooks): 'generate_heartbeat_key': ('leadership.is_leader',), 'setup_neutron_lbaas_proxy': ( 'neutron-load-balancer.available',), + 'get_nova_flavor': ( + 'leadership.is_leader', + 'identity-service.available', + 'config.default.custom-amp-flavor-id',), }, 'when_not': { 'init_db': ('db.synced',), @@ -94,6 +98,24 @@ class TestOctaviaHandlers(test_utils.PatchHelper): endpoint.publish_load_balancer_info.assert_called_with( 'octavia', 'http://1.2.3.4:1234') + def test_get_nova_flavor(self): + self.patch('charms.reactive.endpoint_from_flag', 'endpoint_from_flag') + self.patch_object(handlers.api_crud, 'get_nova_flavor', + name='api_crud_get_nova_flavor') + self.patch('charms.leadership.leader_set', 'leader_set') + flavor = mock.MagicMock() + flavor.id = 'fake-id' + self.api_crud_get_nova_flavor.side_effect = \ + handlers.api_crud.APIUnavailable('nova', 'flavors', Exception) + handlers.get_nova_flavor() + self.assertFalse(self.leader_set.called) + self.api_crud_get_nova_flavor.side_effect = None + self.api_crud_get_nova_flavor.return_value = flavor + handlers.get_nova_flavor() + self.api_crud_get_nova_flavor.assert_called_with( + self.endpoint_from_flag()) + self.leader_set.assert_called_with({'amp-flavor-id': 'fake-id'}) + def test_render(self): self.patch('charms.reactive.set_state', 'set_state') handlers.render('arg1', 'arg2')