From 6a3a39a7670c47bbbeb187f389b79ebe02ccc421 Mon Sep 17 00:00:00 2001 From: rabi Date: Tue, 19 Dec 2017 14:24:30 +0530 Subject: [PATCH] Add octavia Listener Resource Change-Id: I55e7682f6d2cb7f42c683ee4a6abb7c2106a3d86 Partial-Bug: #1737567 --- .../resources/openstack/octavia/listener.py | 204 ++++++++++++++++++ .../openstack/octavia/inline_templates.py | 22 ++ heat/tests/openstack/octavia/test_listener.py | 187 ++++++++++++++++ 3 files changed, 413 insertions(+) create mode 100644 heat/engine/resources/openstack/octavia/listener.py create mode 100644 heat/tests/openstack/octavia/test_listener.py diff --git a/heat/engine/resources/openstack/octavia/listener.py b/heat/engine/resources/openstack/octavia/listener.py new file mode 100644 index 0000000000..e46e3b2da0 --- /dev/null +++ b/heat/engine/resources/openstack/octavia/listener.py @@ -0,0 +1,204 @@ +# +# 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. + +from heat.common import exception +from heat.common.i18n import _ +from heat.engine import attributes +from heat.engine import constraints +from heat.engine import properties +from heat.engine.resources.openstack.octavia import octavia_base +from heat.engine import translation + + +class Listener(octavia_base.OctaviaBase): + """A resource for managing octavia Listeners. + + This resource creates and manages Neutron octavia Listeners, + which represent a listening endpoint for the vip. + """ + + PROPERTIES = ( + PROTOCOL_PORT, PROTOCOL, LOADBALANCER, DEFAULT_POOL, NAME, + ADMIN_STATE_UP, DESCRIPTION, DEFAULT_TLS_CONTAINER_REF, + SNI_CONTAINER_REFS, CONNECTION_LIMIT, TENANT_ID + ) = ( + 'protocol_port', 'protocol', 'loadbalancer', 'default_pool', 'name', + 'admin_state_up', 'description', 'default_tls_container_ref', + 'sni_container_refs', 'connection_limit', 'tenant_id' + ) + + SUPPORTED_PROTOCOLS = (TCP, HTTP, HTTPS, TERMINATED_HTTPS, PROXY) = ( + 'TCP', 'HTTP', 'HTTPS', 'TERMINATED_HTTPS', 'PROXY') + + ATTRIBUTES = ( + LOADBALANCERS_ATTR, DEFAULT_POOL_ID_ATTR + ) = ( + 'loadbalancers', 'default_pool_id' + ) + + properties_schema = { + PROTOCOL_PORT: properties.Schema( + properties.Schema.INTEGER, + _('TCP or UDP port on which to listen for client traffic.'), + required=True, + constraints=[ + constraints.Range(1, 65535), + ] + ), + PROTOCOL: properties.Schema( + properties.Schema.STRING, + _('Protocol on which to listen for the client traffic.'), + required=True, + constraints=[ + constraints.AllowedValues(SUPPORTED_PROTOCOLS), + ] + ), + LOADBALANCER: properties.Schema( + properties.Schema.STRING, + _('ID or name of the load balancer with which listener ' + 'is associated.'), + constraints=[ + constraints.CustomConstraint('octavia.loadbalancer') + ] + ), + DEFAULT_POOL: properties.Schema( + properties.Schema.STRING, + _('ID or name of the default pool for the listener. Requires ' + 'shared_pools service extension.'), + update_allowed=True, + constraints=[ + constraints.CustomConstraint('octavia.pool') + ], + ), + NAME: properties.Schema( + properties.Schema.STRING, + _('Name of this listener.'), + update_allowed=True + ), + ADMIN_STATE_UP: properties.Schema( + properties.Schema.BOOLEAN, + _('The administrative state of this listener.'), + update_allowed=True, + default=True + ), + DESCRIPTION: properties.Schema( + properties.Schema.STRING, + _('Description of this listener.'), + update_allowed=True, + default='' + ), + DEFAULT_TLS_CONTAINER_REF: properties.Schema( + properties.Schema.STRING, + _('Default TLS container reference to retrieve TLS ' + 'information.'), + update_allowed=True + ), + SNI_CONTAINER_REFS: properties.Schema( + properties.Schema.LIST, + _('List of TLS container references for SNI.'), + update_allowed=True + ), + CONNECTION_LIMIT: properties.Schema( + properties.Schema.INTEGER, + _('The maximum number of connections permitted for this ' + 'load balancer. Defaults to -1, which is infinite.'), + update_allowed=True, + default=-1, + constraints=[ + constraints.Range(min=-1), + ] + ), + TENANT_ID: properties.Schema( + properties.Schema.STRING, + _('The ID of the tenant who owns the listener.') + ), + } + + attributes_schema = { + LOADBALANCERS_ATTR: attributes.Schema( + _('ID of the load balancer this listener is associated to.'), + type=attributes.Schema.LIST + ), + DEFAULT_POOL_ID_ATTR: attributes.Schema( + _('ID of the default pool this listener is associated to.'), + type=attributes.Schema.STRING + ) + } + + def translation_rules(self, props): + return [ + translation.TranslationRule( + props, + translation.TranslationRule.RESOLVE, + [self.LOADBALANCER], + client_plugin=self.client_plugin(), + finder='get_loadbalancer', + ), + translation.TranslationRule( + props, + translation.TranslationRule.RESOLVE, + [self.DEFAULT_POOL], + client_plugin=self.client_plugin(), + finder='get_pool' + ), + ] + + def _prepare_args(self, properties): + props = dict((k, v) for k, v in properties.items() if v is not None) + if self.NAME not in props: + props[self.NAME] = self.physical_resource_name() + if self.LOADBALANCER in props: + props['loadbalancer_id'] = props.pop(self.LOADBALANCER) + if self.DEFAULT_POOL in props: + props['default_pool_id'] = props.pop(self.DEFAULT_POOL) + return props + + def validate(self): + super(Listener, self).validate() + if (self.properties[self.LOADBALANCER] is None + and self.properties[self.DEFAULT_POOL] is None): + raise exception.PropertyUnspecifiedError(self.LOADBALANCER, + self.DEFAULT_POOL) + + if self.properties[self.PROTOCOL] == self.TERMINATED_HTTPS: + if self.properties[self.DEFAULT_TLS_CONTAINER_REF] is None: + msg = (_('Property %(ref)s required when protocol is ' + '%(term)s.') % {'ref': self.DEFAULT_TLS_CONTAINER_REF, + 'term': self.TERMINATED_HTTPS}) + raise exception.StackValidationFailed(message=msg) + + def _resource_create(self, properties): + return self.client().listener_create( + json={'listener': properties})['listener'] + + def handle_update(self, json_snippet, tmpl_diff, prop_diff): + self._update_called = False + if self.DEFAULT_POOL in prop_diff: + prop_diff['default_pool_id'] = prop_diff.pop(self.DEFAULT_POOL) + return prop_diff + + def _resource_update(self, prop_diff): + self.client().listener_set(self.resource_id, + json={'listener': prop_diff}) + + def _resource_delete(self): + self.client().listener_delete(self.resource_id) + + def _show_resource(self): + return self.client().listener_show(self.resource_id) + + +def resource_mapping(): + return { + 'OS::Octavia::Listener': Listener, + } diff --git a/heat/tests/openstack/octavia/inline_templates.py b/heat/tests/openstack/octavia/inline_templates.py index 60c068282d..e1cb170dfb 100644 --- a/heat/tests/openstack/octavia/inline_templates.py +++ b/heat/tests/openstack/octavia/inline_templates.py @@ -11,6 +11,28 @@ # License for the specific language governing permissions and limitations # under the License. +LISTENER_TEMPLATE = ''' +heat_template_version: 2016-04-08 +description: Create a listener +resources: + listener: + type: OS::Octavia::Listener + properties: + protocol_port: 80 + protocol: TCP + loadbalancer: 123 + default_pool: my_pool + name: my_listener + description: my listener + admin_state_up: True + default_tls_container_ref: ref + sni_container_refs: + - ref1 + - ref2 + connection_limit: -1 + tenant_id: 1234 +''' + POOL_TEMPLATE = ''' heat_template_version: 2016-04-08 description: Create a pool diff --git a/heat/tests/openstack/octavia/test_listener.py b/heat/tests/openstack/octavia/test_listener.py new file mode 100644 index 0000000000..e4ddc85c76 --- /dev/null +++ b/heat/tests/openstack/octavia/test_listener.py @@ -0,0 +1,187 @@ +# +# 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 yaml + +from osc_lib import exceptions + +from heat.common import exception +from heat.common import template_format +from heat.engine.resources.openstack.octavia import listener +from heat.tests import common +from heat.tests.openstack.octavia import inline_templates +from heat.tests import utils + + +class ListenerTest(common.HeatTestCase): + + def test_resource_mapping(self): + mapping = listener.resource_mapping() + self.assertEqual(listener.Listener, + mapping['OS::Octavia::Listener']) + + def _create_stack(self, tmpl=inline_templates.LISTENER_TEMPLATE): + self.t = template_format.parse(tmpl) + self.stack = utils.parse_stack(self.t) + self.listener = self.stack['listener'] + + self.octavia_client = mock.MagicMock() + self.listener.client = mock.MagicMock(return_value=self.octavia_client) + self.listener.client_plugin().client = mock.MagicMock( + return_value=self.octavia_client) + + def test_validate_terminated_https(self): + tmpl = yaml.safe_load(inline_templates.LISTENER_TEMPLATE) + props = tmpl['resources']['listener']['properties'] + props['protocol'] = 'TERMINATED_HTTPS' + del props['default_tls_container_ref'] + self._create_stack(tmpl=yaml.safe_dump(tmpl)) + + self.assertRaises(exception.StackValidationFailed, + self.listener.validate) + + def test_create(self): + self._create_stack() + self.octavia_client.listener_show.side_effect = [ + {'provisioning_status': 'PENDING_CREATE'}, + {'provisioning_status': 'PENDING_CREATE'}, + {'provisioning_status': 'ACTIVE'}, + ] + self.octavia_client.listener_create.side_effect = [ + exceptions.Conflict(409), {'listener': {'id': '1234'}} + ] + expected = { + 'listener': { + 'protocol_port': 80, + 'protocol': 'TCP', + 'loadbalancer_id': '123', + 'default_pool_id': 'my_pool', + 'name': 'my_listener', + 'description': 'my listener', + 'admin_state_up': True, + 'default_tls_container_ref': 'ref', + 'sni_container_refs': ['ref1', 'ref2'], + 'connection_limit': -1, + 'tenant_id': '1234', + } + } + + props = self.listener.handle_create() + + self.assertFalse(self.listener.check_create_complete(props)) + self.octavia_client.listener_create.assert_called_with(json=expected) + self.assertFalse(self.listener.check_create_complete(props)) + self.octavia_client.listener_create.assert_called_with(json=expected) + self.assertFalse(self.listener.check_create_complete(props)) + self.assertTrue(self.listener.check_create_complete(props)) + + def test_create_missing_properties(self): + for prop in ('protocol', 'protocol_port', 'loadbalancer'): + tmpl = yaml.safe_load(inline_templates.LISTENER_TEMPLATE) + del tmpl['resources']['listener']['properties'][prop] + del tmpl['resources']['listener']['properties']['default_pool'] + self._create_stack(tmpl=yaml.safe_dump(tmpl)) + if prop == 'loadbalancer': + self.assertRaises(exception.PropertyUnspecifiedError, + self.listener.validate) + else: + self.assertRaises(exception.StackValidationFailed, + self.listener.validate) + + def test_show_resource(self): + self._create_stack() + self.listener.resource_id_set('1234') + self.octavia_client.listener_show.return_value = {'id': '1234'} + self.assertEqual({'id': '1234'}, self.listener._show_resource()) + + self.octavia_client.listener_show.assert_called_with('1234') + + def test_update(self): + self._create_stack() + self.listener.resource_id_set('1234') + self.octavia_client.listener_show.side_effect = [ + {'provisioning_status': 'PENDING_UPDATE'}, + {'provisioning_status': 'PENDING_UPDATE'}, + {'provisioning_status': 'ACTIVE'}, + ] + self.octavia_client.listener_set.side_effect = [ + exceptions.Conflict(409), None] + prop_diff = { + 'admin_state_up': False, + 'name': 'your_listener', + } + + prop_diff = self.listener.handle_update(self.listener.t, + None, prop_diff) + + self.assertFalse(self.listener.check_update_complete(prop_diff)) + self.assertFalse(self.listener._update_called) + self.octavia_client.listener_set.assert_called_with( + '1234', json={'listener': prop_diff}) + self.assertFalse(self.listener.check_update_complete(prop_diff)) + self.assertTrue(self.listener._update_called) + self.octavia_client.listener_set.assert_called_with( + '1234', json={'listener': prop_diff}) + self.assertFalse(self.listener.check_update_complete(prop_diff)) + self.assertTrue(self.listener.check_update_complete(prop_diff)) + + def test_delete(self): + self._create_stack() + self.listener.resource_id_set('1234') + self.octavia_client.listener_show.side_effect = [ + {'provisioning_status': 'PENDING_DELETE'}, + {'provisioning_status': 'PENDING_DELETE'}, + {'provisioning_status': 'DELETED'}, + ] + self.octavia_client.listener_delete.side_effect = [ + exceptions.Conflict(409), None] + + self.listener.handle_delete() + + self.assertFalse(self.listener.check_delete_complete(None)) + self.assertFalse(self.listener._delete_called) + self.octavia_client.listener_delete.assert_called_with('1234') + self.assertFalse(self.listener.check_delete_complete(None)) + self.assertTrue(self.listener._delete_called) + self.octavia_client.listener_delete.assert_called_with('1234') + self.assertTrue(self.listener.check_delete_complete(None)) + + def test_delete_not_found(self): + self._create_stack() + self.listener.resource_id_set('1234') + self.octavia_client.listener_show.side_effect = [ + {'provisioning_status': 'PENDING_DELETE'}, + ] + self.octavia_client.listener_delete.side_effect = [ + exceptions.Conflict(409), + exceptions.NotFound(404)] + + self.listener.handle_delete() + + self.assertFalse(self.listener.check_delete_complete(None)) + self.assertFalse(self.listener._delete_called) + self.octavia_client.listener_delete.assert_called_with('1234') + self.assertTrue(self.listener.check_delete_complete(None)) + self.octavia_client.listener_delete.assert_called_with('1234') + + def test_delete_failed(self): + self._create_stack() + self.listener.resource_id_set('1234') + self.octavia_client.listener_delete.side_effect = ( + exceptions.Unauthorized(401)) + + self.listener.handle_delete() + self.assertRaises(exceptions.Unauthorized, + self.listener.check_delete_complete, None)