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:
parent
4d834c4d2d
commit
3f1480e8c9
|
@ -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.
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
|
@ -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')
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
tenacity
|
||||
keystoneauth1
|
||||
pbr
|
||||
python-novaclient
|
|
@ -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
|
||||
|
|
|
@ -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)
|
|
@ -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')
|
||||
|
|
|
@ -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')
|
||||
|
|
Loading…
Reference in New Issue