diff --git a/heat/engine/resources/openstack/neutron/security_group_rule.py b/heat/engine/resources/openstack/neutron/security_group_rule.py new file mode 100644 index 0000000000..e06dd4830b --- /dev/null +++ b/heat/engine/resources/openstack/neutron/security_group_rule.py @@ -0,0 +1,200 @@ +# +# 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 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 SecurityGroupRule(neutron.NeutronResource): + """A resource for managing Neutron security group rules. + + Rules to use in security group resource. + """ + + required_service_extension = 'security-group' + + support_status = support.SupportStatus(version='7.0.0') + + PROPERTIES = ( + SECURITY_GROUP, DESCRIPTION, DIRECTION, ETHERTYPE, + PORT_RANGE_MIN, PORT_RANGE_MAX, PROTOCOL, REMOTE_GROUP, + REMOTE_IP_PREFIX + ) = ( + 'security_group', 'description', 'direction', 'ethertype', + 'port_range_min', 'port_range_max', 'protocol', 'remote_group', + 'remote_ip_prefix' + ) + + _allowed_protocols = list(range(256)) + [ + 'ah', 'dccp', 'egp', 'esp', 'gre', 'icmp', 'icmpv6', 'igmp', + 'ipv6-encap', 'ipv6-frag', 'ipv6-icmp', 'ipv6-nonxt', 'ipv6-opts', + 'ipv6-route', 'ospf', 'pgm', 'rsvp', 'sctp', 'tcp', 'udp', 'udplite', + 'vrrp' + ] + + properties_schema = { + SECURITY_GROUP: properties.Schema( + properties.Schema.STRING, + _('Security group name or ID to add rule.'), + required=True, + constraints=[ + constraints.CustomConstraint('neutron.security_group') + ] + ), + DESCRIPTION: properties.Schema( + properties.Schema.STRING, + _('Description of the security group rule.') + ), + DIRECTION: properties.Schema( + properties.Schema.STRING, + _('The direction in which the security group rule is applied. ' + 'For a compute instance, an ingress security group rule ' + 'matches traffic that is incoming (ingress) for that ' + 'instance. An egress rule is applied to traffic leaving ' + 'the instance.'), + default='ingress', + constraints=[ + constraints.AllowedValues(['ingress', 'egress']), + ] + ), + ETHERTYPE: properties.Schema( + properties.Schema.STRING, + _('Ethertype of the traffic.'), + default='IPv4', + constraints=[ + constraints.AllowedValues(['IPv4', 'IPv6']), + ] + ), + PORT_RANGE_MIN: properties.Schema( + properties.Schema.INTEGER, + _('The minimum port number in the range that is matched by the ' + 'security group rule. If the protocol is TCP or UDP, this ' + 'value must be less than or equal to the value of the ' + 'port_range_max attribute. If the protocol is ICMP, this ' + 'value must be an ICMP type.'), + constraints=[ + constraints.Range(0, 65535) + ] + ), + PORT_RANGE_MAX: properties.Schema( + properties.Schema.INTEGER, + _('The maximum port number in the range that is matched by the ' + 'security group rule. The port_range_min attribute constrains ' + 'the port_range_max attribute. If the protocol is ICMP, this ' + 'value must be an ICMP code.'), + constraints=[ + constraints.Range(0, 65535) + ] + ), + PROTOCOL: properties.Schema( + properties.Schema.STRING, + _('The protocol that is matched by the security group rule. ' + 'Allowed values are ah, dccp, egp, esp, gre, icmp, icmpv6, ' + 'igmp, ipv6-encap, ipv6-frag, ipv6-icmp, ipv6-nonxt, ipv6-opts, ' + 'ipv6-route, ospf, pgm, rsvp, sctp, tcp, udp, udplite, vrrp ' + 'and integer representations [0-255].'), + default='tcp', + constraints=[constraints.AllowedValues(_allowed_protocols)] + ), + REMOTE_GROUP: properties.Schema( + properties.Schema.STRING, + _('The remote group name or ID to be associated with this ' + 'security group rule.'), + constraints=[ + constraints.CustomConstraint('neutron.security_group') + ] + ), + REMOTE_IP_PREFIX: properties.Schema( + properties.Schema.STRING, + _('The remote IP prefix (CIDR) to be associated with this ' + 'security group rule.'), + constraints=[ + constraints.CustomConstraint('net_cidr') + ] + ) + } + + def translation_rules(self, props): + return [ + translation.TranslationRule( + props, + translation.TranslationRule.RESOLVE, + [self.SECURITY_GROUP], + client_plugin=self.client_plugin(), + finder='find_resourceid_by_name_or_id', + entity='security_group' + ), + translation.TranslationRule( + props, + translation.TranslationRule.RESOLVE, + [self.REMOTE_GROUP], + client_plugin=self.client_plugin(), + finder='find_resourceid_by_name_or_id', + entity='security_group' + ), + ] + + def _show_resource(self): + return self.client().show_security_group_rule( + self.resource_id)['security_group_rule'] + + def validate(self): + super(SecurityGroupRule, self).validate() + if (self.properties[self.REMOTE_GROUP] is not None and + self.properties[self.REMOTE_IP_PREFIX] is not None): + raise exception.ResourcePropertyConflict( + self.REMOTE_GROUP, self.REMOTE_IP_PREFIX) + port_max = self.properties[self.PORT_RANGE_MAX] + port_min = self.properties[self.PORT_RANGE_MIN] + protocol = self.properties[self.PROTOCOL] + if (port_max is not None and port_min is not None and + protocol not in ('icmp', 'icmpv6', 'ipv6-icmp') and + port_max < port_min): + msg = _('The minimum port number must be less than or equal to ' + 'the maximum port number.') + raise exception.StackValidationFailed(message=msg) + + def handle_create(self): + props = self.prepare_properties( + self.properties, + self.physical_resource_name()) + props['security_group_id'] = props.pop(self.SECURITY_GROUP) + if self.REMOTE_GROUP in props: + props['remote_group_id'] = props.pop(self.REMOTE_GROUP) + + for key in (self.PORT_RANGE_MIN, self.PORT_RANGE_MAX): + if props.get(key) is not None: + props[key] = str(props[key]) + + rule = self.client().create_security_group_rule( + {'security_group_rule': props})['security_group_rule'] + + self.resource_id_set(rule['id']) + + def handle_delete(self): + if self.resource_id is None: + return + + with self.client_plugin().ignore_not_found: + self.client().delete_security_group_rule(self.resource_id) + + +def resource_mapping(): + return { + 'OS::Neutron::SecurityGroupRule': SecurityGroupRule + } diff --git a/heat/tests/openstack/neutron/inline_templates.py b/heat/tests/openstack/neutron/inline_templates.py index 555cb5b91d..891122cb69 100644 --- a/heat/tests/openstack/neutron/inline_templates.py +++ b/heat/tests/openstack/neutron/inline_templates.py @@ -142,3 +142,16 @@ resources: type: HTTP url_path: /health ''' + +SECURITY_GROUP_RULE_TEMPLATE = ''' +heat_template_version: 2016-10-14 +resources: + security_group_rule: + type: OS::Neutron::SecurityGroupRule + properties: + security_group: 123 + description: test description + remote_group: 123 + protocol: tcp + port_range_min: 100 +''' diff --git a/heat/tests/openstack/neutron/test_neutron_security_group_rule.py b/heat/tests/openstack/neutron/test_neutron_security_group_rule.py new file mode 100644 index 0000000000..01b7036d59 --- /dev/null +++ b/heat/tests/openstack/neutron/test_neutron_security_group_rule.py @@ -0,0 +1,98 @@ +# +# 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.common import exception +from heat.common import template_format +from heat.engine.resources.openstack.neutron import security_group_rule +from heat.tests import common +from heat.tests.openstack.neutron import inline_templates +from heat.tests import utils + + +class SecurityGroupRuleTest(common.HeatTestCase): + + def test_resource_mapping(self): + mapping = security_group_rule.resource_mapping() + self.assertEqual(mapping['OS::Neutron::SecurityGroupRule'], + security_group_rule.SecurityGroupRule) + + @mock.patch('heat.engine.clients.os.neutron.' + 'NeutronClientPlugin.has_extension', return_value=True) + def _create_stack(self, ext_func, + tmpl=inline_templates.SECURITY_GROUP_RULE_TEMPLATE): + self.t = template_format.parse(tmpl) + self.stack = utils.parse_stack(self.t) + self.sg_rule = self.stack['security_group_rule'] + self.neutron_client = mock.MagicMock() + self.sg_rule.client = mock.MagicMock(return_value=self.neutron_client) + + self.sg_rule.client_plugin().find_resourceid_by_name_or_id = ( + mock.MagicMock(return_value='123')) + + def test_create(self): + self._create_stack() + self.neutron_client.create_security_group_rule.return_value = { + 'security_group_rule': {'id': '1234'}} + expected = { + 'security_group_rule': { + 'security_group_id': u'123', + 'description': u'test description', + 'remote_group_id': u'123', + 'protocol': u'tcp', + 'port_range_min': '100', + 'direction': 'ingress', + 'ethertype': 'IPv4' + } + } + + self.sg_rule.handle_create() + + self.neutron_client.create_security_group_rule.assert_called_with( + expected) + + def test_validate_conflict_props(self): + tmpl = inline_templates.SECURITY_GROUP_RULE_TEMPLATE + tmpl += ' remote_ip_prefix: "123"' + self._create_stack(tmpl=tmpl) + + self.assertRaises(exception.ResourcePropertyConflict, + self.sg_rule.validate) + + def test_validate_max_port_less_than_min_port(self): + tmpl = inline_templates.SECURITY_GROUP_RULE_TEMPLATE + tmpl += ' port_range_max: 50' + self._create_stack(tmpl=tmpl) + + self.assertRaises(exception.StackValidationFailed, + self.sg_rule.validate) + + def test_show_resource(self): + self._create_stack() + self.sg_rule.resource_id_set('1234') + self.neutron_client.show_security_group_rule.return_value = { + 'security_group_rule': {'id': '1234'} + } + + self.assertEqual({'id': '1234'}, self.sg_rule._show_resource()) + self.neutron_client.show_security_group_rule.assert_called_with('1234') + + def test_delete(self): + self._create_stack() + self.sg_rule.resource_id_set('1234') + + self.sg_rule.handle_delete() + + self.neutron_client.delete_security_group_rule.assert_called_with( + '1234')