From d6150661dc1be0338ed5269efe80547ecd199715 Mon Sep 17 00:00:00 2001 From: Kanagaraj Manickam Date: Tue, 27 Dec 2016 11:32:54 +0530 Subject: [PATCH] Add designate recordset support Designate supports the recordset from v2 api and this patch adds support for recordset resource plugin. Change-Id: I5ace854ede4de3566445d1324c720cdcbb982aa2 implements: blueprint heat-designate-recordset-zone --- heat/engine/clients/os/designate.py | 19 +- .../openstack/designate/recordset.py | 175 ++++++++++++++ heat/tests/clients/test_designate_client.py | 20 ++ .../openstack/designate/test_recordset.py | 217 ++++++++++++++++++ setup.cfg | 1 + 5 files changed, 431 insertions(+), 1 deletion(-) create mode 100644 heat/engine/resources/openstack/designate/recordset.py create mode 100644 heat/tests/openstack/designate/test_recordset.py diff --git a/heat/engine/clients/os/designate.py b/heat/engine/clients/os/designate.py index 1d5f94c869..262b464e24 100644 --- a/heat/engine/clients/os/designate.py +++ b/heat/engine/clients/os/designate.py @@ -56,6 +56,19 @@ class DesignateClientPlugin(client_plugin.ClientPlugin): raise heat_exception.EntityNotFound(entity='Designate Domain', name=domain_id_or_name) + def get_zone_id(self, zone_id_or_name): + try: + zone_obj = self.client(version=self.V2).zones.get(zone_id_or_name) + return zone_obj['id'] + except exceptions.NotFound: + zones = self.client().zones.list( + criterion=dict(name=zone_id_or_name)) + if len(zones) == 1: + return zones[0]['id'] + + raise heat_exception.EntityNotFound(entity='Designate Zone', + name=zone_id_or_name) + def domain_create(self, **kwargs): domain = domains.Domain(**kwargs) return self.client().domains.create(domain) @@ -95,6 +108,10 @@ class DesignateClientPlugin(client_plugin.ClientPlugin): class DesignateDomainConstraint(constraints.BaseCustomConstraint): - resource_client_name = CLIENT_NAME resource_getter_name = 'get_domain_id' + + +class DesignateZoneConstraint(constraints.BaseCustomConstraint): + resource_client_name = CLIENT_NAME + resource_getter_name = 'get_zone_id' diff --git a/heat/engine/resources/openstack/designate/recordset.py b/heat/engine/resources/openstack/designate/recordset.py new file mode 100644 index 0000000000..a97cb90ade --- /dev/null +++ b/heat/engine/resources/openstack/designate/recordset.py @@ -0,0 +1,175 @@ +# +# 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 six + +from heat.common import exception +from heat.common.i18n import _ +from heat.engine import constraints +from heat.engine import properties +from heat.engine import resource +from heat.engine import support + + +class DesignateRecordSet(resource.Resource): + """Heat Template Resource for Designate RecordSet. + + Designate provides DNS-as-a-Service services for OpenStack. RecordSet + helps to add more than one records. + """ + + support_status = support.SupportStatus( + version='8.0.0') + + PROPERTIES = ( + NAME, TTL, DESCRIPTION, TYPE, RECORDS, ZONE + ) = ( + 'name', 'ttl', 'description', 'type', 'records', 'zone' + ) + + _ALLOWED_TYPES = ( + A, AAAA, CNAME, MX, SRV, TXT, SPF, + NS, PTR, SSHFP, SOA + ) = ( + 'A', 'AAAA', 'CNAME', 'MX', 'SRV', 'TXT', 'SPF', + 'NS', 'PTR', 'SSHFP', 'SOA' + ) + + properties_schema = { + # Based on RFC 1035, length of name is set to max of 255 + NAME: properties.Schema( + properties.Schema.STRING, + _('RecordSet name.'), + constraints=[constraints.Length(max=255)] + ), + # Based on RFC 1035, range for ttl is set to 1 to signed 32 bit number + TTL: properties.Schema( + properties.Schema.INTEGER, + _('Time To Live (Seconds).'), + update_allowed=True, + constraints=[constraints.Range(min=1, + max=2147483647)] + ), + # designate mandates to the max length of 160 for description + DESCRIPTION: properties.Schema( + properties.Schema.STRING, + _('Description of RecordSet.'), + update_allowed=True, + constraints=[constraints.Length(max=160)] + ), + TYPE: properties.Schema( + properties.Schema.STRING, + _('DNS RecordSet type.'), + required=True, + constraints=[constraints.AllowedValues( + _ALLOWED_TYPES + )] + ), + RECORDS: properties.Schema( + properties.Schema.LIST, + _('A list of data for this RecordSet. Each item will be a ' + 'separate record in Designate These items should conform to the ' + 'DNS spec for the record type - e.g. A records must be IPv4 ' + 'addresses, CNAME records must be a hostname. DNS record data ' + 'varies based on the type of record. For more details, please ' + 'refer rfc 1035.'), + update_allowed=True, + required=True + ), + ZONE: properties.Schema( + properties.Schema.STRING, + _('DNS Zone id or name.'), + required=True, + constraints=[constraints.CustomConstraint('designate.zone')] + ), + } + + default_client_name = 'designate' + + entity = 'recordsets' + + def client(self): + return super(DesignateRecordSet, + self).client(version=self.client_plugin().V2) + + def handle_create(self): + args = dict((k, v) for k, v in six.iteritems(self.properties) if v) + args['type_'] = args.pop(self.TYPE) + if not args.get(self.NAME): + args[self.NAME] = self.physical_resource_name() + + rs = self.client().recordsets.create(**args) + + self.resource_id_set(rs['id']) + + def _check_status_complete(self): + recordset = self.client().recordsets.get( + recordset=self.resource_id, + zone=self.properties[self.ZONE] + ) + + if recordset['status'] == 'ERROR': + raise exception.ResourceInError( + resource_status=recordset['status'], + status_reason=_('Error in RecordSet')) + + return recordset['status'] != 'PENDING' + + def check_create_complete(self, handler_data=None): + return self._check_status_complete() + + def handle_update(self, json_snippet, tmpl_diff, prop_diff): + args = dict() + + for prp in (self.TTL, self.DESCRIPTION, self.RECORDS): + if prop_diff.get(prp): + args[prp] = prop_diff.get(prp) + + if prop_diff.get(self.TYPE): + args['type_'] = prop_diff.get(self.TYPE) + + if len(args.keys()) > 0: + self.client().recordsets.update( + recordset=self.resource_id, + zone=self.properties[self.ZONE], + values=args) + + def check_update_complete(self, handler_data=None): + return self._check_status_complete() + + def handle_delete(self): + if self.resource_id is not None: + with self.client_plugin().ignore_not_found: + self.client().recordsets.delete( + recordset=self.resource_id, + zone=self.properties[self.ZONE] + ) + + def check_delete_complete(self, handler_data=None): + if self.resource_id is not None: + with self.client_plugin().ignore_not_found: + return self._check_status_complete() + + return True + + def _show_resource(self): + return self.client().recordsets.get( + recordset=self.resource_id, + zone=self.properties[self.ZONE] + ) + + +def resource_mapping(): + return { + 'OS::Designate::RecordSet': DesignateRecordSet + } diff --git a/heat/tests/clients/test_designate_client.py b/heat/tests/clients/test_designate_client.py index 6f6d7bd019..e3dbe29ab5 100644 --- a/heat/tests/clients/test_designate_client.py +++ b/heat/tests/clients/test_designate_client.py @@ -299,3 +299,23 @@ class DesignateClientPluginRecordTest(common.HeatTestCase): self._client.records.get.assert_called_once_with( self.sample_domain_id, self.sample_uuid) + + +class DesignateZoneConstraintTest(common.HeatTestCase): + + def test_expected_exceptions(self): + self.assertEqual((heat_exception.EntityNotFound,), + client.DesignateZoneConstraint.expected_exceptions, + "DesignateZoneConstraint expected exceptions error") + + def test_constrain(self): + constrain = client.DesignateZoneConstraint() + client_mock = mock.MagicMock() + client_plugin_mock = mock.MagicMock() + client_plugin_mock.get_zone_id.return_value = None + client_mock.client_plugin.return_value = client_plugin_mock + + self.assertIsNone(constrain.validate_with_client(client_mock, + 'zone_1')) + + client_plugin_mock.get_zone_id.assert_called_once_with('zone_1') diff --git a/heat/tests/openstack/designate/test_recordset.py b/heat/tests/openstack/designate/test_recordset.py new file mode 100644 index 0000000000..43bec33a91 --- /dev/null +++ b/heat/tests/openstack/designate/test_recordset.py @@ -0,0 +1,217 @@ +# +# 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 designateclient import exceptions as designate_exception +import mock + +from heat.common import exception +from heat.engine.resources.openstack.designate import recordset +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': '2015-04-30', + 'resources': { + 'test_resource': { + 'type': 'OS::Designate::RecordSet', + 'properties': { + 'name': 'test-record.com', + 'description': 'Test record', + 'ttl': 3600, + 'type': 'A', + 'records': ['1.1.1.1'], + 'zone': '1234567' + } + } + } +} + +RESOURCE_TYPE = 'OS::Designate::Record' + + +class DesignateRecordSetTest(common.HeatTestCase): + + def setUp(self): + super(DesignateRecordSetTest, self).setUp() + + self.ctx = utils.dummy_context() + + self.stack = stack.Stack( + self.ctx, 'test_stack', + template.Template(sample_template) + ) + + self.test_resource = self.stack['test_resource'] + + # Mock client plugin + self.test_client_plugin = mock.MagicMock() + self.test_resource.client_plugin = mock.MagicMock( + return_value=self.test_client_plugin) + + # Mock client + self.test_client = mock.MagicMock() + self.test_resource.client = mock.MagicMock( + return_value=self.test_client) + + def _get_mock_resource(self): + value = {} + value['id'] = '477e8273-60a7-4c41-b683-fdb0bc7cd152' + + return value + + def test_resource_validate_properties(self): + mock_record_create = self.test_client_plugin.record_create + mock_resource = self._get_mock_resource() + mock_record_create.return_value = mock_resource + + # validate the properties + self.assertEqual( + 'test-record.com', + self.test_resource.properties.get( + recordset.DesignateRecordSet.NAME)) + self.assertEqual( + 'Test record', + self.test_resource.properties.get( + recordset.DesignateRecordSet.DESCRIPTION)) + self.assertEqual( + 3600, + self.test_resource.properties.get( + recordset.DesignateRecordSet.TTL)) + self.assertEqual( + 'A', + self.test_resource.properties.get( + recordset.DesignateRecordSet.TYPE)) + self.assertEqual( + ['1.1.1.1'], + self.test_resource.properties.get( + recordset.DesignateRecordSet.RECORDS)) + self.assertEqual( + '1234567', + self.test_resource.properties.get( + recordset.DesignateRecordSet.ZONE)) + + def test_resource_handle_create(self): + mock_record_create = self.test_client.recordsets.create + mock_resource = self._get_mock_resource() + mock_record_create.return_value = mock_resource + + self.test_resource.properties = args = dict( + name='test-record.com', + description='Test record', + ttl=3600, + type='A', + records=['1.1.1.1'], + zone='1234567' + ) + + self.test_resource.handle_create() + args['type_'] = args.pop('type') + mock_record_create.assert_called_with( + **args + ) + + # validate physical resource id + self.assertEqual(mock_resource['id'], self.test_resource.resource_id) + + def _mock_check_status_active(self): + self.test_client.recordsets.get.side_effect = [ + {'status': 'PENDING'}, + {'status': 'ACTIVE'}, + {'status': 'ERROR'} + ] + + def test_check_create_complete(self): + self._mock_check_status_active() + self.assertFalse(self.test_resource.check_create_complete()) + self.assertTrue(self.test_resource.check_create_complete()) + ex = self.assertRaises(exception.ResourceInError, + self.test_resource.check_create_complete) + self.assertIn('Error in RecordSet', + ex.message) + + def test_resource_handle_update(self): + mock_record_update = self.test_client.recordsets.update + self.test_resource.resource_id = '477e8273-60a7-4c41-b683-fdb0bc7cd151' + + prop_diff = args = { + recordset.DesignateRecordSet.DESCRIPTION: 'updated description', + recordset.DesignateRecordSet.TTL: 4200, + recordset.DesignateRecordSet.TYPE: 'B', + recordset.DesignateRecordSet.RECORDS: ['2.2.2.2'] + } + self.test_resource.handle_update(json_snippet=None, + tmpl_diff=None, + prop_diff=prop_diff) + + args['type_'] = args.pop('type') + mock_record_update.assert_called_with( + zone='1234567', + recordset='477e8273-60a7-4c41-b683-fdb0bc7cd151', + values=args) + + def test_check_update_complete(self): + self._mock_check_status_active() + self.assertFalse(self.test_resource.check_update_complete()) + self.assertTrue(self.test_resource.check_update_complete()) + ex = self.assertRaises(exception.ResourceInError, + self.test_resource.check_create_complete) + self.assertIn('Error in RecordSet', + ex.message) + + def test_resource_handle_delete(self): + mock_record_delete = self.test_client.recordsets.delete + self.test_resource.resource_id = '477e8273-60a7-4c41-b683-fdb0bc7cd151' + mock_record_delete.return_value = None + + self.assertIsNone(self.test_resource.handle_delete()) + mock_record_delete.assert_called_once_with( + zone='1234567', + recordset=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()) + + def test_resource_handle_delete_not_found(self): + mock_record_delete = self.test_client_plugin.record_delete + mock_record_delete.side_effect = designate_exception.NotFound + self.assertIsNone(self.test_resource.handle_delete()) + + def test_check_delete_complete(self): + self.test_resource.resource_id = self._get_mock_resource()['id'] + self._mock_check_status_active() + self.assertFalse(self.test_resource.check_delete_complete()) + self.assertTrue(self.test_resource.check_delete_complete()) + ex = self.assertRaises(exception.ResourceInError, + self.test_resource.check_create_complete) + self.assertIn('Error in RecordSet', + ex.message) + + def test_resource_show_resource(self): + args = dict( + name='test-record.com', + description='Test record', + ttl=3600, + type='A', + records=['1.1.1.1'] + ) + mock_get = self.test_client.recordsets.get + mock_get.return_value = args + + self.assertEqual(args, + self.test_resource._show_resource(), + 'Failed to show resource') diff --git a/setup.cfg b/setup.cfg index 41234417c0..1ec855a694 100644 --- a/setup.cfg +++ b/setup.cfg @@ -101,6 +101,7 @@ heat.constraints = cinder.volume = heat.engine.clients.os.cinder:VolumeConstraint cinder.vtype = heat.engine.clients.os.cinder:VolumeTypeConstraint designate.domain = heat.engine.clients.os.designate:DesignateDomainConstraint + designate.zone = heat.engine.clients.os.designate:DesignateZoneConstraint glance.image = heat.engine.clients.os.glance:ImageConstraint heat.resource_type = heat.engine.constraint.heat_constraints:ResourceTypeConstraint keystone.domain = heat.engine.clients.os.keystone.keystone_constraints:KeystoneDomainConstraint