diff --git a/heat/common/netutils.py b/heat/common/netutils.py new file mode 100644 index 0000000000..34c1709b26 --- /dev/null +++ b/heat/common/netutils.py @@ -0,0 +1,26 @@ +# +# 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 netaddr + + +def is_prefix_subset(orig_prefixes, new_prefixes): + """Check whether orig_prefixes is subset of new_prefixes. + + + This takes valid prefix lists for orig_prefixes and new_prefixes, + returns 'True', if orig_prefixes is subset of new_prefixes. + """ + orig_set = netaddr.IPSet(orig_prefixes) + new_set = netaddr.IPSet(new_prefixes) + return orig_set.issubset(new_set) diff --git a/heat/engine/resources/openstack/neutron/subnetpool.py b/heat/engine/resources/openstack/neutron/subnetpool.py new file mode 100644 index 0000000000..4dd8d6a087 --- /dev/null +++ b/heat/engine/resources/openstack/neutron/subnetpool.py @@ -0,0 +1,212 @@ +# +# 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 neutronclient.neutron import v2_0 as nV20 + +from heat.common import exception +from heat.common.i18n import _ +from heat.common import netutils +from heat.engine import constraints +from heat.engine import properties +from heat.engine.resources.openstack.neutron import neutron +from heat.engine import support + + +class SubnetPool(neutron.NeutronResource): + """A resource that implements neutron subnet pool. + + This resource can be used to create a subnet pool with a large block + of addresses and create subnets from it. + """ + + support_status = support.SupportStatus(version='6.0.0') + + required_service_extension = 'subnet_allocation' + + PROPERTIES = ( + NAME, PREFIXES, ADDRESS_SCOPE, DEFAULT_QUOTA, + DEFAULT_PREFIXLEN, MIN_PREFIXLEN, MAX_PREFIXLEN, + IS_DEFAULT, TENANT_ID, SHARED, + ) = ( + 'name', 'prefixes', 'address_scope', 'default_quota', + 'default_prefixlen', 'min_prefixlen', 'max_prefixlen', + 'is_default', 'tenant_id', 'shared', + ) + + properties_schema = { + NAME: properties.Schema( + properties.Schema.STRING, + _('Name of the subnet pool.'), + update_allowed=True + ), + PREFIXES: properties.Schema( + properties.Schema.LIST, + _('List of subnet prefixes to assign.'), + schema=properties.Schema( + properties.Schema.STRING, + constraints=[ + constraints.CustomConstraint('net_cidr'), + ], + ), + constraints=[constraints.Length(min=1)], + required=True, + update_allowed=True, + ), + ADDRESS_SCOPE: properties.Schema( + properties.Schema.STRING, + _('An address scope ID to assign to the subnet pool.'), + constraints=[ + constraints.CustomConstraint('neutron.address_scope') + ], + update_allowed=True, + ), + DEFAULT_QUOTA: properties.Schema( + properties.Schema.INTEGER, + _('A per-tenant quota on the prefix space that can be allocated ' + 'from the subnet pool for tenant subnets.'), + constraints=[constraints.Range(min=0)], + update_allowed=True, + ), + DEFAULT_PREFIXLEN: properties.Schema( + properties.Schema.INTEGER, + _('The size of the prefix to allocate when the cidr or ' + 'prefixlen attributes are not specified while creating ' + 'a subnet.'), + constraints=[constraints.Range(min=0)], + update_allowed=True, + ), + MIN_PREFIXLEN: properties.Schema( + properties.Schema.INTEGER, + _('Smallest prefix size that can be allocated ' + 'from the subnet pool.'), + constraints=[constraints.Range(min=0)], + update_allowed=True, + ), + MAX_PREFIXLEN: properties.Schema( + properties.Schema.INTEGER, + _('Maximum prefix size that can be allocated ' + 'from the subnet pool.'), + constraints=[constraints.Range(min=0)], + update_allowed=True, + ), + IS_DEFAULT: properties.Schema( + properties.Schema.BOOLEAN, + _('Whether this is default IPv4/IPv6 subnet pool.' + 'There can only be one default subnet pool for each IP family.' + 'Note that the default policy setting restricts administrative ' + 'users to set this to True'), + default=False, + update_allowed=True, + ), + TENANT_ID: properties.Schema( + properties.Schema.STRING, + _('The ID of the tenant who owns the subnet pool. Only ' + 'administrative users can specify a tenant ID ' + 'other than their own.') + ), + SHARED: properties.Schema( + properties.Schema.BOOLEAN, + _('Whether the subnet pool will be shared across all tenants.' + 'Note that the default policy setting restricts usage of this ' + 'attribute to administrative users only.'), + default=False, + ), + } + + def validate(self): + super(SubnetPool, self).validate() + self._validate_prefix_bounds() + + def _validate_prefix_bounds(self): + min_prefixlen = self.properties[self.MIN_PREFIXLEN] + default_prefixlen = self.properties[self.DEFAULT_PREFIXLEN] + max_prefixlen = self.properties[self.MAX_PREFIXLEN] + msg_fmt = _('Illegal prefix bounds: %(key1)s=%(value1)s, ' + '%(key2)s=%(value2)s.') + # min_prefixlen can not be greater than max_prefixlen + if min_prefixlen and max_prefixlen and min_prefixlen > max_prefixlen: + msg = msg_fmt % dict(key1=self.MAX_PREFIXLEN, + value1=max_prefixlen, + key2=self.MIN_PREFIXLEN, + value2=min_prefixlen) + raise exception.StackValidationFailed(message=msg) + + if default_prefixlen: + # default_prefixlen can not be greater than max_prefixlen + if max_prefixlen and default_prefixlen > max_prefixlen: + msg = msg_fmt % dict(key1=self.MAX_PREFIXLEN, + value1=max_prefixlen, + key2=self.DEFAULT_PREFIXLEN, + value2=default_prefixlen) + raise exception.StackValidationFailed(message=msg) + # min_prefixlen can not be greater than default_prefixlen + if min_prefixlen and min_prefixlen > default_prefixlen: + msg = msg_fmt % dict(key1=self.MIN_PREFIXLEN, + value1=min_prefixlen, + key2=self.DEFAULT_PREFIXLEN, + value2=default_prefixlen) + raise exception.StackValidationFailed(message=msg) + + def _validate_prefixes_for_update(self, prop_diff): + old_prefixes = self.properties[self.PREFIXES] + new_prefixes = prop_diff[self.PREFIXES] + # check new_prefixes is a superset of old_prefixes + if not netutils.is_prefix_subset(old_prefixes, new_prefixes): + msg = (_('Property %(key)s updated value %(new)s should ' + 'be superset of existing value ' + '%(old)s.') % dict(key=self.PREFIXES, + new=sorted(new_prefixes), + old=sorted(old_prefixes))) + raise exception.StackValidationFailed(message=msg) + + def handle_create(self): + props = self.prepare_properties( + self.properties, + self.physical_resource_name()) + if self.ADDRESS_SCOPE in props and props[self.ADDRESS_SCOPE]: + props['address_scope_id'] = nV20.find_resourceid_by_name_or_id( + self.client(), 'address_scope', props.pop(self.ADDRESS_SCOPE)) + subnetpool = self.client().create_subnetpool( + {'subnetpool': props})['subnetpool'] + self.resource_id_set(subnetpool['id']) + + def handle_delete(self): + if self.resource_id is not None: + with self.client_plugin().ignore_not_found: + self.client().delete_subnetpool(self.resource_id) + + def _show_resource(self): + return self.client().show_subnetpool(self.resource_id)['subnetpool'] + + def handle_update(self, json_snippet, tmpl_diff, prop_diff): + # check that new prefixes are superset of existing prefixes + if self.PREFIXES in prop_diff: + self._validate_prefixes_for_update(prop_diff) + if self.ADDRESS_SCOPE in prop_diff: + if prop_diff[self.ADDRESS_SCOPE]: + prop_diff[ + 'address_scope_id'] = nV20.find_resourceid_by_name_or_id( + self.client(), 'address_scope', + prop_diff.pop(self.ADDRESS_SCOPE)) + else: + prop_diff[ + 'address_scope_id'] = prop_diff.pop(self.ADDRESS_SCOPE) + if prop_diff: + self.client().update_subnetpool( + self.resource_id, {'subnetpool': prop_diff}) + + +def resource_mapping(): + return { + 'OS::Neutron::SubnetPool': SubnetPool, + } diff --git a/heat/tests/common.py b/heat/tests/common.py index 7318fac796..b5ef8416e2 100644 --- a/heat/tests/common.py +++ b/heat/tests/common.py @@ -265,6 +265,10 @@ class HeatTestCase(testscenarios.WithScenarios, validate = self.patchobject(neutron.SubnetConstraint, 'validate') validate.return_value = True + def stub_AddressScopeConstraint_validate(self): + validate = self.patchobject(neutron.AddressScopeConstraint, 'validate') + validate.return_value = True + def stub_RouterConstraint_validate(self): validate = self.patchobject(neutron.RouterConstraint, 'validate') validate.return_value = True diff --git a/heat/tests/openstack/neutron/inline_templates.py b/heat/tests/openstack/neutron/inline_templates.py new file mode 100644 index 0000000000..69d10f311b --- /dev/null +++ b/heat/tests/openstack/neutron/inline_templates.py @@ -0,0 +1,44 @@ +# +# 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. + +SPOOL_TEMPLATE = ''' +heat_template_version: 2015-04-30 +description: Template to test subnetpool Neutron resource +resources: + sub_pool: + type: OS::Neutron::SubnetPool + properties: + name: the_sp + prefixes: + - 10.1.0.0/16 + address_scope: test + default_quota: 2 + default_prefixlen: 28 + min_prefixlen: 8 + max_prefixlen: 32 + is_default: False + tenant_id: c1210485b2424d48804aad5d39c61b8f + shared: False +''' + +SPOOL_MINIMAL_TEMPLATE = ''' +heat_template_version: 2015-04-30 +description: Template to test subnetpool Neutron resource +resources: + sub_pool: + type: OS::Neutron::SubnetPool + properties: + prefixes: + - 10.0.0.0/16 + - 10.1.0.0/16 +''' diff --git a/heat/tests/openstack/neutron/test_neutron_subnetpool.py b/heat/tests/openstack/neutron/test_neutron_subnetpool.py new file mode 100644 index 0000000000..3b953d2e06 --- /dev/null +++ b/heat/tests/openstack/neutron/test_neutron_subnetpool.py @@ -0,0 +1,271 @@ +# +# 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 neutronclient.common import exceptions as qe +from neutronclient.neutron import v2_0 as neutronV20 +from neutronclient.v2_0 import client as neutronclient +import six + +from heat.common import exception +from heat.common import template_format +from heat.engine.clients.os import neutron +from heat.engine.resources.openstack.neutron import subnetpool +from heat.engine import rsrc_defn +from heat.engine import scheduler +from heat.tests import common +from heat.tests.openstack.neutron import inline_templates +from heat.tests import utils + + +class NeutronSubnetPoolTest(common.HeatTestCase): + + def setUp(self): + super(NeutronSubnetPoolTest, self).setUp() + self.patchobject(neutron.NeutronClientPlugin, 'has_extension', + return_value=True) + self.find_resource = self.patchobject(neutronV20, + 'find_resourceid_by_name_or_id', + return_value='new_test') + + def create_subnetpool(self, status='COMPLETE'): + self.t = template_format.parse(inline_templates.SPOOL_TEMPLATE) + self.stack = utils.parse_stack(self.t) + resource_defns = self.stack.t.resource_definitions(self.stack) + rsrc = subnetpool.SubnetPool('sub_pool', resource_defns['sub_pool'], + self.stack) + if status == 'FAILED': + self.patchobject(neutronclient.Client, 'create_subnetpool', + side_effect=qe.NeutronClientException( + status_code=500)) + error = self.assertRaises(exception.ResourceFailure, + scheduler.TaskRunner(rsrc.create)) + self.assertEqual( + 'NeutronClientException: resources.sub_pool: ' + 'An unknown exception occurred.', + six.text_type(error)) + else: + self.patchobject(neutronclient.Client, 'create_subnetpool', + return_value={'subnetpool': { + 'id': 'fc68ea2c-b60b-4b4f-bd82-94ec81110766' + }}) + scheduler.TaskRunner(rsrc.create)() + + self.assertEqual((rsrc.CREATE, status), rsrc.state) + return rsrc + + def test_resource_mapping(self): + self.t = template_format.parse(inline_templates.SPOOL_TEMPLATE) + self.stack = utils.parse_stack(self.t) + rsrc = self.stack['sub_pool'] + mapping = subnetpool.resource_mapping() + self.assertEqual(1, len(mapping)) + self.assertEqual(subnetpool.SubnetPool, + mapping['OS::Neutron::SubnetPool']) + self.assertIsInstance(rsrc, subnetpool.SubnetPool) + + def test_validate_prefixlen_min_gt_max(self): + self.t = template_format.parse(inline_templates.SPOOL_TEMPLATE) + props = self.t['resources']['sub_pool']['properties'] + props['min_prefixlen'] = 28 + props['max_prefixlen'] = 24 + self.stack = utils.parse_stack(self.t) + rsrc = self.stack['sub_pool'] + errMessage = ('Illegal prefix bounds: max_prefixlen=24, ' + 'min_prefixlen=28.') + error = self.assertRaises(exception.StackValidationFailed, + rsrc.validate) + self.assertEqual(errMessage, six.text_type(error)) + + def test_validate_prefixlen_default_gt_max(self): + self.t = template_format.parse(inline_templates.SPOOL_TEMPLATE) + props = self.t['resources']['sub_pool']['properties'] + props['default_prefixlen'] = 28 + props['max_prefixlen'] = 24 + self.stack = utils.parse_stack(self.t) + rsrc = self.stack['sub_pool'] + errMessage = ('Illegal prefix bounds: max_prefixlen=24, ' + 'default_prefixlen=28.') + error = self.assertRaises(exception.StackValidationFailed, + rsrc.validate) + self.assertEqual(errMessage, six.text_type(error)) + + def test_validate_prefixlen_min_gt_default(self): + self.t = template_format.parse(inline_templates.SPOOL_TEMPLATE) + props = self.t['resources']['sub_pool']['properties'] + props['min_prefixlen'] = 28 + props['default_prefixlen'] = 24 + self.stack = utils.parse_stack(self.t) + rsrc = self.stack['sub_pool'] + errMessage = ('Illegal prefix bounds: min_prefixlen=28, ' + 'default_prefixlen=24.') + error = self.assertRaises(exception.StackValidationFailed, + rsrc.validate) + self.assertEqual(errMessage, six.text_type(error)) + + def test_validate_minimal(self): + self.t = template_format.parse(inline_templates.SPOOL_MINIMAL_TEMPLATE) + self.stack = utils.parse_stack(self.t) + rsrc = self.stack['sub_pool'] + self.assertIsNone(rsrc.validate()) + + def test_create_subnetpool(self): + rsrc = self.create_subnetpool() + ref_id = rsrc.FnGetRefId() + self.assertEqual('fc68ea2c-b60b-4b4f-bd82-94ec81110766', ref_id) + + def test_create_subnetpool_failed(self): + self.create_subnetpool('FAILED') + + def test_delete_subnetpool(self): + self.patchobject(neutronclient.Client, 'delete_subnetpool') + rsrc = self.create_subnetpool() + ref_id = rsrc.FnGetRefId() + self.assertEqual('fc68ea2c-b60b-4b4f-bd82-94ec81110766', ref_id) + self.assertIsNone(scheduler.TaskRunner(rsrc.delete)()) + self.assertEqual((rsrc.DELETE, rsrc.COMPLETE), rsrc.state) + + def test_delete_subnetpool_not_found(self): + self.patchobject(neutronclient.Client, 'delete_subnetpool', + side_effect=qe.NotFound(status_code=404)) + rsrc = self.create_subnetpool() + ref_id = rsrc.FnGetRefId() + self.assertEqual('fc68ea2c-b60b-4b4f-bd82-94ec81110766', ref_id) + self.assertIsNone(scheduler.TaskRunner(rsrc.delete)()) + self.assertEqual((rsrc.DELETE, rsrc.COMPLETE), rsrc.state) + + def test_delete_subnetpool_resource_id_none(self): + delete_pool = self.patchobject(neutronclient.Client, + 'delete_subnetpool') + rsrc = self.create_subnetpool() + rsrc.resource_id = None + self.assertIsNone(scheduler.TaskRunner(rsrc.delete)()) + delete_pool.assert_not_called() + + def test_update_subnetpool(self): + update_subnetpool = self.patchobject(neutronclient.Client, + 'update_subnetpool') + rsrc = self.create_subnetpool() + ref_id = rsrc.FnGetRefId() + self.assertEqual('fc68ea2c-b60b-4b4f-bd82-94ec81110766', ref_id) + props = { + 'name': 'the_new_sp', + 'prefixes': [ + '10.1.0.0/16', + '10.2.0.0/16'], + 'address_scope': 'new_test', + 'default_quota': '16', + 'default_prefixlen': '24', + 'min_prefixlen': '24', + 'max_prefixlen': '28', + 'is_default': False, + } + update_snippet = rsrc_defn.ResourceDefinition(rsrc.name, rsrc.type(), + props) + self.assertIsNone(rsrc.handle_update(update_snippet, {}, props)) + self.assertEqual(1, update_subnetpool.call_count) + + def test_update_subnetpool_no_prop_diff(self): + update_subnetpool = self.patchobject(neutronclient.Client, + 'update_subnetpool') + rsrc = self.create_subnetpool() + ref_id = rsrc.FnGetRefId() + self.assertEqual('fc68ea2c-b60b-4b4f-bd82-94ec81110766', ref_id) + update_snippet = rsrc_defn.ResourceDefinition(rsrc.name, rsrc.type(), + rsrc.t) + self.assertIsNone(rsrc.handle_update(update_snippet, {}, {})) + update_subnetpool.assert_not_called() + + def test_update_subnetpool_validate_prefixes(self): + update_subnetpool = self.patchobject(neutronclient.Client, + 'update_subnetpool') + rsrc = self.create_subnetpool() + ref_id = rsrc.FnGetRefId() + self.assertEqual('fc68ea2c-b60b-4b4f-bd82-94ec81110766', ref_id) + prefix_old = rsrc.properties['prefixes'] + props = { + 'name': 'the_new_sp', + 'prefixes': ['10.5.0.0/16'] + } + prefix_new = props['prefixes'] + update_snippet = rsrc_defn.ResourceDefinition(rsrc.name, rsrc.type(), + props) + errMessage = ('Property prefixes updated value %(value1)s ' + 'should be superset of existing value %(value2)s.' + % dict(value1=sorted(prefix_new), + value2=sorted(prefix_old))) + + error = self.assertRaises(exception.StackValidationFailed, + rsrc.handle_update, + update_snippet, {}, props) + + self.assertEqual(errMessage, six.text_type(error)) + update_subnetpool.assert_not_called() + + props = { + 'name': 'the_new_sp', + 'prefixes': ['10.0.0.0/8', + '10.6.0.0/16'], + } + + update_snippet = rsrc_defn.ResourceDefinition(rsrc.name, rsrc.type(), + props) + self.assertIsNone(rsrc.handle_update(update_snippet, {}, props)) + update_subnetpool.assert_called_once_with( + 'fc68ea2c-b60b-4b4f-bd82-94ec81110766', + {'subnetpool': props}) + + def test_update_subnetpool_update_address_scope(self): + update_subnetpool = self.patchobject(neutronclient.Client, + 'update_subnetpool') + rsrc = self.create_subnetpool() + ref_id = rsrc.FnGetRefId() + self.assertEqual('fc68ea2c-b60b-4b4f-bd82-94ec81110766', ref_id) + props = { + 'name': 'the_new_sp', + 'address_scope': 'new_test', + 'prefixes': ['10.0.0.0/8', + '10.6.0.0/16'], + } + update_dict = { + 'name': 'the_new_sp', + 'address_scope_id': 'new_test', + 'prefixes': ['10.0.0.0/8', + '10.6.0.0/16'], + } + update_snippet = rsrc_defn.ResourceDefinition(rsrc.name, rsrc.type(), + props) + self.assertIsNone(rsrc.handle_update(update_snippet, {}, props)) + self.assertEqual(3, self.find_resource.call_count) + update_subnetpool.assert_called_once_with( + 'fc68ea2c-b60b-4b4f-bd82-94ec81110766', + {'subnetpool': update_dict}) + + def test_update_subnetpool_remove_address_scope(self): + update_subnetpool = self.patchobject(neutronclient.Client, + 'update_subnetpool') + rsrc = self.create_subnetpool() + ref_id = rsrc.FnGetRefId() + self.assertEqual('fc68ea2c-b60b-4b4f-bd82-94ec81110766', ref_id) + props = { + 'name': 'the_new_sp', + 'prefixes': ['10.0.0.0/8', + '10.6.0.0/16'], + } + props_diff = {'address_scope': None} + update_snippet = rsrc_defn.ResourceDefinition(rsrc.name, rsrc.type(), + props) + self.assertIsNone(rsrc.handle_update(update_snippet, {}, props_diff)) + self.assertEqual(2, self.find_resource.call_count) + update_subnetpool.assert_called_once_with( + 'fc68ea2c-b60b-4b4f-bd82-94ec81110766', + {'subnetpool': props_diff})