diff --git a/heat/engine/resources/openstack/octavia/__init__.py b/heat/engine/resources/openstack/octavia/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/heat/engine/resources/openstack/octavia/octavia_base.py b/heat/engine/resources/openstack/octavia/octavia_base.py new file mode 100644 index 0000000000..7cb1104115 --- /dev/null +++ b/heat/engine/resources/openstack/octavia/octavia_base.py @@ -0,0 +1,95 @@ +# +# 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.engine import resource +from heat.engine import support + + +class OctaviaBase(resource.Resource): + + default_client_name = 'octavia' + + support_status = support.SupportStatus(version='10.0.0') + + def _check_status(self, expected_status='ACTIVE'): + res = self._show_resource() + status = res['provisioning_status'] + if status == 'ERROR': + raise exception.ResourceInError(resource_status=status) + return status == expected_status + + def _check_deleted(self): + with self.client_plugin().ignore_not_found: + return self._check_status('DELETED') + return True + + def _resolve_attribute(self, name): + if self.resource_id is None: + return + attributes = self._show_resource() + return attributes[name] + + def handle_create(self): + return self._prepare_args(self.properties) + + def check_create_complete(self, properties): + if self.resource_id is None: + try: + res = self._resource_create(properties) + self.resource_id_set(res['id']) + except Exception as ex: + if self.client_plugin().is_conflict(ex): + return False + raise + + return self._check_status() + + def handle_update(self, json_snippet, tmpl_diff, prop_diff): + self._update_called = False + return prop_diff + + def check_update_complete(self, prop_diff): + if not prop_diff: + return True + + if not self._update_called: + try: + self._resource_update(prop_diff) + self._update_called = True + except Exception as ex: + if self.client_plugin().is_conflict(ex): + return False + raise + + return self._check_status() + + def handle_delete(self): + self._delete_called = False + + def check_delete_complete(self, data): + if self.resource_id is None: + return True + + if not self._delete_called: + try: + self._resource_delete() + self._delete_called = True + except Exception as ex: + if self.client_plugin().is_conflict(ex): + return self._check_status('DELETED') + elif self.client_plugin().is_not_found(ex): + return True + raise + + return self._check_deleted() diff --git a/heat/engine/resources/openstack/octavia/pool_member.py b/heat/engine/resources/openstack/octavia/pool_member.py new file mode 100644 index 0000000000..e4d29e8e0d --- /dev/null +++ b/heat/engine/resources/openstack/octavia/pool_member.py @@ -0,0 +1,153 @@ +# +# 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.i18n import _ +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 PoolMember(octavia_base.OctaviaBase): + """A resource for managing Octavia Pool Members. + + A pool member represents a single backend node. + """ + + PROPERTIES = ( + POOL, ADDRESS, PROTOCOL_PORT, MONITOR_ADDRESS, MONITOR_PORT, + WEIGHT, ADMIN_STATE_UP, SUBNET, + ) = ( + 'pool', 'address', 'protocol_port', 'monitor_address', 'monitor_port', + 'weight', 'admin_state_up', 'subnet' + ) + + ATTRIBUTES = ( + ADDRESS_ATTR, POOL_ID_ATTR + ) = ( + 'address', 'pool_id' + ) + + properties_schema = { + POOL: properties.Schema( + properties.Schema.STRING, + _('Name or ID of the load balancing pool.'), + required=True, + constraints=[ + constraints.CustomConstraint('octavia.pool') + ] + ), + ADDRESS: properties.Schema( + properties.Schema.STRING, + _('IP address of the pool member on the pool network.'), + required=True, + constraints=[ + constraints.CustomConstraint('ip_addr') + ] + ), + PROTOCOL_PORT: properties.Schema( + properties.Schema.INTEGER, + _('Port on which the pool member listens for requests or ' + 'connections.'), + required=True, + constraints=[ + constraints.Range(1, 65535), + ] + ), + MONITOR_ADDRESS: properties.Schema( + properties.Schema.STRING, + _('Alternate IP address which health monitor can use for ' + 'health check.'), + constraints=[ + constraints.CustomConstraint('ip_addr') + ] + ), + MONITOR_PORT: properties.Schema( + properties.Schema.INTEGER, + _('Alternate Port which health monitor can use for health check.'), + constraints=[ + constraints.Range(1, 65535), + ] + ), + WEIGHT: properties.Schema( + properties.Schema.INTEGER, + _('Weight of pool member in the pool (default to 1).'), + default=1, + constraints=[ + constraints.Range(0, 256), + ], + update_allowed=True + ), + ADMIN_STATE_UP: properties.Schema( + properties.Schema.BOOLEAN, + _('The administrative state of the pool member.'), + default=True, + update_allowed=True + ), + SUBNET: properties.Schema( + properties.Schema.STRING, + _('Subnet name or ID of this member.'), + constraints=[ + constraints.CustomConstraint('neutron.subnet') + ], + ), + } + + def translation_rules(self, props): + return [ + translation.TranslationRule( + props, + translation.TranslationRule.RESOLVE, + [self.SUBNET], + client_plugin=self.client_plugin('neutron'), + finder='find_resourceid_by_name_or_id', + entity='subnet' + ), + translation.TranslationRule( + props, + translation.TranslationRule.RESOLVE, + [self.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) + props.pop(self.POOL) + if self.SUBNET in props: + props['subnet_id'] = props.pop(self.SUBNET) + return props + + def _resource_create(self, properties): + return self.client().member_create( + self.properties[self.POOL], json={'member': properties})['member'] + + def _resource_update(self, prop_diff): + self.client().member_set(self.properties[self.POOL], + self.resource_id, + json={'member': prop_diff}) + + def _resource_delete(self): + self.client().member_delete(self.properties[self.POOL], + self.resource_id) + + def _show_resource(self): + return self.client().member_show( + self.properties[self.POOL], self.resource_id) + + +def resource_mapping(): + return { + 'OS::Octavia::PoolMember': PoolMember, + } diff --git a/heat/tests/openstack/octavia/__init__.py b/heat/tests/openstack/octavia/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/heat/tests/openstack/octavia/inline_templates.py b/heat/tests/openstack/octavia/inline_templates.py new file mode 100644 index 0000000000..1990b2f480 --- /dev/null +++ b/heat/tests/openstack/octavia/inline_templates.py @@ -0,0 +1,27 @@ +# +# 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. + +MEMBER_TEMPLATE = ''' +heat_template_version: 2016-04-08 +description: Create a pool member +resources: + member: + type: OS::Octavia::PoolMember + properties: + pool: 123 + address: 1.2.3.4 + protocol_port: 80 + weight: 1 + subnet: sub123 + admin_state_up: True +''' diff --git a/heat/tests/openstack/octavia/test_pool_member.py b/heat/tests/openstack/octavia/test_pool_member.py new file mode 100644 index 0000000000..93035c18d7 --- /dev/null +++ b/heat/tests/openstack/octavia/test_pool_member.py @@ -0,0 +1,167 @@ +# +# 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 + +from neutronclient.neutron import v2_0 as neutronV20 +from osc_lib import exceptions + +from heat.common import template_format +from heat.engine.resources.openstack.octavia import pool_member +from heat.tests import common +from heat.tests.openstack.octavia import inline_templates +from heat.tests import utils + + +class PoolMemberTest(common.HeatTestCase): + + def test_resource_mapping(self): + mapping = pool_member.resource_mapping() + self.assertEqual(pool_member.PoolMember, + mapping['OS::Octavia::PoolMember']) + + def _create_stack(self, tmpl=inline_templates.MEMBER_TEMPLATE): + self.t = template_format.parse(tmpl) + self.stack = utils.parse_stack(self.t) + self.member = self.stack['member'] + self.patchobject(neutronV20, 'find_resourceid_by_name_or_id', + return_value='123') + + self.octavia_client = mock.MagicMock() + self.member.client = mock.MagicMock(return_value=self.octavia_client) + self.member.client_plugin().get_pool = ( + mock.MagicMock(return_value='123')) + self.member.client_plugin().client = mock.MagicMock( + return_value=self.octavia_client) + self.member.translate_properties(self.member.properties) + + def test_create(self): + self._create_stack() + self.octavia_client.member_show.side_effect = [ + {'provisioning_status': 'PENDING_CREATE'}, + {'provisioning_status': 'PENDING_CREATE'}, + {'provisioning_status': 'ACTIVE'}, + ] + self.octavia_client.member_create.side_effect = [ + exceptions.Conflict(409), {'member': {'id': '1234'}}] + expected = { + 'member': { + 'address': '1.2.3.4', + 'protocol_port': 80, + 'weight': 1, + 'subnet_id': '123', + 'admin_state_up': True, + } + } + props = self.member.handle_create() + self.assertFalse(self.member.check_create_complete(props)) + self.octavia_client.member_create.assert_called_with('123', + json=expected) + self.assertFalse(self.member.check_create_complete(props)) + self.octavia_client.member_create.assert_called_with('123', + json=expected) + self.assertFalse(self.member.check_create_complete(props)) + self.assertTrue(self.member.check_create_complete(props)) + + def test_show_resource(self): + self._create_stack() + self.member.resource_id_set('1234') + self.octavia_client.member_show.return_value = {'id': '1234'} + + self.assertEqual(self.member._show_resource(), {'id': '1234'}) + + self.octavia_client.member_show.assert_called_with('123', '1234') + + def test_update(self): + self._create_stack() + self.member.resource_id_set('1234') + self.octavia_client.member_show.side_effect = [ + {'provisioning_status': 'PENDING_UPDATE'}, + {'provisioning_status': 'PENDING_UPDATE'}, + {'provisioning_status': 'ACTIVE'}, + ] + self.octavia_client.member_set.side_effect = [ + exceptions.Conflict(409), None] + prop_diff = { + 'admin_state_up': False, + 'weight': 2, + } + + prop_diff = self.member.handle_update(None, None, prop_diff) + + self.assertFalse(self.member.check_update_complete(prop_diff)) + self.assertFalse(self.member._update_called) + self.octavia_client.member_set.assert_called_with( + '123', '1234', json={'member': prop_diff}) + self.assertFalse(self.member.check_update_complete(prop_diff)) + self.assertTrue(self.member._update_called) + self.octavia_client.member_set.assert_called_with( + '123', '1234', json={'member': prop_diff}) + self.assertFalse(self.member.check_update_complete(prop_diff)) + self.assertTrue(self.member.check_update_complete(prop_diff)) + + def test_delete(self): + self._create_stack() + self.member.resource_id_set('1234') + self.octavia_client.member_show.side_effect = [ + {'provisioning_status': 'PENDING_DELETE'}, + {'provisioning_status': 'PENDING_DELETE'}, + {'provisioning_status': 'DELETED'}, + ] + self.octavia_client.member_delete.side_effect = [ + exceptions.Conflict(409), + None] + + self.member.handle_delete() + + self.assertFalse(self.member.check_delete_complete(None)) + self.assertFalse(self.member._delete_called) + self.octavia_client.member_delete.assert_called_with('123', + '1234') + self.assertFalse(self.member.check_delete_complete(None)) + self.octavia_client.member_delete.assert_called_with('123', + '1234') + self.assertTrue(self.member._delete_called) + self.assertTrue(self.member.check_delete_complete(None)) + + def test_delete_not_found(self): + self._create_stack() + self.member.resource_id_set('1234') + self.octavia_client.member_show.side_effect = [ + {'provisioning_status': 'PENDING_DELETE'}, + ] + self.octavia_client.member_delete.side_effect = [ + exceptions.Conflict(409), + exceptions.NotFound(404)] + + self.member.handle_delete() + + self.assertFalse(self.member.check_delete_complete(None)) + self.assertFalse(self.member._delete_called) + self.octavia_client.member_delete.assert_called_with('123', + '1234') + self.assertTrue(self.member.check_delete_complete(None)) + self.octavia_client.member_delete.assert_called_with('123', + '1234') + self.assertFalse(self.member._delete_called) + + def test_delete_failed(self): + self._create_stack() + self.member.resource_id_set('1234') + self.octavia_client.member_delete.side_effect = ( + exceptions.Unauthorized(401)) + + self.member.handle_delete() + + self.assertRaises(exceptions.Unauthorized, + self.member.check_delete_complete, None)