Add Nova flavor management

Manage the Nova flavor Octavia should use when launching its instances.

The end user may choose to override this by manually creating the
flavor and configure it through the ``custom-amp-flavor-id``
configuration option.

Change-Id: Id2cbbc2936996c689fb5a2221a9d22ecf93f510e
This commit is contained in:
Frode Nordahl 2018-11-15 14:45:34 +01:00
parent 4d834c4d2d
commit 3f1480e8c9
10 changed files with 277 additions and 13 deletions

View File

@ -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.

View File

@ -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:

View File

@ -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)

View File

@ -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')

View File

@ -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')

4
src/wheelhouse.txt Normal file
View File

@ -0,0 +1,4 @@
tenacity
keystoneauth1
pbr
python-novaclient

View File

@ -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

View File

@ -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)

View File

@ -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')

View File

@ -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')