diff --git a/heat/engine/clients/os/neutron/__init__.py b/heat/engine/clients/os/neutron/__init__.py index 93226891b2..f5823e79e9 100644 --- a/heat/engine/clients/os/neutron/__init__.py +++ b/heat/engine/clients/os/neutron/__init__.py @@ -17,6 +17,7 @@ from neutronclient.v2_0 import client as nc from oslo_utils import uuidutils from heat.common import exception +from heat.common.i18n import _ from heat.engine.clients import client_plugin from heat.engine.clients import os as os_client @@ -155,17 +156,20 @@ class NeutronClientPlugin(client_plugin.ClientPlugin): raise exception.PhysicalResourceNameAmbiguity(name=sg) return seclist - def _resove_resource_path(self, resource): + def _resolve_resource_path(self, resource): """Returns sfc resource path.""" if resource == 'port_pair': - RESOURCE_PATH = "/sfc/port_pairs" - return RESOURCE_PATH + path = "/sfc/port_pairs" + elif resource == 'port_pair_group': + path = "/sfc/port_pair_groups" + + return path def create_sfc_resource(self, resource, props): """Returns created sfc resource record.""" - path = self._resove_resource_path(resource) + path = self._resolve_resource_path(resource) record = self.client().create_ext(path, {resource: props} ).get(resource) return record @@ -173,19 +177,48 @@ class NeutronClientPlugin(client_plugin.ClientPlugin): def update_sfc_resource(self, resource, prop_diff, resource_id): """Returns updated sfc resource record.""" - path = self._resove_resource_path(resource) - return self.client().update_ext(path + '/%s', self.resource_id, + path = self._resolve_resource_path(resource) + return self.client().update_ext(path + '/%s', resource_id, {resource: prop_diff}) def delete_sfc_resource(self, resource, resource_id): - """deletes sfc resource record and returns status""" + """Deletes sfc resource record and returns status.""" - path = self._resove_resource_path(resource) - return self.client().delete_ext(path + '/%s', self.resource_id) + path = self._resolve_resource_path(resource) + return self.client().delete_ext(path + '/%s', resource_id) def show_sfc_resource(self, resource, resource_id): - """returns specific sfc resource record""" + """Returns specific sfc resource record.""" - path = self._resove_resource_path(resource) - return self.client().show_ext(path + '/%s', self.resource_id + path = self._resolve_resource_path(resource) + return self.client().show_ext(path + '/%s', resource_id ).get(resource) + + def resolve_ext_resource(self, resource, name_or_id): + """Returns the id and validate neutron ext resource.""" + + path = self._resolve_resource_path(resource) + + try: + record = self.client().show_ext(path + '/%s', name_or_id) + return record.get(resource).get('id') + except exceptions.NotFound: + res_plural = resource + 's' + result = self.client().list_ext(collection=res_plural, + path=path, retrieve_all=True) + resources = result.get(res_plural) + matched = [] + for res in resources: + if res.get('name') == name_or_id: + matched.append(res.get('id')) + if len(matched) > 1: + raise exceptions.NeutronClientNoUniqueMatch(resource=resource, + name=name_or_id) + elif len(matched) == 0: + not_found_message = (_("Unable to find %(resource)s with name " + "or id '%(name_or_id)s'") % + {'resource': resource, + 'name_or_id': name_or_id}) + raise exceptions.NotFound(message=not_found_message) + else: + return matched[0] diff --git a/heat/engine/clients/os/neutron/neutron_constraints.py b/heat/engine/clients/os/neutron/neutron_constraints.py index 44f051e886..6b0a7e9ae4 100644 --- a/heat/engine/clients/os/neutron/neutron_constraints.py +++ b/heat/engine/clients/os/neutron/neutron_constraints.py @@ -59,6 +59,17 @@ class NeutronConstraint(constraints.BaseCustomConstraint): self.resource_name, value, cmd_resource=self.cmd_resource) +class NeutronExtConstraint(NeutronConstraint): + + def validate_with_client(self, client, value): + neutron_plugin = client.client_plugin(CLIENT_NAME) + if (self.extension and + not neutron_plugin.has_extension(self.extension)): + raise exception.EntityNotFound(entity='neutron extension', + name=self.extension) + neutron_plugin.resolve_ext_resource(self.resource_name, value) + + class PortConstraint(NeutronConstraint): resource_name = 'port' @@ -90,6 +101,11 @@ class QoSPolicyConstraint(NeutronConstraint): extension = 'qos' +class PortPairConstraint(NeutronExtConstraint): + resource_name = 'port_pair' + extension = 'sfc' + + class ProviderConstraint(constraints.BaseCustomConstraint): expected_exceptions = (exception.StackValidationFailed,) diff --git a/heat/engine/resources/openstack/neutron/sfc/port_pair_group.py b/heat/engine/resources/openstack/neutron/sfc/port_pair_group.py new file mode 100644 index 0000000000..4738bfdbae --- /dev/null +++ b/heat/engine/resources/openstack/neutron/sfc/port_pair_group.py @@ -0,0 +1,112 @@ +# +# 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.neutron import neutron +from heat.engine import support +from heat.engine import translation + + +class PortPairGroup(neutron.NeutronResource): + """Heat Template Resource for networking-sfc port-pair-group. + + Multiple port-pairs may be included in a port-pair-group to allow the + specification of a set of functionally equivalent Service Functions that + can be be used for load distribution. + """ + + support_status = support.SupportStatus( + version='8.0.0', + status=support.UNSUPPORTED) + + required_service_extension = 'sfc' + + PROPERTIES = ( + NAME, DESCRIPTION, PORT_PAIRS, + ) = ( + 'name', 'description', 'port_pairs', + ) + + properties_schema = { + NAME: properties.Schema( + properties.Schema.STRING, + _('Name for the Port Pair Group.'), + update_allowed=True + ), + DESCRIPTION: properties.Schema( + properties.Schema.STRING, + _('Description for the Port Pair Group.'), + update_allowed=True + ), + PORT_PAIRS: properties.Schema( + properties.Schema.LIST, + _('A list of Port Pair IDs or names to apply.'), + required=True, + update_allowed=True, + schema=properties.Schema( + properties.Schema.STRING, + _('Port Pair ID or name .'), + constraints=[ + constraints.CustomConstraint('neutron.port_pair') + ] + ) + ), + } + + def translation_rules(self, props): + return [ + translation.TranslationRule( + props, + translation.TranslationRule.RESOLVE, + [self.PORT_PAIRS], + client_plugin=self.client_plugin(), + finder='resolve_ext_resource', + entity='port_pair' + ) + ] + + def _show_resource(self): + return self.client_plugin().show_sfc_resource('port_pair_group', + self.resource_id) + + def handle_create(self): + props = self.prepare_properties( + self.properties, + self.physical_resource_name()) + + port_pair_group = self.client_plugin().create_sfc_resource( + 'port_pair_group', props) + self.resource_id_set(port_pair_group['id']) + + def handle_update(self, json_snippet, tmpl_diff, prop_diff): + if prop_diff: + self.prepare_update_properties(prop_diff) + self.client_plugin().update_sfc_resource( + 'port_pair_group', + prop_diff, + self.resource_id) + + def handle_delete(self): + if self.resource_id is None: + return + with self.client_plugin().ignore_not_found: + self.client_plugin().delete_sfc_resource('port_pair_group', + self.resource_id) + + +def resource_mapping(): + return { + 'OS::Neutron::PortPairGroup': PortPairGroup, + } diff --git a/heat/tests/openstack/neutron/test_sfc/test_port_pair_group.py b/heat/tests/openstack/neutron/test_sfc/test_port_pair_group.py new file mode 100644 index 0000000000..2ff4f5fab3 --- /dev/null +++ b/heat/tests/openstack/neutron/test_sfc/test_port_pair_group.py @@ -0,0 +1,186 @@ +# +# 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 heat.engine.clients.os import neutron +from heat.engine.resources.openstack.neutron.sfc import port_pair_group +from heat.engine import stack +from heat.engine import template +from heat.tests import common +from heat.tests import utils + +sample_template = { + 'heat_template_version': '2016-04-08', + 'resources': { + 'test_resource': { + 'type': 'OS::Neutron::PortPairGroup', + 'properties': { + 'name': 'test_port_pair_group', + 'description': 'desc', + 'port_pairs': ['port1'] + } + } + } + } + +RESOURCE_TYPE = 'OS::Neutron::PortPairGroup' + + +class PortPairGroupTest(common.HeatTestCase): + + def setUp(self): + super(PortPairGroupTest, self).setUp() + + self.patchobject(neutron.NeutronClientPlugin, 'has_extension', + return_value=True) + + self.ctx = utils.dummy_context() + + self.stack = stack.Stack( + self.ctx, 'test_stack', + template.Template(sample_template) + ) + self.test_resource = self.stack['test_resource'] + + self.test_client_plugin = mock.MagicMock() + + self.test_resource.client_plugin = mock.MagicMock( + return_value=self.test_client_plugin) + + self.test_client = mock.MagicMock() + self.test_resource.client = mock.MagicMock( + return_value=self.test_client) + + self.test_client_plugin.get_notification = mock.MagicMock( + return_value='sample_notification') + + self.patchobject(self.test_client_plugin, + 'resolve_ext_resource').return_value = ('port1') + + def test_resource_mapping(self): + mapping = port_pair_group.resource_mapping() + self.assertEqual(port_pair_group.PortPairGroup, + mapping['OS::Neutron::PortPairGroup']) + + def _get_mock_resource(self): + value = mock.MagicMock() + value.id = '477e8273-60a7-4c41-b683-fdb0bc7cd152' + return value + + def _resolve_sfc_resource(self): + value = mock.MagicMock() + value.id = '[port1]' + return value.id + + def test_resource_handle_create(self): + mock_ppg_create = self.test_client_plugin.create_sfc_resource + mock_resource = self._get_mock_resource() + mock_ppg_create.return_value = mock_resource + + # validate the properties + self.assertEqual( + 'test_port_pair_group', + self.test_resource.properties.get( + port_pair_group.PortPairGroup.NAME)) + self.assertEqual( + 'desc', + self.test_resource.properties.get( + port_pair_group.PortPairGroup.DESCRIPTION)) + self.assertEqual( + ['port1'], + self.test_resource.properties.get( + port_pair_group.PortPairGroup.PORT_PAIRS)) + + self.test_resource.data_set = mock.Mock() + self.test_resource.handle_create() + + mock_ppg_create.assert_called_once_with( + 'port_pair_group', + { + 'name': 'test_port_pair_group', + 'description': 'desc', + 'port_pairs': ['port1'], + } + ) + + def test_resource_handle_delete(self): + mock_ppg_delete = self.test_client_plugin.delete_sfc_resource + self.test_resource.resource_id = '477e8273-60a7-4c41-b683-fdb0bc7cd151' + mock_ppg_delete.return_value = None + self.assertIsNone(self.test_resource.handle_delete()) + mock_ppg_delete.assert_called_once_with( + 'port_pair_group', self.test_resource.resource_id) + + def test_resource_handle_delete_resource_id_is_none(self): + self.test_resource.resource_id = None + self.assertIsNone(self.test_resource.handle_delete()) + self.assertEqual(0, self.test_client_plugin. + delete_sfc_resource.call_count) + + def test_resource_handle_delete_not_found(self): + self.test_resource.resource_id = '477e8273-60a7-4c41-b683-fdb0bc7cd151' + mock_ppg_delete = self.test_client_plugin.delete_sfc_resource + mock_ppg_delete.side_effect = self.test_client_plugin.NotFound + self.assertIsNone(self.test_resource.handle_delete()) + + def test_resource_show_resource(self): + mock_ppg_get = self.test_client_plugin.show_sfc_resource + mock_ppg_get.return_value = {} + self.assertEqual({}, + self.test_resource._show_resource(), + 'Failed to show resource') + + def test_resource_handle_update(self): + mock_ppg_patch = self.test_client_plugin.update_sfc_resource + self.test_resource.resource_id = '477e8273-60a7-4c41-b683-fdb0bc7cd151' + prop_diff = { + 'name': 'name-updated', + 'description': 'description-updated', + } + self.test_resource.handle_update(json_snippet=None, + tmpl_diff=None, + prop_diff=prop_diff) + + mock_ppg_patch.assert_called_once_with( + 'port_pair_group', + { + 'name': 'name-updated', + 'description': 'description-updated', + }, self.test_resource.resource_id) + + def test_resource_handle_update_port_pairs(self): + self.patchobject(self.test_client_plugin, + 'resolve_ext_resource').return_value = ('port2') + mock_ppg_patch = self.test_client_plugin.update_sfc_resource + self.test_resource.resource_id = '477e8273-60a7-4c41-b683-fdb0bc7cd151' + + prop_diff = { + port_pair_group.PortPairGroup.NAME: + 'name', + port_pair_group.PortPairGroup.DESCRIPTION: + 'description', + port_pair_group.PortPairGroup.PORT_PAIRS: + ['port2'], + } + self.test_resource.handle_update(json_snippet=None, + tmpl_diff=None, + prop_diff=prop_diff) + + mock_ppg_patch.assert_called_once_with( + 'port_pair_group', + { + 'name': 'name', + 'description': 'description', + 'port_pairs': ['port2'], + }, self.test_resource.resource_id) diff --git a/setup.cfg b/setup.cfg index ed467d9eea..804f8c2a26 100644 --- a/setup.cfg +++ b/setup.cfg @@ -124,6 +124,7 @@ heat.constraints = neutron.lb.provider = heat.engine.clients.os.neutron.neutron_constraints:LBaasV1ProviderConstraint neutron.network = heat.engine.clients.os.neutron.neutron_constraints:NetworkConstraint neutron.port = heat.engine.clients.os.neutron.neutron_constraints:PortConstraint + neutron.port_pair = heat.engine.clients.os.neutron.neutron_constraints:PortPairConstraint neutron.qos_policy = heat.engine.clients.os.neutron.neutron_constraints:QoSPolicyConstraint neutron.router = heat.engine.clients.os.neutron.neutron_constraints:RouterConstraint neutron.security_group = heat.engine.clients.os.neutron.neutron_constraints:SecurityGroupConstraint